Merge branch 'feat/playground-ui-advanced-F7' into refactor/pages-v3-C-playground-search-tools

This commit is contained in:
diegosouzapw
2026-05-28 12:31:22 -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>
);
}

View File

@@ -0,0 +1,221 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("remark-gfm", () => ({ default: () => {} }));
vi.mock("react-markdown", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="markdown-content">{children}</div>
),
}));
function setInputValue(
el: HTMLTextAreaElement | HTMLInputElement,
value: string,
): void {
const nativeSetter =
el instanceof HTMLTextAreaElement
? Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set
: Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
const { DEFAULT_PARAMS } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders"
);
const { default: BuildTab } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab"
);
const BASE_CONFIG = {
endpoint: "chat.completions" as const,
baseUrl: "http://localhost:20128",
model: "openai/gpt-4o",
systemPrompt: "You are helpful.",
params: { ...DEFAULT_PARAMS },
};
if (typeof Element.prototype.scrollIntoView === "undefined") {
Object.defineProperty(Element.prototype, "scrollIntoView", {
value: () => {},
writable: true,
configurable: true,
});
}
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderBuildTab(config = BASE_CONFIG): HTMLDivElement {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<BuildTab configState={config} />);
});
containers.push({ root, el });
return el;
}
afterEach(() => {
for (const { root, el } of containers) {
act(() => root.unmount());
el.remove();
}
containers.length = 0;
vi.restoreAllMocks();
});
describe("BuildTab", () => {
it("renders Run button", () => {
const el = renderBuildTab();
const runBtn = el.querySelector("[class*='bg-primary']");
expect(runBtn?.textContent).toContain("Run");
});
it("renders Function calling section", () => {
const el = renderBuildTab();
expect(el.textContent).toContain("Function calling");
expect(el.textContent).toContain("Add tool");
});
it("renders Structured output section", () => {
const el = renderBuildTab();
expect(el.textContent).toContain("Structured output");
expect(el.textContent).toContain("JSON mode");
});
it("adds a tool and shows it in function calling UI", async () => {
const el = renderBuildTab();
// Find add tool form inputs in the right panel
const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf<HTMLInputElement>;
// The first or second input should be the function name
const nameInput = allInputs[0];
act(() => setInputValue(nameInput, "search_web"));
const addBtns = el.querySelectorAll("button");
const addToolBtn = Array.from(addBtns).find(
(b) => b.textContent?.trim() === "+ Add tool",
) as HTMLButtonElement;
expect(addToolBtn).not.toBeNull();
await act(async () => { addToolBtn.click(); });
expect(el.textContent).toContain("search_web");
expect(el.textContent).toContain("Tools (1)");
});
it("shows validation error for invalid JSON in tool params", async () => {
const el = renderBuildTab();
const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf<HTMLInputElement>;
act(() => setInputValue(allInputs[0], "bad_tool"));
// The parameters textarea is in the Add tool form section — it has default valid JSON.
// We need to find the textarea labeled "JSON schema for parameters" in the add form.
const paramsTextareas = Array.from(el.querySelectorAll("textarea")).filter(
(t) => t.getAttribute("aria-label") === "JSON schema for parameters",
);
// The last one is in the Add tool form (the first may be the message prompt textarea)
const paramsTextarea = paramsTextareas[paramsTextareas.length - 1] as HTMLTextAreaElement;
act(() => setInputValue(paramsTextarea, "NOT JSON {{{"));
const addBtns = el.querySelectorAll("button");
const addToolBtn = Array.from(addBtns).find(
(b) => b.textContent?.trim() === "+ Add tool",
) as HTMLButtonElement;
await act(async () => { addToolBtn.click(); });
expect(el.textContent).toContain("valid JSON");
});
it("enables JSON mode toggle and shows schema editor", async () => {
const el = renderBuildTab();
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
expect(toggle).not.toBeNull();
await act(async () => { toggle.click(); });
// JSON mode should be enabled
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(el.textContent).toContain("JSON schema");
});
it("shows tool badge in toolbar when tools are added", async () => {
const el = renderBuildTab();
const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf<HTMLInputElement>;
act(() => setInputValue(allInputs[0], "my_tool"));
const addBtns = el.querySelectorAll("button");
const addToolBtn = Array.from(addBtns).find(
(b) => b.textContent?.trim() === "+ Add tool",
) as HTMLButtonElement;
await act(async () => { addToolBtn.click(); });
// Badge "1 tool" should appear in toolbar
expect(el.textContent).toContain("1 tool");
});
it("calls /v1/chat/completions with tools array when Run is clicked", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({
choices: [{ message: { content: "Result", role: "assistant" } }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
),
) as typeof fetch,
);
const el = renderBuildTab();
// Add a tool
const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf<HTMLInputElement>;
act(() => setInputValue(allInputs[0], "tool_one"));
const addBtns = el.querySelectorAll("button");
const addToolBtn = Array.from(addBtns).find(
(b) => b.textContent?.trim() === "+ Add tool",
) as HTMLButtonElement;
await act(async () => { addToolBtn.click(); });
// Type a prompt
const promptTextarea = el.querySelector("textarea[placeholder*='message']") as HTMLTextAreaElement;
act(() => setInputValue(promptTextarea, "Run this tool"));
// Click Run
const runBtns = el.querySelectorAll("button");
const runBtn = Array.from(runBtns).find(
(b) => b.textContent?.includes("Run") && !b.textContent?.includes("Clear"),
) as HTMLButtonElement;
await act(async () => { runBtn.click(); });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// fetch should be called
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
const [, opts] = (vi.mocked(fetch) as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const body = JSON.parse(opts.body as string) as Record<string, unknown>;
expect(body["tools"]).toBeDefined();
expect(Array.isArray(body["tools"])).toBe(true);
});
it("shows JSON mode badge in toolbar when JSON mode is enabled", async () => {
const el = renderBuildTab();
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
await act(async () => { toggle.click(); });
expect(el.textContent).toContain("JSON mode");
});
});

View File

@@ -0,0 +1,158 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
vi.mock("remark-gfm", () => ({ default: () => {} }));
vi.mock("react-markdown", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="markdown-content">{children}</div>
),
}));
const { default: CompareColumn } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/CompareColumn"
);
type ColumnStatus = "idle" | "streaming" | "done" | "error";
const BASE_METRICS = {
ttftMs: null,
totalMs: null,
tokensIn: 0,
tokensOut: 0,
tps: null,
costUsd: null,
};
function makeColumn(overrides: Partial<{
id: string;
model: string;
status: ColumnStatus;
metrics: typeof BASE_METRICS;
response: string;
errorMessage: string;
}> = {}) {
return {
id: "col-1",
model: "openai/gpt-4o",
status: "idle" as ColumnStatus,
metrics: { ...BASE_METRICS },
response: "",
...overrides,
};
}
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderColumn(
column: ReturnType<typeof makeColumn>,
onCancel = vi.fn(),
onRemove = vi.fn(),
): HTMLDivElement {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<CompareColumn column={column} onCancel={onCancel} onRemove={onRemove} />,
);
});
containers.push({ root, el });
return el;
}
beforeEach(() => {
if (typeof Element.prototype.scrollIntoView === "undefined") {
Object.defineProperty(Element.prototype, "scrollIntoView", {
value: () => {},
writable: true,
configurable: true,
});
}
});
afterEach(() => {
for (const { root, el } of containers) {
act(() => root.unmount());
el.remove();
}
containers.length = 0;
});
describe("CompareColumn", () => {
it("renders model name in header", () => {
const el = renderColumn(makeColumn({ model: "anthropic/claude-3-opus" }));
expect(el.textContent).toContain("anthropic/claude-3-opus");
});
it("shows idle state with ready message", () => {
const el = renderColumn(makeColumn({ status: "idle" }));
expect(el.textContent).toContain("Ready to run");
});
it("shows streaming skeleton when status=streaming and response empty", () => {
const el = renderColumn(makeColumn({ status: "streaming", response: "" }));
expect(el.textContent).toContain("Waiting for response");
});
it("renders markdown response when streaming and response is non-empty", () => {
const el = renderColumn(
makeColumn({ status: "streaming", response: "Hello from model" }),
);
const markdown = el.querySelector("[data-testid='markdown-content']");
expect(markdown).not.toBeNull();
expect(markdown?.textContent).toContain("Hello from model");
});
it("shows error message when status=error", () => {
const el = renderColumn(
makeColumn({ status: "error", errorMessage: "Rate limit exceeded" }),
);
expect(el.textContent).toContain("Rate limit exceeded");
});
it("shows ProviderMetrics bar when streaming or done", () => {
const metrics = {
ttftMs: 234,
totalMs: 1200,
tokensIn: 142,
tokensOut: 38,
tps: 31.6,
costUsd: 0.002,
};
const el = renderColumn(makeColumn({ status: "done", metrics, response: "Done." }));
// Should show estimated
expect(el.textContent).toContain("estimated");
// Should show TTFT
expect(el.textContent).toContain("TTFT");
});
it("calls onCancel when cancel button clicked during streaming", () => {
const onCancel = vi.fn();
const el = renderColumn(makeColumn({ status: "streaming" }), onCancel);
const cancelBtn = el.querySelector("[aria-label='Cancel stream']") as HTMLButtonElement;
expect(cancelBtn).not.toBeNull();
act(() => cancelBtn.click());
expect(onCancel).toHaveBeenCalledWith("col-1");
});
it("calls onRemove when remove button clicked", () => {
const onRemove = vi.fn();
const el = renderColumn(makeColumn(), vi.fn(), onRemove);
const removeBtn = el.querySelector("[aria-label='Remove column for openai/gpt-4o']") as HTMLButtonElement;
expect(removeBtn).not.toBeNull();
act(() => removeBtn.click());
expect(onRemove).toHaveBeenCalledWith("col-1");
});
it("updates metrics displayed when metrics change (TTFT after first chunk)", () => {
const metricsWithTtft = { ...BASE_METRICS, ttftMs: 187 };
const el = renderColumn(
makeColumn({ status: "streaming", metrics: metricsWithTtft, response: "Hi" }),
);
expect(el.textContent).toContain("187ms");
});
});

View File

@@ -0,0 +1,261 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("remark-gfm", () => ({ default: () => {} }));
vi.mock("react-markdown", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="markdown-content">{children}</div>
),
}));
// crypto.randomUUID polyfill for jsdom
let _uuidCounter = 0;
if (typeof crypto === "undefined" || !crypto.randomUUID) {
Object.defineProperty(globalThis, "crypto", {
value: {
randomUUID: () => `test-uuid-${++_uuidCounter}`,
},
configurable: true,
});
} else {
vi.spyOn(crypto, "randomUUID").mockImplementation(() => `test-uuid-${++_uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`);
}
// Mock AbortController to track abort calls — use real AbortController but spy on abort()
const abortCallTracker = { calls: 0 };
const OriginalAbortController = globalThis.AbortController;
class MockAbortController extends OriginalAbortController {
abort() {
abortCallTracker.calls++;
super.abort();
}
}
vi.stubGlobal("AbortController", MockAbortController);
const { DEFAULT_PARAMS } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders"
);
const { default: CompareTab } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab"
);
const BASE_CONFIG = {
endpoint: "chat.completions" as const,
baseUrl: "http://localhost:20128",
model: "openai/gpt-4o",
systemPrompt: "You are helpful.",
params: { ...DEFAULT_PARAMS },
};
function buildSseResponse(content: string) {
const encoder = new TextEncoder();
const chunks = [
`data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`,
"data: [DONE]\n\n",
];
let idx = 0;
return new Response(
new ReadableStream({
pull(controller) {
if (idx < chunks.length) {
controller.enqueue(encoder.encode(chunks[idx++]));
} else {
controller.close();
}
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
if (typeof Element.prototype.scrollIntoView === "undefined") {
Object.defineProperty(Element.prototype, "scrollIntoView", {
value: () => {},
writable: true,
configurable: true,
});
}
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderCompareTab(config = BASE_CONFIG): HTMLDivElement {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<CompareTab configState={config} />);
});
containers.push({ root, el });
return el;
}
function setInputValue(el: HTMLInputElement, value: string) {
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeSetter?.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
afterEach(() => {
for (const { root, el } of containers) {
act(() => root.unmount());
el.remove();
}
containers.length = 0;
_uuidCounter = 0;
abortCallTracker.calls = 0;
vi.restoreAllMocks();
});
describe("CompareTab", () => {
it("renders initial column with model from configState", () => {
const el = renderCompareTab();
expect(el.textContent).toContain("openai/gpt-4o");
});
it("shows Run all button initially", () => {
const el = renderCompareTab();
const btn = el.querySelector("[aria-label='Run all columns']");
expect(btn).not.toBeNull();
});
it("shows Add model button", () => {
const el = renderCompareTab();
const btn = el.querySelector("[aria-label='Add model column']");
expect(btn).not.toBeNull();
});
it("adds a second column when Add model is clicked", async () => {
const el = renderCompareTab();
const input = el.querySelector("[aria-label='Model name for new column']") as HTMLInputElement;
const addBtn = el.querySelector("[aria-label='Add model column']") as HTMLButtonElement;
act(() => {
setInputValue(input, "anthropic/claude-3");
});
await act(async () => {
addBtn.click();
});
// Should show 2/4 columns
expect(el.textContent).toContain("2/4 columns");
expect(el.textContent).toContain("anthropic/claude-3");
});
it("disables Add model button after 4 columns", async () => {
const el = renderCompareTab();
const input = el.querySelector("[aria-label='Model name for new column']") as HTMLInputElement;
const addBtn = el.querySelector("[aria-label='Add model column']") as HTMLButtonElement;
// Add 3 more columns (already have 1)
for (let i = 0; i < 3; i++) {
act(() => setInputValue(input, `model-${i}`));
await act(async () => { addBtn.click(); });
}
// Now at 4/4
expect(el.textContent).toContain("4/4 columns");
// Button should be disabled
const disabledAddBtn = el.querySelector("[aria-label='Add model column']") as HTMLButtonElement;
expect(disabledAddBtn.disabled).toBe(true);
});
it("removes a column when remove button is clicked", async () => {
const el = renderCompareTab();
const input = el.querySelector("[aria-label='Model name for new column']") as HTMLInputElement;
const addBtn = el.querySelector("[aria-label='Add model column']") as HTMLButtonElement;
// Add a second column
act(() => setInputValue(input, "to-remove"));
await act(async () => { addBtn.click(); });
expect(el.textContent).toContain("2/4 columns");
// Remove the second column
const removeBtn = el.querySelector("[aria-label='Remove column for to-remove']") as HTMLButtonElement;
expect(removeBtn).not.toBeNull();
await act(async () => { removeBtn.click(); });
expect(el.textContent).toContain("1/4 columns");
expect(el.textContent).not.toContain("to-remove");
});
it("runs all streams in parallel when Run all is clicked", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(buildSseResponse("Hello"))) as typeof fetch,
);
const el = renderCompareTab();
// Add a second column
const input = el.querySelector("[aria-label='Model name for new column']") as HTMLInputElement;
const addBtn = el.querySelector("[aria-label='Add model column']") as HTMLButtonElement;
act(() => setInputValue(input, "model-2"));
await act(async () => { addBtn.click(); });
const runBtn = el.querySelector("[aria-label='Run all columns']") as HTMLButtonElement;
await act(async () => { runBtn.click(); });
// fetch should have been called twice (once per column)
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it("shows Cancel all when streams are running", async () => {
// Make fetch hang until aborted
vi.stubGlobal(
"fetch",
vi.fn(() => new Promise<Response>(() => {})) as typeof fetch,
);
const el = renderCompareTab();
const runBtn = el.querySelector("[aria-label='Run all columns']") as HTMLButtonElement;
act(() => { runBtn.click(); });
// Need to flush promises to get to streaming state
await act(async () => {
await Promise.resolve();
});
const cancelBtn = el.querySelector("[aria-label='Cancel all streams']");
expect(cancelBtn).not.toBeNull();
});
it("cancel all calls abort on all controllers", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => new Promise<Response>(() => {})) as typeof fetch,
);
const el = renderCompareTab();
// Add a second column
const input = el.querySelector("[aria-label='Model name for new column']") as HTMLInputElement;
const addBtn = el.querySelector("[aria-label='Add model column']") as HTMLButtonElement;
act(() => setInputValue(input, "model-2"));
await act(async () => { addBtn.click(); });
const runBtn = el.querySelector("[aria-label='Run all columns']") as HTMLButtonElement;
act(() => { runBtn.click(); });
await act(async () => { await Promise.resolve(); });
const cancelBtn = el.querySelector("[aria-label='Cancel all streams']") as HTMLButtonElement | null;
if (cancelBtn) {
act(() => { cancelBtn.click(); });
// AbortController.abort should have been called
expect(abortCallTracker.calls).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,151 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { API_KEY_PLACEHOLDER } from "../../../src/lib/playground/codeExport";
const { default: ExportCodeModal } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/ExportCodeModal"
);
const BASE_STATE = {
endpoint: "chat.completions" as const,
baseUrl: "http://localhost:20128",
model: "openai/gpt-4o",
systemPrompt: "You are helpful.",
messages: [{ role: "user" as const, content: "Hello" }],
stream: true,
};
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderModal(state = BASE_STATE, onClose = vi.fn()): HTMLDivElement {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<ExportCodeModal state={state} onClose={onClose} />);
});
containers.push({ root, el });
return el;
}
afterEach(() => {
for (const { root, el } of containers) {
act(() => root.unmount());
el.remove();
}
containers.length = 0;
vi.restoreAllMocks();
});
describe("ExportCodeModal", () => {
it("renders 3 language tabs: curl, Python, TypeScript", () => {
const el = renderModal();
const tabs = el.querySelectorAll("[role='tab']");
const labels = Array.from(tabs).map((t) => t.textContent?.trim());
expect(labels).toContain("curl");
expect(labels).toContain("Python");
expect(labels).toContain("TypeScript");
});
it("shows Export code title", () => {
const el = renderModal();
expect(el.textContent).toContain("Export code");
});
it("shows curl code with $OMNIROUTE_API_KEY placeholder", () => {
const el = renderModal();
const pre = el.querySelector("pre");
expect(pre?.textContent).toContain(API_KEY_PLACEHOLDER);
expect(pre?.textContent).toContain("$OMNIROUTE_API_KEY");
});
it("never contains a real API key pattern (sk-...) in any tab", async () => {
const el = renderModal();
const tabs = el.querySelectorAll("[role='tab']") as NodeListOf<HTMLButtonElement>;
for (const tab of Array.from(tabs)) {
await act(async () => { tab.click(); });
const pre = el.querySelector("pre");
const code = pre?.textContent ?? "";
// Real key regex — must not match
expect(code).not.toMatch(/sk-[A-Za-z0-9_-]{16,}/);
}
});
it("Copy button calls navigator.clipboard.writeText with code containing API_KEY_PLACEHOLDER", async () => {
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText: writeTextMock },
writable: true,
configurable: true,
});
const el = renderModal();
const copyBtn = el.querySelector("[aria-label='Copy curl code']") as HTMLButtonElement;
expect(copyBtn).not.toBeNull();
await act(async () => { copyBtn.click(); });
expect(writeTextMock).toHaveBeenCalledTimes(1);
const copiedText = writeTextMock.mock.calls[0][0] as string;
expect(copiedText).toContain(API_KEY_PLACEHOLDER);
// Must NOT contain a real key pattern
expect(copiedText).not.toMatch(/sk-[A-Za-z0-9_-]{16,}/);
});
it("switching tab to Python shows different code snippet", async () => {
const el = renderModal();
// Get initial curl code
const initialPre = el.querySelector("pre");
const curlCode = initialPre?.textContent ?? "";
// Click Python tab
const tabs = el.querySelectorAll("[role='tab']") as NodeListOf<HTMLButtonElement>;
const pythonTab = Array.from(tabs).find((t) => t.textContent?.trim() === "Python");
expect(pythonTab).not.toBeNull();
await act(async () => { pythonTab!.click(); });
const newPre = el.querySelector("pre");
const pythonCode = newPre?.textContent ?? "";
// Python code should differ from curl
expect(pythonCode).not.toBe(curlCode);
// Python code should also have the placeholder
expect(pythonCode).toContain(API_KEY_PLACEHOLDER);
});
it("switching tab to TypeScript shows TS-specific code", async () => {
const el = renderModal();
const tabs = el.querySelectorAll("[role='tab']") as NodeListOf<HTMLButtonElement>;
const tsTab = Array.from(tabs).find((t) => t.textContent?.trim() === "TypeScript");
await act(async () => { tsTab!.click(); });
const pre = el.querySelector("pre");
const code = pre?.textContent ?? "";
// TypeScript code should contain OMNIROUTE_API_KEY
expect(code).toContain(API_KEY_PLACEHOLDER);
expect(code).not.toMatch(/sk-[A-Za-z0-9_-]{16,}/);
});
it("calls onClose when backdrop is clicked", () => {
const onClose = vi.fn();
const el = renderModal(BASE_STATE, onClose);
const backdrop = el.querySelector("[role='dialog']") as HTMLDivElement;
act(() => backdrop.click());
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when Close button is clicked", () => {
const onClose = vi.fn();
const el = renderModal(BASE_STATE, onClose);
const closeBtn = el.querySelector("[aria-label='Close export modal']") as HTMLButtonElement;
act(() => closeBtn.click());
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,163 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
const { DEFAULT_PARAMS } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders"
);
const { default: ImprovePromptButton } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton"
);
const BASE_CONFIG = {
endpoint: "chat.completions" as const,
baseUrl: "http://localhost:20128",
model: "openai/gpt-4o",
systemPrompt: "You are helpful.",
params: { ...DEFAULT_PARAMS },
};
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderButton(config = BASE_CONFIG, setConfig = vi.fn()): HTMLDivElement {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<ImprovePromptButton configState={config} setConfigState={setConfig} />,
);
});
containers.push({ root, el });
return el;
}
afterEach(() => {
for (const { root, el } of containers) {
act(() => root.unmount());
el.remove();
}
containers.length = 0;
vi.restoreAllMocks();
});
describe("ImprovePromptButton", () => {
it("renders the Improve prompt button", () => {
const el = renderButton();
const btn = el.querySelector("[aria-label='Improve prompt using AI']");
expect(btn).not.toBeNull();
expect(btn?.textContent).toContain("Improve prompt");
});
it("button is disabled when model is empty", () => {
const config = { ...BASE_CONFIG, model: "" };
const el = renderButton(config);
const btn = el.querySelector("[aria-label='Improve prompt using AI']") as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it("button is enabled when model is set", () => {
const el = renderButton();
const btn = el.querySelector("[aria-label='Improve prompt using AI']") as HTMLButtonElement;
expect(btn.disabled).toBe(false);
});
it("shows confirmation modal when button is clicked", async () => {
const el = renderButton();
const btn = el.querySelector("[aria-label='Improve prompt using AI']") as HTMLButtonElement;
await act(async () => { btn.click(); });
const modal = el.querySelector("[role='dialog']");
expect(modal).not.toBeNull();
expect(modal?.textContent).toContain("Improve prompt");
});
it("confirmation modal shows quota warning message", async () => {
const el = renderButton();
const btn = el.querySelector("[aria-label='Improve prompt using AI']") as HTMLButtonElement;
await act(async () => { btn.click(); });
const modal = el.querySelector("[role='dialog']");
expect(modal?.textContent).toContain("quota");
});
it("confirmation modal shows the configured model name", async () => {
const el = renderButton();
const btn = el.querySelector("[aria-label='Improve prompt using AI']") as HTMLButtonElement;
await act(async () => { btn.click(); });
const modal = el.querySelector("[role='dialog']");
expect(modal?.textContent).toContain("openai/gpt-4o");
});
it("cancels modal without calling API when Cancel is clicked", async () => {
vi.stubGlobal("fetch", vi.fn());
const el = renderButton();
const btn = el.querySelector("[aria-label='Improve prompt using AI']") as HTMLButtonElement;
await act(async () => { btn.click(); });
const cancelBtn = el.querySelector("[role='dialog'] button:first-child") as HTMLButtonElement;
await act(async () => { cancelBtn.click(); });
// Modal should close
expect(el.querySelector("[role='dialog']")).toBeNull();
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});
it("calls useImprovePrompt.improve (POST /api/playground/improve-prompt) on confirm", async () => {
const mockResponse = {
improvedSystem: "You are a highly specialized assistant.",
tokensIn: 50,
tokensOut: 30,
};
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify(mockResponse), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
) as typeof fetch,
);
const setConfig = vi.fn();
const el = renderButton(BASE_CONFIG, setConfig);
const btn = el.querySelector("[aria-label='Improve prompt using AI']") as HTMLButtonElement;
await act(async () => { btn.click(); });
// Click confirm (Improve button)
const modal = el.querySelector("[role='dialog']");
const allBtns = modal?.querySelectorAll("button") ?? [];
const improveBtn = Array.from(allBtns).find(
(b) => b.textContent?.includes("Improve") && !b.textContent?.includes("Improve prompt"),
) as HTMLButtonElement;
expect(improveBtn).not.toBeNull();
await act(async () => { improveBtn.click(); });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// fetch should have been called with POST to improve-prompt
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
const [url, opts] = (vi.mocked(fetch) as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
expect(url).toContain("improve-prompt");
expect(opts.method).toBe("POST");
// setConfigState should have been called with improved system prompt
expect(setConfig).toHaveBeenCalledTimes(1);
const updatedConfig = setConfig.mock.calls[0][0] as typeof BASE_CONFIG;
expect(updatedConfig.systemPrompt).toBe("You are a highly specialized assistant.");
});
});

View File

@@ -0,0 +1,206 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
const { DEFAULT_PARAMS } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders"
);
const { default: PresetPicker } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/PresetPicker"
);
function setInputValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
const nativeSetter =
el instanceof HTMLTextAreaElement
? Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set
: Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
const BASE_CONFIG = {
endpoint: "chat.completions" as const,
baseUrl: "http://localhost:20128",
model: "openai/gpt-4o",
systemPrompt: "You are helpful.",
params: { ...DEFAULT_PARAMS },
};
const MOCK_PRESETS = [
{
id: "preset-1",
name: "My preset",
endpoint: "chat.completions",
model: "anthropic/claude-3-opus",
system: "Act as an expert.",
params: { temperature: 0.5 },
created_at: new Date().toISOString(),
},
];
function buildFetchMock(presets = MOCK_PRESETS) {
return vi.fn((url: string, opts?: RequestInit) => {
const method = opts?.method ?? "GET";
if (typeof url === "string" && url.includes("/api/playground/presets") && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ presets }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}
if (typeof url === "string" && url.includes("/api/playground/presets") && method === "POST") {
const newPreset = {
id: "preset-new",
name: "New",
endpoint: "chat.completions",
model: "test",
system: null,
params: {},
created_at: new Date().toISOString(),
};
return Promise.resolve(
new Response(JSON.stringify(newPreset), {
status: 201,
headers: { "content-type": "application/json" },
}),
);
}
if (typeof url === "string" && url.includes("/presets/") && method === "DELETE") {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(new Response(JSON.stringify({ presets: [] }), { status: 200 }));
}) as typeof fetch;
}
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderPicker(
config = BASE_CONFIG,
setConfig = vi.fn(),
): HTMLDivElement {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<PresetPicker configState={config} setConfigState={setConfig} />);
});
containers.push({ root, el });
return el;
}
afterEach(() => {
for (const { root, el } of containers) {
act(() => root.unmount());
el.remove();
}
containers.length = 0;
vi.restoreAllMocks();
});
describe("PresetPicker", () => {
it("renders Load preset dropdown and Save button", () => {
vi.stubGlobal("fetch", buildFetchMock([]));
const el = renderPicker();
expect(el.textContent).toContain("Presets");
const saveBtn = el.querySelector("[aria-label='Save current config as preset']");
expect(saveBtn).not.toBeNull();
});
it("shows preset names after fetch succeeds", async () => {
vi.stubGlobal("fetch", buildFetchMock(MOCK_PRESETS));
const el = renderPicker();
// Wait for fetch to resolve
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(el.textContent).toContain("My preset");
});
it("loads a preset and calls setConfigState when a preset button is clicked", async () => {
vi.stubGlobal("fetch", buildFetchMock(MOCK_PRESETS));
const setConfig = vi.fn();
const el = renderPicker(BASE_CONFIG, setConfig);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// Find and click the preset load button
const loadBtn = el.querySelector("[aria-label='Load preset \"My preset\"']") as HTMLButtonElement;
if (loadBtn) {
await act(async () => { loadBtn.click(); });
expect(setConfig).toHaveBeenCalledTimes(1);
const newConfig = setConfig.mock.calls[0][0] as typeof BASE_CONFIG;
expect(newConfig.model).toBe("anthropic/claude-3-opus");
}
});
it("opens save modal when Save button is clicked", async () => {
vi.stubGlobal("fetch", buildFetchMock([]));
const el = renderPicker();
const saveBtn = el.querySelector("[aria-label='Save current config as preset']") as HTMLButtonElement;
await act(async () => { saveBtn.click(); });
// Modal should appear
const modal = el.querySelector("[role='dialog']");
expect(modal).not.toBeNull();
expect(modal?.textContent).toContain("Save preset");
});
it("calls create hook (POST to /api/playground/presets) when saving", async () => {
const fetchMock = buildFetchMock([]);
vi.stubGlobal("fetch", fetchMock);
const el = renderPicker();
// Open modal
const saveBtn = el.querySelector("[aria-label='Save current config as preset']") as HTMLButtonElement;
await act(async () => { saveBtn.click(); });
// Enter name
const nameInput = el.querySelector("input[type='text']") as HTMLInputElement;
act(() => setInputValue(nameInput, "Test preset"));
// Submit
const submitBtn = el.querySelector("[role='dialog'] button:last-child") as HTMLButtonElement;
await act(async () => { submitBtn.click(); });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// POST should have been called
const calls = (fetchMock as ReturnType<typeof vi.fn>).mock.calls;
const postCall = calls.find((c: unknown[]) => {
const [, opts] = c as [string, RequestInit?];
return opts?.method === "POST";
});
expect(postCall).toBeDefined();
});
it("shows error when saving with empty name", async () => {
vi.stubGlobal("fetch", buildFetchMock([]));
const el = renderPicker();
const saveBtn = el.querySelector("[aria-label='Save current config as preset']") as HTMLButtonElement;
await act(async () => { saveBtn.click(); });
// Submit without entering a name
const submitBtn = el.querySelector("[role='dialog'] button:last-child") as HTMLButtonElement;
await act(async () => { submitBtn.click(); });
expect(el.textContent).toContain("Name is required");
});
});

View File

@@ -0,0 +1,156 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
const { useStructuredOutput } = await import(
"../../../src/app/(dashboard)/dashboard/playground/hooks/useStructuredOutput"
);
const { default: StructuredOutputEditor } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor"
);
function setInputValue(
el: HTMLTextAreaElement | HTMLInputElement,
value: string,
): void {
const nativeSetter =
el instanceof HTMLTextAreaElement
? Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set
: Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
function StructuredOutputEditorWrapper({ onReady }: {
onReady?: (so: ReturnType<typeof useStructuredOutput>) => void;
}) {
const so = useStructuredOutput();
React.useEffect(() => { onReady?.(so); });
return <StructuredOutputEditor structuredOutput={so} />;
}
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderEditor(onReady?: (so: ReturnType<typeof useStructuredOutput>) => void): HTMLDivElement {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<StructuredOutputEditorWrapper onReady={onReady} />);
});
containers.push({ root, el });
return el;
}
afterEach(() => {
for (const { root, el } of containers) {
act(() => root.unmount());
el.remove();
}
containers.length = 0;
vi.restoreAllMocks();
});
describe("StructuredOutputEditor", () => {
it("renders JSON mode toggle off by default", () => {
const el = renderEditor();
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
expect(toggle.getAttribute("aria-checked")).toBe("false");
});
it("shows schema editor when toggle is enabled", async () => {
const el = renderEditor();
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
await act(async () => { toggle.click(); });
// Should now show schema name field and textarea
const textareas = el.querySelectorAll("textarea");
expect(textareas.length).toBeGreaterThan(0);
expect(el.textContent).toContain("JSON schema");
});
it("hides schema editor when toggle is disabled", async () => {
const el = renderEditor();
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
// Enable
await act(async () => { toggle.click(); });
// Disable
await act(async () => { toggle.click(); });
// Should not show textarea
const textareas = el.querySelectorAll("textarea");
expect(textareas.length).toBe(0);
});
it("validates a valid JSON schema successfully", async () => {
const el = renderEditor();
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
await act(async () => { toggle.click(); });
// Set valid schema
const textarea = el.querySelector("textarea") as HTMLTextAreaElement;
const validSchema = JSON.stringify({ type: "object", properties: { name: { type: "string" } }, required: ["name"] });
act(() => setInputValue(textarea, validSchema));
// Click Validate
const validateBtn = el.querySelector("button:last-child") as HTMLButtonElement;
// Find the validate button specifically
const allBtns = el.querySelectorAll("button");
const validateBtnActual = Array.from(allBtns).find(
(b) => b.textContent?.includes("Validate"),
) as HTMLButtonElement;
expect(validateBtnActual).not.toBeNull();
await act(async () => { validateBtnActual.click(); });
// Should show validated status
expect(el.textContent).toContain("validated");
});
it("shows error for invalid JSON in schema textarea", async () => {
const el = renderEditor();
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
await act(async () => { toggle.click(); });
const textarea = el.querySelector("textarea") as HTMLTextAreaElement;
act(() => setInputValue(textarea, "not valid json {{{"));
const allBtns = el.querySelectorAll("button");
const validateBtn = Array.from(allBtns).find(
(b) => b.textContent?.includes("Validate"),
) as HTMLButtonElement;
await act(async () => { validateBtn.click(); });
expect(el.textContent).toContain("Invalid JSON");
});
it("shows Zod error when schema name is empty after validation", async () => {
const el = renderEditor();
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
await act(async () => { toggle.click(); });
// Clear the name field
const inputs = el.querySelectorAll("input[type='text']") as NodeListOf<HTMLInputElement>;
act(() => setInputValue(inputs[0], "")); // empty name
// Set valid JSON
const textarea = el.querySelector("textarea") as HTMLTextAreaElement;
act(() => setInputValue(textarea, JSON.stringify({ type: "object", properties: {} })));
const allBtns = el.querySelectorAll("button");
const validateBtn = Array.from(allBtns).find(
(b) => b.textContent?.includes("Validate"),
) as HTMLButtonElement;
await act(async () => { validateBtn.click(); });
// Empty name should use fallback "my_schema" which is valid, so no error expected
// (the component uses nameField.trim() || "my_schema")
// So it should actually succeed
expect(el.textContent).toContain("validated");
});
});

View File

@@ -0,0 +1,163 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
const { useToolsBuilder } = await import(
"../../../src/app/(dashboard)/dashboard/playground/hooks/useToolsBuilder"
);
const { default: ToolsBuilder } = await import(
"../../../src/app/(dashboard)/dashboard/playground/components/ToolsBuilder"
);
if (typeof Element.prototype.scrollIntoView === "undefined") {
Object.defineProperty(Element.prototype, "scrollIntoView", {
value: () => {},
writable: true,
configurable: true,
});
}
function setInputValue(
el: HTMLTextAreaElement | HTMLInputElement,
value: string,
): void {
const nativeSetter =
el instanceof HTMLTextAreaElement
? Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set
: Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
// Wrapper component to expose the useToolsBuilder hook
function ToolsBuilderWrapper({ onReady }: { onReady?: (builder: ReturnType<typeof useToolsBuilder>) => void }) {
const builder = useToolsBuilder();
React.useEffect(() => {
onReady?.(builder);
});
return <ToolsBuilder toolsBuilder={builder} />;
}
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderToolsBuilder(onReady?: (b: ReturnType<typeof useToolsBuilder>) => void): HTMLDivElement {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<ToolsBuilderWrapper onReady={onReady} />);
});
containers.push({ root, el });
return el;
}
afterEach(() => {
for (const { root, el } of containers) {
act(() => root.unmount());
el.remove();
}
containers.length = 0;
vi.restoreAllMocks();
});
describe("ToolsBuilder", () => {
it("renders Add tool form by default", () => {
const el = renderToolsBuilder();
expect(el.textContent).toContain("Add tool");
});
it("shows error when name is empty and Add is clicked", async () => {
const el = renderToolsBuilder();
const addBtn = el.querySelector("button[class*='bg-primary']") as HTMLButtonElement;
expect(addBtn?.textContent?.trim()).toBe("+ Add tool");
await act(async () => { addBtn.click(); });
// Zod validation should show error
expect(el.textContent).toMatch(/String must contain at least 1 character|Too small|name|required/i);
});
it("adds a valid tool to the list", async () => {
const el = renderToolsBuilder();
// Fill out the form
const inputs = el.querySelectorAll("input[type='text']") as NodeListOf<HTMLInputElement>;
const nameInput = inputs[0]; // first input = function name
const descInput = inputs[1]; // second input = description
act(() => {
setInputValue(nameInput, "get_weather");
setInputValue(descInput, "Get current weather");
});
// Parameters textarea already has valid JSON by default
const addBtn = el.querySelector("button[class*='bg-primary']") as HTMLButtonElement;
await act(async () => { addBtn.click(); });
// Should show the tool in the list
expect(el.textContent).toContain("get_weather");
// Should show "1" in the Tools count
expect(el.textContent).toContain("Tools (1)");
});
it("shows Zod error for invalid JSON in parameters", async () => {
const el = renderToolsBuilder();
const nameInput = el.querySelector("input[type='text']") as HTMLInputElement;
act(() => setInputValue(nameInput, "my_func"));
// Find the parameters textarea and set invalid JSON
const textareas = el.querySelectorAll("textarea");
const paramsTextarea = textareas[0]; // parameters textarea
act(() => setInputValue(paramsTextarea, "not valid json"));
const addBtn = el.querySelector("button[class*='bg-primary']") as HTMLButtonElement;
await act(async () => { addBtn.click(); });
// Should show JSON parse error
expect(el.textContent).toContain("valid JSON");
});
it("removes a tool from the list", async () => {
const el = renderToolsBuilder();
// Add a tool first
const inputs = el.querySelectorAll("input[type='text']") as NodeListOf<HTMLInputElement>;
act(() => setInputValue(inputs[0], "to_remove"));
const addBtn = el.querySelector("button[class*='bg-primary']") as HTMLButtonElement;
await act(async () => { addBtn.click(); });
expect(el.textContent).toContain("to_remove");
// Click delete button
const deleteBtn = el.querySelector("[aria-label='Remove tool to_remove']") as HTMLButtonElement;
expect(deleteBtn).not.toBeNull();
await act(async () => { deleteBtn.click(); });
expect(el.textContent).not.toContain("to_remove");
expect(el.textContent).not.toContain("Tools (1)");
});
it("shows edit mode for an existing tool", async () => {
const el = renderToolsBuilder();
// Add a tool
const inputs = el.querySelectorAll("input[type='text']") as NodeListOf<HTMLInputElement>;
act(() => setInputValue(inputs[0], "edit_me"));
const addBtn = el.querySelector("button[class*='bg-primary']") as HTMLButtonElement;
await act(async () => { addBtn.click(); });
// Click edit button
const editBtn = el.querySelector("[aria-label='Edit tool edit_me']") as HTMLButtonElement;
expect(editBtn).not.toBeNull();
await act(async () => { editBtn.click(); });
// Should show Save/Cancel buttons
expect(el.textContent).toContain("Save");
expect(el.textContent).toContain("Cancel");
});
});