Update nodes: schema-driven fields, node utility, generic/container/task-step nodes

This commit is contained in:
Lucio Lelii 2026-04-24 12:33:21 +02:00
parent 6611bbe752
commit 9b0e2bbd89
13 changed files with 790 additions and 206 deletions

View File

@ -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;

View File

@ -208,104 +208,67 @@
</div>
</div>
}
@if (parameterFieldGroups.length) {
<div class="container-node__param-groups">
@for (group of parameterFieldGroups; track group.key) {
<fieldset class="container-node__param-fieldset">
<legend class="container-node__param-legend">{{ group.legend }}</legend>
@if (group.fields.length) {
<div class="container-node__params container-node__params--grouped">
@for (field of group.fields; track field.path) {
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--disabled]="!field.enabled">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ field.label }}</span>
@if (field.type === 'boolean' && !isReadonly) {
<button
type="button"
class="container-node__bool-toggle"
[class.container-node__bool-toggle--on]="field.booleanValue"
[disabled]="!field.enabled"
[attr.aria-pressed]="field.booleanValue"
title="Toggle field"
(pointerdown)="$event.stopPropagation()"
(click)="toggleBooleanParameter(field.path, $event)">
<span class="container-node__bool-toggle-thumb"></span>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="container-node__param-edit"
[disabled]="!field.enabled"
title="Edit field"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<span class="container-node__param-value" [class.container-node__param-value--clamped]="field.expandable">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
</div>
}
</div>
}
@for (contentField of group.richContentFields; track contentField.path) {
<div class="container-node__param-chip container-node__param-chip--wide">
@for (section of parameterDisplaySections; track section.key) {
@if (section.group; as group) {
<fieldset class="container-node__param-fieldset">
<legend class="container-node__param-legend">{{ group.legend }}</legend>
<div class="container-node__params container-node__params--grouped">
@for (item of group.items; track item.path) {
@if (item.field; as field) {
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--boolean]="field.type === 'boolean'" [class.container-node__param-chip--disabled]="!field.enabled">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ contentField.label }}</span>
@if (contentField.expandable && isReadonly) {
<span class="container-node__param-key">{{ field.label }}</span>
@if (field.type === 'boolean') {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
class="container-node__bool-toggle"
[class.container-node__bool-toggle--on]="field.booleanValue"
[disabled]="isReadonly || !field.enabled"
[attr.aria-pressed]="field.booleanValue"
title="Toggle field"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
(click)="toggleBooleanParameter(field.path, $event)">
<span class="container-node__bool-toggle-thumb"></span>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="container-node__param-edit"
[disabled]="!field.enabled"
title="Edit field"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(contentField.path, $event)">
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="container-node__param-value" [class.container-node__param-value--clamped]="contentField.expandable">
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
</div>
@if (field.type !== 'boolean') {
<span class="container-node__param-value" [class.container-node__param-value--clamped]="field.expandable">{{ field.value }}</span>
}
</div>
}
</fieldset>
}
</div>
}
@for (field of parameterFields; track field.path) {
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--disabled]="!field.enabled">
}
</div>
</fieldset>
} @else if (section.item?.field; as field) {
<div class="container-node__param-chip container-node__param-chip--root" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--boolean]="field.type === 'boolean'" [class.container-node__param-chip--disabled]="!field.enabled">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ field.label }}</span>
@if (field.type === 'boolean' && !isReadonly) {
@if (field.type === 'boolean') {
<button
type="button"
class="container-node__bool-toggle"
[class.container-node__bool-toggle--on]="field.booleanValue"
[disabled]="!field.enabled"
[disabled]="isReadonly || !field.enabled"
[attr.aria-pressed]="field.booleanValue"
title="Toggle field"
(pointerdown)="$event.stopPropagation()"
@ -333,10 +296,11 @@
</button>
}
</div>
<span class="container-node__param-value" [class.container-node__param-value--clamped]="field.expandable">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
@if (field.type !== 'boolean') {
<span class="container-node__param-value" [class.container-node__param-value--clamped]="field.expandable">{{ field.value }}</span>
}
</div>
}
@for (contentField of richContentFields; track contentField.path) {
} @else if (section.item?.richContentField; as contentField) {
<div class="container-node__param-chip container-node__param-chip--wide">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ contentField.label }}</span>
@ -371,6 +335,7 @@
</div>
</div>
}
}
</div>
}

View File

@ -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<ContainerFieldType>;
type RichContentView = SchemaRichContentFieldView;
type ContainerFieldGroupView = SchemaFieldGroup<ContainerFieldView, RichContentView>;
type ContainerDisplayItem = SchemaDisplayItem<ContainerFieldView, RichContentView>;
type ContainerFieldGroupView = SchemaDisplayGroup<ContainerDisplayItem>;
type ContainerDisplaySection = SchemaDisplaySection<ContainerDisplayItem>;
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<string, any> | null): ContainerFieldDefinition[] {

View File

@ -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;

View File

@ -228,22 +228,22 @@
</div>
</div>
}
@if (parameterFieldGroups.length) {
<div class="llm-param-groups">
@for (group of parameterFieldGroups; track group.key) {
@for (section of parameterDisplaySections; track section.key) {
@if (section.group; as group) {
<fieldset class="llm-param-fieldset">
<legend class="llm-param-legend">{{ group.legend }}</legend>
<div class="llm-param-grid">
@for (field of group.fields; track field.path) {
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-disabled]="!field.enabled">
@for (item of group.items; track item.path) {
@if (item.field; as field) {
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-boolean]="field.type === 'boolean'" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (field.type === 'boolean' && !isReadonly) {
@if (field.type === 'boolean') {
<button
type="button"
class="llm-bool-toggle"
[class.llm-bool-toggle-on]="field.booleanValue"
[disabled]="!field.enabled"
[disabled]="isReadonly || !field.enabled"
[attr.aria-pressed]="field.booleanValue"
[attr.title]="'Toggle ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
@ -271,27 +271,109 @@
</button>
}
</div>
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
@if (field.type !== 'boolean') {
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.value }}</span>
}
</div>
} @else {
@if (item.richContentField; as contentField) {
<div class="llm-param-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ contentField.label }}</div>
@if (!isReadonly) {
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (contentField.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="llm-param-text" [class.llm-param-text-clamped]="contentField.expandable">
@if (!contentField.parts.length) {
-
} @else {
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
}
</div>
</div>
}
@if (item.arrayField; as arrayField) {
<div class="llm-array-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ arrayField.label }}</div>
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
[attr.title]="'Add ' + arrayField.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="addArrayItem(arrayField.path, $event)">
<i class="bi bi-plus-lg"></i>
</button>
}
</div>
@if (!arrayField.items.length) {
<div class="llm-array-empty">No items</div>
} @else {
@for (entry of arrayField.items; track entry.index) {
<div class="llm-array-item">
<span class="llm-array-item-summary">{{ entry.summary }}</span>
<div class="llm-array-item-actions">
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
title="Edit item"
(pointerdown)="$event.stopPropagation()"
(click)="editArrayItem(arrayField.path, entry.index, $event)">
<i class="bi bi-pen"></i>
</button>
}
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn llm-array-remove-btn"
title="Remove item"
(pointerdown)="$event.stopPropagation()"
(click)="removeArrayItem(arrayField.path, entry.index, $event)">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
}
}
</div>
}
}
}
</div>
</fieldset>
}
</div>
}
@if (parameterFields.length) {
} @else if (section.item; as item) {
<div class="llm-param-grid">
@for (field of parameterFields; track field.path) {
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-disabled]="!field.enabled">
@if (item.field; as field) {
<div class="llm-param-chip llm-param-chip-root" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-boolean]="field.type === 'boolean'" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (field.type === 'boolean' && !isReadonly) {
@if (field.type === 'boolean') {
<button
type="button"
class="llm-bool-toggle"
[class.llm-bool-toggle-on]="field.booleanValue"
[disabled]="!field.enabled"
[disabled]="isReadonly || !field.enabled"
[attr.aria-pressed]="field.booleanValue"
[attr.title]="'Toggle ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
@ -319,53 +401,47 @@
</button>
}
</div>
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
</div>
}
</div>
}
@if (hasMainContent()) {
@for (contentField of richContentFields; track contentField.path) {
<div class="llm-param-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ contentField.label }}</div>
@if (!isReadonly) {
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (contentField.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
@if (field.type !== 'boolean') {
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.value }}</span>
}
</div>
<div class="llm-param-text" [class.llm-param-text-clamped]="contentField.expandable">
@if (!contentField.parts.length) {
-
} @else {
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
} @else {
@if (item.richContentField; as contentField) {
<div class="llm-param-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ contentField.label }}</div>
@if (!isReadonly) {
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (contentField.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="llm-param-text" [class.llm-param-text-clamped]="contentField.expandable">
@if (!contentField.parts.length) {
-
} @else {
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
}
}
</div>
</div>
</div>
}
}
@if (arrayFields.length) {
<div class="llm-array-sections">
@for (arrayField of arrayFields; track arrayField.path) {
<div class="llm-array-block">
@if (item.arrayField; as arrayField) {
<div class="llm-array-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ arrayField.label }}</div>
@if (!isReadonly) {
@ -382,9 +458,9 @@
@if (!arrayField.items.length) {
<div class="llm-array-empty">No items</div>
} @else {
@for (item of arrayField.items; track item.index) {
@for (entry of arrayField.items; track entry.index) {
<div class="llm-array-item">
<span class="llm-array-item-summary">{{ item.summary }}</span>
<span class="llm-array-item-summary">{{ entry.summary }}</span>
<div class="llm-array-item-actions">
@if (!isReadonly) {
<button
@ -392,7 +468,7 @@
class="llm-edit-btn"
title="Edit item"
(pointerdown)="$event.stopPropagation()"
(click)="editArrayItem(arrayField.path, item.index, $event)">
(click)="editArrayItem(arrayField.path, entry.index, $event)">
<i class="bi bi-pen"></i>
</button>
}
@ -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)">
<i class="bi bi-trash"></i>
</button>
}
@ -412,7 +488,9 @@
}
</div>
}
}
</div>
}
}
</div>

View File

@ -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<EditableFieldView, RichContentView, ArrayFieldView>;
type EditableFieldGroupView = SchemaDisplayGroup<ParameterDisplayItem>;
type ParameterDisplaySection = SchemaDisplaySection<ParameterDisplayItem>;
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<string, string | boolean> = {};
for (const [key, rawPropertySchema] of Object.entries(properties)) {
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, 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<string, unknown> = {};
for (const [key, rawPropertySchema] of Object.entries(properties)) {
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, 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<string, unknown> = {};
for (const [key, rawPropertySchema] of Object.entries(properties)) {
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, 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<string, any> | 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<string, any>, 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<string, any>, 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<string, unknown>)[key];
if (this.isMissingValue(value)) continue;
if (typeof value === 'string') {

View File

@ -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([

View File

@ -4,6 +4,11 @@ export type UiConditionRule =
| { field: string; in: string[] }
| { field: string; present: boolean };
export type OrderedSchemaPropertyEntry = {
key: string;
schema: Record<string, any> | 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<string, any> | null | undefined,
return current;
}
export function orderedSchemaPropertyEntries(
node: Record<string, any> | null | undefined,
root: Record<string, any>
): OrderedSchemaPropertyEntry[] {
if (!node || typeof node !== 'object') return [];
const resolved = resolveSchemaRef(node, root);
const properties = resolved?.properties as Record<string, unknown> | undefined;
if (!properties) return [];
const propertyOrder = readUiPropertyOrder(resolved?.['x-ui-property-order']);
const prioritized = new Map<string, number>();
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<string, any>, 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<string, unknown>).some((item) => isMeaningfullyPresent(item));
}
if (Array.isArray(value)) return value.length > 0;
if (typeof value === 'object') return true;
return true;
}

View File

@ -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<string, unknown> = {};

View File

@ -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<TItem extends { path: string }> = {
key: string;
legend: string;
items: TItem[];
};
export type SchemaDisplaySection<TItem extends { path: string }> = {
key: string;
group: SchemaDisplayGroup<TItem> | null;
item: TItem | null;
};
export function toSchemaFieldUiMeta(
schema: Record<string, any> | null | undefined,
inheritedUi?: SchemaUiInheritance
@ -225,17 +249,16 @@ export function collectSchemaLeafFields<T>(
const resolved = resolveSchemaRef(node, root);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | 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<string, any>, 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<TField extends { path: string }, TRichContent
};
}
export function buildOrderedSchemaDisplay<
TDefinition extends { path: string },
TField extends { path: string },
TRichContent extends { path: string } = never,
TArray extends { path: string } = never
>(
params: {
definitions: TDefinition[];
fields: TField[];
richContentFields?: TRichContent[];
arrayFields?: TArray[];
resolveGroupLabel: (path: string) => string | null;
resolveLegend?: (groupLabel: string) => string;
shouldGroupItem?: (item: SchemaDisplayItem<TField, TRichContent, TArray>) => boolean;
}
): {
rootItems: Array<SchemaDisplayItem<TField, TRichContent, TArray>>;
groups: Array<SchemaDisplayGroup<SchemaDisplayItem<TField, TRichContent, TArray>>>;
sections: Array<SchemaDisplaySection<SchemaDisplayItem<TField, TRichContent, TArray>>>;
} {
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<SchemaDisplayItem<TField, TRichContent, TArray>> = [];
const groups = new Map<string, SchemaDisplayGroup<SchemaDisplayItem<TField, TRichContent, TArray>>>();
const sections: Array<SchemaDisplaySection<SchemaDisplayItem<TField, TRichContent, TArray>>> = [];
const resolveLegend = params.resolveLegend ?? ((groupLabel: string) => groupLabel);
const shouldGroupItem = params.shouldGroupItem ?? ((item: SchemaDisplayItem<TField, TRichContent, TArray>) => item.field != null);
for (const definition of params.definitions) {
const item: SchemaDisplayItem<TField, TRichContent, TArray> = {
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<string, any> | null | undefined,
path: string,

View File

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

View File

@ -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<string, any> | undefined;
if (!properties) return;
const properties = orderedSchemaPropertyEntries(resolved, root);
if (!properties.length) return;
const requiredSet = new Set<string>(Array.isArray(resolved.required) ? resolved.required : []);
const requiredSet = new Set<string>(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<string, any>, 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<string, any>,
root,

View File

@ -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<string, any> | 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<string, any>, 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<string, any>, 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<string, unknown>)[key];
if (value == null) continue;
if (typeof value === 'string' && value.trim().length > 0) {