mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
feat(playground): Compare freeze fix, Chat provider/model selects, Build wizard (Phase 4)
- Chat (StudioConfigPane): add Provider + Model selects reusing the translator hooks; order Endpoint -> Provider -> Model; ConfigState gains optional provider. - Compare (CompareTab): add a user-prompt input and include it in the request body; throttle per-column stream updates via requestAnimationFrame to stop the UI freeze (was setColumns per chunk x N columns with an empty user message). - Build (BuildTab + build/BuildWizard): redesign as a guided 3-step wizard (What to test -> Configure -> Run) reusing ToolsBuilder/StructuredOutputEditor; all run/tool-call handlers preserved. - i18n: playground.build.* (18 keys) in en + pt-BR.
This commit is contained in:
@@ -8,11 +8,14 @@ import type { PlaygroundEndpoint } from "@/lib/playground/codeExport";
|
||||
import { endpointToPath } from "@/lib/playground/codeExport";
|
||||
import PresetPicker from "./PresetPicker";
|
||||
import ImprovePromptButton from "./ImprovePromptButton";
|
||||
import { useProviderOptions } from "@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels";
|
||||
|
||||
export interface ConfigState {
|
||||
endpoint: PlaygroundEndpoint;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
provider?: string;
|
||||
systemPrompt: string;
|
||||
params: PlaygroundParams;
|
||||
}
|
||||
@@ -46,6 +49,10 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
|
||||
*/
|
||||
export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { provider, setProvider, providerOptions, loading: loadingProviders } = useProviderOptions(
|
||||
configState.provider ?? ""
|
||||
);
|
||||
const { availableModels, loading: loadingModels } = useAvailableModels();
|
||||
|
||||
function update<K extends keyof ConfigState>(key: K, value: ConfigState[K]) {
|
||||
setConfigState({ ...configState, [key]: value });
|
||||
@@ -108,18 +115,56 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Provider */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => {
|
||||
setProvider(e.target.value);
|
||||
update("provider", e.target.value);
|
||||
}}
|
||||
disabled={loadingProviders}
|
||||
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"
|
||||
>
|
||||
<option value="">Auto</option>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configState.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
placeholder="e.g. openai/gpt-4o"
|
||||
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"
|
||||
/>
|
||||
{availableModels.length > 0 ? (
|
||||
<select
|
||||
value={configState.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
disabled={loadingModels}
|
||||
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"
|
||||
>
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={configState.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
placeholder="e.g. openai/gpt-4o"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System prompt */}
|
||||
|
||||
@@ -6,9 +6,8 @@ import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useToolsBuilder } from "../../hooks/useToolsBuilder";
|
||||
import { useStructuredOutput } from "../../hooks/useStructuredOutput";
|
||||
import ToolsBuilder from "../ToolsBuilder";
|
||||
import StructuredOutputEditor from "../StructuredOutputEditor";
|
||||
import MarkdownMessage from "../MarkdownMessage";
|
||||
import BuildWizard from "./build/BuildWizard";
|
||||
import type { ConfigState } from "../StudioConfigPane";
|
||||
|
||||
interface BuildTabProps {
|
||||
@@ -225,177 +224,104 @@ export default function BuildTab({ configState }: BuildTabProps) {
|
||||
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"
|
||||
// Result area: conversation history + tool-call UI + validation badge
|
||||
const resultArea = (
|
||||
<div className="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"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
|
||||
{running ? t("running") : t("runLabel")}
|
||||
</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"
|
||||
>
|
||||
{t("clearAll")}
|
||||
</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>
|
||||
{msg.role === "user" ? (
|
||||
<span className="whitespace-pre-wrap">{msg.content}</span>
|
||||
) : (
|
||||
<MarkdownMessage content={msg.content} />
|
||||
)}
|
||||
</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"}`}>
|
||||
{/* 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
|
||||
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"
|
||||
}`}
|
||||
key={tc.id}
|
||||
className="border border-amber-500/40 rounded-lg p-3 bg-amber-500/5"
|
||||
>
|
||||
{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={t("enterToolResult")}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
Send tool result
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
return (
|
||||
<BuildWizard
|
||||
toolsBuilder={toolsBuilder}
|
||||
structuredOutput={structuredOutput}
|
||||
running={running}
|
||||
onRun={() => void handleRun()}
|
||||
prompt={prompt}
|
||||
setPrompt={setPrompt}
|
||||
result={resultArea}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,13 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
// Metrics trackers per column id (plain class instances, no React hooks)
|
||||
const metricsTrackersRef = useRef<Map<string, ColumnMetricsTracker>>(new Map());
|
||||
|
||||
// User prompt input
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
// RAF throttle: pending chunk accumulator and scheduled frame id
|
||||
const pendingRef = useRef<Record<string, string>>({});
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
// Input for model name when adding a column
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -127,6 +134,30 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
return metricsTrackersRef.current.get(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle per-column chunk updates via requestAnimationFrame.
|
||||
* Accumulates all deltas that arrive within a single frame and flushes
|
||||
* them with a single setColumns call, preventing hundreds of re-renders/s.
|
||||
*/
|
||||
function pushChunk(colId: string, delta: string) {
|
||||
pendingRef.current[colId] = (pendingRef.current[colId] ?? "") + delta;
|
||||
|
||||
if (rafRef.current == null) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const snapshot = pendingRef.current;
|
||||
pendingRef.current = {};
|
||||
rafRef.current = null;
|
||||
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => {
|
||||
const extra = snapshot[c.id];
|
||||
return extra != null ? { ...c, response: c.response + extra } : c;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
if (columns.length >= MAX_COLUMNS) return;
|
||||
const model = newModel.trim() || configState.model;
|
||||
@@ -179,6 +210,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
...(configState.systemPrompt
|
||||
? [{ role: "system", content: configState.systemPrompt }]
|
||||
: []),
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -247,10 +279,8 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
accumulated += content;
|
||||
tracker.onChunk(1);
|
||||
|
||||
updateColumn(col.id, {
|
||||
response: accumulated,
|
||||
metrics: tracker.getMetrics(),
|
||||
});
|
||||
// Throttled: accumulate delta and flush once per animation frame
|
||||
pushChunk(col.id, content);
|
||||
}
|
||||
|
||||
const usage = parsed["usage"] as
|
||||
@@ -269,6 +299,12 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
});
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
// Cancel any pending RAF for this column and clean up its pending delta
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
delete pendingRef.current[col.id];
|
||||
updateColumn(col.id, { status: "idle" });
|
||||
return;
|
||||
}
|
||||
@@ -303,6 +339,18 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Prompt input area */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-border bg-bg-alt shrink-0">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Enter your prompt here…"
|
||||
rows={3}
|
||||
className="w-full text-sm bg-surface border border-border rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
|
||||
aria-label="User prompt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
@@ -318,7 +366,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void runAll()}
|
||||
disabled={columns.length === 0}
|
||||
disabled={columns.length === 0 || !prompt.trim()}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ToolsBuilder from "../../ToolsBuilder";
|
||||
import StructuredOutputEditor from "../../StructuredOutputEditor";
|
||||
import { useToolsBuilder } from "../../../hooks/useToolsBuilder";
|
||||
import { useStructuredOutput } from "../../../hooks/useStructuredOutput";
|
||||
|
||||
type BuildMode = "tools" | "json" | "both";
|
||||
|
||||
interface BuildWizardProps {
|
||||
toolsBuilder: ReturnType<typeof useToolsBuilder>;
|
||||
structuredOutput: ReturnType<typeof useStructuredOutput>;
|
||||
running: boolean;
|
||||
onRun: () => void;
|
||||
prompt: string;
|
||||
setPrompt: (value: string) => void;
|
||||
result: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StepperProps {
|
||||
currentStep: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
function Stepper({ currentStep }: StepperProps) {
|
||||
const t = useTranslations("playground.build");
|
||||
|
||||
const steps: Array<{ num: 1 | 2 | 3; label: string }> = [
|
||||
{ num: 1, label: t("step1Label") },
|
||||
{ num: 2, label: t("step2Label") },
|
||||
{ num: 3, label: t("step3Label") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0 px-4 py-3 border-b border-border bg-bg-alt shrink-0">
|
||||
{steps.map((step, idx) => (
|
||||
<div key={step.num} className="flex items-center">
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`h-px w-8 mx-2 transition-colors ${
|
||||
currentStep > step.num ? "bg-primary" : "bg-border"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`flex items-center justify-center w-5 h-5 rounded-full text-[11px] font-semibold transition-colors ${
|
||||
currentStep === step.num
|
||||
? "bg-primary text-white"
|
||||
: currentStep > step.num
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-border text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{currentStep > step.num ? (
|
||||
<span className="material-symbols-outlined text-[12px]">check</span>
|
||||
) : (
|
||||
step.num
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
currentStep === step.num ? "text-text-main" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModeCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ModeCard({ icon, title, description, selected, onClick }: ModeCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex flex-col gap-2 p-4 rounded-xl border-2 text-left transition-all hover:shadow-sm ${
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-surface hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{icon}</span>
|
||||
<span className={`text-sm font-semibold ${selected ? "text-primary" : "text-text-main"}`}>
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted leading-relaxed">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuildWizard({
|
||||
toolsBuilder,
|
||||
structuredOutput,
|
||||
running,
|
||||
onRun,
|
||||
prompt,
|
||||
setPrompt,
|
||||
result,
|
||||
}: BuildWizardProps) {
|
||||
const t = useTranslations("playground");
|
||||
const tb = useTranslations("playground.build");
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [mode, setMode] = useState<BuildMode>("tools");
|
||||
|
||||
const includesTools = mode === "tools" || mode === "both";
|
||||
const includesJson = mode === "json" || mode === "both";
|
||||
|
||||
function goToStep2() {
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
function goToStep3() {
|
||||
setStep(3);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (step === 2) setStep(1);
|
||||
else if (step === 3) setStep(2);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<Stepper currentStep={step} />
|
||||
|
||||
{/* Step 1: Mode picker */}
|
||||
{step === 1 && (
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-text-main mb-1">{tb("step1Title")}</h2>
|
||||
<p className="text-xs text-text-muted">{tb("step1Subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<ModeCard
|
||||
icon="🔧"
|
||||
title={tb("modeToolsTitle")}
|
||||
description={tb("modeToolsDesc")}
|
||||
selected={mode === "tools"}
|
||||
onClick={() => setMode("tools")}
|
||||
/>
|
||||
<ModeCard
|
||||
icon="📋"
|
||||
title={tb("modeJsonTitle")}
|
||||
description={tb("modeJsonDesc")}
|
||||
selected={mode === "json"}
|
||||
onClick={() => setMode("json")}
|
||||
/>
|
||||
<ModeCard
|
||||
icon="🔧"
|
||||
title={tb("modeBothTitle")}
|
||||
description={tb("modeBothDesc")}
|
||||
selected={mode === "both"}
|
||||
onClick={() => setMode("both")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={goToStep2}
|
||||
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{tb("nextButton")}
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Configure */}
|
||||
{step === 2 && (
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-text-main mb-1">{tb("step2Title")}</h2>
|
||||
<p className="text-xs text-text-muted">{tb("step2Subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{includesTools && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
{t("toolsLabel")}
|
||||
</h3>
|
||||
<ToolsBuilder toolsBuilder={toolsBuilder} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{includesJson && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
{t("structuredOutputLabel")}
|
||||
</h3>
|
||||
<StructuredOutputEditor structuredOutput={structuredOutput} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
|
||||
{tb("backButton")}
|
||||
</button>
|
||||
<button
|
||||
onClick={goToStep3}
|
||||
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{tb("nextButton")}
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Run */}
|
||||
{step === 3 && (
|
||||
<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={goBack}
|
||||
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"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
|
||||
{tb("backButton")}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
<button
|
||||
onClick={onRun}
|
||||
disabled={running || (!prompt.trim())}
|
||||
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 ? t("running") : tb("runButton")}
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-[11px] text-text-muted">
|
||||
{includesTools && 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>
|
||||
)}
|
||||
{includesJson && 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>
|
||||
|
||||
{/* Result area (conversation + tool-call UI + validation badge) */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{result}
|
||||
</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();
|
||||
onRun();
|
||||
}
|
||||
}}
|
||||
placeholder={tb("promptPlaceholder")}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7540,7 +7540,27 @@
|
||||
"invalidJson": "Invalid JSON",
|
||||
"running": "Running…",
|
||||
"runLabel": "Run",
|
||||
"enterToolResult": "Enter tool result…"
|
||||
"enterToolResult": "Enter tool result…",
|
||||
"build": {
|
||||
"step1Label": "What to test?",
|
||||
"step2Label": "Configure",
|
||||
"step3Label": "Run",
|
||||
"step1Title": "What do you want to test?",
|
||||
"step1Subtitle": "Choose the capability you want to explore in this session.",
|
||||
"step2Title": "Configure",
|
||||
"step2Subtitle": "Set up the tools or JSON schema that will be used in the request.",
|
||||
"step3Title": "Run",
|
||||
"modeToolsTitle": "Tools",
|
||||
"modeToolsDesc": "Test function calling — define tools and see how the model invokes them.",
|
||||
"modeJsonTitle": "JSON",
|
||||
"modeJsonDesc": "Test structured output — constrain the response to a JSON schema.",
|
||||
"modeBothTitle": "Tools + JSON",
|
||||
"modeBothDesc": "Combine function calling and structured output in a single request.",
|
||||
"backButton": "Back",
|
||||
"nextButton": "Next",
|
||||
"runButton": "Run",
|
||||
"promptPlaceholder": "Enter your message… (Enter to send, Shift+Enter for newline)"
|
||||
}
|
||||
},
|
||||
"miniPlayground": {
|
||||
"endpoint": "Endpoint",
|
||||
|
||||
@@ -4301,7 +4301,27 @@
|
||||
"invalidJson": "JSON inválido",
|
||||
"running": "Executando…",
|
||||
"runLabel": "Executar",
|
||||
"enterToolResult": "Insira o resultado da ferramenta…"
|
||||
"enterToolResult": "Insira o resultado da ferramenta…",
|
||||
"build": {
|
||||
"step1Label": "O que testar?",
|
||||
"step2Label": "Configurar",
|
||||
"step3Label": "Rodar",
|
||||
"step1Title": "O que você quer testar?",
|
||||
"step1Subtitle": "Escolha a funcionalidade que deseja explorar nesta sessão.",
|
||||
"step2Title": "Configurar",
|
||||
"step2Subtitle": "Configure as ferramentas ou o esquema JSON que serão usados na requisição.",
|
||||
"step3Title": "Rodar",
|
||||
"modeToolsTitle": "Ferramentas",
|
||||
"modeToolsDesc": "Teste function calling — defina ferramentas e veja como o modelo as invoca.",
|
||||
"modeJsonTitle": "JSON",
|
||||
"modeJsonDesc": "Teste saída estruturada — restrinja a resposta a um esquema JSON.",
|
||||
"modeBothTitle": "Ferramentas + JSON",
|
||||
"modeBothDesc": "Combine function calling e saída estruturada em uma única requisição.",
|
||||
"backButton": "Voltar",
|
||||
"nextButton": "Próximo",
|
||||
"runButton": "Executar",
|
||||
"promptPlaceholder": "Digite sua mensagem… (Enter para enviar, Shift+Enter para nova linha)"
|
||||
}
|
||||
},
|
||||
"pricingModal": {
|
||||
"title": "Configuração de preços",
|
||||
|
||||
41
tests/unit/v388-phase4-playground.test.ts
Normal file
41
tests/unit/v388-phase4-playground.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Regression guards for v3.8.8 Playground screen fixes (Phase 4).
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
const read = (p: string) => readFileSync(join(root, p), "utf8");
|
||||
|
||||
test("playground config: adds Provider + Model selects reusing translator hooks", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx");
|
||||
assert.ok(src.includes("useProviderOptions"), "reuses useProviderOptions");
|
||||
assert.ok(src.includes("useAvailableModels"), "reuses useAvailableModels");
|
||||
assert.ok(src.includes('update("provider"'), "writes provider into ConfigState");
|
||||
assert.ok(src.includes("provider?: string"), "ConfigState gains provider");
|
||||
});
|
||||
|
||||
test("playground compare: prompt input + rAF throttle + user message in request", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx");
|
||||
assert.ok(src.includes("requestAnimationFrame"), "throttles stream updates via rAF");
|
||||
assert.ok(/role:\s*"user"/.test(src), "request body includes a user message");
|
||||
assert.ok(src.includes("setPrompt"), "has a prompt input control");
|
||||
});
|
||||
|
||||
test("playground build: wizard with 3 modes reusing editors; BuildTab keeps handlers", () => {
|
||||
const wiz = read("src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx");
|
||||
assert.ok(wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'), "three modes");
|
||||
assert.ok(wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"), "reuses both editors");
|
||||
const tab = read("src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx");
|
||||
assert.ok(tab.includes("<BuildWizard"), "BuildTab mounts BuildWizard");
|
||||
assert.ok(tab.includes("runRequest") && tab.includes("sendToolResult"), "BuildTab preserves run/tool handlers");
|
||||
});
|
||||
|
||||
test("playground build i18n: playground.build keys present with en/pt parity", () => {
|
||||
const en = JSON.parse(read("src/i18n/messages/en.json"));
|
||||
const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
|
||||
const ek = Object.keys(en.playground?.build ?? {});
|
||||
const pk = Object.keys(pt.playground?.build ?? {});
|
||||
assert.ok(ek.length >= 10, `expected >=10 build keys, got ${ek.length}`);
|
||||
assert.deepEqual(ek.sort(), pk.sort(), "en/pt-BR playground.build keys must match");
|
||||
});
|
||||
Reference in New Issue
Block a user