Refine container handling and readonly flow behavior
This commit is contained in:
parent
d01344c19e
commit
8a8978937f
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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?'
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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$/, ''));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue