Add first-class container nodes and subflow preview
This commit is contained in:
parent
856d3e0893
commit
25e133ac04
|
|
@ -2,3 +2,4 @@
|
|||
<app-confirm-dialog-host></app-confirm-dialog-host>
|
||||
<app-human-interaction-dialog-host></app-human-interaction-dialog-host>
|
||||
<app-node-settings-dialog-host></app-node-settings-dialog-host>
|
||||
<app-subflow-preview-dialog-host></app-subflow-preview-dialog-host>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,15 +19,32 @@ export type Flow = {
|
|||
|
||||
export type FlowData = {
|
||||
blocks: FlowBlock[];
|
||||
containers: FlowContainer[];
|
||||
connections: FlowBlockConnection[];
|
||||
};
|
||||
|
||||
export type FlowSubflowValidationError = {
|
||||
entity?: string;
|
||||
id?: string;
|
||||
field?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type FlowSubflowValidationResult = {
|
||||
valid: boolean;
|
||||
errors: FlowSubflowValidationError[];
|
||||
openInputs: FlowContainerOpenInput[];
|
||||
openOutputs: FlowContainerOpenOutput[];
|
||||
};
|
||||
|
||||
export type BlockTypeName = "HumanInteractionBlock" | "LLMBlock" | "SourceBlock" | string;
|
||||
export type NodeFamily = 'block' | 'container';
|
||||
|
||||
export type BlockTypeSchema = Record<string, unknown> | null;
|
||||
|
||||
export type BlockType = {
|
||||
type: BlockTypeName;
|
||||
family: NodeFamily;
|
||||
description: string;
|
||||
userInteractive: boolean;
|
||||
hasExampleBlock?: boolean;
|
||||
|
|
@ -37,7 +54,7 @@ export type BlockType = {
|
|||
schema: BlockTypeSchema;
|
||||
};
|
||||
|
||||
export type FlowBlock = {
|
||||
export type FlowNodeBase = {
|
||||
id: string;
|
||||
name: string;
|
||||
position?: { x: number, y: number };
|
||||
|
|
@ -45,8 +62,19 @@ export type FlowBlock = {
|
|||
outputs: FlowPort[];
|
||||
specificConfiguration: FlowBlockConfiguration;
|
||||
typeName: BlockTypeName;
|
||||
nodeFamily?: NodeFamily;
|
||||
};
|
||||
|
||||
export type FlowBlock = FlowNodeBase & {
|
||||
nodeFamily?: 'block';
|
||||
};
|
||||
|
||||
export type FlowContainer = FlowNodeBase & {
|
||||
nodeFamily: 'container';
|
||||
};
|
||||
|
||||
export type FlowNode = FlowBlock | FlowContainer;
|
||||
|
||||
export type FlowValueKind = {
|
||||
type: string;
|
||||
multiple: boolean;
|
||||
|
|
@ -72,6 +100,32 @@ export type FlowBlockConfiguration =
|
|||
| HumanInteractiveBlockConfiguration
|
||||
| Record<string, unknown>;
|
||||
|
||||
export type FlowContainerPublicInput = {
|
||||
name: string;
|
||||
targetBlockId: string;
|
||||
targetInputName: string;
|
||||
};
|
||||
|
||||
export type FlowContainerPublicOutput = {
|
||||
name: string;
|
||||
sourceBlockId: string;
|
||||
sourceOutputName: string;
|
||||
};
|
||||
|
||||
export type FlowContainerOpenInput = FlowPort & {
|
||||
targetBlockId?: string;
|
||||
targetInputName?: string;
|
||||
blockId?: string;
|
||||
inputName?: string;
|
||||
};
|
||||
|
||||
export type FlowContainerOpenOutput = FlowPort & {
|
||||
sourceBlockId?: string;
|
||||
sourceOutputName?: string;
|
||||
blockId?: string;
|
||||
outputName?: string;
|
||||
};
|
||||
|
||||
export type LLMDescriptor = {
|
||||
provider: string;
|
||||
model: string;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { GetSchemes, ClassicPreset } from "rete";
|
||||
import { FlowBlock } from "./flow";
|
||||
import { FlowNode } from "./flow";
|
||||
|
||||
export type HFNodeData = FlowBlock & {
|
||||
export type HFNodeData = FlowNode & {
|
||||
deleteNode?: () => Promise<void>;
|
||||
replaceWithCreatedBlock?: (block: FlowBlock) => Promise<void>;
|
||||
replaceWithCreatedBlock?: (block: FlowNode) => Promise<void>;
|
||||
assignSelectedBlocksToContainer?: (blockIds?: string[]) => Promise<void>;
|
||||
clearContainerSubflow?: () => Promise<void>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type HFNode = ClassicPreset.Node & {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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[]) : []
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import { BlockType, BlockTypeName, FlowBlock } from "@models/flow";
|
||||
import { BlockType, BlockTypeName, FlowBlock, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class BlocksCallServiceBase {
|
||||
|
||||
abstract retrieveAllBlocksTypes() : Observable<BlockType[]>;
|
||||
|
||||
abstract retrieveAllContainerTypes() : Observable<BlockType[]>;
|
||||
|
||||
abstract createEmptyBlock(blockType: BlockTypeName) : Observable<FlowBlock>;
|
||||
|
||||
abstract createEmptyContainer(containerType: BlockTypeName) : Observable<FlowContainer>;
|
||||
|
||||
abstract updateBlock(blockId : string, configuration : any) : Observable<FlowBlock>;
|
||||
|
||||
}
|
||||
abstract validateContainerSubflow(subFlow: FlowData) : Observable<FlowSubflowValidationResult>;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BlockType, FlowBlock } from "@models/flow";
|
||||
import { BlockType, FlowBlock, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
|
||||
import { Observable, of } from "rxjs";
|
||||
import { BlocksCallServiceBase } from "./block-call.base";
|
||||
|
||||
|
|
@ -6,6 +6,7 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
private readonly blockTypes: BlockType[] =[
|
||||
{
|
||||
"type": "HumanInteractionBlock",
|
||||
"family": "block",
|
||||
"description": "A block that requires human interaction",
|
||||
"userInteractive": true,
|
||||
"configurationType": "HumanInteractiveBlockConfiguration",
|
||||
|
|
@ -69,6 +70,7 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
},
|
||||
{
|
||||
"type": "LLMBlock",
|
||||
"family": "block",
|
||||
"description": "This type represents a LLM node in the workflow manager",
|
||||
"userInteractive": false,
|
||||
"configurationType": "LLMBlockConfiguration",
|
||||
|
|
@ -130,6 +132,56 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
private readonly containerTypes: BlockType[] = [
|
||||
{
|
||||
"type": "GenericContainer",
|
||||
"family": "container",
|
||||
"description": "Container block with an embedded validated subflow",
|
||||
"userInteractive": false,
|
||||
"configurationType": "GenericContainerConfiguration",
|
||||
"configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.GenericContainerConfiguration",
|
||||
"schema": {
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"title": "GenericContainerConfiguration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"GenericContainerConfiguration"
|
||||
],
|
||||
"default": "GenericContainerConfiguration"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"default": "Container"
|
||||
},
|
||||
"subFlow": {
|
||||
"type": "object",
|
||||
"default": {
|
||||
"blocks": [],
|
||||
"containers": [],
|
||||
"connections": []
|
||||
}
|
||||
},
|
||||
"publicInputs": {
|
||||
"type": "array",
|
||||
"default": []
|
||||
},
|
||||
"publicOutputs": {
|
||||
"type": "array",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -137,6 +189,10 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
return of(this.blockTypes);
|
||||
}
|
||||
|
||||
override retrieveAllContainerTypes(): Observable<BlockType[]> {
|
||||
return of(this.containerTypes);
|
||||
}
|
||||
|
||||
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
|
||||
const descriptor = this.blockTypes.find((b) => b.type === blockType);
|
||||
const typeName = descriptor?.type ?? blockType ?? "LLMBlock";
|
||||
|
|
@ -160,12 +216,34 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
inputs: io.inputs,
|
||||
outputs: io.outputs,
|
||||
specificConfiguration,
|
||||
typeName
|
||||
typeName,
|
||||
nodeFamily: 'block'
|
||||
};
|
||||
|
||||
return of(block);
|
||||
}
|
||||
|
||||
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
|
||||
const descriptor = this.containerTypes.find((b) => b.type === containerType);
|
||||
const typeName = descriptor?.type ?? containerType ?? "GenericContainer";
|
||||
const schema = descriptor?.schema as Record<string, any> | null;
|
||||
|
||||
const specificConfiguration = schema
|
||||
? this.buildObjectFromSchema(schema, schema)
|
||||
: {};
|
||||
|
||||
return of({
|
||||
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
|
||||
name: String((specificConfiguration as any)?.name ?? typeName),
|
||||
position: undefined,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
specificConfiguration,
|
||||
typeName,
|
||||
nodeFamily: 'container'
|
||||
});
|
||||
}
|
||||
|
||||
override updateBlock(blockId: string, configuration: any): Observable<FlowBlock> {
|
||||
const typeName = configuration?.typeName ?? "LLMBlock";
|
||||
const io = this.defaultIOForBlockType(typeName);
|
||||
|
|
@ -176,11 +254,41 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
inputs: configuration?.inputs ?? io.inputs,
|
||||
outputs: configuration?.outputs ?? io.outputs,
|
||||
specificConfiguration: configuration?.specificConfiguration ?? {},
|
||||
typeName
|
||||
typeName,
|
||||
nodeFamily: 'block'
|
||||
};
|
||||
return of(block);
|
||||
}
|
||||
|
||||
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
|
||||
const blocks = Array.isArray(subFlow?.blocks) ? subFlow.blocks : [];
|
||||
if (!blocks.length) {
|
||||
return of({
|
||||
valid: false,
|
||||
errors: [{ entity: 'flow', field: 'blocks', message: 'Subflow cannot be empty' }],
|
||||
openInputs: [],
|
||||
openOutputs: []
|
||||
});
|
||||
}
|
||||
|
||||
const nestedContainer = (subFlow?.containers ?? []).find((container) => container?.typeName === 'GenericContainer');
|
||||
if (nestedContainer) {
|
||||
return of({
|
||||
valid: false,
|
||||
errors: [{
|
||||
entity: 'block',
|
||||
id: nestedContainer.id,
|
||||
field: 'type',
|
||||
message: 'Nested GenericContainer blocks are not supported'
|
||||
}],
|
||||
openInputs: [],
|
||||
openOutputs: []
|
||||
});
|
||||
}
|
||||
|
||||
return of({ valid: true, errors: [], openInputs: [], openOutputs: [] });
|
||||
}
|
||||
|
||||
private defaultIOForBlockType(typeName: string) {
|
||||
if (typeName === "SourceBlock") {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BlockType, FlowBlock } from "@models/flow";
|
||||
import { BlockType, FlowBlock, FlowContainer, FlowData, FlowSubflowValidationResult, NodeFamily } from "@models/flow";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import { inject } from "@angular/core";
|
||||
import { environment } from "@environment";
|
||||
|
|
@ -8,12 +8,13 @@ import { BlocksCallServiceBase } from "./block-call.base";
|
|||
export class BlocksCallService extends BlocksCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
private blockTypesCache: BlockType[] | null = null;
|
||||
private containerTypesCache: BlockType[] | null = null;
|
||||
|
||||
override retrieveAllBlocksTypes(): Observable<BlockType[]> {
|
||||
return this.http
|
||||
.get<unknown[]>(`${environment.apiUrl}/blocks/types`)
|
||||
.pipe(
|
||||
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value)) : [])),
|
||||
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value, 'block')) : [])),
|
||||
map((types) => {
|
||||
this.blockTypesCache = types;
|
||||
return types;
|
||||
|
|
@ -21,17 +22,29 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
);
|
||||
}
|
||||
|
||||
override retrieveAllContainerTypes(): Observable<BlockType[]> {
|
||||
return this.http
|
||||
.get<unknown[]>(`${environment.apiUrl}/containers/types`)
|
||||
.pipe(
|
||||
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value, 'container')) : [])),
|
||||
map((types) => {
|
||||
this.containerTypesCache = types;
|
||||
return types;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
|
||||
return this.getBlockTypesForCreate().pipe(
|
||||
take(1),
|
||||
switchMap((types) => {
|
||||
const descriptor = types.find((type) => type.type === blockType);
|
||||
const exampleEndpoint = this.resolveExampleBlockEndpoint(descriptor);
|
||||
const exampleEndpoint = this.resolveExampleEndpoint('block', blockType, descriptor);
|
||||
|
||||
if (exampleEndpoint) {
|
||||
return this.http
|
||||
.get<unknown>(exampleEndpoint)
|
||||
.pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType)));
|
||||
.pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? blockType, 'block') as FlowBlock));
|
||||
}
|
||||
|
||||
const configuration = descriptor
|
||||
|
|
@ -41,7 +54,21 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
|
||||
return this.http
|
||||
.post<unknown>(`${environment.apiUrl}/blocks`, payload)
|
||||
.pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType, payload)));
|
||||
.pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? blockType, 'block', payload) as FlowBlock));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
|
||||
return this.getContainerTypesForCreate().pipe(
|
||||
take(1),
|
||||
switchMap((types) => {
|
||||
const descriptor = types.find((type) => type.type === containerType);
|
||||
const exampleEndpoint = this.resolveExampleEndpoint('container', containerType, descriptor);
|
||||
|
||||
return this.http
|
||||
.get<unknown>(exampleEndpoint)
|
||||
.pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? containerType, 'container') as FlowContainer));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -64,6 +91,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
}),
|
||||
map((raw) =>
|
||||
this.flowBlockFromApi(
|
||||
|
||||
{
|
||||
...(this.toRecord(raw)),
|
||||
id: this.toRecord(raw)["id"] ?? blockId
|
||||
|
|
@ -76,6 +104,15 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
);
|
||||
}
|
||||
|
||||
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
|
||||
return this.http
|
||||
.post<unknown>(
|
||||
`${environment.apiUrl}/containers/types/GenericContainer/validate-subflow`,
|
||||
{ subFlow }
|
||||
)
|
||||
.pipe(map((raw) => this.subflowValidationFromApi(raw)));
|
||||
}
|
||||
|
||||
private getBlockTypesForCreate(): Observable<BlockType[]> {
|
||||
if (this.blockTypesCache) {
|
||||
return of(this.blockTypesCache);
|
||||
|
|
@ -83,10 +120,18 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
return this.retrieveAllBlocksTypes();
|
||||
}
|
||||
|
||||
private blockTypeFromApi(raw: unknown): BlockType {
|
||||
private getContainerTypesForCreate(): Observable<BlockType[]> {
|
||||
if (this.containerTypesCache) {
|
||||
return of(this.containerTypesCache);
|
||||
}
|
||||
return this.retrieveAllContainerTypes();
|
||||
}
|
||||
|
||||
private blockTypeFromApi(raw: unknown, family: NodeFamily): BlockType {
|
||||
const value = this.toRecord(raw);
|
||||
return {
|
||||
type: String(value["type"] ?? value["blockType"] ?? value["name"] ?? "LLMBlock"),
|
||||
family,
|
||||
description: String(value["description"] ?? ""),
|
||||
userInteractive: Boolean(value["userInteractive"] ?? value["interactive"] ?? false),
|
||||
hasExampleBlock: Boolean(value["hasExampleBlock"] ?? false),
|
||||
|
|
@ -97,9 +142,49 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
};
|
||||
}
|
||||
|
||||
private subflowValidationFromApi(raw: unknown): FlowSubflowValidationResult {
|
||||
const value = this.toRecord(raw);
|
||||
const rawErrors = Array.isArray(value['errors']) ? value['errors'] : [];
|
||||
|
||||
return {
|
||||
valid: Boolean(value['valid'] ?? false),
|
||||
errors: rawErrors
|
||||
.map((item) => this.toRecord(item))
|
||||
.map((item) => ({
|
||||
entity: this.toNullableString(item['entity']) ?? undefined,
|
||||
id: this.toNullableString(item['id']) ?? undefined,
|
||||
field: this.toNullableString(item['field']) ?? undefined,
|
||||
message: String(item['message'] ?? 'Invalid subflow')
|
||||
})),
|
||||
openInputs: this.toPorts(value['openInputs'], []).map((port) => ({
|
||||
...port,
|
||||
targetBlockId: this.toNullableString(this.toRecord(port)['targetBlockId']) ?? undefined,
|
||||
targetInputName: this.toNullableString(this.toRecord(port)['targetInputName']) ?? undefined,
|
||||
blockId: this.toNullableString(this.toRecord(port)['blockId']) ?? undefined,
|
||||
inputName: this.toNullableString(this.toRecord(port)['inputName']) ?? undefined
|
||||
})),
|
||||
openOutputs: this.toPorts(value['openOutputs'], []).map((port) => ({
|
||||
...port,
|
||||
sourceBlockId: this.toNullableString(this.toRecord(port)['sourceBlockId']) ?? undefined,
|
||||
sourceOutputName: this.toNullableString(this.toRecord(port)['sourceOutputName']) ?? undefined,
|
||||
blockId: this.toNullableString(this.toRecord(port)['blockId']) ?? undefined,
|
||||
outputName: this.toNullableString(this.toRecord(port)['outputName']) ?? undefined
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
private flowBlockFromApi(raw: unknown, fallbackTypeName = "LLMBlock", fallbackConfig?: Record<string, unknown>): FlowBlock {
|
||||
return this.flowNodeFromApi(raw, fallbackTypeName, 'block', fallbackConfig) as FlowBlock;
|
||||
}
|
||||
|
||||
private flowNodeFromApi(
|
||||
raw: unknown,
|
||||
fallbackTypeName = "LLMBlock",
|
||||
family: NodeFamily = 'block',
|
||||
fallbackConfig?: Record<string, unknown>
|
||||
) {
|
||||
const root = this.toRecord(raw);
|
||||
const value = this.toRecord(root["block"] ?? root["data"] ?? root);
|
||||
const value = this.toRecord(root[family] ?? root["node"] ?? root["block"] ?? root["container"] ?? root["data"] ?? root);
|
||||
const specificConfigurationRaw = value["specificConfiguration"] ?? value["configuration"] ?? value["blockConfiguration"] ?? fallbackConfig ?? {};
|
||||
const specificConfiguration = this.toRecord(specificConfigurationRaw);
|
||||
const typeName = String(value["typeName"] ?? value["blockType"] ?? specificConfiguration["typeName"] ?? fallbackTypeName);
|
||||
|
|
@ -112,7 +197,8 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
inputs: this.toPorts(value["inputs"], io.inputs),
|
||||
outputs: this.toPorts(value["outputs"], io.outputs),
|
||||
specificConfiguration,
|
||||
typeName
|
||||
typeName,
|
||||
nodeFamily: family
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -121,15 +207,17 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
return raw
|
||||
.map((port) => this.toRecord(port))
|
||||
.filter((port) => typeof port["name"] === "string" && (port["name"] as string).length > 0)
|
||||
.map((port) => ({
|
||||
name: String(port["name"]),
|
||||
type: String(port["type"] ?? "TEXT"),
|
||||
multiple: Boolean(port["multiple"] ?? false),
|
||||
valueKinds: this.toValueKinds(port["valueKinds"], {
|
||||
type: String(port["type"] ?? "TEXT"),
|
||||
multiple: Boolean(port["multiple"] ?? false)
|
||||
})
|
||||
}));
|
||||
.map((port) => {
|
||||
const type = String(port["type"] ?? "TEXT");
|
||||
const multiple = Boolean(port["multiple"] ?? false);
|
||||
return {
|
||||
...port,
|
||||
name: String(port["name"]),
|
||||
type,
|
||||
multiple,
|
||||
valueKinds: this.toValueKinds(port["valueKinds"], { type, multiple })
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private toValueKinds(raw: unknown, fallback: { type: string; multiple: boolean }) {
|
||||
|
|
@ -186,9 +274,12 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
};
|
||||
}
|
||||
|
||||
private resolveExampleBlockEndpoint(descriptor?: BlockType): string | null {
|
||||
if (!descriptor?.hasExampleBlock) return null;
|
||||
return descriptor.exampleBlockEndpoint ?? null;
|
||||
private resolveExampleEndpoint(family: NodeFamily, typeName: string, descriptor?: BlockType): string {
|
||||
if (descriptor?.hasExampleBlock && descriptor.exampleBlockEndpoint) {
|
||||
return descriptor.exampleBlockEndpoint;
|
||||
}
|
||||
const base = family === 'container' ? 'containers' : 'blocks';
|
||||
return `${environment.apiUrl}/${base}/types/${encodeURIComponent(typeName)}/example`;
|
||||
}
|
||||
|
||||
private toUpdateBlockError(error: unknown, blockType: string): Error {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { BlockType, BlockTypeName, FlowBlock } from '@models/flow';
|
||||
import { BlockType, BlockTypeName, FlowData, FlowNode, NodeFamily } from '@models/flow';
|
||||
import { BlocksCallServiceBase } from './block-call.base';
|
||||
import { catchError, finalize, firstValueFrom, map, Observable, of, shareReplay, throwError } from 'rxjs';
|
||||
import { catchError, finalize, firstValueFrom, forkJoin, map, Observable, of, shareReplay, throwError } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -12,8 +12,8 @@ export class BlocksService {
|
|||
|
||||
toInit: boolean = true;
|
||||
private loadingPromise: Promise<void> | null = null;
|
||||
private readonly emptyBlockCache = new Map<string, FlowBlock>();
|
||||
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowBlock>>();
|
||||
private readonly emptyBlockCache = new Map<string, FlowNode>();
|
||||
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowNode>>();
|
||||
private readonly pendingServerSyncCount = signal(0);
|
||||
|
||||
private _blockTypes = signal<BlockType[]>([]);
|
||||
|
|
@ -33,9 +33,12 @@ export class BlocksService {
|
|||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
this.loadingPromise = firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes())
|
||||
.then((blockTypes) => {
|
||||
this._blockTypes.set(blockTypes);
|
||||
this.loadingPromise = firstValueFrom(forkJoin({
|
||||
blocks: this.blocksCallService.retrieveAllBlocksTypes(),
|
||||
containers: this.blocksCallService.retrieveAllContainerTypes()
|
||||
}))
|
||||
.then(({ blocks, containers }) => {
|
||||
this._blockTypes.set([...blocks, ...containers]);
|
||||
this.clearEmptyBlockCache();
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -53,27 +56,31 @@ export class BlocksService {
|
|||
const current = this._blockTypes().find((blockType) => blockType.type === typeName);
|
||||
if (current) return current;
|
||||
|
||||
const blockTypes = await firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes());
|
||||
const { blocks, containers } = await firstValueFrom(forkJoin({
|
||||
blocks: this.blocksCallService.retrieveAllBlocksTypes(),
|
||||
containers: this.blocksCallService.retrieveAllContainerTypes()
|
||||
}));
|
||||
const blockTypes = [...blocks, ...containers];
|
||||
this._blockTypes.set(blockTypes);
|
||||
this.clearEmptyBlockCache();
|
||||
return blockTypes.find((blockType) => blockType.type === typeName);
|
||||
}
|
||||
|
||||
createEmptyBlock(blockType: BlockTypeName) {
|
||||
const cacheKey = String(blockType);
|
||||
createEmptyBlock(blockType: BlockTypeName, family?: NodeFamily) {
|
||||
const cacheKey = `${family ?? 'auto'}:${String(blockType)}`;
|
||||
const cached = this.emptyBlockCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return of(this.cloneEmptyBlock(cached));
|
||||
return of(this.cloneEmptyNode(cached));
|
||||
}
|
||||
|
||||
const pending = this.pendingEmptyBlockRequests.get(cacheKey);
|
||||
if (pending) {
|
||||
return pending.pipe(map((block) => this.cloneEmptyBlock(block)));
|
||||
return pending.pipe(map((block) => this.cloneEmptyNode(block)));
|
||||
}
|
||||
|
||||
const request = this.blocksCallService.createEmptyBlock(blockType).pipe(
|
||||
const request = this.createEmptyNodeRequest(blockType, family).pipe(
|
||||
map((block) => {
|
||||
this.emptyBlockCache.set(cacheKey, this.cloneEmptyBlock(block));
|
||||
this.emptyBlockCache.set(cacheKey, this.cloneEmptyNode(block));
|
||||
return block;
|
||||
}),
|
||||
finalize(() => {
|
||||
|
|
@ -85,7 +92,7 @@ export class BlocksService {
|
|||
this.pendingEmptyBlockRequests.set(cacheKey, request);
|
||||
|
||||
return request.pipe(
|
||||
map((block) => this.cloneEmptyBlock(block)),
|
||||
map((block) => this.cloneEmptyNode(block)),
|
||||
catchError((err) => {
|
||||
console.error('Create empty block failed', err);
|
||||
return throwError(() => err);
|
||||
|
|
@ -106,12 +113,28 @@ export class BlocksService {
|
|||
);
|
||||
}
|
||||
|
||||
validateContainerSubflow(subFlow: FlowData) {
|
||||
return this.blocksCallService.validateContainerSubflow(this.deepClone(subFlow)).pipe(
|
||||
catchError((err) => {
|
||||
console.error('Validate container subflow failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private clearEmptyBlockCache() {
|
||||
this.emptyBlockCache.clear();
|
||||
this.pendingEmptyBlockRequests.clear();
|
||||
}
|
||||
|
||||
private cloneEmptyBlock(block: FlowBlock): FlowBlock {
|
||||
private createEmptyNodeRequest(blockType: BlockTypeName, family?: NodeFamily): Observable<FlowNode> {
|
||||
const normalizedFamily = family ?? this._blockTypes().find((type) => type.type === blockType)?.family ?? 'block';
|
||||
return normalizedFamily === 'container'
|
||||
? this.blocksCallService.createEmptyContainer(blockType)
|
||||
: this.blocksCallService.createEmptyBlock(blockType);
|
||||
}
|
||||
|
||||
private cloneEmptyNode(block: FlowNode): FlowNode {
|
||||
const clone = this.deepClone(block);
|
||||
return {
|
||||
...clone,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
import { FlowData } from '@models/flow';
|
||||
|
||||
type SubflowPreviewDialogState = {
|
||||
title: string;
|
||||
flowData: FlowData;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SubflowPreviewDialogService {
|
||||
private readonly _state = signal<SubflowPreviewDialogState | null>(null);
|
||||
|
||||
readonly state = this._state.asReadonly();
|
||||
|
||||
open(flowData: FlowData, title?: string) {
|
||||
this._state.set({
|
||||
title: title?.trim() || 'Subflow Preview',
|
||||
flowData
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this._state.set(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Flow, FlowData, FlowStatus, FlowVisibility } from '@models/flow';
|
||||
import { Flow, FlowBlock, FlowContainer, FlowData, FlowStatus, FlowVisibility } from '@models/flow';
|
||||
|
||||
function parseDate(value: unknown, fallback: Date): Date {
|
||||
if (typeof value !== 'string' || !value) return fallback;
|
||||
|
|
@ -31,7 +31,8 @@ export function flowFromApi(raw: unknown): Flow {
|
|||
published,
|
||||
finalized: typeof value['finalized'] === 'boolean' ? value['finalized'] : undefined,
|
||||
data: {
|
||||
blocks: Array.isArray(data.blocks) ? data.blocks : [],
|
||||
blocks: normalizeNodes(data.blocks, 'block') as FlowBlock[],
|
||||
containers: normalizeNodes(data.containers, 'container') as FlowContainer[],
|
||||
connections: Array.isArray(data.connections) ? data.connections : []
|
||||
}
|
||||
};
|
||||
|
|
@ -44,7 +45,19 @@ export function toFlowCreateRequest(name: string, description?: string, flow?: F
|
|||
status,
|
||||
flow: flow ?? {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNodes(raw: unknown, nodeFamily: 'block' | 'container'): Array<FlowBlock | FlowContainer> {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.filter((value): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value))
|
||||
.map((value) => ({
|
||||
...value,
|
||||
nodeFamily
|
||||
})) as Array<FlowBlock | FlowContainer>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
|
|||
authorizationService = inject(Authorization);
|
||||
|
||||
private data: Record<string, Flow> = {
|
||||
'1': { id: '1', name: 'A Flow', data: { blocks: [], connections: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00") },
|
||||
'2': { id: '2', name: 'Test Flow', data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00") },
|
||||
'1': { id: '1', name: 'A Flow', data: { blocks: [], containers: [], connections: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00") },
|
||||
'2': { id: '2', name: 'Test Flow', data: { blocks: [], containers: [], connections: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00") },
|
||||
'testFlow': flowFromApi(testDataFlow)
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
|
|||
return this.createFlow({
|
||||
name: name || 'New Flow',
|
||||
description: undefined,
|
||||
data: { blocks: [], connections: [] },
|
||||
data: { blocks: [], containers: [], connections: [] },
|
||||
status: 'DRAFT'
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export class FlowsCallService extends FlowsCallServiceBase {
|
|||
description: undefined,
|
||||
data: {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: []
|
||||
},
|
||||
status: 'DRAFT'
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export class FlowsService {
|
|||
description: undefined,
|
||||
data: {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: []
|
||||
},
|
||||
status: 'DRAFT'
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
<div class="blocks-list-root">
|
||||
<div class="blocks-list-search">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Search blocks</mat-label>
|
||||
<mat-label>Search nodes</mat-label>
|
||||
<mat-icon matPrefix fontIcon="search"></mat-icon>
|
||||
<input matInput placeholder="Search blocks..." [(ngModel)]="searchTerm" />
|
||||
<input matInput placeholder="Search blocks and containers..." [(ngModel)]="searchTerm" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
(dragstart)="onDragStart($event, block)">
|
||||
<mat-card-header class="blocks-list-card-header">
|
||||
<div matCardAvatar class="blocks-list-card-avatar">
|
||||
<mat-icon [fontIcon]="block.userInteractive ? 'touch_app' : 'auto_awesome'"></mat-icon>
|
||||
<mat-icon [fontIcon]="block.family === 'container' ? 'inventory_2' : (block.userInteractive ? 'touch_app' : 'auto_awesome')"></mat-icon>
|
||||
</div>
|
||||
<div class="blocks-list-card-heading">
|
||||
<div class="blocks-list-card-title-row">
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<mat-card-subtitle class="blocks-list-card-subtitle">
|
||||
{{ block.userInteractive ? 'Interactive' : 'Automatic' }}
|
||||
{{ block.family === 'container' ? 'Container' : (block.userInteractive ? 'Interactive' : 'Automatic') }}
|
||||
</mat-card-subtitle>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
</mat-card-content>
|
||||
<mat-card-actions class="blocks-list-card-actions" align="end">
|
||||
<mat-chip class="blocks-list-card-chip" [class.blocks-list-card-chip-interactive]="block.userInteractive">
|
||||
{{ block.userInteractive ? 'Human step' : 'Automated step' }}
|
||||
{{ block.family === 'container' ? 'Container node' : (block.userInteractive ? 'Human step' : 'Automated step') }}
|
||||
</mat-chip>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export const CONTAINER_SUBFLOW_DRAG_MIME = 'application/x-hf-selected-subflow';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<div class="container-node" [class.container-node-assigning]="isAssigning">
|
||||
<div class="container-node__header">
|
||||
<div class="container-node__icon">
|
||||
<i class="bi bi-box-seam"></i>
|
||||
</div>
|
||||
<div class="container-node__titles">
|
||||
<div class="container-node__eyebrow">Generic Container</div>
|
||||
<div class="container-node__name">{{ name }}</div>
|
||||
</div>
|
||||
<button type="button" class="container-node__delete" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="deleteNode($event)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="container-node__ports">
|
||||
<div class="container-node__port-column">
|
||||
<div class="container-node__port-title">Inputs</div>
|
||||
@for (input of inputs; track input.key) {
|
||||
<div class="container-node__port-row">
|
||||
<div
|
||||
refComponent
|
||||
class="container-node__socket"
|
||||
[data]="{
|
||||
type: 'socket',
|
||||
side: 'input',
|
||||
key: input.key,
|
||||
nodeId: data.id,
|
||||
payload: input.socket
|
||||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
<span class="container-node__port-label">
|
||||
<span class="container-node__port-name">{{ inputDisplayLabel(input.key) }}</span>
|
||||
<span class="container-node__port-kind">{{ inputKindLabel(input.key) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="container-node__port-column container-node__port-column--right">
|
||||
<div class="container-node__port-title">Outputs</div>
|
||||
@for (output of outputs; track output.key) {
|
||||
<div class="container-node__port-row container-node__port-row--right">
|
||||
<span class="container-node__port-label">
|
||||
<span class="container-node__port-name">{{ outputDisplayLabel(output.key) }}</span>
|
||||
<span class="container-node__port-kind">{{ outputKindLabel(output.key) }}</span>
|
||||
</span>
|
||||
<div
|
||||
refComponent
|
||||
class="container-node__socket container-node__socket--right"
|
||||
[data]="{
|
||||
type: 'socket',
|
||||
side: 'output',
|
||||
key: output.key,
|
||||
nodeId: data.id,
|
||||
payload: output.socket
|
||||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="container-node__dropzone"
|
||||
[class.container-node__dropzone--active]="selectedCount > 0"
|
||||
[class.container-node__dropzone--filled]="subFlowBlockCount > 0"
|
||||
(dragover)="onDropZoneDragOver($event)"
|
||||
(dragleave)="onDropZoneDragLeave($event)"
|
||||
(drop)="onDropZoneDrop($event)">
|
||||
@if (isAssigning) {
|
||||
<div class="container-node__dropzone-main">
|
||||
<span class="container-node__spinner" aria-hidden="true"></span>
|
||||
<span>Validating subflow...</span>
|
||||
</div>
|
||||
} @else if (subFlowBlockCount > 0) {
|
||||
<div class="container-node__dropzone-main">
|
||||
<span>{{ subFlowBlockCount }} nodes</span>
|
||||
<span>{{ subFlowConnectionCount }} connections</span>
|
||||
</div>
|
||||
<div class="container-node__dropzone-note">
|
||||
Drop a new selection here to replace the embedded subflow.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="container-node__dropzone-main">
|
||||
<span class="container-node__dropzone-empty">Drop selected nodes here</span>
|
||||
</div>
|
||||
<div class="container-node__dropzone-note">
|
||||
Use the selection box in the editor, then drag the floating selection badge into this area.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (subFlowBlockCount > 0) {
|
||||
<div class="container-node__subflow-section">
|
||||
<div class="container-node__subflow-head">
|
||||
<div class="container-node__subflow-title">Subflow Content</div>
|
||||
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
|
||||
View Flow
|
||||
</button>
|
||||
</div>
|
||||
<div class="container-node__subflow-preview">
|
||||
@for (node of subFlowPreviewNodes; track node.id) {
|
||||
<div class="container-node__subflow-chip" [class.container-node__subflow-chip--container]="node.family === 'container'">
|
||||
<span class="container-node__subflow-chip-name">{{ node.name }}</span>
|
||||
<span class="container-node__subflow-chip-type">{{ node.type }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (hasMoreSubFlowNodes) {
|
||||
<div class="container-node__subflow-more">
|
||||
+{{ subFlowBlockCount - subFlowPreviewNodes.length }} more
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-node__actions">
|
||||
<button type="button" class="container-node__action" (pointerdown)="$event.stopPropagation()" (click)="removeSubflow($event)">
|
||||
Remove subflow
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (assignmentErrorMessage) {
|
||||
<div class="container-node__message container-node__message--error">
|
||||
{{ assignmentErrorMessage }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (validationErrors.length) {
|
||||
<div class="container-node__message container-node__message--warning">
|
||||
@for (error of validationErrors; track $index) {
|
||||
<div class="container-node__validation-item">
|
||||
<span>{{ error.message }}</span>
|
||||
@if (error.entity || error.id || error.field) {
|
||||
<span class="container-node__validation-meta">
|
||||
{{ error.entity || 'entity' }}
|
||||
@if (error.id) {
|
||||
· {{ error.id }}
|
||||
}
|
||||
@if (error.field) {
|
||||
· {{ error.field }}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, HostBinding, Input, inject } from '@angular/core';
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { ReteModule } from 'rete-angular-plugin/21';
|
||||
import { currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowNode, FlowSubflowValidationError } from '@models/flow';
|
||||
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { pathToLabel } from '../node-utility';
|
||||
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
|
||||
|
||||
@Component({
|
||||
selector: 'app-container-node',
|
||||
imports: [CommonModule, ReteModule],
|
||||
templateUrl: './container-node.html',
|
||||
styleUrl: './container-node.css',
|
||||
host: {
|
||||
'data-testid': 'node'
|
||||
}
|
||||
})
|
||||
export class ContainerNodeComponent {
|
||||
private editorState = inject(EditorStateHolder);
|
||||
private subflowPreview = inject(SubflowPreviewDialogService);
|
||||
|
||||
@Input() data!: any;
|
||||
@Input() emit!: (data: any) => void;
|
||||
@Input() rendered!: () => void;
|
||||
|
||||
@HostBinding('class.selected') get selected() {
|
||||
return this.data.selected || this.editorState.isBlockSelected(this.blockId);
|
||||
}
|
||||
|
||||
@HostBinding('attr.data-block-id') get hostBlockId() {
|
||||
return this.blockId;
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.rendered();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return String(this.configuration?.['name'] ?? this.data?.data?.name ?? 'Container');
|
||||
}
|
||||
|
||||
get inputs() {
|
||||
return Object.entries(this.data?.inputs ?? {}).map(([key, input]) => ({
|
||||
key,
|
||||
socket: (input as any).socket as ClassicPreset.Socket
|
||||
}));
|
||||
}
|
||||
|
||||
get outputs() {
|
||||
return Object.entries(this.data?.outputs ?? {}).map(([key, output]) => ({
|
||||
key,
|
||||
socket: (output as any).socket as ClassicPreset.Socket
|
||||
}));
|
||||
}
|
||||
|
||||
get selectedCount() {
|
||||
return this.editorState.selectedBlockIds().filter((id) => id !== this.blockId).length;
|
||||
}
|
||||
|
||||
get subFlow(): FlowData | null {
|
||||
const value = this.configuration?.['subFlow'];
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const candidate = value as Partial<FlowData>;
|
||||
const blocks = Array.isArray(candidate.blocks) ? candidate.blocks : [];
|
||||
const containers = Array.isArray(candidate.containers) ? candidate.containers : [];
|
||||
const connections = Array.isArray(candidate.connections) ? candidate.connections : [];
|
||||
|
||||
if (!blocks.length && !containers.length && !connections.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
blocks,
|
||||
containers,
|
||||
connections
|
||||
};
|
||||
}
|
||||
|
||||
get subFlowBlockCount() {
|
||||
return (this.subFlow?.blocks?.length ?? 0) + (this.subFlow?.containers?.length ?? 0);
|
||||
}
|
||||
|
||||
get subFlowConnectionCount() {
|
||||
return this.subFlow?.connections?.length ?? 0;
|
||||
}
|
||||
|
||||
get subFlowPreviewNodes() {
|
||||
const subFlow = this.subFlow;
|
||||
if (!subFlow) return [];
|
||||
return [...(subFlow.blocks ?? []), ...(subFlow.containers ?? [])]
|
||||
.slice(0, 6)
|
||||
.map((node) => this.toPreviewNode(node));
|
||||
}
|
||||
|
||||
get hasMoreSubFlowNodes() {
|
||||
return this.subFlowBlockCount > this.subFlowPreviewNodes.length;
|
||||
}
|
||||
|
||||
get validationErrors(): FlowSubflowValidationError[] {
|
||||
const errors = this.data?.data?.['__containerValidationErrors'];
|
||||
return Array.isArray(errors) ? errors : [];
|
||||
}
|
||||
|
||||
get assignmentErrorMessage() {
|
||||
const value = this.data?.data?.['__containerAssignmentError'];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
get isAssigning() {
|
||||
return this.data?.data?.['__containerAssigning'] === true;
|
||||
}
|
||||
|
||||
inputDisplayLabel(inputKey: string) {
|
||||
return pathToLabel(inputKey);
|
||||
}
|
||||
|
||||
outputDisplayLabel(outputKey: string) {
|
||||
return pathToLabel(outputKey);
|
||||
}
|
||||
|
||||
inputKindLabel(inputKey: string) {
|
||||
const port = this.inputs.find((candidate) => candidate.key === inputKey);
|
||||
return port ? flowValueKindLabel(currentFlowPortValueKind((this.data?.data?.inputs ?? []).find((item: any) => item?.name === inputKey) ?? { type: 'ANY', multiple: false })) : 'ANY';
|
||||
}
|
||||
|
||||
outputKindLabel(outputKey: string) {
|
||||
const port = this.outputs.find((candidate) => candidate.key === outputKey);
|
||||
return port ? flowValueKindLabel(currentFlowPortValueKind((this.data?.data?.outputs ?? []).find((item: any) => item?.name === outputKey) ?? { type: 'ANY', multiple: false })) : 'ANY';
|
||||
}
|
||||
|
||||
onDropZoneDragOver(event: DragEvent) {
|
||||
if (!this.canAcceptSelectionDrop()) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
onDropZoneDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const raw = event.dataTransfer?.getData(CONTAINER_SUBFLOW_DRAG_MIME);
|
||||
const payload = this.parseDraggedSelection(raw);
|
||||
const assign = this.data?.data?.assignSelectedBlocksToContainer;
|
||||
if (!payload.length || typeof assign !== 'function') return;
|
||||
|
||||
void assign(payload);
|
||||
this.editorState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
onDropZoneDragLeave(_: DragEvent) {
|
||||
this.editorState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
removeSubflow(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
const clear = this.data?.data?.clearContainerSubflow;
|
||||
if (typeof clear === 'function') {
|
||||
void clear();
|
||||
}
|
||||
}
|
||||
|
||||
deleteNode(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
const remove = this.data?.data?.deleteNode;
|
||||
if (typeof remove === 'function') {
|
||||
void remove();
|
||||
}
|
||||
}
|
||||
|
||||
openSubflowPreview(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (!this.subFlow) return;
|
||||
this.subflowPreview.open(this.subFlow, `${this.name} subflow`);
|
||||
}
|
||||
|
||||
private get configuration(): Record<string, unknown> | null {
|
||||
const value = this.data?.data?.specificConfiguration;
|
||||
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
private get blockId(): string | null {
|
||||
const blockId = this.data?.data?.id;
|
||||
return typeof blockId === 'string' && blockId.length > 0 ? blockId : null;
|
||||
}
|
||||
|
||||
private canAcceptSelectionDrop() {
|
||||
return !this.isAssigning && this.selectedCount > 0;
|
||||
}
|
||||
|
||||
private parseDraggedSelection(raw: string | undefined) {
|
||||
if (!raw) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private toPreviewNode(node: FlowNode) {
|
||||
return {
|
||||
id: node.id,
|
||||
name: String(node.name ?? node.typeName ?? 'Node'),
|
||||
type: this.nodeTypeLabel(String(node.typeName ?? 'Node')),
|
||||
family: node.nodeFamily === 'container' ? 'container' : 'block'
|
||||
};
|
||||
}
|
||||
|
||||
private nodeTypeLabel(typeName: string) {
|
||||
if (typeName === 'HumanInteractionBlock') return 'Human Task';
|
||||
return pathToLabel(typeName.replace(/Block$/, ''));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -125,7 +125,11 @@ export class GenericNodeComponent {
|
|||
@Input() rendered!: () => void;
|
||||
|
||||
@HostBinding('class.selected') get selected() {
|
||||
return this.data.selected;
|
||||
return this.data.selected || this.editorState.isBlockSelected(this.blockId);
|
||||
}
|
||||
|
||||
@HostBinding('attr.data-block-id') get hostBlockId() {
|
||||
return this.blockId;
|
||||
}
|
||||
|
||||
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
|
||||
|
|
@ -457,6 +461,11 @@ export class GenericNodeComponent {
|
|||
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
|
||||
}
|
||||
|
||||
private get blockId(): string | null {
|
||||
const blockId = this.data?.data?.id;
|
||||
return typeof blockId === 'string' && blockId.length > 0 ? blockId : null;
|
||||
}
|
||||
|
||||
private ensureBlockConfiguration(): Record<string, any> {
|
||||
if (!this.data?.data) {
|
||||
this.data.data = {};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,33 @@
|
|||
<div class="rete-editor-shell">
|
||||
<div
|
||||
class="rete-editor-shell"
|
||||
#shell
|
||||
(pointerdown)="onShellPointerDown($event)"
|
||||
(pointermove)="onShellPointerMove($event)"
|
||||
(pointerup)="onShellPointerUp($event)"
|
||||
(pointercancel)="onShellPointerUp($event)">
|
||||
<div class="h-full w-full" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
|
||||
</div>
|
||||
|
||||
@if (hasSelectedBlocks) {
|
||||
<div
|
||||
class="rete-editor-selection-badge"
|
||||
draggable="true"
|
||||
(dragstart)="onSelectionDragStart($event)"
|
||||
(dragend)="onSelectionDragEnd()">
|
||||
Drag {{ selectedBlockCount }} selected {{ selectedBlockCount === 1 ? 'node' : 'nodes' }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectionBox(); as box) {
|
||||
<div
|
||||
class="rete-editor-selection-box"
|
||||
[style.left.px]="box.left"
|
||||
[style.top.px]="box.top"
|
||||
[style.width.px]="box.width"
|
||||
[style.height.px]="box.height">
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (creatingEmptyBlock) {
|
||||
<div class="rete-editor-loading">
|
||||
<div class="rete-editor-loading-card">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Component, ElementRef, Injector, input, OnChanges, OnDestroy, output, SimpleChanges, ViewChild } from '@angular/core';
|
||||
import { BlockType, FlowBlock, FlowData } from '@models/flow';
|
||||
import { Component, ElementRef, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, ViewChild } from '@angular/core';
|
||||
import { BlockType, FlowData, FlowNode } from '@models/flow';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
|
||||
import { CONTAINER_SUBFLOW_DRAG_MIME } from '@shared/nodes/container-node/container-node-drag';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance } from '@utilities/rete-editor';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
|
@ -25,6 +26,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
) {}
|
||||
|
||||
@ViewChild("editor") container!: ElementRef;
|
||||
@ViewChild("shell") shell!: ElementRef<HTMLElement>;
|
||||
|
||||
private rete?: ReteEditorInstance;
|
||||
private viewReady = false;
|
||||
|
|
@ -32,6 +34,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
private suppressDirtyEvents = false;
|
||||
creatingEmptyBlock = false;
|
||||
creatingEmptyBlockType = '';
|
||||
selectionBox = signal<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||
private selectionPointerId: number | null = null;
|
||||
private selectionStart: { x: number; y: number } | null = null;
|
||||
private readonly dirtyEventTypes = new Set([
|
||||
'nodecreated',
|
||||
'noderemoved',
|
||||
|
|
@ -57,12 +62,24 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
ngOnDestroy(): void {
|
||||
this.rete?.area.destroy();
|
||||
this.rete = undefined;
|
||||
this.flowState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
get selectedBlockCount() {
|
||||
return this.flowState.selectedBlockIds().length;
|
||||
}
|
||||
|
||||
get hasSelectedBlocks() {
|
||||
return !this.readonly() && this.selectedBlockCount > 0;
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
const dragTypes = Array.from(event.dataTransfer.types ?? []);
|
||||
event.dataTransfer.dropEffect = dragTypes.includes(CONTAINER_SUBFLOW_DRAG_MIME)
|
||||
? 'move'
|
||||
: 'copy';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,11 +91,11 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
|
||||
const blockType: BlockType = JSON.parse(payload);
|
||||
const position = this.getDropPosition(event);
|
||||
let newBlock: FlowBlock;
|
||||
let newBlock: FlowNode;
|
||||
this.creatingEmptyBlock = true;
|
||||
this.creatingEmptyBlockType = blockType.type;
|
||||
try {
|
||||
newBlock = await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type));
|
||||
newBlock = await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type, blockType.family));
|
||||
} catch (error) {
|
||||
console.error('Failed to create empty block', error);
|
||||
return;
|
||||
|
|
@ -98,11 +115,74 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
this.flowChanged.emit(updatedData);
|
||||
}
|
||||
|
||||
onShellPointerDown(event: PointerEvent) {
|
||||
if (this.readonly()) return;
|
||||
if (event.button !== 0) return;
|
||||
if (!this.canStartSelection(event.target)) return;
|
||||
|
||||
this.selectionPointerId = event.pointerId;
|
||||
this.selectionStart = { x: event.clientX, y: event.clientY };
|
||||
this.selectionBox.set({ left: 0, top: 0, width: 0, height: 0 });
|
||||
this.shell.nativeElement.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
onShellPointerMove(event: PointerEvent) {
|
||||
if (this.selectionPointerId !== event.pointerId || !this.selectionStart) return;
|
||||
|
||||
const shellRect = this.shell.nativeElement.getBoundingClientRect();
|
||||
const left = Math.min(this.selectionStart.x, event.clientX) - shellRect.left;
|
||||
const top = Math.min(this.selectionStart.y, event.clientY) - shellRect.top;
|
||||
const width = Math.abs(event.clientX - this.selectionStart.x);
|
||||
const height = Math.abs(event.clientY - this.selectionStart.y);
|
||||
|
||||
this.selectionBox.set({ left, top, width, height });
|
||||
}
|
||||
|
||||
onShellPointerUp(event: PointerEvent) {
|
||||
if (this.selectionPointerId !== event.pointerId || !this.selectionStart) return;
|
||||
|
||||
const shell = this.shell.nativeElement;
|
||||
if (shell.hasPointerCapture(event.pointerId)) {
|
||||
shell.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const start = this.selectionStart;
|
||||
const moved = Math.abs(event.clientX - start.x) > 4 || Math.abs(event.clientY - start.y) > 4;
|
||||
if (moved) {
|
||||
this.flowState.setSelectedBlocks(this.resolveBlocksInsideSelection());
|
||||
} else if (this.canStartSelection(event.target)) {
|
||||
this.flowState.clearBlockSelection();
|
||||
}
|
||||
|
||||
this.selectionPointerId = null;
|
||||
this.selectionStart = null;
|
||||
this.selectionBox.set(null);
|
||||
}
|
||||
|
||||
onSelectionDragStart(event: DragEvent) {
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const selectedBlockIds = this.flowState.selectedBlockIds();
|
||||
if (!selectedBlockIds.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
this.flowState.startDraggingSelectedBlocks(selectedBlockIds);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData(CONTAINER_SUBFLOW_DRAG_MIME, JSON.stringify(selectedBlockIds));
|
||||
}
|
||||
|
||||
onSelectionDragEnd() {
|
||||
this.flowState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
private async reloadEditor() {
|
||||
const host = this.container?.nativeElement as HTMLElement | undefined;
|
||||
if (!host) return;
|
||||
|
||||
const currentVersion = ++this.loadVersion;
|
||||
this.flowState.clearBlockSelection();
|
||||
this.suppressDirtyEvents = true;
|
||||
this.rete?.area.destroy();
|
||||
this.rete = undefined;
|
||||
|
|
@ -184,4 +264,44 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
this.flowState.updateData(updatedData);
|
||||
this.flowChanged.emit(updatedData);
|
||||
}
|
||||
|
||||
private canStartSelection(target: EventTarget | null) {
|
||||
const element = target instanceof HTMLElement ? target : null;
|
||||
if (!element) return false;
|
||||
if (element.closest('[data-testid="node"]')) return false;
|
||||
if (element.closest('button, input, textarea, select, option, label, a')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolveBlocksInsideSelection() {
|
||||
const selection = this.selectionBox();
|
||||
if (!selection) return [];
|
||||
|
||||
const shellRect = this.shell.nativeElement.getBoundingClientRect();
|
||||
const selectionRect = {
|
||||
left: shellRect.left + selection.left,
|
||||
top: shellRect.top + selection.top,
|
||||
right: shellRect.left + selection.left + selection.width,
|
||||
bottom: shellRect.top + selection.top + selection.height
|
||||
};
|
||||
|
||||
return Array.from(
|
||||
this.shell.nativeElement.querySelectorAll<HTMLElement>('[data-testid="node"][data-block-id]')
|
||||
)
|
||||
.filter((element) => this.isRectIntersecting(selectionRect, element.getBoundingClientRect()))
|
||||
.map((element) => element.dataset['blockId'] ?? '')
|
||||
.filter((blockId) => blockId.length > 0);
|
||||
}
|
||||
|
||||
private isRectIntersecting(
|
||||
selectionRect: { left: number; top: number; right: number; bottom: number },
|
||||
targetRect: DOMRect
|
||||
) {
|
||||
return !(
|
||||
targetRect.right < selectionRect.left
|
||||
|| targetRect.left > selectionRect.right
|
||||
|| targetRect.bottom < selectionRect.top
|
||||
|| targetRect.top > selectionRect.bottom
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
@if (state()) {
|
||||
<div class="subflow-preview fixed inset-0 z-9999">
|
||||
<div class="subflow-preview__backdrop" (click)="close($event)"></div>
|
||||
|
||||
<div class="subflow-preview__panel">
|
||||
<div class="subflow-preview__header">
|
||||
<div>
|
||||
<div class="subflow-preview__eyebrow">Read-only Subflow</div>
|
||||
<h3 class="subflow-preview__title">{{ state()!.title }}</h3>
|
||||
</div>
|
||||
<button type="button" mat-stroked-button (click)="close($event)">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="subflow-preview__canvas">
|
||||
<app-rete-editor
|
||||
[flowData]="state()!.flowData"
|
||||
[flowId]="flowId()"
|
||||
[readonly]="true">
|
||||
</app-rete-editor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export class EditorStateHolder {
|
|||
/** Stato */
|
||||
readonly currentFlow = signal<Flow | null>(null);
|
||||
readonly isDirty = signal(false);
|
||||
readonly selectedBlockIds = signal<string[]>([]);
|
||||
readonly draggingSelectedBlockIds = signal<string[]>([]);
|
||||
|
||||
/** Derived state */
|
||||
readonly hasFlow = computed(() => !!this.currentFlow());
|
||||
|
|
@ -31,6 +33,7 @@ export class EditorStateHolder {
|
|||
|
||||
this.currentFlow.set(doc);
|
||||
this.isDirty.set(false);
|
||||
this.clearBlockSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -48,11 +51,13 @@ export class EditorStateHolder {
|
|||
closeDocument() {
|
||||
this.currentFlow.set(null);
|
||||
this.isDirty.set(false);
|
||||
this.clearBlockSelection();
|
||||
}
|
||||
|
||||
loadAssistantFlow(flow: Flow, options?: { markDirty?: boolean }) {
|
||||
this.currentFlow.set(flow);
|
||||
this.isDirty.set(options?.markDirty === true);
|
||||
this.clearBlockSelection();
|
||||
}
|
||||
|
||||
updateData(data: FlowData) {
|
||||
|
|
@ -72,6 +77,30 @@ export class EditorStateHolder {
|
|||
|
||||
this.currentFlow.set({ ...current, data });
|
||||
}
|
||||
|
||||
setSelectedBlocks(blockIds: string[]) {
|
||||
const unique = Array.from(new Set(blockIds.filter((id) => typeof id === 'string' && id.length > 0)));
|
||||
this.selectedBlockIds.set(unique);
|
||||
}
|
||||
|
||||
clearBlockSelection() {
|
||||
this.selectedBlockIds.set([]);
|
||||
this.draggingSelectedBlockIds.set([]);
|
||||
}
|
||||
|
||||
isBlockSelected(blockId: string | null | undefined): boolean {
|
||||
if (!blockId) return false;
|
||||
return this.selectedBlockIds().includes(blockId);
|
||||
}
|
||||
|
||||
startDraggingSelectedBlocks(blockIds?: string[]) {
|
||||
const nextIds = blockIds?.length ? blockIds : this.selectedBlockIds();
|
||||
this.draggingSelectedBlockIds.set(Array.from(new Set(nextIds)));
|
||||
}
|
||||
|
||||
stopDraggingSelectedBlocks() {
|
||||
this.draggingSelectedBlockIds.set([]);
|
||||
}
|
||||
|
||||
updateFlowTitle(newTitle: string) {
|
||||
const current = this.currentFlow();
|
||||
|
|
|
|||
|
|
@ -7,19 +7,40 @@ import {
|
|||
} from "rete-connection-plugin";
|
||||
import { AngularPlugin, Presets, AngularArea2D } from "rete-angular-plugin/21";
|
||||
import { HFNode, HFSchemes } from "@models/nodes";
|
||||
import { areFlowValueKindsCompatible, FlowBlock, FlowData, normalizeFlowPortValueKinds } from "@models/flow";
|
||||
import {
|
||||
areFlowValueKindsCompatible,
|
||||
FlowBlock,
|
||||
FlowContainerOpenInput,
|
||||
FlowContainerOpenOutput,
|
||||
FlowContainerPublicInput,
|
||||
FlowContainerPublicOutput,
|
||||
FlowData,
|
||||
FlowNode,
|
||||
normalizeFlowPortValueKinds
|
||||
} from "@models/flow";
|
||||
import { BlocksService } from "@services/blocks/blocks";
|
||||
import { NodeSettingsDialogService } from "@services/dialogs/node-settings-dialog";
|
||||
import { EditorStateHolder } from "@stores/flow-editor";
|
||||
import { ContainerNodeComponent } from "@shared/nodes/container-node/container-node";
|
||||
import { GenericNodeComponent } from "@shared/nodes/generic-node/generic-node";
|
||||
import { TaskStepNodeComponent } from "@shared/nodes/task-step-node/task-step-node";
|
||||
import { CustomSocket } from "@shared/custom-socket/custom-socket";
|
||||
|
||||
type AreaExtra = AngularArea2D<HFSchemes>;
|
||||
const editorSockets = new WeakMap<NodeEditor<HFSchemes>, Map<string, ClassicPreset.Socket>>();
|
||||
const editorRuntime = new WeakMap<NodeEditor<HFSchemes>, ReteRuntimeContext>();
|
||||
|
||||
export type ReteEditorInstance = {
|
||||
editor: NodeEditor<HFSchemes>;
|
||||
area: AreaPlugin<HFSchemes, AreaExtra>;
|
||||
};
|
||||
|
||||
type ReteRuntimeContext = {
|
||||
blocksService: BlocksService;
|
||||
flowState: EditorStateHolder;
|
||||
settingsDialog: NodeSettingsDialogService;
|
||||
};
|
||||
|
||||
export async function createEditor(
|
||||
container: HTMLElement,
|
||||
injector: Injector,
|
||||
|
|
@ -32,14 +53,20 @@ export async function createEditor(
|
|||
const connection = new ConnectionPlugin<HFSchemes, AreaExtra>();
|
||||
const render = new AngularPlugin<HFSchemes, AreaExtra>({ injector });
|
||||
const nodeView = options?.nodeView ?? "editor";
|
||||
|
||||
|
||||
const runtime: ReteRuntimeContext = {
|
||||
blocksService: injector.get(BlocksService),
|
||||
flowState: injector.get(EditorStateHolder),
|
||||
settingsDialog: injector.get(NodeSettingsDialogService)
|
||||
};
|
||||
editorRuntime.set(editor, runtime);
|
||||
|
||||
render.addPreset(
|
||||
Presets.classic.setup({
|
||||
customize: {
|
||||
node(_context) {
|
||||
return nodeView === "execution" ? TaskStepNodeComponent : GenericNodeComponent;
|
||||
node(context: any) {
|
||||
if (nodeView === "execution") return TaskStepNodeComponent;
|
||||
const typeName = context?.payload?.data?.typeName;
|
||||
return typeName === "GenericContainer" ? ContainerNodeComponent : GenericNodeComponent;
|
||||
},
|
||||
socket(context: any) {
|
||||
// rete-angular passes only `payload` to the socket component.
|
||||
|
|
@ -84,7 +111,7 @@ export async function createEditor(
|
|||
AreaExtensions.simpleNodesOrder(area);
|
||||
|
||||
if (flowData)
|
||||
await loadFlowData(editor, area, flowData);
|
||||
await loadFlowData(editor, area, flowData, runtime);
|
||||
|
||||
AreaExtensions.zoomAt(area, editor.getNodes());
|
||||
return { editor, area };
|
||||
|
|
@ -92,7 +119,7 @@ export async function createEditor(
|
|||
|
||||
export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
||||
const nodeIdToBlockId = new Map<string, string>();
|
||||
const blocks: FlowBlock[] = editor.getNodes().map((node) => {
|
||||
const nodes: FlowNode[] = editor.getNodes().map((node) => {
|
||||
const blockData = node.data;
|
||||
const blockId = blockData?.id ?? node.id;
|
||||
nodeIdToBlockId.set(node.id, blockId);
|
||||
|
|
@ -107,7 +134,8 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
|||
inputs,
|
||||
outputs,
|
||||
specificConfiguration: cloneValue(blockData?.specificConfiguration ?? {}),
|
||||
typeName: blockData?.typeName ?? "LLMBlock"
|
||||
typeName: blockData?.typeName ?? "LLMBlock",
|
||||
nodeFamily: blockData?.nodeFamily === 'container' ? 'container' : 'block'
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -120,7 +148,8 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
|||
}));
|
||||
|
||||
return {
|
||||
blocks,
|
||||
blocks: nodes.filter((node): node is FlowBlock => node.nodeFamily === 'block'),
|
||||
containers: nodes.filter((node) => node.nodeFamily === 'container'),
|
||||
connections
|
||||
};
|
||||
}
|
||||
|
|
@ -128,9 +157,11 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
|||
export async function addBlockToEditor(
|
||||
editor: NodeEditor<HFSchemes>,
|
||||
area: AreaPlugin<HFSchemes, AreaExtra>,
|
||||
block: FlowBlock,
|
||||
position?: { x: number; y: number }
|
||||
block: FlowNode,
|
||||
position?: { x: number; y: number },
|
||||
runtime?: ReteRuntimeContext
|
||||
) {
|
||||
const resolvedRuntime = runtime ?? editorRuntime.get(editor);
|
||||
const node = new ClassicPreset.Node(toNodeLabel(block.typeName)) as HFNode;
|
||||
const removeNode = async () => {
|
||||
if (!editor.getNode(node.id)) return;
|
||||
|
|
@ -144,7 +175,193 @@ export async function addBlockToEditor(
|
|||
|
||||
await editor.removeNode(node.id);
|
||||
};
|
||||
const replaceWithCreatedBlock = async (createdBlock: FlowBlock) => {
|
||||
const clearContainerSubflow = async () => {
|
||||
const currentNode = editor.getNode(node.id) as HFNode | undefined;
|
||||
if (!currentNode?.data) return;
|
||||
const nextConfiguration = {
|
||||
...cloneValue(currentNode.data.specificConfiguration ?? {})
|
||||
};
|
||||
delete (nextConfiguration as Record<string, unknown>)["subFlow"];
|
||||
delete (nextConfiguration as Record<string, unknown>)["publicInputs"];
|
||||
delete (nextConfiguration as Record<string, unknown>)["publicOutputs"];
|
||||
const replacement = {
|
||||
...cloneValue(currentNode.data),
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
specificConfiguration: nextConfiguration
|
||||
};
|
||||
const replaceNode = currentNode.data['replaceWithCreatedBlock'];
|
||||
if (typeof replaceNode === 'function') {
|
||||
await replaceNode(replacement);
|
||||
} else {
|
||||
currentNode.data = {
|
||||
...currentNode.data,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
specificConfiguration: nextConfiguration,
|
||||
__containerValidationErrors: [],
|
||||
__containerAssignmentError: null,
|
||||
__containerAssigning: false
|
||||
};
|
||||
await area.update("node", node.id);
|
||||
}
|
||||
if (resolvedRuntime) {
|
||||
resolvedRuntime.flowState.updateData(exportGraph(editor));
|
||||
}
|
||||
};
|
||||
const assignSelectedBlocksToContainer = async (selectedBlockIds?: string[]) => {
|
||||
if (!resolvedRuntime) return;
|
||||
|
||||
const currentNode = editor.getNode(node.id) as HFNode | undefined;
|
||||
const containerBlockId = currentNode?.data?.id;
|
||||
if (!currentNode?.data || typeof containerBlockId !== "string") return;
|
||||
|
||||
const selection = Array.from(new Set((selectedBlockIds ?? resolvedRuntime.flowState.selectedBlockIds())
|
||||
.filter((id) => typeof id === "string" && id.length > 0)
|
||||
.filter((id) => id !== containerBlockId)));
|
||||
|
||||
if (!selection.length) {
|
||||
currentNode.data = {
|
||||
...currentNode.data,
|
||||
__containerAssignmentError: "Select one or more nodes before dropping them into the container.",
|
||||
__containerValidationErrors: []
|
||||
};
|
||||
await area.update("node", node.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFlow = exportGraph(editor);
|
||||
const selectedBlocks = currentFlow.blocks.filter((candidate) => selection.includes(candidate.id));
|
||||
const selectedContainers = currentFlow.containers.filter((candidate) => selection.includes(candidate.id));
|
||||
const selectedIds = new Set([
|
||||
...selectedBlocks.map((candidate) => candidate.id),
|
||||
...selectedContainers.map((candidate) => candidate.id)
|
||||
]);
|
||||
const candidateSubFlow: FlowData = {
|
||||
blocks: cloneValue(selectedBlocks),
|
||||
containers: cloneValue(selectedContainers),
|
||||
connections: cloneValue(
|
||||
currentFlow.connections.filter((connection) =>
|
||||
selectedIds.has(connection.sourceId) && selectedIds.has(connection.targetId)
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
currentNode.data = {
|
||||
...currentNode.data,
|
||||
__containerAssigning: true,
|
||||
__containerAssignmentError: null,
|
||||
__containerValidationErrors: []
|
||||
};
|
||||
await area.update("node", node.id);
|
||||
|
||||
resolvedRuntime.blocksService.validateContainerSubflow(candidateSubFlow).subscribe({
|
||||
next: async (result) => {
|
||||
const liveNode = editor.getNode(node.id) as HFNode | undefined;
|
||||
if (!liveNode?.data) return;
|
||||
|
||||
if (!result.valid) {
|
||||
liveNode.data = {
|
||||
...liveNode.data,
|
||||
__containerAssigning: false,
|
||||
__containerAssignmentError: null,
|
||||
__containerValidationErrors: result.errors
|
||||
};
|
||||
await area.update("node", node.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const publicPortMapping = await promptContainerPublicPorts(
|
||||
resolvedRuntime.settingsDialog,
|
||||
result.openInputs,
|
||||
result.openOutputs
|
||||
);
|
||||
if (!publicPortMapping) {
|
||||
liveNode.data = {
|
||||
...liveNode.data,
|
||||
__containerAssigning: false,
|
||||
__containerAssignmentError: null,
|
||||
__containerValidationErrors: []
|
||||
};
|
||||
await area.update("node", node.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextConfiguration = {
|
||||
...cloneValue(liveNode.data.specificConfiguration ?? {}),
|
||||
subFlow: candidateSubFlow,
|
||||
publicInputs: publicPortMapping.publicInputs,
|
||||
publicOutputs: publicPortMapping.publicOutputs
|
||||
};
|
||||
const nextInputs = publicPortMapping.publicInputs.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: result.openInputs[index]?.type ?? 'TEXT',
|
||||
multiple: Boolean(result.openInputs[index]?.multiple ?? false),
|
||||
valueKinds: cloneValue(result.openInputs[index]?.valueKinds ?? [])
|
||||
}));
|
||||
const nextOutputs = publicPortMapping.publicOutputs.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: result.openOutputs[index]?.type ?? 'TEXT',
|
||||
multiple: Boolean(result.openOutputs[index]?.multiple ?? false),
|
||||
valueKinds: cloneValue(result.openOutputs[index]?.valueKinds ?? [])
|
||||
}));
|
||||
|
||||
const selectedNodeIds = editor.getNodes()
|
||||
.filter((candidate) => selectedIds.has(String(candidate.data?.id ?? "")))
|
||||
.map((candidate) => candidate.id)
|
||||
.filter((candidateId) => candidateId !== node.id);
|
||||
|
||||
for (const selectedNodeId of selectedNodeIds) {
|
||||
if (!editor.getNode(selectedNodeId)) continue;
|
||||
|
||||
const relatedConnectionIds = editor.getConnections()
|
||||
.filter((connection) => connection.source === selectedNodeId || connection.target === selectedNodeId)
|
||||
.map((connection) => connection.id);
|
||||
|
||||
for (const connectionId of relatedConnectionIds) {
|
||||
await editor.removeConnection(connectionId);
|
||||
}
|
||||
|
||||
await editor.removeNode(selectedNodeId);
|
||||
}
|
||||
|
||||
const replacement = {
|
||||
...cloneValue(liveNode.data),
|
||||
inputs: nextInputs,
|
||||
outputs: nextOutputs,
|
||||
specificConfiguration: nextConfiguration,
|
||||
__containerAssigning: false,
|
||||
__containerAssignmentError: null,
|
||||
__containerValidationErrors: []
|
||||
};
|
||||
const replaceNode = liveNode.data['replaceWithCreatedBlock'];
|
||||
if (typeof replaceNode === 'function') {
|
||||
await replaceNode(replacement);
|
||||
} else {
|
||||
liveNode.data = replacement;
|
||||
await area.update("node", node.id);
|
||||
}
|
||||
|
||||
resolvedRuntime.flowState.clearBlockSelection();
|
||||
resolvedRuntime.flowState.updateData(exportGraph(editor));
|
||||
},
|
||||
error: async (error) => {
|
||||
const liveNode = editor.getNode(node.id) as HFNode | undefined;
|
||||
if (!liveNode?.data) return;
|
||||
|
||||
liveNode.data = {
|
||||
...liveNode.data,
|
||||
__containerAssigning: false,
|
||||
__containerValidationErrors: [],
|
||||
__containerAssignmentError: error instanceof Error
|
||||
? error.message
|
||||
: "Subflow validation failed"
|
||||
};
|
||||
await area.update("node", node.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
const replaceWithCreatedBlock = async (createdBlock: FlowNode) => {
|
||||
if (!editor.getNode(node.id)) return;
|
||||
const previousConnections = editor.getConnections()
|
||||
.filter((connection) => connection.source === node.id || connection.target === node.id)
|
||||
|
|
@ -164,7 +381,8 @@ export async function addBlockToEditor(
|
|||
editor,
|
||||
area,
|
||||
{ ...createdBlock, position: currentPosition },
|
||||
currentPosition
|
||||
currentPosition,
|
||||
resolvedRuntime
|
||||
);
|
||||
|
||||
if (!replacementNode) return;
|
||||
|
|
@ -201,7 +419,12 @@ export async function addBlockToEditor(
|
|||
...cloneValue(block),
|
||||
position: position ?? block.position,
|
||||
deleteNode: removeNode,
|
||||
replaceWithCreatedBlock
|
||||
replaceWithCreatedBlock,
|
||||
assignSelectedBlocksToContainer,
|
||||
clearContainerSubflow,
|
||||
__containerValidationErrors: [],
|
||||
__containerAssignmentError: null,
|
||||
__containerAssigning: false
|
||||
};
|
||||
|
||||
for (const output of block.outputs ?? []) {
|
||||
|
|
@ -225,14 +448,16 @@ export async function addBlockToEditor(
|
|||
async function loadFlowData(
|
||||
editor: NodeEditor<HFSchemes>,
|
||||
area: AreaPlugin<HFSchemes, AreaExtra>,
|
||||
flowData: FlowData
|
||||
flowData: FlowData,
|
||||
runtime?: ReteRuntimeContext
|
||||
) {
|
||||
if (!flowData.blocks?.length) return;
|
||||
const topLevelNodes = [...(flowData.blocks ?? []), ...(flowData.containers ?? [])];
|
||||
if (!topLevelNodes.length) return;
|
||||
|
||||
const nodeMapping = new Map<string, any>();
|
||||
|
||||
for (const block of flowData.blocks) {
|
||||
const node = await addBlockToEditor(editor, area, block, block.position);
|
||||
for (const block of topLevelNodes) {
|
||||
const node = await addBlockToEditor(editor, area, block, block.position, runtime);
|
||||
nodeMapping.set(block.id, node.id);
|
||||
}
|
||||
|
||||
|
|
@ -271,6 +496,73 @@ function toNodeLabel(typeName: string) {
|
|||
return typeName;
|
||||
}
|
||||
|
||||
async function promptContainerPublicPorts(
|
||||
settingsDialog: NodeSettingsDialogService,
|
||||
openInputs: FlowContainerOpenInput[],
|
||||
openOutputs: FlowContainerOpenOutput[]
|
||||
) {
|
||||
const fields = [
|
||||
...openInputs.map((input, index) => ({
|
||||
key: `input:${index}`,
|
||||
label: `Public input for ${input.name}`,
|
||||
type: "text" as const,
|
||||
required: true
|
||||
})),
|
||||
...openOutputs.map((output, index) => ({
|
||||
key: `output:${index}`,
|
||||
label: `Public output for ${output.name}`,
|
||||
type: "text" as const,
|
||||
required: true
|
||||
}))
|
||||
];
|
||||
|
||||
if (!fields.length) {
|
||||
return { publicInputs: [] as FlowContainerPublicInput[], publicOutputs: [] as FlowContainerPublicOutput[] };
|
||||
}
|
||||
|
||||
const initial = Object.fromEntries([
|
||||
...openInputs.map((input, index) => [`input:${index}`, input.name]),
|
||||
...openOutputs.map((output, index) => [`output:${index}`, output.name])
|
||||
]);
|
||||
|
||||
const result = await settingsDialog.open({
|
||||
title: 'Expose container public ports',
|
||||
fields,
|
||||
initial
|
||||
});
|
||||
if (!result) return null;
|
||||
|
||||
const publicInputs = openInputs.map((input, index) => ({
|
||||
name: String(result[`input:${index}`] ?? input.name).trim(),
|
||||
targetBlockId: resolveContainerInputBlockId(input),
|
||||
targetInputName: resolveContainerInputName(input)
|
||||
})).filter((input) => input.name.length > 0 && input.targetBlockId.length > 0 && input.targetInputName.length > 0);
|
||||
|
||||
const publicOutputs = openOutputs.map((output, index) => ({
|
||||
name: String(result[`output:${index}`] ?? output.name).trim(),
|
||||
sourceBlockId: resolveContainerOutputBlockId(output),
|
||||
sourceOutputName: resolveContainerOutputName(output)
|
||||
})).filter((output) => output.name.length > 0 && output.sourceBlockId.length > 0 && output.sourceOutputName.length > 0);
|
||||
|
||||
return { publicInputs, publicOutputs };
|
||||
}
|
||||
|
||||
function resolveContainerInputBlockId(input: FlowContainerOpenInput) {
|
||||
return String(input.targetBlockId ?? input.blockId ?? '');
|
||||
}
|
||||
|
||||
function resolveContainerInputName(input: FlowContainerOpenInput) {
|
||||
return String(input.targetInputName ?? input.inputName ?? input.name ?? '');
|
||||
}
|
||||
|
||||
function resolveContainerOutputBlockId(output: FlowContainerOpenOutput) {
|
||||
return String(output.sourceBlockId ?? output.blockId ?? '');
|
||||
}
|
||||
|
||||
function resolveContainerOutputName(output: FlowContainerOpenOutput) {
|
||||
return String(output.sourceOutputName ?? output.outputName ?? output.name ?? '');
|
||||
}
|
||||
|
||||
function cloneValue<T>(value: T): T {
|
||||
if (typeof globalThis.structuredClone === "function") {
|
||||
return globalThis.structuredClone(value);
|
||||
|
|
|
|||
Loading…
Reference in New Issue