SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
26.8 KB · 476 lines tsx
Raw Blame History
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