Improve schema-driven nodes and interactive execution chat

This commit is contained in:
Lucio Lelii 2026-03-19 15:18:09 +01:00
parent 971d829685
commit 19b0dadacc
23 changed files with 771 additions and 156 deletions

View File

@ -1,4 +1,4 @@
# HumainFlowGuiA21
# HumAInFlow
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.3.

View File

@ -12,5 +12,5 @@ import { SubflowPreviewDialogHostComponent } from '@shared/subflow-preview-dialo
styleUrl: './app.css'
})
export class App {
protected readonly title = signal('humainFlow-gui-a21');
protected readonly title = signal('HumAInFlow');
}

View File

@ -42,11 +42,23 @@ export type NodeFamily = 'block' | 'container';
export type BlockTypeSchema = Record<string, unknown> | null;
export type BlockInteractionContractKind = 'chat-session' | 'single-response' | string;
export type BlockInteractionContract = {
kind: BlockInteractionContractKind;
messageField: string | null;
completionField: string | null;
historyField: string | null;
responseField: string | null;
supportsPartialResult: boolean;
};
export type BlockType = {
type: BlockTypeName;
family: NodeFamily;
description: string;
userInteractive: boolean;
interactionContract?: BlockInteractionContract | null;
hasExampleBlock?: boolean;
exampleBlockEndpoint?: string | null;
configurationType: string | null;

View File

@ -44,6 +44,7 @@ export type TaskExecutionStep = {
id: string;
inputs: TaskExecutionStepInput[];
outputs: TaskExecutionStepOutput[];
result?: Record<string, unknown>;
status: StepStatus;
started: boolean;
simulated: boolean;

View File

@ -5,7 +5,10 @@
.editor-sidebar-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
min-width: 0;
gap: 1.5rem;
}

View File

@ -48,7 +48,7 @@
<div class="editor-sidebar-content h-full min-h-0 min-w-0 overflow-hidden">
@switch (open) {
@case ('flows') {
<div data-tour="editor-sidebar-flows">
<div data-tour="editor-sidebar-flows" class="h-full min-h-0 flex flex-col">
<app-group-holder title="Flows" icon="bi-lightning-charge-fill">
@if (!creatingFlow()) {
<i ngProjectAs="header-button"
@ -64,14 +64,14 @@
</div>
}
@case ('blocks') {
<div data-tour="editor-sidebar-blocks">
<div data-tour="editor-sidebar-blocks" class="h-full min-h-0 flex flex-col">
<app-group-holder title="Blocks" icon="bi-boxes">
<app-blocks-list></app-blocks-list>
</app-group-holder>
</div>
}
@case ('containers') {
<div data-tour="editor-sidebar-containers">
<div data-tour="editor-sidebar-containers" class="h-full min-h-0 flex flex-col">
<app-group-holder title="Containers" icon="bi-box-seam">
<app-containers-list></app-containers-list>
</app-group-holder>

View File

@ -9,6 +9,14 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
"family": "block",
"description": "A block that requires human interaction",
"userInteractive": true,
"interactionContract": {
"kind": "single-response",
"messageField": null,
"completionField": "output",
"historyField": null,
"responseField": "output",
"supportsPartialResult": false
},
"configurationType": "HumanInteractiveBlockConfiguration",
"configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration",
"schema": {

View File

@ -91,6 +91,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
family: 'block',
description: String(value["description"] ?? ""),
userInteractive: Boolean(value["userInteractive"] ?? value["interactive"] ?? false),
interactionContract: this.toInteractionContract(value["interactionContract"]),
hasExampleBlock: Boolean(value["hasExampleBlock"] ?? false),
exampleBlockEndpoint: this.toApiPath(value["exampleBlockEndpoint"]),
configurationType: this.toNullableString(value["configurationType"]),
@ -166,6 +167,27 @@ export class BlocksCallService extends BlocksCallServiceBase {
return raw as Record<string, unknown>;
}
private toInteractionContract(raw: unknown): BlockType["interactionContract"] {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const kind = typeof value["kind"] === "string" && value["kind"].trim().length > 0
? value["kind"].trim()
: null;
if (!kind) return null;
const asNullableString = (input: unknown) =>
typeof input === "string" && input.trim().length > 0 ? input.trim() : null;
return {
kind,
messageField: asNullableString(value["messageField"]),
completionField: asNullableString(value["completionField"]),
historyField: asNullableString(value["historyField"]),
responseField: asNullableString(value["responseField"]),
supportsPartialResult: value["supportsPartialResult"] === true
};
}
private toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;

View File

@ -1,13 +1,25 @@
import { Injectable, signal } from '@angular/core';
import { BlockInteractionContractKind } from '@models/flow';
export type HumanInteractionChatMessage = {
role: 'user' | 'assistant' | 'system';
content: string;
};
export type HumanInteractionDialogInput = {
title?: string;
actionDescription: string;
currentInput: string;
kind: BlockInteractionContractKind;
actionDescription?: string;
currentInput?: string;
history?: HumanInteractionChatMessage[];
latestResponse?: string;
messageField?: string | null;
completionField?: string | null;
};
export type HumanInteractionDialogResult = {
mode: 'confirm' | 'edit';
mode: 'message' | 'complete';
value: string;
};
@ -15,8 +27,13 @@ export type HumanInteractionDialogResult = {
export class HumanInteractionDialogService {
private _state = signal<{
title: string;
kind: BlockInteractionContractKind;
actionDescription: string;
currentInput: string;
history: HumanInteractionChatMessage[];
latestResponse: string;
messageField: string | null;
completionField: string | null;
resolve: (value: HumanInteractionDialogResult | null) => void;
} | null>(null);
@ -26,8 +43,13 @@ export class HumanInteractionDialogService {
return new Promise((resolve) => {
this._state.set({
title: input.title ?? 'Human interaction',
actionDescription: input.actionDescription,
currentInput: input.currentInput,
kind: input.kind,
actionDescription: input.actionDescription ?? '',
currentInput: input.currentInput ?? '',
history: input.history ?? [],
latestResponse: input.latestResponse ?? '',
messageField: input.messageField ?? null,
completionField: input.completionField ?? null,
resolve
});
});

View File

@ -6,11 +6,58 @@
<div class="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4">
<div>
<h3 class="text-lg font-semibold text-slate-900">{{ currentState.title }}</h3>
<p class="mt-1 text-sm text-slate-500">Confirm the input as node output or edit it before sending.</p>
<p class="mt-1 text-sm text-slate-500">
@if (currentState.kind === 'chat-session') {
Continue the chat session or send the final completion value.
} @else {
Confirm the input as node output or edit it before sending.
}
</p>
</div>
<button type="button" mat-stroked-button (click)="cancel($event)">Close</button>
</div>
@if (currentState.kind === 'chat-session') {
<div class="flex min-h-0 flex-1 flex-col bg-slate-50">
<div class="flex-1 overflow-y-auto px-5 py-5">
<div class="flex flex-col gap-3">
@for (message of currentState.history; track $index) {
<div class="flex" [class.justify-end]="message.role === 'user'" [class.justify-start]="message.role !== 'user'">
<div
class="max-w-[85%] rounded-2xl px-4 py-3 shadow-sm"
[class.bg-emerald-600]="message.role === 'user'"
[class.text-white]="message.role === 'user'"
[class.bg-white]="message.role !== 'user'"
[class.text-slate-900]="message.role !== 'user'"
[class.border]="message.role !== 'user'"
[class.border-slate-200]="message.role !== 'user'">
<div class="text-[11px] font-semibold uppercase tracking-wide opacity-70">{{ message.role }}</div>
<div class="mt-1 whitespace-pre-wrap break-words text-sm">{{ message.content }}</div>
</div>
</div>
}
@if (!currentState.history.length) {
<div class="py-8 text-center text-sm text-slate-500">No messages yet.</div>
}
</div>
</div>
<div class="border-t border-slate-200 bg-white px-5 py-4">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Type a message</mat-label>
<textarea
matInput
class="min-h-[3.5rem] max-h-[14rem] resize-y"
rows="2"
[ngModel]="draftValue"
[attr.data-autofocus]="'true'"
(ngModelChange)="setDraftValue($event)">
</textarea>
</mat-form-field>
</div>
</div>
} @else {
<div class="grid flex-1 gap-4 overflow-y-auto px-5 py-4">
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Action Description</legend>
@ -24,11 +71,13 @@
@if (editing()) {
<mat-form-field appearance="outline">
<mat-label>Edit Response</mat-label>
<mat-label>
Edit Response
</mat-label>
<textarea
matInput
class="min-h-56"
rows="10"
class="min-h-40"
rows="7"
[ngModel]="draftValue"
[attr.data-autofocus]="'true'"
(ngModelChange)="setDraftValue($event)">
@ -36,10 +85,14 @@
</mat-form-field>
}
</div>
}
<div class="flex justify-end gap-2 border-t border-slate-200 px-5 py-4">
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
@if (!editing()) {
@if (currentState.kind === 'chat-session') {
<button type="button" mat-stroked-button [disabled]="!canSendEditedOutput()" (click)="sendChatMessage($event)">Send</button>
<button type="button" mat-flat-button [disabled]="!canSendEditedOutput()" (click)="completeChatSession($event)">Send Final Response</button>
} @else if (!editing()) {
<button type="button" mat-stroked-button (click)="startEditing($event)">Edit Response</button>
<button type="button" mat-flat-button (click)="confirmInput($event)">Confirm Input</button>
} @else {

View File

@ -26,8 +26,8 @@ export class HumanInteractionDialogHostComponent {
effect(() => {
const state = this.state();
if (!state) return;
this.editing.set(false);
this.draftValue = state.currentInput;
this.editing.set(state.kind !== 'chat-session');
this.draftValue = '';
queueMicrotask(() => {
const target = this.host.nativeElement.querySelector('[data-autofocus="true"]') as HTMLElement | null;
target?.focus();
@ -64,7 +64,7 @@ export class HumanInteractionDialogHostComponent {
confirmInput(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.closeWith({ mode: 'confirm', value: this.state()?.currentInput ?? '' });
this.closeWith({ mode: 'complete', value: this.state()?.currentInput ?? '' });
}
sendEditedOutput(event?: Event) {
@ -72,7 +72,23 @@ export class HumanInteractionDialogHostComponent {
event?.stopPropagation();
const value = this.draftValue.trim();
if (!value) return;
this.closeWith({ mode: 'edit', value: this.draftValue });
this.closeWith({ mode: 'complete', value: this.draftValue });
}
sendChatMessage(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const value = this.draftValue.trim();
if (!value) return;
this.closeWith({ mode: 'message', value: this.draftValue });
}
completeChatSession(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const value = this.draftValue.trim();
if (!value) return;
this.closeWith({ mode: 'complete', value: this.draftValue });
}
canSendEditedOutput(): boolean {

View File

@ -272,6 +272,10 @@
padding: 8px 10px;
}
.container-node__param-chip--disabled {
opacity: 0.58;
}
.container-node__param-chip--wide {
grid-column: 1 / -1;
}
@ -318,6 +322,12 @@
background: rgba(15, 118, 110, 0.12);
}
.container-node__param-edit:disabled {
opacity: 0.45;
cursor: default;
pointer-events: none;
}
.container-node__dropzone {
margin: 0 14px 14px;
min-height: 128px;

View File

@ -100,13 +100,14 @@
@if (hasParameterFields()) {
<div class="container-node__params">
@for (field of parameterFields; track field.path) {
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide">
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--disabled]="!field.enabled">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ field.label }}</span>
@if (!isReadonly) {
<button
type="button"
class="container-node__param-edit"
[disabled]="!field.enabled"
title="Edit field"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData } from '@models/flow';
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { NodeSettingField, NodeSettingOption, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { ContainersService } from '@services/containers/containers';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { ClassicPreset } from 'rete';
@ -11,17 +11,25 @@ import { EditorStateHolder } from '@stores/flow-editor';
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
import { firstValueFrom } from 'rxjs';
import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import { evaluateUiConditionRule, getValueByPath, pathToLabel, readUiConditionRule, resolveSchemaRef, valueToDisplayString } from '../node-utility';
import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, readUiConditionRule, resolveSchemaPath, resolveSchemaRef, schemaFieldLabel, shouldSkipSchemaField, valueToDisplayString } from '../node-utility';
type ContainerFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown';
type NodeOptionsSource = {
collection: 'inputs' | 'outputs';
valueField: string;
labelField: string;
};
type ContainerFieldDefinition = {
path: string;
label: string;
type: ContainerFieldType;
enumOptions: string[];
nodeOptionsSource: NodeOptionsSource | null;
widget: 'textarea' | null;
structural: boolean;
enabledWhen: ReturnType<typeof readUiConditionRule>[];
};
type ContainerFieldView = {
@ -29,6 +37,7 @@ type ContainerFieldView = {
label: string;
value: string;
wide: boolean;
enabled: boolean;
};
type StructuredRetrieverConfig = {
@ -56,7 +65,7 @@ export class ContainerNodeComponent {
private containersService = inject(ContainersService);
private settingsDialog = inject(NodeSettingsDialogService);
private containerSchema: Record<string, any> | null = null;
private schemaRequirements: SchemaRequirements = { required: [], conditional: [] };
private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] };
private containerFieldDefinitions: ContainerFieldDefinition[] = [];
deleteConfirmOpen = false;
replaceConfirmSelection: string[] | null = null;
@ -171,8 +180,17 @@ export class ContainerNodeComponent {
return requiredFields
.filter((field, index, fields) => fields.findIndex((candidate) => candidate.path === field.path) === index)
.filter((field) => field.path !== 'name' && field.path !== 'subFlow')
.filter((field) => this.isFieldEnabled(field.path))
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
.map((field) => field.label);
.map((field) => field.label)
.concat(
this.schemaRequirements.requiredObjects
.filter((field) => field.path !== 'name' && field.path !== 'subFlow')
.filter((field) => this.isFieldEnabled(field.path))
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
.map((field) => field.label)
)
.filter((field, index, fields) => fields.indexOf(field) === index);
}
hasParameterFields() {
@ -288,7 +306,7 @@ export class ContainerNodeComponent {
if (this.isReadonly) return;
const definition = this.containerFieldDefinitions.find((field) => field.path === path);
if (!definition) return;
if (!definition || !this.isFieldEnabled(definition.path)) return;
const initialValue = this.getEditorInitialValue(definition);
const field: NodeSettingField = {
@ -297,7 +315,7 @@ export class ContainerNodeComponent {
type: this.toDialogFieldType(definition),
required: this.missingRequiredParams.includes(definition.label),
rows: definition.widget === 'textarea' ? 6 : undefined,
options: definition.enumOptions.map((option) => ({ label: option, value: option }))
options: this.resolveSelectableOptions(definition)
};
const result = await this.settingsDialog.open({
@ -536,6 +554,11 @@ export class ContainerNodeComponent {
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;
}
@ -547,7 +570,8 @@ export class ContainerNodeComponent {
path: field.path,
label: field.label,
value: valueToDisplayString(getValueByPath(config, field.path)),
wide: field.widget === 'textarea' || field.label.length >= 18
wide: field.widget === 'textarea' || field.label.length >= 18,
enabled: this.isFieldEnabled(field.path)
}));
}
@ -563,11 +587,11 @@ export class ContainerNodeComponent {
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
if (key === 'type') continue;
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
if (shouldSkipSchemaField(key, childResolved)) continue;
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
if (path === 'name' || path === 'subFlow') continue;
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path);
@ -576,15 +600,17 @@ export class ContainerNodeComponent {
definitions.push({
path,
label: pathToLabel(path),
label: schemaFieldLabel(path, childResolved),
type: this.toFieldType(childResolved),
enumOptions: Array.isArray(childResolved?.enum)
? childResolved.enum.filter((item: unknown): item is string => typeof item === 'string')
: [],
nodeOptionsSource: this.toNodeOptionsSource(childResolved),
widget: typeof childResolved?.['x-ui-widget'] === 'string' && String(childResolved['x-ui-widget']).toLowerCase() === 'textarea'
? 'textarea'
: null,
structural: childResolved?.['x-ui-structural'] === true
structural: childResolved?.['x-ui-structural'] === true,
enabledWhen: [readUiConditionRule(childResolved?.['x-ui-enabled-when'])].filter((rule) => !!rule)
});
}
};
@ -607,6 +633,23 @@ export class ContainerNodeComponent {
return evaluateUiConditionRule(rule, this.configuration ?? {}, (fieldPath) => this.resolveFieldSchema(fieldPath));
}
private isFieldEnabled(path: string, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
const parent = parentPath(path);
if (parent && !this.isFieldEnabled(parent, visited)) return false;
const schema = this.resolveFieldSchema(path);
const rules = [readUiConditionRule(schema?.['x-ui-enabled-when'])].filter((rule) => !!rule);
return rules.every((rule) => {
if (!rule) return true;
const dependency = typeof rule.field === 'string' ? rule.field : null;
if (dependency && !this.isFieldVisible(dependency, visited)) return false;
return evaluateUiConditionRule(rule, this.configuration ?? {}, (fieldPath) => this.resolveFieldSchema(fieldPath));
});
}
private toFieldType(schema: Record<string, any> | null): ContainerFieldType {
const type = typeof schema?.['type'] === 'string' ? String(schema['type']) : 'unknown';
if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') {
@ -618,10 +661,59 @@ export class ContainerNodeComponent {
private toDialogFieldType(definition: ContainerFieldDefinition): NodeSettingField['type'] {
if (definition.type === 'boolean') return 'checkbox';
if (definition.widget === 'textarea') return 'textarea';
if (definition.enumOptions.length) return 'select';
if (definition.enumOptions.length || definition.nodeOptionsSource) return 'select';
return 'text';
}
private toNodeOptionsSource(schema: Record<string, any> | null | undefined): NodeOptionsSource | null {
if (!schema || typeof schema !== 'object') return null;
const raw = schema['x-ui-options-from-node'];
if (!raw || typeof raw !== 'object') return null;
const collection = (raw as Record<string, unknown>)['collection'];
const valueField = (raw as Record<string, unknown>)['valueField'];
const labelField = (raw as Record<string, unknown>)['labelField'];
if ((collection !== 'inputs' && collection !== 'outputs')
|| typeof valueField !== 'string'
|| typeof labelField !== 'string'
|| valueField.trim().length === 0
|| labelField.trim().length === 0) {
return null;
}
return {
collection,
valueField: valueField.trim(),
labelField: labelField.trim()
};
}
private resolveSelectableOptions(definition: ContainerFieldDefinition): NodeSettingOption[] | undefined {
if (definition.nodeOptionsSource) {
return this.resolveNodeOptions(definition.nodeOptionsSource);
}
if (!definition.enumOptions.length) return undefined;
return definition.enumOptions.map((option) => ({ label: option, value: option }));
}
private resolveNodeOptions(source: NodeOptionsSource): NodeSettingOption[] {
const items = source.collection === 'inputs'
? (Array.isArray(this.data?.data?.inputs) ? this.data.data.inputs : [])
: (Array.isArray(this.data?.data?.outputs) ? this.data.data.outputs : []);
return items
.map((item: unknown) => {
const record = item && typeof item === 'object' ? item as Record<string, unknown> : null;
const value = record?.[source.valueField];
const label = record?.[source.labelField];
if (typeof value !== 'string' || typeof label !== 'string' || value.trim().length === 0 || label.trim().length === 0) {
return null;
}
return { label, value } satisfies NodeSettingOption;
})
.filter((option: NodeSettingOption | null): option is NodeSettingOption => option != null);
}
private getEditorInitialValue(definition: ContainerFieldDefinition): string | boolean {
const raw = getValueByPath(this.configuration ?? {}, definition.path);
if (definition.type === 'boolean') return raw === true;
@ -780,17 +872,7 @@ export class ContainerNodeComponent {
}
private resolveFieldSchema(path: string): Record<string, any> | null {
if (!this.containerSchema) return null;
let current: Record<string, any> | null = this.containerSchema;
for (const key of path.split('.')) {
const resolved = current ? resolveSchemaRef(current, this.containerSchema) : null;
const properties = resolved?.properties as Record<string, any> | undefined;
if (!properties?.[key]) return null;
current = resolveSchemaRef(properties[key], this.containerSchema) as Record<string, any> | null;
}
return current;
return resolveSchemaPath(this.containerSchema, path);
}
}

View File

@ -717,6 +717,10 @@
overflow: hidden;
}
.llm-param-chip-disabled {
opacity: 0.58;
}
.llm-param-chip-wide {
grid-column: 1 / -1;
}
@ -754,6 +758,12 @@
transform: translateY(0);
}
.llm-edit-btn:disabled {
opacity: 0.45;
cursor: default;
pointer-events: none;
}
.llm-param-block {
border: 1px solid #dbe2ea;
background: #ffffff;

View File

@ -163,13 +163,14 @@
<legend class="llm-param-legend">{{ group.legend }}</legend>
<div class="llm-param-grid">
@for (field of group.fields; track field.path) {
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
[disabled]="!field.enabled"
[attr.title]="'Edit ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">
@ -189,13 +190,14 @@
@if (parameterFields.length) {
<div class="llm-param-grid">
@for (field of parameterFields; track field.path) {
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
[disabled]="!field.enabled"
[attr.title]="'Edit ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">
@ -345,8 +347,8 @@
} @else if (localEditorHasRetriever) {
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
<option value="">{{ localEditorOptions.length ? ('Select ' + (localEditorLabel | lowercase) + '...') : 'No options available' }}</option>
@for (option of localEditorOptions; track option) {
<option [value]="option">{{ option }}</option>
@for (option of localEditorOptions; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
} @else {

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, normalizeFlowPortValueKinds } from '@models/flow';
import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, normalizeFlowPortValueKinds } from '@models/flow';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import {
@ -22,10 +22,16 @@ import {
parentPath,
pathToLabel,
readUiConditionRule,
readUiLabel,
readUiGroup,
resolveSchemaRef,
resolveSchemaPath,
schemaFieldDescription,
schemaFieldLabel,
shouldSkipSchemaField,
splitTemplatedTextParts,
toStringOrNull,
validateUniqueByConstraint,
valueToDisplayString
} from '../node-utility';
@ -36,11 +42,18 @@ type RetrieverDependency = {
path: string;
};
type NodeOptionsSource = {
collection: 'inputs' | 'outputs';
valueField: string;
labelField: string;
};
type EditableFieldDefinition = {
path: string;
label: string;
type: FieldType;
enumOptions: string[];
nodeOptionsSource: NodeOptionsSource | null;
retrieverBlockType: string | null;
retrieverKey: string | null;
retrieverUrl: string | null;
@ -54,10 +67,12 @@ type EditableFieldDefinition = {
inputType: string | null;
inputMultiple: boolean | null;
structuralReason?: string;
label?: string;
placeholder?: string;
tip?: string;
rows?: number;
visibleWhen: UiConditionRule[];
enabledWhen: UiConditionRule[];
group: string | null;
};
};
@ -67,15 +82,18 @@ type EditableFieldView = {
label: string;
value: string;
wide: boolean;
enabled: boolean;
};
type ArrayFieldDefinition = {
path: string;
label: string;
itemSchema: Record<string, any> | null;
uniqueBy: string | null;
ui: {
structural: boolean;
visibleWhen: UiConditionRule[];
enabledWhen: UiConditionRule[];
group: string | null;
};
};
@ -148,7 +166,7 @@ export class GenericNodeComponent {
localEditorPath: string | null = null;
localEditorLabel = '';
localEditorValue = '';
localEditorOptions: string[] = [];
localEditorOptions: NodeSettingOption[] = [];
localEditorLoading = false;
localEditorHasRetriever = false;
localEditorType: FieldType = 'string';
@ -161,9 +179,10 @@ export class GenericNodeComponent {
missingRequiredParams: string[] = [];
private blockSchema: Record<string, any> | null = null;
private blockDescriptor: BlockType | null = null;
private editableFieldDefinitions: EditableFieldDefinition[] = [];
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
private schemaRequirements: SchemaRequirements = { required: [], conditional: [] };
private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] };
private conditionalRequiredByPath = new Map<string, boolean>();
private refreshingConditionalRequirements = false;
@ -231,7 +250,7 @@ export class GenericNodeComponent {
const definition = this.editableFieldDefinitions.find((field) => field.path === path);
if (!definition) return;
if (!this.isFieldVisible(definition)) return;
if (!this.isFieldVisible(definition) || !this.isPathEnabled(definition.path)) return;
this.localEditorPath = definition.path;
this.localEditorLabel = definition.label;
@ -239,12 +258,12 @@ export class GenericNodeComponent {
this.localEditorMaxLength = null;
this.localEditorWidget = definition.ui.widget;
this.localEditorValue = this.valueToEditorString(this.getByPath(this.blockConfiguration ?? {}, definition.path), definition.type);
this.localEditorOptions = [...definition.enumOptions];
this.localEditorOptions = this.resolveSelectableOptions(definition);
this.localEditorBindableAsInput = definition.ui.bindableAsInput;
this.localEditorUseInput = this.isBindableFieldUsingInput(definition);
this.localEditorBindableInputName = definition.ui.inputName;
this.localEditorLoading = !!definition.retrieverKey && !this.localEditorUseInput;
this.localEditorHasRetriever = definition.enumOptions.length > 0 || !!definition.retrieverKey;
this.localEditorHasRetriever = this.localEditorOptions.length > 0 || !!definition.retrieverKey || !!definition.nodeOptionsSource;
this.localEditorOpen = true;
if (definition.retrieverKey && !this.localEditorUseInput) {
@ -323,7 +342,7 @@ export class GenericNodeComponent {
if (!this.isPathVisible(path)) return;
const contentLabel = pathToLabel(path);
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);
@ -351,17 +370,17 @@ export class GenericNodeComponent {
}
isHumanNode(): boolean {
return this.blockType === 'HumanInteractionBlock';
return !!this.blockDescriptor?.interactionContract;
}
isConditionalNode(): boolean {
return this.blockType === 'ConditionalBlock';
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 'Node';
if (type === 'HumanInteractionBlock') return 'Human Task';
return type
.replace(/Block$/, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
@ -505,6 +524,7 @@ export class GenericNodeComponent {
if (!type) return;
const blockType = 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);
@ -563,7 +583,7 @@ export class GenericNodeComponent {
const walk = (
node: Record<string, any>,
pathPrefix: string,
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null }
) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
@ -583,6 +603,7 @@ export class GenericNodeComponent {
if (hasChildren) {
walk(childResolved as Record<string, any>, path, {
visibleWhen: childUi.visibleWhen,
enabledWhen: childUi.enabledWhen,
group: childUi.group
});
continue;
@ -594,9 +615,10 @@ export class GenericNodeComponent {
seen.add(path);
definitions.push({
path,
label: pathToLabel(path),
label: schemaFieldLabel(path, childResolved),
type: this.toFieldType(childResolved?.type),
enumOptions: this.toEnumOptions(childResolved),
nodeOptionsSource: this.toNodeOptionsSource(childResolved),
retrieverBlockType: this.toRetrieverBlockType(childResolved),
retrieverKey: this.toRetrieverKey(childResolved),
retrieverUrl: this.toRetrieverUrl(childResolved),
@ -619,7 +641,7 @@ export class GenericNodeComponent {
const walk = (
node: Record<string, any>,
pathPrefix: string,
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null }
) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
@ -639,11 +661,15 @@ export class GenericNodeComponent {
seen.add(path);
definitions.push({
path,
label: pathToLabel(path),
label: schemaFieldLabel(path, childResolved),
itemSchema: this.resolveArrayItemSchema(childResolved, schema),
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,
ui: {
structural: childUi.structural,
visibleWhen: childUi.visibleWhen,
enabledWhen: childUi.enabledWhen,
group: childUi.group
}
});
@ -654,6 +680,7 @@ export class GenericNodeComponent {
if (hasChildren) {
walk(childResolved as Record<string, any>, path, {
visibleWhen: childUi.visibleWhen,
enabledWhen: childUi.enabledWhen,
group: childUi.group
});
}
@ -666,7 +693,7 @@ export class GenericNodeComponent {
private toFieldUiMeta(
schema: Record<string, any> | null | undefined,
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null }
) {
const rawWidget = typeof schema?.['x-ui-widget'] === 'string'
? String(schema['x-ui-widget']).toLowerCase().trim()
@ -676,9 +703,7 @@ export class GenericNodeComponent {
const placeholder = typeof schema?.['x-ui-placeholder'] === 'string'
? String(schema['x-ui-placeholder'])
: undefined;
const tip = typeof schema?.['x-ui-tip'] === 'string'
? String(schema['x-ui-tip'])
: undefined;
const tip = schemaFieldDescription(schema) ?? undefined;
const rowsRaw = schema?.['x-ui-rows'];
const rows = typeof rowsRaw === 'number' && Number.isFinite(rowsRaw) && rowsRaw > 0
? Math.trunc(rowsRaw)
@ -699,7 +724,9 @@ export class GenericNodeComponent {
? String(schema['x-ui-structural-reason'])
: undefined;
const visibleWhen = readUiConditionRule(schema?.['x-ui-visible-when']);
const enabledWhen = readUiConditionRule(schema?.['x-ui-enabled-when']);
const group = readUiGroup(schema?.['x-ui-group']) ?? inheritedUi?.group ?? null;
const label = readUiLabel(schema?.['x-ui-label']) ?? undefined;
return {
widget: normalizedWidget,
@ -710,6 +737,7 @@ export class GenericNodeComponent {
inputType,
inputMultiple,
structuralReason,
label,
placeholder,
tip,
rows,
@ -717,6 +745,10 @@ export class GenericNodeComponent {
...(inheritedUi?.visibleWhen ?? []),
...(visibleWhen ? [visibleWhen] : [])
],
enabledWhen: [
...(inheritedUi?.enabledWhen ?? []),
...(enabledWhen ? [enabledWhen] : [])
],
group
};
}
@ -726,17 +758,24 @@ export class GenericNodeComponent {
if (!root) return this.toFieldUiMeta(null);
let current: Record<string, any> | null = root;
let inheritedUi = { visibleWhen: [] as UiConditionRule[], group: null as string | null };
let inheritedUi = { visibleWhen: [] as UiConditionRule[], enabledWhen: [] 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);
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);
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,
enabledWhen: nextUi.enabledWhen,
group: nextUi.group
};
}
@ -749,19 +788,7 @@ export class GenericNodeComponent {
}
private resolveFieldSchema(path: string): Record<string, any> | null {
const root = this.blockSchema;
if (!root) return null;
let current: Record<string, any> | null = root;
for (const segment of path.split('.')) {
if (!current) return null;
const resolved = resolveSchemaRef(current, root);
const properties = resolved?.properties as Record<string, unknown> | undefined;
if (!properties || !properties[segment]) return null;
current = resolveSchemaRef(properties[segment] as Record<string, any>, root);
}
return current;
return resolveSchemaPath(this.blockSchema, path);
}
private toFieldType(type: unknown): FieldType {
@ -793,6 +820,29 @@ export class GenericNodeComponent {
return raw.filter((value): value is string => typeof value === 'string');
}
private toNodeOptionsSource(schema: Record<string, any> | null | undefined): NodeOptionsSource | null {
if (!schema || typeof schema !== 'object') return null;
const raw = schema['x-ui-options-from-node'];
if (!raw || typeof raw !== 'object') return null;
const collection = (raw as Record<string, unknown>)['collection'];
const valueField = (raw as Record<string, unknown>)['valueField'];
const labelField = (raw as Record<string, unknown>)['labelField'];
if ((collection !== 'inputs' && collection !== 'outputs')
|| typeof valueField !== 'string'
|| typeof labelField !== 'string'
|| valueField.trim().length === 0
|| labelField.trim().length === 0) {
return null;
}
return {
collection,
valueField: valueField.trim(),
labelField: labelField.trim()
};
}
private toRetrieverUrl(schema: Record<string, any> | null | undefined): string | null {
if (!schema || typeof schema !== 'object') return null;
const rawUrl = schema['x-retriever-url'];
@ -948,7 +998,7 @@ export class GenericNodeComponent {
definition.retrieverUrl
)
);
this.localEditorOptions = options ?? [];
this.localEditorOptions = (options ?? []).map((option) => ({ label: option, value: option }));
} catch {
this.localEditorOptions = [];
} finally {
@ -956,6 +1006,31 @@ export class GenericNodeComponent {
}
}
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 refreshParameterFields() {
const config = this.blockConfiguration ?? {};
const richContentPaths = new Set(this.richContentPaths());
@ -966,7 +1041,7 @@ export class GenericNodeComponent {
.filter((path) => this.isPathVisible(path))
.map((path) => ({
path,
label: pathToLabel(path),
label: this.fieldDisplayLabel(path),
parts: this.toRichContentParts(path)
}));
@ -985,7 +1060,8 @@ export class GenericNodeComponent {
path: definition.path,
label: definition.label,
value: this.fieldDisplayValue(definition, value),
wide: this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea')
wide: this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea'),
enabled: this.isPathEnabled(definition.path)
};
}).filter((field) => !richContentPaths.has(field.path))
.filter((field) => this.isPathVisible(field.path));
@ -1005,7 +1081,7 @@ export class GenericNodeComponent {
this.parameterFields = rootFields;
this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({
key,
legend: key.startsWith('group:') ? key.slice('group:'.length) : pathToLabel(key),
legend: key.startsWith('group:') ? key.slice('group:'.length) : this.fieldDisplayLabel(key),
fields
}));
this.refreshView();
@ -1017,9 +1093,10 @@ export class GenericNodeComponent {
.filter((entry) => this.isPathVisible(entry.path))
.map((entry) => ({
path: entry.path,
label: pathToLabel(entry.path),
label: this.fieldDisplayLabel(entry.path),
value: valueToDisplayString(entry.value),
wide: this.shouldRenderWideField(pathToLabel(entry.path), false)
wide: this.shouldRenderWideField(this.fieldDisplayLabel(entry.path), false),
enabled: this.isPathEnabled(entry.path)
}));
for (const field of fallbackFields) {
@ -1037,7 +1114,7 @@ export class GenericNodeComponent {
this.parameterFields = rootFields;
this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({
key,
legend: pathToLabel(key),
legend: this.fieldDisplayLabel(key),
fields
}));
@ -1094,6 +1171,12 @@ export class GenericNodeComponent {
if (!result) return;
const nextItem = this.parseArrayItemDialogResult(definition, result, currentItem);
const duplicateError = this.validateUniqueArrayItem(definition, items, nextItem, index);
if (duplicateError) {
window.alert(duplicateError);
return;
}
if (index == null) {
items.push(nextItem);
} else {
@ -1140,9 +1223,9 @@ export class GenericNodeComponent {
const initial: Record<string, string | boolean> = {};
for (const [key, rawPropertySchema] of Object.entries(properties)) {
if (key === 'type' || key.startsWith('__')) continue;
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
if (shouldSkipSchemaField(key, propertySchema)) continue;
if (this.hasDynamicSchema(propertySchema)) {
const dynamicFields = await this.buildDynamicSchemaFields(key, propertySchema, item);
fields.push(...dynamicFields.fields);
@ -1150,7 +1233,7 @@ export class GenericNodeComponent {
continue;
}
const label = pathToLabel(key);
const label = schemaFieldLabel(key, propertySchema);
const currentValue = item[key];
const options = await this.loadNodeSettingOptions(propertySchema, item, '');
const isObjectLike = propertySchema?.type === 'object';
@ -1167,7 +1250,7 @@ export class GenericNodeComponent {
rows: fieldType === 'textarea' ? 8 : undefined,
options,
placeholder: typeof propertySchema?.['x-ui-placeholder'] === 'string' ? String(propertySchema['x-ui-placeholder']) : undefined,
tip: typeof propertySchema?.['x-ui-tip'] === 'string' ? String(propertySchema['x-ui-tip']) : undefined
tip: schemaFieldDescription(propertySchema) ?? undefined
});
if (fieldType === 'checkbox') {
@ -1205,9 +1288,8 @@ export class GenericNodeComponent {
const nextItem: Record<string, unknown> = {};
for (const [key, rawPropertySchema] of Object.entries(properties)) {
if (key === 'type' || key.startsWith('__')) continue;
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
if (shouldSkipSchemaField(key, propertySchema)) continue;
if (this.hasDynamicSchema(propertySchema)) {
const dynamicValue = this.extractNestedDialogValues(result, key);
nextItem[key] = Object.keys(dynamicValue).length ? dynamicValue : (previousItem[key] ?? {});
@ -1252,8 +1334,8 @@ export class GenericNodeComponent {
const item: Record<string, unknown> = {};
for (const [key, rawPropertySchema] of Object.entries(properties)) {
if (key === 'type' || key.startsWith('__')) continue;
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
if (shouldSkipSchemaField(key, propertySchema)) continue;
if (Object.prototype.hasOwnProperty.call(propertySchema ?? {}, 'default')) {
item[key] = propertySchema.default;
continue;
@ -1304,7 +1386,7 @@ export class GenericNodeComponent {
return {
fields: [{
key: `${baseKey}.__hint`,
label: pathToLabel(baseKey),
label: schemaFieldLabel(baseKey, propertySchema),
type: 'display',
readonly: true
}],
@ -1323,7 +1405,7 @@ export class GenericNodeComponent {
return {
fields: [{
key: `${baseKey}.__hint`,
label: pathToLabel(baseKey),
label: schemaFieldLabel(baseKey, propertySchema),
type: 'display',
readonly: true
}],
@ -1333,7 +1415,7 @@ export class GenericNodeComponent {
};
}
return this.buildDialogFieldsFromSchema(baseKey, pathToLabel(baseKey), resolvedSchema, item[baseKey]);
return this.buildDialogFieldsFromSchema(baseKey, schemaFieldLabel(baseKey, propertySchema), resolvedSchema, item[baseKey]);
}
private async buildDialogFieldsFromSchema(
@ -1359,11 +1441,10 @@ export class GenericNodeComponent {
if (!properties) return;
for (const [childKey, rawChildSchema] of Object.entries(properties)) {
if (childKey === 'type' || childKey.startsWith('__')) continue;
const childSchema = resolveSchemaRef(rawChildSchema as Record<string, any>, schema);
if (shouldSkipSchemaField(childKey, childSchema)) continue;
const nextPath = `${pathPrefix}.${childKey}`;
const nextLabel = `${titlePrefix} ${pathToLabel(childKey)}`;
const nextLabel = `${titlePrefix} ${schemaFieldLabel(childKey, childSchema)}`;
const currentNestedValue = getValueByPath(currentRecord, nextPath.slice(`${keyPrefix}.`.length));
const hasChildren = !!childSchema?.['properties'] || childSchema?.type === 'object';
@ -1391,7 +1472,7 @@ export class GenericNodeComponent {
rows: fieldType === 'textarea' ? 8 : undefined,
options,
placeholder: typeof childSchema?.['x-ui-placeholder'] === 'string' ? String(childSchema['x-ui-placeholder']) : undefined,
tip: typeof childSchema?.['x-ui-tip'] === 'string' ? String(childSchema['x-ui-tip']) : undefined
tip: schemaFieldDescription(childSchema) ?? undefined
});
if (fieldType === 'checkbox') {
@ -1419,11 +1500,20 @@ export class GenericNodeComponent {
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 retrieverKey = this.toRetrieverKey(propertySchema);
const retrieverBlockType = this.toRetrieverBlockType(propertySchema);
if (!retrieverKey || !retrieverBlockType) return undefined;
@ -1482,6 +1572,27 @@ export class GenericNodeComponent {
return summaryParts.length ? summaryParts.join(' · ') : `Item ${index + 1}`;
}
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) => this.areValuesEqual(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';
@ -1615,6 +1726,11 @@ export class GenericNodeComponent {
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;
}
@ -1632,12 +1748,22 @@ export class GenericNodeComponent {
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));
});
this.missingRequiredParams = missingFields.map((field) => field.label);
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();
@ -1807,6 +1933,18 @@ export class GenericNodeComponent {
return this.isFieldConditionSatisfied(path);
}
private isPathEnabled(path: string, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
const ui = this.getFieldUiMeta(path);
return ui.enabledWhen.every((rule) => {
if (!rule) return true;
if (!this.isFieldConditionSatisfied(rule.field, visited)) return false;
return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath));
});
}
private isFieldVisible(field: EditableFieldDefinition): boolean {
return this.isPathVisible(field.path);
}

View File

@ -1,7 +1,8 @@
export type TemplatedTextPart = { text: string; isDynamicInput: boolean };
export type UiConditionRule =
| { field: string; equals: string }
| { field: string; in: string[] };
| { field: string; in: string[] }
| { field: string; present: boolean };
export function toStringOrNull(value: unknown): string | null {
if (typeof value === 'string' && value.trim().length > 0) return value;
@ -45,6 +46,27 @@ export function pathToLabel(path: string): string {
.replace(/^./, (c) => c.toUpperCase());
}
export function readUiLabel(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return normalized.length ? normalized : null;
}
export function readUiDescription(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return normalized.length ? normalized : null;
}
export function schemaFieldLabel(path: string, schema: Record<string, any> | null | undefined): string {
return readUiLabel(schema?.['x-ui-label']) ?? pathToLabel(path);
}
export function schemaFieldDescription(schema: Record<string, any> | null | undefined): string | null {
return readUiDescription(schema?.['x-ui-description'])
?? (typeof schema?.['x-ui-tip'] === 'string' ? String(schema['x-ui-tip']).trim() || null : null);
}
export function parentPath(path: string): string | null {
const index = path.lastIndexOf('.');
if (index <= 0) return null;
@ -71,12 +93,38 @@ export function resolveSchemaRef(node: Record<string, any>, root: Record<string,
};
}
export function resolveSchemaPath(root: Record<string, any> | null | undefined, path: string): Record<string, any> | null {
if (!root || !path.trim()) return root ?? null;
let current: Record<string, any> | null = root;
for (const segment of path.split('.')) {
if (!current) return null;
const resolved = resolveSchemaRef(current, root);
if (!resolved || typeof resolved !== 'object') return null;
if (/^\d+$/.test(segment)) {
const items = resolved.items;
if (!items || typeof items !== 'object') return null;
current = resolveSchemaRef(items as Record<string, any>, root) as Record<string, any> | null;
continue;
}
const properties = resolved.properties as Record<string, unknown> | undefined;
if (!properties || !properties[segment]) return null;
current = resolveSchemaRef(properties[segment] as Record<string, any>, root) as Record<string, any> | null;
}
return current;
}
export function readUiConditionRule(value: unknown): UiConditionRule | null {
if (!value || typeof value !== 'object') return null;
const field = (value as Record<string, unknown>)['field'];
const equals = (value as Record<string, unknown>)['equals'];
const includes = (value as Record<string, unknown>)['in'];
const present = (value as Record<string, unknown>)['present'];
if (typeof field !== 'string' || field.trim().length === 0) return null;
if (typeof equals === 'string') {
return {
@ -94,6 +142,13 @@ export function readUiConditionRule(value: unknown): UiConditionRule | null {
};
}
if (typeof present === 'boolean') {
return {
field: field.trim(),
present
};
}
return null;
}
@ -110,6 +165,47 @@ export function getValueByPath(source: Record<string, any> | null | undefined, p
}, source ?? {});
}
export function validateUniqueByConstraint(
items: unknown[],
nextItem: Record<string, unknown>,
uniqueBy: string | null | undefined,
currentIndex: number | null,
isMissingValue: (value: unknown) => boolean,
areValuesEqual: (left: unknown, right: unknown) => boolean
): { path: string; value: unknown } | null {
if (!uniqueBy) return null;
const uniqueValue = getValueByPath(nextItem, uniqueBy);
if (isMissingValue(uniqueValue)) return null;
const duplicateIndex = items.findIndex((item, index) => {
if (currentIndex != null && index === currentIndex) return false;
const candidateValue = item && typeof item === 'object' && !Array.isArray(item)
? getValueByPath(item as Record<string, unknown>, uniqueBy)
: undefined;
return areValuesEqual(candidateValue, uniqueValue);
});
if (duplicateIndex < 0) return null;
return {
path: uniqueBy,
value: uniqueValue
};
}
export function shouldSkipSchemaField(key: string, schema: Record<string, any> | null | undefined): boolean {
if (key.startsWith('__')) return true;
if (key !== 'type') return false;
const enumValues = Array.isArray(schema?.['enum'])
? schema['enum'].filter((value: unknown): value is string => typeof value === 'string')
: [];
const defaultValue = typeof schema?.['default'] === 'string' ? String(schema['default']) : null;
return enumValues.length === 1 && defaultValue != null && enumValues[0] === defaultValue;
}
export function evaluateUiConditionRule(
rule: UiConditionRule | null | undefined,
config: Record<string, any> | null | undefined,
@ -123,6 +219,11 @@ export function evaluateUiConditionRule(
const type = typeof schemaType === 'string' ? schemaType : null;
const expectedValues = 'in' in rule ? rule.in : null;
const expectedValue = 'equals' in rule ? rule.equals : null;
const expectedPresence = 'present' in rule ? rule.present : null;
if (expectedPresence != null) {
const isPresent = isMeaningfullyPresent(actualValue);
return isPresent === expectedPresence;
}
if (type === 'boolean' || typeof actualValue === 'boolean') {
if (expectedValues) {
@ -153,6 +254,16 @@ function parseBooleanCondition(value: string): boolean {
return value.trim().toLowerCase() === 'true';
}
function isMeaningfullyPresent(value: unknown): boolean {
if (value == null) return false;
if (typeof value === 'string') return value.trim().length > 0;
if (Array.isArray(value)) return value.some((item) => isMeaningfullyPresent(item));
if (typeof value === 'object') {
return Object.values(value as Record<string, unknown>).some((item) => isMeaningfullyPresent(item));
}
return true;
}
export function splitTemplatedTextParts(text: string | null): TemplatedTextPart[] {
if (!text) return [];

View File

@ -1,4 +1,4 @@
import { readUiConditionRule, resolveSchemaRef, type UiConditionRule } from './node-utility';
import { readUiConditionRule, resolveSchemaRef, schemaFieldLabel, type UiConditionRule } from './node-utility';
export type RequiredField = {
path: string;
@ -17,20 +17,23 @@ export type ConditionalRequiredField = {
export type SchemaRequirements = {
required: RequiredField[];
requiredObjects: RequiredField[];
conditional: ConditionalRequiredField[];
};
export function extractSchemaRequirements(schema: Record<string, unknown> | null): SchemaRequirements {
if (!schema) return { required: [], conditional: [] };
if (!schema) return { required: [], requiredObjects: [], conditional: [] };
const required: RequiredField[] = [];
const requiredObjects: RequiredField[] = [];
const conditional: ConditionalRequiredField[] = [];
const seenRequired = new Set<string>();
const seenRequiredObjects = new Set<string>();
const seenConditional = new Set<string>();
walkSchema(schema as Record<string, any>, schema as Record<string, any>, '', required, conditional, seenRequired, seenConditional);
walkSchema(schema as Record<string, any>, schema as Record<string, any>, '', required, requiredObjects, conditional, seenRequired, seenRequiredObjects, seenConditional);
return { required, conditional };
return { required, requiredObjects, conditional };
}
function walkSchema(
@ -38,8 +41,10 @@ function walkSchema(
root: Record<string, any>,
pathPrefix: string,
required: RequiredField[],
requiredObjects: RequiredField[],
conditional: ConditionalRequiredField[],
seenRequired: Set<string>,
seenRequiredObjects: Set<string>,
seenConditional: Set<string>,
requireAllDescendants = false,
inheritedRequiredWhen: UiConditionRule | null = null
@ -56,12 +61,17 @@ function walkSchema(
const propertyPath = pathPrefix ? `${pathPrefix}.${key}` : key;
const propertyResolved = resolveSchemaRef(propertySchema as Record<string, any>, root);
const hasChildren = !!propertyResolved?.properties || propertyResolved?.type === 'object';
const label = toLabel(key);
const label = schemaFieldLabel(key, propertyResolved);
const isRequiredBySchema = requiredSet.has(key);
const isRequiredByAncestor = requireAllDescendants && key !== 'type';
const isRequired = isRequiredBySchema || isRequiredByAncestor;
const requiredWhen = readUiConditionRule(propertyResolved?.['x-ui-required-when']) ?? inheritedRequiredWhen;
if (isRequiredBySchema && hasChildren && key !== 'type' && !seenRequiredObjects.has(propertyPath)) {
seenRequiredObjects.add(propertyPath);
requiredObjects.push({ path: propertyPath, label });
}
if (isRequired && !hasChildren && key !== 'type' && !seenRequired.has(propertyPath)) {
seenRequired.add(propertyPath);
required.push({ path: propertyPath, label });
@ -106,8 +116,10 @@ function walkSchema(
root,
propertyPath,
required,
requiredObjects,
conditional,
seenRequired,
seenRequiredObjects,
seenConditional,
isRequired && !childHasOwnRequired,
requiredWhen
@ -116,13 +128,6 @@ function walkSchema(
}
}
function toLabel(key: string): string {
return key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]/g, ' ')
.replace(/^./, (c) => c.toUpperCase());
}
function parseRetrieverUrl(rawUrl: unknown): { blockType: string; key: string } | null {
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;

View File

@ -6,7 +6,7 @@
[class.llm-node-attention]="needsAttention()"
[class.llm-node-error]="hasExecutionErrors()"
[class.llm-node-warning]="hasExecutionWarnings() && !hasExecutionErrors()">
@if (needsAttention()) {
@if (needsAttention() && isHumanNode()) {
<div class="llm-attention-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button
type="button"

View File

@ -2,7 +2,7 @@ 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 { FlowBlock, FlowContainer, FlowData, FlowPort } from '@models/flow';
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';
@ -17,6 +17,9 @@ import {
readUiConditionRule,
readUiGroup,
resolveSchemaRef,
resolveSchemaPath,
schemaFieldLabel,
shouldSkipSchemaField,
splitTemplatedTextParts,
toStringOrNull,
valueToDisplayString
@ -101,6 +104,7 @@ export class TaskStepNodeComponent {
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>();
@ -138,7 +142,7 @@ export class TaskStepNodeComponent {
const richContentPaths = mainContentEntries.map((entry) => entry.path);
this.mainContentFields = mainContentEntries.map((entry) => ({
path: entry.path,
label: pathToLabel(entry.path),
label: this.displayLabelForPath(entry.path),
parts: this.toMainContentParts(entry.path, String(entry.value))
}));
this.arrayFields = this.arrayFieldDefinitions
@ -157,9 +161,9 @@ export class TaskStepNodeComponent {
.filter((entry) => !this.isEmptyDisplayValue(entry.value))
.map((entry) => ({
path: entry.path,
label: pathToLabel(entry.path),
label: this.displayLabelForPath(entry.path),
value: valueToDisplayString(entry.value),
wide: this.shouldRenderWideField(pathToLabel(entry.path), this.mainContentPaths.has(entry.path))
wide: this.shouldRenderWideField(this.displayLabelForPath(entry.path), this.mainContentPaths.has(entry.path))
}));
for (const field of orderedFields) {
@ -178,7 +182,7 @@ export class TaskStepNodeComponent {
this.parameterFields = rootFields;
this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({
key,
legend: key.startsWith('group:') ? key.slice('group:'.length) : pathToLabel(key),
legend: key.startsWith('group:') ? key.slice('group:'.length) : this.displayLabelForPath(key),
fields
}));
@ -190,11 +194,12 @@ export class TaskStepNodeComponent {
}
isHumanNode(): boolean {
return this.blockType === 'HumanInteractionBlock';
return !!this.interactionContract();
}
isConditionalNode(): boolean {
return this.blockType === 'ConditionalBlock';
const outputNames = this.resolvePorts('output').map((port) => port.name.trim().toLowerCase());
return outputNames.includes('true') && outputNames.includes('false');
}
nodeTitle(): string {
@ -248,7 +253,7 @@ export class TaskStepNodeComponent {
}
isContainerNode(): boolean {
return this.data?.data?.nodeFamily === 'container' || this.blockType === 'GenericContainer';
return this.data?.data?.nodeFamily === 'container';
}
hasViewableSubflow(): boolean {
@ -364,19 +369,29 @@ export class TaskStepNodeComponent {
const executionId = this.executionId();
const executionNodeId = this.executionNodeId();
const interactionFieldName = this.interactionFieldName();
if (!executionId || !executionNodeId || !interactionFieldName) return;
const contract = this.interactionContract();
if (!executionId || !executionNodeId || !contract) return;
const currentInput = this.currentInputValue();
const actionDescription = this.actionDescriptionValue();
const result = await this.humanInteractionDialog.open({
title: `Send output for ${this.name || 'Interaction Step'}`,
title: this.interactionDialogTitle(contract),
kind: contract.kind,
actionDescription,
currentInput
currentInput,
history: this.chatHistory(contract),
latestResponse: this.latestInteractionResponse(contract),
messageField: contract.messageField,
completionField: contract.completionField
});
if (!result) return;
const interactionFieldName = result.mode === 'message'
? contract.messageField
: contract.completionField;
if (!interactionFieldName) return;
this.interactionSubmitting = true;
this.taskExecutionsService.submitInteractionText(
executionId,
@ -432,11 +447,6 @@ export class TaskStepNodeComponent {
return typeof value === 'string' && value.length > 0 ? value : null;
}
private interactionFieldName(): string | null {
const outputKey = this.outputs[0]?.key;
return typeof outputKey === 'string' && outputKey.length > 0 ? outputKey : null;
}
private getExecutionMessages(key: '__executionErrors' | '__executionWarnings'): string[] {
const values = this.blockConfiguration?.[key];
if (!Array.isArray(values)) return [];
@ -499,12 +509,13 @@ export class TaskStepNodeComponent {
const type = this.blockType;
if (!type) return;
const nodeFamily = this.data?.data?.nodeFamily === 'container' || type === 'GenericContainer'
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);
@ -512,6 +523,113 @@ export class TaskStepNodeComponent {
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 = contract.historyField;
if (!historyField) return [];
const rawHistory = this.interactionFieldValue(historyField, Boolean(contract.supportsPartialResult));
if (!Array.isArray(rawHistory)) return [];
return 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);
}
private latestInteractionResponse(contract: BlockInteractionContract): string {
const fieldName = contract.responseField;
if (!fieldName) return '';
const value =
this.interactionFieldValue(fieldName, true)
?? this.interactionFieldValue(fieldName, false);
return typeof value === 'string' ? value : '';
}
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;
@ -565,13 +683,13 @@ export class TaskStepNodeComponent {
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
if (childResolved?.type === 'array') {
if (key === 'type' || key === 'name' || key.startsWith('__') || seen.has(path)) {
if (shouldSkipSchemaField(key, childResolved) || key === 'name' || seen.has(path)) {
continue;
}
seen.add(path);
definitions.push({
path,
label: pathToLabel(path),
label: schemaFieldLabel(path, childResolved),
itemSchema: this.resolveArrayItemSchema(childResolved, schema)
});
continue;
@ -729,6 +847,10 @@ export class TaskStepNodeComponent {
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;
}
@ -767,19 +889,7 @@ export class TaskStepNodeComponent {
}
private resolveFieldSchema(path: string): Record<string, any> | null {
const root = this.blockSchema;
if (!root) return null;
let current: Record<string, any> | null = root;
for (const segment of path.split('.')) {
if (!current) return null;
const resolved = resolveSchemaRef(current, root);
const properties = resolved?.properties as Record<string, unknown> | undefined;
if (!properties || !properties[segment]) return null;
current = resolveSchemaRef(properties[segment] as Record<string, any>, root);
}
return current;
return resolveSchemaPath(this.blockSchema, path);
}
private getFieldUiMeta(path: string) {
@ -792,9 +902,15 @@ export class TaskStepNodeComponent {
for (const segment of path.split('.')) {
if (!current) return this.toFieldUiMeta(null, inheritedUi);
const resolved = resolveSchemaRef(current, root);
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);
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,

View File

@ -149,7 +149,10 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__executionOutputs: this.getExecutionOutputValues(step, contextResults),
__connectedOutputs: this.getConnectedOutputs(step),
__executionErrors: this.getExecutionErrors(step.id, contextErrors),
__executionWarnings: this.getExecutionWarnings(step.id, contextWarnings)
__executionWarnings: this.getExecutionWarnings(step.id, contextWarnings),
__stepResultData: step.result ?? null,
__executionPartialResult: (this.execution()?.context as Record<string, unknown> | undefined)?.['partialResult'] ?? null,
__executionResultData: this.execution()?.context.result ?? {}
},
position: stepNode.position ?? {
x: 120 + (index % 3) * 340,

View File

@ -2,7 +2,7 @@
<html lang="en" data-theme="nord" >
<head>
<meta charset="utf-8">
<title>HumainFlowGuiA21</title>
<title>HumAInFlow</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">