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:
diegosouzapw
2026-05-30 12:54:58 -03:00
parent 8eacd78be4
commit 36c276a6d7
7 changed files with 570 additions and 176 deletions

View File

@@ -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 */}

View File

@@ -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}
/>
);
}

View File

@@ -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"
>

View File

@@ -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>
);
}

View File

@@ -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",

View File

@@ -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",

View 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");
});