Add first-class container nodes and subflow preview

This commit is contained in:
Lucio Lelii 2026-03-14 15:50:55 +01:00
parent 856d3e0893
commit 25e133ac04
31 changed files with 1724 additions and 89 deletions

View File

@ -2,3 +2,4 @@
<app-confirm-dialog-host></app-confirm-dialog-host>
<app-human-interaction-dialog-host></app-human-interaction-dialog-host>
<app-node-settings-dialog-host></app-node-settings-dialog-host>
<app-subflow-preview-dialog-host></app-subflow-preview-dialog-host>

View File

@ -3,10 +3,11 @@ import { RouterOutlet } from '@angular/router';
import { ConfirmDialogHostComponent } from '@shared/confirm-dialog/confirm-dialog';
import { HumanInteractionDialogHostComponent } from '@shared/human-interaction-dialog/human-interaction-dialog';
import { NodeSettingsDialogHostComponent } from '@shared/node-settings-dialog/node-settings-dialog';
import { SubflowPreviewDialogHostComponent } from '@shared/subflow-preview-dialog/subflow-preview-dialog';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ConfirmDialogHostComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent],
imports: [RouterOutlet, ConfirmDialogHostComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent],
templateUrl: './app.html',
styleUrl: './app.css'
})

View File

@ -19,15 +19,32 @@ export type Flow = {
export type FlowData = {
blocks: FlowBlock[];
containers: FlowContainer[];
connections: FlowBlockConnection[];
};
export type FlowSubflowValidationError = {
entity?: string;
id?: string;
field?: string;
message: string;
};
export type FlowSubflowValidationResult = {
valid: boolean;
errors: FlowSubflowValidationError[];
openInputs: FlowContainerOpenInput[];
openOutputs: FlowContainerOpenOutput[];
};
export type BlockTypeName = "HumanInteractionBlock" | "LLMBlock" | "SourceBlock" | string;
export type NodeFamily = 'block' | 'container';
export type BlockTypeSchema = Record<string, unknown> | null;
export type BlockType = {
type: BlockTypeName;
family: NodeFamily;
description: string;
userInteractive: boolean;
hasExampleBlock?: boolean;
@ -37,7 +54,7 @@ export type BlockType = {
schema: BlockTypeSchema;
};
export type FlowBlock = {
export type FlowNodeBase = {
id: string;
name: string;
position?: { x: number, y: number };
@ -45,8 +62,19 @@ export type FlowBlock = {
outputs: FlowPort[];
specificConfiguration: FlowBlockConfiguration;
typeName: BlockTypeName;
nodeFamily?: NodeFamily;
};
export type FlowBlock = FlowNodeBase & {
nodeFamily?: 'block';
};
export type FlowContainer = FlowNodeBase & {
nodeFamily: 'container';
};
export type FlowNode = FlowBlock | FlowContainer;
export type FlowValueKind = {
type: string;
multiple: boolean;
@ -72,6 +100,32 @@ export type FlowBlockConfiguration =
| HumanInteractiveBlockConfiguration
| Record<string, unknown>;
export type FlowContainerPublicInput = {
name: string;
targetBlockId: string;
targetInputName: string;
};
export type FlowContainerPublicOutput = {
name: string;
sourceBlockId: string;
sourceOutputName: string;
};
export type FlowContainerOpenInput = FlowPort & {
targetBlockId?: string;
targetInputName?: string;
blockId?: string;
inputName?: string;
};
export type FlowContainerOpenOutput = FlowPort & {
sourceBlockId?: string;
sourceOutputName?: string;
blockId?: string;
outputName?: string;
};
export type LLMDescriptor = {
provider: string;
model: string;

View File

@ -1,9 +1,12 @@
import { GetSchemes, ClassicPreset } from "rete";
import { FlowBlock } from "./flow";
import { FlowNode } from "./flow";
export type HFNodeData = FlowBlock & {
export type HFNodeData = FlowNode & {
deleteNode?: () => Promise<void>;
replaceWithCreatedBlock?: (block: FlowBlock) => Promise<void>;
replaceWithCreatedBlock?: (block: FlowNode) => Promise<void>;
assignSelectedBlocksToContainer?: (blockIds?: string[]) => Promise<void>;
clearContainerSubflow?: () => Promise<void>;
[key: string]: unknown;
};
export type HFNode = ClassicPreset.Node & {

View File

@ -7,6 +7,7 @@ import {
AssistantSessionState,
AssistantValidationIssue
} from '@models/assistant';
import { FlowData } from '@models/flow';
import { Observable, of } from 'rxjs';
import { AssistantCallServiceBase } from './assistant-call.base';
@ -206,7 +207,7 @@ export class AssistantCallServiceFake extends AssistantCallServiceBase {
}
}
function buildTicketFlow(model: string) {
function buildTicketFlow(model: string): FlowData {
return {
blocks: [
{
@ -219,7 +220,8 @@ function buildTicketFlow(model: string) {
type: 'InputBlockConfiguration',
name: 'Incoming Ticket'
},
typeName: 'InputBlock'
typeName: 'InputBlock',
nodeFamily: 'block'
},
{
id: 'assistant-llm',
@ -236,7 +238,8 @@ function buildTicketFlow(model: string) {
},
prompt: 'Classify the ticket urgency as true for urgent, false otherwise. Ticket: ${{name}}'
},
typeName: 'LLMBlock'
typeName: 'LLMBlock',
nodeFamily: 'block'
},
{
id: 'assistant-condition',
@ -252,7 +255,8 @@ function buildTicketFlow(model: string) {
name: 'Urgent?',
condition: '${{input}} == true'
},
typeName: 'ConditionalBlock'
typeName: 'ConditionalBlock',
nodeFamily: 'block'
},
{
id: 'assistant-human',
@ -271,7 +275,8 @@ function buildTicketFlow(model: string) {
inputAsList: false,
outputAsList: false
},
typeName: 'HumanInteractionBlock'
typeName: 'HumanInteractionBlock',
nodeFamily: 'block'
},
{
id: 'assistant-output',
@ -283,9 +288,11 @@ function buildTicketFlow(model: string) {
type: 'OutputBlockConfiguration',
name: 'Resolved Route'
},
typeName: 'OutputBlock'
typeName: 'OutputBlock',
nodeFamily: 'block'
}
],
containers: [],
connections: [
{
id: 'assistant-conn-1',
@ -340,7 +347,8 @@ function addHumanReviewTail(flow: AssistantDraftPayload['flow']) {
inputAsList: false,
outputAsList: false
},
typeName: 'HumanInteractionBlock'
typeName: 'HumanInteractionBlock',
nodeFamily: 'block'
});
flow.connections.push({

View File

@ -160,6 +160,7 @@ function mapAssistantDraftPayload(raw: unknown): AssistantDraftPayload | null {
description: typeof value['description'] === 'string' ? value['description'] : undefined,
flow: {
blocks: Array.isArray(flow['blocks']) ? (flow['blocks'] as any[]) : [],
containers: Array.isArray(flow['containers']) ? (flow['containers'] as any[]) : [],
connections: Array.isArray(flow['connections']) ? (flow['connections'] as any[]) : []
}
};

View File

@ -1,12 +1,18 @@
import { BlockType, BlockTypeName, FlowBlock } from "@models/flow";
import { BlockType, BlockTypeName, FlowBlock, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
import { Observable } from "rxjs";
export abstract class BlocksCallServiceBase {
abstract retrieveAllBlocksTypes() : Observable<BlockType[]>;
abstract retrieveAllContainerTypes() : Observable<BlockType[]>;
abstract createEmptyBlock(blockType: BlockTypeName) : Observable<FlowBlock>;
abstract createEmptyContainer(containerType: BlockTypeName) : Observable<FlowContainer>;
abstract updateBlock(blockId : string, configuration : any) : Observable<FlowBlock>;
}
abstract validateContainerSubflow(subFlow: FlowData) : Observable<FlowSubflowValidationResult>;
}

View File

@ -1,4 +1,4 @@
import { BlockType, FlowBlock } from "@models/flow";
import { BlockType, FlowBlock, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
import { Observable, of } from "rxjs";
import { BlocksCallServiceBase } from "./block-call.base";
@ -6,6 +6,7 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
private readonly blockTypes: BlockType[] =[
{
"type": "HumanInteractionBlock",
"family": "block",
"description": "A block that requires human interaction",
"userInteractive": true,
"configurationType": "HumanInteractiveBlockConfiguration",
@ -69,6 +70,7 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
},
{
"type": "LLMBlock",
"family": "block",
"description": "This type represents a LLM node in the workflow manager",
"userInteractive": false,
"configurationType": "LLMBlockConfiguration",
@ -130,6 +132,56 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
}
}
}
},
];
private readonly containerTypes: BlockType[] = [
{
"type": "GenericContainer",
"family": "container",
"description": "Container block with an embedded validated subflow",
"userInteractive": false,
"configurationType": "GenericContainerConfiguration",
"configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.GenericContainerConfiguration",
"schema": {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "GenericContainerConfiguration",
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"GenericContainerConfiguration"
],
"default": "GenericContainerConfiguration"
},
"name": {
"type": "string",
"default": "Container"
},
"subFlow": {
"type": "object",
"default": {
"blocks": [],
"containers": [],
"connections": []
}
},
"publicInputs": {
"type": "array",
"default": []
},
"publicOutputs": {
"type": "array",
"default": []
}
},
"required": [
"type",
"name"
]
}
}
];
@ -137,6 +189,10 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
return of(this.blockTypes);
}
override retrieveAllContainerTypes(): Observable<BlockType[]> {
return of(this.containerTypes);
}
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
const descriptor = this.blockTypes.find((b) => b.type === blockType);
const typeName = descriptor?.type ?? blockType ?? "LLMBlock";
@ -160,12 +216,34 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
inputs: io.inputs,
outputs: io.outputs,
specificConfiguration,
typeName
typeName,
nodeFamily: 'block'
};
return of(block);
}
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
const descriptor = this.containerTypes.find((b) => b.type === containerType);
const typeName = descriptor?.type ?? containerType ?? "GenericContainer";
const schema = descriptor?.schema as Record<string, any> | null;
const specificConfiguration = schema
? this.buildObjectFromSchema(schema, schema)
: {};
return of({
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
name: String((specificConfiguration as any)?.name ?? typeName),
position: undefined,
inputs: [],
outputs: [],
specificConfiguration,
typeName,
nodeFamily: 'container'
});
}
override updateBlock(blockId: string, configuration: any): Observable<FlowBlock> {
const typeName = configuration?.typeName ?? "LLMBlock";
const io = this.defaultIOForBlockType(typeName);
@ -176,11 +254,41 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
inputs: configuration?.inputs ?? io.inputs,
outputs: configuration?.outputs ?? io.outputs,
specificConfiguration: configuration?.specificConfiguration ?? {},
typeName
typeName,
nodeFamily: 'block'
};
return of(block);
}
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
const blocks = Array.isArray(subFlow?.blocks) ? subFlow.blocks : [];
if (!blocks.length) {
return of({
valid: false,
errors: [{ entity: 'flow', field: 'blocks', message: 'Subflow cannot be empty' }],
openInputs: [],
openOutputs: []
});
}
const nestedContainer = (subFlow?.containers ?? []).find((container) => container?.typeName === 'GenericContainer');
if (nestedContainer) {
return of({
valid: false,
errors: [{
entity: 'block',
id: nestedContainer.id,
field: 'type',
message: 'Nested GenericContainer blocks are not supported'
}],
openInputs: [],
openOutputs: []
});
}
return of({ valid: true, errors: [], openInputs: [], openOutputs: [] });
}
private defaultIOForBlockType(typeName: string) {
if (typeName === "SourceBlock") {
return {

View File

@ -1,4 +1,4 @@
import { BlockType, FlowBlock } from "@models/flow";
import { BlockType, FlowBlock, FlowContainer, FlowData, FlowSubflowValidationResult, NodeFamily } from "@models/flow";
import { HttpClient } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
@ -8,12 +8,13 @@ import { BlocksCallServiceBase } from "./block-call.base";
export class BlocksCallService extends BlocksCallServiceBase {
private readonly http = inject(HttpClient);
private blockTypesCache: BlockType[] | null = null;
private containerTypesCache: BlockType[] | null = null;
override retrieveAllBlocksTypes(): Observable<BlockType[]> {
return this.http
.get<unknown[]>(`${environment.apiUrl}/blocks/types`)
.pipe(
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value)) : [])),
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value, 'block')) : [])),
map((types) => {
this.blockTypesCache = types;
return types;
@ -21,17 +22,29 @@ export class BlocksCallService extends BlocksCallServiceBase {
);
}
override retrieveAllContainerTypes(): Observable<BlockType[]> {
return this.http
.get<unknown[]>(`${environment.apiUrl}/containers/types`)
.pipe(
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value, 'container')) : [])),
map((types) => {
this.containerTypesCache = types;
return types;
})
);
}
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
return this.getBlockTypesForCreate().pipe(
take(1),
switchMap((types) => {
const descriptor = types.find((type) => type.type === blockType);
const exampleEndpoint = this.resolveExampleBlockEndpoint(descriptor);
const exampleEndpoint = this.resolveExampleEndpoint('block', blockType, descriptor);
if (exampleEndpoint) {
return this.http
.get<unknown>(exampleEndpoint)
.pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType)));
.pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? blockType, 'block') as FlowBlock));
}
const configuration = descriptor
@ -41,7 +54,21 @@ export class BlocksCallService extends BlocksCallServiceBase {
return this.http
.post<unknown>(`${environment.apiUrl}/blocks`, payload)
.pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType, payload)));
.pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? blockType, 'block', payload) as FlowBlock));
})
);
}
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
return this.getContainerTypesForCreate().pipe(
take(1),
switchMap((types) => {
const descriptor = types.find((type) => type.type === containerType);
const exampleEndpoint = this.resolveExampleEndpoint('container', containerType, descriptor);
return this.http
.get<unknown>(exampleEndpoint)
.pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? containerType, 'container') as FlowContainer));
})
);
}
@ -64,6 +91,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
}),
map((raw) =>
this.flowBlockFromApi(
{
...(this.toRecord(raw)),
id: this.toRecord(raw)["id"] ?? blockId
@ -76,6 +104,15 @@ export class BlocksCallService extends BlocksCallServiceBase {
);
}
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
return this.http
.post<unknown>(
`${environment.apiUrl}/containers/types/GenericContainer/validate-subflow`,
{ subFlow }
)
.pipe(map((raw) => this.subflowValidationFromApi(raw)));
}
private getBlockTypesForCreate(): Observable<BlockType[]> {
if (this.blockTypesCache) {
return of(this.blockTypesCache);
@ -83,10 +120,18 @@ export class BlocksCallService extends BlocksCallServiceBase {
return this.retrieveAllBlocksTypes();
}
private blockTypeFromApi(raw: unknown): BlockType {
private getContainerTypesForCreate(): Observable<BlockType[]> {
if (this.containerTypesCache) {
return of(this.containerTypesCache);
}
return this.retrieveAllContainerTypes();
}
private blockTypeFromApi(raw: unknown, family: NodeFamily): BlockType {
const value = this.toRecord(raw);
return {
type: String(value["type"] ?? value["blockType"] ?? value["name"] ?? "LLMBlock"),
family,
description: String(value["description"] ?? ""),
userInteractive: Boolean(value["userInteractive"] ?? value["interactive"] ?? false),
hasExampleBlock: Boolean(value["hasExampleBlock"] ?? false),
@ -97,9 +142,49 @@ export class BlocksCallService extends BlocksCallServiceBase {
};
}
private subflowValidationFromApi(raw: unknown): FlowSubflowValidationResult {
const value = this.toRecord(raw);
const rawErrors = Array.isArray(value['errors']) ? value['errors'] : [];
return {
valid: Boolean(value['valid'] ?? false),
errors: rawErrors
.map((item) => this.toRecord(item))
.map((item) => ({
entity: this.toNullableString(item['entity']) ?? undefined,
id: this.toNullableString(item['id']) ?? undefined,
field: this.toNullableString(item['field']) ?? undefined,
message: String(item['message'] ?? 'Invalid subflow')
})),
openInputs: this.toPorts(value['openInputs'], []).map((port) => ({
...port,
targetBlockId: this.toNullableString(this.toRecord(port)['targetBlockId']) ?? undefined,
targetInputName: this.toNullableString(this.toRecord(port)['targetInputName']) ?? undefined,
blockId: this.toNullableString(this.toRecord(port)['blockId']) ?? undefined,
inputName: this.toNullableString(this.toRecord(port)['inputName']) ?? undefined
})),
openOutputs: this.toPorts(value['openOutputs'], []).map((port) => ({
...port,
sourceBlockId: this.toNullableString(this.toRecord(port)['sourceBlockId']) ?? undefined,
sourceOutputName: this.toNullableString(this.toRecord(port)['sourceOutputName']) ?? undefined,
blockId: this.toNullableString(this.toRecord(port)['blockId']) ?? undefined,
outputName: this.toNullableString(this.toRecord(port)['outputName']) ?? undefined
}))
};
}
private flowBlockFromApi(raw: unknown, fallbackTypeName = "LLMBlock", fallbackConfig?: Record<string, unknown>): FlowBlock {
return this.flowNodeFromApi(raw, fallbackTypeName, 'block', fallbackConfig) as FlowBlock;
}
private flowNodeFromApi(
raw: unknown,
fallbackTypeName = "LLMBlock",
family: NodeFamily = 'block',
fallbackConfig?: Record<string, unknown>
) {
const root = this.toRecord(raw);
const value = this.toRecord(root["block"] ?? root["data"] ?? root);
const value = this.toRecord(root[family] ?? root["node"] ?? root["block"] ?? root["container"] ?? root["data"] ?? root);
const specificConfigurationRaw = value["specificConfiguration"] ?? value["configuration"] ?? value["blockConfiguration"] ?? fallbackConfig ?? {};
const specificConfiguration = this.toRecord(specificConfigurationRaw);
const typeName = String(value["typeName"] ?? value["blockType"] ?? specificConfiguration["typeName"] ?? fallbackTypeName);
@ -112,7 +197,8 @@ export class BlocksCallService extends BlocksCallServiceBase {
inputs: this.toPorts(value["inputs"], io.inputs),
outputs: this.toPorts(value["outputs"], io.outputs),
specificConfiguration,
typeName
typeName,
nodeFamily: family
};
}
@ -121,15 +207,17 @@ export class BlocksCallService extends BlocksCallServiceBase {
return raw
.map((port) => this.toRecord(port))
.filter((port) => typeof port["name"] === "string" && (port["name"] as string).length > 0)
.map((port) => ({
name: String(port["name"]),
type: String(port["type"] ?? "TEXT"),
multiple: Boolean(port["multiple"] ?? false),
valueKinds: this.toValueKinds(port["valueKinds"], {
type: String(port["type"] ?? "TEXT"),
multiple: Boolean(port["multiple"] ?? false)
})
}));
.map((port) => {
const type = String(port["type"] ?? "TEXT");
const multiple = Boolean(port["multiple"] ?? false);
return {
...port,
name: String(port["name"]),
type,
multiple,
valueKinds: this.toValueKinds(port["valueKinds"], { type, multiple })
};
});
}
private toValueKinds(raw: unknown, fallback: { type: string; multiple: boolean }) {
@ -186,9 +274,12 @@ export class BlocksCallService extends BlocksCallServiceBase {
};
}
private resolveExampleBlockEndpoint(descriptor?: BlockType): string | null {
if (!descriptor?.hasExampleBlock) return null;
return descriptor.exampleBlockEndpoint ?? null;
private resolveExampleEndpoint(family: NodeFamily, typeName: string, descriptor?: BlockType): string {
if (descriptor?.hasExampleBlock && descriptor.exampleBlockEndpoint) {
return descriptor.exampleBlockEndpoint;
}
const base = family === 'container' ? 'containers' : 'blocks';
return `${environment.apiUrl}/${base}/types/${encodeURIComponent(typeName)}/example`;
}
private toUpdateBlockError(error: unknown, blockType: string): Error {

View File

@ -1,8 +1,8 @@
import { computed, Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType, BlockTypeName, FlowBlock } from '@models/flow';
import { BlockType, BlockTypeName, FlowData, FlowNode, NodeFamily } from '@models/flow';
import { BlocksCallServiceBase } from './block-call.base';
import { catchError, finalize, firstValueFrom, map, Observable, of, shareReplay, throwError } from 'rxjs';
import { catchError, finalize, firstValueFrom, forkJoin, map, Observable, of, shareReplay, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',
@ -12,8 +12,8 @@ export class BlocksService {
toInit: boolean = true;
private loadingPromise: Promise<void> | null = null;
private readonly emptyBlockCache = new Map<string, FlowBlock>();
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowBlock>>();
private readonly emptyBlockCache = new Map<string, FlowNode>();
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowNode>>();
private readonly pendingServerSyncCount = signal(0);
private _blockTypes = signal<BlockType[]>([]);
@ -33,9 +33,12 @@ export class BlocksService {
return this.loadingPromise;
}
this.loadingPromise = firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes())
.then((blockTypes) => {
this._blockTypes.set(blockTypes);
this.loadingPromise = firstValueFrom(forkJoin({
blocks: this.blocksCallService.retrieveAllBlocksTypes(),
containers: this.blocksCallService.retrieveAllContainerTypes()
}))
.then(({ blocks, containers }) => {
this._blockTypes.set([...blocks, ...containers]);
this.clearEmptyBlockCache();
})
.catch((err) => {
@ -53,27 +56,31 @@ export class BlocksService {
const current = this._blockTypes().find((blockType) => blockType.type === typeName);
if (current) return current;
const blockTypes = await firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes());
const { blocks, containers } = await firstValueFrom(forkJoin({
blocks: this.blocksCallService.retrieveAllBlocksTypes(),
containers: this.blocksCallService.retrieveAllContainerTypes()
}));
const blockTypes = [...blocks, ...containers];
this._blockTypes.set(blockTypes);
this.clearEmptyBlockCache();
return blockTypes.find((blockType) => blockType.type === typeName);
}
createEmptyBlock(blockType: BlockTypeName) {
const cacheKey = String(blockType);
createEmptyBlock(blockType: BlockTypeName, family?: NodeFamily) {
const cacheKey = `${family ?? 'auto'}:${String(blockType)}`;
const cached = this.emptyBlockCache.get(cacheKey);
if (cached) {
return of(this.cloneEmptyBlock(cached));
return of(this.cloneEmptyNode(cached));
}
const pending = this.pendingEmptyBlockRequests.get(cacheKey);
if (pending) {
return pending.pipe(map((block) => this.cloneEmptyBlock(block)));
return pending.pipe(map((block) => this.cloneEmptyNode(block)));
}
const request = this.blocksCallService.createEmptyBlock(blockType).pipe(
const request = this.createEmptyNodeRequest(blockType, family).pipe(
map((block) => {
this.emptyBlockCache.set(cacheKey, this.cloneEmptyBlock(block));
this.emptyBlockCache.set(cacheKey, this.cloneEmptyNode(block));
return block;
}),
finalize(() => {
@ -85,7 +92,7 @@ export class BlocksService {
this.pendingEmptyBlockRequests.set(cacheKey, request);
return request.pipe(
map((block) => this.cloneEmptyBlock(block)),
map((block) => this.cloneEmptyNode(block)),
catchError((err) => {
console.error('Create empty block failed', err);
return throwError(() => err);
@ -106,12 +113,28 @@ export class BlocksService {
);
}
validateContainerSubflow(subFlow: FlowData) {
return this.blocksCallService.validateContainerSubflow(this.deepClone(subFlow)).pipe(
catchError((err) => {
console.error('Validate container subflow failed', err);
return throwError(() => err);
})
);
}
private clearEmptyBlockCache() {
this.emptyBlockCache.clear();
this.pendingEmptyBlockRequests.clear();
}
private cloneEmptyBlock(block: FlowBlock): FlowBlock {
private createEmptyNodeRequest(blockType: BlockTypeName, family?: NodeFamily): Observable<FlowNode> {
const normalizedFamily = family ?? this._blockTypes().find((type) => type.type === blockType)?.family ?? 'block';
return normalizedFamily === 'container'
? this.blocksCallService.createEmptyContainer(blockType)
: this.blocksCallService.createEmptyBlock(blockType);
}
private cloneEmptyNode(block: FlowNode): FlowNode {
const clone = this.deepClone(block);
return {
...clone,

View File

@ -0,0 +1,25 @@
import { Injectable, signal } from '@angular/core';
import { FlowData } from '@models/flow';
type SubflowPreviewDialogState = {
title: string;
flowData: FlowData;
};
@Injectable({ providedIn: 'root' })
export class SubflowPreviewDialogService {
private readonly _state = signal<SubflowPreviewDialogState | null>(null);
readonly state = this._state.asReadonly();
open(flowData: FlowData, title?: string) {
this._state.set({
title: title?.trim() || 'Subflow Preview',
flowData
});
}
close() {
this._state.set(null);
}
}

View File

@ -1,4 +1,4 @@
import { Flow, FlowData, FlowStatus, FlowVisibility } from '@models/flow';
import { Flow, FlowBlock, FlowContainer, FlowData, FlowStatus, FlowVisibility } from '@models/flow';
function parseDate(value: unknown, fallback: Date): Date {
if (typeof value !== 'string' || !value) return fallback;
@ -31,7 +31,8 @@ export function flowFromApi(raw: unknown): Flow {
published,
finalized: typeof value['finalized'] === 'boolean' ? value['finalized'] : undefined,
data: {
blocks: Array.isArray(data.blocks) ? data.blocks : [],
blocks: normalizeNodes(data.blocks, 'block') as FlowBlock[],
containers: normalizeNodes(data.containers, 'container') as FlowContainer[],
connections: Array.isArray(data.connections) ? data.connections : []
}
};
@ -44,7 +45,19 @@ export function toFlowCreateRequest(name: string, description?: string, flow?: F
status,
flow: flow ?? {
blocks: [],
containers: [],
connections: []
}
};
}
function normalizeNodes(raw: unknown, nodeFamily: 'block' | 'container'): Array<FlowBlock | FlowContainer> {
if (!Array.isArray(raw)) return [];
return raw
.filter((value): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value))
.map((value) => ({
...value,
nodeFamily
})) as Array<FlowBlock | FlowContainer>;
}

View File

@ -17,8 +17,8 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
authorizationService = inject(Authorization);
private data: Record<string, Flow> = {
'1': { id: '1', name: 'A Flow', data: { blocks: [], connections: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00") },
'2': { id: '2', name: 'Test Flow', data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00") },
'1': { id: '1', name: 'A Flow', data: { blocks: [], containers: [], connections: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00") },
'2': { id: '2', name: 'Test Flow', data: { blocks: [], containers: [], connections: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00") },
'testFlow': flowFromApi(testDataFlow)
}
@ -52,7 +52,7 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
return this.createFlow({
name: name || 'New Flow',
description: undefined,
data: { blocks: [], connections: [] },
data: { blocks: [], containers: [], connections: [] },
status: 'DRAFT'
});
}

View File

@ -31,6 +31,7 @@ export class FlowsCallService extends FlowsCallServiceBase {
description: undefined,
data: {
blocks: [],
containers: [],
connections: []
},
status: 'DRAFT'

View File

@ -102,6 +102,7 @@ export class FlowsService {
description: undefined,
data: {
blocks: [],
containers: [],
connections: []
},
status: 'DRAFT'

View File

@ -6,9 +6,9 @@
<div class="blocks-list-root">
<div class="blocks-list-search">
<mat-form-field appearance="outline">
<mat-label>Search blocks</mat-label>
<mat-label>Search nodes</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput placeholder="Search blocks..." [(ngModel)]="searchTerm" />
<input matInput placeholder="Search blocks and containers..." [(ngModel)]="searchTerm" />
</mat-form-field>
</div>
@ -31,7 +31,7 @@
(dragstart)="onDragStart($event, block)">
<mat-card-header class="blocks-list-card-header">
<div matCardAvatar class="blocks-list-card-avatar">
<mat-icon [fontIcon]="block.userInteractive ? 'touch_app' : 'auto_awesome'"></mat-icon>
<mat-icon [fontIcon]="block.family === 'container' ? 'inventory_2' : (block.userInteractive ? 'touch_app' : 'auto_awesome')"></mat-icon>
</div>
<div class="blocks-list-card-heading">
<div class="blocks-list-card-title-row">
@ -48,7 +48,7 @@
</button>
</div>
<mat-card-subtitle class="blocks-list-card-subtitle">
{{ block.userInteractive ? 'Interactive' : 'Automatic' }}
{{ block.family === 'container' ? 'Container' : (block.userInteractive ? 'Interactive' : 'Automatic') }}
</mat-card-subtitle>
</div>
</mat-card-header>
@ -57,7 +57,7 @@
</mat-card-content>
<mat-card-actions class="blocks-list-card-actions" align="end">
<mat-chip class="blocks-list-card-chip" [class.blocks-list-card-chip-interactive]="block.userInteractive">
{{ block.userInteractive ? 'Human step' : 'Automated step' }}
{{ block.family === 'container' ? 'Container node' : (block.userInteractive ? 'Human step' : 'Automated step') }}
</mat-chip>
</mat-card-actions>
</mat-card>

View File

@ -0,0 +1 @@
export const CONTAINER_SUBFLOW_DRAG_MIME = 'application/x-hf-selected-subflow';

View File

@ -0,0 +1,309 @@
.container-node {
width: 340px;
border: 1px solid #cbd5e1;
border-radius: 18px;
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.18), transparent 36%),
linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
color: #0f172a;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.14);
overflow: hidden;
}
:host.selected .container-node {
border-color: #0f766e;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18), 0 16px 36px rgba(15, 23, 42, 0.18);
}
.container-node__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: linear-gradient(135deg, #0f766e 0%, #0f766e 45%, #0ea5e9 100%);
color: #f8fafc;
}
.container-node__icon {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.26);
font-size: 16px;
}
.container-node__titles {
min-width: 0;
flex: 1;
}
.container-node__eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.86;
}
.container-node__name {
font-size: 16px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.container-node__delete {
border: 0;
width: 30px;
height: 30px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
color: #f8fafc;
}
.container-node__ports {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
padding: 14px;
}
.container-node__port-title {
margin-bottom: 8px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
}
.container-node__port-column--right .container-node__port-title {
text-align: right;
}
.container-node__port-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.container-node__port-row--right {
justify-content: flex-end;
}
.container-node__port-label {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #e2e8f0;
color: #0f172a;
font-size: 12px;
font-weight: 600;
}
.container-node__port-name {
line-height: 1.1;
}
.container-node__port-kind {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: #475569;
}
.container-node__dropzone {
margin: 0 14px 14px;
min-height: 152px;
border: 2px dashed #94a3b8;
border-radius: 18px;
padding: 18px 14px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
background:
linear-gradient(135deg, rgba(240, 253, 250, 0.9) 0%, rgba(239, 246, 255, 0.95) 100%);
text-align: center;
}
.container-node__dropzone--active {
border-color: #0f766e;
background:
linear-gradient(135deg, rgba(204, 251, 241, 0.95) 0%, rgba(224, 242, 254, 0.98) 100%);
}
.container-node__dropzone--filled {
border-style: solid;
}
.container-node__dropzone-main {
display: flex;
justify-content: center;
gap: 10px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.container-node__dropzone-note {
font-size: 12px;
line-height: 1.45;
color: #475569;
}
.container-node__subflow-preview {
display: grid;
gap: 8px;
margin-top: 4px;
text-align: left;
}
.container-node__subflow-chip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.38);
border-radius: 12px;
background: rgba(255, 255, 255, 0.86);
}
.container-node__subflow-chip--container {
border-color: rgba(14, 165, 233, 0.34);
background: rgba(224, 242, 254, 0.72);
}
.container-node__subflow-chip-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
color: #0f172a;
}
.container-node__subflow-chip-type {
font-size: 11px;
font-weight: 600;
color: #475569;
white-space: nowrap;
}
.container-node__subflow-more {
font-size: 11px;
font-weight: 700;
color: #0f766e;
text-align: center;
}
.container-node__dropzone-empty {
font-size: 16px;
}
.container-node__actions {
display: flex;
justify-content: flex-end;
padding: 0 14px 14px;
}
.container-node__subflow-section {
margin: 0 14px 14px;
border: 1px solid #dbe2ea;
border-radius: 16px;
padding: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96));
}
.container-node__subflow-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.container-node__subflow-title {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #475569;
}
.container-node__subflow-view {
border: 1px solid #bae6fd;
border-radius: 999px;
background: #f0f9ff;
color: #0c4a6e;
font-size: 11px;
font-weight: 700;
padding: 6px 10px;
}
.container-node__action {
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #ffffff;
color: #0f172a;
font-size: 12px;
font-weight: 600;
padding: 8px 12px;
}
.container-node__message {
margin: 0 14px 14px;
border-radius: 12px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.45;
}
.container-node__message--error {
border: 1px solid #fecaca;
background: #fff1f2;
color: #991b1b;
}
.container-node__message--warning {
border: 1px solid #fde68a;
background: #fefce8;
color: #854d0e;
}
.container-node__validation-item + .container-node__validation-item {
margin-top: 8px;
}
.container-node__validation-meta {
display: block;
margin-top: 2px;
opacity: 0.82;
}
.container-node__spinner {
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid rgba(15, 118, 110, 0.2);
border-top-color: #0f766e;
animation: container-node-spin 0.75s linear infinite;
}
@keyframes container-node-spin {
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,150 @@
<div class="container-node" [class.container-node-assigning]="isAssigning">
<div class="container-node__header">
<div class="container-node__icon">
<i class="bi bi-box-seam"></i>
</div>
<div class="container-node__titles">
<div class="container-node__eyebrow">Generic Container</div>
<div class="container-node__name">{{ name }}</div>
</div>
<button type="button" class="container-node__delete" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="deleteNode($event)">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="container-node__ports">
<div class="container-node__port-column">
<div class="container-node__port-title">Inputs</div>
@for (input of inputs; track input.key) {
<div class="container-node__port-row">
<div
refComponent
class="container-node__socket"
[data]="{
type: 'socket',
side: 'input',
key: input.key,
nodeId: data.id,
payload: input.socket
}"
[emit]="emit">
</div>
<span class="container-node__port-label">
<span class="container-node__port-name">{{ inputDisplayLabel(input.key) }}</span>
<span class="container-node__port-kind">{{ inputKindLabel(input.key) }}</span>
</span>
</div>
}
</div>
<div class="container-node__port-column container-node__port-column--right">
<div class="container-node__port-title">Outputs</div>
@for (output of outputs; track output.key) {
<div class="container-node__port-row container-node__port-row--right">
<span class="container-node__port-label">
<span class="container-node__port-name">{{ outputDisplayLabel(output.key) }}</span>
<span class="container-node__port-kind">{{ outputKindLabel(output.key) }}</span>
</span>
<div
refComponent
class="container-node__socket container-node__socket--right"
[data]="{
type: 'socket',
side: 'output',
key: output.key,
nodeId: data.id,
payload: output.socket
}"
[emit]="emit">
</div>
</div>
}
</div>
</div>
<div
class="container-node__dropzone"
[class.container-node__dropzone--active]="selectedCount > 0"
[class.container-node__dropzone--filled]="subFlowBlockCount > 0"
(dragover)="onDropZoneDragOver($event)"
(dragleave)="onDropZoneDragLeave($event)"
(drop)="onDropZoneDrop($event)">
@if (isAssigning) {
<div class="container-node__dropzone-main">
<span class="container-node__spinner" aria-hidden="true"></span>
<span>Validating subflow...</span>
</div>
} @else if (subFlowBlockCount > 0) {
<div class="container-node__dropzone-main">
<span>{{ subFlowBlockCount }} nodes</span>
<span>{{ subFlowConnectionCount }} connections</span>
</div>
<div class="container-node__dropzone-note">
Drop a new selection here to replace the embedded subflow.
</div>
} @else {
<div class="container-node__dropzone-main">
<span class="container-node__dropzone-empty">Drop selected nodes here</span>
</div>
<div class="container-node__dropzone-note">
Use the selection box in the editor, then drag the floating selection badge into this area.
</div>
}
</div>
@if (subFlowBlockCount > 0) {
<div class="container-node__subflow-section">
<div class="container-node__subflow-head">
<div class="container-node__subflow-title">Subflow Content</div>
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
View Flow
</button>
</div>
<div class="container-node__subflow-preview">
@for (node of subFlowPreviewNodes; track node.id) {
<div class="container-node__subflow-chip" [class.container-node__subflow-chip--container]="node.family === 'container'">
<span class="container-node__subflow-chip-name">{{ node.name }}</span>
<span class="container-node__subflow-chip-type">{{ node.type }}</span>
</div>
}
@if (hasMoreSubFlowNodes) {
<div class="container-node__subflow-more">
+{{ subFlowBlockCount - subFlowPreviewNodes.length }} more
</div>
}
</div>
</div>
<div class="container-node__actions">
<button type="button" class="container-node__action" (pointerdown)="$event.stopPropagation()" (click)="removeSubflow($event)">
Remove subflow
</button>
</div>
}
@if (assignmentErrorMessage) {
<div class="container-node__message container-node__message--error">
{{ assignmentErrorMessage }}
</div>
}
@if (validationErrors.length) {
<div class="container-node__message container-node__message--warning">
@for (error of validationErrors; track $index) {
<div class="container-node__validation-item">
<span>{{ error.message }}</span>
@if (error.entity || error.id || error.field) {
<span class="container-node__validation-meta">
{{ error.entity || 'entity' }}
@if (error.id) {
· {{ error.id }}
}
@if (error.field) {
· {{ error.field }}
}
</span>
}
</div>
}
</div>
}
</div>

View File

@ -0,0 +1,225 @@
import { CommonModule } from '@angular/common';
import { Component, HostBinding, Input, inject } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowNode, FlowSubflowValidationError } from '@models/flow';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { EditorStateHolder } from '@stores/flow-editor';
import { pathToLabel } from '../node-utility';
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
@Component({
selector: 'app-container-node',
imports: [CommonModule, ReteModule],
templateUrl: './container-node.html',
styleUrl: './container-node.css',
host: {
'data-testid': 'node'
}
})
export class ContainerNodeComponent {
private editorState = inject(EditorStateHolder);
private subflowPreview = inject(SubflowPreviewDialogService);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@HostBinding('class.selected') get selected() {
return this.data.selected || this.editorState.isBlockSelected(this.blockId);
}
@HostBinding('attr.data-block-id') get hostBlockId() {
return this.blockId;
}
ngAfterViewInit() {
this.rendered();
}
get name() {
return String(this.configuration?.['name'] ?? this.data?.data?.name ?? 'Container');
}
get inputs() {
return Object.entries(this.data?.inputs ?? {}).map(([key, input]) => ({
key,
socket: (input as any).socket as ClassicPreset.Socket
}));
}
get outputs() {
return Object.entries(this.data?.outputs ?? {}).map(([key, output]) => ({
key,
socket: (output as any).socket as ClassicPreset.Socket
}));
}
get selectedCount() {
return this.editorState.selectedBlockIds().filter((id) => id !== this.blockId).length;
}
get subFlow(): FlowData | null {
const value = this.configuration?.['subFlow'];
if (!value || typeof value !== 'object') return null;
const candidate = value as Partial<FlowData>;
const blocks = Array.isArray(candidate.blocks) ? candidate.blocks : [];
const containers = Array.isArray(candidate.containers) ? candidate.containers : [];
const connections = Array.isArray(candidate.connections) ? candidate.connections : [];
if (!blocks.length && !containers.length && !connections.length) {
return null;
}
return {
blocks,
containers,
connections
};
}
get subFlowBlockCount() {
return (this.subFlow?.blocks?.length ?? 0) + (this.subFlow?.containers?.length ?? 0);
}
get subFlowConnectionCount() {
return this.subFlow?.connections?.length ?? 0;
}
get subFlowPreviewNodes() {
const subFlow = this.subFlow;
if (!subFlow) return [];
return [...(subFlow.blocks ?? []), ...(subFlow.containers ?? [])]
.slice(0, 6)
.map((node) => this.toPreviewNode(node));
}
get hasMoreSubFlowNodes() {
return this.subFlowBlockCount > this.subFlowPreviewNodes.length;
}
get validationErrors(): FlowSubflowValidationError[] {
const errors = this.data?.data?.['__containerValidationErrors'];
return Array.isArray(errors) ? errors : [];
}
get assignmentErrorMessage() {
const value = this.data?.data?.['__containerAssignmentError'];
return typeof value === 'string' && value.length > 0 ? value : null;
}
get isAssigning() {
return this.data?.data?.['__containerAssigning'] === true;
}
inputDisplayLabel(inputKey: string) {
return pathToLabel(inputKey);
}
outputDisplayLabel(outputKey: string) {
return pathToLabel(outputKey);
}
inputKindLabel(inputKey: string) {
const port = this.inputs.find((candidate) => candidate.key === inputKey);
return port ? flowValueKindLabel(currentFlowPortValueKind((this.data?.data?.inputs ?? []).find((item: any) => item?.name === inputKey) ?? { type: 'ANY', multiple: false })) : 'ANY';
}
outputKindLabel(outputKey: string) {
const port = this.outputs.find((candidate) => candidate.key === outputKey);
return port ? flowValueKindLabel(currentFlowPortValueKind((this.data?.data?.outputs ?? []).find((item: any) => item?.name === outputKey) ?? { type: 'ANY', multiple: false })) : 'ANY';
}
onDropZoneDragOver(event: DragEvent) {
if (!this.canAcceptSelectionDrop()) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
onDropZoneDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
const raw = event.dataTransfer?.getData(CONTAINER_SUBFLOW_DRAG_MIME);
const payload = this.parseDraggedSelection(raw);
const assign = this.data?.data?.assignSelectedBlocksToContainer;
if (!payload.length || typeof assign !== 'function') return;
void assign(payload);
this.editorState.stopDraggingSelectedBlocks();
}
onDropZoneDragLeave(_: DragEvent) {
this.editorState.stopDraggingSelectedBlocks();
}
removeSubflow(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const clear = this.data?.data?.clearContainerSubflow;
if (typeof clear === 'function') {
void clear();
}
}
deleteNode(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const remove = this.data?.data?.deleteNode;
if (typeof remove === 'function') {
void remove();
}
}
openSubflowPreview(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.subFlow) return;
this.subflowPreview.open(this.subFlow, `${this.name} subflow`);
}
private get configuration(): Record<string, unknown> | null {
const value = this.data?.data?.specificConfiguration;
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
private get blockId(): string | null {
const blockId = this.data?.data?.id;
return typeof blockId === 'string' && blockId.length > 0 ? blockId : null;
}
private canAcceptSelectionDrop() {
return !this.isAssigning && this.selectedCount > 0;
}
private parseDraggedSelection(raw: string | undefined) {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === 'string' && value.length > 0)
: [];
} catch {
return [];
}
}
private toPreviewNode(node: FlowNode) {
return {
id: node.id,
name: String(node.name ?? node.typeName ?? 'Node'),
type: this.nodeTypeLabel(String(node.typeName ?? 'Node')),
family: node.nodeFamily === 'container' ? 'container' : 'block'
};
}
private nodeTypeLabel(typeName: string) {
if (typeName === 'HumanInteractionBlock') return 'Human Task';
return pathToLabel(typeName.replace(/Block$/, ''));
}
}

View File

@ -11,6 +11,11 @@
color: #0f172a;
}
:host.selected .llm-node {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.24), 0 12px 28px rgba(15, 23, 42, 0.16);
}
.llm-delete-overlay {
position: absolute;
inset: 0;

View File

@ -125,7 +125,11 @@ export class GenericNodeComponent {
@Input() rendered!: () => void;
@HostBinding('class.selected') get selected() {
return this.data.selected;
return this.data.selected || this.editorState.isBlockSelected(this.blockId);
}
@HostBinding('attr.data-block-id') get hostBlockId() {
return this.blockId;
}
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
@ -457,6 +461,11 @@ export class GenericNodeComponent {
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
}
private get blockId(): string | null {
const blockId = this.data?.data?.id;
return typeof blockId === 'string' && blockId.length > 0 ? blockId : null;
}
private ensureBlockConfiguration(): Record<string, any> {
if (!this.data?.data) {
this.data.data = {};

View File

@ -9,6 +9,35 @@
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.rete-editor-selection-badge {
position: absolute;
top: 16px;
right: 16px;
z-index: 35;
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 14px;
border: 1px solid #99f6e4;
border-radius: 999px;
background: rgba(15, 118, 110, 0.96);
color: #f0fdfa;
font-size: 13px;
font-weight: 700;
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.18);
cursor: grab;
}
.rete-editor-selection-box {
position: absolute;
z-index: 30;
border: 1px solid #0f766e;
background: rgba(20, 184, 166, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
pointer-events: none;
}
.rete-editor-loading {

View File

@ -1,7 +1,33 @@
<div class="rete-editor-shell">
<div
class="rete-editor-shell"
#shell
(pointerdown)="onShellPointerDown($event)"
(pointermove)="onShellPointerMove($event)"
(pointerup)="onShellPointerUp($event)"
(pointercancel)="onShellPointerUp($event)">
<div class="h-full w-full" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
</div>
@if (hasSelectedBlocks) {
<div
class="rete-editor-selection-badge"
draggable="true"
(dragstart)="onSelectionDragStart($event)"
(dragend)="onSelectionDragEnd()">
Drag {{ selectedBlockCount }} selected {{ selectedBlockCount === 1 ? 'node' : 'nodes' }}
</div>
}
@if (selectionBox(); as box) {
<div
class="rete-editor-selection-box"
[style.left.px]="box.left"
[style.top.px]="box.top"
[style.width.px]="box.width"
[style.height.px]="box.height">
</div>
}
@if (creatingEmptyBlock) {
<div class="rete-editor-loading">
<div class="rete-editor-loading-card">

View File

@ -1,7 +1,8 @@
import { Component, ElementRef, Injector, input, OnChanges, OnDestroy, output, SimpleChanges, ViewChild } from '@angular/core';
import { BlockType, FlowBlock, FlowData } from '@models/flow';
import { Component, ElementRef, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, ViewChild } from '@angular/core';
import { BlockType, FlowData, FlowNode } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
import { CONTAINER_SUBFLOW_DRAG_MIME } from '@shared/nodes/container-node/container-node-drag';
import { EditorStateHolder } from '@stores/flow-editor';
import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance } from '@utilities/rete-editor';
import { firstValueFrom } from 'rxjs';
@ -25,6 +26,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
) {}
@ViewChild("editor") container!: ElementRef;
@ViewChild("shell") shell!: ElementRef<HTMLElement>;
private rete?: ReteEditorInstance;
private viewReady = false;
@ -32,6 +34,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
private suppressDirtyEvents = false;
creatingEmptyBlock = false;
creatingEmptyBlockType = '';
selectionBox = signal<{ left: number; top: number; width: number; height: number } | null>(null);
private selectionPointerId: number | null = null;
private selectionStart: { x: number; y: number } | null = null;
private readonly dirtyEventTypes = new Set([
'nodecreated',
'noderemoved',
@ -57,12 +62,24 @@ export class ReteEditor implements OnChanges, OnDestroy {
ngOnDestroy(): void {
this.rete?.area.destroy();
this.rete = undefined;
this.flowState.stopDraggingSelectedBlocks();
}
get selectedBlockCount() {
return this.flowState.selectedBlockIds().length;
}
get hasSelectedBlocks() {
return !this.readonly() && this.selectedBlockCount > 0;
}
onDragOver(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
const dragTypes = Array.from(event.dataTransfer.types ?? []);
event.dataTransfer.dropEffect = dragTypes.includes(CONTAINER_SUBFLOW_DRAG_MIME)
? 'move'
: 'copy';
}
}
@ -74,11 +91,11 @@ export class ReteEditor implements OnChanges, OnDestroy {
const blockType: BlockType = JSON.parse(payload);
const position = this.getDropPosition(event);
let newBlock: FlowBlock;
let newBlock: FlowNode;
this.creatingEmptyBlock = true;
this.creatingEmptyBlockType = blockType.type;
try {
newBlock = await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type));
newBlock = await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type, blockType.family));
} catch (error) {
console.error('Failed to create empty block', error);
return;
@ -98,11 +115,74 @@ export class ReteEditor implements OnChanges, OnDestroy {
this.flowChanged.emit(updatedData);
}
onShellPointerDown(event: PointerEvent) {
if (this.readonly()) return;
if (event.button !== 0) return;
if (!this.canStartSelection(event.target)) return;
this.selectionPointerId = event.pointerId;
this.selectionStart = { x: event.clientX, y: event.clientY };
this.selectionBox.set({ left: 0, top: 0, width: 0, height: 0 });
this.shell.nativeElement.setPointerCapture(event.pointerId);
}
onShellPointerMove(event: PointerEvent) {
if (this.selectionPointerId !== event.pointerId || !this.selectionStart) return;
const shellRect = this.shell.nativeElement.getBoundingClientRect();
const left = Math.min(this.selectionStart.x, event.clientX) - shellRect.left;
const top = Math.min(this.selectionStart.y, event.clientY) - shellRect.top;
const width = Math.abs(event.clientX - this.selectionStart.x);
const height = Math.abs(event.clientY - this.selectionStart.y);
this.selectionBox.set({ left, top, width, height });
}
onShellPointerUp(event: PointerEvent) {
if (this.selectionPointerId !== event.pointerId || !this.selectionStart) return;
const shell = this.shell.nativeElement;
if (shell.hasPointerCapture(event.pointerId)) {
shell.releasePointerCapture(event.pointerId);
}
const start = this.selectionStart;
const moved = Math.abs(event.clientX - start.x) > 4 || Math.abs(event.clientY - start.y) > 4;
if (moved) {
this.flowState.setSelectedBlocks(this.resolveBlocksInsideSelection());
} else if (this.canStartSelection(event.target)) {
this.flowState.clearBlockSelection();
}
this.selectionPointerId = null;
this.selectionStart = null;
this.selectionBox.set(null);
}
onSelectionDragStart(event: DragEvent) {
if (!event.dataTransfer) return;
const selectedBlockIds = this.flowState.selectedBlockIds();
if (!selectedBlockIds.length) {
event.preventDefault();
return;
}
this.flowState.startDraggingSelectedBlocks(selectedBlockIds);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(CONTAINER_SUBFLOW_DRAG_MIME, JSON.stringify(selectedBlockIds));
}
onSelectionDragEnd() {
this.flowState.stopDraggingSelectedBlocks();
}
private async reloadEditor() {
const host = this.container?.nativeElement as HTMLElement | undefined;
if (!host) return;
const currentVersion = ++this.loadVersion;
this.flowState.clearBlockSelection();
this.suppressDirtyEvents = true;
this.rete?.area.destroy();
this.rete = undefined;
@ -184,4 +264,44 @@ export class ReteEditor implements OnChanges, OnDestroy {
this.flowState.updateData(updatedData);
this.flowChanged.emit(updatedData);
}
private canStartSelection(target: EventTarget | null) {
const element = target instanceof HTMLElement ? target : null;
if (!element) return false;
if (element.closest('[data-testid="node"]')) return false;
if (element.closest('button, input, textarea, select, option, label, a')) return false;
return true;
}
private resolveBlocksInsideSelection() {
const selection = this.selectionBox();
if (!selection) return [];
const shellRect = this.shell.nativeElement.getBoundingClientRect();
const selectionRect = {
left: shellRect.left + selection.left,
top: shellRect.top + selection.top,
right: shellRect.left + selection.left + selection.width,
bottom: shellRect.top + selection.top + selection.height
};
return Array.from(
this.shell.nativeElement.querySelectorAll<HTMLElement>('[data-testid="node"][data-block-id]')
)
.filter((element) => this.isRectIntersecting(selectionRect, element.getBoundingClientRect()))
.map((element) => element.dataset['blockId'] ?? '')
.filter((blockId) => blockId.length > 0);
}
private isRectIntersecting(
selectionRect: { left: number; top: number; right: number; bottom: number },
targetRect: DOMRect
) {
return !(
targetRect.right < selectionRect.left
|| targetRect.left > selectionRect.right
|| targetRect.bottom < selectionRect.top
|| targetRect.top > selectionRect.bottom
);
}
}

View File

@ -0,0 +1,53 @@
.subflow-preview {
display: flex;
align-items: center;
justify-content: center;
}
.subflow-preview__backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.48);
}
.subflow-preview__panel {
position: relative;
z-index: 1;
width: min(1100px, calc(100vw - 48px));
height: min(760px, calc(100vh - 48px));
border-radius: 24px;
border: 1px solid #dbe2ea;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.3);
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
.subflow-preview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 22px;
border-bottom: 1px solid #e2e8f0;
}
.subflow-preview__eyebrow {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #0f766e;
}
.subflow-preview__title {
margin: 4px 0 0;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.subflow-preview__canvas {
min-height: 0;
}

View File

@ -0,0 +1,23 @@
@if (state()) {
<div class="subflow-preview fixed inset-0 z-9999">
<div class="subflow-preview__backdrop" (click)="close($event)"></div>
<div class="subflow-preview__panel">
<div class="subflow-preview__header">
<div>
<div class="subflow-preview__eyebrow">Read-only Subflow</div>
<h3 class="subflow-preview__title">{{ state()!.title }}</h3>
</div>
<button type="button" mat-stroked-button (click)="close($event)">Close</button>
</div>
<div class="subflow-preview__canvas">
<app-rete-editor
[flowData]="state()!.flowData"
[flowId]="flowId()"
[readonly]="true">
</app-rete-editor>
</div>
</div>
</div>
}

View File

@ -0,0 +1,27 @@
import { Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { ReteEditor } from '@shared/rete-editor/rete-editor';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
@Component({
selector: 'app-subflow-preview-dialog-host',
imports: [CommonModule, MatButtonModule, ReteEditor],
templateUrl: './subflow-preview-dialog.html',
styleUrl: './subflow-preview-dialog.css'
})
export class SubflowPreviewDialogHostComponent {
private dialog = inject(SubflowPreviewDialogService);
readonly state = this.dialog.state;
readonly flowId = computed(() => {
const state = this.state();
return state ? `subflow-preview:${state.title}` : 'subflow-preview';
});
close(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.dialog.close();
}
}

View File

@ -120,6 +120,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const steps = this.stepsArray();
const blocks = steps.map((step, index) => ({
...step.block,
nodeFamily: 'block' as const,
specificConfiguration: {
...(step.block.specificConfiguration ?? {}),
__executionId: this.execution()?.id ?? null,
@ -141,7 +142,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
}));
const connections = this.getExecutionConnections(steps);
return { blocks, connections };
return { blocks, containers: [], connections };
});
readonly formattedDuration = computed(() => {

View File

@ -11,6 +11,8 @@ export class EditorStateHolder {
/** Stato */
readonly currentFlow = signal<Flow | null>(null);
readonly isDirty = signal(false);
readonly selectedBlockIds = signal<string[]>([]);
readonly draggingSelectedBlockIds = signal<string[]>([]);
/** Derived state */
readonly hasFlow = computed(() => !!this.currentFlow());
@ -31,6 +33,7 @@ export class EditorStateHolder {
this.currentFlow.set(doc);
this.isDirty.set(false);
this.clearBlockSelection();
return true;
}
@ -48,11 +51,13 @@ export class EditorStateHolder {
closeDocument() {
this.currentFlow.set(null);
this.isDirty.set(false);
this.clearBlockSelection();
}
loadAssistantFlow(flow: Flow, options?: { markDirty?: boolean }) {
this.currentFlow.set(flow);
this.isDirty.set(options?.markDirty === true);
this.clearBlockSelection();
}
updateData(data: FlowData) {
@ -72,6 +77,30 @@ export class EditorStateHolder {
this.currentFlow.set({ ...current, data });
}
setSelectedBlocks(blockIds: string[]) {
const unique = Array.from(new Set(blockIds.filter((id) => typeof id === 'string' && id.length > 0)));
this.selectedBlockIds.set(unique);
}
clearBlockSelection() {
this.selectedBlockIds.set([]);
this.draggingSelectedBlockIds.set([]);
}
isBlockSelected(blockId: string | null | undefined): boolean {
if (!blockId) return false;
return this.selectedBlockIds().includes(blockId);
}
startDraggingSelectedBlocks(blockIds?: string[]) {
const nextIds = blockIds?.length ? blockIds : this.selectedBlockIds();
this.draggingSelectedBlockIds.set(Array.from(new Set(nextIds)));
}
stopDraggingSelectedBlocks() {
this.draggingSelectedBlockIds.set([]);
}
updateFlowTitle(newTitle: string) {
const current = this.currentFlow();

View File

@ -7,19 +7,40 @@ import {
} from "rete-connection-plugin";
import { AngularPlugin, Presets, AngularArea2D } from "rete-angular-plugin/21";
import { HFNode, HFSchemes } from "@models/nodes";
import { areFlowValueKindsCompatible, FlowBlock, FlowData, normalizeFlowPortValueKinds } from "@models/flow";
import {
areFlowValueKindsCompatible,
FlowBlock,
FlowContainerOpenInput,
FlowContainerOpenOutput,
FlowContainerPublicInput,
FlowContainerPublicOutput,
FlowData,
FlowNode,
normalizeFlowPortValueKinds
} from "@models/flow";
import { BlocksService } from "@services/blocks/blocks";
import { NodeSettingsDialogService } from "@services/dialogs/node-settings-dialog";
import { EditorStateHolder } from "@stores/flow-editor";
import { ContainerNodeComponent } from "@shared/nodes/container-node/container-node";
import { GenericNodeComponent } from "@shared/nodes/generic-node/generic-node";
import { TaskStepNodeComponent } from "@shared/nodes/task-step-node/task-step-node";
import { CustomSocket } from "@shared/custom-socket/custom-socket";
type AreaExtra = AngularArea2D<HFSchemes>;
const editorSockets = new WeakMap<NodeEditor<HFSchemes>, Map<string, ClassicPreset.Socket>>();
const editorRuntime = new WeakMap<NodeEditor<HFSchemes>, ReteRuntimeContext>();
export type ReteEditorInstance = {
editor: NodeEditor<HFSchemes>;
area: AreaPlugin<HFSchemes, AreaExtra>;
};
type ReteRuntimeContext = {
blocksService: BlocksService;
flowState: EditorStateHolder;
settingsDialog: NodeSettingsDialogService;
};
export async function createEditor(
container: HTMLElement,
injector: Injector,
@ -32,14 +53,20 @@ export async function createEditor(
const connection = new ConnectionPlugin<HFSchemes, AreaExtra>();
const render = new AngularPlugin<HFSchemes, AreaExtra>({ injector });
const nodeView = options?.nodeView ?? "editor";
const runtime: ReteRuntimeContext = {
blocksService: injector.get(BlocksService),
flowState: injector.get(EditorStateHolder),
settingsDialog: injector.get(NodeSettingsDialogService)
};
editorRuntime.set(editor, runtime);
render.addPreset(
Presets.classic.setup({
customize: {
node(_context) {
return nodeView === "execution" ? TaskStepNodeComponent : GenericNodeComponent;
node(context: any) {
if (nodeView === "execution") return TaskStepNodeComponent;
const typeName = context?.payload?.data?.typeName;
return typeName === "GenericContainer" ? ContainerNodeComponent : GenericNodeComponent;
},
socket(context: any) {
// rete-angular passes only `payload` to the socket component.
@ -84,7 +111,7 @@ export async function createEditor(
AreaExtensions.simpleNodesOrder(area);
if (flowData)
await loadFlowData(editor, area, flowData);
await loadFlowData(editor, area, flowData, runtime);
AreaExtensions.zoomAt(area, editor.getNodes());
return { editor, area };
@ -92,7 +119,7 @@ export async function createEditor(
export function exportGraph(editor: NodeEditor<HFSchemes>) {
const nodeIdToBlockId = new Map<string, string>();
const blocks: FlowBlock[] = editor.getNodes().map((node) => {
const nodes: FlowNode[] = editor.getNodes().map((node) => {
const blockData = node.data;
const blockId = blockData?.id ?? node.id;
nodeIdToBlockId.set(node.id, blockId);
@ -107,7 +134,8 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
inputs,
outputs,
specificConfiguration: cloneValue(blockData?.specificConfiguration ?? {}),
typeName: blockData?.typeName ?? "LLMBlock"
typeName: blockData?.typeName ?? "LLMBlock",
nodeFamily: blockData?.nodeFamily === 'container' ? 'container' : 'block'
};
});
@ -120,7 +148,8 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
}));
return {
blocks,
blocks: nodes.filter((node): node is FlowBlock => node.nodeFamily === 'block'),
containers: nodes.filter((node) => node.nodeFamily === 'container'),
connections
};
}
@ -128,9 +157,11 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
export async function addBlockToEditor(
editor: NodeEditor<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,
block: FlowBlock,
position?: { x: number; y: number }
block: FlowNode,
position?: { x: number; y: number },
runtime?: ReteRuntimeContext
) {
const resolvedRuntime = runtime ?? editorRuntime.get(editor);
const node = new ClassicPreset.Node(toNodeLabel(block.typeName)) as HFNode;
const removeNode = async () => {
if (!editor.getNode(node.id)) return;
@ -144,7 +175,193 @@ export async function addBlockToEditor(
await editor.removeNode(node.id);
};
const replaceWithCreatedBlock = async (createdBlock: FlowBlock) => {
const clearContainerSubflow = async () => {
const currentNode = editor.getNode(node.id) as HFNode | undefined;
if (!currentNode?.data) return;
const nextConfiguration = {
...cloneValue(currentNode.data.specificConfiguration ?? {})
};
delete (nextConfiguration as Record<string, unknown>)["subFlow"];
delete (nextConfiguration as Record<string, unknown>)["publicInputs"];
delete (nextConfiguration as Record<string, unknown>)["publicOutputs"];
const replacement = {
...cloneValue(currentNode.data),
inputs: [],
outputs: [],
specificConfiguration: nextConfiguration
};
const replaceNode = currentNode.data['replaceWithCreatedBlock'];
if (typeof replaceNode === 'function') {
await replaceNode(replacement);
} else {
currentNode.data = {
...currentNode.data,
inputs: [],
outputs: [],
specificConfiguration: nextConfiguration,
__containerValidationErrors: [],
__containerAssignmentError: null,
__containerAssigning: false
};
await area.update("node", node.id);
}
if (resolvedRuntime) {
resolvedRuntime.flowState.updateData(exportGraph(editor));
}
};
const assignSelectedBlocksToContainer = async (selectedBlockIds?: string[]) => {
if (!resolvedRuntime) return;
const currentNode = editor.getNode(node.id) as HFNode | undefined;
const containerBlockId = currentNode?.data?.id;
if (!currentNode?.data || typeof containerBlockId !== "string") return;
const selection = Array.from(new Set((selectedBlockIds ?? resolvedRuntime.flowState.selectedBlockIds())
.filter((id) => typeof id === "string" && id.length > 0)
.filter((id) => id !== containerBlockId)));
if (!selection.length) {
currentNode.data = {
...currentNode.data,
__containerAssignmentError: "Select one or more nodes before dropping them into the container.",
__containerValidationErrors: []
};
await area.update("node", node.id);
return;
}
const currentFlow = exportGraph(editor);
const selectedBlocks = currentFlow.blocks.filter((candidate) => selection.includes(candidate.id));
const selectedContainers = currentFlow.containers.filter((candidate) => selection.includes(candidate.id));
const selectedIds = new Set([
...selectedBlocks.map((candidate) => candidate.id),
...selectedContainers.map((candidate) => candidate.id)
]);
const candidateSubFlow: FlowData = {
blocks: cloneValue(selectedBlocks),
containers: cloneValue(selectedContainers),
connections: cloneValue(
currentFlow.connections.filter((connection) =>
selectedIds.has(connection.sourceId) && selectedIds.has(connection.targetId)
)
)
};
currentNode.data = {
...currentNode.data,
__containerAssigning: true,
__containerAssignmentError: null,
__containerValidationErrors: []
};
await area.update("node", node.id);
resolvedRuntime.blocksService.validateContainerSubflow(candidateSubFlow).subscribe({
next: async (result) => {
const liveNode = editor.getNode(node.id) as HFNode | undefined;
if (!liveNode?.data) return;
if (!result.valid) {
liveNode.data = {
...liveNode.data,
__containerAssigning: false,
__containerAssignmentError: null,
__containerValidationErrors: result.errors
};
await area.update("node", node.id);
return;
}
const publicPortMapping = await promptContainerPublicPorts(
resolvedRuntime.settingsDialog,
result.openInputs,
result.openOutputs
);
if (!publicPortMapping) {
liveNode.data = {
...liveNode.data,
__containerAssigning: false,
__containerAssignmentError: null,
__containerValidationErrors: []
};
await area.update("node", node.id);
return;
}
const nextConfiguration = {
...cloneValue(liveNode.data.specificConfiguration ?? {}),
subFlow: candidateSubFlow,
publicInputs: publicPortMapping.publicInputs,
publicOutputs: publicPortMapping.publicOutputs
};
const nextInputs = publicPortMapping.publicInputs.map((item, index) => ({
name: item.name,
type: result.openInputs[index]?.type ?? 'TEXT',
multiple: Boolean(result.openInputs[index]?.multiple ?? false),
valueKinds: cloneValue(result.openInputs[index]?.valueKinds ?? [])
}));
const nextOutputs = publicPortMapping.publicOutputs.map((item, index) => ({
name: item.name,
type: result.openOutputs[index]?.type ?? 'TEXT',
multiple: Boolean(result.openOutputs[index]?.multiple ?? false),
valueKinds: cloneValue(result.openOutputs[index]?.valueKinds ?? [])
}));
const selectedNodeIds = editor.getNodes()
.filter((candidate) => selectedIds.has(String(candidate.data?.id ?? "")))
.map((candidate) => candidate.id)
.filter((candidateId) => candidateId !== node.id);
for (const selectedNodeId of selectedNodeIds) {
if (!editor.getNode(selectedNodeId)) continue;
const relatedConnectionIds = editor.getConnections()
.filter((connection) => connection.source === selectedNodeId || connection.target === selectedNodeId)
.map((connection) => connection.id);
for (const connectionId of relatedConnectionIds) {
await editor.removeConnection(connectionId);
}
await editor.removeNode(selectedNodeId);
}
const replacement = {
...cloneValue(liveNode.data),
inputs: nextInputs,
outputs: nextOutputs,
specificConfiguration: nextConfiguration,
__containerAssigning: false,
__containerAssignmentError: null,
__containerValidationErrors: []
};
const replaceNode = liveNode.data['replaceWithCreatedBlock'];
if (typeof replaceNode === 'function') {
await replaceNode(replacement);
} else {
liveNode.data = replacement;
await area.update("node", node.id);
}
resolvedRuntime.flowState.clearBlockSelection();
resolvedRuntime.flowState.updateData(exportGraph(editor));
},
error: async (error) => {
const liveNode = editor.getNode(node.id) as HFNode | undefined;
if (!liveNode?.data) return;
liveNode.data = {
...liveNode.data,
__containerAssigning: false,
__containerValidationErrors: [],
__containerAssignmentError: error instanceof Error
? error.message
: "Subflow validation failed"
};
await area.update("node", node.id);
}
});
};
const replaceWithCreatedBlock = async (createdBlock: FlowNode) => {
if (!editor.getNode(node.id)) return;
const previousConnections = editor.getConnections()
.filter((connection) => connection.source === node.id || connection.target === node.id)
@ -164,7 +381,8 @@ export async function addBlockToEditor(
editor,
area,
{ ...createdBlock, position: currentPosition },
currentPosition
currentPosition,
resolvedRuntime
);
if (!replacementNode) return;
@ -201,7 +419,12 @@ export async function addBlockToEditor(
...cloneValue(block),
position: position ?? block.position,
deleteNode: removeNode,
replaceWithCreatedBlock
replaceWithCreatedBlock,
assignSelectedBlocksToContainer,
clearContainerSubflow,
__containerValidationErrors: [],
__containerAssignmentError: null,
__containerAssigning: false
};
for (const output of block.outputs ?? []) {
@ -225,14 +448,16 @@ export async function addBlockToEditor(
async function loadFlowData(
editor: NodeEditor<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,
flowData: FlowData
flowData: FlowData,
runtime?: ReteRuntimeContext
) {
if (!flowData.blocks?.length) return;
const topLevelNodes = [...(flowData.blocks ?? []), ...(flowData.containers ?? [])];
if (!topLevelNodes.length) return;
const nodeMapping = new Map<string, any>();
for (const block of flowData.blocks) {
const node = await addBlockToEditor(editor, area, block, block.position);
for (const block of topLevelNodes) {
const node = await addBlockToEditor(editor, area, block, block.position, runtime);
nodeMapping.set(block.id, node.id);
}
@ -271,6 +496,73 @@ function toNodeLabel(typeName: string) {
return typeName;
}
async function promptContainerPublicPorts(
settingsDialog: NodeSettingsDialogService,
openInputs: FlowContainerOpenInput[],
openOutputs: FlowContainerOpenOutput[]
) {
const fields = [
...openInputs.map((input, index) => ({
key: `input:${index}`,
label: `Public input for ${input.name}`,
type: "text" as const,
required: true
})),
...openOutputs.map((output, index) => ({
key: `output:${index}`,
label: `Public output for ${output.name}`,
type: "text" as const,
required: true
}))
];
if (!fields.length) {
return { publicInputs: [] as FlowContainerPublicInput[], publicOutputs: [] as FlowContainerPublicOutput[] };
}
const initial = Object.fromEntries([
...openInputs.map((input, index) => [`input:${index}`, input.name]),
...openOutputs.map((output, index) => [`output:${index}`, output.name])
]);
const result = await settingsDialog.open({
title: 'Expose container public ports',
fields,
initial
});
if (!result) return null;
const publicInputs = openInputs.map((input, index) => ({
name: String(result[`input:${index}`] ?? input.name).trim(),
targetBlockId: resolveContainerInputBlockId(input),
targetInputName: resolveContainerInputName(input)
})).filter((input) => input.name.length > 0 && input.targetBlockId.length > 0 && input.targetInputName.length > 0);
const publicOutputs = openOutputs.map((output, index) => ({
name: String(result[`output:${index}`] ?? output.name).trim(),
sourceBlockId: resolveContainerOutputBlockId(output),
sourceOutputName: resolveContainerOutputName(output)
})).filter((output) => output.name.length > 0 && output.sourceBlockId.length > 0 && output.sourceOutputName.length > 0);
return { publicInputs, publicOutputs };
}
function resolveContainerInputBlockId(input: FlowContainerOpenInput) {
return String(input.targetBlockId ?? input.blockId ?? '');
}
function resolveContainerInputName(input: FlowContainerOpenInput) {
return String(input.targetInputName ?? input.inputName ?? input.name ?? '');
}
function resolveContainerOutputBlockId(output: FlowContainerOpenOutput) {
return String(output.sourceBlockId ?? output.blockId ?? '');
}
function resolveContainerOutputName(output: FlowContainerOpenOutput) {
return String(output.sourceOutputName ?? output.outputName ?? output.name ?? '');
}
function cloneValue<T>(value: T): T {
if (typeof globalThis.structuredClone === "function") {
return globalThis.structuredClone(value);