Update nodes: schema-driven fields, node utility, generic/container/task-step nodes
This commit is contained in:
parent
6611bbe752
commit
9b0e2bbd89
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue