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).
495 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|