diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index 89de989..4889dae 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -1,7 +1,12 @@ -import { Component, signal } from '@angular/core'; +import { Component, computed, effect, inject, signal } from '@angular/core'; import { TaskExecution } from '@models/task-execution'; -import { TasksExecutionsListComponent, TaskExecutionListItem } from '@shared/tasks-executions-list/tasks-executions-list'; +import { + TaskExecutionListItem, + TaskExecutionStatus, + TasksExecutionsListComponent +} from '@shared/tasks-executions-list/tasks-executions-list'; import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer'; +import { TaskExecutionsService } from '@services/task-executions/task-executions'; @Component({ selector: 'app-tasks-executor', @@ -10,244 +15,68 @@ import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task styleUrl: './tasks-executor.css', }) export class TasksExecutor { - readonly executionDetails = signal([ - { - id: 'c106be9d-5467-428c-8992-0b5f40a59aac', - name: 'Test Flow', - creationTime: 1772619308394, - context: { - inputs : { - 'feeb2977-370f-4bf9-aa84-04fa2f11e365:name' : 'Leonardo da Vinci' - }, - result: {}, - startTime: 1772619308397, - endTime: 1772619308398, - errors: { - '5ceb9b7b-88a0-41bb-afef-76fcb1f57918': 'Provider not found: testProvider' - }, - warnings: { - 'b2540579-ca7b-4beb-8ed3-65136e7f03d6': 'Response was slower than expected' - }, - steps: { - 'b2540579-ca7b-4beb-8ed3-65136e7f03d6': { - 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 }], - specificConfiguration: { - type: 'LLMBlockConfiguration', - name: 'first', - llmDescriptor: { provider: 'testProvider', model: 'testModel' }, - prompt: 'When ${{name}} died ? give me only the year as number' - }, - typeName: 'LLMBlock' - }, - id: 'b2540579-ca7b-4beb-8ed3-65136e7f03d6', - inputs: [ - { - descriptor: { name: 'name', type: 'TEXT', multiple: false }, - value: 'Leonardo da Vinci', - registered: false, - set: true - } - ], - outputs: [ - { - descriptor: { name: 'response', type: 'TEXT', multiple: false }, - connected: true - } - ], - status: 'FAILED', - started: true, - simulated: false - }, - '5ceb9b7b-88a0-41bb-afef-76fcb1f57918': { - 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 }], - specificConfiguration: { - type: 'LLMBlockConfiguration', - name: 'second', - llmDescriptor: { provider: 'testProvider', model: 'testModel' }, - prompt: 'what was the most important event in year ${{year}}?' - }, - typeName: 'LLMBlock' - }, - id: '5ceb9b7b-88a0-41bb-afef-76fcb1f57918', - inputs: [ - { - descriptor: { name: 'year', type: 'TEXT', multiple: false }, - value: null, - registered: true, - set: false - } - ], - outputs: [ - { - descriptor: { name: 'response', type: 'TEXT', multiple: false }, - connected: false - } - ], - status: 'WAITING_FOR_INPUT', - started: true, - simulated: false - } - }, - status: 'ERROR', - waitingSteps: [], - executionResult: {} - } - }, - { - id: 'eda4040e-ae58-4ffb-a3b4-16dfef45a6c2', - name: 'Test Flow', - creationTime: 1772623910033, - context: { - inputs: { - '95ebb03f-80e0-412d-87ee-2d4b7ddef240:name': 'marie curie' - }, - result: { - '68c5949c-1c74-400e-a1aa-b5f7739e5bb2:output': 'Based on the provided context, Marie Curie is a remarkable scientist and explorer who made significant contributions to the field of science and exploration. She is often considered a pioneer in the field of radioactivity.\n' - }, - startTime: 1772623910037, - endTime: 1772623947755, - errors: {}, - warnings: {}, - steps: { - '95ebb03f-80e0-412d-87ee-2d4b7ddef240': { - 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 }], - specificConfiguration: { - type: 'LLMBlockConfiguration', - name: 'first', - llmDescriptor: { - provider: 'ollamaTestProvider', - model: 'sam860/gemma3:270m' - }, - prompt: 'Make a question about ${{name}}' - }, - typeName: 'LLMBlock' - }, - id: '95ebb03f-80e0-412d-87ee-2d4b7ddef240', - inputs: [ - { - descriptor: { - name: 'name', - type: 'TEXT', - multiple: false - }, - value: 'marie curie', - registered: false, - set: true - } - ], - outputs: [ - { - descriptor: { - name: 'response', - type: 'TEXT', - multiple: false - }, - connected: true - } - ], - status: 'COMPLETED', - started: true, - simulated: false - }, - '68c5949c-1c74-400e-a1aa-b5f7739e5bb2': { - 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 }], - specificConfiguration: { - type: 'HumanInteractiveBlockConfiguration', - name: 'interactive', - actionDescription: 'Answer the question in input', - llmDescriptor: { - provider: 'ollamaTestProvider', - model: 'sam860/gemma3:270m' - }, - }, - typeName: 'HumanInteractionBlock' - }, - id: '68c5949c-1c74-400e-a1aa-b5f7739e5bb2', - inputs: [ - { - descriptor: { - name: 'input', - type: 'TEXT', - multiple: false - }, - value: 'Marie Curie is a remarkable scientist and explorer who made significant contributions to science and exploration. She is often considered a pioneer in the field of radioactivity. What is your opinion about her contributions to the field of science and exploration?', - registered: true, - set: true - } - ], - outputs: [ - { - descriptor: { - name: 'output', - type: 'TEXT', - multiple: false - }, - connected: false - } - ], - status: 'COMPLETED', - started: true, - simulated: true - } - }, - status: 'SUCCESS', - waitingSteps: [], - executionResult: { - '68c5949c-1c74-400e-a1aa-b5f7739e5bb2:output': 'Based on the provided context, Marie Curie is a remarkable scientist and explorer who made significant contributions to the field of science and exploration. She is often considered a pioneer in the field of radioactivity.\n' - } - } - } - ]); + private taskExecutionsService = inject(TaskExecutionsService); - readonly executions = signal([ - { - id: 'c106be9d-5467-428c-8992-0b5f40a59aac', - title: 'Test Flow', - flowName: 'Test Flow', - status: 'ERROR', - startedAt: '2026-03-03 10:35', - duration: '00:00:00' - }, - { - id: 'eda4040e-ae58-4ffb-a3b4-16dfef45a6c2', - title: 'Test Flow', - flowName: 'Test Flow', - status: 'COMPLETED', - startedAt: '2026-03-03 11:51', - duration: '00:37:43' - } - ]); + readonly executionDetails = this.taskExecutionsService.taskExecutions; - readonly selectedExecutionId = signal(this.executions()[0]?.id ?? null); + readonly executions = computed(() => + this.executionDetails().map((execution) => ({ + id: execution.id, + title: execution.name, + flowName: execution.name, + status: this.toListStatus(execution.context.status), + startedAt: this.formatDateTime(execution.creationTime), + duration: this.formatDuration(execution.context.startTime ?? null, execution.context.endTime ?? null) + })) + ); - readonly selectedExecution = () => - this.executionDetails().find((execution) => execution.id === this.selectedExecutionId()) ?? null; + readonly selectedExecutionId = signal(null); + + readonly selectedExecution = computed(() => { + const selectedId = this.selectedExecutionId(); + const details = this.executionDetails(); + if (!details.length) return null; + if (!selectedId) return details[0]; + return details.find((execution) => execution.id === selectedId) ?? details[0]; + }); + + constructor() { + this.taskExecutionsService.init(); + effect(() => { + if (this.selectedExecutionId()) return; + const first = this.executions()[0]; + if (first) this.selectedExecutionId.set(first.id); + }); + } selectExecution(id: string) { this.selectedExecutionId.set(id); } + + private toListStatus(status: string): TaskExecutionStatus { + if (status === 'COMPLETED') return 'COMPLETED'; + if (status === 'FAILED' || status === 'ERROR') return 'FAILED'; + if (status === 'RUNNING' || status === 'WAITING' || status === 'WAITING_FOR_INPUT') return 'RUNNING'; + return 'QUEUED'; + } + + private formatDateTime(timestamp: number): string { + const date = new Date(timestamp); + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mi = String(date.getMinutes()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; + } + + private formatDuration(startTime: number | null, endTime: number | null): string { + if (!startTime || !endTime) return '00:00:00'; + const diffMs = Math.max(0, endTime - startTime); + const totalSeconds = Math.floor(diffMs / 1000); + const hh = String(Math.floor(totalSeconds / 3600)).padStart(2, '0'); + const mm = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0'); + const ss = String(totalSeconds % 60).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; + } } diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts new file mode 100644 index 0000000..0b79769 --- /dev/null +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -0,0 +1,7 @@ +import { TaskExecution } from '@models/task-execution'; +import { Observable } from 'rxjs'; + +export abstract class TaskExecutionsCallServiceBase { + abstract retrieveAllTaskExecutions(): Observable; +} + diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts new file mode 100644 index 0000000..d6cf5b1 --- /dev/null +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -0,0 +1,451 @@ +import { TaskExecution } from '@models/task-execution'; +import { Observable, of } from 'rxjs'; +import { TaskExecutionsCallServiceBase } from './task-executions-call.base'; + +export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase { + private readonly data: TaskExecution[] = [ + { + id: 'c106be9d-5467-428c-8992-0b5f40a59aac', + name: 'Test Flow', + creationTime: 1772619308394, + context: { + inputs: { + 'feeb2977-370f-4bf9-aa84-04fa2f11e365:name': 'Leonardo da Vinci' + }, + result: {}, + startTime: 1772619308397, + endTime: 1772619308398, + errors: { + '5ceb9b7b-88a0-41bb-afef-76fcb1f57918': 'Provider not found: testProvider' + }, + warnings: { + 'b2540579-ca7b-4beb-8ed3-65136e7f03d6': 'Response was slower than expected' + }, + steps: { + 'b2540579-ca7b-4beb-8ed3-65136e7f03d6': { + 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 }], + specificConfiguration: { + type: 'LLMBlockConfiguration', + name: 'first', + llmDescriptor: { provider: 'testProvider', model: 'testModel' }, + prompt: 'When ${{name}} died ? give me only the year as number' + }, + typeName: 'LLMBlock' + }, + id: 'b2540579-ca7b-4beb-8ed3-65136e7f03d6', + inputs: [ + { + descriptor: { name: 'name', type: 'TEXT', multiple: false }, + value: 'Leonardo da Vinci', + registered: false, + set: true + } + ], + outputs: [ + { + descriptor: { name: 'response', type: 'TEXT', multiple: false }, + connected: true + } + ], + status: 'FAILED', + started: true, + simulated: false + }, + '5ceb9b7b-88a0-41bb-afef-76fcb1f57918': { + 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 }], + specificConfiguration: { + type: 'LLMBlockConfiguration', + name: 'second', + llmDescriptor: { provider: 'testProvider', model: 'testModel' }, + prompt: 'what was the most important event in year ${{year}}?' + }, + typeName: 'LLMBlock' + }, + id: '5ceb9b7b-88a0-41bb-afef-76fcb1f57918', + inputs: [ + { + descriptor: { name: 'year', type: 'TEXT', multiple: false }, + value: null, + registered: true, + set: false + } + ], + outputs: [ + { + descriptor: { name: 'response', type: 'TEXT', multiple: false }, + connected: false + } + ], + status: 'WAITING_FOR_INPUT', + started: true, + simulated: false + } + }, + status: 'ERROR', + waitingSteps: [], + executionResult: {} + } + }, + { + id: 'eda4040e-ae58-4ffb-a3b4-16dfef45a6c2', + name: 'Test Flow', + creationTime: 1772623910033, + context: { + inputs: { + '95ebb03f-80e0-412d-87ee-2d4b7ddef240:name': 'marie curie' + }, + result: { + '68c5949c-1c74-400e-a1aa-b5f7739e5bb2:output': + 'Based on the provided context, Marie Curie is a remarkable scientist and explorer who made significant contributions to the field of science and exploration. She is often considered a pioneer in the field of radioactivity.\n' + }, + startTime: 1772623910037, + endTime: 1772623947755, + errors: {}, + warnings: {}, + steps: { + '95ebb03f-80e0-412d-87ee-2d4b7ddef240': { + 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 }], + specificConfiguration: { + type: 'LLMBlockConfiguration', + name: 'first', + llmDescriptor: { + provider: 'ollamaTestProvider', + model: 'sam860/gemma3:270m' + }, + prompt: 'Make a question about ${{name}}' + }, + typeName: 'LLMBlock' + }, + id: '95ebb03f-80e0-412d-87ee-2d4b7ddef240', + inputs: [ + { + descriptor: { + name: 'name', + type: 'TEXT', + multiple: false + }, + value: 'marie curie', + registered: false, + set: true + } + ], + outputs: [ + { + descriptor: { + name: 'response', + type: 'TEXT', + multiple: false + }, + connected: true + } + ], + status: 'COMPLETED', + started: true, + simulated: false + }, + '68c5949c-1c74-400e-a1aa-b5f7739e5bb2': { + 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 }], + specificConfiguration: { + type: 'HumanInteractiveBlockConfiguration', + name: 'interactive', + actionDescription: 'Answer the question in input', + llmDescriptor: { + provider: 'ollamaTestProvider', + model: 'sam860/gemma3:270m' + } + }, + typeName: 'HumanInteractionBlock' + }, + id: '68c5949c-1c74-400e-a1aa-b5f7739e5bb2', + inputs: [ + { + descriptor: { + name: 'input', + type: 'TEXT', + multiple: false + }, + value: + 'Marie Curie is a remarkable scientist and explorer who made significant contributions to science and exploration. She is often considered a pioneer in the field of radioactivity. What is your opinion about her contributions to the field of science and exploration?', + registered: true, + set: true + } + ], + outputs: [ + { + descriptor: { + name: 'output', + type: 'TEXT', + multiple: false + }, + connected: false + } + ], + status: 'COMPLETED', + started: true, + simulated: true + } + }, + status: 'SUCCESS', + waitingSteps: [], + executionResult: { + '68c5949c-1c74-400e-a1aa-b5f7739e5bb2:output': + 'Based on the provided context, Marie Curie is a remarkable scientist and explorer who made significant contributions to the field of science and exploration. She is often considered a pioneer in the field of radioactivity.\n' + } + } + }, + { + id: '74ec477f-b04e-494c-80cc-968a40527bef', + name: 'Test Flow', + creationTime: 1772705504567, + context: { + inputs: { + 'f91ec0f7-03e8-4208-89ac-bd9db46dca8c:name': 'marie curie' + }, + result: {}, + startTime: null, + endTime: null, + errors: {}, + warnings: {}, + steps: { + 'ab7e0b08-c653-4d11-b808-e0e51c89d989': { + 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 }], + specificConfiguration: { + type: 'HumanInteractiveBlockConfiguration', + name: 'interactive', + actionDescription: 'Answer the question in input', + simulateWith: { + provider: 'ollamaTestProvider', + model: 'sam860/gemma3:270m' + } + }, + typeName: 'HumanInteractionBlock' + }, + id: 'ab7e0b08-c653-4d11-b808-e0e51c89d989', + inputs: [ + { + descriptor: { + name: 'input', + type: 'TEXT', + multiple: false + }, + value: null, + registered: true, + set: false + } + ], + outputs: [ + { + descriptor: { + name: 'output', + type: 'TEXT', + multiple: false + }, + connected: false + } + ], + status: 'WAITING_FOR_INPUT', + started: false, + simulated: false + }, + 'f91ec0f7-03e8-4208-89ac-bd9db46dca8c': { + 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 }], + specificConfiguration: { + type: 'LLMBlockConfiguration', + name: 'first', + llmDescriptor: { + provider: 'ollamaTestProvider', + model: 'sam860/gemma3:270m' + }, + prompt: 'Make a question about ${{name}}' + }, + typeName: 'LLMBlock' + }, + id: 'f91ec0f7-03e8-4208-89ac-bd9db46dca8c', + inputs: [ + { + descriptor: { + name: 'name', + type: 'TEXT', + multiple: false + }, + value: 'marie curie', + registered: false, + set: true + } + ], + outputs: [ + { + descriptor: { + name: 'response', + type: 'TEXT', + multiple: false + }, + connected: true + } + ], + status: 'READY', + started: false, + simulated: false + } + }, + status: 'READY', + waitingSteps: [], + executionResult: {} + } + }, + { + id: '228b8689-e53c-4fcc-8426-fcf9f8aad634', + name: 'Test Flow Waiting', + creationTime: 1772705840477, + context: { + inputs: { + 'f80bce81-f1e4-4e03-9982-d35a042b1276:name': 'marie curie' + }, + result: {}, + startTime: 1772705842371, + endTime: null, + errors: {}, + warnings: {}, + steps: { + '82844256-d9c1-4f81-a415-49b18c371a13': { + 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 }], + specificConfiguration: { + type: 'HumanInteractiveBlockConfiguration', + name: 'interactive', + actionDescription: 'Answer the question in input', + simulateWith: { + provider: 'ollamaTestProvider', + model: 'sam860/gemma3:270m' + } + }, + typeName: 'HumanInteractionBlock' + }, + id: '82844256-d9c1-4f81-a415-49b18c371a13', + inputs: [ + { + descriptor: { + name: 'input', + type: 'TEXT', + multiple: false + }, + value: + 'Marie Curie is a pioneering scientist who revolutionized our understanding of radioactivity and the development of nuclear medicine. Her work has inspired generations of scientists and continues to be studied and understood.\n', + registered: true, + set: true + } + ], + outputs: [ + { + descriptor: { + name: 'output', + type: 'TEXT', + multiple: false + }, + connected: false + } + ], + status: 'WAITING_FOR_INTERACTION', + started: true, + simulated: false + }, + 'f80bce81-f1e4-4e03-9982-d35a042b1276': { + 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 }], + specificConfiguration: { + type: 'LLMBlockConfiguration', + name: 'first', + llmDescriptor: { + provider: 'ollamaTestProvider', + model: 'sam860/gemma3:270m' + }, + prompt: 'Make a question about ${{name}}' + }, + typeName: 'LLMBlock' + }, + id: 'f80bce81-f1e4-4e03-9982-d35a042b1276', + inputs: [ + { + descriptor: { + name: 'name', + type: 'TEXT', + multiple: false + }, + value: 'marie curie', + registered: false, + set: true + } + ], + outputs: [ + { + descriptor: { + name: 'response', + type: 'TEXT', + multiple: false + }, + connected: true + } + ], + status: 'COMPLETED', + started: true, + simulated: false + } + }, + status: 'WAITING', + waitingSteps: ['82844256-d9c1-4f81-a415-49b18c371a13'], + executionResult: {} + } + } + ]; + + override retrieveAllTaskExecutions(): Observable { + return of(this.data); + } +} + diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts new file mode 100644 index 0000000..72a2206 --- /dev/null +++ b/src/app/services/task-executions/task-executions-call.ts @@ -0,0 +1,10 @@ +import { TaskExecution } from '@models/task-execution'; +import { Observable } from 'rxjs'; +import { TaskExecutionsCallServiceBase } from './task-executions-call.base'; + +export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { + override retrieveAllTaskExecutions(): Observable { + throw new Error('Method not implemented.'); + } +} + diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts new file mode 100644 index 0000000..9658295 --- /dev/null +++ b/src/app/services/task-executions/task-executions.ts @@ -0,0 +1,28 @@ +import { Injectable, signal } from '@angular/core'; +import { environment } from '@environment'; +import { TaskExecution } from '@models/task-execution'; +import { TaskExecutionsCallServiceBase } from './task-executions-call.base'; + +@Injectable({ + providedIn: 'root', +}) +export class TaskExecutionsService { + taskExecutionsCallService: TaskExecutionsCallServiceBase = new environment.taskExecutionsCallService(); + private initialized = false; + private _taskExecutions = signal([]); + + taskExecutions = this._taskExecutions.asReadonly(); + + init() { + if (this.initialized) return; + this.initialized = true; + this.refresh(); + } + + refresh() { + this.taskExecutionsCallService.retrieveAllTaskExecutions().subscribe((taskExecutions) => { + this._taskExecutions.set(taskExecutions); + }); + } +} + diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index a7b1cd0..b1f13ff 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -5,10 +5,19 @@ import { ClassicPreset } from 'rete'; import { ReteModule } from 'rete-angular-plugin/21'; import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; import { EditorStateHolder } from '@stores/flow-editor'; -import { FieldRetreiver } from '@services/retreiver/field-retreiver'; +import { FieldRetreiver as FieldRetriever } from '@services/retreiver/field-retreiver'; import { BlocksService } from '@services/blocks/blocks'; import { firstValueFrom } from 'rxjs'; import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements'; +import { + flattenPrimitiveValues, + parentPath, + pathToLabel, + resolveSchemaRef, + splitTemplatedTextParts, + toStringOrNull, + valueToDisplayString +} from '../node-utility'; type FieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown'; @@ -57,7 +66,7 @@ export class GenericNodeComponent { private settingsDialog = inject(NodeSettingsDialogService); private editorState = inject(EditorStateHolder); - private fieldRetreiver = inject(FieldRetreiver); + private fieldRetriever = inject(FieldRetriever); private blocksService = inject(BlocksService); private cdr = inject(ChangeDetectorRef); @@ -108,7 +117,7 @@ export class GenericNodeComponent { const config = this.ensureBlockConfiguration(); - this.name = this.toStringOrNull(config['name']) || this.name; + this.name = toStringOrNull(config['name']) || this.name; this.refreshValidationState(); this.refreshParameterFields(); @@ -236,7 +245,7 @@ export class GenericNodeComponent { mainContentLabel(): string { const contentKey = this.mainContentKey(); if (!contentKey) return 'Content'; - return this.pathToLabel(contentKey); + return pathToLabel(contentKey); } hasMainContent(): boolean { @@ -246,11 +255,11 @@ export class GenericNodeComponent { mainContentParts(): { text: string; isDynamicInput: boolean }[] { const contentKey = this.mainContentKey(); if (!contentKey) return []; - const content = this.toStringOrNull(this.getByPath(this.blockConfiguration ?? {}, contentKey)); + const content = toStringOrNull(this.getByPath(this.blockConfiguration ?? {}, contentKey)); if (!content) return []; const ui = this.getFieldUiMeta(contentKey); if (ui.widget === 'textarea' && ui.acceptVariableAsPlaceholder) { - return this.splitTemplatedTextParts(content); + return splitTemplatedTextParts(content); } return [{ text: content, isDynamicInput: false }]; } @@ -279,11 +288,6 @@ export class GenericNodeComponent { return this.data.data.specificConfiguration; } - private toStringOrNull(value: unknown): string | null { - if (typeof value === 'string' && value.trim().length > 0) return value; - return null; - } - private markFlowDirty() { const flow = this.editorState.currentFlow(); if (!flow) return; @@ -341,14 +345,14 @@ export class GenericNodeComponent { const contentKey = this.mainContentKey(); const walk = (node: Record, pathPrefix: string) => { - const resolved = this.resolveRef(node, schema); + const resolved = resolveSchemaRef(node, schema); if (!resolved || typeof resolved !== 'object') return; const properties = resolved.properties as Record | undefined; if (!properties) return; for (const [key, childSchema] of Object.entries(properties)) { - const childResolved = this.resolveRef(childSchema as Record, schema); + const childResolved = resolveSchemaRef(childSchema as Record, schema); const path = pathPrefix ? `${pathPrefix}.${key}` : key; const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; @@ -364,7 +368,7 @@ export class GenericNodeComponent { seen.add(path); definitions.push({ path, - label: this.pathToLabel(path), + label: pathToLabel(path), type: this.toFieldType(childResolved?.type), retrieverKey: this.toRetrieverKey(childResolved), retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix), @@ -415,10 +419,10 @@ export class GenericNodeComponent { let current: Record | null = root; for (const segment of path.split('.')) { if (!current) return null; - const resolved = this.resolveRef(current, root); + const resolved = resolveSchemaRef(current, root); const properties = resolved?.properties as Record | undefined; if (!properties || !properties[segment]) return null; - current = this.resolveRef(properties[segment] as Record, root); + current = resolveSchemaRef(properties[segment] as Record, root); } return current; @@ -468,7 +472,7 @@ export class GenericNodeComponent { try { const options = await firstValueFrom( - this.fieldRetreiver.retrieveValues(blockType, definition.retrieverKey, context) + this.fieldRetriever.retrieveValues(blockType, definition.retrieverKey, context) ); this.localEditorOptions = options ?? []; } catch { @@ -489,25 +493,25 @@ export class GenericNodeComponent { return { path: definition.path, label: definition.label, - value: this.valueToDisplayString(value) + value: valueToDisplayString(value) }; }); for (const field of orderedFields) { - const parentPath = this.parentPath(field.path); - if (!parentPath) { + const parentKey = parentPath(field.path); + if (!parentKey) { rootFields.push(field); continue; } - if (!grouped.has(parentPath)) { - grouped.set(parentPath, []); + if (!grouped.has(parentKey)) { + grouped.set(parentKey, []); } - grouped.get(parentPath)!.push(field); + grouped.get(parentKey)!.push(field); } this.parameterFields = rootFields; this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({ key, - legend: this.pathToLabel(key), + legend: pathToLabel(key), fields })); this.refreshView(); @@ -515,68 +519,36 @@ export class GenericNodeComponent { } const contentKey = this.mainContentKey(); - const fallbackFields = this.flattenPrimitiveValues(config) + const fallbackFields = flattenPrimitiveValues(config) .filter((entry) => entry.path !== 'name' && entry.path !== 'type' && entry.path !== contentKey) .map((entry) => ({ path: entry.path, - label: this.pathToLabel(entry.path), - value: this.valueToDisplayString(entry.value) + label: pathToLabel(entry.path), + value: valueToDisplayString(entry.value) })); for (const field of fallbackFields) { - const parentPath = this.parentPath(field.path); - if (!parentPath) { + const parentKey = parentPath(field.path); + if (!parentKey) { rootFields.push(field); continue; } - if (!grouped.has(parentPath)) { - grouped.set(parentPath, []); + if (!grouped.has(parentKey)) { + grouped.set(parentKey, []); } - grouped.get(parentPath)!.push(field); + grouped.get(parentKey)!.push(field); } this.parameterFields = rootFields; this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({ key, - legend: this.pathToLabel(key), + legend: pathToLabel(key), fields })); this.refreshView(); } - private parentPath(path: string): string | null { - const index = path.lastIndexOf('.'); - if (index <= 0) return null; - return path.slice(0, index); - } - - private flattenPrimitiveValues(source: Record, prefix = ''): Array<{ path: string; value: unknown }> { - const entries: Array<{ path: string; value: unknown }> = []; - for (const [key, value] of Object.entries(source ?? {})) { - const path = prefix ? `${prefix}.${key}` : key; - if (key.startsWith('__')) continue; - if (value != null && typeof value === 'object' && !Array.isArray(value)) { - entries.push(...this.flattenPrimitiveValues(value as Record, path)); - } else { - entries.push({ path, value }); - } - } - return entries; - } - - private valueToDisplayString(value: unknown): string { - if (value == null) return '-'; - if (typeof value === 'string') return value.trim().length ? value : '-'; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) return value.length ? JSON.stringify(value) : '-'; - try { - return JSON.stringify(value); - } catch { - return String(value); - } - } - private valueToEditorString(value: unknown, type: FieldType): string { if (type === 'boolean') { return value === true ? 'true' : 'false'; @@ -633,14 +605,6 @@ export class GenericNodeComponent { return null; } - private pathToLabel(path: string): string { - const lastSegment = path.split('.').at(-1) ?? path; - return lastSegment - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[_-]/g, ' ') - .replace(/^./, (c) => c.toUpperCase()); - } - private getByPath(source: Record, path: string): unknown { return path.split('.').reduce((acc, key) => { if (acc == null || typeof acc !== 'object') return undefined; @@ -704,60 +668,13 @@ export class GenericNodeComponent { try { return await firstValueFrom( - this.fieldRetreiver.isFieldRequired(blockType, field.retrieverKey, context) + this.fieldRetriever.isFieldRequired(blockType, field.retrieverKey, context) ); } catch { return false; } } - private resolveRef(node: Record, root: Record) { - if (!node || typeof node !== 'object') return node; - const ref = node['$ref']; - if (typeof ref !== 'string' || !ref.startsWith('#/')) return node; - - const path = ref.slice(2).split('/'); - let current: any = root; - for (const segment of path) { - current = current?.[segment]; - if (current == null) return node; - } - return current; - } - - private splitTemplatedTextParts(prompt: string | null): { text: string; isDynamicInput: boolean }[] { - if (!prompt) return []; - - const parts: { text: string; isDynamicInput: boolean }[] = []; - const re = /\$\{\{[^}]+\}\}/g; - let lastIndex = 0; - let match: RegExpExecArray | null; - - while ((match = re.exec(prompt)) !== null) { - if (match.index > lastIndex) { - parts.push({ - text: prompt.slice(lastIndex, match.index), - isDynamicInput: false - }); - } - - parts.push({ - text: match[0], - isDynamicInput: true - }); - lastIndex = re.lastIndex; - } - - if (lastIndex < prompt.length) { - parts.push({ - text: prompt.slice(lastIndex), - isDynamicInput: false - }); - } - - return parts; - } - private refreshView() { queueMicrotask(() => { try { diff --git a/src/app/shared/nodes/node-utility.spec.ts b/src/app/shared/nodes/node-utility.spec.ts new file mode 100644 index 0000000..9b2a760 --- /dev/null +++ b/src/app/shared/nodes/node-utility.spec.ts @@ -0,0 +1,70 @@ +import { + flattenPrimitiveValues, + parentPath, + pathToLabel, + resolveSchemaRef, + splitTemplatedTextParts, + toStringOrNull, + valueToDisplayString +} from './node-utility'; + +describe('node-utility', () => { + it('toStringOrNull returns trimmed non-empty strings and null otherwise', () => { + expect(toStringOrNull('hello')).toBe('hello'); + expect(toStringOrNull(' ')).toBeNull(); + expect(toStringOrNull(10)).toBeNull(); + }); + + it('flattenPrimitiveValues flattens nested objects and skips internal keys', () => { + const result = flattenPrimitiveValues({ + name: 'Node', + nested: { value: 5, __internal: 'skip' }, + __meta: 'skip' + }); + + expect(result).toEqual([ + { path: 'name', value: 'Node' }, + { path: 'nested.value', value: 5 } + ]); + }); + + it('valueToDisplayString formats primitive and empty values', () => { + expect(valueToDisplayString(null)).toBe('-'); + expect(valueToDisplayString('')).toBe('-'); + expect(valueToDisplayString('ok')).toBe('ok'); + expect(valueToDisplayString(1)).toBe('1'); + expect(valueToDisplayString(false)).toBe('false'); + expect(valueToDisplayString([])).toBe('-'); + }); + + it('pathToLabel builds readable labels', () => { + expect(pathToLabel('message.actionDescription')).toBe('Action Description'); + expect(pathToLabel('step_name')).toBe('Step name'); + }); + + it('parentPath returns parent path for nested keys', () => { + expect(parentPath('a.b.c')).toBe('a.b'); + expect(parentPath('root')).toBeNull(); + }); + + it('resolveSchemaRef resolves local schema refs and falls back to input node', () => { + const root = { + definitions: { + Message: { type: 'string' } + } + }; + const refNode = { $ref: '#/definitions/Message' }; + + expect(resolveSchemaRef(refNode, root)).toEqual({ type: 'string' }); + expect(resolveSchemaRef({ $ref: '#/definitions/Missing' }, root)).toEqual({ $ref: '#/definitions/Missing' }); + }); + + it('splitTemplatedTextParts identifies dynamic placeholders', () => { + const parts = splitTemplatedTextParts('Hello ${{ user.name }}!'); + expect(parts).toEqual([ + { text: 'Hello ', isDynamicInput: false }, + { text: '${{ user.name }}', isDynamicInput: true }, + { text: '!', isDynamicInput: false } + ]); + }); +}); diff --git a/src/app/shared/nodes/node-utility.ts b/src/app/shared/nodes/node-utility.ts new file mode 100644 index 0000000..9c58f71 --- /dev/null +++ b/src/app/shared/nodes/node-utility.ts @@ -0,0 +1,96 @@ +export type TemplatedTextPart = { text: string; isDynamicInput: boolean }; + +export function toStringOrNull(value: unknown): string | null { + if (typeof value === 'string' && value.trim().length > 0) return value; + return null; +} + +export function flattenPrimitiveValues( + source: Record, + prefix = '' +): Array<{ path: string; value: unknown }> { + const entries: Array<{ path: string; value: unknown }> = []; + for (const [key, value] of Object.entries(source ?? {})) { + const path = prefix ? `${prefix}.${key}` : key; + if (key.startsWith('__')) continue; + if (value != null && typeof value === 'object' && !Array.isArray(value)) { + entries.push(...flattenPrimitiveValues(value as Record, path)); + } else { + entries.push({ path, value }); + } + } + return entries; +} + +export function valueToDisplayString(value: unknown): string { + if (value == null) return '-'; + if (typeof value === 'string') return value.trim().length ? value : '-'; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return value.length ? JSON.stringify(value) : '-'; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function pathToLabel(path: string): string { + const lastSegment = path.split('.').at(-1) ?? path; + return lastSegment + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]/g, ' ') + .replace(/^./, (c) => c.toUpperCase()); +} + +export function parentPath(path: string): string | null { + const index = path.lastIndexOf('.'); + if (index <= 0) return null; + return path.slice(0, index); +} + +export function resolveSchemaRef(node: Record, root: Record) { + if (!node || typeof node !== 'object') return node; + const ref = node['$ref']; + if (typeof ref !== 'string' || !ref.startsWith('#/')) return node; + + const path = ref.slice(2).split('/'); + let current: any = root; + for (const segment of path) { + current = current?.[segment]; + if (current == null) return node; + } + return current; +} + +export function splitTemplatedTextParts(text: string | null): TemplatedTextPart[] { + if (!text) return []; + + const parts: TemplatedTextPart[] = []; + const re = /\$\{\{[^}]+\}\}/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = re.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push({ + text: text.slice(lastIndex, match.index), + isDynamicInput: false + }); + } + + parts.push({ + text: match[0], + isDynamicInput: true + }); + lastIndex = re.lastIndex; + } + + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + isDynamicInput: false + }); + } + + return parts; +} diff --git a/src/app/shared/nodes/schema-requirements.ts b/src/app/shared/nodes/schema-requirements.ts index 6778dc8..e442947 100644 --- a/src/app/shared/nodes/schema-requirements.ts +++ b/src/app/shared/nodes/schema-requirements.ts @@ -1,3 +1,5 @@ +import { resolveSchemaRef } from './node-utility'; + export type RequiredField = { path: string; label: string; @@ -37,7 +39,7 @@ function walkSchema( seenRequired: Set, seenConditional: Set ) { - const resolved = resolveRef(node, root); + const resolved = resolveSchemaRef(node, root); if (!resolved || typeof resolved !== 'object') return; const properties = resolved.properties as Record | undefined; @@ -47,7 +49,7 @@ function walkSchema( for (const [key, propertySchema] of Object.entries(properties)) { const propertyPath = pathPrefix ? `${pathPrefix}.${key}` : key; - const propertyResolved = resolveRef(propertySchema as Record, root); + const propertyResolved = resolveSchemaRef(propertySchema as Record, root); const hasChildren = !!propertyResolved?.properties || propertyResolved?.type === 'object'; const label = toLabel(key); @@ -88,20 +90,6 @@ function walkSchema( } } -function resolveRef(node: Record, root: Record) { - if (!node || typeof node !== 'object') return node; - const ref = node['$ref']; - if (typeof ref !== 'string' || !ref.startsWith('#/')) return node; - - const path = ref.slice(2).split('/'); - let current: any = root; - for (const segment of path) { - current = current?.[segment]; - if (current == null) return node; - } - return current; -} - function toLabel(key: string): string { return key .replace(/([a-z0-9])([A-Z])/g, '$1 $2') 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 f08bb9f..fe26d44 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 @@ -28,6 +28,16 @@ box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.22), 0 10px 24px rgba(15, 23, 42, 0.12); } +.llm-node-completed { + border: 2px solid #16a34a; + box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.2), 0 10px 24px rgba(15, 23, 42, 0.12); +} + +.llm-node-attention { + border: 2px solid #f97316; + animation: llmAttentionBorderPulse 1.2s ease-in-out infinite; +} + .llm-error-alert-wrap, .llm-warning-alert-wrap { position: absolute; @@ -35,6 +45,33 @@ z-index: 120; } +.llm-attention-wrap { + position: absolute; + left: 50%; + top: -14px; + transform: translateX(-50%); + z-index: 125; +} + +.llm-attention-trigger { + width: 34px; + height: 34px; + border: 1px solid #fdba74; + border-radius: 999px; + background: #f97316; + color: #fff7ed; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.45); + animation: llmAttentionIconPulse 1.1s ease-in-out infinite; +} + +.llm-attention-trigger i { + font-size: 17px; +} + .llm-error-alert-wrap { right: -12px; } @@ -226,6 +263,19 @@ justify-self: end; } +.llm-socket-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.llm-socket-wrap-output { + justify-self: end; +} + .llm-pill { height: 24px; border-radius: 7px; @@ -236,6 +286,15 @@ display: inline-flex; align-items: center; min-width: 0; + position: relative; + overflow: visible; +} + +.llm-pill-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .llm-pill-input { @@ -254,28 +313,69 @@ border-color: #fecdd3; } -.llm-input-info-wrap { +.llm-input-value-wrap { position: relative; - display: inline-flex; + display: flex; align-items: center; + justify-content: center; + width: 16px; + height: 16px; } -.llm-input-info { +.llm-input-value-icon { border: none; background: transparent; - color: #166534; - width: 14px; - height: 14px; + color: #68ae82; + width: 16px; + height: 16px; display: inline-flex; align-items: center; justify-content: center; padding: 0; cursor: help; + border-radius: 999px; } -.llm-input-tooltip { +.llm-input-value-icon:hover { + background: rgba(104, 174, 130, 0.22); +} + +.llm-input-value-icon i, +.llm-output-value-icon i { + font-size: 16px; + line-height: 1; +} + +.llm-output-value-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + justify-self: end; +} + +.llm-output-value-icon { + border: none; + background: transparent; + color: #be123c; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: help; + border-radius: 999px; +} + +.llm-output-value-icon:hover { + background: rgba(225, 29, 72, 0.12); +} + +.llm-io-tooltip { position: absolute; - left: calc(100% + 6px); top: 50%; transform: translateY(-50%); min-width: 140px; @@ -296,12 +396,108 @@ pointer-events: none; } -.llm-input-info-wrap:hover .llm-input-tooltip, -.llm-input-info-wrap:focus-within .llm-input-tooltip { +.llm-io-tooltip-input { + left: calc(100% + 8px); +} + +.llm-io-tooltip-output { + right: calc(100% + 8px); +} + +.llm-input-value-wrap:hover .llm-io-tooltip, +.llm-input-value-wrap:focus-within .llm-io-tooltip, +.llm-output-value-wrap:hover .llm-io-tooltip, +.llm-output-value-wrap:focus-within .llm-io-tooltip, +.llm-socket-wrap:hover .llm-io-tooltip, +.llm-socket-wrap:focus-within .llm-io-tooltip { opacity: 1; visibility: visible; } +.llm-modal-backdrop { + position: absolute; + inset: 0; + border-radius: 16px; + background: rgba(15, 23, 42, 0.4); + z-index: 180; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; +} + +.llm-modal { + width: 100%; + max-width: 250px; + border: 1px solid #cbd5e1; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.28); + padding: 10px; +} + +.llm-modal-title { + font-size: 12px; + font-weight: 700; + color: #0f172a; + margin-bottom: 8px; +} + +.llm-modal-content { + font-size: 11px; + color: #334155; + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} + +.llm-modal-actions { + display: flex; + justify-content: flex-end; +} + +.llm-btn { + border: none; + border-radius: 8px; + padding: 6px 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.llm-btn-primary { + background: #2563eb; + color: #eff6ff; +} + +@keyframes llmAttentionBorderPulse { + 0% { + box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.38), 0 10px 24px rgba(15, 23, 42, 0.12); + } + 50% { + box-shadow: 0 0 0 4px rgba(249, 115, 22, 0.14), 0 10px 24px rgba(15, 23, 42, 0.12); + } + 100% { + box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.05), 0 10px 24px rgba(15, 23, 42, 0.12); + } +} + +@keyframes llmAttentionIconPulse { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.45); + } + 70% { + transform: scale(1.08); + box-shadow: 0 0 0 7px rgba(249, 115, 22, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(249, 115, 22, 0); + } +} + .llm-params { margin: 0 12px 12px; padding: 10px; 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 81f3360..0a84570 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,7 +1,20 @@
+ @if (needsAttention()) { +
+ +
+ } @if (hasExecutionErrors()) {
@@ -50,32 +63,38 @@
Inputs
@for (input of inputs; track input.key) {
-
-
- - {{ input.key }} + @if (isInputConnected(input.key)) { + +
+
@if (executionInputTooltip(input.key); as inputTooltip) { - - - {{ inputTooltip }} - + {{ inputTooltip }} }
+ } @else { + + + {{ inputValueTooltip(input.key) }} + + } + + {{ input.key }} +
}
@@ -84,19 +103,38 @@
Outputs
@for (output of outputs; track output.key) {
- {{ output.key }} -
-
+ + {{ output.key }} + + @if (isOutputConnected(output.key)) { + +
+
+ @if (executionOutputTooltip(output.key); as outputTooltip) { + {{ outputTooltip }} + } +
+ } @else { + + + {{ outputValueTooltip(output.key) }} + + }
}
@@ -153,4 +191,19 @@
} + + @if (interactionModalOpen) { +
+
+
Step Waiting
+
+
Node: {{ name }}
+
Status: {{ stepStatus() || 'UNKNOWN' }}
+
+
+ +
+
+
+ } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index 0387ee3..71d0f3e 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -3,6 +3,15 @@ import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angul import { ClassicPreset } from 'rete'; import { ReteModule } from 'rete-angular-plugin/21'; import { BlocksService } from '@services/blocks/blocks'; +import { + flattenPrimitiveValues, + parentPath, + pathToLabel, + resolveSchemaRef, + splitTemplatedTextParts, + toStringOrNull, + valueToDisplayString +} from '../node-utility'; type DisplayField = { path: string; @@ -50,6 +59,7 @@ export class TaskStepNodeComponent { name = 'Step'; mainContent: MainContentView | null = null; + interactionModalOpen = false; private blockSchema: Record | null = null; private variablePlaceholderPaths = new Set(); @@ -74,14 +84,14 @@ export class TaskStepNodeComponent { private rebuildDisplayState() { const config = this.blockConfiguration ?? {}; - this.name = this.toStringOrNull(config['name']) ?? this.name; - const primitiveEntries = this.flattenPrimitiveValues(config); + this.name = toStringOrNull(config['name']) ?? this.name; + const primitiveEntries = flattenPrimitiveValues(config); const contentEntry = this.pickMainContentEntry(primitiveEntries); this.mainContent = contentEntry ? { path: contentEntry.path, - label: this.pathToLabel(contentEntry.path), + label: pathToLabel(contentEntry.path), parts: this.toMainContentParts(contentEntry.path, String(contentEntry.value)) } : null; @@ -93,26 +103,26 @@ export class TaskStepNodeComponent { .filter((entry) => entry.path !== contentEntry?.path) .map((entry) => ({ path: entry.path, - label: this.pathToLabel(entry.path), - value: this.valueToDisplayString(entry.value) + label: pathToLabel(entry.path), + value: valueToDisplayString(entry.value) })); for (const field of orderedFields) { - const parentPath = this.parentPath(field.path); - if (!parentPath) { + const parentKey = parentPath(field.path); + if (!parentKey) { rootFields.push(field); continue; } - if (!grouped.has(parentPath)) { - grouped.set(parentPath, []); + if (!grouped.has(parentKey)) { + grouped.set(parentKey, []); } - grouped.get(parentPath)!.push(field); + grouped.get(parentKey)!.push(field); } this.parameterFields = rootFields; this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({ key, - legend: this.pathToLabel(key), + legend: pathToLabel(key), fields })); @@ -158,6 +168,31 @@ export class TaskStepNodeComponent { if (!values || !Object.prototype.hasOwnProperty.call(values, inputName)) return null; const value = values[inputName]; + if (value == null) return 'not ready yet'; + if (typeof value === 'string') return value.trim().length > 0 ? value : null; + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + isInputConnected(inputName: string): boolean { + const connectedInputs = this.blockConfiguration?.['__connectedInputs']; + if (!Array.isArray(connectedInputs)) return true; + return connectedInputs.includes(inputName); + } + + inputValueTooltip(inputName: string): string { + return this.executionInputTooltip(inputName) ?? 'not ready yet'; + } + + executionOutputTooltip(outputName: string): string | null { + const values = this.blockConfiguration?.['__executionOutputs'] as Record | undefined; + if (!values || !Object.prototype.hasOwnProperty.call(values, outputName)) return null; + + const value = values[outputName]; if (value === undefined) return null; if (typeof value === 'string') return value.trim().length > 0 ? value : null; @@ -168,6 +203,16 @@ export class TaskStepNodeComponent { } } + isOutputConnected(outputName: string): boolean { + const connectedOutputs = this.blockConfiguration?.['__connectedOutputs']; + if (!Array.isArray(connectedOutputs)) return true; + return connectedOutputs.includes(outputName); + } + + outputValueTooltip(outputName: string): string { + return this.executionOutputTooltip(outputName) ?? 'No output result'; + } + executionErrors(): string[] { return this.getExecutionMessages('__executionErrors'); } @@ -184,6 +229,31 @@ export class TaskStepNodeComponent { return this.executionWarnings().length > 0; } + needsAttention(): boolean { + return this.blockConfiguration?.['__isWaitingStep'] === true; + } + + isCompleted(): boolean { + return this.stepStatus() === 'COMPLETED'; + } + + stepStatus(): string { + const status = this.blockConfiguration?.['__stepStatus']; + return typeof status === 'string' ? status.toUpperCase() : ''; + } + + openInteractionModal(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.interactionModalOpen = true; + } + + closeInteractionModal(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.interactionModalOpen = false; + } + private get blockConfiguration(): Record | null { return this.data?.data?.specificConfiguration ?? null; } @@ -193,25 +263,6 @@ export class TaskStepNodeComponent { return typeof typeName === 'string' && typeName.length > 0 ? typeName : null; } - private toStringOrNull(value: unknown): string | null { - if (typeof value === 'string' && value.trim().length > 0) return value; - return null; - } - - private flattenPrimitiveValues(source: Record, prefix = ''): Array<{ path: string; value: unknown }> { - const entries: Array<{ path: string; value: unknown }> = []; - for (const [key, value] of Object.entries(source ?? {})) { - const path = prefix ? `${prefix}.${key}` : key; - if (key.startsWith('__')) continue; - if (value != null && typeof value === 'object' && !Array.isArray(value)) { - entries.push(...this.flattenPrimitiveValues(value as Record, path)); - } else { - entries.push({ path, value }); - } - } - return entries; - } - private pickMainContentEntry(entries: Array<{ path: string; value: unknown }>) { const candidates = entries .filter((entry) => !['name', 'type'].includes(entry.path)) @@ -235,32 +286,6 @@ export class TaskStepNodeComponent { return { path: chosen.path, value: chosen.text }; } - private valueToDisplayString(value: unknown): string { - if (value == null) return '-'; - if (typeof value === 'string') return value.trim().length ? value : '-'; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) return value.length ? JSON.stringify(value) : '-'; - try { - return JSON.stringify(value); - } catch { - return String(value); - } - } - - private pathToLabel(path: string): string { - const lastSegment = path.split('.').at(-1) ?? path; - return lastSegment - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[_-]/g, ' ') - .replace(/^./, (c) => c.toUpperCase()); - } - - private parentPath(path: string): string | null { - const index = path.lastIndexOf('.'); - if (index <= 0) return null; - return path.slice(0, index); - } - private getExecutionMessages(key: '__executionErrors' | '__executionWarnings'): string[] { const values = this.blockConfiguration?.[key]; if (!Array.isArray(values)) return []; @@ -269,44 +294,11 @@ export class TaskStepNodeComponent { private toMainContentParts(path: string, value: string): { text: string; isDynamicInput: boolean }[] { if (this.variablePlaceholderPaths.has(path)) { - return this.splitTemplatedTextParts(value); + return splitTemplatedTextParts(value); } return [{ text: value, isDynamicInput: false }]; } - private splitTemplatedTextParts(text: string | null): { text: string; isDynamicInput: boolean }[] { - if (!text) return []; - - const parts: { text: string; isDynamicInput: boolean }[] = []; - const re = /\$\{\{[^}]+\}\}/g; - let lastIndex = 0; - let match: RegExpExecArray | null; - - while ((match = re.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push({ - text: text.slice(lastIndex, match.index), - isDynamicInput: false - }); - } - - parts.push({ - text: match[0], - isDynamicInput: true - }); - lastIndex = re.lastIndex; - } - - if (lastIndex < text.length) { - parts.push({ - text: text.slice(lastIndex), - isDynamicInput: false - }); - } - - return parts; - } - private async loadSchemaContext() { const type = this.blockType; if (!type) return; @@ -322,14 +314,14 @@ export class TaskStepNodeComponent { if (!schema) return paths; const walk = (node: Record, pathPrefix: string) => { - const resolved = this.resolveRef(node, schema); + const resolved = resolveSchemaRef(node, schema); if (!resolved || typeof resolved !== 'object') return; const properties = resolved.properties as Record | undefined; if (!properties) return; for (const [key, childSchema] of Object.entries(properties)) { - const childResolved = this.resolveRef(childSchema as Record, schema); + const childResolved = resolveSchemaRef(childSchema as Record, schema); const path = pathPrefix ? `${pathPrefix}.${key}` : key; const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; if (hasChildren) { @@ -352,20 +344,6 @@ export class TaskStepNodeComponent { return paths; } - private resolveRef(node: Record, root: Record) { - if (!node || typeof node !== 'object') return node; - const ref = node['$ref']; - if (typeof ref !== 'string' || !ref.startsWith('#/')) return node; - - const path = ref.slice(2).split('/'); - let current: any = root; - for (const segment of path) { - current = current?.[segment]; - if (current == null) return node; - } - return current; - } - private refreshView() { queueMicrotask(() => { try { diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index cdb58bb..6a96344 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -1,35 +1,35 @@ -
+
@if (execution()) {
-
+

{{ execution()!.name }}

Execution ID: {{ execution()!.id }}

-
-
+
+
Status
{{ execution()!.context.status }}
-
+
Start
{{ execution()!.context.startTime ? (execution()!.context.startTime | date:'dd/MM/yyyy HH:mm:ss') : '-' }}
-
+
End
{{ execution()!.context.endTime ? (execution()!.context.endTime | date:'dd/MM/yyyy HH:mm:ss') : '-' }}
-
+
Duration
{{ durationMs() != null ? durationMs() + ' ms' : '-' }}
-
+
Steps
{{ stepsArray().length }}
-
+
Execution Graph (read-only)
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 86f7e4a..f5c15c8 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -19,14 +19,24 @@ export class TaskExecutionViewerComponent { readonly executionFlowData = computed(() => { const contextInputs = this.execution()?.context.inputs ?? {}; + const contextResults = { + ...(this.execution()?.context.result ?? {}), + ...(this.execution()?.context.executionResult ?? {}) + }; const contextErrors = this.execution()?.context.errors ?? {}; const contextWarnings = this.execution()?.context.warnings ?? {}; + const waitingSteps = this.execution()?.context.waitingSteps ?? []; const steps = this.stepsArray(); const blocks = steps.map((step, index) => ({ ...step.block, specificConfiguration: { ...(step.block.specificConfiguration ?? {}), + __stepStatus: step.status, + __isWaitingStep: waitingSteps.includes(step.id), __executionInputs: this.getExecutionInputValues(step, contextInputs), + __connectedInputs: this.getConnectedInputs(step), + __executionOutputs: this.getExecutionOutputValues(step, contextResults), + __connectedOutputs: this.getConnectedOutputs(step), __executionErrors: this.getExecutionErrors(step.id, contextErrors), __executionWarnings: this.getExecutionWarnings(step.id, contextWarnings) }, @@ -107,6 +117,39 @@ export class TaskExecutionViewerComponent { return result; } + private getConnectedInputs(step: TaskExecutionStep): string[] { + return (step.inputs ?? []) + .filter((input) => input.registered) + .map((input) => input.descriptor?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + } + + private getExecutionOutputValues( + step: TaskExecutionStep, + contextResults: Record + ): Record { + const result: Record = {}; + + for (const output of step.outputs ?? []) { + const outputName = output.descriptor?.name; + if (!outputName) continue; + + const key = `${step.id}:${outputName}`; + if (Object.prototype.hasOwnProperty.call(contextResults, key)) { + result[outputName] = contextResults[key]; + } + } + + return result; + } + + private getConnectedOutputs(step: TaskExecutionStep): string[] { + return (step.outputs ?? []) + .filter((output) => output.connected) + .map((output) => output.descriptor?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + } + private getExecutionErrors( stepId: string, contextErrors: Record diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 12dbfa0..9f7156e 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -2,6 +2,7 @@ import { AuthorizationCallFakeService } from "@services/authorization/authorizat import { BlocksCallServiceFake } from "@services/blocks/blocks-call.fake"; import { FlowsCallServiceFake } from "@services/flows/flows-call.fake"; import { FieldRetreiverCallServiceFake } from "@services/retreiver/field-retreiver-call.fake"; +import { TaskExecutionsCallServiceFake } from "@services/task-executions/task-executions-call.fake"; export const environment = { production: false, @@ -9,5 +10,6 @@ export const environment = { authorizationCallService: AuthorizationCallFakeService, // Assign the appropriate service here flowsCallService: FlowsCallServiceFake, blocksCallService: BlocksCallServiceFake, - fieldRetreiverCallService: FieldRetreiverCallServiceFake + fieldRetreiverCallService: FieldRetreiverCallServiceFake, + taskExecutionsCallService: TaskExecutionsCallServiceFake }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index d41d25e..83cd28b 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -2,6 +2,7 @@ import { AuthorizationCallService } from "@services/authorization/authorization- import { BlocksCallService } from "@services/blocks/blocks-call"; import { FlowsCallService } from "@services/flows/flows-call"; import { FieldRetreiverCallService } from "@services/retreiver/field-retreiver-call"; +import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call"; export const environment = { apiUrl: '/api', @@ -9,5 +10,6 @@ export const environment = { authorizationCallService: AuthorizationCallService, flowsCallService: FlowsCallService, blocksCallService: BlocksCallService, - fieldRetreiverCallService: FieldRetreiverCallService + fieldRetreiverCallService: FieldRetreiverCallService, + taskExecutionsCallService: TaskExecutionsCallService };