// Forms preserve the JSON schema's values and defaults. Complex unions and free-form // objects use a JSON editor instead of guessing which action variant the user meant. import { esc } from "./util.js"; const LONG = /(prompt|text|code|content|description|instruction|message|answer|transcript|latex|query|body|script|reasoning|patch)/i; const own = (o, k) => Object.prototype.hasOwnProperty.call(o || {}, k); const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v); export function resolveSchema(schema, root = schema, seen = new Set()) { if (!object(schema)) return {}; if (!schema.$ref) return schema; const ref = schema.$ref; if (typeof ref !== "string" || !ref.startsWith("#/") || seen.has(ref) || seen.size > 20) return {}; let target = root; for (const part of ref.slice(2).split("/").map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"))) { if (!own(target, part)) return {}; target = target[part]; } const { $ref, ...rest } = schema; return { ...resolveSchema(target, root, new Set([...seen, ref])), ...rest }; } function kind(raw, root) { const p = resolveSchema(raw, root); if (own(p, "const")) return { t: "const", p }; if (Array.isArray(p.enum)) return { t: "enum", p }; const alternatives = p.anyOf || p.oneOf; if (Array.isArray(alternatives)) { const choices = alternatives.map((x) => resolveSchema(x, root)).filter((x) => x.type !== "null"); if (choices.length === 1 && choices.length < alternatives.length) { const { anyOf, oneOf, ...rest } = p; return { ...kind({ ...choices[0], ...rest }, root), nullable: true }; } return { t: "json", p }; } if (p.allOf) return { t: "json", p }; const ts = Array.isArray(p.type) ? p.type.filter((x) => x !== "null") : [p.type]; const t = ts.length === 1 ? ts[0] : undefined; return { t: t === "string" ? (LONG.test(p.title || "") ? "text" : "str") : t === "integer" ? "int" : t === "number" ? "num" : t === "boolean" ? "bool" : "json", p, nullable: Array.isArray(p.type) && p.type.includes("null") }; } // Deliberately a bounded, conservative subset of JSON Schema: server validation // remains authoritative for formats, patterns and custom framework constraints. export function validateValue(value, schema, path = "Input", root = schema, depth = 0) { if (depth > 24) return; if (schema === false) throw new Error(`${path} is not allowed`); const p = resolveSchema(schema, root); const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); if (own(p, "const") && !equal(value, p.const)) throw new Error(`${path} must be ${JSON.stringify(p.const)}`); if (p.enum && !p.enum.some((x) => equal(x, value))) throw new Error(`${path} must be one of the listed choices`); for (const option of p.allOf || []) validateValue(value, option, path, root, depth + 1); const alternatives = p.anyOf || p.oneOf; if (alternatives) { const matches = alternatives.filter((x) => { try { validateValue(value, x, path, root, depth + 1); return true; } catch { return false; } }); if (!matches.length || (p.oneOf && matches.length !== 1)) throw new Error(`${path} must match ${p.oneOf ? "exactly one" : "one"} of the allowed schemas`); } const types = Array.isArray(p.type) ? p.type : p.type ? [p.type] : []; const is = (t) => t === "null" ? value === null : t === "object" ? object(value) : t === "array" ? Array.isArray(value) : t === "integer" ? Number.isSafeInteger(value) : t === "number" ? typeof value === "number" && Number.isFinite(value) : typeof value === t; if (types.length && !types.some(is)) throw new Error(`${path} must be ${types.join(" or ")}`); if (typeof value === "number") { if (!Number.isFinite(value)) throw new Error(`${path} must be a finite number`); for (const [key, bad, word] of [["minimum", (n) => value < n, "at least"], ["maximum", (n) => value > n, "at most"], ["exclusiveMinimum", (n) => value <= n, "greater than"], ["exclusiveMaximum", (n) => value >= n, "less than"]]) { if (typeof p[key] === "number" && bad(p[key])) throw new Error(`${path} must be ${word} ${p[key]}`); } } if (typeof value === "string") { const length = [...value].length; if (p.minLength != null && length < p.minLength) throw new Error(`${path} needs at least ${p.minLength} characters`); if (p.maxLength != null && length > p.maxLength) throw new Error(`${path} allows at most ${p.maxLength} characters`); } if (Array.isArray(value)) { if (p.minItems != null && value.length < p.minItems) throw new Error(`${path} needs at least ${p.minItems} items`); if (p.maxItems != null && value.length > p.maxItems) throw new Error(`${path} allows at most ${p.maxItems} items`); if (p.items) value.forEach((v, i) => validateValue(v, p.items, `${path}[${i}]`, root, depth + 1)); } else if (object(value)) { for (const key of p.required || []) if (!own(value, key)) throw new Error(`${path}.${key} is required`); for (const [key, v] of Object.entries(value)) { if (own(p.properties, key)) validateValue(v, p.properties[key], `${path}.${key}`, root, depth + 1); else if (p.additionalProperties === false) throw new Error(`${path}.${key} is not an allowed field`); else if (object(p.additionalProperties)) validateValue(v, p.additionalProperties, `${path}.${key}`, root, depth + 1); } } } export function schemaForm(schema, { prefix = "sf", values = {}, skip = ["metadata"] } = {}) { const root = schema || {}, s = resolveSchema(root, root); const required = new Set(s.required || []); const props = Object.entries(s.properties || {}).filter(([k]) => !skip.includes(k) || required.has(k)); const idBase = esc(prefix); if (s.anyOf || s.oneOf || s.allOf || !props.length && s.additionalProperties !== false) { const initial = Object.keys(values).length ? values : s.default; return ``; } if (!props.length) return '

No inputs.

'; return `
${props.map(([k, raw], index) => { const { t, p, nullable } = kind({ title: k, ...raw }, root); // Schema property names are untrusted and can collide after sanitization. const id = `${idBase}-${index}`, v = own(values, k) ? values[k] : p.default; const hint = [required.has(k) ? "required" : "optional; blank uses the server default", p.description || ""].filter(Boolean).join(" ยท "); if (t === "const") return ""; const attrs = `id="${id}" data-k="${esc(k)}" data-t="${t}" ${required.has(k) ? 'aria-required="true"' : ""} aria-describedby="${id}-hint"`; let input; if (t === "enum" || t === "bool") { const choices = t === "bool" ? [true, false, ...(nullable ? [null] : [])] : p.enum; input = ``; } else if (t === "int" || t === "num") input = ``; else if (t === "str") input = ``; else input = ``; return `
${esc(hint)}${input}
`; }).join("")}
`; } export function readForm(el, schema) { const rootInput = el.querySelector("[data-root-json]"); if (rootInput) { let value; try { value = JSON.parse(rootInput.value || "{}"); } catch { throw new Error("Input isn't valid JSON"); } validateValue(value, schema); return value; } const out = Object.create(null), s = resolveSchema(schema, schema); const required = new Set(s.required || []); for (const f of el.querySelectorAll("[data-k]")) { const k = f.dataset.k, t = f.dataset.t, raw = f.value; if (raw === "" || raw == null) { if (required.has(k)) { if (t !== "str" && t !== "text") throw new Error(`${k} is required`); out[k] = ""; } continue; } if (t === "int" || t === "num") { const n = Number(raw); if (!Number.isFinite(n)) throw new Error(`${k} must be a finite number`); if (t === "int" && !Number.isSafeInteger(n)) throw new Error(`${k} must be a whole number within the safe integer range`); out[k] = n; } else if (["enum", "bool", "json"].includes(t)) { try { out[k] = JSON.parse(raw); } catch { throw new Error(`${k} isn't valid JSON`); } } else out[k] = raw; } for (const [k, raw] of Object.entries(s.properties || {})) { const p = resolveSchema(raw, schema); if (own(p, "const") && !own(out, k)) out[k] = p.const; } validateValue(out, schema); return out; } export function wireForm() { /* Native labelled controls handle their own state. */ }