Add Chat page with WebSocket connection to LLM router
This commit is contained in:
@@ -7,12 +7,14 @@ import { Memory } from './pages/Memory'
|
|||||||
import { Backups } from './pages/Backups'
|
import { Backups } from './pages/Backups'
|
||||||
import { Logs } from './pages/Logs'
|
import { Logs } from './pages/Logs'
|
||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
|
import { Chat } from './pages/Chat'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<Overview />} />
|
<Route index element={<Overview />} />
|
||||||
|
<Route path="chat" element={<Chat />} />
|
||||||
<Route path="skills" element={<Skills />} />
|
<Route path="skills" element={<Skills />} />
|
||||||
<Route path="skills/:name" element={<SkillDetail />} />
|
<Route path="skills/:name" element={<SkillDetail />} />
|
||||||
<Route path="memory" element={<Memory />} />
|
<Route path="memory" element={<Memory />} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
MessageSquare,
|
||||||
Blocks,
|
Blocks,
|
||||||
Brain,
|
Brain,
|
||||||
Archive,
|
Archive,
|
||||||
@@ -12,6 +13,7 @@ import { clsx } from 'clsx'
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: LayoutDashboard, label: 'Overview' },
|
{ to: '/', icon: LayoutDashboard, label: 'Overview' },
|
||||||
|
{ to: '/chat', icon: MessageSquare, label: 'Chat' },
|
||||||
{ to: '/skills', icon: Blocks, label: 'Skills' },
|
{ to: '/skills', icon: Blocks, label: 'Skills' },
|
||||||
{ to: '/memory', icon: Brain, label: 'Memory' },
|
{ to: '/memory', icon: Brain, label: 'Memory' },
|
||||||
{ to: '/backups', icon: Archive, label: 'Backups' },
|
{ to: '/backups', icon: Archive, label: 'Backups' },
|
||||||
|
|||||||
260
ui/src/pages/Chat.tsx
Normal file
260
ui/src/pages/Chat.tsx
Normal file
@@ -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<Message[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [sessionId, setSessionId] = useState<string>('')
|
||||||
|
const [provider, setProvider] = useState<string>('')
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const pendingMessageRef = useRef<string>('')
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Chat</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{isConnected ? `Connected${provider ? ` to ${provider}` : ''}` : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
{sessionId && (
|
||||||
|
<span className="text-xs text-gray-500 font-mono">
|
||||||
|
Session: {sessionId.slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={clearChat}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{!isConnected && (
|
||||||
|
<button
|
||||||
|
onClick={connect}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<Card className="flex-1 min-h-0 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<Bot className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>Start a conversation</p>
|
||||||
|
<p className="text-sm mt-1">Messages are routed through the LLM skill</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((message) => (
|
||||||
|
<MessageBubble key={message.id} message={message} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{isLoading && messages[messages.length - 1]?.content === '' && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-400">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span className="text-sm">Thinking...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<form onSubmit={sendMessage} className="p-4 border-t border-gray-800">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isConnected || isLoading || !input.trim()}
|
||||||
|
className="px-4 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:hover:bg-purple-600"
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ message }: { message: Message }) {
|
||||||
|
const isUser = message.role === 'user'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||||
|
isUser ? 'bg-purple-600' : 'bg-gray-700'
|
||||||
|
}`}>
|
||||||
|
{isUser ? (
|
||||||
|
<User className="w-4 h-4 text-white" />
|
||||||
|
) : (
|
||||||
|
<Bot className="w-4 h-4 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`max-w-[80%] ${isUser ? 'text-right' : ''}`}>
|
||||||
|
<div className={`inline-block px-4 py-2 rounded-2xl ${
|
||||||
|
isUser
|
||||||
|
? 'bg-purple-600 text-white rounded-br-md'
|
||||||
|
: 'bg-gray-800 text-gray-100 rounded-bl-md'
|
||||||
|
}`}>
|
||||||
|
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{message.timestamp.toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { Overview } from './Overview'
|
export { Overview } from './Overview'
|
||||||
|
export { Chat } from './Chat'
|
||||||
export { Skills } from './Skills'
|
export { Skills } from './Skills'
|
||||||
export { SkillDetail } from './SkillDetail'
|
export { SkillDetail } from './SkillDetail'
|
||||||
export { Memory } from './Memory'
|
export { Memory } from './Memory'
|
||||||
|
|||||||
Reference in New Issue
Block a user