mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(playground): add ToolsBuilder + StructuredOutputEditor
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useStructuredOutput } from "../hooks/useStructuredOutput";
|
||||
import type { StructuredOutputSchemaInput } from "../hooks/useStructuredOutput";
|
||||
|
||||
interface StructuredOutputEditorProps {
|
||||
structuredOutput: ReturnType<typeof useStructuredOutput>;
|
||||
}
|
||||
|
||||
const DEFAULT_SCHEMA: StructuredOutputSchemaInput = {
|
||||
name: "my_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { type: "string" },
|
||||
},
|
||||
required: ["result"],
|
||||
},
|
||||
strict: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* StructuredOutputEditor — toggle JSON mode on/off + edit JSON schema.
|
||||
*
|
||||
* When enabled, the Build tab sends response_format: { type: "json_schema", json_schema: {...} }.
|
||||
* Schema validation is client-side via Zod StructuredOutputSchema (D9).
|
||||
*/
|
||||
export default function StructuredOutputEditor({ structuredOutput }: StructuredOutputEditorProps) {
|
||||
const { enabled, schema, error, setEnabled, setSchema } = structuredOutput;
|
||||
|
||||
const [schemaRaw, setSchemaRaw] = useState(
|
||||
JSON.stringify(schema ?? DEFAULT_SCHEMA, null, 2),
|
||||
);
|
||||
const [nameField, setNameField] = useState(schema?.name ?? "my_schema");
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
|
||||
function handleValidate() {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(schemaRaw);
|
||||
} catch {
|
||||
setParseError("Invalid JSON");
|
||||
return;
|
||||
}
|
||||
setParseError(null);
|
||||
|
||||
// The parsed JSON should be the inner schema object (not the full response_format wrapper)
|
||||
const input: StructuredOutputSchemaInput = {
|
||||
name: nameField.trim() || "my_schema",
|
||||
schema: parsed as Record<string, unknown>,
|
||||
strict: true,
|
||||
};
|
||||
|
||||
setSchema(input);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium text-text-main">JSON mode</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
Forces response_format: json_schema
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex w-10 h-5 rounded-full transition-colors ${
|
||||
enabled ? "bg-primary" : "bg-text-muted/30"
|
||||
}`}
|
||||
aria-label={enabled ? "Disable JSON mode" : "Enable JSON mode"}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||
enabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Schema editor (shown only when enabled) */}
|
||||
{enabled && (
|
||||
<div className="flex flex-col gap-2 border border-border rounded-lg p-3 bg-surface">
|
||||
{/* Schema name */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Schema name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nameField}
|
||||
onChange={(e) => setNameField(e.target.value)}
|
||||
placeholder="my_schema"
|
||||
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Schema JSON textarea */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-text-muted uppercase tracking-wider">
|
||||
JSON schema
|
||||
</label>
|
||||
<textarea
|
||||
value={schemaRaw}
|
||||
onChange={(e) => setSchemaRaw(e.target.value)}
|
||||
rows={8}
|
||||
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
|
||||
aria-label="JSON schema editor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{(parseError ?? error) && (
|
||||
<p className="text-xs text-destructive">
|
||||
{parseError ?? error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Status — show when schema is set and no errors */}
|
||||
{schema != null && !parseError && !error && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
✓ Schema validated
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Validate button */}
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors self-start"
|
||||
>
|
||||
Validate schema
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useToolsBuilder } from "../hooks/useToolsBuilder";
|
||||
import type { ToolDefinition } from "@/lib/playground/codeExport";
|
||||
|
||||
interface ToolsBuilderProps {
|
||||
toolsBuilder: ReturnType<typeof useToolsBuilder>;
|
||||
}
|
||||
|
||||
const EMPTY_TOOL: { name: string; description: string; parametersRaw: string } = {
|
||||
name: "",
|
||||
description: "",
|
||||
parametersRaw: JSON.stringify({ type: "object", properties: {}, required: [] }, null, 2),
|
||||
};
|
||||
|
||||
/**
|
||||
* ToolsBuilder — client-only UI (D9) for editing the tools[] array.
|
||||
*
|
||||
* Each tool has: name, description, and a JSON schema textarea for parameters.
|
||||
* Validation uses Zod ToolDefinitionSchema (via useToolsBuilder).
|
||||
*/
|
||||
export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
|
||||
const { tools, errors, add, remove, update } = toolsBuilder;
|
||||
|
||||
// Form state for the "Add tool" form
|
||||
const [form, setForm] = useState(EMPTY_TOOL);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Per-tool editing state: index → draft values
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [editDraft, setEditDraft] = useState<{ name: string; description: string; parametersRaw: string } | null>(null);
|
||||
|
||||
function handleAdd() {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(form.parametersRaw);
|
||||
} catch {
|
||||
setFormError("Parameters must be valid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
const tool: ToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
parameters: parsed as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
|
||||
const result = add(tool);
|
||||
if (!result.ok) {
|
||||
setFormError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setForm(EMPTY_TOOL);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function startEdit(index: number) {
|
||||
const tool = tools[index];
|
||||
setEditingIndex(index);
|
||||
setEditDraft({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description ?? "",
|
||||
parametersRaw: JSON.stringify(tool.function.parameters, null, 2),
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdate(index: number) {
|
||||
if (editDraft == null) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(editDraft.parametersRaw);
|
||||
} catch {
|
||||
return; // Keep edit mode open — show nothing or rely on inline error
|
||||
}
|
||||
|
||||
const tool: ToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: editDraft.name.trim(),
|
||||
description: editDraft.description.trim() || undefined,
|
||||
parameters: parsed as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
|
||||
const result = update(index, tool);
|
||||
if (result.ok) {
|
||||
setEditingIndex(null);
|
||||
setEditDraft(null);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingIndex(null);
|
||||
setEditDraft(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Existing tools */}
|
||||
{tools.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Tools ({tools.length})
|
||||
</span>
|
||||
{tools.map((tool, idx) => {
|
||||
const isEditing = editingIndex === idx;
|
||||
const toolError = errors.get(idx);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Tool header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-bg-alt">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
function
|
||||
</span>
|
||||
<code className="text-xs font-mono text-text-main">
|
||||
{tool.function.name}
|
||||
</code>
|
||||
{tool.function.description && (
|
||||
<span className="text-[11px] text-text-muted truncate max-w-[200px]">
|
||||
— {tool.function.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={() => startEdit(idx)}
|
||||
className="p-1 rounded text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label={`Edit tool ${tool.function.name}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">edit</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => remove(idx)}
|
||||
className="p-1 rounded text-text-muted hover:text-destructive transition-colors"
|
||||
aria-label={`Remove tool ${tool.function.name}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit form */}
|
||||
{isEditing && editDraft != null && (
|
||||
<div className="flex flex-col gap-2 p-3 bg-surface">
|
||||
<input
|
||||
type="text"
|
||||
value={editDraft.name}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
|
||||
placeholder="Function name"
|
||||
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editDraft.description}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, description: e.target.value })}
|
||||
placeholder="Description (optional)"
|
||||
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
/>
|
||||
<textarea
|
||||
value={editDraft.parametersRaw}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, parametersRaw: e.target.value })}
|
||||
rows={6}
|
||||
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
|
||||
aria-label="JSON schema for parameters"
|
||||
/>
|
||||
{toolError && (
|
||||
<p className="text-xs text-destructive">{toolError}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdate(idx)}
|
||||
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="text-xs px-2.5 py-1 rounded border border-border text-text-muted hover:text-text-main transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add tool form */}
|
||||
<div className="border border-border rounded-lg p-3 flex flex-col gap-2 bg-surface">
|
||||
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Add tool
|
||||
</span>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Function name *"
|
||||
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Description (optional)"
|
||||
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Parameters (JSON schema)
|
||||
</label>
|
||||
<textarea
|
||||
value={form.parametersRaw}
|
||||
onChange={(e) => setForm({ ...form, parametersRaw: e.target.value })}
|
||||
rows={6}
|
||||
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
|
||||
aria-label="JSON schema for parameters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p className="text-xs text-destructive">{formError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors self-start"
|
||||
>
|
||||
+ Add tool
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user