diff --git a/.gitignore b/.gitignore index 7a736186..ddd6efdc 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,5 @@ Thumbs.db .directory apps/web/node_modules/ apps/web/.next/ +venv_models/ +models/ diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 14716d9d..7462c58e 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -14,6 +14,7 @@ "next": "15.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "swr": "^2.3.6", "tailwind-merge": "^2.5.4" }, "devDependencies": { @@ -2342,6 +2343,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5602,6 +5612,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -6010,6 +6033,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 76b13d0b..33dfe14e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,24 +9,24 @@ "lint": "next lint" }, "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.460.0", "next": "15.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "lucide-react": "^0.460.0", - "clsx": "^2.1.1", - "tailwind-merge": "^2.5.4", - "class-variance-authority": "^0.7.1" + "swr": "^2.3.6", + "tailwind-merge": "^2.5.4" }, "devDependencies": { "@types/node": "^22.10.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "typescript": "^5.7.2", - "tailwindcss": "^3.4.15", - "postcss": "^8.4.49", "autoprefixer": "^10.4.20", "eslint": "^9.15.0", - "eslint-config-next": "15.0.3" + "eslint-config-next": "15.0.3", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.7.2" } } - diff --git a/apps/web/src/app/agents/[agentId]/page.tsx b/apps/web/src/app/agents/[agentId]/page.tsx new file mode 100644 index 00000000..65c1befe --- /dev/null +++ b/apps/web/src/app/agents/[agentId]/page.tsx @@ -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(null); + const [chatLoading, setChatLoading] = useState(false); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [invoking, setInvoking] = useState(false); + const messagesEndRef = useRef(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 ( +
+
+
+
+
+

Loading agent dashboard...

+
+
+
+
+ ); + } + + // Error state + if (dashboardError && activeTab === 'dashboard') { + return ( +
+
+
+

Failed to load agent dashboard

+

{dashboardError.message}

+
+ + + Back to Agents + +
+
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+ + + + + +
+

+ {dashboard?.profile.display_name || agent?.name || agentId} +

+

Agent Cabinet

+
+
+ + {/* Tabs */} +
+ + +
+
+ + {/* Dashboard Tab */} + {activeTab === 'dashboard' && dashboard && ( +
+ +
+ +
+ + +
+
+ {/* System Prompts - Full Width */} + + + {/* Public Profile Settings */} + + + +
+ )} + + {/* Chat Tab */} + {activeTab === 'chat' && ( +
+ {/* Messages */} +
+ {messages.length === 0 && ( +
+

💬

+

Start a conversation with {dashboard?.profile.display_name || agent?.name || agentId}

+
+ )} + {messages.map(msg => ( +
+
+

{msg.content}

+ {msg.meta && ( +
+ {msg.meta.latency_ms && {msg.meta.latency_ms}ms} + {msg.meta.tokens_out && {msg.meta.tokens_out} tokens} +
+ )} +
+
+ ))} + {invoking && ( +
+
+
+
+ Thinking... +
+
+
+ )} +
+
+ + {/* Input */} +
+
+ 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} + /> + +
+
+
+ )} +
+
+ ); +} + diff --git a/apps/web/src/app/agents/[id]/page.tsx b/apps/web/src/app/agents/[id]/page.tsx deleted file mode 100644 index e22573d8..00000000 --- a/apps/web/src/app/agents/[id]/page.tsx +++ /dev/null @@ -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(null) - const [loading, setLoading] = useState(true) - const [messages, setMessages] = useState([]) - const [input, setInput] = useState('') - const [invoking, setInvoking] = useState(false) - const messagesEndRef = useRef(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 ( -
- -
- ) - } - - if (!agent) { - return ( -
-
- -

Агент не знайдений

- - Повернутися до списку - -
-
- ) - } - - return ( -
- {/* Header */} -
-
- - - Назад до агентів - - -
-
- -
-
-

{agent.name}

-

{agent.description || 'AI Agent'}

-
-
- - {agent.is_active ? 'Активний' : 'Неактивний'} - -
-
-
-
- - {/* Chat Area */} -
-
- {/* Messages */} -
- {messages.length === 0 ? ( -
- -

- Почніть розмову з {agent.name} -

-

- Напишіть повідомлення нижче, щоб викликати агента та отримати відповідь. -

-
- ) : ( - <> - {messages.map((message) => ( -
-
- {message.role === 'user' ? ( - U - ) : ( - - )} -
- -
-
- {message.content} -
- - {message.meta && ( -
- {message.meta.latency_ms && ( - - - {message.meta.latency_ms}ms - - )} - {message.meta.tokens_out && ( - {message.meta.tokens_out} tokens - )} -
- )} -
-
- ))} -
- - )} -
- - {/* Input */} -
-
{ - e.preventDefault() - handleInvoke() - }} - className="flex gap-3" - > - 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" - /> - -
-
-
- - {/* Agent Stats */} -
- - - - m.role === 'user').length.toString()} /> -
-
-
- ) -} - -function StatCard({ - icon: Icon, - label, - value -}: { - icon: React.ComponentType<{ className?: string }> - label: string - value: string -}) { - return ( -
- -
{label}
-
{value}
-
- ) -} - diff --git a/apps/web/src/app/api/agents/[agentId]/dashboard/route.ts b/apps/web/src/app/api/agents/[agentId]/dashboard/route.ts new file mode 100644 index 00000000..c3824be0 --- /dev/null +++ b/apps/web/src/app/api/agents/[agentId]/dashboard/route.ts @@ -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 } + ); + } +} + diff --git a/apps/web/src/app/api/agents/[agentId]/microdao-membership/[microdaoId]/route.ts b/apps/web/src/app/api/agents/[agentId]/microdao-membership/[microdaoId]/route.ts new file mode 100644 index 00000000..60b866f6 --- /dev/null +++ b/apps/web/src/app/api/agents/[agentId]/microdao-membership/[microdaoId]/route.ts @@ -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 = {}; + + 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 } + ); + } +} + + diff --git a/apps/web/src/app/api/agents/[agentId]/microdao-membership/route.ts b/apps/web/src/app/api/agents/[agentId]/microdao-membership/route.ts new file mode 100644 index 00000000..68f5b85f --- /dev/null +++ b/apps/web/src/app/api/agents/[agentId]/microdao-membership/route.ts @@ -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 = { + "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 } + ); + } +} + + diff --git a/apps/web/src/app/api/agents/[agentId]/prompts/[kind]/route.ts b/apps/web/src/app/api/agents/[agentId]/prompts/[kind]/route.ts new file mode 100644 index 00000000..48d72d3f --- /dev/null +++ b/apps/web/src/app/api/agents/[agentId]/prompts/[kind]/route.ts @@ -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 } + ); + } +} + diff --git a/apps/web/src/app/api/agents/[agentId]/public-profile/route.ts b/apps/web/src/app/api/agents/[agentId]/public-profile/route.ts new file mode 100644 index 00000000..b5ae7003 --- /dev/null +++ b/apps/web/src/app/api/agents/[agentId]/public-profile/route.ts @@ -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 } + ); + } +} + diff --git a/apps/web/src/app/api/citizens/[slug]/route.ts b/apps/web/src/app/api/citizens/[slug]/route.ts new file mode 100644 index 00000000..2d3de767 --- /dev/null +++ b/apps/web/src/app/api/citizens/[slug]/route.ts @@ -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 } + ); + } +} + diff --git a/apps/web/src/app/api/citizens/route.ts b/apps/web/src/app/api/citizens/route.ts new file mode 100644 index 00000000..67744ffd --- /dev/null +++ b/apps/web/src/app/api/citizens/route.ts @@ -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 } + ); + } +} + diff --git a/apps/web/src/app/api/microdao/options/route.ts b/apps/web/src/app/api/microdao/options/route.ts new file mode 100644 index 00000000..2cbd77e7 --- /dev/null +++ b/apps/web/src/app/api/microdao/options/route.ts @@ -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 } + ); + } +} + + diff --git a/apps/web/src/app/api/node/dashboard/route.ts b/apps/web/src/app/api/node/dashboard/route.ts new file mode 100644 index 00000000..6d5a736c --- /dev/null +++ b/apps/web/src/app/api/node/dashboard/route.ts @@ -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 } + ); + } +} + diff --git a/apps/web/src/app/api/public/citizens/[slug]/ask/route.ts b/apps/web/src/app/api/public/citizens/[slug]/ask/route.ts new file mode 100644 index 00000000..d81f8081 --- /dev/null +++ b/apps/web/src/app/api/public/citizens/[slug]/ask/route.ts @@ -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 } + ); + } +} + + diff --git a/apps/web/src/app/api/public/citizens/[slug]/interaction/route.ts b/apps/web/src/app/api/public/citizens/[slug]/interaction/route.ts new file mode 100644 index 00000000..13d726e8 --- /dev/null +++ b/apps/web/src/app/api/public/citizens/[slug]/interaction/route.ts @@ -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 } + ); + } +} + + diff --git a/apps/web/src/app/api/public/citizens/[slug]/route.ts b/apps/web/src/app/api/public/citizens/[slug]/route.ts new file mode 100644 index 00000000..e588744b --- /dev/null +++ b/apps/web/src/app/api/public/citizens/[slug]/route.ts @@ -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 } + ); + } +} + + diff --git a/apps/web/src/app/api/public/citizens/route.ts b/apps/web/src/app/api/public/citizens/route.ts new file mode 100644 index 00000000..c840f478 --- /dev/null +++ b/apps/web/src/app/api/public/citizens/route.ts @@ -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 } + ); + } +} + + diff --git a/apps/web/src/app/citizens/[slug]/page.tsx b/apps/web/src/app/citizens/[slug]/page.tsx new file mode 100644 index 00000000..7d49a1e7 --- /dev/null +++ b/apps/web/src/app/citizens/[slug]/page.tsx @@ -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; + +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(null); + const [askError, setAskError] = useState(null); + const [asking, setAsking] = useState(false); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (error || !citizen) { + return ( +
+
+
+

{error?.message || 'Citizen not found'}

+ + ← Back to Citizens + +
+
+
+ ); + } + + 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 ( +
+
+ + ← Back to Citizens + + +
+
+
+
+ {getAgentKindIcon(citizen.kind || '')} +
+
+

{citizen.display_name}

+

+ {citizen.public_title || citizen.kind || 'Citizen of DAARION'} +

+
+ + {status} + + {citizen.district && ( + + {citizen.district} District + + )} + {citizen.microdao && ( + + MicroDAO: {citizen.microdao.name} + + )} +
+
+ {citizen.admin_panel_url && ( + + ⚙️ Agent Dashboard + + )} +
+
+
+ {citizen.public_tagline && ( +
+ "{citizen.public_tagline}" +
+ )} + + {citizen.public_skills?.length > 0 && ( +
+

Skills

+
+ {citizen.public_skills.map((skill) => ( + + {skill} + + ))} +
+
+ )} + +
+ {citizen.district && ( +
+

District

+

{citizen.district}

+
+ )} + {citizen.city_presence?.primary_room_slug && ( +
+

Primary Room

+

+ #{citizen.city_presence.primary_room_slug} +

+
+ )} + {citizen.node_id && ( +
+

Node

+

{citizen.node_id}

+
+ )} +
+
+
+ +
+

Взаємодія з громадянином

+ +
+

Чат

+ {interactionLoading ? ( +
Завантаження…
+ ) : interaction?.primary_room_slug ? ( + + Відкрити чат у кімнаті{' '} + {interaction.primary_room_name ?? interaction.primary_room_slug} + + ) : ( +
+ Для цього громадянина ще не налаштована публічна кімната чату. +
+ )} + {interactionError && ( +
+ Не вдалося завантажити інформацію про чат. +
+ )} +
+ +
+

Поставити запитання

+