feat: Add Chat, Agent Console, and Governance UI

- WebSocket chat client for city rooms
- Chat UI components (ChatMessage, ChatInput, ChatRoom)
- Agent Console with agent list and invoke functionality
- Governance/DAO page with proposals and voting
- Updated navigation with new routes
- Extended API client for agents and microdao
This commit is contained in:
Apple
2025-11-26 09:57:50 -08:00
parent 25b638ae3d
commit 2c4eb7d432
11 changed files with 1618 additions and 47 deletions

View File

@@ -0,0 +1,289 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { ArrowLeft, Bot, Send, Loader2, Zap, Clock, CheckCircle2 } from 'lucide-react'
import { api, Agent, AgentInvokeResponse } from '@/lib/api'
import { cn } from '@/lib/utils'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
meta?: {
tokens_in?: number
tokens_out?: number
latency_ms?: number
}
}
export default function AgentPage() {
const params = useParams()
const agentId = params.id as string
const [agent, setAgent] = useState<Agent | null>(null)
const [loading, setLoading] = useState(true)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [invoking, setInvoking] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
async function loadAgent() {
try {
const data = await api.getAgent(agentId)
setAgent(data)
} catch (error) {
console.error('Failed to load agent:', error)
} finally {
setLoading(false)
}
}
loadAgent()
}, [agentId])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleInvoke = async () => {
if (!input.trim() || invoking) return
const userMessage: Message = {
id: `user_${Date.now()}`,
role: 'user',
content: input.trim(),
timestamp: new Date()
}
setMessages(prev => [...prev, userMessage])
setInput('')
setInvoking(true)
try {
const response: AgentInvokeResponse = await api.invokeAgent(agentId, userMessage.content)
const assistantMessage: Message = {
id: `assistant_${Date.now()}`,
role: 'assistant',
content: response.reply || 'Немає відповіді',
timestamp: new Date(),
meta: {
tokens_in: response.tokens_in,
tokens_out: response.tokens_out,
latency_ms: response.latency_ms
}
}
setMessages(prev => [...prev, assistantMessage])
} catch (error) {
const errorMessage: Message = {
id: `error_${Date.now()}`,
role: 'assistant',
content: `Помилка: ${error instanceof Error ? error.message : 'Невідома помилка'}`,
timestamp: new Date()
}
setMessages(prev => [...prev, errorMessage])
} finally {
setInvoking(false)
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 text-violet-400 animate-spin" />
</div>
)
}
if (!agent) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<Bot className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Агент не знайдений</h2>
<Link href="/agents" className="text-violet-400 hover:text-violet-300">
Повернутися до списку
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen">
{/* Header */}
<div className="px-4 py-6 border-b border-white/5">
<div className="max-w-4xl mx-auto">
<Link
href="/agents"
className="inline-flex items-center gap-2 text-slate-400 hover:text-white transition-colors mb-4"
>
<ArrowLeft className="w-4 h-4" />
Назад до агентів
</Link>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-violet-500/30 to-purple-600/30 flex items-center justify-center">
<Bot className="w-8 h-8 text-violet-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">{agent.name}</h1>
<p className="text-slate-400">{agent.description || 'AI Agent'}</p>
</div>
<div className="ml-auto flex items-center gap-2">
<span className={cn(
'px-3 py-1 rounded-full text-sm',
agent.is_active
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-slate-700/50 text-slate-400'
)}>
{agent.is_active ? 'Активний' : 'Неактивний'}
</span>
</div>
</div>
</div>
</div>
{/* Chat Area */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="glass-panel h-[500px] flex flex-col">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center">
<Zap className="w-12 h-12 text-violet-400 mb-4" />
<h3 className="text-lg font-medium text-white mb-2">
Почніть розмову з {agent.name}
</h3>
<p className="text-sm text-slate-400 max-w-sm">
Напишіть повідомлення нижче, щоб викликати агента та отримати відповідь.
</p>
</div>
) : (
<>
{messages.map((message) => (
<div
key={message.id}
className={cn(
'flex gap-3',
message.role === 'user' && 'flex-row-reverse'
)}
>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
message.role === 'user'
? 'bg-cyan-500/30'
: 'bg-violet-500/30'
)}>
{message.role === 'user' ? (
<span className="text-xs text-cyan-400">U</span>
) : (
<Bot className="w-4 h-4 text-violet-400" />
)}
</div>
<div className={cn(
'max-w-[80%]',
message.role === 'user' && 'text-right'
)}>
<div className={cn(
'inline-block px-4 py-2 rounded-2xl text-sm',
message.role === 'user'
? 'bg-cyan-500/20 text-white rounded-tr-sm'
: 'bg-slate-800/50 text-slate-200 rounded-tl-sm'
)}>
{message.content}
</div>
{message.meta && (
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
{message.meta.latency_ms && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{message.meta.latency_ms}ms
</span>
)}
{message.meta.tokens_out && (
<span>{message.meta.tokens_out} tokens</span>
)}
</div>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input */}
<div className="p-4 border-t border-white/10">
<form
onSubmit={(e) => {
e.preventDefault()
handleInvoke()
}}
className="flex gap-3"
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={`Напишіть повідомлення для ${agent.name}...`}
disabled={invoking}
className="flex-1 px-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50 disabled:opacity-50"
/>
<button
type="submit"
disabled={invoking || !input.trim()}
className={cn(
'px-4 py-3 rounded-xl transition-all',
input.trim() && !invoking
? 'bg-gradient-to-r from-violet-500 to-purple-600 text-white shadow-lg shadow-violet-500/20'
: 'bg-slate-800/50 text-slate-500 cursor-not-allowed'
)}
>
{invoking ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</form>
</div>
</div>
{/* Agent Stats */}
<div className="mt-6 grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard icon={Zap} label="Тип" value={agent.kind} />
<StatCard icon={Bot} label="Модель" value={agent.model || 'N/A'} />
<StatCard icon={CheckCircle2} label="Статус" value={agent.status} />
<StatCard icon={Clock} label="Викликів" value={messages.filter(m => m.role === 'user').length.toString()} />
</div>
</div>
</div>
)
}
function StatCard({
icon: Icon,
label,
value
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: string
}) {
return (
<div className="glass-panel p-4">
<Icon className="w-5 h-5 text-violet-400 mb-2" />
<div className="text-xs text-slate-400 mb-1">{label}</div>
<div className="text-sm font-medium text-white truncate">{value}</div>
</div>
)
}

View File

@@ -0,0 +1,202 @@
import Link from 'next/link'
import { Bot, Zap, Clock, CheckCircle2, XCircle, Sparkles } from 'lucide-react'
import { api, Agent } from '@/lib/api'
import { cn } from '@/lib/utils'
// Force dynamic rendering
export const dynamic = 'force-dynamic'
async function getAgents(): Promise<Agent[]> {
try {
return await api.getAgents()
} catch (error) {
console.error('Failed to fetch agents:', error)
return []
}
}
export default async function AgentsPage() {
const agents = await getAgents()
return (
<div className="min-h-screen px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 rounded-xl bg-gradient-to-br from-violet-500/20 to-purple-600/20">
<Bot className="w-8 h-8 text-violet-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Agent Console</h1>
<p className="text-slate-400">Управління та виклик AI-агентів</p>
</div>
</div>
</div>
{/* Agents Grid */}
{agents.length === 0 ? (
<div className="glass-panel p-12 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>
<p className="text-slate-400">
Наразі немає доступних агентів. Спробуйте пізніше.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
)}
{/* Quick Actions */}
<div className="mt-12">
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<Sparkles className="w-5 h-5 text-violet-400" />
Швидкі дії
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<QuickAction
icon={Zap}
title="Швидкий виклик"
description="Викликати агента одним кліком"
href="/agents/invoke"
/>
<QuickAction
icon={Clock}
title="Історія"
description="Переглянути історію викликів"
href="/agents/history"
/>
<QuickAction
icon={Bot}
title="Створити агента"
description="Налаштувати нового агента"
href="/agents/create"
/>
<QuickAction
icon={Sparkles}
title="Blueprints"
description="Шаблони агентів"
href="/agents/blueprints"
/>
</div>
</div>
</div>
</div>
)
}
function AgentCard({ agent }: { agent: Agent }) {
const isOnline = agent.status === 'active' && agent.is_active
return (
<Link
href={`/agents/${agent.id}`}
className="glass-panel-hover p-6 group block"
>
<div className="flex items-start gap-4">
{/* Avatar */}
<div className={cn(
'w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0',
'bg-gradient-to-br from-violet-500/30 to-purple-600/30'
)}>
{agent.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={agent.avatar_url}
alt={agent.name}
className="w-full h-full rounded-xl object-cover"
/>
) : (
<Bot className="w-7 h-7 text-violet-400" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-white truncate group-hover:text-violet-400 transition-colors">
{agent.name}
</h3>
{isOnline ? (
<CheckCircle2 className="w-4 h-4 text-emerald-400 flex-shrink-0" />
) : (
<XCircle className="w-4 h-4 text-slate-500 flex-shrink-0" />
)}
</div>
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
{agent.description || 'Без опису'}
</p>
<div className="flex items-center gap-3 text-xs">
<span className={cn(
'px-2 py-0.5 rounded-full',
agent.kind === 'text' && 'bg-cyan-500/20 text-cyan-400',
agent.kind === 'multimodal' && 'bg-violet-500/20 text-violet-400',
agent.kind === 'system' && 'bg-amber-500/20 text-amber-400'
)}>
{agent.kind}
</span>
{agent.model && (
<span className="text-slate-500 truncate">
{agent.model}
</span>
)}
</div>
</div>
</div>
{/* Capabilities */}
{agent.capabilities && agent.capabilities.length > 0 && (
<div className="mt-4 pt-4 border-t border-white/5">
<div className="flex flex-wrap gap-1.5">
{agent.capabilities.slice(0, 3).map((cap, i) => (
<span
key={i}
className="px-2 py-0.5 text-xs bg-slate-800/50 text-slate-400 rounded"
>
{cap}
</span>
))}
{agent.capabilities.length > 3 && (
<span className="px-2 py-0.5 text-xs text-slate-500">
+{agent.capabilities.length - 3}
</span>
)}
</div>
</div>
)}
</Link>
)
}
function QuickAction({
icon: Icon,
title,
description,
href
}: {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
href: string
}) {
return (
<Link
href={href}
className="glass-panel p-4 hover:bg-white/5 transition-colors group"
>
<Icon className="w-6 h-6 text-violet-400 mb-3 group-hover:scale-110 transition-transform" />
<h3 className="font-medium text-white mb-1">{title}</h3>
<p className="text-xs text-slate-400">{description}</p>
</Link>
)
}

View File

@@ -1,8 +1,9 @@
import Link from 'next/link'
import { ArrowLeft, Users, MessageSquare, FileText, Clock, Send } from 'lucide-react'
import { ArrowLeft, Users, FileText, Clock } from 'lucide-react'
import { api, CityRoom } from '@/lib/api'
import { formatDate } from '@/lib/utils'
import { notFound } from 'next/navigation'
import { ChatRoom } from '@/components/chat/ChatRoom'
// Force dynamic rendering - don't prerender at build time
export const dynamic = 'force-dynamic'
@@ -71,48 +72,12 @@ export default async function RoomPage({ params }: PageProps) {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Chat Area */}
<div className="lg:col-span-2">
<div className="glass-panel h-[500px] sm:h-[600px] flex flex-col">
{/* Chat Header */}
<div className="px-6 py-4 border-b border-white/10">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-cyan-400" />
<span className="font-medium text-white">Чат кімнати</span>
</div>
</div>
{/* Messages Placeholder */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-800/50 flex items-center justify-center">
<MessageSquare className="w-8 h-8 text-slate-600" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
Чат скоро буде доступний
</h3>
<p className="text-sm text-slate-400 max-w-sm">
Matrix/WebSocket інтеграція буде додана в наступному оновленні.
Поки що ви можете переглядати інформацію про кімнату.
</p>
</div>
</div>
{/* Input Placeholder */}
<div className="px-4 py-4 border-t border-white/10">
<div className="flex gap-3">
<input
type="text"
placeholder="Напишіть повідомлення..."
disabled
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-cyan-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
disabled
className="px-4 py-3 bg-cyan-500/20 text-cyan-400 rounded-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="w-5 h-5" />
</button>
</div>
</div>
<div className="glass-panel h-[500px] sm:h-[600px] flex flex-col overflow-hidden">
<ChatRoom
roomId={room.id}
roomSlug={room.slug}
initialMessages={[]}
/>
</div>
</div>
@@ -202,4 +167,3 @@ function InfoRow({ label, value }: { label: string; value: string }) {
</div>
)
}

View File

@@ -0,0 +1,276 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { ArrowLeft, Shield, Users, Vote, FileText, Loader2, CheckCircle2, XCircle, Clock, ThumbsUp, ThumbsDown, Minus } from 'lucide-react'
import { api, MicroDAO, Proposal } from '@/lib/api'
import { cn, formatDate } from '@/lib/utils'
export default function DAOPage() {
const params = useParams()
const daoId = params.id as string
const [dao, setDAO] = useState<MicroDAO | null>(null)
const [proposals, setProposals] = useState<Proposal[]>([])
const [loading, setLoading] = useState(true)
const [voting, setVoting] = useState<string | null>(null)
useEffect(() => {
async function loadData() {
try {
const [daoData, proposalsData] = await Promise.all([
api.getMicroDAO(daoId),
api.getProposals(daoId)
])
setDAO(daoData)
setProposals(proposalsData)
} catch (error) {
console.error('Failed to load DAO:', error)
} finally {
setLoading(false)
}
}
loadData()
}, [daoId])
const handleVote = async (proposalId: string, choice: 'yes' | 'no' | 'abstain') => {
setVoting(proposalId)
try {
await api.vote(proposalId, choice)
// Reload proposals to get updated vote counts
const updated = await api.getProposals(daoId)
setProposals(updated)
} catch (error) {
console.error('Failed to vote:', error)
} finally {
setVoting(null)
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 text-amber-400 animate-spin" />
</div>
)
}
if (!dao) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<Shield className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">MicroDAO не знайдено</h2>
<Link href="/governance" className="text-amber-400 hover:text-amber-300">
Повернутися до списку
</Link>
</div>
</div>
)
}
const activeProposals = proposals.filter(p => p.status === 'open')
const closedProposals = proposals.filter(p => p.status !== 'open')
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="/governance"
className="inline-flex items-center gap-2 text-slate-400 hover:text-white transition-colors mb-4"
>
<ArrowLeft className="w-4 h-4" />
Назад до Governance
</Link>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-amber-500/30 to-orange-600/30 flex items-center justify-center">
<Shield className="w-8 h-8 text-amber-400" />
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-white">{dao.name}</h1>
<p className="text-slate-400">{dao.description || dao.slug}</p>
</div>
<span className={cn(
'px-3 py-1 rounded-full text-sm',
dao.is_active
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-slate-700/50 text-slate-400'
)}>
{dao.is_active ? 'Активний' : 'Неактивний'}
</span>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-4xl mx-auto px-4 py-6">
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="glass-panel p-4 text-center">
<Users className="w-6 h-6 text-amber-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">0</div>
<div className="text-xs text-slate-400">Учасників</div>
</div>
<div className="glass-panel p-4 text-center">
<Vote className="w-6 h-6 text-cyan-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{activeProposals.length}</div>
<div className="text-xs text-slate-400">Активних</div>
</div>
<div className="glass-panel p-4 text-center">
<FileText className="w-6 h-6 text-violet-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{proposals.length}</div>
<div className="text-xs text-slate-400">Всього</div>
</div>
</div>
{/* Active Proposals */}
<div className="mb-8">
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<Vote className="w-5 h-5 text-cyan-400" />
Активні пропозиції
</h2>
{activeProposals.length === 0 ? (
<div className="glass-panel p-8 text-center">
<Vote className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<p className="text-slate-400">Немає активних пропозицій для голосування</p>
</div>
) : (
<div className="space-y-4">
{activeProposals.map((proposal) => (
<ProposalCard
key={proposal.id}
proposal={proposal}
onVote={handleVote}
voting={voting === proposal.id}
/>
))}
</div>
)}
</div>
{/* Closed Proposals */}
{closedProposals.length > 0 && (
<div>
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-slate-400" />
Завершені пропозиції
</h2>
<div className="space-y-4">
{closedProposals.map((proposal) => (
<ProposalCard
key={proposal.id}
proposal={proposal}
onVote={handleVote}
voting={false}
readonly
/>
))}
</div>
</div>
)}
</div>
</div>
)
}
function ProposalCard({
proposal,
onVote,
voting,
readonly = false
}: {
proposal: Proposal
onVote: (id: string, choice: 'yes' | 'no' | 'abstain') => void
voting: boolean
readonly?: boolean
}) {
const totalVotes = proposal.votes_yes + proposal.votes_no + proposal.votes_abstain
const yesPercent = totalVotes > 0 ? (proposal.votes_yes / totalVotes) * 100 : 0
const noPercent = totalVotes > 0 ? (proposal.votes_no / totalVotes) * 100 : 0
const statusColors = {
draft: 'bg-slate-500/20 text-slate-400',
open: 'bg-cyan-500/20 text-cyan-400',
accepted: 'bg-emerald-500/20 text-emerald-400',
rejected: 'bg-red-500/20 text-red-400',
expired: 'bg-amber-500/20 text-amber-400'
}
return (
<div className="glass-panel p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-white mb-1">{proposal.title}</h3>
<p className="text-sm text-slate-400">{proposal.description || 'Без опису'}</p>
</div>
<span className={cn('px-2 py-0.5 text-xs rounded-full', statusColors[proposal.status])}>
{proposal.status}
</span>
</div>
{/* Vote Progress */}
<div className="mb-4">
<div className="flex justify-between text-xs text-slate-400 mb-1">
<span>За: {proposal.votes_yes}</span>
<span>Проти: {proposal.votes_no}</span>
</div>
<div className="h-2 bg-slate-800 rounded-full overflow-hidden flex">
<div
className="bg-emerald-500 transition-all"
style={{ width: `${yesPercent}%` }}
/>
<div
className="bg-red-500 transition-all"
style={{ width: `${noPercent}%` }}
/>
</div>
<div className="text-xs text-slate-500 mt-1">
Всього голосів: {totalVotes}
</div>
</div>
{/* Vote Buttons */}
{!readonly && proposal.status === 'open' && (
<div className="flex gap-2 pt-4 border-t border-white/5">
<button
onClick={() => onVote(proposal.id, 'yes')}
disabled={voting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-emerald-500/20 text-emerald-400 rounded-xl hover:bg-emerald-500/30 transition-colors disabled:opacity-50"
>
{voting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ThumbsUp className="w-4 h-4" />}
За
</button>
<button
onClick={() => onVote(proposal.id, 'no')}
disabled={voting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-red-500/20 text-red-400 rounded-xl hover:bg-red-500/30 transition-colors disabled:opacity-50"
>
{voting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ThumbsDown className="w-4 h-4" />}
Проти
</button>
<button
onClick={() => onVote(proposal.id, 'abstain')}
disabled={voting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-slate-700/50 text-slate-400 rounded-xl hover:bg-slate-700 transition-colors disabled:opacity-50"
>
{voting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Minus className="w-4 h-4" />}
Утримався
</button>
</div>
)}
{/* Meta */}
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-white/5 text-xs text-slate-500">
<span>Створено: {formatDate(proposal.created_at)}</span>
{proposal.closes_at && <span>Закінчується: {formatDate(proposal.closes_at)}</span>}
</div>
</div>
)
}

View File

@@ -0,0 +1,204 @@
import Link from 'next/link'
import { Wallet, Users, Vote, FileText, TrendingUp, Shield, ArrowRight } from 'lucide-react'
import { api, MicroDAO } from '@/lib/api'
import { cn } from '@/lib/utils'
// Force dynamic rendering
export const dynamic = 'force-dynamic'
async function getMicroDAOs(): Promise<MicroDAO[]> {
try {
return await api.getMicroDAOs()
} catch (error) {
console.error('Failed to fetch MicroDAOs:', error)
return []
}
}
export default async function GovernancePage() {
const daos = await getMicroDAOs()
return (
<div className="min-h-screen px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/20">
<Wallet className="w-8 h-8 text-amber-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Governance</h1>
<p className="text-slate-400">MicroDAO управління та голосування</p>
</div>
</div>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<StatCard icon={Users} label="MicroDAOs" value={daos.length.toString()} color="amber" />
<StatCard icon={Vote} label="Активних пропозицій" value="0" color="cyan" />
<StatCard icon={FileText} label="Всього пропозицій" value="0" color="violet" />
<StatCard icon={TrendingUp} label="Участь" value="0%" color="emerald" />
</div>
{/* MicroDAOs List */}
<div className="mb-12">
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-400" />
Ваші MicroDAO
</h2>
{daos.length === 0 ? (
<div className="glass-panel p-12 text-center">
<Wallet className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">
MicroDAO не знайдено
</h3>
<p className="text-slate-400 mb-6">
Ви ще не є учасником жодного MicroDAO.
</p>
<button className="px-6 py-3 bg-gradient-to-r from-amber-500 to-orange-600 rounded-xl font-medium text-white hover:from-amber-400 hover:to-orange-500 transition-all">
Створити MicroDAO
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{daos.map((dao) => (
<DAOCard key={dao.id} dao={dao} />
))}
</div>
)}
</div>
{/* Quick Actions */}
<div>
<h2 className="text-xl font-semibold text-white mb-4">Швидкі дії</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<ActionCard
icon={Vote}
title="Голосувати"
description="Переглянути активні пропозиції та проголосувати"
href="/governance/proposals"
/>
<ActionCard
icon={FileText}
title="Створити пропозицію"
description="Запропонувати зміни для вашого MicroDAO"
href="/governance/create-proposal"
/>
<ActionCard
icon={Users}
title="Учасники"
description="Переглянути членів та їх ролі"
href="/governance/members"
/>
</div>
</div>
</div>
</div>
)
}
function StatCard({
icon: Icon,
label,
value,
color
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: string
color: 'amber' | 'cyan' | 'violet' | 'emerald'
}) {
const colorClasses = {
amber: 'text-amber-400',
cyan: 'text-cyan-400',
violet: 'text-violet-400',
emerald: 'text-emerald-400'
}
return (
<div className="glass-panel p-4">
<Icon className={cn('w-5 h-5 mb-2', colorClasses[color])} />
<div className="text-2xl font-bold text-white">{value}</div>
<div className="text-xs text-slate-400">{label}</div>
</div>
)
}
function DAOCard({ dao }: { dao: MicroDAO }) {
return (
<Link
href={`/governance/${dao.id}`}
className="glass-panel-hover p-6 group block"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500/30 to-orange-600/30 flex items-center justify-center">
<Shield className="w-6 h-6 text-amber-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white group-hover:text-amber-400 transition-colors">
{dao.name}
</h3>
<p className="text-sm text-slate-400">{dao.slug}</p>
</div>
</div>
<span className={cn(
'px-2 py-0.5 text-xs rounded-full',
dao.is_active
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-slate-700/50 text-slate-400'
)}>
{dao.is_active ? 'Активний' : 'Неактивний'}
</span>
</div>
<p className="text-sm text-slate-400 mb-4 line-clamp-2">
{dao.description || 'Без опису'}
</p>
<div className="flex items-center justify-between pt-4 border-t border-white/5">
<div className="flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
0 учасників
</span>
<span className="flex items-center gap-1">
<Vote className="w-3 h-3" />
0 пропозицій
</span>
</div>
<ArrowRight className="w-5 h-5 text-slate-500 group-hover:text-amber-400 group-hover:translate-x-1 transition-all" />
</div>
</Link>
)
}
function ActionCard({
icon: Icon,
title,
description,
href
}: {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
href: string
}) {
return (
<Link
href={href}
className="glass-panel p-5 hover:bg-white/5 transition-colors group"
>
<Icon className="w-8 h-8 text-amber-400 mb-3 group-hover:scale-110 transition-transform" />
<h3 className="font-semibold text-white mb-1">{title}</h3>
<p className="text-sm text-slate-400">{description}</p>
</Link>
)
}

View File

@@ -3,12 +3,14 @@
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Menu, X, Home, Building2, User, Sparkles } from 'lucide-react'
import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet } from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ href: '/', label: 'Головна', icon: Home },
{ href: '/city', label: 'Місто', icon: Building2 },
{ href: '/agents', label: 'Агенти', icon: Bot },
{ href: '/governance', label: 'DAO', icon: Wallet },
{ href: '/secondme', label: 'Second Me', icon: User },
]

View File

@@ -0,0 +1,98 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Send, Smile, Paperclip } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ChatInputProps {
onSend: (message: string) => void
disabled?: boolean
placeholder?: string
}
export function ChatInput({ onSend, disabled = false, placeholder = 'Напишіть повідомлення...' }: ChatInputProps) {
const [message, setMessage] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (message.trim() && !disabled) {
onSend(message.trim())
setMessage('')
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
// Auto-resize textarea
useEffect(() => {
if (inputRef.current) {
inputRef.current.style.height = 'auto'
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 120)}px`
}
}, [message])
return (
<form onSubmit={handleSubmit} className="px-4 py-3 border-t border-white/10">
<div className="flex items-end gap-2">
{/* Attachment button */}
<button
type="button"
disabled={disabled}
className="p-2 text-slate-400 hover:text-white transition-colors disabled:opacity-50"
>
<Paperclip className="w-5 h-5" />
</button>
{/* Input field */}
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
'w-full px-4 py-3 pr-10 bg-slate-800/50 border border-white/10 rounded-2xl',
'text-white placeholder-slate-500 resize-none',
'focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20',
'disabled:opacity-50 disabled:cursor-not-allowed',
'transition-all duration-200'
)}
/>
{/* Emoji button */}
<button
type="button"
disabled={disabled}
className="absolute right-3 bottom-3 text-slate-400 hover:text-white transition-colors disabled:opacity-50"
>
<Smile className="w-5 h-5" />
</button>
</div>
{/* Send button */}
<button
type="submit"
disabled={disabled || !message.trim()}
className={cn(
'p-3 rounded-xl transition-all duration-200',
message.trim() && !disabled
? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/20 hover:shadow-cyan-500/40'
: 'bg-slate-800/50 text-slate-500 cursor-not-allowed'
)}
>
<Send className="w-5 h-5" />
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { Bot, User } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ChatMessage as ChatMessageType } from '@/lib/websocket'
interface ChatMessageProps {
message: ChatMessageType
isOwn?: boolean
}
export function ChatMessage({ message, isOwn = false }: ChatMessageProps) {
const isAgent = !!message.author_agent_id
const authorName = isAgent
? message.author_agent_id?.replace('ag_', '') || 'Agent'
: message.author_user_id?.replace('u_', '') || 'User'
const time = new Date(message.created_at).toLocaleTimeString('uk-UA', {
hour: '2-digit',
minute: '2-digit'
})
return (
<div className={cn(
'flex gap-3 px-4 py-2 hover:bg-white/5 transition-colors',
isOwn && 'flex-row-reverse'
)}>
{/* Avatar */}
<div className={cn(
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
isAgent
? 'bg-gradient-to-br from-violet-500/30 to-purple-600/30'
: 'bg-gradient-to-br from-cyan-500/30 to-blue-600/30'
)}>
{isAgent ? (
<Bot className="w-4 h-4 text-violet-400" />
) : (
<User className="w-4 h-4 text-cyan-400" />
)}
</div>
{/* Content */}
<div className={cn('flex-1 min-w-0', isOwn && 'text-right')}>
<div className={cn(
'flex items-baseline gap-2 mb-1',
isOwn && 'flex-row-reverse'
)}>
<span className={cn(
'text-sm font-medium',
isAgent ? 'text-violet-400' : 'text-cyan-400'
)}>
{authorName}
</span>
<span className="text-xs text-slate-500">{time}</span>
</div>
<div className={cn(
'inline-block max-w-[80%] px-3 py-2 rounded-2xl text-sm',
isOwn
? 'bg-cyan-500/20 text-white rounded-tr-sm'
: 'bg-slate-800/50 text-slate-200 rounded-tl-sm'
)}>
{message.body}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { MessageSquare, Wifi, WifiOff, Loader2 } from 'lucide-react'
import { ChatMessage } from './ChatMessage'
import { ChatInput } from './ChatInput'
import { getWSClient, type ChatMessage as ChatMessageType } from '@/lib/websocket'
import { cn } from '@/lib/utils'
interface ChatRoomProps {
roomId: string
roomSlug: string
initialMessages?: ChatMessageType[]
}
export function ChatRoom({ roomId, roomSlug, initialMessages = [] }: ChatRoomProps) {
const [messages, setMessages] = useState<ChatMessageType[]>(initialMessages)
const [isConnected, setIsConnected] = useState(false)
const [isConnecting, setIsConnecting] = useState(true)
const messagesEndRef = useRef<HTMLDivElement>(null)
const wsClient = useRef(getWSClient())
// Scroll to bottom when new messages arrive
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
useEffect(() => {
scrollToBottom()
}, [messages, scrollToBottom])
// Connect to WebSocket
useEffect(() => {
const client = wsClient.current
// Handle new messages
const unsubMessage = client.onMessage((message) => {
setMessages(prev => [...prev, message])
})
// Handle connection events
const unsubEvent = client.onEvent((event) => {
if (event.event === 'room.join' || event.event === 'connected') {
setIsConnected(true)
setIsConnecting(false)
} else if (event.event === 'room.leave' || event.event === 'disconnected') {
setIsConnected(false)
}
})
// Connect to room
client.connect(roomSlug)
// Check connection status periodically
const checkConnection = setInterval(() => {
setIsConnected(client.isConnected)
if (client.isConnected) {
setIsConnecting(false)
}
}, 1000)
// Timeout for initial connection
const connectionTimeout = setTimeout(() => {
setIsConnecting(false)
}, 5000)
return () => {
unsubMessage()
unsubEvent()
clearInterval(checkConnection)
clearTimeout(connectionTimeout)
client.disconnect()
}
}, [roomSlug])
const handleSendMessage = (body: string) => {
wsClient.current.sendMessage(body)
// Optimistically add message (will be replaced by server response)
const tempMessage: ChatMessageType = {
id: `temp_${Date.now()}`,
room_id: roomId,
author_user_id: 'u_current_user', // TODO: Get from auth
author_agent_id: null,
body,
created_at: new Date().toISOString()
}
setMessages(prev => [...prev, tempMessage])
}
return (
<div className="flex flex-col h-full">
{/* Connection status */}
<div className="px-4 py-2 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-medium text-white">Чат кімнати</span>
</div>
<div className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-full text-xs',
isConnecting && 'bg-amber-500/20 text-amber-400',
isConnected && 'bg-emerald-500/20 text-emerald-400',
!isConnected && !isConnecting && 'bg-red-500/20 text-red-400'
)}>
{isConnecting ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span>Підключення...</span>
</>
) : isConnected ? (
<>
<Wifi className="w-3 h-3" />
<span>Онлайн</span>
</>
) : (
<>
<WifiOff className="w-3 h-3" />
<span>Офлайн</span>
</>
)}
</div>
</div>
{/* Messages area */}
<div className="flex-1 overflow-y-auto py-4 space-y-1">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-16 h-16 mb-4 rounded-full bg-slate-800/50 flex items-center justify-center">
<MessageSquare className="w-8 h-8 text-slate-600" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
Поки що немає повідомлень
</h3>
<p className="text-sm text-slate-400 max-w-sm">
Будьте першим, хто напише в цій кімнаті! Ваше повідомлення побачать всі учасники.
</p>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
isOwn={message.author_user_id === 'u_current_user'} // TODO: Get from auth
/>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input area */}
<ChatInput
onSend={handleSendMessage}
disabled={!isConnected}
placeholder={isConnected ? 'Напишіть повідомлення...' : 'Очікування підключення...'}
/>
</div>
)
}

View File

@@ -39,6 +39,64 @@ export interface ApiError {
detail: string
}
export interface Agent {
id: string
external_id?: string
name: string
description?: string
kind: string
model?: string
status: string
is_active: boolean
avatar_url?: string
capabilities?: string[]
created_at: string
updated_at: string
}
export interface AgentInvokeResponse {
status: string
reply?: string
agent_id: string
tokens_in?: number
tokens_out?: number
latency_ms?: number
}
export interface MicroDAO {
id: string
external_id?: string
slug: string
name: string
description?: string
owner_user_id?: string
is_active: boolean
created_at: string
updated_at: string
}
export interface Proposal {
id: string
microdao_id: string
title: string
description?: string
creator_id: string
creator_type: 'user' | 'agent'
status: 'draft' | 'open' | 'accepted' | 'rejected' | 'expired'
created_at: string
opens_at?: string
closes_at?: string
votes_yes: number
votes_no: number
votes_abstain: number
}
export interface VoteResponse {
status: string
proposal_id: string
choice: string
}
class ApiClient {
private baseUrl: string
@@ -91,8 +149,51 @@ class ApiClient {
}
// Agents
async getAgents(): Promise<unknown[]> {
return this.fetch<unknown[]>('/api/agents/')
async getAgents(): Promise<Agent[]> {
return this.fetch<Agent[]>('/api/agents/agents')
}
async getAgent(agentId: string): Promise<Agent | null> {
try {
return await this.fetch<Agent>(`/api/agents/agents/${agentId}`)
} catch {
return null
}
}
async invokeAgent(agentId: string, input: string, context?: Record<string, unknown>): Promise<AgentInvokeResponse> {
return this.fetch<AgentInvokeResponse>('/api/agents/invoke', {
method: 'POST',
body: JSON.stringify({
agent_id: agentId,
input,
context: context || {}
})
})
}
// MicroDAO
async getMicroDAOs(): Promise<MicroDAO[]> {
return this.fetch<MicroDAO[]>('/api/microdao/microdaos')
}
async getMicroDAO(id: string): Promise<MicroDAO | null> {
try {
return await this.fetch<MicroDAO>(`/api/microdao/microdaos/${id}`)
} catch {
return null
}
}
async getProposals(microdaoId: string): Promise<Proposal[]> {
return this.fetch<Proposal[]>(`/api/microdao/microdaos/${microdaoId}/proposals`)
}
async vote(proposalId: string, choice: 'yes' | 'no' | 'abstain'): Promise<VoteResponse> {
return this.fetch<VoteResponse>(`/api/microdao/proposals/${proposalId}/vote`, {
method: 'POST',
body: JSON.stringify({ choice })
})
}
}

View File

@@ -0,0 +1,204 @@
'use client'
/**
* WebSocket client for DAARION City Rooms chat
*/
export interface ChatMessage {
id: string
room_id: string
author_user_id: string | null
author_agent_id: string | null
body: string
created_at: string
}
export interface WSEvent {
event: string
room_id?: string
user_id?: string
message?: ChatMessage
status?: string
data?: unknown
}
type MessageHandler = (message: ChatMessage) => void
type EventHandler = (event: WSEvent) => void
class WebSocketClient {
private ws: WebSocket | null = null
private url: string
private roomId: string | null = null
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectDelay = 1000
private messageHandlers: Set<MessageHandler> = new Set()
private eventHandlers: Set<EventHandler> = new Set()
private heartbeatInterval: NodeJS.Timeout | null = null
constructor() {
// Use wss:// for production, ws:// for development
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:3000'
this.url = `${protocol}//${host}/ws/city/rooms`
}
connect(roomId: string): void {
if (this.ws?.readyState === WebSocket.OPEN && this.roomId === roomId) {
return // Already connected to this room
}
this.roomId = roomId
this.disconnect() // Close existing connection
try {
this.ws = new WebSocket(`${this.url}/${roomId}`)
this.ws.onopen = () => {
console.log(`[WS] Connected to room: ${roomId}`)
this.reconnectAttempts = 0
this.startHeartbeat()
// Join room
this.send({
event: 'room.join',
room_id: roomId
})
}
this.ws.onmessage = (event) => {
try {
const data: WSEvent = JSON.parse(event.data)
this.handleEvent(data)
} catch (err) {
console.error('[WS] Failed to parse message:', err)
}
}
this.ws.onclose = (event) => {
console.log(`[WS] Disconnected: ${event.code} ${event.reason}`)
this.stopHeartbeat()
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
}
this.ws.onerror = (error) => {
console.error('[WS] Error:', error)
}
} catch (err) {
console.error('[WS] Failed to connect:', err)
}
}
disconnect(): void {
if (this.ws) {
this.stopHeartbeat()
if (this.roomId) {
this.send({
event: 'room.leave',
room_id: this.roomId
})
}
this.ws.close()
this.ws = null
}
}
send(data: WSEvent): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data))
}
}
sendMessage(body: string): void {
if (!this.roomId) return
this.send({
event: 'room.message.send',
room_id: this.roomId,
data: { body }
})
}
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler)
return () => this.messageHandlers.delete(handler)
}
onEvent(handler: EventHandler): () => void {
this.eventHandlers.add(handler)
return () => this.eventHandlers.delete(handler)
}
private handleEvent(event: WSEvent): void {
// Notify all event handlers
this.eventHandlers.forEach(handler => handler(event))
// Handle specific events
switch (event.event) {
case 'room.message':
if (event.message) {
this.messageHandlers.forEach(handler => handler(event.message!))
}
break
case 'room.join':
console.log(`[WS] User joined: ${event.user_id}`)
break
case 'room.leave':
console.log(`[WS] User left: ${event.user_id}`)
break
case 'presence.update':
console.log(`[WS] Presence update: ${event.user_id} is ${event.status}`)
break
}
}
private startHeartbeat(): void {
this.heartbeatInterval = setInterval(() => {
this.send({ event: 'ping' })
}, 30000)
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
}
private scheduleReconnect(): void {
this.reconnectAttempts++
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
setTimeout(() => {
if (this.roomId) {
this.connect(this.roomId)
}
}, delay)
}
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN
}
get currentRoom(): string | null {
return this.roomId
}
}
// Singleton instance
let wsClient: WebSocketClient | null = null
export function getWSClient(): WebSocketClient {
if (!wsClient) {
wsClient = new WebSocketClient()
}
return wsClient
}