diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 16c9ef2..4010b62 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -7,12 +7,14 @@ import { Memory } from './pages/Memory' import { Backups } from './pages/Backups' import { Logs } from './pages/Logs' import { Settings } from './pages/Settings' +import { Chat } from './pages/Chat' export default function App() { return ( }> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 25e888f..73f7acc 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, + MessageSquare, Blocks, Brain, Archive, @@ -12,6 +13,7 @@ import { clsx } from 'clsx' const navItems = [ { to: '/', icon: LayoutDashboard, label: 'Overview' }, + { to: '/chat', icon: MessageSquare, label: 'Chat' }, { to: '/skills', icon: Blocks, label: 'Skills' }, { to: '/memory', icon: Brain, label: 'Memory' }, { to: '/backups', icon: Archive, label: 'Backups' }, diff --git a/ui/src/pages/Chat.tsx b/ui/src/pages/Chat.tsx new file mode 100644 index 0000000..8bdf970 --- /dev/null +++ b/ui/src/pages/Chat.tsx @@ -0,0 +1,260 @@ +import { useState, useRef, useEffect } from 'react' +import { Send, Bot, User, Loader2, RefreshCw } from 'lucide-react' +import { Card } from '../components/Card' + +interface Message { + id: string + role: 'user' | 'assistant' + content: string + timestamp: Date +} + +export function Chat() { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [isConnected, setIsConnected] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [sessionId, setSessionId] = useState('') + const [provider, setProvider] = useState('') + + const wsRef = useRef(null) + const messagesEndRef = useRef(null) + const pendingMessageRef = useRef('') + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const connect = () => { + if (wsRef.current?.readyState === WebSocket.OPEN) return + + const ws = new WebSocket('ws://localhost:8082/chat') + + ws.onopen = () => { + setIsConnected(true) + console.log('Connected to LLM router') + } + + ws.onclose = () => { + setIsConnected(false) + setIsLoading(false) + console.log('Disconnected from LLM router') + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + setIsConnected(false) + setIsLoading(false) + } + + ws.onmessage = (event) => { + const data = JSON.parse(event.data) + + switch (data.type) { + case 'start': + setSessionId(data.session_id) + setProvider(data.provider || '') + pendingMessageRef.current = '' + // Add empty assistant message that we'll stream into + setMessages(prev => [...prev, { + id: data.session_id + '-response', + role: 'assistant', + content: '', + timestamp: new Date(), + }]) + break + + case 'token': + pendingMessageRef.current += data.content + // Update the last message with new content + setMessages(prev => { + const updated = [...prev] + const lastMsg = updated[updated.length - 1] + if (lastMsg && lastMsg.role === 'assistant') { + lastMsg.content = pendingMessageRef.current + } + return updated + }) + break + + case 'end': + setIsLoading(false) + break + + case 'error': + setIsLoading(false) + setMessages(prev => [...prev, { + id: Date.now().toString(), + role: 'assistant', + content: `Error: ${data.message}`, + timestamp: new Date(), + }]) + break + + case 'pong': + // Heartbeat response + break + } + } + + wsRef.current = ws + } + + const disconnect = () => { + wsRef.current?.close() + wsRef.current = null + } + + useEffect(() => { + connect() + return () => disconnect() + }, []) + + const sendMessage = (e: React.FormEvent) => { + e.preventDefault() + if (!input.trim() || !isConnected || isLoading) return + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: input.trim(), + timestamp: new Date(), + } + + setMessages(prev => [...prev, userMessage]) + setIsLoading(true) + + wsRef.current?.send(JSON.stringify({ + type: 'message', + content: input.trim(), + session_id: sessionId || undefined, + })) + + setInput('') + } + + const clearChat = () => { + setMessages([]) + setSessionId('') + } + + return ( +
+ {/* Header */} +
+
+

Chat

+
+ + + {isConnected ? `Connected${provider ? ` to ${provider}` : ''}` : 'Disconnected'} + + {sessionId && ( + + Session: {sessionId.slice(0, 8)}... + + )} +
+
+
+ + {!isConnected && ( + + )} +
+
+ + {/* Messages */} + +
+ {messages.length === 0 ? ( +
+
+ +

Start a conversation

+

Messages are routed through the LLM skill

+
+
+ ) : ( + messages.map((message) => ( + + )) + )} + {isLoading && messages[messages.length - 1]?.content === '' && ( +
+ + Thinking... +
+ )} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + placeholder={isConnected ? "Type a message..." : "Connecting..."} + disabled={!isConnected || isLoading} + className="flex-1 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 disabled:opacity-50" + /> + +
+
+ +
+ ) +} + +function MessageBubble({ message }: { message: Message }) { + const isUser = message.role === 'user' + + return ( +
+
+ {isUser ? ( + + ) : ( + + )} +
+
+
+

{message.content}

+
+

+ {message.timestamp.toLocaleTimeString()} +

+
+
+ ) +} diff --git a/ui/src/pages/index.ts b/ui/src/pages/index.ts index a8187f5..500caae 100644 --- a/ui/src/pages/index.ts +++ b/ui/src/pages/index.ts @@ -1,4 +1,5 @@ export { Overview } from './Overview' +export { Chat } from './Chat' export { Skills } from './Skills' export { SkillDetail } from './SkillDetail' export { Memory } from './Memory'