diff --git a/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx b/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx index 150e4632a4..5796fc90fb 100644 --- a/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx +++ b/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx @@ -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() { )} {effectiveTab === "compare" && ( -
-
- - compare - -

Compare tab — F7 implementation pending

-
-
+ )} {effectiveTab === "api" && ( )} {effectiveTab === "build" && ( -
-
- - build - -

Build tab — F7 implementation pending

-
-
+ )} diff --git a/src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx b/src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx new file mode 100644 index 0000000000..5c9ff6c064 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx @@ -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 ( +
+ {/* Column header */} +
+
+ {/* Status indicator */} + + + {model || No model} + +
+ +
+ {status === "streaming" && ( + + )} + +
+
+ + {/* Metrics bar */} + {(status === "streaming" || status === "done") && ( +
+ +
+ )} + + {/* Response content */} +
+ {status === "idle" && ( +

+ Ready to run. +

+ )} + + {status === "error" && ( +
+ Error: + {errorMessage ?? "Unknown error occurred."} +
+ )} + + {status === "streaming" && response === "" && ( +
+ + Waiting for response… +
+ )} + + {(status === "streaming" || status === "done") && response !== "" && ( + + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx b/src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx new file mode 100644 index 0000000000..db872cb6d6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx @@ -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("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 ( +
+
e.stopPropagation()} + > + {/* Modal header */} +
+
+ </> +

Export code

+
+ +
+ + {/* Language tabs */} +
+ {LANGUAGE_TABS.map((lang) => ( + + ))} +
+ + {/* Code block */} +
+ {hasRealKey ? ( +
+ Security warning: export blocked — a real API key was detected in the output. + Please reset your API key and try again. +
+ ) : ( +
+              {currentCode}
+            
+ )} + + {/* Placeholder hint */} +

+ Replace{" "} + {API_KEY_PLACEHOLDER} + {" "}with your actual API key, or set it as an environment variable. +

+
+ + {/* Footer with copy button */} +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx b/src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx new file mode 100644 index 0000000000..0d11f690f7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx @@ -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(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 ( + <> +
+ + + {improveError && ( +

{improveError}

+ )} +
+ + {/* Quota confirmation modal */} + {confirmOpen && ( +
setConfirmOpen(false)} + role="dialog" + aria-modal="true" + aria-label="Confirm improve prompt" + > +
e.stopPropagation()} + > +
+ +
+

+ Improve prompt +

+

+ This will send your current system prompt to{" "} + {configState.model} + {" "}to generate an improved version. +

+

+ This action will consume model quota. +

+
+
+ +
+ + +
+
+
+ )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx b/src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx new file mode 100644 index 0000000000..d2367678a8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx @@ -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(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, + }); + + 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 ( + <> +
+ + Presets + + + {/* Load preset select */} +
+ + + +
+ + {/* Preset list with delete buttons (compact) */} + {presets.length > 0 && ( +
+ {presets.map((preset) => ( +
+ + +
+ ))} +
+ )} +
+ + {/* Save preset modal */} + {saveModalOpen && ( +
+
e.stopPropagation()} + > +

Save preset

+ +
+ 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 && ( +

{saveError}

+ )} + +
+ + +
+
+
+
+ )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/playground/components/ProviderMetrics.tsx b/src/app/(dashboard)/dashboard/playground/components/ProviderMetrics.tsx new file mode 100644 index 0000000000..481f8f191f --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/ProviderMetrics.tsx @@ -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 ( +
+ {/* TTFT */} + + TTFT {formatMs(ttftMs)} + + + {/* TPS */} + + · {formatTps(tps)} + + + {/* Token counts */} + + · {tokensIn}↑ {tokensOut}↓ + + + {/* Cost */} + + · {formatCost(costUsd)} (estimated) + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx b/src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx new file mode 100644 index 0000000000..f51ff4b16d --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx @@ -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; +} + +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(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, + strict: true, + }; + + setSchema(input); + } + + return ( +
+ {/* Toggle */} +
+
+ JSON mode + + Forces response_format: json_schema + +
+ +
+ + {/* Schema editor (shown only when enabled) */} + {enabled && ( +
+ {/* Schema name */} +
+ + 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" + /> +
+ + {/* Schema JSON textarea */} +
+ +