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

1010 lines
34 KiB
TypeScript

import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { BlockInteractionContract, BlockType, FlowBlock, FlowContainer, FlowData, FlowPort } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import {
type UiConditionRule,
evaluateUiConditionRule,
flattenPrimitiveValues,
parentPath,
pathToLabel,
readUiConditionRule,
readUiGroup,
resolveSchemaRef,
resolveSchemaPath,
schemaFieldLabel,
shouldSkipSchemaField,
splitTemplatedTextParts,
toStringOrNull,
valueToDisplayString
} from '../node-utility';
type DisplayField = {
path: string;
label: string;
value: string;
wide: 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;
parts: { text: string; isDynamicInput: boolean }[];
};
type PortLabelParts = {
context: string | null;
name: string;
};
@Component({
selector: 'app-task-step-node',
imports: [CommonModule, ReteModule],
templateUrl: './task-step-node.html',
styleUrl: './task-step-node.css',
host: {
'data-testid': 'task-step-node'
}
})
export class TaskStepNodeComponent {
private blocksService = inject(BlocksService);
private containersService = inject(ContainersService);
private subflowPreview = inject(SubflowPreviewDialogService);
private cdr = inject(ChangeDetectorRef);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private taskExecutionsService = inject(TaskExecutionsService);
@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;
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
parameterFields: DisplayField[] = [];
parameterFieldGroups: DisplayFieldGroup[] = [];
arrayFields: ArrayFieldView[] = [];
name = 'Step';
mainContentFields: MainContentView[] = [];
interactionSubmitting = false;
private blockSchema: Record<string, any> | null = null;
private blockDescriptor: BlockType | null = null;
private variablePlaceholderPaths = new Set<string>();
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
private mainContentPaths = new Set<string>();
ngOnInit() {
this.outputs = [];
this.inputs = [];
this.parameterFields = [];
this.parameterFieldGroups = [];
this.arrayFields = [];
Object.entries(this.data.outputs).forEach(([key, output]) => {
this.outputs.push({ key, socket: (output as any).socket });
});
Object.entries(this.data.inputs).forEach(([key, input]) => {
this.inputs.push({ key, socket: (input as any).socket });
});
this.rebuildDisplayState();
void this.loadSchemaContext();
}
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 mainContentEntries = visibleEntries
.filter((entry) => this.mainContentPaths.has(entry.path))
.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: this.displayLabelForPath(entry.path),
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) => ({
path: entry.path,
label: this.displayLabelForPath(entry.path),
value: valueToDisplayString(entry.value),
wide: this.shouldRenderWideField(this.displayLabelForPath(entry.path), this.mainContentPaths.has(entry.path))
}));
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 !!this.interactionContract();
}
isConditionalNode(): boolean {
const outputNames = this.resolvePorts('output').map((port) => port.name.trim().toLowerCase());
return outputNames.includes('true') && outputNames.includes('false');
}
nodeTitle(): string {
const type = this.blockType;
if (!type) return 'Task Step';
return type
.replace(/Block$/, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.trim();
}
outputsTitle(): string {
return this.isConditionalNode() ? 'On Condition' : 'Outputs';
}
outputPillClass(outputKey: string): string | null {
if (!this.isConditionalNode()) return null;
const normalized = outputKey.trim().toLowerCase();
if (normalized === 'true') return 'llm-pill-output-true';
if (normalized === 'false') return 'llm-pill-output-false';
return null;
}
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.data?.data?.nodeFamily === 'container';
}
hasViewableSubflow(): boolean {
const subFlow = this.subFlow();
return !!subFlow && ((subFlow.blocks?.length ?? 0) > 0 || (subFlow.containers?.length ?? 0) > 0 || (subFlow.connections?.length ?? 0) > 0);
}
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);
}
inputValueTooltip(inputName: string): string {
return this.executionInputTooltip(inputName) ?? 'not ready yet';
}
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;
}
executionWarnings(): string[] {
return this.getExecutionMessages('__executionWarnings');
}
hasExecutionWarnings(): boolean {
return this.executionWarnings().length > 0;
}
needsAttention(): boolean {
return this.blockConfiguration?.['__isWaitingStep'] === true;
}
isCompleted(): boolean {
return this.stepStatus() === 'COMPLETED';
}
isRunning(): boolean {
return this.stepStatus() === 'RUNNING';
}
isSkipped(): boolean {
return this.stepStatus() === 'SKIPPED';
}
stepStatus(): string {
const status = this.blockConfiguration?.['__stepStatus'];
return typeof status === 'string' ? status.toUpperCase() : '';
}
async openInteractionModal(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.interactionSubmitting) 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(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const subFlow = this.subFlow();
if (!subFlow) return;
this.subflowPreview.open(subFlow, `${this.name || this.nodeTitle()} subflow`);
}
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;
}
private actionDescriptionValue(): string {
const value = this.blockConfiguration?.['actionDescription'];
if (typeof value === 'string') return value;
return '';
}
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 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.variablePlaceholderPaths.has(path)) {
return splitTemplatedTextParts(value);
}
return [{ text: value, isDynamicInput: false }];
}
private async loadSchemaContext() {
const type = this.blockType;
if (!type) return;
const nodeFamily = this.data?.data?.nodeFamily === 'container'
? 'container'
: 'block';
const typeDescriptor = 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;
this.variablePlaceholderPaths = this.extractVariablePlaceholderPaths(this.blockSchema);
this.arrayFieldDefinitions = this.extractArrayFieldDefinitions(this.blockSchema);
this.mainContentPaths = this.extractMainContentPaths(this.blockSchema);
this.rebuildDisplayState();
}
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'}`;
}
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 executionResultData(): Record<string, unknown> | null {
const value = this.blockConfiguration?.['__executionResultData'];
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() : this.executionResultData();
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(),
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
};
}
private submitInteractionResult(
executionId: string,
executionNodeId: string,
contract: BlockInteractionContract,
result: { mode: 'message' | 'complete'; value: string }
) {
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;
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 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 extractVariablePlaceholderPaths(schema: Record<string, any> | null): Set<string> {
const paths = new Set<string>();
if (!schema) return paths;
const walk = (node: Record<string, any>, pathPrefix: string) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path);
continue;
}
const rawWidget = typeof childResolved?.['x-ui-widget'] === 'string'
? String(childResolved['x-ui-widget']).toLowerCase().trim()
: '';
const isTextarea = rawWidget === 'textarea' || rawWidget === 'text-area';
const acceptsVariable = childResolved?.['x-ui-accept-variable-as-placeholder'] === true;
if (isTextarea && acceptsVariable) {
paths.add(path);
}
}
};
walk(schema, '');
return paths;
}
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 = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
if (childResolved?.type === 'array') {
if (shouldSkipSchemaField(key, childResolved) || key === 'name' || 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) => !definition.path.startsWith('subFlow.'))
: definitions;
}
private subFlow(): FlowData | null {
if (!this.isContainerNode()) return null;
const raw = this.blockConfiguration?.['subFlow'];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const candidate = raw as Record<string, unknown>;
const blocks = this.normalizeSubFlowBlocks(candidate['blocks']);
const containers = this.normalizeSubFlowContainers(candidate['containers']);
const connections = Array.isArray(candidate['connections'])
? candidate['connections'].filter((item): item is FlowData['connections'][number] => !!item && typeof item === 'object')
: [];
if (!blocks.length && !containers.length && !connections.length) return null;
return { blocks, containers, connections };
}
private normalizeSubFlowBlocks(raw: unknown): FlowBlock[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item) => ({
...item,
nodeFamily: 'block',
position: this.normalizePosition(item['position'])
})) as FlowBlock[];
}
private normalizeSubFlowContainers(raw: unknown): FlowContainer[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item) => ({
...item,
nodeFamily: 'container',
position: this.normalizePosition(item['position'])
})) as FlowContainer[];
}
private normalizePosition(raw: unknown): { x: number; y: number } | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const value = raw as Record<string, unknown>;
const x = typeof value['x'] === 'number' ? value['x'] : Number(value['x']);
const y = typeof value['y'] === 'number' ? value['y'] : Number(value['y']);
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { x, y };
}
private shouldHideConfigPath(path: string): boolean {
return this.isContainerNode() && (path === 'subFlow' || path.startsWith('subFlow.'));
}
private extractMainContentPaths(schema: Record<string, any> | null): Set<string> {
const paths = new Set<string>();
if (!schema) return paths;
const walk = (node: Record<string, any>, pathPrefix: string) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path);
continue;
}
const rawWidget = typeof childResolved?.['x-ui-widget'] === 'string'
? String(childResolved['x-ui-widget']).toLowerCase().trim()
: '';
const isTextarea = rawWidget === 'textarea' || rawWidget === 'text-area';
const acceptsVariable = childResolved?.['x-ui-accept-variable-as-placeholder'] === true;
if (isTextarea && acceptsVariable) {
paths.add(path);
}
}
};
walk(schema, '');
return paths;
}
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 Object.keys(properties)) {
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 {
return schemaFieldLabel(path, this.resolveFieldSchema(path));
}
private shouldRenderWideField(label: string, isTextarea: boolean) {
return isTextarea || label.trim().length >= 18;
}
private isEmptyDisplayValue(value: unknown) {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;
if (Array.isArray(value)) return value.length === 0;
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;
if (!this.isPathVisible(rule.field, visited)) return false;
return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath));
});
}
private groupLabelForPath(path: string): string | null {
return this.getFieldUiMeta(path).group ?? parentPath(path);
}
private resolveFieldSchema(path: string): Record<string, any> | null {
return resolveSchemaPath(this.blockSchema, path);
}
private getFieldUiMeta(path: string) {
const root = this.blockSchema;
if (!root) return this.toFieldUiMeta(null);
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) return this.toFieldUiMeta(null, inheritedUi);
const resolved = resolveSchemaRef(current, root);
if (/^\d+$/.test(segment)) {
const items = resolved?.items;
if (!items || typeof items !== 'object') return this.toFieldUiMeta(null, inheritedUi);
current = resolveSchemaRef(items as Record<string, any>, root);
} else {
const properties = resolved?.properties as Record<string, unknown> | undefined;
if (!properties || !properties[segment]) return this.toFieldUiMeta(null, inheritedUi);
current = resolveSchemaRef(properties[segment] as Record<string, any>, root);
}
const nextUi = this.toFieldUiMeta(current, inheritedUi);
inheritedUi = {
visibleWhen: nextUi.visibleWhen,
group: nextUi.group
};
}
return this.toFieldUiMeta(current, inheritedUi);
}
private toFieldUiMeta(
schema: Record<string, any> | null | undefined,
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
) {
const visibleWhen = readUiConditionRule(schema?.['x-ui-visible-when']);
const group = readUiGroup(schema?.['x-ui-group']) ?? inheritedUi?.group ?? null;
return {
visibleWhen: [
...(inheritedUi?.visibleWhen ?? []),
...(visibleWhen ? [visibleWhen] : [])
],
group
};
}
}