Add dependency flow validation and execution input UX updates
This commit is contained in:
parent
a904bd7951
commit
27f9bc1285
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -34,24 +34,42 @@
|
|||
|
||||
@if (assistantEnabled && !readonly()) {
|
||||
<section class="flow-editor-assistant" data-tour="editor-assistant" [class.flow-editor-assistant-collapsed]="!assistantOpen()">
|
||||
<div class="flow-editor-side-rail">
|
||||
<button
|
||||
type="button"
|
||||
class="flow-editor-side-rail-button"
|
||||
[class.flow-editor-side-rail-button-active]="activeRightPanel() === 'assistant' && assistantOpen()"
|
||||
matTooltip="Assistant"
|
||||
(click)="setRightPanel('assistant')">
|
||||
<mat-icon fontIcon="auto_awesome"></mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flow-editor-side-rail-button"
|
||||
[class.flow-editor-side-rail-button-active]="activeRightPanel() === 'errors' && assistantOpen()"
|
||||
[disabled]="!flow() || validationErrorCount() === 0"
|
||||
matTooltip="Validation errors"
|
||||
(click)="setRightPanel('errors')">
|
||||
<mat-icon fontIcon="error"></mat-icon>
|
||||
@if (validationErrorCount() > 0) {
|
||||
<span class="flow-editor-side-rail-badge">{{ validationErrorCount() }}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@if (assistantOpen()) {
|
||||
<button
|
||||
type="button"
|
||||
class="flow-editor-assistant-toggle"
|
||||
mat-stroked-button
|
||||
(click)="toggleAssistant()"
|
||||
[attr.aria-label]="assistantOpen() ? 'Collapse assistant' : 'Expand assistant'">
|
||||
<mat-icon [fontIcon]="assistantOpen() ? 'right_panel_close' : 'auto_awesome'"></mat-icon>
|
||||
<span>{{ assistantOpen() ? 'Hide' : 'AI' }}</span>
|
||||
aria-label="Collapse assistant">
|
||||
<mat-icon fontIcon="keyboard_arrow_right"></mat-icon>
|
||||
</button>
|
||||
|
||||
@if (assistantOpen()) {
|
||||
<app-flow-assistant></app-flow-assistant>
|
||||
} @else {
|
||||
<div class="flow-editor-assistant-rail">
|
||||
<mat-icon class="flow-editor-assistant-rail-icon" fontIcon="auto_awesome" aria-hidden="true"></mat-icon>
|
||||
<div class="flow-editor-assistant-rail-text">AI</div>
|
||||
<mat-icon class="flow-editor-assistant-rail-wand" fontIcon="magic_button" aria-hidden="true"></mat-icon>
|
||||
</div>
|
||||
@if (activeRightPanel() === 'assistant') {
|
||||
<app-flow-assistant></app-flow-assistant>
|
||||
} @else {
|
||||
<app-flow-validation-panel></app-flow-validation-panel>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>>({});
|
||||
|
|
@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> => !!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
|
||||
|
|
|
|||
|
|
@ -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<string, TaskExecutionAuthorizationRequirement>;
|
||||
providedAuthorizations?: Record<string, unknown>;
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -322,7 +322,8 @@ function buildTicketFlow(model: string): FlowData {
|
|||
targetId: 'assistant-output',
|
||||
targetName: 'input'
|
||||
}
|
||||
]
|
||||
],
|
||||
dependencies: []
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]) : []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ export class ContainersCallServiceFake extends ContainersCallServiceBase {
|
|||
subFlow: {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: []
|
||||
connections: [],
|
||||
dependencies: []
|
||||
}
|
||||
},
|
||||
typeName: descriptor?.type ?? containerType,
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Flow>;
|
||||
|
||||
abstract getFlowValidation(flowId: string) : Observable<FlowValidationError[]>;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Flow> = {
|
||||
'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<FlowValidationError[]> {
|
||||
return of(this.requireFlow(flowId).validationErrors ?? []);
|
||||
}
|
||||
}
|
||||
const testDataFlow ={
|
||||
"id": "testFlow",
|
||||
|
|
|
|||
|
|
@ -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<unknown>(`${environment.apiUrl}/flows/${encodedId}/finalized`, { value: true })
|
||||
.pipe(map((raw) => flowFromApi(raw)));
|
||||
}
|
||||
|
||||
override getFlowValidation(flowId: string) {
|
||||
const encodedId = encodeURIComponent(flowId);
|
||||
return this.http
|
||||
.get<unknown>(`${environment.apiUrl}/flows/${encodedId}/validation`)
|
||||
.pipe(map((raw) => normalizeFlowValidationErrors((raw as any)?.errors ?? raw)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Flow, 'name' | 'description' | 'data' | 'status'>): Observable<Flow> {
|
||||
return this.createFlow({
|
||||
name: `${flow.name} (cloned)`,
|
||||
|
|
@ -150,7 +159,8 @@ export class FlowsService {
|
|||
data: {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: []
|
||||
connections: [],
|
||||
dependencies: []
|
||||
},
|
||||
status: 'DRAFT'
|
||||
}).pipe(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GraphSelectionService {
|
||||
readonly selectedConnectionId = signal<string | null>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: `
|
||||
<svg data-testid="connection" [attr.data-connection-id]="connectionId">
|
||||
<path
|
||||
[attr.d]="path"
|
||||
[attr.stroke]="strokeColor"
|
||||
[attr.stroke-width]="strokeWidth"
|
||||
[attr.stroke-dasharray]="strokeDasharray"
|
||||
[attr.opacity]="opacity"
|
||||
(pointerdown)="selectConnection($event)"
|
||||
(click)="selectConnection($event)">
|
||||
</path>
|
||||
@if (isSelected) {
|
||||
<foreignObject
|
||||
class="connection-delete-wrap"
|
||||
[attr.x]="deleteButtonX"
|
||||
[attr.y]="deleteButtonY"
|
||||
width="28"
|
||||
height="28">
|
||||
<button
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
type="button"
|
||||
class="connection-delete"
|
||||
title="Delete connection"
|
||||
(pointerdown)="deleteConnection($event)"
|
||||
(click)="deleteConnection($event)">
|
||||
<span>×</span>
|
||||
</button>
|
||||
</foreignObject>
|
||||
}
|
||||
</svg>
|
||||
`,
|
||||
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<ClassicPreset.Node, ClassicPreset.Node>;
|
||||
@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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,30 @@
|
|||
<header class="assistant-header">
|
||||
<div>
|
||||
<p class="assistant-eyebrow">Workflow Copilot</p>
|
||||
<h2 class="assistant-title">Assistant</h2>
|
||||
<h2 class="assistant-title">{{ assistantModeLabel() }}</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="assistant-card assistant-model-card">
|
||||
<div class="assistant-section-head">
|
||||
<div>
|
||||
<p class="assistant-copy">
|
||||
{{ assistantModeDescription() }}
|
||||
</p>
|
||||
<p class="assistant-label">Mode</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="assistant-meta">
|
||||
@if (canOfferCreate()) {
|
||||
Create mode
|
||||
} @else if (canOfferFix()) {
|
||||
Refine / Fix / Explain mode
|
||||
} @else {
|
||||
Refine / Explain mode
|
||||
}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@if (activePhaseLabel()) {
|
||||
<div class="assistant-intent-row">
|
||||
<span class="assistant-intent">{{ activePhaseLabel() }}</span>
|
||||
|
|
@ -38,7 +58,11 @@
|
|||
<div class="assistant-section-head">
|
||||
<div>
|
||||
<p class="assistant-copy">
|
||||
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.
|
||||
}
|
||||
</p>
|
||||
<p class="assistant-label">Model</p>
|
||||
</div>
|
||||
|
|
@ -88,7 +112,7 @@
|
|||
</div>
|
||||
@if (quickPromptsOpen()) {
|
||||
<div class="assistant-chip-grid">
|
||||
@for (starter of starterPrompts; track starter) {
|
||||
@for (starter of starterPrompts(); track starter) {
|
||||
<button type="button" mat-stroked-button class="assistant-chip" (click)="useStarter(starter)">
|
||||
{{ starter }}
|
||||
</button>
|
||||
|
|
@ -142,7 +166,7 @@
|
|||
[disabled]="!selectedModel() || assistantBusy()"
|
||||
name="assistantPrompt"
|
||||
rows="4"
|
||||
placeholder="Ask the assistant to create or refine a workflow"></textarea>
|
||||
[placeholder]="promptPlaceholder()"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -44,17 +44,50 @@ export class FlowAssistant implements OnInit, OnDestroy {
|
|||
readonly selectedModel = signal('');
|
||||
readonly modelPickerOpen = signal(false);
|
||||
readonly quickPromptsOpen = signal(true);
|
||||
readonly editorHasOpenFlow = computed(() => !!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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<aside class="validation-shell">
|
||||
<div class="validation-scroll">
|
||||
<header class="validation-header">
|
||||
<div>
|
||||
<p class="validation-eyebrow">Flow Validation</p>
|
||||
<h2 class="validation-title">Errors</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (validationRequiresSave()) {
|
||||
<section class="validation-card validation-stale">
|
||||
<mat-icon fontIcon="schedule" aria-hidden="true"></mat-icon>
|
||||
<p>Validation will be recomputed after save.</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (hasErrors()) {
|
||||
<section class="validation-card validation-actions">
|
||||
<button type="button" mat-stroked-button (click)="highlightAll()">Highlight all</button>
|
||||
<button type="button" mat-stroked-button (click)="clearHighlight()">Clear highlight</button>
|
||||
</section>
|
||||
|
||||
<section class="validation-list">
|
||||
@for (error of errors(); track trackByError($index, error)) {
|
||||
<article class="validation-item">
|
||||
<div class="validation-item-head">
|
||||
<span class="validation-code">{{ error.code || 'VALIDATION_ERROR' }}</span>
|
||||
@if ((error.relatedNodeIds?.length ?? 0) > 0) {
|
||||
<button type="button" class="validation-focus" mat-stroked-button (click)="focusError(error)">
|
||||
Highlight
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<p class="validation-message">{{ error.message }}</p>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<section class="validation-card validation-empty">
|
||||
<mat-icon fontIcon="task_alt" aria-hidden="true"></mat-icon>
|
||||
<p>No validation errors for the current flow.</p>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -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([]);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,13 +22,13 @@
|
|||
<div class="flows-list-ordering">
|
||||
<app-ordering [orderFields]="orderFields" [orderView]="orderView" (orderChanged)="onOrderChanged($event)" ></app-ordering>
|
||||
</div>
|
||||
@if (filteredFlows().length === 0) {
|
||||
@if (orderedFlows().length === 0) {
|
||||
<div class="flows-list-empty">
|
||||
No flows found
|
||||
</div>
|
||||
} @else {
|
||||
<mat-list class="flows-list-items">
|
||||
@for (flow of filteredFlows(); track flow.id) {
|
||||
@for (flow of orderedFlows(); track flow.id) {
|
||||
<mat-list-item class="flows-list-list-item">
|
||||
<app-flow-item [(detailOpenedId)]="detailOpenedId" [flow]="flow"></app-flow-item>
|
||||
</mat-list-item>
|
||||
|
|
|
|||
|
|
@ -89,19 +89,27 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
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 };
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -100,6 +100,58 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if (hasExecutionDependencyPorts) {
|
||||
<div class="container-node__ports">
|
||||
<div class="container-node__port-column">
|
||||
<div class="container-node__port-title">Dependency</div>
|
||||
@if (dependencyInput; as dependencyInput) {
|
||||
<div class="container-node__port-row">
|
||||
<div
|
||||
refComponent
|
||||
class="container-node__socket"
|
||||
[data]="{
|
||||
type: 'socket',
|
||||
side: 'input',
|
||||
key: dependencyInput.key,
|
||||
nodeId: data.id,
|
||||
__readonly: isReadonly,
|
||||
payload: dependencyInput.socket
|
||||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
<span class="container-node__port-label container-node__port-label--input container-node__port-label--dependency-input">
|
||||
<span class="container-node__port-name">Depends on</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="container-node__port-column container-node__port-column--right">
|
||||
<div class="container-node__port-title">Dependant</div>
|
||||
@if (dependantOutput; as dependantOutput) {
|
||||
<div class="container-node__port-row container-node__port-row--right">
|
||||
<span class="container-node__port-label container-node__port-label--output container-node__port-label--dependency-output">
|
||||
<span class="container-node__port-name">Prerequisite of</span>
|
||||
</span>
|
||||
<div
|
||||
refComponent
|
||||
class="container-node__socket container-node__socket--right"
|
||||
[data]="{
|
||||
type: 'socket',
|
||||
side: 'output',
|
||||
key: dependantOutput.key,
|
||||
nodeId: data.id,
|
||||
__readonly: isReadonly,
|
||||
payload: dependantOutput.socket
|
||||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasParameterFields() || hasMainContent()) {
|
||||
<div class="container-node__params container-node__params-shell">
|
||||
@if (!schemaReady) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -158,6 +158,54 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if (hasExecutionDependencyPorts()) {
|
||||
<div class="llm-body llm-body-dependency">
|
||||
<div class="llm-column">
|
||||
<div class="llm-column-title">Dependency</div>
|
||||
@if (dependencyInput; as dependencyInput) {
|
||||
<div class="llm-row llm-row-input">
|
||||
<div
|
||||
refComponent
|
||||
class="llm-socket"
|
||||
[data]="{
|
||||
type: 'socket',
|
||||
side: 'input',
|
||||
key: dependencyInput.key,
|
||||
nodeId: data.id,
|
||||
__readonly: isReadonly,
|
||||
payload: dependencyInput.socket
|
||||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
<span class="llm-pill llm-pill-input llm-pill-dependency-input">Depends on</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="llm-column">
|
||||
<div class="llm-column-title llm-column-title-right">Dependant</div>
|
||||
@if (dependantOutput; as dependantOutput) {
|
||||
<div class="llm-row llm-row-output">
|
||||
<span class="llm-pill llm-pill-output llm-pill-dependency-output">Prerequisite of</span>
|
||||
<div
|
||||
refComponent
|
||||
class="llm-socket llm-socket-right"
|
||||
[data]="{
|
||||
type: 'socket',
|
||||
side: 'output',
|
||||
key: dependantOutput.key,
|
||||
nodeId: data.id,
|
||||
__readonly: isReadonly,
|
||||
payload: dependantOutput.socket
|
||||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="llm-params llm-params-shell">
|
||||
@if (!schemaReady) {
|
||||
<div class="llm-skeleton-overlay" aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -161,6 +161,54 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if (hasExecutionDependencyPorts()) {
|
||||
<div class="llm-body llm-body-dependency">
|
||||
<div class="llm-column">
|
||||
<div class="llm-column-title">Dependency</div>
|
||||
@if (hasConnectedDependencyInput() && dependencyInput; as dependencyInput) {
|
||||
<div class="llm-row llm-row-input">
|
||||
<div
|
||||
refComponent
|
||||
class="llm-socket"
|
||||
[data]="{
|
||||
type: 'socket',
|
||||
side: 'input',
|
||||
key: dependencyInput.key,
|
||||
nodeId: data.id,
|
||||
__readonly: true,
|
||||
payload: dependencyInput.socket
|
||||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
<span class="llm-pill llm-pill-input llm-pill-dependency-input">Depends on</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="llm-column">
|
||||
<div class="llm-column-title llm-column-title-right">Dependant</div>
|
||||
@if (hasConnectedDependantOutput() && dependantOutput; as dependantOutput) {
|
||||
<div class="llm-row llm-row-output">
|
||||
<span class="llm-pill llm-pill-output llm-pill-dependency-output">Prerequisite of</span>
|
||||
<div
|
||||
refComponent
|
||||
class="llm-socket llm-socket-right"
|
||||
[data]="{
|
||||
type: 'socket',
|
||||
side: 'output',
|
||||
key: dependantOutput.key,
|
||||
nodeId: data.id,
|
||||
__readonly: true,
|
||||
payload: dependantOutput.socket
|
||||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="llm-params llm-params-shell">
|
||||
@if (!schemaReady) {
|
||||
<div class="llm-skeleton-overlay" aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
class="rete-editor-shell"
|
||||
#shell
|
||||
(pointerdown)="onShellPointerDown($event)"
|
||||
(click)="onShellClick($event)"
|
||||
(pointermove)="onShellPointerMove($event)"
|
||||
(pointerup)="onShellPointerUp($event)"
|
||||
(pointercancel)="onShellPointerUp($event)">
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -122,18 +122,40 @@
|
|||
(click)="addTextItem(executionInput)">
|
||||
Add item
|
||||
</button>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
[disabled]="!canSubmitTextInput(executionInput)"
|
||||
(click)="submitTextInput(executionInput, $event)">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>{{ executionInput.subtitle }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="4"
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="executionInput.value"
|
||||
(ngModelChange)="onTextInputChange(executionInput, $event)">
|
||||
</textarea>
|
||||
</mat-form-field>
|
||||
<div class="space-y-3">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>{{ executionInput.subtitle }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="4"
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="executionInput.value"
|
||||
(ngModelChange)="onTextInputChange(executionInput, $event)">
|
||||
</textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
[disabled]="!canSubmitTextInput(executionInput)"
|
||||
(click)="submitTextInput(executionInput, $event)">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export class TaskExecutionInputsPanelComponent {
|
|||
readonly readOnly = input<boolean>(false);
|
||||
|
||||
readonly textInputChange = output<{ input: EditableExecutionInput; value: string | string[] }>();
|
||||
readonly textInputSubmit = output<EditableExecutionInput>();
|
||||
readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>();
|
||||
readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>();
|
||||
readonly authorizationSubmit = output<TaskExecutionAuthorizationRequirement>();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
</app-task-execution-inputs-panel>
|
||||
} @else if (activeAsideTab() === 'logs') {
|
||||
|
|
|
|||
|
|
@ -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<string, ReturnType<typeof setTimeout>>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Flow | null>(null);
|
||||
readonly isDirty = signal(false);
|
||||
readonly selectedBlockIds = signal<string[]>([]);
|
||||
readonly draggingSelectedBlockIds = signal<string[]>([]);
|
||||
readonly flowValidationErrors = signal<FlowValidationError[]>([]);
|
||||
readonly highlightedValidationNodeIds = signal<string[]>([]);
|
||||
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?.() ?? ''}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HFSchemes>;
|
||||
|
|
@ -33,6 +37,8 @@ export type ReteEditorInstance = {
|
|||
area: AreaPlugin<HFSchemes, AreaExtra>;
|
||||
};
|
||||
|
||||
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<HFSchemes>) {
|
|||
};
|
||||
});
|
||||
|
||||
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<string, unknown>;
|
||||
const nextConfiguration: Record<string, unknown> = {
|
||||
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<HFSchemes>, type: string) {
|
||||
|
|
@ -535,7 +576,9 @@ function getSocket(editor: NodeEditor<HFSchemes>, 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<T>(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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue