mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user