Merge branch 'feat/playground-ui-advanced-F7' into feat/playground-search-i18n-docs-F9

This commit is contained in:
diegosouzapw
2026-05-28 11:26:17 -03:00
20 changed files with 3374 additions and 25 deletions

View File

@@ -13,6 +13,8 @@ import type { StreamMetrics } from "@/shared/schemas/playground";
// Lazy-load tabs to reduce initial bundle size
const ChatTab = dynamic(() => import("./components/tabs/ChatTab"), { ssr: false });
const ApiTab = dynamic(() => import("./components/tabs/ApiTab"), { ssr: false });
const CompareTab = dynamic(() => import("./components/tabs/CompareTab"), { ssr: false });
const BuildTab = dynamic(() => import("./components/tabs/BuildTab"), { ssr: false });
const INITIAL_METRICS: StreamMetrics = {
ttftMs: null,
@@ -74,6 +76,13 @@ export function PlaygroundStudio() {
activeTab={effectiveTab}
onTabChange={handleTabChange}
metrics={metrics}
exportState={{
endpoint: configState.endpoint,
baseUrl: configState.baseUrl,
model: configState.model,
systemPrompt: configState.systemPrompt,
params: configState.params,
}}
/>
{/* Main content area: tab content + config pane */}
@@ -84,27 +93,13 @@ export function PlaygroundStudio() {
<ChatTab configState={configState} onMetricsUpdate={handleMetricsUpdate} />
)}
{effectiveTab === "compare" && (
<div className="flex items-center justify-center h-full text-text-muted">
<div className="text-center space-y-2">
<span className="material-symbols-outlined text-[48px] text-text-muted/30">
compare
</span>
<p className="text-sm">Compare tab F7 implementation pending</p>
</div>
</div>
<CompareTab configState={configState} />
)}
{effectiveTab === "api" && (
<ApiTab configState={configState} />
)}
{effectiveTab === "build" && (
<div className="flex items-center justify-center h-full text-text-muted">
<div className="text-center space-y-2">
<span className="material-symbols-outlined text-[48px] text-text-muted/30">
build
</span>
<p className="text-sm">Build tab F7 implementation pending</p>
</div>
</div>
<BuildTab configState={configState} />
)}
</div>

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,157 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx
import { useState, useEffect, useCallback } from "react";
import type { PlaygroundState, ExportLanguage } from "@/lib/playground/codeExport";
import { exportAllLanguages, API_KEY_PLACEHOLDER } from "@/lib/playground/codeExport";
interface ExportCodeModalProps {
state: PlaygroundState;
onClose: () => void;
}
const LANGUAGE_TABS: Array<{ id: ExportLanguage; label: string }> = [
{ id: "curl", label: "curl" },
{ id: "python", label: "Python" },
{ id: "typescript", label: "TypeScript" },
];
/**
* ExportCodeModal — shows curl / Python / TypeScript snippets for the current playground state.
*
* Security: always uses API_KEY_PLACEHOLDER ("$OMNIROUTE_API_KEY") — never a real key (D11 / Hard Rule #1).
*/
export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps) {
const [activeLanguage, setActiveLanguage] = useState<ExportLanguage>("curl");
const [copied, setCopied] = useState(false);
// Generate all snippets once (state is passed in from parent, not re-fetched).
const snippets = exportAllLanguages(state);
const currentCode = snippets[activeLanguage];
// Verify that no real API key is embedded (assertion — Hard Rule #1 / D11).
// The regex checks for typical API key patterns (sk-, or other 16+ char alphanumeric strings
// that are NOT the placeholder).
const hasRealKey = /sk-[A-Za-z0-9_-]{16,}/.test(currentCode);
const handleCopy = useCallback(async () => {
if (hasRealKey) return; // Never copy if somehow a real key slipped through
try {
await navigator.clipboard.writeText(currentCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API unavailable (e.g. insecure context) — fail silently
}
}, [currentCode, hasRealKey]);
// Close on Escape
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Export code"
>
<div
className="bg-surface border border-border rounded-xl w-[640px] max-w-[96vw] max-h-[80vh] flex flex-col shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Modal header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-text-muted">&lt;/&gt;</span>
<h2 className="text-sm font-semibold text-text-main">Export code</h2>
</div>
<button
onClick={onClose}
className="p-1 rounded text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Close export modal"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
{/* Language tabs */}
<div className="flex items-center gap-1 px-4 pt-3 shrink-0" role="tablist">
{LANGUAGE_TABS.map((lang) => (
<button
key={lang.id}
role="tab"
aria-selected={activeLanguage === lang.id}
onClick={() => {
setActiveLanguage(lang.id);
setCopied(false);
}}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
activeLanguage === lang.id
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{lang.label}
</button>
))}
</div>
{/* Code block */}
<div className="flex-1 overflow-y-auto px-4 pt-3 pb-4 min-h-0">
{hasRealKey ? (
<div className="text-xs text-destructive bg-destructive/10 rounded p-3">
Security warning: export blocked a real API key was detected in the output.
Please reset your API key and try again.
</div>
) : (
<pre className="text-xs font-mono text-text-main bg-bg-alt border border-border rounded-lg p-4 overflow-x-auto whitespace-pre-wrap break-all">
<code>{currentCode}</code>
</pre>
)}
{/* Placeholder hint */}
<p className="text-[11px] text-text-muted mt-2">
Replace{" "}
<code className="font-mono text-primary">{API_KEY_PLACEHOLDER}</code>
{" "}with your actual API key, or set it as an environment variable.
</p>
</div>
{/* Footer with copy button */}
<div className="flex items-center justify-end gap-3 px-5 py-3 border-t border-border shrink-0">
<button
onClick={onClose}
className="text-xs px-3 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"
>
Close
</button>
<button
onClick={handleCopy}
disabled={hasRealKey}
className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded border transition-colors ${
copied
? "border-green-500 text-green-500 bg-green-500/10"
: "border-primary text-primary hover:bg-primary/10"
} disabled:opacity-40 disabled:cursor-not-allowed`}
aria-label={`Copy ${activeLanguage} code`}
>
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? "Copied!" : "Copy"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx
import { useState } from "react";
import { useImprovePrompt } from "../hooks/useImprovePrompt";
import type { ConfigState } from "./StudioConfigPane";
interface ImprovePromptButtonProps {
configState: ConfigState;
setConfigState: (s: ConfigState) => void;
}
/**
* ImprovePromptButton — "✨ Improve prompt" button with quota-consumption warning.
*
* Flow: click → confirmation modal → confirm → calls useImprovePrompt.improve()
* → on success, updates configState.systemPrompt and/or configState.params.
*
* D8: uses the model configured in the Config pane (never overrides with cheap model).
*/
export default function ImprovePromptButton({ configState, setConfigState }: ImprovePromptButtonProps) {
const { loading, error, improve } = useImprovePrompt();
const [confirmOpen, setConfirmOpen] = useState(false);
const [improveError, setImproveError] = useState<string | null>(null);
async function handleConfirm() {
setConfirmOpen(false);
setImproveError(null);
const model = configState.model.trim();
if (!model) {
setImproveError("Please set a model in the Config pane first.");
return;
}
const result = await improve({
system: configState.systemPrompt || undefined,
model,
});
if (result == null) {
setImproveError(error ?? "Improve prompt failed.");
return;
}
// Apply improved versions if returned
const next = { ...configState };
if (result.improvedSystem != null) {
next.systemPrompt = result.improvedSystem;
}
setConfigState(next);
}
const isDisabled = loading || !configState.model.trim();
return (
<>
<div className="flex flex-col gap-1">
<button
onClick={() => {
setImproveError(null);
setConfirmOpen(true);
}}
disabled={isDisabled}
className="flex items-center gap-1.5 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 self-start"
aria-label="Improve prompt using AI"
title={!configState.model.trim() ? "Set a model first" : "Improve your prompt with AI"}
>
<span className="text-[13px]"></span>
{loading ? "Improving…" : "Improve prompt"}
</button>
{improveError && (
<p className="text-[11px] text-destructive">{improveError}</p>
)}
</div>
{/* Quota confirmation modal */}
{confirmOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setConfirmOpen(false)}
role="dialog"
aria-modal="true"
aria-label="Confirm improve prompt"
>
<div
className="bg-surface border border-border rounded-xl p-5 w-80 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-3 mb-4">
<span className="text-[24px] shrink-0"></span>
<div>
<h3 className="text-sm font-semibold text-text-main mb-1">
Improve prompt
</h3>
<p className="text-xs text-text-muted">
This will send your current system prompt to{" "}
<code className="font-mono text-primary">{configState.model}</code>
{" "}to generate an improved version.
</p>
<p className="text-xs text-text-muted mt-1.5 font-medium">
This action will consume model quota.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setConfirmOpen(false)}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
Cancel
</button>
<button
onClick={() => void handleConfirm()}
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"
>
<span className="text-[12px]"></span>
Improve
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx
import { useEffect, useState } from "react";
import { usePresets } from "../hooks/usePresets";
import type { ConfigState } from "./StudioConfigPane";
interface PresetPickerProps {
configState: ConfigState;
setConfigState: (s: ConfigState) => void;
}
/**
* PresetPicker — load/save playground presets.
*
* Presets are stored in DB via /api/playground/presets (F3 backend).
* Load applies preset values to configState; Save opens a name-input modal.
*/
export default function PresetPicker({ configState, setConfigState }: PresetPickerProps) {
const { presets, loading, list, create, remove } = usePresets();
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [presetName, setPresetName] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Load presets on mount
useEffect(() => {
void list();
}, [list]);
function handleLoad(presetId: string) {
const preset = presets.find((p) => p.id === presetId);
if (!preset) return;
setConfigState({
...configState,
endpoint: preset.endpoint as ConfigState["endpoint"],
model: preset.model,
systemPrompt: preset.system ?? configState.systemPrompt,
params: {
...configState.params,
...(typeof preset.params === "object" && preset.params != null ? preset.params : {}),
},
});
}
async function handleSave() {
if (!presetName.trim()) {
setSaveError("Name is required");
return;
}
setSaving(true);
setSaveError(null);
const result = await create({
name: presetName.trim(),
endpoint: configState.endpoint,
model: configState.model,
system: configState.systemPrompt || null,
params: configState.params as Record<string, unknown>,
});
setSaving(false);
if (result == null) {
setSaveError("Failed to save preset");
return;
}
setSaveModalOpen(false);
setPresetName("");
}
function openSave() {
setSaveModalOpen(true);
setPresetName("");
setSaveError(null);
}
function closeSave() {
setSaveModalOpen(false);
setPresetName("");
setSaveError(null);
}
return (
<>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Presets
</span>
{/* Load preset select */}
<div className="flex items-center gap-2">
<select
disabled={loading || presets.length === 0}
onChange={(e) => {
if (e.target.value) {
handleLoad(e.target.value);
// Reset select to placeholder after loading
e.target.value = "";
}
}}
defaultValue=""
className="flex-1 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 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Load a preset"
>
<option value="" disabled>
{loading ? "Loading…" : presets.length === 0 ? "No presets" : "Load preset…"}
</option>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</select>
<button
onClick={openSave}
className="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 shrink-0"
aria-label="Save current config as preset"
>
Save
</button>
</div>
{/* Preset list with delete buttons (compact) */}
{presets.length > 0 && (
<div className="flex flex-col gap-1">
{presets.map((preset) => (
<div
key={preset.id}
className="flex items-center justify-between text-[11px] text-text-muted hover:text-text-main group"
>
<button
onClick={() => handleLoad(preset.id)}
className="truncate text-left flex-1 hover:text-primary transition-colors"
aria-label={`Load preset "${preset.name}"`}
>
{preset.name}
<span className="ml-1.5 opacity-60">{preset.model}</span>
</button>
<button
onClick={() => void remove(preset.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 text-text-muted hover:text-destructive transition-all"
aria-label={`Delete preset "${preset.name}"`}
>
<span className="material-symbols-outlined text-[12px]">delete</span>
</button>
</div>
))}
</div>
)}
</div>
{/* Save preset modal */}
{saveModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={closeSave}
role="dialog"
aria-modal="true"
aria-label="Save preset"
>
<div
className="bg-surface border border-border rounded-xl p-5 w-80 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold text-text-main mb-4">Save preset</h3>
<div className="flex flex-col gap-3">
<input
type="text"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSave();
if (e.key === "Escape") closeSave();
}}
placeholder="Preset name"
autoFocus
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
{saveError && (
<p className="text-xs text-destructive">{saveError}</p>
)}
<div className="flex items-center justify-end gap-2">
<button
onClick={closeSave}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
Cancel
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
className="text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</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,143 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx
import { useState } from "react";
import { useStructuredOutput } from "../hooks/useStructuredOutput";
import type { StructuredOutputSchemaInput } from "../hooks/useStructuredOutput";
interface StructuredOutputEditorProps {
structuredOutput: ReturnType<typeof useStructuredOutput>;
}
const DEFAULT_SCHEMA: StructuredOutputSchemaInput = {
name: "my_schema",
schema: {
type: "object",
properties: {
result: { type: "string" },
},
required: ["result"],
},
strict: true,
};
/**
* StructuredOutputEditor — toggle JSON mode on/off + edit JSON schema.
*
* When enabled, the Build tab sends response_format: { type: "json_schema", json_schema: {...} }.
* Schema validation is client-side via Zod StructuredOutputSchema (D9).
*/
export default function StructuredOutputEditor({ structuredOutput }: StructuredOutputEditorProps) {
const { enabled, schema, error, setEnabled, setSchema } = structuredOutput;
const [schemaRaw, setSchemaRaw] = useState(
JSON.stringify(schema ?? DEFAULT_SCHEMA, null, 2),
);
const [nameField, setNameField] = useState(schema?.name ?? "my_schema");
const [parseError, setParseError] = useState<string | null>(null);
function handleValidate() {
let parsed: unknown;
try {
parsed = JSON.parse(schemaRaw);
} catch {
setParseError("Invalid JSON");
return;
}
setParseError(null);
// The parsed JSON should be the inner schema object (not the full response_format wrapper)
const input: StructuredOutputSchemaInput = {
name: nameField.trim() || "my_schema",
schema: parsed as Record<string, unknown>,
strict: true,
};
setSchema(input);
}
return (
<div className="flex flex-col gap-3">
{/* Toggle */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-text-main">JSON mode</span>
<span className="text-[11px] text-text-muted">
Forces response_format: json_schema
</span>
</div>
<button
role="switch"
aria-checked={enabled}
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex w-10 h-5 rounded-full transition-colors ${
enabled ? "bg-primary" : "bg-text-muted/30"
}`}
aria-label={enabled ? "Disable JSON mode" : "Enable JSON mode"}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{/* Schema editor (shown only when enabled) */}
{enabled && (
<div className="flex flex-col gap-2 border border-border rounded-lg p-3 bg-surface">
{/* Schema name */}
<div className="flex flex-col gap-1">
<label className="text-[10px] text-text-muted uppercase tracking-wider">
Schema name
</label>
<input
type="text"
value={nameField}
onChange={(e) => setNameField(e.target.value)}
placeholder="my_schema"
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
</div>
{/* Schema JSON textarea */}
<div className="flex flex-col gap-1">
<label className="text-[10px] text-text-muted uppercase tracking-wider">
JSON schema
</label>
<textarea
value={schemaRaw}
onChange={(e) => setSchemaRaw(e.target.value)}
rows={8}
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
aria-label="JSON schema editor"
/>
</div>
{/* Errors */}
{(parseError ?? error) && (
<p className="text-xs text-destructive">
{parseError ?? error}
</p>
)}
{/* Status — show when schema is set and no errors */}
{schema != null && !parseError && !error && (
<p className="text-xs text-green-600 dark:text-green-400">
Schema validated
</p>
)}
{/* Validate button */}
<button
onClick={handleValidate}
className="text-xs px-3 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 self-start"
>
Validate schema
</button>
</div>
)}
</div>
);
}

View File

@@ -6,6 +6,8 @@ import { useState } from "react";
import ParamSliders, { type PlaygroundParams } from "./ParamSliders";
import type { PlaygroundEndpoint } from "@/lib/playground/codeExport";
import { endpointToPath } from "@/lib/playground/codeExport";
import PresetPicker from "./PresetPicker";
import ImprovePromptButton from "./ImprovePromptButton";
export interface ConfigState {
endpoint: PlaygroundEndpoint;
@@ -82,8 +84,8 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
</div>
<div className="flex flex-col gap-5 p-4">
{/* SLOT_PRESETS: F7 substituirá com PresetPicker */}
{/* SLOT_PRESETS */}
{/* PresetPicker — injected by F7 */}
<PresetPicker configState={configState} setConfigState={setConfigState} />
{/* Endpoint */}
<div className="flex flex-col gap-1.5">
@@ -129,8 +131,8 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
rows={4}
className="w-full 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 resize-y"
/>
{/* SLOT_IMPROVE: F7 substituirá com ImprovePromptButton */}
{/* SLOT_IMPROVE */}
{/* ImprovePromptButton — injected by F7 */}
<ImprovePromptButton configState={configState} setConfigState={setConfigState} />
</div>
{/* Param sliders */}

View File

@@ -4,7 +4,9 @@
import { useState } from "react";
import TokenCostCounter from "./TokenCostCounter";
import ExportCodeModal from "./ExportCodeModal";
import type { StreamMetrics } from "@/shared/schemas/playground";
import type { PlaygroundState } from "@/lib/playground/codeExport";
export type StudioTab = "chat" | "compare" | "api" | "build";
@@ -12,6 +14,8 @@ interface StudioTopBarProps {
activeTab: StudioTab;
onTabChange: (tab: StudioTab) => void;
metrics: StreamMetrics;
/** Optional playground state for the Export code modal. If omitted, a minimal state is used. */
exportState?: PlaygroundState;
}
interface TabConfig {
@@ -29,9 +33,9 @@ const TABS: TabConfig[] = [
/**
* Top bar with tab switcher, token/cost counter, and export code button.
* Export code modal is a placeholder in F6; F7 replaces it with ExportCodeModal.
* Export code modal uses ExportCodeModal (F7) when exportState is provided.
*/
export default function StudioTopBar({ activeTab, onTabChange, metrics }: StudioTopBarProps) {
export default function StudioTopBar({ activeTab, onTabChange, metrics, exportState }: StudioTopBarProps) {
const [exportOpen, setExportOpen] = useState(false);
return (
@@ -77,8 +81,11 @@ export default function StudioTopBar({ activeTab, onTabChange, metrics }: Studio
</div>
</div>
{/* Export code placeholder modal — F7 replaces with ExportCodeModal */}
{exportOpen && (
{/* Export code modal — uses ExportCodeModal (F7) */}
{exportOpen && exportState != null && (
<ExportCodeModal state={exportState} onClose={() => setExportOpen(false)} />
)}
{exportOpen && exportState == null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={() => setExportOpen(false)}
@@ -98,7 +105,7 @@ export default function StudioTopBar({ activeTab, onTabChange, metrics }: Studio
</button>
</div>
<p className="text-sm text-text-muted">
Export code modal F7 implementation pending.
No playground state available to export.
</p>
</div>
</div>

View File

@@ -0,0 +1,254 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx
import { useState } from "react";
import { useToolsBuilder } from "../hooks/useToolsBuilder";
import type { ToolDefinition } from "@/lib/playground/codeExport";
interface ToolsBuilderProps {
toolsBuilder: ReturnType<typeof useToolsBuilder>;
}
const EMPTY_TOOL: { name: string; description: string; parametersRaw: string } = {
name: "",
description: "",
parametersRaw: JSON.stringify({ type: "object", properties: {}, required: [] }, null, 2),
};
/**
* ToolsBuilder — client-only UI (D9) for editing the tools[] array.
*
* Each tool has: name, description, and a JSON schema textarea for parameters.
* Validation uses Zod ToolDefinitionSchema (via useToolsBuilder).
*/
export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
const { tools, errors, add, remove, update } = toolsBuilder;
// Form state for the "Add tool" form
const [form, setForm] = useState(EMPTY_TOOL);
const [formError, setFormError] = useState<string | null>(null);
// Per-tool editing state: index → draft values
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState<{ name: string; description: string; parametersRaw: string } | null>(null);
function handleAdd() {
let parsed: unknown;
try {
parsed = JSON.parse(form.parametersRaw);
} catch {
setFormError("Parameters must be valid JSON");
return;
}
const tool: ToolDefinition = {
type: "function",
function: {
name: form.name.trim(),
description: form.description.trim() || undefined,
parameters: parsed as Record<string, unknown>,
},
};
const result = add(tool);
if (!result.ok) {
setFormError(result.error);
return;
}
setForm(EMPTY_TOOL);
setFormError(null);
}
function startEdit(index: number) {
const tool = tools[index];
setEditingIndex(index);
setEditDraft({
name: tool.function.name,
description: tool.function.description ?? "",
parametersRaw: JSON.stringify(tool.function.parameters, null, 2),
});
}
function handleUpdate(index: number) {
if (editDraft == null) return;
let parsed: unknown;
try {
parsed = JSON.parse(editDraft.parametersRaw);
} catch {
return; // Keep edit mode open — show nothing or rely on inline error
}
const tool: ToolDefinition = {
type: "function",
function: {
name: editDraft.name.trim(),
description: editDraft.description.trim() || undefined,
parameters: parsed as Record<string, unknown>,
},
};
const result = update(index, tool);
if (result.ok) {
setEditingIndex(null);
setEditDraft(null);
}
}
function cancelEdit() {
setEditingIndex(null);
setEditDraft(null);
}
return (
<div className="flex flex-col gap-4">
{/* Existing tools */}
{tools.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Tools ({tools.length})
</span>
{tools.map((tool, idx) => {
const isEditing = editingIndex === idx;
const toolError = errors.get(idx);
return (
<div
key={idx}
className="border border-border rounded-lg overflow-hidden"
>
{/* Tool header */}
<div className="flex items-center justify-between px-3 py-2 bg-bg-alt">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[14px] text-text-muted">
function
</span>
<code className="text-xs font-mono text-text-main">
{tool.function.name}
</code>
{tool.function.description && (
<span className="text-[11px] text-text-muted truncate max-w-[200px]">
{tool.function.description}
</span>
)}
</div>
<div className="flex items-center gap-1">
{!isEditing && (
<button
onClick={() => startEdit(idx)}
className="p-1 rounded text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label={`Edit tool ${tool.function.name}`}
>
<span className="material-symbols-outlined text-[14px]">edit</span>
</button>
)}
<button
onClick={() => remove(idx)}
className="p-1 rounded text-text-muted hover:text-destructive transition-colors"
aria-label={`Remove tool ${tool.function.name}`}
>
<span className="material-symbols-outlined text-[14px]">delete</span>
</button>
</div>
</div>
{/* Edit form */}
{isEditing && editDraft != null && (
<div className="flex flex-col gap-2 p-3 bg-surface">
<input
type="text"
value={editDraft.name}
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
placeholder="Function name"
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
<input
type="text"
value={editDraft.description}
onChange={(e) => setEditDraft({ ...editDraft, description: e.target.value })}
placeholder="Description (optional)"
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
<textarea
value={editDraft.parametersRaw}
onChange={(e) => setEditDraft({ ...editDraft, parametersRaw: e.target.value })}
rows={6}
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
aria-label="JSON schema for parameters"
/>
{toolError && (
<p className="text-xs text-destructive">{toolError}</p>
)}
<div className="flex items-center gap-2">
<button
onClick={() => handleUpdate(idx)}
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:bg-primary/90 transition-colors"
>
Save
</button>
<button
onClick={cancelEdit}
className="text-xs px-2.5 py-1 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* Add tool form */}
<div className="border border-border rounded-lg p-3 flex flex-col gap-2 bg-surface">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Add tool
</span>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Function name *"
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
<input
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Description (optional)"
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-text-muted uppercase tracking-wider">
Parameters (JSON schema)
</label>
<textarea
value={form.parametersRaw}
onChange={(e) => setForm({ ...form, parametersRaw: e.target.value })}
rows={6}
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
aria-label="JSON schema for parameters"
/>
</div>
{formError && (
<p className="text-xs text-destructive">{formError}</p>
)}
<button
onClick={handleAdd}
className="text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors self-start"
>
+ Add tool
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,399 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx
import { useRef, useState } from "react";
import { useToolsBuilder } from "../../hooks/useToolsBuilder";
import { useStructuredOutput } from "../../hooks/useStructuredOutput";
import ToolsBuilder from "../ToolsBuilder";
import StructuredOutputEditor from "../StructuredOutputEditor";
import MarkdownMessage from "../MarkdownMessage";
import type { ConfigState } from "../StudioConfigPane";
interface BuildTabProps {
configState: ConfigState;
}
interface ToolCall {
id: string;
function: {
name: string;
arguments: string;
};
}
interface Message {
role: "user" | "assistant" | "tool";
content: string;
toolCallId?: string;
}
interface ToolResultDraft {
toolCallId: string;
functionName: string;
draft: string;
}
/**
* BuildTab — tools / function calling UI + structured output (D9).
*
* Runs /v1/chat/completions with:
* - tools[] if any are defined
* - response_format: json_schema if structured output is enabled
*
* When tool_calls appear in the response, shows each tool call with an input
* for the tool_result + "Send result" button to continue the conversation.
*/
export default function BuildTab({ configState }: BuildTabProps) {
const toolsBuilder = useToolsBuilder();
const structuredOutput = useStructuredOutput();
const [prompt, setPrompt] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const [running, setRunning] = useState(false);
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
const [toolResultDrafts, setToolResultDrafts] = useState<ToolResultDraft[]>([]);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
error?: string;
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
function buildRequestBody(msgs: Message[]) {
const body: Record<string, unknown> = {
model: configState.model,
stream: false,
messages: [
...(configState.systemPrompt
? [{ role: "system", content: configState.systemPrompt }]
: []),
...msgs.map((m) => {
if (m.role === "tool") {
return {
role: "tool",
content: m.content,
tool_call_id: m.toolCallId ?? "",
};
}
return { role: m.role, content: m.content };
}),
],
};
// Attach tools if any defined
if (toolsBuilder.tools.length > 0) {
body["tools"] = toolsBuilder.tools;
}
// Attach response_format if JSON mode enabled and schema set
if (structuredOutput.enabled && structuredOutput.schema != null) {
body["response_format"] = {
type: "json_schema",
json_schema: structuredOutput.schema,
};
}
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;
return body;
}
async function runRequest(msgs: Message[]) {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setRunning(true);
setToolCalls([]);
setToolResultDrafts([]);
setValidationResult(null);
try {
const res = await fetch(`${configState.baseUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildRequestBody(msgs)),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "Unknown error");
const errMsg = text.slice(0, 300);
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${errMsg}` }]);
return;
}
const data = (await res.json()) as {
choices?: Array<{
message?: {
content?: string | null;
tool_calls?: ToolCall[];
};
}>;
};
const choice = data.choices?.[0];
const assistantMsg = choice?.message;
if (assistantMsg == null) {
setMessages((prev) => [
...prev,
{ role: "assistant", content: "(empty response)" },
]);
return;
}
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
// Tool call response — show tool call UI
setToolCalls(assistantMsg.tool_calls);
setMessages((prev) => [
...prev,
{
role: "assistant",
content: assistantMsg.content ?? "(tool call)",
},
]);
// Initialize drafts
setToolResultDrafts(
assistantMsg.tool_calls.map((tc) => ({
toolCallId: tc.id,
functionName: tc.function.name,
draft: "",
})),
);
} else {
const content = assistantMsg.content ?? "";
setMessages((prev) => [
...prev,
{ role: "assistant", content },
]);
// Validate structured output response if enabled
if (structuredOutput.enabled && structuredOutput.schema != null) {
const validation = structuredOutput.validateResponse(content);
setValidationResult(validation);
}
}
} catch (err) {
if (controller.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${msg}` }]);
} finally {
setRunning(false);
}
}
async function handleRun() {
if (!prompt.trim() && messages.length === 0) return;
const newMessages: Message[] = [
...messages,
...(prompt.trim() ? [{ role: "user" as const, content: prompt }] : []),
];
if (prompt.trim()) {
setMessages(newMessages);
setPrompt("");
}
await runRequest(newMessages);
}
async function sendToolResult(toolCallId: string) {
const draft = toolResultDrafts.find((d) => d.toolCallId === toolCallId);
if (draft == null) return;
const toolResultMsg: Message = {
role: "tool",
content: draft.draft,
toolCallId,
};
const newMessages = [...messages, toolResultMsg];
setMessages(newMessages);
setToolResultDrafts([]);
setToolCalls([]);
await runRequest(newMessages);
}
function clearConversation() {
setMessages([]);
setToolCalls([]);
setToolResultDrafts([]);
setValidationResult(null);
setPrompt("");
}
return (
<div className="flex h-full overflow-hidden">
{/* Left panel: conversation + run */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-bg-alt shrink-0">
<button
onClick={() => void handleRun()}
disabled={running || (!prompt.trim() && messages.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"
>
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
{running ? "Running…" : "Run"}
</button>
{messages.length > 0 && (
<button
onClick={clearConversation}
className="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"
>
Clear
</button>
)}
<div className="ml-auto flex items-center gap-2 text-[11px] text-text-muted">
{toolsBuilder.tools.length > 0 && (
<span className="px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{toolsBuilder.tools.length} tool{toolsBuilder.tools.length !== 1 ? "s" : ""}
</span>
)}
{structuredOutput.enabled && (
<span className="px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">
JSON mode
</span>
)}
</div>
</div>
{/* Conversation history */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${
msg.role === "user"
? "bg-primary text-white"
: msg.role === "tool"
? "bg-yellow-500/10 border border-yellow-500/30 text-text-main"
: "bg-bg-alt border border-border text-text-main"
}`}
>
{msg.role === "user" ? (
<span className="whitespace-pre-wrap">{msg.content}</span>
) : (
<MarkdownMessage content={msg.content} />
)}
</div>
</div>
))}
{/* Tool call UI */}
{toolCalls.length > 0 && (
<div className="space-y-2">
{toolCalls.map((tc) => {
const draft = toolResultDrafts.find((d) => d.toolCallId === tc.id);
return (
<div
key={tc.id}
className="border border-amber-500/40 rounded-lg p-3 bg-amber-500/5"
>
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[14px] text-amber-500">
function
</span>
<code className="text-xs font-mono text-text-main">
{tc.function.name}
</code>
</div>
<pre className="text-[11px] font-mono text-text-muted bg-bg-alt rounded p-2 overflow-x-auto mb-2 whitespace-pre-wrap break-all">
{tc.function.arguments}
</pre>
<div className="flex flex-col gap-2">
<label className="text-[10px] text-text-muted uppercase tracking-wider">
Tool result
</label>
<textarea
value={draft?.draft ?? ""}
onChange={(e) =>
setToolResultDrafts((prev) =>
prev.map((d) =>
d.toolCallId === tc.id ? { ...d, draft: e.target.value } : d,
),
)
}
rows={3}
placeholder="Enter tool result…"
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
/>
<button
onClick={() => void sendToolResult(tc.id)}
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:bg-primary/90 transition-colors self-start"
>
Send tool result
</button>
</div>
</div>
);
})}
</div>
)}
{/* Structured output validation */}
{validationResult != null && (
<div
className={`text-xs rounded-lg px-3 py-2 border ${
validationResult.valid
? "border-green-500/40 bg-green-500/5 text-green-600 dark:text-green-400"
: "border-destructive/40 bg-destructive/5 text-destructive"
}`}
>
{validationResult.valid ? "✅ Valid JSON schema response" : `${validationResult.error}`}
</div>
)}
</div>
{/* Prompt input */}
<div className="px-4 py-3 border-t border-border shrink-0">
<div className="flex items-end gap-2">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleRun();
}
}}
placeholder="Enter your message… (Enter to send, Shift+Enter for newline)"
rows={2}
className="flex-1 text-sm bg-surface border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-none"
/>
</div>
</div>
</div>
{/* Right panel: tools + structured output config */}
<div className="w-72 shrink-0 border-l border-border bg-bg-alt overflow-y-auto p-4 flex flex-col gap-6">
{/* Tools section */}
<div>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
Function calling
</h3>
<ToolsBuilder toolsBuilder={toolsBuilder} />
</div>
{/* Structured output section */}
<div>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
Structured output
</h3>
<StructuredOutputEditor structuredOutput={structuredOutput} />
</div>
</div>
</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>
);
}