- Search blocks
+ Search nodes
-
+
@@ -31,7 +31,7 @@
(dragstart)="onDragStart($event, block)">
@@ -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 @@
+
+
+
+
+
+
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) }}
+
+
+
+
+ }
+
+
+
+
0"
+ [class.container-node__dropzone--filled]="subFlowBlockCount > 0"
+ (dragover)="onDropZoneDragOver($event)"
+ (dragleave)="onDropZoneDragLeave($event)"
+ (drop)="onDropZoneDrop($event)">
+ @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()) {
+
+}
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);