diff --git a/apps/web/src/app/agents/[id]/page.tsx b/apps/web/src/app/agents/[id]/page.tsx new file mode 100644 index 00000000..e22573d8 --- /dev/null +++ b/apps/web/src/app/agents/[id]/page.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [invoking, setInvoking] = useState(false) + const messagesEndRef = useRef(null) + + useEffect(() => { + async function loadAgent() { + try { + const data = await api.getAgent(agentId) + setAgent(data) + } catch (error) { + console.error('Failed to load agent:', error) + } finally { + setLoading(false) + } + } + loadAgent() + }, [agentId]) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const handleInvoke = async () => { + if (!input.trim() || invoking) return + + const userMessage: Message = { + id: `user_${Date.now()}`, + role: 'user', + content: input.trim(), + timestamp: new Date() + } + + setMessages(prev => [...prev, userMessage]) + setInput('') + setInvoking(true) + + try { + const response: AgentInvokeResponse = await api.invokeAgent(agentId, userMessage.content) + + const assistantMessage: Message = { + id: `assistant_${Date.now()}`, + role: 'assistant', + content: response.reply || 'Немає відповіді', + timestamp: new Date(), + meta: { + tokens_in: response.tokens_in, + tokens_out: response.tokens_out, + latency_ms: response.latency_ms + } + } + + setMessages(prev => [...prev, assistantMessage]) + } catch (error) { + const errorMessage: Message = { + id: `error_${Date.now()}`, + role: 'assistant', + content: `Помилка: ${error instanceof Error ? error.message : 'Невідома помилка'}`, + timestamp: new Date() + } + setMessages(prev => [...prev, errorMessage]) + } finally { + setInvoking(false) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!agent) { + return ( +
+
+ +

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

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

{agent.name}

+

{agent.description || 'AI Agent'}

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

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

+

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

+
+ ) : ( + <> + {messages.map((message) => ( +
+
+ {message.role === 'user' ? ( + U + ) : ( + + )} +
+ +
+
+ {message.content} +
+ + {message.meta && ( +
+ {message.meta.latency_ms && ( + + + {message.meta.latency_ms}ms + + )} + {message.meta.tokens_out && ( + {message.meta.tokens_out} tokens + )} +
+ )} +
+
+ ))} +
+ + )} +
+ + {/* Input */} +
+
{ + e.preventDefault() + handleInvoke() + }} + className="flex gap-3" + > + setInput(e.target.value)} + placeholder={`Напишіть повідомлення для ${agent.name}...`} + disabled={invoking} + className="flex-1 px-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50 disabled:opacity-50" + /> + +
+
+
+ + {/* Agent Stats */} +
+ + + + m.role === 'user').length.toString()} /> +
+
+
+ ) +} + +function StatCard({ + icon: Icon, + label, + value +}: { + icon: React.ComponentType<{ className?: string }> + label: string + value: string +}) { + return ( +
+ +
{label}
+
{value}
+
+ ) +} + diff --git a/apps/web/src/app/agents/page.tsx b/apps/web/src/app/agents/page.tsx new file mode 100644 index 00000000..992ab7ac --- /dev/null +++ b/apps/web/src/app/agents/page.tsx @@ -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 { + 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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Agent Console

+

Управління та виклик AI-агентів

+
+
+
+ + {/* Agents Grid */} + {agents.length === 0 ? ( +
+ +

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

+

+ Наразі немає доступних агентів. Спробуйте пізніше. +

+
+ ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )} + + {/* Quick Actions */} +
+

+ + Швидкі дії +

+ +
+ + + + +
+
+
+
+ ) +} + +function AgentCard({ agent }: { agent: Agent }) { + const isOnline = agent.status === 'active' && agent.is_active + + return ( + +
+ {/* Avatar */} +
+ {agent.avatar_url ? ( + // eslint-disable-next-line @next/next/no-img-element + {agent.name} + ) : ( + + )} +
+ + {/* Info */} +
+
+

+ {agent.name} +

+ {isOnline ? ( + + ) : ( + + )} +
+ +

+ {agent.description || 'Без опису'} +

+ +
+ + {agent.kind} + + {agent.model && ( + + {agent.model} + + )} +
+
+
+ + {/* Capabilities */} + {agent.capabilities && agent.capabilities.length > 0 && ( +
+
+ {agent.capabilities.slice(0, 3).map((cap, i) => ( + + {cap} + + ))} + {agent.capabilities.length > 3 && ( + + +{agent.capabilities.length - 3} + + )} +
+
+ )} + + ) +} + +function QuickAction({ + icon: Icon, + title, + description, + href +}: { + icon: React.ComponentType<{ className?: string }> + title: string + description: string + href: string +}) { + return ( + + +

{title}

+

{description}

+ + ) +} + diff --git a/apps/web/src/app/city/[slug]/page.tsx b/apps/web/src/app/city/[slug]/page.tsx index 1574efb2..cff9618e 100644 --- a/apps/web/src/app/city/[slug]/page.tsx +++ b/apps/web/src/app/city/[slug]/page.tsx @@ -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) {
{/* Chat Area */}
-
- {/* Chat Header */} -
-
- - Чат кімнати -
-
- - {/* Messages Placeholder */} -
-
-
- -
-

- Чат скоро буде доступний -

-

- Matrix/WebSocket інтеграція буде додана в наступному оновленні. - Поки що ви можете переглядати інформацію про кімнату. -

-
-
- - {/* Input Placeholder */} -
-
- - -
-
+
+
@@ -202,4 +167,3 @@ function InfoRow({ label, value }: { label: string; value: string }) {
) } - diff --git a/apps/web/src/app/governance/[id]/page.tsx b/apps/web/src/app/governance/[id]/page.tsx new file mode 100644 index 00000000..3dbe5b4a --- /dev/null +++ b/apps/web/src/app/governance/[id]/page.tsx @@ -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(null) + const [proposals, setProposals] = useState([]) + const [loading, setLoading] = useState(true) + const [voting, setVoting] = useState(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 ( +
+ +
+ ) + } + + if (!dao) { + return ( +
+
+ +

MicroDAO не знайдено

+ + Повернутися до списку + +
+
+ ) + } + + const activeProposals = proposals.filter(p => p.status === 'open') + const closedProposals = proposals.filter(p => p.status !== 'open') + + return ( +
+ {/* Header */} +
+
+ + + Назад до Governance + + +
+
+ +
+
+

{dao.name}

+

{dao.description || dao.slug}

+
+ + {dao.is_active ? 'Активний' : 'Неактивний'} + +
+
+
+ + {/* Content */} +
+ {/* Stats */} +
+
+ +
0
+
Учасників
+
+
+ +
{activeProposals.length}
+
Активних
+
+
+ +
{proposals.length}
+
Всього
+
+
+ + {/* Active Proposals */} +
+

+ + Активні пропозиції +

+ + {activeProposals.length === 0 ? ( +
+ +

Немає активних пропозицій для голосування

+
+ ) : ( +
+ {activeProposals.map((proposal) => ( + + ))} +
+ )} +
+ + {/* Closed Proposals */} + {closedProposals.length > 0 && ( +
+

+ + Завершені пропозиції +

+ +
+ {closedProposals.map((proposal) => ( + + ))} +
+
+ )} +
+
+ ) +} + +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 ( +
+
+
+

{proposal.title}

+

{proposal.description || 'Без опису'}

+
+ + {proposal.status} + +
+ + {/* Vote Progress */} +
+
+ За: {proposal.votes_yes} + Проти: {proposal.votes_no} +
+
+
+
+
+
+ Всього голосів: {totalVotes} +
+
+ + {/* Vote Buttons */} + {!readonly && proposal.status === 'open' && ( +
+ + + +
+ )} + + {/* Meta */} +
+ Створено: {formatDate(proposal.created_at)} + {proposal.closes_at && Закінчується: {formatDate(proposal.closes_at)}} +
+
+ ) +} + diff --git a/apps/web/src/app/governance/page.tsx b/apps/web/src/app/governance/page.tsx new file mode 100644 index 00000000..2a90ab89 --- /dev/null +++ b/apps/web/src/app/governance/page.tsx @@ -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 { + 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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Governance

+

MicroDAO управління та голосування

+
+
+
+ + {/* Stats Overview */} +
+ + + + +
+ + {/* MicroDAOs List */} +
+

+ + Ваші MicroDAO +

+ + {daos.length === 0 ? ( +
+ +

+ MicroDAO не знайдено +

+

+ Ви ще не є учасником жодного MicroDAO. +

+ +
+ ) : ( +
+ {daos.map((dao) => ( + + ))} +
+ )} +
+ + {/* Quick Actions */} +
+

Швидкі дії

+ +
+ + + +
+
+
+
+ ) +} + +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 ( +
+ +
{value}
+
{label}
+
+ ) +} + +function DAOCard({ dao }: { dao: MicroDAO }) { + return ( + +
+
+
+ +
+
+

+ {dao.name} +

+

{dao.slug}

+
+
+ + + {dao.is_active ? 'Активний' : 'Неактивний'} + +
+ +

+ {dao.description || 'Без опису'} +

+ +
+
+ + + 0 учасників + + + + 0 пропозицій + +
+ + +
+ + ) +} + +function ActionCard({ + icon: Icon, + title, + description, + href +}: { + icon: React.ComponentType<{ className?: string }> + title: string + description: string + href: string +}) { + return ( + + +

{title}

+

{description}

+ + ) +} + diff --git a/apps/web/src/components/Navigation.tsx b/apps/web/src/components/Navigation.tsx index 57d3e782..119d799c 100644 --- a/apps/web/src/components/Navigation.tsx +++ b/apps/web/src/components/Navigation.tsx @@ -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 }, ] diff --git a/apps/web/src/components/chat/ChatInput.tsx b/apps/web/src/components/chat/ChatInput.tsx new file mode 100644 index 00000000..288444a8 --- /dev/null +++ b/apps/web/src/components/chat/ChatInput.tsx @@ -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(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 ( +
+
+ {/* Attachment button */} + + + {/* Input field */} +
+