Refine container handling and readonly flow behavior

This commit is contained in:
Lucio Lelii 2026-03-16 15:41:08 +01:00
parent d01344c19e
commit 8a8978937f
34 changed files with 1169 additions and 401 deletions

View File

@ -15,7 +15,7 @@
<mat-card class="flow-editor-canvas-shell">
<app-title-toolbar></app-title-toolbar>
<div class="flow-editor-canvas">
<app-rete-editor [flowId]="flow()!.id" [flowData]="flow()!.data"></app-rete-editor>
<app-rete-editor [flowId]="flow()!.id" [flowData]="flow()!.data" [readonly]="readonly()"></app-rete-editor>
</div>
</mat-card>
} @else {
@ -26,7 +26,7 @@
}
</main>
@if (assistantEnabled) {
@if (assistantEnabled && !readonly()) {
<section class="flow-editor-assistant" [class.flow-editor-assistant-collapsed]="!assistantOpen()">
<button
type="button"

View File

@ -22,6 +22,7 @@ export class FlowEditor {
assistantEnabled = environment.assistantEnabled;
assistantOpen = signal(true);
flow = this.editorState.currentFlow;
readonly = this.editorState.isCurrentFlowReadOnly;
toggleAssistant() {
this.assistantOpen.update((value) => !value);

View File

@ -100,18 +100,6 @@ 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;

View File

@ -1,10 +1,11 @@
import { GetSchemes, ClassicPreset } from "rete";
import { FlowNode } from "./flow";
import { FlowData, FlowNode } from "./flow";
export type HFNodeData = FlowNode & {
deleteNode?: () => Promise<void>;
replaceWithCreatedBlock?: (block: FlowNode) => Promise<void>;
replaceWithCreatedNode?: (block: FlowNode) => Promise<void>;
assignSelectedBlocksToContainer?: (blockIds?: string[]) => Promise<void>;
assignImportedSubflow?: (subFlow: FlowData, validationUrl?: string | null) => Promise<void>;
clearContainerSubflow?: () => Promise<void>;
[key: string]: unknown;
};

View File

@ -6,6 +6,8 @@ import { ContainersList } from '@shared/containers-list/containers-list';
import { EditorStateHolder } from '@stores/flow-editor';
import { CommonModule } from '@angular/common';
import { FlowsService } from '@services/flows/flows';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { ListState } from '@stores/list-state';
import { finalize } from 'rxjs';
@ -23,6 +25,8 @@ export class EditorSidebar {
flowState = inject(EditorStateHolder);
flowService = inject(FlowsService);
blocksService = inject(BlocksService);
containersService = inject(ContainersService);
blockDisabled = computed(() => !this.flowState.hasFlow());
@ -31,6 +35,14 @@ export class EditorSidebar {
open: OpenedId | null = null;
ngOnInit() {
void this.blocksService.getAllBlocksTypes().catch((err) => {
console.error('Error preloading block types', err);
});
void this.containersService.getAllContainerTypes().catch((err) => {
console.error('Error preloading container types', err);
});
}
openSide(id: OpenedId) {
this.open = id;

View File

@ -26,8 +26,8 @@ export class BlocksService {
async getAllBlocksTypes() {
if (this.toInit) {
await this.refresh();
this.toInit = false;
await this.refresh();
}
return this._blockTypes.asReadonly();
@ -127,7 +127,11 @@ export class BlocksService {
private deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(value);
try {
return globalThis.structuredClone(value);
} catch {
// Some cached payloads may carry non-cloneable runtime fields.
}
}
return JSON.parse(JSON.stringify(value)) as T;
}

View File

@ -6,5 +6,7 @@ export abstract class ContainersCallServiceBase {
abstract createEmptyContainer(containerType: BlockTypeName): Observable<FlowContainer>;
abstract validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult>;
abstract createContainer(containerId: string, configuration: any): Observable<FlowContainer>;
abstract validateContainerSubflow(subFlow: FlowData, validationUrl?: string | null): Observable<FlowSubflowValidationResult>;
}

View File

@ -28,19 +28,16 @@ export class ContainersCallServiceFake extends ContainersCallServiceBase {
},
"subFlow": {
"type": "object",
"x-retriever-name": "Flows",
"x-retriever-url": "/secure-retriever/Flows/subFlow/items",
"x-retriever-structured-data": true,
"x-retriever-requires-auth": true,
"x-retriever-validation-url": "/containers/validate-subflow",
"default": {
"blocks": [],
"containers": [],
"connections": []
}
},
"publicInputs": {
"type": "array",
"default": []
},
"publicOutputs": {
"type": "array",
"default": []
}
},
"required": ["type", "name"]
@ -67,16 +64,35 @@ export class ContainersCallServiceFake extends ContainersCallServiceBase {
blocks: [],
containers: [],
connections: []
},
publicInputs: [],
publicOutputs: []
}
},
typeName: descriptor?.type ?? containerType,
nodeFamily: 'container'
});
}
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
override createContainer(containerId: string, configuration: any): Observable<FlowContainer> {
const typeName = String(configuration?.typeName ?? configuration?.type ?? 'GenericContainer');
const specificConfiguration = {
...configuration,
name: typeof configuration?.name === 'string' && configuration.name.length > 0
? configuration.name
: 'Container'
};
return of({
id: containerId,
name: String(specificConfiguration.name ?? typeName),
position: undefined,
inputs: [],
outputs: [],
specificConfiguration,
typeName,
nodeFamily: 'container'
});
}
override validateContainerSubflow(subFlow: FlowData, _validationUrl?: string | null): Observable<FlowSubflowValidationResult> {
const blocks = Array.isArray(subFlow?.blocks) ? subFlow.blocks : [];
const containers = Array.isArray(subFlow?.containers) ? subFlow.containers : [];

View File

@ -12,12 +12,17 @@ import { ContainersCallServiceBase } from "./container-call.base";
export class ContainersCallService extends ContainersCallServiceBase {
private readonly http = inject(HttpClient);
private containerTypesCache: BlockType[] | null = null;
override retrieveAllContainerTypes(): Observable<BlockType[]> {
return this.http
.get<unknown[]>(`${environment.apiUrl}/containers/types`)
.pipe(
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.containerTypeFromApi(value)) : []))
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.containerTypeFromApi(value)) : [])),
map((types) => {
this.containerTypesCache = types;
return types;
})
);
}
@ -27,10 +32,39 @@ export class ContainersCallService extends ContainersCallServiceBase {
.pipe(map((raw) => this.flowContainerFromApi(raw, containerType)));
}
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
override createContainer(containerId: string, configuration: any): Observable<FlowContainer> {
const containerType = String(configuration?.typeName ?? configuration?.type ?? "GenericContainer");
const payload = this.buildContainerConfigurationPayload(
containerType,
this.toRecord(configuration?.specificConfiguration ?? configuration)
);
return this.http
.post<unknown>(`${environment.apiUrl}/containers`, payload)
.pipe(
map((raw) => {
if (!raw || typeof raw !== "object") {
throw new Error(`Invalid createContainer response for ${containerType}`);
}
return raw;
}),
map((raw) =>
this.flowContainerFromApi(
{
...(this.toRecord(raw)),
id: this.toRecord(raw)["id"] ?? containerId
},
containerType
)
)
);
}
override validateContainerSubflow(subFlow: FlowData, validationUrl?: string | null): Observable<FlowSubflowValidationResult> {
const resolvedValidationUrl = this.toApiPath(validationUrl) ?? `${environment.apiUrl}/containers/validate-subflow`;
return this.http
.post<unknown>(
`${environment.apiUrl}/containers/types/GenericContainer/validate-subflow`,
resolvedValidationUrl,
{ subFlow }
)
.pipe(map((raw) => this.subflowValidationFromApi(raw)));
@ -84,23 +118,49 @@ export class ContainersCallService extends ContainersCallServiceBase {
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
}))
openInputs: this.toOpenInputs(value['openInputs']),
openOutputs: this.toOpenOutputs(value['openOutputs'])
};
}
private toOpenInputs(raw: unknown) {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => this.toRecord(item))
.map((item) => {
const io = this.toRecord(item['io']);
const port = this.toPorts([Object.keys(io).length ? io : item])[0];
if (!port) return null;
return {
...port,
targetBlockId: this.toNullableString(item['targetBlockId'] ?? item['blockId'] ?? item['nodeId']) ?? undefined,
targetInputName: this.toNullableString(item['targetInputName'] ?? item['inputName'] ?? io['name']) ?? undefined,
blockId: this.toNullableString(item['blockId'] ?? item['nodeId']) ?? undefined,
inputName: this.toNullableString(item['inputName'] ?? io['name']) ?? undefined
};
})
.filter((item): item is NonNullable<typeof item> => !!item);
}
private toOpenOutputs(raw: unknown) {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => this.toRecord(item))
.map((item) => {
const io = this.toRecord(item['io']);
const port = this.toPorts([Object.keys(io).length ? io : item])[0];
if (!port) return null;
return {
...port,
sourceBlockId: this.toNullableString(item['sourceBlockId'] ?? item['blockId'] ?? item['nodeId']) ?? undefined,
sourceOutputName: this.toNullableString(item['sourceOutputName'] ?? item['outputName'] ?? io['name']) ?? undefined,
blockId: this.toNullableString(item['blockId'] ?? item['nodeId']) ?? undefined,
outputName: this.toNullableString(item['outputName'] ?? io['name']) ?? undefined
};
})
.filter((item): item is NonNullable<typeof item> => !!item);
}
private toPorts(raw: unknown) {
if (!Array.isArray(raw)) return [];
return raw
@ -162,4 +222,14 @@ export class ContainersCallService extends ContainersCallServiceBase {
if (/^https?:\/\//.test(value)) return value;
return `${environment.apiUrl}${value.startsWith("/") ? value : `/${value}`}`;
}
private buildContainerConfigurationPayload(containerType: string, configuration: Record<string, unknown>) {
const { typeName: _ignoreTypeName, ...sanitized } = configuration;
return {
...sanitized,
name: typeof sanitized["name"] === "string" && sanitized["name"].length > 0
? sanitized["name"]
: containerType
};
}
}

View File

@ -26,8 +26,8 @@ export class ContainersService {
async getAllContainerTypes() {
if (this.toInit) {
await this.refresh();
this.toInit = false;
await this.refresh();
}
return this._containerTypes.asReadonly();
@ -98,8 +98,21 @@ export class ContainersService {
);
}
validateContainerSubflow(subFlow: FlowData) {
return this.containersCallService.validateContainerSubflow(this.deepClone(subFlow)).pipe(
createContainer(containerId: string, configuration: any) {
this.pendingServerSyncCount.update((count) => count + 1);
return this.containersCallService.createContainer(containerId, configuration).pipe(
finalize(() => {
this.pendingServerSyncCount.update((count) => Math.max(0, count - 1));
}),
catchError((err) => {
console.error('Create container failed', err);
return throwError(() => err);
})
);
}
validateContainerSubflow(subFlow: FlowData, validationUrl?: string | null) {
return this.containersCallService.validateContainerSubflow(this.deepClone(subFlow), validationUrl).pipe(
catchError((err) => {
console.error('Validate container subflow failed', err);
return throwError(() => err);
@ -123,7 +136,11 @@ export class ContainersService {
private deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(value);
try {
return globalThis.structuredClone(value);
} catch {
// Some cached payloads may carry non-cloneable runtime fields.
}
}
return JSON.parse(JSON.stringify(value)) as T;
}

View File

@ -2,7 +2,7 @@ import { Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { Flow } from '@models/flow';
import { FlowsCallServiceBase } from './flows-call.base';
import { catchError, combineLatest, Observable, switchMap, tap, throwError } from 'rxjs';
import { catchError, firstValueFrom, Observable, tap, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',
@ -12,24 +12,42 @@ export class FlowsService {
flowsCallService: FlowsCallServiceBase = new environment.flowsCallService();
toInit: boolean = true;
private loadingPromise: Promise<void> | null = null;
private _flows = signal<Flow[]>([]);
readonly flows = this._flows.asReadonly();
async getAllFlows() {
console.log('Flows signal accessed');
if (this.toInit) {
this.refresh();
this.toInit = false;
}
return this._flows.asReadonly();
hasLoadedFlows() {
return this._flows().length > 0 || !this.toInit;
}
refresh() {
this.flowsCallService.retrieveAllFlows().subscribe(flows => {
this._flows.set(flows);
});
async getAllFlows() {
if (this.toInit) {
this.toInit = false;
await this.refresh();
}
return this.flows;
}
async refresh(force = false): Promise<void> {
if (this.loadingPromise && !force) {
return this.loadingPromise;
}
this.loadingPromise = firstValueFrom(this.flowsCallService.retrieveAllFlows())
.then((flows) => {
this._flows.set(flows);
})
.catch((err) => {
console.error('Retrieve flows failed', err);
throw err;
})
.finally(() => {
this.loadingPromise = null;
});
return this.loadingPromise;
}
updateFlow(flow: Flow) {
@ -75,25 +93,18 @@ export class FlowsService {
);
}
cloneFlow(flowId: string): Observable<Flow> {
const originalFlow$ = this.flowsCallService.getFlowById(flowId);
const newFlow$ = this.flowsCallService.createNewFlow();
return combineLatest([originalFlow$, newFlow$]).pipe(
switchMap(([originalFlow, newFlow]) => {
newFlow.name = this.nextFileName(originalFlow.name);
newFlow.data = originalFlow.data ;
return this.flowsCallService.updateFlow(newFlow);
}),
tap(() => this.refresh()),
cloneFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'>): Observable<Flow> {
return this.createFlow({
name: `${flow.name} (cloned)`,
description: flow.description,
data: flow.data,
status: flow.status
}).pipe(
catchError(err => {
console.error('Cloning flow failed', err);
return throwError(() => err);
})
);
}
createNewFlow(name? : string) {

View File

@ -1,5 +1,19 @@
import { Observable } from "rxjs";
export type RetrieverStructuredItemDescriptor = {
label: string;
description?: string;
meta?: Record<string, unknown>;
};
export type RetrieverStructuredItem<T = unknown> = {
descriptor: RetrieverStructuredItemDescriptor;
data: T;
structuredData: boolean;
valid?: boolean;
validationErrors?: unknown[];
};
export abstract class FieldRetrieverCallServiceBase {
abstract retrieveValues(
blockType: string,
@ -8,6 +22,13 @@ export abstract class FieldRetrieverCallServiceBase {
retrieverUrl?: string | null
): Observable<string[]>;
abstract retrieveItems<T = unknown>(
blockType: string,
key: string,
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<RetrieverStructuredItem<T>[]>;
abstract isFieldRequired(
blockType: string,
key: string,

View File

@ -1,5 +1,5 @@
import { Observable, of } from "rxjs";
import { FieldRetrieverCallServiceBase } from "./field-retriever-call.base";
import { FieldRetrieverCallServiceBase, RetrieverStructuredItem } from "./field-retriever-call.base";
export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase {
private readonly providersByBlockType: Record<string, string[]> = {
@ -12,6 +12,52 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
OllamaTestProvider: ["sam860/gemma3:270m", "llama3.2:3b"]
};
private readonly subFlowItems = [
{
descriptor: {
label: 'Containerized Candidate Review',
description: 'Collect candidate text and run internal analysis'
},
data: {
blocks: [
{
id: 'fake-source',
name: 'Collect Candidate',
position: { x: 40, y: 60 },
inputs: [],
outputs: [{ name: 'candidate', type: 'TEXT', multiple: false, valueKinds: [{ type: 'TEXT', multiple: false }] }],
specificConfiguration: { name: 'Collect Candidate' },
typeName: 'SourceBlock',
nodeFamily: 'block'
},
{
id: 'fake-analysis',
name: 'Analyze Candidate',
position: { x: 240, y: 120 },
inputs: [{ name: 'candidate', type: 'TEXT', multiple: false, valueKinds: [{ type: 'TEXT', multiple: false }] }],
outputs: [{ name: 'response', type: 'TEXT', multiple: false, valueKinds: [{ type: 'TEXT', multiple: false }] }],
specificConfiguration: { name: 'Analyze Candidate' },
typeName: 'LLMBlock',
nodeFamily: 'block'
}
],
containers: [],
connections: [
{
id: 'fake-connection',
sourceId: 'fake-source',
sourceName: 'candidate',
targetId: 'fake-analysis',
targetName: 'candidate'
}
]
},
structuredData: true,
valid: true,
validationErrors: []
}
];
override retrieveValues(
blockType: string,
key: string,
@ -30,6 +76,18 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
return of([]);
}
override retrieveItems<T = unknown>(
_blockType: string,
key: string,
_context?: Record<string, string>,
_retrieverUrl?: string | null
): Observable<RetrieverStructuredItem<T>[]> {
if (key === 'subFlow') {
return of(this.subFlowItems as unknown as RetrieverStructuredItem<T>[]);
}
return of([]);
}
override isFieldRequired(
_blockType: string,
_key: string,

View File

@ -2,7 +2,7 @@ import { HttpClient, HttpParams } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import { map, Observable, of } from "rxjs";
import { FieldRetrieverCallServiceBase } from "./field-retriever-call.base";
import { FieldRetrieverCallServiceBase, RetrieverStructuredItem } from "./field-retriever-call.base";
export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
private readonly http = inject(HttpClient);
@ -19,6 +19,18 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
);
}
override retrieveItems<T = unknown>(
blockType: string,
key: string,
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<RetrieverStructuredItem<T>[]> {
const { url, params } = this.resolveRequest(blockType, key, context, retrieverUrl);
return this.http.get<unknown>(url, { params }).pipe(
map((raw) => this.normalizeStructuredItems<T>(raw))
);
}
override isFieldRequired(
blockType: string,
key: string,
@ -127,4 +139,52 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
return candidate.filter((item): item is string => typeof item === 'string');
}
private normalizeStructuredItems<T>(raw: unknown): RetrieverStructuredItem<T>[] {
const candidate = this.extractItemsArray(raw);
return candidate
.map((item) => this.toRecord(item))
.filter((item) => Object.keys(item).length > 0)
.map((item, index) => {
const descriptor = this.toRecord(item['descriptor']);
return {
descriptor: {
label: String(descriptor['label'] ?? descriptor['name'] ?? `Item ${index + 1}`),
description: typeof descriptor['description'] === 'string' ? descriptor['description'] : undefined,
meta: this.toRecordOrNull(descriptor['meta']) ?? undefined
},
data: (item['data'] as T) ?? (item as T),
structuredData: Boolean(item['structuredData'] ?? true),
valid: typeof item['valid'] === 'boolean' ? item['valid'] : undefined,
validationErrors: Array.isArray(item['validationErrors']) ? item['validationErrors'] : undefined
};
});
}
private extractItemsArray(raw: unknown): unknown[] {
if (Array.isArray(raw)) return raw;
if (!raw || typeof raw !== 'object') return [];
const payload = raw as Record<string, unknown>;
const candidate =
payload['items'] ??
payload['values'] ??
payload['data'] ??
payload['result'] ??
[];
return Array.isArray(candidate) ? candidate : [];
}
private toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
private toRecordOrNull(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
}

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { environment } from '@environment';
import { catchError, throwError } from 'rxjs';
import { FieldRetrieverCallServiceBase } from './field-retriever-call.base';
import { FieldRetrieverCallServiceBase, RetrieverStructuredItem } from './field-retriever-call.base';
@Injectable({
providedIn: 'root',
@ -23,6 +23,20 @@ export class FieldRetriever {
);
}
retrieveItems<T = unknown>(
blockType: string,
key: string,
context?: Record<string, string>,
retrieverUrl?: string | null
) {
return this.fieldRetrieverCallService.retrieveItems<T>(blockType, key, context, retrieverUrl).pipe(
catchError((err) => {
console.error('Structured field retrieval failed', err);
return throwError(() => err);
})
);
}
isFieldRequired(
blockType: string,
key: string,

View File

@ -18,7 +18,13 @@ export class CustomSocket {
@HostBinding("style.display") d = "block";
@HostBinding("style.borderRadius") br = "4px";
@HostBinding("style.border") border = "1px solid rgba(255,255,255,0.9)";
@HostBinding("style.cursor") cursor = "crosshair";
@HostBinding("style.cursor") get cursor() {
return this.data?.__readonly === true ? "default" : "crosshair";
}
@HostBinding("style.pointerEvents") get pointerEvents() {
return this.data?.__readonly === true ? "none" : "auto";
}
@HostBinding("style.background")
get bg() {

View File

@ -101,3 +101,10 @@
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
}
.flow-item-actions .mat-mdc-icon-button.mat-mdc-button-base[disabled] {
color: #94a3b8;
background: rgba(226, 232, 240, 0.82);
border: 1px solid rgba(148, 163, 184, 0.28);
opacity: 1;
}

View File

@ -22,11 +22,14 @@
<mat-icon [fontIcon]="flow().visibility === 'PRIVATE' ? 'lock' : 'public'"></mat-icon>
</button>
<button mat-icon-button matTooltip="Clone flow"
(click)="$event.stopPropagation(); clone()" [disabled]="this.flow().visibility!=='PRIVATE'">
(click)="$event.stopPropagation(); clone()">
<mat-icon fontIcon="content_copy"></mat-icon>
</button>
<button mat-icon-button matTooltip="Delete flow"
(click)="$event.stopPropagation(); remove()" [disabled]="this.flow().visibility!=='PRIVATE'">
<button
mat-icon-button
[matTooltip]="deleteTooltip()"
(click)="$event.stopPropagation(); remove()"
[disabled]="!canDelete()">
<mat-icon fontIcon="delete"></mat-icon>
</button>
</div>

View File

@ -5,6 +5,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Flow } from '@models/flow';
import { Authorization } from '@services/authorization/authorization';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FlowsService } from '@services/flows/flows';
import { EditorStateHolder } from '@stores/flow-editor';
@ -19,6 +20,7 @@ export class FlowItem {
private editorState = inject(EditorStateHolder);
private confirm = inject(ConfirmDialogService);
private authorization = inject(Authorization);
private flowsService = inject(FlowsService);
@ -27,6 +29,15 @@ export class FlowItem {
detailOpenedId = model<string | null>(null);
openedFlowId = computed(() => this.editorState.currentFlow()?.id);
canDelete = computed(() => {
const username = this.authorization.loggedInUser()?.username ?? null;
return !!username && this.flow().author === username;
});
deleteTooltip = computed(() =>
this.canDelete()
? 'Delete flow'
: 'Only the owner can delete this flow'
);
async open() {
console.log('Opening flow:', this.flow());
@ -42,7 +53,7 @@ export class FlowItem {
}
clone() {
this.flowsService.cloneFlow(this.flow().id).subscribe({
this.flowsService.cloneFlow(this.flow()).subscribe({
next: clonedFlow => {
console.log('Flow cloned:', clonedFlow);
},
@ -51,6 +62,8 @@ export class FlowItem {
}
async remove() {
if (!this.canDelete()) return;
const confirmed = await this.confirm.open(
this.editorState.isDirty()
? 'You have unsaved changes in the current flow. Delete this flow anyway?'

View File

@ -60,16 +60,24 @@ export class FlowsList extends ListStateViewHolder<Flow> {
this.loading.set(false);
if (existingState.filter)
this.filter.set(existingState.filter as FlowFilter || 'all');
console.log('FlowsList loaded from ListState');
} else {
this.flowsService.getAllFlows().then(flowsSignal => {
this.flows = flowsSignal;
this.loading.set(false);
this.view.list = this.flows;
console.log('FlowsList initialized from service');
});
return;
}
if (this.flowsService.hasLoadedFlows()) {
this.flows = this.flowsService.flows;
this.view.list = this.flows;
this.loading.set(false);
return;
}
this.flowsService.getAllFlows().then(flowsSignal => {
this.flows = flowsSignal;
this.view.list = this.flows;
}).catch((err) => {
console.error('Error loading flows', err);
}).finally(() => {
this.loading.set(false);
});
}
filteredFlows = computed(() => {

View File

@ -92,14 +92,14 @@
width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid #99f6e4;
background: #14b8a6;
color: #ecfeff;
border: 1px solid #fecaca;
background: #dc2626;
color: #fff7ed;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.22);
box-shadow: 0 0 0 3px rgba(127, 29, 29, 0.2);
}
.container-node__warning-tooltip {
@ -108,10 +108,10 @@
right: 0;
min-width: 220px;
max-width: 320px;
border: 1px solid #99f6e4;
border: 1px solid #fecaca;
border-radius: 8px;
background: #f0fdfa;
color: #115e59;
background: #fff1f2;
color: #7f1d1d;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
padding: 8px;
z-index: 13;
@ -213,15 +213,23 @@
display: inline-flex;
flex-direction: column;
align-items: flex-start;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
min-height: 36px;
padding: 4px 10px;
border-radius: 14px;
background: #e2e8f0;
color: #0f172a;
font-size: 12px;
font-weight: 600;
}
.container-node__port-context {
font-size: 9px;
line-height: 1.1;
font-weight: 700;
letter-spacing: 0.04em;
color: #64748b;
}
.container-node__port-name {
line-height: 1.1;
}
@ -248,6 +256,41 @@
text-align: center;
}
.container-node__snapshot {
position: relative;
width: 100%;
height: 74px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.3);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(226, 232, 240, 0.56) 100%);
overflow: hidden;
}
.container-node__snapshot::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(148, 163, 184, 0.12) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
background-size: 18px 18px;
}
.container-node__snapshot-node {
position: absolute;
z-index: 1;
display: block;
border-radius: 5px;
background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%);
box-shadow: 0 2px 4px rgba(15, 23, 42, 0.16);
}
.container-node__snapshot-node--container {
background: linear-gradient(135deg, #0284c7 0%, #38bdf8 100%);
border-radius: 6px;
}
.container-node__dropzone--active {
border-color: #0f766e;
background:
@ -267,12 +310,86 @@
color: #0f172a;
}
.container-node__replace-confirm {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.container-node__replace-confirm-text {
font-size: 15px;
font-weight: 800;
color: #0f172a;
}
.container-node__replace-confirm-note {
font-size: 12px;
line-height: 1.45;
color: #475569;
max-width: 260px;
}
.container-node__replace-confirm-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.container-node__replace-confirm-cancel,
.container-node__replace-confirm-action {
border: 0;
border-radius: 999px;
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
}
.container-node__replace-confirm-cancel {
background: #ffffff;
color: #0f172a;
box-shadow: inset 0 0 0 1px #cbd5e1;
}
.container-node__replace-confirm-action {
background: #0f766e;
color: #f8fafc;
}
:host.container-node--readonly,
:host.container-node--readonly * {
cursor: default !important;
}
.container-node__dropzone-note {
font-size: 12px;
line-height: 1.45;
color: #475569;
}
.container-node__dropzone-actions {
display: flex;
justify-content: center;
margin-top: 2px;
}
.container-node__import {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 116px;
border: 0;
border-radius: 999px;
padding: 8px 12px;
background: #0f766e;
color: #f8fafc;
font-size: 11px;
font-weight: 700;
box-shadow: 0 10px 18px rgba(15, 118, 110, 0.18);
}
.container-node__subflow-preview {
display: grid;
gap: 8px;
@ -325,12 +442,6 @@
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;

View File

@ -2,7 +2,7 @@
@if (deleteConfirmOpen) {
<div class="container-node__delete-overlay"></div>
}
<div class="container-node__header">
<div class="container-node__header" [class.cursor-move]="!isReadonly">
<div class="container-node__icon">
<i class="bi bi-box-seam"></i>
</div>
@ -24,16 +24,18 @@
</div>
</div>
}
@if (deleteConfirmOpen) {
@if (!isReadonly && deleteConfirmOpen) {
<div class="container-node__delete-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<span class="container-node__delete-confirm-text">Delete container?</span>
<button type="button" class="container-node__delete-confirm-cancel" (click)="cancelDelete($event)">Cancel</button>
<button type="button" class="container-node__delete-confirm-action" (click)="deleteNode($event)">Delete</button>
</div>
}
@if (!isReadonly) {
<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>
@ -50,12 +52,16 @@
side: 'input',
key: input.key,
nodeId: data.id,
__readonly: isReadonly,
payload: input.socket
}"
[emit]="emit">
</div>
<span class="container-node__port-label">
<span class="container-node__port-name">{{ inputDisplayLabel(input.key) }}</span>
@if (inputDisplayLabelParts(input.key).context; as context) {
<span class="container-node__port-context">{{ context }}</span>
}
<span class="container-node__port-name">{{ inputDisplayLabelParts(input.key).name }}</span>
<span class="container-node__port-kind">{{ inputKindLabel(input.key) }}</span>
</span>
</div>
@ -67,7 +73,10 @@
@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>
@if (outputDisplayLabelParts(output.key).context; as context) {
<span class="container-node__port-context">{{ context }}</span>
}
<span class="container-node__port-name">{{ outputDisplayLabelParts(output.key).name }}</span>
<span class="container-node__port-kind">{{ outputKindLabel(output.key) }}</span>
</span>
<div
@ -78,6 +87,7 @@
side: 'output',
key: output.key,
nodeId: data.id,
__readonly: isReadonly,
payload: output.socket
}"
[emit]="emit">
@ -89,17 +99,38 @@
<div
class="container-node__dropzone"
[class.container-node__dropzone--active]="selectedCount > 0"
[class.container-node__dropzone--active]="!isReadonly && selectedCount > 0"
[class.container-node__dropzone--filled]="subFlowBlockCount > 0"
(dragover)="onDropZoneDragOver($event)"
(dragleave)="onDropZoneDragLeave($event)"
(drop)="onDropZoneDrop($event)">
@if (isAssigning) {
@if (replaceConfirmOpen) {
<div class="container-node__replace-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div class="container-node__replace-confirm-text">Replace current subflow?</div>
<div class="container-node__replace-confirm-note">The existing embedded flow will be removed and replaced by the dropped selection.</div>
<div class="container-node__replace-confirm-actions">
<button type="button" class="container-node__replace-confirm-cancel" (click)="cancelReplaceSubflow($event)">Cancel</button>
<button type="button" class="container-node__replace-confirm-action" (click)="confirmReplaceSubflow($event)">Replace</button>
</div>
</div>
} @else if (isAssigning) {
<div class="container-node__dropzone-main">
<span class="container-node__spinner" aria-hidden="true"></span>
<span>Validating subflow...</span>
<span>Updating container...</span>
</div>
} @else if (subFlowBlockCount > 0) {
<div class="container-node__snapshot" aria-hidden="true">
@for (node of subFlowSnapshotNodes; track node.id) {
<span
class="container-node__snapshot-node"
[class.container-node__snapshot-node--container]="node.family === 'container'"
[style.left.%]="node.left"
[style.top.%]="node.top"
[style.width.px]="node.width"
[style.height.px]="node.height">
</span>
}
</div>
<div class="container-node__dropzone-main">
<span>{{ subFlowBlockCount }} nodes</span>
<span>{{ subFlowConnectionCount }} connections</span>
@ -115,6 +146,18 @@
Use the selection box in the editor, then drag the floating selection badge into this area.
</div>
}
@if (!isReadonly && !replaceConfirmOpen && !isAssigning) {
<div class="container-node__dropzone-actions">
<button type="button" class="container-node__import" (pointerdown)="$event.stopPropagation()" (click)="importSubflow($event)">
@if (importLoading) {
<span class="container-node__spinner" aria-hidden="true"></span>
<span>Loading flows...</span>
} @else {
<span>Import flow</span>
}
</button>
</div>
}
</div>
@if (subFlowBlockCount > 0) {
@ -139,11 +182,6 @@
}
</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) {
@ -151,25 +189,4 @@
{{ assignmentErrorMessage }}
</div>
}
@if (validationErrors.length) {
<div class="container-node__message container-node__message--warning">
@for (error of validationErrors; track $index) {
<div class="container-node__validation-item">
<span>{{ error.message }}</span>
@if (error.entity || error.id || error.field) {
<span class="container-node__validation-meta">
{{ error.entity || 'entity' }}
@if (error.id) {
· {{ error.id }}
}
@if (error.field) {
· {{ error.field }}
}
</span>
}
</div>
}
</div>
}
</div>

View File

@ -1,12 +1,24 @@
import { CommonModule } from '@angular/common';
import { Component, HostBinding, Input, inject } from '@angular/core';
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FlowNode } from '@models/flow';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { ContainersService } from '@services/containers/containers';
import { FieldRetriever } from '@services/retriever/field-retriever';
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';
import { firstValueFrom } from 'rxjs';
type StructuredRetrieverConfig = {
retrieverName: string;
retrieverUrl: string;
validationUrl: string | null;
structuredData: boolean;
requiresAuth: boolean;
};
@Component({
selector: 'app-container-node',
@ -20,7 +32,13 @@ import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
export class ContainerNodeComponent {
private editorState = inject(EditorStateHolder);
private subflowPreview = inject(SubflowPreviewDialogService);
private fieldRetriever = inject(FieldRetriever);
private containersService = inject(ContainersService);
private settingsDialog = inject(NodeSettingsDialogService);
deleteConfirmOpen = false;
replaceConfirmSelection: string[] | null = null;
importLoading = false;
private importErrorMessage: string | null = null;
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -34,10 +52,18 @@ export class ContainerNodeComponent {
return this.blockId;
}
@HostBinding('class.container-node--readonly') get readonlyClass() {
return this.isReadonly;
}
ngAfterViewInit() {
this.rendered();
}
get isReadonly() {
return this.data?.data?.['__readonly'] === true;
}
get name() {
return String(this.configuration?.['name'] ?? this.data?.data?.name ?? 'Container');
}
@ -63,10 +89,12 @@ export class ContainerNodeComponent {
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 : [];
const candidate = value as Record<string, unknown>;
const blocks = this.normalizeSubFlowBlocks(candidate['blocks']);
const containers = this.normalizeSubFlowContainers(candidate['containers']);
const connections = Array.isArray(candidate['connections'])
? candidate['connections'].filter((item): item is FlowData['connections'][number] => !!item && typeof item === 'object')
: [];
if (!blocks.length && !containers.length && !connections.length) {
return null;
@ -99,14 +127,47 @@ export class ContainerNodeComponent {
return this.subFlowBlockCount > this.subFlowPreviewNodes.length;
}
get validationErrors(): FlowSubflowValidationError[] {
const errors = this.data?.data?.['__containerValidationErrors'];
return Array.isArray(errors) ? errors : [];
get subFlowSnapshotNodes() {
const subFlow = this.subFlow;
if (!subFlow) return [];
const allNodes = [...(subFlow.blocks ?? []), ...(subFlow.containers ?? [])];
if (!allNodes.length) return [];
const positioned = allNodes.map((node) => ({
id: node.id,
family: node.nodeFamily === 'container' ? 'container' : 'block',
x: typeof node.position?.x === 'number' ? node.position.x : 0,
y: typeof node.position?.y === 'number' ? node.position.y : 0,
width: node.nodeFamily === 'container' ? 24 : 18,
height: node.nodeFamily === 'container' ? 14 : 12
}));
const minX = Math.min(...positioned.map((node) => node.x));
const minY = Math.min(...positioned.map((node) => node.y));
const maxX = Math.max(...positioned.map((node) => node.x + node.width));
const maxY = Math.max(...positioned.map((node) => node.y + node.height));
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
return positioned.map((node) => ({
id: node.id,
family: node.family,
left: 6 + ((node.x - minX) / spanX) * 100,
top: 6 + ((node.y - minY) / spanY) * 52,
width: node.width,
height: node.height
}));
}
get replaceConfirmOpen() {
return Array.isArray(this.replaceConfirmSelection) && this.replaceConfirmSelection.length > 0;
}
get assignmentErrorMessage() {
const value = this.data?.data?.['__containerAssignmentError'];
return typeof value === 'string' && value.length > 0 ? value : null;
if (typeof value === 'string' && value.length > 0) return value;
return this.importErrorMessage;
}
get isAssigning() {
@ -121,35 +182,113 @@ export class ContainerNodeComponent {
if (!this.subFlow) {
missing.push('Sub Flow');
}
const config = this.configuration ?? {};
if (!Array.isArray(config['publicInputs'])) {
missing.push('Public Inputs');
}
if (!Array.isArray(config['publicOutputs'])) {
missing.push('Public Outputs');
}
return missing;
}
inputDisplayLabel(inputKey: string) {
return pathToLabel(inputKey);
return this.resolvePortName('input', inputKey);
}
outputDisplayLabel(outputKey: string) {
return pathToLabel(outputKey);
return this.resolvePortName('output', 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';
const port = this.resolvePortDefinition('input', inputKey);
return port ? flowValueKindLabel(currentFlowPortValueKind(port)) : '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';
const port = this.resolvePortDefinition('output', outputKey);
return port ? flowValueKindLabel(currentFlowPortValueKind(port)) : 'ANY';
}
inputDisplayLabelParts(inputKey: string) {
return this.toPortLabelParts(this.inputDisplayLabel(inputKey));
}
outputDisplayLabelParts(outputKey: string) {
return this.toPortLabelParts(this.outputDisplayLabel(outputKey));
}
async importSubflow(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly || this.importLoading || this.replaceConfirmOpen) return;
this.importLoading = true;
this.importErrorMessage = null;
try {
const retriever = await this.resolveStructuredRetrieverConfig();
if (!retriever || !retriever.structuredData) {
this.importErrorMessage = 'Subflow import is not available for this container.';
return;
}
const items = await firstValueFrom(
this.fieldRetriever.retrieveItems<FlowData>(
retriever.retrieverName || this.typeName,
'subFlow',
{
context: 'CONTAINER',
validOnly: 'true',
includeValidation: 'false'
},
retriever.retrieverUrl
)
);
if (!items.length) {
this.importErrorMessage = 'No importable flows were returned by the retriever.';
return;
}
const result = await this.settingsDialog.open({
title: 'Import flow into container',
fields: [
{
key: 'selectedFlow',
label: 'Available flows',
type: 'select',
required: true,
options: items.map((item, index) => ({
value: String(index),
label: item.descriptor.description
? `${item.descriptor.label} - ${item.descriptor.description}`
: item.descriptor.label
}))
}
],
initial: {
selectedFlow: '0'
}
});
if (!result) return;
const selectedIndex = Number(result['selectedFlow'] ?? -1);
const selectedItem = Number.isInteger(selectedIndex) ? items[selectedIndex] : undefined;
if (!selectedItem?.data) {
this.importErrorMessage = 'Invalid flow selection.';
return;
}
const assignImportedSubflow = this.data?.data?.assignImportedSubflow;
if (typeof assignImportedSubflow !== 'function') {
this.importErrorMessage = 'Container import is not available in the editor runtime.';
return;
}
await assignImportedSubflow(selectedItem.data, retriever.validationUrl);
} catch {
this.importErrorMessage = 'Failed to load importable flows.';
} finally {
this.importLoading = false;
}
}
onDropZoneDragOver(event: DragEvent) {
if (this.isReadonly) return;
if (!this.canAcceptSelectionDrop()) return;
event.preventDefault();
if (event.dataTransfer) {
@ -158,35 +297,50 @@ export class ContainerNodeComponent {
}
onDropZoneDrop(event: DragEvent) {
if (this.isReadonly) return;
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;
if (!payload.length) return;
void assign(payload);
this.editorState.stopDraggingSelectedBlocks();
if (this.subFlowBlockCount > 0) {
this.replaceConfirmSelection = payload;
this.editorState.stopDraggingSelectedBlocks();
return;
}
this.assignSelectionToContainer(payload);
}
onDropZoneDragLeave(_: DragEvent) {
if (this.isReadonly) return;
this.editorState.stopDraggingSelectedBlocks();
}
removeSubflow(event?: Event) {
confirmReplaceSubflow(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const clear = this.data?.data?.clearContainerSubflow;
if (typeof clear === 'function') {
void clear();
}
const payload = this.replaceConfirmSelection;
this.replaceConfirmSelection = null;
if (!payload?.length) return;
this.assignSelectionToContainer(payload);
}
cancelReplaceSubflow(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.replaceConfirmSelection = null;
}
deleteNode(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
if (!this.deleteConfirmOpen) {
this.deleteConfirmOpen = true;
return;
@ -221,8 +375,21 @@ export class ContainerNodeComponent {
return typeof blockId === 'string' && blockId.length > 0 ? blockId : null;
}
private get typeName(): string {
return String(this.data?.data?.typeName ?? 'GenericContainer');
}
private canAcceptSelectionDrop() {
return !this.isAssigning && this.selectedCount > 0;
return !this.isAssigning && !this.replaceConfirmOpen && this.selectedCount > 0;
}
private assignSelectionToContainer(payload: string[]) {
this.importErrorMessage = null;
const assign = this.data?.data?.assignSelectedBlocksToContainer;
if (typeof assign !== 'function' || !payload.length) return;
void assign(payload);
this.editorState.stopDraggingSelectedBlocks();
}
private parseDraggedSelection(raw: string | undefined) {
@ -238,6 +405,33 @@ export class ContainerNodeComponent {
}
}
private resolvePortName(kind: 'input' | 'output', key: string) {
const port = this.resolvePortDefinition(kind, key);
return typeof port?.name === 'string' && port.name.length > 0 ? port.name : key;
}
private toPortLabelParts(label: string) {
const trimmed = String(label ?? '').trim();
const separatorIndex = trimmed.lastIndexOf('.');
if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
return {
context: null,
name: trimmed
};
}
return {
context: trimmed.slice(0, separatorIndex),
name: trimmed.slice(separatorIndex + 1)
};
}
private resolvePortDefinition(kind: 'input' | 'output', key: string) {
const ports = this.data?.data?.[kind === 'input' ? 'inputs' : 'outputs'];
if (!Array.isArray(ports)) return null;
return ports.find((item: any) => item?.name === key) ?? null;
}
private toPreviewNode(node: FlowNode) {
return {
id: node.id,
@ -247,6 +441,67 @@ export class ContainerNodeComponent {
};
}
private normalizeSubFlowBlocks(raw: unknown): FlowBlock[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item) => ({
...item,
position: this.normalizePosition(item['position']),
nodeFamily: 'block'
})) as FlowBlock[];
}
private normalizeSubFlowContainers(raw: unknown): FlowContainer[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item) => ({
...item,
position: this.normalizePosition(item['position']),
nodeFamily: 'container'
})) as FlowContainer[];
}
private normalizePosition(raw: unknown): { x: number; y: number } | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const value = raw as Record<string, unknown>;
const x = typeof value['x'] === 'number' ? value['x'] : Number(value['x']);
const y = typeof value['y'] === 'number' ? value['y'] : Number(value['y']);
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { x, y };
}
private async resolveStructuredRetrieverConfig(): Promise<StructuredRetrieverConfig | null> {
const containerType = await this.containersService.getContainerType(this.typeName);
const schema = containerType?.schema;
const properties = schema?.['properties'];
const propertySchema = properties && typeof properties === 'object' && !Array.isArray(properties)
? (properties as Record<string, unknown>)['subFlow']
: null;
if (!propertySchema || typeof propertySchema !== 'object' || Array.isArray(propertySchema)) {
return null;
}
const fieldSchema = propertySchema as Record<string, unknown>;
const retrieverUrl = typeof fieldSchema['x-retriever-url'] === 'string' ? fieldSchema['x-retriever-url'] : null;
const retrieverName = typeof fieldSchema['x-retriever-name'] === 'string' ? fieldSchema['x-retriever-name'] : this.typeName;
if (!retrieverUrl) return null;
return {
retrieverName,
retrieverUrl,
validationUrl: typeof fieldSchema['x-retriever-validation-url'] === 'string'
? fieldSchema['x-retriever-validation-url']
: null,
structuredData: fieldSchema['x-retriever-structured-data'] === true,
requiresAuth: fieldSchema['x-retriever-requires-auth'] === true
};
}
private nodeTypeLabel(typeName: string) {
if (typeName === 'HumanInteractionBlock') return 'Human Task';
return pathToLabel(typeName.replace(/Block$/, ''));

View File

@ -11,6 +11,11 @@
color: #0f172a;
}
:host.llm-node-readonly,
:host.llm-node-readonly * {
cursor: default !important;
}
: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);
@ -180,6 +185,18 @@
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
}
.llm-node-id {
margin-top: 2px;
font-size: 9px;
line-height: 1.1;
font-weight: 500;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.78);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.llm-warning-alert-wrap:hover .llm-warning-list-tooltip {
opacity: 1;
visibility: visible;

View File

@ -13,7 +13,7 @@
@if (deleteConfirmOpen) {
<div class="llm-delete-overlay"></div>
}
<div class="llm-header cursor-move">
<div class="llm-header" [class.cursor-move]="!isReadonly">
<div class="llm-icon">
@if (isHumanNode()) {
<i class="bi bi-person-check-fill"></i>
@ -25,10 +25,13 @@
<span class="llm-title">{{ nodeTitle() }}</span>
<div class="llm-subtitle-row">
<span class="llm-subtitle">{{ name }}</span>
@if (!isReadonly) {
<button type="button" class="llm-name-edit-btn" title="Edit name" (pointerdown)="$event.stopPropagation()" (click)="openNameEditor($event)">
<i class="bi bi-pencil-square"></i>
</button>
}
</div>
<div class="llm-node-id">{{ nodeIdLabel }}</div>
</div>
<div class="llm-header-actions">
@if (hasUpdateBlockError()) {
@ -55,16 +58,18 @@
</div>
</div>
}
@if (deleteConfirmOpen) {
@if (!isReadonly && deleteConfirmOpen) {
<div class="llm-delete-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<span class="llm-delete-confirm-text">Delete node?</span>
<button type="button" class="llm-delete-confirm-cancel" (click)="cancelDelete($event)">Cancel</button>
<button type="button" class="llm-delete-confirm-action" (click)="confirmDelete($event)">Delete</button>
</div>
}
@if (!isReadonly) {
<button type="button" class="llm-delete-btn" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
@ -81,6 +86,7 @@
side: 'input',
key: input.key,
nodeId: data.id,
__readonly: isReadonly,
payload: input.socket
}"
[emit]="emit">
@ -88,7 +94,7 @@
<span class="llm-pill llm-pill-input">
<span class="llm-pill-meta">
<span class="llm-pill-name">{{ inputDisplayLabel(input.key) }}</span>
@if (canTogglePortMultiplicity('input', input.key)) {
@if (!isReadonly && canTogglePortMultiplicity('input', input.key)) {
<select
class="llm-pill-kind-select"
[ngModel]="portCurrentKindValue('input', input.key)"
@ -115,7 +121,7 @@
<span class="llm-pill llm-pill-output" [ngClass]="outputPillClass(output.key)">
<span class="llm-pill-meta llm-pill-meta-output">
<span class="llm-pill-name">{{ outputDisplayLabel(output.key) }}</span>
@if (canTogglePortMultiplicity('output', output.key)) {
@if (!isReadonly && canTogglePortMultiplicity('output', output.key)) {
<select
class="llm-pill-kind-select"
[ngModel]="portCurrentKindValue('output', output.key)"
@ -139,6 +145,7 @@
side: 'output',
key: output.key,
nodeId: data.id,
__readonly: isReadonly,
payload: output.socket
}"
[emit]="emit">
@ -159,6 +166,7 @@
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
@ -167,6 +175,7 @@
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
}
</div>
<span class="llm-param-value">{{ field.value }}</span>
</div>
@ -183,6 +192,7 @@
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
@ -191,6 +201,7 @@
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
}
</div>
<span class="llm-param-value">{{ field.value }}</span>
</div>
@ -203,9 +214,11 @@
<div class="llm-param-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ contentField.label }}</div>
@if (!isReadonly) {
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
}
</div>
<div class="llm-param-text">
@if (!contentField.parts.length) {
@ -230,6 +243,7 @@
<div class="llm-array-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ arrayField.label }}</div>
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
@ -238,6 +252,7 @@
(click)="addArrayItem(arrayField.path, $event)">
<i class="bi bi-plus-lg"></i>
</button>
}
</div>
@if (!arrayField.items.length) {
<div class="llm-array-empty">No items</div>
@ -246,6 +261,7 @@
<div class="llm-array-item">
<span class="llm-array-item-summary">{{ item.summary }}</span>
<div class="llm-array-item-actions">
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
@ -254,6 +270,8 @@
(click)="editArrayItem(arrayField.path, item.index, $event)">
<i class="bi bi-pen"></i>
</button>
}
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn llm-array-remove-btn"
@ -262,6 +280,7 @@
(click)="removeArrayItem(arrayField.path, item.index, $event)">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
}

View File

@ -132,6 +132,10 @@ export class GenericNodeComponent {
return this.blockId;
}
@HostBinding('class.llm-node-readonly') get readonlyClass() {
return this.isReadonly;
}
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
parameterFields: EditableFieldView[] = [];
@ -192,9 +196,18 @@ export class GenericNodeComponent {
this.rendered();
}
get isReadonly() {
return this.data?.data?.['__readonly'] === true;
}
get nodeIdLabel() {
return this.blockId ?? 'unknown-id';
}
async openNameEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
this.localEditorPath = 'name';
this.localEditorLabel = 'Name';
@ -214,6 +227,7 @@ export class GenericNodeComponent {
async openParameterEditor(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const definition = this.editableFieldDefinitions.find((field) => field.path === path);
if (!definition) return;
@ -271,6 +285,7 @@ export class GenericNodeComponent {
saveSimpleParamEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
if (!this.localEditorPath) return;
if (!this.canSaveLocalEditor()) return;
@ -304,6 +319,7 @@ export class GenericNodeComponent {
async openMainContentEditor(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
if (!this.isPathVisible(path)) return;
@ -316,6 +332,7 @@ export class GenericNodeComponent {
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
if (!this.deleteConfirmOpen) {
this.deleteConfirmOpen = true;
return;
@ -406,6 +423,7 @@ export class GenericNodeComponent {
onPortKindChange(kind: 'input' | 'output', key: string, nextValue: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const ports = this.resolvePorts(kind);
const index = ports.findIndex((candidate) => candidate.name === key);
@ -1029,18 +1047,21 @@ export class GenericNodeComponent {
async addArrayItem(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
await this.openArrayItemEditor(path, null);
}
async editArrayItem(path: string, index: number, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
await this.openArrayItemEditor(path, index);
}
removeArrayItem(path: string, index: number, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const config = this.ensureBlockConfiguration();
const current = this.getByPath(config, path);
@ -1650,6 +1671,12 @@ export class GenericNodeComponent {
}
private async fetchConditionalRequirement(blockType: string, field: ConditionalRequiredField) {
if (field.requiredWhen) {
return evaluateUiConditionRule(field.requiredWhen, this.blockConfiguration ?? {}, (path) => this.resolveFieldSchema(path));
}
if (!field.retrieverKey) return false;
const retrieverBlockType = field.retrieverBlockType ?? blockType;
const context: Record<string, string> = {};
for (const dep of field.dependsOn) {
@ -1690,7 +1717,11 @@ export class GenericNodeComponent {
private cloneFlowData<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(value);
try {
return globalThis.structuredClone(value);
} catch {
// Node runtime objects may include non-cloneable values.
}
}
return JSON.parse(JSON.stringify(value)) as T;
}
@ -1732,7 +1763,7 @@ export class GenericNodeComponent {
).subscribe({
next: (createdBlock) => {
const current = (this.data?.data ?? {}) as Record<string, unknown>;
const replaceNode = current['replaceWithCreatedBlock'];
const replaceNode = current['replaceWithCreatedNode'];
if (typeof replaceNode === 'function') {
void replaceNode({
...createdBlock,

View File

@ -1,4 +1,4 @@
import { resolveSchemaRef } from './node-utility';
import { readUiConditionRule, resolveSchemaRef, type UiConditionRule } from './node-utility';
export type RequiredField = {
path: string;
@ -9,9 +9,10 @@ export type ConditionalRequiredField = {
path: string;
label: string;
retrieverBlockType: string | null;
retrieverKey: string;
retrieverKey: string | null;
retrieverUrl: string | null;
dependsOn: Array<{ key: string; path: string }>;
requiredWhen: UiConditionRule | null;
};
export type SchemaRequirements = {
@ -40,7 +41,8 @@ function walkSchema(
conditional: ConditionalRequiredField[],
seenRequired: Set<string>,
seenConditional: Set<string>,
requireAllDescendants = false
requireAllDescendants = false,
inheritedRequiredWhen: UiConditionRule | null = null
) {
const resolved = resolveSchemaRef(node, root);
if (!resolved || typeof resolved !== 'object') return;
@ -58,6 +60,7 @@ function walkSchema(
const isRequiredBySchema = requiredSet.has(key);
const isRequiredByAncestor = requireAllDescendants && key !== 'type';
const isRequired = isRequiredBySchema || isRequiredByAncestor;
const requiredWhen = readUiConditionRule(propertyResolved?.['x-ui-required-when']) ?? inheritedRequiredWhen;
if (isRequired && !hasChildren && key !== 'type' && !seenRequired.has(propertyPath)) {
seenRequired.add(propertyPath);
@ -65,9 +68,10 @@ function walkSchema(
}
const retrieverRequiredUrl = propertyResolved?.['x-retriever-required-url'];
if (typeof retrieverRequiredUrl === 'string') {
if ((requiredWhen || typeof retrieverRequiredUrl === 'string') && key !== 'type' && !hasChildren) {
const parsedRetriever = parseRetrieverUrl(retrieverRequiredUrl);
const retrieverKey = parsedRetriever?.key ?? String(propertyResolved?.['x-retriever-name'] ?? key);
const retrieverKey = parsedRetriever?.key
?? (typeof propertyResolved?.['x-retriever-name'] === 'string' ? String(propertyResolved['x-retriever-name']) : null);
const retrieverBlockType = parsedRetriever?.blockType ?? null;
const rawDepends = Array.isArray(propertyResolved?.['x-retriever-required-depends-on'])
? (propertyResolved['x-retriever-required-depends-on'] as unknown[])
@ -80,7 +84,7 @@ function walkSchema(
path: pathPrefix ? `${pathPrefix}.${dep}` : dep
}));
const signature = `${propertyPath}|${retrieverKey}|${dependsOn.map((d) => d.path).join(',')}`;
const signature = `${propertyPath}|${retrieverKey ?? 'local'}|${dependsOn.map((d) => d.path).join(',')}|${JSON.stringify(requiredWhen ?? null)}`;
if (!seenConditional.has(signature)) {
seenConditional.add(signature);
conditional.push({
@ -88,8 +92,9 @@ function walkSchema(
label,
retrieverBlockType,
retrieverKey,
retrieverUrl: retrieverRequiredUrl,
dependsOn
retrieverUrl: typeof retrieverRequiredUrl === 'string' ? retrieverRequiredUrl : null,
dependsOn,
requiredWhen
});
}
}
@ -104,7 +109,8 @@ function walkSchema(
conditional,
seenRequired,
seenConditional,
isRequired && !childHasOwnRequired
isRequired && !childHasOwnRequired,
requiredWhen
);
}
}

View File

@ -11,6 +11,11 @@
color: #0f172a;
}
:host.llm-node-readonly,
:host.llm-node-readonly * {
cursor: default !important;
}
.llm-node--human {
border-color: #f7d7a7;
background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%);

View File

@ -45,7 +45,7 @@
</div>
}
<div class="llm-header cursor-move">
<div class="llm-header">
<div class="llm-icon">
@if (isHumanNode()) {
<i class="bi bi-person-check-fill"></i>
@ -76,6 +76,7 @@
side: 'input',
key: input.key,
nodeId: data.id,
__readonly: true,
payload: input.socket
}"
[emit]="emit">
@ -127,6 +128,7 @@
side: 'output',
key: output.key,
nodeId: data.id,
__readonly: true,
payload: output.socket
}"
[emit]="emit">

View File

@ -79,6 +79,8 @@ export class TaskStepNodeComponent {
return this.data.selected;
}
@HostBinding('class.llm-node-readonly') readonlyClass = true;
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
parameterFields: DisplayField[] = [];

View File

@ -24,7 +24,8 @@
type="button"
mat-icon-button
class="title-toolbar-edit-button"
matTooltip="Edit flow title"
[matTooltip]="readOnly() ? 'Read-only public flow' : 'Edit flow title'"
[disabled]="readOnly()"
(click)="startEditingTitle()">
<mat-icon fontIcon="edit"></mat-icon>
</button>
@ -39,7 +40,11 @@
<span class="title-toolbar-spinner title-toolbar-spinner--blue" aria-hidden="true"></span>
<span>Updating blocks...</span>
</span>
} @else if (canExecute()) {
}
@if (readOnly()) {
<span class="title-toolbar-pill title-toolbar-pill-info">Read only</span>
}
@if (canExecute()) {
<button
type="button"
mat-stroked-button

View File

@ -28,6 +28,7 @@ export class TitleToolbar {
private blocksService = inject(BlocksService);
private taskExecutionsService = inject(TaskExecutionsService);
flow = computed(() => this.editorState.currentFlow());
readOnly = this.editorState.isCurrentFlowReadOnly;
title = computed(() => {
const flow = this.flow();
return flow ? flow.name : 'No Flow Opened';
@ -35,7 +36,7 @@ export class TitleToolbar {
notSaved = computed(() => this.editorState.isDirty());
blockSyncInProgress = this.blocksService.hasPendingServerSync;
canSave = computed(() => this.notSaved() && !this.blockSyncInProgress());
canSave = computed(() => !this.readOnly() && this.notSaved() && !this.blockSyncInProgress());
canExecute = computed(() => {
const flow = this.flow();
return !!flow && !this.notSaved() && !this.blockSyncInProgress() && flow.status === 'EXECUTABLE';
@ -48,7 +49,7 @@ export class TitleToolbar {
startEditingTitle() {
const flow = this.flow();
if (!flow) return;
if (!flow || this.readOnly()) return;
this.draftTitle.set(flow.name);
this.editingTitle.set(true);
queueMicrotask(() => {
@ -63,6 +64,7 @@ export class TitleToolbar {
}
changeTitle(value: string) {
if (this.readOnly()) return;
const trimmed = value.trim();
if (trimmed === this.title()) {
this.editingTitle.set(false);

View File

@ -1,8 +1,9 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { Flow, FlowData } from '@models/flow';
import { Authorization } from '@services/authorization/authorization';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FlowsService } from '@services/flows/flows';
import { tap } from 'rxjs';
import { tap, throwError } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class EditorStateHolder {
@ -16,10 +17,19 @@ export class EditorStateHolder {
/** Derived state */
readonly hasFlow = computed(() => !!this.currentFlow());
readonly isCurrentFlowReadOnly = computed(() => {
const flow = this.currentFlow();
const currentUsername = this.authorization.loggedInUser()?.username ?? null;
if (!flow) return false;
return flow.visibility === 'PUBLIC' && flow.author !== currentUsername;
});
flowsService: FlowsService = inject(FlowsService);
constructor(private confirm: ConfirmDialogService) { }
constructor(
private confirm: ConfirmDialogService,
private authorization: Authorization
) { }
/** Intent: open document */
async openDocument(doc: Flow, options?: { skipDirtyCheck?: boolean }): Promise<boolean> {
@ -55,12 +65,14 @@ export class EditorStateHolder {
}
loadAssistantFlow(flow: Flow, options?: { markDirty?: boolean }) {
if (this.isCurrentFlowReadOnly()) return;
this.currentFlow.set(flow);
this.isDirty.set(options?.markDirty === true);
this.clearBlockSelection();
}
updateData(data: FlowData) {
if (this.isCurrentFlowReadOnly()) return;
const current = this.currentFlow();
if (!current) return;
if (this.areFlowDataEqual(current.data, data)) return;
@ -103,6 +115,7 @@ export class EditorStateHolder {
}
updateFlowTitle(newTitle: string) {
if (this.isCurrentFlowReadOnly()) return;
const current = this.currentFlow();
if (!current) return;
if (current.name === newTitle) return;
@ -113,6 +126,9 @@ export class EditorStateHolder {
}
save() {
if (this.isCurrentFlowReadOnly()) {
return throwError(() => new Error('Read-only public flows cannot be saved by non-owners.'));
}
const flow = this.currentFlow()!;
const save$ = flow.id.startsWith(EditorStateHolder.ASSISTANT_DRAFT_PREFIX)
? this.flowsService.createFlow({

View File

@ -10,26 +10,23 @@ import { HFNode, HFSchemes } from "@models/nodes";
import {
areFlowValueKindsCompatible,
FlowBlock,
FlowContainerOpenInput,
FlowContainerOpenOutput,
FlowContainerPublicInput,
FlowContainerPublicOutput,
FlowData,
FlowNode,
normalizeFlowPortValueKinds
} from "@models/flow";
import { BlocksService } from "@services/blocks/blocks";
import { ContainersService } from "@services/containers/containers";
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";
import { firstValueFrom } from "rxjs";
type AreaExtra = AngularArea2D<HFSchemes>;
const editorSockets = new WeakMap<NodeEditor<HFSchemes>, Map<string, ClassicPreset.Socket>>();
const editorRuntime = new WeakMap<NodeEditor<HFSchemes>, ReteRuntimeContext>();
const areaProgrammaticTranslations = new WeakMap<AreaPlugin<HFSchemes, AreaExtra>, Set<string>>();
export type ReteEditorInstance = {
editor: NodeEditor<HFSchemes>;
@ -40,7 +37,7 @@ type ReteRuntimeContext = {
blocksService: BlocksService;
containersService: ContainersService;
flowState: EditorStateHolder;
settingsDialog: NodeSettingsDialogService;
readonly: boolean;
};
export async function createEditor(
@ -56,11 +53,13 @@ export async function createEditor(
const render = new AngularPlugin<HFSchemes, AreaExtra>({ injector });
const nodeView = options?.nodeView ?? "editor";
const readonly = options?.readonly === true;
const programmaticTranslations = new Set<string>();
areaProgrammaticTranslations.set(area, programmaticTranslations);
const runtime: ReteRuntimeContext = {
blocksService: injector.get(BlocksService),
containersService: injector.get(ContainersService),
flowState: injector.get(EditorStateHolder),
settingsDialog: injector.get(NodeSettingsDialogService)
readonly
};
editorRuntime.set(editor, runtime);
@ -109,7 +108,10 @@ export async function createEditor(
AreaExtensions.simpleNodesOrder(area);
area.addPipe((context: any) => {
if (readonly && context?.type === 'nodetranslate') return;
if (readonly && context?.type === 'nodetranslate') {
const nodeId = String(context?.data?.id ?? '');
if (!programmaticTranslations.has(nodeId)) return;
}
return context;
});
@ -191,15 +193,13 @@ export async function addBlockToEditor(
...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'];
const replaceNode = currentNode.data['replaceWithCreatedNode'];
if (typeof replaceNode === 'function') {
await replaceNode(replacement);
} else {
@ -218,6 +218,115 @@ export async function addBlockToEditor(
resolvedRuntime.flowState.updateData(exportGraph(editor));
}
};
const applyContainerSubflow = async (
candidateSubFlow: FlowData,
options?: { selectedIds?: Set<string>; validationUrl?: string | null; preValidate?: boolean; source?: 'drag' | 'import' }
) => {
if (!resolvedRuntime) return;
const liveNode = editor.getNode(node.id) as HFNode | undefined;
if (!liveNode?.data) return;
liveNode.data = {
...liveNode.data,
__containerAssigning: true,
__containerAssignmentError: null,
__containerValidationErrors: []
};
await area.update("node", node.id);
try {
const currentLiveNode = editor.getNode(node.id) as HFNode | undefined;
if (!currentLiveNode?.data) return;
if (options?.preValidate) {
const validation = await firstValueFrom(
resolvedRuntime.containersService.validateContainerSubflow(candidateSubFlow, options?.validationUrl)
);
if (!validation.valid) {
currentLiveNode.data = {
...currentLiveNode.data,
__containerAssigning: false,
__containerAssignmentError: validation.errors[0]?.message ?? "Selected subflow is not valid",
__containerValidationErrors: validation.errors
};
await area.update("node", node.id);
return;
}
}
const nextConfiguration = {
...cloneValue(currentLiveNode.data.specificConfiguration ?? {}),
subFlow: candidateSubFlow
};
const nextPosition = cloneValue(currentLiveNode.data['position'] ?? null);
const containerId = String(currentLiveNode.data['id'] ?? '');
const replacementFromServer = await firstValueFrom(
resolvedRuntime.containersService.createContainer(containerId, {
...nextConfiguration,
position: nextPosition,
typeName: currentLiveNode.data['typeName']
})
);
if (options?.source === 'import') {
console.log('Container create response after subFlow import:', replacementFromServer);
}
const selectedIds = options?.selectedIds ?? new Set<string>();
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(currentLiveNode.data),
...cloneValue(replacementFromServer),
position: cloneValue(currentLiveNode.data['position'] ?? replacementFromServer.position),
specificConfiguration: nextConfiguration,
__containerAssigning: false,
__containerAssignmentError: null,
__containerValidationErrors: []
};
const replaceNode = currentLiveNode.data['replaceWithCreatedNode'];
if (typeof replaceNode === 'function') {
await replaceNode(replacement);
} else {
currentLiveNode.data = replacement;
await area.update("node", node.id);
}
resolvedRuntime.flowState.clearBlockSelection();
resolvedRuntime.flowState.updateData(exportGraph(editor));
} catch (error) {
const currentLiveNode = editor.getNode(node.id) as HFNode | undefined;
if (!currentLiveNode?.data) return;
currentLiveNode.data = {
...currentLiveNode.data,
__containerAssigning: false,
__containerValidationErrors: [],
__containerAssignmentError: error instanceof Error
? error.message
: "Container update failed"
};
await area.update("node", node.id);
}
};
const assignSelectedBlocksToContainer = async (selectedBlockIds?: string[]) => {
if (!resolvedRuntime) return;
@ -255,122 +364,12 @@ export async function addBlockToEditor(
)
)
};
currentNode.data = {
...currentNode.data,
__containerAssigning: true,
__containerAssignmentError: null,
__containerValidationErrors: []
};
await area.update("node", node.id);
resolvedRuntime.containersService.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);
}
});
await applyContainerSubflow(candidateSubFlow, { selectedIds, preValidate: true });
};
const replaceWithCreatedBlock = async (createdBlock: FlowNode) => {
const assignImportedSubflow = async (subFlow: FlowData, validationUrl?: string | null) => {
await applyContainerSubflow(cloneValue(subFlow), { validationUrl, source: 'import' });
};
const replaceWithCreatedNode = async (createdBlock: FlowNode) => {
if (!editor.getNode(node.id)) return;
const previousConnections = editor.getConnections()
.filter((connection) => connection.source === node.id || connection.target === node.id)
@ -382,10 +381,6 @@ export async function addBlockToEditor(
targetInput: connection.targetInput
}));
const currentPosition = (node.data?.position ?? position ?? createdBlock.position) as { x: number; y: number } | undefined;
for (const connection of previousConnections) {
await editor.removeConnection(connection.id);
}
await editor.removeNode(node.id);
const replacementNode = await addBlockToEditor(
editor,
area,
@ -396,6 +391,11 @@ export async function addBlockToEditor(
if (!replacementNode) return;
for (const connection of previousConnections) {
await editor.removeConnection(connection.id);
}
await editor.removeNode(node.id);
const replacementOutputNames = new Set(Object.keys(replacementNode.outputs));
const replacementInputNames = new Set(Object.keys(replacementNode.inputs));
@ -419,17 +419,26 @@ export async function addBlockToEditor(
if (connection.source === node.id && !replacementOutputNames.has(sourceOutput)) continue;
if (connection.target === node.id && !replacementInputNames.has(targetInput)) continue;
await editor.addConnection(
new ClassicPreset.Connection(sourceNode as HFNode, sourceOutput, targetNode as HFNode, targetInput)
);
try {
await editor.addConnection(
new ClassicPreset.Connection(sourceNode as HFNode, sourceOutput, targetNode as HFNode, targetInput)
);
} catch (error) {
console.warn('Failed to restore connection after node replacement', {
connection,
error
});
}
}
};
node.data = {
...cloneValue(block),
position: position ?? block.position,
__readonly: resolvedRuntime?.readonly === true,
deleteNode: removeNode,
replaceWithCreatedBlock,
replaceWithCreatedNode,
assignSelectedBlocksToContainer,
assignImportedSubflow,
clearContainerSubflow,
__containerValidationErrors: [],
__containerAssignmentError: null,
@ -448,7 +457,11 @@ export async function addBlockToEditor(
const targetPosition = position ?? block.position;
if (targetPosition) {
await area.translate(node.id, targetPosition);
node.data = {
...node.data,
position: { x: targetPosition.x, y: targetPosition.y }
};
await applyNodePosition(area, node.id, targetPosition);
}
return node;
@ -465,8 +478,12 @@ async function loadFlowData(
const nodeMapping = new Map<string, any>();
for (const block of topLevelNodes) {
const node = await addBlockToEditor(editor, area, block, block.position, runtime);
for (const [index, block] of topLevelNodes.entries()) {
const fallbackPosition = block.position ?? {
x: 120 + (index % 3) * 340,
y: 100 + Math.floor(index / 3) * 220
};
const node = await addBlockToEditor(editor, area, block, fallbackPosition, runtime);
nodeMapping.set(block.id, node.id);
}
@ -505,76 +522,27 @@ function toNodeLabel(typeName: string) {
return typeName;
}
async function promptContainerPublicPorts(
settingsDialog: NodeSettingsDialogService,
openInputs: FlowContainerOpenInput[],
openOutputs: FlowContainerOpenOutput[]
async function applyNodePosition(
area: AreaPlugin<HFSchemes, AreaExtra>,
nodeId: string,
position: { x: number; y: number }
) {
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 programmaticTranslations = areaProgrammaticTranslations.get(area);
if (programmaticTranslations) programmaticTranslations.add(nodeId);
try {
await area.translate(nodeId, position);
} finally {
programmaticTranslations?.delete(nodeId);
}
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);
try {
return globalThis.structuredClone(value);
} catch {
// Functions and runtime socket objects are not cloneable; fall back to JSON-safe clone.
}
}
return JSON.parse(JSON.stringify(value)) as T;
}