diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 72780e2..d90c31d 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -1169,14 +1169,16 @@ export class GenericNodeComponent implements OnDestroy { path: field.path, field, richContentField: null, - arrayField: null + arrayField: null, + optionalGroupField: null })) })); this.parameterDisplayItems = groupedFallback.rootFields.map((field) => ({ path: field.path, field, richContentField: null, - arrayField: null + arrayField: null, + optionalGroupField: null })); this.parameterDisplaySections = [ ...groupedFallback.groups.map((group) => ({ @@ -1188,7 +1190,8 @@ export class GenericNodeComponent implements OnDestroy { path: field.path, field, richContentField: null, - arrayField: null + arrayField: null, + optionalGroupField: null })) }, item: null @@ -1200,7 +1203,8 @@ export class GenericNodeComponent implements OnDestroy { path: field.path, field, richContentField: null, - arrayField: null + arrayField: null, + optionalGroupField: null } })) ]; diff --git a/src/app/shared/nodes/schema-driven-fields.spec.ts b/src/app/shared/nodes/schema-driven-fields.spec.ts index fc01f6c..4d17562 100644 --- a/src/app/shared/nodes/schema-driven-fields.spec.ts +++ b/src/app/shared/nodes/schema-driven-fields.spec.ts @@ -7,6 +7,7 @@ import { buildSchemaEditableFieldDefinitions, buildSchemaFieldViewModel, buildSchemaRetrieverContext, + collectSchemaLeafFields, deleteSchemaValueByPath, getSchemaPathUiMeta, parseSchemaRetrieverUrl, @@ -508,3 +509,67 @@ describe('schema-driven-fields', () => { }); }); }); +describe('optional groups', () => { + /** The real shape: a nested object the backend marked, beside two ordinary fields. */ + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + llmDescriptor: { + type: 'object', + properties: { + provider: { type: 'string' }, + model: { type: 'string' }, + parameters: { + type: 'object', + 'x-ui-optional-group': true, + 'x-ui-optional-group-label': 'Model parameters', + properties: { + temperature: { type: 'number', minimum: 0, maximum: 2 }, + seed: { type: 'integer' } + } + } + } + } + } + }; + + const paths = (options?: Parameters[2]) => + collectSchemaLeafFields(schema, ({ path }) => path, options); + + it('does not unfold a marked object into its properties', () => { + // Five chips reading "-" took more room on the card than the prompt did. + expect(paths()).toEqual(['name', 'llmDescriptor.provider', 'llmDescriptor.model']); + }); + + it('emits the object itself as one leaf when asked for', () => { + expect(paths({ includeOptionalGroups: true })).toEqual([ + 'name', 'llmDescriptor.provider', 'llmDescriptor.model', 'llmDescriptor.parameters' + ]); + }); + + it('still unfolds a nested object that is not marked', () => { + // The change must be opt-in per object: every other nested object keeps its inline fields. + const plain = JSON.parse(JSON.stringify(schema)); + delete plain.properties.llmDescriptor.properties.parameters['x-ui-optional-group']; + + expect(collectSchemaLeafFields(plain, ({ path }) => path)).toEqual([ + 'name', 'llmDescriptor.provider', 'llmDescriptor.model', + 'llmDescriptor.parameters.temperature', 'llmDescriptor.parameters.seed' + ]); + }); + + it('carries the marked object schema through to the caller', () => { + // The caller needs the object's own schema to build the dialog from it. + const leaves = collectSchemaLeafFields( + schema, + ({ path, schema: leafSchema }) => ({ path, leafSchema }), + { includeOptionalGroups: true } + ); + const group = leaves.find((leaf) => leaf.path === 'llmDescriptor.parameters')!; + + expect(group.leafSchema?.['x-ui-optional-group-label']).toBe('Model parameters'); + expect(Object.keys(group.leafSchema?.['properties'] ?? {})).toEqual(['temperature', 'seed']); + }); +}); + diff --git a/src/app/shared/nodes/schema-driven-fields.ts b/src/app/shared/nodes/schema-driven-fields.ts index f89b8fb..45c58c4 100644 --- a/src/app/shared/nodes/schema-driven-fields.ts +++ b/src/app/shared/nodes/schema-driven-fields.ts @@ -100,12 +100,15 @@ export type SchemaRichContentFieldView = { export type SchemaDisplayItem< TField extends { path: string }, TRichContent extends { path: string } = never, - TArray extends { path: string } = never + TArray extends { path: string } = never, + TOptionalGroup extends { path: string } = never > = { path: string; field: TField | null; richContentField: TRichContent | null; arrayField: TArray | null; + /** A whole object rendered as one control that opens a dialog. */ + optionalGroupField: TOptionalGroup | null; }; export type SchemaDisplayGroup = { @@ -230,6 +233,11 @@ export function collectSchemaLeafFields( mapLeaf: (context: SchemaLeafFieldContext) => T | null, options?: { includeArrays?: boolean; + /** + * Emit an `x-ui-optional-group` object as one leaf rather than unfolding it. Opt-in for the + * same reason arrays are: most callers want editable scalars and would choke on an object. + */ + includeOptionalGroups?: boolean; shouldSkip?: (context: { key: string; path: string; schema: Record | null }) => boolean; } ): T[] { @@ -261,7 +269,12 @@ export function collectSchemaLeafFields( continue; } - if (hasChildren && !isArray) { + // An object marked as an optional group is emitted whole instead of being unfolded: it is + // one control that opens a dialog, not a fieldset of its properties. Without stopping here, + // five settings most nodes never touch take more room on the card than the prompt does. + const isOptionalGroup = hasChildren && !isArray && childResolved?.['x-ui-optional-group'] === true; + + if (hasChildren && !isArray && !isOptionalGroup) { walk(childResolved as Record, path, { visibleWhen: childUi.visibleWhen, enabledWhen: childUi.enabledWhen, @@ -270,6 +283,10 @@ export function collectSchemaLeafFields( continue; } + if (isOptionalGroup && options?.includeOptionalGroups !== true) { + continue; + } + if (seen.has(path)) continue; seen.add(path); @@ -354,40 +371,44 @@ export function buildOrderedSchemaDisplay< TDefinition extends { path: string }, TField extends { path: string }, TRichContent extends { path: string } = never, - TArray extends { path: string } = never + TArray extends { path: string } = never, + TOptionalGroup extends { path: string } = never >( params: { definitions: TDefinition[]; fields: TField[]; richContentFields?: TRichContent[]; arrayFields?: TArray[]; + optionalGroupFields?: TOptionalGroup[]; resolveGroupLabel: (path: string) => string | null; resolveLegend?: (groupLabel: string) => string; - shouldGroupItem?: (item: SchemaDisplayItem) => boolean; + shouldGroupItem?: (item: SchemaDisplayItem) => boolean; } ): { - rootItems: Array>; - groups: Array>>; - sections: Array>>; + rootItems: Array>; + groups: Array>>; + sections: Array>>; } { const fieldByPath = new Map(params.fields.map((field) => [field.path, field] as const)); const richContentByPath = new Map((params.richContentFields ?? []).map((field) => [field.path, field] as const)); const arrayByPath = new Map((params.arrayFields ?? []).map((field) => [field.path, field] as const)); - const rootItems: Array> = []; - const groups = new Map>>(); - const sections: Array>> = []; + const optionalGroupByPath = new Map((params.optionalGroupFields ?? []).map((field) => [field.path, field] as const)); + const rootItems: Array> = []; + const groups = new Map>>(); + const sections: Array>> = []; const resolveLegend = params.resolveLegend ?? ((groupLabel: string) => groupLabel); - const shouldGroupItem = params.shouldGroupItem ?? ((item: SchemaDisplayItem) => item.field != null); + const shouldGroupItem = params.shouldGroupItem ?? ((item: SchemaDisplayItem) => item.field != null); for (const definition of params.definitions) { - const item: SchemaDisplayItem = { + const item: SchemaDisplayItem = { path: definition.path, field: fieldByPath.get(definition.path) ?? null, richContentField: richContentByPath.get(definition.path) ?? null, - arrayField: arrayByPath.get(definition.path) ?? null + arrayField: arrayByPath.get(definition.path) ?? null, + optionalGroupField: optionalGroupByPath.get(definition.path) ?? null }; - if (!item.field && !item.richContentField && !item.arrayField) { + if (!item.field && !item.richContentField && !item.arrayField && !item.optionalGroupField) { continue; }