Support dynamic array schemas in node editors

This commit is contained in:
Lucio Lelii 2026-03-12 17:22:23 +01:00
parent d6e3a2a534
commit b24b8177a9
12 changed files with 1072 additions and 57 deletions

View File

@ -4,12 +4,19 @@ export abstract class FieldRetrieverCallServiceBase {
abstract retrieveValues(
blockType: string,
key: string,
context?: Record<string, string>
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<string[]>;
abstract isFieldRequired(
blockType: string,
key: string,
context?: Record<string, string>
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<boolean>;
abstract retrieveSchema(
schemaUrl: string,
context?: Record<string, string>
): Observable<Record<string, unknown> | null>;
}

View File

@ -15,7 +15,8 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
override retrieveValues(
blockType: string,
key: string,
context?: Record<string, string>
context?: Record<string, string>,
_retrieverUrl?: string | null
): Observable<string[]> {
if (key === "providers") {
return of(this.providersByBlockType[blockType] ?? []);
@ -32,9 +33,41 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
override isFieldRequired(
_blockType: string,
_key: string,
context?: Record<string, string>
context?: Record<string, string>,
_retrieverUrl?: string | null
): Observable<boolean> {
void context;
return of(false);
}
override retrieveSchema(
schemaUrl: string,
context?: Record<string, string>
): Observable<Record<string, unknown> | null> {
if (!schemaUrl.includes('/retriever/MCPServers/definitions/schema')) {
return of(null);
}
const serverName = context?.['serverName'] ?? '';
if (!serverName) {
return of(null);
}
return of({
type: 'object',
properties: {
endpoint: {
type: 'string'
},
tool: {
type: 'string',
default: `${serverName}-default`
},
enabled: {
type: 'boolean',
default: true
}
}
});
}
}

View File

@ -1,29 +1,19 @@
import { HttpClient, HttpParams } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import { map, Observable } from "rxjs";
import { map, Observable, of } from "rxjs";
import { FieldRetrieverCallServiceBase } from "./field-retriever-call.base";
export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
private readonly http = inject(HttpClient);
private buildParams(context?: Record<string, string>) {
let params = new HttpParams();
const entries = Object.entries(context ?? {});
for (const [ctxKey, ctxValue] of entries) {
params = params.set(ctxKey, ctxValue);
}
return params;
}
override retrieveValues(
blockType: string,
key: string,
context?: Record<string, string>
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<string[]> {
const url = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}`;
const params = this.buildParams(context);
const { url, params } = this.resolveRequest(blockType, key, context, retrieverUrl);
return this.http.get<unknown>(url, { params }).pipe(
map((raw) => this.normalizeStringList(raw))
);
@ -32,13 +22,88 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
override isFieldRequired(
blockType: string,
key: string,
context?: Record<string, string>
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<boolean> {
const url = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}/required`;
const params = this.buildParams(context);
const requiredRetrieverUrl = this.appendRequiredSuffix(retrieverUrl);
const { url, params } = this.resolveRequest(blockType, key, context, requiredRetrieverUrl, true);
return this.http.get<boolean>(url, { params });
}
override retrieveSchema(
schemaUrl: string,
context?: Record<string, string>
): Observable<Record<string, unknown> | null> {
const resolvedUrl = this.resolveApiUrl(schemaUrl);
if (!resolvedUrl) {
return of(null);
}
const parsed = this.parseUrl(resolvedUrl);
let params = parsed.params;
if (context && Object.keys(context).length > 0) {
for (const [ctxKey, ctxValue] of Object.entries(context)) {
params = params.set(ctxKey, ctxValue);
}
}
return this.http.get<unknown>(parsed.url, { params }).pipe(
map((raw) => (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : null))
);
}
private resolveRequest(
blockType: string,
key: string,
context?: Record<string, string>,
retrieverUrl?: string | null,
isRequired = false
) {
const fallbackUrl = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}${isRequired ? '/required' : ''}`;
const baseUrl = this.resolveApiUrl(retrieverUrl) ?? fallbackUrl;
const parsed = this.parseUrl(baseUrl);
let params = parsed.params;
if (context && Object.keys(context).length > 0) {
for (const [ctxKey, ctxValue] of Object.entries(context)) {
params = params.set(ctxKey, ctxValue);
}
}
return {
url: parsed.url,
params
};
}
private resolveApiUrl(rawUrl?: string | null): string | null {
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
if (/^https?:\/\//.test(rawUrl)) return rawUrl;
return `${environment.apiUrl}${rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`}`;
}
private parseUrl(rawUrl: string) {
const [url, queryString] = rawUrl.split('?', 2);
let params = new HttpParams();
if (queryString) {
const searchParams = new URLSearchParams(queryString);
for (const [key, value] of searchParams.entries()) {
params = params.append(key, value);
}
}
return { url, params };
}
private appendRequiredSuffix(rawUrl?: string | null): string | null {
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
const [path, queryString] = rawUrl.split('?', 2);
const normalizedPath = path.endsWith('/required') ? path : `${path}/required`;
return queryString ? `${normalizedPath}?${queryString}` : normalizedPath;
}
private normalizeStringList(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.filter((item): item is string => typeof item === 'string');

View File

@ -12,9 +12,10 @@ export class FieldRetriever {
retrieveValues(
blockType: string,
key: string,
context?: Record<string, string>
context?: Record<string, string>,
retrieverUrl?: string | null
) {
return this.fieldRetrieverCallService.retrieveValues(blockType, key, context).pipe(
return this.fieldRetrieverCallService.retrieveValues(blockType, key, context, retrieverUrl).pipe(
catchError((err) => {
console.error('Field retrieval failed', err);
return throwError(() => err);
@ -25,13 +26,26 @@ export class FieldRetriever {
isFieldRequired(
blockType: string,
key: string,
context?: Record<string, string>
context?: Record<string, string>,
retrieverUrl?: string | null
) {
return this.fieldRetrieverCallService.isFieldRequired(blockType, key, context).pipe(
return this.fieldRetrieverCallService.isFieldRequired(blockType, key, context, retrieverUrl).pipe(
catchError((err) => {
console.error('Field required check failed', err);
return throwError(() => err);
})
);
}
retrieveSchema(
schemaUrl: string,
context?: Record<string, string>
) {
return this.fieldRetrieverCallService.retrieveSchema(schemaUrl, context).pipe(
catchError((err) => {
console.error('Schema retrieval failed', err);
return throwError(() => err);
})
);
}
}

View File

@ -726,6 +726,58 @@
font-weight: 600;
}
.llm-array-sections {
display: flex;
flex-direction: column;
gap: 10px;
}
.llm-array-block {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid #dbe7f5;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.llm-array-empty {
font-size: 12px;
color: #64748b;
}
.llm-array-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(239, 246, 255, 0.9);
border: 1px solid rgba(191, 219, 254, 0.9);
}
.llm-array-item-summary {
min-width: 0;
font-size: 12px;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.llm-array-item-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.llm-array-remove-btn {
color: #b91c1c;
}
.llm-param-list {
display: flex;
flex-direction: column;

View File

@ -122,7 +122,7 @@
<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">
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
<button
@ -146,7 +146,7 @@
@if (parameterFields.length) {
<div class="llm-param-grid">
@for (field of parameterFields; track field.path) {
<div class="llm-param-chip">
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
<button
@ -189,6 +189,53 @@
</div>
}
}
@if (arrayFields.length) {
<div class="llm-array-sections">
@for (arrayField of arrayFields; track arrayField.path) {
<div class="llm-array-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ arrayField.label }}</div>
<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 (item of arrayField.items; track item.index) {
<div class="llm-array-item">
<span class="llm-array-item-summary">{{ item.summary }}</span>
<div class="llm-array-item-actions">
<button
type="button"
class="llm-edit-btn"
title="Edit item"
(pointerdown)="$event.stopPropagation()"
(click)="editArrayItem(arrayField.path, item.index, $event)">
<i class="bi bi-pen"></i>
</button>
<button
type="button"
class="llm-edit-btn llm-array-remove-btn"
title="Remove item"
(pointerdown)="$event.stopPropagation()"
(click)="removeArrayItem(arrayField.path, item.index, $event)">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
}
}
</div>
}
</div>
}
</div>
@if (localEditorOpen) {

View File

@ -4,7 +4,11 @@ import { FormsModule } from '@angular/forms';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { FlowData } from '@models/flow';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import {
NodeSettingField,
NodeSettingOption,
NodeSettingsDialogService
} from '@services/dialogs/node-settings-dialog';
import { EditorStateHolder } from '@stores/flow-editor';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { BlocksService } from '@services/blocks/blocks';
@ -38,6 +42,7 @@ type EditableFieldDefinition = {
type: FieldType;
retrieverBlockType: string | null;
retrieverKey: string | null;
retrieverUrl: string | null;
retrieverDependsOn: RetrieverDependency[];
ui: {
widget: 'textarea' | null;
@ -56,6 +61,29 @@ type EditableFieldView = {
path: string;
label: string;
value: string;
wide: boolean;
};
type ArrayFieldDefinition = {
path: string;
label: string;
itemSchema: Record<string, any> | null;
ui: {
structural: boolean;
visibleWhen: UiConditionRule[];
group: string | null;
};
};
type ArrayFieldItemView = {
index: number;
summary: string;
};
type ArrayFieldView = {
path: string;
label: string;
items: ArrayFieldItemView[];
};
type EditableFieldGroupView = {
@ -100,6 +128,7 @@ export class GenericNodeComponent {
parameterFields: EditableFieldView[] = [];
parameterFieldGroups: EditableFieldGroupView[] = [];
richContentFields: RichContentView[] = [];
arrayFields: ArrayFieldView[] = [];
name = 'noName';
localEditorOpen = false;
@ -116,6 +145,7 @@ export class GenericNodeComponent {
missingRequiredParams: string[] = [];
private blockSchema: Record<string, any> | null = null;
private editableFieldDefinitions: EditableFieldDefinition[] = [];
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
private schemaRequirements: SchemaRequirements = { required: [], conditional: [] };
private conditionalRequiredByPath = new Map<string, boolean>();
private refreshingConditionalRequirements = false;
@ -126,6 +156,7 @@ export class GenericNodeComponent {
this.parameterFields = [];
this.parameterFieldGroups = [];
this.richContentFields = [];
this.arrayFields = [];
Object.entries(this.data.outputs).forEach(([key, output]) => {
this.outputs.push({ key, socket: (output as any).socket });
@ -370,6 +401,7 @@ export class GenericNodeComponent {
this.blockSchema = (blockType?.schema ?? null) as Record<string, any> | null;
this.schemaRequirements = extractSchemaRequirements(this.blockSchema);
this.editableFieldDefinitions = this.buildEditableFieldDefinitions(this.blockSchema);
this.arrayFieldDefinitions = this.buildArrayFieldDefinitions(this.blockSchema);
await this.refreshConditionalRequirements();
this.refreshParameterFields();
@ -435,6 +467,9 @@ export class GenericNodeComponent {
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
if (childResolved?.type === 'array') {
continue;
}
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
const childUi = this.toFieldUiMeta(childResolved, inheritedUi);
@ -456,6 +491,7 @@ export class GenericNodeComponent {
type: this.toFieldType(childResolved?.type),
retrieverBlockType: this.toRetrieverBlockType(childResolved),
retrieverKey: this.toRetrieverKey(childResolved),
retrieverUrl: this.toRetrieverUrl(childResolved),
retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix),
ui: childUi
});
@ -466,6 +502,60 @@ export class GenericNodeComponent {
return definitions;
}
private buildArrayFieldDefinitions(schema: Record<string, any> | null): ArrayFieldDefinition[] {
if (!schema) return [];
const definitions: ArrayFieldDefinition[] = [];
const seen = new Set<string>();
const walk = (
node: Record<string, any>,
pathPrefix: string,
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
const childUi = this.toFieldUiMeta(childResolved, inheritedUi);
if (childResolved?.type === 'array') {
if (key === 'type' || key === 'name' || key.startsWith('__') || seen.has(path)) {
continue;
}
seen.add(path);
definitions.push({
path,
label: pathToLabel(path),
itemSchema: this.resolveArrayItemSchema(childResolved, schema),
ui: {
structural: childUi.structural,
visibleWhen: childUi.visibleWhen,
group: childUi.group
}
});
continue;
}
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path, {
visibleWhen: childUi.visibleWhen,
group: childUi.group
});
}
}
};
walk(schema, '');
return definitions;
}
private toFieldUiMeta(
schema: Record<string, any> | null | undefined,
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
@ -575,6 +665,12 @@ export class GenericNodeComponent {
return this.parseRetrieverUrl(schema['x-retriever-url'])?.blockType ?? null;
}
private toRetrieverUrl(schema: Record<string, any> | null | undefined): string | null {
if (!schema || typeof schema !== 'object') return null;
const rawUrl = schema['x-retriever-url'];
return typeof rawUrl === 'string' && rawUrl.trim().length > 0 ? rawUrl : null;
}
private parseRetrieverUrl(rawUrl: unknown): { blockType: string; key: string } | null {
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
@ -620,7 +716,12 @@ export class GenericNodeComponent {
try {
const options = await firstValueFrom(
this.fieldRetriever.retrieveValues(blockType, definition.retrieverKey, context)
this.fieldRetriever.retrieveValues(
blockType,
definition.retrieverKey,
definition.retrieverDependsOn.length ? context : undefined,
definition.retrieverUrl
)
);
this.localEditorOptions = options ?? [];
} catch {
@ -644,13 +745,22 @@ export class GenericNodeComponent {
parts: this.toRichContentParts(path)
}));
this.arrayFields = this.arrayFieldDefinitions
.filter((definition) => this.isPathVisible(definition.path))
.map((definition) => ({
path: definition.path,
label: definition.label,
items: this.toArrayFieldItems(definition, this.getByPath(config, definition.path))
}));
if (this.editableFieldDefinitions.length) {
const orderedFields = this.editableFieldDefinitions.map((definition) => {
const value = this.getByPath(config, definition.path);
return {
path: definition.path,
label: definition.label,
value: valueToDisplayString(value)
value: valueToDisplayString(value),
wide: this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea')
};
}).filter((field) => !richContentPaths.has(field.path))
.filter((field) => this.isPathVisible(field.path));
@ -683,7 +793,8 @@ export class GenericNodeComponent {
.map((entry) => ({
path: entry.path,
label: pathToLabel(entry.path),
value: valueToDisplayString(entry.value)
value: valueToDisplayString(entry.value),
wide: this.shouldRenderWideField(pathToLabel(entry.path), false)
}));
for (const field of fallbackFields) {
@ -708,6 +819,441 @@ export class GenericNodeComponent {
this.refreshView();
}
async addArrayItem(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
await this.openArrayItemEditor(path, null);
}
async editArrayItem(path: string, index: number, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
await this.openArrayItemEditor(path, index);
}
removeArrayItem(path: string, index: number, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const config = this.ensureBlockConfiguration();
const current = this.getByPath(config, path);
const items = Array.isArray(current) ? [...current] : [];
if (index < 0 || index >= items.length) return;
items.splice(index, 1);
this.setByPath(config, path, items);
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
}
private async openArrayItemEditor(path: string, index: number | null) {
const definition = this.arrayFieldDefinitions.find((field) => field.path === path);
if (!definition || !this.isPathVisible(path)) return;
const config = this.ensureBlockConfiguration();
const current = this.getByPath(config, path);
const items = Array.isArray(current) ? [...current] : [];
const currentItem = index == null ? this.createEmptyArrayItem(definition.itemSchema) : this.cloneFlowData(items[index] ?? {});
const dialog = await this.buildArrayItemDialog(definition, currentItem, index);
if (!dialog) return;
const result = await this.settingsDialog.open(dialog);
if (!result) return;
const nextItem = this.parseArrayItemDialogResult(definition, result, currentItem);
if (index == null) {
items.push(nextItem);
} else {
items[index] = nextItem;
}
this.setByPath(config, path, items);
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
}
private async buildArrayItemDialog(
definition: ArrayFieldDefinition,
item: Record<string, unknown>,
index: number | null
) {
const itemSchema = definition.itemSchema;
const properties = itemSchema?.['properties'] as Record<string, any> | undefined;
const schemaRoot = this.blockSchema ?? itemSchema ?? {};
if (!properties) {
return {
title: `${index == null ? 'Add' : 'Edit'} ${definition.label} item`,
fields: [
{
key: '__raw',
label: 'Item JSON',
type: 'textarea' as const,
rows: 10
}
],
initial: {
__raw: JSON.stringify(item ?? {}, null, 2)
}
};
}
const fields: NodeSettingField[] = [];
const initial: Record<string, string | boolean> = {};
for (const [key, rawPropertySchema] of Object.entries(properties)) {
if (key === 'type' || key.startsWith('__')) continue;
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
if (this.hasDynamicSchema(propertySchema)) {
const dynamicFields = await this.buildDynamicSchemaFields(key, propertySchema, item);
fields.push(...dynamicFields.fields);
Object.assign(initial, dynamicFields.initial);
continue;
}
const label = pathToLabel(key);
const currentValue = item[key];
const options = await this.loadNodeSettingOptions(propertySchema, item, '');
const isObjectLike = propertySchema?.type === 'object';
const fieldType =
options ? 'select' :
propertySchema?.type === 'boolean' ? 'checkbox' :
propertySchema?.['x-ui-widget'] === 'textarea' || isObjectLike ? 'textarea' :
'text';
fields.push({
key,
label,
type: fieldType,
rows: fieldType === 'textarea' ? 8 : undefined,
options,
placeholder: typeof propertySchema?.['x-ui-placeholder'] === 'string' ? String(propertySchema['x-ui-placeholder']) : undefined,
tip: typeof propertySchema?.['x-ui-tip'] === 'string' ? String(propertySchema['x-ui-tip']) : undefined
});
if (fieldType === 'checkbox') {
initial[key] = currentValue === true;
} else if (fieldType === 'textarea' && isObjectLike) {
initial[key] = JSON.stringify(currentValue ?? {}, null, 2);
} else {
initial[key] = currentValue == null ? '' : String(currentValue);
}
}
return {
title: `${index == null ? 'Add' : 'Edit'} ${definition.label} item`,
fields,
initial
};
}
private parseArrayItemDialogResult(
definition: ArrayFieldDefinition,
result: Record<string, string | boolean>,
previousItem: Record<string, unknown>
) {
const itemSchema = definition.itemSchema;
const properties = itemSchema?.['properties'] as Record<string, any> | undefined;
const schemaRoot = this.blockSchema ?? itemSchema ?? {};
if (!properties) {
try {
return JSON.parse(String(result['__raw'] ?? '{}')) as Record<string, unknown>;
} catch {
return previousItem;
}
}
const nextItem: Record<string, unknown> = {};
for (const [key, rawPropertySchema] of Object.entries(properties)) {
if (key === 'type' || key.startsWith('__')) continue;
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
if (this.hasDynamicSchema(propertySchema)) {
const dynamicValue = this.extractNestedDialogValues(result, key);
nextItem[key] = Object.keys(dynamicValue).length ? dynamicValue : (previousItem[key] ?? {});
continue;
}
const rawValue = result[key];
const isObjectLike = propertySchema?.type === 'object';
if (propertySchema?.type === 'boolean') {
nextItem[key] = rawValue === true;
continue;
}
if (isObjectLike) {
try {
nextItem[key] = JSON.parse(String(rawValue ?? '{}'));
} catch {
nextItem[key] = previousItem[key] ?? {};
}
continue;
}
if (propertySchema?.type === 'number' || propertySchema?.type === 'integer') {
const numeric = Number(rawValue ?? 0);
nextItem[key] = Number.isFinite(numeric)
? (propertySchema.type === 'integer' ? Math.trunc(numeric) : numeric)
: 0;
continue;
}
nextItem[key] = String(rawValue ?? '');
}
return nextItem;
}
private createEmptyArrayItem(itemSchema: Record<string, any> | null) {
const properties = itemSchema?.['properties'] as Record<string, any> | undefined;
const schemaRoot = this.blockSchema ?? itemSchema ?? {};
if (!properties) return {};
const item: Record<string, unknown> = {};
for (const [key, rawPropertySchema] of Object.entries(properties)) {
if (key === 'type' || key.startsWith('__')) continue;
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
if (Object.prototype.hasOwnProperty.call(propertySchema ?? {}, 'default')) {
item[key] = propertySchema.default;
continue;
}
if (propertySchema?.type === 'boolean') {
item[key] = false;
} else if (propertySchema?.type === 'number' || propertySchema?.type === 'integer') {
item[key] = 0;
} else if (propertySchema?.type === 'object' || this.hasDynamicSchema(propertySchema)) {
item[key] = {};
} else {
item[key] = '';
}
}
return item;
}
private resolveArrayItemSchema(node: Record<string, any> | null | undefined, root: Record<string, any>) {
const items = node?.['items'];
if (!items || typeof items !== 'object') return null;
const resolved = resolveSchemaRef(items as Record<string, any>, root);
return resolved && typeof resolved === 'object' ? resolved as Record<string, any> : null;
}
private hasDynamicSchema(schema: Record<string, any> | null | undefined) {
return typeof schema?.['x-ui-schema-url'] === 'string' && String(schema['x-ui-schema-url']).trim().length > 0;
}
private async buildDynamicSchemaFields(
baseKey: string,
propertySchema: Record<string, any>,
item: Record<string, unknown>
): Promise<{ fields: NodeSettingField[]; initial: Record<string, string | boolean> }> {
const schemaUrl = String(propertySchema['x-ui-schema-url'] ?? '');
const dependsOn = Array.isArray(propertySchema['x-ui-schema-depends-on'])
? (propertySchema['x-ui-schema-depends-on'] as unknown[]).filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
: [];
const context: Record<string, string> = {};
for (const key of dependsOn) {
const value = item[key];
if (value != null && String(value).trim().length > 0) {
context[key] = String(value);
}
}
if (dependsOn.some((key) => !context[key])) {
return {
fields: [{
key: `${baseKey}.__hint`,
label: pathToLabel(baseKey),
type: 'display',
readonly: true
}],
initial: {
[`${baseKey}.__hint`]: `Select ${dependsOn.map((key) => pathToLabel(key)).join(', ')} first`
}
};
}
const dynamicSchema = await firstValueFrom(this.fieldRetriever.retrieveSchema(schemaUrl, context));
const resolvedSchema = dynamicSchema && typeof dynamicSchema === 'object'
? dynamicSchema as Record<string, any>
: null;
if (!resolvedSchema) {
return {
fields: [{
key: `${baseKey}.__hint`,
label: pathToLabel(baseKey),
type: 'display',
readonly: true
}],
initial: {
[`${baseKey}.__hint`]: 'No dynamic schema available'
}
};
}
return this.buildDialogFieldsFromSchema(baseKey, pathToLabel(baseKey), resolvedSchema, item[baseKey]);
}
private async buildDialogFieldsFromSchema(
keyPrefix: string,
labelPrefix: string,
schema: Record<string, any>,
currentValue: unknown
): Promise<{ fields: NodeSettingField[]; initial: Record<string, string | boolean> }> {
const fields: NodeSettingField[] = [];
const initial: Record<string, string | boolean> = {};
const currentRecord =
currentValue && typeof currentValue === 'object' && !Array.isArray(currentValue)
? currentValue as Record<string, unknown>
: {};
const walk = async (
node: Record<string, any>,
pathPrefix: string,
titlePrefix: string
) => {
const resolved = resolveSchemaRef(node, schema);
const properties = resolved?.['properties'] as Record<string, any> | undefined;
if (!properties) return;
for (const [childKey, rawChildSchema] of Object.entries(properties)) {
if (childKey === 'type' || childKey.startsWith('__')) continue;
const childSchema = resolveSchemaRef(rawChildSchema as Record<string, any>, schema);
const nextPath = `${pathPrefix}.${childKey}`;
const nextLabel = `${titlePrefix} ${pathToLabel(childKey)}`;
const currentNestedValue = getValueByPath(currentRecord, nextPath.slice(`${keyPrefix}.`.length));
const hasChildren = !!childSchema?.['properties'] || childSchema?.type === 'object';
if (hasChildren) {
await walk(childSchema as Record<string, any>, nextPath, nextLabel);
continue;
}
const options = await this.loadNodeSettingOptions(
childSchema,
currentRecord,
pathPrefix === keyPrefix ? '' : pathPrefix.slice(`${keyPrefix}.`.length)
);
const fieldType =
options ? 'select' :
childSchema?.type === 'boolean' ? 'checkbox' :
childSchema?.['x-ui-widget'] === 'textarea' ? 'textarea' :
'text';
fields.push({
key: nextPath,
label: nextLabel,
type: fieldType,
rows: fieldType === 'textarea' ? 8 : undefined,
options,
placeholder: typeof childSchema?.['x-ui-placeholder'] === 'string' ? String(childSchema['x-ui-placeholder']) : undefined,
tip: typeof childSchema?.['x-ui-tip'] === 'string' ? String(childSchema['x-ui-tip']) : undefined
});
if (fieldType === 'checkbox') {
initial[nextPath] = currentNestedValue === true;
} else {
initial[nextPath] = currentNestedValue == null ? '' : String(currentNestedValue);
}
}
};
await walk(schema, keyPrefix, labelPrefix);
return { fields, initial };
}
private extractNestedDialogValues(result: Record<string, string | boolean>, keyPrefix: string) {
const nested: Record<string, unknown> = {};
const prefix = `${keyPrefix}.`;
for (const [key, value] of Object.entries(result)) {
if (!key.startsWith(prefix)) continue;
const nestedPath = key.slice(prefix.length);
this.setByPath(nested, nestedPath, value);
}
return nested;
}
private async loadNodeSettingOptions(
propertySchema: Record<string, any>,
item: Record<string, unknown>,
pathPrefix: string
): Promise<NodeSettingOption[] | undefined> {
const retrieverKey = this.toRetrieverKey(propertySchema);
const retrieverBlockType = this.toRetrieverBlockType(propertySchema);
if (!retrieverKey || !retrieverBlockType) return undefined;
const retrieverDependsOn = this.toRetrieverDependsOn(propertySchema, pathPrefix);
const retrieverContext: Record<string, string> = {};
for (const dep of retrieverDependsOn) {
const depValue = getValueByPath(item as Record<string, any>, dep.path);
retrieverContext[dep.key] = depValue == null ? '' : String(depValue);
}
try {
const values = await firstValueFrom(
this.fieldRetriever.retrieveValues(
retrieverBlockType,
retrieverKey,
retrieverDependsOn.length ? retrieverContext : undefined,
this.toRetrieverUrl(propertySchema)
)
);
return values.map((value) => ({ label: value, value }));
} catch {
return [];
}
}
private toArrayFieldItems(definition: ArrayFieldDefinition, value: unknown): ArrayFieldItemView[] {
if (!Array.isArray(value)) return [];
return value.map((item, index) => ({
index,
summary: this.toArrayItemSummary(definition, item, index)
}));
}
private toArrayItemSummary(definition: ArrayFieldDefinition, item: unknown, index: number) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return `Item ${index + 1}`;
}
const properties = definition.itemSchema?.['properties'] as Record<string, any> | undefined;
if (!properties) return `Item ${index + 1}`;
const summaryParts: string[] = [];
for (const key of Object.keys(properties)) {
const value = (item as Record<string, unknown>)[key];
if (this.isMissingValue(value)) continue;
if (typeof value === 'string') {
summaryParts.push(value);
} else if (typeof value === 'number' || typeof value === 'boolean') {
summaryParts.push(String(value));
}
if (summaryParts.length === 2) break;
}
return summaryParts.length ? summaryParts.join(' · ') : `Item ${index + 1}`;
}
private valueToEditorString(value: unknown, type: FieldType): string {
if (type === 'boolean') {
return value === true ? 'true' : 'false';
@ -844,6 +1390,10 @@ export class GenericNodeComponent {
return false;
}
private shouldRenderWideField(label: string, isTextarea: boolean) {
return isTextarea || label.trim().length >= 18;
}
private refreshValidationState() {
const config = this.blockConfiguration ?? {};
const requiredFields = [
@ -898,7 +1448,12 @@ export class GenericNodeComponent {
try {
return await firstValueFrom(
this.fieldRetriever.isFieldRequired(retrieverBlockType, field.retrieverKey, context)
this.fieldRetriever.isFieldRequired(
retrieverBlockType,
field.retrieverKey,
field.dependsOn.length ? context : undefined,
field.retrieverUrl
)
);
} catch {
return false;

View File

@ -10,6 +10,7 @@ export type ConditionalRequiredField = {
label: string;
retrieverBlockType: string | null;
retrieverKey: string;
retrieverUrl: string | null;
dependsOn: Array<{ key: string; path: string }>;
};
@ -87,6 +88,7 @@ function walkSchema(
label,
retrieverBlockType,
retrieverKey,
retrieverUrl: retrieverRequiredUrl,
dependsOn
});
}

View File

@ -545,6 +545,10 @@
min-width: 0;
}
.llm-param-chip-wide {
grid-column: 1 / -1;
}
.llm-param-row-head {
display: flex;
align-items: center;
@ -595,3 +599,43 @@
font-size: 10px;
font-weight: 600;
}
.llm-array-sections {
display: flex;
flex-direction: column;
gap: 10px;
}
.llm-array-block {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid #dbe7f5;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.llm-array-empty {
font-size: 12px;
color: #64748b;
}
.llm-array-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(239, 246, 255, 0.9);
border: 1px solid rgba(191, 219, 254, 0.9);
}
.llm-array-item-summary {
min-width: 0;
font-size: 12px;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -155,7 +155,7 @@
<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">
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
</div>
@ -171,7 +171,7 @@
@if (parameterFields.length) {
<div class="llm-param-grid">
@for (field of parameterFields; track field.path) {
<div class="llm-param-chip">
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
</div>
@ -199,6 +199,27 @@
</div>
}
}
@if (arrayFields.length) {
<div class="llm-array-sections">
@for (arrayField of arrayFields; track arrayField.path) {
<div class="llm-array-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ arrayField.label }}</div>
</div>
@if (!arrayField.items.length) {
<div class="llm-array-empty">No items</div>
} @else {
@for (item of arrayField.items; track item.index) {
<div class="llm-array-item">
<span class="llm-array-item-summary">{{ item.summary }}</span>
</div>
}
}
</div>
}
</div>
}
</div>
</div>

View File

@ -23,6 +23,24 @@ type DisplayField = {
path: string;
label: string;
value: string;
wide: boolean;
};
type ArrayFieldDefinition = {
path: string;
label: string;
itemSchema: Record<string, any> | null;
};
type ArrayFieldItemView = {
index: number;
summary: string;
};
type ArrayFieldView = {
path: string;
label: string;
items: ArrayFieldItemView[];
};
type DisplayFieldGroup = {
@ -64,6 +82,7 @@ export class TaskStepNodeComponent {
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
parameterFields: DisplayField[] = [];
parameterFieldGroups: DisplayFieldGroup[] = [];
arrayFields: ArrayFieldView[] = [];
name = 'Step';
mainContentFields: MainContentView[] = [];
@ -71,12 +90,15 @@ export class TaskStepNodeComponent {
private blockSchema: Record<string, any> | null = null;
private variablePlaceholderPaths = new Set<string>();
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
private mainContentPaths = new Set<string>();
ngOnInit() {
this.outputs = [];
this.inputs = [];
this.parameterFields = [];
this.parameterFieldGroups = [];
this.arrayFields = [];
Object.entries(this.data.outputs).forEach(([key, output]) => {
this.outputs.push({ key, socket: (output as any).socket });
@ -93,24 +115,38 @@ export class TaskStepNodeComponent {
private rebuildDisplayState() {
const config = this.blockConfiguration ?? {};
this.name = toStringOrNull(config['name']) ?? this.name;
const primitiveEntries = flattenPrimitiveValues(config);
const arrayFieldPaths = new Set(this.arrayFieldDefinitions.map((definition) => definition.path));
const primitiveEntries = flattenPrimitiveValues(config)
.filter((entry) => !arrayFieldPaths.has(entry.path));
const visibleEntries = primitiveEntries.filter((entry) => this.isPathVisible(entry.path));
const richContentPaths = this.pickMainContentEntries(visibleEntries).map((entry) => entry.path);
this.mainContentFields = this.pickMainContentEntries(visibleEntries).map((entry) => ({
const mainContentEntries = visibleEntries
.filter((entry) => this.mainContentPaths.has(entry.path))
.filter((entry) => typeof entry.value === 'string' && String(entry.value).trim().length > 0);
const richContentPaths = mainContentEntries.map((entry) => entry.path);
this.mainContentFields = mainContentEntries.map((entry) => ({
path: entry.path,
label: pathToLabel(entry.path),
parts: this.toMainContentParts(entry.path, String(entry.value))
}));
this.arrayFields = this.arrayFieldDefinitions
.filter((definition) => this.isPathVisible(definition.path))
.map((definition) => ({
path: definition.path,
label: definition.label,
items: this.toArrayFieldItems(definition, this.getByPath(config, definition.path))
}));
const grouped = new Map<string, DisplayField[]>();
const rootFields: DisplayField[] = [];
const orderedFields = visibleEntries
.filter((entry) => !['name', 'type'].includes(entry.path))
.filter((entry) => !richContentPaths.includes(entry.path))
.filter((entry) => !this.isEmptyDisplayValue(entry.value))
.map((entry) => ({
path: entry.path,
label: pathToLabel(entry.path),
value: valueToDisplayString(entry.value)
value: valueToDisplayString(entry.value),
wide: this.shouldRenderWideField(pathToLabel(entry.path), this.mainContentPaths.has(entry.path))
}));
for (const field of orderedFields) {
@ -347,25 +383,6 @@ export class TaskStepNodeComponent {
return typeof outputKey === 'string' && outputKey.length > 0 ? outputKey : null;
}
private pickMainContentEntries(entries: Array<{ path: string; value: unknown }>) {
const candidates = entries
.filter((entry) => !['name', 'type'].includes(entry.path))
.filter((entry) => typeof entry.value === 'string')
.map((entry) => ({ ...entry, text: String(entry.value).trim() }))
.filter((entry) => entry.text.length > 0);
if (!candidates.length) return [];
const placeholderCandidates = candidates.filter((entry) =>
this.variablePlaceholderPaths.has(entry.path)
);
const scope = placeholderCandidates.length ? placeholderCandidates : candidates;
scope.sort((a, b) => a.path.localeCompare(b.path));
return scope.map((chosen) => ({ path: chosen.path, value: chosen.text }));
}
private getExecutionMessages(key: '__executionErrors' | '__executionWarnings'): string[] {
const values = this.blockConfiguration?.[key];
if (!Array.isArray(values)) return [];
@ -386,6 +403,8 @@ export class TaskStepNodeComponent {
const blockType = await this.blocksService.getBlockType(type);
this.blockSchema = (blockType?.schema ?? null) as Record<string, any> | null;
this.variablePlaceholderPaths = this.extractVariablePlaceholderPaths(this.blockSchema);
this.arrayFieldDefinitions = this.extractArrayFieldDefinitions(this.blockSchema);
this.mainContentPaths = this.extractMainContentPaths(this.blockSchema);
this.rebuildDisplayState();
}
@ -424,6 +443,143 @@ export class TaskStepNodeComponent {
return paths;
}
private extractArrayFieldDefinitions(schema: Record<string, any> | null): ArrayFieldDefinition[] {
if (!schema) return [];
const definitions: ArrayFieldDefinition[] = [];
const seen = new Set<string>();
const walk = (node: Record<string, any>, pathPrefix: string) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
if (childResolved?.type === 'array') {
if (key === 'type' || key === 'name' || key.startsWith('__') || seen.has(path)) {
continue;
}
seen.add(path);
definitions.push({
path,
label: pathToLabel(path),
itemSchema: this.resolveArrayItemSchema(childResolved, schema)
});
continue;
}
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path);
}
}
};
walk(schema, '');
return definitions;
}
private extractMainContentPaths(schema: Record<string, any> | null): Set<string> {
const paths = new Set<string>();
if (!schema) return paths;
const walk = (node: Record<string, any>, pathPrefix: string) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path);
continue;
}
const rawWidget = typeof childResolved?.['x-ui-widget'] === 'string'
? String(childResolved['x-ui-widget']).toLowerCase().trim()
: '';
const isTextarea = rawWidget === 'textarea' || rawWidget === 'text-area';
const acceptsVariable = childResolved?.['x-ui-accept-variable-as-placeholder'] === true;
if (isTextarea && acceptsVariable) {
paths.add(path);
}
}
};
walk(schema, '');
return paths;
}
private toArrayFieldItems(definition: ArrayFieldDefinition, value: unknown): ArrayFieldItemView[] {
if (!Array.isArray(value)) return [];
return value.map((item, index) => ({
index,
summary: this.toArrayItemSummary(definition, item, index)
}));
}
private toArrayItemSummary(definition: ArrayFieldDefinition, item: unknown, index: number) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return `Item ${index + 1}`;
}
const properties = definition.itemSchema?.['properties'] as Record<string, any> | undefined;
if (!properties) return `Item ${index + 1}`;
const summaryParts: string[] = [];
for (const key of Object.keys(properties)) {
const value = (item as Record<string, unknown>)[key];
if (value == null) continue;
if (typeof value === 'string' && value.trim().length > 0) {
summaryParts.push(value);
} else if (typeof value === 'number' || typeof value === 'boolean') {
summaryParts.push(String(value));
}
if (summaryParts.length === 2) break;
}
return summaryParts.length ? summaryParts.join(' · ') : `Item ${index + 1}`;
}
private resolveArrayItemSchema(node: Record<string, any> | null | undefined, root: Record<string, any>) {
const items = node?.['items'];
if (!items || typeof items !== 'object') return null;
const resolved = resolveSchemaRef(items as Record<string, any>, root);
return resolved && typeof resolved === 'object' ? resolved as Record<string, any> : null;
}
private getByPath(source: Record<string, any>, path: string): unknown {
const keys = path.split('.').filter(Boolean);
let current: unknown = source;
for (const key of keys) {
if (!current || typeof current !== 'object' || Array.isArray(current)) return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
private shouldRenderWideField(label: string, isTextarea: boolean) {
return isTextarea || label.trim().length >= 18;
}
private isEmptyDisplayValue(value: unknown) {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;
if (Array.isArray(value)) return value.length === 0;
return false;
}
private refreshView() {
queueMicrotask(() => {
try {

View File

@ -37,6 +37,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private taskExecutionsService = inject(TaskExecutionsService);
private readonly textInputDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
private lastExecutionId: string | null = null;
private lastExecutionStatus: string | null = null;
readonly execution = input<TaskExecution | null>(null);
readonly contextAsideOpen = signal(true);
readonly activeAsideTab = signal<'inputs' | 'output'>('inputs');
@ -54,6 +55,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const executionId = this.execution()?.id ?? null;
if (executionId === this.lastExecutionId) return;
this.lastExecutionId = executionId;
this.lastExecutionStatus = String(this.execution()?.context.status ?? '').toUpperCase() || null;
this.pendingAuthorizationValues.set({});
this.savingAuthorizations.set({});
this.authorizationErrors.set({});
@ -66,6 +68,23 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.activeAsideTab.set('inputs');
}
});
effect(() => {
const status = String(this.execution()?.context.status ?? '').toUpperCase();
if (!status) return;
const becameSuccessful =
(status === 'SUCCESS' || status === 'COMPLETED') &&
this.lastExecutionStatus !== status &&
this.lastExecutionStatus !== 'SUCCESS' &&
this.lastExecutionStatus !== 'COMPLETED';
if (becameSuccessful && this.executionOutputTabEnabled()) {
this.activeAsideTab.set('output');
}
this.lastExecutionStatus = status;
});
}
readonly stepsArray = computed(() =>