TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Bookmark, Braces, Calculator, ChevronDown, Clock, Dices, Globe, RotateCcw, Save, Settings2, SlidersHorizontal, Sparkles, Terminal, Wrench, X } from "lucide-react";4import type { PolyModel, GenerationSettingsInput, ModelPreset } from "@/lib/client/types";5import { Button } from "@/components/ui/button";6import { Input, Textarea, Label } from "@/components/ui/input";7import { Slider } from "@/components/ui/slider";8import { Switch } from "@/components/ui/switch";9import { Segmented } from "@/components/ui/segmented";10import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";11import { ResponsiveDialog } from "@/components/ui/sheet";12import { Tooltip } from "@/components/ui/tooltip";13import { Badge } from "@/components/ui/badge";14import { EmptyState, Skeleton } from "@/components/ui/misc";15import { PromptDialog } from "@/components/common/prompt-dialog";16import { ProviderIcon } from "@/components/brand/provider-icon";17import { useApp } from "@/components/app/store";18import { api, useApi } from "@/lib/client/api";19import { errorMessage } from "@/lib/client/humanize";20import { toast } from "@/components/ui/toast";21import { groupsFor, isSetValue, presetCompatibility, presetToSettings, PARAM_DEFS, type SettingsGroup } from "@/lib/models/params";22import { cn, formatTokens } from "@/lib/utils";2324export type ChatSettings = GenerationSettingsInput & { tools?: string[] };2526const EFFORT_LABEL: Record<string, string> = { none: "None", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Maximum" };27const GROUP_ICON: Record<SettingsGroup, React.ReactNode> = { generation: <SlidersHorizontal />, reasoning: <Sparkles />, output: <Braces />, tools: <Wrench />, advanced: <Settings2 /> };28const DEFAULT_OPEN: SettingsGroup[] = ["generation", "reasoning"];29const UNSET = "__default";3031export function countActiveSettings(s: ChatSettings | undefined): number {32 if (!s) return 0;33 return Object.entries(s).filter(([k, v]) => isSetValue(k, v)).length;34}3536/**37 * Capability-driven model configuration, grouped Generation / Reasoning / Output / Tools / Advanced.38 * Only controls the model supports are rendered; unsupported ones are listed at the bottom and never sent.39 * Phone: full bottom sheet. Desktop: right-side panel so the conversation stays visible.40 */41export function ModelConfig({ model, settings, onChange, systemPrompt, onSystemPromptChange, trigger, allowSavePreset = true }: { model: PolyModel | undefined; settings: ChatSettings; onChange: (s: ChatSettings) => void; systemPrompt: string; onSystemPromptChange: (v: string) => void; trigger?: React.ReactNode; allowSavePreset?: boolean }) {42 const [open, setOpen] = React.useState(false);43 const [presetsOpen, setPresetsOpen] = React.useState(false);44 const [saveOpen, setSaveOpen] = React.useState(false);45 const [openGroups, setOpenGroups] = React.useState<Set<SettingsGroup>>(() => new Set(DEFAULT_OPEN));46 const p = model?.parameters ?? {};47 const c = model?.capabilities;48 const set = <K extends keyof ChatSettings>(k: K, v: ChatSettings[K]) => onChange({ ...settings, [k]: v });49 const unset = (k: keyof ChatSettings) => {50 const next = { ...settings };51 delete next[k];52 onChange(next);53 };54 const active = countActiveSettings(settings);55 const groups = React.useMemo(() => (model ? groupsFor(model) : []), [model]);56 const unsupported = React.useMemo(() => (model ? PARAM_DEFS.filter((d) => !d.supports(model)).map((d) => d.label) : []), [model]);57 const toggleGroup = (g: SettingsGroup) =>58 setOpenGroups((prev) => {59 const n = new Set(prev);60 if (n.has(g)) n.delete(g);61 else n.add(g);62 return n;63 });6465 const savePreset = async (name: string) => {66 if (!model) return;67 const { tools, ...parameters } = settings;68 try {69 await api("/api/presets?kind=model", { method: "POST", json: { name, modelKey: model.key, systemPrompt: systemPrompt || null, parameters, tools: { builtin: tools ?? [] } } });70 toast.success("Preset saved", "Find it under Presets, or apply it from here.");71 } catch (e) {72 toast.error("Could not save preset", errorMessage(e));73 throw e;74 }75 };7677 const applyPreset = (preset: ModelPreset) => {78 const next = presetToSettings(preset) as ChatSettings;79 onChange(next);80 if (preset.systemPrompt) onSystemPromptChange(preset.systemPrompt);81 setPresetsOpen(false);82 toast.success(`Applied “${preset.name}”`, preset.systemPrompt ? "Parameters and system prompt updated." : "Parameters updated.");83 };8485 const samplingNote = model?.metadata?.samplingMode === "conditional" && settings.reasoningEffort !== "none" ? "Temperature, top-p and penalties are accepted only when reasoning effort is “None”. They are dropped otherwise." : null;86 const activeIn = (g: SettingsGroup) => PARAM_DEFS.filter((d) => d.group === g && isSetValue(d.key, (settings as Record<string, unknown>)[d.key])).length;8788 const renderControl = (key: string) => {89 if (!model) return null;90 switch (key) {91 case "temperature":92 return <SliderRow key={key} label="Temperature" hint="Lower is more deterministic" value={settings.temperature} min={p.temperatureRange?.min ?? 0} max={p.temperatureRange?.max ?? 2} step={0.05} onChange={(v) => (v === undefined ? unset("temperature") : set("temperature", v))} />;93 case "topP":94 return <SliderRow key={key} label="Top-p" hint="Nucleus sampling cutoff" value={settings.topP} min={0} max={1} step={0.01} onChange={(v) => (v === undefined ? unset("topP") : set("topP", v))} />;95 case "topK":96 return <SliderRow key={key} label="Top-k" value={settings.topK} min={1} max={200} step={1} onChange={(v) => (v === undefined ? unset("topK") : set("topK", v))} />;97 case "maxTokens":98 return (99 <NumberRow key={key} label="Max output tokens" hint={model.limits?.maxOutputTokens ? `Up to ${formatTokens(model.limits.maxOutputTokens)}${c?.reasoning ? " — reasoning tokens count against it" : ""}` : undefined} value={settings.maxTokens} min={16} max={model.limits?.maxOutputTokens ?? 400_000} placeholder="default" onChange={(v) => (v === undefined ? unset("maxTokens") : set("maxTokens", v))} />100 );101 case "reasoningEffort": {102 const levels = p.reasoningEffortLevels ?? [];103 return <ChoiceRow key={key} label="Reasoning effort" hint={model.metadata?.defaultReasoningEffort ? `Provider default: ${String(model.metadata.defaultReasoningEffort)}` : undefined} value={settings.reasoningEffort} options={levels.map((l) => ({ value: l, label: EFFORT_LABEL[l] ?? l }))} onChange={(v) => (v === undefined ? unset("reasoningEffort") : set("reasoningEffort", v as ChatSettings["reasoningEffort"]))} />;104 }105 case "thinkingBudget": {106 const r = p.thinkingBudgetRange ?? { min: 0, max: 32_000 };107 return <SliderRow key={key} label="Thinking budget" hint={`${formatTokens(r.min)}–${formatTokens(r.max)} tokens`} value={settings.thinkingBudget} min={r.min} max={r.max} step={Math.max(1, Math.round((r.max - r.min) / 256))} onChange={(v) => (v === undefined ? unset("thinkingBudget") : set("thinkingBudget", Math.round(v)))} />;108 }109 case "includeReasoning":110 return <SwitchRow key={key} label="Show reasoning" hint="Stream the model's reasoning summary when the provider exposes it" checked={settings.includeReasoning !== false} onChange={(v) => (v ? unset("includeReasoning") : set("includeReasoning", false))} />;111 case "verbosity":112 return <ChoiceRow key={key} label="Verbosity" value={settings.verbosity} options={["low", "medium", "high"].map((l) => ({ value: l, label: EFFORT_LABEL[l] }))} onChange={(v) => (v === undefined ? unset("verbosity") : set("verbosity", v as ChatSettings["verbosity"]))} />;113 case "responseFormat":114 return (115 <div key={key} className="space-y-2">116 <ChoiceRow label="Response format" value={settings.responseFormat?.type ?? "text"} noDefault options={[{ value: "text", label: "Text" }, { value: "json", label: "JSON object" }, ...(model.metadata?.jsonSchema === false ? [] : [{ value: "json_schema", label: "JSON schema" }])]} onChange={(v) => (!v || v === "text" ? unset("responseFormat") : set("responseFormat", { type: v as "json" | "json_schema", schema: settings.responseFormat?.schema, strict: true }))} />117 {settings.responseFormat?.type === "json_schema" ? <SchemaEditor value={settings.responseFormat.schema} onChange={(schema) => set("responseFormat", { type: "json_schema", schema, strict: true })} /> : null}118 </div>119 );120 case "stop":121 return (122 <FieldRow key={key} label="Stop sequences" hint="Comma-separated, up to 4">123 <Input className="h-10 sm:h-8 sm:w-56 sm:text-[13px]" value={(settings.stop ?? []).join(", ")} onChange={(e) => set("stop", e.target.value.split(",").map((s) => s.trim()).filter(Boolean).slice(0, 4))} placeholder="e.g. END, ###" />124 </FieldRow>125 );126 case "webSearch":127 return <SwitchRow key={key} icon={<Globe />} label="Web search" hint="Provider-native search with citations (billed by the provider)" checked={Boolean(settings.webSearch)} onChange={(v) => (v ? set("webSearch", true) : unset("webSearch"))} />;128 case "codeExecution":129 return <SwitchRow key={key} icon={<Terminal />} label="Code execution" hint="Provider-hosted sandbox (not combinable with web search on Anthropic)" checked={Boolean(settings.codeExecution)} onChange={(v) => (v ? set("codeExecution", true) : unset("codeExecution"))} />;130 case "tools":131 return (132 <div key={key} className="space-y-2">133 <p className="text-[12px] text-fg-muted">Built-in PolyLLM tools (server-side, side-effect free)</p>134 <div className="flex flex-wrap gap-1.5">135 {[136 { id: "calculator", label: "Calculator", icon: <Calculator className="size-3.5" /> },137 { id: "clock", label: "Clock", icon: <Clock className="size-3.5" /> },138 { id: "random", label: "Random", icon: <Dices className="size-3.5" /> },139 ].map((t) => {140 const on = settings.tools?.includes(t.id);141 return (142 <button key={t.id} type="button" aria-pressed={on} onClick={() => set("tools", on ? (settings.tools ?? []).filter((x) => x !== t.id) : [...(settings.tools ?? []), t.id])} className={cn("inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-[13px] transition-colors sm:h-8", on ? "border-accent bg-accent-soft text-accent" : "border-border text-fg-muted hover:border-border-strong")}>143 {t.icon} {t.label}144 </button>145 );146 })}147 </div>148 </div>149 );150 case "toolChoice":151 return settings.tools?.length ? <ChoiceRow key={key} label="Tool choice" noDefault value={typeof settings.toolChoice === "string" ? settings.toolChoice : "auto"} options={[{ value: "auto", label: "Auto" }, { value: "required", label: "Required" }, { value: "none", label: "None" }]} onChange={(v) => set("toolChoice", (v ?? "auto") as "auto" | "none" | "required")} /> : null;152 case "seed":153 return <NumberRow key={key} label="Seed" hint="Best-effort determinism" value={settings.seed} min={0} max={2_147_483_647} placeholder="random" onChange={(v) => (v === undefined ? unset("seed") : set("seed", v))} />;154 case "frequencyPenalty":155 return <SliderRow key={key} label="Frequency penalty" value={settings.frequencyPenalty} min={-2} max={2} step={0.1} onChange={(v) => (v === undefined ? unset("frequencyPenalty") : set("frequencyPenalty", v))} />;156 case "presencePenalty":157 return <SliderRow key={key} label="Presence penalty" value={settings.presencePenalty} min={-2} max={2} step={0.1} onChange={(v) => (v === undefined ? unset("presencePenalty") : set("presencePenalty", v))} />;158 default:159 return null;160 }161 };162163 return (164 <>165 <span onClick={() => setOpen(true)} className="contents">166 {trigger ?? (167 <Tooltip content="Model configuration">168 <Button variant="outline" size="sm" className="relative" aria-label="Model configuration">169 <Settings2 />170 <span className="hidden sm:inline">Configure</span>171 {active > 0 ? <span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-accent text-[10px] font-semibold text-accent-fg">{active}</span> : null}172 </Button>173 </Tooltip>174 )}175 </span>176177 <ResponsiveDialog178 open={open}179 onOpenChange={setOpen}180 desktop="panel"181 snap="full"182 title={183 <span className="flex items-center gap-2">184 {model ? <ProviderIcon provider={model.provider} size={16} /> : null}185 <span className="truncate">Configure {model?.displayName ?? "model"}</span>186 {model?.limits?.contextTokens ? <Badge variant="outline">{formatTokens(model.limits.contextTokens)} ctx</Badge> : null}187 </span>188 }189 description="Only settings this model supports are shown. Nothing else is ever sent to the provider."190 footer={191 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">192 <Button variant="ghost" size="sm" className="h-10 justify-start sm:h-8" onClick={() => onChange({})} disabled={active === 0}>193 <RotateCcw /> Reset to defaults194 </Button>195 <div className="grid grid-cols-2 gap-2 sm:flex">196 <Button variant="outline" size="sm" className="h-10 sm:h-8" onClick={() => setPresetsOpen(true)} disabled={!model}>197 <Bookmark /> Apply preset198 </Button>199 {allowSavePreset ? (200 <Button variant="outline" size="sm" className="h-10 sm:h-8" onClick={() => setSaveOpen(true)} disabled={!model}>201 <Save /> Save preset202 </Button>203 ) : (204 <Button size="sm" className="h-10 sm:h-8" onClick={() => setOpen(false)}>205 Done206 </Button>207 )}208 </div>209 </div>210 }211 >212 <div className="space-y-3 pt-1">213 <section className="space-y-1.5">214 <Label htmlFor="sys">System prompt</Label>215 <Textarea id="sys" value={systemPrompt} onChange={(e) => onSystemPromptChange(e.target.value)} placeholder="Instructions that apply to the whole conversation…" rows={4} className="min-h-[96px]" />216 </section>217218 {!model ? <EmptyState title="Pick a model first" description="Settings depend on what the selected model supports." /> : null}219 {samplingNote ? <p className="rounded-md bg-warning-soft px-2.5 py-1.5 text-xs text-warning">{samplingNote}</p> : null}220221 {groups.map(({ group, defs }) => {222 const isOpen = openGroups.has(group.key);223 const n = activeIn(group.key);224 return (225 <section key={group.key} className="panel">226 <button type="button" onClick={() => toggleGroup(group.key)} aria-expanded={isOpen} className="flex min-h-11 w-full items-center gap-2.5 px-3 text-left">227 <span className="text-fg-muted [&_svg]:size-4">{GROUP_ICON[group.key]}</span>228 <span className="flex-1">229 <span className="block text-[13.5px] font-semibold">{group.label}</span>230 {!isOpen ? <span className="block text-[11.5px] text-fg-subtle">{defs.map((d) => d.label).join(" · ")}</span> : null}231 </span>232 {n > 0 ? <Badge variant="accent">{n}</Badge> : null}233 <ChevronDown className={cn("size-4 text-fg-subtle transition-transform", isOpen && "rotate-180")} />234 </button>235 {isOpen ? (236 <div className="space-y-4 px-3 pb-3.5">237 {defs.map((d) => renderControl(d.key))}238 {group.key === "reasoning" && model?.metadata?.alwaysReasoning ? <p className="text-[11.5px] text-fg-subtle">This model always reasons; effort only changes how much.</p> : null}239 </div>240 ) : null}241 </section>242 );243 })}244245 {unsupported.length ? <p className="px-1 text-[11.5px] text-fg-subtle">Not available for this model: {unsupported.join(", ")}.</p> : null}246 </div>247 </ResponsiveDialog>248249 {model ? <PresetPicker open={presetsOpen} onOpenChange={setPresetsOpen} model={model} onApply={applyPreset} /> : null}250 <PromptDialog open={saveOpen} onOpenChange={setSaveOpen} title="Save as preset" description="A model, its system prompt and these parameters, saved as one thing." label="Preset name" placeholder={model ? `${model.displayName} custom` : "My preset"} defaultValue={model ? `${model.displayName} custom` : ""} maxLength={80} onSubmit={savePreset} />251 </>252 );253}254255/* --------------------------------------------------------------------------------------------- */256257function PresetPicker({ open, onOpenChange, model, onApply }: { open: boolean; onOpenChange: (v: boolean) => void; model: PolyModel; onApply: (p: ModelPreset) => void }) {258 const { modelsByKey } = useApp();259 const q = useApi<{ modelPresets: ModelPreset[] }>(open ? "/api/presets" : null);260 const presets = React.useMemo(() => {261 const list = q.data?.modelPresets ?? [];262 return list263 .map((p) => ({ preset: p, compat: presetCompatibility(p, model) }))264 .sort((a, b) => Number(b.compat.compatible) - Number(a.compat.compatible) || Number(b.preset.modelKey === model.key) - Number(a.preset.modelKey === model.key) || a.preset.name.localeCompare(b.preset.name));265 }, [q.data, model]);266 return (267 <ResponsiveDialog open={open} onOpenChange={onOpenChange} title="Apply a preset" description={`Presets whose every parameter is supported by ${model.displayName} can be applied. Others explain why not.`} size="md" snap="full">268 {q.isLoading ? (269 <div className="space-y-2 pt-1">270 {Array.from({ length: 3 }).map((_, i) => (271 <Skeleton key={i} className="h-16" />272 ))}273 </div>274 ) : presets.length === 0 ? (275 <EmptyState className="mt-1" icon={<Bookmark />} title="No presets yet" description="Save your current settings as a preset from the configuration panel, or create one under Presets." />276 ) : (277 <ul className="space-y-2 pt-1">278 {presets.map(({ preset: p, compat }) => {279 const saved = modelsByKey.get(p.modelKey);280 return (281 <li key={p.id} className={cn("panel flex items-start gap-3 p-3", !compat.compatible && "opacity-80")}>282 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-elevated text-lg leading-none" aria-hidden>283 {p.icon || <Bookmark className="size-4 text-fg-muted" />}284 </span>285 <div className="min-w-0 flex-1">286 <p className="truncate text-[14px] font-medium">{p.name}</p>287 <p className="mt-0.5 flex items-center gap-1.5 text-[12px] text-fg-muted">288 <ProviderIcon provider={saved?.provider ?? p.modelKey.split("/")[0]} size={11} />289 <span className="truncate">Saved for {saved?.displayName ?? p.modelKey}</span>290 <span className="text-fg-subtle">· {compat.total} setting{compat.total === 1 ? "" : "s"}</span>291 </p>292 {compat.compatible ? null : (293 <ul className="mt-1.5 space-y-0.5 text-[11.5px] text-warning">294 {compat.unsupported.map((u) => (295 <li key={u.key} className="flex items-start gap-1">296 <X className="mt-0.5 size-3 shrink-0" /> {u.reason}297 </li>298 ))}299 </ul>300 )}301 </div>302 <Button size="sm" variant={compat.compatible ? "primary" : "outline"} className="h-9 shrink-0 sm:h-8" disabled={!compat.compatible} onClick={() => onApply(p)}>303 Apply304 </Button>305 </li>306 );307 })}308 </ul>309 )}310 </ResponsiveDialog>311 );312}313314/* --------------------------------------------------------------------------------------------- */315316function FieldRow({ label, hint, children }: { label: React.ReactNode; hint?: string; children: React.ReactNode }) {317 return (318 <div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-4">319 <div className="min-w-0">320 <div className="text-[13px] font-medium">{label}</div>321 {hint ? <div className="text-[11.5px] text-fg-subtle">{hint}</div> : null}322 </div>323 <div className="sm:shrink-0">{children}</div>324 </div>325 );326}327328function SwitchRow({ label, hint, icon, checked, onChange }: { label: string; hint?: string; icon?: React.ReactNode; checked: boolean; onChange: (v: boolean) => void }) {329 const id = React.useId();330 return (331 <div className="flex items-center justify-between gap-4">332 <label htmlFor={id} className="min-w-0 cursor-pointer">333 <span className="flex items-center gap-1.5 text-[13px] font-medium [&_svg]:size-3.5">334 {icon} {label}335 </span>336 {hint ? <span className="block text-[11.5px] text-fg-subtle">{hint}</span> : null}337 </label>338 <Switch id={id} checked={checked} onCheckedChange={onChange} />339 </div>340 );341}342343/** Slider with an inline numeric input; "Default" means unset (provider default). */344function SliderRow({ label, hint, value, min, max, step, onChange }: { label: string; hint?: string; value: number | undefined; min: number; max: number; step: number; onChange: (v: number | undefined) => void }) {345 const id = React.useId();346 const on = value !== undefined;347 const decimals = step >= 1 ? 0 : step >= 0.1 ? 1 : 2;348 const shown = on ? value! : (min + max) / 2;349 return (350 <div className="space-y-1">351 <div className="flex items-center justify-between gap-3">352 <Label htmlFor={id} className="text-fg">353 {label}354 </Label>355 <div className="flex items-center gap-1.5">356 <Input357 id={id}358 type="number"359 inputMode="decimal"360 className="h-9 w-24 text-right font-mono text-[15px] tabular-nums sm:h-7 sm:w-20 sm:text-[12.5px]"361 value={on ? value : ""}362 placeholder="default"363 min={min}364 max={max}365 step={step}366 onChange={(e) => {367 const raw = e.target.value;368 if (raw === "") return onChange(undefined);369 const n = Number(raw);370 if (Number.isFinite(n)) onChange(Math.min(max, Math.max(min, n)));371 }}372 />373 {on ? (374 <button type="button" onClick={() => onChange(undefined)} className="tap rounded p-1 text-fg-subtle hover:text-fg" aria-label={`Reset ${label} to default`}>375 <X className="size-3.5" />376 </button>377 ) : (378 <span className="w-[22px]" />379 )}380 </div>381 </div>382 <Slider value={[shown]} min={min} max={max} step={step} onValueChange={([v]) => onChange(Number(v.toFixed(decimals)))} className={cn("py-2.5 sm:py-2", !on && "opacity-50")} aria-label={label} />383 <div className="flex justify-between text-[10.5px] text-fg-subtle">384 <span>{min}</span>385 {hint ? <span className="truncate px-2">{hint}</span> : null}386 <span>{max}</span>387 </div>388 </div>389 );390}391392function NumberRow({ label, hint, value, min, max, placeholder, onChange }: { label: string; hint?: string; value: number | undefined; min: number; max: number; placeholder?: string; onChange: (v: number | undefined) => void }) {393 return (394 <FieldRow label={label} hint={hint}>395 <Input396 type="number"397 inputMode="numeric"398 className="h-10 font-mono tabular-nums sm:h-8 sm:w-36 sm:text-[13px]"399 value={value ?? ""}400 min={min}401 max={max}402 placeholder={placeholder}403 onChange={(e) => {404 const v = e.target.value;405 if (v === "") return onChange(undefined);406 const n = Number(v);407 if (Number.isFinite(n)) onChange(Math.min(max, Math.max(min, Math.floor(n))));408 }}409 />410 </FieldRow>411 );412}413414/** Segmented control for ≤ 4 options (plus Default), Select otherwise. */415function ChoiceRow({ label, hint, value, options, onChange, noDefault }: { label: string; hint?: string; value: string | undefined; options: { value: string; label: string }[]; onChange: (v: string | undefined) => void; noDefault?: boolean }) {416 const all = noDefault ? options : [{ value: UNSET, label: "Default" }, ...options];417 const current = value ?? (noDefault ? options[0]?.value : UNSET);418 return (419 <FieldRow label={label} hint={hint}>420 {all.length <= 5 ? (421 <Segmented size="sm" ariaLabel={label} value={current} onChange={(v) => onChange(v === UNSET ? undefined : v)} options={all} className="w-full sm:w-auto" fill />422 ) : (423 <Select value={current} onValueChange={(v) => onChange(v === UNSET ? undefined : v)}>424 <SelectTrigger className="h-10 w-full sm:h-8 sm:w-48 sm:text-[13px]" aria-label={label}>425 <SelectValue />426 </SelectTrigger>427 <SelectContent>428 {all.map((o) => (429 <SelectItem key={o.value} value={o.value}>430 {o.label}431 </SelectItem>432 ))}433 </SelectContent>434 </Select>435 )}436 </FieldRow>437 );438}439440function SchemaEditor({ value, onChange }: { value: Record<string, unknown> | undefined; onChange: (schema: Record<string, unknown>) => void }) {441 const [text, setText] = React.useState(() => (value ? JSON.stringify(value, null, 2) : '{\n "type": "object",\n "properties": {\n "answer": { "type": "string" }\n },\n "required": ["answer"],\n "additionalProperties": false\n}'));442 const [error, setError] = React.useState<string | null>(null);443 React.useEffect(() => {444 if (!value) {445 try {446 onChange(JSON.parse(text));447 } catch {448 /* ignore */449 }450 }451 // eslint-disable-next-line react-hooks/exhaustive-deps452 }, []);453 return (454 <div className="space-y-1">455 <Textarea456 value={text}457 onChange={(e) => {458 setText(e.target.value);459 try {460 const parsed = JSON.parse(e.target.value);461 if (!parsed || typeof parsed !== "object") throw new Error("Schema must be an object");462 setError(null);463 onChange(parsed);464 } catch (err) {465 setError((err as Error).message);466 }467 }}468 className="min-h-[160px] font-mono text-[12px]"469 spellCheck={false}470 aria-label="JSON schema"471 />472 {error ? <p className="text-xs text-danger">{error}</p> : <p className="text-xs text-fg-subtle">JSON Schema (object root, `additionalProperties: false` recommended for strict mode).</p>}473 </div>474 );475}476