diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index e2994c5..28e5fc9 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -1,6 +1,7 @@ export type FlowVisibility = 'PUBLIC' | 'PRIVATE'; +export type FlowStatus = 'DRAFT' | 'EXECUTABLE'; export type Flow = { id: string; @@ -10,6 +11,7 @@ export type Flow = { author: string; description?: string; createdAt: Date; + status: FlowStatus; updatedAt: Date; published?: boolean; finalized?: boolean; @@ -37,7 +39,6 @@ export type BlockType = { export type FlowBlock = { id: string; - sink: boolean; name: string; position?: { x: number, y: number }; inputs: FlowPort[]; diff --git a/src/app/services/blocks/blocks-call.fake.ts b/src/app/services/blocks/blocks-call.fake.ts index 038e49f..97334b1 100644 --- a/src/app/services/blocks/blocks-call.fake.ts +++ b/src/app/services/blocks/blocks-call.fake.ts @@ -155,7 +155,6 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase { const io = this.defaultIOForBlockType(typeName); const block: FlowBlock = { id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`, - sink: typeName === "HumanInteractionBlock", name: typeName, position: undefined, inputs: io.inputs, @@ -172,7 +171,6 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase { const io = this.defaultIOForBlockType(typeName); const block: FlowBlock = { id: blockId, - sink: typeName === "HumanInteractionBlock", name: configuration?.name ?? typeName, position: configuration?.position, inputs: configuration?.inputs ?? io.inputs, diff --git a/src/app/services/blocks/blocks-call.ts b/src/app/services/blocks/blocks-call.ts index ea7c5aa..d29a32b 100644 --- a/src/app/services/blocks/blocks-call.ts +++ b/src/app/services/blocks/blocks-call.ts @@ -107,7 +107,6 @@ export class BlocksCallService extends BlocksCallServiceBase { return { id: String(value["id"] ?? crypto.randomUUID()), - sink: typeof value["sink"] === "boolean" ? value["sink"] : typeName === "HumanInteractionBlock", name: String(value["name"] ?? specificConfiguration["name"] ?? typeName), position: this.toPosition(value["position"]), inputs: this.toPorts(value["inputs"], io.inputs), diff --git a/src/app/services/blocks/blocks.ts b/src/app/services/blocks/blocks.ts index aae3e16..a11c7f6 100644 --- a/src/app/services/blocks/blocks.ts +++ b/src/app/services/blocks/blocks.ts @@ -1,4 +1,4 @@ -import { Injectable, signal } from '@angular/core'; +import { computed, Injectable, signal } from '@angular/core'; import { environment } from '@environment'; import { BlockType, BlockTypeName, FlowBlock } from '@models/flow'; import { BlocksCallServiceBase } from './block-call.base'; @@ -14,8 +14,10 @@ export class BlocksService { private loadingPromise: Promise | null = null; private readonly emptyBlockCache = new Map(); private readonly pendingEmptyBlockRequests = new Map>(); + private readonly pendingServerSyncCount = signal(0); private _blockTypes = signal([]); + readonly hasPendingServerSync = computed(() => this.pendingServerSyncCount() > 0); async getAllBlocksTypes() { if (this.toInit) { @@ -92,7 +94,11 @@ export class BlocksService { } updateBlock(blockId: string, configuration: any) { + this.pendingServerSyncCount.update((count) => count + 1); return this.blocksCallService.updateBlock(blockId, configuration).pipe( + finalize(() => { + this.pendingServerSyncCount.update((count) => Math.max(0, count - 1)); + }), catchError((err) => { console.error('Update block failed', err); return throwError(() => err); diff --git a/src/app/services/flows/flow-mapper.ts b/src/app/services/flows/flow-mapper.ts index 88e1379..855772b 100644 --- a/src/app/services/flows/flow-mapper.ts +++ b/src/app/services/flows/flow-mapper.ts @@ -1,4 +1,4 @@ -import { Flow, FlowData, FlowVisibility } from '@models/flow'; +import { Flow, FlowData, FlowStatus, FlowVisibility } from '@models/flow'; function parseDate(value: unknown, fallback: Date): Date { if (typeof value !== 'string' || !value) return fallback; @@ -16,6 +16,8 @@ export function flowFromApi(raw: unknown): Flow { ? value['published'] : ((value['visibility'] as FlowVisibility | undefined) === 'PUBLIC'); const visibility: FlowVisibility = published ? 'PUBLIC' : 'PRIVATE'; + const rawStatus = typeof value['status'] === 'string' ? value['status'].toUpperCase() : null; + const status: FlowStatus = rawStatus === 'EXECUTABLE' ? 'EXECUTABLE' : 'DRAFT'; return { id: String(value['id'] ?? crypto.randomUUID()), @@ -23,6 +25,7 @@ export function flowFromApi(raw: unknown): Flow { description: typeof value['description'] === 'string' ? value['description'] : undefined, author: String(value['author'] ?? value['owner'] ?? 'unknown'), createdAt, + status, updatedAt, visibility, published, @@ -34,10 +37,11 @@ export function flowFromApi(raw: unknown): Flow { }; } -export function toFlowCreateRequest(name: string, description?: string, flow?: FlowData) { +export function toFlowCreateRequest(name: string, description?: string, flow?: FlowData, status: FlowStatus = 'DRAFT') { return { name, description: description ?? '', + status, flow: flow ?? { blocks: [], connections: [] diff --git a/src/app/services/flows/flows-call.fake.ts b/src/app/services/flows/flows-call.fake.ts index ddffa4b..97369d4 100644 --- a/src/app/services/flows/flows-call.fake.ts +++ b/src/app/services/flows/flows-call.fake.ts @@ -17,8 +17,8 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase { authorizationService = inject(Authorization); private data: Record = { - '1': { id: '1', name: 'A Flow', data: { blocks: [], connections: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), updatedAt: new Date("January 7, 2026 12:24:00") }, - '2': { id: '2', name: 'Test Flow', data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), updatedAt: new Date("April 27, 2025 18:42:00") }, + '1': { id: '1', name: 'A Flow', data: { blocks: [], 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: [], 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") }, 'testFlow': flowFromApi(testDataFlow) } @@ -33,7 +33,7 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase { override createNewFlow(name?: string): Observable { const newId = (Object.keys(this.data).length + 1).toString(); - this.data[newId] = { id: newId, name: name || `New Flow`, data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: this.authorizationService.loggedInUser()!.username, createdAt: new Date(), updatedAt: new Date() }; + this.data[newId] = { id: newId, name: name || `New Flow`, data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: this.authorizationService.loggedInUser()!.username, createdAt: new Date(), status: 'DRAFT', updatedAt: new Date() }; return of(this.data[newId]); } @@ -46,6 +46,7 @@ const testDataFlow ={ "id": "testFlow", "name" : "Test Flow", "owner": "lucio", + "status": "DRAFT", "published": false, "finalized": false, "description" : "This is a test flow", diff --git a/src/app/services/flows/flows-call.ts b/src/app/services/flows/flows-call.ts index df91f6f..4abec72 100644 --- a/src/app/services/flows/flows-call.ts +++ b/src/app/services/flows/flows-call.ts @@ -18,7 +18,7 @@ export class FlowsCallService extends FlowsCallServiceBase { override createNewFlow(name?: string): Observable { return this.http - .post(`${environment.apiUrl}/flows`, toFlowCreateRequest(name ?? 'New Flow')) + .post(`${environment.apiUrl}/flows`, toFlowCreateRequest(name ?? 'New Flow', undefined, undefined, 'DRAFT')) .pipe(map((raw) => flowFromApi(raw))); } @@ -38,7 +38,7 @@ export class FlowsCallService extends FlowsCallServiceBase { return this.http .put( `${environment.apiUrl}/flows/${encodedId}`, - toFlowCreateRequest(flow.name, flow.description, flow.data) + toFlowCreateRequest(flow.name, flow.description, flow.data, flow.status) ) .pipe(map((raw) => flowFromApi(raw))); } diff --git a/src/app/services/flows/flows.ts b/src/app/services/flows/flows.ts index 3c169b5..8d20a0e 100644 --- a/src/app/services/flows/flows.ts +++ b/src/app/services/flows/flows.ts @@ -34,7 +34,18 @@ export class FlowsService { updateFlow(flow: Flow) { return this.flowsCallService.updateFlow(flow).pipe( - tap(() => this.refresh()), + tap((updatedFlow) => { + this._flows.update((flows) => { + const index = flows.findIndex((current) => current.id === updatedFlow.id); + if (index < 0) { + return [updatedFlow, ...flows]; + } + + const next = [...flows]; + next[index] = updatedFlow; + return next; + }); + }), catchError(err => { console.error('Update flow failed', err); return throwError(() => err); diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index 33a4947..ba57c35 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -26,7 +26,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase block: { id: 'b2540579-ca7b-4beb-8ed3-65136e7f03d6', position: { x: 120, y: 160 }, - sink: false, name: 'first', inputs: [{ name: 'name', type: 'TEXT', multiple: false }], outputs: [{ name: 'response', type: 'TEXT', multiple: false }], @@ -61,7 +60,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase block: { id: '5ceb9b7b-88a0-41bb-afef-76fcb1f57918', position: { x: 500, y: 160 }, - sink: true, name: 'second', inputs: [{ name: 'year', type: 'TEXT', multiple: false }], outputs: [{ name: 'response', type: 'TEXT', multiple: false }], @@ -119,7 +117,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase block: { id: '95ebb03f-80e0-412d-87ee-2d4b7ddef240', position: { x: 120, y: 140 }, - sink: false, name: 'first', inputs: [{ name: 'name', type: 'TEXT', multiple: false }], outputs: [{ name: 'response', type: 'TEXT', multiple: false }], @@ -165,7 +162,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase block: { id: '68c5949c-1c74-400e-a1aa-b5f7739e5bb2', position: { x: 500, y: 140 }, - sink: true, name: 'interactive', inputs: [{ name: 'input', type: 'TEXT', multiple: false }], outputs: [{ name: 'output', type: 'TEXT', multiple: false }], @@ -235,7 +231,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase block: { id: 'ab7e0b08-c653-4d11-b808-e0e51c89d989', position: { x: 500, y: 140 }, - sink: true, name: 'interactive', inputs: [{ name: 'input', type: 'TEXT', multiple: false }], outputs: [{ name: 'output', type: 'TEXT', multiple: false }], @@ -281,7 +276,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase block: { id: 'f91ec0f7-03e8-4208-89ac-bd9db46dca8c', position: { x: 120, y: 140 }, - sink: false, name: 'first', inputs: [{ name: 'name', type: 'TEXT', multiple: false }], outputs: [{ name: 'response', type: 'TEXT', multiple: false }], @@ -347,7 +341,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase block: { id: '82844256-d9c1-4f81-a415-49b18c371a13', position: { x: 500, y: 140 }, - sink: true, name: 'interactive', inputs: [{ name: 'input', type: 'TEXT', multiple: false }], outputs: [{ name: 'output', type: 'TEXT', multiple: false }], @@ -394,7 +387,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase block: { id: 'f80bce81-f1e4-4e03-9982-d35a042b1276', position: { x: 120, y: 140 }, - sink: false, name: 'first', inputs: [{ name: 'name', type: 'TEXT', multiple: false }], outputs: [{ name: 'response', type: 'TEXT', multiple: false }], 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 d0ed572..50426fb 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 @@ -33,6 +33,12 @@ box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.2), 0 10px 24px rgba(15, 23, 42, 0.12); } +.llm-node-running { + border: 2px solid #facc15; + box-shadow: 0 0 0 0 rgba(250, 204, 21, 0.45), 0 10px 24px rgba(15, 23, 42, 0.12); + animation: llmRunningBorderPulse 0.95s ease-in-out infinite; +} + .llm-node-attention { border: 2px solid #f97316; animation: llmAttentionBorderPulse 1.2s ease-in-out infinite; @@ -142,6 +148,17 @@ transform: translateY(0); } +@keyframes llmRunningBorderPulse { + 0%, 100% { + border-color: #fde047; + box-shadow: 0 0 0 0 rgba(250, 204, 21, 0.12), 0 10px 24px rgba(15, 23, 42, 0.12); + } + 50% { + border-color: #eab308; + box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.32), 0 10px 24px rgba(15, 23, 42, 0.18); + } +} + .llm-error-title, .llm-warning-list-title { font-size: 11px; 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 bc5d0d2..a5225ab 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 @@ -1,5 +1,6 @@
@if (notSaved()) { not saved + } @else if (blockSyncInProgress()) { + + + Updating blocks... + + } @else if (canExecute()) { + }
@@ -32,7 +48,7 @@ flex items-center gap-1 enabled:hover:bg-green-200 enabled:text-green-600 disabled:text-green-300 p-1" - title="Save" [disabled]="!notSaved()" (click)="save()"> + title="Save" [disabled]="!canSave()" (click)="save()"> diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index fea324a..028bdb7 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -1,5 +1,8 @@ import { CommonModule } from '@angular/common'; import { Component, computed, ElementRef, inject, signal, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { BlocksService } from '@services/blocks/blocks'; +import { TaskExecutionsService } from '@services/task-executions/task-executions'; import { take } from 'rxjs'; import { EditorStateHolder } from '@stores/flow-editor'; @@ -15,6 +18,9 @@ export class TitleToolbar { @ViewChild('titleInput') myInputRef!: ElementRef; editorState: EditorStateHolder = inject(EditorStateHolder); + private router = inject(Router); + private blocksService = inject(BlocksService); + private taskExecutionsService = inject(TaskExecutionsService); flow = computed(() => this.editorState.currentFlow()); title = computed(() => { const flow = this.flow(); @@ -22,6 +28,13 @@ export class TitleToolbar { }); notSaved = computed(() => this.editorState.isDirty()); + blockSyncInProgress = this.blocksService.hasPendingServerSync; + canSave = computed(() => this.notSaved() && !this.blockSyncInProgress()); + canExecute = computed(() => { + const flow = this.flow(); + return !!flow && !this.notSaved() && !this.blockSyncInProgress() && flow.status === 'EXECUTABLE'; + }); + executeLoading = signal(false); snackbarMessage = signal(null); snackbarType = signal<'success' | 'error'>('success'); @@ -38,7 +51,7 @@ export class TitleToolbar { } save() { - if (!this.notSaved()) return; + if (!this.canSave()) return; this.editorState.save().pipe( take(1) ).subscribe({ @@ -61,6 +74,26 @@ export class TitleToolbar { this.editorState.redo(); } + execute() { + const flow = this.flow(); + if (!flow || !this.canExecute() || this.executeLoading()) return; + + this.executeLoading.set(true); + this.taskExecutionsService.createExecution(flow.id).pipe( + take(1) + ).subscribe({ + next: () => { + this.executeLoading.set(false); + this.router.navigate(['/tasks']); + }, + error: (err) => { + this.executeLoading.set(false); + console.error('Create execution failed', err); + this.showSnackbar('Errore durante la creazione dell\'esecuzione', 'error'); + } + }); + } + private showSnackbar(message: string, type: 'success' | 'error') { this.snackbarMessage.set(message); this.snackbarType.set(type); diff --git a/src/app/stores/flow-editor.ts b/src/app/stores/flow-editor.ts index eca1e83..8811383 100644 --- a/src/app/stores/flow-editor.ts +++ b/src/app/stores/flow-editor.ts @@ -81,7 +81,10 @@ export class EditorStateHolder { save() { return this.flowsService.updateFlow(this.currentFlow()!).pipe( - tap(() => this.markSaved()) + tap((savedFlow) => { + this.currentFlow.set(savedFlow); + this.markSaved(); + }) ) } diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 29bbd63..c84a0c6 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -99,7 +99,6 @@ export function exportGraph(editor: NodeEditor) { return { id: blockId, - sink: blockData?.sink ?? false, name: blockData?.name ?? node.label, position: blockData?.position, inputs,