import { useState, useEffect, useRef, useCallback } from "react"; import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils"; import type { AdapterConfigFieldsProps } from "./types"; import { Field, DraftInput, DraftNumberInput, DraftTextarea, ToggleField, } from "../components/agent-config-primitives"; import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover"; import { ChevronDown } from "lucide-react"; // ── Select field (extracted to keep hooks at component top level) ────── function SelectField({ value, options, onChange, }: { value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void; }) { const [open, setOpen] = useState(false); const selectedOpt = options.find((o) => o.value === value); return ( {options.map((opt) => ( ))} ); } const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; // --------------------------------------------------------------------------- // Combobox: type-to-filter dropdown with free text fallback // --------------------------------------------------------------------------- function ComboboxField({ value, options, onChange, placeholder, }: { value: string; options: { label: string; value: string; group?: string }[]; onChange: (val: string) => void; placeholder?: string; }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const inputRef = useRef(null); // Sync filter with external value when it changes (e.g. provider switch resets model) useEffect(() => { setFilter(""); }, [value]); const filtered = options.filter((opt) => { if (!filter) return true; const q = filter.toLowerCase(); return ( opt.value.toLowerCase().includes(q) || opt.label.toLowerCase().includes(q) || (opt.group && opt.group.toLowerCase().includes(q)) ); }); const selectedOpt = options.find((o) => o.value === value); const displayValue = filter || selectedOpt?.value || value || ""; // Group filtered options by `group` field if present const grouped = new Map(); for (const opt of filtered) { const g = opt.group ?? ""; if (!grouped.has(g)) grouped.set(g, []); grouped.get(g)!.push(opt); } const select = useCallback( (val: string) => { onChange(val); setOpen(false); setFilter(""); inputRef.current?.blur(); }, [onChange], ); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); // If exactly one match, select it. Otherwise commit the typed value. if (filtered.length === 1) { select(filtered[0].value); } else if (filter) { select(filter); } } else if (e.key === "Escape") { setOpen(false); setFilter(""); } else if (e.key === "ArrowDown" && !open) { e.preventDefault(); setOpen(true); } }; return (
{ setFilter(e.target.value); if (!open) setOpen(true); }} onFocus={() => { if (!open) setOpen(true); }} onBlur={() => { // Delay close to allow click on option to register setTimeout(() => setOpen(false), 150); }} onKeyDown={handleKeyDown} /> 0} onOpenChange={setOpen}> e.preventDefault()} > {Array.from(grouped.entries()).map(([group, opts]) => (
{group && (
{group}
)} {opts.map((opt) => ( ))}
))} {filter && filtered.length === 0 && (
Use "{filter}" as custom value (press Enter)
)}
); } // --------------------------------------------------------------------------- // SchemaConfigFields component // --------------------------------------------------------------------------- const schemaCache = new Map(); const schemaFetchInflight = new Map>(); const failedSchemaTypes = new Set(); async function fetchConfigSchema(adapterType: string): Promise { const cached = schemaCache.get(adapterType); if (cached !== undefined) return cached; if (failedSchemaTypes.has(adapterType)) return null; const inflight = schemaFetchInflight.get(adapterType); if (inflight) return inflight; const promise = (async () => { try { const res = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/config-schema`); if (!res.ok) { failedSchemaTypes.add(adapterType); return null; } const schema = (await res.json()) as AdapterConfigSchema; schemaCache.set(adapterType, schema); return schema; } catch { failedSchemaTypes.add(adapterType); return null; } finally { schemaFetchInflight.delete(adapterType); } })(); schemaFetchInflight.set(adapterType, promise); return promise; } export function invalidateConfigSchemaCache(adapterType: string): void { schemaCache.delete(adapterType); failedSchemaTypes.delete(adapterType); } // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- function useConfigSchema(adapterType: string): AdapterConfigSchema | null { const [schema, setSchema] = useState( schemaCache.get(adapterType) ?? null, ); useEffect(() => { let cancelled = false; fetchConfigSchema(adapterType).then((s) => { if (!cancelled) setSchema(s); }); return () => { cancelled = true; }; }, [adapterType]); return schema; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function getDefaultValue(field: ConfigFieldSchema): unknown { if (field.default !== undefined) return field.default; switch (field.type) { case "toggle": return false; case "number": return 0; case "text": case "textarea": return ""; case "select": return field.options?.[0]?.value ?? ""; } } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function SchemaConfigFields({ adapterType, isCreate, values, set, config, eff, mark, }: AdapterConfigFieldsProps) { const schema = useConfigSchema(adapterType); const [defaultsApplied, setDefaultsApplied] = useState(false); useEffect(() => { if (!schema || !isCreate || defaultsApplied) return; const defaults: Record = {}; for (const field of schema.fields) { const def = getDefaultValue(field); if (def !== undefined && def !== "") { defaults[field.key] = def; } } if (Object.keys(defaults).length > 0) { set?.({ adapterSchemaValues: { ...values?.adapterSchemaValues, ...defaults }, }); } setDefaultsApplied(true); }, [schema, isCreate, defaultsApplied, set, values?.adapterSchemaValues]); if (!schema || schema.fields.length === 0) return null; function readValue(field: ConfigFieldSchema): unknown { if (isCreate) { return values?.adapterSchemaValues?.[field.key] ?? getDefaultValue(field); } const stored = config[field.key]; return eff("adapterConfig", field.key, (stored ?? getDefaultValue(field)) as string); } function writeValue(field: ConfigFieldSchema, value: unknown): void { if (isCreate) { const next = { adapterSchemaValues: { ...values?.adapterSchemaValues, [field.key]: value, }, }; // When provider changes, auto-clear model if it's not in the new provider's list if (field.key === "provider" && schema) { const modelField = schema.fields.find((f) => f.key === "model"); if (modelField?.meta?.providerModels) { const modelsByProvider = modelField.meta.providerModels as Record; const providerModels = modelsByProvider[String(value)] ?? []; const currentModel = values?.adapterSchemaValues?.model; if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) { next.adapterSchemaValues.model = ""; } } } set?.(next); } else { mark("adapterConfig", field.key, value); // Same logic for edit mode if (field.key === "provider" && schema) { const modelField = schema.fields.find((f) => f.key === "model"); if (modelField?.meta?.providerModels) { const modelsByProvider = modelField.meta.providerModels as Record; const providerModels = modelsByProvider[String(value)] ?? []; const currentModel = eff("adapterConfig", "model", ""); if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) { mark("adapterConfig", "model", ""); } } } } } return ( <> {schema.fields.map((field) => { switch (field.type) { case "select": { const currentVal = String(readValue(field) ?? ""); return ( writeValue(field, v)} /> ); } case "toggle": return ( writeValue(field, v)} /> ); case "number": return ( writeValue(field, v)} immediate className={inputClass} /> ); case "textarea": return ( writeValue(field, v || undefined)} immediate /> ); case "combobox": { const currentVal = String(readValue(field) ?? ""); // Dynamic options: if meta.providerModels exists, compute options // based on the current provider value let comboboxOptions = field.options ?? []; if (field.meta?.providerModels) { const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto"); const modelsByProvider = field.meta.providerModels as Record; if (providerVal === "auto") { // Auto: show all models from all providers, grouped by provider const providerLabel = schema.fields.find((f) => f.key === "provider"); const providerOptions = providerLabel?.options ?? []; comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) => models.map((m) => ({ label: m, value: m, group: providerOptions.find((p) => p.value === prov)?.label ?? prov, })), ); } else { const providerModels = modelsByProvider[providerVal] ?? []; const providerLabel = schema.fields.find((f) => f.key === "provider"); const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal; comboboxOptions = providerModels.map((m) => ({ label: m, value: m, group: provName, })); } } return ( writeValue(field, v || undefined)} placeholder={field.hint} /> ); } case "text": default: return ( writeValue(field, v || undefined)} immediate className={inputClass} /> ); } })} ); } // --------------------------------------------------------------------------- // Build adapter config from schema values + standard CreateConfigValues fields // --------------------------------------------------------------------------- export function buildSchemaAdapterConfig( values: CreateConfigValues, ): Record { const ac: Record = {}; if (values.model?.trim()) ac.model = values.model.trim(); if (values.cwd) ac.cwd = values.cwd; if (values.command) ac.command = values.command; if (values.instructionsFilePath) ac.instructionsFilePath = values.instructionsFilePath; if (values.thinkingEffort) ac.thinkingEffort = values.thinkingEffort; if (values.extraArgs) { ac.extraArgs = values.extraArgs .split(/\s+/) .filter(Boolean); } if (values.adapterSchemaValues) { Object.assign(ac, values.adapterSchemaValues); } return ac; }