Spaces:
Runtime error
Runtime error
| import { useState, useRef, useEffect } from 'react' | |
| import { useQuery } from '@tanstack/react-query' | |
| import { apiFetch } from '@/lib/api' | |
| import { Button } from '@/components/ui/button' | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' | |
| import { PageHeader } from '@/components/page-header' | |
| import { Markdown } from '@/components/markdown' | |
| interface FallbackEntry { | |
| modelDbId: number | |
| priority: number | |
| enabled: boolean | |
| platform: string | |
| modelId: string | |
| displayName: string | |
| sizeLabel: string | |
| keyCount: number | |
| } | |
| interface ChatMessage { | |
| role: 'user' | 'assistant' | |
| content: string | |
| meta?: { | |
| platform?: string | |
| model?: string | |
| latency?: number | |
| fallbackAttempts?: number | |
| } | |
| } | |
| export default function PlaygroundPage() { | |
| const [messages, setMessages] = useState<ChatMessage[]>([]) | |
| const [input, setInput] = useState('') | |
| const [loading, setLoading] = useState(false) | |
| const [selectedModel, setSelectedModel] = useState<string>('auto') | |
| const messagesEndRef = useRef<HTMLDivElement>(null) | |
| const inputRef = useRef<HTMLTextAreaElement>(null) | |
| const { data: keyData } = useQuery<{ apiKey: string }>({ | |
| queryKey: ['unified-key'], | |
| queryFn: () => apiFetch('/api/settings/api-key'), | |
| }) | |
| const { data: fallbackEntries = [] } = useQuery<FallbackEntry[]>({ | |
| queryKey: ['fallback'], | |
| queryFn: () => apiFetch('/api/fallback'), | |
| }) | |
| const availableModels = fallbackEntries.filter(e => e.keyCount > 0 && e.enabled) | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) | |
| }, [messages]) | |
| const handleSend = async () => { | |
| const text = input.trim() | |
| if (!text || loading) return | |
| const userMsg: ChatMessage = { role: 'user', content: text } | |
| const newMessages = [...messages, userMsg] | |
| setMessages(newMessages) | |
| setInput('') | |
| setLoading(true) | |
| inputRef.current?.focus() | |
| try { | |
| const headers: Record<string, string> = { 'Content-Type': 'application/json' } | |
| if (keyData?.apiKey) headers['Authorization'] = `Bearer ${keyData.apiKey}` | |
| const body: any = { | |
| messages: newMessages.map(m => ({ role: m.role, content: m.content })), | |
| } | |
| if (selectedModel !== 'auto') body.model = selectedModel | |
| const base = import.meta.env.BASE_URL.replace(/\/$/, '') | |
| const start = Date.now() | |
| const res = await fetch(`${base}/v1/chat/completions`, { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify(body), | |
| }) | |
| const latency = Date.now() - start | |
| const routedVia = res.headers.get('X-Routed-Via') | |
| const fallbackAttempts = res.headers.get('X-Fallback-Attempts') | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({ error: { message: `HTTP ${res.status}` } })) | |
| setMessages([...newMessages, { | |
| role: 'assistant', | |
| content: `Error: ${err.error?.message ?? 'Unknown error'}`, | |
| }]) | |
| return | |
| } | |
| const data = await res.json() | |
| const content = data.choices?.[0]?.message?.content ?? JSON.stringify(data, null, 2) | |
| const via = data._routed_via ?? (routedVia ? { | |
| platform: routedVia.split('/')[0], | |
| model: routedVia.split('/').slice(1).join('/'), | |
| } : undefined) | |
| setMessages([...newMessages, { | |
| role: 'assistant', | |
| content, | |
| meta: { | |
| platform: via?.platform, | |
| model: via?.model, | |
| latency, | |
| fallbackAttempts: fallbackAttempts ? parseInt(fallbackAttempts) : undefined, | |
| }, | |
| }]) | |
| } catch (err: any) { | |
| setMessages([...newMessages, { | |
| role: 'assistant', | |
| content: `Error: ${err.message}`, | |
| }]) | |
| } finally { | |
| setLoading(false) | |
| setTimeout(() => inputRef.current?.focus(), 0) | |
| } | |
| } | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault() | |
| handleSend() | |
| } | |
| } | |
| const handleClear = () => { | |
| setMessages([]) | |
| inputRef.current?.focus() | |
| } | |
| const activeModelLabel = selectedModel === 'auto' | |
| ? 'Auto (fallback chain)' | |
| : availableModels.find(m => m.modelId === selectedModel)?.displayName ?? selectedModel | |
| return ( | |
| <div className="flex flex-col h-[calc(100vh-8rem)]"> | |
| <PageHeader | |
| title="Playground" | |
| description="Send a chat completion through the router and see which provider serves it." | |
| actions={ | |
| <> | |
| <Select value={selectedModel} onValueChange={(v) => setSelectedModel(v ?? 'auto')}> | |
| <SelectTrigger className="w-[260px]"> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="auto">Auto (fallback chain)</SelectItem> | |
| {availableModels.map(m => ( | |
| <SelectItem key={m.modelDbId} value={m.modelId}> | |
| <span className="flex items-center gap-2"> | |
| <span>{m.displayName}</span> | |
| <span className="text-xs text-muted-foreground">{m.platform}</span> | |
| </span> | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| {messages.length > 0 && ( | |
| <Button variant="outline" size="sm" onClick={handleClear}> | |
| Clear | |
| </Button> | |
| )} | |
| </> | |
| } | |
| /> | |
| <div className="flex-1 flex flex-col rounded-3xl border bg-card overflow-hidden min-h-0"> | |
| <div className="flex-1 overflow-y-auto p-6 space-y-4"> | |
| {messages.length === 0 ? ( | |
| <div className="flex items-center justify-center h-full text-center"> | |
| <div className="space-y-2 max-w-sm"> | |
| <p className="text-base font-medium">Send a message to get started.</p> | |
| <p className="text-sm text-muted-foreground"> | |
| Using <span className="text-foreground">{activeModelLabel}</span>. Switch models in the selector above. | |
| </p> | |
| </div> | |
| </div> | |
| ) : ( | |
| <> | |
| {messages.map((msg, i) => ( | |
| <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}> | |
| <div | |
| className={`max-w-[78%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${ | |
| msg.role === 'user' | |
| ? 'bg-primary text-primary-foreground' | |
| : 'bg-muted' | |
| }`} | |
| > | |
| {msg.role === 'assistant' ? ( | |
| <Markdown>{msg.content}</Markdown> | |
| ) : ( | |
| <div className="whitespace-pre-wrap">{msg.content}</div> | |
| )} | |
| {msg.meta && ( | |
| <div className="flex items-center gap-2 mt-2 flex-wrap text-[11px] opacity-70 tabular-nums"> | |
| {msg.meta.platform && <span>{msg.meta.platform}</span>} | |
| {msg.meta.model && <span className="font-mono">· {msg.meta.model}</span>} | |
| {msg.meta.latency != null && <span>· {msg.meta.latency} ms</span>} | |
| {msg.meta.fallbackAttempts != null && msg.meta.fallbackAttempts > 0 && ( | |
| <span>· {msg.meta.fallbackAttempts} fallback{msg.meta.fallbackAttempts > 1 ? 's' : ''}</span> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| {loading && ( | |
| <div className="flex justify-start"> | |
| <div className="bg-muted rounded-2xl px-4 py-3"> | |
| <div className="flex gap-1"> | |
| <span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '0ms' }} /> | |
| <span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '150ms' }} /> | |
| <span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '300ms' }} /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </> | |
| )} | |
| </div> | |
| <div className="border-t bg-background/50 p-3"> | |
| <div className="flex gap-2 items-end"> | |
| <textarea | |
| ref={inputRef} | |
| value={input} | |
| onChange={e => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="Type a message… (⏎ to send, ⇧⏎ for newline)" | |
| rows={1} | |
| className="flex-1 resize-none rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50 min-h-[40px] max-h-[160px]" | |
| style={{ height: 'auto', overflow: 'hidden' }} | |
| onInput={e => { | |
| const el = e.target as HTMLTextAreaElement | |
| el.style.height = 'auto' | |
| el.style.height = Math.min(el.scrollHeight, 160) + 'px' | |
| }} | |
| /> | |
| <Button onClick={handleSend} disabled={loading || !input.trim()} size="default"> | |
| {loading ? 'Sending…' : 'Send'} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |