diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index d4b0cc6..49f29aa 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -409,6 +409,7 @@ } .container-node__param-fieldset { + grid-column: 1 / -1; margin: 0 14px; padding: 12px; border: 1px solid #dbe2ea; @@ -433,6 +434,10 @@ padding: 8px 10px; } +.container-node__param-chip--root { + grid-column: 1 / -1; +} + .container-node__param-chip--disabled { opacity: 0.58; } @@ -441,6 +446,15 @@ grid-column: 1 / -1; } +.container-node__param-chip--boolean { + padding-top: 6px; + padding-bottom: 6px; +} + +.container-node__param-chip--boolean .container-node__param-head { + margin-bottom: 0; +} + .container-node__param-head { display: flex; align-items: center; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index ddbd321..90a9829 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -208,104 +208,67 @@ } - @if (parameterFieldGroups.length) { -
- @for (group of parameterFieldGroups; track group.key) { -
- {{ group.legend }} - @if (group.fields.length) { -
- @for (field of group.fields; track field.path) { -
-
- {{ field.label }} - @if (field.type === 'boolean' && !isReadonly) { - - } @else if (!isReadonly) { - - } @else if (field.expandable) { - - } -
- {{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }} -
- } -
- } - @for (contentField of group.richContentFields; track contentField.path) { -
+ @for (section of parameterDisplaySections; track section.key) { + @if (section.group; as group) { +
+ {{ group.legend }} +
+ @for (item of group.items; track item.path) { + @if (item.field; as field) { +
- {{ contentField.label }} - @if (contentField.expandable && isReadonly) { + {{ field.label }} + @if (field.type === 'boolean') { } @else if (!isReadonly) { + } @else if (field.expandable) { + }
-
- @for (part of contentField.parts; track $index) { - @if (part.isDynamicInput) { - {{ formatDynamicInputToken(part.text) }} - } @else { - {{ part.text }} - } - } -
+ @if (field.type !== 'boolean') { + {{ field.value }} + }
} -
- } -
- } - @for (field of parameterFields; track field.path) { -
+ } +
+
+ } @else if (section.item?.field; as field) { +
{{ field.label }} - @if (field.type === 'boolean' && !isReadonly) { + @if (field.type === 'boolean') {
- {{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }} + @if (field.type !== 'boolean') { + {{ field.value }} + }
- } - @for (contentField of richContentFields; track contentField.path) { + } @else if (section.item?.richContentField; as contentField) {
{{ contentField.label }} @@ -371,6 +335,7 @@
} + }
} diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 79ea205..b9f45cb 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -18,13 +18,16 @@ import { buildSchemaEditableFieldDefinitions, buildSchemaFieldViewModel, buildSchemaRetrieverContext, + buildOrderedSchemaDisplay, pruneInactiveSchemaConfiguration, resetDependentSchemaRetrieverFields, schemaValuesEqual, setSchemaValueByPath, type SchemaEditableFieldDefinition, + type SchemaDisplayGroup, + type SchemaDisplayItem, + type SchemaDisplaySection, type SchemaFieldType, - type SchemaFieldGroup, type SchemaNodeOptionsSource, type SchemaParameterFieldView, type SchemaRichContentFieldView, @@ -47,7 +50,11 @@ type ContainerFieldView = SchemaParameterFieldView; type RichContentView = SchemaRichContentFieldView; -type ContainerFieldGroupView = SchemaFieldGroup; +type ContainerDisplayItem = SchemaDisplayItem; + +type ContainerFieldGroupView = SchemaDisplayGroup; + +type ContainerDisplaySection = SchemaDisplaySection; type StructuredRetrieverConfig = { retrieverName: string; @@ -84,6 +91,8 @@ export class ContainerNodeComponent { parameterFields: ContainerFieldView[] = []; parameterFieldGroups: ContainerFieldGroupView[] = []; richContentFields: RichContentView[] = []; + parameterDisplayItems: ContainerDisplayItem[] = []; + parameterDisplaySections: ContainerDisplaySection[] = []; schemaReady = false; private schemaLoading = false; nameEditorOpen = false; @@ -265,11 +274,11 @@ export class ContainerNodeComponent { } hasParameterFields() { - return this.parameterFields.length > 0 || this.parameterFieldGroups.some((group) => group.fields.length > 0); + return this.parameterDisplaySections.length > 0; } hasMainContent() { - return this.richContentFields.length > 0 || this.parameterFieldGroups.some((group) => group.richContentFields.length > 0); + return this.richContentFields.length > 0; } formatDynamicInputToken(token: string): string { @@ -770,9 +779,26 @@ export class ContainerNodeComponent { resolveGroupLabel: (path) => getSchemaPathUiMeta(this.containerSchema, path).group ?? parentPath(path) }); + const allFields = [ + ...grouped.parameterFields, + ...grouped.parameterFieldGroups.flatMap((group) => group.fields) + ]; + const allRichContentFields = [ + ...grouped.richContentFields, + ...grouped.parameterFieldGroups.flatMap((group) => group.richContentFields) + ]; + this.parameterFields = grouped.parameterFields; this.richContentFields = grouped.richContentFields; - this.parameterFieldGroups = grouped.parameterFieldGroups; + const ordered = buildOrderedSchemaDisplay({ + definitions: this.containerFieldDefinitions.filter((field) => !this.isContainerTypeField(field.path)), + fields: allFields, + richContentFields: allRichContentFields, + resolveGroupLabel: (path) => getSchemaPathUiMeta(this.containerSchema, path).group ?? parentPath(path) + }); + this.parameterDisplayItems = ordered.rootItems; + this.parameterFieldGroups = ordered.groups; + this.parameterDisplaySections = ordered.sections; } private buildContainerFieldDefinitions(schema: Record | null): ContainerFieldDefinition[] { diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index bc763a1..bd77f5b 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -714,6 +714,10 @@ overflow: hidden; } +.llm-param-chip-root { + grid-column: 1 / -1; +} + .llm-param-chip-disabled { opacity: 0.58; } @@ -722,6 +726,11 @@ grid-column: 1 / -1; } +.llm-param-chip-boolean { + padding-top: 5px; + padding-bottom: 5px; +} + .llm-param-row-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 4b977e9..099cafc 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -228,22 +228,22 @@ } - @if (parameterFieldGroups.length) { -
- @for (group of parameterFieldGroups; track group.key) { + @for (section of parameterDisplaySections; track section.key) { + @if (section.group; as group) {
{{ group.legend }}
- @for (field of group.fields; track field.path) { -
+ @for (item of group.items; track item.path) { + @if (item.field; as field) { +
{{ field.label }} - @if (field.type === 'boolean' && !isReadonly) { + @if (field.type === 'boolean') {
- {{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }} + @if (field.type !== 'boolean') { + {{ field.value }} + }
+ } @else { + @if (item.richContentField; as contentField) { +
+
+
{{ contentField.label }}
+ @if (!isReadonly) { + + } @else if (contentField.expandable) { + + } +
+
+ @if (!contentField.parts.length) { + - + } @else { + @for (part of contentField.parts; track $index) { + @if (part.isDynamicInput) { + {{ formatDynamicInputToken(part.text) }} + } @else { + {{ part.text }} + } + } + } +
+
+ } + @if (item.arrayField; as arrayField) { +
+
+
{{ arrayField.label }}
+ @if (!isReadonly) { + + } +
+ @if (!arrayField.items.length) { +
No items
+ } @else { + @for (entry of arrayField.items; track entry.index) { +
+ {{ entry.summary }} +
+ @if (!isReadonly) { + + } + @if (!isReadonly) { + + } +
+
+ } + } +
+ } + } }
- } -
- } - - @if (parameterFields.length) { + } @else if (section.item; as item) {
- @for (field of parameterFields; track field.path) { -
+ @if (item.field; as field) { +
{{ field.label }} - @if (field.type === 'boolean' && !isReadonly) { + @if (field.type === 'boolean') {
- {{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }} -
- } -
- } - - @if (hasMainContent()) { - @for (contentField of richContentFields; track contentField.path) { -
-
-
{{ contentField.label }}
- @if (!isReadonly) { - - } @else if (contentField.expandable) { - + @if (field.type !== 'boolean') { + {{ field.value }} }
-
- @if (!contentField.parts.length) { - - - } @else { - @for (part of contentField.parts; track $index) { - @if (part.isDynamicInput) { - {{ formatDynamicInputToken(part.text) }} - } @else { - {{ part.text }} + } @else { + @if (item.richContentField; as contentField) { +
+
+
{{ contentField.label }}
+ @if (!isReadonly) { + + } @else if (contentField.expandable) { + + } +
+
+ @if (!contentField.parts.length) { + - + } @else { + @for (part of contentField.parts; track $index) { + @if (part.isDynamicInput) { + {{ formatDynamicInputToken(part.text) }} + } @else { + {{ part.text }} + } } } - } +
-
} - } - - @if (arrayFields.length) { -
- @for (arrayField of arrayFields; track arrayField.path) { -
+ @if (item.arrayField; as arrayField) { +
{{ arrayField.label }}
@if (!isReadonly) { @@ -382,9 +458,9 @@ @if (!arrayField.items.length) {
No items
} @else { - @for (item of arrayField.items; track item.index) { + @for (entry of arrayField.items; track entry.index) {
- {{ item.summary }} + {{ entry.summary }}
@if (!isReadonly) { } @@ -402,7 +478,7 @@ class="llm-edit-btn llm-array-remove-btn" title="Remove item" (pointerdown)="$event.stopPropagation()" - (click)="removeArrayItem(arrayField.path, item.index, $event)"> + (click)="removeArrayItem(arrayField.path, entry.index, $event)"> } @@ -412,7 +488,9 @@ }
} + }
+ } }
diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 2e4efa6..e41cf4f 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -26,6 +26,7 @@ import { getValueByPath, isConditionalByPorts, isHumanInteractiveNode, + orderedSchemaPropertyEntries, parentPath, pathToLabel, readUiConditionRule, @@ -49,6 +50,9 @@ import { schemaValuesEqual, setSchemaValueByPath, type SchemaEditableFieldDefinition, + type SchemaDisplayGroup, + type SchemaDisplayItem, + type SchemaDisplaySection, type SchemaFieldType, type SchemaParameterFieldView, type SchemaRichContentFieldView, @@ -56,6 +60,7 @@ import { type SchemaNodeOptionsSource, type SchemaRetrieverDependency, buildTemplatedRichContentParts, + buildOrderedSchemaDisplay, collectSchemaLeafFields, getSchemaPathUiMeta, groupSchemaFields, @@ -101,14 +106,14 @@ type ArrayFieldView = { items: ArrayFieldItemView[]; }; -type EditableFieldGroupView = { - key: string; - legend: string; - fields: EditableFieldView[]; -}; - type RichContentView = SchemaRichContentFieldView; +type ParameterDisplayItem = SchemaDisplayItem; + +type EditableFieldGroupView = SchemaDisplayGroup; + +type ParameterDisplaySection = SchemaDisplaySection; + type RenderedSocketPort = { key: string; socket: ClassicPreset.Socket; @@ -159,6 +164,8 @@ export class GenericNodeComponent { parameterFields: EditableFieldView[] = []; parameterFieldGroups: EditableFieldGroupView[] = []; richContentFields: RichContentView[] = []; + parameterDisplayItems: ParameterDisplayItem[] = []; + parameterDisplaySections: ParameterDisplaySection[] = []; arrayFields: ArrayFieldView[] = []; name = 'noName'; @@ -199,6 +206,8 @@ export class GenericNodeComponent { this.parameterFields = []; this.parameterFieldGroups = []; this.richContentFields = []; + this.parameterDisplayItems = []; + this.parameterDisplaySections = []; this.arrayFields = []; Object.entries(this.data.outputs).forEach(([key, output]) => { @@ -511,6 +520,10 @@ export class GenericNodeComponent { return this.richContentFields.length > 0; } + hasOrderedParameterItems(): boolean { + return this.parameterDisplaySections.length > 0; + } + formatDynamicInputToken(token: string): string { const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/); return match ? match[1] : token; @@ -883,6 +896,8 @@ export class GenericNodeComponent { private refreshParameterFields() { const config = this.blockConfiguration ?? {}; const richContentPaths = new Set(this.richContentPaths()); + this.parameterDisplayItems = []; + this.parameterDisplaySections = []; this.richContentFields = this.richContentPaths() .filter((path) => this.isPathVisible(path)) @@ -918,13 +933,26 @@ export class GenericNodeComponent { resolveGroupLabel: (path) => getSchemaPathUiMeta(this.blockSchema, path).group ?? parentPath(path), groupRichContent: false }); + const allFields = [ + ...groupedFields.parameterFields, + ...groupedFields.parameterFieldGroups.flatMap((group) => group.fields) + ]; + const allRichContentFields = [ + ...groupedFields.richContentFields, + ...groupedFields.parameterFieldGroups.flatMap((group) => group.richContentFields) + ]; this.parameterFields = groupedFields.parameterFields; - this.parameterFieldGroups = groupedFields.parameterFieldGroups.map((group) => ({ - key: group.key, - legend: group.legend, - fields: group.fields - })); this.richContentFields = groupedFields.richContentFields; + const ordered = buildOrderedSchemaDisplay({ + definitions: this.orderedDisplayPaths(), + fields: allFields, + richContentFields: allRichContentFields, + arrayFields: this.arrayFields, + resolveGroupLabel: (path) => getSchemaPathUiMeta(this.blockSchema, path).group ?? parentPath(path) + }); + this.parameterDisplayItems = ordered.rootItems; + this.parameterFieldGroups = ordered.groups; + this.parameterDisplaySections = ordered.sections; this.refreshView(); return; } @@ -952,13 +980,59 @@ export class GenericNodeComponent { this.parameterFields = groupedFallback.rootFields; this.parameterFieldGroups = groupedFallback.groups.map((group) => ({ key: group.key, - legend: group.legend, - fields: group.fields + legend: group.legend, + items: group.fields.map((field) => ({ + path: field.path, + field, + richContentField: null, + arrayField: null + })) + })); + this.parameterDisplayItems = groupedFallback.rootFields.map((field) => ({ + path: field.path, + field, + richContentField: null, + arrayField: null })); + this.parameterDisplaySections = [ + ...groupedFallback.groups.map((group) => ({ + key: group.key, + group: { + key: group.key, + legend: group.legend, + items: group.fields.map((field) => ({ + path: field.path, + field, + richContentField: null, + arrayField: null + })) + }, + item: null + })), + ...groupedFallback.rootFields.map((field) => ({ + key: `item:${field.path}`, + group: null, + item: { + path: field.path, + field, + richContentField: null, + arrayField: null + } + })) + ]; this.refreshView(); } + private orderedDisplayPaths(): Array<{ path: string }> { + return collectSchemaLeafFields(this.blockSchema, ({ key, path }) => { + if (key === 'type' || key === 'name' || key.startsWith('__')) return null; + return { path }; + }, { + includeArrays: true + }); + } + async addArrayItem(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); @@ -1062,8 +1136,8 @@ export class GenericNodeComponent { const fields: NodeSettingField[] = []; const initial: Record = {}; - for (const [key, rawPropertySchema] of Object.entries(properties)) { - const propertySchema = resolveSchemaRef(rawPropertySchema as Record, schemaRoot); + for (const { key, schema: propertySchema } of orderedSchemaPropertyEntries(itemSchema, schemaRoot)) { + if (!propertySchema) continue; if (shouldSkipSchemaField(key, propertySchema)) continue; const fieldUi = this.toFieldUiMeta(propertySchema); @@ -1082,10 +1156,10 @@ export class GenericNodeComponent { const label = schemaFieldLabel(key, propertySchema); const currentValue = item[key]; const options = await this.loadNodeSettingOptions(propertySchema, item, ''); - const isObjectLike = propertySchema?.type === 'object'; + const isObjectLike = propertySchema?.['type'] === 'object'; const fieldType = options ? 'select' : - propertySchema?.type === 'boolean' ? 'checkbox' : + propertySchema?.['type'] === 'boolean' ? 'checkbox' : propertySchema?.['x-ui-widget'] === 'textarea' || isObjectLike ? 'textarea' : 'text'; @@ -1145,8 +1219,8 @@ export class GenericNodeComponent { const nextItem: Record = {}; - for (const [key, rawPropertySchema] of Object.entries(properties)) { - const propertySchema = resolveSchemaRef(rawPropertySchema as Record, schemaRoot); + for (const { key, schema: propertySchema } of orderedSchemaPropertyEntries(itemSchema, schemaRoot)) { + if (!propertySchema) continue; if (shouldSkipSchemaField(key, propertySchema)) continue; const fieldUi = this.toFieldUiMeta(propertySchema); @@ -1162,9 +1236,9 @@ export class GenericNodeComponent { } const rawValue = result[key]; - const isObjectLike = propertySchema?.type === 'object'; + const isObjectLike = propertySchema?.['type'] === 'object'; - if (propertySchema?.type === 'boolean') { + if (propertySchema?.['type'] === 'boolean') { nextItem[key] = rawValue === true; continue; } @@ -1178,10 +1252,10 @@ export class GenericNodeComponent { continue; } - if (propertySchema?.type === 'number' || propertySchema?.type === 'integer') { + if (propertySchema?.['type'] === 'number' || propertySchema?.['type'] === 'integer') { const numeric = Number(rawValue ?? 0); nextItem[key] = Number.isFinite(numeric) - ? (propertySchema.type === 'integer' ? Math.trunc(numeric) : numeric) + ? (propertySchema['type'] === 'integer' ? Math.trunc(numeric) : numeric) : 0; continue; } @@ -1198,18 +1272,18 @@ export class GenericNodeComponent { if (!properties) return {}; const item: Record = {}; - for (const [key, rawPropertySchema] of Object.entries(properties)) { - const propertySchema = resolveSchemaRef(rawPropertySchema as Record, schemaRoot); + for (const { key, schema: propertySchema } of orderedSchemaPropertyEntries(itemSchema, schemaRoot)) { + if (!propertySchema) continue; if (shouldSkipSchemaField(key, propertySchema)) continue; if (Object.prototype.hasOwnProperty.call(propertySchema ?? {}, 'default')) { - item[key] = propertySchema.default; + item[key] = propertySchema['default']; continue; } - if (propertySchema?.type === 'boolean') { + if (propertySchema?.['type'] === 'boolean') { item[key] = false; - } else if (propertySchema?.type === 'number' || propertySchema?.type === 'integer') { + } else if (propertySchema?.['type'] === 'number' || propertySchema?.['type'] === 'integer') { item[key] = 0; - } else if (propertySchema?.type === 'object' || this.hasDynamicSchema(propertySchema)) { + } else if (propertySchema?.['type'] === 'object' || this.hasDynamicSchema(propertySchema)) { item[key] = {}; } else { item[key] = ''; @@ -1303,11 +1377,11 @@ export class GenericNodeComponent { inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null } ) => { const resolved = resolveSchemaRef(node, schema); - const properties = resolved?.['properties'] as Record | undefined; - if (!properties) return; + const properties = orderedSchemaPropertyEntries(resolved, schema); + if (!properties.length) return; - for (const [childKey, rawChildSchema] of Object.entries(properties)) { - const childSchema = resolveSchemaRef(rawChildSchema as Record, schema); + for (const { key: childKey, schema: childSchema } of properties) { + if (!childSchema) continue; if (shouldSkipSchemaField(childKey, childSchema)) continue; const childUi = this.toFieldUiMeta(childSchema, inheritedUi); @@ -1323,7 +1397,7 @@ export class GenericNodeComponent { const nextLabel = `${titlePrefix} ${schemaFieldLabel(childKey, childSchema)}`; const currentNestedValue = getValueByPath(currentRecord, fieldRelativePath); - const hasChildren = !!childSchema?.['properties'] || childSchema?.type === 'object'; + const hasChildren = !!childSchema?.['properties'] || childSchema?.['type'] === 'object'; if (hasChildren) { await walk(childSchema as Record, nextPath, nextLabel, { visibleWhen: childUi.visibleWhen, @@ -1341,7 +1415,7 @@ export class GenericNodeComponent { const fieldType = options ? 'select' : - childSchema?.type === 'boolean' ? 'checkbox' : + childSchema?.['type'] === 'boolean' ? 'checkbox' : childSchema?.['x-ui-widget'] === 'textarea' ? 'textarea' : 'text'; @@ -1433,7 +1507,7 @@ export class GenericNodeComponent { if (!properties) return `Item ${index + 1}`; const summaryParts: string[] = []; - for (const key of Object.keys(properties)) { + for (const { key } of orderedSchemaPropertyEntries(definition.itemSchema, this.blockSchema ?? definition.itemSchema ?? {})) { const value = (item as Record)[key]; if (this.isMissingValue(value)) continue; if (typeof value === 'string') { diff --git a/src/app/shared/nodes/node-utility.spec.ts b/src/app/shared/nodes/node-utility.spec.ts index e6a40ed..dc19ff1 100644 --- a/src/app/shared/nodes/node-utility.spec.ts +++ b/src/app/shared/nodes/node-utility.spec.ts @@ -1,6 +1,7 @@ import { evaluateUiConditionRule, flattenPrimitiveValues, + orderedSchemaPropertyEntries, parentPath, pathToLabel, readUiConditionRule, @@ -115,6 +116,38 @@ describe('node-utility', () => { }); }); + it('orderedSchemaPropertyEntries prioritizes x-ui-property-order, then x-ui-order, then stable schema order', () => { + const root = { + type: 'object', + 'x-ui-property-order': ['name', 'subFlow', 'maxIterations', 'useLlm', 'guardCondition', 'llmDescriptor', 'guardPrompt'], + properties: { + guardPrompt: { type: 'string' }, + type: { type: 'string', 'x-ui-order': -10 }, + llmDescriptor: { type: 'object', 'x-ui-order': 20 }, + extraAlpha: { type: 'string', 'x-ui-order': 10 }, + maxIterations: { type: 'integer' }, + guardCondition: { type: 'string' }, + extraBeta: { type: 'string' }, + name: { type: 'string' }, + subFlow: { type: 'object' }, + useLlm: { type: 'boolean' } + } + }; + + expect(orderedSchemaPropertyEntries(root, root).map((entry) => entry.key)).toEqual([ + 'name', + 'subFlow', + 'maxIterations', + 'useLlm', + 'guardCondition', + 'llmDescriptor', + 'guardPrompt', + 'extraAlpha', + 'extraBeta', + 'type' + ]); + }); + it('getValueByPath resolves nested values', () => { expect(getValueByPath({ llm: { enabled: true } }, 'llm.enabled')).toBe(true); expect(getValueByPath({ llm: { enabled: true } }, 'llm.missing')).toBeUndefined(); @@ -129,6 +162,10 @@ describe('node-utility', () => { field: 'method', in: ['POST', 'PUT'] }); + expect(readUiConditionRule({ field: 'feedbackInput', present: true })).toEqual({ + field: 'feedbackInput', + present: true + }); expect(readUiConditionRule({ field: '', equals: 'true' })).toBeNull(); expect(readUiConditionRule({ field: 'useLlm', equals: true })).toBeNull(); expect(readUiConditionRule({ field: 'method', in: [] })).toBeNull(); @@ -158,6 +195,25 @@ describe('node-utility', () => { expect(evaluateUiConditionRule({ field: 'useLlm', in: ['false', 'true'] }, config, resolveFieldSchema)).toBe(true); }); + it('evaluateUiConditionRule treats present using non-null and collection length semantics', () => { + const config = { + feedbackInput: ' hello ', + emptyFeedback: ' ', + selectedTools: [''], + noTools: [], + feedbackPrompt: {}, + missingPrompt: null + }; + + expect(evaluateUiConditionRule({ field: 'feedbackInput', present: true }, config)).toBe(true); + expect(evaluateUiConditionRule({ field: 'emptyFeedback', present: true }, config)).toBe(false); + expect(evaluateUiConditionRule({ field: 'selectedTools', present: true }, config)).toBe(true); + expect(evaluateUiConditionRule({ field: 'noTools', present: true }, config)).toBe(false); + expect(evaluateUiConditionRule({ field: 'feedbackPrompt', present: true }, config)).toBe(true); + expect(evaluateUiConditionRule({ field: 'missingPrompt', present: true }, config)).toBe(false); + expect(evaluateUiConditionRule({ field: 'missingPrompt', present: false }, config)).toBe(true); + }); + it('splitTemplatedTextParts identifies dynamic placeholders', () => { const parts = splitTemplatedTextParts('Hello ${{ user.name }}!'); expect(parts).toEqual([ diff --git a/src/app/shared/nodes/node-utility.ts b/src/app/shared/nodes/node-utility.ts index b4d1498..672e8fc 100644 --- a/src/app/shared/nodes/node-utility.ts +++ b/src/app/shared/nodes/node-utility.ts @@ -4,6 +4,11 @@ export type UiConditionRule = | { field: string; in: string[] } | { field: string; present: boolean }; +export type OrderedSchemaPropertyEntry = { + key: string; + schema: Record | null; +}; + export function toStringOrNull(value: unknown): string | null { if (typeof value === 'string' && value.trim().length > 0) return value; return null; @@ -118,6 +123,63 @@ export function resolveSchemaPath(root: Record | null | undefined, return current; } +export function orderedSchemaPropertyEntries( + node: Record | null | undefined, + root: Record +): OrderedSchemaPropertyEntry[] { + if (!node || typeof node !== 'object') return []; + + const resolved = resolveSchemaRef(node, root); + const properties = resolved?.properties as Record | undefined; + if (!properties) return []; + + const propertyOrder = readUiPropertyOrder(resolved?.['x-ui-property-order']); + const prioritized = new Map(); + propertyOrder.forEach((key, index) => { + if (!prioritized.has(key)) { + prioritized.set(key, index); + } + }); + + return Object.entries(properties) + .map(([key, childSchema], originalIndex) => { + const resolvedChild = childSchema && typeof childSchema === 'object' + ? resolveSchemaRef(childSchema as Record, root) + : null; + const rawOrder = resolvedChild?.['x-ui-order']; + const uiOrder = typeof rawOrder === 'number' && Number.isInteger(rawOrder) ? rawOrder : null; + const priorityIndex = prioritized.get(key) ?? null; + const isTechnical = key === 'type'; + return { + key, + schema: resolvedChild, + originalIndex, + uiOrder, + priorityIndex, + isTechnical + }; + }) + .sort((left, right) => { + const leftBucket = left.priorityIndex != null ? 0 : left.isTechnical ? 2 : 1; + const rightBucket = right.priorityIndex != null ? 0 : right.isTechnical ? 2 : 1; + if (leftBucket !== rightBucket) return leftBucket - rightBucket; + + if (left.priorityIndex != null && right.priorityIndex != null) { + return left.priorityIndex - right.priorityIndex; + } + + const leftHasOrder = left.uiOrder != null; + const rightHasOrder = right.uiOrder != null; + if (leftHasOrder !== rightHasOrder) return leftHasOrder ? -1 : 1; + if (left.uiOrder != null && right.uiOrder != null && left.uiOrder !== right.uiOrder) { + return left.uiOrder - right.uiOrder; + } + + return left.originalIndex - right.originalIndex; + }) + .map(({ key, schema }) => ({ key, schema })); +} + export function readUiConditionRule(value: unknown): UiConditionRule | null { if (!value || typeof value !== 'object') return null; @@ -260,13 +322,19 @@ function parseBooleanCondition(value: string): boolean { return value.trim().toLowerCase() === 'true'; } +function readUiPropertyOrder(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + function isMeaningfullyPresent(value: unknown): boolean { if (value == null) return false; if (typeof value === 'string') return value.trim().length > 0; - if (Array.isArray(value)) return value.some((item) => isMeaningfullyPresent(item)); - if (typeof value === 'object') { - return Object.values(value as Record).some((item) => isMeaningfullyPresent(item)); - } + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') return true; return true; } diff --git a/src/app/shared/nodes/schema-driven-fields.spec.ts b/src/app/shared/nodes/schema-driven-fields.spec.ts index 2c46049..6c8ead2 100644 --- a/src/app/shared/nodes/schema-driven-fields.spec.ts +++ b/src/app/shared/nodes/schema-driven-fields.spec.ts @@ -1,4 +1,5 @@ import { + buildOrderedSchemaDisplay, buildSchemaEditableFieldDefinitions, buildSchemaFieldViewModel, buildSchemaRetrieverContext, @@ -138,6 +139,71 @@ describe('schema-driven-fields', () => { ]); }); + it('orders editable schema field definitions using x-ui-property-order and x-ui-order', () => { + const definitions = buildSchemaEditableFieldDefinitions({ + type: 'object', + 'x-ui-property-order': ['name', 'subFlow', 'maxIterations', 'useLlm', 'guardCondition', 'llmDescriptor', 'guardPrompt'], + properties: { + guardPrompt: { + type: 'string' + }, + type: { + type: 'string' + }, + llmDescriptor: { + type: 'object', + properties: { + model: { + type: 'string', + 'x-ui-order': 20 + }, + provider: { + type: 'string', + 'x-ui-order': 10 + } + } + }, + extraAlpha: { + type: 'string', + 'x-ui-order': 50 + }, + maxIterations: { + type: 'integer' + }, + guardCondition: { + type: 'string' + }, + extraBeta: { + type: 'string' + }, + name: { + type: 'string' + }, + subFlow: { + type: 'string' + }, + useLlm: { + type: 'boolean' + } + } + }, { + shouldSkip: ({ key }) => key === 'type' + }); + + expect(definitions.map((definition) => definition.path)).toEqual([ + 'name', + 'subFlow', + 'maxIterations', + 'useLlm', + 'guardCondition', + 'llmDescriptor.provider', + 'llmDescriptor.model', + 'guardPrompt', + 'extraAlpha', + 'extraBeta' + ]); + }); + it('builds grouped schema field view models', () => { const result = buildSchemaFieldViewModel({ definitions: [ @@ -173,6 +239,80 @@ describe('schema-driven-fields', () => { expect(result.richContentFields.map((field) => field.path)).toEqual(['prompt']); }); + it('builds ordered display sections without pushing rich content into grouped fieldsets', () => { + const result = buildOrderedSchemaDisplay({ + definitions: [ + { path: 'name' }, + { path: 'llmDescriptor.provider' }, + { path: 'llmDescriptor.model' }, + { path: 'prompt' }, + { path: 'examples' } + ], + fields: [ + { + path: 'name', + label: 'Name', + value: 'Conditional', + wide: false, + expandable: false, + enabled: true, + type: 'string' as const, + booleanValue: false + }, + { + path: 'llmDescriptor.provider', + label: 'Provider', + value: 'OpenAI', + wide: false, + expandable: false, + enabled: true, + type: 'string' as const, + booleanValue: false + }, + { + path: 'llmDescriptor.model', + label: 'Model', + value: 'gpt-5.4', + wide: false, + expandable: false, + enabled: true, + type: 'string' as const, + booleanValue: false + } + ], + richContentFields: [ + { + path: 'prompt', + label: 'Prompt', + rawValue: 'Decide true or false', + expandable: false, + parts: [{ text: 'Decide true or false', isDynamicInput: false }] + } + ], + arrayFields: [ + { + path: 'examples', + label: 'Examples', + items: [{ index: 0, summary: 'Example 1' }] + } + ], + resolveGroupLabel: (path) => path.startsWith('llmDescriptor.') ? 'llm' : null + }); + + expect(result.rootItems.map((item) => item.path)).toEqual(['name', 'prompt', 'examples']); + expect(result.groups).toHaveLength(1); + expect(result.groups[0].items.map((item) => item.path)).toEqual([ + 'llmDescriptor.provider', + 'llmDescriptor.model' + ]); + expect(result.sections.map((section) => section.group?.key ?? section.item?.path)).toEqual([ + 'name', + 'group:llm', + 'prompt', + 'examples' + ]); + }); + it('updates and deletes nested schema values by path', () => { const config: Record = {}; diff --git a/src/app/shared/nodes/schema-driven-fields.ts b/src/app/shared/nodes/schema-driven-fields.ts index d20e9fe..72749f9 100644 --- a/src/app/shared/nodes/schema-driven-fields.ts +++ b/src/app/shared/nodes/schema-driven-fields.ts @@ -1,4 +1,5 @@ import { + orderedSchemaPropertyEntries, type UiConditionRule, evaluateUiConditionRule, getValueByPath, @@ -103,6 +104,29 @@ export type SchemaRichContentFieldView = { parts: { text: string; isDynamicInput: boolean }[]; }; +export type SchemaDisplayItem< + TField extends { path: string }, + TRichContent extends { path: string } = never, + TArray extends { path: string } = never +> = { + path: string; + field: TField | null; + richContentField: TRichContent | null; + arrayField: TArray | null; +}; + +export type SchemaDisplayGroup = { + key: string; + legend: string; + items: TItem[]; +}; + +export type SchemaDisplaySection = { + key: string; + group: SchemaDisplayGroup | null; + item: TItem | null; +}; + export function toSchemaFieldUiMeta( schema: Record | null | undefined, inheritedUi?: SchemaUiInheritance @@ -225,17 +249,16 @@ export function collectSchemaLeafFields( const resolved = resolveSchemaRef(node, root); if (!resolved || typeof resolved !== 'object') return; - const properties = resolved.properties as Record | undefined; - if (!properties) return; + const properties = orderedSchemaPropertyEntries(resolved, root); + if (!properties.length) return; - for (const [key, childSchema] of Object.entries(properties)) { - const childResolved = resolveSchemaRef(childSchema as Record, root); + for (const { key, schema: childResolved } of properties) { const path = pathPrefix ? `${pathPrefix}.${key}` : key; if (options?.shouldSkip?.({ key, path, schema: childResolved })) continue; const childUi = toSchemaFieldUiMeta(childResolved, inheritedUi); - const isArray = childResolved?.type === 'array'; - const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; + const isArray = childResolved?.['type'] === 'array'; + const hasChildren = !!childResolved?.['properties'] || childResolved?.['type'] === 'object'; if (isArray && options?.includeArrays !== true) { continue; @@ -330,6 +353,84 @@ export function groupSchemaFields( + params: { + definitions: TDefinition[]; + fields: TField[]; + richContentFields?: TRichContent[]; + arrayFields?: TArray[]; + resolveGroupLabel: (path: string) => string | null; + resolveLegend?: (groupLabel: string) => string; + shouldGroupItem?: (item: SchemaDisplayItem) => boolean; + } +): { + 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 resolveLegend = params.resolveLegend ?? ((groupLabel: string) => groupLabel); + const shouldGroupItem = params.shouldGroupItem ?? ((item: SchemaDisplayItem) => item.field != null); + + for (const definition of params.definitions) { + const item: SchemaDisplayItem = { + path: definition.path, + field: fieldByPath.get(definition.path) ?? null, + richContentField: richContentByPath.get(definition.path) ?? null, + arrayField: arrayByPath.get(definition.path) ?? null + }; + + if (!item.field && !item.richContentField && !item.arrayField) { + continue; + } + + const groupLabel = shouldGroupItem(item) ? params.resolveGroupLabel(definition.path) : null; + if (!groupLabel) { + rootItems.push(item); + sections.push({ + key: `item:${definition.path}`, + group: null, + item + }); + continue; + } + + const groupKey = `group:${groupLabel}`; + let group = groups.get(groupKey); + if (!group) { + group = { + key: groupKey, + legend: resolveLegend(groupLabel), + items: [] + }; + groups.set(groupKey, group); + sections.push({ + key: groupKey, + group, + item: null + }); + } + + group.items.push(item); + } + + return { + rootItems, + groups: Array.from(groups.values()), + sections + }; +} + export function isSchemaPathVisible( root: Record | null | undefined, path: string, diff --git a/src/app/shared/nodes/schema-requirements.spec.ts b/src/app/shared/nodes/schema-requirements.spec.ts new file mode 100644 index 0000000..48a36e4 --- /dev/null +++ b/src/app/shared/nodes/schema-requirements.spec.ts @@ -0,0 +1,53 @@ +import { extractSchemaRequirements } from './schema-requirements'; + +describe('schema-requirements', () => { + it('extracts x-ui-required-when rules using present conditions', () => { + const requirements = extractSchemaRequirements({ + type: 'object', + properties: { + feedbackInput: { + type: 'string' + }, + feedbackPrompt: { + type: 'object', + properties: { + title: { + type: 'string' + } + } + }, + followUpQuestion: { + type: 'string', + 'x-ui-required-when': { + field: 'feedbackInput', + present: true + } + }, + followUpSummary: { + type: 'string', + 'x-ui-required-when': { + field: 'feedbackPrompt', + present: true + } + } + } + }); + + expect(requirements.conditional).toEqual([ + expect.objectContaining({ + path: 'followUpQuestion', + requiredWhen: { + field: 'feedbackInput', + present: true + } + }), + expect.objectContaining({ + path: 'followUpSummary', + requiredWhen: { + field: 'feedbackPrompt', + present: true + } + }) + ]); + }); +}); diff --git a/src/app/shared/nodes/schema-requirements.ts b/src/app/shared/nodes/schema-requirements.ts index bfcaf0c..f0c564e 100644 --- a/src/app/shared/nodes/schema-requirements.ts +++ b/src/app/shared/nodes/schema-requirements.ts @@ -1,4 +1,4 @@ -import { readUiConditionRule, resolveSchemaRef, schemaFieldLabel, type UiConditionRule } from './node-utility'; +import { orderedSchemaPropertyEntries, readUiConditionRule, resolveSchemaRef, schemaFieldLabel, type UiConditionRule } from './node-utility'; import { parseSchemaRetrieverUrl, toSchemaRetrieverDependency } from './schema-driven-fields'; export type RequiredField = { @@ -53,15 +53,14 @@ function walkSchema( const resolved = resolveSchemaRef(node, root); if (!resolved || typeof resolved !== 'object') return; - const properties = resolved.properties as Record | undefined; - if (!properties) return; + const properties = orderedSchemaPropertyEntries(resolved, root); + if (!properties.length) return; - const requiredSet = new Set(Array.isArray(resolved.required) ? resolved.required : []); + const requiredSet = new Set(Array.isArray(resolved['required']) ? resolved['required'] : []); - for (const [key, propertySchema] of Object.entries(properties)) { + for (const { key, schema: propertyResolved } of properties) { const propertyPath = pathPrefix ? `${pathPrefix}.${key}` : key; - const propertyResolved = resolveSchemaRef(propertySchema as Record, root); - const hasChildren = !!propertyResolved?.properties || propertyResolved?.type === 'object'; + const hasChildren = !!propertyResolved?.['properties'] || propertyResolved?.['type'] === 'object'; const label = schemaFieldLabel(key, propertyResolved); const isRequiredBySchema = requiredSet.has(key); const isRequiredByAncestor = requireAllDescendants && key !== 'type'; @@ -108,7 +107,7 @@ function walkSchema( } if (hasChildren) { - const childHasOwnRequired = Array.isArray(propertyResolved?.required) && propertyResolved.required.length > 0; + const childHasOwnRequired = Array.isArray(propertyResolved?.['required']) && propertyResolved['required'].length > 0; walkSchema( propertyResolved as Record, root, diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index 4986927..eebbbbc 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -18,6 +18,7 @@ import { getOutputsTitle, isConditionalByPorts, isHumanInteractiveNode, + orderedSchemaPropertyEntries, parentPath, pathToLabel, readUiConditionRule, @@ -811,14 +812,14 @@ export class TaskStepNodeComponent { const resolved = resolveSchemaRef(node, schema); if (!resolved || typeof resolved !== 'object') return; - const properties = resolved.properties as Record | undefined; - if (!properties) return; + const properties = orderedSchemaPropertyEntries(resolved, schema); + if (!properties.length) return; - for (const [key, childSchema] of Object.entries(properties)) { - const childResolved = resolveSchemaRef(childSchema as Record, schema); + for (const { key, schema: childResolved } of properties) { + if (!childResolved) continue; const path = pathPrefix ? `${pathPrefix}.${key}` : key; - if (childResolved?.type === 'array') { + if (childResolved?.['type'] === 'array') { if (shouldSkipSchemaField(key, childResolved) || key === 'name' || seen.has(path)) { continue; } @@ -831,7 +832,7 @@ export class TaskStepNodeComponent { continue; } - const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; + const hasChildren = !!childResolved?.['properties'] || childResolved?.['type'] === 'object'; if (hasChildren) { walk(childResolved as Record, path); } @@ -932,7 +933,7 @@ export class TaskStepNodeComponent { if (!properties) return `Item ${index + 1}`; const summaryParts: string[] = []; - for (const key of Object.keys(properties)) { + for (const { key } of orderedSchemaPropertyEntries(definition.itemSchema, this.blockSchema ?? definition.itemSchema ?? {})) { const value = (item as Record)[key]; if (value == null) continue; if (typeof value === 'string' && value.trim().length > 0) {