feat: add Test Chat page with streaming, model picker, stop button
This commit is contained in:
@@ -10,6 +10,7 @@ import ApiKeys from './pages/ApiKeys'
|
||||
import Providers from './pages/Providers'
|
||||
import Routing from './pages/Routing'
|
||||
import Analytics from './pages/Analytics'
|
||||
import Chat from './pages/Chat'
|
||||
import Models from './pages/Models'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
@@ -35,6 +36,7 @@ export default function App() {
|
||||
<Route path="/" element={<Layout currentUser={currentUser} />}>
|
||||
<Route index element={<Navigate to="/overview" replace />} />
|
||||
<Route path="overview" element={<Overview />} />
|
||||
<Route path="chat" element={<Chat />} />
|
||||
<Route path="logs" element={<Logs />} />
|
||||
<Route path="keys" element={<ApiKeys />} />
|
||||
<Route path="analytics" element={<Analytics />} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { CurrentUser } from '../lib/types'
|
||||
|
||||
const USER_NAV = [
|
||||
{ to: '/overview', label: 'Overview', icon: <IconGrid /> },
|
||||
{ to: '/chat', label: 'Test Chat', icon: <IconChat /> },
|
||||
{ to: '/logs', label: 'Request Logs', icon: <IconLogs /> },
|
||||
{ to: '/keys', label: 'API Keys', icon: <IconKey /> },
|
||||
{ to: '/analytics',label: 'Analytics', icon: <IconChart /> },
|
||||
@@ -192,6 +193,11 @@ function IconBox() {
|
||||
<polyline points="2,4.5 8,8 14,4.5"/><line x1="8" y1="8" x2="8" y2="15"/>
|
||||
</svg>
|
||||
}
|
||||
function IconChat() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
}
|
||||
function IconSettings() {
|
||||
return <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
|
||||
279
backend/dashboard/src/pages/Chat.tsx
Normal file
279
backend/dashboard/src/pages/Chat.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getToken } from '../lib/api'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
interface ModelOption {
|
||||
id: string
|
||||
owned_by: string
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const [models, setModels] = useState<ModelOption[]>([])
|
||||
const [model, setModel] = useState('')
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/v1/models', { headers: { Authorization: `Bearer ${getToken()}` } })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const list: ModelOption[] = d.data ?? []
|
||||
setModels(list)
|
||||
if (list.length > 0) setModel(list[0].id)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || streaming || !model) return
|
||||
setInput('')
|
||||
setError('')
|
||||
|
||||
const next: Message[] = [...messages, { role: 'user', content: text }]
|
||||
setMessages([...next, { role: 'assistant', content: '', pending: true }])
|
||||
|
||||
const abort = new AbortController()
|
||||
abortRef.current = abort
|
||||
setStreaming(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
signal: abort.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: true,
|
||||
messages: next.map(m => ({ role: m.role, content: m.content })),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => res.statusText)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
let reply = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue
|
||||
const data = line.slice(5).trim()
|
||||
if (data === '[DONE]') break
|
||||
try {
|
||||
const chunk = JSON.parse(data)
|
||||
const delta = chunk.choices?.[0]?.delta?.content
|
||||
if (delta) {
|
||||
reply += delta
|
||||
setMessages([...next, { role: 'assistant', content: reply }])
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!reply) reply = '(empty response)'
|
||||
setMessages([...next, { role: 'assistant', content: reply }])
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
setMessages(prev => prev.map((m, i) =>
|
||||
i === prev.length - 1 ? { ...m, pending: false } : m))
|
||||
} else {
|
||||
setError(e.message)
|
||||
setMessages(next)
|
||||
}
|
||||
} finally {
|
||||
setStreaming(false)
|
||||
abortRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
|
||||
function handleKey(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
const empty = messages.length === 0
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', maxWidth: 780, margin: '0 auto' }}>
|
||||
{/* Model selector */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16, flexShrink: 0 }}>
|
||||
<select
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
disabled={streaming}
|
||||
style={{
|
||||
background: '#17171c', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 7,
|
||||
padding: '7px 12px', fontSize: 13, color: '#ededf0', cursor: 'pointer', outline: 'none',
|
||||
fontFamily: "'DM Mono',monospace", maxWidth: 380,
|
||||
}}
|
||||
>
|
||||
{models.length === 0
|
||||
? <option value="">No models available</option>
|
||||
: models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)
|
||||
}
|
||||
</select>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => { setMessages([]); setError('') }}
|
||||
disabled={streaming}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid rgba(255,255,255,0.09)',
|
||||
borderRadius: 7, padding: '7px 12px', fontSize: 12,
|
||||
color: 'rgba(237,237,240,0.4)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div style={{
|
||||
flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column',
|
||||
gap: 16, paddingBottom: 16,
|
||||
}}>
|
||||
{empty && (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
color: 'rgba(237,237,240,0.2)', fontSize: 13,
|
||||
}}>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Send a message to test the model
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex',
|
||||
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
|
||||
gap: 10, alignItems: 'flex-start',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
|
||||
background: m.role === 'user'
|
||||
? 'linear-gradient(135deg,#7c3aed,#2563eb)'
|
||||
: 'rgba(255,255,255,0.07)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 600, color: 'rgba(237,237,240,0.7)',
|
||||
}}>
|
||||
{m.role === 'user' ? 'U' : 'AI'}
|
||||
</div>
|
||||
<div style={{
|
||||
maxWidth: '80%',
|
||||
background: m.role === 'user' ? 'rgba(124,58,237,0.15)' : '#17171c',
|
||||
border: `1px solid ${m.role === 'user' ? 'rgba(124,58,237,0.25)' : 'rgba(255,255,255,0.07)'}`,
|
||||
borderRadius: m.role === 'user' ? '12px 4px 12px 12px' : '4px 12px 12px 12px',
|
||||
padding: '10px 14px',
|
||||
fontSize: 13.5, lineHeight: 1.6, color: '#ededf0',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{m.content || (m.pending
|
||||
? <span style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
|
||||
{[0,1,2].map(j => (
|
||||
<span key={j} style={{
|
||||
width: 5, height: 5, borderRadius: '50%',
|
||||
background: 'rgba(237,237,240,0.3)',
|
||||
animation: `pulse 1s ${j * 0.2}s infinite`,
|
||||
}}/>
|
||||
))}
|
||||
</span>
|
||||
: ''
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)',
|
||||
borderRadius: 8, padding: '10px 14px', fontSize: 12.5, color: '#f87171',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{
|
||||
flexShrink: 0, display: 'flex', gap: 8, alignItems: 'flex-end',
|
||||
background: '#17171c', border: '1px solid rgba(255,255,255,0.09)',
|
||||
borderRadius: 10, padding: '10px 10px 10px 14px',
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => {
|
||||
setInput(e.target.value)
|
||||
e.target.style.height = 'auto'
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px'
|
||||
}}
|
||||
onKeyDown={handleKey}
|
||||
disabled={streaming || !model}
|
||||
placeholder={model ? 'Message… (Enter to send, Shift+Enter for newline)' : 'No model selected'}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1, background: 'transparent', border: 'none', outline: 'none',
|
||||
resize: 'none', fontSize: 13.5, color: '#ededf0', lineHeight: 1.55,
|
||||
fontFamily: 'inherit', padding: 0, minHeight: 24, maxHeight: 160,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
/>
|
||||
{streaming
|
||||
? (
|
||||
<button onClick={stop} style={{
|
||||
background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.25)',
|
||||
borderRadius: 7, padding: '6px 12px', fontSize: 12,
|
||||
color: '#f87171', cursor: 'pointer', flexShrink: 0,
|
||||
}}>Stop</button>
|
||||
)
|
||||
: (
|
||||
<button onClick={send} disabled={!input.trim() || !model} style={{
|
||||
background: '#7c3aed', border: 'none', borderRadius: 7,
|
||||
padding: '6px 14px', fontSize: 13, fontWeight: 500,
|
||||
color: 'white', cursor: 'pointer', flexShrink: 0,
|
||||
opacity: !input.trim() || !model ? 0.4 : 1,
|
||||
}}>Send</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user