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:
289
apps/web/src/app/agents/[id]/page.tsx
Normal file
289
apps/web/src/app/agents/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
202
apps/web/src/app/agents/page.tsx
Normal file
202
apps/web/src/app/agents/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
276
apps/web/src/app/governance/[id]/page.tsx
Normal file
276
apps/web/src/app/governance/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
204
apps/web/src/app/governance/page.tsx
Normal file
204
apps/web/src/app/governance/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
98
apps/web/src/components/chat/ChatInput.tsx
Normal file
98
apps/web/src/components/chat/ChatInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
69
apps/web/src/components/chat/ChatMessage.tsx
Normal file
69
apps/web/src/components/chat/ChatMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
162
apps/web/src/components/chat/ChatRoom.tsx
Normal file
162
apps/web/src/components/chat/ChatRoom.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
204
apps/web/src/lib/websocket.ts
Normal file
204
apps/web/src/lib/websocket.ts
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user