Files
boocode/apps/web/src/components/control/PlaygroundTab.tsx
indifferentketchup b18de2a331 chore: snapshot working tree - pty_exited notifications + in-flight inference WIP
feat(booterm): structured pty_exited WS notifications. Plan-validated, impl-validated, code-reviewed green (contracts build clean, contracts test 29/29, booterm + web typecheck clean).

wip: in-progress inference/provider refactor (agents.ts, provider.ts, new llama-providers.ts, removed llama-args-validator), plus arena, dispatcher, compaction, schema changes.

openspec: pty-exit-notifications complete; x-agent-flags planned (not yet implemented).
2026-06-14 12:48:47 +00:00

495 lines
17 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Send, Loader2, Swords, Sparkles } from 'lucide-react';
interface PlaygroundTabProps {
providerIds: string[];
}
interface ModelEntry {
id: string;
providerId: string;
}
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
export function PlaygroundTab({ providerIds }: PlaygroundTabProps) {
const [models, setModels] = useState<ModelEntry[]>([]);
const [selectedModel, setSelectedModel] = useState<string>('');
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [temperature, setTemperature] = useState(0.7);
const [topP, setTopP] = useState(0.9);
const [maxTokens, setMaxTokens] = useState(1024);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [streaming, setStreaming] = useState(false);
const [abMode, setAbMode] = useState(false);
const [modelB, setModelB] = useState('');
const [providerB, setProviderB] = useState('');
const [responseA, setResponseA] = useState('');
const [responseB, setResponseB] = useState('');
const [streamingAb, setStreamingAb] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
fetchModels();
}, []);
const fetchModels = useCallback(async () => {
try {
const res = await fetch('/api/control/playground/models');
if (!res.ok) return;
const data = await res.json() as { models: Array<{ providerId: string; models: string[] }> };
const flattened: ModelEntry[] = [];
for (const group of data.models) {
for (const m of group.models) {
flattened.push({ id: m, providerId: group.providerId });
}
}
setModels(flattened);
if (flattened.length > 0 && !selectedModel) {
const first = flattened[0];
if (first) {
setSelectedModel(first.id);
setSelectedProvider(first.providerId);
}
}
} catch {
// silent
}
}, [selectedModel]);
const groupedModels = models.reduce((acc, m) => {
if (!acc[m.providerId]) {
acc[m.providerId] = [];
}
const group = acc[m.providerId];
if (group) {
group.push(m);
}
return acc;
}, {} as Record<string, ModelEntry[]>);
const handleSend = async () => {
if (!input.trim() || !selectedModel || streaming) return;
const userMsg: ChatMessage = { role: 'user', content: input.trim() };
const newMessages = [...messages, userMsg, { role: 'assistant' as const, content: '' }];
setMessages(newMessages);
setInput('');
setStreaming(true);
try {
const chatMessages = newMessages.slice(0, -1).map((m) => ({
role: m.role,
content: m.content,
}));
const res = await fetch('/api/control/playground/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: selectedProvider,
model: selectedModel,
messages: chatMessages,
temperature,
topP,
maxTokens,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
setMessages((prev) => [...prev.slice(0, -1), { role: 'assistant', content: `Error: ${err.error || 'Request failed'}` }]);
setStreaming(false);
return;
}
const reader = res.body?.getReader();
if (!reader) {
setStreaming(false);
return;
}
const decoder = new TextDecoder();
let buffer = '';
let assistantContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed === 'data: [DONE]') continue;
const jsonStr = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
try {
const parsed = JSON.parse(jsonStr);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
assistantContent += delta;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = { role: 'assistant', content: assistantContent };
return updated;
});
}
} catch {
// skip
}
}
}
setStreaming(false);
} catch (err) {
const msg = (err as Error).message ?? String(err);
setMessages((prev) => [...prev.slice(0, -1), { role: 'assistant', content: `Error: ${msg}` }]);
setStreaming(false);
}
};
const handleABCompare = async () => {
if (!input.trim() || !selectedModel || !modelB || streamingAb) return;
const userMsg: ChatMessage = { role: 'user', content: input.trim() };
setMessages([...messages, userMsg]);
setInput('');
setResponseA('');
setResponseB('');
setStreamingAb(true);
try {
const res = await fetch('/api/control/playground/chat-ab', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerIdA: selectedProvider,
modelA: selectedModel,
providerIdB: providerB,
modelB,
messages: [...messages, userMsg],
temperature,
topP,
maxTokens,
}),
});
if (!res.ok) {
setStreamingAb(false);
return;
}
const reader = res.body?.getReader();
if (!reader) {
setStreamingAb(false);
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const jsonStr = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
try {
const parsed = JSON.parse(jsonStr);
if (parsed.done) {
if (parsed.lane === 'A') setStreamingAb((p) => {
// Check if B is also done elsewhere
return p;
});
continue;
}
if (parsed.raw) {
const innerStr = parsed.raw.startsWith('data: ') ? parsed.raw.slice(6) : parsed.raw;
const inner = JSON.parse(innerStr);
const delta = inner.choices?.[0]?.delta?.content;
if (delta) {
if (parsed.lane === 'A') {
setResponseA((p) => p + delta);
} else {
setResponseB((p) => p + delta);
}
}
}
} catch {
// skip
}
}
}
setStreamingAb(false);
} catch {
setStreamingAb(false);
}
};
const getArenaBattleUrl = () => {
const prompt = encodeURIComponent(input || messages[messages.length - 1]?.content || '');
const modelA = encodeURIComponent(selectedModel);
const modelBParam = encodeURIComponent(modelB || '');
return `/arena?prompt=${prompt}&models=${modelA},${modelBParam}`;
};
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Model and param controls */}
<div className="flex flex-wrap items-center gap-3 px-4 py-3 border-b border-border/40 shrink-0">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Host</label>
<select
value={selectedProvider}
onChange={(e) => {
setSelectedProvider(e.target.value);
const firstModel = groupedModels[e.target.value]?.[0]?.id;
if (firstModel) setSelectedModel(firstModel);
}}
className="bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
>
{Object.keys(groupedModels).map((pid) => (
<option key={pid} value={pid}>{pid}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Model</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm min-w-[200px]"
>
{(groupedModels[selectedProvider] ?? []).map((m) => (
<option key={m.id} value={m.id}>{m.id}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Temp</label>
<input
type="number"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value) || 0.7)}
className="w-16 bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Top P</label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={topP}
onChange={(e) => setTopP(parseFloat(e.target.value) || 0.9)}
className="w-16 bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Max</label>
<input
type="number"
min={1}
max={8192}
step={128}
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 1024)}
className="w-20 bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
<button
type="button"
onClick={() => setAbMode(!abMode)}
className={cn(
'flex items-center gap-1 px-2 py-1 text-xs rounded transition-colors',
abMode
? 'bg-accent/20 text-accent border border-accent/30'
: 'text-muted-foreground hover:text-foreground border border-transparent'
)}
>
<Swords className="size-3" />
A/B
</button>
</div>
{/* A/B model B selector */}
{abMode && (
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/40 bg-muted/20">
<label className="text-xs text-muted-foreground">Model B</label>
<select
value={providerB}
onChange={(e) => {
setProviderB(e.target.value);
const firstModel = groupedModels[e.target.value]?.[0]?.id;
if (firstModel) setModelB(firstModel);
}}
className="bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
>
{Object.keys(groupedModels).map((pid) => (
<option key={pid} value={pid}>{pid}</option>
))}
</select>
<select
value={modelB}
onChange={(e) => setModelB(e.target.value)}
className="bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm min-w-[200px]"
>
{(groupedModels[providerB] ?? []).map((m) => (
<option key={m.id} value={m.id}>{m.id}</option>
))}
</select>
</div>
)}
{/* Chat area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{!abMode ? (
<>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.map((msg, i) => (
<div
key={i}
className={cn(
'max-w-[80%] rounded-lg px-3 py-2 text-sm',
msg.role === 'user'
? 'ml-auto bg-accent/20 text-accent-foreground'
: 'bg-muted/50 text-foreground'
)}
>
{msg.content || (msg.role === 'assistant' && streaming ? <Loader2 className="size-4 animate-spin" /> : null)}
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="flex items-center gap-2 px-4 py-3 border-t border-border/40 shrink-0">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Type a message..."
className="flex-1 bg-muted/50 border border-border/50 rounded-lg px-3 py-2 text-sm resize-none min-h-[40px] max-h-[120px]"
rows={1}
/>
<button
type="button"
onClick={handleSend}
disabled={streaming || !input.trim()}
className={cn(
'p-2 rounded-lg transition-colors',
streaming || !input.trim()
? 'text-muted-foreground/50'
: 'bg-accent/20 text-accent hover:bg-accent/30'
)}
>
{streaming ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
</button>
</div>
</>
) : (
<>
{/* A/B comparison */}
<div className="flex-1 flex gap-2 px-4 py-3 min-h-0 overflow-hidden">
<div className="flex-1 flex flex-col min-h-0 bg-muted/20 rounded-lg border border-border/30 overflow-hidden">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/30 shrink-0">
Model A: {selectedModel}
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 text-sm whitespace-pre-wrap">
{responseA || (streamingAb ? <Loader2 className="size-4 animate-spin" /> : 'Waiting...')}
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 bg-muted/20 rounded-lg border border-border/30 overflow-hidden">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/30 shrink-0">
Model B: {modelB}
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 text-sm whitespace-pre-wrap">
{responseB || (streamingAb ? <Loader2 className="size-4 animate-spin" /> : 'Waiting...')}
</div>
</div>
</div>
{/* A/B input */}
<div className="flex items-center gap-2 px-4 py-3 border-t border-border/40 shrink-0">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleABCompare();
}
}}
placeholder="Type a prompt for A/B comparison..."
className="flex-1 bg-muted/50 border border-border/50 rounded-lg px-3 py-2 text-sm resize-none min-h-[40px]"
rows={1}
/>
<button
type="button"
onClick={handleABCompare}
disabled={streamingAb || !input.trim() || !modelB}
className={cn(
'p-2 rounded-lg transition-colors',
streamingAb || !input.trim() || !modelB
? 'text-muted-foreground/50'
: 'bg-accent/20 text-accent hover:bg-accent/30'
)}
>
{streamingAb ? <Loader2 className="size-4 animate-spin" /> : <Swords className="size-4" />}
</button>
</div>
</>
)}
</div>
{/* Battle in Arena link */}
<div className="px-4 py-2 border-t border-border/40 shrink-0">
<a
href={getArenaBattleUrl()}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Sparkles className="size-3" />
Battle in Arena
</a>
</div>
</div>
);
}