2561 lines
94 KiB
TypeScript
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);
|
|
}
|
|
}
|