mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
Merge branch 'feat/playground-ui-advanced-F7' into feat/playground-search-i18n-docs-F9
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"></></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
221
tests/unit/ui/playground-build-tab.test.tsx
Normal file
221
tests/unit/ui/playground-build-tab.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
158
tests/unit/ui/playground-compare-column.test.tsx
Normal file
158
tests/unit/ui/playground-compare-column.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
261
tests/unit/ui/playground-compare-tab.test.tsx
Normal file
261
tests/unit/ui/playground-compare-tab.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
151
tests/unit/ui/playground-export-modal.test.tsx
Normal file
151
tests/unit/ui/playground-export-modal.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
163
tests/unit/ui/playground-improve-prompt-button.test.tsx
Normal file
163
tests/unit/ui/playground-improve-prompt-button.test.tsx
Normal 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.");
|
||||
});
|
||||
});
|
||||
206
tests/unit/ui/playground-preset-picker.test.tsx
Normal file
206
tests/unit/ui/playground-preset-picker.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
156
tests/unit/ui/playground-structured-output-editor.test.tsx
Normal file
156
tests/unit/ui/playground-structured-output-editor.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
163
tests/unit/ui/playground-tools-builder.test.tsx
Normal file
163
tests/unit/ui/playground-tools-builder.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user