From b13c2019eb75926f439e5efd2ed2c47e471874da Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 17 Sep 2026 12:30:43 +0200 Subject: [PATCH] Let a retriever-backed field fall back to free text in two more places The backend already publishes /retriever/LLM/models/open, which says whether a provider's model field is a fixed list or free text - Gemini has used it in production for a while, through generic-node.ts's own leaf-field editor. Two other renderers never asked that question, and a provider that cannot list (any hosted one with no listing endpoint) got stuck there with an empty, unusable select instead: - container-node.ts's own field editor decided select-versus-text from retrieverKey alone, synchronously, before the free-text question could even be asked. toDialogFieldType now takes the answer as an argument, resolved first through a new fetchRetrieverFreeText. - Nested objects - an LLMDescriptor inside a container's configuration or an array item, reached through the buildSchemaObjectDialog both generic-node.ts and container-node.ts share - had no way to ask at all. SchemaObjectDialogHooks grows an optional loadFreeText hook; when it says yes, loadOptions is skipped entirely (the same fetch a listable retriever would otherwise make and throw away) and the field is forced to text. One fix in the shared function covers both components that call it. Deliberately not "an empty options list means free text" - that heuristic would be wrong for a retriever whose empty list is a real answer, a project's global inputs among them, where nothing to choose from is not the same question as nothing to type. A property with no loadFreeText hook, or one that answers no, keeps rendering as a select, empty or not, exactly as before. Co-Authored-By: Claude Sonnet 5 --- .../nodes/container-node/container-node.ts | 66 +++++++++++++--- .../shared/nodes/generic-node/generic-node.ts | 18 +++++ .../shared/nodes/schema-driven-fields.spec.ts | 79 +++++++++++++++++++ src/app/shared/nodes/schema-driven-fields.ts | 34 ++++++-- 4 files changed, 178 insertions(+), 19 deletions(-) diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index d13c967..fa3e9f1 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -546,7 +546,11 @@ export class ContainerNodeComponent implements OnDestroy { if (!definition || !this.isFieldEnabled(definition.path)) return; const initialValue = this.getEditorInitialValue(definition); - const dialogFieldType = this.toDialogFieldType(definition); + // A retriever-backed field can be free text rather than a select - a hosted provider's model + // name, for one, which nothing here can enumerate without a credential the editor does not + // have. Resolved before the field type is decided, since that decision depends on it. + const retrieverIsFreeText = definition.retrieverKey ? await this.fetchRetrieverFreeText(definition) : false; + const dialogFieldType = this.toDialogFieldType(definition, retrieverIsFreeText); const required = this.isPathRequired(definition.path); const field: NodeSettingField = { key: definition.path, @@ -567,7 +571,7 @@ export class ContainerNodeComponent implements OnDestroy { pattern: dialogFieldType === 'number' ? undefined : definition.ui.pattern, defaultsWhenEmpty: !required && dialogFieldType !== 'checkbox', defaultValue: this.schemaDeclaredDefault(definition.path) ?? undefined, - options: await this.resolveSelectableOptions(definition) + options: retrieverIsFreeText ? undefined : await this.resolveSelectableOptions(definition) }; const result = await this.settingsDialog.open({ @@ -636,15 +640,26 @@ export class ContainerNodeComponent implements OnDestroy { private objectDialogHooks(objectSchema: Record | null): SchemaObjectDialogHooks { return { schemaRoot: this.containerSchema ?? objectSchema ?? {}, - loadOptions: (propertySchema) => this.resolveSelectableOptions({ - path: '', - label: '', - type: schemaFieldTypeFromSchema(propertySchema), - enumOptions: schemaEnumOptions(propertySchema), - nodeOptionsSource: schemaNodeOptionsSource(propertySchema), - ...schemaRetrieverMeta(propertySchema, ''), - ui: toSchemaFieldUiMeta(propertySchema) - }) + loadOptions: (propertySchema) => this.resolveSelectableOptions(this.pseudoFieldDefinition(propertySchema)), + loadFreeText: (propertySchema) => this.fetchRetrieverFreeText(this.pseudoFieldDefinition(propertySchema)) + }; + } + + /** + * A stand-in {@link ContainerFieldDefinition} for a bare nested-object property schema - {@link + * resolveSelectableOptions} and {@link fetchRetrieverFreeText} both take one, but a nested + * property never gets a real one of its own (that only exists for the container's own top-level + * fields, built once from the whole schema in {@link refreshParameterFields}). + */ + private pseudoFieldDefinition(propertySchema: Record): ContainerFieldDefinition { + return { + path: '', + label: '', + type: schemaFieldTypeFromSchema(propertySchema), + enumOptions: schemaEnumOptions(propertySchema), + nodeOptionsSource: schemaNodeOptionsSource(propertySchema), + ...schemaRetrieverMeta(propertySchema, ''), + ui: toSchemaFieldUiMeta(propertySchema) }; } @@ -986,15 +1001,40 @@ export class ContainerNodeComponent implements OnDestroy { return isSchemaPathEnabled(this.containerSchema, path, config); } - private toDialogFieldType(definition: ContainerFieldDefinition): NodeSettingField['type'] { + private toDialogFieldType( + definition: ContainerFieldDefinition, + retrieverIsFreeText: boolean + ): NodeSettingField['type'] { if (definition.type === 'boolean') return 'checkbox'; if (definition.ui.widget === 'textarea') return 'textarea'; - if (definition.enumOptions.length || definition.nodeOptionsSource || definition.retrieverKey) return 'select'; + if (definition.enumOptions.length || definition.nodeOptionsSource) return 'select'; + // Only a retriever-backed field can be free text - an enum or a node-options list is always a + // fixed set to pick from, whatever the provider capability said. + if (definition.retrieverKey) return retrieverIsFreeText ? 'text' : 'select'; // A bounded number after the select check: an enum of numbers is still a list to pick from. if (definition.type === 'number' || definition.type === 'integer') return 'number'; return 'text'; } + /** + * Whether a retriever-backed field is a select to choose from or free text - the same question + * {@code generic-node.ts}'s leaf-field editor already asks via {@link FieldRetriever.isFieldOpen}, + * asked here too so a container's own fields (an `LLMDescriptor` on a container configuration, + * for one) are not stuck as an unusable empty select when the provider cannot enumerate its models. + */ + private async fetchRetrieverFreeText(definition: ContainerFieldDefinition): Promise { + const blockType = definition.retrieverBlockType ?? this.typeName; + if (!blockType || !definition.retrieverKey) return false; + const context = this.buildRetrieverContext(this.configuration ?? {}, definition.retrieverDependsOn); + try { + return await firstValueFrom( + this.fieldRetriever.isFieldOpen(blockType, definition.retrieverKey, context, definition.retrieverUrl) + ) === true; + } catch { + return false; + } + } + private async resolveSelectableOptions(definition: ContainerFieldDefinition): Promise { if (definition.nodeOptionsSource) { return this.resolveNodeOptions(definition.nodeOptionsSource); diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index c32ee37..416ca1e 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -1568,6 +1568,7 @@ export class GenericNodeComponent implements OnDestroy { return { schemaRoot: this.blockSchema ?? objectSchema ?? {}, loadOptions: (propertySchema, draft) => this.loadNodeSettingOptions(propertySchema, draft, ''), + loadFreeText: (propertySchema, draft) => this.loadNodeSettingFreeText(propertySchema, draft, ''), dynamic: { isDynamic: (propertySchema) => this.hasDynamicSchema(propertySchema), buildFields: (key, propertySchema, draft) => this.buildDynamicSchemaFields(key, propertySchema, draft), @@ -1815,6 +1816,23 @@ export class GenericNodeComponent implements OnDestroy { } } + /** The nested-object counterpart of {@link fetchRetrieverFreeText}, for a bare property schema. */ + private async loadNodeSettingFreeText( + propertySchema: Record, + item: Record, + pathPrefix: string + ): Promise { + const retrieverMeta = schemaRetrieverMeta(propertySchema, pathPrefix); + if (!retrieverMeta.retrieverKey || !retrieverMeta.retrieverBlockType) return false; + + return this.fetchRetrieverFreeText( + retrieverMeta.retrieverBlockType, + retrieverMeta.retrieverKey, + retrieverMeta.retrieverUrl, + this.buildRetrieverContext(item as Record, retrieverMeta.retrieverDependsOn) + ); + } + private toArrayFieldItems(definition: ArrayFieldDefinition, value: unknown): ArrayFieldItemView[] { if (!Array.isArray(value)) return []; diff --git a/src/app/shared/nodes/schema-driven-fields.spec.ts b/src/app/shared/nodes/schema-driven-fields.spec.ts index 391373a..9230aa7 100644 --- a/src/app/shared/nodes/schema-driven-fields.spec.ts +++ b/src/app/shared/nodes/schema-driven-fields.spec.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. +import { vi } from 'vitest'; import { collectSchemaFlowDataFields, isFlowDataFieldPath @@ -789,3 +790,81 @@ describe('buildTemplatedRichContentParts', () => { expect(sourceType.defaultValue).toBe('CATALOG'); }); }); + +describe('buildSchemaObjectDialog - free text on a retriever-backed property', () => { + /** + * An LLMDescriptor's model, nested inside a container or array-item configuration - the case that + * used to be stuck as an empty, unusable select for a provider that cannot list its models. + */ + const descriptorSchema = { + type: 'object', + required: [], + properties: { + model: { + type: 'string', + title: 'Model', + 'x-retriever-url': '/retriever/LLM/models', + 'x-retriever-depends-on': ['provider'] + } + } + }; + + it('renders as text when loadFreeText says so, without ever calling loadOptions', async () => { + const loadOptions = vi.fn().mockResolvedValue([]); + const hooks = { + schemaRoot: descriptorSchema, + loadOptions, + loadFreeText: () => true + }; + + const dialog = await buildSchemaObjectDialog(descriptorSchema, 'LLM', {}, hooks); + const model = dialog.fields.find((field) => field.key === 'model')!; + + expect(model.type).toBe('text'); + expect(model.options).toBeUndefined(); + expect(loadOptions).not.toHaveBeenCalled(); + }); + + it('keeps rendering as an (empty) select when loadFreeText says no', async () => { + // The heuristic the plan explicitly rejects: an empty options list is not by itself a reason + // to fall back to text - a retriever this component never asked "can you list?" about, or one + // that answered no, must not silently become a text box. + const hooks = { + schemaRoot: descriptorSchema, + loadOptions: () => [], + loadFreeText: () => false + }; + + const dialog = await buildSchemaObjectDialog(descriptorSchema, 'LLM', {}, hooks); + const model = dialog.fields.find((field) => field.key === 'model')!; + + expect(model.type).toBe('select'); + }); + + it('keeps rendering as select when no loadFreeText hook is wired up at all', async () => { + const hooks = { + schemaRoot: descriptorSchema, + loadOptions: () => [] + }; + + const dialog = await buildSchemaObjectDialog(descriptorSchema, 'LLM', {}, hooks); + const model = dialog.fields.find((field) => field.key === 'model')!; + + expect(model.type).toBe('select'); + }); + + it('never asks loadFreeText about a property no retriever backs', async () => { + const plainSchema = { + type: 'object', + required: [], + properties: { label: { type: 'string', title: 'Label' } } + }; + const loadFreeText = vi.fn().mockReturnValue(true); + const hooks = { schemaRoot: plainSchema, loadOptions: () => undefined, loadFreeText }; + + const dialog = await buildSchemaObjectDialog(plainSchema, 'Item', {}, hooks); + + expect(dialog.fields.find((field) => field.key === 'label')!.type).toBe('text'); + expect(loadFreeText).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/nodes/schema-driven-fields.ts b/src/app/shared/nodes/schema-driven-fields.ts index 16d8925..459e48b 100644 --- a/src/app/shared/nodes/schema-driven-fields.ts +++ b/src/app/shared/nodes/schema-driven-fields.ts @@ -885,6 +885,19 @@ export type SchemaObjectDialogHooks = { propertySchema: Record, draft: Record ) => Promise | NodeSettingOption[] | undefined; + /** + * Whether a retriever-backed property is free text rather than a fixed list - the same question + * the top-level leaf-field editors already ask via `FieldRetriever.isFieldOpen`, asked here too so + * a provider that cannot enumerate (an OpenAI-compatible gateway's models, say) does not leave a + * property nested in an object or an array item stuck as an empty, unusable select. Optional: a + * caller that never nests a retriever-backed field with no listing has nothing to wire up here, + * and every retriever-backed property then keeps rendering as a select, empty or not - exactly the + * previous behaviour. + */ + loadFreeText?: ( + propertySchema: Record, + draft: Record + ) => Promise | boolean; /** Optional: a property whose schema is fetched at runtime rather than declared inline. */ dynamic?: { isDynamic: (propertySchema: Record | null | undefined) => boolean; @@ -956,16 +969,25 @@ export async function buildSchemaObjectDialog( } const currentValue = value[key]; - const options = await hooks.loadOptions(propertySchema, value); + // Decided before loadOptions runs, and only for a property a retriever actually backs: an enum + // or a node-options list has no such override, and neither does a retriever-backed property + // with no loadFreeText hook wired up - both keep resolving through loadOptions exactly as + // before. Free text also skips the loadOptions call entirely, the same fetch a listable + // retriever would otherwise make and throw away. + const retrieverKey = schemaRetrieverMeta(propertySchema)?.retrieverKey; + const isFreeTextRetriever = !!retrieverKey && !!hooks.loadFreeText + && await hooks.loadFreeText(propertySchema, value); + const options = isFreeTextRetriever ? undefined : await hooks.loadOptions(propertySchema, value); const isObjectLike = propertySchema?.['type'] === 'object'; const schemaType = propertySchema?.['type']; const isNumeric = schemaType === 'number' || schemaType === 'integer'; const fieldType = - options ? 'select' : - schemaType === 'boolean' ? 'checkbox' : - isNumeric ? 'number' : - propertySchema?.['x-ui-widget'] === 'textarea' || isObjectLike ? 'textarea' : - 'text'; + isFreeTextRetriever ? 'text' : + options ? 'select' : + schemaType === 'boolean' ? 'checkbox' : + isNumeric ? 'number' : + propertySchema?.['x-ui-widget'] === 'textarea' || isObjectLike ? 'textarea' : + 'text'; fields.push({ key,