import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow'; import { ClassicPreset } from 'rete'; import { ReteModule } from 'rete-angular-plugin/21'; import { NodeSettingField, NodeSettingsDialogInput, 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'; import { firstValueFrom, take } from 'rxjs'; import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements'; import { type UiConditionRule, evaluateUiConditionRule, flattenPrimitiveValues, getValueByPath, parentPath, pathToLabel, readUiConditionRule, readEffectiveUiVisibleConditionRule, readUiLabel, readUiGroup, resolveSchemaRef, resolveSchemaPath, schemaFieldDescription, schemaFieldLabel, shouldSkipSchemaField, splitTemplatedTextParts, toStringOrNull, validateUniqueByConstraint, valueToDisplayString } from '../node-utility'; type FieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown'; type RetrieverDependency = { key: string; path: string; source: 'field' | 'context'; }; type NodeOptionsSource = { collection: 'inputs' | 'outputs'; valueField: string; labelField: string; }; type EditableFieldDefinition = { path: string; label: string; type: FieldType; enumOptions: string[]; nodeOptionsSource: NodeOptionsSource | null; retrieverBlockType: string | null; retrieverKey: string | null; retrieverUrl: string | null; retrieverStructuredData: boolean; retrieverDependsOn: RetrieverDependency[]; ui: { widget: 'textarea' | null; acceptVariableAsPlaceholder: boolean; structural: boolean; bindableAsInput: boolean; inputName: string | null; inputType: string | null; inputMultiple: boolean | null; structuralReason?: string; label?: string; placeholder?: string; tip?: string; rows?: number; visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null; }; }; type EditableFieldView = { path: string; label: string; value: string; wide: boolean; expandable: boolean; enabled: boolean; type: FieldType; booleanValue: boolean; }; type ArrayFieldDefinition = { path: string; label: string; itemSchema: Record | null; uniqueBy: string | null; ui: { structural: boolean; visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null; }; }; type ArrayFieldItemView = { index: number; summary: string; }; type ArrayFieldView = { path: string; label: string; items: ArrayFieldItemView[]; }; type EditableFieldGroupView = { key: string; legend: string; fields: EditableFieldView[]; }; type RichContentView = { path: string; label: string; rawValue: string; expandable: boolean; parts: { text: string; isDynamicInput: boolean }[]; }; type RenderedSocketPort = { key: string; socket: ClassicPreset.Socket; }; @Component({ selector: 'app-generic-node', imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule], templateUrl: './generic-node.html', styleUrl: './generic-node.css', host: { 'data-testid': 'node' } }) export class GenericNodeComponent { private settingsDialog = inject(NodeSettingsDialogService); private editorState = inject(EditorStateHolder); private fieldRetriever = inject(FieldRetriever); private blocksService = inject(BlocksService); private cdr = inject(ChangeDetectorRef); @Input() data!: any; @Input() emit!: (data: any) => void; @Input() rendered!: () => void; @HostBinding('class.selected') get selected() { return this.data.selected || this.editorState.isBlockSelected(this.blockId); } @HostBinding('class.validation-highlighted') get validationHighlighted() { return this.editorState.isValidationNodeHighlighted(this.blockId); } @HostBinding('attr.data-block-id') get hostBlockId() { return this.blockId; } @HostBinding('class.llm-node-readonly') get readonlyClass() { return this.isReadonly; } outputs: { key: string; socket: ClassicPreset.Socket }[] = []; inputs: { key: string; socket: ClassicPreset.Socket }[] = []; dependantOutput: RenderedSocketPort | null = null; dependencyInput: RenderedSocketPort | null = null; parameterFields: EditableFieldView[] = []; parameterFieldGroups: EditableFieldGroupView[] = []; richContentFields: RichContentView[] = []; arrayFields: ArrayFieldView[] = []; name = 'noName'; localEditorOpen = false; localEditorPath: string | null = null; localEditorLabel = ''; localEditorValue = ''; localEditorOptions: NodeSettingOption[] = []; localEditorLoading = false; localEditorHasRetriever = false; localEditorType: FieldType = 'string'; localEditorMaxLength: number | null = null; localEditorWidget: 'textarea' | null = null; localEditorRows: number | null = null; localEditorBindableAsInput = false; localEditorUseInput = false; localEditorBindableInputName: string | null = null; deleteConfirmOpen = false; schemaReady = false; missingRequiredParams: string[] = []; private blockSchema: Record | null = null; private blockDescriptor: BlockType | null = null; private editableFieldDefinitions: EditableFieldDefinition[] = []; private arrayFieldDefinitions: ArrayFieldDefinition[] = []; private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] }; private conditionalRequiredByPath = new Map(); private refreshingConditionalRequirements = false; ngOnInit() { this.outputs = []; this.inputs = []; this.parameterFields = []; this.parameterFieldGroups = []; this.richContentFields = []; this.arrayFields = []; Object.entries(this.data.outputs).forEach(([key, output]) => { const entry = { key, socket: (output as any).socket }; if (key === FLOW_DEPENDANT_PORT_KEY) { this.dependantOutput = entry; return; } this.outputs.push(entry); }); Object.entries(this.data.inputs).forEach(([key, input]) => { const entry = { key, socket: (input as any).socket }; if (key === FLOW_DEPENDENCY_PORT_KEY) { this.dependencyInput = entry; return; } this.inputs.push(entry); }); const config = this.ensureBlockConfiguration(); this.name = toStringOrNull(config['name']) || this.name; this.refreshValidationState(); this.refreshParameterFields(); void this.loadSchemaContext(); } ngAfterViewInit() { this.rendered(); } get isReadonly() { return this.data?.data?.['__readonly'] === true; } get nodeIdLabel() { return this.blockId ?? 'unknown-id'; } async openNameEditor(event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; this.localEditorPath = 'name'; this.localEditorLabel = 'Name'; this.localEditorType = 'string'; this.localEditorMaxLength = 20; this.localEditorValue = this.name ?? ''; this.localEditorOptions = []; this.localEditorLoading = false; this.localEditorHasRetriever = false; this.localEditorOpen = true; this.localEditorWidget = null; this.localEditorRows = null; this.localEditorBindableAsInput = false; this.localEditorUseInput = false; this.localEditorBindableInputName = null; } async openParameterEditor(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; const definition = this.editableFieldDefinitions.find((field) => field.path === path); if (!definition) return; if (!this.isFieldVisible(definition) || !this.isPathEnabled(definition.path)) return; this.localEditorPath = definition.path; this.localEditorLabel = definition.label; this.localEditorType = definition.type; this.localEditorMaxLength = null; this.localEditorWidget = definition.ui.widget; this.localEditorRows = definition.ui.rows ?? null; this.localEditorValue = this.valueToEditorString(this.getByPath(this.blockConfiguration ?? {}, definition.path), definition.type); this.localEditorOptions = this.resolveSelectableOptions(definition); this.localEditorBindableAsInput = definition.ui.bindableAsInput; this.localEditorUseInput = this.isBindableFieldUsingInput(definition); this.localEditorBindableInputName = definition.ui.inputName; this.localEditorLoading = !!definition.retrieverKey && !this.localEditorUseInput; this.localEditorHasRetriever = this.localEditorOptions.length > 0 || !!definition.retrieverKey || !!definition.nodeOptionsSource; this.localEditorOpen = true; if (definition.retrieverKey && !this.localEditorUseInput) { const missingDependencies = definition.retrieverDependsOn .filter((dep) => { const value = this.resolveRetrieverDependencyValue(this.blockConfiguration ?? {}, dep); return this.isMissingValue(value); }) .map((dep) => dep.source === 'context' ? pathToLabel(dep.key) : pathToLabel(dep.path)); const hasMissingFieldDependencies = definition.retrieverDependsOn.some((dep) => { if (dep.source !== 'field') return false; const value = this.resolveRetrieverDependencyValue(this.blockConfiguration ?? {}, dep); return this.isMissingValue(value); }); if (hasMissingFieldDependencies) { this.localEditorLoading = false; return; } await this.loadLocalEditorOptions(definition); } } closeSimpleParamEditor(event?: Event) { event?.preventDefault(); event?.stopPropagation(); this.localEditorOpen = false; this.localEditorPath = null; this.localEditorLabel = ''; this.localEditorOptions = []; this.localEditorLoading = false; this.localEditorHasRetriever = false; this.localEditorType = 'string'; this.localEditorMaxLength = null; this.localEditorWidget = null; this.localEditorRows = null; this.localEditorBindableAsInput = false; this.localEditorUseInput = false; this.localEditorBindableInputName = null; } saveSimpleParamEditor(event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; if (!this.localEditorPath) return; if (!this.canSaveLocalEditor()) return; const config = this.ensureBlockConfiguration(); if (this.localEditorPath === 'name') { const nameValue = this.localEditorValue.trim().slice(0, 20); config['name'] = nameValue; this.name = nameValue || this.name; } else { const previousValue = this.getByPath(config, this.localEditorPath); const parsedValue = this.localEditorUseInput ? this.emptyValueForFieldType(this.localEditorType) : this.parseEditorValue(this.localEditorValue, this.localEditorType); this.setByPath(config, this.localEditorPath, parsedValue); if (!this.areValuesEqual(previousValue, parsedValue)) { this.resetDependentRetrieverFields(config, this.localEditorPath); if (this.isStructuralField(this.localEditorPath)) { this.markBlockForServerRecreate(); } } } this.pruneInactiveConfiguration(config); this.refreshParameterFields(); this.refreshValidationState(); this.markFlowDirty(); this.maybeCreateBlockOnServer(); this.closeSimpleParamEditor(); } async openMainContentEditor(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; if (!this.isPathVisible(path)) return; const contentLabel = this.fieldDisplayLabel(path); const ui = this.getFieldUiMeta(path); const currentValue = String(this.getByPath(this.blockConfiguration ?? {}, path) ?? ''); await this.openTextareaEditor(path, contentLabel, currentValue, ui); } async confirmDelete(event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; if (!this.deleteConfirmOpen) { this.deleteConfirmOpen = true; return; } const deleteNode = this.data?.data?.deleteNode; if (typeof deleteNode === 'function') { await deleteNode(); } } cancelDelete(event?: Event) { event?.preventDefault(); event?.stopPropagation(); this.deleteConfirmOpen = false; } async cloneCurrentNode(event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; const cloneNode = this.data?.data?.cloneNode; if (typeof cloneNode === 'function') { await cloneNode(); } } isHumanNode(): boolean { return !!this.blockDescriptor?.interactionContract; } isConditionalNode(): boolean { const outputNames = this.resolvePorts('output').map((port) => port.name.trim().toLowerCase()); return outputNames.includes('true') && outputNames.includes('false'); } nodeTitle(): string { const type = this.blockType; if (!type) return 'Node'; return type .replace(/Block$/, '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .trim(); } outputsTitle(): string { return this.isConditionalNode() ? 'On Condition' : 'Outputs'; } outputPillClass(outputKey: string): string | null { if (!this.isConditionalNode()) return null; const normalized = outputKey.trim().toLowerCase(); if (normalized === 'true') return 'llm-pill-output-true'; if (normalized === 'false') return 'llm-pill-output-false'; return null; } hasExecutionDependencyPorts(): boolean { return !!this.dependencyInput || !!this.dependantOutput; } inputDisplayLabel(inputKey: string): string { return this.portDisplayLabel('input', inputKey); } outputDisplayLabel(outputKey: string): string { return this.portDisplayLabel('output', outputKey); } canTogglePortMultiplicity(kind: 'input' | 'output', key: string): boolean { const port = this.resolvePorts(kind).find((candidate) => candidate.name === key); return !!port && this.portSelectableKinds(port).length > 1; } portCurrentKindLabel(kind: 'input' | 'output', key: string): string { const port = this.resolvePorts(kind).find((candidate) => candidate.name === key); return port ? flowValueKindLabel(currentFlowPortValueKind(port)) : 'ANY'; } portCurrentKindValue(kind: 'input' | 'output', key: string): string { const port = this.resolvePorts(kind).find((candidate) => candidate.name === key); if (!port) return ''; const current = currentFlowPortValueKind(port); const exact = this.portSelectableKinds(port).find((kindOption) => kindOption.type === current.type && kindOption.multiple === current.multiple ); const selected = exact ?? this.portSelectableKinds(port)[0]; return selected ? this.flowValueKindValue(selected) : ''; } portSelectableKindOptions(kind: 'input' | 'output', key: string): Array<{ value: string; label: string }> { const port = this.resolvePorts(kind).find((candidate) => candidate.name === key); if (!port) return []; return this.portSelectableKinds(port).map((kindOption) => ({ value: this.flowValueKindValue(kindOption), label: flowValueKindLabel(kindOption) })); } onPortKindChange(kind: 'input' | 'output', key: string, nextValue: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; const ports = this.resolvePorts(kind); const index = ports.findIndex((candidate) => candidate.name === key); if (index < 0) return; const port = ports[index]; const nextKind = this.portSelectableKinds(port).find( (kindOption) => this.flowValueKindValue(kindOption) === nextValue ); if (!nextKind) return; ports[index] = { ...port, type: nextKind.type, multiple: nextKind.multiple }; this.syncPortSocketType(kind, key, nextKind.type); this.markFlowDirty(); this.refreshView(); } hasMainContent(): boolean { return this.richContentFields.length > 0; } formatDynamicInputToken(token: string): string { const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/); return match ? match[1] : token; } isCreatingOnServer(): boolean { return this.data?.data?.['__isCreatingOnServer'] === true; } hasUpdateBlockError(): boolean { return typeof this.data?.data?.['__updateBlockError'] === 'string' && this.data.data['__updateBlockError'].length > 0; } updateBlockErrorMessage(): string | null { const error = this.data?.data?.['__updateBlockError']; return typeof error === 'string' && error.length > 0 ? error : null; } private get blockConfiguration(): Record | null { return this.data?.data?.specificConfiguration ?? null; } private get blockType(): string | null { const typeName = this.data?.data?.typeName; return typeof typeName === 'string' && typeName.length > 0 ? typeName : null; } private get blockId(): string | null { const blockId = this.data?.data?.id; return typeof blockId === 'string' && blockId.length > 0 ? blockId : null; } private ensureBlockConfiguration(): Record { if (!this.data?.data) { this.data.data = {}; } if (!this.data.data.specificConfiguration) { this.data.data.specificConfiguration = {}; } return this.data.data.specificConfiguration; } private markFlowDirty() { const flow = this.editorState.currentFlow(); if (!flow) return; this.editorState.updateData(this.cloneCurrentFlowWithNodeChanges(flow.data)); } private async loadSchemaContext() { const type = this.blockType; if (!type) { this.schemaReady = true; return; } try { const blockType = this.blocksService.peekBlockType(type) ?? await this.blocksService.getBlockType(type); this.blockDescriptor = blockType ?? null; 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); this.pruneInactiveConfiguration(this.ensureBlockConfiguration()); await this.refreshConditionalRequirements(); this.refreshParameterFields(); this.refreshValidationState(); this.maybeCreateBlockOnServer(); } finally { this.schemaReady = true; } } private async openTextareaEditor( path: string, label: string, initialValue: string, ui: { placeholder?: string; tip?: string; rows?: number } ) { const result = await this.settingsDialog.open({ title: `${label} for "${this.name}"`, fields: [ { key: path, label, type: 'textarea', rows: ui.rows ?? 12, placeholder: ui.placeholder ?? '', tip: ui.tip } ], initial: { [path]: initialValue } }); if (!result) return; const config = this.ensureBlockConfiguration(); const nextValue = String(result[path] ?? ''); const previousValue = this.getByPath(config, path); this.setByPath(config, path, nextValue); if (!this.areValuesEqual(previousValue, nextValue)) { this.resetDependentRetrieverFields(config, path); if (this.isStructuralField(path)) { this.markBlockForServerRecreate(); } } this.pruneInactiveConfiguration(config); this.refreshParameterFields(); this.refreshValidationState(); this.markFlowDirty(); this.maybeCreateBlockOnServer(); } private buildEditableFieldDefinitions(schema: Record | null): EditableFieldDefinition[] { if (!schema) return []; const definitions: EditableFieldDefinition[] = []; const seen = new Set(); const walk = ( node: Record, pathPrefix: string, inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: 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; if (childResolved?.type === 'array') { continue; } const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; const childUi = this.toFieldUiMeta(childResolved, inheritedUi); if (hasChildren) { walk(childResolved as Record, path, { visibleWhen: childUi.visibleWhen, enabledWhen: childUi.enabledWhen, group: childUi.group }); continue; } if (key === 'type' || key === 'name' || key.startsWith('__')) continue; if (seen.has(path)) continue; seen.add(path); definitions.push({ path, label: schemaFieldLabel(path, childResolved), type: this.toFieldType(childResolved?.type), enumOptions: this.toEnumOptions(childResolved), nodeOptionsSource: this.toNodeOptionsSource(childResolved), retrieverBlockType: this.toRetrieverBlockType(childResolved), retrieverKey: this.toRetrieverKey(childResolved), retrieverUrl: this.toRetrieverUrl(childResolved), retrieverStructuredData: childResolved?.['x-retriever-structured-data'] === true, retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix), ui: childUi }); } }; walk(schema, ''); 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[]; enabledWhen: 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: schemaFieldLabel(path, childResolved), itemSchema: this.resolveArrayItemSchema(childResolved, schema), uniqueBy: typeof childResolved?.['x-ui-unique-by'] === 'string' && String(childResolved['x-ui-unique-by']).trim().length > 0 ? String(childResolved['x-ui-unique-by']).trim() : null, ui: { structural: childUi.structural, visibleWhen: childUi.visibleWhen, enabledWhen: childUi.enabledWhen, group: childUi.group } }); continue; } const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; if (hasChildren) { walk(childResolved as Record, path, { visibleWhen: childUi.visibleWhen, enabledWhen: childUi.enabledWhen, group: childUi.group }); } } }; walk(schema, ''); return definitions; } private toFieldUiMeta( schema: Record | null | undefined, inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null } ) { const rawWidget = typeof schema?.['x-ui-widget'] === 'string' ? String(schema['x-ui-widget']).toLowerCase().trim() : ''; const normalizedWidget: 'textarea' | null = rawWidget === 'textarea' || rawWidget === 'text-area' ? 'textarea' : null; const placeholder = typeof schema?.['x-ui-placeholder'] === 'string' ? String(schema['x-ui-placeholder']) : undefined; const tip = schemaFieldDescription(schema) ?? undefined; const rowsRaw = schema?.['x-ui-rows']; const rows = typeof rowsRaw === 'number' && Number.isFinite(rowsRaw) && rowsRaw > 0 ? Math.trunc(rowsRaw) : undefined; const acceptVariableAsPlaceholder = schema?.['x-ui-accept-variable-as-placeholder'] === true; const structural = schema?.['x-ui-structural'] === true; const bindableAsInput = schema?.['x-ui-bindable-as-input'] === true; const inputName = typeof schema?.['x-ui-input-name'] === 'string' ? String(schema['x-ui-input-name']) : null; const inputType = typeof schema?.['x-ui-input-type'] === 'string' ? String(schema['x-ui-input-type']).toUpperCase() : null; const inputMultiple = typeof schema?.['x-ui-input-multiple'] === 'boolean' ? Boolean(schema['x-ui-input-multiple']) : null; const structuralReason = typeof schema?.['x-ui-structural-reason'] === 'string' ? String(schema['x-ui-structural-reason']) : undefined; const visibleWhen = readEffectiveUiVisibleConditionRule(schema); const enabledWhen = readUiConditionRule(schema?.['x-ui-enabled-when']); const label = readUiLabel(schema?.['x-ui-label']) ?? undefined; const isObjectLike = schema?.['type'] === 'object' || !!schema?.['properties']; const group = readUiGroup(schema?.['x-ui-group']) ?? (isObjectLike ? label ?? null : null) ?? inheritedUi?.group ?? null; return { widget: normalizedWidget, acceptVariableAsPlaceholder, structural, bindableAsInput, inputName, inputType, inputMultiple, structuralReason, label, placeholder, tip, rows, visibleWhen: [ ...(inheritedUi?.visibleWhen ?? []), ...(visibleWhen ? [visibleWhen] : []) ], enabledWhen: [ ...(inheritedUi?.enabledWhen ?? []), ...(enabledWhen ? [enabledWhen] : []) ], group }; } private getFieldUiMeta(path: string) { const root = this.blockSchema; if (!root) return this.toFieldUiMeta(null); let current: Record | null = root; let inheritedUi = { visibleWhen: [] as UiConditionRule[], enabledWhen: [] as UiConditionRule[], group: null as string | null }; for (const segment of path.split('.')) { if (!current) return this.toFieldUiMeta(null, inheritedUi); const resolved = resolveSchemaRef(current, root); if (/^\d+$/.test(segment)) { const items = resolved?.items; if (!items || typeof items !== 'object') return this.toFieldUiMeta(null, inheritedUi); current = resolveSchemaRef(items as Record, root); } else { const properties = resolved?.properties as Record | undefined; if (!properties || !properties[segment]) return this.toFieldUiMeta(null, inheritedUi); current = resolveSchemaRef(properties[segment] as Record, root); } const nextUi = this.toFieldUiMeta(current, inheritedUi); inheritedUi = { visibleWhen: nextUi.visibleWhen, enabledWhen: nextUi.enabledWhen, group: nextUi.group }; } return this.toFieldUiMeta(current, inheritedUi); } private isStructuralField(path: string): boolean { return this.getFieldUiMeta(path).structural; } private resolveFieldSchema(path: string): Record | null { return resolveSchemaPath(this.blockSchema, path); } private toFieldType(type: unknown): FieldType { if (type === 'string') return 'string'; if (type === 'number') return 'number'; if (type === 'integer') return 'integer'; if (type === 'boolean') return 'boolean'; return 'unknown'; } private toRetrieverKey(schema: Record | null | undefined): string | null { if (!schema || typeof schema !== 'object') return null; const fromUrl = this.parseRetrieverUrl(schema['x-retriever-url'])?.key; if (fromUrl) return fromUrl; const retrieverName = schema['x-retriever-name']; return typeof retrieverName === 'string' && retrieverName.length > 0 ? retrieverName : null; } private toRetrieverBlockType(schema: Record | null | undefined): string | null { if (!schema || typeof schema !== 'object') return null; return this.parseRetrieverUrl(schema['x-retriever-url'])?.blockType ?? (typeof schema['x-retriever-owner'] === 'string' && String(schema['x-retriever-owner']).trim().length > 0 ? String(schema['x-retriever-owner']).trim() : null); } private toEnumOptions(schema: Record | null | undefined): string[] { const raw = schema?.['enum']; if (!Array.isArray(raw)) return []; return raw.filter((value): value is string => typeof value === 'string'); } private toNodeOptionsSource(schema: Record | null | undefined): NodeOptionsSource | null { if (!schema || typeof schema !== 'object') return null; const raw = schema['x-ui-options-from-node']; if (!raw || typeof raw !== 'object') return null; const collection = (raw as Record)['collection']; const valueField = (raw as Record)['valueField']; const labelField = (raw as Record)['labelField']; if ((collection !== 'inputs' && collection !== 'outputs') || typeof valueField !== 'string' || typeof labelField !== 'string' || valueField.trim().length === 0 || labelField.trim().length === 0) { return null; } return { collection, valueField: valueField.trim(), labelField: labelField.trim() }; } 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; const path = rawUrl.split('?')[0]; const parts = path.split('/').filter(Boolean); const retrieverIndex = parts.findIndex((part) => part === 'retriever' || part === 'secure-retriever'); if (retrieverIndex < 0 || parts.length < retrieverIndex + 3) return null; const blockType = parts[retrieverIndex + 1]; const key = parts[retrieverIndex + 2]; if (!blockType || !key) return null; return { blockType, key }; } private portDisplayLabel(kind: 'input' | 'output', key: string): string { const ports = this.resolvePorts(kind); const port = ports.find((candidate) => candidate.name === key); return port?.name ?? key; } private portValueKinds(port: FlowPort) { return normalizeFlowPortValueKinds(port); } private portSelectableKinds(port: FlowPort): FlowValueKind[] { const expanded = new Map(); for (const kind of this.portValueKinds(port)) { const type = String(kind.type ?? 'ANY').toUpperCase(); if (type === 'ANY') { for (const concreteType of ['TEXT', 'FILE']) { const concreteKind = { type: concreteType, multiple: Boolean(kind.multiple) }; expanded.set(this.flowValueKindValue(concreteKind), concreteKind); } continue; } expanded.set(this.flowValueKindValue(kind), kind); } return Array.from(expanded.values()); } private resolvePorts(kind: 'input' | 'output'): FlowPort[] { const ports = this.data?.data?.[kind === 'input' ? 'inputs' : 'outputs']; return Array.isArray(ports) ? ports as FlowPort[] : []; } private flowValueKindValue(kind: FlowValueKind): string { return `${String(kind.type ?? 'ANY').toUpperCase()}::${kind.multiple ? 'multi' : 'single'}`; } private isBindableFieldUsingInput(definition: EditableFieldDefinition): boolean { if (!definition.ui.bindableAsInput) return false; const value = this.getByPath(this.blockConfiguration ?? {}, definition.path); if (!this.isMissingValue(value)) return false; const actualInputName = definition.ui.inputName; if (!actualInputName) return true; return this.resolvePorts('input').some((port) => port.name === actualInputName); } private fieldDisplayValue(definition: EditableFieldDefinition, value: unknown): string { if (this.isBindableFieldUsingInput(definition)) { const actualPort = this.resolvePorts('input').find((port) => port.name === definition.ui.inputName); const fallbackType = definition.ui.inputType ?? definition.type.toUpperCase(); const fallbackMultiple = Boolean(definition.ui.inputMultiple); const kindLabel = actualPort ? flowValueKindLabel(currentFlowPortValueKind(actualPort)) : flowValueKindLabel({ type: fallbackType, multiple: fallbackMultiple }); const inputName = definition.ui.inputName ?? definition.label; return `Provided by input ${inputName} (${kindLabel})`; } return valueToDisplayString(value); } canSaveLocalEditor(): boolean { if (this.localEditorLoading) return false; if (!this.localEditorBindableAsInput || this.localEditorUseInput) return true; if (this.localEditorType === 'boolean') return true; return this.localEditorValue.trim().length > 0; } private emptyValueForFieldType(type: FieldType): unknown { if (type === 'number' || type === 'integer' || type === 'boolean') return null; return ''; } private syncPortSocketType(kind: 'input' | 'output', key: string, type: string) { const socketHost = this.data?.[kind === 'input' ? 'inputs' : 'outputs']?.[key]; if (!socketHost) return; socketHost.socket = new ClassicPreset.Socket(type); } async onLocalEditorSourceModeChange(nextMode: string) { this.localEditorUseInput = nextMode === 'input'; if (this.localEditorUseInput || !this.localEditorPath) { this.localEditorLoading = false; return; } const definition = this.editableFieldDefinitions.find((field) => field.path === this.localEditorPath); if (!definition?.retrieverKey || this.localEditorOptions.length > 0) return; this.localEditorLoading = true; await this.loadLocalEditorOptions(definition); } private toRetrieverDependsOn(schema: Record | null | undefined, pathPrefix: string): RetrieverDependency[] { if (!schema || typeof schema !== 'object') return []; const raw = Array.isArray(schema['x-retriever-depends-on']) ? (schema['x-retriever-depends-on'] as unknown[]) : []; return raw .filter((dep): dep is string => typeof dep === 'string' && dep.length > 0) .map((dep) => this.toRetrieverDependency(dep, pathPrefix)); } private toRetrieverDependency(dependency: string, pathPrefix: string): RetrieverDependency { const normalized = dependency.trim(); if (normalized.startsWith('$context.')) { const contextKey = normalized.slice('$context.'.length).trim(); return { key: contextKey, path: normalized, source: 'context' }; } return { key: normalized, path: pathPrefix ? `${pathPrefix}.${normalized}` : normalized, source: 'field' }; } private async loadLocalEditorOptions(definition: EditableFieldDefinition) { const blockType = definition.retrieverBlockType ?? this.blockType; if (!blockType || !definition.retrieverKey) { this.localEditorLoading = false; return; } const context = this.buildRetrieverContext( this.blockConfiguration ?? {}, definition.retrieverDependsOn ); try { this.localEditorOptions = await this.fetchRetrieverOptions( blockType, definition.retrieverKey, definition.retrieverUrl, definition.retrieverStructuredData, context ); } catch { this.localEditorOptions = []; } finally { this.localEditorLoading = false; } } private resolveSelectableOptions(definition: EditableFieldDefinition): NodeSettingOption[] { if (definition.nodeOptionsSource) { return this.resolveNodeOptions(definition.nodeOptionsSource); } return definition.enumOptions.map((option) => ({ label: option, value: option })); } private resolveNodeOptions(source: NodeOptionsSource): NodeSettingOption[] { const items = this.resolvePorts(source.collection === 'inputs' ? 'input' : 'output'); return items .map((item) => { const record = item as unknown as Record; const value = record[source.valueField]; const label = record[source.labelField]; if (typeof value !== 'string' || typeof label !== 'string' || value.trim().length === 0 || label.trim().length === 0) { return null; } return { label, value } satisfies NodeSettingOption; }) .filter((option): option is NodeSettingOption => option != null); } private isLongTextValue(value: string): boolean { return String(value ?? '').trim().length > 80; } private refreshParameterFields() { const config = this.blockConfiguration ?? {}; const richContentPaths = new Set(this.richContentPaths()); const grouped = new Map(); const rootFields: EditableFieldView[] = []; this.richContentFields = this.richContentPaths() .filter((path) => this.isPathVisible(path)) .map((path) => { const rawValue = String(this.getByPath(config, path) ?? ''); return { path, label: this.fieldDisplayLabel(path), rawValue, expandable: this.isLongTextValue(rawValue), 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: this.fieldDisplayValue(definition, value), wide: this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea'), expandable: this.isLongTextValue(this.fieldDisplayValue(definition, value)), enabled: this.isPathEnabled(definition.path), type: definition.type, booleanValue: value === true }; }).filter((field) => !richContentPaths.has(field.path)) .filter((field) => this.isPathVisible(field.path)); for (const field of orderedFields) { const groupLabel = this.getFieldUiMeta(field.path).group ?? parentPath(field.path); const groupKey = groupLabel ? `group:${groupLabel}` : null; if (!groupKey || !groupLabel) { rootFields.push(field); continue; } if (!grouped.has(groupKey)) { grouped.set(groupKey, []); } grouped.get(groupKey)!.push(field); } this.parameterFields = rootFields; this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({ key, legend: key.startsWith('group:') ? key.slice('group:'.length) : this.fieldDisplayLabel(key), fields })); this.refreshView(); return; } const fallbackFields = flattenPrimitiveValues(config) .filter((entry) => entry.path !== 'name' && entry.path !== 'type' && !richContentPaths.has(entry.path)) .filter((entry) => this.isPathVisible(entry.path)) .map((entry) => ({ path: entry.path, label: this.fieldDisplayLabel(entry.path), value: valueToDisplayString(entry.value), wide: this.shouldRenderWideField(this.fieldDisplayLabel(entry.path), false), expandable: this.isLongTextValue(valueToDisplayString(entry.value)), enabled: this.isPathEnabled(entry.path), type: (typeof entry.value === 'boolean' ? 'boolean' : 'unknown') as FieldType, booleanValue: entry.value === true })); for (const field of fallbackFields) { const parentKey = parentPath(field.path); if (!parentKey) { rootFields.push(field); continue; } if (!grouped.has(parentKey)) { grouped.set(parentKey, []); } grouped.get(parentKey)!.push(field); } this.parameterFields = rootFields; this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({ key, legend: this.fieldDisplayLabel(key), fields })); this.refreshView(); } async addArrayItem(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; await this.openArrayItemEditor(path, null); } async editArrayItem(path: string, index: number, event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; await this.openArrayItemEditor(path, index); } removeArrayItem(path: string, index: number, event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; 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.pruneInactiveConfiguration(config); 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); const duplicateError = this.validateUniqueArrayItem(definition, items, nextItem, index); if (duplicateError) { window.alert(duplicateError); return; } if (index == null) { items.push(nextItem); } else { items[index] = nextItem; } this.setByPath(config, path, items); if (this.isStructuralField(path)) { this.markBlockForServerRecreate(); } this.pruneInactiveConfiguration(config); this.refreshParameterFields(); this.refreshValidationState(); this.markFlowDirty(); this.maybeCreateBlockOnServer(); } private async buildArrayItemDialog( definition: ArrayFieldDefinition, item: Record, index: number | null ): Promise { 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)) { const propertySchema = resolveSchemaRef(rawPropertySchema as Record, schemaRoot); if (shouldSkipSchemaField(key, propertySchema)) continue; if (this.hasDynamicSchema(propertySchema)) { const dynamicFields = await this.buildDynamicSchemaFields(key, propertySchema, item); fields.push(...dynamicFields.fields); Object.assign(initial, dynamicFields.initial); continue; } const label = schemaFieldLabel(key, propertySchema); 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: schemaFieldDescription(propertySchema) ?? 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, onValuesChange: async (draft) => { const draftItem = this.parseArrayItemDialogResult(definition, draft, item); const nextDialog = await this.buildArrayItemDialog(definition, draftItem, index); if (!nextDialog) return null; return { fields: nextDialog.fields, initial: nextDialog.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)) { const propertySchema = resolveSchemaRef(rawPropertySchema as Record, schemaRoot); if (shouldSkipSchemaField(key, propertySchema)) continue; 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)) { const propertySchema = resolveSchemaRef(rawPropertySchema as Record, schemaRoot); if (shouldSkipSchemaField(key, propertySchema)) continue; 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: schemaFieldLabel(baseKey, propertySchema), 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: schemaFieldLabel(baseKey, propertySchema), type: 'display', readonly: true }], initial: { [`${baseKey}.__hint`]: 'No dynamic schema available' } }; } return this.buildDialogFieldsFromSchema(baseKey, schemaFieldLabel(baseKey, propertySchema), 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)) { const childSchema = resolveSchemaRef(rawChildSchema as Record, schema); if (shouldSkipSchemaField(childKey, childSchema)) continue; const nextPath = `${pathPrefix}.${childKey}`; const nextLabel = `${titlePrefix} ${schemaFieldLabel(childKey, childSchema)}`; 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: schemaFieldDescription(childSchema) ?? 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 fieldDisplayLabel(path: string): string { return schemaFieldLabel(path, this.resolveFieldSchema(path)); } private async loadNodeSettingOptions( propertySchema: Record, item: Record, pathPrefix: string ): Promise { const enumOptions = this.toEnumOptions(propertySchema); if (enumOptions.length) { return enumOptions.map((value) => ({ label: value, value })); } const retrieverKey = this.toRetrieverKey(propertySchema); const retrieverBlockType = this.toRetrieverBlockType(propertySchema); if (!retrieverKey || !retrieverBlockType) return undefined; const retrieverDependsOn = this.toRetrieverDependsOn(propertySchema, pathPrefix); const retrieverContext = this.buildRetrieverContext(item as Record, retrieverDependsOn); try { return await this.fetchRetrieverOptions( retrieverBlockType, retrieverKey, this.toRetrieverUrl(propertySchema), propertySchema['x-retriever-structured-data'] === true, retrieverContext ); } 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 validateUniqueArrayItem( definition: ArrayFieldDefinition, items: unknown[], nextItem: Record, currentIndex: number | null ): string | null { const violation = validateUniqueByConstraint( items, nextItem, definition.uniqueBy, currentIndex, (value) => this.isMissingValue(value), (left, right) => this.areValuesEqual(left, right) ); if (!violation) return null; const uniqueLabel = pathToLabel(violation.path); const fieldLabel = definition.label; return `${fieldLabel} requires a unique ${uniqueLabel}. "${String(violation.value)}" is already used.`; } private valueToEditorString(value: unknown, type: FieldType): string { if (type === 'boolean') { return value === true ? 'true' : 'false'; } if (value == null) return ''; if (typeof value === 'string') return value; return String(value); } private parseEditorValue(value: string, type: FieldType): unknown { const trimmed = value.trim(); if (type === 'boolean') { return trimmed === 'true'; } if (type === 'number' || type === 'integer') { const numeric = Number(trimmed); if (!Number.isFinite(numeric)) return 0; return type === 'integer' ? Math.trunc(numeric) : numeric; } return value; } private setByPath(target: Record, path: string, value: unknown) { const keys = path.split('.').filter(Boolean); if (!keys.length) return; let current: Record = target; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; const next = current[key]; if (next == null || typeof next !== 'object' || Array.isArray(next)) { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; } private deleteByPath(target: Record, path: string) { const keys = path.split('.').filter(Boolean); if (!keys.length) return; let current: Record | undefined = target; const parents: Array<{ owner: Record; key: string }> = []; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; const next = current?.[key]; if (!next || typeof next !== 'object' || Array.isArray(next)) { return; } parents.push({ owner: current!, key }); current = next as Record; } if (!current) return; delete current[keys[keys.length - 1]]; for (let i = parents.length - 1; i >= 0; i--) { const { owner, key } = parents[i]; const value = owner[key]; if (!value || typeof value !== 'object' || Array.isArray(value)) break; if (Object.keys(value).length > 0) break; delete owner[key]; } } private pruneInactiveConfiguration(config: Record) { const candidatePaths = [ ...this.editableFieldDefinitions.map((field) => field.path), ...this.arrayFieldDefinitions.map((field) => field.path) ].sort((left, right) => right.length - left.length); for (const path of candidatePaths) { if (this.isPathVisible(path) && this.isPathEnabled(path)) continue; this.deleteByPath(config, path); } } private resetDependentRetrieverFields( config: Record, changedPath: string, visited = new Set() ) { const dependentFields = this.editableFieldDefinitions.filter((definition) => definition.retrieverDependsOn.some((dep) => dep.path === changedPath) ); for (const field of dependentFields) { if (visited.has(field.path)) continue; visited.add(field.path); this.setByPath(config, field.path, ''); this.resetDependentRetrieverFields(config, field.path, visited); } } private areValuesEqual(left: unknown, right: unknown): boolean { if (left === right) return true; return JSON.stringify(left) === JSON.stringify(right); } private richContentPaths(): string[] { const textareaPaths = this.editableFieldDefinitions .filter((field) => field.ui.widget === 'textarea') .map((field) => field.path); if (textareaPaths.length) { return textareaPaths; } return this.findMainContentCandidatePaths(this.blockSchema); } private toRichContentParts(path: string): { text: string; isDynamicInput: boolean }[] { const content = toStringOrNull(this.getByPath(this.blockConfiguration ?? {}, path)); if (!content) return []; const ui = this.getFieldUiMeta(path); if (ui.widget === 'textarea' && ui.acceptVariableAsPlaceholder) { return splitTemplatedTextParts(content); } return [{ text: content, isDynamicInput: false }]; } private findMainContentCandidatePaths(schema: Record | null): string[] { if (!schema) return []; const paths: string[] = []; 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; 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'; if (!isTextarea || seen.has(path)) continue; seen.add(path); paths.push(path); } }; walk(schema, ''); return paths; } private getByPath(source: Record, path: string): unknown { return getValueByPath(source, path); } private isMissingValue(value: unknown): boolean { if (value == null) return true; if (typeof value === 'string') return value.trim().length === 0; if (Array.isArray(value)) return value.length === 0 || value.every((item) => this.isMissingValue(item)); if (typeof value === 'object') { const entries = Object.values(value as Record); return entries.length === 0 || entries.every((item) => this.isMissingValue(item)); } return false; } private shouldRenderWideField(label: string, isTextarea: boolean) { return isTextarea || label.trim().length >= 18; } private refreshValidationState() { const config = this.blockConfiguration ?? {}; const requiredFields = [ ...this.schemaRequirements.required, ...this.schemaRequirements.conditional.filter((field) => this.conditionalRequiredByPath.get(field.path)) ].filter((field) => field.path !== 'name') .filter((field) => this.isFieldConditionSatisfied(field.path)); const missingFields = requiredFields .filter((field) => { if (!this.isPathEnabled(field.path)) return false; const definition = this.editableFieldDefinitions.find((candidate) => candidate.path === field.path); if (definition && this.isBindableFieldUsingInput(definition)) return false; return this.isMissingValue(this.getByPath(config, field.path)); }); const missingLabels = new Set(missingFields.map((field) => field.label)); for (const objectField of this.schemaRequirements.requiredObjects) { if (objectField.path === 'name') continue; if (!this.isFieldConditionSatisfied(objectField.path)) continue; if (!this.isMissingValue(this.getByPath(config, objectField.path))) continue; missingLabels.add(objectField.label); } this.missingRequiredParams = Array.from(missingLabels); if (!this.refreshingConditionalRequirements) { void this.refreshConditionalRequirements(); } this.refreshView(); } private async refreshConditionalRequirements() { if (!this.schemaRequirements.conditional.length) return; const type = this.blockType; if (!type) return; this.refreshingConditionalRequirements = true; let changed = false; for (const field of this.schemaRequirements.conditional) { const required = await this.fetchConditionalRequirement(type, field); if (this.conditionalRequiredByPath.get(field.path) !== required) { this.conditionalRequiredByPath.set(field.path, required); changed = true; } } this.refreshingConditionalRequirements = false; if (changed) { this.refreshValidationState(); this.maybeCreateBlockOnServer(); } } private async fetchConditionalRequirement(blockType: string, field: ConditionalRequiredField) { if (field.requiredWhen) { return evaluateUiConditionRule(field.requiredWhen, this.blockConfiguration ?? {}, (path) => this.resolveFieldSchema(path)); } if (!field.retrieverKey) return false; const retrieverBlockType = field.retrieverBlockType ?? blockType; const context = this.buildRetrieverContext(this.blockConfiguration ?? {}, field.dependsOn); try { return await firstValueFrom( this.fieldRetriever.isFieldRequired( retrieverBlockType, field.retrieverKey, field.dependsOn.length ? context : undefined, field.retrieverUrl ) ); } catch { return false; } } private refreshView() { queueMicrotask(() => { try { this.cdr.detectChanges(); requestAnimationFrame(() => { try { this.rendered(); } catch { // Node may have been removed while async refresh was running. } }); } catch { // Node may have been removed while async validation was running. } }); } private cloneFlowData(value: T): T { if (typeof globalThis.structuredClone === 'function') { try { return globalThis.structuredClone(value); } catch { // Node runtime objects may include non-cloneable values. } } return JSON.parse(JSON.stringify(value)) as T; } private cloneCurrentFlowWithNodeChanges(flowData: FlowData): FlowData { const nextFlowData = this.cloneFlowData(flowData); const nodeData = this.data?.data as Record | undefined; const blockId = typeof nodeData?.['id'] === 'string' ? nodeData['id'] : null; if (!blockId) return nextFlowData; const block = nextFlowData.blocks.find((item) => item.id === blockId); if (!block) return nextFlowData; const configuredName = toStringOrNull(this.blockConfiguration?.['name']); block.name = configuredName || this.name || block.name; block.typeName = this.blockType ?? block.typeName; block.specificConfiguration = this.cloneFlowData(this.blockConfiguration ?? {}); return nextFlowData; } private maybeCreateBlockOnServer() { const nodeData = this.data?.data as Record | undefined; if (!nodeData?.['__needsServerCreate']) return; if (nodeData['__isCreatingOnServer'] === true) return; const blockType = this.blockType; if (!blockType) return; nodeData['__isCreatingOnServer'] = true; nodeData['__updateBlockError'] = null; const configuration = this.ensureBlockConfiguration(); this.blocksService.updateBlock(String(nodeData['id'] ?? ''), { ...configuration, typeName: blockType }, { flowId: this.editorFlowId(), replacesBlockId: String(nodeData['id'] ?? '') }).pipe( take(1) ).subscribe({ next: (createdBlock) => { const current = (this.data?.data ?? {}) as Record; const replaceNode = current['replaceWithCreatedNode']; if (typeof replaceNode === 'function') { void replaceNode({ ...createdBlock, position: (current['position'] as { x: number; y: number } | undefined) ?? createdBlock.position }); return; } this.data.data = { ...current, ...createdBlock, position: (current['position'] as { x: number; y: number } | undefined) ?? createdBlock.position, __needsServerCreate: false, __isCreatingOnServer: false, __createdOnServer: true, __updateBlockError: null }; this.name = toStringOrNull(this.ensureBlockConfiguration()['name']) || createdBlock.name || this.name; this.refreshParameterFields(); this.refreshValidationState(); this.markFlowDirty(); }, error: (err) => { nodeData['__isCreatingOnServer'] = false; nodeData['__updateBlockError'] = err instanceof Error ? err.message : 'Block update failed'; this.refreshView(); console.error('Create block failed', err); } }); } private markBlockForServerRecreate() { const nodeData = this.data?.data as Record | undefined; if (!nodeData) return; nodeData['__needsServerCreate'] = true; nodeData['__createdOnServer'] = false; nodeData['__updateBlockError'] = null; } private isPathVisible(path: string): boolean { return this.isFieldConditionSatisfied(path); } private editorFlowId(): string | null { const flowId = this.editorState.currentFlow()?.id; return typeof flowId === 'string' && flowId.trim().length > 0 ? flowId.trim() : null; } private buildRetrieverContext( source: Record, dependencies: RetrieverDependency[] ) { const context = this.withEditorFlowContext({}); for (const dep of dependencies) { const value = this.resolveRetrieverDependencyValue(source, dep); context[dep.key] = value == null ? '' : String(value); } return context; } private resolveRetrieverDependencyValue(source: Record, dependency: RetrieverDependency): unknown { if (dependency.source === 'context') { return this.resolveEditorContextDependencyValue(dependency.key); } return getValueByPath(source as Record, dependency.path); } private resolveEditorContextDependencyValue(contextKey: string): unknown { if (contextKey === 'flowId') { return this.editorFlowId(); } if (contextKey === 'blockId') { const blockId = this.blockId; return typeof blockId === 'string' && blockId.trim().length > 0 ? blockId.trim() : null; } if (contextKey === 'inputNames') { return this.resolvePorts('input').map((port) => port.name).join(','); } if (contextKey === 'outputNames') { return this.resolvePorts('output').map((port) => port.name).join(','); } return null; } private async fetchRetrieverOptions( blockType: string, retrieverKey: string, retrieverUrl: string | null, structuredData: boolean, context?: Record ): Promise { if (structuredData) { const items = await firstValueFrom( this.fieldRetriever.retrieveItems( blockType, retrieverKey, context, retrieverUrl ) ); return this.toStructuredRetrieverOptions(items ?? []); } const values = await firstValueFrom( this.fieldRetriever.retrieveValues( blockType, retrieverKey, context, retrieverUrl ) ); return (values ?? []).map((value) => ({ label: value, value })); } private toStructuredRetrieverOptions(items: Array<{ descriptor?: { label?: string; description?: string }; data?: unknown }>) { return items .map((item, index) => { const value = item?.data == null ? '' : String(item.data); const label = item.descriptor?.label?.trim() || value || `Item ${index + 1}`; const description = item.descriptor?.description?.trim(); return { label: description ? `${label} - ${description}` : label, value }; }) .filter((option) => option.value.trim().length > 0); } private withEditorFlowContext(context?: Record) { const nextContext = { ...(context ?? {}) }; const flowId = this.editorFlowId(); if (flowId) { nextContext['flowId'] = flowId; } return nextContext; } private isPathEnabled(path: string, visited = new Set()): boolean { if (visited.has(path)) return true; visited.add(path); const ui = this.getFieldUiMeta(path); return ui.enabledWhen.every((rule) => { if (!rule) return true; return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath)); }); } private isFieldVisible(field: EditableFieldDefinition): boolean { return this.isPathVisible(field.path); } toggleBooleanParameter(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (this.isReadonly) return; const definition = this.editableFieldDefinitions.find((field) => field.path === path); if (!definition || definition.type !== 'boolean') return; if (!this.isFieldVisible(definition) || !this.isPathEnabled(definition.path)) return; const config = this.ensureBlockConfiguration(); const previousValue = this.getByPath(config, path); const nextValue = previousValue !== true; this.setByPath(config, path, nextValue); this.resetDependentRetrieverFields(config, path); if (!this.areValuesEqual(previousValue, nextValue) && this.isStructuralField(path)) { this.markBlockForServerRecreate(); } this.pruneInactiveConfiguration(config); this.refreshParameterFields(); this.refreshValidationState(); this.markFlowDirty(); this.maybeCreateBlockOnServer(); } private isFieldConditionSatisfied(path: string, visited = new Set()): boolean { if (visited.has(path)) return true; visited.add(path); const ui = this.getFieldUiMeta(path); return ui.visibleWhen.every((rule) => { if (!rule) return true; return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath)); }); } }