diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddf397a9..bc54a8650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ - Control UI: show a reading indicator bubble while the assistant is responding. - Control UI: animate reading indicator dots (honors reduced-motion). - Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). +- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268. - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). - Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split. diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 8de0b25ab..2236a21b7 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -72,7 +72,7 @@ describe("config form renderer", () => { const select = container.querySelector("select") as HTMLSelectElement | null; expect(select).not.toBeNull(); if (!select) return; - select.value = "token"; + select.value = "1"; select.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["mode"], "token"); diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts index dcf5d01f3..5dbd2aa0c 100644 --- a/ui/src/ui/views/config-form.ts +++ b/ui/src/ui/views/config-form.ts @@ -1,4 +1,4 @@ -import { html, nothing } from "lit"; +import { html, nothing, type TemplateResult } from "lit"; import type { ConfigUiHint, ConfigUiHints } from "../types"; export type ConfigFormProps = { @@ -70,7 +70,7 @@ function renderNode(params: { disabled: boolean; showLabel?: boolean; onPatch: (path: Array, value: unknown) => void; -}) { +}): TemplateResult | typeof nothing { const { schema, value, path, hints, unsupported, disabled, onPatch } = params; const showLabel = params.showLabel ?? true; const type = schemaType(schema); @@ -85,7 +85,95 @@ function renderNode(params: { `; } - if (schema.anyOf || schema.oneOf || schema.allOf) { + if (schema.anyOf || schema.oneOf) { + const variants = schema.anyOf ?? schema.oneOf ?? []; + const nonNull = variants.filter( + (v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))), + ); + + if (nonNull.length === 1) { + return renderNode({ ...params, schema: nonNull[0] }); + } + + const extractLiteral = (v: JsonSchema): unknown | undefined => { + if (v.const !== undefined) return v.const; + if (v.enum && v.enum.length === 1) return v.enum[0]; + return undefined; + }; + const literals = nonNull.map(extractLiteral); + const allLiterals = literals.every((v) => v !== undefined); + + if (allLiterals && literals.length > 0) { + const currentIndex = literals.findIndex( + (lit) => lit === value || String(lit) === String(value), + ); + return html` + + `; + } + + const primitiveTypes = ["string", "number", "integer", "boolean"]; + const allPrimitive = nonNull.every((v) => v.type && primitiveTypes.includes(String(v.type))); + if (allPrimitive) { + const typeHint = nonNull.map((v) => v.type).join(" | "); + const hasBoolean = nonNull.some((v) => v.type === "boolean"); + const hasNumber = nonNull.some((v) => v.type === "number" || v.type === "integer"); + const isInteger = nonNull.every((v) => v.type !== "number"); + return html` + + `; + } + + return html`
+ ${label}: unsupported schema node. Use Raw. +
`; + } + + if (schema.allOf) { return html`
${label}: unsupported schema node. Use Raw.
`; @@ -182,18 +270,26 @@ function renderNode(params: { } if (schema.enum) { + const enumValues = schema.enum; + const currentIndex = enumValues.findIndex( + (v) => v === value || String(v) === String(value), + ); + const unsetValue = "__unset__"; return html` @@ -327,7 +423,7 @@ function renderMapField(params: { disabled: boolean; reservedKeys: Set; onPatch: (path: Array, value: unknown) => void; -}) { +}): TemplateResult { const { schema, value, @@ -517,7 +613,8 @@ function normalizeUnion( schema: JsonSchema, path: Array, ): ConfigSchemaAnalysis | null { - const variants = schema.anyOf ?? schema.oneOf ?? schema.allOf; + if (schema.allOf) return null; + const variants = schema.anyOf ?? schema.oneOf; if (!variants) return null; const values: unknown[] = []; const nonLiteral: JsonSchema[] = []; @@ -568,11 +665,22 @@ function normalizeUnion( if (nonLiteral.length === 1) { const result = normalizeSchemaNode(nonLiteral[0], path); if (result.schema) { - result.schema.nullable = true; + result.schema.nullable = nullable || result.schema.nullable; } return result; } + const primitiveTypes = ["string", "number", "integer", "boolean"]; + const allPrimitive = nonLiteral.every( + (v) => v.type && primitiveTypes.includes(String(v.type)), + ); + if (allPrimitive && nonLiteral.length > 0 && values.length === 0) { + return { + schema: { ...schema, nullable }, + unsupportedPaths: [], + }; + } + return null; }