feat: unified Agent/Citizen model with visibility controls

- Add visibility_scope, is_listed_in_directory, is_system, primary_microdao_id to agents
- Create unified list_agent_summaries method
- Add PUT /city/agents/{id}/visibility endpoint
- Add AgentVisibilityCard component
- Update AgentSummary types for frontend
This commit is contained in:
Apple
2025-11-28 07:56:33 -08:00
parent 6f4270aa64
commit 15714fb170
9 changed files with 637 additions and 22 deletions

View File

@@ -13,7 +13,10 @@ import {
AgentPublicProfileCard,
AgentMicrodaoMembershipCard
} from '@/components/agent-dashboard';
import { AgentVisibilityCard } from '@/components/agent-dashboard/AgentVisibilityCard';
import { api, Agent, AgentInvokeResponse } from '@/lib/api';
import { updateAgentVisibility } from '@/lib/api/agents';
import { AgentVisibilityPayload, VisibilityScope } from '@/lib/types/agents';
// Chat Message type
interface Message {
@@ -221,6 +224,17 @@ export default function AgentPage() {
onUpdated={refresh}
/>
{/* Visibility Settings */}
<AgentVisibilityCard
agentId={dashboard.profile.agent_id}
visibilityScope={(dashboard.public_profile?.visibility_scope as VisibilityScope) || 'city'}
isListedInDirectory={dashboard.public_profile?.is_listed_in_directory ?? true}
onUpdate={async (payload: AgentVisibilityPayload) => {
await updateAgentVisibility(dashboard.profile.agent_id, payload);
refresh();
}}
/>
{/* Public Profile Settings */}
<AgentPublicProfileCard
agentId={dashboard.profile.agent_id}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
const CITY_API_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || 'http://daarion-city-service:7001';
export async function PUT(
request: NextRequest,
{ params }: { params: { agentId: string } }
) {
try {
const { agentId } = params;
const body = await request.json();
const response = await fetch(`${CITY_API_URL}/city/agents/${agentId}/visibility`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
console.error('Failed to update agent visibility:', response.status, text);
return NextResponse.json(
{ error: 'Failed to update visibility' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Error updating agent visibility:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,165 @@
'use client';
import { useState } from 'react';
import { Eye, EyeOff, Users, Lock, Globe, Loader2 } from 'lucide-react';
import { VisibilityScope, AgentVisibilityPayload } from '@/lib/types/agents';
interface AgentVisibilityCardProps {
agentId: string;
visibilityScope: VisibilityScope;
isListedInDirectory: boolean;
onUpdate?: (payload: AgentVisibilityPayload) => Promise<void>;
readOnly?: boolean;
}
const VISIBILITY_OPTIONS: { value: VisibilityScope; label: string; description: string; icon: React.ReactNode }[] = [
{
value: 'city',
label: 'Публічний',
description: 'Видимий всім у міських сервісах',
icon: <Globe className="w-4 h-4" />,
},
{
value: 'microdao',
label: 'Тільки MicroDAO',
description: 'Видимий лише членам MicroDAO',
icon: <Users className="w-4 h-4" />,
},
{
value: 'owner_only',
label: 'Приватний',
description: 'Видимий тільки власнику',
icon: <Lock className="w-4 h-4" />,
},
];
export function AgentVisibilityCard({
agentId,
visibilityScope,
isListedInDirectory,
onUpdate,
readOnly = false,
}: AgentVisibilityCardProps) {
const [scope, setScope] = useState<VisibilityScope>(visibilityScope);
const [listed, setListed] = useState(isListedInDirectory);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleScopeChange = async (newScope: VisibilityScope) => {
if (readOnly || saving) return;
setScope(newScope);
setError(null);
// If changing to non-city, auto-unlist from directory
const newListed = newScope === 'city' ? listed : false;
if (newScope !== 'city') {
setListed(false);
}
if (onUpdate) {
setSaving(true);
try {
await onUpdate({ visibility_scope: newScope, is_listed_in_directory: newListed });
} catch (e) {
setError('Не вдалося зберегти');
setScope(visibilityScope);
setListed(isListedInDirectory);
} finally {
setSaving(false);
}
}
};
const handleListedChange = async (checked: boolean) => {
if (readOnly || saving || scope !== 'city') return;
setListed(checked);
setError(null);
if (onUpdate) {
setSaving(true);
try {
await onUpdate({ visibility_scope: scope, is_listed_in_directory: checked });
} catch (e) {
setError('Не вдалося зберегти');
setListed(isListedInDirectory);
} finally {
setSaving(false);
}
}
};
return (
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Eye className="w-5 h-5 text-blue-400" />
Видимість
</h3>
{saving && <Loader2 className="w-4 h-4 text-blue-400 animate-spin" />}
</div>
{error && (
<div className="mb-4 p-2 bg-red-500/20 border border-red-500/30 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
<div className="space-y-3">
{VISIBILITY_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleScopeChange(option.value)}
disabled={readOnly || saving}
className={`w-full p-3 rounded-lg border transition-all text-left flex items-start gap-3 ${
scope === option.value
? 'bg-blue-500/20 border-blue-500/50 text-white'
: 'bg-white/5 border-white/10 text-white/70 hover:bg-white/10'
} ${readOnly || saving ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<div className={`mt-0.5 ${scope === option.value ? 'text-blue-400' : 'text-white/50'}`}>
{option.icon}
</div>
<div>
<div className="font-medium">{option.label}</div>
<div className="text-xs text-white/50">{option.description}</div>
</div>
{scope === option.value && (
<div className="ml-auto">
<div className="w-2 h-2 rounded-full bg-blue-400" />
</div>
)}
</button>
))}
</div>
{/* Citizens Directory toggle - only for city visibility */}
{scope === 'city' && (
<div className="mt-4 pt-4 border-t border-white/10">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={listed}
onChange={(e) => handleListedChange(e.target.checked)}
disabled={readOnly || saving}
className="w-4 h-4 rounded border-white/30 bg-white/10 text-blue-500 focus:ring-blue-500/50"
/>
<div>
<div className="text-white font-medium flex items-center gap-2">
{listed ? <Eye className="w-4 h-4 text-green-400" /> : <EyeOff className="w-4 h-4 text-white/50" />}
Показувати в каталозі Громадян
</div>
<div className="text-xs text-white/50">
{listed
? 'Агент відображається на сторінці /citizens'
: 'Агент прихований з публічного каталогу'}
</div>
</div>
</label>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { AgentVisibilityPayload } from '@/lib/types/agents';
/**
* Update agent visibility settings
*/
export async function updateAgentVisibility(
agentId: string,
payload: AgentVisibilityPayload
): Promise<void> {
const response = await fetch(`/api/agents/${agentId}/visibility`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to update visibility');
}
}

View File

@@ -1,5 +1,14 @@
import { HomeNode } from './citizens';
export type VisibilityScope = 'city' | 'microdao' | 'owner_only';
export interface MicrodaoBadge {
id: string;
name: string;
slug?: string | null;
role?: string | null;
}
export interface AgentMicrodaoMembership {
microdao_id: string;
microdao_slug: string;
@@ -10,16 +19,35 @@ export interface AgentMicrodaoMembership {
export interface AgentSummary {
id: string;
slug?: string | null;
display_name: string;
title?: string | null;
tagline?: string | null;
kind: string;
avatar_url?: string | null;
status: string;
is_public: boolean;
public_slug?: string | null;
public_title?: string | null;
district?: string | null;
// Node info
node_id?: string | null;
node_label?: string | null;
home_node?: HomeNode | null;
// Visibility
visibility_scope: VisibilityScope;
is_listed_in_directory: boolean;
is_system: boolean;
is_public: boolean;
// MicroDAO
primary_microdao_id?: string | null;
primary_microdao_name?: string | null;
primary_microdao_slug?: string | null;
district?: string | null;
microdaos: MicrodaoBadge[];
microdao_memberships: AgentMicrodaoMembership[];
// Skills
public_skills: string[];
}
export interface AgentListResponse {
@@ -29,26 +57,52 @@ export interface AgentListResponse {
export interface AgentDashboard {
id: string;
slug?: string | null;
display_name: string;
kind: string;
avatar_url?: string | null;
status: string;
// Visibility
visibility_scope: VisibilityScope;
is_listed_in_directory: boolean;
is_system: boolean;
is_public: boolean;
// Profile
public_slug?: string | null;
public_title?: string | null;
public_tagline?: string | null;
public_skills: string[];
district?: string | null;
// Node
node_id?: string | null;
node_label?: string | null;
home_node?: HomeNode | null;
// MicroDAO
primary_microdao_id?: string | null;
primary_microdao_name?: string | null;
primary_microdao_slug?: string | null;
microdaos: MicrodaoBadge[];
microdao_memberships: AgentMicrodaoMembership[];
// System prompts
system_prompts?: {
core?: string;
safety?: string;
governance?: string;
tools?: string;
};
// Capabilities
capabilities: string[];
model?: string | null;
role?: string | null;
}
export interface AgentVisibilityPayload {
visibility_scope: VisibilityScope;
is_listed_in_directory: boolean;
}

View File

@@ -0,0 +1,77 @@
-- Migration: Add visibility and listing fields to agents
-- Purpose: Unify Agent/Citizen model with proper visibility control
-- Date: 2025-11-28
-- Add visibility scope field
ALTER TABLE agents
ADD COLUMN IF NOT EXISTS visibility_scope TEXT NOT NULL DEFAULT 'city';
-- Add listing in directory flag
ALTER TABLE agents
ADD COLUMN IF NOT EXISTS is_listed_in_directory BOOLEAN NOT NULL DEFAULT true;
-- Add system agent flag
ALTER TABLE agents
ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT false;
-- Add primary microDAO reference
ALTER TABLE agents
ADD COLUMN IF NOT EXISTS primary_microdao_id TEXT NULL;
-- Add slug for public URL
ALTER TABLE agents
ADD COLUMN IF NOT EXISTS slug TEXT NULL;
-- Create unique index on slug
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_slug ON agents(slug) WHERE slug IS NOT NULL;
-- Add check constraint for visibility_scope
ALTER TABLE agents
DROP CONSTRAINT IF EXISTS chk_visibility_scope;
ALTER TABLE agents
ADD CONSTRAINT chk_visibility_scope
CHECK (visibility_scope IN ('city', 'microdao', 'owner_only'));
-- Create index for faster filtering
CREATE INDEX IF NOT EXISTS idx_agents_visibility ON agents(visibility_scope, is_listed_in_directory);
CREATE INDEX IF NOT EXISTS idx_agents_primary_microdao ON agents(primary_microdao_id);
-- Update existing public agents to be listed
UPDATE agents
SET is_listed_in_directory = true
WHERE is_public = true;
-- Update non-public agents to not be listed
UPDATE agents
SET is_listed_in_directory = false
WHERE is_public = false;
-- Set system agents (infrastructure agents)
UPDATE agents
SET is_system = true
WHERE kind IN ('system', 'monitor', 'infrastructure')
OR id LIKE 'ag_%';
-- Generate slugs from public_slug or id
UPDATE agents
SET slug = COALESCE(public_slug, LOWER(REPLACE(display_name, ' ', '-')))
WHERE slug IS NULL;
-- Set primary_microdao_id from first membership
UPDATE agents a
SET primary_microdao_id = (
SELECT ma.microdao_id
FROM microdao_agents ma
WHERE ma.agent_id = a.id
ORDER BY ma.created_at
LIMIT 1
)
WHERE primary_microdao_id IS NULL;
-- Comments
COMMENT ON COLUMN agents.visibility_scope IS 'Visibility: city (public), microdao (members only), owner_only (private)';
COMMENT ON COLUMN agents.is_listed_in_directory IS 'Show in public Citizens directory';
COMMENT ON COLUMN agents.is_system IS 'System/infrastructure agent';
COMMENT ON COLUMN agents.primary_microdao_id IS 'Primary MicroDAO for this agent';
COMMENT ON COLUMN agents.slug IS 'URL-friendly slug for agent profile';

View File

@@ -227,19 +227,47 @@ class UsageStats(BaseModel):
last_active: Optional[str] = None
class AgentSummary(BaseModel):
"""Agent summary for Agent Console"""
class MicrodaoBadge(BaseModel):
"""MicroDAO badge for agent display"""
id: str
name: str
slug: Optional[str] = None
role: Optional[str] = None # orchestrator, member, etc.
class AgentSummary(BaseModel):
"""Unified Agent summary for Agent Console and Citizens"""
id: str
slug: Optional[str] = None
display_name: str
title: Optional[str] = None # public_title
tagline: Optional[str] = None # public_tagline
kind: str = "assistant"
avatar_url: Optional[str] = None
status: str = "offline"
is_public: bool = False
public_slug: Optional[str] = None
public_title: Optional[str] = None
district: Optional[str] = None
# Node info
node_id: Optional[str] = None
node_label: Optional[str] = None # "НОДА1" / "НОДА2"
home_node: Optional[HomeNodeView] = None
microdao_memberships: List[Dict[str, Any]] = []
# Visibility
visibility_scope: str = "city" # city, microdao, owner_only
is_listed_in_directory: bool = True
is_system: bool = False
is_public: bool = False # backward compatibility
# MicroDAO
primary_microdao_id: Optional[str] = None
primary_microdao_name: Optional[str] = None
primary_microdao_slug: Optional[str] = None
district: Optional[str] = None
microdaos: List[MicrodaoBadge] = []
microdao_memberships: List[Dict[str, Any]] = [] # backward compatibility
# Skills
public_skills: List[str] = []
# Future: model bindings and usage stats
model_bindings: Optional[ModelBindings] = None
usage_stats: Optional[UsageStats] = None

View File

@@ -292,8 +292,137 @@ async def get_rooms_for_map() -> List[dict]:
# Agents Repository
# =============================================================================
async def list_agent_summaries(
*,
node_id: Optional[str] = None,
visibility_scope: Optional[str] = None,
listed_only: Optional[bool] = None,
kinds: Optional[List[str]] = None,
include_system: bool = True,
include_archived: bool = False,
limit: int = 200,
offset: int = 0
) -> Tuple[List[dict], int]:
"""
Unified method to list agents with all necessary data.
Used by both Agent Console and Citizens page.
"""
pool = await get_pool()
params: List[Any] = []
where_clauses = []
# Always filter archived unless explicitly included
if not include_archived:
where_clauses.append("COALESCE(a.is_archived, false) = false")
if node_id:
params.append(node_id)
where_clauses.append(f"a.node_id = ${len(params)}")
if visibility_scope:
params.append(visibility_scope)
where_clauses.append(f"COALESCE(a.visibility_scope, 'city') = ${len(params)}")
if listed_only is True:
where_clauses.append("COALESCE(a.is_listed_in_directory, true) = true")
elif listed_only is False:
where_clauses.append("COALESCE(a.is_listed_in_directory, true) = false")
if kinds:
params.append(kinds)
where_clauses.append(f"a.kind = ANY(${len(params)})")
if not include_system:
where_clauses.append("COALESCE(a.is_system, false) = false")
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query = f"""
SELECT
a.id,
COALESCE(a.slug, a.public_slug, LOWER(REPLACE(a.display_name, ' ', '-'))) AS slug,
a.display_name,
COALESCE(a.public_title, '') AS title,
COALESCE(a.public_tagline, '') AS tagline,
a.kind,
a.avatar_url,
COALESCE(a.status, 'offline') AS status,
a.node_id,
nc.node_name AS node_label,
nc.hostname AS node_hostname,
nc.roles AS node_roles,
nc.environment AS node_environment,
COALESCE(a.visibility_scope, 'city') AS visibility_scope,
COALESCE(a.is_listed_in_directory, true) AS is_listed_in_directory,
COALESCE(a.is_system, false) AS is_system,
COALESCE(a.is_public, false) AS is_public,
a.primary_microdao_id,
pm.name AS primary_microdao_name,
pm.slug AS primary_microdao_slug,
pm.district AS district,
COALESCE(a.public_skills, ARRAY[]::text[]) AS public_skills,
COUNT(*) OVER() AS total_count
FROM agents a
LEFT JOIN node_cache nc ON a.node_id = nc.node_id
LEFT JOIN microdaos pm ON a.primary_microdao_id = pm.id
WHERE {where_sql}
ORDER BY a.display_name
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
"""
params.append(limit)
params.append(offset)
rows = await pool.fetch(query, *params)
if not rows:
return [], 0
total = rows[0]["total_count"]
items = []
for row in rows:
data = dict(row)
data.pop("total_count", None)
# Build home_node object
if data.get("node_id"):
data["home_node"] = {
"id": data.get("node_id"),
"name": data.get("node_label"),
"hostname": data.get("node_hostname"),
"roles": list(data.get("node_roles") or []),
"environment": data.get("node_environment")
}
else:
data["home_node"] = None
# Clean up intermediate fields
for key in ["node_hostname", "node_roles", "node_environment"]:
data.pop(key, None)
# Get MicroDAO memberships
memberships = await get_agent_microdao_memberships(data["id"])
data["microdaos"] = [
{
"id": m.get("microdao_id", ""),
"name": m.get("name", ""),
"slug": m.get("slug"),
"role": m.get("role")
}
for m in memberships
]
data["microdao_memberships"] = memberships # backward compatibility
data["public_skills"] = list(data.get("public_skills") or [])
items.append(data)
return items, total
async def get_all_agents() -> List[dict]:
"""Отримати всіх агентів (non-archived)"""
"""Отримати всіх агентів (non-archived) - legacy method"""
pool = await get_pool()
query = """
@@ -308,6 +437,29 @@ async def get_all_agents() -> List[dict]:
return [dict(row) for row in rows]
async def update_agent_visibility(
agent_id: str,
visibility_scope: str,
is_listed_in_directory: bool
) -> bool:
"""Оновити налаштування видимості агента"""
pool = await get_pool()
query = """
UPDATE agents
SET visibility_scope = $2,
is_listed_in_directory = $3,
is_public = $3,
updated_at = NOW()
WHERE id = $1
AND COALESCE(is_archived, false) = false
RETURNING id
"""
result = await pool.fetchrow(query, agent_id, visibility_scope, is_listed_in_directory)
return result is not None
async def get_agents_with_home_node(
kind: Optional[str] = None,
node_id: Optional[str] = None,

View File

@@ -22,6 +22,7 @@ from models_city import (
AgentRead,
AgentPresence,
AgentSummary,
MicrodaoBadge,
HomeNodeView,
NodeProfile,
PublicCitizenSummary,
@@ -67,14 +68,19 @@ class MicrodaoMembershipPayload(BaseModel):
async def list_agents(
kind: Optional[str] = Query(None, description="Filter by agent kind"),
node_id: Optional[str] = Query(None, description="Filter by node_id"),
visibility_scope: Optional[str] = Query(None, description="Filter by visibility: city, microdao, owner_only"),
include_system: bool = Query(True, description="Include system agents"),
limit: int = Query(100, le=200),
offset: int = Query(0, ge=0)
):
"""Список всіх агентів для Agent Console"""
"""Список всіх агентів для Agent Console (unified API)"""
try:
agents, total = await repo_city.get_agents_with_home_node(
kind=kind,
kinds_list = [kind] if kind else None
agents, total = await repo_city.list_agent_summaries(
node_id=node_id,
visibility_scope=visibility_scope,
kinds=kinds_list,
include_system=include_system,
limit=limit,
offset=offset
)
@@ -93,21 +99,40 @@ async def list_agents(
environment=home_node_data.get("environment")
)
# Get microdao memberships
memberships = await repo_city.get_agent_microdao_memberships(agent["id"])
# Build microdao badges
microdaos = [
MicrodaoBadge(
id=m.get("id", ""),
name=m.get("name", ""),
slug=m.get("slug"),
role=m.get("role")
)
for m in agent.get("microdaos", [])
]
items.append(AgentSummary(
id=agent["id"],
slug=agent.get("slug"),
display_name=agent["display_name"],
title=agent.get("title"),
tagline=agent.get("tagline"),
kind=agent.get("kind", "assistant"),
avatar_url=agent.get("avatar_url"),
status=agent.get("status", "offline"),
is_public=agent.get("is_public", False),
public_slug=agent.get("public_slug"),
public_title=agent.get("public_title"),
district=agent.get("public_district"),
node_id=agent.get("node_id"),
node_label=agent.get("node_label"),
home_node=home_node,
microdao_memberships=memberships
visibility_scope=agent.get("visibility_scope", "city"),
is_listed_in_directory=agent.get("is_listed_in_directory", True),
is_system=agent.get("is_system", False),
is_public=agent.get("is_public", False),
primary_microdao_id=agent.get("primary_microdao_id"),
primary_microdao_name=agent.get("primary_microdao_name"),
primary_microdao_slug=agent.get("primary_microdao_slug"),
district=agent.get("district"),
microdaos=microdaos,
microdao_memberships=agent.get("microdao_memberships", []),
public_skills=agent.get("public_skills", [])
))
return {"items": items, "total": total}
@@ -116,6 +141,43 @@ async def list_agents(
raise HTTPException(status_code=500, detail="Failed to list agents")
class AgentVisibilityPayload(BaseModel):
visibility_scope: str # city, microdao, owner_only
is_listed_in_directory: bool = True
@router.put("/agents/{agent_id}/visibility")
async def update_agent_visibility(
agent_id: str,
payload: AgentVisibilityPayload
):
"""Оновити налаштування видимості агента"""
try:
# Validate visibility_scope
if payload.visibility_scope not in ("city", "microdao", "owner_only"):
raise HTTPException(
status_code=400,
detail="visibility_scope must be one of: city, microdao, owner_only"
)
# Update in database
success = await repo_city.update_agent_visibility(
agent_id=agent_id,
visibility_scope=payload.visibility_scope,
is_listed_in_directory=payload.is_listed_in_directory
)
if not success:
raise HTTPException(status_code=404, detail="Agent not found")
return {"status": "ok", "agent_id": agent_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update agent visibility: {e}")
raise HTTPException(status_code=500, detail="Failed to update visibility")
# =============================================================================
# Nodes API (for Node Directory)
# =============================================================================