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;
}