fix(ui): add anyOf/oneOf support in config form (#268)
* fix(ui): add anyOf/oneOf support in config form - Handle literal unions as dropdowns with type preservation - Handle primitive unions (string|number, boolean|string) as text inputs - Unwrap single-variant optional types - Fix enum handler to preserve types via index-based values - Update normalizeUnion to support primitive unions in schema analysis - Exclude allOf from union normalization (stays unsupported) Fields like Thinking Default, Allow From, Memory now render properly instead of showing 'unsupported schema node' errors. * UI: fix enum placeholder collision * Docs: update changelog for PR #268 --------- Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
committed by
GitHub
parent
8880128ebf
commit
1f4d9e83ff
@@ -46,6 +46,7 @@
|
|||||||
- Control UI: show a reading indicator bubble while the assistant is responding.
|
- Control UI: show a reading indicator bubble while the assistant is responding.
|
||||||
- Control UI: animate reading indicator dots (honors reduced-motion).
|
- 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: 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 runtime (docker/direct) and move shortcuts to `/help`.
|
||||||
- Status: show model auth source (api-key/oauth).
|
- Status: show model auth source (api-key/oauth).
|
||||||
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe("config form renderer", () => {
|
|||||||
const select = container.querySelector("select") as HTMLSelectElement | null;
|
const select = container.querySelector("select") as HTMLSelectElement | null;
|
||||||
expect(select).not.toBeNull();
|
expect(select).not.toBeNull();
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
select.value = "token";
|
select.value = "1";
|
||||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
|
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing, type TemplateResult } from "lit";
|
||||||
import type { ConfigUiHint, ConfigUiHints } from "../types";
|
import type { ConfigUiHint, ConfigUiHints } from "../types";
|
||||||
|
|
||||||
export type ConfigFormProps = {
|
export type ConfigFormProps = {
|
||||||
@@ -70,7 +70,7 @@ function renderNode(params: {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
}) {
|
}): TemplateResult | typeof nothing {
|
||||||
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
||||||
const showLabel = params.showLabel ?? true;
|
const showLabel = params.showLabel ?? true;
|
||||||
const type = schemaType(schema);
|
const type = schemaType(schema);
|
||||||
@@ -85,7 +85,95 @@ function renderNode(params: {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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`
|
||||||
|
<label class="field">
|
||||||
|
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||||
|
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||||
|
<select
|
||||||
|
.value=${currentIndex >= 0 ? String(currentIndex) : ""}
|
||||||
|
?disabled=${disabled}
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
const idx = (e.target as HTMLSelectElement).value;
|
||||||
|
onPatch(path, idx === "" ? undefined : literals[Number(idx)]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
${literals.map(
|
||||||
|
(opt, i) => html`<option value=${String(i)}>${String(opt)}</option>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
<label class="field">
|
||||||
|
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||||
|
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder=${typeHint}
|
||||||
|
.value=${value == null ? "" : String(value)}
|
||||||
|
?disabled=${disabled}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const raw = (e.target as HTMLInputElement).value;
|
||||||
|
if (raw === "") {
|
||||||
|
onPatch(path, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasBoolean && (raw === "true" || raw === "false")) {
|
||||||
|
onPatch(path, raw === "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasNumber && /^-?\d+(\.\d+)?$/.test(raw)) {
|
||||||
|
const num = Number(raw);
|
||||||
|
if (Number.isFinite(num) && (!isInteger || Number.isInteger(num))) {
|
||||||
|
onPatch(path, num);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onPatch(path, raw);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<div class="callout danger">
|
||||||
|
${label}: unsupported schema node. Use Raw.
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.allOf) {
|
||||||
return html`<div class="callout danger">
|
return html`<div class="callout danger">
|
||||||
${label}: unsupported schema node. Use Raw.
|
${label}: unsupported schema node. Use Raw.
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -182,18 +270,26 @@ function renderNode(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (schema.enum) {
|
if (schema.enum) {
|
||||||
|
const enumValues = schema.enum;
|
||||||
|
const currentIndex = enumValues.findIndex(
|
||||||
|
(v) => v === value || String(v) === String(value),
|
||||||
|
);
|
||||||
|
const unsetValue = "__unset__";
|
||||||
return html`
|
return html`
|
||||||
<label class="field">
|
<label class="field">
|
||||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||||
<select
|
<select
|
||||||
.value=${value == null ? "" : String(value)}
|
.value=${currentIndex >= 0 ? String(currentIndex) : unsetValue}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) => {
|
||||||
onPatch(path, (e.target as HTMLSelectElement).value)}
|
const idx = (e.target as HTMLSelectElement).value;
|
||||||
|
onPatch(path, idx === unsetValue ? undefined : enumValues[Number(idx)]);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
${schema.enum.map(
|
<option value=${unsetValue}>—</option>
|
||||||
(opt) => html`<option value=${String(opt)}>${String(opt)}</option>`,
|
${enumValues.map(
|
||||||
|
(opt, i) => html`<option value=${String(i)}>${String(opt)}</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -327,7 +423,7 @@ function renderMapField(params: {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
reservedKeys: Set<string>;
|
reservedKeys: Set<string>;
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
}) {
|
}): TemplateResult {
|
||||||
const {
|
const {
|
||||||
schema,
|
schema,
|
||||||
value,
|
value,
|
||||||
@@ -517,7 +613,8 @@ function normalizeUnion(
|
|||||||
schema: JsonSchema,
|
schema: JsonSchema,
|
||||||
path: Array<string | number>,
|
path: Array<string | number>,
|
||||||
): ConfigSchemaAnalysis | null {
|
): 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;
|
if (!variants) return null;
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
const nonLiteral: JsonSchema[] = [];
|
const nonLiteral: JsonSchema[] = [];
|
||||||
@@ -568,11 +665,22 @@ function normalizeUnion(
|
|||||||
if (nonLiteral.length === 1) {
|
if (nonLiteral.length === 1) {
|
||||||
const result = normalizeSchemaNode(nonLiteral[0], path);
|
const result = normalizeSchemaNode(nonLiteral[0], path);
|
||||||
if (result.schema) {
|
if (result.schema) {
|
||||||
result.schema.nullable = true;
|
result.schema.nullable = nullable || result.schema.nullable;
|
||||||
}
|
}
|
||||||
return result;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user