feat(playground): add CompareTab with parallel streams + metrics

This commit is contained in:
diegosouzapw
2026-05-28 11:20:06 -03:00
parent 80910714a3
commit 05a0dceba7
3 changed files with 570 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx
import type { StreamMetrics } from "@/shared/schemas/playground";
import MarkdownMessage from "./MarkdownMessage";
import ProviderMetrics from "./ProviderMetrics";
export type ColumnStatus = "idle" | "streaming" | "done" | "error";
export interface CompareColumnData {
id: string;
model: string;
status: ColumnStatus;
metrics: StreamMetrics;
response: string;
errorMessage?: string;
}
interface CompareColumnProps {
column: CompareColumnData;
onCancel: (id: string) => void;
onRemove: (id: string) => void;
}
/**
* CompareColumn — a single column in the Compare tab.
* Shows the model name, streaming response (via MarkdownMessage), and ProviderMetrics.
*/
export default function CompareColumn({ column, onCancel, onRemove }: CompareColumnProps) {
const { id, model, status, metrics, response, errorMessage } = column;
return (
<div className="flex flex-col h-full border-r border-border last:border-r-0 min-w-0">
{/* Column header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-bg-alt shrink-0">
<div className="flex items-center gap-2 min-w-0">
{/* Status indicator */}
<span
className={`w-2 h-2 rounded-full shrink-0 ${
status === "streaming"
? "bg-primary animate-pulse"
: status === "done"
? "bg-green-500"
: status === "error"
? "bg-destructive"
: "bg-text-muted/30"
}`}
aria-label={`Status: ${status}`}
/>
<span
className="text-xs font-medium text-text-main truncate"
title={model}
>
{model || <span className="text-text-muted italic">No model</span>}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{status === "streaming" && (
<button
onClick={() => onCancel(id)}
className="text-[10px] px-1.5 py-0.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Cancel stream"
>
Cancel
</button>
)}
<button
onClick={() => onRemove(id)}
className="p-0.5 rounded text-text-muted hover:text-destructive transition-colors"
title="Remove column"
aria-label={`Remove column for ${model}`}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
</div>
</div>
{/* Metrics bar */}
{(status === "streaming" || status === "done") && (
<div className="px-3 py-1.5 border-b border-border shrink-0">
<ProviderMetrics metrics={metrics} />
</div>
)}
{/* Response content */}
<div className="flex-1 overflow-y-auto px-3 py-3 text-sm">
{status === "idle" && (
<p className="text-text-muted text-xs italic">
Ready to run.
</p>
)}
{status === "error" && (
<div className="text-destructive text-xs bg-destructive/10 rounded p-2">
<span className="font-medium">Error: </span>
{errorMessage ?? "Unknown error occurred."}
</div>
)}
{status === "streaming" && response === "" && (
<div className="flex items-center gap-2 text-text-muted text-xs">
<span className="inline-block w-1.5 h-4 bg-primary/60 animate-pulse rounded-sm" />
Waiting for response
</div>
)}
{(status === "streaming" || status === "done") && response !== "" && (
<MarkdownMessage content={response} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/ProviderMetrics.tsx
import type { StreamMetrics } from "@/shared/schemas/playground";
interface ProviderMetricsProps {
metrics: StreamMetrics;
}
function formatMs(ms: number | null): string {
if (ms == null) return "—";
if (ms < 1000) return `${ms.toFixed(0)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function formatTps(tps: number | null): string {
if (tps == null) return "—";
return `${tps.toFixed(1)} t/s`;
}
function formatCost(usd: number | null): string {
if (usd == null) return "—";
if (usd < 0.001) return `$${(usd * 1000).toFixed(3)}m`;
return `$${usd.toFixed(4)}`;
}
/**
* ProviderMetrics — displays TTFT, TPS, token counts, and estimated cost.
*
* All metrics are client-perceived (D12): measured from the first SSE chunk
* to the last. Labeled "(estimated)" as required by D13.
*/
export default function ProviderMetrics({ metrics }: ProviderMetricsProps) {
const { ttftMs, tps, tokensIn, tokensOut, costUsd } = metrics;
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-text-muted font-mono">
{/* TTFT */}
<span title="Time to first token (client-side estimate)">
TTFT {formatMs(ttftMs)}
</span>
{/* TPS */}
<span title="Tokens per second (client-side estimate)">
· {formatTps(tps)}
</span>
{/* Token counts */}
<span title="Prompt tokens ↑ / Completion tokens ↓">
· {tokensIn} {tokensOut}
</span>
{/* Cost */}
<span title="Estimated cost (not guaranteed accurate)">
· {formatCost(costUsd)} <span className="opacity-60">(estimated)</span>
</span>
</div>
);
}

View File

@@ -0,0 +1,395 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
import { useEffect, useRef, useState } from "react";
import CompareColumn, { type CompareColumnData, type ColumnStatus } from "../CompareColumn";
import type { ConfigState } from "../StudioConfigPane";
import type { StreamMetrics } from "@/shared/schemas/playground";
interface CompareTabProps {
configState: ConfigState;
}
const MAX_COLUMNS = 4; // D10: cap at 4 columns
interface ColumnState {
id: string;
model: string;
status: ColumnStatus;
metrics: StreamMetrics;
response: string;
errorMessage?: string;
}
const INITIAL_METRICS: StreamMetrics = {
ttftMs: null,
totalMs: null,
tokensIn: 0,
tokensOut: 0,
tps: null,
costUsd: null,
};
/**
* ColumnMetricsTracker — plain (non-React) imperative object for tracking per-column stream metrics.
* Uses simple mutable state (no React hooks) so it can be stored in a ref and called safely
* from async callbacks without violating Rules of Hooks.
*/
class ColumnMetricsTracker {
private startedAt: number | null = null;
private firstChunkAt: number | null = null;
private finishedAt: number | null = null;
private tokensOut = 0;
private tokensIn = 0;
reset() {
this.startedAt = null;
this.firstChunkAt = null;
this.finishedAt = null;
this.tokensOut = 0;
this.tokensIn = 0;
}
start() {
this.startedAt = Date.now();
}
onFirstChunk() {
if (this.firstChunkAt == null) {
this.firstChunkAt = Date.now();
}
}
onChunk(n: number) {
this.tokensOut += n;
}
finish(usage?: { prompt_tokens?: number; completion_tokens?: number }) {
this.finishedAt = Date.now();
if (usage?.prompt_tokens != null) this.tokensIn = usage.prompt_tokens;
if (usage?.completion_tokens != null) this.tokensOut = usage.completion_tokens;
}
getMetrics(): StreamMetrics {
const { startedAt, firstChunkAt, finishedAt, tokensOut, tokensIn } = this;
const ttftMs = startedAt != null && firstChunkAt != null ? firstChunkAt - startedAt : null;
const totalMs = startedAt != null && finishedAt != null ? finishedAt - startedAt : null;
const tps =
totalMs != null && totalMs > 0 && tokensOut > 0 ? (tokensOut / totalMs) * 1000 : null;
return { ttftMs, totalMs, tokensIn, tokensOut, tps, costUsd: null };
}
}
/**
* CompareTab — up to 4 parallel streaming columns (D10 + D19 + D22).
*
* Streaming uses native fetch + ReadableStream (not EventSource).
* Each column has its own AbortController.
* Cmd+K (or Ctrl+K) focuses the "add column" model input.
*/
export default function CompareTab({ configState }: CompareTabProps) {
const [columns, setColumns] = useState<ColumnState[]>(() => [
{
id: crypto.randomUUID(),
model: configState.model,
status: "idle",
metrics: INITIAL_METRICS,
response: "",
},
]);
// AbortControllers per column id
const controllersRef = useRef<Map<string, AbortController>>(new Map());
// Metrics trackers per column id (plain class instances, no React hooks)
const metricsTrackersRef = useRef<Map<string, ColumnMetricsTracker>>(new Map());
// Input for model name when adding a column
const [newModel, setNewModel] = useState("");
const addInputRef = useRef<HTMLInputElement>(null);
// Cmd+K / Ctrl+K shortcut to focus model input (D10)
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
addInputRef.current?.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
function getOrCreateTracker(id: string): ColumnMetricsTracker {
if (!metricsTrackersRef.current.has(id)) {
metricsTrackersRef.current.set(id, new ColumnMetricsTracker());
}
return metricsTrackersRef.current.get(id)!;
}
function addColumn() {
if (columns.length >= MAX_COLUMNS) return;
const model = newModel.trim() || configState.model;
const id = crypto.randomUUID();
setColumns((prev) => [
...prev,
{ id, model, status: "idle", metrics: INITIAL_METRICS, response: "" },
]);
setNewModel("");
}
function removeColumn(id: string) {
controllersRef.current.get(id)?.abort();
controllersRef.current.delete(id);
metricsTrackersRef.current.delete(id);
setColumns((prev) => prev.filter((c) => c.id !== id));
}
function cancelColumn(id: string) {
controllersRef.current.get(id)?.abort();
}
function cancelAll() {
for (const ctrl of controllersRef.current.values()) {
ctrl.abort();
}
}
function updateColumn(id: string, patch: Partial<ColumnState>) {
setColumns((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)));
}
/**
* Stream a single column's SSE request (D19).
*/
async function streamColumn(col: ColumnState): Promise<void> {
const tracker = getOrCreateTracker(col.id);
tracker.reset();
const controller = new AbortController();
controllersRef.current.set(col.id, controller);
updateColumn(col.id, { status: "streaming", response: "", metrics: INITIAL_METRICS });
tracker.start();
const body: Record<string, unknown> = {
model: col.model,
stream: true,
messages: [
...(configState.systemPrompt
? [{ role: "system", content: configState.systemPrompt }]
: []),
],
};
const { params } = configState;
if (params.temperature != null) body["temperature"] = params.temperature;
if (params.max_tokens != null) body["max_tokens"] = params.max_tokens;
if (params.top_p != null) body["top_p"] = params.top_p;
if (params.presence_penalty != null) body["presence_penalty"] = params.presence_penalty;
if (params.frequency_penalty != null) body["frequency_penalty"] = params.frequency_penalty;
if (params.seed != null) body["seed"] = params.seed;
if (params.stop != null) body["stop"] = params.stop;
let accumulated = "";
let firstChunk = true;
try {
const res = await fetch(`${configState.baseUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "Unknown error");
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
}
if (!res.body) throw new Error("No response body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = 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) {
if (!line.startsWith("data: ")) continue;
const data = line.slice("data: ".length).trim();
if (data === "[DONE]") continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(data) as Record<string, unknown>;
} catch {
continue;
}
const choices =
(parsed["choices"] as Array<Record<string, unknown>> | undefined) ?? [];
const delta = choices[0]?.["delta"] as Record<string, unknown> | undefined;
const content = delta?.["content"];
if (typeof content === "string" && content !== "") {
if (firstChunk) {
tracker.onFirstChunk();
firstChunk = false;
}
accumulated += content;
tracker.onChunk(1);
updateColumn(col.id, {
response: accumulated,
metrics: tracker.getMetrics(),
});
}
const usage = parsed["usage"] as
| { prompt_tokens?: number; completion_tokens?: number }
| undefined;
if (usage != null) {
tracker.finish(usage);
}
}
}
tracker.finish();
updateColumn(col.id, {
status: "done",
metrics: tracker.getMetrics(),
});
} catch (err) {
if (controller.signal.aborted) {
updateColumn(col.id, { status: "idle" });
return;
}
const message = err instanceof Error ? err.message : String(err);
updateColumn(col.id, {
status: "error",
errorMessage: message,
metrics: tracker.getMetrics(),
});
}
}
/**
* Run all columns in parallel (D10).
*/
async function runAll() {
cancelAll();
await Promise.allSettled(columns.map((col) => streamColumn(col)));
}
const isAnyStreaming = columns.some((c) => c.status === "streaming");
const atColumnLimit = columns.length >= MAX_COLUMNS;
const displayColumns: CompareColumnData[] = columns.map((c) => ({
id: c.id,
model: c.model,
status: c.status,
metrics: c.metrics,
response: c.response,
errorMessage: c.errorMessage,
}));
return (
<div className="flex flex-col h-full">
{/* Compare toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-bg-alt shrink-0">
{/* Run / Cancel all */}
{isAnyStreaming ? (
<button
onClick={cancelAll}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-destructive/10 text-destructive border border-destructive/30 hover:bg-destructive/20 transition-colors"
aria-label="Cancel all streams"
>
<span className="material-symbols-outlined text-[14px]">stop</span>
Cancel all
</button>
) : (
<button
onClick={() => void runAll()}
disabled={columns.length === 0}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Run all columns"
>
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
Run all
</button>
)}
{/* Add column */}
<div className="flex items-center gap-1.5 ml-2">
<input
ref={addInputRef}
type="text"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addColumn();
}}
placeholder="Model (Cmd+K)…"
disabled={atColumnLimit}
className="text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main w-40 disabled:opacity-40"
aria-label="Model name for new column"
/>
<button
onClick={addColumn}
disabled={atColumnLimit}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={atColumnLimit ? `Maximum ${MAX_COLUMNS} columns` : "Add column"}
aria-label="Add model column"
>
<span className="material-symbols-outlined text-[14px]">add</span>
Add model
</button>
</div>
{/* Column count indicator */}
<span className="ml-auto text-[11px] text-text-muted">
{columns.length}/{MAX_COLUMNS} columns
</span>
</div>
{/* Columns area */}
<div
className="flex-1 grid overflow-hidden"
style={{
gridTemplateColumns: `repeat(${Math.max(columns.length, 1)}, minmax(0, 1fr))`,
}}
>
{displayColumns.map((col) => (
<CompareColumn
key={col.id}
column={col}
onCancel={cancelColumn}
onRemove={removeColumn}
/>
))}
{columns.length === 0 && (
<div className="flex items-center justify-center h-full text-text-muted col-span-4">
<div className="text-center space-y-2">
<span className="material-symbols-outlined text-[48px] text-text-muted/30">
compare
</span>
<p className="text-sm">Add a model column to compare</p>
<p className="text-xs text-text-muted/60">
Up to {MAX_COLUMNS} models simultaneously
</p>
</div>
</div>
)}
</div>
</div>
);
}