feat: add Test Chat page with streaming, model picker, stop button

This commit is contained in:
2026-06-29 12:04:44 +02:00
parent 5febc03412
commit ef3e894484
3 changed files with 287 additions and 0 deletions

View File

@@ -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 />} />

View File

@@ -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"/>

View 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>
)
}