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,