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:
@@ -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}
|
||||
|
||||
40
apps/web/src/app/api/agents/[agentId]/visibility/route.ts
Normal file
40
apps/web/src/app/api/agents/[agentId]/visibility/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
165
apps/web/src/components/agent-dashboard/AgentVisibilityCard.tsx
Normal file
165
apps/web/src/components/agent-dashboard/AgentVisibilityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
23
apps/web/src/lib/api/agents.ts
Normal file
23
apps/web/src/lib/api/agents.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
77
migrations/024_agent_visibility_fields.sql
Normal file
77
migrations/024_agent_visibility_fields.sql
Normal 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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user