diff --git a/src/app/layouts/flow-editor/flow-editor.css b/src/app/layouts/flow-editor/flow-editor.css index d8f79ec..c658420 100644 --- a/src/app/layouts/flow-editor/flow-editor.css +++ b/src/app/layouts/flow-editor/flow-editor.css @@ -25,6 +25,7 @@ .flow-editor-assistant { display: flex; position: relative; + flex-direction: column; flex: 0 0 380px; width: 380px; min-width: 320px; @@ -36,6 +37,60 @@ transition: width 180ms ease, flex-basis 180ms ease, min-width 180ms ease; } +.flow-editor-side-rail { + position: absolute; + top: 60px; + right: 12px; + z-index: 4; + display: inline-flex; + flex-direction: column; + gap: 8px; +} + +.flow-editor-side-rail-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 14px; + background: rgba(255, 255, 255, 0.94); + color: #334155; + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08); +} + +.flow-editor-side-rail-button-active { + border-color: #93c5fd; + background: #eff6ff; + color: #1d4ed8; +} + +.flow-editor-side-rail-button:disabled { + opacity: 0.48; + color: #94a3b8; + box-shadow: none; +} + +.flow-editor-side-rail-badge { + position: absolute; + top: -4px; + right: -4px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: #dc2626; + color: #fff; + font-size: 10px; + font-weight: 800; + line-height: 1; +} + .flow-editor-assistant-collapsed { flex: 0 0 72px; width: 72px; @@ -70,36 +125,6 @@ display: none; } -.flow-editor-assistant-rail { - display: flex; - flex: 1 1 auto; - min-height: 0; - align-items: center; - justify-content: center; - flex-direction: column; - gap: 10px; - width: 100%; - background: - radial-gradient(circle at top, rgba(251, 191, 36, 0.22), transparent 28%), - linear-gradient(180deg, #fffdf7 0%, #f8fafc 100%); - color: #0f172a; -} - -.flow-editor-assistant-rail-icon, -.flow-editor-assistant-rail-wand { - font-size: 18px; - color: #0f766e; -} - -.flow-editor-assistant-rail-text { - writing-mode: vertical-rl; - transform: rotate(180deg); - font-size: 12px; - font-weight: 800; - letter-spacing: 0.18em; - text-transform: uppercase; -} - .flow-editor-empty { display: flex; flex: 1 1 auto; diff --git a/src/app/layouts/flow-editor/flow-editor.html b/src/app/layouts/flow-editor/flow-editor.html index b5f9656..b3f8578 100644 --- a/src/app/layouts/flow-editor/flow-editor.html +++ b/src/app/layouts/flow-editor/flow-editor.html @@ -34,24 +34,42 @@ @if (assistantEnabled && !readonly()) {
+
+ + +
+ @if (assistantOpen()) { - - @if (assistantOpen()) { - - } @else { -
- -
AI
- -
+ @if (activeRightPanel() === 'assistant') { + + } @else { + + } }
} diff --git a/src/app/layouts/flow-editor/flow-editor.ts b/src/app/layouts/flow-editor/flow-editor.ts index 622751a..89b477f 100644 --- a/src/app/layouts/flow-editor/flow-editor.ts +++ b/src/app/layouts/flow-editor/flow-editor.ts @@ -12,6 +12,7 @@ import { BlocksService } from '@services/blocks/blocks'; import { FlowsService } from '@services/flows/flows'; import { EditorStateHolder } from '@stores/flow-editor'; import { FlowAssistant } from '@shared/flow-assistant/flow-assistant'; +import { FlowValidationPanel } from '@shared/flow-validation-panel/flow-validation-panel'; import { TitleToolbar } from "@shared/title-toolbar/title-toolbar"; import { ReteEditor } from "@shared/rete-editor/rete-editor"; import { firstValueFrom } from 'rxjs'; @@ -26,7 +27,7 @@ type TourStep = { @Component({ selector: 'app-flow-editor', - imports: [CommonModule, EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant, MatButtonModule, MatCardModule, MatIconModule], + imports: [CommonModule, EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant, FlowValidationPanel, MatButtonModule, MatCardModule, MatIconModule], templateUrl: './flow-editor.html', styleUrl: './flow-editor.css', }) @@ -44,8 +45,11 @@ export class FlowEditor { assistantEnabled = environment.assistantEnabled; assistantOpen = signal(true); + activeRightPanel = signal<'assistant' | 'errors'>('assistant'); flow = this.editorState.currentFlow; readonly = this.editorState.isCurrentFlowReadOnly; + validationErrors = this.editorState.flowValidationErrors; + validationErrorCount = computed(() => this.validationErrors().length); tourActive = signal(false); tourStepIndex = signal(0); tourSpotlightStyle = signal>({}); @@ -121,6 +125,18 @@ export class FlowEditor { this.activeTourStep(); setTimeout(() => this.syncTourLayout()); }); + + effect(() => { + if (this.validationErrorCount() > 0) { + this.activeRightPanel.set('errors'); + } + }); + + effect(() => { + if (this.activeRightPanel() === 'errors' && this.validationErrorCount() === 0) { + this.activeRightPanel.set('assistant'); + } + }); } toggleAssistant() { @@ -130,6 +146,13 @@ export class FlowEditor { } } + setRightPanel(panel: 'assistant' | 'errors') { + this.activeRightPanel.set(panel); + if (!this.assistantOpen()) { + this.assistantOpen.set(true); + } + } + @HostListener('window:resize') onWindowResize() { if (!this.tourActive()) return; @@ -265,7 +288,8 @@ export class FlowEditor { targetId: right.id, targetName: targetInput } - ] + ], + dependencies: [] }; } diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 66992c3..14e04f8 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -15,12 +15,14 @@ export type Flow = { updatedAt: Date; published?: boolean; finalized?: boolean; + validationErrors?: FlowValidationError[]; }; export type FlowData = { blocks: FlowBlock[]; containers: FlowContainer[]; connections: FlowBlockConnection[]; + dependencies: FlowNodeDependency[]; }; export type FlowSubflowValidationError = { @@ -107,6 +109,41 @@ export type FlowBlockConnection = { targetName: string; }; +export type FlowNodeDependency = { + sourceId: string; + targetId: string; +}; + +export type FlowValidationError = { + code?: string | null; + entity?: string | null; + id?: string | null; + field?: string | null; + message: string; + relatedNodeIds?: string[]; +}; + +export const FLOW_DEPENDANT_PORT_KEY = '__dependant'; +export const FLOW_DEPENDENCY_PORT_KEY = '__dependency'; +export const FLOW_DEPENDENCY_SOCKET_TYPE = '__FLOW_DEPENDENCY__'; + +export function normalizeFlowValidationErrors(raw: unknown): FlowValidationError[] { + if (!Array.isArray(raw)) return []; + + return raw + .filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item)) + .map((item) => ({ + code: typeof item['code'] === 'string' ? item['code'] : null, + entity: typeof item['entity'] === 'string' ? item['entity'] : null, + id: typeof item['id'] === 'string' ? item['id'] : null, + field: typeof item['field'] === 'string' ? item['field'] : null, + message: String(item['message'] ?? item['error'] ?? 'Validation error'), + relatedNodeIds: Array.isArray(item['relatedNodeIds']) + ? item['relatedNodeIds'].map((value) => String(value)).filter((value) => value.length > 0) + : [] + })); +} + export type FlowBlockConfiguration = | LLMBlockConfiguration | HumanInteractiveBlockConfiguration diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index 7b3c165..b576b39 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -1,9 +1,9 @@ -import { FlowBlockConnection, FlowNode, FlowPort, LLMDescriptor } from './flow'; +import { FlowBlockConnection, FlowNode, FlowNodeDependency, FlowPort, LLMDescriptor } from './flow'; export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUSPENDED' | 'SUCCESS' | 'ERROR' | 'CANCELLED'; export type TaskExecutionStatusGroup = 'INIT' | 'RUNNING' | 'PAUSED' | 'FINAL'; -export type StepStatus = 'WAITING_FOR_INPUT' | 'FAILED' | 'COMPLETED' | 'RUNNING' | string; +export type StepStatus = 'WAITING_FOR_INPUT' | 'WAITING_FOR_DEPENDENCY' | 'FAILED' | 'COMPLETED' | 'RUNNING' | string; export type ExecutionEventLogEntry = { id: string; @@ -26,6 +26,7 @@ export type TaskExecution = { simulationAvailable?: boolean; interactionSimulationDescriptor?: LLMDescriptor; stepConnections?: FlowBlockConnection[]; + stepDependencies?: FlowNodeDependency[]; requiredAuthorizations?: Record; providedAuthorizations?: Record; missingAuthorizationKeys?: string[]; @@ -101,7 +102,12 @@ export function normalizeExecutionStatus(status: string | null | undefined): Tas if (normalized === 'CREATED') return 'CREATED'; if (normalized === 'READY') return 'READY'; if (normalized === 'RUNNING') return 'RUNNING'; - if (normalized === 'WAITING' || normalized === 'WAITING_FOR_INPUT' || normalized === 'WAITING_FOR_INTERACTION') { + if ( + normalized === 'WAITING' || + normalized === 'WAITING_FOR_INPUT' || + normalized === 'WAITING_FOR_INTERACTION' || + normalized === 'WAITING_FOR_DEPENDENCY' + ) { return 'WAITING'; } if (normalized === 'SUSPENDED') return 'SUSPENDED'; diff --git a/src/app/services/assistant/assistant-call.fake.ts b/src/app/services/assistant/assistant-call.fake.ts index d56f5f0..601453b 100644 --- a/src/app/services/assistant/assistant-call.fake.ts +++ b/src/app/services/assistant/assistant-call.fake.ts @@ -322,7 +322,8 @@ function buildTicketFlow(model: string): FlowData { targetId: 'assistant-output', targetName: 'input' } - ] + ], + dependencies: [] }; } diff --git a/src/app/services/assistant/assistant-call.ts b/src/app/services/assistant/assistant-call.ts index 914bb5c..2fb52c1 100644 --- a/src/app/services/assistant/assistant-call.ts +++ b/src/app/services/assistant/assistant-call.ts @@ -161,7 +161,8 @@ function mapAssistantDraftPayload(raw: unknown): AssistantDraftPayload | null { flow: { blocks: Array.isArray(flow['blocks']) ? (flow['blocks'] as any[]) : [], containers: Array.isArray(flow['containers']) ? (flow['containers'] as any[]) : [], - connections: Array.isArray(flow['connections']) ? (flow['connections'] as any[]) : [] + connections: Array.isArray(flow['connections']) ? (flow['connections'] as any[]) : [], + dependencies: Array.isArray(flow['dependencies']) ? (flow['dependencies'] as any[]) : [] } }; } diff --git a/src/app/services/containers/containers-call.fake.ts b/src/app/services/containers/containers-call.fake.ts index 2fbcdde..1105c6e 100644 --- a/src/app/services/containers/containers-call.fake.ts +++ b/src/app/services/containers/containers-call.fake.ts @@ -63,7 +63,8 @@ export class ContainersCallServiceFake extends ContainersCallServiceBase { subFlow: { blocks: [], containers: [], - connections: [] + connections: [], + dependencies: [] } }, typeName: descriptor?.type ?? containerType, diff --git a/src/app/services/flows/flow-mapper.ts b/src/app/services/flows/flow-mapper.ts index 2c29132..122ca0b 100644 --- a/src/app/services/flows/flow-mapper.ts +++ b/src/app/services/flows/flow-mapper.ts @@ -1,4 +1,4 @@ -import { Flow, FlowBlock, FlowContainer, FlowData, FlowStatus, FlowVisibility } from '@models/flow'; +import { Flow, FlowBlock, FlowContainer, FlowData, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow'; function parseDate(value: unknown, fallback: Date): Date { if (typeof value !== 'string' || !value) return fallback; @@ -30,10 +30,12 @@ export function flowFromApi(raw: unknown): Flow { visibility, published, finalized: typeof value['finalized'] === 'boolean' ? value['finalized'] : undefined, + validationErrors: normalizeFlowValidationErrors(value['validationErrors'] ?? value['errors']), data: { blocks: normalizeNodes(data.blocks, 'block') as FlowBlock[], containers: normalizeNodes(data.containers, 'container') as FlowContainer[], - connections: Array.isArray(data.connections) ? data.connections : [] + connections: Array.isArray(data.connections) ? data.connections : [], + dependencies: Array.isArray(data.dependencies) ? data.dependencies : [] } }; } @@ -46,7 +48,8 @@ export function toFlowCreateRequest(name: string, description?: string, flow?: F flow: flow ?? { blocks: [], containers: [], - connections: [] + connections: [], + dependencies: [] } }; } diff --git a/src/app/services/flows/flows-call.base.ts b/src/app/services/flows/flows-call.base.ts index a6c698c..f2f1b2b 100644 --- a/src/app/services/flows/flows-call.base.ts +++ b/src/app/services/flows/flows-call.base.ts @@ -1,4 +1,4 @@ -import { Flow } from "@models/flow"; +import { Flow, FlowValidationError } from "@models/flow"; import { Observable } from "rxjs"; export abstract class FlowsCallServiceBase { @@ -19,4 +19,6 @@ export abstract class FlowsCallServiceBase { abstract finalizeFlow(flowId: string) : Observable; + abstract getFlowValidation(flowId: string) : Observable; + } diff --git a/src/app/services/flows/flows-call.fake.ts b/src/app/services/flows/flows-call.fake.ts index b71cef5..7956be6 100644 --- a/src/app/services/flows/flows-call.fake.ts +++ b/src/app/services/flows/flows-call.fake.ts @@ -1,4 +1,4 @@ -import { Flow } from "@models/flow"; +import { Flow, FlowValidationError } from "@models/flow"; import { FlowsCallServiceBase } from "./flows-call.base"; import { Observable, of } from "rxjs"; import { Authorization } from "@services/authorization/authorization"; @@ -31,8 +31,8 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase { authorizationService = inject(Authorization); private data: Record = { - '1': { id: '1', name: 'A Flow', data: { blocks: [], containers: [], connections: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00") }, - '2': { id: '2', name: 'Test Flow', data: { blocks: [], containers: [], connections: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00") }, + '1': { id: '1', name: 'A Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00") }, + '2': { id: '2', name: 'Test Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00") }, 'testFlow': flowFromApi(testDataFlow) } @@ -69,7 +69,7 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase { return this.createFlow({ name: name || 'New Flow', description: undefined, - data: { blocks: [], containers: [], connections: [] }, + data: { blocks: [], containers: [], connections: [], dependencies: [] }, status: 'DRAFT' }); } @@ -110,6 +110,10 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase { this.data[flowId] = updated; return of(updated); } + + override getFlowValidation(flowId: string): Observable { + return of(this.requireFlow(flowId).validationErrors ?? []); + } } const testDataFlow ={ "id": "testFlow", diff --git a/src/app/services/flows/flows-call.ts b/src/app/services/flows/flows-call.ts index c4269b5..80ed8ef 100644 --- a/src/app/services/flows/flows-call.ts +++ b/src/app/services/flows/flows-call.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { inject } from '@angular/core'; import { environment } from '@environment'; -import { Flow } from '@models/flow'; +import { Flow, normalizeFlowValidationErrors } from '@models/flow'; import { map, Observable } from 'rxjs'; import { flowFromApi, toFlowCreateRequest } from './flow-mapper'; import { FlowsCallServiceBase } from './flows-call.base'; @@ -32,7 +32,8 @@ export class FlowsCallService extends FlowsCallServiceBase { data: { blocks: [], containers: [], - connections: [] + connections: [], + dependencies: [] }, status: 'DRAFT' }); @@ -72,4 +73,11 @@ export class FlowsCallService extends FlowsCallServiceBase { .put(`${environment.apiUrl}/flows/${encodedId}/finalized`, { value: true }) .pipe(map((raw) => flowFromApi(raw))); } + + override getFlowValidation(flowId: string) { + const encodedId = encodeURIComponent(flowId); + return this.http + .get(`${environment.apiUrl}/flows/${encodedId}/validation`) + .pipe(map((raw) => normalizeFlowValidationErrors((raw as any)?.errors ?? raw))); + } } diff --git a/src/app/services/flows/flows.ts b/src/app/services/flows/flows.ts index 9462884..0d5e8c0 100644 --- a/src/app/services/flows/flows.ts +++ b/src/app/services/flows/flows.ts @@ -129,6 +129,15 @@ export class FlowsService { ); } + getFlowValidation(flowId: string) { + return this.flowsCallService.getFlowValidation(flowId).pipe( + catchError(err => { + console.error('Retrieve flow validation failed', err); + return throwError(() => err); + }) + ); + } + cloneFlow(flow: Pick): Observable { return this.createFlow({ name: `${flow.name} (cloned)`, @@ -150,7 +159,8 @@ export class FlowsService { data: { blocks: [], containers: [], - connections: [] + connections: [], + dependencies: [] }, status: 'DRAFT' }).pipe( diff --git a/src/app/services/graph-selection/graph-selection.ts b/src/app/services/graph-selection/graph-selection.ts new file mode 100644 index 0000000..b5e7beb --- /dev/null +++ b/src/app/services/graph-selection/graph-selection.ts @@ -0,0 +1,20 @@ +import { Injectable, signal } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class GraphSelectionService { + readonly selectedConnectionId = signal(null); + readonly deleteConnectionRequestTick = signal(0); + + selectConnection(connectionId: string | null | undefined) { + this.selectedConnectionId.set(connectionId ? String(connectionId) : null); + } + + clearConnectionSelection() { + this.selectedConnectionId.set(null); + } + + requestDeleteSelectedConnection() { + if (!this.selectedConnectionId()) return; + this.deleteConnectionRequestTick.update((value) => value + 1); + } +} diff --git a/src/app/shared/custom-connection/custom-connection.ts b/src/app/shared/custom-connection/custom-connection.ts new file mode 100644 index 0000000..3c91330 --- /dev/null +++ b/src/app/shared/custom-connection/custom-connection.ts @@ -0,0 +1,141 @@ +import { Component, inject, Input } from '@angular/core'; +import { ClassicPreset } from 'rete'; +import { FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow'; +import { GraphSelectionService } from '@services/graph-selection/graph-selection'; + +@Component({ + selector: 'app-custom-connection', + standalone: true, + template: ` + + + + @if (isSelected) { + + + + } + + `, + styles: [` + :host svg { + overflow: visible !important; + position: absolute; + pointer-events: none; + width: 9999px; + height: 9999px; + } + + :host path { + fill: none; + pointer-events: auto; + cursor: pointer; + stroke-linecap: round; + stroke-linejoin: round; + } + + :host .connection-delete { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 0; + border-radius: 999px; + background: #fff; + color: #dc2626; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.18); + cursor: pointer; + font-size: 18px; + font-weight: 700; + line-height: 1; + padding: 0; + pointer-events: auto; + user-select: none; + -webkit-user-select: none; + } + + :host .connection-delete-wrap { + overflow: visible; + pointer-events: auto; + } + `] +}) +export class CustomConnectionComponent { + private readonly graphSelection = inject(GraphSelectionService); + @Input() data!: ClassicPreset.Connection; + @Input() start!: { x: number; y: number }; + @Input() end!: { x: number; y: number }; + @Input() path = ''; + + get isDependencyConnection(): boolean { + return this.data?.sourceOutput === FLOW_DEPENDANT_PORT_KEY && this.data?.targetInput === FLOW_DEPENDENCY_PORT_KEY; + } + + get connectionId(): string { + return String(this.data?.id ?? ''); + } + + get isSelected(): boolean { + return this.graphSelection.selectedConnectionId() === this.connectionId; + } + + get strokeColor(): string { + if (this.isSelected) return '#f97316'; + return this.isDependencyConnection ? '#7c8ba1' : '#4682b4'; + } + + get strokeWidth(): number { + if (this.isSelected) return this.isDependencyConnection ? 3.25 : 6; + return this.isDependencyConnection ? 2.25 : 5; + } + + get strokeDasharray(): string | null { + return this.isDependencyConnection ? '6 6' : null; + } + + get opacity(): number { + return this.isDependencyConnection ? 0.95 : 1; + } + + get deleteButtonX(): number { + return ((this.start?.x ?? 0) + (this.end?.x ?? 0)) / 2 - 12; + } + + get deleteButtonY(): number { + return ((this.start?.y ?? 0) + (this.end?.y ?? 0)) / 2 - 12; + } + + selectConnection(event: Event) { + event.preventDefault(); + event.stopPropagation(); + this.graphSelection.selectConnection(this.connectionId); + } + + deleteConnection(event: Event) { + event.preventDefault(); + event.stopPropagation(); + this.graphSelection.selectConnection(this.connectionId); + this.graphSelection.requestDeleteSelectedConnection(); + } +} diff --git a/src/app/shared/custom-socket/custom-socket.ts b/src/app/shared/custom-socket/custom-socket.ts index 7235934..fac7b2f 100644 --- a/src/app/shared/custom-socket/custom-socket.ts +++ b/src/app/shared/custom-socket/custom-socket.ts @@ -8,6 +8,7 @@ export class CustomSocket { @Input() emit!: any; @Input() rendered!: any; private side: "input" | "output" = "input"; + private kind: "data" | "dependency" = "data"; @HostBinding("title") get title() { return this.data.name; @@ -28,6 +29,11 @@ export class CustomSocket { @HostBinding("style.background") get bg() { + if (this.kind === "dependency") { + return this.side === "input" + ? "linear-gradient(145deg, #fbbf24 0%, #d97706 100%)" + : "linear-gradient(145deg, #60a5fa 0%, #2563eb 100%)"; + } return this.side === "input" ? "linear-gradient(145deg, #4ade80 0%, #16a34a 100%)" : "linear-gradient(145deg, #fb7185 0%, #dc2626 100%)"; @@ -35,6 +41,10 @@ export class CustomSocket { @HostBinding("style.boxShadow") get sh() { + if (this.kind === "dependency") { + const c = this.side === "input" ? "rgba(217,119,6,0.45)" : "rgba(37,99,235,0.45)"; + return `0 2px 6px ${c}, 0 0 0 1px ${c}`; + } const c = this.side === "input" ? "rgba(22,163,74,0.45)" : "rgba(220,38,38,0.45)"; return `0 2px 6px ${c}, 0 0 0 1px ${c}`; } @@ -44,8 +54,13 @@ export class CustomSocket { return side === "output" ? "output" : "input"; } + private resolveSocketKind(): "data" | "dependency" { + return this.data?.__hfKind === "dependency" ? "dependency" : "data"; + } + ngOnChanges(): void { this.side = this.resolveSocketSide(); + this.kind = this.resolveSocketKind(); requestAnimationFrame(() => this.rendered()); } } diff --git a/src/app/shared/flow-assistant/flow-assistant.html b/src/app/shared/flow-assistant/flow-assistant.html index f24dd81..21ab6a4 100644 --- a/src/app/shared/flow-assistant/flow-assistant.html +++ b/src/app/shared/flow-assistant/flow-assistant.html @@ -3,10 +3,30 @@

Workflow Copilot

-

Assistant

+

{{ assistantModeLabel() }}

+
+
+
+

+ {{ assistantModeDescription() }} +

+

Mode

+
+
+

+ @if (canOfferCreate()) { + Create mode + } @else if (canOfferFix()) { + Refine / Fix / Explain mode + } @else { + Refine / Explain mode + } +

+
+ @if (activePhaseLabel()) {
{{ activePhaseLabel() }} @@ -38,7 +58,11 @@

- Create, refine, fix, and explain flows in chat. The backend remains the source of truth. + @if (canOfferCreate()) { + Create a new flow draft in chat. Once a flow exists, the same session is treated as attached to that flow. + } @else { + Refine, fix, or explain the flow already open in the editor. Create mode is not offered while a flow is attached. + }

Model

@@ -88,7 +112,7 @@
@if (quickPromptsOpen()) {
- @for (starter of starterPrompts; track starter) { + @for (starter of starterPrompts(); track starter) { @@ -142,7 +166,7 @@ [disabled]="!selectedModel() || assistantBusy()" name="assistantPrompt" rows="4" - placeholder="Ask the assistant to create or refine a workflow"> + [placeholder]="promptPlaceholder()"> + + + +
+ @for (error of errors(); track trackByError($index, error)) { +
+
+ {{ error.code || 'VALIDATION_ERROR' }} + @if ((error.relatedNodeIds?.length ?? 0) > 0) { + + } +
+

{{ error.message }}

+
+ } +
+ } @else { +
+ +

No validation errors for the current flow.

+
+ } +
+ diff --git a/src/app/shared/flow-validation-panel/flow-validation-panel.ts b/src/app/shared/flow-validation-panel/flow-validation-panel.ts new file mode 100644 index 0000000..32bd71e --- /dev/null +++ b/src/app/shared/flow-validation-panel/flow-validation-panel.ts @@ -0,0 +1,40 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { FlowValidationError } from '@models/flow'; +import { EditorStateHolder } from '@stores/flow-editor'; + +@Component({ + selector: 'app-flow-validation-panel', + standalone: true, + imports: [CommonModule, MatButtonModule, MatIconModule], + templateUrl: './flow-validation-panel.html', + styleUrl: './flow-validation-panel.css' +}) +export class FlowValidationPanel { + private readonly editorState = inject(EditorStateHolder); + + readonly errors = this.editorState.flowValidationErrors; + readonly validationRequiresSave = this.editorState.validationRequiresSave; + readonly errorCount = computed(() => this.errors().length); + readonly hasErrors = computed(() => this.errorCount() > 0); + + trackByError(_index: number, error: FlowValidationError) { + return `${error.code ?? 'VALIDATION_ERROR'}:${error.entity ?? ''}:${error.id ?? ''}:${error.field ?? ''}:${error.message}`; + } + + focusError(error: FlowValidationError) { + const nodeIds = Array.isArray(error.relatedNodeIds) ? error.relatedNodeIds : []; + this.editorState.setHighlightedValidationNodes(nodeIds); + } + + highlightAll() { + const nodeIds = this.errors().flatMap((error) => Array.isArray(error.relatedNodeIds) ? error.relatedNodeIds : []); + this.editorState.setHighlightedValidationNodes(nodeIds); + } + + clearHighlight() { + this.editorState.setHighlightedValidationNodes([]); + } +} diff --git a/src/app/shared/flows-list/flows-list.html b/src/app/shared/flows-list/flows-list.html index e1f753f..19f7b08 100644 --- a/src/app/shared/flows-list/flows-list.html +++ b/src/app/shared/flows-list/flows-list.html @@ -22,13 +22,13 @@
- @if (filteredFlows().length === 0) { + @if (orderedFlows().length === 0) {
No flows found
} @else { - @for (flow of filteredFlows(); track flow.id) { + @for (flow of orderedFlows(); track flow.id) { diff --git a/src/app/shared/flows-list/flows-list.ts b/src/app/shared/flows-list/flows-list.ts index 3304e5e..0ff2a55 100644 --- a/src/app/shared/flows-list/flows-list.ts +++ b/src/app/shared/flows-list/flows-list.ts @@ -89,19 +89,27 @@ export class FlowsList extends ListStateViewHolder { return filteredFlows.filter(f => f.visibility === this.filter()); }); - - onOrderChanged(event: OrderEvent) { - const { orderBy, orderDir } = event; - this.view.order = { orderBy, orderDir }; - const flows = this.filteredFlows(); + orderedFlows = computed(() => { + const flows = [...this.filteredFlows()]; + const { orderBy, orderDir } = this.view.order; if (!orderBy) return flows; + return flows.sort((a, b) => { const aValue = (a as any)[orderBy]; const bValue = (b as any)[orderBy]; + if (aValue == null && bValue == null) return 0; + if (aValue == null) return orderDir === 'asc' ? -1 : 1; + if (bValue == null) return orderDir === 'asc' ? 1 : -1; if (aValue < bValue) return orderDir === 'asc' ? -1 : 1; if (aValue > bValue) return orderDir === 'asc' ? 1 : -1; return 0; }); + }); + + + onOrderChanged(event: OrderEvent) { + const { orderBy, orderDir } = event; + this.view.order = { orderBy, orderDir }; } } diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index a2a9a05..eb52163 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -106,6 +106,11 @@ box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18), 0 16px 36px rgba(15, 23, 42, 0.18); } +:host.validation-highlighted .container-node { + border-color: #dc2626; + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.18), 0 18px 38px rgba(127, 29, 29, 0.16); +} + .container-node__header { display: flex; align-items: center; @@ -328,6 +333,18 @@ text-align: right; } +.container-node__port-label--dependency-input { + background: #fff7ed; + color: #92400e; + border-color: #fed7aa; +} + +.container-node__port-label--dependency-output { + background: #eff6ff; + color: #1d4ed8; + border-color: #bfdbfe; +} + .container-node__port-context { font-size: 9px; line-height: 1.1; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index e2b544a..e449da1 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -100,6 +100,58 @@
+ @if (hasExecutionDependencyPorts) { +
+
+
Dependency
+ @if (dependencyInput; as dependencyInput) { +
+
+
+ + Depends on + +
+ } +
+ +
+
Dependant
+ @if (dependantOutput; as dependantOutput) { +
+ + Prerequisite of + +
+
+
+ } +
+
+ } + @if (hasParameterFields() || hasMainContent()) {
@if (!schemaReady) { diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 0742951..2e9bc34 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData } from '@models/flow'; +import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow'; import { NodeSettingField, NodeSettingOption, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; import { ContainersService } from '@services/containers/containers'; import { FieldRetriever } from '@services/retriever/field-retriever'; @@ -95,6 +95,10 @@ export class ContainerNodeComponent { return this.data.selected || this.editorState.isBlockSelected(this.blockId); } + @HostBinding('class.validation-highlighted') get validationHighlighted() { + return this.editorState.isValidationNodeHighlighted(this.blockId); + } + @HostBinding('attr.data-block-id') get hostBlockId() { return this.blockId; } @@ -124,17 +128,39 @@ export class ContainerNodeComponent { } get inputs() { - return Object.entries(this.data?.inputs ?? {}).map(([key, input]) => ({ + return Object.entries(this.data?.inputs ?? {}) + .filter(([key]) => key !== FLOW_DEPENDENCY_PORT_KEY) + .map(([key, input]) => ({ key, socket: (input as any).socket as ClassicPreset.Socket - })); + })); } get outputs() { - return Object.entries(this.data?.outputs ?? {}).map(([key, output]) => ({ + return Object.entries(this.data?.outputs ?? {}) + .filter(([key]) => key !== FLOW_DEPENDANT_PORT_KEY) + .map(([key, output]) => ({ key, socket: (output as any).socket as ClassicPreset.Socket - })); + })); + } + + get dependencyInput() { + const input = this.data?.inputs?.[FLOW_DEPENDENCY_PORT_KEY]; + return input + ? { key: FLOW_DEPENDENCY_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket } + : null; + } + + get dependantOutput() { + const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY]; + return output + ? { key: FLOW_DEPENDANT_PORT_KEY, socket: (output as any).socket as ClassicPreset.Socket } + : null; + } + + get hasExecutionDependencyPorts() { + return !!this.dependencyInput || !!this.dependantOutput; } get selectedCount() { @@ -150,15 +176,19 @@ export class ContainerNodeComponent { const connections = Array.isArray(candidate['connections']) ? candidate['connections'].filter((item): item is FlowData['connections'][number] => !!item && typeof item === 'object') : []; + const dependencies = Array.isArray(candidate['dependencies']) + ? candidate['dependencies'].filter((item): item is FlowData['dependencies'][number] => !!item && typeof item === 'object') + : []; - if (!blocks.length && !containers.length && !connections.length) { + if (!blocks.length && !containers.length && !connections.length && !dependencies.length) { return null; } return { blocks, containers, - connections + connections, + dependencies }; } @@ -916,7 +946,8 @@ export class ContainerNodeComponent { } : container ), - connections: flow.data.connections + connections: flow.data.connections, + dependencies: flow.data.dependencies ?? [] }; this.editorState.updateData(nextFlow); diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index 55495b6..bc763a1 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -21,6 +21,11 @@ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.24), 0 12px 28px rgba(15, 23, 42, 0.16); } +:host.validation-highlighted .llm-node { + border-color: #dc2626; + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.18), 0 14px 28px rgba(127, 29, 29, 0.18); +} + .llm-delete-overlay { position: absolute; inset: 0; @@ -558,6 +563,18 @@ border-color: #fecdd3; } +.llm-pill-dependency-input { + color: #92400e; + background: #fff7ed; + border-color: #fed7aa; +} + +.llm-pill-dependency-output { + color: #1d4ed8; + background: #eff6ff; + border-color: #bfdbfe; +} + .llm-pill-kind-select { max-width: 88px; border: 1px solid rgba(148, 163, 184, 0.35); diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 285d890..209a503 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -158,6 +158,54 @@
+ @if (hasExecutionDependencyPorts()) { +
+
+
Dependency
+ @if (dependencyInput; as dependencyInput) { +
+
+
+ Depends on +
+ } +
+ +
+
Dependant
+ @if (dependantOutput; as dependantOutput) { +
+ Prerequisite of +
+
+
+ } +
+
+ } +
@if (!schemaReady) {
+ @if (hasExecutionDependencyPorts()) { +
+
+
Dependency
+ @if (hasConnectedDependencyInput() && dependencyInput; as dependencyInput) { +
+
+
+ Depends on +
+ } +
+ +
+
Dependant
+ @if (hasConnectedDependantOutput() && dependantOutput; as dependantOutput) { +
+ Prerequisite of +
+
+
+ } +
+
+ } +
@if (!schemaReady) { } @else { - - {{ executionInput.subtitle }} - - +
+ + {{ executionInput.subtitle }} + + + +
+ +
+
} } diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts index 14a7ec8..3913460 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts @@ -33,6 +33,7 @@ export class TaskExecutionInputsPanelComponent { readonly readOnly = input(false); readonly textInputChange = output<{ input: EditableExecutionInput; value: string | string[] }>(); + readonly textInputSubmit = output(); readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>(); readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>(); readonly authorizationSubmit = output(); @@ -59,6 +60,13 @@ export class TaskExecutionInputsPanelComponent { this.textInputChange.emit({ input, value }); } + submitTextInput(input: EditableExecutionInput, event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + if (this.readOnly() || this.isInputSaving(input.key)) return; + this.textInputSubmit.emit(input); + } + onFileInputChange(input: EditableExecutionInput, event: Event) { if (this.readOnly()) return; const target = event.target as HTMLInputElement | null; @@ -92,6 +100,10 @@ export class TaskExecutionInputsPanelComponent { return this.savingInputs()[key] === true; } + canSubmitTextInput(input: EditableExecutionInput): boolean { + return !this.readOnly() && !this.isInputSaving(input.key); + } + inputSavingError(key: string): string | null { return this.savingErrors()[key] ?? null; } diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index 1a9a64c..a4111b3 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -151,6 +151,7 @@ (authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)" (authorizationSubmit)="submitAuthorization($event)" (textInputChange)="onTextInputChange($event.input, $event.value)" + (textInputSubmit)="submitTextInput($event)" (fileInputChange)="onFileInputChange($event.input, $event.files)"> } @else if (activeAsideTab() === 'logs') { diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 9b7c2ff..848a798 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -11,6 +11,7 @@ import { FlowData, LLMDescriptor, FlowNode, + FlowNodeDependency, normalizeFlowPortValueKinds } from '@models/flow'; import { @@ -62,7 +63,6 @@ type ExecutionLogEntryView = ExecutionEventLogEntry & { styleUrl: './task-execution-viewer.css', }) export class TaskExecutionViewerComponent implements OnDestroy { - private static readonly TEXT_INPUT_DEBOUNCE_MS = 1200; private static readonly EVENTS_POLL_INTERVAL_MS = 5000; private static readonly OUTPUT_PREVIEW_LIMIT = 80; private taskExecutionsService = inject(TaskExecutionsService); @@ -70,7 +70,6 @@ export class TaskExecutionViewerComponent implements OnDestroy { private settingsDialog = inject(NodeSettingsDialogService); private fieldRetriever = inject(FieldRetriever); private containersService = inject(ContainersService); - private readonly textInputDebounceTimers = new Map>(); private lastExecutionId: string | null = null; private lastExecutionStatus: string | null = null; private static readonly SIMULATOR_PROVIDER_RETRIEVER_URL = '/retriever/LLM/providers'; @@ -270,6 +269,8 @@ export class TaskExecutionViewerComponent implements OnDestroy { __connectedInputs: this.getConnectedInputs(step), __executionOutputs: this.getExecutionOutputValues(step, contextResults), __connectedOutputs: this.getConnectedOutputs(step), + __hasDependencyInputConnection: this.hasIncomingDependency(step.id), + __hasDependantOutputConnection: this.hasOutgoingDependency(step.id), __executionErrors: this.getExecutionErrors(step.id, contextErrors), __executionWarnings: this.getExecutionWarnings(step.id, contextWarnings), __stepResultData: step.result ?? null, @@ -289,7 +290,8 @@ export class TaskExecutionViewerComponent implements OnDestroy { } const connections = this.getExecutionConnections(steps); - return { blocks, containers, connections }; + const dependencies = this.getExecutionDependencies(); + return { blocks, containers, connections, dependencies }; }); readonly formattedDuration = computed(() => { @@ -533,19 +535,20 @@ export class TaskExecutionViewerComponent implements OnDestroy { } onTextInputChange(input: EditableExecutionInput, value: string | string[]) { + if (this.inputsReadOnly()) return; + this.pendingTextInputs.update((current) => ({ ...current, [input.key]: value })); + this.savingErrors.update((current) => { + const next = { ...current }; + delete next[input.key]; + return next; + }); + } + + submitTextInput(input: EditableExecutionInput) { if (this.inputsReadOnly()) return; const executionId = this.execution()?.id; if (!executionId) return; - - this.pendingTextInputs.update((current) => ({ ...current, [input.key]: value })); - - const timerKey = `${executionId}:${input.key}`; - this.clearDebounceTimer(timerKey); - const timer = setTimeout(() => { - this.textInputDebounceTimers.delete(timerKey); - this.sendPreparedTextInput(input, executionId); - }, TaskExecutionViewerComponent.TEXT_INPUT_DEBOUNCE_MS); - this.textInputDebounceTimers.set(timerKey, timer); + this.sendPreparedTextInput(input, executionId); } onFileInputChange(input: EditableExecutionInput, files: File[]) { @@ -620,10 +623,6 @@ export class TaskExecutionViewerComponent implements OnDestroy { } ngOnDestroy() { - for (const timer of this.textInputDebounceTimers.values()) { - clearTimeout(timer); - } - this.textInputDebounceTimers.clear(); } private setAuthorizationSaving(key: string, saving: boolean) { @@ -862,13 +861,6 @@ export class TaskExecutionViewerComponent implements OnDestroy { }; } - private clearDebounceTimer(timerKey: string) { - const timer = this.textInputDebounceTimers.get(timerKey); - if (!timer) return; - clearTimeout(timer); - this.textInputDebounceTimers.delete(timerKey); - } - private formatDuration(startTime: number, endTime: number): string { const diffMs = Math.max(0, endTime - startTime); const totalSeconds = Math.floor(diffMs / 1000); @@ -955,6 +947,21 @@ export class TaskExecutionViewerComponent implements OnDestroy { return this.inferConnections(steps); } + private getExecutionDependencies(): FlowNodeDependency[] { + return (this.execution()?.stepDependencies ?? []).map((dependency) => ({ + sourceId: String(dependency.sourceId), + targetId: String(dependency.targetId) + })); + } + + private hasIncomingDependency(stepId: string): boolean { + return (this.execution()?.stepDependencies ?? []).some((dependency) => String(dependency.targetId) === stepId); + } + + private hasOutgoingDependency(stepId: string): boolean { + return (this.execution()?.stepDependencies ?? []).some((dependency) => String(dependency.sourceId) === stepId); + } + private pickBestConnectionCandidate( candidates: Array<{ sourceStep: TaskExecutionStep; sourceIndex: number; sourceOutputName: string }>, targetIndex: number diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index 3bc0ef5..aa9203c 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -101,13 +101,17 @@ export class TitleToolbar { this.editorState.save().pipe( take(1) ).subscribe({ - next: () => { + next: (savedFlow) => { console.log('Flow saved'); - this.showSnackbar('Flow saved', 'success'); + if ((savedFlow.validationErrors?.length ?? 0) > 0 && savedFlow.status === 'DRAFT') { + this.showSnackbar('Flow saved as draft with validation errors', 'error'); + } else { + this.showSnackbar('Flow saved', 'success'); + } }, error: err => { console.error('Save failed', err); - this.showSnackbar('Errore durante il salvataggio', 'error'); + this.showSnackbar(err instanceof Error ? err.message : 'Errore durante il salvataggio', 'error'); } }); } diff --git a/src/app/stores/flow-editor.ts b/src/app/stores/flow-editor.ts index 714667e..a7c132c 100644 --- a/src/app/stores/flow-editor.ts +++ b/src/app/stores/flow-editor.ts @@ -1,19 +1,23 @@ import { computed, inject, Injectable, signal } from '@angular/core'; -import { Flow, FlowData } from '@models/flow'; +import { Flow, FlowData, FlowValidationError, normalizeFlowValidationErrors } from '@models/flow'; import { Authorization } from '@services/authorization/authorization'; import { ConfirmDialogService } from '@services/dialogs/confirm-dialog'; import { FlowsService } from '@services/flows/flows'; -import { tap, throwError } from 'rxjs'; +import { catchError, of, switchMap, take, tap, throwError } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class EditorStateHolder { static readonly ASSISTANT_DRAFT_PREFIX = 'assistant-draft:'; + private lastValidationFetchKey: string | null = null; /** Stato */ readonly currentFlow = signal(null); readonly isDirty = signal(false); readonly selectedBlockIds = signal([]); readonly draggingSelectedBlockIds = signal([]); + readonly flowValidationErrors = signal([]); + readonly highlightedValidationNodeIds = signal([]); + readonly validationRequiresSave = signal(false); /** Derived state */ readonly hasFlow = computed(() => !!this.currentFlow()); @@ -44,6 +48,9 @@ export class EditorStateHolder { this.currentFlow.set(doc); this.isDirty.set(false); + this.validationRequiresSave.set(false); + this.applyFlowValidationErrors(doc.validationErrors ?? []); + this.ensureValidationForFlow(doc); this.clearBlockSelection(); return true; } @@ -62,6 +69,9 @@ export class EditorStateHolder { closeDocument() { this.currentFlow.set(null); this.isDirty.set(false); + this.validationRequiresSave.set(false); + this.applyFlowValidationErrors([]); + this.lastValidationFetchKey = null; this.clearBlockSelection(); } @@ -69,10 +79,13 @@ export class EditorStateHolder { if (this.isCurrentFlowReadOnly()) return; this.currentFlow.set(flow); this.isDirty.set(options?.markDirty === true); + this.validationRequiresSave.set(options?.markDirty === true); + this.applyFlowValidationErrors(flow.validationErrors ?? []); + this.ensureValidationForFlow(flow); this.clearBlockSelection(); } - updateData(data: FlowData) { + updateData(data: FlowData, options?: { structural?: boolean }) { if (this.isCurrentFlowReadOnly()) return; const current = this.currentFlow(); if (!current) return; @@ -81,6 +94,9 @@ export class EditorStateHolder { const nextFlow = { ...current, data }; this.currentFlow.set(nextFlow); this.markDirty(); + if (options?.structural !== false) { + this.validationRequiresSave.set(true); + } } replaceDataWithoutDirty(data: FlowData) { @@ -141,14 +157,96 @@ export class EditorStateHolder { : this.flowsService.updateFlow(flow); return save$.pipe( - tap((savedFlow) => { - this.currentFlow.set(savedFlow); - this.markSaved(); + switchMap((savedFlow) => { + const validation$ = savedFlow.status !== 'EXECUTABLE' + ? this.flowsService.getFlowValidation(savedFlow.id) + : of([]); + + return validation$.pipe( + tap((validationErrors) => { + const nextFlow = { + ...savedFlow, + validationErrors + }; + this.currentFlow.set(nextFlow); + this.lastValidationFetchKey = this.validationFetchKey(nextFlow); + this.applyFlowValidationErrors(validationErrors); + this.markSaved(); + this.validationRequiresSave.set(false); + }), + switchMap(() => of({ + ...savedFlow, + validationErrors: this.flowValidationErrors() + })) + ); + }), + catchError((error) => { + this.applyFlowValidationErrors(this.extractValidationErrors(error)); + return throwError(() => error); }) ) } + setHighlightedValidationNodes(nodeIds: string[]) { + const unique = Array.from(new Set((nodeIds ?? []).filter((id) => typeof id === 'string' && id.length > 0))); + this.highlightedValidationNodeIds.set(unique); + } + + isValidationNodeHighlighted(blockId: string | null | undefined): boolean { + if (!blockId) return false; + return this.highlightedValidationNodeIds().includes(blockId); + } + private areFlowDataEqual(left: FlowData, right: FlowData): boolean { return JSON.stringify(left) === JSON.stringify(right); } + + private applyFlowValidationErrors(errors: FlowValidationError[]) { + const normalized = Array.isArray(errors) ? errors : []; + this.flowValidationErrors.set(normalized); + this.highlightedValidationNodeIds.set(Array.from(new Set( + normalized.flatMap((error) => Array.isArray(error.relatedNodeIds) ? error.relatedNodeIds : []) + ))); + } + + private extractValidationErrors(error: unknown): FlowValidationError[] { + const candidate = (error as any)?.error?.errors + ?? (error as any)?.errors + ?? (error as any)?.error?.validationErrors + ?? (error as any)?.validationErrors + ?? []; + + return normalizeFlowValidationErrors(candidate); + } + + private ensureValidationForFlow(flow: Flow | null) { + if (!flow) return; + if (flow.status === 'EXECUTABLE') { + this.lastValidationFetchKey = this.validationFetchKey(flow); + return; + } + + const fetchKey = this.validationFetchKey(flow); + if (this.lastValidationFetchKey === fetchKey) return; + this.lastValidationFetchKey = fetchKey; + + this.flowsService.getFlowValidation(flow.id).pipe(take(1)).subscribe({ + next: (validationErrors) => { + const current = this.currentFlow(); + if (!current || current.id !== flow.id) return; + this.currentFlow.set({ + ...current, + validationErrors + }); + this.applyFlowValidationErrors(validationErrors); + }, + error: (error) => { + console.error('Retrieve flow validation failed', error); + } + }); + } + + private validationFetchKey(flow: Flow): string { + return `${flow.id}:${flow.status}:${flow.updatedAt?.toISOString?.() ?? ''}`; + } } diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 9457a69..402d7cc 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -11,6 +11,9 @@ import { areFlowValueKindsCompatible, FlowBlock, FlowData, + FLOW_DEPENDANT_PORT_KEY, + FLOW_DEPENDENCY_PORT_KEY, + FLOW_DEPENDENCY_SOCKET_TYPE, FlowNode, normalizeFlowPortValueKinds } from "@models/flow"; @@ -21,6 +24,7 @@ import { ContainerNodeComponent } from "@shared/nodes/container-node/container-n 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 { CustomConnectionComponent } from "@shared/custom-connection/custom-connection"; import { firstValueFrom } from "rxjs"; type AreaExtra = AngularArea2D; @@ -33,6 +37,8 @@ export type ReteEditorInstance = { area: AreaPlugin; }; +type GraphConnectionKind = "data" | "dependency"; + type ReteRuntimeContext = { blocksService: BlocksService; containersService: ContainersService; @@ -71,6 +77,9 @@ export async function createEditor( const nodeFamily = context?.payload?.data?.nodeFamily; return nodeFamily === "container" ? ContainerNodeComponent : GenericNodeComponent; }, + connection() { + return CustomConnectionComponent; + }, socket(context: any) { // rete-angular passes only `payload` to the socket component. // Build a per-render payload copy to avoid mutating shared socket objects. @@ -88,6 +97,12 @@ export async function createEditor( editor.addPipe((context) => { if (context.type !== "connectioncreate") return context; + const connectionKind = getGraphConnectionKind(context.data.sourceOutput, context.data.targetInput); + if (connectionKind === "dependency") { + if (context.data.source === context.data.target) return; + return context; + } + const sourceNode = editor.getNode(context.data.source) as HFNode | undefined; const targetNode = editor.getNode(context.data.target) as HFNode | undefined; const sourcePort = resolveNodePort(sourceNode, "output", context.data.sourceOutput); @@ -150,18 +165,24 @@ export function exportGraph(editor: NodeEditor) { }; }); - const connections = editor.getConnections().map((c) => ({ + const allConnections = editor.getConnections().map((c) => ({ id: String(c.id), sourceId: nodeIdToBlockId.get(c.source) ?? c.source, sourceName: c.sourceOutput, targetId: nodeIdToBlockId.get(c.target) ?? c.target, - targetName: c.targetInput + targetName: c.targetInput, + kind: getGraphConnectionKind(c.sourceOutput, c.targetInput) })); return { blocks: nodes.filter((node): node is FlowBlock => node.nodeFamily === 'block'), containers: nodes.filter((node) => node.nodeFamily === 'container'), - connections + connections: allConnections + .filter((connection) => connection.kind === 'data') + .map(({ kind, ...connection }) => connection), + dependencies: allConnections + .filter((connection) => connection.kind === 'dependency') + .map(({ sourceId, targetId }) => ({ sourceId, targetId })) }; } @@ -255,8 +276,9 @@ export async function addBlockToEditor( } } - const nextConfiguration = { - ...cloneValue(currentLiveNode.data.specificConfiguration ?? {}), + const currentConfiguration = cloneValue(currentLiveNode.data.specificConfiguration ?? {}) as Record; + const nextConfiguration: Record = { + name: String(currentConfiguration['name'] ?? currentLiveNode.data['name'] ?? 'Container'), subFlow: candidateSubFlow }; const nextPosition = cloneValue(currentLiveNode.data['position'] ?? null); @@ -362,6 +384,11 @@ export async function addBlockToEditor( currentFlow.connections.filter((connection) => selectedIds.has(connection.sourceId) && selectedIds.has(connection.targetId) ) + ), + dependencies: cloneValue( + (currentFlow.dependencies ?? []).filter((dependency) => + selectedIds.has(dependency.sourceId) && selectedIds.has(dependency.targetId) + ) ) }; await applyContainerSubflow(candidateSubFlow, { selectedIds, preValidate: true }); @@ -475,6 +502,9 @@ export async function addBlockToEditor( __containerAssigning: false }; + node.addOutput(FLOW_DEPENDANT_PORT_KEY, new ClassicPreset.Output(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE))); + node.addInput(FLOW_DEPENDENCY_PORT_KEY, new ClassicPreset.Input(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE), undefined, true)); + for (const output of block.outputs ?? []) { node.addOutput(output.name, new ClassicPreset.Output(getSocket(editor, output.type ?? "ANY"))); } @@ -527,6 +557,17 @@ async function loadFlowData( new ClassicPreset.Connection(sourceNode, c.sourceName, targetNode, c.targetName) ); } + + for (const dependency of flowData.dependencies ?? []) { + if (!nodeMapping.has(dependency.sourceId) || !nodeMapping.has(dependency.targetId)) continue; + + const sourceNode = editor.getNode(nodeMapping.get(dependency.sourceId)) as any; + const targetNode = editor.getNode(nodeMapping.get(dependency.targetId)) as any; + + await editor.addConnection( + new ClassicPreset.Connection(sourceNode, FLOW_DEPENDANT_PORT_KEY, targetNode, FLOW_DEPENDENCY_PORT_KEY) + ); + } } function getSocket(editor: NodeEditor, type: string) { @@ -535,7 +576,9 @@ function getSocket(editor: NodeEditor, type: string) { } const map = editorSockets.get(editor)!; if (!map.has(type)) { - map.set(type, new ClassicPreset.Socket(type)); + const socket = new ClassicPreset.Socket(type) as ClassicPreset.Socket & { __hfKind?: GraphConnectionKind }; + socket.__hfKind = type === FLOW_DEPENDENCY_SOCKET_TYPE ? 'dependency' : 'data'; + map.set(type, socket); } return map.get(type)!; } @@ -570,3 +613,9 @@ function cloneValue(value: T): T { } return JSON.parse(JSON.stringify(value)) as T; } + +function getGraphConnectionKind(sourceOutput: string, targetInput: string): GraphConnectionKind { + return sourceOutput === FLOW_DEPENDANT_PORT_KEY && targetInput === FLOW_DEPENDENCY_PORT_KEY + ? 'dependency' + : 'data'; +}