;
+ @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 @@
+
+
+
+
+ {{ 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) {
{{ starter }}
@@ -142,7 +166,7 @@
[disabled]="!selectedModel() || assistantBusy()"
name="assistantPrompt"
rows="4"
- placeholder="Ask the assistant to create or refine a workflow">
+ [placeholder]="promptPlaceholder()">
!!this.currentFlow());
+ readonly sessionHasDraft = computed(() => !!this.currentDraft());
+ readonly canOfferCreate = computed(() => !this.editorHasOpenFlow() && !this.sessionHasDraft());
+ readonly canOfferFix = computed(() => !this.canOfferCreate() && (this.sessionState()?.lastValidationErrors?.length ?? 0) > 0);
+ readonly assistantModeLabel = computed(() => this.canOfferCreate() ? 'Create with assistant' : 'Refine with assistant');
+ readonly assistantModeDescription = computed(() => {
+ if (this.canOfferCreate()) {
+ return 'No flow is open, so the assistant is in create mode and can draft a new workflow.';
+ }
+ if (this.canOfferFix()) {
+ return 'A flow is already attached, so use the assistant to refine, fix, or explain it.';
+ }
+ return 'A flow is already open or attached to this session, so create mode is not offered here.';
+ });
+ readonly promptPlaceholder = computed(() =>
+ this.canOfferCreate()
+ ? 'Ask the assistant to create a new workflow'
+ : 'Ask the assistant to refine, fix, or explain the current workflow'
+ );
+ readonly starterPrompts = computed(() => {
+ if (this.canOfferCreate()) {
+ return [
+ 'Create a flow that classifies incoming tickets and sends urgent ones to a human',
+ 'Create a flow that downloads a file, indexes it, and then queries it',
+ 'Create a recruiter workflow that reviews a CV and produces a final assessment'
+ ];
+ }
- readonly initialSystemMessage: AssistantChatMessage = {
- id: 'assistant-system-welcome',
- role: 'system',
- content: 'Select a model, then ask me to create, refine, fix, or explain a workflow.'
- };
+ const prompts = [
+ 'Modify the current flow to add a review step after the LLM block',
+ 'Explain this flow and describe what each branch does'
+ ];
+
+ if (this.canOfferFix()) {
+ prompts.unshift('Fix the problems in this flow');
+ }
+
+ return prompts;
+ });
readonly displayedMessages = computed(() => {
const baseMessages = this.sessionState()?.messages?.length
? this.sessionState()!.messages
- : [this.initialSystemMessage];
+ : [this.systemWelcomeMessage()];
return [...baseMessages, ...this.localMessages()];
});
readonly assistantBusy = computed(() => {
@@ -101,12 +134,6 @@ export class FlowAssistant implements OnInit, OnDestroy {
});
readonly currentFlow = this.editorState.currentFlow;
readonly currentDraft = computed(() => this.sessionState()?.currentDraftFlow ?? null);
- readonly starterPrompts = [
- 'Create a flow that classifies incoming tickets and sends urgent ones to a human',
- 'Modify the current flow to add a review step after the LLM block',
- 'Fix the problems in this flow',
- 'Explain why this flow is not valid'
- ];
ngOnInit(): void {
this.bootstrapAssistant();
@@ -279,7 +306,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
? session
: {
...session,
- messages: [this.initialSystemMessage]
+ messages: [this.systemWelcomeMessage()]
};
this.sessionState.set(normalizedSession);
@@ -338,6 +365,16 @@ export class FlowAssistant implements OnInit, OnDestroy {
]);
}
+ private systemWelcomeMessage(): AssistantChatMessage {
+ return {
+ id: 'assistant-system-welcome',
+ role: 'system',
+ content: this.canOfferCreate()
+ ? 'Select a model, then ask me to create a new workflow.'
+ : 'Select a model, then ask me to refine, fix, or explain the current workflow.'
+ };
+ }
+
private stopPolling() {
if (this.pollTick) {
clearInterval(this.pollTick);
diff --git a/src/app/shared/flow-validation-panel/flow-validation-panel.css b/src/app/shared/flow-validation-panel/flow-validation-panel.css
new file mode 100644
index 0000000..6c6ef4b
--- /dev/null
+++ b/src/app/shared/flow-validation-panel/flow-validation-panel.css
@@ -0,0 +1,123 @@
+.validation-shell {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ background:
+ linear-gradient(180deg, #fff 0%, #fff7f7 100%);
+ color: #0f172a;
+}
+
+.validation-scroll {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ padding: 20px 18px 18px;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.validation-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.validation-eyebrow {
+ margin: 0 0 4px;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: #b91c1c;
+}
+
+.validation-title {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 800;
+}
+
+.validation-card {
+ border: 1px solid rgba(248, 113, 113, 0.28);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.92);
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
+}
+
+.validation-actions {
+ display: flex;
+ gap: 8px;
+ padding: 12px;
+}
+
+.validation-stale {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 16px;
+ border-color: rgba(251, 191, 36, 0.38);
+ background: linear-gradient(180deg, rgba(255, 251, 235, 0.98), rgba(255, 247, 237, 0.98));
+ color: #92400e;
+}
+
+.validation-stale mat-icon {
+ color: #d97706;
+}
+
+.validation-stale p {
+ margin: 0;
+ line-height: 1.4;
+ font-weight: 600;
+}
+
+.validation-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.validation-item {
+ padding: 14px;
+ border: 1px solid rgba(248, 113, 113, 0.28);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.94);
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
+}
+
+.validation-item-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.validation-code {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: #fee2e2;
+ color: #991b1b;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+}
+
+.validation-message {
+ margin: 0;
+ line-height: 1.45;
+ color: #334155;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.validation-empty {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 16px;
+ color: #0f766e;
+}
diff --git a/src/app/shared/flow-validation-panel/flow-validation-panel.html b/src/app/shared/flow-validation-panel/flow-validation-panel.html
new file mode 100644
index 0000000..efcfd88
--- /dev/null
+++ b/src/app/shared/flow-validation-panel/flow-validation-panel.html
@@ -0,0 +1,45 @@
+
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) {
+
+ }
+
+
+
+
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) {
+
+ }
+
+
+
+
Dependant
+ @if (dependantOutput; as dependantOutput) {
+
+ }
+
+
+ }
+
@if (!schemaReady) {
diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts
index 007f00a..04c7d4a 100644
--- a/src/app/shared/nodes/generic-node/generic-node.ts
+++ b/src/app/shared/nodes/generic-node/generic-node.ts
@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
-import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, normalizeFlowPortValueKinds } from '@models/flow';
+import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import {
@@ -131,6 +131,11 @@ type RichContentView = {
parts: { text: string; isDynamicInput: boolean }[];
};
+type RenderedSocketPort = {
+ key: string;
+ socket: ClassicPreset.Socket;
+};
+
@Component({
selector: 'app-generic-node',
imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule],
@@ -156,6 +161,10 @@ export class GenericNodeComponent {
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;
}
@@ -166,6 +175,8 @@ export class GenericNodeComponent {
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
+ dependantOutput: RenderedSocketPort | null = null;
+ dependencyInput: RenderedSocketPort | null = null;
parameterFields: EditableFieldView[] = [];
parameterFieldGroups: EditableFieldGroupView[] = [];
richContentFields: RichContentView[] = [];
@@ -207,11 +218,21 @@ export class GenericNodeComponent {
this.arrayFields = [];
Object.entries(this.data.outputs).forEach(([key, output]) => {
- this.outputs.push({ key, socket: (output as any).socket });
+ const entry = { key, socket: (output as any).socket };
+ if (key === FLOW_DEPENDANT_PORT_KEY) {
+ this.dependantOutput = entry;
+ return;
+ }
+ this.outputs.push(entry);
});
Object.entries(this.data.inputs).forEach(([key, input]) => {
- this.inputs.push({ key, socket: (input as any).socket });
+ const entry = { key, socket: (input as any).socket };
+ if (key === FLOW_DEPENDENCY_PORT_KEY) {
+ this.dependencyInput = entry;
+ return;
+ }
+ this.inputs.push(entry);
});
const config = this.ensureBlockConfiguration();
@@ -433,6 +454,10 @@ export class GenericNodeComponent {
return null;
}
+ hasExecutionDependencyPorts(): boolean {
+ return !!this.dependencyInput || !!this.dependantOutput;
+ }
+
inputDisplayLabel(inputKey: string): string {
return this.portDisplayLabel('input', inputKey);
}
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css
index d7020f3..ef90127 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.css
+++ b/src/app/shared/nodes/task-step-node/task-step-node.css
@@ -407,6 +407,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-input-value-wrap {
position: relative;
display: flex;
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html
index d06b89f..7e54f34 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.html
+++ b/src/app/shared/nodes/task-step-node/task-step-node.html
@@ -161,6 +161,54 @@
+ @if (hasExecutionDependencyPorts()) {
+
+
+
Dependency
+ @if (hasConnectedDependencyInput() && dependencyInput; as dependencyInput) {
+
+ }
+
+
+
+
Dependant
+ @if (hasConnectedDependantOutput() && dependantOutput; as dependantOutput) {
+
+ }
+
+
+ }
+
@if (!schemaReady) {
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts
index fab2f2f..694669c 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.ts
+++ b/src/app/shared/nodes/task-step-node/task-step-node.ts
@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
-import { BlockInteractionContract, BlockType, FlowBlock, FlowContainer, FlowData, FlowPort } from '@models/flow';
+import { BlockInteractionContract, BlockType, FlowBlock, FlowContainer, FlowData, FlowPort, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
@@ -113,6 +113,8 @@ export class TaskStepNodeComponent {
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
+ dependantOutput: { key: string; socket: ClassicPreset.Socket } | null = null;
+ dependencyInput: { key: string; socket: ClassicPreset.Socket } | null = null;
parameterFields: DisplayField[] = [];
parameterFieldGroups: DisplayFieldGroup[] = [];
arrayFields: ArrayFieldView[] = [];
@@ -134,11 +136,21 @@ export class TaskStepNodeComponent {
this.arrayFields = [];
Object.entries(this.data.outputs).forEach(([key, output]) => {
- this.outputs.push({ key, socket: (output as any).socket });
+ const entry = { key, socket: (output as any).socket };
+ if (key === FLOW_DEPENDANT_PORT_KEY) {
+ this.dependantOutput = entry;
+ return;
+ }
+ this.outputs.push(entry);
});
Object.entries(this.data.inputs).forEach(([key, input]) => {
- this.inputs.push({ key, socket: (input as any).socket });
+ const entry = { key, socket: (input as any).socket };
+ if (key === FLOW_DEPENDENCY_PORT_KEY) {
+ this.dependencyInput = entry;
+ return;
+ }
+ this.inputs.push(entry);
});
this.rebuildDisplayState();
@@ -293,7 +305,24 @@ export class TaskStepNodeComponent {
hasViewableSubflow(): boolean {
const subFlow = this.subFlow();
- return !!subFlow && ((subFlow.blocks?.length ?? 0) > 0 || (subFlow.containers?.length ?? 0) > 0 || (subFlow.connections?.length ?? 0) > 0);
+ return !!subFlow && (
+ (subFlow.blocks?.length ?? 0) > 0 ||
+ (subFlow.containers?.length ?? 0) > 0 ||
+ (subFlow.connections?.length ?? 0) > 0 ||
+ (subFlow.dependencies?.length ?? 0) > 0
+ );
+ }
+
+ hasExecutionDependencyPorts(): boolean {
+ return this.hasConnectedDependencyInput() || this.hasConnectedDependantOutput();
+ }
+
+ hasConnectedDependencyInput(): boolean {
+ return !!this.dependencyInput && this.blockConfiguration?.['__hasDependencyInputConnection'] === true;
+ }
+
+ hasConnectedDependantOutput(): boolean {
+ return !!this.dependantOutput && this.blockConfiguration?.['__hasDependantOutputConnection'] === true;
}
formatDynamicInputToken(token: string): string {
@@ -819,9 +848,12 @@ export class TaskStepNodeComponent {
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) return null;
- return { blocks, containers, connections };
+ if (!blocks.length && !containers.length && !connections.length && !dependencies.length) return null;
+ return { blocks, containers, connections, dependencies };
}
private normalizeSubFlowBlocks(raw: unknown): FlowBlock[] {
diff --git a/src/app/shared/rete-editor/rete-editor.html b/src/app/shared/rete-editor/rete-editor.html
index 9b09975..05a61ad 100644
--- a/src/app/shared/rete-editor/rete-editor.html
+++ b/src/app/shared/rete-editor/rete-editor.html
@@ -2,6 +2,7 @@
class="rete-editor-shell"
#shell
(pointerdown)="onShellPointerDown($event)"
+ (click)="onShellClick($event)"
(pointermove)="onShellPointerMove($event)"
(pointerup)="onShellPointerUp($event)"
(pointercancel)="onShellPointerUp($event)">
diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts
index 9e36299..3e1f061 100644
--- a/src/app/shared/rete-editor/rete-editor.ts
+++ b/src/app/shared/rete-editor/rete-editor.ts
@@ -1,10 +1,11 @@
-import { Component, ElementRef, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, ViewChild } from '@angular/core';
+import { Component, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, untracked, ViewChild } from '@angular/core';
import { BlockType, FlowData, FlowNode } from '@models/flow';
import { Drag } from 'rete-area-plugin';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
import { CONTAINER_SUBFLOW_DRAG_MIME } from '@shared/nodes/container-node/container-node-drag';
+import { GraphSelectionService } from '@services/graph-selection/graph-selection';
import { EditorStateHolder } from '@stores/flow-editor';
import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance } from '@utilities/rete-editor';
import { firstValueFrom } from 'rxjs';
@@ -25,8 +26,18 @@ export class ReteEditor implements OnChanges, OnDestroy {
private injector: Injector,
private flowState: EditorStateHolder,
private blocksService: BlocksService,
- private containersService: ContainersService
- ) {}
+ private containersService: ContainersService,
+ private graphSelection: GraphSelectionService
+ ) {
+ effect(() => {
+ this.graphSelection.deleteConnectionRequestTick();
+ const connectionId = untracked(() => this.graphSelection.selectedConnectionId());
+ const rete = untracked(() => this.rete);
+ const isReadonly = untracked(() => this.readonly());
+ if (!connectionId || !rete || isReadonly) return;
+ void this.deleteSelectedConnection(connectionId);
+ });
+ }
@ViewChild("editor") container!: ElementRef;
@ViewChild("shell") shell!: ElementRef
;
@@ -72,6 +83,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
ngOnDestroy(): void {
this.rete?.area.destroy();
this.rete = undefined;
+ this.graphSelection.clearConnectionSelection();
this.flowState.stopDraggingSelectedBlocks();
}
@@ -135,6 +147,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
onShellPointerDown(event: PointerEvent) {
if (this.readonly()) return;
+ this.graphSelection.clearConnectionSelection();
if (event.button !== 0) return;
if (!this.canStartSelection(event.target)) return;
@@ -220,6 +233,46 @@ export class ReteEditor implements OnChanges, OnDestroy {
this.flowState.stopDraggingSelectedBlocks();
}
+ onShellClick(event: MouseEvent) {
+ const target = event.target as Element | null;
+ if (!target) {
+ this.graphSelection.clearConnectionSelection();
+ return;
+ }
+
+ if (
+ target.closest('[data-testid="connection"]') ||
+ target.closest('.connection-delete') ||
+ target.closest('[data-testid="node"]') ||
+ target.closest('.rete-editor-toolbar') ||
+ target.closest('.rete-editor-selection-badge')
+ ) {
+ return;
+ }
+
+ this.graphSelection.clearConnectionSelection();
+ }
+
+ @HostListener('window:pointerdown', ['$event'])
+ onWindowPointerDown(event: PointerEvent) {
+ const target = event.target as Element | null;
+ if (!target) return;
+ if (target.closest('[data-testid="connection"]') || target.closest('.connection-delete')) return;
+ this.graphSelection.clearConnectionSelection();
+ }
+
+ @HostListener('window:keydown', ['$event'])
+ onWindowKeydown(event: KeyboardEvent) {
+ if (this.readonly()) return;
+ if (event.key !== 'Delete' && event.key !== 'Backspace') return;
+
+ const connectionId = this.graphSelection.selectedConnectionId();
+ if (!connectionId || !this.rete) return;
+
+ event.preventDefault();
+ void this.deleteSelectedConnection(connectionId);
+ }
+
private async reloadEditor() {
const host = this.container?.nativeElement as HTMLElement | undefined;
if (!host) return;
@@ -232,6 +285,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
this.selectionStart = null;
this.selectionBox.set(null);
this.flowState.clearBlockSelection();
+ this.graphSelection.clearConnectionSelection();
this.suppressDirtyEvents = true;
this.rete?.area.destroy();
this.rete = undefined;
@@ -276,6 +330,22 @@ export class ReteEditor implements OnChanges, OnDestroy {
});
}
+ private async deleteSelectedConnection(connectionId: string) {
+ if (!this.rete) return;
+
+ const currentConnection = this.rete.editor.getConnections().find((connection) => String(connection.id) === connectionId);
+ if (!currentConnection) {
+ this.graphSelection.clearConnectionSelection();
+ return;
+ }
+
+ await this.rete.editor.removeConnection(currentConnection.id);
+ const updatedData = exportGraph(this.rete.editor);
+ this.flowState.updateData(updatedData, { structural: true });
+ this.flowChanged.emit(updatedData);
+ this.graphSelection.clearConnectionSelection();
+ }
+
private async syncReadonlyFlowData() {
if (!this.readonly()) return;
const rete = this.rete;
@@ -342,7 +412,17 @@ export class ReteEditor implements OnChanges, OnDestroy {
.sort();
if (currentConnections.length !== nextConnections.length) return false;
- return currentConnections.every((connection, index) => connection === nextConnections[index]);
+ if (!currentConnections.every((connection, index) => connection === nextConnections[index])) return false;
+
+ const currentDependencies = [...(currentFlowData.dependencies ?? [])]
+ .map((dependency) => `${dependency.sourceId}->${dependency.targetId}`)
+ .sort();
+ const nextDependencies = [...(nextFlowData.dependencies ?? [])]
+ .map((dependency) => `${dependency.sourceId}->${dependency.targetId}`)
+ .sort();
+
+ if (currentDependencies.length !== nextDependencies.length) return false;
+ return currentDependencies.every((dependency, index) => dependency === nextDependencies[index]);
}
private async patchReadonlyNodes(rete: ReteEditorInstance, nextFlowData: FlowData) {
@@ -434,7 +514,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
if (this.flowState.currentFlow()?.id !== loadedFlowId) return;
const updatedData = exportGraph(rete.editor);
- this.flowState.updateData(updatedData);
+ this.flowState.updateData(updatedData, { structural: context?.type !== 'nodetranslated' });
this.flowChanged.emit(updatedData);
}
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html
index 5e795d8..c4450e9 100644
--- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html
+++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html
@@ -122,18 +122,40 @@
(click)="addTextItem(executionInput)">
Add item
+
+
+
+ Save
+
+
} @else {
-
- {{ executionInput.subtitle }}
-
-
+
+
+ {{ executionInput.subtitle }}
+
+
+
+
+
+ Save
+
+
+
}
}
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';
+}