FreeLLMAPI / client /src /pages /PlaygroundPage.tsx
Nryn215's picture
Upload folder using huggingface_hub
077865a verified
Raw
History Blame Contribute Delete
9.48 kB
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>
)
}