moved task in a service

This commit is contained in:
Lucio Lelii 2026-03-05 14:58:24 +01:00
parent 36167f13ac
commit 1c5184892d
16 changed files with 1205 additions and 535 deletions

View File

@ -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<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'
}
}
}
]);
private taskExecutionsService = inject(TaskExecutionsService);
readonly executions = signal<TaskExecutionListItem[]>([
{
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<string | null>(this.executions()[0]?.id ?? null);
readonly executions = computed<TaskExecutionListItem[]>(() =>
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<string | null>(null);
readonly selectedExecution = computed<TaskExecution | null>(() => {
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}`;
}
}

View File

@ -0,0 +1,7 @@
import { TaskExecution } from '@models/task-execution';
import { Observable } from 'rxjs';
export abstract class TaskExecutionsCallServiceBase {
abstract retrieveAllTaskExecutions(): Observable<TaskExecution[]>;
}

View File

@ -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<TaskExecution[]> {
return of(this.data);
}
}

View File

@ -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<TaskExecution[]> {
throw new Error('Method not implemented.');
}
}

View File

@ -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<TaskExecution[]>([]);
taskExecutions = this._taskExecutions.asReadonly();
init() {
if (this.initialized) return;
this.initialized = true;
this.refresh();
}
refresh() {
this.taskExecutionsCallService.retrieveAllTaskExecutions().subscribe((taskExecutions) => {
this._taskExecutions.set(taskExecutions);
});
}
}

View File

@ -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<string, any>, 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<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = this.resolveRef(childSchema as Record<string, any>, schema);
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, 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<string, any> | 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<string, unknown> | undefined;
if (!properties || !properties[segment]) return null;
current = this.resolveRef(properties[segment] as Record<string, any>, root);
current = resolveSchemaRef(properties[segment] as Record<string, any>, 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<string, any>, 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<string, any>, 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<string, any>, path: string): unknown {
return path.split('.').reduce<unknown>((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<string, any>, root: Record<string, any>) {
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 {

View File

@ -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 }
]);
});
});

View File

@ -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<string, any>,
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<string, any>, 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<string, any>, root: Record<string, any>) {
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;
}

View File

@ -1,3 +1,5 @@
import { resolveSchemaRef } from './node-utility';
export type RequiredField = {
path: string;
label: string;
@ -37,7 +39,7 @@ function walkSchema(
seenRequired: Set<string>,
seenConditional: Set<string>
) {
const resolved = resolveRef(node, root);
const resolved = resolveSchemaRef(node, root);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | 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<string, any>, root);
const propertyResolved = resolveSchemaRef(propertySchema as Record<string, any>, root);
const hasChildren = !!propertyResolved?.properties || propertyResolved?.type === 'object';
const label = toLabel(key);
@ -88,20 +90,6 @@ function walkSchema(
}
}
function resolveRef(node: Record<string, any>, root: Record<string, any>) {
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')

View File

@ -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;

View File

@ -1,7 +1,20 @@
<div class="llm-node"
[class.llm-node--human]="isHumanNode()"
[class.llm-node-completed]="isCompleted() && !needsAttention() && !hasExecutionErrors() && !hasExecutionWarnings()"
[class.llm-node-attention]="needsAttention()"
[class.llm-node-error]="hasExecutionErrors()"
[class.llm-node-warning]="hasExecutionWarnings() && !hasExecutionErrors()">
@if (needsAttention()) {
<div class="llm-attention-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button
type="button"
class="llm-attention-trigger"
aria-label="Open waiting step details"
(click)="openInteractionModal($event)">
<i class="bi bi-hand-index-thumb-fill"></i>
</button>
</div>
}
@if (hasExecutionErrors()) {
<div class="llm-error-alert-wrap">
<div class="llm-error-alert">
@ -50,32 +63,38 @@
<div class="llm-column-title">Inputs</div>
@for (input of inputs; track input.key) {
<div class="llm-row llm-row-input">
<div
refComponent
class="llm-socket"
[data]="{
type: 'socket',
side: 'input',
key: input.key,
nodeId: data.id,
payload: input.socket
}"
[emit]="emit">
</div>
<span class="llm-pill llm-pill-input">
{{ input.key }}
@if (isInputConnected(input.key)) {
<span class="llm-socket-wrap llm-socket-wrap-input">
<div
refComponent
class="llm-socket"
[data]="{
type: 'socket',
side: 'input',
key: input.key,
nodeId: data.id,
payload: input.socket
}"
[emit]="emit">
</div>
@if (executionInputTooltip(input.key); as inputTooltip) {
<span class="llm-input-info-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button
type="button"
class="llm-input-info"
aria-label="Show input value">
<i class="bi bi-info-circle"></i>
</button>
<span class="llm-input-tooltip">{{ inputTooltip }}</span>
</span>
<span class="llm-io-tooltip llm-io-tooltip-input">{{ inputTooltip }}</span>
}
</span>
} @else {
<span class="llm-input-value-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button
type="button"
class="llm-input-value-icon"
aria-label="Show input value">
<i class="bi bi-box-arrow-in-right"></i>
</button>
<span class="llm-io-tooltip llm-io-tooltip-input">{{ inputValueTooltip(input.key) }}</span>
</span>
}
<span class="llm-pill llm-pill-input">
<span class="llm-pill-label">{{ input.key }}</span>
</span>
</div>
}
</div>
@ -84,19 +103,38 @@
<div class="llm-column-title llm-column-title-right">Outputs</div>
@for (output of outputs; track output.key) {
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output">{{ output.key }}</span>
<div
refComponent
class="llm-socket llm-socket-right"
[data]="{
type: 'socket',
side: 'output',
key: output.key,
nodeId: data.id,
payload: output.socket
}"
[emit]="emit">
</div>
<span class="llm-pill llm-pill-output">
<span class="llm-pill-label">{{ output.key }}</span>
</span>
@if (isOutputConnected(output.key)) {
<span class="llm-socket-wrap llm-socket-wrap-output">
<div
refComponent
class="llm-socket llm-socket-right"
[data]="{
type: 'socket',
side: 'output',
key: output.key,
nodeId: data.id,
payload: output.socket
}"
[emit]="emit">
</div>
@if (executionOutputTooltip(output.key); as outputTooltip) {
<span class="llm-io-tooltip llm-io-tooltip-output">{{ outputTooltip }}</span>
}
</span>
} @else {
<span class="llm-output-value-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button
type="button"
class="llm-output-value-icon"
aria-label="Show output result">
<i class="bi bi-box-arrow-right"></i>
</button>
<span class="llm-io-tooltip llm-io-tooltip-output">{{ outputValueTooltip(output.key) }}</span>
</span>
}
</div>
}
</div>
@ -153,4 +191,19 @@
</div>
}
</div>
@if (interactionModalOpen) {
<div class="llm-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="closeInteractionModal($event)">
<div class="llm-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div class="llm-modal-title">Step Waiting</div>
<div class="llm-modal-content">
<div><strong>Node:</strong> {{ name }}</div>
<div><strong>Status:</strong> {{ stepStatus() || 'UNKNOWN' }}</div>
</div>
<div class="llm-modal-actions">
<button type="button" class="llm-btn llm-btn-primary" (click)="closeInteractionModal($event)">Close</button>
</div>
</div>
</div>
}
</div>

View File

@ -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<string, any> | null = null;
private variablePlaceholderPaths = new Set<string>();
@ -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<string, unknown> | 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<string, any> | 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<string, any>, 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<string, any>, 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<string, any>, 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<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = this.resolveRef(childSchema as Record<string, any>, schema);
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, 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<string, any>, root: Record<string, any>) {
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 {

View File

@ -1,35 +1,35 @@
<section class="h-full p-4">
<section class="h-full p-1">
@if (execution()) {
<div class="h-full bg-white border border-slate-200 rounded-md flex flex-col overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 bg-slate-50">
<div class="px-5 pt-2 border-b border-slate-200 bg-slate-50">
<h2 class="text-base font-semibold text-slate-900">{{ execution()!.name }}</h2>
<p class="text-sm text-slate-500">Execution ID: {{ execution()!.id }}</p>
</div>
<div class="p-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-slate-200">
<div class="bg-slate-50 border border-slate-200 rounded-md p-3">
<div class="p-1 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-slate-200">
<div class="bg-slate-50 border border-slate-200 rounded-md p-2">
<div class="text-xs text-slate-500">Status</div>
<div class="text-sm font-medium text-slate-800">{{ execution()!.context.status }}</div>
</div>
<div class="bg-slate-50 border border-slate-200 rounded-md p-3">
<div class="bg-slate-50 border border-slate-200 rounded-md p-2">
<div class="text-xs text-slate-500">Start</div>
<div class="text-sm font-medium text-slate-800">{{ execution()!.context.startTime ? (execution()!.context.startTime | date:'dd/MM/yyyy HH:mm:ss') : '-' }}</div>
</div>
<div class="bg-slate-50 border border-slate-200 rounded-md p-3">
<div class="bg-slate-50 border border-slate-200 rounded-md p-2">
<div class="text-xs text-slate-500">End</div>
<div class="text-sm font-medium text-slate-800">{{ execution()!.context.endTime ? (execution()!.context.endTime | date:'dd/MM/yyyy HH:mm:ss') : '-' }}</div>
</div>
<div class="bg-slate-50 border border-slate-200 rounded-md p-3">
<div class="bg-slate-50 border border-slate-200 rounded-md p-2">
<div class="text-xs text-slate-500">Duration</div>
<div class="text-sm font-medium text-slate-800">{{ durationMs() != null ? durationMs() + ' ms' : '-' }}</div>
</div>
<div class="bg-slate-50 border border-slate-200 rounded-md p-3">
<div class="bg-slate-50 border border-slate-200 rounded-md p-2">
<div class="text-xs text-slate-500">Steps</div>
<div class="text-sm font-medium text-slate-800">{{ stepsArray().length }}</div>
</div>
</div>
<div class="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-3 gap-4 p-4">
<div class="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-3 gap-4 p-1">
<div class="lg:col-span-2 border border-slate-200 rounded-md bg-slate-50 min-h-0 overflow-hidden">
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-white">Execution Graph (read-only)</div>
<div class="h-[calc(100%-33px)] execution-rete-readonly">

View File

@ -19,14 +19,24 @@ export class TaskExecutionViewerComponent {
readonly executionFlowData = computed<FlowData>(() => {
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<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>

View File

@ -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
};

View File

@ -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
};