+ {/* 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 */}
+
+
+
+
+ {/* Errors */}
+ {(parseError ?? error) && (
+
+ {parseError ?? error}
+
+ )}
+
+ {/* Status — show when schema is set and no errors */}
+ {schema != null && !parseError && !error && (
+
+ ✓ Schema validated
+
+ )}
+
+ {/* Validate button */}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
index a4c6cc5ec7..f2e76bf95b 100644
--- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
@@ -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
- {/* SLOT_PRESETS: F7 substituirá com PresetPicker */}
- {/* SLOT_PRESETS */}
+ {/* PresetPicker — injected by F7 */}
+
{/* Endpoint */}
@@ -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 */}
+
{/* Param sliders */}
diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx
index 3fcaade678..a477d419f7 100644
--- a/src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx
@@ -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
- {/* Export code placeholder modal — F7 replaces with ExportCodeModal */}
- {exportOpen && (
+ {/* Export code modal — uses ExportCodeModal (F7) */}
+ {exportOpen && exportState != null && (
+ setExportOpen(false)} />
+ )}
+ {exportOpen && exportState == null && (
setExportOpen(false)}
@@ -98,7 +105,7 @@ export default function StudioTopBar({ activeTab, onTabChange, metrics }: Studio
- Export code modal — F7 implementation pending.
+ No playground state available to export.
diff --git a/src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx b/src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx
new file mode 100644
index 0000000000..c129a23b8c
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx
@@ -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;
+}
+
+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(null);
+
+ // Per-tool editing state: index → draft values
+ const [editingIndex, setEditingIndex] = useState(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,
+ },
+ };
+
+ 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,
+ },
+ };
+
+ const result = update(index, tool);
+ if (result.ok) {
+ setEditingIndex(null);
+ setEditDraft(null);
+ }
+ }
+
+ function cancelEdit() {
+ setEditingIndex(null);
+ setEditDraft(null);
+ }
+
+ return (
+
+ {/* Existing tools */}
+ {tools.length > 0 && (
+
+
+ Tools ({tools.length})
+
+ {tools.map((tool, idx) => {
+ const isEditing = editingIndex === idx;
+ const toolError = errors.get(idx);
+
+ return (
+
+ {/* Tool header */}
+
+
+
+ function
+
+
+ {tool.function.name}
+
+ {tool.function.description && (
+
+ — {tool.function.description}
+
+ )}
+
+
+ {!isEditing && (
+
+ )}
+
+
+
+
+ {/* Edit form */}
+ {isEditing && editDraft != null && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {/* Add tool form */}
+
+
+ Add tool
+
+
+
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"
+ />
+
+
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"
+ />
+
+
+
+
+
+ {formError && (
+
{formError}
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx
new file mode 100644
index 0000000000..bf67889bf5
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx
@@ -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([]);
+ const [running, setRunning] = useState(false);
+ const [toolCalls, setToolCalls] = useState([]);
+ const [toolResultDrafts, setToolResultDrafts] = useState([]);
+ const [validationResult, setValidationResult] = useState<{
+ valid: boolean;
+ error?: string;
+ } | null>(null);
+ const abortRef = useRef(null);
+
+ function buildRequestBody(msgs: Message[]) {
+ const body: Record = {
+ 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 (
+
+ {/* Left panel: conversation + run */}
+
+ {/* Toolbar */}
+
+
+
+ {messages.length > 0 && (
+
+ )}
+
+
+ {toolsBuilder.tools.length > 0 && (
+
+ {toolsBuilder.tools.length} tool{toolsBuilder.tools.length !== 1 ? "s" : ""}
+
+ )}
+ {structuredOutput.enabled && (
+
+ JSON mode
+
+ )}
+
+
+
+ {/* Conversation history */}
+
+ {messages.map((msg, idx) => (
+
+
+ {msg.role === "user" ? (
+ {msg.content}
+ ) : (
+
+ )}
+
+
+ ))}
+
+ {/* Tool call UI */}
+ {toolCalls.length > 0 && (
+
+ {toolCalls.map((tc) => {
+ const draft = toolResultDrafts.find((d) => d.toolCallId === tc.id);
+ return (
+
+
+
+ function
+
+
+ {tc.function.name}
+
+
+
+ {tc.function.arguments}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Structured output validation */}
+ {validationResult != null && (
+
+ {validationResult.valid ? "✅ Valid JSON schema response" : `❌ ${validationResult.error}`}
+
+ )}
+
+
+ {/* Prompt input */}
+
+
+
+ {/* Right panel: tools + structured output config */}
+
+ {/* Tools section */}
+
+
+ Function calling
+
+
+
+
+ {/* Structured output section */}
+
+
+ Structured output
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
new file mode 100644
index 0000000000..6719b7fc40
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
@@ -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(() => [
+ {
+ id: crypto.randomUUID(),
+ model: configState.model,
+ status: "idle",
+ metrics: INITIAL_METRICS,
+ response: "",
+ },
+ ]);
+
+ // AbortControllers per column id
+ const controllersRef = useRef