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 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-17 12:30:43 +02:00
parent e1708f95b8
commit b13c2019eb
4 changed files with 178 additions and 19 deletions

View File

@ -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<string, any> | 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<string, any>): 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<boolean> {
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<NodeSettingOption[] | undefined> {
if (definition.nodeOptionsSource) {
return this.resolveNodeOptions(definition.nodeOptionsSource);

View File

@ -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<string, any>,
item: Record<string, unknown>,
pathPrefix: string
): Promise<boolean> {
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<string, any>, retrieverMeta.retrieverDependsOn)
);
}
private toArrayFieldItems(definition: ArrayFieldDefinition, value: unknown): ArrayFieldItemView[] {
if (!Array.isArray(value)) return [];

View File

@ -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();
});
});

View File

@ -885,6 +885,19 @@ export type SchemaObjectDialogHooks = {
propertySchema: Record<string, any>,
draft: Record<string, unknown>
) => Promise<NodeSettingOption[] | undefined> | 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<string, any>,
draft: Record<string, unknown>
) => Promise<boolean> | boolean;
/** Optional: a property whose schema is fetched at runtime rather than declared inline. */
dynamic?: {
isDynamic: (propertySchema: Record<string, any> | 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,