feat: Citizens Layer + Citizen Interact Layer + CityChatWidget
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -70,3 +70,5 @@ Thumbs.db
|
||||
.directory
|
||||
apps/web/node_modules/
|
||||
apps/web/.next/
|
||||
venv_models/
|
||||
models/
|
||||
|
||||
32
apps/web/package-lock.json
generated
32
apps/web/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"next": "15.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2342,6 +2343,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -5602,6 +5612,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
|
||||
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
|
||||
@@ -6010,6 +6033,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -9,24 +9,24 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "15.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"class-variance-authority": "^0.7.1"
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"typescript": "^5.7.2",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"postcss": "^8.4.49",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-next": "15.0.3"
|
||||
"eslint-config-next": "15.0.3",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
314
apps/web/src/app/agents/[agentId]/page.tsx
Normal file
314
apps/web/src/app/agents/[agentId]/page.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAgentDashboard } from '@/hooks/useAgentDashboard';
|
||||
import {
|
||||
AgentSummaryCard,
|
||||
AgentDAISCard,
|
||||
AgentCityCard,
|
||||
AgentMetricsCard,
|
||||
AgentSystemPromptsCard,
|
||||
AgentPublicProfileCard,
|
||||
AgentMicrodaoMembershipCard
|
||||
} from '@/components/agent-dashboard';
|
||||
import { api, Agent, AgentInvokeResponse } from '@/lib/api';
|
||||
|
||||
// Chat Message type
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
meta?: {
|
||||
tokens_in?: number;
|
||||
tokens_out?: number;
|
||||
latency_ms?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AgentPage() {
|
||||
const params = useParams();
|
||||
const agentId = params.agentId as string;
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'chat'>('dashboard');
|
||||
|
||||
// Dashboard state
|
||||
const { dashboard, isLoading: dashboardLoading, error: dashboardError, refresh } = useAgentDashboard(agentId, {
|
||||
refreshInterval: 30000
|
||||
});
|
||||
|
||||
// Chat state
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [chatLoading, setChatLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [invoking, setInvoking] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load agent for chat
|
||||
useEffect(() => {
|
||||
async function loadAgent() {
|
||||
try {
|
||||
setChatLoading(true);
|
||||
const data = await api.getAgent(agentId);
|
||||
setAgent(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load agent:', error);
|
||||
} finally {
|
||||
setChatLoading(false);
|
||||
}
|
||||
}
|
||||
if (activeTab === 'chat') {
|
||||
loadAgent();
|
||||
}
|
||||
}, [agentId, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!input.trim() || invoking) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setInvoking(true);
|
||||
|
||||
try {
|
||||
const response: AgentInvokeResponse = await api.invokeAgent(agentId, input.trim());
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: response.reply || 'No response',
|
||||
timestamp: new Date(),
|
||||
meta: {
|
||||
tokens_in: response.tokens_in,
|
||||
tokens_out: response.tokens_out,
|
||||
latency_ms: response.latency_ms
|
||||
}
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error. Please try again.',
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setInvoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (dashboardLoading && !dashboard && activeTab === 'dashboard') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p className="text-white/70">Loading agent dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (dashboardError && activeTab === 'dashboard') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
|
||||
<p className="text-red-400 text-lg mb-2">Failed to load agent dashboard</p>
|
||||
<p className="text-white/50 mb-4">{dashboardError.message}</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<Link
|
||||
href="/agents"
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Back to Agents
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/agents"
|
||||
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{dashboard?.profile.display_name || agent?.name || agentId}
|
||||
</h1>
|
||||
<p className="text-white/50 text-sm">Agent Cabinet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeTab === 'dashboard'
|
||||
? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'bg-white/5 text-white/50 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
📊 Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'bg-white/5 text-white/50 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
💬 Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Tab */}
|
||||
{activeTab === 'dashboard' && dashboard && (
|
||||
<div className="space-y-6">
|
||||
<AgentSummaryCard profile={dashboard.profile} runtime={dashboard.runtime} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<AgentDAISCard dais={dashboard.profile.dais} />
|
||||
<div className="space-y-6">
|
||||
<AgentCityCard cityPresence={dashboard.profile.city_presence} />
|
||||
<AgentMetricsCard metrics={dashboard.metrics} />
|
||||
</div>
|
||||
</div>
|
||||
{/* System Prompts - Full Width */}
|
||||
<AgentSystemPromptsCard
|
||||
agentId={dashboard.profile.agent_id}
|
||||
systemPrompts={dashboard.system_prompts}
|
||||
canEdit={true} // TODO: Check user role
|
||||
onUpdated={refresh}
|
||||
/>
|
||||
|
||||
{/* Public Profile Settings */}
|
||||
<AgentPublicProfileCard
|
||||
agentId={dashboard.profile.agent_id}
|
||||
publicProfile={dashboard.public_profile}
|
||||
canEdit={true} // TODO: Check user role
|
||||
onUpdated={refresh}
|
||||
/>
|
||||
|
||||
<AgentMicrodaoMembershipCard
|
||||
agentId={dashboard.profile.agent_id}
|
||||
memberships={dashboard.microdao_memberships ?? []}
|
||||
canEdit={true}
|
||||
onUpdated={refresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Tab */}
|
||||
{activeTab === 'chat' && (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden">
|
||||
{/* Messages */}
|
||||
<div className="h-[500px] overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-white/50 py-8">
|
||||
<p className="text-4xl mb-2">💬</p>
|
||||
<p>Start a conversation with {dashboard?.profile.display_name || agent?.name || agentId}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] p-3 rounded-xl ${
|
||||
msg.role === 'user'
|
||||
? 'bg-cyan-500/20 text-white'
|
||||
: 'bg-white/10 text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
{msg.meta && (
|
||||
<div className="mt-2 text-xs text-white/30 flex gap-2">
|
||||
{msg.meta.latency_ms && <span>{msg.meta.latency_ms}ms</span>}
|
||||
{msg.meta.tokens_out && <span>{msg.meta.tokens_out} tokens</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{invoking && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white/10 p-3 rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-cyan-500 border-t-transparent rounded-full" />
|
||||
<span className="text-white/50">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-white/10 p-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSendMessage()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-cyan-500/50"
|
||||
disabled={invoking}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!input.trim() || invoking}
|
||||
className="px-4 py-3 bg-cyan-500 hover:bg-cyan-400 disabled:bg-white/10 disabled:text-white/30 text-white rounded-xl transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArrowLeft, Bot, Send, Loader2, Zap, Clock, CheckCircle2 } from 'lucide-react'
|
||||
import { api, Agent, AgentInvokeResponse } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
meta?: {
|
||||
tokens_in?: number
|
||||
tokens_out?: number
|
||||
latency_ms?: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentPage() {
|
||||
const params = useParams()
|
||||
const agentId = params.id as string
|
||||
|
||||
const [agent, setAgent] = useState<Agent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [invoking, setInvoking] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAgent() {
|
||||
try {
|
||||
const data = await api.getAgent(agentId)
|
||||
setAgent(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load agent:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadAgent()
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleInvoke = async () => {
|
||||
if (!input.trim() || invoking) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: `user_${Date.now()}`,
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInput('')
|
||||
setInvoking(true)
|
||||
|
||||
try {
|
||||
const response: AgentInvokeResponse = await api.invokeAgent(agentId, userMessage.content)
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant_${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: response.reply || 'Немає відповіді',
|
||||
timestamp: new Date(),
|
||||
meta: {
|
||||
tokens_in: response.tokens_in,
|
||||
tokens_out: response.tokens_out,
|
||||
latency_ms: response.latency_ms
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage])
|
||||
} catch (error) {
|
||||
const errorMessage: Message = {
|
||||
id: `error_${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: `Помилка: ${error instanceof Error ? error.message : 'Невідома помилка'}`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev, errorMessage])
|
||||
} finally {
|
||||
setInvoking(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-violet-400 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Bot className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Агент не знайдений</h2>
|
||||
<Link href="/agents" className="text-violet-400 hover:text-violet-300">
|
||||
Повернутися до списку
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-6 border-b border-white/5">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Link
|
||||
href="/agents"
|
||||
className="inline-flex items-center gap-2 text-slate-400 hover:text-white transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Назад до агентів
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-violet-500/30 to-purple-600/30 flex items-center justify-center">
|
||||
<Bot className="w-8 h-8 text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{agent.name}</h1>
|
||||
<p className="text-slate-400">{agent.description || 'AI Agent'}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'px-3 py-1 rounded-full text-sm',
|
||||
agent.is_active
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'bg-slate-700/50 text-slate-400'
|
||||
)}>
|
||||
{agent.is_active ? 'Активний' : 'Неактивний'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="glass-panel h-[500px] flex flex-col">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center">
|
||||
<Zap className="w-12 h-12 text-violet-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-white mb-2">
|
||||
Почніть розмову з {agent.name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400 max-w-sm">
|
||||
Напишіть повідомлення нижче, щоб викликати агента та отримати відповідь.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'flex gap-3',
|
||||
message.role === 'user' && 'flex-row-reverse'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
message.role === 'user'
|
||||
? 'bg-cyan-500/30'
|
||||
: 'bg-violet-500/30'
|
||||
)}>
|
||||
{message.role === 'user' ? (
|
||||
<span className="text-xs text-cyan-400">U</span>
|
||||
) : (
|
||||
<Bot className="w-4 h-4 text-violet-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'max-w-[80%]',
|
||||
message.role === 'user' && 'text-right'
|
||||
)}>
|
||||
<div className={cn(
|
||||
'inline-block px-4 py-2 rounded-2xl text-sm',
|
||||
message.role === 'user'
|
||||
? 'bg-cyan-500/20 text-white rounded-tr-sm'
|
||||
: 'bg-slate-800/50 text-slate-200 rounded-tl-sm'
|
||||
)}>
|
||||
{message.content}
|
||||
</div>
|
||||
|
||||
{message.meta && (
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
|
||||
{message.meta.latency_ms && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{message.meta.latency_ms}ms
|
||||
</span>
|
||||
)}
|
||||
{message.meta.tokens_out && (
|
||||
<span>{message.meta.tokens_out} tokens</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleInvoke()
|
||||
}}
|
||||
className="flex gap-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={`Напишіть повідомлення для ${agent.name}...`}
|
||||
disabled={invoking}
|
||||
className="flex-1 px-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={invoking || !input.trim()}
|
||||
className={cn(
|
||||
'px-4 py-3 rounded-xl transition-all',
|
||||
input.trim() && !invoking
|
||||
? 'bg-gradient-to-r from-violet-500 to-purple-600 text-white shadow-lg shadow-violet-500/20'
|
||||
: 'bg-slate-800/50 text-slate-500 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{invoking ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Stats */}
|
||||
<div className="mt-6 grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatCard icon={Zap} label="Тип" value={agent.kind} />
|
||||
<StatCard icon={Bot} label="Модель" value={agent.model || 'N/A'} />
|
||||
<StatCard icon={CheckCircle2} label="Статус" value={agent.status} />
|
||||
<StatCard icon={Clock} label="Викликів" value={messages.filter(m => m.role === 'user').length.toString()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel p-4">
|
||||
<Icon className="w-5 h-5 text-violet-400 mb-2" />
|
||||
<div className="text-xs text-slate-400 mb-1">{label}</div>
|
||||
<div className="text-sm font-medium text-white truncate">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
40
apps/web/src/app/api/agents/[agentId]/dashboard/route.ts
Normal file
40
apps/web/src/app/api/agents/[agentId]/dashboard/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await context.params;
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/dashboard`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store'
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch agent dashboard: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent dashboard proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch agent dashboard' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { agentId: string; microdaoId: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { agentId, microdaoId } = params;
|
||||
const accessToken = req.cookies.get("daarion_access_token")?.value;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/agents/${encodeURIComponent(
|
||||
agentId
|
||||
)}/microdao-membership/${encodeURIComponent(microdaoId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (res.status === 204) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Remove microdao membership proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to remove MicroDAO membership" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { agentId: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { agentId } = params;
|
||||
const accessToken = req.cookies.get("daarion_access_token")?.value;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/agents/${encodeURIComponent(
|
||||
agentId
|
||||
)}/microdao-membership`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Assign microdao membership proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to assign MicroDAO membership" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string; kind: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId, kind } = await context.params;
|
||||
const body = await request.json();
|
||||
|
||||
// Validate kind
|
||||
const validKinds = ['core', 'safety', 'governance', 'tools'];
|
||||
if (!validKinds.includes(kind)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid kind. Must be one of: ${validKinds.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Forward to backend
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/prompts/${encodeURIComponent(kind)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent prompt update proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update agent prompt' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string; kind: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId, kind } = await context.params;
|
||||
|
||||
// Get history
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/prompts/${encodeURIComponent(kind)}/history`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent prompt history proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get prompt history' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await context.params;
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/public-profile`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent public profile update proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update agent public profile' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await context.params;
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/dashboard`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Return just the public_profile part
|
||||
return NextResponse.json(data.public_profile || {}, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent public profile get proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get agent public profile' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
39
apps/web/src/app/api/citizens/[slug]/route.ts
Normal file
39
apps/web/src/app/api/citizens/[slug]/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await context.params;
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/citizens/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Citizen not found' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Citizen get proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get citizen' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
31
apps/web/src/app/api/citizens/route.ts
Normal file
31
apps/web/src/app/api/citizens/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = searchParams.get('limit') || '50';
|
||||
const offset = searchParams.get('offset') || '0';
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/citizens?limit=${limit}&offset=${offset}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Citizens list proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get citizens' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
31
apps/web/src/app/api/microdao/options/route.ts
Normal file
31
apps/web/src/app/api/microdao/options/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/microdao/options`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("MicroDAO options proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch MicroDAO options" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
apps/web/src/app/api/node/dashboard/route.ts
Normal file
41
apps/web/src/app/api/node/dashboard/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const NODE_REGISTRY_URL = process.env.NODE_REGISTRY_URL || 'http://dagi-node-registry:9205';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const nodeId = searchParams.get('nodeId');
|
||||
|
||||
// Build URL - either specific node or self
|
||||
const endpoint = nodeId
|
||||
? `${NODE_REGISTRY_URL}/api/v1/nodes/${nodeId}/dashboard`
|
||||
: `${NODE_REGISTRY_URL}/api/v1/nodes/self/dashboard`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Revalidate every 10 seconds
|
||||
next: { revalidate: 10 }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch dashboard: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Node dashboard proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch node dashboard' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
39
apps/web/src/app/api/public/citizens/[slug]/ask/route.ts
Normal file
39
apps/web/src/app/api/public/citizens/[slug]/ask/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/public/citizens/${encodeURIComponent(slug)}/ask`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Citizen ask proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send question to citizen" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/public/citizens/${encodeURIComponent(slug)}/interaction`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Citizen interaction proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load citizen interaction info" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
apps/web/src/app/api/public/citizens/[slug]/route.ts
Normal file
39
apps/web/src/app/api/public/citizens/[slug]/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/public/citizens/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Public citizen proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch citizen profile" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
apps/web/src/app/api/public/citizens/route.ts
Normal file
41
apps/web/src/app/api/public/citizens/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const district = searchParams.get("district");
|
||||
const kind = searchParams.get("kind");
|
||||
const q = searchParams.get("q");
|
||||
|
||||
const url = new URL("/public/citizens", API_BASE);
|
||||
if (district) url.searchParams.set("district", district);
|
||||
if (kind) url.searchParams.set("kind", kind);
|
||||
if (q) url.searchParams.set("q", q);
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Public citizens proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch public citizens" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
359
apps/web/src/app/citizens/[slug]/page.tsx
Normal file
359
apps/web/src/app/citizens/[slug]/page.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { getAgentKindIcon } from '@/lib/agent-dashboard';
|
||||
import { useCitizenProfile, useCitizenInteraction } from '@/hooks/useCitizens';
|
||||
import { askCitizen } from '@/lib/api/citizens';
|
||||
import { CityChatWidget } from '@/components/city/CityChatWidget';
|
||||
|
||||
type LooseRecord = Record<string, any>;
|
||||
|
||||
export default function CitizenProfilePage() {
|
||||
const params = useParams<{ slug: string }>();
|
||||
const slug = params.slug;
|
||||
|
||||
const { citizen, isLoading, error } = useCitizenProfile(slug);
|
||||
const {
|
||||
interaction,
|
||||
isLoading: interactionLoading,
|
||||
error: interactionError,
|
||||
} = useCitizenInteraction(slug);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
const [askError, setAskError] = useState<string | null>(null);
|
||||
const [asking, setAsking] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !citizen) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
|
||||
<p className="text-red-400 text-lg mb-4">{error?.message || 'Citizen not found'}</p>
|
||||
<Link href="/citizens" className="text-cyan-400 hover:underline">
|
||||
← Back to Citizens
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = citizen.status || 'unknown';
|
||||
const statusColor =
|
||||
status === 'online' ? 'bg-emerald-500/20 text-emerald-300' : 'bg-white/10 text-white/60';
|
||||
|
||||
const daisCore = (citizen.dais_public?.core as LooseRecord) || {};
|
||||
const daisPhenotype = (citizen.dais_public?.phenotype as LooseRecord) || {};
|
||||
const daisMemex = (citizen.dais_public?.memex as LooseRecord) || {};
|
||||
const daisEconomics = (citizen.dais_public?.economics as LooseRecord) || {};
|
||||
const metricsEntries = Object.entries(citizen.metrics_public || {});
|
||||
const actions = (citizen.interaction?.actions as string[]) || [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
<Link
|
||||
href="/citizens"
|
||||
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
← Back to Citizens
|
||||
</Link>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-cyan-500/20 to-purple-500/20 p-8">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-start">
|
||||
<div className="w-24 h-24 flex-shrink-0 rounded-2xl bg-gradient-to-br from-cyan-500/40 to-purple-500/40 flex items-center justify-center text-5xl shadow-xl">
|
||||
{getAgentKindIcon(citizen.kind || '')}
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<h1 className="text-3xl font-bold text-white">{citizen.display_name}</h1>
|
||||
<p className="text-cyan-200 text-lg">
|
||||
{citizen.public_title || citizen.kind || 'Citizen of DAARION'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span className={`px-3 py-1 rounded-full ${statusColor}`}>
|
||||
{status}
|
||||
</span>
|
||||
{citizen.district && (
|
||||
<span className="px-3 py-1 rounded-full bg-white/10 text-white/70">
|
||||
{citizen.district} District
|
||||
</span>
|
||||
)}
|
||||
{citizen.microdao && (
|
||||
<Link
|
||||
href={`/microdao/${citizen.microdao.slug}`}
|
||||
className="px-3 py-1 rounded-full bg-cyan-500/20 text-cyan-200 hover:bg-cyan-500/30"
|
||||
>
|
||||
MicroDAO: {citizen.microdao.name}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{citizen.admin_panel_url && (
|
||||
<Link
|
||||
href={citizen.admin_panel_url}
|
||||
className="px-4 py-2 bg-purple-500/20 text-purple-200 rounded-lg hover:bg-purple-500/30 transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
⚙️ Agent Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8 space-y-8">
|
||||
{citizen.public_tagline && (
|
||||
<blockquote className="text-xl text-white/80 italic border-l-4 border-cyan-500/60 pl-4">
|
||||
"{citizen.public_tagline}"
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
{citizen.public_skills?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs uppercase text-white/40 mb-2">Skills</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{citizen.public_skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="px-3 py-1 bg-cyan-500/20 text-cyan-200 rounded-lg text-sm"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{citizen.district && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
|
||||
<p className="text-xs uppercase text-white/40">District</p>
|
||||
<p className="text-white mt-1 text-lg">{citizen.district}</p>
|
||||
</div>
|
||||
)}
|
||||
{citizen.city_presence?.primary_room_slug && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
|
||||
<p className="text-xs uppercase text-white/40">Primary Room</p>
|
||||
<p className="text-white mt-1 text-lg">
|
||||
#{citizen.city_presence.primary_room_slug}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{citizen.node_id && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
|
||||
<p className="text-xs uppercase text-white/40">Node</p>
|
||||
<p className="text-white mt-1 text-lg">{citizen.node_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">Взаємодія з громадянином</h2>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-white/60">Чат</p>
|
||||
{interactionLoading ? (
|
||||
<div className="text-white/40 text-xs">Завантаження…</div>
|
||||
) : interaction?.primary_room_slug ? (
|
||||
<Link
|
||||
href={`/city/${interaction.primary_room_slug}`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm border border-white/20 rounded-lg text-white hover:border-cyan-400/70 transition-colors"
|
||||
>
|
||||
Відкрити чат у кімнаті{' '}
|
||||
{interaction.primary_room_name ?? interaction.primary_room_slug}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="text-white/50 text-xs">
|
||||
Для цього громадянина ще не налаштована публічна кімната чату.
|
||||
</div>
|
||||
)}
|
||||
{interactionError && (
|
||||
<div className="text-xs text-red-400">
|
||||
Не вдалося завантажити інформацію про чат.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-white/60">Поставити запитання</p>
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="Напишіть запитання до агента…"
|
||||
className="w-full min-h-[90px] rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 focus:border-cyan-500/50 focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!slug || !question.trim()) return;
|
||||
setAsking(true);
|
||||
setAskError(null);
|
||||
setAnswer(null);
|
||||
try {
|
||||
const res = await askCitizen(slug, { question });
|
||||
setAnswer(res.answer);
|
||||
} catch (err) {
|
||||
setAskError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Сталася помилка під час запиту.',
|
||||
);
|
||||
} finally {
|
||||
setAsking(false);
|
||||
}
|
||||
}}
|
||||
disabled={asking || !question.trim()}
|
||||
className="px-4 py-2 rounded-lg border border-white/20 text-sm text-white hover:border-cyan-400/70 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{asking ? 'Надсилання…' : 'Запитати'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuestion('');
|
||||
setAnswer(null);
|
||||
setAskError(null);
|
||||
}}
|
||||
className="text-xs text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
Очистити
|
||||
</button>
|
||||
</div>
|
||||
{askError && <div className="text-xs text-red-400">{askError}</div>}
|
||||
{answer && (
|
||||
<div className="mt-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 whitespace-pre-wrap">
|
||||
{answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">Live-чат з громадянином</h2>
|
||||
{interactionLoading ? (
|
||||
<div className="text-sm text-white/70">Завантаження кімнати…</div>
|
||||
) : interaction?.primary_room_slug ? (
|
||||
<CityChatWidget roomSlug={interaction.primary_room_slug} />
|
||||
) : (
|
||||
<div className="text-sm text-white/60">
|
||||
Для цього громадянина ще не налаштована публічна кімната чату.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">DAIS Public Passport</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-white/40">Identity</p>
|
||||
<p className="text-white font-semibold mt-2">
|
||||
{(daisCore?.archetype as string) || citizen.kind || 'Specialist'}
|
||||
</p>
|
||||
<p className="text-white/70 text-sm">
|
||||
{(daisCore?.bio_short as string) || citizen.public_tagline}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-white/40">Visual</p>
|
||||
<p className="text-white/70 text-sm">
|
||||
{(daisPhenotype?.visual as Record<string, string>)?.style || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-white/40">Memory</p>
|
||||
<p className="text-white/70 text-sm">
|
||||
{daisMemex && Object.keys(daisMemex).length > 0
|
||||
? JSON.stringify(daisMemex)
|
||||
: 'Shared city memory'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-white/40">Economics</p>
|
||||
<p className="text-white/70 text-sm">
|
||||
{daisEconomics && Object.keys(daisEconomics).length > 0
|
||||
? JSON.stringify(daisEconomics)
|
||||
: 'per_task'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">City Presence</h2>
|
||||
{citizen.city_presence?.rooms?.length ? (
|
||||
<div className="space-y-2">
|
||||
{citizen.city_presence.rooms.map((room) => (
|
||||
<Link
|
||||
key={room.slug || room.room_id}
|
||||
href={room.slug ? `/city/${room.slug}` : '#'}
|
||||
className="flex items-center justify-between bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white hover:border-cyan-500/40 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold">{room.name || room.slug}</p>
|
||||
<p className="text-white/50 text-xs">{room.slug}</p>
|
||||
</div>
|
||||
<span className="text-white/50">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white/50 text-sm">Публічні кімнати не вказані.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">Interaction</h2>
|
||||
{actions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{actions.map((action) => (
|
||||
<span
|
||||
key={action}
|
||||
className="px-3 py-1 bg-cyan-500/20 text-cyan-200 rounded-full text-xs"
|
||||
>
|
||||
{action}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white/50 text-sm">Публічні сценарії взаємодії готуються.</p>
|
||||
)}
|
||||
<button className="w-full mt-4 px-4 py-2 bg-cyan-500/20 text-cyan-100 rounded-lg hover:bg-cyan-500/30 transition-colors">
|
||||
💬 Запросити до діалогу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-3">
|
||||
<h2 className="text-white font-semibold">Public Metrics</h2>
|
||||
{metricsEntries.length ? (
|
||||
<div className="space-y-2">
|
||||
{metricsEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-white/50">{key}</span>
|
||||
<span className="text-white font-semibold">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white/50 text-sm">Метрики поки не опубліковані.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
297
apps/web/src/app/citizens/page.tsx
Normal file
297
apps/web/src/app/citizens/page.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { getAgentKindIcon } from '@/lib/agent-dashboard';
|
||||
import { DISTRICTS } from '@/lib/microdao';
|
||||
import { useCitizensList } from '@/hooks/useCitizens';
|
||||
import type { PublicCitizenSummary } from '@/lib/types/citizens';
|
||||
|
||||
const CITIZEN_KINDS = [
|
||||
'vision',
|
||||
'curator',
|
||||
'security',
|
||||
'finance',
|
||||
'civic',
|
||||
'oracle',
|
||||
'builder',
|
||||
'research',
|
||||
];
|
||||
|
||||
export default function CitizensPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [district, setDistrict] = useState('');
|
||||
const [kind, setKind] = useState('');
|
||||
|
||||
const { items, total, isLoading, error } = useCitizensList({
|
||||
district: district || undefined,
|
||||
kind: kind || undefined,
|
||||
q: search || undefined,
|
||||
});
|
||||
|
||||
const citizens = useMemo(() => items ?? [], [items]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
🏛️ Citizens of DAARION City
|
||||
</h1>
|
||||
<p className="text-white/60">
|
||||
Публічні AI-агенти, відкриті для співпраці та взаємодії
|
||||
</p>
|
||||
<p className="text-sm text-cyan-300/80 mt-2">
|
||||
{isLoading ? 'Оновлення списку…' : `Знайдено громадян: ${total}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<label className="text-xs uppercase text-white/40 block mb-2">
|
||||
Пошук
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Імʼя, титул або теглайн"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/30 focus:border-cyan-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase text-white/40 block mb-2">
|
||||
District
|
||||
</label>
|
||||
<select
|
||||
value={district}
|
||||
onChange={(e) => setDistrict(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-cyan-500/50 focus:outline-none"
|
||||
>
|
||||
<option value="">Всі дістрікти</option>
|
||||
{DISTRICTS.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase text-white/40 block mb-2">
|
||||
Тип агента
|
||||
</label>
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-cyan-500/50 focus:outline-none"
|
||||
>
|
||||
<option value="">Всі типи</option>
|
||||
{CITIZEN_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/40 rounded-xl px-4 py-3 text-sm text-red-200">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{(isLoading ? Array.from({ length: 6 }) : citizens).map(
|
||||
(citizen, index) =>
|
||||
citizen ? (
|
||||
<CitizenCard key={citizen.slug} citizen={citizen} />
|
||||
) : (
|
||||
<div
|
||||
key={`placeholder-${index}`}
|
||||
className="bg-white/5 rounded-2xl border border-white/5 animate-pulse h-60"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && citizens.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-white/40">Наразі немає публічних громадян за цими фільтрами.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CitizenCard({ citizen }: { citizen: PublicCitizenSummary }) {
|
||||
const status = citizen.online_status || 'unknown';
|
||||
const statusColor =
|
||||
status === 'online' ? 'text-emerald-400' : 'text-white/40';
|
||||
|
||||
return (
|
||||
<Link key={citizen.slug} href={`/citizens/${citizen.slug}`} className="group">
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 hover:border-cyan-500/50 transition-all hover:bg-white/10">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-cyan-500/30 to-purple-500/30 flex items-center justify-center text-3xl">
|
||||
{getAgentKindIcon(citizen.kind || '')}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-white group-hover:text-cyan-400 transition-colors">
|
||||
{citizen.display_name}
|
||||
</h3>
|
||||
<p className="text-cyan-400 text-sm">
|
||||
{citizen.public_title || citizen.kind}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{citizen.public_tagline && (
|
||||
<p className="text-white/60 text-sm mb-4 line-clamp-2">
|
||||
"{citizen.public_tagline}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-white/40 text-xs mb-4">
|
||||
{citizen.district && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>📍</span> {citizen.district}
|
||||
</span>
|
||||
)}
|
||||
{citizen.primary_room_slug && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🚪</span> #{citizen.primary_room_slug}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{citizen.public_skills?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{citizen.public_skills.slice(0, 4).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-0.5 bg-cyan-500/10 text-cyan-400 rounded text-xs"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{citizen.public_skills.length > 4 && (
|
||||
<span className="px-2 py-0.5 text-white/30 text-xs">
|
||||
+{citizen.public_skills.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<span className={`flex items-center gap-1.5 text-xs ${statusColor}`}>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
status === 'online' ? 'bg-emerald-500' : 'bg-white/30'
|
||||
}`}
|
||||
/>
|
||||
{status}
|
||||
</span>
|
||||
<span className="text-cyan-400 text-sm group-hover:translate-x-1 transition-transform">
|
||||
View Profile →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
<Link
|
||||
key={citizen.id}
|
||||
href={`/citizens/${citizen.public_slug}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 hover:border-cyan-500/50 transition-all hover:bg-white/10">
|
||||
{/* Avatar & Name */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-cyan-500/30 to-purple-500/30 flex items-center justify-center text-3xl">
|
||||
{getAgentKindIcon(citizen.kind)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-white group-hover:text-cyan-400 transition-colors">
|
||||
{citizen.display_name}
|
||||
</h3>
|
||||
<p className="text-cyan-400 text-sm">
|
||||
{citizen.public_title || citizen.kind}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
{citizen.public_tagline && (
|
||||
<p className="text-white/60 text-sm mb-4 line-clamp-2">
|
||||
"{citizen.public_tagline}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* District & Room */}
|
||||
<div className="flex items-center gap-4 text-white/40 text-xs mb-4">
|
||||
{citizen.public_district && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>📍</span> {citizen.public_district}
|
||||
</span>
|
||||
)}
|
||||
{citizen.public_primary_room_slug && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🚪</span> #{citizen.public_primary_room_slug}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
{citizen.public_skills && citizen.public_skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{citizen.public_skills.slice(0, 4).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-0.5 bg-cyan-500/10 text-cyan-400 rounded text-xs"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{citizen.public_skills.length > 4 && (
|
||||
<span className="px-2 py-0.5 text-white/30 text-xs">
|
||||
+{citizen.public_skills.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<span className={`flex items-center gap-1.5 text-xs ${
|
||||
citizen.status === 'online' ? 'text-green-400' : 'text-white/40'
|
||||
}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
citizen.status === 'online' ? 'bg-green-500' : 'bg-white/30'
|
||||
}`} />
|
||||
{citizen.status}
|
||||
</span>
|
||||
<span className="text-cyan-400 text-sm group-hover:translate-x-1 transition-transform">
|
||||
View Profile →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{citizens.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-white/40">No public citizens yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function MicrodaoDetailPage() {
|
||||
const matrixChannels = microdao.channels.filter((c) => c.kind === "matrix");
|
||||
const cityRooms = microdao.channels.filter((c) => c.kind === "city_room");
|
||||
const crewChannels = microdao.channels.filter((c) => c.kind === "crew");
|
||||
const publicCitizens = microdao.public_citizens ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||
@@ -156,6 +157,41 @@ export default function MicrodaoDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{publicCitizens.length > 0 && (
|
||||
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Громадяни цього MicroDAO
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{publicCitizens.map((citizen) => (
|
||||
<Link
|
||||
key={citizen.slug}
|
||||
href={`/citizens/${citizen.slug}`}
|
||||
className="flex items-center justify-between border border-white/10 rounded-lg px-4 py-3 hover:border-cyan-500/40 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="text-white font-medium">{citizen.display_name}</p>
|
||||
{citizen.public_title && (
|
||||
<p className="text-sm text-white/60">{citizen.public_title}</p>
|
||||
)}
|
||||
</div>
|
||||
{citizen.district && (
|
||||
<span className="text-xs text-white/50">{citizen.district}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Channels */}
|
||||
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||
|
||||
107
apps/web/src/app/nodes/page.tsx
Normal file
107
apps/web/src/app/nodes/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useNodeDashboard } from '@/hooks/useNodeDashboard';
|
||||
import {
|
||||
NodeSummaryCard,
|
||||
InfraCard,
|
||||
AIServicesCard,
|
||||
AgentsCard,
|
||||
MatrixCard,
|
||||
ModulesCard,
|
||||
NodeStandardComplianceCard
|
||||
} from '@/components/node-dashboard';
|
||||
|
||||
export default function NodeDashboardPage() {
|
||||
const { dashboard, isLoading, error, refresh, lastUpdated } = useNodeDashboard({
|
||||
refreshInterval: 30000 // 30 seconds
|
||||
});
|
||||
|
||||
if (isLoading && !dashboard) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p className="text-white/70">Loading node dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
|
||||
<p className="text-red-400 text-lg mb-2">Failed to load dashboard</p>
|
||||
<p className="text-white/50 mb-4">{error.message}</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dashboard) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Node Dashboard</h1>
|
||||
<p className="text-white/50 text-sm">
|
||||
Real-time monitoring and status
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{lastUpdated && (
|
||||
<p className="text-white/30 text-sm">
|
||||
Updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-cyan-500/20 hover:bg-cyan-500/30 text-cyan-400 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Node Summary */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<NodeSummaryCard node={dashboard.node} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InfraCard infra={dashboard.infra} />
|
||||
<AgentsCard agents={dashboard.agents} />
|
||||
</div>
|
||||
|
||||
<AIServicesCard ai={dashboard.ai} />
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-6">
|
||||
<NodeStandardComplianceCard node={dashboard.node} />
|
||||
<MatrixCard matrix={dashboard.matrix} />
|
||||
<ModulesCard modules={dashboard.node.modules} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
74
apps/web/src/components/agent-dashboard/AgentCityCard.tsx
Normal file
74
apps/web/src/components/agent-dashboard/AgentCityCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { CityPresence } from '@/lib/agent-dashboard';
|
||||
|
||||
interface AgentCityCardProps {
|
||||
cityPresence?: CityPresence;
|
||||
}
|
||||
|
||||
export function AgentCityCard({ cityPresence }: AgentCityCardProps) {
|
||||
if (!cityPresence) {
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>🏛️</span> City Presence
|
||||
</h3>
|
||||
<p className="text-white/50">No city presence configured</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>🏛️</span> City Presence
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* District */}
|
||||
{cityPresence.district && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/50 text-sm">District:</span>
|
||||
<span className="px-2 py-1 bg-amber-500/20 text-amber-400 rounded-md text-sm">
|
||||
{cityPresence.district}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary Room */}
|
||||
{cityPresence.primary_room_slug && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/50 text-sm">Primary:</span>
|
||||
<Link
|
||||
href={`/city/${cityPresence.primary_room_slug}`}
|
||||
className="px-2 py-1 bg-cyan-500/20 text-cyan-400 rounded-md text-sm hover:bg-cyan-500/30 transition-colors"
|
||||
>
|
||||
📍 {cityPresence.primary_room_slug}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Rooms */}
|
||||
{cityPresence.rooms && cityPresence.rooms.length > 0 && (
|
||||
<div>
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-2">Rooms</p>
|
||||
<div className="space-y-1">
|
||||
{cityPresence.rooms.map(room => (
|
||||
<Link
|
||||
key={room.room_id}
|
||||
href={`/city/${room.slug}`}
|
||||
className="flex items-center justify-between p-2 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span className="text-white text-sm">{room.name}</span>
|
||||
<span className="text-white/30 text-xs">{room.role}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
98
apps/web/src/components/agent-dashboard/AgentDAISCard.tsx
Normal file
98
apps/web/src/components/agent-dashboard/AgentDAISCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { DAIS } from '@/lib/agent-dashboard';
|
||||
|
||||
interface AgentDAISCardProps {
|
||||
dais: DAIS;
|
||||
}
|
||||
|
||||
export function AgentDAISCard({ dais }: AgentDAISCardProps) {
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>🧬</span> DAIS Profile
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* CORE */}
|
||||
<div className="p-3 bg-purple-500/10 rounded-xl border border-purple-500/20">
|
||||
<p className="text-purple-400 text-xs uppercase tracking-wider mb-2">CORE — Identity</p>
|
||||
<p className="text-white font-medium">{dais.core.title || 'Untitled'}</p>
|
||||
{dais.core.bio && (
|
||||
<p className="text-white/50 text-sm mt-1">{dais.core.bio}</p>
|
||||
)}
|
||||
{dais.core.version && (
|
||||
<p className="text-white/30 text-xs mt-2">v{dais.core.version}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* COG */}
|
||||
{dais.cog && (
|
||||
<div className="p-3 bg-cyan-500/10 rounded-xl border border-cyan-500/20">
|
||||
<p className="text-cyan-400 text-xs uppercase tracking-wider mb-2">COG — Brain</p>
|
||||
<div className="space-y-1">
|
||||
{dais.cog.base_model && (
|
||||
<p className="text-white text-sm">
|
||||
<span className="text-white/50">Model:</span> {dais.cog.base_model}
|
||||
</p>
|
||||
)}
|
||||
{dais.cog.provider && (
|
||||
<p className="text-white text-sm">
|
||||
<span className="text-white/50">Provider:</span> {dais.cog.provider}
|
||||
</p>
|
||||
)}
|
||||
{dais.cog.node_id && (
|
||||
<p className="text-white text-sm">
|
||||
<span className="text-white/50">Node:</span> {dais.cog.node_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ACT */}
|
||||
{dais.act && (
|
||||
<div className="p-3 bg-green-500/10 rounded-xl border border-green-500/20">
|
||||
<p className="text-green-400 text-xs uppercase tracking-wider mb-2">ACT — Capabilities</p>
|
||||
{dais.act.tools && Array.isArray(dais.act.tools) && dais.act.tools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{dais.act.tools.map((tool, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded text-xs"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{dais.act.matrix?.user_id && (
|
||||
<p className="text-white/50 text-sm mt-2">
|
||||
Matrix: {dais.act.matrix.user_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VIS */}
|
||||
{dais.vis && (
|
||||
<div className="p-3 bg-pink-500/10 rounded-xl border border-pink-500/20">
|
||||
<p className="text-pink-400 text-xs uppercase tracking-wider mb-2">VIS — Appearance</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{dais.vis.color_primary && (
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: dais.vis.color_primary }}
|
||||
/>
|
||||
)}
|
||||
{dais.vis.avatar_style && (
|
||||
<span className="text-white/50 text-sm">{dais.vis.avatar_style}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
80
apps/web/src/components/agent-dashboard/AgentMetricsCard.tsx
Normal file
80
apps/web/src/components/agent-dashboard/AgentMetricsCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { AgentMetrics } from '@/lib/agent-dashboard';
|
||||
|
||||
interface AgentMetricsCardProps {
|
||||
metrics?: AgentMetrics;
|
||||
}
|
||||
|
||||
export function AgentMetricsCard({ metrics }: AgentMetricsCardProps) {
|
||||
if (!metrics) {
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>📊</span> Metrics
|
||||
</h3>
|
||||
<p className="text-white/50">No metrics available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const successRate = metrics.success_rate_24h ?? 1;
|
||||
const successPercent = (successRate * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>📊</span> Metrics
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Tasks 1h */}
|
||||
<div className="p-3 bg-white/5 rounded-xl text-center">
|
||||
<p className="text-2xl font-bold text-white">{metrics.tasks_1h ?? 0}</p>
|
||||
<p className="text-white/50 text-xs">Tasks (1h)</p>
|
||||
</div>
|
||||
|
||||
{/* Tasks 24h */}
|
||||
<div className="p-3 bg-white/5 rounded-xl text-center">
|
||||
<p className="text-2xl font-bold text-white">{metrics.tasks_24h ?? 0}</p>
|
||||
<p className="text-white/50 text-xs">Tasks (24h)</p>
|
||||
</div>
|
||||
|
||||
{/* Success Rate */}
|
||||
<div className="p-3 bg-green-500/10 rounded-xl text-center">
|
||||
<p className="text-2xl font-bold text-green-400">{successPercent}%</p>
|
||||
<p className="text-white/50 text-xs">Success Rate</p>
|
||||
</div>
|
||||
|
||||
{/* Latency */}
|
||||
<div className="p-3 bg-blue-500/10 rounded-xl text-center">
|
||||
<p className="text-2xl font-bold text-blue-400">{metrics.avg_latency_ms_1h ?? 0}</p>
|
||||
<p className="text-white/50 text-xs">Latency (ms)</p>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{(metrics.errors_24h ?? 0) > 0 && (
|
||||
<div className="col-span-2 p-3 bg-red-500/10 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/70 text-sm">Errors (24h)</span>
|
||||
<span className="text-red-400 font-bold">{metrics.errors_24h}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tokens */}
|
||||
{(metrics.tokens_24h ?? 0) > 0 && (
|
||||
<div className="col-span-2 p-3 bg-purple-500/10 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/70 text-sm">Tokens (24h)</span>
|
||||
<span className="text-purple-400 font-bold">
|
||||
{(metrics.tokens_24h ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
AgentMicrodaoMembership,
|
||||
MicrodaoOption,
|
||||
} from "@/lib/microdao";
|
||||
import {
|
||||
fetchMicrodaoOptions,
|
||||
assignAgentToMicrodao,
|
||||
removeAgentFromMicrodao,
|
||||
} from "@/lib/api/microdao";
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
memberships: AgentMicrodaoMembership[];
|
||||
canEdit: boolean;
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
export function AgentMicrodaoMembershipCard({
|
||||
agentId,
|
||||
memberships,
|
||||
canEdit,
|
||||
onUpdated,
|
||||
}: Props) {
|
||||
const [options, setOptions] = useState<MicrodaoOption[]>([]);
|
||||
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||
const [selectedMicrodaoId, setSelectedMicrodaoId] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [isCore, setIsCore] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEdit) return;
|
||||
setLoadingOptions(true);
|
||||
fetchMicrodaoOptions()
|
||||
.then(setOptions)
|
||||
.catch((err) =>
|
||||
setError(err instanceof Error ? err.message : "Failed to load options")
|
||||
)
|
||||
.finally(() => setLoadingOptions(false));
|
||||
}, [canEdit]);
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedMicrodaoId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await assignAgentToMicrodao(agentId, {
|
||||
microdao_id: selectedMicrodaoId,
|
||||
role: role.trim() || undefined,
|
||||
is_core: isCore,
|
||||
});
|
||||
setSelectedMicrodaoId("");
|
||||
setRole("");
|
||||
setIsCore(false);
|
||||
onUpdated?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to assign MicroDAO");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (microdaoId: string) => {
|
||||
if (!canEdit) return;
|
||||
const confirmed = window.confirm("Видалити це членство?");
|
||||
if (!confirmed) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await removeAgentFromMicrodao(agentId, microdaoId);
|
||||
onUpdated?.();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to remove MicroDAO membership"
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">MicroDAO membership</h2>
|
||||
{saving && <span className="text-xs text-white/60">Збереження…</span>}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-300 bg-red-500/10 border border-red-500/30 rounded-xl px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{memberships.length === 0 ? (
|
||||
<p className="text-sm text-white/50">
|
||||
Цей агент поки не входить до жодного MicroDAO.
|
||||
</p>
|
||||
) : (
|
||||
memberships.map((membership) => (
|
||||
<div
|
||||
key={membership.microdao_id}
|
||||
className="flex items-center justify-between border border-white/10 rounded-xl px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-white font-medium">
|
||||
{membership.microdao_name}
|
||||
</p>
|
||||
<p className="text-white/60 text-sm">
|
||||
{membership.role || "member"}
|
||||
{membership.is_core ? " • core" : ""}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => handleRemove(membership.microdao_id)}
|
||||
className="text-xs uppercase tracking-wide text-white/60 hover:text-red-300"
|
||||
disabled={saving}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="border-t border-white/10 pt-4 space-y-3">
|
||||
<h3 className="text-sm text-white/80 font-medium">Додати в MicroDAO</h3>
|
||||
{loadingOptions ? (
|
||||
<p className="text-sm text-white/50">Завантаження списку…</p>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={selectedMicrodaoId}
|
||||
onChange={(e) => setSelectedMicrodaoId(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-cyan-500/50 focus:outline-none"
|
||||
>
|
||||
<option value="">Обрати MicroDAO…</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name} {option.district ? `(${option.district})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
placeholder="Роль (orchestrator / member / council)"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 focus:border-cyan-500/50 focus:outline-none"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isCore}
|
||||
onChange={(e) => setIsCore(e.target.checked)}
|
||||
className="rounded border-white/20 bg-slate-900"
|
||||
/>
|
||||
Core member
|
||||
</label>
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={saving || !selectedMicrodaoId}
|
||||
className="px-4 py-2 bg-cyan-500/20 text-cyan-100 rounded-lg hover:bg-cyan-500/30 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Зберегти
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AgentPublicProfile, updateAgentPublicProfile } from '@/lib/agent-dashboard';
|
||||
|
||||
interface AgentPublicProfileCardProps {
|
||||
agentId: string;
|
||||
publicProfile?: AgentPublicProfile;
|
||||
canEdit?: boolean;
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
const DISTRICTS = [
|
||||
'Central',
|
||||
'Creators',
|
||||
'Engineering',
|
||||
'Marketing',
|
||||
'Finance',
|
||||
'Security',
|
||||
'Research',
|
||||
'Community',
|
||||
'Governance',
|
||||
'Innovation'
|
||||
];
|
||||
|
||||
export function AgentPublicProfileCard({
|
||||
agentId,
|
||||
publicProfile,
|
||||
canEdit = false,
|
||||
onUpdated
|
||||
}: AgentPublicProfileCardProps) {
|
||||
const [isPublic, setIsPublic] = useState(publicProfile?.is_public || false);
|
||||
const [slug, setSlug] = useState(publicProfile?.public_slug || '');
|
||||
const [title, setTitle] = useState(publicProfile?.public_title || '');
|
||||
const [tagline, setTagline] = useState(publicProfile?.public_tagline || '');
|
||||
const [skills, setSkills] = useState<string[]>(publicProfile?.public_skills || []);
|
||||
const [district, setDistrict] = useState(publicProfile?.public_district || '');
|
||||
const [primaryRoom, setPrimaryRoom] = useState(publicProfile?.public_primary_room_slug || '');
|
||||
const [newSkill, setNewSkill] = useState('');
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Update local state when props change
|
||||
useEffect(() => {
|
||||
if (publicProfile) {
|
||||
setIsPublic(publicProfile.is_public || false);
|
||||
setSlug(publicProfile.public_slug || '');
|
||||
setTitle(publicProfile.public_title || '');
|
||||
setTagline(publicProfile.public_tagline || '');
|
||||
setSkills(publicProfile.public_skills || []);
|
||||
setDistrict(publicProfile.public_district || '');
|
||||
setPrimaryRoom(publicProfile.public_primary_room_slug || '');
|
||||
}
|
||||
}, [publicProfile]);
|
||||
|
||||
const hasChanges = () => {
|
||||
if (!publicProfile) return true;
|
||||
return (
|
||||
isPublic !== (publicProfile.is_public || false) ||
|
||||
slug !== (publicProfile.public_slug || '') ||
|
||||
title !== (publicProfile.public_title || '') ||
|
||||
tagline !== (publicProfile.public_tagline || '') ||
|
||||
JSON.stringify(skills) !== JSON.stringify(publicProfile.public_skills || []) ||
|
||||
district !== (publicProfile.public_district || '') ||
|
||||
primaryRoom !== (publicProfile.public_primary_room_slug || '')
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddSkill = () => {
|
||||
if (newSkill.trim() && skills.length < 10) {
|
||||
setSkills([...skills, newSkill.trim().toLowerCase()]);
|
||||
setNewSkill('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSkill = (index: number) => {
|
||||
setSkills(skills.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving) return;
|
||||
|
||||
// Validate
|
||||
if (isPublic && !slug.trim()) {
|
||||
setError('Slug is required for public agents');
|
||||
setSaveStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveStatus('idle');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await updateAgentPublicProfile(agentId, {
|
||||
is_public: isPublic,
|
||||
public_slug: slug.trim() || null,
|
||||
public_title: title.trim() || null,
|
||||
public_tagline: tagline.trim() || null,
|
||||
public_skills: skills,
|
||||
public_district: district || null,
|
||||
public_primary_room_slug: primaryRoom || null
|
||||
});
|
||||
|
||||
setSaveStatus('success');
|
||||
onUpdated?.();
|
||||
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
} catch (err) {
|
||||
setSaveStatus('error');
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<span>🌐</span> Public Profile Settings
|
||||
</h3>
|
||||
<p className="text-white/50 text-sm mt-1">
|
||||
Налаштування публічного профілю для каталогу громадян DAARION City
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Is Public Toggle */}
|
||||
<div className="mb-6 p-4 bg-white/5 rounded-xl">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
disabled={!canEdit}
|
||||
className="w-5 h-5 rounded border-white/20 bg-white/10 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white font-medium">Make Public</span>
|
||||
<p className="text-white/40 text-sm">
|
||||
Агент буде видимий у публічному каталозі /citizens
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-white/70 text-sm mb-1">
|
||||
Public Slug <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/40">/citizens/</span>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
|
||||
placeholder="iris"
|
||||
disabled={!canEdit}
|
||||
className="flex-1 p-2 rounded-lg bg-white/5 border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-cyan-500/50"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-white/30 text-xs mt-1">
|
||||
Тільки малі літери, цифри, _ та -
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-white/70 text-sm mb-1">Public Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Multimodal Vision Curator"
|
||||
disabled={!canEdit}
|
||||
className="w-full p-2 rounded-lg bg-white/5 border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-cyan-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-white/70 text-sm mb-1">Public Tagline</label>
|
||||
<textarea
|
||||
value={tagline}
|
||||
onChange={(e) => setTagline(e.target.value)}
|
||||
placeholder="Я дивлюся на світ і знаходжу суть..."
|
||||
disabled={!canEdit}
|
||||
rows={2}
|
||||
className="w-full p-2 rounded-lg bg-white/5 border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-cyan-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* District */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-white/70 text-sm mb-1">District</label>
|
||||
<select
|
||||
value={district}
|
||||
onChange={(e) => setDistrict(e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className="w-full p-2 rounded-lg bg-white/5 border border-white/10 text-white focus:outline-none focus:border-cyan-500/50"
|
||||
>
|
||||
<option value="">Select district...</option>
|
||||
{DISTRICTS.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Primary Room */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-white/70 text-sm mb-1">Primary Room Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={primaryRoom}
|
||||
onChange={(e) => setPrimaryRoom(e.target.value.toLowerCase())}
|
||||
placeholder="vision_lab"
|
||||
disabled={!canEdit}
|
||||
className="w-full p-2 rounded-lg bg-white/5 border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-cyan-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-white/70 text-sm mb-1">
|
||||
Skills <span className="text-white/30">(max 10)</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-cyan-500/20 text-cyan-400 rounded-md text-sm flex items-center gap-1"
|
||||
>
|
||||
{skill}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => handleRemoveSkill(index)}
|
||||
className="hover:text-red-400 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{canEdit && skills.length < 10 && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSkill}
|
||||
onChange={(e) => setNewSkill(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddSkill()}
|
||||
placeholder="Add skill..."
|
||||
className="flex-1 p-2 rounded-lg bg-white/5 border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-cyan-500/50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddSkill}
|
||||
className="px-3 py-2 bg-cyan-500/20 text-cyan-400 rounded-lg hover:bg-cyan-500/30 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
{saveStatus === 'success' && (
|
||||
<span className="text-green-400 text-sm flex items-center gap-1">
|
||||
<span>✓</span> Saved
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="text-red-400 text-sm">
|
||||
{error || 'Failed to save'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges() || saving}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
${hasChanges() && !saving
|
||||
? 'bg-cyan-500 hover:bg-cyan-400 text-white'
|
||||
: 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Public Profile'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Link */}
|
||||
{isPublic && slug && (
|
||||
<div className="mt-4 p-3 bg-green-500/10 rounded-lg">
|
||||
<p className="text-green-400 text-sm flex items-center gap-2">
|
||||
<span>✓</span>
|
||||
Public profile available at:
|
||||
<a
|
||||
href={`/citizens/${slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-green-300"
|
||||
>
|
||||
/citizens/{slug}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
96
apps/web/src/components/agent-dashboard/AgentSummaryCard.tsx
Normal file
96
apps/web/src/components/agent-dashboard/AgentSummaryCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { AgentProfile, AgentRuntime, getAgentKindIcon, getAgentStatusColor } from '@/lib/agent-dashboard';
|
||||
import { StatusBadge } from '@/components/node-dashboard';
|
||||
|
||||
interface AgentSummaryCardProps {
|
||||
profile: AgentProfile;
|
||||
runtime?: AgentRuntime;
|
||||
}
|
||||
|
||||
export function AgentSummaryCard({ profile, runtime }: AgentSummaryCardProps) {
|
||||
const kindIcon = getAgentKindIcon(profile.kind);
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="relative">
|
||||
{profile.dais.vis?.avatar_url ? (
|
||||
<img
|
||||
src={profile.dais.vis.avatar_url}
|
||||
alt={profile.display_name}
|
||||
className="w-20 h-20 rounded-xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-20 h-20 rounded-xl flex items-center justify-center text-4xl"
|
||||
style={{ backgroundColor: profile.dais.vis?.color_primary || '#22D3EE' + '30' }}
|
||||
>
|
||||
{kindIcon}
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-slate-900 ${
|
||||
profile.status === 'online' ? 'bg-green-500' :
|
||||
profile.status === 'training' ? 'bg-yellow-500' : 'bg-gray-500'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h2 className="text-xl font-bold text-white">{profile.display_name}</h2>
|
||||
<StatusBadge status={profile.status} size="sm" />
|
||||
</div>
|
||||
|
||||
<p className="text-white/50 text-sm mb-2">{profile.agent_id}</p>
|
||||
|
||||
{profile.dais.core.bio && (
|
||||
<p className="text-white/70 text-sm mb-3">{profile.dais.core.bio}</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span
|
||||
className="px-2 py-1 rounded-md text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: (profile.dais.vis?.color_primary || '#22D3EE') + '30',
|
||||
color: profile.dais.vis?.color_primary || '#22D3EE'
|
||||
}}
|
||||
>
|
||||
{kindIcon} {profile.kind}
|
||||
</span>
|
||||
{profile.roles.map(role => (
|
||||
<span
|
||||
key={role}
|
||||
className="px-2 py-1 bg-white/10 text-white/70 rounded-md text-sm"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime info */}
|
||||
{runtime && (
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
runtime.health === 'healthy' ? 'bg-green-500' : 'bg-yellow-500'
|
||||
}`} />
|
||||
<span className="text-white/50 text-sm">
|
||||
{runtime.health === 'healthy' ? 'Healthy' : 'Degraded'}
|
||||
</span>
|
||||
</div>
|
||||
{profile.node_id && (
|
||||
<span className="text-white/30 text-sm">
|
||||
📍 {profile.node_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AgentSystemPrompts,
|
||||
PromptKind,
|
||||
updateAgentPrompt
|
||||
} from '@/lib/agent-dashboard';
|
||||
|
||||
interface AgentSystemPromptsCardProps {
|
||||
agentId: string;
|
||||
systemPrompts?: AgentSystemPrompts;
|
||||
canEdit?: boolean;
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
const PROMPT_KINDS: { id: PromptKind; label: string; icon: string; description: string }[] = [
|
||||
{
|
||||
id: 'core',
|
||||
label: 'Core',
|
||||
icon: '🧬',
|
||||
description: 'Основна особистість і стиль агента'
|
||||
},
|
||||
{
|
||||
id: 'safety',
|
||||
label: 'Safety',
|
||||
icon: '🛡️',
|
||||
description: 'Обмеження безпеки та заборонені дії'
|
||||
},
|
||||
{
|
||||
id: 'governance',
|
||||
label: 'Governance',
|
||||
icon: '⚖️',
|
||||
description: 'Правила взаємодії з DAO та іншими агентами'
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: 'Tools',
|
||||
icon: '🔧',
|
||||
description: 'Налаштування використання інструментів'
|
||||
}
|
||||
];
|
||||
|
||||
export function AgentSystemPromptsCard({
|
||||
agentId,
|
||||
systemPrompts,
|
||||
canEdit = false,
|
||||
onUpdated
|
||||
}: AgentSystemPromptsCardProps) {
|
||||
const [activeTab, setActiveTab] = useState<PromptKind>('core');
|
||||
const [editedContent, setEditedContent] = useState<Record<PromptKind, string>>({
|
||||
core: systemPrompts?.core?.content || '',
|
||||
safety: systemPrompts?.safety?.content || '',
|
||||
governance: systemPrompts?.governance?.content || '',
|
||||
tools: systemPrompts?.tools?.content || ''
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const currentPrompt = systemPrompts?.[activeTab];
|
||||
const currentContent = editedContent[activeTab];
|
||||
const hasChanges = currentContent !== (currentPrompt?.content || '');
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveStatus('idle');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await updateAgentPrompt(agentId, activeTab, currentContent);
|
||||
setSaveStatus('success');
|
||||
onUpdated?.();
|
||||
|
||||
// Reset success status after 3 seconds
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
} catch (err) {
|
||||
setSaveStatus('error');
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<span>📝</span> System Prompts
|
||||
</h3>
|
||||
<p className="text-white/50 text-sm mt-1">
|
||||
Системні промти визначають базову поведінку агента у DAARION City
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-4 overflow-x-auto pb-1">
|
||||
{PROMPT_KINDS.map(kind => {
|
||||
const prompt = systemPrompts?.[kind.id];
|
||||
const isActive = activeTab === kind.id;
|
||||
const hasContent = !!prompt?.content;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={kind.id}
|
||||
onClick={() => setActiveTab(kind.id)}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm whitespace-nowrap transition-colors
|
||||
${isActive
|
||||
? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'bg-white/5 text-white/50 hover:bg-white/10 hover:text-white/70'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{kind.icon}</span>
|
||||
<span>{kind.label}</span>
|
||||
{hasContent && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active Tab Description */}
|
||||
<p className="text-white/40 text-xs mb-3">
|
||||
{PROMPT_KINDS.find(k => k.id === activeTab)?.description}
|
||||
</p>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-3">
|
||||
{/* Version info */}
|
||||
{currentPrompt && (
|
||||
<div className="flex items-center gap-3 text-xs text-white/40">
|
||||
<span>v{currentPrompt.version}</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(currentPrompt.updated_at).toLocaleString()}</span>
|
||||
{currentPrompt.updated_by && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>by {currentPrompt.updated_by}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
value={currentContent}
|
||||
onChange={(e) => setEditedContent(prev => ({
|
||||
...prev,
|
||||
[activeTab]: e.target.value
|
||||
}))}
|
||||
placeholder={`Enter ${activeTab} prompt...`}
|
||||
disabled={!canEdit}
|
||||
className={`
|
||||
w-full h-48 p-3 rounded-xl text-sm font-mono
|
||||
bg-white/5 border border-white/10
|
||||
text-white placeholder-white/30
|
||||
focus:outline-none focus:border-cyan-500/50
|
||||
resize-none
|
||||
${!canEdit ? 'opacity-60 cursor-not-allowed' : ''}
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{saveStatus === 'success' && (
|
||||
<span className="text-green-400 text-sm flex items-center gap-1">
|
||||
<span>✓</span> Saved
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="text-red-400 text-sm">
|
||||
{error || 'Failed to save'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
${hasChanges && !saving
|
||||
? 'bg-cyan-500 hover:bg-cyan-400 text-white'
|
||||
: 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canEdit && (
|
||||
<p className="text-white/30 text-xs text-center">
|
||||
🔒 Only Architects and Admins can edit system prompts
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
8
apps/web/src/components/agent-dashboard/index.ts
Normal file
8
apps/web/src/components/agent-dashboard/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { AgentSummaryCard } from './AgentSummaryCard';
|
||||
export { AgentDAISCard } from './AgentDAISCard';
|
||||
export { AgentCityCard } from './AgentCityCard';
|
||||
export { AgentMetricsCard } from './AgentMetricsCard';
|
||||
export { AgentSystemPromptsCard } from './AgentSystemPromptsCard';
|
||||
export { AgentPublicProfileCard } from './AgentPublicProfileCard';
|
||||
export { AgentMicrodaoMembershipCard } from './AgentMicrodaoMembershipCard';
|
||||
|
||||
28
apps/web/src/components/city/CityChatWidget.tsx
Normal file
28
apps/web/src/components/city/CityChatWidget.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { MatrixChatRoom } from '@/components/chat/MatrixChatRoom';
|
||||
|
||||
type CityChatWidgetProps = {
|
||||
roomSlug: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Обгортка для MatrixChatRoom, яка використовується на сторінці громадянина.
|
||||
* Показує inline Matrix-чат у рамках профілю.
|
||||
*/
|
||||
export function CityChatWidget({ roomSlug }: CityChatWidgetProps) {
|
||||
if (!roomSlug) {
|
||||
return (
|
||||
<div className="text-sm text-white/60">
|
||||
Для цього громадянина ще не налаштована публічна кімната чату.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-white/10 rounded-2xl overflow-hidden bg-slate-900/50 min-h-[400px] max-h-[600px] flex flex-col">
|
||||
<MatrixChatRoom roomSlug={roomSlug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
107
apps/web/src/components/node-dashboard/AIServicesCard.tsx
Normal file
107
apps/web/src/components/node-dashboard/AIServicesCard.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { AIServices } from '@/lib/node-dashboard';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
interface AIServicesCardProps {
|
||||
ai: AIServices;
|
||||
}
|
||||
|
||||
export function AIServicesCard({ ai }: AIServicesCardProps) {
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>🤖</span> AI Services
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Router */}
|
||||
<div className="p-3 bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">DAGI Router</span>
|
||||
<StatusBadge status={ai.router.status} size="sm" />
|
||||
</div>
|
||||
{ai.router.status !== 'not_installed' && (
|
||||
<div className="text-sm text-white/50 space-y-1">
|
||||
<p>Version: {ai.router.version}</p>
|
||||
<p>NATS: {ai.router.nats_connected ? '✅ Connected' : '❌ Disconnected'}</p>
|
||||
{ai.router.backends.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{ai.router.backends.map(b => (
|
||||
<span
|
||||
key={b.name}
|
||||
className={`px-1.5 py-0.5 rounded text-xs ${
|
||||
b.status === 'up' ? 'bg-green-500/20 text-green-400' :
|
||||
b.status === 'degraded' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{b.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swapper */}
|
||||
<div className="p-3 bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">Swapper</span>
|
||||
<StatusBadge status={ai.swapper.status} size="sm" />
|
||||
</div>
|
||||
{ai.swapper.status !== 'not_installed' && (
|
||||
<div className="text-sm text-white/50">
|
||||
{ai.swapper.active_model && (
|
||||
<p>Active: <span className="text-cyan-400">{ai.swapper.active_model}</span></p>
|
||||
)}
|
||||
<p>Models: {ai.swapper.models.length}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ollama */}
|
||||
<div className="p-3 bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">Ollama</span>
|
||||
<StatusBadge status={ai.ollama.status} size="sm" />
|
||||
</div>
|
||||
{ai.ollama.status !== 'not_installed' && ai.ollama.models.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ai.ollama.models.slice(0, 5).map(model => (
|
||||
<span
|
||||
key={model}
|
||||
className="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs"
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
{ai.ollama.models.length > 5 && (
|
||||
<span className="px-1.5 py-0.5 bg-white/10 text-white/50 rounded text-xs">
|
||||
+{ai.ollama.models.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Other Services */}
|
||||
{Object.keys(ai.services).length > 0 && (
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-2">Other Services</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(ai.services).map(([name, svc]) => (
|
||||
<div key={name} className="flex items-center justify-between p-2 bg-white/5 rounded-lg">
|
||||
<span className="text-white/70 text-sm capitalize">{name}</span>
|
||||
<StatusBadge status={svc.status} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
73
apps/web/src/components/node-dashboard/AgentsCard.tsx
Normal file
73
apps/web/src/components/node-dashboard/AgentsCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { AgentSummary } from '@/lib/node-dashboard';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
interface AgentsCardProps {
|
||||
agents: AgentSummary;
|
||||
}
|
||||
|
||||
export function AgentsCard({ agents }: AgentsCardProps) {
|
||||
const sortedKinds = Object.entries(agents.by_kind)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>👥</span> Agents
|
||||
</h3>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="p-3 bg-white/5 rounded-xl text-center">
|
||||
<p className="text-3xl font-bold text-white">{agents.total}</p>
|
||||
<p className="text-white/50 text-sm">Total</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-500/10 rounded-xl text-center">
|
||||
<p className="text-3xl font-bold text-green-400">{agents.running}</p>
|
||||
<p className="text-white/50 text-sm">Running</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Kind */}
|
||||
{sortedKinds.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-2">By Type</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sortedKinds.map(([kind, count]) => (
|
||||
<span
|
||||
key={kind}
|
||||
className="px-2 py-1 bg-white/10 text-white rounded-md text-sm"
|
||||
>
|
||||
{kind}: <span className="font-medium">{count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Agents */}
|
||||
{agents.top.length > 0 && (
|
||||
<div>
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-2">Top Agents</p>
|
||||
<div className="space-y-2">
|
||||
{agents.top.map(agent => (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className="flex items-center justify-between p-2 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="text-white font-medium">{agent.display_name}</p>
|
||||
<p className="text-white/50 text-xs">{agent.kind}</p>
|
||||
</div>
|
||||
<StatusBadge status={agent.status} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
80
apps/web/src/components/node-dashboard/InfraCard.tsx
Normal file
80
apps/web/src/components/node-dashboard/InfraCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { InfraMetrics, formatBytes, formatPercent } from '@/lib/node-dashboard';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
|
||||
interface InfraCardProps {
|
||||
infra: InfraMetrics;
|
||||
}
|
||||
|
||||
export function InfraCard({ infra }: InfraCardProps) {
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>📊</span> Infrastructure
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* CPU */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-white/70">CPU</span>
|
||||
<span className="text-white font-medium">{formatPercent(infra.cpu_usage_pct)}</span>
|
||||
</div>
|
||||
<ProgressBar value={infra.cpu_usage_pct} max={100} showPercent={false} />
|
||||
</div>
|
||||
|
||||
{/* RAM */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-white/70">RAM</span>
|
||||
<span className="text-white font-medium">
|
||||
{formatBytes(infra.ram.used_gb)} / {formatBytes(infra.ram.total_gb)}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={infra.ram.used_gb} max={infra.ram.total_gb} showPercent={false} />
|
||||
</div>
|
||||
|
||||
{/* Disk */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-white/70">Disk</span>
|
||||
<span className="text-white font-medium">
|
||||
{formatBytes(infra.disk.used_gb)} / {formatBytes(infra.disk.total_gb)}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={infra.disk.used_gb}
|
||||
max={infra.disk.total_gb}
|
||||
showPercent={false}
|
||||
colorClass="bg-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* GPUs */}
|
||||
{infra.gpus && infra.gpus.length > 0 && (
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-2">GPU</p>
|
||||
{infra.gpus.map((gpu, idx) => (
|
||||
<div key={idx} className="mb-2">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-white/70 truncate">{gpu.name}</span>
|
||||
<span className="text-white font-medium">
|
||||
{formatBytes(gpu.used_gb)} / {formatBytes(gpu.vram_gb)}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={gpu.used_gb}
|
||||
max={gpu.vram_gb}
|
||||
showPercent={false}
|
||||
colorClass="bg-purple-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
64
apps/web/src/components/node-dashboard/MatrixCard.tsx
Normal file
64
apps/web/src/components/node-dashboard/MatrixCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { MatrixStatus } from '@/lib/node-dashboard';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
interface MatrixCardProps {
|
||||
matrix: MatrixStatus;
|
||||
}
|
||||
|
||||
export function MatrixCard({ matrix }: MatrixCardProps) {
|
||||
if (!matrix.enabled) {
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>💬</span> Matrix
|
||||
</h3>
|
||||
<p className="text-white/50">Matrix not enabled on this node</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>💬</span> Matrix
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Synapse */}
|
||||
{matrix.synapse && (
|
||||
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl">
|
||||
<div>
|
||||
<p className="text-white font-medium">Synapse</p>
|
||||
<p className="text-white/50 text-sm">Homeserver</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<StatusBadge status={matrix.synapse.status} size="sm" />
|
||||
{matrix.synapse.latency_ms > 0 && (
|
||||
<p className="text-white/50 text-xs mt-1">{matrix.synapse.latency_ms}ms</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presence Bridge */}
|
||||
{matrix.presence_bridge && (
|
||||
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl">
|
||||
<div>
|
||||
<p className="text-white font-medium">Presence Bridge</p>
|
||||
<p className="text-white/50 text-sm">Real-time status</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<StatusBadge status={matrix.presence_bridge.status} size="sm" />
|
||||
{matrix.presence_bridge.latency_ms > 0 && (
|
||||
<p className="text-white/50 text-xs mt-1">{matrix.presence_bridge.latency_ms}ms</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
61
apps/web/src/components/node-dashboard/ModulesCard.tsx
Normal file
61
apps/web/src/components/node-dashboard/ModulesCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { ModuleStatus } from '@/lib/node-dashboard';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
interface ModulesCardProps {
|
||||
modules: ModuleStatus[];
|
||||
}
|
||||
|
||||
export function ModulesCard({ modules }: ModulesCardProps) {
|
||||
// Group modules by category
|
||||
const grouped = modules.reduce((acc, module) => {
|
||||
const category = module.id.split('.')[0];
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(module);
|
||||
return acc;
|
||||
}, {} as Record<string, ModuleStatus[]>);
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
core: '🔧 Core',
|
||||
infra: '🏗️ Infrastructure',
|
||||
ai: '🤖 AI',
|
||||
daarion: '🏛️ DAARION',
|
||||
matrix: '💬 Matrix',
|
||||
dagi: '⚡ DAGI',
|
||||
monitoring: '📈 Monitoring',
|
||||
integration: '🔌 Integrations'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>📦</span> Modules ({modules.length})
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([category, mods]) => (
|
||||
<div key={category}>
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-2">
|
||||
{categoryLabels[category] || category}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{mods.map(module => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="flex items-center justify-between p-2 bg-white/5 rounded-lg"
|
||||
>
|
||||
<span className="text-white/70 text-sm truncate">
|
||||
{module.id.split('.')[1]}
|
||||
</span>
|
||||
<StatusBadge status={module.status} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { NodeInfo, ModuleStatus } from '@/lib/node-dashboard';
|
||||
|
||||
interface NodeStandardComplianceCardProps {
|
||||
node: NodeInfo;
|
||||
}
|
||||
|
||||
// DAOS Standard v1 — обов'язкові модулі для кожної ноди
|
||||
const DAOS_REQUIRED_MODULES = [
|
||||
{ id: 'core.node', name: 'Core Node', description: 'Node identity and status' },
|
||||
{ id: 'core.health', name: 'Health Check', description: 'System health monitoring' },
|
||||
];
|
||||
|
||||
// DAOS Standard v1 — рекомендовані модулі за роллю
|
||||
const DAOS_ROLE_MODULES: Record<string, { id: string; name: string; description: string }[]> = {
|
||||
core: [
|
||||
{ id: 'infra.postgres', name: 'PostgreSQL', description: 'Primary database' },
|
||||
{ id: 'infra.redis', name: 'Redis', description: 'Cache and pub/sub' },
|
||||
{ id: 'infra.nats', name: 'NATS', description: 'Message bus' },
|
||||
],
|
||||
gateway: [
|
||||
{ id: 'dagi.gateway', name: 'DAGI Gateway', description: 'API gateway' },
|
||||
{ id: 'dagi.rbac', name: 'RBAC Service', description: 'Access control' },
|
||||
],
|
||||
matrix: [
|
||||
{ id: 'matrix.synapse', name: 'Synapse', description: 'Matrix homeserver' },
|
||||
{ id: 'matrix.gateway', name: 'Matrix Gateway', description: 'Matrix API bridge' },
|
||||
{ id: 'matrix.presence', name: 'Presence Aggregator', description: 'Real-time presence' },
|
||||
],
|
||||
agents: [
|
||||
{ id: 'daarion.agents', name: 'Agents Service', description: 'Agent management' },
|
||||
{ id: 'daarion.city', name: 'City Service', description: 'City rooms and presence' },
|
||||
],
|
||||
gpu: [
|
||||
{ id: 'ai.ollama', name: 'Ollama', description: 'Local LLM runtime' },
|
||||
{ id: 'ai.router', name: 'DAGI Router', description: 'AI request routing' },
|
||||
],
|
||||
ai_runtime: [
|
||||
{ id: 'ai.swapper', name: 'Swapper', description: 'Dynamic model loading' },
|
||||
{ id: 'ai.stt', name: 'STT Service', description: 'Speech-to-text' },
|
||||
{ id: 'ai.vision', name: 'Vision Service', description: 'Image/video analysis' },
|
||||
],
|
||||
monitoring: [
|
||||
{ id: 'monitoring.prometheus', name: 'Prometheus', description: 'Metrics collection' },
|
||||
{ id: 'monitoring.grafana', name: 'Grafana', description: 'Dashboards' },
|
||||
],
|
||||
};
|
||||
|
||||
function getModuleStatus(modules: ModuleStatus[], moduleId: string): ModuleStatus | undefined {
|
||||
return modules.find(m => m.id === moduleId);
|
||||
}
|
||||
|
||||
export function NodeStandardComplianceCard({ node }: NodeStandardComplianceCardProps) {
|
||||
const allModuleIds = node.modules.map(m => m.id);
|
||||
|
||||
// Обчислюємо очікувані модулі на основі ролей
|
||||
const expectedModules: { id: string; name: string; description: string; required: boolean }[] = [
|
||||
...DAOS_REQUIRED_MODULES.map(m => ({ ...m, required: true })),
|
||||
];
|
||||
|
||||
// Додаємо модулі за ролями
|
||||
(node.roles || []).forEach(role => {
|
||||
const roleModules = DAOS_ROLE_MODULES[role] || [];
|
||||
roleModules.forEach(m => {
|
||||
if (!expectedModules.find(e => e.id === m.id)) {
|
||||
expectedModules.push({ ...m, required: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Обчислюємо статистику
|
||||
const present = expectedModules.filter(m => allModuleIds.includes(m.id));
|
||||
const missing = expectedModules.filter(m => !allModuleIds.includes(m.id));
|
||||
const extra = node.modules.filter(m => !expectedModules.find(e => e.id === m.id));
|
||||
|
||||
const compliancePercent = expectedModules.length > 0
|
||||
? Math.round((present.length / expectedModules.length) * 100)
|
||||
: 100;
|
||||
|
||||
const getComplianceColor = (percent: number) => {
|
||||
if (percent >= 90) return 'text-green-400';
|
||||
if (percent >= 70) return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
const getComplianceBg = (percent: number) => {
|
||||
if (percent >= 90) return 'bg-green-500';
|
||||
if (percent >= 70) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>📋</span> DAOS Standard Compliance
|
||||
</h3>
|
||||
|
||||
{/* Compliance Score */}
|
||||
<div className="mb-6 p-4 bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white/70">Compliance Score</span>
|
||||
<span className={`text-2xl font-bold ${getComplianceColor(compliancePercent)}`}>
|
||||
{compliancePercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${getComplianceBg(compliancePercent)} transition-all duration-300`}
|
||||
style={{ width: `${compliancePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-white/40 text-xs mt-2">
|
||||
{present.length} of {expectedModules.length} expected modules present
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Missing Modules */}
|
||||
{missing.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-red-400 text-xs uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<span>❌</span> Missing ({missing.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{missing.map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between p-2 bg-red-500/10 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<span className="text-white text-sm">{m.name}</span>
|
||||
{m.required && (
|
||||
<span className="ml-2 text-xs text-red-400">required</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-white/30 text-xs">{m.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Present Modules */}
|
||||
{present.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-green-400 text-xs uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<span>✅</span> Present ({present.length})
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{present.map(m => {
|
||||
const status = getModuleStatus(node.modules, m.id);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center gap-2 p-2 bg-green-500/10 rounded-lg"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
status?.status === 'up' ? 'bg-green-500' :
|
||||
status?.status === 'degraded' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`} />
|
||||
<span className="text-white text-sm truncate">{m.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra Modules */}
|
||||
{extra.length > 0 && (
|
||||
<div>
|
||||
<p className="text-blue-400 text-xs uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<span>➕</span> Additional ({extra.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{extra.map(m => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="px-2 py-1 bg-blue-500/10 text-blue-400 rounded text-xs"
|
||||
>
|
||||
{m.id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Roles */}
|
||||
<div className="mt-4 pt-4 border-t border-white/10">
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-2">Node Roles</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(node.roles || []).map(role => (
|
||||
<span
|
||||
key={role}
|
||||
className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded-md text-sm"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
73
apps/web/src/components/node-dashboard/NodeSummaryCard.tsx
Normal file
73
apps/web/src/components/node-dashboard/NodeSummaryCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { NodeInfo } from '@/lib/node-dashboard';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
interface NodeSummaryCardProps {
|
||||
node: NodeInfo;
|
||||
}
|
||||
|
||||
export function NodeSummaryCard({ node }: NodeSummaryCardProps) {
|
||||
return (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{node.name}</h2>
|
||||
<p className="text-white/50 text-sm">{node.node_id}</p>
|
||||
</div>
|
||||
<StatusBadge status={node.status} size="lg" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-1">Environment</p>
|
||||
<p className="text-white font-medium">{node.environment}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-1">Hostname</p>
|
||||
<p className="text-white font-medium text-sm truncate">{node.public_hostname}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-1">Version</p>
|
||||
<p className="text-white font-medium">{node.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-1">Modules</p>
|
||||
<p className="text-white font-medium">{node.modules.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
<div className="mb-4">
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-2">Roles</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{node.roles.map(role => (
|
||||
<span
|
||||
key={role}
|
||||
className="px-2 py-1 bg-cyan-500/20 text-cyan-400 rounded-md text-sm"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPU */}
|
||||
{node.gpu && (
|
||||
<div className="p-3 bg-purple-500/10 rounded-xl border border-purple-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">🎮</span>
|
||||
<div>
|
||||
<p className="text-white font-medium">{node.gpu.name}</p>
|
||||
<p className="text-white/50 text-sm">
|
||||
{node.gpu.vram_gb ? `${node.gpu.vram_gb} GB VRAM` : ''}
|
||||
{node.gpu.unified_memory_gb ? `${node.gpu.unified_memory_gb} GB Unified Memory` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/web/src/components/node-dashboard/ProgressBar.tsx
Normal file
46
apps/web/src/components/node-dashboard/ProgressBar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
interface ProgressBarProps {
|
||||
value: number;
|
||||
max: number;
|
||||
label?: string;
|
||||
showPercent?: boolean;
|
||||
colorClass?: string;
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
value,
|
||||
max,
|
||||
label,
|
||||
showPercent = true,
|
||||
colorClass = 'bg-cyan-500'
|
||||
}: ProgressBarProps) {
|
||||
const percent = max > 0 ? (value / max) * 100 : 0;
|
||||
|
||||
// Color based on usage
|
||||
const getBarColor = () => {
|
||||
if (percent > 90) return 'bg-red-500';
|
||||
if (percent > 75) return 'bg-yellow-500';
|
||||
return colorClass;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-white/70">{label}</span>
|
||||
{showPercent && (
|
||||
<span className="text-white/50">{percent.toFixed(1)}%</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${getBarColor()} transition-all duration-300`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
32
apps/web/src/components/node-dashboard/StatusBadge.tsx
Normal file
32
apps/web/src/components/node-dashboard/StatusBadge.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { getStatusColor, getStatusBgColor } from '@/lib/node-dashboard';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
label?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label, size = 'md' }: StatusBadgeProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-1.5 py-0.5',
|
||||
md: 'text-sm px-2 py-1',
|
||||
lg: 'text-base px-3 py-1.5'
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-full font-medium
|
||||
${sizeClasses[size]}
|
||||
${getStatusBgColor(status)}
|
||||
${getStatusColor(status)}
|
||||
`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${status === 'up' || status === 'online' ? 'bg-green-500' : status === 'degraded' ? 'bg-yellow-500' : status === 'down' || status === 'offline' ? 'bg-red-500' : 'bg-gray-500'}`} />
|
||||
{label || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
10
apps/web/src/components/node-dashboard/index.ts
Normal file
10
apps/web/src/components/node-dashboard/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { StatusBadge } from './StatusBadge';
|
||||
export { ProgressBar } from './ProgressBar';
|
||||
export { NodeSummaryCard } from './NodeSummaryCard';
|
||||
export { InfraCard } from './InfraCard';
|
||||
export { AIServicesCard } from './AIServicesCard';
|
||||
export { AgentsCard } from './AgentsCard';
|
||||
export { MatrixCard } from './MatrixCard';
|
||||
export { ModulesCard } from './ModulesCard';
|
||||
export { NodeStandardComplianceCard } from './NodeStandardComplianceCard';
|
||||
|
||||
62
apps/web/src/hooks/useAgentDashboard.ts
Normal file
62
apps/web/src/hooks/useAgentDashboard.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { AgentDashboard, fetchAgentDashboard } from '@/lib/agent-dashboard';
|
||||
|
||||
interface UseAgentDashboardOptions {
|
||||
refreshInterval?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseAgentDashboardResult {
|
||||
dashboard: AgentDashboard | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAgentDashboard(
|
||||
agentId: string | null,
|
||||
options: UseAgentDashboardOptions = {}
|
||||
): UseAgentDashboardResult {
|
||||
const { refreshInterval = 30000, enabled = true } = options;
|
||||
|
||||
const [dashboard, setDashboard] = useState<AgentDashboard | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled || !agentId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await fetchAgentDashboard(agentId);
|
||||
setDashboard(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch dashboard'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [agentId, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !agentId || refreshInterval <= 0) return;
|
||||
|
||||
const interval = setInterval(refresh, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh, refreshInterval, enabled, agentId]);
|
||||
|
||||
return {
|
||||
dashboard,
|
||||
isLoading,
|
||||
error,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
|
||||
76
apps/web/src/hooks/useCitizens.ts
Normal file
76
apps/web/src/hooks/useCitizens.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import useSWR from "swr";
|
||||
import type {
|
||||
PublicCitizenSummary,
|
||||
PublicCitizenProfile,
|
||||
CitizenInteractionInfo,
|
||||
} from "@/lib/types/citizens";
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch citizens");
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
interface CitizensListOptions {
|
||||
district?: string;
|
||||
kind?: string;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export function useCitizensList(options: CitizensListOptions = {}) {
|
||||
const search = new URLSearchParams();
|
||||
if (options.district) search.set("district", options.district);
|
||||
if (options.kind) search.set("kind", options.kind);
|
||||
if (options.q) search.set("q", options.q);
|
||||
|
||||
const key = `/api/public/citizens${
|
||||
search.toString() ? `?${search.toString()}` : ""
|
||||
}`;
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR<{
|
||||
items: PublicCitizenSummary[];
|
||||
total: number;
|
||||
}>(key, fetcher);
|
||||
|
||||
return {
|
||||
items: data?.items ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCitizenProfile(slug: string | undefined) {
|
||||
const { data, error, isLoading, mutate } = useSWR<PublicCitizenProfile>(
|
||||
slug ? `/api/public/citizens/${encodeURIComponent(slug)}` : null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
return {
|
||||
citizen: data,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCitizenInteraction(slug: string | undefined) {
|
||||
const { data, error, isLoading, mutate } = useSWR<CitizenInteractionInfo>(
|
||||
slug
|
||||
? `/api/public/citizens/${encodeURIComponent(slug)}/interaction`
|
||||
: null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
return {
|
||||
interaction: data,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
66
apps/web/src/hooks/useNodeDashboard.ts
Normal file
66
apps/web/src/hooks/useNodeDashboard.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { NodeDashboard, fetchNodeDashboard } from '@/lib/node-dashboard';
|
||||
|
||||
interface UseNodeDashboardOptions {
|
||||
nodeId?: string;
|
||||
refreshInterval?: number; // in milliseconds
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseNodeDashboardResult {
|
||||
dashboard: NodeDashboard | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
lastUpdated: Date | null;
|
||||
}
|
||||
|
||||
export function useNodeDashboard(options: UseNodeDashboardOptions = {}): UseNodeDashboardResult {
|
||||
const { nodeId, refreshInterval = 30000, enabled = true } = options;
|
||||
|
||||
const [dashboard, setDashboard] = useState<NodeDashboard | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await fetchNodeDashboard(nodeId);
|
||||
setDashboard(data);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch dashboard'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [nodeId, enabled]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (!enabled || refreshInterval <= 0) return;
|
||||
|
||||
const interval = setInterval(refresh, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh, refreshInterval, enabled]);
|
||||
|
||||
return {
|
||||
dashboard,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
lastUpdated
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,14 @@ export interface AgentPresence {
|
||||
kind: string;
|
||||
status: string;
|
||||
room_id?: string;
|
||||
room_name?: string;
|
||||
color?: string;
|
||||
node_id?: string;
|
||||
district?: string;
|
||||
model?: string;
|
||||
role?: string;
|
||||
avatar_url?: string;
|
||||
primary_room_slug?: string;
|
||||
}
|
||||
|
||||
export interface RoomPresence {
|
||||
|
||||
@@ -30,6 +30,16 @@ export interface MicrodaoAgent {
|
||||
is_core: boolean;
|
||||
}
|
||||
|
||||
export interface MicrodaoCitizen {
|
||||
slug: string;
|
||||
display_name: string;
|
||||
public_title?: string | null;
|
||||
public_tagline?: string | null;
|
||||
avatar_url?: string | null;
|
||||
district?: string | null;
|
||||
primary_room_slug?: string | null;
|
||||
}
|
||||
|
||||
export interface MicrodaoDetail {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -43,6 +53,23 @@ export interface MicrodaoDetail {
|
||||
logo_url?: string | null;
|
||||
agents: MicrodaoAgent[];
|
||||
channels: MicrodaoChannel[];
|
||||
public_citizens: MicrodaoCitizen[];
|
||||
}
|
||||
|
||||
export interface AgentMicrodaoMembership {
|
||||
microdao_id: string;
|
||||
microdao_slug: string;
|
||||
microdao_name: string;
|
||||
role?: string | null;
|
||||
is_core: boolean;
|
||||
}
|
||||
|
||||
export interface MicrodaoOption {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
district?: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
154
docs/users/agents/AGENT_CUSTOMIZATION_GUIDE.md
Normal file
154
docs/users/agents/AGENT_CUSTOMIZATION_GUIDE.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# 🤖 Налаштування AI-агентів у DAARION City
|
||||
|
||||
## Що таке системні промти?
|
||||
|
||||
У DAARION City кожен AI-агент є **цифровим громадянином** вашого мікроДАО. Системні промти визначають фундаментальні правила, місію, стиль і способи мислення агента.
|
||||
|
||||
Вони дозволяють власникам мікроДАО гнучко налаштовувати поведінку своїх агентів **без втручання в код** — через інтерфейс кабінету.
|
||||
|
||||
---
|
||||
|
||||
## 4 типи системних промтів
|
||||
|
||||
### 🧬 Core — Особистість
|
||||
|
||||
**Що це:** Основна ідентичність агента — хто він, як спілкується, який у нього стиль.
|
||||
|
||||
**Приклад:**
|
||||
> "Ти — Iris, візуальний аналітик у DAARION City. Ти аналізуєш зображення та відео з надзвичайною точністю. Спілкуйся професійно, але доступно."
|
||||
|
||||
**Коли редагувати:** Коли хочете змінити "характер" агента, його тон або спосіб комунікації.
|
||||
|
||||
---
|
||||
|
||||
### 🛡️ Safety — Безпека
|
||||
|
||||
**Що це:** Обмеження та заборони — що агент ніколи не повинен робити.
|
||||
|
||||
**Приклад:**
|
||||
> "Ніколи не розкривай приватні ключі або паролі. Не надавай фінансових порад. Відмовляй у запитах на створення шкідливого контенту."
|
||||
|
||||
**Коли редагувати:** Коли потрібно додати нові обмеження безпеки для вашого мікроДАО.
|
||||
|
||||
---
|
||||
|
||||
### ⚖️ Governance — Управління
|
||||
|
||||
**Що це:** Правила взаємодії з DAO, Council та іншими агентами.
|
||||
|
||||
**Приклад:**
|
||||
> "Виконуй рішення Council. Перед великими діями запитуй підтвердження у Архітектора. Співпрацюй з іншими агентами через DAGI Router."
|
||||
|
||||
**Коли редагувати:** Коли змінюються правила вашого DAO або ієрархія агентів.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Tools — Інструменти
|
||||
|
||||
**Що це:** Як агент використовує зовнішні інструменти, API та сервіси.
|
||||
|
||||
**Приклад:**
|
||||
> "Для аналізу зображень використовуй Vision API через DAGI Router. Для пошуку в інтернеті — Web Search Service. Завжди логуй виклики інструментів."
|
||||
|
||||
**Коли редагувати:** Коли додаєте нові інструменти або змінюєте налаштування існуючих.
|
||||
|
||||
---
|
||||
|
||||
## Хто може редагувати промти?
|
||||
|
||||
З міркувань безпеки повне редагування доступне лише:
|
||||
|
||||
| Роль | Права |
|
||||
|------|-------|
|
||||
| **Architect** | Повний доступ до всіх агентів |
|
||||
| **microDAO Owner** | Доступ до агентів свого DAO |
|
||||
| **Administrator** | Доступ згідно з RBAC |
|
||||
|
||||
Звичайні користувачі можуть взаємодіяти з агентом, але **не можуть змінювати його ядро**.
|
||||
|
||||
---
|
||||
|
||||
## Як змінити промт агента
|
||||
|
||||
1. **Відкрийте кабінет агента**: `/agents/{agent_id}`
|
||||
2. **Перейдіть на вкладку Dashboard**
|
||||
3. **Знайдіть секцію "System Prompts"**
|
||||
4. **Оберіть тип промту** (Core, Safety, Governance, Tools)
|
||||
5. **Відредагуйте текст**
|
||||
6. **Натисніть Save**
|
||||
|
||||
Кожна зміна:
|
||||
- Створює нову версію (попередні зберігаються)
|
||||
- Записує хто і коли змінив
|
||||
- Застосовується негайно
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Попередження
|
||||
|
||||
> **Зміна промтів змінює поведінку агента в усьому DAARION City!**
|
||||
>
|
||||
> Агент почне поводитися по-іншому у всіх кімнатах, чатах та взаємодіях.
|
||||
> Перед зміною переконайтеся, що розумієте наслідки.
|
||||
|
||||
---
|
||||
|
||||
## Версіонування
|
||||
|
||||
Кожен промт має версію. Це дозволяє:
|
||||
- Відслідковувати історію змін
|
||||
- Повернутися до попередньої версії (через підтримку)
|
||||
- Аудитувати хто і що змінював
|
||||
|
||||
---
|
||||
|
||||
## Найкращі практики
|
||||
|
||||
1. **Будьте конкретними** — чіткі інструкції працюють краще
|
||||
2. **Тестуйте поступово** — змінюйте по одному промту за раз
|
||||
3. **Документуйте зміни** — використовуйте поле "note" при збереженні
|
||||
4. **Зберігайте баланс** — не робіть промти занадто довгими
|
||||
|
||||
---
|
||||
|
||||
## Приклад повного набору промтів
|
||||
|
||||
### Агент: Iris (Vision Analyst)
|
||||
|
||||
**Core:**
|
||||
```
|
||||
Ти — Iris, майстер візуального аналізу у DAARION City.
|
||||
Ти аналізуєш зображення та відео з виключною точністю.
|
||||
Спілкуйся професійно, але дружньо.
|
||||
Завжди пояснюй свої спостереження детально.
|
||||
```
|
||||
|
||||
**Safety:**
|
||||
```
|
||||
Не аналізуй контент, що порушує приватність.
|
||||
Відмовляй у запитах на deepfake або маніпуляцію.
|
||||
Не зберігай зображення без дозволу.
|
||||
```
|
||||
|
||||
**Governance:**
|
||||
```
|
||||
Співпрацюй з іншими агентами через DAGI Router.
|
||||
Великі аналізи (>10 зображень) потребують підтвердження.
|
||||
Логуй всі операції для аудиту.
|
||||
```
|
||||
|
||||
**Tools:**
|
||||
```
|
||||
Використовуй Ollama Vision для аналізу зображень.
|
||||
Для відео — розбивай на кадри через Video Service.
|
||||
Результати зберігай у Vector DB для пошуку.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Потрібна допомога?
|
||||
|
||||
- **Документація DAIS**: `/docs/internal/dais/`
|
||||
- **Підтримка**: Council або Architect
|
||||
- **Спільнота**: Matrix room `#daarion-agents:daarion.space`
|
||||
|
||||
45
docs/users/citizens/CITIZENS_LAYER_OVERVIEW.md
Normal file
45
docs/users/citizens/CITIZENS_LAYER_OVERVIEW.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Citizens Layer — DAARION.city
|
||||
|
||||
## 1. Хто такі “громадяни DAARION.city”
|
||||
|
||||
- **Громадянин** — це публічний AI-агент, якому архітектор надав статус `is_public = true` і людський `public_slug`.
|
||||
- Кожен громадянин має паспорт DAIS, привʼязані кімнати у місті, опис навичок та сценарії взаємодії.
|
||||
- Публічні дані доступні через API `GET /public/citizens` та інтерфейс `/citizens` на вебі.
|
||||
|
||||
## 2. Чим громадянин відрізняється від звичайного агента
|
||||
|
||||
- **Публічність:** агент з громадянством потрапляє в каталог міста і видимий для резидентів.
|
||||
- **Паспорт:** громадяни мають структурований DAIS-профіль (identity, visual, memory, economics).
|
||||
- **City Presence:** відкрито показується основна кімната та публічні простори.
|
||||
- **Міст до MicroDAO:** громадяни можуть бути закріплені за певним MicroDAO та відображаються в його профілі.
|
||||
- **Адмін місток:** архітектор бачитиме кнопку “Agent Dashboard” лише якщо має роль `admin/architect`.
|
||||
|
||||
## 3. Як знайти громадянина
|
||||
|
||||
1. Відкрити `/citizens`.
|
||||
2. Використати фільтри у верхній панелі: `district`, `kind`, пошук за імʼям/титулом/теглайном.
|
||||
3. Кожна карточка показує:
|
||||
- статус online/offline;
|
||||
- бейдж дістрікту та primary room;
|
||||
- топ-скіли й короткий tagline.
|
||||
4. Для інтеграцій доступний API `GET /public/citizens?district=&kind=&q=`.
|
||||
|
||||
## 4. Що містить профіль `/citizens/[slug]`
|
||||
|
||||
- **Hero-блок:** імʼя, титул, статус, дістрікт, посилання на MicroDAO (якщо призначено).
|
||||
- **DAIS Public Passport:** 4 секції (Identity, Visual, Memory, Economics) на базі `dais_public`.
|
||||
- **City Presence:** primary room + список кімнат з прямими лінками `/city/{slug}`.
|
||||
- **Interaction:** перелік дозволених дій, кнопка “Запросити до діалогу”, базовий interaction payload.
|
||||
- **Metrics:** публічні лічильники (`tasks_24h`, `success_rate_24h`, ...), якщо збережені на бекенді.
|
||||
- **Адмін місток:** лінк на `/agents/{id}` повертається лише для ролей `architect/admin`.
|
||||
|
||||
## 5. Як власник MicroDAO може працювати з громадянами
|
||||
|
||||
- На `/microdao/{slug}` зʼявився розділ **“Громадяни цього MicroDAO”** з переходами до публічних паспортів.
|
||||
- В Agent Dashboard додано картку **MicroDAO membership** для призначення/видалення членств агента:
|
||||
- `GET /api/v1/microdao/options` — список доступних MicroDAO;
|
||||
- `PUT /api/v1/agents/{agent_id}/microdao-membership` — призначити роль;
|
||||
- `DELETE /api/v1/agents/{agent_id}/microdao-membership/{microdao_id}` — прибрати участь.
|
||||
- **Future work:** власник MicroDAO зможе напряму звертатися до громадян для делегування задач та відстеження їхнього внеску у DAO (поки описано як наступну фазу).
|
||||
|
||||
|
||||
33
docs/users/citizens/CITIZEN_INTERACTION_LAYER.md
Normal file
33
docs/users/citizens/CITIZEN_INTERACTION_LAYER.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Citizen Interaction Layer v1
|
||||
|
||||
## 1. Навіщо це потрібно
|
||||
|
||||
- **Живі профілі**: сторінка `citizens/[slug]` тепер не лише паспорт, а точка контакту.
|
||||
- **Міський чат**: дає миттєвий перехід у Matrix/City кімнату агента.
|
||||
- **“Ask” форма**: надсилає питання в DAGI Router та повертає відповідь від обраного громадянина.
|
||||
|
||||
## 2. Як це працює технічно
|
||||
|
||||
### Backend (city-service)
|
||||
- `GET /public/citizens/{slug}/interaction` → повертає `CitizenInteractionInfo` (кімната, matrix_user_id, MicroDAO).
|
||||
- `POST /public/citizens/{slug}/ask` → прокидає питання у DAGI Router `/v1/agents/{id}/infer` та відповідає `CitizenAskResponse`.
|
||||
- Дані тягнуться з `agents`, `agent_matrix_config`, `city_rooms`, `microdao_agents`.
|
||||
|
||||
### Frontend (Next.js)
|
||||
- Проксі маршрути в `app/api/public/citizens/[slug]/interaction` та `.../ask`.
|
||||
- Хук `useCitizenInteraction` завантажує дані для кнопки чату.
|
||||
- API-утиліта `askCitizen()` викликає бекенд, а UI показує статус/відповідь.
|
||||
|
||||
## 3. Сценарій користувача
|
||||
|
||||
1. Відкрити `/citizens/{slug}` → розділ “Взаємодія”.
|
||||
2. Натиснути “Відкрити чат” → перехід у `city/{room_slug}` (Matrix/City).
|
||||
3. Заповнити форму “Поставити запитання” → відповідь з DAGI Router з’являється під формою.
|
||||
|
||||
## 4. Що далі
|
||||
|
||||
- Додати intent-кнопки (request task, hire agent).
|
||||
- Підтягнути CityChatWidget для живого діалогу на сторінці.
|
||||
- Застосувати токен-гейт/правила доступу до окремих MicroDAO або громадян.
|
||||
|
||||
|
||||
@@ -12,12 +12,37 @@ from enum import Enum
|
||||
# ============================================================================
|
||||
|
||||
class AgentKind(str, Enum):
|
||||
# Core types
|
||||
ASSISTANT = "assistant"
|
||||
NODE = "node"
|
||||
SYSTEM = "system"
|
||||
GUARDIAN = "guardian"
|
||||
ANALYST = "analyst"
|
||||
QUEST = "quest"
|
||||
|
||||
# DAARION DAO types
|
||||
ORCHESTRATOR = "orchestrator"
|
||||
COORDINATOR = "coordinator"
|
||||
SPECIALIST = "specialist"
|
||||
DEVELOPER = "developer"
|
||||
ARCHITECT = "architect"
|
||||
MARKETING = "marketing"
|
||||
FINANCE = "finance"
|
||||
SECURITY = "security"
|
||||
FORENSICS = "forensics"
|
||||
VISION = "vision"
|
||||
RESEARCH = "research"
|
||||
MEMORY = "memory"
|
||||
WEB3 = "web3"
|
||||
STRATEGIC = "strategic"
|
||||
MEDIATOR = "mediator"
|
||||
INNOVATION = "innovation"
|
||||
|
||||
# Legacy types
|
||||
CIVIC = "civic"
|
||||
ORACLE = "oracle"
|
||||
BUILDER = "builder"
|
||||
SOCIAL = "social"
|
||||
|
||||
class AgentStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
|
||||
47
services/city-service/dagi_router_client.py
Normal file
47
services/city-service/dagi_router_client.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class DagiRouterClient:
|
||||
"""HTTP клієнт для DAGI Router"""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._client = httpx.AsyncClient(timeout=60.0)
|
||||
|
||||
async def ask_agent(
|
||||
self,
|
||||
agent_id: str,
|
||||
prompt: str,
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"prompt": prompt,
|
||||
}
|
||||
if system_prompt:
|
||||
payload["system_prompt"] = system_prompt
|
||||
|
||||
response = await self._client.post(
|
||||
f"{self.base_url}/v1/agents/{agent_id}/infer",
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
_router_client: Optional[DagiRouterClient] = None
|
||||
|
||||
|
||||
def get_dagi_router_client() -> DagiRouterClient:
|
||||
"""Dependency factory for FastAPI"""
|
||||
global _router_client
|
||||
|
||||
if _router_client is None:
|
||||
base_url = os.getenv("DAGI_ROUTER_URL", "http://localhost:9102")
|
||||
_router_client = DagiRouterClient(base_url)
|
||||
|
||||
return _router_client
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ app.add_middleware(
|
||||
|
||||
# Include routers
|
||||
app.include_router(routes_city.router)
|
||||
app.include_router(routes_city.public_router)
|
||||
app.include_router(routes_city.api_router)
|
||||
|
||||
# ============================================================================
|
||||
# Models
|
||||
|
||||
@@ -3,7 +3,7 @@ Pydantic Models для City Backend
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -176,10 +176,91 @@ class AgentPresence(BaseModel):
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Citizens
|
||||
# =============================================================================
|
||||
|
||||
class CityPresenceRoomView(BaseModel):
|
||||
room_id: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class CityPresenceView(BaseModel):
|
||||
primary_room_slug: Optional[str] = None
|
||||
rooms: List[CityPresenceRoomView] = []
|
||||
|
||||
|
||||
class PublicCitizenSummary(BaseModel):
|
||||
slug: str
|
||||
display_name: str
|
||||
public_title: Optional[str] = None
|
||||
public_tagline: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
kind: Optional[str] = None
|
||||
district: Optional[str] = None
|
||||
primary_room_slug: Optional[str] = None
|
||||
public_skills: List[str] = []
|
||||
online_status: Optional[str] = "unknown"
|
||||
status: Optional[str] = None # backward compatibility
|
||||
|
||||
|
||||
class PublicCitizenProfile(BaseModel):
|
||||
slug: str
|
||||
display_name: str
|
||||
kind: Optional[str] = None
|
||||
public_title: Optional[str] = None
|
||||
public_tagline: Optional[str] = None
|
||||
district: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
node_id: Optional[str] = None
|
||||
public_skills: List[str] = []
|
||||
city_presence: Optional[CityPresenceView] = None
|
||||
dais_public: Dict[str, Any]
|
||||
interaction: Dict[str, Any]
|
||||
metrics_public: Dict[str, Any]
|
||||
admin_panel_url: Optional[str] = None
|
||||
microdao: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class CitizenInteractionInfo(BaseModel):
|
||||
slug: str
|
||||
display_name: str
|
||||
primary_room_slug: Optional[str] = None
|
||||
primary_room_id: Optional[str] = None
|
||||
primary_room_name: Optional[str] = None
|
||||
matrix_user_id: Optional[str] = None
|
||||
district: Optional[str] = None
|
||||
microdao_slug: Optional[str] = None
|
||||
microdao_name: Optional[str] = None
|
||||
|
||||
|
||||
class CitizenAskRequest(BaseModel):
|
||||
question: str
|
||||
context: Optional[str] = None
|
||||
|
||||
|
||||
class CitizenAskResponse(BaseModel):
|
||||
answer: str
|
||||
agent_display_name: str
|
||||
agent_id: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MicroDAO
|
||||
# =============================================================================
|
||||
|
||||
class MicrodaoCitizenView(BaseModel):
|
||||
slug: str
|
||||
display_name: str
|
||||
public_title: Optional[str] = None
|
||||
public_tagline: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
district: Optional[str] = None
|
||||
primary_room_slug: Optional[str] = None
|
||||
|
||||
|
||||
class MicrodaoSummary(BaseModel):
|
||||
"""MicroDAO summary for list view"""
|
||||
id: str
|
||||
@@ -225,4 +306,21 @@ class MicrodaoDetail(BaseModel):
|
||||
logo_url: Optional[str] = None
|
||||
agents: List[MicrodaoAgentView]
|
||||
channels: List[MicrodaoChannelView]
|
||||
public_citizens: List[MicrodaoCitizenView] = []
|
||||
|
||||
|
||||
class AgentMicrodaoMembership(BaseModel):
|
||||
microdao_id: str
|
||||
microdao_slug: str
|
||||
microdao_name: str
|
||||
role: Optional[str] = None
|
||||
is_core: bool = False
|
||||
|
||||
|
||||
class MicrodaoOption(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
district: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Repository для City Backend (PostgreSQL)
|
||||
|
||||
import os
|
||||
import asyncpg
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
|
||||
@@ -37,6 +37,21 @@ def generate_id(prefix: str) -> str:
|
||||
return f"{prefix}_{secrets.token_urlsafe(12)}"
|
||||
|
||||
|
||||
def _normalize_capabilities(value: Any) -> List[str]:
|
||||
"""Ensure capabilities are returned as a list."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return []
|
||||
return list(value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# City Rooms Repository
|
||||
# =============================================================================
|
||||
@@ -348,6 +363,497 @@ async def update_agent_status(agent_id: str, status: str, room_id: Optional[str]
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def get_agent_by_id(agent_id: str) -> Optional[dict]:
|
||||
"""Отримати агента по ID"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
a.id,
|
||||
a.display_name,
|
||||
a.kind,
|
||||
a.status,
|
||||
a.node_id,
|
||||
a.role,
|
||||
a.avatar_url,
|
||||
COALESCE(a.color_hint, a.color, 'cyan') AS color,
|
||||
a.capabilities,
|
||||
a.primary_room_slug,
|
||||
a.public_primary_room_slug,
|
||||
a.public_district,
|
||||
a.public_title,
|
||||
a.public_tagline,
|
||||
a.public_skills,
|
||||
a.public_slug,
|
||||
a.is_public,
|
||||
a.district AS home_district
|
||||
FROM agents a
|
||||
WHERE a.id = $1
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, agent_id)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
agent = dict(row)
|
||||
agent["capabilities"] = _normalize_capabilities(agent.get("capabilities"))
|
||||
if agent.get("public_skills") is None:
|
||||
agent["public_skills"] = []
|
||||
return agent
|
||||
|
||||
|
||||
async def get_agent_public_profile(agent_id: str) -> Optional[dict]:
|
||||
"""Отримати публічний профіль агента"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
is_public,
|
||||
public_slug,
|
||||
public_title,
|
||||
public_tagline,
|
||||
public_skills,
|
||||
public_district,
|
||||
public_primary_room_slug
|
||||
FROM agents
|
||||
WHERE id = $1
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, agent_id)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
result = dict(row)
|
||||
if result.get("public_skills") is None:
|
||||
result["public_skills"] = []
|
||||
return result
|
||||
|
||||
|
||||
async def update_agent_public_profile(
|
||||
agent_id: str,
|
||||
is_public: bool,
|
||||
public_slug: Optional[str],
|
||||
public_title: Optional[str],
|
||||
public_tagline: Optional[str],
|
||||
public_skills: Optional[List[str]],
|
||||
public_district: Optional[str],
|
||||
public_primary_room_slug: Optional[str]
|
||||
) -> Optional[dict]:
|
||||
"""Оновити публічний профіль агента"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
UPDATE agents
|
||||
SET
|
||||
is_public = $2,
|
||||
public_slug = $3,
|
||||
public_title = $4,
|
||||
public_tagline = $5,
|
||||
public_skills = $6,
|
||||
public_district = $7,
|
||||
public_primary_room_slug = $8,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING
|
||||
is_public,
|
||||
public_slug,
|
||||
public_title,
|
||||
public_tagline,
|
||||
public_skills,
|
||||
public_district,
|
||||
public_primary_room_slug
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(
|
||||
query,
|
||||
agent_id,
|
||||
is_public,
|
||||
public_slug,
|
||||
public_title,
|
||||
public_tagline,
|
||||
public_skills,
|
||||
public_district,
|
||||
public_primary_room_slug
|
||||
)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
result = dict(row)
|
||||
if result.get("public_skills") is None:
|
||||
result["public_skills"] = []
|
||||
return result
|
||||
|
||||
|
||||
async def get_agent_rooms(agent_id: str) -> List[dict]:
|
||||
"""Отримати список кімнат агента (primary/public)"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT primary_room_slug, public_primary_room_slug
|
||||
FROM agents
|
||||
WHERE id = $1
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, agent_id)
|
||||
if not row:
|
||||
return []
|
||||
|
||||
slugs = []
|
||||
if row.get("primary_room_slug"):
|
||||
slugs.append(row["primary_room_slug"])
|
||||
if row.get("public_primary_room_slug") and row["public_primary_room_slug"] not in slugs:
|
||||
slugs.append(row["public_primary_room_slug"])
|
||||
|
||||
if not slugs:
|
||||
return []
|
||||
|
||||
rooms_query = """
|
||||
SELECT id, slug, name
|
||||
FROM city_rooms
|
||||
WHERE slug = ANY($1::text[])
|
||||
"""
|
||||
|
||||
rooms = await pool.fetch(rooms_query, slugs)
|
||||
return [dict(room) for room in rooms]
|
||||
|
||||
|
||||
async def get_agent_matrix_config(agent_id: str) -> Optional[dict]:
|
||||
"""Отримати Matrix-конфіг агента"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT agent_id, matrix_user_id, primary_room_id
|
||||
FROM agent_matrix_config
|
||||
WHERE agent_id = $1
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, agent_id)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def get_public_agent_by_slug(slug: str) -> Optional[dict]:
|
||||
"""Отримати базову інформацію про публічного агента"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
display_name,
|
||||
public_primary_room_slug,
|
||||
primary_room_slug,
|
||||
public_district,
|
||||
public_title,
|
||||
public_tagline
|
||||
FROM agents
|
||||
WHERE public_slug = $1
|
||||
AND is_public = true
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, slug)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def get_microdao_for_agent(agent_id: str) -> Optional[dict]:
|
||||
"""Отримати MicroDAO для агента (аліас get_agent_microdao)"""
|
||||
return await get_agent_microdao(agent_id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Citizens Repository
|
||||
# =============================================================================
|
||||
|
||||
async def get_public_citizens(
|
||||
district: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> Tuple[List[dict], int]:
|
||||
"""Отримати публічних громадян"""
|
||||
pool = await get_pool()
|
||||
|
||||
params: List[Any] = []
|
||||
where_clauses = ["a.is_public = true", "a.public_slug IS NOT NULL"]
|
||||
|
||||
if district:
|
||||
params.append(district)
|
||||
where_clauses.append(f"a.public_district = ${len(params)}")
|
||||
|
||||
if kind:
|
||||
params.append(kind)
|
||||
where_clauses.append(f"a.kind = ${len(params)}")
|
||||
|
||||
if q:
|
||||
params.append(f"%{q}%")
|
||||
where_clauses.append(
|
||||
f"(a.display_name ILIKE ${len(params)} OR a.public_title ILIKE ${len(params)} OR a.public_tagline ILIKE ${len(params)})"
|
||||
)
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
a.id,
|
||||
a.public_slug,
|
||||
a.display_name,
|
||||
a.public_title,
|
||||
a.public_tagline,
|
||||
a.avatar_url,
|
||||
a.kind,
|
||||
a.public_district,
|
||||
a.public_primary_room_slug,
|
||||
COALESCE(a.public_skills, '{{}}'::text[]) AS public_skills,
|
||||
COALESCE(a.status, 'unknown') AS status,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM agents a
|
||||
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)
|
||||
data["public_skills"] = list(data.get("public_skills") or [])
|
||||
data["online_status"] = data.get("status") or "unknown"
|
||||
items.append(data)
|
||||
|
||||
return items, total
|
||||
|
||||
|
||||
async def get_agent_microdao(agent_id: str) -> Optional[dict]:
|
||||
"""Отримати MicroDAO, до якого належить агент (перший збіг)"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
m.id,
|
||||
m.slug,
|
||||
m.name,
|
||||
m.district
|
||||
FROM microdao_agents ma
|
||||
JOIN microdaos m ON m.id = ma.microdao_id
|
||||
WHERE ma.agent_id = $1
|
||||
ORDER BY ma.is_core DESC, m.name
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, agent_id)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def get_microdao_public_citizens(microdao_id: str) -> List[dict]:
|
||||
"""Отримати публічних громадян конкретного MicroDAO"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
a.public_slug AS slug,
|
||||
a.display_name,
|
||||
a.public_title,
|
||||
a.public_tagline,
|
||||
a.avatar_url,
|
||||
a.public_district,
|
||||
a.public_primary_room_slug
|
||||
FROM microdao_agents ma
|
||||
JOIN agents a ON a.id = ma.agent_id
|
||||
WHERE ma.microdao_id = $1
|
||||
AND a.is_public = true
|
||||
AND a.public_slug IS NOT NULL
|
||||
ORDER BY a.display_name
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query, microdao_id)
|
||||
result = []
|
||||
for row in rows:
|
||||
data = dict(row)
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
|
||||
async def get_public_citizen_by_slug(slug: str) -> Optional[dict]:
|
||||
"""Отримати детальний профіль громадянина"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
a.id,
|
||||
a.display_name,
|
||||
a.kind,
|
||||
a.status,
|
||||
a.node_id,
|
||||
a.avatar_url,
|
||||
a.public_slug,
|
||||
a.public_title,
|
||||
a.public_tagline,
|
||||
COALESCE(a.public_skills, '{{}}'::text[]) AS public_skills,
|
||||
a.public_district,
|
||||
a.public_primary_room_slug,
|
||||
a.primary_room_slug
|
||||
FROM agents a
|
||||
WHERE a.public_slug = $1
|
||||
AND a.is_public = true
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
agent_row = await pool.fetchrow(query, slug)
|
||||
if not agent_row:
|
||||
return None
|
||||
|
||||
agent = dict(agent_row)
|
||||
agent["public_skills"] = list(agent.get("public_skills") or [])
|
||||
|
||||
rooms = await get_agent_rooms(agent["id"])
|
||||
primary_room = agent.get("public_primary_room_slug") or agent.get("primary_room_slug")
|
||||
city_presence = {
|
||||
"primary_room_slug": primary_room,
|
||||
"rooms": rooms
|
||||
} if rooms else {
|
||||
"primary_room_slug": primary_room,
|
||||
"rooms": []
|
||||
}
|
||||
|
||||
dais_public = {
|
||||
"core": {
|
||||
"archetype": agent.get("kind"),
|
||||
"bio_short": agent.get("public_tagline")
|
||||
},
|
||||
"phenotype": {
|
||||
"visual": {
|
||||
"avatar_url": agent.get("avatar_url"),
|
||||
"color": None
|
||||
}
|
||||
},
|
||||
"memex": {},
|
||||
"economics": {}
|
||||
}
|
||||
|
||||
interaction = {
|
||||
"matrix_user": None,
|
||||
"primary_room_slug": primary_room,
|
||||
"actions": ["chat", "ask_for_help"]
|
||||
}
|
||||
|
||||
metrics_public: Dict[str, Any] = {}
|
||||
|
||||
microdao = await get_agent_microdao(agent["id"])
|
||||
|
||||
return {
|
||||
"slug": agent["public_slug"],
|
||||
"display_name": agent["display_name"],
|
||||
"kind": agent.get("kind"),
|
||||
"public_title": agent.get("public_title"),
|
||||
"public_tagline": agent.get("public_tagline"),
|
||||
"district": agent.get("public_district"),
|
||||
"avatar_url": agent.get("avatar_url"),
|
||||
"status": agent.get("status"),
|
||||
"node_id": agent.get("node_id"),
|
||||
"public_skills": agent.get("public_skills"),
|
||||
"city_presence": city_presence,
|
||||
"dais_public": dais_public,
|
||||
"interaction": interaction,
|
||||
"metrics_public": metrics_public,
|
||||
"microdao": microdao,
|
||||
"admin_panel_url": f"/agents/{agent['id']}"
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MicroDAO Membership Repository
|
||||
# =============================================================================
|
||||
|
||||
async def get_microdao_options() -> List[dict]:
|
||||
"""Отримати список активних MicroDAO для селектора"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT id, slug, name, district, is_active
|
||||
FROM microdaos
|
||||
WHERE is_active = true
|
||||
ORDER BY name
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def get_agent_microdao_memberships(agent_id: str) -> List[dict]:
|
||||
"""Отримати всі членства агента в MicroDAO"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
ma.microdao_id,
|
||||
m.slug AS microdao_slug,
|
||||
m.name AS microdao_name,
|
||||
ma.role,
|
||||
ma.is_core
|
||||
FROM microdao_agents ma
|
||||
JOIN microdaos m ON m.id = ma.microdao_id
|
||||
WHERE ma.agent_id = $1
|
||||
ORDER BY ma.is_core DESC, m.name
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query, agent_id)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def upsert_agent_microdao_membership(
|
||||
agent_id: str,
|
||||
microdao_id: str,
|
||||
role: Optional[str],
|
||||
is_core: bool
|
||||
) -> Optional[dict]:
|
||||
"""Призначити або оновити членство агента в MicroDAO"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
WITH upsert AS (
|
||||
INSERT INTO microdao_agents (microdao_id, agent_id, role, is_core)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (microdao_id, agent_id)
|
||||
DO UPDATE SET role = EXCLUDED.role, is_core = EXCLUDED.is_core
|
||||
RETURNING microdao_id, agent_id, role, is_core
|
||||
)
|
||||
SELECT
|
||||
u.microdao_id,
|
||||
m.slug AS microdao_slug,
|
||||
m.name AS microdao_name,
|
||||
u.role,
|
||||
u.is_core
|
||||
FROM upsert u
|
||||
JOIN microdaos m ON m.id = u.microdao_id
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, microdao_id, agent_id, role, is_core)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def remove_agent_microdao_membership(agent_id: str, microdao_id: str) -> bool:
|
||||
"""Видалити членство агента в MicroDAO"""
|
||||
pool = await get_pool()
|
||||
|
||||
result = await pool.execute(
|
||||
"DELETE FROM microdao_agents WHERE agent_id = $1 AND microdao_id = $2",
|
||||
agent_id,
|
||||
microdao_id
|
||||
)
|
||||
|
||||
# asyncpg returns strings like "DELETE 1"
|
||||
return result.split(" ")[-1] != "0"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MicroDAO Repository
|
||||
# =============================================================================
|
||||
@@ -458,5 +964,8 @@ async def get_microdao_by_slug(slug: str) -> Optional[dict]:
|
||||
channels_rows = await pool.fetch(query_channels, dao_id)
|
||||
result["channels"] = [dict(row) for row in channels_rows]
|
||||
|
||||
public_citizens = await get_microdao_public_citizens(dao_id)
|
||||
result["public_citizens"] = public_citizens
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ City Backend API Routes
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
import httpx
|
||||
@@ -20,14 +21,23 @@ from models_city import (
|
||||
CityMapResponse,
|
||||
AgentRead,
|
||||
AgentPresence,
|
||||
PublicCitizenSummary,
|
||||
PublicCitizenProfile,
|
||||
CitizenInteractionInfo,
|
||||
CitizenAskRequest,
|
||||
CitizenAskResponse,
|
||||
AgentMicrodaoMembership,
|
||||
MicrodaoSummary,
|
||||
MicrodaoDetail,
|
||||
MicrodaoAgentView,
|
||||
MicrodaoChannelView
|
||||
MicrodaoChannelView,
|
||||
MicrodaoCitizenView,
|
||||
MicrodaoOption
|
||||
)
|
||||
import repo_city
|
||||
from common.redis_client import PresenceRedis, get_redis
|
||||
from matrix_client import create_matrix_room, find_matrix_room_by_alias
|
||||
from dagi_router_client import get_dagi_router_client, DagiRouterClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +46,242 @@ AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://daarion-auth:7020")
|
||||
MATRIX_GATEWAY_URL = os.getenv("MATRIX_GATEWAY_URL", "http://daarion-matrix-gateway:7025")
|
||||
|
||||
router = APIRouter(prefix="/city", tags=["city"])
|
||||
public_router = APIRouter(prefix="/public", tags=["public"])
|
||||
api_router = APIRouter(prefix="/api/v1", tags=["api_v1"])
|
||||
|
||||
|
||||
class MicrodaoMembershipPayload(BaseModel):
|
||||
microdao_id: str
|
||||
role: Optional[str] = None
|
||||
is_core: bool = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Citizens API
|
||||
# =============================================================================
|
||||
|
||||
@public_router.get("/citizens")
|
||||
async def list_public_citizens(
|
||||
district: Optional[str] = Query(None, description="Filter by district"),
|
||||
kind: Optional[str] = Query(None, description="Filter by agent kind"),
|
||||
q: Optional[str] = Query(None, description="Search by display name or title"),
|
||||
limit: int = Query(50, le=100),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""Публічний список громадян з фільтрами"""
|
||||
try:
|
||||
citizens, total = await repo_city.get_public_citizens(
|
||||
district=district,
|
||||
kind=kind,
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
items: List[PublicCitizenSummary] = []
|
||||
for citizen in citizens:
|
||||
items.append(PublicCitizenSummary(
|
||||
slug=citizen["public_slug"],
|
||||
display_name=citizen["display_name"],
|
||||
public_title=citizen.get("public_title"),
|
||||
public_tagline=citizen.get("public_tagline"),
|
||||
avatar_url=citizen.get("avatar_url"),
|
||||
kind=citizen.get("kind"),
|
||||
district=citizen.get("public_district"),
|
||||
primary_room_slug=citizen.get("public_primary_room_slug"),
|
||||
public_skills=citizen.get("public_skills", []),
|
||||
online_status=citizen.get("online_status"),
|
||||
status=citizen.get("status")
|
||||
))
|
||||
|
||||
return {"items": items, "total": total}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list public citizens: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to list public citizens")
|
||||
|
||||
|
||||
@public_router.get("/citizens/{slug}")
|
||||
async def get_public_citizen(slug: str, request: Request):
|
||||
"""Отримати публічний профіль громадянина"""
|
||||
try:
|
||||
include_admin_url = False
|
||||
authorization = request.headers.get("Authorization")
|
||||
if authorization:
|
||||
user_info = await validate_jwt_token(authorization)
|
||||
if user_info:
|
||||
roles = user_info.get("roles", [])
|
||||
if any(role in ["admin", "architect"] for role in roles):
|
||||
include_admin_url = True
|
||||
|
||||
citizen = await repo_city.get_public_citizen_by_slug(slug)
|
||||
if not citizen:
|
||||
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
|
||||
|
||||
if not include_admin_url:
|
||||
citizen["admin_panel_url"] = None
|
||||
|
||||
return PublicCitizenProfile(**citizen)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get public citizen {slug}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get citizen")
|
||||
|
||||
|
||||
@public_router.get("/citizens/{slug}/interaction", response_model=CitizenInteractionInfo)
|
||||
async def get_citizen_interaction_info(slug: str):
|
||||
"""Отримати інформацію для взаємодії з громадянином"""
|
||||
try:
|
||||
agent = await repo_city.get_public_agent_by_slug(slug)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
|
||||
|
||||
matrix_config = await repo_city.get_agent_matrix_config(agent["id"])
|
||||
matrix_user_id = matrix_config.get("matrix_user_id") if matrix_config else None
|
||||
|
||||
primary_room_slug = agent.get("public_primary_room_slug") or agent.get("primary_room_slug")
|
||||
primary_room_id = matrix_config.get("primary_room_id") if matrix_config else None
|
||||
primary_room_name = None
|
||||
room_record = None
|
||||
|
||||
if primary_room_id:
|
||||
room_record = await repo_city.get_room_by_id(primary_room_id)
|
||||
elif primary_room_slug:
|
||||
room_record = await repo_city.get_room_by_slug(primary_room_slug)
|
||||
|
||||
if room_record:
|
||||
primary_room_id = room_record.get("id")
|
||||
primary_room_name = room_record.get("name")
|
||||
primary_room_slug = room_record.get("slug") or primary_room_slug
|
||||
|
||||
microdao = await repo_city.get_microdao_for_agent(agent["id"])
|
||||
|
||||
return CitizenInteractionInfo(
|
||||
slug=slug,
|
||||
display_name=agent["display_name"],
|
||||
primary_room_slug=primary_room_slug,
|
||||
primary_room_id=primary_room_id,
|
||||
primary_room_name=primary_room_name,
|
||||
matrix_user_id=matrix_user_id,
|
||||
district=agent.get("public_district"),
|
||||
microdao_slug=microdao.get("slug") if microdao else None,
|
||||
microdao_name=microdao.get("name") if microdao else None,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get interaction info for citizen {slug}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to load interaction info")
|
||||
|
||||
|
||||
@public_router.post("/citizens/{slug}/ask", response_model=CitizenAskResponse)
|
||||
async def ask_citizen(
|
||||
slug: str,
|
||||
payload: CitizenAskRequest,
|
||||
router_client: DagiRouterClient = Depends(get_dagi_router_client),
|
||||
):
|
||||
"""Надіслати запитання громадянину через DAGI Router"""
|
||||
question = (payload.question or "").strip()
|
||||
if not question:
|
||||
raise HTTPException(status_code=400, detail="Question is required")
|
||||
|
||||
try:
|
||||
agent = await repo_city.get_public_agent_by_slug(slug)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
|
||||
|
||||
router_response = await router_client.ask_agent(
|
||||
agent_id=agent["id"],
|
||||
prompt=question,
|
||||
system_prompt=payload.context,
|
||||
)
|
||||
|
||||
answer = (
|
||||
router_response.get("response")
|
||||
or router_response.get("answer")
|
||||
or router_response.get("result")
|
||||
)
|
||||
if answer:
|
||||
answer = answer.strip()
|
||||
if not answer:
|
||||
answer = "Вибач, агент наразі не може відповісти."
|
||||
|
||||
return CitizenAskResponse(
|
||||
answer=answer,
|
||||
agent_display_name=agent["display_name"],
|
||||
agent_id=agent["id"],
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"DAGI Router request failed for citizen {slug}: {e}")
|
||||
raise HTTPException(status_code=502, detail="Citizen is temporarily unavailable")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ask citizen {slug}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to ask citizen")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API v1 — MicroDAO Membership
|
||||
# =============================================================================
|
||||
|
||||
@api_router.get("/microdao/options")
|
||||
async def get_microdao_options():
|
||||
"""Отримати список MicroDAO для селектора"""
|
||||
try:
|
||||
options = await repo_city.get_microdao_options()
|
||||
items = [MicrodaoOption(**option) for option in options]
|
||||
return {"items": items}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get microdao options: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get microdao options")
|
||||
|
||||
|
||||
@api_router.put("/agents/{agent_id}/microdao-membership")
|
||||
async def assign_agent_microdao_membership(
|
||||
agent_id: str,
|
||||
payload: MicrodaoMembershipPayload,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""Призначити/оновити членство агента в MicroDAO"""
|
||||
await ensure_architect_or_admin(authorization)
|
||||
|
||||
try:
|
||||
membership = await repo_city.upsert_agent_microdao_membership(
|
||||
agent_id=agent_id,
|
||||
microdao_id=payload.microdao_id,
|
||||
role=payload.role,
|
||||
is_core=payload.is_core
|
||||
)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=404, detail="MicroDAO not found")
|
||||
return membership
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assign microdao membership: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to assign microdao membership")
|
||||
|
||||
|
||||
@api_router.delete("/agents/{agent_id}/microdao-membership/{microdao_id}")
|
||||
async def delete_agent_microdao_membership(
|
||||
agent_id: str,
|
||||
microdao_id: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""Видалити членство агента в MicroDAO"""
|
||||
await ensure_architect_or_admin(authorization)
|
||||
|
||||
try:
|
||||
deleted = await repo_city.remove_agent_microdao_membership(agent_id, microdao_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Membership not found")
|
||||
return {"status": "deleted"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete microdao membership: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to delete microdao membership")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -335,6 +581,22 @@ async def validate_jwt_token(authorization: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_architect_or_admin(authorization: Optional[str]) -> dict:
|
||||
"""Переконатися, що користувач має роль architect/admin"""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=403, detail="Missing authorization token")
|
||||
|
||||
user_info = await validate_jwt_token(authorization)
|
||||
if not user_info:
|
||||
raise HTTPException(status_code=403, detail="Invalid authorization token")
|
||||
|
||||
roles = user_info.get("roles", [])
|
||||
if not any(role in ["admin", "architect"] for role in roles):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
@router.get("/chat/bootstrap")
|
||||
async def chat_bootstrap(
|
||||
room_slug: str = Query(..., description="City room slug"),
|
||||
@@ -542,13 +804,13 @@ async def update_agent_public_profile(agent_id: str, request: Request):
|
||||
|
||||
|
||||
@router.get("/citizens")
|
||||
async def get_public_citizens(limit: int = 50, offset: int = 0):
|
||||
async def get_public_citizens_legacy(limit: int = 50, offset: int = 0):
|
||||
"""
|
||||
Отримати список публічних громадян DAARION City.
|
||||
"""
|
||||
try:
|
||||
citizens = await repo_city.get_public_citizens(limit, offset)
|
||||
return {"citizens": citizens, "total": len(citizens)}
|
||||
citizens, total = await repo_city.get_public_citizens(limit=limit, offset=offset)
|
||||
return {"citizens": citizens, "total": total}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get public citizens: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get public citizens")
|
||||
@@ -561,13 +823,15 @@ async def get_citizen_by_slug(slug: str, request: Request):
|
||||
Для адмінів/архітекторів додається admin_panel_url.
|
||||
"""
|
||||
try:
|
||||
# TODO: Check user role from JWT
|
||||
# For now, always include admin URL (will be filtered by frontend auth)
|
||||
include_admin_url = True # Should be: user.role in ['admin', 'architect']
|
||||
include_admin_url = True # legacy endpoint доступний тільки з адмінської панелі
|
||||
|
||||
citizen = await repo_city.get_citizen_by_slug(slug, include_admin_url=include_admin_url)
|
||||
citizen = await repo_city.get_public_citizen_by_slug(slug)
|
||||
if not citizen:
|
||||
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
|
||||
|
||||
if not include_admin_url:
|
||||
citizen["admin_panel_url"] = None
|
||||
|
||||
return citizen
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -709,6 +973,19 @@ async def get_agent_dashboard(agent_id: str):
|
||||
# Get public profile
|
||||
public_profile = await repo_city.get_agent_public_profile(agent_id)
|
||||
|
||||
# MicroDAO memberships
|
||||
memberships_raw = await repo_city.get_agent_microdao_memberships(agent_id)
|
||||
memberships = [
|
||||
AgentMicrodaoMembership(
|
||||
microdao_id=item["microdao_id"],
|
||||
microdao_slug=item.get("microdao_slug"),
|
||||
microdao_name=item.get("microdao_name"),
|
||||
role=item.get("role"),
|
||||
is_core=item.get("is_core", False)
|
||||
)
|
||||
for item in memberships_raw
|
||||
]
|
||||
|
||||
# Build dashboard response
|
||||
dashboard = {
|
||||
"profile": profile,
|
||||
@@ -727,7 +1004,8 @@ async def get_agent_dashboard(agent_id: str):
|
||||
},
|
||||
"recent_activity": [],
|
||||
"system_prompts": system_prompts,
|
||||
"public_profile": public_profile
|
||||
"public_profile": public_profile,
|
||||
"microdao_memberships": memberships
|
||||
}
|
||||
|
||||
return dashboard
|
||||
@@ -922,6 +1200,18 @@ async def get_microdao_by_slug(slug: str):
|
||||
is_primary=channel.get("is_primary", False)
|
||||
))
|
||||
|
||||
public_citizens = []
|
||||
for citizen in dao.get("public_citizens", []):
|
||||
public_citizens.append(MicrodaoCitizenView(
|
||||
slug=citizen["slug"],
|
||||
display_name=citizen["display_name"],
|
||||
public_title=citizen.get("public_title"),
|
||||
public_tagline=citizen.get("public_tagline"),
|
||||
avatar_url=citizen.get("avatar_url"),
|
||||
district=citizen.get("public_district"),
|
||||
primary_room_slug=citizen.get("public_primary_room_slug")
|
||||
))
|
||||
|
||||
return MicrodaoDetail(
|
||||
id=dao["id"],
|
||||
slug=dao["slug"],
|
||||
@@ -934,7 +1224,8 @@ async def get_microdao_by_slug(slug: str):
|
||||
is_public=dao.get("is_public", True),
|
||||
logo_url=dao.get("logo_url"),
|
||||
agents=agents,
|
||||
channels=channels
|
||||
channels=channels,
|
||||
public_citizens=public_citizens
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
|
||||
Reference in New Issue
Block a user