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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user