humainflow-web/src/app/shared/nodes/task-step-node/task-step-node.ts

1572 lines
54 KiB
TypeScript

import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject, viewChild } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { BiasAnnotation, BlockInteractionContract, BlockType, DEFAULT_NODE_CAPABILITIES, FlowData, FlowNode, FlowPort, isProbeExecutable, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, NodeTypeCapabilities } from '@models/flow';
import { BiasCapabilities } from '@models/bias-impact';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { ExecutionErrorDialogService } from '@services/dialogs/execution-error-dialog';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import {
HumanDecisionOption,
HumanInteractionDialogResult,
HumanInteractionDialogService,
HumanInteractionRuntimeInput
} from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { buildTemplateSubstitutions } from '@shared/template-placeholder-text/template-placeholder';
import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasAnnotationsComponent } from '@shared/bias-annotations/bias-annotations';
import { take } from 'rxjs';
import {
collectSchemaFlowDataFields,
isFlowDataFieldPath,
normalizeFlowDataValue,
type SchemaFlowDataFieldDefinition
} from '../flow-data-schema-fields';
import { evaluateUiConditionRule, flattenPrimitiveValues, formatNodeTitle, getOutputPillClass, getOutputsTitle, isConditionalByPorts, isHumanInteractiveNode, orderedSchemaPropertyEntries, parentGroupLabel, pathToLabel, readEffectiveUiVisibleConditionRule, readUiConditionRule, readUiGroup, readUiLabel, resolveNodeIcon, resolveSchemaPath, resolveSchemaRef, schemaFieldLabel, shouldSkipSchemaField, splitTemplatedTextParts, toStringOrNull, type UiConditionRule, valueToDisplayString } from '../node-utility';
type DisplayField = {
path: string;
label: string;
value: string;
wide: boolean;
expandable: boolean;
};
type ArrayFieldDefinition = {
path: string;
label: string;
itemSchema: Record<string, any> | null;
};
type ArrayFieldItemView = {
index: number;
summary: string;
};
type ArrayFieldView = {
path: string;
label: string;
items: ArrayFieldItemView[];
};
type DisplayFieldGroup = {
key: string;
legend: string;
fields: DisplayField[];
};
type MainContentView = {
path: string;
label: string;
rawValue: string;
expandable: boolean;
parts: { text: string; isDynamicInput: boolean }[];
};
type PortLabelParts = {
context: string | null;
name: string;
};
type FieldUiMeta = {
visibleWhen: UiConditionRule[];
group: string | null;
widget: 'textarea' | null;
acceptVariableAsPlaceholder: boolean;
};
@Component({
selector: 'app-task-step-node',
imports: [CommonModule, ReteModule, BiasAnnotationsComponent],
templateUrl: './task-step-node.html',
styleUrl: './task-step-node.css',
host: {
'data-testid': 'task-step-node'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskStepNodeComponent {
/** How much of a failure the hover tooltip shows before the dialog has to be opened. */
private static readonly ERROR_SUMMARY_MAX_LENGTH = 90;
private static readonly globalFieldSchemaCache = new Map<string, Map<string, Record<string, any> | null>>();
private static readonly globalFieldUiMetaCache = new Map<string, Map<string, FieldUiMeta>>();
private static readonly globalFieldLabelCache = new Map<string, Map<string, string>>();
private blocksService = inject(BlocksService);
private containersService = inject(ContainersService);
private settingsDialog = inject(NodeSettingsDialogService);
private subflowPreview = inject(SubflowPreviewDialogService);
private executionErrorDialog = inject(ExecutionErrorDialogService);
private cdr = inject(ChangeDetectorRef);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private taskExecutionsService = inject(TaskExecutionsService);
private biasImpactExperimentDialog = inject(BiasImpactExperimentDialogService);
private biasComparisonViewState = inject(BiasComparisonViewStateService);
private readonly biasAnnotationsViewer = viewChild(BiasAnnotationsComponent);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@HostBinding('class.selected') get selected() {
return this.data.selected;
}
@HostBinding('class.llm-node-readonly') readonlyClass = true;
parameterFields: DisplayField[] = [];
parameterFieldGroups: DisplayFieldGroup[] = [];
arrayFields: ArrayFieldView[] = [];
name = 'Step';
mainContentFields: MainContentView[] = [];
interactionSubmitting = false;
schemaReady = false;
biasCapabilities: BiasCapabilities | null = null;
private blockSchema: Record<string, any> | null = null;
private blockDescriptor: BlockType | null = null;
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
private flowFieldDefinitions: SchemaFlowDataFieldDefinition[] = [];
get outputs(): { key: string; socket: ClassicPreset.Socket }[] {
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(): { key: string; socket: ClassicPreset.Socket }[] {
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(): { key: string; socket: ClassicPreset.Socket } | 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(): { key: string; socket: ClassicPreset.Socket } | 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;
}
ngOnInit() {
this.parameterFields = [];
this.parameterFieldGroups = [];
this.arrayFields = [];
this.rebuildDisplayState();
void this.loadSchemaContext();
void this.loadBiasAnnotationsDescriptor();
this.loadBiasCapabilities();
}
private rebuildDisplayState() {
const config = this.blockConfiguration ?? {};
this.name = toStringOrNull(config['name']) ?? this.name;
const arrayFieldPaths = new Set(this.arrayFieldDefinitions.map((definition) => definition.path));
const primitiveEntries = flattenPrimitiveValues(config)
.filter((entry) => !this.shouldHideConfigPath(entry.path))
.filter((entry) => !arrayFieldPaths.has(entry.path));
const visibleEntries = primitiveEntries.filter((entry) => this.isPathVisible(entry.path));
const fieldMetaByPath = new Map<string, { label: string; ui: FieldUiMeta; value: string }>();
for (const entry of visibleEntries) {
const ui = this.getFieldUiMeta(entry.path);
const label = this.displayLabelForPath(entry.path);
fieldMetaByPath.set(entry.path, {
label,
ui,
value: valueToDisplayString(entry.value)
});
}
const mainContentEntries = visibleEntries
.filter((entry) => fieldMetaByPath.get(entry.path)?.ui.widget === 'textarea')
.filter((entry) => typeof entry.value === 'string' && String(entry.value).trim().length > 0);
const richContentPaths = mainContentEntries.map((entry) => entry.path);
this.mainContentFields = mainContentEntries.map((entry) => ({
path: entry.path,
label: fieldMetaByPath.get(entry.path)?.label ?? this.displayLabelForPath(entry.path),
rawValue: String(entry.value),
expandable: this.isLongTextValue(String(entry.value)),
parts: this.toMainContentParts(entry.path, String(entry.value))
}));
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))
}));
const grouped = new Map<string, DisplayField[]>();
const rootFields: DisplayField[] = [];
const orderedFields = visibleEntries
.filter((entry) => !['name', 'type'].includes(entry.path))
.filter((entry) => !richContentPaths.includes(entry.path))
.filter((entry) => !this.isEmptyDisplayValue(entry.value))
.map((entry) => {
const meta = fieldMetaByPath.get(entry.path);
const label = meta?.label ?? this.displayLabelForPath(entry.path);
const value = meta?.value ?? valueToDisplayString(entry.value);
return {
path: entry.path,
label,
value,
wide: this.shouldRenderWideField(label, meta?.ui.widget === 'textarea'),
expandable: this.isLongTextValue(value)
};
});
for (const field of orderedFields) {
const groupLabel = this.groupLabelForPath(field.path);
const groupKey = groupLabel ? `group:${groupLabel}` : null;
if (!groupKey || !groupLabel) {
rootFields.push(field);
continue;
}
if (!grouped.has(groupKey)) {
grouped.set(groupKey, []);
}
grouped.get(groupKey)!.push(field);
}
this.parameterFields = rootFields;
this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({
key,
legend: key.startsWith('group:') ? key.slice('group:'.length) : this.displayLabelForPath(key),
fields
}));
this.refreshView();
}
ngAfterViewInit() {
this.rendered();
}
isHumanNode(): boolean {
return isHumanInteractiveNode(this.interactionContract());
}
isConditionalNode(): boolean {
return isConditionalByPorts(this.resolvePorts('output'));
}
nodeTitle(): string {
return formatNodeTitle(this.blockType, 'Task Step');
}
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());
}
inputDisplayLabel(inputKey: string): string {
return this.portDisplayLabel('input', inputKey);
}
outputDisplayLabel(outputKey: string): string {
return this.portDisplayLabel('output', outputKey);
}
inputKindLabel(inputKey: string): string {
return this.portKindLabel('input', inputKey);
}
outputKindLabel(outputKey: string): string {
return this.portKindLabel('output', outputKey);
}
inputDisplayLabelParts(inputKey: string): PortLabelParts {
return this.toPortLabelParts(this.inputDisplayLabel(inputKey));
}
outputDisplayLabelParts(outputKey: string): PortLabelParts {
return this.toPortLabelParts(this.outputDisplayLabel(outputKey));
}
hasMainContent(): boolean {
return this.mainContentFields.length > 0;
}
isContainerNode(): boolean {
return this.resolvedNodeFamily() === 'container';
}
hasViewableSubflow(): boolean {
return this.viewableSubflows().length > 0;
}
viewableSubflows(): Array<{ path: string; label: string; flow: FlowData }> {
if (!this.isContainerNode()) return [];
return this.resolveFlowFieldDefinitions()
.map((field) => ({
path: field.path,
label: field.label,
flow: this.flowAtPath(field.path)
}))
.filter((item): item is { path: string; label: string; flow: FlowData } => item.flow != null);
}
hasExecutionDependencyPorts(): boolean {
return this.hasConnectedDependencyInput() || this.hasConnectedDependantOutput();
}
hasConnectedDependencyInput(): boolean {
return !!this.dependencyInput && this.blockConfiguration?.['__hasDependencyInputConnection'] === true;
}
hasConnectedDependantOutput(): boolean {
return !!this.dependantOutput && this.blockConfiguration?.['__hasDependantOutputConnection'] === true;
}
formatDynamicInputToken(token: string): string {
const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/);
return match ? match[1] : token;
}
executionInputTooltip(inputName: string): string | null {
const values = this.blockConfiguration?.['__executionInputs'] as Record<string, unknown> | undefined;
if (!values || !Object.prototype.hasOwnProperty.call(values, inputName)) return null;
const value = values[inputName];
if (value == null) return 'not ready yet';
if (typeof value === 'string') return value.trim().length > 0 ? value : null;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
isInputConnected(inputName: string): boolean {
const connectedInputs = this.blockConfiguration?.['__connectedInputs'];
if (!Array.isArray(connectedInputs)) return true;
return connectedInputs.includes(inputName);
}
showInputTooltip(): boolean {
const statusGroup = this.blockConfiguration?.['__executionStatusGroup'];
return statusGroup !== 'INIT';
}
executionOutputTooltip(outputName: string): string | null {
const values = this.blockConfiguration?.['__executionOutputs'] as Record<string, unknown> | undefined;
if (!values || !Object.prototype.hasOwnProperty.call(values, outputName)) return null;
const value = values[outputName];
if (value === undefined) return null;
if (typeof value === 'string') return value.trim().length > 0 ? value : null;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
isOutputConnected(outputName: string): boolean {
const connectedOutputs = this.blockConfiguration?.['__connectedOutputs'];
if (!Array.isArray(connectedOutputs)) return true;
return connectedOutputs.includes(outputName);
}
outputValueTooltip(outputName: string): string {
return this.executionOutputTooltip(outputName) ?? 'No output result';
}
showOutputTooltip(): boolean {
return this.showInputTooltip();
}
executionErrors(): string[] {
return this.getExecutionMessages('__executionErrors');
}
hasExecutionErrors(): boolean {
return this.executionErrors().length > 0;
}
executionErrorHeadline(): string {
const count = this.executionErrors().length;
return count > 1 ? `Error executing (${count})` : 'Error executing';
}
/**
* The opening of the failure, at tooltip length.
*
* A step's error is regularly a provider payload or a stack: on hover it can only ever be a
* tease, so it is cut at the first line break and clamped, and the whole of it is a click away.
*/
executionErrorSummary(): string {
const first = this.executionErrors()[0];
if (!first) return '';
const firstLine = first.split(/\r?\n/, 1)[0].trim() || first.trim();
return firstLine.length > TaskStepNodeComponent.ERROR_SUMMARY_MAX_LENGTH
? `${firstLine.slice(0, TaskStepNodeComponent.ERROR_SUMMARY_MAX_LENGTH).trimEnd()}…`
: firstLine;
}
openExecutionErrors(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const errors = this.executionErrors();
if (!errors.length) return;
this.executionErrorDialog.open({
nodeName: this.nodeTitle(),
nodeId: this.executionNodeId(),
errors
});
}
executionWarnings(): string[] {
return this.getExecutionMessages('__executionWarnings');
}
hasExecutionWarnings(): boolean {
return this.executionWarnings().length > 0;
}
needsAttention(): boolean {
return this.stepStatus() === 'WAITING_FOR_INTERACTION';
}
isCompleted(): boolean {
return this.stepStatus() === 'COMPLETED';
}
executableBiasAnnotations(): BiasAnnotation[] {
return this.allBiasAnnotations()
.filter((annotation) => isProbeExecutable(annotation.biasProbe) || isProbeExecutable(annotation.mitigationProbe));
}
allBiasAnnotations(): BiasAnnotation[] {
const annotations = this.data?.data?.biasAnnotations;
return Array.isArray(annotations)
? annotations.filter((annotation): annotation is BiasAnnotation => !!annotation)
: [];
}
activeBiasAnnotationCount(): number {
const ids = this.blockConfiguration?.['__biasActiveAnnotationIds'];
return Array.isArray(ids) ? ids.length : 0;
}
biasAnnotationBadgeLabel(): string {
const count = this.allBiasAnnotations().length;
return `${count} bias ${count === 1 ? 'annotation' : 'annotations'}`;
}
openBiasAnnotations(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.biasAnnotationsViewer()?.openList(event);
}
biasAnnotationsBlock(): FlowNode | null {
const node = this.data?.data;
return node && typeof node === 'object' ? node as FlowNode : null;
}
typeCapabilities(): NodeTypeCapabilities {
return this.resolvedTypeCapabilities()
?? DEFAULT_NODE_CAPABILITIES;
}
isBiasCapable(): boolean {
return this.resolvedTypeCapabilities()?.biasAnnotationsAllowed === true;
}
visualRoleLabel(): string {
const role = this.typeCapabilities().visualRole.toLowerCase();
return role.charAt(0).toUpperCase() + role.slice(1);
}
capabilitiesTooltip(): string {
const capabilities = this.typeCapabilities();
return [
`Role: ${this.visualRoleLabel()}`,
`Terminal: ${capabilities.terminal ? 'yes' : 'no'}`,
`Incoming connections: ${capabilities.allowsIncomingConnections ? 'allowed' : 'blocked'}`,
`Outgoing connections: ${capabilities.allowsOutgoingConnections ? 'allowed' : 'blocked'}`,
`Dependencies: ${capabilities.canDependOnOtherNodes || capabilities.canHaveDependentNodes ? 'supported' : 'blocked'}`,
`Bias annotations: ${capabilities.biasAnnotationsAllowed ? 'allowed' : 'blocked'}`
].join('\n');
}
/**
* Whether the node shows the measure control at all: it has probes worth measuring.
*
* It deliberately does *not* require isolatedExperimentSupported. A user-interactive block -
* ChatInteraction, HumanDecision - cannot be replayed in isolation, and hiding the button there
* left an annotated node looking identical to an unannotated one. The control is shown and
* disabled, and the tooltip says which experiment is available instead.
*/
hasMeasurableBiasAnnotations(): boolean {
return this.isBiasCapable() && this.executableBiasAnnotations().length > 0;
}
private supportsIsolatedBiasExperiment(): boolean {
return this.biasCapabilities?.isolatedExperimentSupported === true;
}
canMeasureBiasImpact(): boolean {
return this.hasMeasurableBiasAnnotations()
&& this.supportsIsolatedBiasExperiment()
&& this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL';
}
measureBiasImpactTooltip(): string {
if (!this.supportsIsolatedBiasExperiment()) {
// A rule of the domain, not a fault: say what *is* possible rather than only what is not.
return 'This node cannot be replayed on its own, so there is no isolated experiment for it. '
+ 'Use "Create biased rerun" on the toolbar to measure it as part of the whole flow.';
}
if (this.blockConfiguration?.['__executionStatusGroup'] !== 'FINAL') {
return 'Available once the execution reaches a final state';
}
return 'Measure bias impact';
}
isBiasActive(): boolean {
const ids = this.blockConfiguration?.['__biasActiveAnnotationIds'];
return Array.isArray(ids) && ids.length > 0;
}
isBiasDownstreamChanged(): boolean {
return this.biasComparisonViewState.isNodeDownstreamChanged(this.executionNodeId());
}
isBiasRoutingChangeSource(): boolean {
return this.biasComparisonViewState.isRoutingChangeSource(this.executionNodeId());
}
measureBiasImpact(event: Event) {
event.preventDefault();
event.stopPropagation();
const executionId = this.executionId();
const stepId = this.executionNodeId();
const capabilities = this.biasCapabilities;
if (!executionId || !stepId || !capabilities || !this.canMeasureBiasImpact()) return;
this.biasImpactExperimentDialog.open({
executionId,
stepId,
nodeId: String(this.data?.id ?? stepId),
nodeName: this.nodeTitle(),
annotations: this.executableBiasAnnotations(),
capabilities
});
}
isRunning(): boolean {
return this.stepStatus() === 'RUNNING';
}
/**
* A container delegating to a subflow. Its own status is WAITING_FOR_SUBFLOW, so it took none of
* the in-progress styling and sat there looking inert for minutes while its child worked - which
* is indistinguishable from a stuck run.
*/
isSubflowRunning(): boolean {
return this.stepStatus() === 'WAITING_FOR_SUBFLOW';
}
/** The iteration a working container is on, so the signal says what, not just that. */
containerIterationIndex(): number | null {
const index = this.blockConfiguration?.['__containerIterationIndex'];
return typeof index === 'number' ? index : null;
}
isSkipped(): boolean {
return this.stepStatus() === 'SKIPPED';
}
stepStatus(): string {
const status = this.blockConfiguration?.['__stepStatus'];
return typeof status === 'string' ? status.toUpperCase() : '';
}
stepSkipReason(): string | null {
const reason = this.blockConfiguration?.['__stepSkipReason'];
return typeof reason === 'string' && reason.trim().length > 0 ? reason.trim() : null;
}
async openInteractionModal(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.interactionSubmitting) return;
if (this.executionStatus() === 'SUSPENDED') return;
if (this.isInteractionSimulationEnabled()) return;
if (this.stepStatus() !== 'WAITING_FOR_INTERACTION') return;
const executionId = this.executionId();
const executionNodeId = this.executionNodeId();
const contract = this.interactionContract();
if (!executionId || !executionNodeId || !contract) return;
this.humanInteractionDialog.open({
...this.buildInteractionDialogState(executionId, executionNodeId, contract),
onSubmit: (result) => {
this.submitInteractionResult(executionId, executionNodeId, contract, result);
}
});
}
openSubflowPreview(path?: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const candidatePath = path ?? this.viewableSubflows()[0]?.path;
const subFlow = candidatePath ? this.flowAtPath(candidatePath) : null;
if (!subFlow) return;
const sourceName = this.name || this.nodeTitle();
const label = this.resolveFlowFieldDefinitions().find((field) => field.path === candidatePath)?.label ?? 'Subflow';
this.subflowPreview.open(subFlow, `${sourceName} ${label}`, sourceName);
}
openFieldPreview(field: DisplayField, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
void this.openReadonlyTextDialog(field.label, field.value);
}
openMainContentPreview(field: MainContentView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
void this.openReadonlyTextDialog(field.label, field.rawValue);
}
private get blockConfiguration(): Record<string, any> | null {
return this.data?.data?.specificConfiguration ?? null;
}
private loadBiasCapabilities() {
const blockType = this.blockType;
if (!blockType) return;
const capabilities$ = this.isContainerNode()
? this.containersService.retrieveBiasCapabilities(blockType)
: this.blocksService.retrieveBiasCapabilities(blockType);
capabilities$.pipe(take(1)).subscribe({
next: (capabilities) => {
this.biasCapabilities = capabilities;
this.cdr.markForCheck();
},
error: () => {
this.biasCapabilities = null;
this.cdr.markForCheck();
}
});
}
private async loadBiasAnnotationsDescriptor() {
try {
await this.blocksService.getBiasAnnotationsDescriptor();
this.cdr.markForCheck();
} catch {
// The annotations remain available in the execution payload even if their descriptor cannot be loaded.
}
}
private get blockType(): string | null {
const typeName = this.data?.data?.typeName;
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
}
private resolvedTypeCapabilities(): NodeTypeCapabilities | null {
return this.data?.data?.capabilities
?? this.blockDescriptor?.capabilities
?? null;
}
private actionDescriptionValue(): string {
const value = this.blockConfiguration?.['actionDescription'];
if (typeof value === 'string') return value;
return '';
}
private globalInputsValue(): Record<string, unknown> {
const value = this.blockConfiguration?.['__globalInputs'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
private executionVariablesValue(): Record<string, unknown> {
const value = this.blockConfiguration?.['__executionVariables'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
private projectContextValue(): Record<string, unknown> {
const value = this.blockConfiguration?.['__projectContext'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
private executionName(): string | null {
const value = this.blockConfiguration?.['__executionName'];
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
private templateSubstitutions(): Record<string, unknown> {
const inputs = (this.blockConfiguration?.['__executionInputs'] ?? {}) as Record<string, unknown>;
return buildTemplateSubstitutions(inputs, this.globalInputsValue(), this.executionVariablesValue(), {
executionId: this.executionId(),
executionName: this.executionName()
}, this.projectContextValue());
}
private currentInputValue(): string {
const inputKey = this.inputs[0]?.key ?? 'input';
return this.executionInputTooltip(inputKey) ?? 'not ready yet';
}
private executionId(): string | null {
const value = this.blockConfiguration?.['__executionId'];
return typeof value === 'string' && value.length > 0 ? value : null;
}
private executionNodeId(): string | null {
const value = this.blockConfiguration?.['__executionNodeId'];
return typeof value === 'string' && value.length > 0 ? value : null;
}
private executionStatus(): string {
const value = this.blockConfiguration?.['__executionStatus'];
return typeof value === 'string' ? value.toUpperCase() : '';
}
private isInteractionSimulationEnabled(): boolean {
return this.blockConfiguration?.['__interactionSimulationEnabled'] === true;
}
private getExecutionMessages(key: '__executionErrors' | '__executionWarnings'): string[] {
const values = this.blockConfiguration?.[key];
if (!Array.isArray(values)) return [];
return values.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
}
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 toPortLabelParts(label: string): PortLabelParts {
if (!this.isContainerNode()) {
return {
context: null,
name: label
};
}
const trimmed = String(label ?? '').trim();
const separatorIndex = trimmed.lastIndexOf('.');
if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
return {
context: null,
name: trimmed
};
}
return {
context: trimmed.slice(0, separatorIndex),
name: trimmed.slice(separatorIndex + 1)
};
}
private portTypeLabel(port: FlowPort): string {
const type = String(port.type ?? 'TEXT').toUpperCase();
return port.multiple ? `${type}[]` : type;
}
private portKindLabel(kind: 'input' | 'output', key: string): string {
const ports = this.resolvePorts(kind);
const port = ports.find((candidate) => candidate.name === key);
return port ? this.portTypeLabel(port) : 'ANY';
}
private resolvePorts(kind: 'input' | 'output'): FlowPort[] {
const ports = this.data?.data?.[kind === 'input' ? 'inputs' : 'outputs'];
return Array.isArray(ports) ? ports as FlowPort[] : [];
}
private toMainContentParts(path: string, value: string): { text: string; isDynamicInput: boolean }[] {
if (this.getFieldUiMeta(path).acceptVariableAsPlaceholder) {
return splitTemplatedTextParts(value);
}
return [{ text: value, isDynamicInput: false }];
}
private schemaLoading = false;
private async loadSchemaContext() {
if (this.schemaLoading) return;
const type = this.blockType;
if (!type) return;
this.schemaLoading = true;
try {
const nodeFamily = this.resolvedNodeFamily();
const cachedDescriptor = nodeFamily === 'container'
? this.containersService.peekContainerType(type)
: this.blocksService.peekBlockType(type);
const typeDescriptor = cachedDescriptor ?? (
nodeFamily === 'container'
? await this.containersService.getContainerType(type)
: await this.blocksService.getBlockType(type)
);
this.blockDescriptor = (typeDescriptor ?? null) as BlockType | null;
this.blockSchema = (typeDescriptor?.schema ?? null) as Record<string, any> | null;
const typeKey = this.nodeTypeCacheKey();
if (typeKey) {
TaskStepNodeComponent.globalFieldSchemaCache.delete(typeKey);
TaskStepNodeComponent.globalFieldUiMetaCache.delete(typeKey);
TaskStepNodeComponent.globalFieldLabelCache.delete(typeKey);
}
this.flowFieldDefinitions = collectSchemaFlowDataFields(this.blockSchema);
this.arrayFieldDefinitions = this.extractArrayFieldDefinitions(this.blockSchema);
this.rebuildDisplayState();
this.schemaReady = true;
} finally {
this.schemaLoading = false;
}
}
private interactionContract(): BlockInteractionContract | null {
return this.blockDescriptor?.interactionContract ?? null;
}
private interactionDialogTitle(contract: BlockInteractionContract): string {
if (contract.kind === 'chat-session') {
return `Chat with ${this.name || 'Interaction Step'}`;
}
if (contract.kind === 'human-decision') {
return `Decision: ${this.name || 'Human Decision'}`;
}
return `Send response for ${this.name || 'Interaction Step'}`;
}
private executionPartialResult(): Record<string, unknown> | null {
const value = this.blockConfiguration?.['__executionPartialResult'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
private stepResultData(): Record<string, unknown> | null {
const value = this.blockConfiguration?.['__stepResultData'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
private executionScopedFieldName(fieldName: string): string | null {
const nodeId = this.executionNodeId();
return nodeId ? `${nodeId}:${fieldName}` : null;
}
private interactionFieldValue(fieldName: string | null | undefined, preferPartial: boolean): unknown {
if (!fieldName) return undefined;
if (!preferPartial) {
const stepSource = this.stepResultData();
if (stepSource && Object.prototype.hasOwnProperty.call(stepSource, fieldName)) {
return stepSource[fieldName];
}
}
const scopedFieldName = this.executionScopedFieldName(fieldName);
const executionSource = preferPartial ? this.executionPartialResult() : null;
if (executionSource) {
if (Object.prototype.hasOwnProperty.call(executionSource, fieldName)) {
return executionSource[fieldName];
}
if (scopedFieldName && Object.prototype.hasOwnProperty.call(executionSource, scopedFieldName)) {
return executionSource[scopedFieldName];
}
}
return undefined;
}
private chatHistory(contract: BlockInteractionContract): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> {
const historyField = this.resolveInteractionFieldName(contract, 'history');
const latestResponse = this.latestInteractionResponse(contract);
if (!historyField) {
return latestResponse
? [{ role: 'assistant', content: latestResponse }]
: [];
}
const rawHistory =
this.interactionFieldValue(historyField, true)
?? this.interactionFieldValue(historyField, false);
const normalizedHistory = !Array.isArray(rawHistory)
? []
: rawHistory
.map((entry) => {
if (typeof entry === 'string') {
return this.parseChatHistoryLine(entry);
}
if (!entry || typeof entry !== 'object') return null;
const record = entry as Record<string, unknown>;
const role = record['role'];
const content = record['content'] ?? record['message'] ?? record['text'];
if ((role !== 'user' && role !== 'assistant' && role !== 'system') || typeof content !== 'string') {
return null;
}
return { role, content };
})
.filter((entry): entry is { role: 'user' | 'assistant' | 'system'; content: string } => entry != null);
if (!latestResponse) return normalizedHistory;
const lastMessage = normalizedHistory[normalizedHistory.length - 1];
if (lastMessage?.role === 'assistant' && lastMessage.content === latestResponse) {
return normalizedHistory;
}
return [
...normalizedHistory,
{ role: 'assistant', content: latestResponse }
];
}
private latestInteractionResponse(contract: BlockInteractionContract): string {
const fieldName = this.resolveInteractionFieldName(contract, 'response');
if (!fieldName) return '';
const value =
this.interactionFieldValue(fieldName, true)
?? this.interactionFieldValue(fieldName, false);
return typeof value === 'string' ? value : '';
}
private resolveInteractionFieldName(
contract: BlockInteractionContract,
target: 'history' | 'response'
): string | null {
const explicit = target === 'history'
? contract.historyField
: contract.responseField;
if (explicit) return explicit;
if (contract.kind !== 'chat-session') return null;
return target;
}
private buildInteractionDialogState(executionId: string, executionNodeId: string, contract: BlockInteractionContract) {
return {
executionId,
nodeId: executionNodeId,
title: this.interactionDialogTitle(contract),
kind: contract.kind,
actionDescription: this.actionDescriptionValue(),
currentInput: this.currentInputValue(),
runtimeInputs: this.interactionRuntimeInputs(),
history: this.chatHistory(contract),
latestResponse: this.latestInteractionResponse(contract),
historyField: contract.historyField,
responseField: contract.responseField,
messageField: contract.messageField,
completionField: contract.completionField,
pendingUserMessage: null,
awaitingAssistantResponse: false,
assistantResponseBaseline: this.latestInteractionResponse(contract),
isRunning: this.isRunning(),
isSubmitting: this.interactionSubmitting,
submitError: null,
question: this.decisionQuestion(),
decisionOptions: this.decisionOptions(),
rationaleRequired: this.blockConfiguration?.['rationaleRequired'] === true,
rationaleLabel: this.decisionRationaleLabel(),
templateValues: this.templateSubstitutions()
};
}
private submitInteractionResult(
executionId: string,
executionNodeId: string,
contract: BlockInteractionContract,
result: HumanInteractionDialogResult
) {
if (this.isInteractionSimulationEnabled()) return;
if (this.stepStatus() !== 'WAITING_FOR_INTERACTION') {
this.humanInteractionDialog.close(null);
this.taskExecutionsService.refresh();
return;
}
if (result.mode === 'decision') {
this.submitHumanDecision(executionId, executionNodeId, contract, result);
return;
}
const interactionFieldName = result.mode === 'message'
? contract.messageField
: contract.completionField;
if (!interactionFieldName) return;
this.interactionSubmitting = true;
this.humanInteractionDialog.update({
pendingUserMessage: result.value,
awaitingAssistantResponse: true,
assistantResponseBaseline: this.latestInteractionResponse(contract),
isSubmitting: true,
submitError: null
});
this.taskExecutionsService.submitInteractionText(
executionId,
executionNodeId,
interactionFieldName,
result.value
).subscribe({
next: () => {
this.interactionSubmitting = false;
if (result.mode === 'complete') {
this.humanInteractionDialog.close(result);
return;
}
this.humanInteractionDialog.update({
isSubmitting: false,
submitError: null
});
},
error: (error) => {
this.interactionSubmitting = false;
this.humanInteractionDialog.update({
isSubmitting: false,
submitError: 'Failed to send the interaction response.'
});
console.error('Submit interaction output failed', error);
}
});
}
private submitHumanDecision(
executionId: string,
executionNodeId: string,
contract: BlockInteractionContract,
result: Extract<HumanInteractionDialogResult, { mode: 'decision' }>
) {
const options = this.decisionOptions();
if (!options.some((option) => option.name === result.choice)) {
this.humanInteractionDialog.update({
submitError: 'Select one of the available decision options.'
});
return;
}
const rationale = result.rationale.trim();
if (this.blockConfiguration?.['rationaleRequired'] === true && !rationale) {
this.humanInteractionDialog.update({
submitError: `${this.decisionRationaleLabel()} is required.`
});
return;
}
const rationaleField = contract.messageField;
const choiceField = contract.completionField;
if (rationale && !rationaleField) {
this.humanInteractionDialog.update({
submitError: 'The decision contract has no rationale field.'
});
return;
}
if (!choiceField) {
this.humanInteractionDialog.update({
submitError: 'The decision contract has no completion field.'
});
return;
}
this.interactionSubmitting = true;
this.humanInteractionDialog.update({ isSubmitting: true, submitError: null });
const submitChoice = () => {
this.taskExecutionsService.submitInteractionText(
executionId,
executionNodeId,
choiceField,
result.choice
).subscribe({
next: () => {
this.interactionSubmitting = false;
this.humanInteractionDialog.close(result);
},
error: (error) => {
this.handleDecisionSubmitError(error);
this.taskExecutionsService.refresh();
}
});
};
if (rationale && rationaleField) {
this.taskExecutionsService.submitInteractionText(
executionId,
executionNodeId,
rationaleField,
rationale
).subscribe({
next: submitChoice,
error: (error) => this.handleDecisionSubmitError(error)
});
return;
}
submitChoice();
}
private handleDecisionSubmitError(error: unknown) {
this.interactionSubmitting = false;
const errorBody = (error as { error?: unknown })?.error;
const serialized = typeof errorBody === 'string'
? errorBody
: JSON.stringify(errorBody ?? {});
const invalidChoice = serialized.includes('HUMAN_DECISION_INVALID_CHOICE');
this.humanInteractionDialog.update({
isSubmitting: false,
submitError: invalidChoice
? 'The selected option is no longer valid. Reload the execution and choose again.'
: 'Unable to submit the decision. Your choice and rationale have been preserved.'
});
}
private interactionRuntimeInputs(): HumanInteractionRuntimeInput[] {
return this.resolvePorts('input').map((input) => ({
name: input.name,
value: this.executionInputTooltip(input.name) ?? 'Not available'
}));
}
private decisionQuestion(): string {
const question = this.blockConfiguration?.['question'];
return typeof question === 'string' ? question : '';
}
private decisionRationaleLabel(): string {
const label = this.blockConfiguration?.['rationaleLabel'];
return typeof label === 'string' && label.trim().length > 0 ? label.trim() : 'Rationale';
}
private decisionOptions(): HumanDecisionOption[] {
const options = this.blockConfiguration?.['options'];
if (!Array.isArray(options)) return [];
return options.flatMap((option) => {
if (!option || typeof option !== 'object' || Array.isArray(option)) return [];
const record = option as Record<string, unknown>;
const name = typeof record['name'] === 'string' ? record['name'].trim() : '';
const label = typeof record['label'] === 'string' ? record['label'].trim() : '';
return name ? [{ name, label: label || name }] : [];
}).slice(0, 10);
}
private parseChatHistoryLine(rawLine: string): { role: 'user' | 'assistant' | 'system'; content: string } | null {
const line = rawLine.trim();
if (!line) return null;
const prefixed = line.match(/^\[(USER|ASSISTANT|SYSTEM)\]\s*([\s\S]*)$/i);
if (prefixed) {
const role = prefixed[1].toLowerCase() as 'user' | 'assistant' | 'system';
return {
role,
content: prefixed[2] ?? ''
};
}
return {
role: 'assistant',
content: line
};
}
private extractArrayFieldDefinitions(schema: Record<string, any> | null): ArrayFieldDefinition[] {
if (!schema) return [];
const definitions: ArrayFieldDefinition[] = [];
const seen = new Set<string>();
const walk = (node: Record<string, any>, pathPrefix: string) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
const properties = orderedSchemaPropertyEntries(resolved, schema);
if (!properties.length) return;
for (const { key, schema: childResolved } of properties) {
if (!childResolved) continue;
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
if (childResolved?.['type'] === 'array') {
if (shouldSkipSchemaField(key, childResolved) || key === 'name' || isFlowDataFieldPath(path, this.resolveFlowFieldDefinitions()) || seen.has(path)) {
continue;
}
seen.add(path);
definitions.push({
path,
label: schemaFieldLabel(path, childResolved),
itemSchema: this.resolveArrayItemSchema(childResolved, schema)
});
continue;
}
const hasChildren = !!childResolved?.['properties'] || childResolved?.['type'] === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path);
}
}
};
walk(schema, '');
return this.isContainerNode()
? definitions.filter((definition) => !isFlowDataFieldPath(definition.path, this.resolveFlowFieldDefinitions()))
: definitions;
}
private resolveFlowFieldDefinitions(): SchemaFlowDataFieldDefinition[] {
if (this.flowFieldDefinitions.length) return this.flowFieldDefinitions;
const config = this.blockConfiguration ?? {};
return Object.keys(config)
.filter((key) => normalizeFlowDataValue(config[key]))
.map((key) => ({
path: key,
label: key === 'subFlow' ? 'Subflow' : pathToLabel(key),
retrieverBlockType: null,
retrieverKey: null,
retrieverUrl: null,
retrieverStructuredData: false,
retrieverDependsOn: [],
validationUrl: null,
validationType: null,
requiresAuth: false,
ui: {
widget: null,
acceptVariableAsPlaceholder: false,
structural: true,
bindableAsInput: false,
inputName: null,
inputType: null,
inputMultiple: null,
visibleWhen: [],
enabledWhen: [],
group: null
}
}));
}
private flowAtPath(path: string): FlowData | null {
return normalizeFlowDataValue(this.getByPath(this.blockConfiguration ?? {}, path));
}
private shouldHideConfigPath(path: string): boolean {
const isContainerTypePath = [
'type',
'typeName',
'containerType',
'configurationType',
'configurationClass'
].some((key) => path === key || path.endsWith(`.${key}`));
return this.isContainerNode()
&& (
isContainerTypePath
|| isFlowDataFieldPath(path, this.resolveFlowFieldDefinitions())
);
}
private toArrayFieldItems(definition: ArrayFieldDefinition, value: unknown): ArrayFieldItemView[] {
if (!Array.isArray(value)) return [];
return value.map((item, index) => ({
index,
summary: this.toArrayItemSummary(definition, item, index)
}));
}
private toArrayItemSummary(definition: ArrayFieldDefinition, item: unknown, index: number) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return `Item ${index + 1}`;
}
const properties = definition.itemSchema?.['properties'] as Record<string, any> | undefined;
if (!properties) return `Item ${index + 1}`;
const summaryParts: string[] = [];
for (const { key } of orderedSchemaPropertyEntries(definition.itemSchema, this.blockSchema ?? definition.itemSchema ?? {})) {
const value = (item as Record<string, unknown>)[key];
if (value == null) continue;
if (typeof value === 'string' && value.trim().length > 0) {
summaryParts.push(value);
} else if (typeof value === 'number' || typeof value === 'boolean') {
summaryParts.push(String(value));
}
if (summaryParts.length === 2) break;
}
return summaryParts.length ? summaryParts.join(' · ') : `Item ${index + 1}`;
}
private 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 getByPath(source: Record<string, any>, path: string): unknown {
const keys = path.split('.').filter(Boolean);
let current: unknown = source;
for (const key of keys) {
if (!current || typeof current !== 'object' || Array.isArray(current)) return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
private displayLabelForPath(path: string): string {
const typeKey = this.nodeTypeCacheKey();
if (!typeKey || !this.blockSchema) return schemaFieldLabel(path, this.resolveFieldSchema(path));
const cache = this.getGlobalCache(TaskStepNodeComponent.globalFieldLabelCache, typeKey);
if (cache.has(path)) {
return cache.get(path) ?? path;
}
const label = schemaFieldLabel(path, this.resolveFieldSchema(path));
cache.set(path, label);
return label;
}
private shouldRenderWideField(label: string, isTextarea: boolean) {
return isTextarea || label.trim().length >= 18;
}
private isEmptyDisplayValue(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.isEmptyDisplayValue(item));
if (typeof value === 'object') {
const entries = Object.values(value as Record<string, unknown>);
return entries.length === 0 || entries.every((item) => this.isEmptyDisplayValue(item));
}
return false;
}
private refreshView() {
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while async updates were running.
}
});
}
private isPathVisible(path: string, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
const ui = this.getFieldUiMeta(path);
return ui.visibleWhen.every((rule) => {
if (!rule) return true;
return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath));
});
}
private groupLabelForPath(path: string): string | null {
return this.getFieldUiMeta(path).group ?? parentGroupLabel(path);
}
private resolveFieldSchema(path: string): Record<string, any> | null {
const typeKey = this.nodeTypeCacheKey();
if (!typeKey || !this.blockSchema) {
return resolveSchemaPath(this.blockSchema, path);
}
const cache = this.getGlobalCache(TaskStepNodeComponent.globalFieldSchemaCache, typeKey);
if (cache.has(path)) {
return cache.get(path) ?? null;
}
const resolved = resolveSchemaPath(this.blockSchema, path);
cache.set(path, resolved);
return resolved;
}
private getFieldUiMeta(path: string): FieldUiMeta {
const typeKey = this.nodeTypeCacheKey();
const globalCache = typeKey && this.blockSchema
? this.getGlobalCache(TaskStepNodeComponent.globalFieldUiMetaCache, typeKey)
: null;
const cached = globalCache?.get(path);
if (cached) return cached;
const root = this.blockSchema;
if (!root) {
const empty = this.toFieldUiMeta(null);
globalCache?.set(path, empty);
return empty;
}
let current: Record<string, any> | null = root;
let inheritedUi = { visibleWhen: [] as UiConditionRule[], group: null as string | null };
for (const segment of path.split('.')) {
if (!current) {
const empty = this.toFieldUiMeta(null, inheritedUi);
globalCache?.set(path, empty);
return empty;
}
const resolved = resolveSchemaRef(current, root);
if (/^\d+$/.test(segment)) {
const items = resolved?.items;
if (!items || typeof items !== 'object') {
const empty = this.toFieldUiMeta(null, inheritedUi);
globalCache?.set(path, empty);
return empty;
}
current = resolveSchemaRef(items as Record<string, any>, root);
} else {
const properties = resolved?.properties as Record<string, unknown> | undefined;
if (!properties || !properties[segment]) {
const empty = this.toFieldUiMeta(null, inheritedUi);
globalCache?.set(path, empty);
return empty;
}
current = resolveSchemaRef(properties[segment] as Record<string, any>, root);
}
const nextUi = this.toFieldUiMeta(current, inheritedUi);
inheritedUi = {
visibleWhen: nextUi.visibleWhen,
group: nextUi.group
};
}
const meta = this.toFieldUiMeta(current, inheritedUi);
globalCache?.set(path, meta);
return meta;
}
private nodeTypeCacheKey(): string | null {
const type = this.blockType;
if (!type) return null;
const family = this.resolvedNodeFamily();
return `${family}:${type}`;
}
private resolvedNodeFamily(): 'block' | 'container' {
if (this.data?.data?.nodeFamily === 'container') {
return 'container';
}
const config = this.blockConfiguration;
if (config && Object.values(config).some((value) => normalizeFlowDataValue(value))) {
return 'container';
}
const type = this.blockType;
if (type && this.containersService.peekContainerType(type)) {
return 'container';
}
return 'block';
}
private getGlobalCache<T>(store: Map<string, Map<string, T>>, typeKey: string): Map<string, T> {
let cache = store.get(typeKey);
if (!cache) {
cache = new Map<string, T>();
store.set(typeKey, cache);
}
return cache;
}
private toFieldUiMeta(
schema: Record<string, any> | null | undefined,
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
): FieldUiMeta {
const visibleWhen = readEffectiveUiVisibleConditionRule(schema);
const label = readUiLabel(schema?.['x-ui-label']);
const isObjectLike = schema?.['type'] === 'object' || !!schema?.['properties'];
const group = readUiGroup(schema?.['x-ui-group'])
?? (isObjectLike ? label ?? null : null)
?? inheritedUi?.group
?? null;
const rawWidget = typeof schema?.['x-ui-widget'] === 'string'
? String(schema['x-ui-widget']).toLowerCase().trim()
: '';
const widget: 'textarea' | null =
rawWidget === 'textarea' || rawWidget === 'text-area' ? 'textarea' : null;
const acceptVariableAsPlaceholder = schema?.['x-ui-accept-variable-as-placeholder'] === true;
return {
visibleWhen: [
...(inheritedUi?.visibleWhen ?? []),
...(visibleWhen ? [visibleWhen] : [])
],
group,
widget,
acceptVariableAsPlaceholder
};
}
private isLongTextValue(value: string): boolean {
const normalized = String(value ?? '');
if (!normalized.trim()) return false;
const lineCount = normalized.split(/\r?\n/).length;
return lineCount > 2 || normalized.length > 80;
}
/**
* A value with `${{name}}` placeholders is shown as text plus its own expandable segment per
* placeholder (via the `template` field type / TemplatePlaceholderTextComponent), never as a
* flat string replace - the latter silently duplicates a resolved value wherever the same
* placeholder repeats in the source text (e.g. `${{x}} != null && ${{x}}.contains(...)`), and
* dumps a raw, unbounded runtime value inline.
*/
private async openReadonlyTextDialog(label: string, value: string) {
const source = String(value ?? '');
const hasPlaceholders = source.includes('${{');
await this.settingsDialog.open({
title: label,
previewOnly: true,
fields: [
hasPlaceholders
? {
key: 'value',
label,
type: 'template',
readonly: true,
templateValues: this.templateSubstitutions()
}
: {
key: 'value',
label,
type: 'textarea',
readonly: true,
rows: 18
}
],
initial: {
value: source
}
});
}
}