humainflow-web/src/app/shared/nodes/container-node/container-node.ts

1250 lines
42 KiB
TypeScript

import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, Input, OnDestroy, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
import { NodeSettingField, NodeSettingOption, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { ContainersService } from '@services/containers/containers';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { EditorStateHolder } from '@stores/flow-editor';
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
import { firstValueFrom } from 'rxjs';
import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, splitTemplatedTextParts, valueToDisplayString } from '../node-utility';
import {
buildSchemaEditableFieldDefinitions,
buildSchemaFieldViewModel,
buildSchemaRetrieverContext,
buildOrderedSchemaDisplay,
pruneInactiveSchemaConfiguration,
resetDependentSchemaRetrieverFields,
schemaValuesEqual,
setSchemaValueByPath,
type SchemaEditableFieldDefinition,
type SchemaDisplayGroup,
type SchemaDisplayItem,
type SchemaDisplaySection,
type SchemaFieldType,
type SchemaNodeOptionsSource,
type SchemaParameterFieldView,
type SchemaRichContentFieldView,
type SchemaRetrieverDependency,
buildTemplatedRichContentParts,
getSchemaPathUiMeta,
isLongTextValue,
isSchemaPathEnabled,
isSchemaPathVisible,
schemaNodeOptionsSource
} from '../schema-driven-fields';
type ContainerFieldType = SchemaFieldType;
type NodeOptionsSource = SchemaNodeOptionsSource;
type ContainerFieldDefinition = SchemaEditableFieldDefinition;
type ContainerFieldView = SchemaParameterFieldView<ContainerFieldType>;
type RichContentView = SchemaRichContentFieldView;
type ContainerDisplayItem = SchemaDisplayItem<ContainerFieldView, RichContentView>;
type ContainerFieldGroupView = SchemaDisplayGroup<ContainerDisplayItem>;
type ContainerDisplaySection = SchemaDisplaySection<ContainerDisplayItem>;
type StructuredRetrieverConfig = {
retrieverName: string;
retrieverUrl: string;
validationUrl: string | null;
structuredData: boolean;
requiresAuth: boolean;
};
@Component({
selector: 'app-container-node',
imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule],
templateUrl: './container-node.html',
styleUrl: './container-node.css',
host: {
'data-testid': 'node'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContainerNodeComponent implements OnDestroy {
private static readonly BODY_LOCK_CLASS = 'node-focus-modal-open';
private static readonly BODY_LOCK_COUNT_ATTR = 'data-node-focus-lock-count';
private editorState = inject(EditorStateHolder);
private cdr = inject(ChangeDetectorRef);
private hostElement = inject<ElementRef<HTMLElement>>(ElementRef);
private subflowPreview = inject(SubflowPreviewDialogService);
private fieldRetriever = inject(FieldRetriever);
private containersService = inject(ContainersService);
private settingsDialog = inject(NodeSettingsDialogService);
private containerSchema: Record<string, any> | null = null;
private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] };
private containerFieldDefinitions: ContainerFieldDefinition[] = [];
deleteConfirmOpen = false;
replaceConfirmSelection: string[] | null = null;
importLoading = false;
private importErrorMessage: string | null = null;
parameterFields: ContainerFieldView[] = [];
parameterFieldGroups: ContainerFieldGroupView[] = [];
richContentFields: RichContentView[] = [];
parameterDisplayItems: ContainerDisplayItem[] = [];
parameterDisplaySections: ContainerDisplaySection[] = [];
schemaReady = false;
focusOpen = false;
private schemaLoading = false;
private focusPlaceholder: Comment | null = null;
private focusOriginalParent: Node | null = null;
private focusOriginalNextSibling: Node | null = null;
private pageScrollLocked = false;
nameEditorOpen = false;
draftName = '';
get isSchemaLoading() {
return this.schemaLoading;
}
@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.container-node--readonly') get readonlyClass() {
return this.isReadonly;
}
ngOnInit() {
this.restorePersistedFocusState();
void this.loadSchemaContext();
}
@HostListener('click')
onNodeClick() {
if (!this.shouldRetrySchemaLoad()) return;
void this.loadSchemaContext();
}
@HostListener('document:keydown.escape')
onEscape() {
if (this.focusOpen) {
this.setFocusOpen(false);
}
}
ngOnDestroy() {
this.releasePageScrollLock();
this.restoreHostFromModalLayer();
}
ngAfterViewInit() {
this.rendered();
}
get isReadonly() {
return this.data?.data?.['__readonly'] === true;
}
toggleFocus(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.setFocusOpen(!this.focusOpen);
}
closeFocus(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.setFocusOpen(false);
}
private setFocusOpen(value: boolean) {
if (this.focusOpen === value) return;
this.focusOpen = value;
this.syncPersistedFocusState();
if (value) {
this.attachHostToModalLayer();
this.applyPageScrollLock();
} else {
this.releasePageScrollLock();
this.restoreHostFromModalLayer();
}
this.cdr.markForCheck();
}
private restorePersistedFocusState() {
const nodeData = this.data?.data as Record<string, unknown> | undefined;
if (nodeData?.['__focusOpen'] !== true) return;
this.setFocusOpen(true);
}
private syncPersistedFocusState() {
const nodeData = this.data?.data as Record<string, unknown> | undefined;
if (!nodeData) return;
nodeData['__focusOpen'] = this.focusOpen;
}
private applyPageScrollLock() {
if (this.pageScrollLocked) return;
const body = document.body;
const currentCount = Number(body.getAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
const nextCount = Number.isFinite(currentCount) ? currentCount + 1 : 1;
body.setAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
body.classList.add(ContainerNodeComponent.BODY_LOCK_CLASS);
this.pageScrollLocked = true;
}
private releasePageScrollLock() {
if (!this.pageScrollLocked) return;
const body = document.body;
const currentCount = Number(body.getAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
const nextCount = Number.isFinite(currentCount) ? Math.max(0, currentCount - 1) : 0;
if (nextCount === 0) {
body.removeAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR);
body.classList.remove(ContainerNodeComponent.BODY_LOCK_CLASS);
} else {
body.setAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
}
this.pageScrollLocked = false;
}
private attachHostToModalLayer() {
const host = this.hostElement.nativeElement;
const parent = host.parentNode;
if (!parent || host.parentNode === document.body) return;
this.focusOriginalParent = parent;
this.focusOriginalNextSibling = host.nextSibling;
this.focusPlaceholder = document.createComment('container-node-focus-placeholder');
parent.insertBefore(this.focusPlaceholder, host);
document.body.appendChild(host);
}
private restoreHostFromModalLayer() {
const host = this.hostElement.nativeElement;
if (!this.focusOriginalParent) return;
if (this.focusPlaceholder?.parentNode === this.focusOriginalParent) {
this.focusOriginalParent.insertBefore(host, this.focusPlaceholder);
this.focusOriginalParent.removeChild(this.focusPlaceholder);
} else if (this.focusOriginalNextSibling?.parentNode === this.focusOriginalParent) {
this.focusOriginalParent.insertBefore(host, this.focusOriginalNextSibling);
} else {
this.focusOriginalParent.appendChild(host);
}
this.focusPlaceholder = null;
this.focusOriginalParent = null;
this.focusOriginalNextSibling = null;
}
get name() {
return String(this.configuration?.['name'] ?? this.data?.data?.name ?? 'Container');
}
get containerLabel() {
return pathToLabel(this.typeName.replace(/Container$/, ' Container'));
}
nodeIcon(): { type: 'class' | 'img'; value: string } {
const icon = resolveNodeIcon(this.containerSchema, false);
if (icon.type === 'class' && icon.value === 'bi bi-person-check-fill') {
return { type: 'class', value: 'bi bi-box-seam' };
}
return icon.type === 'img' && icon.value === 'llm_node.png'
? { type: 'class', value: 'bi bi-box-seam' }
: icon;
}
get inputs() {
return Object.entries(this.data?.inputs ?? {})
.filter(([key]) => key !== FLOW_DEPENDENCY_PORT_KEY)
.map(([key, input]) => ({
key,
socket: (input as any).socket as ClassicPreset.Socket
}));
}
get outputs() {
return Object.entries(this.data?.outputs ?? {})
.filter(([key]) => key !== FLOW_DEPENDANT_PORT_KEY)
.map(([key, output]) => ({
key,
socket: (output as any).socket as ClassicPreset.Socket
}));
}
get dependencyInput() {
const input = this.data?.inputs?.[FLOW_DEPENDENCY_PORT_KEY];
return input
? { key: FLOW_DEPENDENCY_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket }
: null;
}
get dependantOutput() {
const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY];
return output
? { key: FLOW_DEPENDANT_PORT_KEY, socket: (output as any).socket as ClassicPreset.Socket }
: null;
}
get hasExecutionDependencyPorts() {
return !!this.dependencyInput || !!this.dependantOutput;
}
get selectedCount() {
return this.editorState.selectedBlockIds().filter((id) => id !== this.blockId).length;
}
get subFlow(): FlowData | null {
const value = this.configuration?.['subFlow'];
if (!value || typeof value !== 'object') return null;
const candidate = value as Record<string, unknown>;
const blocks = this.normalizeSubFlowBlocks(candidate['blocks']);
const containers = this.normalizeSubFlowContainers(candidate['containers']);
const connections = Array.isArray(candidate['connections'])
? candidate['connections'].filter((item): item is FlowData['connections'][number] => !!item && typeof item === 'object')
: [];
const dependencies = Array.isArray(candidate['dependencies'])
? candidate['dependencies'].filter((item): item is FlowData['dependencies'][number] => !!item && typeof item === 'object')
: [];
if (!blocks.length && !containers.length && !connections.length && !dependencies.length) {
return null;
}
return {
blocks,
containers,
connections,
dependencies
};
}
get subFlowBlockCount() {
return (this.subFlow?.blocks?.length ?? 0) + (this.subFlow?.containers?.length ?? 0);
}
get replaceConfirmOpen() {
return Array.isArray(this.replaceConfirmSelection) && this.replaceConfirmSelection.length > 0;
}
get assignmentErrorMessage() {
const value = this.data?.data?.['__containerAssignmentError'];
if (typeof value === 'string' && value.length > 0) return value;
return this.importErrorMessage;
}
get isAssigning() {
return this.data?.data?.['__containerAssigning'] === true;
}
get missingRequiredParams() {
const config = this.configuration ?? {};
const requiredFields = [
...this.schemaRequirements.required,
...this.schemaRequirements.conditional.filter((field) =>
field.requiredWhen
? evaluateUiConditionRule(field.requiredWhen, config, (path) => this.resolveFieldSchema(path))
: false
)
].filter((field) => field.path !== 'type');
return requiredFields
.filter((field, index, fields) => fields.findIndex((candidate) => candidate.path === field.path) === index)
.filter((field) => field.path !== 'name')
.filter((field) => field.path !== 'subFlow')
.filter((field) => !field.path.startsWith('subFlow.'))
.filter((field) => this.isFieldEnabled(field.path, config))
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
.map((field) => field.label)
.concat(
this.schemaRequirements.requiredObjects
.filter((field) => field.path !== 'name')
.filter((field) => field.path !== 'subFlow')
.filter((field) => !field.path.startsWith('subFlow.'))
.filter((field) => this.isFieldEnabled(field.path, config))
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
.map((field) => field.label)
)
.concat(this.subFlow ? [] : ['Subflow'])
.filter((field, index, fields) => fields.indexOf(field) === index);
}
hasParameterFields() {
return this.parameterDisplaySections.length > 0;
}
hasMainContent() {
return this.richContentFields.length > 0;
}
formatDynamicInputToken(token: string): string {
const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/);
return match ? match[1] : token;
}
inputDisplayLabel(inputKey: string) {
return this.resolvePortName('input', inputKey);
}
outputDisplayLabel(outputKey: string) {
return this.resolvePortName('output', outputKey);
}
inputKindLabel(inputKey: string) {
const port = this.resolvePortDefinition('input', inputKey);
return port ? flowValueKindLabel(currentFlowPortValueKind(port)) : 'ANY';
}
outputKindLabel(outputKey: string) {
const port = this.resolvePortDefinition('output', outputKey);
return port ? flowValueKindLabel(currentFlowPortValueKind(port)) : 'ANY';
}
inputDisplayLabelParts(inputKey: string) {
return this.toPortLabelParts(this.inputDisplayLabel(inputKey));
}
outputDisplayLabelParts(outputKey: string) {
return this.toPortLabelParts(this.outputDisplayLabel(outputKey));
}
async importSubflow(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly || this.importLoading || this.replaceConfirmOpen) return;
this.importLoading = true;
this.importErrorMessage = null;
try {
const retriever = await this.resolveStructuredRetrieverConfig();
if (!retriever || !retriever.structuredData) {
this.importErrorMessage = 'Subflow import is not available for this container.';
return;
}
const items = await firstValueFrom(
this.fieldRetriever.retrieveItems<FlowData>(
retriever.retrieverName || this.typeName,
'subFlow',
{
context: 'CONTAINER',
validOnly: 'true',
includeValidation: 'false'
},
retriever.retrieverUrl
)
);
if (!items.length) {
this.importErrorMessage = 'No importable flows were returned by the retriever.';
return;
}
const result = await this.settingsDialog.open({
title: 'Import flow into container',
fields: [
{
key: 'selectedFlow',
label: 'Available flows',
type: 'select',
required: true,
options: items.map((item, index) => ({
value: String(index),
label: item.descriptor.description
? `${item.descriptor.label} - ${item.descriptor.description}`
: item.descriptor.label
}))
}
],
initial: {
selectedFlow: '0'
}
});
if (!result) return;
const selectedIndex = Number(result['selectedFlow'] ?? -1);
const selectedItem = Number.isInteger(selectedIndex) ? items[selectedIndex] : undefined;
if (!selectedItem?.data) {
this.importErrorMessage = 'Invalid flow selection.';
return;
}
const assignImportedSubflow = this.data?.data?.assignImportedSubflow;
if (typeof assignImportedSubflow !== 'function') {
this.importErrorMessage = 'Container import is not available in the editor runtime.';
return;
}
await assignImportedSubflow(selectedItem.data, retriever.validationUrl);
this.refreshParameterFields();
} catch {
this.importErrorMessage = 'Failed to load importable flows.';
} finally {
this.importLoading = false;
}
}
openNameEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
this.draftName = this.name;
this.nameEditorOpen = true;
}
cancelNameEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.nameEditorOpen = false;
this.draftName = this.name;
}
onDraftNameChange(value: string) {
this.draftName = value;
}
saveNameEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const nextName = this.draftName.trim().slice(0, 20);
if (!nextName || nextName === this.name) {
this.cancelNameEditor();
return;
}
const nextConfiguration = this.cloneConfiguration();
nextConfiguration['name'] = nextName;
this.data.data = {
...this.data.data,
name: nextName,
specificConfiguration: nextConfiguration
};
this.updateCurrentFlowData(nextConfiguration);
this.refreshParameterFields();
this.refreshView();
this.nameEditorOpen = false;
}
async openParameterEditor(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const definition = this.containerFieldDefinitions.find((field) => field.path === path);
if (!definition || !this.isFieldEnabled(definition.path)) return;
const initialValue = this.getEditorInitialValue(definition);
const field: NodeSettingField = {
key: definition.path,
label: definition.label,
type: this.toDialogFieldType(definition),
required: this.missingRequiredParams.includes(definition.label),
placeholder: definition.ui.placeholder,
tip: definition.ui.tip,
rows: definition.ui.widget === 'textarea' ? definition.ui.rows ?? 6 : undefined,
options: await this.resolveSelectableOptions(definition)
};
const result = await this.settingsDialog.open({
title: `Edit ${definition.label}`,
fields: [field],
initial: {
[definition.path]: initialValue
}
});
if (!result) return;
await this.applyFieldValue(definition, result[definition.path]);
}
async toggleBooleanParameter(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const definition = this.containerFieldDefinitions.find((field) => field.path === path);
if (!definition || definition.type !== 'boolean' || !this.isFieldEnabled(definition.path)) return;
const currentValue = getValueByPath(this.configuration ?? {}, definition.path);
await this.applyFieldValue(definition, currentValue !== true);
}
onDropZoneDragOver(event: DragEvent) {
if (this.isReadonly) return;
if (!this.canAcceptSelectionDrop()) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
onDropZoneDrop(event: DragEvent) {
if (this.isReadonly) return;
event.preventDefault();
event.stopPropagation();
const raw = event.dataTransfer?.getData(CONTAINER_SUBFLOW_DRAG_MIME);
const payload = this.parseDraggedSelection(raw);
if (!payload.length) return;
if (this.subFlowBlockCount > 0) {
this.replaceConfirmSelection = payload;
this.editorState.stopDraggingSelectedBlocks();
return;
}
this.assignSelectionToContainer(payload);
}
onDropZoneDragLeave(_: DragEvent) {
if (this.isReadonly) return;
this.editorState.stopDraggingSelectedBlocks();
}
confirmReplaceSubflow(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const payload = this.replaceConfirmSelection;
this.replaceConfirmSelection = null;
if (!payload?.length) return;
this.assignSelectionToContainer(payload);
}
cancelReplaceSubflow(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.replaceConfirmSelection = null;
}
deleteNode(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
if (!this.deleteConfirmOpen) {
this.deleteConfirmOpen = true;
return;
}
const remove = this.data?.data?.deleteNode;
if (typeof remove === 'function') {
void remove();
}
}
cancelDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.deleteConfirmOpen = false;
}
cloneCurrentNode(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const cloneNode = this.data?.data?.cloneNode;
if (typeof cloneNode === 'function') {
void cloneNode();
}
}
openSubflowPreview(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.subFlow) return;
this.subflowPreview.open(this.subFlow, `${this.name} subflow`, this.name);
}
async openFieldPreview(field: ContainerFieldView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
await this.openReadonlyTextDialog(field.label, field.value);
}
async openMainContentPreview(field: RichContentView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
await this.openReadonlyTextDialog(field.label, field.rawValue);
}
private get configuration(): Record<string, unknown> | null {
const value = this.data?.data?.specificConfiguration;
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
private get blockId(): string | null {
const blockId = this.data?.data?.id;
return typeof blockId === 'string' && blockId.length > 0 ? blockId : null;
}
private get typeName(): string {
return String(this.data?.data?.typeName ?? 'GenericContainer');
}
private canAcceptSelectionDrop() {
return !this.isAssigning && !this.replaceConfirmOpen && this.selectedCount > 0;
}
private async openReadonlyTextDialog(label: string, value: string) {
await this.settingsDialog.open({
title: label,
previewOnly: true,
fields: [
{
key: 'value',
label,
type: 'textarea',
readonly: true,
rows: 18
}
],
initial: {
value
}
});
}
private assignSelectionToContainer(payload: string[]) {
this.importErrorMessage = null;
const assign = this.data?.data?.assignSelectedBlocksToContainer;
if (typeof assign !== 'function' || !payload.length) return;
void assign(payload);
this.editorState.stopDraggingSelectedBlocks();
}
private parseDraggedSelection(raw: string | undefined) {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === 'string' && value.length > 0)
: [];
} catch {
return [];
}
}
private resolvePortName(kind: 'input' | 'output', key: string) {
const port = this.resolvePortDefinition(kind, key);
return typeof port?.name === 'string' && port.name.length > 0 ? port.name : key;
}
private toPortLabelParts(label: string) {
const trimmed = String(label ?? '').trim();
const separatorIndex = trimmed.lastIndexOf('.');
if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
return {
context: null,
name: trimmed
};
}
return {
context: trimmed.slice(0, separatorIndex),
name: trimmed.slice(separatorIndex + 1)
};
}
private resolvePortDefinition(kind: 'input' | 'output', key: string) {
const ports = this.data?.data?.[kind === 'input' ? 'inputs' : 'outputs'];
if (!Array.isArray(ports)) return null;
return ports.find((item: any) => item?.name === key) ?? null;
}
private normalizeSubFlowBlocks(raw: unknown): FlowBlock[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item) => ({
...item,
position: this.normalizePosition(item['position']),
nodeFamily: 'block'
})) as FlowBlock[];
}
private normalizeSubFlowContainers(raw: unknown): FlowContainer[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item) => ({
...item,
position: this.normalizePosition(item['position']),
nodeFamily: 'container'
})) as FlowContainer[];
}
private normalizePosition(raw: unknown): { x: number; y: number } | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const value = raw as Record<string, unknown>;
const x = typeof value['x'] === 'number' ? value['x'] : Number(value['x']);
const y = typeof value['y'] === 'number' ? value['y'] : Number(value['y']);
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { x, y };
}
private async resolveStructuredRetrieverConfig(): Promise<StructuredRetrieverConfig | null> {
const containerType = await this.containersService.getContainerType(this.typeName);
const schema = containerType?.schema;
const properties = schema?.['properties'];
const propertySchema = properties && typeof properties === 'object' && !Array.isArray(properties)
? (properties as Record<string, unknown>)['subFlow']
: null;
if (!propertySchema || typeof propertySchema !== 'object' || Array.isArray(propertySchema)) {
return null;
}
const fieldSchema = propertySchema as Record<string, unknown>;
const retrieverUrl = typeof fieldSchema['x-retriever-url'] === 'string' ? fieldSchema['x-retriever-url'] : null;
const retrieverName = typeof fieldSchema['x-retriever-name'] === 'string' ? fieldSchema['x-retriever-name'] : this.typeName;
if (!retrieverUrl) return null;
return {
retrieverName,
retrieverUrl,
validationUrl: typeof fieldSchema['x-retriever-validation-url'] === 'string'
? fieldSchema['x-retriever-validation-url']
: null,
structuredData: fieldSchema['x-retriever-structured-data'] === true,
requiresAuth: fieldSchema['x-retriever-requires-auth'] === true
};
}
private async loadSchemaContext() {
if (this.schemaLoading) return;
this.schemaLoading = true;
this.schemaReady = false;
try {
const containerType = this.containersService.peekContainerType(this.typeName) ?? await this.containersService.getContainerType(this.typeName);
this.containerSchema = (containerType?.schema ?? null) as Record<string, any> | null;
this.schemaRequirements = extractSchemaRequirements(this.containerSchema);
this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema);
this.refreshParameterFields();
} finally {
this.schemaLoading = false;
this.schemaReady = true;
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while schema was loading.
}
});
}
}
private shouldRetrySchemaLoad(): boolean {
if (this.schemaLoading) return false;
return !this.containerSchema || this.containerFieldDefinitions.length === 0;
}
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<string, unknown>);
return entries.length === 0 || entries.every((item) => this.isMissingValue(item));
}
return false;
}
private refreshParameterFields() {
const config = this.configuration ?? {};
const grouped = buildSchemaFieldViewModel({
definitions: this.containerFieldDefinitions.filter((field) => !this.isContainerTypeField(field.path)),
config,
richContentPaths: this.richContentPaths(),
isPathVisible: (path, nextConfig) => this.isFieldVisible(path, nextConfig),
isPathEnabled: (path, nextConfig) => this.isFieldEnabled(path, nextConfig),
getFieldValue: (definition, nextConfig) => valueToDisplayString(getValueByPath(nextConfig, definition.path)),
isFieldWide: (definition) => definition.ui.widget === 'textarea' || definition.label.length >= 18,
getRichContentParts: (path) => this.toRichContentParts(path),
resolveGroupLabel: (path) => getSchemaPathUiMeta(this.containerSchema, path).group ?? parentPath(path)
});
const allFields = [
...grouped.parameterFields,
...grouped.parameterFieldGroups.flatMap((group) => group.fields)
];
const allRichContentFields = [
...grouped.richContentFields,
...grouped.parameterFieldGroups.flatMap((group) => group.richContentFields)
];
this.parameterFields = grouped.parameterFields;
this.richContentFields = grouped.richContentFields;
const ordered = buildOrderedSchemaDisplay({
definitions: this.containerFieldDefinitions.filter((field) => !this.isContainerTypeField(field.path)),
fields: allFields,
richContentFields: allRichContentFields,
resolveGroupLabel: (path) => getSchemaPathUiMeta(this.containerSchema, path).group ?? parentPath(path)
});
this.parameterDisplayItems = ordered.rootItems;
this.parameterFieldGroups = ordered.groups;
this.parameterDisplaySections = ordered.sections;
}
private buildContainerFieldDefinitions(schema: Record<string, any> | null): ContainerFieldDefinition[] {
return buildSchemaEditableFieldDefinitions(schema, {
shouldSkip: ({ key, path }) =>
key.startsWith('__') || path === 'name' || path === 'subFlow' || this.isContainerTypeField(path)
});
}
private richContentPaths(): string[] {
return this.containerFieldDefinitions
.filter((field) => !this.isContainerTypeField(field.path))
.filter((field) => field.ui.widget === 'textarea')
.map((field) => field.path);
}
private isContainerTypeField(path: string): boolean {
return [
'type',
'typeName',
'containerType',
'configurationType',
'configurationClass'
].some((key) => path === key || path.endsWith(`.${key}`));
}
private toRichContentParts(path: string): { text: string; isDynamicInput: boolean }[] {
return buildTemplatedRichContentParts(this.configuration ?? {}, path, this.containerSchema, splitTemplatedTextParts);
}
private isFieldVisible(path: string, config = this.configuration ?? {}, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
return isSchemaPathVisible(this.containerSchema, path, config);
}
private isFieldEnabled(path: string, config = this.configuration ?? {}, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
return isSchemaPathEnabled(this.containerSchema, path, config);
}
private toDialogFieldType(definition: ContainerFieldDefinition): NodeSettingField['type'] {
if (definition.type === 'boolean') return 'checkbox';
if (definition.ui.widget === 'textarea') return 'textarea';
if (definition.enumOptions.length || definition.nodeOptionsSource || definition.retrieverKey) return 'select';
return 'text';
}
private async resolveSelectableOptions(definition: ContainerFieldDefinition): Promise<NodeSettingOption[] | undefined> {
if (definition.nodeOptionsSource) {
return this.resolveNodeOptions(definition.nodeOptionsSource);
}
if (definition.enumOptions.length) {
return definition.enumOptions.map((option) => ({ label: option, value: option }));
}
if (!definition.retrieverKey) return undefined;
try {
return await this.fetchRetrieverOptions(definition);
} catch {
return [];
}
}
private resolveNodeOptions(source: NodeOptionsSource): NodeSettingOption[] {
const items = source.collection === 'inputs'
? (Array.isArray(this.data?.data?.inputs) ? this.data.data.inputs : [])
: (Array.isArray(this.data?.data?.outputs) ? this.data.data.outputs : []);
return items
.map((item: unknown) => {
const record = item && typeof item === 'object' ? item as Record<string, unknown> : null;
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: NodeSettingOption | null): option is NodeSettingOption => option != null);
}
private getEditorInitialValue(definition: ContainerFieldDefinition): string | boolean {
const raw = getValueByPath(this.configuration ?? {}, definition.path);
if (definition.type === 'boolean') return raw === true;
if (raw == null) return '';
return String(raw);
}
private async applyFieldValue(definition: ContainerFieldDefinition, rawValue: string | boolean | undefined) {
const nextValue = this.parseFieldValue(definition, rawValue);
const nextConfiguration = this.cloneConfiguration();
const previousValue = getValueByPath(nextConfiguration, definition.path);
setSchemaValueByPath(nextConfiguration, definition.path, nextValue);
if (!schemaValuesEqual(previousValue, nextValue)) {
resetDependentSchemaRetrieverFields(nextConfiguration, definition.path, this.containerFieldDefinitions);
}
this.pruneInactiveConfiguration(nextConfiguration);
if (definition.ui.structural) {
this.updateCurrentFlowData(nextConfiguration);
await this.recreateContainer(nextConfiguration);
return;
}
this.data.data = {
...this.data.data,
specificConfiguration: nextConfiguration
};
this.refreshParameterFields();
this.updateCurrentFlowData(nextConfiguration);
this.refreshView();
}
private parseFieldValue(definition: ContainerFieldDefinition, rawValue: string | boolean | undefined): unknown {
if (definition.type === 'boolean') return rawValue === true;
const stringValue = typeof rawValue === 'string' ? rawValue : '';
if (definition.type === 'number' || definition.type === 'integer') {
const parsed = Number(stringValue);
if (!Number.isFinite(parsed)) return null;
return definition.type === 'integer' ? Math.trunc(parsed) : parsed;
}
return stringValue;
}
private cloneConfiguration(): Record<string, unknown> {
if (typeof globalThis.structuredClone === 'function') {
try {
return globalThis.structuredClone(this.configuration ?? {});
} catch {
// Ignore non-cloneable runtime metadata.
}
}
return JSON.parse(JSON.stringify(this.configuration ?? {})) as Record<string, unknown>;
}
private async recreateContainer(nextConfiguration: Record<string, unknown>) {
const current = (this.data?.data ?? {}) as Record<string, unknown>;
const containerId = String(current['id'] ?? '');
if (!containerId) return;
this.data.data = {
...current,
__containerAssigning: true,
__containerAssignmentError: null,
specificConfiguration: nextConfiguration
};
this.refreshView();
try {
const createdContainer = await firstValueFrom(
this.containersService.createContainer(containerId, {
...nextConfiguration,
position: current['position'],
typeName: this.typeName
})
);
const replaceNode = current['replaceWithCreatedNode'];
if (typeof replaceNode === 'function') {
await replaceNode({
...createdContainer,
position: (current['position'] as { x: number; y: number } | undefined) ?? createdContainer.position,
__focusOpen: current['__focusOpen'] === true
});
return;
}
this.data.data = {
...current,
...createdContainer,
specificConfiguration: nextConfiguration,
position: (current['position'] as { x: number; y: number } | undefined) ?? createdContainer.position,
__containerAssigning: false,
__containerAssignmentError: null
};
this.refreshParameterFields();
this.updateCurrentFlowData(nextConfiguration);
this.refreshView();
} catch (error) {
this.data.data = {
...current,
__containerAssigning: false,
__containerAssignmentError: error instanceof Error ? error.message : 'Container update failed'
};
this.refreshView();
}
}
private updateCurrentFlowData(nextConfiguration: Record<string, unknown>) {
const flow = this.editorState.currentFlow();
const blockId = this.blockId;
if (!flow || !blockId) return;
const nextFlow: FlowData = {
blocks: flow.data.blocks,
containers: flow.data.containers.map((container) =>
container.id === blockId
? {
...container,
name: String(nextConfiguration['name'] ?? container.name),
specificConfiguration: this.cloneConfigurationValue(nextConfiguration)
}
: container
),
connections: flow.data.connections,
dependencies: flow.data.dependencies ?? [],
globalInputs: flow.data.globalInputs ?? []
};
this.editorState.updateData(nextFlow);
}
private cloneConfigurationValue<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
try {
return globalThis.structuredClone(value);
} catch {
// Ignore non-cloneable runtime metadata.
}
}
return JSON.parse(JSON.stringify(value)) as T;
}
private pruneInactiveConfiguration(config: Record<string, any>) {
pruneInactiveSchemaConfiguration(
config,
this.containerFieldDefinitions.map((field) => field.path),
(path, nextConfig) => this.isFieldVisible(path, nextConfig) && this.isFieldEnabled(path, nextConfig)
);
}
private buildRetrieverContext(source: Record<string, unknown>, dependencies: SchemaRetrieverDependency[]) {
return buildSchemaRetrieverContext(source, dependencies, {
baseContext: this.withEditorFlowContext({}),
resolveContextDependency: (key) => this.resolveEditorContextDependencyValue(key)
});
}
private resolveEditorContextDependencyValue(contextKey: string): unknown {
if (contextKey === 'flowId') {
const flowId = this.editorState.currentFlow()?.id;
return typeof flowId === 'string' && flowId.trim().length > 0 ? flowId.trim() : null;
}
if (contextKey === 'blockId') {
return this.blockId;
}
if (contextKey === 'inputNames') {
return (Array.isArray(this.data?.data?.inputs) ? this.data.data.inputs : [])
.map((port: { name?: string }) => port?.name)
.filter((name: unknown): name is string => typeof name === 'string' && name.trim().length > 0)
.join(',');
}
if (contextKey === 'outputNames') {
return (Array.isArray(this.data?.data?.outputs) ? this.data.data.outputs : [])
.map((port: { name?: string }) => port?.name)
.filter((name: unknown): name is string => typeof name === 'string' && name.trim().length > 0)
.join(',');
}
return null;
}
private withEditorFlowContext(context?: Record<string, string>) {
const nextContext = { ...(context ?? {}) };
const flowId = this.editorState.currentFlow()?.id;
if (typeof flowId === 'string' && flowId.trim().length > 0) {
nextContext['flowId'] = flowId.trim();
}
return nextContext;
}
private async fetchRetrieverOptions(definition: ContainerFieldDefinition): Promise<NodeSettingOption[]> {
const blockType = definition.retrieverBlockType ?? this.typeName;
if (!blockType || !definition.retrieverKey) return [];
const context = this.buildRetrieverContext(
this.configuration ?? {},
definition.retrieverDependsOn
);
if (definition.retrieverStructuredData) {
const items = await firstValueFrom(
this.fieldRetriever.retrieveItems<unknown>(
blockType,
definition.retrieverKey,
context,
definition.retrieverUrl
)
);
return this.toStructuredRetrieverOptions(items ?? []);
}
const values = await firstValueFrom(
this.fieldRetriever.retrieveValues(
blockType,
definition.retrieverKey,
context,
definition.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 refreshView() {
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while async refresh was running.
}
});
}
private resolveFieldSchema(path: string): Record<string, any> | null {
return resolveSchemaPath(this.containerSchema, path);
}
}