From b24b8177a9b37f73c5e9e27b1afdedb166deeefa Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 12 Mar 2026 17:22:23 +0100 Subject: [PATCH] Support dynamic array schemas in node editors --- .../retriever/field-retriever-call.base.ts | 11 +- .../retriever/field-retriever-call.fake.ts | 37 +- .../retriever/field-retriever-call.ts | 99 ++- src/app/services/retriever/field-retriever.ts | 22 +- .../nodes/generic-node/generic-node.css | 52 ++ .../nodes/generic-node/generic-node.html | 51 +- .../shared/nodes/generic-node/generic-node.ts | 565 +++++++++++++++++- src/app/shared/nodes/schema-requirements.ts | 2 + .../nodes/task-step-node/task-step-node.css | 44 ++ .../nodes/task-step-node/task-step-node.html | 25 +- .../nodes/task-step-node/task-step-node.ts | 202 ++++++- .../task-execution-viewer.ts | 19 + 12 files changed, 1072 insertions(+), 57 deletions(-) diff --git a/src/app/services/retriever/field-retriever-call.base.ts b/src/app/services/retriever/field-retriever-call.base.ts index e72183d..72a0955 100644 --- a/src/app/services/retriever/field-retriever-call.base.ts +++ b/src/app/services/retriever/field-retriever-call.base.ts @@ -4,12 +4,19 @@ export abstract class FieldRetrieverCallServiceBase { abstract retrieveValues( blockType: string, key: string, - context?: Record + context?: Record, + retrieverUrl?: string | null ): Observable; abstract isFieldRequired( blockType: string, key: string, - context?: Record + context?: Record, + retrieverUrl?: string | null ): Observable; + + abstract retrieveSchema( + schemaUrl: string, + context?: Record + ): Observable | null>; } diff --git a/src/app/services/retriever/field-retriever-call.fake.ts b/src/app/services/retriever/field-retriever-call.fake.ts index 20de5bc..1a6d63e 100644 --- a/src/app/services/retriever/field-retriever-call.fake.ts +++ b/src/app/services/retriever/field-retriever-call.fake.ts @@ -15,7 +15,8 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase override retrieveValues( blockType: string, key: string, - context?: Record + context?: Record, + _retrieverUrl?: string | null ): Observable { 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 + context?: Record, + _retrieverUrl?: string | null ): Observable { void context; return of(false); } + + override retrieveSchema( + schemaUrl: string, + context?: Record + ): Observable | 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 + } + } + }); + } } diff --git a/src/app/services/retriever/field-retriever-call.ts b/src/app/services/retriever/field-retriever-call.ts index 4c41b8b..931cadf 100644 --- a/src/app/services/retriever/field-retriever-call.ts +++ b/src/app/services/retriever/field-retriever-call.ts @@ -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) { - 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 + context?: Record, + retrieverUrl?: string | null ): Observable { - 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(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 + context?: Record, + retrieverUrl?: string | null ): Observable { - 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(url, { params }); } + override retrieveSchema( + schemaUrl: string, + context?: Record + ): Observable | 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(parsed.url, { params }).pipe( + map((raw) => (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record : null)) + ); + } + + private resolveRequest( + blockType: string, + key: string, + context?: Record, + 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'); diff --git a/src/app/services/retriever/field-retriever.ts b/src/app/services/retriever/field-retriever.ts index 44623f8..7dbdf44 100644 --- a/src/app/services/retriever/field-retriever.ts +++ b/src/app/services/retriever/field-retriever.ts @@ -12,9 +12,10 @@ export class FieldRetriever { retrieveValues( blockType: string, key: string, - context?: Record + context?: Record, + 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 + context?: Record, + 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 + ) { + return this.fieldRetrieverCallService.retrieveSchema(schemaUrl, context).pipe( + catchError((err) => { + console.error('Schema retrieval failed', err); + return throwError(() => err); + }) + ); + } } diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index ce08e78..edb03f6 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -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; diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 6c1fd3f..dc39dbf 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -122,7 +122,7 @@ {{ group.legend }}
@for (field of group.fields; track field.path) { -
+
{{ field.label }} +
+ @if (!arrayField.items.length) { +
No items
+ } @else { + @for (item of arrayField.items; track item.index) { +
+ {{ item.summary }} +
+ + +
+
+ } + } +
+ } +
+ }
@if (localEditorOpen) { diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index c16dd62..ffe7c3b 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -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 | 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 | null = null; private editableFieldDefinitions: EditableFieldDefinition[] = []; + private arrayFieldDefinitions: ArrayFieldDefinition[] = []; private schemaRequirements: SchemaRequirements = { required: [], conditional: [] }; private conditionalRequiredByPath = new Map(); 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 | 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, 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 | null): ArrayFieldDefinition[] { + if (!schema) return []; + + const definitions: ArrayFieldDefinition[] = []; + const seen = new Set(); + + const walk = ( + node: Record, + 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 | undefined; + if (!properties) return; + + for (const [key, childSchema] of Object.entries(properties)) { + const childResolved = resolveSchemaRef(childSchema as Record, 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, path, { + visibleWhen: childUi.visibleWhen, + group: childUi.group + }); + } + } + }; + + walk(schema, ''); + return definitions; + } + private toFieldUiMeta( schema: Record | 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 | 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, + index: number | null + ) { + const itemSchema = definition.itemSchema; + const properties = itemSchema?.['properties'] as Record | 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 = {}; + + for (const [key, rawPropertySchema] of Object.entries(properties)) { + if (key === 'type' || key.startsWith('__')) continue; + + const propertySchema = resolveSchemaRef(rawPropertySchema as Record, 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, + previousItem: Record + ) { + const itemSchema = definition.itemSchema; + const properties = itemSchema?.['properties'] as Record | undefined; + const schemaRoot = this.blockSchema ?? itemSchema ?? {}; + if (!properties) { + try { + return JSON.parse(String(result['__raw'] ?? '{}')) as Record; + } catch { + return previousItem; + } + } + + const nextItem: Record = {}; + + for (const [key, rawPropertySchema] of Object.entries(properties)) { + if (key === 'type' || key.startsWith('__')) continue; + + const propertySchema = resolveSchemaRef(rawPropertySchema as Record, 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 | null) { + const properties = itemSchema?.['properties'] as Record | undefined; + const schemaRoot = this.blockSchema ?? itemSchema ?? {}; + if (!properties) return {}; + + const item: Record = {}; + for (const [key, rawPropertySchema] of Object.entries(properties)) { + if (key === 'type' || key.startsWith('__')) continue; + const propertySchema = resolveSchemaRef(rawPropertySchema as Record, 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 | null | undefined, root: Record) { + const items = node?.['items']; + if (!items || typeof items !== 'object') return null; + const resolved = resolveSchemaRef(items as Record, root); + return resolved && typeof resolved === 'object' ? resolved as Record : null; + } + + private hasDynamicSchema(schema: Record | 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, + item: Record + ): Promise<{ fields: NodeSettingField[]; initial: Record }> { + 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 = {}; + 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 + : 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, + currentValue: unknown + ): Promise<{ fields: NodeSettingField[]; initial: Record }> { + const fields: NodeSettingField[] = []; + const initial: Record = {}; + const currentRecord = + currentValue && typeof currentValue === 'object' && !Array.isArray(currentValue) + ? currentValue as Record + : {}; + + const walk = async ( + node: Record, + pathPrefix: string, + titlePrefix: string + ) => { + const resolved = resolveSchemaRef(node, schema); + const properties = resolved?.['properties'] as Record | undefined; + if (!properties) return; + + for (const [childKey, rawChildSchema] of Object.entries(properties)) { + if (childKey === 'type' || childKey.startsWith('__')) continue; + + const childSchema = resolveSchemaRef(rawChildSchema as Record, 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, 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, keyPrefix: string) { + const nested: Record = {}; + 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, + item: Record, + pathPrefix: string + ): Promise { + const retrieverKey = this.toRetrieverKey(propertySchema); + const retrieverBlockType = this.toRetrieverBlockType(propertySchema); + if (!retrieverKey || !retrieverBlockType) return undefined; + + const retrieverDependsOn = this.toRetrieverDependsOn(propertySchema, pathPrefix); + const retrieverContext: Record = {}; + for (const dep of retrieverDependsOn) { + const depValue = getValueByPath(item as Record, 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 | undefined; + if (!properties) return `Item ${index + 1}`; + + const summaryParts: string[] = []; + for (const key of Object.keys(properties)) { + const value = (item as Record)[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; diff --git a/src/app/shared/nodes/schema-requirements.ts b/src/app/shared/nodes/schema-requirements.ts index 80cbb97..efe1601 100644 --- a/src/app/shared/nodes/schema-requirements.ts +++ b/src/app/shared/nodes/schema-requirements.ts @@ -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 }); } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 47c72dd..2233742 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -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; +} diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index e43a485..a41283a 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -155,7 +155,7 @@ {{ group.legend }}
@for (field of group.fields; track field.path) { -
+
{{ field.label }}
@@ -171,7 +171,7 @@ @if (parameterFields.length) {
@for (field of parameterFields; track field.path) { -
+
{{ field.label }}
@@ -199,6 +199,27 @@
} } + + @if (arrayFields.length) { +
+ @for (arrayField of arrayFields; track arrayField.path) { +
+
+
{{ arrayField.label }}
+
+ @if (!arrayField.items.length) { +
No items
+ } @else { + @for (item of arrayField.items; track item.index) { +
+ {{ item.summary }} +
+ } + } +
+ } +
+ }
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index 2023fbc..d509d56 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -23,6 +23,24 @@ type DisplayField = { path: string; label: string; value: string; + wide: boolean; +}; + +type ArrayFieldDefinition = { + path: string; + label: string; + itemSchema: Record | 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 | null = null; private variablePlaceholderPaths = new Set(); + private arrayFieldDefinitions: ArrayFieldDefinition[] = []; + private mainContentPaths = new Set(); 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(); 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 | 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 | null): ArrayFieldDefinition[] { + if (!schema) return []; + + const definitions: ArrayFieldDefinition[] = []; + const seen = new Set(); + + const walk = (node: Record, pathPrefix: string) => { + const resolved = resolveSchemaRef(node, schema); + if (!resolved || typeof resolved !== 'object') return; + + const properties = resolved.properties as Record | undefined; + if (!properties) return; + + for (const [key, childSchema] of Object.entries(properties)) { + const childResolved = resolveSchemaRef(childSchema as Record, 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, path); + } + } + }; + + walk(schema, ''); + return definitions; + } + + private extractMainContentPaths(schema: Record | null): Set { + const paths = new Set(); + if (!schema) return paths; + + const walk = (node: Record, pathPrefix: string) => { + const resolved = resolveSchemaRef(node, schema); + if (!resolved || typeof resolved !== 'object') return; + + const properties = resolved.properties as Record | undefined; + if (!properties) return; + + for (const [key, childSchema] of Object.entries(properties)) { + const childResolved = resolveSchemaRef(childSchema as Record, schema); + const path = pathPrefix ? `${pathPrefix}.${key}` : key; + const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; + + if (hasChildren) { + walk(childResolved as Record, 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 | undefined; + if (!properties) return `Item ${index + 1}`; + + const summaryParts: string[] = []; + for (const key of Object.keys(properties)) { + const value = (item as Record)[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 | null | undefined, root: Record) { + const items = node?.['items']; + if (!items || typeof items !== 'object') return null; + const resolved = resolveSchemaRef(items as Record, root); + return resolved && typeof resolved === 'object' ? resolved as Record : null; + } + + private getByPath(source: Record, 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)[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 { diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index c249ca4..dcfa539 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -37,6 +37,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { private taskExecutionsService = inject(TaskExecutionsService); private readonly textInputDebounceTimers = new Map>(); private lastExecutionId: string | null = null; + private lastExecutionStatus: string | null = null; readonly execution = input(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(() =>