+ } @else if (section.item?.optionalGroupField; as optionalGroup) {
+
diff --git a/src/app/shared/nodes/container-node/container-node.spec.ts b/src/app/shared/nodes/container-node/container-node.spec.ts
index 147baac..94107c9 100644
--- a/src/app/shared/nodes/container-node/container-node.spec.ts
+++ b/src/app/shared/nodes/container-node/container-node.spec.ts
@@ -8,6 +8,7 @@ import { FieldRetriever } from '@services/retriever/field-retriever';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { EditorStateHolder } from '@stores/flow-editor';
+import { buildSchemaOptionalGroupFieldDefinitions } from '../schema-driven-fields';
import { ContainerNodeComponent } from './container-node';
describe('ContainerNodeComponent', () => {
@@ -145,6 +146,98 @@ describe('ContainerNodeComponent', () => {
]);
});
+ describe('an optional group', () => {
+ // The same annotation, honoured the same way as in a block: one control that opens the whole
+ // object, and a write-back that can return the group to "nothing set".
+ const schema = {
+ type: 'object',
+ properties: {
+ maxIterations: { type: 'integer' },
+ llmDescriptor: {
+ type: 'object',
+ properties: {
+ provider: { type: 'string' },
+ parameters: {
+ type: 'object',
+ 'x-ui-optional-group': true,
+ 'x-ui-optional-group-label': 'Model parameters',
+ properties: {
+ temperature: { type: 'number' },
+ topK: { type: 'integer' }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ function withSchema(parameters?: Record) {
+ component.data = {
+ data: {
+ specificConfiguration: {
+ llmDescriptor: { provider: 'p', ...(parameters ? { parameters } : {}) }
+ },
+ inputs: [],
+ outputs: []
+ }
+ };
+ (component as any).containerSchema = schema;
+ (component as any).containerFieldDefinitions = (component as any).buildContainerFieldDefinitions(schema);
+ (component as any).optionalGroupFieldDefinitions =
+ buildSchemaOptionalGroupFieldDefinitions(schema);
+ }
+
+ it('collects the group as one item instead of a field per setting', () => {
+ withSchema();
+
+ const definitions = (component as any).optionalGroupFieldDefinitions;
+ expect(definitions.map((definition: any) => definition.path)).toEqual(['llmDescriptor.parameters']);
+ expect(definitions[0].label).toBe('Model parameters');
+ // The settings inside it must not also appear as ordinary chips.
+ expect((component as any).containerFieldDefinitions.map((field: any) => field.path))
+ .not.toContain('llmDescriptor.parameters.temperature');
+ });
+
+ it('opens the whole group in one dialog, prefilled with what is set', async () => {
+ withSchema({ temperature: 0.7 });
+ const openSpy = vi.spyOn(settingsDialog, 'open').mockResolvedValue(null);
+
+ await component.openOptionalGroupEditor('llmDescriptor.parameters');
+
+ const dialog = openSpy.mock.calls.at(-1)?.[0];
+ expect(dialog?.title).toBe('Model parameters');
+ expect(dialog?.fields.map((field) => field.key)).toEqual(['temperature', 'topK']);
+ expect(dialog?.initial).toEqual({ temperature: '0.7', topK: '' });
+ });
+
+ it('writes only what was filled in, keeping a temperature of 0', async () => {
+ withSchema();
+ vi.spyOn(settingsDialog, 'open').mockResolvedValue({ temperature: '0', topK: '' });
+
+ await component.openOptionalGroupEditor('llmDescriptor.parameters');
+
+ expect(component.data.data.specificConfiguration.llmDescriptor.parameters).toEqual({ temperature: 0 });
+ });
+
+ it('removes the group entirely when everything is cleared', async () => {
+ withSchema({ temperature: 0.7 });
+ vi.spyOn(settingsDialog, 'open').mockResolvedValue({ temperature: '', topK: '' });
+
+ await component.openOptionalGroupEditor('llmDescriptor.parameters');
+
+ expect('parameters' in component.data.data.specificConfiguration.llmDescriptor).toBe(false);
+ });
+
+ it('counts a temperature of 0 as set', () => {
+ withSchema({ temperature: 0, topK: null });
+
+ expect((component as any).optionalGroupViews()[0]).toMatchObject({
+ label: 'Model parameters',
+ setCount: 1
+ });
+ });
+ });
+
it('shows external flow import without subflow drag-and-drop copy', async () => {
component.data = {
data: {
diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts
index 4d29c16..ad15834 100644
--- a/src/app/shared/nodes/container-node/container-node.ts
+++ b/src/app/shared/nodes/container-node/container-node.ts
@@ -33,6 +33,18 @@ import {
} from '../flow-data-schema-fields';
import {
buildSchemaEditableFieldDefinitions,
+ buildSchemaObjectDialog,
+ buildSchemaOptionalGroupFieldDefinitions,
+ countSetSchemaGroupValues,
+ deleteSchemaValueByPath,
+ parseSchemaObjectDialogResult,
+ schemaEnumOptions,
+ schemaFieldTypeFromSchema,
+ schemaNodeOptionsSource,
+ schemaRetrieverMeta,
+ toSchemaFieldUiMeta,
+ type SchemaObjectDialogHooks,
+ type SchemaOptionalGroupFieldDefinition,
buildSchemaFieldViewModel,
buildSchemaRetrieverContext,
buildOrderedSchemaDisplay,
@@ -53,8 +65,7 @@ import {
getSchemaPathUiMeta,
isLongTextValue,
isSchemaPathEnabled,
- isSchemaPathVisible,
- schemaNodeOptionsSource
+ isSchemaPathVisible
} from '../schema-driven-fields';
type ContainerFieldType = SchemaFieldType;
@@ -67,7 +78,15 @@ type ContainerFieldView = SchemaParameterFieldView;
type RichContentView = SchemaRichContentFieldView;
-type ContainerDisplayItem = SchemaDisplayItem;
+/** What the card shows for an optional group: its name, and how many of its settings are set. */
+type OptionalGroupView = {
+ path: string;
+ label: string;
+ setCount: number;
+ enabled: boolean;
+};
+
+type ContainerDisplayItem = SchemaDisplayItem;
type ContainerFieldGroupView = SchemaDisplayGroup;
@@ -110,6 +129,8 @@ export class ContainerNodeComponent implements OnDestroy {
parameterFieldGroups: ContainerFieldGroupView[] = [];
richContentFields: RichContentView[] = [];
parameterDisplayItems: ContainerDisplayItem[] = [];
+ optionalGroupFields: OptionalGroupView[] = [];
+ optionalGroupFieldDefinitions: SchemaOptionalGroupFieldDefinition[] = [];
parameterDisplaySections: ContainerDisplaySection[] = [];
schemaReady = false;
focusOpen = false;
@@ -544,6 +565,84 @@ export class ContainerNodeComponent implements OnDestroy {
await this.applyFieldValue(definition, result[definition.path]);
}
+ /**
+ * Opens a whole optional group in one dialog, the same round trip the block editor uses. A group
+ * that comes back with nothing set is removed outright, so a saved flow never carries an empty
+ * object that reads as a choice someone made.
+ */
+ async openOptionalGroupEditor(path: string, event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ if (this.isReadonly) return;
+
+ const definition = this.optionalGroupFieldDefinitions.find((field) => field.path === path);
+ if (!definition || !this.isFieldVisible(path) || !this.isFieldEnabled(path)) return;
+
+ const current = getValueByPath(this.configuration ?? {}, path);
+ const currentValue = current && typeof current === 'object' && !Array.isArray(current)
+ ? { ...(current as Record) }
+ : {};
+
+ const hooks = this.objectDialogHooks(definition.objectSchema);
+ const dialog = await buildSchemaObjectDialog(definition.objectSchema, definition.label, currentValue, hooks);
+ const result = await this.settingsDialog.open(dialog);
+ if (!result) return;
+
+ const next = parseSchemaObjectDialogResult(definition.objectSchema, result, currentValue, hooks);
+ const nextConfiguration = this.cloneConfiguration();
+ if (Object.keys(next).length) {
+ setSchemaValueByPath(nextConfiguration, path, next);
+ } else {
+ deleteSchemaValueByPath(nextConfiguration, path);
+ }
+ this.pruneInactiveConfiguration(nextConfiguration);
+
+ if (definition.ui.structural) {
+ this.updateCurrentFlowData(nextConfiguration);
+ await this.recreateContainer(nextConfiguration);
+ return;
+ }
+
+ this.data.data = {
+ ...this.data.data,
+ specificConfiguration: nextConfiguration
+ };
+ this.refreshParameterFields();
+ this.updateCurrentFlowData(nextConfiguration);
+ this.refreshView();
+ }
+
+ /**
+ * Select options are the one part the shared round trip cannot resolve on its own: it sees a
+ * property schema, while this component resolves options from a field definition.
+ */
+ 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)
+ })
+ };
+ }
+
+ private optionalGroupViews(): OptionalGroupView[] {
+ const config = this.configuration ?? {};
+ return this.optionalGroupFieldDefinitions
+ .filter((definition) => this.isFieldVisible(definition.path))
+ .map((definition) => ({
+ path: definition.path,
+ label: definition.label,
+ setCount: countSetSchemaGroupValues(getValueByPath(config, definition.path)),
+ enabled: this.isFieldEnabled(definition.path)
+ }));
+ }
+
async toggleBooleanParameter(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
@@ -738,6 +837,7 @@ export class ContainerNodeComponent implements OnDestroy {
this.schemaRequirements = extractSchemaRequirements(this.containerSchema);
this.containerFlowFieldDefinitions = collectSchemaFlowDataFields(this.containerSchema);
this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema);
+ this.optionalGroupFieldDefinitions = buildSchemaOptionalGroupFieldDefinitions(this.containerSchema);
this.refreshParameterFields();
} finally {
this.schemaLoading = false;
@@ -793,10 +893,15 @@ export class ContainerNodeComponent implements OnDestroy {
this.parameterFields = grouped.parameterFields;
this.richContentFields = grouped.richContentFields;
+ this.optionalGroupFields = this.optionalGroupViews();
const ordered = buildOrderedSchemaDisplay({
- definitions: this.containerFieldDefinitions.filter((field) => !this.isContainerTypeField(field.path)),
+ definitions: [
+ ...this.containerFieldDefinitions.filter((field) => !this.isContainerTypeField(field.path)),
+ ...this.optionalGroupFieldDefinitions
+ ],
fields: allFields,
richContentFields: allRichContentFields,
+ optionalGroupFields: this.optionalGroupFields,
resolveGroupLabel: (path) => getSchemaPathUiMeta(this.containerSchema, path).group ?? parentGroupLabel(path)
});
this.parameterDisplayItems = ordered.rootItems;