feat: Citizens Layer + Citizen Interact Layer + CityChatWidget

This commit is contained in:
Apple
2025-11-28 03:10:47 -08:00
parent 94bb222c9c
commit 06d0cba7d4
55 changed files with 5035 additions and 310 deletions

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

View File

@@ -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>
)
}

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

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

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

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

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

View File

@@ -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">

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

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

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

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

View File

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

View File

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

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

View File

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

View 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';

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

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

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

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

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

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

View File

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

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

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

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

View 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';

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

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

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

View File

@@ -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 {

View File

@@ -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;
}
// =============================================================================