feat: Citizens Layer + Citizen Interact Layer + CityChatWidget
This commit is contained in:
314
apps/web/src/app/agents/[agentId]/page.tsx
Normal file
314
apps/web/src/app/agents/[agentId]/page.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAgentDashboard } from '@/hooks/useAgentDashboard';
|
||||
import {
|
||||
AgentSummaryCard,
|
||||
AgentDAISCard,
|
||||
AgentCityCard,
|
||||
AgentMetricsCard,
|
||||
AgentSystemPromptsCard,
|
||||
AgentPublicProfileCard,
|
||||
AgentMicrodaoMembershipCard
|
||||
} from '@/components/agent-dashboard';
|
||||
import { api, Agent, AgentInvokeResponse } from '@/lib/api';
|
||||
|
||||
// Chat Message type
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
meta?: {
|
||||
tokens_in?: number;
|
||||
tokens_out?: number;
|
||||
latency_ms?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AgentPage() {
|
||||
const params = useParams();
|
||||
const agentId = params.agentId as string;
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'chat'>('dashboard');
|
||||
|
||||
// Dashboard state
|
||||
const { dashboard, isLoading: dashboardLoading, error: dashboardError, refresh } = useAgentDashboard(agentId, {
|
||||
refreshInterval: 30000
|
||||
});
|
||||
|
||||
// Chat state
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [chatLoading, setChatLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [invoking, setInvoking] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load agent for chat
|
||||
useEffect(() => {
|
||||
async function loadAgent() {
|
||||
try {
|
||||
setChatLoading(true);
|
||||
const data = await api.getAgent(agentId);
|
||||
setAgent(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load agent:', error);
|
||||
} finally {
|
||||
setChatLoading(false);
|
||||
}
|
||||
}
|
||||
if (activeTab === 'chat') {
|
||||
loadAgent();
|
||||
}
|
||||
}, [agentId, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!input.trim() || invoking) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setInvoking(true);
|
||||
|
||||
try {
|
||||
const response: AgentInvokeResponse = await api.invokeAgent(agentId, input.trim());
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: response.reply || 'No response',
|
||||
timestamp: new Date(),
|
||||
meta: {
|
||||
tokens_in: response.tokens_in,
|
||||
tokens_out: response.tokens_out,
|
||||
latency_ms: response.latency_ms
|
||||
}
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error. Please try again.',
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setInvoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (dashboardLoading && !dashboard && activeTab === 'dashboard') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p className="text-white/70">Loading agent dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (dashboardError && activeTab === 'dashboard') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
|
||||
<p className="text-red-400 text-lg mb-2">Failed to load agent dashboard</p>
|
||||
<p className="text-white/50 mb-4">{dashboardError.message}</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<Link
|
||||
href="/agents"
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Back to Agents
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/agents"
|
||||
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{dashboard?.profile.display_name || agent?.name || agentId}
|
||||
</h1>
|
||||
<p className="text-white/50 text-sm">Agent Cabinet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeTab === 'dashboard'
|
||||
? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'bg-white/5 text-white/50 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
📊 Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'bg-white/5 text-white/50 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
💬 Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Tab */}
|
||||
{activeTab === 'dashboard' && dashboard && (
|
||||
<div className="space-y-6">
|
||||
<AgentSummaryCard profile={dashboard.profile} runtime={dashboard.runtime} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<AgentDAISCard dais={dashboard.profile.dais} />
|
||||
<div className="space-y-6">
|
||||
<AgentCityCard cityPresence={dashboard.profile.city_presence} />
|
||||
<AgentMetricsCard metrics={dashboard.metrics} />
|
||||
</div>
|
||||
</div>
|
||||
{/* System Prompts - Full Width */}
|
||||
<AgentSystemPromptsCard
|
||||
agentId={dashboard.profile.agent_id}
|
||||
systemPrompts={dashboard.system_prompts}
|
||||
canEdit={true} // TODO: Check user role
|
||||
onUpdated={refresh}
|
||||
/>
|
||||
|
||||
{/* Public Profile Settings */}
|
||||
<AgentPublicProfileCard
|
||||
agentId={dashboard.profile.agent_id}
|
||||
publicProfile={dashboard.public_profile}
|
||||
canEdit={true} // TODO: Check user role
|
||||
onUpdated={refresh}
|
||||
/>
|
||||
|
||||
<AgentMicrodaoMembershipCard
|
||||
agentId={dashboard.profile.agent_id}
|
||||
memberships={dashboard.microdao_memberships ?? []}
|
||||
canEdit={true}
|
||||
onUpdated={refresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Tab */}
|
||||
{activeTab === 'chat' && (
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden">
|
||||
{/* Messages */}
|
||||
<div className="h-[500px] overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-white/50 py-8">
|
||||
<p className="text-4xl mb-2">💬</p>
|
||||
<p>Start a conversation with {dashboard?.profile.display_name || agent?.name || agentId}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] p-3 rounded-xl ${
|
||||
msg.role === 'user'
|
||||
? 'bg-cyan-500/20 text-white'
|
||||
: 'bg-white/10 text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
{msg.meta && (
|
||||
<div className="mt-2 text-xs text-white/30 flex gap-2">
|
||||
{msg.meta.latency_ms && <span>{msg.meta.latency_ms}ms</span>}
|
||||
{msg.meta.tokens_out && <span>{msg.meta.tokens_out} tokens</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{invoking && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white/10 p-3 rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-cyan-500 border-t-transparent rounded-full" />
|
||||
<span className="text-white/50">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-white/10 p-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSendMessage()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-cyan-500/50"
|
||||
disabled={invoking}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!input.trim() || invoking}
|
||||
className="px-4 py-3 bg-cyan-500 hover:bg-cyan-400 disabled:bg-white/10 disabled:text-white/30 text-white rounded-xl transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArrowLeft, Bot, Send, Loader2, Zap, Clock, CheckCircle2 } from 'lucide-react'
|
||||
import { api, Agent, AgentInvokeResponse } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
meta?: {
|
||||
tokens_in?: number
|
||||
tokens_out?: number
|
||||
latency_ms?: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentPage() {
|
||||
const params = useParams()
|
||||
const agentId = params.id as string
|
||||
|
||||
const [agent, setAgent] = useState<Agent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [invoking, setInvoking] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAgent() {
|
||||
try {
|
||||
const data = await api.getAgent(agentId)
|
||||
setAgent(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load agent:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadAgent()
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleInvoke = async () => {
|
||||
if (!input.trim() || invoking) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: `user_${Date.now()}`,
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInput('')
|
||||
setInvoking(true)
|
||||
|
||||
try {
|
||||
const response: AgentInvokeResponse = await api.invokeAgent(agentId, userMessage.content)
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant_${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: response.reply || 'Немає відповіді',
|
||||
timestamp: new Date(),
|
||||
meta: {
|
||||
tokens_in: response.tokens_in,
|
||||
tokens_out: response.tokens_out,
|
||||
latency_ms: response.latency_ms
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage])
|
||||
} catch (error) {
|
||||
const errorMessage: Message = {
|
||||
id: `error_${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: `Помилка: ${error instanceof Error ? error.message : 'Невідома помилка'}`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev, errorMessage])
|
||||
} finally {
|
||||
setInvoking(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-violet-400 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Bot className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Агент не знайдений</h2>
|
||||
<Link href="/agents" className="text-violet-400 hover:text-violet-300">
|
||||
Повернутися до списку
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-6 border-b border-white/5">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Link
|
||||
href="/agents"
|
||||
className="inline-flex items-center gap-2 text-slate-400 hover:text-white transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Назад до агентів
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-violet-500/30 to-purple-600/30 flex items-center justify-center">
|
||||
<Bot className="w-8 h-8 text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{agent.name}</h1>
|
||||
<p className="text-slate-400">{agent.description || 'AI Agent'}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'px-3 py-1 rounded-full text-sm',
|
||||
agent.is_active
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'bg-slate-700/50 text-slate-400'
|
||||
)}>
|
||||
{agent.is_active ? 'Активний' : 'Неактивний'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="glass-panel h-[500px] flex flex-col">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center">
|
||||
<Zap className="w-12 h-12 text-violet-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-white mb-2">
|
||||
Почніть розмову з {agent.name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400 max-w-sm">
|
||||
Напишіть повідомлення нижче, щоб викликати агента та отримати відповідь.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'flex gap-3',
|
||||
message.role === 'user' && 'flex-row-reverse'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
message.role === 'user'
|
||||
? 'bg-cyan-500/30'
|
||||
: 'bg-violet-500/30'
|
||||
)}>
|
||||
{message.role === 'user' ? (
|
||||
<span className="text-xs text-cyan-400">U</span>
|
||||
) : (
|
||||
<Bot className="w-4 h-4 text-violet-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'max-w-[80%]',
|
||||
message.role === 'user' && 'text-right'
|
||||
)}>
|
||||
<div className={cn(
|
||||
'inline-block px-4 py-2 rounded-2xl text-sm',
|
||||
message.role === 'user'
|
||||
? 'bg-cyan-500/20 text-white rounded-tr-sm'
|
||||
: 'bg-slate-800/50 text-slate-200 rounded-tl-sm'
|
||||
)}>
|
||||
{message.content}
|
||||
</div>
|
||||
|
||||
{message.meta && (
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
|
||||
{message.meta.latency_ms && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{message.meta.latency_ms}ms
|
||||
</span>
|
||||
)}
|
||||
{message.meta.tokens_out && (
|
||||
<span>{message.meta.tokens_out} tokens</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleInvoke()
|
||||
}}
|
||||
className="flex gap-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={`Напишіть повідомлення для ${agent.name}...`}
|
||||
disabled={invoking}
|
||||
className="flex-1 px-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={invoking || !input.trim()}
|
||||
className={cn(
|
||||
'px-4 py-3 rounded-xl transition-all',
|
||||
input.trim() && !invoking
|
||||
? 'bg-gradient-to-r from-violet-500 to-purple-600 text-white shadow-lg shadow-violet-500/20'
|
||||
: 'bg-slate-800/50 text-slate-500 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{invoking ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Stats */}
|
||||
<div className="mt-6 grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatCard icon={Zap} label="Тип" value={agent.kind} />
|
||||
<StatCard icon={Bot} label="Модель" value={agent.model || 'N/A'} />
|
||||
<StatCard icon={CheckCircle2} label="Статус" value={agent.status} />
|
||||
<StatCard icon={Clock} label="Викликів" value={messages.filter(m => m.role === 'user').length.toString()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel p-4">
|
||||
<Icon className="w-5 h-5 text-violet-400 mb-2" />
|
||||
<div className="text-xs text-slate-400 mb-1">{label}</div>
|
||||
<div className="text-sm font-medium text-white truncate">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
40
apps/web/src/app/api/agents/[agentId]/dashboard/route.ts
Normal file
40
apps/web/src/app/api/agents/[agentId]/dashboard/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await context.params;
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/dashboard`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store'
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch agent dashboard: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent dashboard proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch agent dashboard' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { agentId: string; microdaoId: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { agentId, microdaoId } = params;
|
||||
const accessToken = req.cookies.get("daarion_access_token")?.value;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/agents/${encodeURIComponent(
|
||||
agentId
|
||||
)}/microdao-membership/${encodeURIComponent(microdaoId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (res.status === 204) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Remove microdao membership proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to remove MicroDAO membership" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { agentId: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { agentId } = params;
|
||||
const accessToken = req.cookies.get("daarion_access_token")?.value;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/agents/${encodeURIComponent(
|
||||
agentId
|
||||
)}/microdao-membership`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Assign microdao membership proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to assign MicroDAO membership" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string; kind: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId, kind } = await context.params;
|
||||
const body = await request.json();
|
||||
|
||||
// Validate kind
|
||||
const validKinds = ['core', 'safety', 'governance', 'tools'];
|
||||
if (!validKinds.includes(kind)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid kind. Must be one of: ${validKinds.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Forward to backend
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/prompts/${encodeURIComponent(kind)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent prompt update proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update agent prompt' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string; kind: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId, kind } = await context.params;
|
||||
|
||||
// Get history
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/prompts/${encodeURIComponent(kind)}/history`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent prompt history proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get prompt history' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await context.params;
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/public-profile`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent public profile update proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update agent public profile' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await context.params;
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/dashboard`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Return just the public_profile part
|
||||
return NextResponse.json(data.public_profile || {}, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Agent public profile get proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get agent public profile' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
39
apps/web/src/app/api/citizens/[slug]/route.ts
Normal file
39
apps/web/src/app/api/citizens/[slug]/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await context.params;
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/citizens/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Citizen not found' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Citizen get proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get citizen' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
31
apps/web/src/app/api/citizens/route.ts
Normal file
31
apps/web/src/app/api/citizens/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || 'http://daarion-city-service:7001';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = searchParams.get('limit') || '50';
|
||||
const offset = searchParams.get('offset') || '0';
|
||||
|
||||
const response = await fetch(
|
||||
`${CITY_SERVICE_URL}/city/citizens?limit=${limit}&offset=${offset}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Citizens list proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get citizens' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
31
apps/web/src/app/api/microdao/options/route.ts
Normal file
31
apps/web/src/app/api/microdao/options/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/microdao/options`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("MicroDAO options proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch MicroDAO options" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
apps/web/src/app/api/node/dashboard/route.ts
Normal file
41
apps/web/src/app/api/node/dashboard/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const NODE_REGISTRY_URL = process.env.NODE_REGISTRY_URL || 'http://dagi-node-registry:9205';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const nodeId = searchParams.get('nodeId');
|
||||
|
||||
// Build URL - either specific node or self
|
||||
const endpoint = nodeId
|
||||
? `${NODE_REGISTRY_URL}/api/v1/nodes/${nodeId}/dashboard`
|
||||
: `${NODE_REGISTRY_URL}/api/v1/nodes/self/dashboard`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Revalidate every 10 seconds
|
||||
next: { revalidate: 10 }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch dashboard: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Node dashboard proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch node dashboard' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
39
apps/web/src/app/api/public/citizens/[slug]/ask/route.ts
Normal file
39
apps/web/src/app/api/public/citizens/[slug]/ask/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/public/citizens/${encodeURIComponent(slug)}/ask`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Citizen ask proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send question to citizen" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/public/citizens/${encodeURIComponent(slug)}/interaction`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Citizen interaction proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load citizen interaction info" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
apps/web/src/app/api/public/citizens/[slug]/route.ts
Normal file
39
apps/web/src/app/api/public/citizens/[slug]/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/public/citizens/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Public citizen proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch citizen profile" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
apps/web/src/app/api/public/citizens/route.ts
Normal file
41
apps/web/src/app/api/public/citizens/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.CITY_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "CITY_API_BASE_URL is not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const district = searchParams.get("district");
|
||||
const kind = searchParams.get("kind");
|
||||
const q = searchParams.get("q");
|
||||
|
||||
const url = new URL("/public/citizens", API_BASE);
|
||||
if (district) url.searchParams.set("district", district);
|
||||
if (kind) url.searchParams.set("kind", kind);
|
||||
if (q) url.searchParams.set("q", q);
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (error) {
|
||||
console.error("Public citizens proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch public citizens" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
359
apps/web/src/app/citizens/[slug]/page.tsx
Normal file
359
apps/web/src/app/citizens/[slug]/page.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { getAgentKindIcon } from '@/lib/agent-dashboard';
|
||||
import { useCitizenProfile, useCitizenInteraction } from '@/hooks/useCitizens';
|
||||
import { askCitizen } from '@/lib/api/citizens';
|
||||
import { CityChatWidget } from '@/components/city/CityChatWidget';
|
||||
|
||||
type LooseRecord = Record<string, any>;
|
||||
|
||||
export default function CitizenProfilePage() {
|
||||
const params = useParams<{ slug: string }>();
|
||||
const slug = params.slug;
|
||||
|
||||
const { citizen, isLoading, error } = useCitizenProfile(slug);
|
||||
const {
|
||||
interaction,
|
||||
isLoading: interactionLoading,
|
||||
error: interactionError,
|
||||
} = useCitizenInteraction(slug);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
const [askError, setAskError] = useState<string | null>(null);
|
||||
const [asking, setAsking] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !citizen) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
|
||||
<p className="text-red-400 text-lg mb-4">{error?.message || 'Citizen not found'}</p>
|
||||
<Link href="/citizens" className="text-cyan-400 hover:underline">
|
||||
← Back to Citizens
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = citizen.status || 'unknown';
|
||||
const statusColor =
|
||||
status === 'online' ? 'bg-emerald-500/20 text-emerald-300' : 'bg-white/10 text-white/60';
|
||||
|
||||
const daisCore = (citizen.dais_public?.core as LooseRecord) || {};
|
||||
const daisPhenotype = (citizen.dais_public?.phenotype as LooseRecord) || {};
|
||||
const daisMemex = (citizen.dais_public?.memex as LooseRecord) || {};
|
||||
const daisEconomics = (citizen.dais_public?.economics as LooseRecord) || {};
|
||||
const metricsEntries = Object.entries(citizen.metrics_public || {});
|
||||
const actions = (citizen.interaction?.actions as string[]) || [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
<Link
|
||||
href="/citizens"
|
||||
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
← Back to Citizens
|
||||
</Link>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-cyan-500/20 to-purple-500/20 p-8">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-start">
|
||||
<div className="w-24 h-24 flex-shrink-0 rounded-2xl bg-gradient-to-br from-cyan-500/40 to-purple-500/40 flex items-center justify-center text-5xl shadow-xl">
|
||||
{getAgentKindIcon(citizen.kind || '')}
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<h1 className="text-3xl font-bold text-white">{citizen.display_name}</h1>
|
||||
<p className="text-cyan-200 text-lg">
|
||||
{citizen.public_title || citizen.kind || 'Citizen of DAARION'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span className={`px-3 py-1 rounded-full ${statusColor}`}>
|
||||
{status}
|
||||
</span>
|
||||
{citizen.district && (
|
||||
<span className="px-3 py-1 rounded-full bg-white/10 text-white/70">
|
||||
{citizen.district} District
|
||||
</span>
|
||||
)}
|
||||
{citizen.microdao && (
|
||||
<Link
|
||||
href={`/microdao/${citizen.microdao.slug}`}
|
||||
className="px-3 py-1 rounded-full bg-cyan-500/20 text-cyan-200 hover:bg-cyan-500/30"
|
||||
>
|
||||
MicroDAO: {citizen.microdao.name}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{citizen.admin_panel_url && (
|
||||
<Link
|
||||
href={citizen.admin_panel_url}
|
||||
className="px-4 py-2 bg-purple-500/20 text-purple-200 rounded-lg hover:bg-purple-500/30 transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
⚙️ Agent Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8 space-y-8">
|
||||
{citizen.public_tagline && (
|
||||
<blockquote className="text-xl text-white/80 italic border-l-4 border-cyan-500/60 pl-4">
|
||||
"{citizen.public_tagline}"
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
{citizen.public_skills?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs uppercase text-white/40 mb-2">Skills</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{citizen.public_skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="px-3 py-1 bg-cyan-500/20 text-cyan-200 rounded-lg text-sm"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{citizen.district && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
|
||||
<p className="text-xs uppercase text-white/40">District</p>
|
||||
<p className="text-white mt-1 text-lg">{citizen.district}</p>
|
||||
</div>
|
||||
)}
|
||||
{citizen.city_presence?.primary_room_slug && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
|
||||
<p className="text-xs uppercase text-white/40">Primary Room</p>
|
||||
<p className="text-white mt-1 text-lg">
|
||||
#{citizen.city_presence.primary_room_slug}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{citizen.node_id && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
|
||||
<p className="text-xs uppercase text-white/40">Node</p>
|
||||
<p className="text-white mt-1 text-lg">{citizen.node_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">Взаємодія з громадянином</h2>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-white/60">Чат</p>
|
||||
{interactionLoading ? (
|
||||
<div className="text-white/40 text-xs">Завантаження…</div>
|
||||
) : interaction?.primary_room_slug ? (
|
||||
<Link
|
||||
href={`/city/${interaction.primary_room_slug}`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm border border-white/20 rounded-lg text-white hover:border-cyan-400/70 transition-colors"
|
||||
>
|
||||
Відкрити чат у кімнаті{' '}
|
||||
{interaction.primary_room_name ?? interaction.primary_room_slug}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="text-white/50 text-xs">
|
||||
Для цього громадянина ще не налаштована публічна кімната чату.
|
||||
</div>
|
||||
)}
|
||||
{interactionError && (
|
||||
<div className="text-xs text-red-400">
|
||||
Не вдалося завантажити інформацію про чат.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-white/60">Поставити запитання</p>
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="Напишіть запитання до агента…"
|
||||
className="w-full min-h-[90px] rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 focus:border-cyan-500/50 focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!slug || !question.trim()) return;
|
||||
setAsking(true);
|
||||
setAskError(null);
|
||||
setAnswer(null);
|
||||
try {
|
||||
const res = await askCitizen(slug, { question });
|
||||
setAnswer(res.answer);
|
||||
} catch (err) {
|
||||
setAskError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Сталася помилка під час запиту.',
|
||||
);
|
||||
} finally {
|
||||
setAsking(false);
|
||||
}
|
||||
}}
|
||||
disabled={asking || !question.trim()}
|
||||
className="px-4 py-2 rounded-lg border border-white/20 text-sm text-white hover:border-cyan-400/70 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{asking ? 'Надсилання…' : 'Запитати'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuestion('');
|
||||
setAnswer(null);
|
||||
setAskError(null);
|
||||
}}
|
||||
className="text-xs text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
Очистити
|
||||
</button>
|
||||
</div>
|
||||
{askError && <div className="text-xs text-red-400">{askError}</div>}
|
||||
{answer && (
|
||||
<div className="mt-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 whitespace-pre-wrap">
|
||||
{answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">Live-чат з громадянином</h2>
|
||||
{interactionLoading ? (
|
||||
<div className="text-sm text-white/70">Завантаження кімнати…</div>
|
||||
) : interaction?.primary_room_slug ? (
|
||||
<CityChatWidget roomSlug={interaction.primary_room_slug} />
|
||||
) : (
|
||||
<div className="text-sm text-white/60">
|
||||
Для цього громадянина ще не налаштована публічна кімната чату.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">DAIS Public Passport</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-white/40">Identity</p>
|
||||
<p className="text-white font-semibold mt-2">
|
||||
{(daisCore?.archetype as string) || citizen.kind || 'Specialist'}
|
||||
</p>
|
||||
<p className="text-white/70 text-sm">
|
||||
{(daisCore?.bio_short as string) || citizen.public_tagline}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-white/40">Visual</p>
|
||||
<p className="text-white/70 text-sm">
|
||||
{(daisPhenotype?.visual as Record<string, string>)?.style || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-white/40">Memory</p>
|
||||
<p className="text-white/70 text-sm">
|
||||
{daisMemex && Object.keys(daisMemex).length > 0
|
||||
? JSON.stringify(daisMemex)
|
||||
: 'Shared city memory'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-white/40">Economics</p>
|
||||
<p className="text-white/70 text-sm">
|
||||
{daisEconomics && Object.keys(daisEconomics).length > 0
|
||||
? JSON.stringify(daisEconomics)
|
||||
: 'per_task'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">City Presence</h2>
|
||||
{citizen.city_presence?.rooms?.length ? (
|
||||
<div className="space-y-2">
|
||||
{citizen.city_presence.rooms.map((room) => (
|
||||
<Link
|
||||
key={room.slug || room.room_id}
|
||||
href={room.slug ? `/city/${room.slug}` : '#'}
|
||||
className="flex items-center justify-between bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white hover:border-cyan-500/40 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold">{room.name || room.slug}</p>
|
||||
<p className="text-white/50 text-xs">{room.slug}</p>
|
||||
</div>
|
||||
<span className="text-white/50">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white/50 text-sm">Публічні кімнати не вказані.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-white font-semibold">Interaction</h2>
|
||||
{actions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{actions.map((action) => (
|
||||
<span
|
||||
key={action}
|
||||
className="px-3 py-1 bg-cyan-500/20 text-cyan-200 rounded-full text-xs"
|
||||
>
|
||||
{action}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white/50 text-sm">Публічні сценарії взаємодії готуються.</p>
|
||||
)}
|
||||
<button className="w-full mt-4 px-4 py-2 bg-cyan-500/20 text-cyan-100 rounded-lg hover:bg-cyan-500/30 transition-colors">
|
||||
💬 Запросити до діалогу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 space-y-3">
|
||||
<h2 className="text-white font-semibold">Public Metrics</h2>
|
||||
{metricsEntries.length ? (
|
||||
<div className="space-y-2">
|
||||
{metricsEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-white/50">{key}</span>
|
||||
<span className="text-white font-semibold">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white/50 text-sm">Метрики поки не опубліковані.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
297
apps/web/src/app/citizens/page.tsx
Normal file
297
apps/web/src/app/citizens/page.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { getAgentKindIcon } from '@/lib/agent-dashboard';
|
||||
import { DISTRICTS } from '@/lib/microdao';
|
||||
import { useCitizensList } from '@/hooks/useCitizens';
|
||||
import type { PublicCitizenSummary } from '@/lib/types/citizens';
|
||||
|
||||
const CITIZEN_KINDS = [
|
||||
'vision',
|
||||
'curator',
|
||||
'security',
|
||||
'finance',
|
||||
'civic',
|
||||
'oracle',
|
||||
'builder',
|
||||
'research',
|
||||
];
|
||||
|
||||
export default function CitizensPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [district, setDistrict] = useState('');
|
||||
const [kind, setKind] = useState('');
|
||||
|
||||
const { items, total, isLoading, error } = useCitizensList({
|
||||
district: district || undefined,
|
||||
kind: kind || undefined,
|
||||
q: search || undefined,
|
||||
});
|
||||
|
||||
const citizens = useMemo(() => items ?? [], [items]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
🏛️ Citizens of DAARION City
|
||||
</h1>
|
||||
<p className="text-white/60">
|
||||
Публічні AI-агенти, відкриті для співпраці та взаємодії
|
||||
</p>
|
||||
<p className="text-sm text-cyan-300/80 mt-2">
|
||||
{isLoading ? 'Оновлення списку…' : `Знайдено громадян: ${total}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<label className="text-xs uppercase text-white/40 block mb-2">
|
||||
Пошук
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Імʼя, титул або теглайн"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/30 focus:border-cyan-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase text-white/40 block mb-2">
|
||||
District
|
||||
</label>
|
||||
<select
|
||||
value={district}
|
||||
onChange={(e) => setDistrict(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-cyan-500/50 focus:outline-none"
|
||||
>
|
||||
<option value="">Всі дістрікти</option>
|
||||
{DISTRICTS.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase text-white/40 block mb-2">
|
||||
Тип агента
|
||||
</label>
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-cyan-500/50 focus:outline-none"
|
||||
>
|
||||
<option value="">Всі типи</option>
|
||||
{CITIZEN_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/40 rounded-xl px-4 py-3 text-sm text-red-200">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{(isLoading ? Array.from({ length: 6 }) : citizens).map(
|
||||
(citizen, index) =>
|
||||
citizen ? (
|
||||
<CitizenCard key={citizen.slug} citizen={citizen} />
|
||||
) : (
|
||||
<div
|
||||
key={`placeholder-${index}`}
|
||||
className="bg-white/5 rounded-2xl border border-white/5 animate-pulse h-60"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && citizens.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-white/40">Наразі немає публічних громадян за цими фільтрами.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CitizenCard({ citizen }: { citizen: PublicCitizenSummary }) {
|
||||
const status = citizen.online_status || 'unknown';
|
||||
const statusColor =
|
||||
status === 'online' ? 'text-emerald-400' : 'text-white/40';
|
||||
|
||||
return (
|
||||
<Link key={citizen.slug} href={`/citizens/${citizen.slug}`} className="group">
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 hover:border-cyan-500/50 transition-all hover:bg-white/10">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-cyan-500/30 to-purple-500/30 flex items-center justify-center text-3xl">
|
||||
{getAgentKindIcon(citizen.kind || '')}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-white group-hover:text-cyan-400 transition-colors">
|
||||
{citizen.display_name}
|
||||
</h3>
|
||||
<p className="text-cyan-400 text-sm">
|
||||
{citizen.public_title || citizen.kind}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{citizen.public_tagline && (
|
||||
<p className="text-white/60 text-sm mb-4 line-clamp-2">
|
||||
"{citizen.public_tagline}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-white/40 text-xs mb-4">
|
||||
{citizen.district && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>📍</span> {citizen.district}
|
||||
</span>
|
||||
)}
|
||||
{citizen.primary_room_slug && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🚪</span> #{citizen.primary_room_slug}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{citizen.public_skills?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{citizen.public_skills.slice(0, 4).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-0.5 bg-cyan-500/10 text-cyan-400 rounded text-xs"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{citizen.public_skills.length > 4 && (
|
||||
<span className="px-2 py-0.5 text-white/30 text-xs">
|
||||
+{citizen.public_skills.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<span className={`flex items-center gap-1.5 text-xs ${statusColor}`}>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
status === 'online' ? 'bg-emerald-500' : 'bg-white/30'
|
||||
}`}
|
||||
/>
|
||||
{status}
|
||||
</span>
|
||||
<span className="text-cyan-400 text-sm group-hover:translate-x-1 transition-transform">
|
||||
View Profile →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
<Link
|
||||
key={citizen.id}
|
||||
href={`/citizens/${citizen.public_slug}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 hover:border-cyan-500/50 transition-all hover:bg-white/10">
|
||||
{/* Avatar & Name */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-cyan-500/30 to-purple-500/30 flex items-center justify-center text-3xl">
|
||||
{getAgentKindIcon(citizen.kind)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-white group-hover:text-cyan-400 transition-colors">
|
||||
{citizen.display_name}
|
||||
</h3>
|
||||
<p className="text-cyan-400 text-sm">
|
||||
{citizen.public_title || citizen.kind}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
{citizen.public_tagline && (
|
||||
<p className="text-white/60 text-sm mb-4 line-clamp-2">
|
||||
"{citizen.public_tagline}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* District & Room */}
|
||||
<div className="flex items-center gap-4 text-white/40 text-xs mb-4">
|
||||
{citizen.public_district && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>📍</span> {citizen.public_district}
|
||||
</span>
|
||||
)}
|
||||
{citizen.public_primary_room_slug && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🚪</span> #{citizen.public_primary_room_slug}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
{citizen.public_skills && citizen.public_skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{citizen.public_skills.slice(0, 4).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-0.5 bg-cyan-500/10 text-cyan-400 rounded text-xs"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{citizen.public_skills.length > 4 && (
|
||||
<span className="px-2 py-0.5 text-white/30 text-xs">
|
||||
+{citizen.public_skills.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<span className={`flex items-center gap-1.5 text-xs ${
|
||||
citizen.status === 'online' ? 'text-green-400' : 'text-white/40'
|
||||
}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
citizen.status === 'online' ? 'bg-green-500' : 'bg-white/30'
|
||||
}`} />
|
||||
{citizen.status}
|
||||
</span>
|
||||
<span className="text-cyan-400 text-sm group-hover:translate-x-1 transition-transform">
|
||||
View Profile →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{citizens.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-white/40">No public citizens yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function MicrodaoDetailPage() {
|
||||
const matrixChannels = microdao.channels.filter((c) => c.kind === "matrix");
|
||||
const cityRooms = microdao.channels.filter((c) => c.kind === "city_room");
|
||||
const crewChannels = microdao.channels.filter((c) => c.kind === "crew");
|
||||
const publicCitizens = microdao.public_citizens ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||
@@ -156,6 +157,41 @@ export default function MicrodaoDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{publicCitizens.length > 0 && (
|
||||
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Громадяни цього MicroDAO
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{publicCitizens.map((citizen) => (
|
||||
<Link
|
||||
key={citizen.slug}
|
||||
href={`/citizens/${citizen.slug}`}
|
||||
className="flex items-center justify-between border border-white/10 rounded-lg px-4 py-3 hover:border-cyan-500/40 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="text-white font-medium">{citizen.display_name}</p>
|
||||
{citizen.public_title && (
|
||||
<p className="text-sm text-white/60">{citizen.public_title}</p>
|
||||
)}
|
||||
</div>
|
||||
{citizen.district && (
|
||||
<span className="text-xs text-white/50">{citizen.district}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Channels */}
|
||||
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||
|
||||
107
apps/web/src/app/nodes/page.tsx
Normal file
107
apps/web/src/app/nodes/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useNodeDashboard } from '@/hooks/useNodeDashboard';
|
||||
import {
|
||||
NodeSummaryCard,
|
||||
InfraCard,
|
||||
AIServicesCard,
|
||||
AgentsCard,
|
||||
MatrixCard,
|
||||
ModulesCard,
|
||||
NodeStandardComplianceCard
|
||||
} from '@/components/node-dashboard';
|
||||
|
||||
export default function NodeDashboardPage() {
|
||||
const { dashboard, isLoading, error, refresh, lastUpdated } = useNodeDashboard({
|
||||
refreshInterval: 30000 // 30 seconds
|
||||
});
|
||||
|
||||
if (isLoading && !dashboard) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p className="text-white/70">Loading node dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
|
||||
<p className="text-red-400 text-lg mb-2">Failed to load dashboard</p>
|
||||
<p className="text-white/50 mb-4">{error.message}</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dashboard) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Node Dashboard</h1>
|
||||
<p className="text-white/50 text-sm">
|
||||
Real-time monitoring and status
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{lastUpdated && (
|
||||
<p className="text-white/30 text-sm">
|
||||
Updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-cyan-500/20 hover:bg-cyan-500/30 text-cyan-400 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Node Summary */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<NodeSummaryCard node={dashboard.node} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InfraCard infra={dashboard.infra} />
|
||||
<AgentsCard agents={dashboard.agents} />
|
||||
</div>
|
||||
|
||||
<AIServicesCard ai={dashboard.ai} />
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-6">
|
||||
<NodeStandardComplianceCard node={dashboard.node} />
|
||||
<MatrixCard matrix={dashboard.matrix} />
|
||||
<ModulesCard modules={dashboard.node.modules} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user