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

2561 lines
94 KiB
TypeScript

// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, ElementRef, HostBinding, HostListener, inject, Input, OnDestroy, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { environment } from '@environment';
import { BiasAnnotation, BiasAnnotationsDescriptor, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowData, FlowGlobalInput, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, isProbeExecutable, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotationsComponent } from '../../bias-annotations/bias-annotations';
import { NodeFocusModalController } from '../node-focus-modal-controller';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import {
NodeSettingField,
NodeSettingsValues,
NodeSettingsDialogInput,
NodeSettingOption,
NodeSettingsDialogService,
validateFieldValue
} 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 { LlmProviderService } from '@services/llm-provider/llm-provider';
import { LlmProviderCapability } from '@models/llm-provider';
import { findCapability, isModelParameterField, providerAppliesParameter } from '@services/llm-provider/model-parameter-support';
import { firstValueFrom, take } from 'rxjs';
import { SWIMLANES_ENABLED } from '@shared/feature-flags';
import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import { evaluateUiConditionRule, flattenPrimitiveValues, readEffectiveUiVisibleConditionRule, formatNodeTitle, getOutputPillClass, getOutputsTitle, getValueByPath, isConditionalByPorts, isHumanInteractiveNode, orderedSchemaPropertyEntries, parentGroupLabel, parentPath, pathToLabel, readUiConditionRule, resolveNodeIcon, resolveSchemaPath, resolveSchemaRef, schemaFieldDescription, schemaFieldLabel, shouldSkipSchemaField, splitTemplatedTextParts, toStringOrNull, type UiConditionRule, validateUniqueByConstraint, valueToDisplayString } from '../node-utility';
import {
buildSchemaEditableFieldDefinitions,
buildSchemaObjectDialog,
buildSchemaOptionalGroupFieldDefinitions,
countSetSchemaGroupValues,
parseSchemaObjectDialogResult,
type SchemaObjectDialogHooks,
type SchemaOptionalGroupFieldDefinition,
buildSchemaFieldViewModel,
buildSchemaRetrieverContext,
deleteSchemaValueByPath,
pruneInactiveSchemaConfiguration,
resetDependentSchemaRetrieverFields,
schemaValuesEqual,
setSchemaValueByPath,
type SchemaEditableFieldDefinition,
type SchemaDisplayGroup,
type SchemaDisplayItem,
type SchemaDisplaySection,
type SchemaFieldType,
type SchemaParameterFieldView,
type SchemaRichContentFieldView,
type SchemaFieldUiMeta,
type SchemaNodeOptionsSource,
type SchemaRetrieverDependency,
buildTemplatedRichContentParts,
buildOrderedSchemaDisplay,
collectSchemaLeafFields,
getSchemaPathUiMeta,
groupSchemaFields,
isLongTextValue,
isSchemaPathEnabled,
isSchemaPathVisible,
schemaRetrieverMeta,
schemaEnumOptions,
schemaFieldTypeFromSchema,
schemaNodeOptionsSource,
toSchemaFieldUiMeta,
parsePrimitiveItem,
primitiveItemType,
type PrimitiveItemType
} from '../schema-driven-fields';
import { NodeListFieldComponent } from '../node-list-field/node-list-field';
type FieldType = SchemaFieldType;
type NodeOptionsSource = SchemaNodeOptionsSource;
type EditableFieldDefinition = SchemaEditableFieldDefinition;
type EditableFieldView = SchemaParameterFieldView<FieldType>;
type OptionalGroupFieldDefinition = SchemaOptionalGroupFieldDefinition;
/**
* An item property whose chosen value is only an id, with the thing it names readable elsewhere.
* The schema says so and says where (`x-retriever-definition-url`), so a row offers to show it
* without the editor knowing what kind of binding it is looking at.
*/
type ItemDefinitionSource = {
property: string;
url: string;
};
/** What one row needs to fetch its own definition: the endpoint, and the value to read there. */
type ArrayItemDefinition = {
url: string;
value: string;
};
type ArrayFieldDefinition = {
path: string;
label: string;
description: string | null;
itemSchema: Record<string, any> | null;
uniqueBy: string | null;
definitionSource: ItemDefinitionSource | null;
ui: {
structural: boolean;
visibleWhen: UiConditionRule[];
enabledWhen: UiConditionRule[];
group: string | null;
};
};
type ArrayFieldItemView = {
index: number;
summary: string;
definition: ArrayItemDefinition | null;
};
type ArrayFieldView = {
path: string;
label: string;
items: ArrayFieldItemView[];
};
type RichContentView = SchemaRichContentFieldView;
/** What the card shows for an optional group: its name, and how many of its settings are set. */
type OptionalGroupView = {
path: string;
label: string;
setCount: number;
enabled: boolean;
};
type ParameterDisplayItem =
SchemaDisplayItem<EditableFieldView, RichContentView, ArrayFieldView, OptionalGroupView>;
type EditableFieldGroupView = SchemaDisplayGroup<ParameterDisplayItem>;
type ParameterDisplaySection = SchemaDisplaySection<ParameterDisplayItem>;
type RenderedSocketPort = {
key: string;
socket: ClassicPreset.Socket;
};
@Component({
selector: 'app-generic-node',
imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, BiasAnnotationsComponent, NodeListFieldComponent],
templateUrl: './generic-node.html',
styleUrl: './generic-node.css',
host: {
'data-testid': 'node'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GenericNodeComponent implements OnDestroy {
private settingsDialog = inject(NodeSettingsDialogService);
private http = inject(HttpClient);
private editorState = inject(EditorStateHolder);
private fieldRetriever = inject(FieldRetriever);
private blocksService = inject(BlocksService);
private llmProviders = inject(LlmProviderService);
private cdr = inject(ChangeDetectorRef);
private hostElement = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly focusModal = new NodeFocusModalController(this.hostElement, 'generic-node-focus-placeholder');
@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;
}
parameterFields: EditableFieldView[] = [];
parameterFieldGroups: EditableFieldGroupView[] = [];
richContentFields: RichContentView[] = [];
parameterDisplayItems: ParameterDisplayItem[] = [];
parameterDisplaySections: ParameterDisplaySection[] = [];
arrayFields: ArrayFieldView[] = [];
optionalGroupFields: OptionalGroupView[] = [];
name = 'noName';
localEditorOpen = false;
localEditorPath: string | null = null;
localEditorLabel = '';
localEditorValue = '';
localEditorOptions: NodeSettingOption[] = [];
localEditorLoading = false;
localEditorHasRetriever = false;
localEditorType: FieldType = 'string';
localEditorMaxLength: number | null = null;
localEditorMinLength: number | null = null;
localEditorPattern: string | null = null;
/** The field's own description. The modal never rendered one, so every tip was invisible. */
localEditorTip: string | null = null;
/** Empty means unset on this field, and the modal says so. Every optional field, not just numbers. */
localEditorDefaultsWhenEmpty = false;
localEditorDefaultValue: string | null = null;
localEditorMin: number | null = null;
localEditorMax: number | null = null;
localEditorWidget: 'textarea' | null = null;
localEditorRows: number | null = null;
localEditorBindableAsInput = false;
localEditorUseInput = false;
/**
* The third Source choice: the value is a reference to a declared global input. Kept apart from
* localEditorUseInput rather than folded into a tri-state, so the ten places that ask "is this
* provided by a port" keep reading as they did - a global reference is a configured value, not a
* port, and the two can never both be true.
*/
localEditorFromGlobal = false;
localEditorGlobalName = '';
localEditorGlobalInputs: FlowGlobalInput[] = [];
/** A retriever whose values are an incomplete list: the field takes a typed value instead. */
localEditorFreeText = false;
localEditorBindableInputName: string | null = null;
deleteConfirmOpen = false;
focusOpen = false;
schemaReady = false;
private schemaLoading = false;
get isSchemaLoading() {
return this.schemaLoading;
}
get outputs(): RenderedSocketPort[] {
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 inputs(): RenderedSocketPort[] {
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 dependantOutput(): RenderedSocketPort | null {
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 dependencyInput(): RenderedSocketPort | null {
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;
}
missingRequiredParams: string[] = [];
private blockSchema: Record<string, any> | null = null;
private blockDescriptor: BlockType | null = null;
private editableFieldDefinitions: EditableFieldDefinition[] = [];
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] };
private conditionalRequiredByPath = new Map<string, boolean>();
private refreshingConditionalRequirements = false;
/**
* This template renders inside a node card, which rete.js positions with a
* CSS `transform` for pan/zoom, so a plain fixed-position backdrop would be
* confined to the node's box. A native `<dialog>` shown via `showModal()`
* escapes that via the browser's top layer, same fix as bias-annotations.
*/
private readonly simpleEditorDialog = viewChild<ElementRef<HTMLDialogElement>>('simpleEditorDialog');
constructor() {
effect(() => {
const descriptorSignal = (this.blocksService as BlocksService & {
biasAnnotationsDescriptor?: () => { blockProperty?: string } | null
}).biasAnnotationsDescriptor;
const property = typeof descriptorSignal === 'function' ? descriptorSignal()?.blockProperty : null;
if (this.data?.data && typeof property === 'string' && property.length) {
this.data.data.__biasAnnotationsProperty = property;
}
});
effect(() => {
const dialog = this.simpleEditorDialog()?.nativeElement;
if (dialog && typeof dialog.showModal === 'function' && !dialog.open) {
dialog.showModal();
}
});
}
onSimpleEditorDialogClick(event: MouseEvent) {
if (event.target === this.simpleEditorDialog()?.nativeElement) {
this.closeSimpleParamEditor(event);
}
}
ngOnInit() {
if (this.data?.data) {
this.data.data.__biasAnnotationsProperty = this.biasAnnotationsProperty;
}
this.parameterFields = [];
this.parameterFieldGroups = [];
this.richContentFields = [];
this.parameterDisplayItems = [];
this.parameterDisplaySections = [];
this.arrayFields = [];
this.optionalGroupFields = [];
const config = this.ensureBlockConfiguration();
this.name = toStringOrNull(config['name']) || this.name;
this.refreshValidationState();
this.refreshParameterFields();
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.focusModal.close();
}
ngAfterViewInit() {
this.rendered();
}
get isReadonly() {
return this.data?.data?.['__readonly'] === true;
}
get nodeIdLabel() {
return this.blockId ?? 'unknown-id';
}
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.focusModal.open();
else this.focusModal.close();
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;
}
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.localEditorMinLength = null;
this.localEditorPattern = null;
this.localEditorTip = null;
this.localEditorDefaultsWhenEmpty = false;
this.localEditorDefaultValue = null;
this.localEditorMin = null;
this.localEditorMax = null;
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.localEditorFromGlobal = false;
this.localEditorGlobalName = '';
this.localEditorGlobalInputs = [];
this.localEditorFreeText = 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 = definition.ui.maxLength ?? null;
this.localEditorMinLength = definition.ui.minLength ?? null;
this.localEditorPattern = definition.ui.pattern ?? null;
this.localEditorTip = definition.ui.tip ?? null;
// A boolean has no empty state: false is a value, not an absence.
this.localEditorDefaultsWhenEmpty = definition.type !== 'boolean' && !this.isPathRequired(definition.path);
this.localEditorDefaultValue = this.schemaDeclaredDefault(definition.path);
this.localEditorMin = definition.ui.minimum ?? null;
this.localEditorMax = definition.ui.maximum ?? 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.localEditorGlobalInputs = this.editorState.activeFlowData()?.globalInputs ?? [];
const referencedGlobal = this.globalReferenceName(this.getByPath(this.blockConfiguration ?? {}, definition.path));
this.localEditorFromGlobal = !!referencedGlobal;
this.localEditorGlobalName = referencedGlobal ?? '';
this.localEditorBindableInputName = definition.ui.inputName;
this.localEditorLoading = !!definition.retrieverKey && !this.localEditorUseInput && !this.localEditorFromGlobal;
this.localEditorHasRetriever = this.localEditorOptions.length > 0 || !!definition.retrieverKey || !!definition.nodeOptionsSource;
this.localEditorOpen = true;
if (definition.retrieverKey && !this.localEditorUseInput && !this.localEditorFromGlobal) {
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.localEditorMinLength = null;
this.localEditorPattern = null;
this.localEditorTip = null;
this.localEditorDefaultsWhenEmpty = false;
this.localEditorDefaultValue = null;
this.localEditorWidget = null;
this.localEditorRows = null;
this.localEditorBindableAsInput = false;
this.localEditorUseInput = false;
this.localEditorFromGlobal = false;
this.localEditorGlobalName = '';
this.localEditorGlobalInputs = [];
this.localEditorFreeText = 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);
if (parsedValue === undefined) {
// Cleared, not set to something: the key goes away so the value falls back to whatever
// default applies, and deleteSchemaValueByPath prunes the parent it leaves empty.
deleteSchemaValueByPath(config, this.localEditorPath);
} else {
setSchemaValueByPath(config, this.localEditorPath, parsedValue);
}
if (!schemaValuesEqual(previousValue, parsedValue)) {
resetDependentSchemaRetrieverFields(config, this.localEditorPath, this.editableFieldDefinitions);
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 isHumanInteractiveNode(this.blockDescriptor?.interactionContract);
}
isConditionalNode(): boolean {
return isConditionalByPorts(this.resolvePorts('output'));
}
nodeTitle(): string {
return formatNodeTitle(this.blockType);
}
outputsTitle(): string {
return getOutputsTitle(this.isConditionalNode());
}
outputPillClass(outputKey: string): string | null {
return getOutputPillClass(outputKey, this.isConditionalNode(), this.blockDescriptor?.schema);
}
nodeIcon(): { type: 'class' | 'img'; value: string } {
return resolveNodeIcon(this.blockDescriptor?.schema, this.isHumanNode());
}
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(kind, key, 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 selectableKinds = this.portSelectableKinds(kind, key, port);
const exact = selectableKinds.find((kindOption) =>
kindOption.type === current.type && kindOption.multiple === current.multiple
);
const selected = exact ?? selectableKinds[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(kind, key, 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(kind, key, 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;
}
hasOrderedParameterItems(): boolean {
return this.parameterDisplaySections.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<string, any> | 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;
}
get blockId(): string | null {
const blockId = this.data?.data?.id;
return typeof blockId === 'string' && blockId.length > 0 ? blockId : null;
}
get biasAnnotations(): BiasAnnotation[] {
const nodeData = this.data?.data as Record<string, unknown> | undefined;
const value = nodeData?.[this.biasAnnotationsProperty];
return Array.isArray(value) ? value as BiasAnnotation[] : [];
}
get laneBadge(): { name: string; color: string | null } | null {
if (!SWIMLANES_ENABLED) return null;
const laneId = (this.data?.data as Record<string, unknown> | undefined)?.['laneId'];
if (typeof laneId !== 'string' || !laneId) return null;
const lane = this.editorState.activeFlowData()?.lanes?.find((candidate) => candidate.id === laneId);
return lane ? { name: lane.name, color: lane.color ?? null } : null;
}
get biasAnnotationsAllowed(): boolean {
return this.blockDescriptor?.capabilities?.biasAnnotationsAllowed !== false;
}
get biasAnnotationBadge(): { count: number; hasExecutableProbe: boolean; maxSeverityLabel: string | null } | null {
if (!this.biasAnnotationsAllowed) return null;
const annotations = this.biasAnnotations;
if (!annotations.length) return null;
return {
count: annotations.length,
hasExecutableProbe: annotations.some((annotation) => isProbeExecutable(annotation.biasProbe) || isProbeExecutable(annotation.mitigationProbe)),
maxSeverityLabel: this.mostSevereBiasLabel(annotations)
};
}
get biasBlock(): FlowBlock | null {
const nodeData = this.data?.data as Record<string, unknown> | undefined;
const blockId = this.blockId;
const typeName = this.blockType;
if (!nodeData || !blockId || !typeName) return null;
return {
id: blockId,
name: this.name,
position: nodeData['position'] as { x: number; y: number } | undefined,
inputs: this.resolvePorts('input').map((port) => ({ ...port })),
outputs: this.resolvePorts('output').map((port) => ({ ...port })),
specificConfiguration: this.blockConfiguration ?? {},
typeName,
nodeFamily: 'block',
biasAnnotations: this.biasAnnotations
};
}
private get biasAnnotationsProperty(): string {
const descriptorSignal = (this.blocksService as BlocksService & {
biasAnnotationsDescriptor?: () => { blockProperty?: string } | null
}).biasAnnotationsDescriptor;
const property = typeof descriptorSignal === 'function' ? descriptorSignal()?.blockProperty : null;
return typeof property === 'string' && property.length ? property : 'biasAnnotations';
}
private mostSevereBiasLabel(annotations: BiasAnnotation[]): string | null {
const descriptorSignal = (this.blocksService as BlocksService & {
biasAnnotationsDescriptor?: () => BiasAnnotationsDescriptor | null
}).biasAnnotationsDescriptor;
const severityOptions = descriptorSignal?.()?.options?.['severity'] ?? [];
const rankByValue = new Map(severityOptions.map((option, index) => [option.value, index]));
let mostSevereValue: unknown = undefined;
let mostSevereRank = -1;
for (const annotation of annotations) {
const value = annotation.severity;
const rank = typeof value === 'string' && rankByValue.has(value) ? rankByValue.get(value)! : -1;
if (mostSevereValue === undefined || rank > mostSevereRank) {
mostSevereValue = value;
mostSevereRank = rank;
}
}
if (mostSevereValue == null) return null;
return severityOptions.find((option) => option.value === mostSevereValue)?.label
?? (typeof mostSevereValue === 'string' ? mostSevereValue : null);
}
private ensureBlockConfiguration(): Record<string, any> {
if (!this.data?.data) {
this.data.data = {};
}
if (!this.data.data.specificConfiguration) {
this.data.data.specificConfiguration = {};
}
return this.data.data.specificConfiguration;
}
private markFlowDirty() {
const activeData = this.editorState.activeFlowData();
if (!activeData) return;
this.editorState.updateData(this.cloneCurrentFlowWithNodeChanges(activeData));
}
updateBiasAnnotations(annotations: BiasAnnotation[]) {
if (this.isReadonly || !this.data?.data) return;
this.data.data[this.biasAnnotationsProperty] = this.cloneFlowData(annotations);
this.data.data.__biasAnnotationsProperty = this.biasAnnotationsProperty;
this.markFlowDirty();
this.refreshView();
}
private async loadSchemaContext() {
if (this.schemaLoading) return;
const type = this.blockType;
if (!type) {
this.schemaReady = true;
return;
}
this.schemaLoading = true;
this.schemaReady = false;
try {
const blockType = this.blocksService.peekBlockType(type) ?? await this.blocksService.getBlockType(type);
this.blockDescriptor = blockType ?? null;
this.blockSchema = (blockType?.schema ?? null) as Record<string, any> | null;
this.schemaRequirements = extractSchemaRequirements(this.blockSchema);
this.editableFieldDefinitions = this.buildEditableFieldDefinitions(this.blockSchema);
this.arrayFieldDefinitions = this.buildArrayFieldDefinitions(this.blockSchema);
this.optionalGroupFieldDefinitions = buildSchemaOptionalGroupFieldDefinitions(this.blockSchema);
this.pruneInactiveConfiguration(this.ensureBlockConfiguration());
await this.refreshConditionalRequirements();
this.refreshParameterFields();
this.refreshValidationState();
this.maybeCreateBlockOnServer();
} 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;
if (!this.blockType) return false;
return !this.blockSchema || (!this.editableFieldDefinitions.length && !this.arrayFieldDefinitions.length);
}
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);
setSchemaValueByPath(config, path, nextValue);
if (!schemaValuesEqual(previousValue, nextValue)) {
resetDependentSchemaRetrieverFields(config, path, this.editableFieldDefinitions);
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
}
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
}
private buildEditableFieldDefinitions(schema: Record<string, any> | null): EditableFieldDefinition[] {
return buildSchemaEditableFieldDefinitions(schema, {
shouldSkip: ({ key, schema: childResolved }) =>
childResolved?.['type'] === 'array' || key === 'type' || key === 'name' || key.startsWith('__')
});
}
optionalGroupFieldDefinitions: OptionalGroupFieldDefinition[] = [];
private buildArrayFieldDefinitions(schema: Record<string, any> | null): ArrayFieldDefinition[] {
return collectSchemaLeafFields(schema, ({ key, path, schema: childResolved, ui }) => {
if (childResolved?.['type'] !== 'array') return null;
if (key === 'type' || key === 'name' || key.startsWith('__')) return null;
const itemSchema = this.resolveArrayItemSchema(childResolved, schema ?? {});
return {
path,
label: schemaFieldLabel(path, childResolved),
description: schemaFieldDescription(childResolved),
itemSchema,
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,
definitionSource: this.findItemDefinitionSource(itemSchema, schema ?? {}),
ui: {
structural: ui.structural,
visibleWhen: ui.visibleWhen,
enabledWhen: ui.enabledWhen,
group: ui.group
}
};
}, {
includeArrays: true
});
}
/** The first item property, if any, that declares where the value it holds can be read in full. */
private findItemDefinitionSource(
itemSchema: Record<string, any> | null,
rootSchema: Record<string, any>
): ItemDefinitionSource | null {
if (!itemSchema) return null;
for (const { key, schema } of orderedSchemaPropertyEntries(itemSchema, rootSchema)) {
const url = toStringOrNull(schema?.['x-retriever-definition-url']);
if (url) return { property: key, url: url.trim() };
}
return null;
}
/**
* A group that holds values must not look like an empty one: starting a run with settings you
* cannot see is exactly what a collapsed control risks.
*/
private optionalGroupViews(): OptionalGroupView[] {
const config = this.blockConfiguration ?? {};
return this.optionalGroupFieldDefinitions
.filter((definition) => this.isPathVisible(definition.path))
.map((definition) => {
return {
path: definition.path,
label: definition.label,
setCount: countSetSchemaGroupValues(this.getByPath(config, definition.path)),
enabled: this.isPathEnabled(definition.path)
};
});
}
/** Whether the schema insists on this path, conditional requirements included. */
private isPathRequired(path: string): boolean {
if (this.schemaRequirements.required.some((field) => field.path === path)) return true;
if (this.schemaRequirements.requiredObjects.some((field) => field.path === path)) return true;
return this.schemaRequirements.conditional.some((field) =>
field.path === path && this.conditionalRequiredByPath.get(path) === true);
}
private schemaDeclaredDefault(path: string): string | null {
const schema = this.resolveFieldSchema(path);
const declared = schema?.['default'];
return declared == null ? null : String(declared);
}
private isStructuralField(path: string): boolean {
return getSchemaPathUiMeta(this.blockSchema, path).structural;
}
private resolveFieldSchema(path: string): Record<string, any> | null {
return resolveSchemaPath(this.blockSchema, path);
}
private toFieldType(type: unknown): FieldType {
if (type && typeof type === 'object' && !Array.isArray(type)) {
return schemaFieldTypeFromSchema(type as Record<string, any>);
}
if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') {
return type;
}
return 'unknown';
}
private toEnumOptions(schema: Record<string, any> | null | undefined): string[] {
return schemaEnumOptions(schema);
}
private toNodeOptionsSource(schema: Record<string, any> | null | undefined): NodeOptionsSource | null {
return schemaNodeOptionsSource(schema);
}
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);
}
/**
* Once a port is discovered to be kind-flexible (declared as ANY), that fact is
* remembered here by port key. Picking a concrete kind narrows `port.type`/`multiple`
* for connection-compatibility purposes, which would otherwise make the port look
* like a fixed single-kind port on the next read and permanently hide the selector.
*/
private flexiblePortKeys = new Set<string>();
private portSelectableKinds(kindSide: 'input' | 'output', key: string, port: FlowPort): FlowValueKind[] {
const expanded = new Map<string, FlowValueKind>();
const portKey = `${kindSide}:${key}`;
const valueKinds = this.portValueKinds(port);
const isFlexible = valueKinds.some((kind) => String(kind.type ?? 'ANY').toUpperCase() === 'ANY')
|| this.flexiblePortKeys.has(portKey);
if (isFlexible) {
this.flexiblePortKeys.add(portKey);
const multiple = Boolean(valueKinds[0]?.multiple ?? port.multiple);
for (const concreteType of ['TEXT', 'FILE', 'JSON']) {
const concreteKind = { type: concreteType, multiple };
expanded.set(this.flowValueKindValue(concreteKind), concreteKind);
}
return Array.from(expanded.values());
}
for (const kind of valueKinds) {
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);
}
/**
* Why the typed value cannot be saved, or null. Same function as the settings dialog uses, so a
* bound declared once in the schema reads the same wherever the value can be typed.
*/
localEditorError(): string | null {
// A global reference is not the final value, so there is nothing to validate against the
// field's own bounds - the value it stands for arrives at run time.
if (this.localEditorLoading || this.localEditorUseInput || this.localEditorFromGlobal) return null;
return validateFieldValue(
{
type: this.localEditorType === 'number' || this.localEditorType === 'integer' ? 'number' : 'text',
// Requiredness is deliberately not checked here: an empty value already means "unset" for
// an optional parameter, and a missing required one is reported by the node's own warning.
min: this.localEditorMin ?? undefined,
max: this.localEditorMax ?? undefined,
step: this.localEditorType === 'integer' ? 1 : undefined,
minLength: this.localEditorMinLength ?? undefined,
maxLength: this.localEditorMaxLength ?? undefined,
pattern: this.localEditorPattern ?? undefined
},
this.localEditorValue
);
}
localEditorUsesDefault(): boolean {
return this.localEditorDefaultsWhenEmpty
&& !this.localEditorUseInput
&& !this.localEditorFromGlobal
&& this.localEditorValue.trim().length === 0;
}
localEditorDefaultHint(): string | null {
if (!this.localEditorUsesDefault()) return null;
return this.localEditorDefaultValue
? `Using the default: ${this.localEditorDefaultValue}`
: 'Using the default';
}
/**
* Whether there is a default to go back to at all. Apart from {@link canUseLocalEditorDefault},
* which says whether pressing it would do anything: the control keeps its place and greys out
* instead of appearing once a value is typed.
*/
showLocalEditorDefault(): boolean {
return this.localEditorDefaultsWhenEmpty && !this.localEditorUseInput && !this.localEditorFromGlobal;
}
canUseLocalEditorDefault(): boolean {
return this.showLocalEditorDefault() && !this.localEditorUsesDefault();
}
useLocalEditorDefault(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.localEditorValue = '';
}
canSaveLocalEditor(): boolean {
if (this.localEditorLoading) return false;
if (this.localEditorType !== 'boolean' && this.localEditorError() !== null) 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);
}
localEditorSourceMode(): 'static' | 'input' | 'global' {
if (this.localEditorUseInput) return 'input';
return this.localEditorFromGlobal ? 'global' : 'static';
}
/**
* Whether the field can take its value from a declared global input.
*
* <p>Offered wherever the field can be configured as an input at all: a port replaces the whole
* value, so such a field is by construction a single value rather than prose with references
* embedded in it - which is what makes replacing it with one reference meaningful. Prose fields
* like a prompt have no Source selector and never reach this.
*/
canTakeGlobalInput(): boolean {
return this.localEditorBindableAsInput && this.localEditorPath !== 'name';
}
globalInputLabel(input: FlowGlobalInput): string {
return `${input.name} (${String(input.type ?? 'ANY').toUpperCase()}${input.multiple ? '[]' : ''})`;
}
onLocalEditorGlobalInputChange(name: string) {
this.localEditorGlobalName = name;
// The value the field actually stores. Written for the user rather than typed by hand, which
// is the whole point: on a provider with a closed model list there is no text box to type in.
this.localEditorValue = name ? `\${{global.${name}}}` : '';
}
async onLocalEditorSourceModeChange(nextMode: string) {
const wasFromGlobal = this.localEditorFromGlobal;
this.localEditorUseInput = nextMode === 'input';
this.localEditorFromGlobal = nextMode === 'global';
if (this.localEditorFromGlobal || wasFromGlobal) {
// Entering the mode starts from nothing to pick, and leaving it must not leave a
// ${{...}} behind as though it had been typed as a literal.
this.localEditorGlobalName = '';
this.localEditorValue = '';
}
if (this.localEditorUseInput || this.localEditorFromGlobal || !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);
}
/**
* The global input a value refers to, when the value is *exactly* that reference. A value with
* text around it is a template the user wrote by hand, and belongs in the static mode.
*/
private globalReferenceName(value: unknown): string | null {
if (typeof value !== 'string') return null;
const match = /^\$\{\{global\.([^}]+)\}\}$/.exec(value.trim());
return match ? match[1] : null;
}
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
);
// Each request carries its own fallback: an open field is precisely the one whose value list
// comes back empty, so a failure on either side must not decide the other.
const [options, freeText] = await Promise.all([
this.fetchRetrieverOptions(
blockType,
definition.retrieverKey,
definition.retrieverUrl,
definition.retrieverStructuredData,
context
).catch(() => [] as NodeSettingOption[]),
this.fetchRetrieverFreeText(
blockType,
definition.retrieverKey,
definition.retrieverUrl,
context
)
]);
this.localEditorOptions = options;
this.localEditorFreeText = freeText;
this.localEditorLoading = false;
}
async openFieldPreview(field: EditableFieldView, 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 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<string, unknown>;
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 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 refreshParameterFields() {
const config = this.blockConfiguration ?? {};
const richContentPaths = new Set(this.richContentPaths());
this.parameterDisplayItems = [];
this.parameterDisplaySections = [];
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: 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))
}));
this.optionalGroupFields = this.optionalGroupViews();
if (this.editableFieldDefinitions.length) {
const groupedFields = buildSchemaFieldViewModel({
definitions: this.editableFieldDefinitions,
config,
richContentPaths: this.richContentPaths(),
isPathVisible: (path, nextConfig) => this.isPathVisible(path, nextConfig),
isPathEnabled: (path, nextConfig) => this.isPathEnabled(path, nextConfig),
getFieldValue: (definition, nextConfig) => this.fieldDisplayValue(definition, this.getByPath(nextConfig, definition.path)),
isFieldWide: (definition) => this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea'),
getRichContentParts: (path, _nextConfig) => this.toRichContentParts(path),
resolveGroupLabel: (path) => getSchemaPathUiMeta(this.blockSchema, path).group ?? parentGroupLabel(path),
groupRichContent: false
});
const allFields = [
...groupedFields.parameterFields,
...groupedFields.parameterFieldGroups.flatMap((group) => group.fields)
];
const allRichContentFields = [
...groupedFields.richContentFields,
...groupedFields.parameterFieldGroups.flatMap((group) => group.richContentFields)
];
this.parameterFields = groupedFields.parameterFields;
this.richContentFields = groupedFields.richContentFields;
const ordered = buildOrderedSchemaDisplay({
definitions: this.orderedDisplayPaths(),
fields: allFields,
richContentFields: allRichContentFields,
arrayFields: this.arrayFields,
optionalGroupFields: this.optionalGroupFields,
resolveGroupLabel: (path) => getSchemaPathUiMeta(this.blockSchema, path).group ?? parentGroupLabel(path)
});
this.parameterDisplayItems = ordered.rootItems;
this.parameterFieldGroups = ordered.groups;
this.parameterDisplaySections = ordered.sections;
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: isLongTextValue(valueToDisplayString(entry.value)),
enabled: this.isPathEnabled(entry.path),
type: (typeof entry.value === 'boolean' ? 'boolean' : 'unknown') as FieldType,
booleanValue: entry.value === true
}));
const groupedFallback = groupSchemaFields({
fields: fallbackFields,
resolveGroupLabel: (path) => parentPath(path),
resolveLegend: (groupLabel) => this.fieldDisplayLabel(groupLabel)
});
this.parameterFields = groupedFallback.rootFields;
this.parameterFieldGroups = groupedFallback.groups.map((group) => ({
key: group.key,
legend: group.legend,
items: group.fields.map((field) => ({
path: field.path,
field,
richContentField: null,
arrayField: null,
optionalGroupField: null
}))
}));
this.parameterDisplayItems = groupedFallback.rootFields.map((field) => ({
path: field.path,
field,
richContentField: null,
arrayField: null,
optionalGroupField: null
}));
this.parameterDisplaySections = [
...groupedFallback.groups.map((group) => ({
key: group.key,
group: {
key: group.key,
legend: group.legend,
items: group.fields.map((field) => ({
path: field.path,
field,
richContentField: null,
arrayField: null,
optionalGroupField: null
}))
},
item: null
})),
...groupedFallback.rootFields.map((field) => ({
key: `item:${field.path}`,
group: null,
item: {
path: field.path,
field,
richContentField: null,
arrayField: null,
optionalGroupField: null
}
}))
];
this.refreshView();
}
private orderedDisplayPaths(): Array<{ path: string }> {
return collectSchemaLeafFields(this.blockSchema, ({ key, path }) => {
if (key === 'type' || key === 'name' || key.startsWith('__')) return null;
return { path };
}, {
includeArrays: true,
includeOptionalGroups: true
});
}
/**
* Opens the whole group in one dialog. It reuses the round trip the array editor uses - the same
* builder, the same parser, the same post-save tail - so an optional numeric cleared here is
* removed rather than written as 0, exactly as it is when edited inline.
*/
async openOptionalGroupEditor(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const definition = this.optionalGroupFieldDefinitions.find((field) => field.path === path);
if (!definition || !this.isPathVisible(path) || !this.isPathEnabled(path)) return;
const config = this.ensureBlockConfiguration();
const current = this.getByPath(config, path);
const currentValue = current && typeof current === 'object' && !Array.isArray(current)
? { ...(current as Record<string, unknown>) }
: {};
const dialog = await this.buildObjectDialog(definition.objectSchema, definition.label, currentValue);
if (!dialog) return;
const result = await this.settingsDialog.open({
...dialog,
fields: await this.dropParametersTheProviderIgnores(path, dialog.fields)
});
if (!result) return;
const next = this.parseObjectDialogResult(definition.objectSchema, result, currentValue);
if (Object.keys(next).length) {
setSchemaValueByPath(config, path, next);
} else {
// Nothing set: the key goes away entirely, so the object never appears in a saved flow as an
// empty husk that implies settings were chosen.
deleteSchemaValueByPath(config, path);
}
if (this.isStructuralField(path)) this.markBlockForServerRecreate();
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
}
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);
setSchemaValueByPath(config, path, items);
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
}
/** Always available, even read-only: reading what a chosen value stands for needs no edit rights. */
async viewItemDefinition(definition: ArrayItemDefinition, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
try {
const loaded = await firstValueFrom(
this.http.get<{ name?: string; content?: string }>(
`${this.toApiUrl(definition.url)}/${encodeURIComponent(definition.value)}`
)
);
await this.openReadonlyTextDialog(loaded.name ?? definition.value, loaded.content ?? '');
} catch {
await this.openReadonlyTextDialog(definition.value, 'Could not load this content.');
}
}
private toApiUrl(url: string): string {
if (/^https?:\/\//.test(url)) return url;
return `${environment.apiUrl}${url.startsWith('/') ? url : `/${url}`}`;
}
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 primitiveType = primitiveItemType(definition.itemSchema);
if (primitiveType) {
await this.openPrimitiveArrayItemEditor(definition, config, items, index, primitiveType);
return;
}
const currentItem = index == null ? this.createEmptyArrayItem(definition.itemSchema) : this.cloneFlowData(items[index] ?? {});
const dialog = await this.buildObjectDialog(
definition.itemSchema,
`${index == null ? 'Add' : 'Edit'} ${definition.label} item`,
currentItem);
if (!dialog) return;
const result = await this.settingsDialog.open(dialog);
if (!result) return;
const nextItem = this.parseObjectDialogResult(definition.itemSchema, 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;
}
setSchemaValueByPath(config, path, items);
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
}
/**
* An element that is a plain value - a tool name, a step of a script - edited as that value in
* one field, labelled and explained as the list is. Without this such an element has no fields
* to build a dialog from, and fell back to a raw JSON box nobody could guess the shape of.
*/
private async openPrimitiveArrayItemEditor(
definition: ArrayFieldDefinition,
config: Record<string, any>,
items: unknown[],
index: number | null,
type: PrimitiveItemType
) {
const currentValue = index == null ? null : items[index];
const result = await this.settingsDialog.open({
title: `${index == null ? 'Add' : 'Edit'} ${definition.label} item`,
fields: [{
key: '__value',
label: definition.label,
type: type === 'boolean' ? 'checkbox' : type === 'string' ? 'text' : 'number',
tip: definition.description ?? undefined,
autofocus: true
}],
initial: { __value: type === 'boolean' ? currentValue === true : currentValue == null ? '' : String(currentValue) }
});
if (!result) return;
const nextItem = parsePrimitiveItem(type, result['__value']);
if (nextItem == null) return;
if (items.some((item, itemIndex) => itemIndex !== index && item === nextItem)) {
window.alert(`${definition.label} already contains ${String(nextItem)}.`);
return;
}
if (index == null) {
items.push(nextItem);
} else {
items[index] = nextItem;
}
setSchemaValueByPath(config, definition.path, items);
if (this.isStructuralField(definition.path)) {
this.markBlockForServerRecreate();
}
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
}
/** The parts of the object round trip that only this component can supply. */
private objectDialogHooks(objectSchema: Record<string, any> | null): SchemaObjectDialogHooks {
return {
schemaRoot: this.blockSchema ?? objectSchema ?? {},
loadOptions: (propertySchema, draft) => this.loadNodeSettingOptions(propertySchema, draft, ''),
loadFreeText: (propertySchema, draft) => this.loadNodeSettingFreeText(propertySchema, draft, ''),
dynamic: {
isDynamic: (propertySchema) => this.hasDynamicSchema(propertySchema),
buildFields: (key, propertySchema, draft) => this.buildDynamicSchemaFields(key, propertySchema, draft),
readValue: (result, key) => this.extractNestedDialogValues(result, key)
}
};
}
/**
* Drops the sampling knobs the chosen provider does not apply.
*
* <p>The provider sits beside the parameters it samples with - `llmDescriptor.provider` next to
* `llmDescriptor.parameters` - which is what makes this answerable while editing. Until now every
* provider was offered all five and the ones it ignores were dropped at run time, reported in a
* warning on an execution that had already happened.
*
* <p>Leaves everything in place when the answer is not knowable: no provider chosen yet, a
* provider the server does not list, or a capability call that failed. A knob wrongly hidden
* cannot be set at all, which is worse than one that does nothing.
*/
private async dropParametersTheProviderIgnores(
path: string,
fields: NodeSettingField[]
): Promise<NodeSettingField[]> {
if (!fields.some((field) => isModelParameterField(field.key))) return fields;
const parentPath = path.split('.').slice(0, -1).join('.');
const providerPath = parentPath ? `${parentPath}.provider` : 'provider';
const provider = this.getByPath(this.ensureBlockConfiguration(), providerPath);
if (typeof provider !== 'string' || !provider.trim()) return fields;
const capability = findCapability(await this.llmProviderCapabilities(), provider);
if (!capability) return fields;
return fields.filter((field) => providerAppliesParameter(capability, field.key));
}
/** Fetched at most once per node, and never fatal: an empty list means "no claim". */
private async llmProviderCapabilities(): Promise<LlmProviderCapability[]> {
if (this.cachedLlmCapabilities) return this.cachedLlmCapabilities;
try {
this.cachedLlmCapabilities = await firstValueFrom(this.llmProviders.listCapabilities());
} catch {
this.cachedLlmCapabilities = [];
}
return this.cachedLlmCapabilities;
}
private cachedLlmCapabilities: LlmProviderCapability[] | null = null;
private buildObjectDialog(
objectSchema: Record<string, any> | null,
title: string,
value: Record<string, unknown>
): Promise<NodeSettingsDialogInput | null> {
return buildSchemaObjectDialog(objectSchema, title, value, this.objectDialogHooks(objectSchema));
}
private parseObjectDialogResult(
objectSchema: Record<string, any> | null,
result: NodeSettingsValues,
previousValue: Record<string, unknown>
) {
return parseSchemaObjectDialogResult(objectSchema, result, previousValue, this.objectDialogHooks(objectSchema));
}
private createEmptyArrayItem(itemSchema: Record<string, any> | null) {
const properties = itemSchema?.['properties'] as Record<string, any> | undefined;
const schemaRoot = this.blockSchema ?? itemSchema ?? {};
if (!properties) return {};
const item: Record<string, unknown> = {};
for (const { key, schema: propertySchema } of orderedSchemaPropertyEntries(itemSchema, schemaRoot)) {
if (!propertySchema) continue;
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<string, any> | null | undefined, root: Record<string, any>) {
const items = node?.['items'];
if (!items || typeof items !== 'object') return null;
const resolved = resolveSchemaRef(items as Record<string, any>, root);
return resolved && typeof resolved === 'object' ? resolved as Record<string, any> : null;
}
private hasDynamicSchema(schema: Record<string, any> | 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<string, any>,
item: Record<string, unknown>
): Promise<{ fields: NodeSettingField[]; initial: Record<string, string | boolean> }> {
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<string, string> = {};
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<string, any>
: 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<string, any>,
currentValue: unknown
): Promise<{ fields: NodeSettingField[]; initial: Record<string, string | boolean> }> {
const fields: NodeSettingField[] = [];
const initial: Record<string, string | boolean> = {};
const currentRecord =
currentValue && typeof currentValue === 'object' && !Array.isArray(currentValue)
? currentValue as Record<string, unknown>
: {};
const walk = async (
node: Record<string, any>,
pathPrefix: string,
titlePrefix: string,
inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null }
) => {
const resolved = resolveSchemaRef(node, schema);
const properties = orderedSchemaPropertyEntries(resolved, schema);
if (!properties.length) return;
for (const { key: childKey, schema: childSchema } of properties) {
if (!childSchema) continue;
if (shouldSkipSchemaField(childKey, childSchema)) continue;
const childUi = this.toFieldUiMeta(childSchema, inheritedUi);
const fieldRelativePath = pathPrefix === keyPrefix
? childKey
: `${pathPrefix.slice(`${keyPrefix}.`.length)}.${childKey}`;
const visible = childUi.visibleWhen.every((rule) =>
evaluateUiConditionRule(rule, currentRecord, (fieldPath) => resolveSchemaPath(schema, fieldPath))
);
if (!visible) continue;
const nextPath = `${pathPrefix}.${childKey}`;
const nextLabel = `${titlePrefix} ${schemaFieldLabel(childKey, childSchema)}`;
const currentNestedValue = getValueByPath(currentRecord, fieldRelativePath);
const hasChildren = !!childSchema?.['properties'] || childSchema?.['type'] === 'object';
if (hasChildren) {
await walk(childSchema as Record<string, any>, nextPath, nextLabel, {
visibleWhen: childUi.visibleWhen,
enabledWhen: childUi.enabledWhen,
group: childUi.group
});
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,
readonly: !childUi.enabledWhen.every((rule) =>
evaluateUiConditionRule(rule, currentRecord, (fieldPath) => resolveSchemaPath(schema, fieldPath))
)
});
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: NodeSettingsValues, keyPrefix: string) {
const nested: Record<string, unknown> = {};
const prefix = `${keyPrefix}.`;
for (const [key, value] of Object.entries(result)) {
if (!key.startsWith(prefix)) continue;
const nestedPath = key.slice(prefix.length);
setSchemaValueByPath(nested, nestedPath, value);
}
return nested;
}
private fieldDisplayLabel(path: string): string {
return schemaFieldLabel(path, this.resolveFieldSchema(path));
}
private async loadNodeSettingOptions(
propertySchema: Record<string, any>,
item: Record<string, unknown>,
pathPrefix: string
): Promise<NodeSettingOption[] | undefined> {
const enumOptions = this.toEnumOptions(propertySchema);
if (enumOptions.length) {
return enumOptions.map((value) => ({ label: value, value }));
}
const retrieverMeta = schemaRetrieverMeta(propertySchema, pathPrefix);
const retrieverKey = retrieverMeta.retrieverKey;
const retrieverBlockType = retrieverMeta.retrieverBlockType;
if (!retrieverKey || !retrieverBlockType) return undefined;
try {
return await this.fetchRetrieverOptions(
retrieverBlockType,
retrieverKey,
retrieverMeta.retrieverUrl,
retrieverMeta.retrieverStructuredData,
this.buildRetrieverContext(item as Record<string, any>, retrieverMeta.retrieverDependsOn)
);
} catch {
return [];
}
}
/** The nested-object counterpart of {@link fetchRetrieverFreeText}, for a bare property schema. */
private async loadNodeSettingFreeText(
propertySchema: Record<string, any>,
item: Record<string, unknown>,
pathPrefix: string
): Promise<boolean> {
const retrieverMeta = schemaRetrieverMeta(propertySchema, pathPrefix);
if (!retrieverMeta.retrieverKey || !retrieverMeta.retrieverBlockType) return false;
return this.fetchRetrieverFreeText(
retrieverMeta.retrieverBlockType,
retrieverMeta.retrieverKey,
retrieverMeta.retrieverUrl,
this.buildRetrieverContext(item as Record<string, any>, retrieverMeta.retrieverDependsOn)
);
}
private toArrayFieldItems(definition: ArrayFieldDefinition, value: unknown): ArrayFieldItemView[] {
if (!Array.isArray(value)) return [];
return value.map((item, index) => ({
index,
summary: this.toArrayItemSummary(definition, item, index),
definition: this.toArrayItemDefinition(definition.definitionSource, item)
}));
}
private toArrayItemDefinition(source: ItemDefinitionSource | null, item: unknown): ArrayItemDefinition | null {
if (!source || !item || typeof item !== 'object' || Array.isArray(item)) return null;
const value = toStringOrNull((item as Record<string, unknown>)[source.property]);
return value ? { url: source.url, value } : null;
}
private toArrayItemSummary(definition: ArrayFieldDefinition, item: unknown, index: number) {
// An array of plain values - a task script, a list of names - is its own summary. Reading back
// "Item 1 / Item 2" where the row holds "Sign in" tells the reader nothing they can act on.
if (typeof item === 'string') return item.trim() || `Item ${index + 1}`;
if (typeof item === 'number' || typeof item === 'boolean') return String(item);
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return `Item ${index + 1}`;
}
const properties = definition.itemSchema?.['properties'] as Record<string, any> | undefined;
if (!properties) return `Item ${index + 1}`;
const summaryParts: string[] = [];
for (const { key, schema } of orderedSchemaPropertyEntries(definition.itemSchema, this.blockSchema ?? definition.itemSchema ?? {})) {
const value = (item as Record<string, unknown>)[key];
if (this.isMissingValue(value) || value === false) continue;
if (!this.isArrayItemFieldVisible(schema, item as Record<string, unknown>, definition)) 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}`;
}
/**
* What identifies one row of an array field, for the collapsed list on the node.
*
* <p>Two kinds of value are skipped because they identify nothing. A false flag is an absence
* dressed as a fact - an upload row taking its file from a global read "GLOBAL · false", the
* false being an untouched "several files" box. And a field the row's own choice hides is
* irrelevant by construction: the summary should read the live branch, which for that row is the
* global it names.
*/
private isArrayItemFieldVisible(
propertySchema: Record<string, any> | null | undefined,
item: Record<string, unknown>,
definition: ArrayFieldDefinition
): boolean {
const rule = readEffectiveUiVisibleConditionRule(propertySchema);
if (!rule) return true;
return evaluateUiConditionRule(rule, item, (fieldPath) =>
resolveSchemaPath(definition.itemSchema ?? {}, fieldPath));
}
private validateUniqueArrayItem(
definition: ArrayFieldDefinition,
items: unknown[],
nextItem: Record<string, unknown>,
currentIndex: number | null
): string | null {
const violation = validateUniqueByConstraint(
items,
nextItem,
definition.uniqueBy,
currentIndex,
(value) => this.isMissingValue(value),
(left, right) => schemaValuesEqual(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);
}
/**
* Returns undefined for a numeric field the user has cleared, which the caller reads as "remove
* this", not as a value.
*
* `Number('')` is 0, so an emptied box used to save a real 0. For a temperature that is the worst
* possible confusion: 0 is a valid and useful setting - the repeatable one - so once it had been
* given a value there was no way back to "leave it to the provider".
*/
/**
* What the empty box should say. For a bounded optional number that is the range plus the fact
* that clearing it is allowed - otherwise the only way to learn either is to save and be refused.
*/
editorPlaceholder(): string {
if (this.localEditorMaxLength) {
return `Max ${this.localEditorMaxLength} characters`;
}
if (this.localEditorMin !== null && this.localEditorMax !== null) {
return `${this.localEditorMin} to ${this.localEditorMax} - leave empty for the default`;
}
if (this.localEditorMin !== null) {
return `${this.localEditorMin} or more - leave empty for the default`;
}
if (this.localEditorFreeText) {
// No list to pick from, so the placeholder has to carry what the select used to imply.
return `Type the ${this.localEditorLabel.toLowerCase()}`;
}
return 'Value...';
}
private parseEditorValue(value: string, type: FieldType): unknown {
const trimmed = value.trim();
if (type === 'boolean') {
return trimmed === 'true';
}
if (type === 'number' || type === 'integer') {
if (!trimmed.length) return undefined;
const numeric = Number(trimmed);
if (!Number.isFinite(numeric)) return undefined;
return type === 'integer' ? Math.trunc(numeric) : numeric;
}
return value;
}
private pruneInactiveConfiguration(config: Record<string, any>) {
pruneInactiveSchemaConfiguration(config, [
...this.editableFieldDefinitions.map((field) => field.path),
...this.arrayFieldDefinitions.map((field) => field.path)
], (path, nextConfig) => this.isPathVisible(path, nextConfig) && this.isPathEnabled(path, nextConfig));
}
private richContentPaths(): string[] {
const textareaPaths = this.editableFieldDefinitions
.filter((field) => field.ui.widget === 'textarea')
.map((field) => field.path);
if (textareaPaths.length) {
return textareaPaths;
}
return collectSchemaLeafFields(this.blockSchema, ({ path, schema }) => {
if (schema?.['type'] === 'array') return null;
return getSchemaPathUiMeta(this.blockSchema, path).widget === 'textarea' ? path : null;
});
}
private toRichContentParts(path: string): { text: string; isDynamicInput: boolean }[] {
const content = toStringOrNull(this.getByPath(this.blockConfiguration ?? {}, path));
if (!content) return [];
return buildTemplatedRichContentParts(this.blockConfiguration ?? {}, path, this.blockSchema, splitTemplatedTextParts);
}
private getByPath(source: Record<string, any>, 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<string, unknown>);
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<T>(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<string, any> | 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 ?? {});
(block as unknown as Record<string, unknown>)[this.biasAnnotationsProperty] = this.cloneFlowData(
Array.isArray(nodeData?.[this.biasAnnotationsProperty]) ? nodeData[this.biasAnnotationsProperty] : []
);
return nextFlowData;
}
private maybeCreateBlockOnServer() {
const nodeData = this.data?.data as Record<string, unknown> | 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<string, unknown>;
const annotationsProperty = this.biasAnnotationsProperty;
const replaceNode = current['replaceWithCreatedNode'];
if (typeof replaceNode === 'function') {
void replaceNode({
...createdBlock,
id: String(current['id'] ?? createdBlock.id),
position: (current['position'] as { x: number; y: number } | undefined) ?? createdBlock.position,
[annotationsProperty]: Array.isArray(current[annotationsProperty])
? this.cloneFlowData(current[annotationsProperty])
: [],
__biasAnnotationsProperty: annotationsProperty,
__focusOpen: current['__focusOpen'] === true
});
return;
}
this.data.data = {
...current,
...createdBlock,
id: String(current['id'] ?? createdBlock.id),
position: (current['position'] as { x: number; y: number } | undefined) ?? createdBlock.position,
[annotationsProperty]: Array.isArray(current[annotationsProperty])
? this.cloneFlowData(current[annotationsProperty])
: [],
__biasAnnotationsProperty: annotationsProperty,
__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<string, unknown> | undefined;
if (!nodeData) return;
nodeData['__needsServerCreate'] = true;
nodeData['__createdOnServer'] = false;
nodeData['__updateBlockError'] = null;
}
private isPathVisible(path: string, config = this.blockConfiguration): boolean {
return isSchemaPathVisible(this.blockSchema, path, config);
}
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<string, unknown>,
dependencies: SchemaRetrieverDependency[]
) {
return buildSchemaRetrieverContext(source, dependencies, {
baseContext: this.withEditorFlowContext({}),
resolveContextDependency: (key) => this.resolveEditorContextDependencyValue(key)
});
}
private resolveRetrieverDependencyValue(source: Record<string, unknown>, dependency: SchemaRetrieverDependency): unknown {
return dependency.source === 'context'
? this.resolveEditorContextDependencyValue(dependency.key)
: getValueByPath(source as Record<string, any>, 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<string, string>
): Promise<NodeSettingOption[]> {
if (structuredData) {
const items = await firstValueFrom(
this.fieldRetriever.retrieveItems<unknown>(
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 }));
}
/**
* Closed on any failure: a select the user cannot get past is a visible problem, where a text
* field silently accepting anything against a closed list would be a wrong value saved.
*/
private async fetchRetrieverFreeText(
blockType: string,
retrieverKey: string,
retrieverUrl: string | null,
context?: Record<string, string>
): Promise<boolean> {
try {
return await firstValueFrom(
this.fieldRetriever.isFieldOpen(blockType, retrieverKey, context, retrieverUrl)
) === true;
} catch {
return false;
}
}
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<string, string>) {
const nextContext = { ...(context ?? {}) };
const flowId = this.editorFlowId();
if (flowId) {
nextContext['flowId'] = flowId;
}
return nextContext;
}
private isPathEnabled(path: string, config = this.blockConfiguration, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
return isSchemaPathEnabled(this.blockSchema, path, config);
}
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;
setSchemaValueByPath(config, path, nextValue);
resetDependentSchemaRetrieverFields(config, path, this.editableFieldDefinitions);
if (!schemaValuesEqual(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<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
return isSchemaPathVisible(this.blockSchema, path, this.blockConfiguration);
}
private toFieldUiMeta(
schema: Record<string, any> | null | undefined,
inheritedUi?: Pick<SchemaFieldUiMeta, 'visibleWhen' | 'enabledWhen' | 'group'>
) {
return toSchemaFieldUiMeta(schema, inheritedUi);
}
private getFieldUiMeta(path: string) {
return getSchemaPathUiMeta(this.blockSchema, path);
}
}