From 971d829685ea2ff2a5affc6552c18386eb54ab23 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 17 Mar 2026 11:54:07 +0100 Subject: [PATCH] Make container nodes schema-driven --- .../nodes/container-node/container-node.css | 61 +++ .../nodes/container-node/container-node.html | 25 +- .../nodes/container-node/container-node.ts | 370 +++++++++++++++++- src/app/utilities/rete-editor.ts | 12 +- src/environments/environment.staging.ts | 2 +- 5 files changed, 449 insertions(+), 21 deletions(-) diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index b5fef64..7cb5067 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -257,6 +257,67 @@ color: #475569; } +.container-node__params { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 0 14px 14px; +} + +.container-node__param-chip { + min-width: 0; + border: 1px solid #dbe2ea; + border-radius: 12px; + background: rgba(255, 255, 255, 0.9); + padding: 8px 10px; +} + +.container-node__param-chip--wide { + grid-column: 1 / -1; +} + +.container-node__param-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.container-node__param-key { + min-width: 0; + font-size: 10px; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.container-node__param-value { + display: block; + min-width: 0; + font-size: 12px; + color: #0f172a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.container-node__param-edit { + border: 0; + background: transparent; + color: #0f766e; + width: 20px; + height: 20px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.container-node__param-edit:hover { + background: rgba(15, 118, 110, 0.12); +} + .container-node__dropzone { margin: 0 14px 14px; min-height: 128px; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index 877e473..bb55934 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -7,7 +7,7 @@
-
Generic Container
+
{{ containerLabel }}
{{ name }}
@@ -97,6 +97,29 @@
+ @if (hasParameterFields()) { +
+ @for (field of parameterFields; track field.path) { +
+
+ {{ field.label }} + @if (!isReadonly) { + + } +
+ {{ field.value }} +
+ } +
+ } +
| null = null; + private schemaRequirements: SchemaRequirements = { required: [], conditional: [] }; + private containerFieldDefinitions: ContainerFieldDefinition[] = []; deleteConfirmOpen = false; replaceConfirmSelection: string[] | null = null; importLoading = false; private importErrorMessage: string | null = null; + parameterFields: ContainerFieldView[] = []; @Input() data!: any; @Input() emit!: (data: any) => void; @@ -55,6 +80,10 @@ export class ContainerNodeComponent { return this.isReadonly; } + ngOnInit() { + void this.loadSchemaContext(); + } + ngAfterViewInit() { this.rendered(); } @@ -67,6 +96,10 @@ export class ContainerNodeComponent { return String(this.configuration?.['name'] ?? this.data?.data?.name ?? 'Container'); } + get containerLabel() { + return pathToLabel(this.typeName.replace(/Container$/, ' Container')); + } + get inputs() { return Object.entries(this.data?.inputs ?? {}).map(([key, input]) => ({ key, @@ -125,14 +158,25 @@ export class ContainerNodeComponent { } get missingRequiredParams() { - const missing: string[] = []; - if (!this.name.trim()) { - missing.push('Name'); - } - if (!this.subFlow) { - missing.push('Sub Flow'); - } - return missing; + const config = this.configuration ?? {}; + const requiredFields = [ + ...this.schemaRequirements.required, + ...this.schemaRequirements.conditional.filter((field) => + field.requiredWhen + ? evaluateUiConditionRule(field.requiredWhen, config, (path) => this.resolveFieldSchema(path)) + : false + ) + ].filter((field) => field.path !== 'type'); + + return requiredFields + .filter((field, index, fields) => fields.findIndex((candidate) => candidate.path === field.path) === index) + .filter((field) => field.path !== 'name' && field.path !== 'subFlow') + .filter((field) => this.isMissingValue(getValueByPath(config, field.path))) + .map((field) => field.label); + } + + hasParameterFields() { + return this.parameterFields.length > 0; } inputDisplayLabel(inputKey: string) { @@ -230,6 +274,7 @@ export class ContainerNodeComponent { } await assignImportedSubflow(selectedItem.data, retriever.validationUrl); + this.refreshParameterFields(); } catch { this.importErrorMessage = 'Failed to load importable flows.'; } finally { @@ -237,6 +282,36 @@ export class ContainerNodeComponent { } } + async openParameterEditor(path: string, event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + if (this.isReadonly) return; + + const definition = this.containerFieldDefinitions.find((field) => field.path === path); + if (!definition) return; + + const initialValue = this.getEditorInitialValue(definition); + const field: NodeSettingField = { + key: definition.path, + label: definition.label, + type: this.toDialogFieldType(definition), + required: this.missingRequiredParams.includes(definition.label), + rows: definition.widget === 'textarea' ? 6 : undefined, + options: definition.enumOptions.map((option) => ({ label: option, value: option })) + }; + + const result = await this.settingsDialog.open({ + title: `Edit ${definition.label}`, + fields: [field], + initial: { + [definition.path]: initialValue + } + }); + + if (!result) return; + await this.applyFieldValue(definition, result[definition.path]); + } + onDropZoneDragOver(event: DragEvent) { if (this.isReadonly) return; if (!this.canAcceptSelectionDrop()) return; @@ -443,4 +518,279 @@ export class ContainerNodeComponent { }; } + private async loadSchemaContext() { + const containerType = await this.containersService.getContainerType(this.typeName); + this.containerSchema = (containerType?.schema ?? null) as Record | null; + this.schemaRequirements = extractSchemaRequirements(this.containerSchema); + this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema); + this.refreshParameterFields(); + queueMicrotask(() => { + try { + this.cdr.detectChanges(); + } catch { + // Node may have been removed while schema was loading. + } + }); + } + + private isMissingValue(value: unknown): boolean { + if (value == null) return true; + if (typeof value === 'string') return value.trim().length === 0; + return false; + } + + private refreshParameterFields() { + const config = this.configuration ?? {}; + this.parameterFields = this.containerFieldDefinitions + .filter((field) => this.isFieldVisible(field.path)) + .map((field) => ({ + path: field.path, + label: field.label, + value: valueToDisplayString(getValueByPath(config, field.path)), + wide: field.widget === 'textarea' || field.label.length >= 18 + })); + } + + private buildContainerFieldDefinitions(schema: Record | null): ContainerFieldDefinition[] { + if (!schema) return []; + + const definitions: ContainerFieldDefinition[] = []; + 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)) { + if (key === 'type') continue; + const path = pathPrefix ? `${pathPrefix}.${key}` : key; + if (path === 'name' || path === 'subFlow') continue; + + const childResolved = resolveSchemaRef(childSchema as Record, schema); + const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; + if (hasChildren) { + walk(childResolved as Record, path); + continue; + } + + definitions.push({ + path, + label: pathToLabel(path), + type: this.toFieldType(childResolved), + enumOptions: Array.isArray(childResolved?.enum) + ? childResolved.enum.filter((item: unknown): item is string => typeof item === 'string') + : [], + widget: typeof childResolved?.['x-ui-widget'] === 'string' && String(childResolved['x-ui-widget']).toLowerCase() === 'textarea' + ? 'textarea' + : null, + structural: childResolved?.['x-ui-structural'] === true + }); + } + }; + + walk(schema, ''); + return definitions; + } + + private isFieldVisible(path: string, visited = new Set()): boolean { + if (visited.has(path)) return true; + visited.add(path); + + const schema = this.resolveFieldSchema(path); + const rule = readUiConditionRule(schema?.['x-ui-visible-when']); + if (!rule) return true; + + const parent = typeof rule.field === 'string' ? rule.field : null; + if (parent && !this.isFieldVisible(parent, visited)) return false; + + return evaluateUiConditionRule(rule, this.configuration ?? {}, (fieldPath) => this.resolveFieldSchema(fieldPath)); + } + + private toFieldType(schema: Record | null): ContainerFieldType { + const type = typeof schema?.['type'] === 'string' ? String(schema['type']) : 'unknown'; + if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') { + return type; + } + return 'unknown'; + } + + private toDialogFieldType(definition: ContainerFieldDefinition): NodeSettingField['type'] { + if (definition.type === 'boolean') return 'checkbox'; + if (definition.widget === 'textarea') return 'textarea'; + if (definition.enumOptions.length) return 'select'; + return 'text'; + } + + private getEditorInitialValue(definition: ContainerFieldDefinition): string | boolean { + const raw = getValueByPath(this.configuration ?? {}, definition.path); + if (definition.type === 'boolean') return raw === true; + if (raw == null) return ''; + return String(raw); + } + + private async applyFieldValue(definition: ContainerFieldDefinition, rawValue: string | boolean | undefined) { + const nextValue = this.parseFieldValue(definition, rawValue); + const nextConfiguration = this.cloneConfiguration(); + this.setByPath(nextConfiguration, definition.path, nextValue); + + if (definition.structural) { + this.updateCurrentFlowData(nextConfiguration); + await this.recreateContainer(nextConfiguration); + return; + } + + this.data.data = { + ...this.data.data, + specificConfiguration: nextConfiguration + }; + this.refreshParameterFields(); + this.updateCurrentFlowData(nextConfiguration); + this.refreshView(); + } + + private parseFieldValue(definition: ContainerFieldDefinition, rawValue: string | boolean | undefined): unknown { + if (definition.type === 'boolean') return rawValue === true; + const stringValue = typeof rawValue === 'string' ? rawValue : ''; + if (definition.type === 'number' || definition.type === 'integer') { + const parsed = Number(stringValue); + return Number.isFinite(parsed) ? parsed : null; + } + return stringValue; + } + + private cloneConfiguration(): Record { + if (typeof globalThis.structuredClone === 'function') { + try { + return globalThis.structuredClone(this.configuration ?? {}); + } catch { + // Ignore non-cloneable runtime metadata. + } + } + return JSON.parse(JSON.stringify(this.configuration ?? {})) as Record; + } + + private async recreateContainer(nextConfiguration: Record) { + const current = (this.data?.data ?? {}) as Record; + const containerId = String(current['id'] ?? ''); + if (!containerId) return; + + this.data.data = { + ...current, + __containerAssigning: true, + __containerAssignmentError: null, + specificConfiguration: nextConfiguration + }; + this.refreshView(); + + try { + const createdContainer = await firstValueFrom( + this.containersService.createContainer(containerId, { + ...nextConfiguration, + position: current['position'], + typeName: this.typeName + }) + ); + + const replaceNode = current['replaceWithCreatedNode']; + if (typeof replaceNode === 'function') { + await replaceNode({ + ...createdContainer, + position: (current['position'] as { x: number; y: number } | undefined) ?? createdContainer.position + }); + return; + } + + this.data.data = { + ...current, + ...createdContainer, + specificConfiguration: nextConfiguration, + position: (current['position'] as { x: number; y: number } | undefined) ?? createdContainer.position, + __containerAssigning: false, + __containerAssignmentError: null + }; + this.refreshParameterFields(); + this.updateCurrentFlowData(nextConfiguration); + this.refreshView(); + } catch (error) { + this.data.data = { + ...current, + __containerAssigning: false, + __containerAssignmentError: error instanceof Error ? error.message : 'Container update failed' + }; + this.refreshView(); + } + } + + private updateCurrentFlowData(nextConfiguration: Record) { + const flow = this.editorState.currentFlow(); + const blockId = this.blockId; + if (!flow || !blockId) return; + + const nextFlow: FlowData = { + blocks: flow.data.blocks, + containers: flow.data.containers.map((container) => + container.id === blockId + ? { + ...container, + name: String(nextConfiguration['name'] ?? container.name), + specificConfiguration: this.cloneConfigurationValue(nextConfiguration) + } + : container + ), + connections: flow.data.connections + }; + + this.editorState.updateData(nextFlow); + } + + private cloneConfigurationValue(value: T): T { + if (typeof globalThis.structuredClone === 'function') { + try { + return globalThis.structuredClone(value); + } catch { + // Ignore non-cloneable runtime metadata. + } + } + return JSON.parse(JSON.stringify(value)) as T; + } + + private setByPath(target: Record, path: string, value: unknown) { + const parts = path.split('.'); + let current = target; + for (let index = 0; index < parts.length - 1; index += 1) { + const key = parts[index]; + const next = current[key]; + if (!next || typeof next !== 'object' || Array.isArray(next)) { + current[key] = {}; + } + current = current[key] as Record; + } + current[parts[parts.length - 1]] = value; + } + + private refreshView() { + queueMicrotask(() => { + try { + this.cdr.detectChanges(); + } catch { + // Node may have been removed while async refresh was running. + } + }); + } + + private resolveFieldSchema(path: string): Record | null { + if (!this.containerSchema) return null; + + let current: Record | null = this.containerSchema; + for (const key of path.split('.')) { + const resolved = current ? resolveSchemaRef(current, this.containerSchema) : null; + const properties = resolved?.properties as Record | undefined; + if (!properties?.[key]) return null; + current = resolveSchemaRef(properties[key], this.containerSchema) as Record | null; + } + + return current; + } + } diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index c808367..5ff7d0e 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -68,8 +68,8 @@ export async function createEditor( customize: { node(context: any) { if (nodeView === "execution") return TaskStepNodeComponent; - const typeName = context?.payload?.data?.typeName; - return typeName === "GenericContainer" ? ContainerNodeComponent : GenericNodeComponent; + const nodeFamily = context?.payload?.data?.nodeFamily; + return nodeFamily === "container" ? ContainerNodeComponent : GenericNodeComponent; }, socket(context: any) { // rete-angular passes only `payload` to the socket component. @@ -173,7 +173,7 @@ export async function addBlockToEditor( runtime?: ReteRuntimeContext ) { const resolvedRuntime = runtime ?? editorRuntime.get(editor); - const node = new ClassicPreset.Node(toNodeLabel(block.typeName)) as HFNode; + const node = new ClassicPreset.Node(block.typeName) as HFNode; const removeNode = async () => { if (!editor.getNode(node.id)) return; const relatedConnectionIds = editor.getConnections() @@ -516,12 +516,6 @@ function resolveNodePort(node: HFNode | undefined, kind: "input" | "output", por return ports.find((port) => port?.name === portName) ?? null; } -function toNodeLabel(typeName: string) { - if (typeName === "InputBlock" || typeName === "SourceBlock") return "Input"; - if (typeName === "OutputBlock") return "Output"; - return typeName; -} - async function applyNodePosition( area: AreaPlugin, nodeId: string, diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index b6d3017..519fdd3 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -10,7 +10,7 @@ export const environment = { production: false, apiUrl: 'http://localhost:8080', assistantEnabled: true, - tourModeAlwaysOn: true, + tourModeAlwaysOn: false, assistantCallService: AssistantCallService, authorizationCallService: AuthorizationCallService, flowsCallService: FlowsCallService,