From 25e133ac04c6fbf0482c394376aa897124855c5d Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Sat, 14 Mar 2026 15:50:55 +0100 Subject: [PATCH] Add first-class container nodes and subflow preview --- src/app/app.html | 1 + src/app/app.ts | 3 +- src/app/models/flow.ts | 56 ++- src/app/models/nodes.ts | 9 +- .../services/assistant/assistant-call.fake.ts | 22 +- src/app/services/assistant/assistant-call.ts | 1 + src/app/services/blocks/block-call.base.ts | 10 +- src/app/services/blocks/blocks-call.fake.ts | 114 +++++- src/app/services/blocks/blocks-call.ts | 131 +++++-- src/app/services/blocks/blocks.ts | 55 ++- .../dialogs/subflow-preview-dialog.ts | 25 ++ src/app/services/flows/flow-mapper.ts | 17 +- src/app/services/flows/flows-call.fake.ts | 6 +- src/app/services/flows/flows-call.ts | 1 + src/app/services/flows/flows.ts | 1 + src/app/shared/blocks-list/blocks-list.html | 10 +- .../container-node/container-node-drag.ts | 1 + .../nodes/container-node/container-node.css | 309 +++++++++++++++++ .../nodes/container-node/container-node.html | 150 ++++++++ .../nodes/container-node/container-node.ts | 225 ++++++++++++ .../nodes/generic-node/generic-node.css | 5 + .../shared/nodes/generic-node/generic-node.ts | 11 +- src/app/shared/rete-editor/rete-editor.css | 29 ++ src/app/shared/rete-editor/rete-editor.html | 28 +- src/app/shared/rete-editor/rete-editor.ts | 130 ++++++- .../subflow-preview-dialog.css | 53 +++ .../subflow-preview-dialog.html | 23 ++ .../subflow-preview-dialog.ts | 27 ++ .../task-execution-viewer.ts | 3 +- src/app/stores/flow-editor.ts | 29 ++ src/app/utilities/rete-editor.ts | 328 +++++++++++++++++- 31 files changed, 1724 insertions(+), 89 deletions(-) create mode 100644 src/app/services/dialogs/subflow-preview-dialog.ts create mode 100644 src/app/shared/nodes/container-node/container-node-drag.ts create mode 100644 src/app/shared/nodes/container-node/container-node.css create mode 100644 src/app/shared/nodes/container-node/container-node.html create mode 100644 src/app/shared/nodes/container-node/container-node.ts create mode 100644 src/app/shared/subflow-preview-dialog/subflow-preview-dialog.css create mode 100644 src/app/shared/subflow-preview-dialog/subflow-preview-dialog.html create mode 100644 src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts diff --git a/src/app/app.html b/src/app/app.html index c6e5303..311c9f5 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -2,3 +2,4 @@ + diff --git a/src/app/app.ts b/src/app/app.ts index decbfbc..61d957a 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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' }) diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index ab0d19a..92c9f89 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -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 | 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; +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; diff --git a/src/app/models/nodes.ts b/src/app/models/nodes.ts index b9353bf..c12a28a 100644 --- a/src/app/models/nodes.ts +++ b/src/app/models/nodes.ts @@ -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; - replaceWithCreatedBlock?: (block: FlowBlock) => Promise; + replaceWithCreatedBlock?: (block: FlowNode) => Promise; + assignSelectedBlocksToContainer?: (blockIds?: string[]) => Promise; + clearContainerSubflow?: () => Promise; + [key: string]: unknown; }; export type HFNode = ClassicPreset.Node & { diff --git a/src/app/services/assistant/assistant-call.fake.ts b/src/app/services/assistant/assistant-call.fake.ts index 7c0cd59..d56f5f0 100644 --- a/src/app/services/assistant/assistant-call.fake.ts +++ b/src/app/services/assistant/assistant-call.fake.ts @@ -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({ diff --git a/src/app/services/assistant/assistant-call.ts b/src/app/services/assistant/assistant-call.ts index dd4e4cf..914bb5c 100644 --- a/src/app/services/assistant/assistant-call.ts +++ b/src/app/services/assistant/assistant-call.ts @@ -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[]) : [] } }; diff --git a/src/app/services/blocks/block-call.base.ts b/src/app/services/blocks/block-call.base.ts index 31554f1..63b045d 100644 --- a/src/app/services/blocks/block-call.base.ts +++ b/src/app/services/blocks/block-call.base.ts @@ -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; + abstract retrieveAllContainerTypes() : Observable; + abstract createEmptyBlock(blockType: BlockTypeName) : Observable; + abstract createEmptyContainer(containerType: BlockTypeName) : Observable; + abstract updateBlock(blockId : string, configuration : any) : Observable; -} \ No newline at end of file + abstract validateContainerSubflow(subFlow: FlowData) : Observable; + +} diff --git a/src/app/services/blocks/blocks-call.fake.ts b/src/app/services/blocks/blocks-call.fake.ts index 97334b1..4f409f1 100644 --- a/src/app/services/blocks/blocks-call.fake.ts +++ b/src/app/services/blocks/blocks-call.fake.ts @@ -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 { + return of(this.containerTypes); + } + override createEmptyBlock(blockType: string): Observable { 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 { + const descriptor = this.containerTypes.find((b) => b.type === containerType); + const typeName = descriptor?.type ?? containerType ?? "GenericContainer"; + const schema = descriptor?.schema as Record | 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 { 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 { + 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 { diff --git a/src/app/services/blocks/blocks-call.ts b/src/app/services/blocks/blocks-call.ts index 6fe99ef..b7f666c 100644 --- a/src/app/services/blocks/blocks-call.ts +++ b/src/app/services/blocks/blocks-call.ts @@ -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 { return this.http .get(`${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 { + return this.http + .get(`${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 { 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(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(`${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 { + 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(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 { + return this.http + .post( + `${environment.apiUrl}/containers/types/GenericContainer/validate-subflow`, + { subFlow } + ) + .pipe(map((raw) => this.subflowValidationFromApi(raw))); + } + private getBlockTypesForCreate(): Observable { 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 { + 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): FlowBlock { + return this.flowNodeFromApi(raw, fallbackTypeName, 'block', fallbackConfig) as FlowBlock; + } + + private flowNodeFromApi( + raw: unknown, + fallbackTypeName = "LLMBlock", + family: NodeFamily = 'block', + fallbackConfig?: Record + ) { 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 { diff --git a/src/app/services/blocks/blocks.ts b/src/app/services/blocks/blocks.ts index a11c7f6..5cba845 100644 --- a/src/app/services/blocks/blocks.ts +++ b/src/app/services/blocks/blocks.ts @@ -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 | null = null; - private readonly emptyBlockCache = new Map(); - private readonly pendingEmptyBlockRequests = new Map>(); + private readonly emptyBlockCache = new Map(); + private readonly pendingEmptyBlockRequests = new Map>(); private readonly pendingServerSyncCount = signal(0); private _blockTypes = signal([]); @@ -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 { + 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, diff --git a/src/app/services/dialogs/subflow-preview-dialog.ts b/src/app/services/dialogs/subflow-preview-dialog.ts new file mode 100644 index 0000000..2e0ee8e --- /dev/null +++ b/src/app/services/dialogs/subflow-preview-dialog.ts @@ -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(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); + } +} diff --git a/src/app/services/flows/flow-mapper.ts b/src/app/services/flows/flow-mapper.ts index 855772b..2c29132 100644 --- a/src/app/services/flows/flow-mapper.ts +++ b/src/app/services/flows/flow-mapper.ts @@ -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 { + if (!Array.isArray(raw)) return []; + + return raw + .filter((value): value is Record => !!value && typeof value === 'object' && !Array.isArray(value)) + .map((value) => ({ + ...value, + nodeFamily + })) as Array; +} diff --git a/src/app/services/flows/flows-call.fake.ts b/src/app/services/flows/flows-call.fake.ts index c9d0f20..2e03ee2 100644 --- a/src/app/services/flows/flows-call.fake.ts +++ b/src/app/services/flows/flows-call.fake.ts @@ -17,8 +17,8 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase { authorizationService = inject(Authorization); private data: Record = { - '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' }); } diff --git a/src/app/services/flows/flows-call.ts b/src/app/services/flows/flows-call.ts index 328844c..d8f9a2e 100644 --- a/src/app/services/flows/flows-call.ts +++ b/src/app/services/flows/flows-call.ts @@ -31,6 +31,7 @@ export class FlowsCallService extends FlowsCallServiceBase { description: undefined, data: { blocks: [], + containers: [], connections: [] }, status: 'DRAFT' diff --git a/src/app/services/flows/flows.ts b/src/app/services/flows/flows.ts index 9bc29cd..5724f3d 100644 --- a/src/app/services/flows/flows.ts +++ b/src/app/services/flows/flows.ts @@ -102,6 +102,7 @@ export class FlowsService { description: undefined, data: { blocks: [], + containers: [], connections: [] }, status: 'DRAFT' diff --git a/src/app/shared/blocks-list/blocks-list.html b/src/app/shared/blocks-list/blocks-list.html index 64b04a4..5b68ca6 100644 --- a/src/app/shared/blocks-list/blocks-list.html +++ b/src/app/shared/blocks-list/blocks-list.html @@ -6,9 +6,9 @@
@@ -31,7 +31,7 @@ (dragstart)="onDragStart($event, block)">
- +
@@ -48,7 +48,7 @@
- {{ block.userInteractive ? 'Interactive' : 'Automatic' }} + {{ block.family === 'container' ? 'Container' : (block.userInteractive ? 'Interactive' : 'Automatic') }}
@@ -57,7 +57,7 @@ - {{ block.userInteractive ? 'Human step' : 'Automated step' }} + {{ block.family === 'container' ? 'Container node' : (block.userInteractive ? 'Human step' : 'Automated step') }} diff --git a/src/app/shared/nodes/container-node/container-node-drag.ts b/src/app/shared/nodes/container-node/container-node-drag.ts new file mode 100644 index 0000000..cca2a5f --- /dev/null +++ b/src/app/shared/nodes/container-node/container-node-drag.ts @@ -0,0 +1 @@ +export const CONTAINER_SUBFLOW_DRAG_MIME = 'application/x-hf-selected-subflow'; diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css new file mode 100644 index 0000000..51cd432 --- /dev/null +++ b/src/app/shared/nodes/container-node/container-node.css @@ -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); + } +} diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html new file mode 100644 index 0000000..355e832 --- /dev/null +++ b/src/app/shared/nodes/container-node/container-node.html @@ -0,0 +1,150 @@ +
+
+
+ +
+
+
Generic Container
+
{{ name }}
+
+ +
+ +
+
+
Inputs
+ @for (input of inputs; track input.key) { +
+
+
+ + {{ inputDisplayLabel(input.key) }} + {{ inputKindLabel(input.key) }} + +
+ } +
+ +
+
Outputs
+ @for (output of outputs; track output.key) { +
+ + {{ outputDisplayLabel(output.key) }} + {{ outputKindLabel(output.key) }} + +
+
+
+ } +
+
+ +
+ @if (isAssigning) { +
+ + Validating subflow... +
+ } @else if (subFlowBlockCount > 0) { +
+ {{ subFlowBlockCount }} nodes + {{ subFlowConnectionCount }} connections +
+
+ Drop a new selection here to replace the embedded subflow. +
+ } @else { +
+ Drop selected nodes here +
+
+ Use the selection box in the editor, then drag the floating selection badge into this area. +
+ } +
+ + @if (subFlowBlockCount > 0) { +
+
+
Subflow Content
+ +
+
+ @for (node of subFlowPreviewNodes; track node.id) { +
+ {{ node.name }} + {{ node.type }} +
+ } + @if (hasMoreSubFlowNodes) { +
+ +{{ subFlowBlockCount - subFlowPreviewNodes.length }} more +
+ } +
+
+
+ +
+ } + + @if (assignmentErrorMessage) { +
+ {{ assignmentErrorMessage }} +
+ } + + @if (validationErrors.length) { +
+ @for (error of validationErrors; track $index) { +
+ {{ error.message }} + @if (error.entity || error.id || error.field) { + + {{ error.entity || 'entity' }} + @if (error.id) { + · {{ error.id }} + } + @if (error.field) { + · {{ error.field }} + } + + } +
+ } +
+ } +
diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts new file mode 100644 index 0000000..dc91681 --- /dev/null +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -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; + 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 | null { + const value = this.data?.data?.specificConfiguration; + return value && typeof value === 'object' ? value as Record : 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$/, '')); + } +} diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index 1bd1a61..f3582f1 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -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; diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 31874fe..5087479 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -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 { if (!this.data?.data) { this.data.data = {}; diff --git a/src/app/shared/rete-editor/rete-editor.css b/src/app/shared/rete-editor/rete-editor.css index 695e5d4..9ee8af7 100644 --- a/src/app/shared/rete-editor/rete-editor.css +++ b/src/app/shared/rete-editor/rete-editor.css @@ -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 { diff --git a/src/app/shared/rete-editor/rete-editor.html b/src/app/shared/rete-editor/rete-editor.html index 953465f..1b99706 100644 --- a/src/app/shared/rete-editor/rete-editor.html +++ b/src/app/shared/rete-editor/rete-editor.html @@ -1,7 +1,33 @@ -
+
+ @if (hasSelectedBlocks) { +
+ Drag {{ selectedBlockCount }} selected {{ selectedBlockCount === 1 ? 'node' : 'nodes' }} +
+ } + + @if (selectionBox(); as box) { +
+
+ } + @if (creatingEmptyBlock) {
diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index 1ecb17a..b977a27 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -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; 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('[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 + ); + } } diff --git a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.css b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.css new file mode 100644 index 0000000..188e98b --- /dev/null +++ b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.css @@ -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; +} diff --git a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.html b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.html new file mode 100644 index 0000000..1573e6f --- /dev/null +++ b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.html @@ -0,0 +1,23 @@ +@if (state()) { +
+
+ +
+
+
+
Read-only Subflow
+

{{ state()!.title }}

+
+ +
+ +
+ + +
+
+
+} diff --git a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts new file mode 100644 index 0000000..3960052 --- /dev/null +++ b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts @@ -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(); + } +} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 7edbf1d..c1a667c 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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(() => { diff --git a/src/app/stores/flow-editor.ts b/src/app/stores/flow-editor.ts index 83f1dec..52d646b 100644 --- a/src/app/stores/flow-editor.ts +++ b/src/app/stores/flow-editor.ts @@ -11,6 +11,8 @@ export class EditorStateHolder { /** Stato */ readonly currentFlow = signal(null); readonly isDirty = signal(false); + readonly selectedBlockIds = signal([]); + readonly draggingSelectedBlockIds = signal([]); /** 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(); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 62dfef8..a3f71ab 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -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; const editorSockets = new WeakMap, Map>(); +const editorRuntime = new WeakMap, ReteRuntimeContext>(); export type ReteEditorInstance = { editor: NodeEditor; area: AreaPlugin; }; +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(); const render = new AngularPlugin({ 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) { const nodeIdToBlockId = new Map(); - 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) { 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) { })); 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) { export async function addBlockToEditor( editor: NodeEditor, area: AreaPlugin, - 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)["subFlow"]; + delete (nextConfiguration as Record)["publicInputs"]; + delete (nextConfiguration as Record)["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, area: AreaPlugin, - 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(); - 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(value: T): T { if (typeof globalThis.structuredClone === "function") { return globalThis.structuredClone(value);