import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ClassicPreset } from 'rete'; import { ReteModule } from 'rete-angular-plugin/21'; import { 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, readUiGroup, resolveSchemaRef, splitTemplatedTextParts, toStringOrNull, valueToDisplayString } from '../node-utility'; type FieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown'; type RetrieverDependency = { key: string; path: string; }; type EditableFieldDefinition = { path: string; label: string; type: FieldType; retrieverBlockType: string | null; retrieverKey: string | null; retrieverDependsOn: RetrieverDependency[]; ui: { widget: 'textarea' | null; acceptVariableAsPlaceholder: boolean; structural: boolean; structuralReason?: string; placeholder?: string; tip?: string; rows?: number; visibleWhen: UiConditionRule[]; group: string | null; }; }; type EditableFieldView = { path: string; label: string; value: string; }; type EditableFieldGroupView = { key: string; legend: string; fields: EditableFieldView[]; }; type RichContentView = { path: string; label: string; parts: { text: string; isDynamicInput: boolean }[]; }; @Component({ selector: 'app-generic-node', imports: [CommonModule, FormsModule, ReteModule], 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; } outputs: { key: string; socket: ClassicPreset.Socket }[] = []; inputs: { key: string; socket: ClassicPreset.Socket }[] = []; parameterFields: EditableFieldView[] = []; parameterFieldGroups: EditableFieldGroupView[] = []; richContentFields: RichContentView[] = []; name = 'noName'; localEditorOpen = false; localEditorPath: string | null = null; localEditorLabel = ''; localEditorValue = ''; localEditorOptions: string[] = []; localEditorLoading = false; localEditorHasRetriever = false; localEditorType: FieldType = 'string'; localEditorMaxLength: number | null = null; missingRequiredParams: string[] = []; private blockSchema: Record | null = null; private editableFieldDefinitions: EditableFieldDefinition[] = []; private schemaRequirements: SchemaRequirements = { required: [], conditional: [] }; private conditionalRequiredByPath = new Map(); private refreshingConditionalRequirements = false; ngOnInit() { this.outputs = []; this.inputs = []; this.parameterFields = []; this.parameterFieldGroups = []; this.richContentFields = []; Object.entries(this.data.outputs).forEach(([key, output]) => { this.outputs.push({ key, socket: (output as any).socket }); }); Object.entries(this.data.inputs).forEach(([key, input]) => { this.inputs.push({ key, socket: (input as any).socket }); }); const config = this.ensureBlockConfiguration(); this.name = toStringOrNull(config['name']) || this.name; this.refreshValidationState(); this.refreshParameterFields(); void this.loadSchemaContext(); } ngAfterViewInit() { this.rendered(); } async openNameEditor(event?: Event) { event?.preventDefault(); event?.stopPropagation(); 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; } async openParameterEditor(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); const definition = this.editableFieldDefinitions.find((field) => field.path === path); if (!definition) return; if (!this.isFieldVisible(definition)) return; if (definition.ui.widget === 'textarea') { const currentValue = this.valueToEditorString( this.getByPath(this.blockConfiguration ?? {}, definition.path), definition.type ); await this.openTextareaEditor(definition.path, definition.label, currentValue, definition.ui); return; } this.localEditorPath = definition.path; this.localEditorLabel = definition.label; this.localEditorType = definition.type; this.localEditorMaxLength = null; this.localEditorValue = this.valueToEditorString(this.getByPath(this.blockConfiguration ?? {}, definition.path), definition.type); this.localEditorOptions = []; this.localEditorLoading = !!definition.retrieverKey; this.localEditorHasRetriever = !!definition.retrieverKey; this.localEditorOpen = true; if (definition.retrieverKey) { const missingDependencies = definition.retrieverDependsOn .filter((dep) => { const value = this.getByPath(this.blockConfiguration ?? {}, dep.path); return this.isMissingValue(value); }) .map((dep) => pathToLabel(dep.path)); if (missingDependencies.length > 0) { 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; } saveSimpleParamEditor(event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (!this.localEditorPath) 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.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.refreshParameterFields(); this.refreshValidationState(); this.markFlowDirty(); this.maybeCreateBlockOnServer(); this.closeSimpleParamEditor(); } async openMainContentEditor(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (!this.isPathVisible(path)) return; const contentLabel = pathToLabel(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(); const confirmed = window.confirm('Do you want to delete this node from the flow?'); if (!confirmed) return; const deleteNode = this.data?.data?.deleteNode; if (typeof deleteNode === 'function') { await deleteNode(); } } isHumanNode(): boolean { return this.blockType === 'HumanInteractionBlock'; } isConditionalNode(): boolean { return this.blockType === 'ConditionalBlock'; } nodeTitle(): string { const type = this.blockType; if (!type) return 'Node'; if (type === 'HumanInteractionBlock') return 'Human Task'; 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; } 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 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.cloneFlowData(flow.data)); } private async loadSchemaContext() { const type = this.blockType; if (!type) return; const blockType = await this.blocksService.getBlockType(type); this.blockSchema = (blockType?.schema ?? null) as Record | null; this.schemaRequirements = extractSchemaRequirements(this.blockSchema); this.editableFieldDefinitions = this.buildEditableFieldDefinitions(this.blockSchema); await this.refreshConditionalRequirements(); this.refreshParameterFields(); this.refreshValidationState(); this.maybeCreateBlockOnServer(); } 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.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[]; 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 hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; const childUi = this.toFieldUiMeta(childResolved, inheritedUi); if (hasChildren) { walk(childResolved as Record, path, { visibleWhen: childUi.visibleWhen, group: childUi.group }); continue; } if (key === 'type' || key === 'name' || key.startsWith('__')) continue; if (seen.has(path)) continue; seen.add(path); definitions.push({ path, label: pathToLabel(path), type: this.toFieldType(childResolved?.type), retrieverBlockType: this.toRetrieverBlockType(childResolved), retrieverKey: this.toRetrieverKey(childResolved), retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix), ui: childUi }); } }; walk(schema, ''); return definitions; } private toFieldUiMeta( schema: Record | null | undefined, inheritedUi?: { visibleWhen: 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 = typeof schema?.['x-ui-tip'] === 'string' ? String(schema['x-ui-tip']) : 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 structuralReason = typeof schema?.['x-ui-structural-reason'] === 'string' ? String(schema['x-ui-structural-reason']) : undefined; const visibleWhen = readUiConditionRule(schema?.['x-ui-visible-when']); const group = readUiGroup(schema?.['x-ui-group']) ?? inheritedUi?.group ?? null; return { widget: normalizedWidget, acceptVariableAsPlaceholder, structural, structuralReason, placeholder, tip, rows, visibleWhen: [ ...(inheritedUi?.visibleWhen ?? []), ...(visibleWhen ? [visibleWhen] : []) ], 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[], group: null as string | null }; for (const segment of path.split('.')) { if (!current) return this.toFieldUiMeta(null, inheritedUi); const resolved = resolveSchemaRef(current, root); 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, group: nextUi.group }; } return this.toFieldUiMeta(current, inheritedUi); } private isStructuralField(path: string): boolean { return this.getFieldUiMeta(path).structural; } private resolveFieldSchema(path: string): Record | null { const root = this.blockSchema; if (!root) return null; let current: Record | null = root; for (const segment of path.split('.')) { if (!current) return null; const resolved = resolveSchemaRef(current, root); const properties = resolved?.properties as Record | undefined; if (!properties || !properties[segment]) return null; current = resolveSchemaRef(properties[segment] as Record, root); } return current; } 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 ?? 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'); 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 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) => ({ key: dep, path: pathPrefix ? `${pathPrefix}.${dep}` : dep })); } private async loadLocalEditorOptions(definition: EditableFieldDefinition) { const blockType = definition.retrieverBlockType ?? this.blockType; if (!blockType || !definition.retrieverKey) { this.localEditorLoading = false; return; } const context: Record = {}; for (const dep of definition.retrieverDependsOn) { const value = this.getByPath(this.blockConfiguration ?? {}, dep.path); context[dep.key] = value == null ? '' : String(value); } try { const options = await firstValueFrom( this.fieldRetriever.retrieveValues(blockType, definition.retrieverKey, context) ); this.localEditorOptions = options ?? []; } catch { this.localEditorOptions = []; } finally { this.localEditorLoading = false; } } 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) => ({ path, label: pathToLabel(path), parts: this.toRichContentParts(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) }; }).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) : pathToLabel(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: pathToLabel(entry.path), value: valueToDisplayString(entry.value) })); 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: pathToLabel(key), fields })); this.refreshView(); } 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 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 preferredPaths = this.editableFieldDefinitions .filter((field) => field.ui.widget === 'textarea' && field.ui.acceptVariableAsPlaceholder) .map((field) => field.path); if (preferredPaths.length) { return preferredPaths; } 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'; const acceptsVariable = childResolved?.['x-ui-accept-variable-as-placeholder'] === true; if (!isTextarea || !acceptsVariable || 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; return false; } 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) => this.isMissingValue(this.getByPath(config, field.path))); this.missingRequiredParams = missingFields.map((field) => field.label); 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) { const retrieverBlockType = field.retrieverBlockType ?? blockType; const context: Record = {}; for (const dep of field.dependsOn) { const value = this.getByPath(this.blockConfiguration ?? {}, dep.path); context[dep.key] = typeof value === 'string' ? value : ''; } try { return await firstValueFrom( this.fieldRetriever.isFieldRequired(retrieverBlockType, field.retrieverKey, context) ); } catch { return false; } } private refreshView() { queueMicrotask(() => { try { this.cdr.detectChanges(); } catch { // Node may have been removed while async validation was running. } }); } private cloneFlowData(value: T): T { if (typeof globalThis.structuredClone === 'function') { return globalThis.structuredClone(value); } return JSON.parse(JSON.stringify(value)) as T; } 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 }).pipe( take(1) ).subscribe({ next: (createdBlock) => { const current = (this.data?.data ?? {}) as Record; const replaceNode = current['replaceWithCreatedBlock']; 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 isFieldVisible(field: EditableFieldDefinition): boolean { return this.isPathVisible(field.path); } private isFieldConditionSatisfied(path: string): boolean { const ui = this.getFieldUiMeta(path); return ui.visibleWhen.every((rule) => evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath)) ); } }