added task step part and make nodes generic

This commit is contained in:
Lucio Lelii 2026-03-04 16:55:14 +01:00
parent b77ee47306
commit 36167f13ac
34 changed files with 2625 additions and 1793 deletions

View File

@ -1,4 +1,5 @@
import { Component, signal } from '@angular/core';
import { TaskExecution } from '@models/task-execution';
import { TasksExecutionsListComponent, TaskExecutionListItem } from '@shared/tasks-executions-list/tasks-executions-list';
import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer';
@ -9,37 +10,242 @@ 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'
}
}
}
]);
readonly executions = signal<TaskExecutionListItem[]>([
{
id: 'run-001',
title: 'Customer onboarding flow',
flowName: 'Onboarding',
status: 'RUNNING',
startedAt: '2026-03-03 10:21',
duration: '00:02:13'
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: 'run-002',
title: 'Weekly report generation',
flowName: 'Report Builder',
id: 'eda4040e-ae58-4ffb-a3b4-16dfef45a6c2',
title: 'Test Flow',
flowName: 'Test Flow',
status: 'COMPLETED',
startedAt: '2026-03-03 09:15',
duration: '00:06:48'
},
{
id: 'run-003',
title: 'Data enrichment pipeline',
flowName: 'Enricher',
status: 'FAILED',
startedAt: '2026-03-03 08:42',
duration: '00:01:59'
startedAt: '2026-03-03 11:51',
duration: '00:37:43'
}
]);
readonly selectedExecutionId = signal<string | null>(this.executions()[0]?.id ?? null);
readonly selectedExecution = () =>
this.executions().find((execution) => execution.id === this.selectedExecutionId()) ?? null;
this.executionDetails().find((execution) => execution.id === this.selectedExecutionId()) ?? null;
selectExecution(id: string) {
this.selectedExecutionId.set(id);

View File

@ -64,7 +64,6 @@ export type FlowBlockConfiguration =
export type LLMDescriptor = {
provider: string;
model: string;
authorization?: string;
};
export type LLMBlockConfiguration = {

View File

@ -0,0 +1,46 @@
import { FlowBlock, FlowPort } from './flow';
export type ExecutionStatus = 'RUNNING' | 'COMPLETED' | 'ERROR' | 'FAILED' | 'WAITING_FOR_INPUT' | string;
export type StepStatus = 'WAITING_FOR_INPUT' | 'FAILED' | 'COMPLETED' | 'RUNNING' | string;
export type TaskExecution = {
id: string;
name: string;
creationTime: number;
context: TaskExecutionContext;
};
export type TaskExecutionContext = {
inputs: Record<string, unknown>;
result: Record<string, unknown>;
startTime?: number | null;
endTime?: number | null;
errors: Record<string, string>;
warnings: Record<string, string>;
steps: Record<string, TaskExecutionStep>;
status: ExecutionStatus;
waitingSteps: string[];
executionResult: Record<string, unknown>;
};
export type TaskExecutionStep = {
block: FlowBlock;
id: string;
inputs: TaskExecutionStepInput[];
outputs: TaskExecutionStepOutput[];
status: StepStatus;
started: boolean;
simulated: boolean;
};
export type TaskExecutionStepInput = {
descriptor: FlowPort;
value: unknown;
registered: boolean;
set: boolean;
};
export type TaskExecutionStepOutput = {
descriptor: FlowPort;
connected: boolean;
};

View File

@ -3,7 +3,7 @@ import { Observable, of } from "rxjs";
import { BlocksCallServiceBase } from "./block-call.base";
export class BlocksCallServiceFake extends BlocksCallServiceBase {
private readonly blockTypes: BlockType[] = [
private readonly blockTypes: BlockType[] =[
{
"type": "HumanInteractionBlock",
"description": "A block that requires human interaction",
@ -27,45 +27,36 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
"type": "string"
},
"actionDescription": {
"type": "string"
"type": "string",
"x-ui-widget": "textarea",
"x-ui-placeholder": "Describe the human task",
"x-ui-accept-variable-as-placeholder": false
},
"llmDescriptor": {
"simulateWith": {
"$ref": "#/definitions/LLMDescriptor"
},
"inputAsList": {
"type": "boolean"
},
"outputAsList": {
"type": "boolean"
}
},
"required": [
"type",
"name",
"actionDescription",
"llmDescriptor",
"inputAsList",
"outputAsList"
"simulateWith"
],
"definitions": {
"LLMDescriptor": {
"type": "object",
"additionalProperties": false,
"required": [
"provider",
"model"
],
"properties": {
"provider": {
"type": "string",
"x-retriever-name": "providers",
"x-retriever-url": "/retriever/{blockType}/providers",
"x-retriever-name": "LLM",
"x-retriever-url": "/retriever/LLM/providers",
"x-retriever-owner": "LLMDescriptor"
},
"model": {
"type": "string",
"x-retriever-name": "models",
"x-retriever-url": "/retriever/{blockType}/models",
"x-retriever-name": "LLM",
"x-retriever-url": "/retriever/LLM/models",
"x-retriever-owner": "LLMDescriptor",
"x-retriever-depends-on": [
"provider"
@ -102,7 +93,11 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
"$ref": "#/definitions/LLMDescriptor"
},
"prompt": {
"type": "string"
"type": "string",
"x-ui-widget": "textarea",
"x-ui-placeholder": "Write the prompt to execute",
"x-ui-tip": "Use ${{}} to indicate variables, for example: What is the capital of ${{country}}?",
"x-ui-accept-variable-as-placeholder": true
}
},
"required": [
@ -115,21 +110,17 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
"LLMDescriptor": {
"type": "object",
"additionalProperties": false,
"required": [
"provider",
"model"
],
"properties": {
"provider": {
"type": "string",
"x-retriever-name": "providers",
"x-retriever-url": "/retriever/{blockType}/providers",
"x-retriever-name": "LLM",
"x-retriever-url": "/retriever/LLM/providers",
"x-retriever-owner": "LLMDescriptor"
},
"model": {
"type": "string",
"x-retriever-name": "models",
"x-retriever-url": "/retriever/{blockType}/models",
"x-retriever-name": "LLM",
"x-retriever-url": "/retriever/LLM/models",
"x-retriever-owner": "LLMDescriptor",
"x-retriever-depends-on": [
"provider"
@ -139,14 +130,6 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
}
}
}
},
{
"type": "SourceBlock",
"description": "This type represents a source node in the workflow manager",
"userInteractive": true,
"configurationType": null,
"configurationClass": null,
"schema": null
}
];

View File

@ -112,7 +112,7 @@ const testDataFlow ={
"type" : "HumanInteractiveBlockConfiguration",
"name" : "interactive",
"actionDescription" : "Answer the question in input",
"llmDescriptor" : {
"simulateWith" : {
"provider" : "testProvider",
"model" : "testModel"
},

View File

@ -31,19 +31,11 @@ export class FieldRetreiverCallServiceFake extends FieldRetreiverCallServiceBase
}
override isFieldRequired(
blockType: string,
key: string,
_blockType: string,
_key: string,
context?: Record<string, string>
): Observable<boolean> {
if (key !== "authorization") {
return of(false);
}
const provider = context?.["provider"] ?? "";
if (blockType === "LLMBlock") {
return of(provider === "Anthropic");
}
void context;
return of(false);
}
}

View File

@ -1,4 +1,4 @@
import { Component, Input, HostBinding, ChangeDetectorRef } from "@angular/core";
import { Component, HostBinding, Input } from "@angular/core";
@Component({
template: ``,
@ -7,6 +7,7 @@ export class CustomSocket {
@Input() data!: any;
@Input() emit!: any;
@Input() rendered!: any;
private side: "input" | "output" = "input";
@HostBinding("title") get title() {
return this.data.name;
@ -21,29 +22,24 @@ export class CustomSocket {
@HostBinding("style.background")
get bg() {
return this.socketSide === "input"
return this.side === "input"
? "linear-gradient(145deg, #4ade80 0%, #16a34a 100%)"
: "linear-gradient(145deg, #fb7185 0%, #dc2626 100%)";
}
@HostBinding("style.boxShadow")
get sh() {
const c = this.socketSide === "input" ? "rgba(22,163,74,0.45)" : "rgba(220,38,38,0.45)";
const c = this.side === "input" ? "rgba(22,163,74,0.45)" : "rgba(220,38,38,0.45)";
return `0 2px 6px ${c}, 0 0 0 1px ${c}`;
}
private get socketSide(): "input" | "output" {
private resolveSocketSide(): "input" | "output" {
const side = this.data?.__hfSide ?? this.data?.side;
return side === "output" ? "output" : "input";
}
constructor(private cdr: ChangeDetectorRef) {
this.cdr.detach();
}
ngOnChanges(): void {
this.cdr.detectChanges();
this.side = this.resolveSocketSide();
requestAnimationFrame(() => this.rendered());
}
}

View File

@ -23,15 +23,15 @@
</span>
</div>
<div class="mt-2 text-[11px] text-slate-500 flex justify-between items-center gap-2" (click)="$event.stopPropagation()">
<div class="mt-2 text-[11px] text-slate-500 flex justify-between items-center gap-2">
<span>{{ flow().createdAt | date:'dd/MM/yyyy' }}</span>
<div class="flex items-center gap-1.5 text-sm">
<button class="p-1 rounded enabled:hover:bg-blue-100 enabled:text-blue-600 disabled:text-blue-200" title="Clone"
(click)="clone()" [disabled]="this.flow().visibility!=='PRIVATE'">
(click)="$event.stopPropagation(); clone()" [disabled]="this.flow().visibility!=='PRIVATE'">
<i class="bi bi-files"></i>
</button>
<button class="p-1 rounded enabled:hover:bg-red-100 enabled:text-red-600 disabled:text-red-200" title="Delete"
(click)="remove()" [disabled]="this.flow().visibility!=='PRIVATE'">
(click)="$event.stopPropagation(); remove()" [disabled]="this.flow().visibility!=='PRIVATE'">
<i class="bi bi-trash"></i>
</button>
</div>

View File

@ -1,6 +1,8 @@
.llm-node {
position: relative;
width: 280px;
user-select: none;
-webkit-user-select: none;
border-radius: 16px;
border: 1px solid #dbe2ea;
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
@ -9,6 +11,137 @@
color: #0f172a;
}
.llm-node--human {
border-color: #f7d7a7;
background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%);
box-shadow: 0 10px 24px rgba(124, 45, 18, 0.14);
color: #431407;
}
.llm-node-error {
border: 2px solid #dc2626;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.2), 0 10px 24px rgba(15, 23, 42, 0.12);
}
.llm-node-warning {
border: 2px solid #eab308;
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.22), 0 10px 24px rgba(15, 23, 42, 0.12);
}
.llm-error-alert-wrap {
position: absolute;
top: -12px;
right: -12px;
z-index: 120;
}
.llm-error-alert {
width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid #fecaca;
background: #dc2626;
color: #fff7ed;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 0 0 3px rgba(127, 29, 29, 0.2);
}
.llm-error-tooltip {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
max-width: 320px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fff1f2;
color: #7f1d1d;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
padding: 8px;
z-index: 130;
opacity: 0;
visibility: hidden;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
}
.llm-error-alert-wrap:hover .llm-error-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.llm-error-title {
font-size: 11px;
font-weight: 700;
margin-bottom: 4px;
}
.llm-error-item {
font-size: 11px;
line-height: 1.3;
}
.llm-warning-alert-wrap {
position: absolute;
top: -12px;
left: -12px;
z-index: 120;
}
.llm-warning-alert {
width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid #fde68a;
background: #eab308;
color: #422006;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 0 0 3px rgba(202, 138, 4, 0.22);
}
.llm-warning-list-tooltip {
position: absolute;
top: calc(100% + 8px);
left: 0;
min-width: 220px;
max-width: 320px;
border: 1px solid #fde68a;
border-radius: 8px;
background: #fef9c3;
color: #713f12;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
padding: 8px;
z-index: 130;
opacity: 0;
visibility: hidden;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
}
.llm-warning-alert-wrap:hover .llm-warning-list-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.llm-warning-list-title {
font-size: 11px;
font-weight: 700;
margin-bottom: 4px;
}
.llm-warning-list-item {
font-size: 11px;
line-height: 1.3;
}
.llm-header {
display: flex;
align-items: center;
@ -20,6 +153,23 @@
border-top-right-radius: 16px;
}
.llm-node--human .llm-header {
background: linear-gradient(135deg, #f59e0b 0%, #ea580c 100%);
border-bottom-color: #fdba74;
}
.llm-node--human .llm-icon {
color: #fffbeb;
}
.llm-node--human .llm-title {
color: #fffbeb;
}
.llm-node--human .llm-subtitle {
color: #fff7ed;
}
.llm-icon {
width: 32px;
height: 32px;
@ -238,6 +388,20 @@
color: #166534;
background: #ecfdf3;
border-color: #bbf7d0;
gap: 4px;
overflow: visible;
}
.llm-node--human .llm-pill-input {
color: #7c2d12;
background: #ffedd5;
border-color: #fed7aa;
}
.llm-node--human .llm-pill-output {
color: #9f1239;
background: #fff1f2;
border-color: #fecdd3;
}
.llm-pill-output {
@ -247,6 +411,55 @@
border-color: #fecdd3;
}
.llm-input-info {
border: none;
background: transparent;
color: #166534;
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: help;
flex-shrink: 0;
}
.llm-input-info-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.llm-input-tooltip {
position: absolute;
left: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
min-width: 140px;
max-width: 260px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #ffffff;
color: #0f172a;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
font-size: 11px;
line-height: 1.3;
padding: 6px 8px;
white-space: pre-wrap;
word-break: break-word;
z-index: 80;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.llm-input-info-wrap:hover .llm-input-tooltip,
.llm-input-info-wrap:focus-within .llm-input-tooltip {
opacity: 1;
visibility: visible;
}
.llm-params {
margin: 0 12px 12px;
padding: 10px;
@ -279,6 +492,50 @@
gap: 6px;
}
.llm-param-groups {
display: flex;
flex-direction: column;
gap: 8px;
}
.llm-param-fieldset {
margin: 0;
padding: 8px;
border: 1px solid #cbd5e1;
border-radius: 0;
background: transparent;
}
.llm-param-legend {
margin: 0;
padding: 0 4px;
font-size: 10px;
font-weight: 700;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.03em;
display: table;
max-width: 100%;
white-space: nowrap;
float: none;
width: auto;
margin-bottom: 0;
}
.llm-params-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.llm-params-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #64748b;
font-weight: 700;
}
.llm-param-chip {
border: 1px solid #dbeafe;
background: #eff6ff;

View File

@ -1,10 +1,15 @@
<div class="llm-node">
<div class="llm-node"
[class.llm-node--human]="isHumanNode()">
<div class="llm-header cursor-move">
<div class="llm-icon">
@if (isHumanNode()) {
<i class="bi bi-person-check-fill"></i>
} @else {
<img src="llm_node.png" alt="LLM" class="llm-icon-image" />
}
</div>
<div class="llm-header-content">
<span class="llm-title">LLM Node</span>
<span class="llm-title">{{ nodeTitle() }}</span>
<div class="llm-subtitle-row">
<span class="llm-subtitle">{{ name }}</span>
<button type="button" class="llm-name-edit-btn" title="Edit name" (pointerdown)="$event.stopPropagation()" (click)="openNameEditor($event)">
@ -49,7 +54,9 @@
}"
[emit]="emit">
</div>
<span class="llm-pill llm-pill-input">{{ input.key }}</span>
<span class="llm-pill llm-pill-input">
{{ input.key }}
</span>
</div>
}
</div>
@ -77,52 +84,68 @@
</div>
<div class="llm-params">
@if (parameterFieldGroups.length) {
<div class="llm-param-groups">
@for (group of parameterFieldGroups; track group.key) {
<fieldset class="llm-param-fieldset">
<legend class="llm-param-legend">{{ group.legend }}</legend>
<div class="llm-param-grid">
@for (field of group.fields; track field.path) {
<div class="llm-param-chip">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
<button
type="button"
class="llm-edit-btn"
[attr.title]="'Edit ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
</div>
<span class="llm-param-value">{{ field.value }}</span>
</div>
}
</div>
</fieldset>
}
</div>
}
@if (parameterFields.length) {
<div class="llm-param-grid">
@for (field of parameterFields; track field.path) {
<div class="llm-param-chip">
<div class="llm-param-row-head">
<span class="llm-param-key">Provider</span>
<button type="button" class="llm-edit-btn" title="Edit provider" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('provider', $event)">
<span class="llm-param-key">{{ field.label }}</span>
<button
type="button"
class="llm-edit-btn"
[attr.title]="'Edit ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
</div>
<span class="llm-param-value">{{ provider || '-' }}</span>
</div>
<div class="llm-param-chip">
<div class="llm-param-row-head">
<span class="llm-param-key">Model</span>
<button type="button" class="llm-edit-btn" title="Edit model" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('model', $event)">
<i class="bi bi-pen"></i>
</button>
</div>
<span class="llm-param-value">{{ model || '-' }}</span>
</div>
@if (isConditionallyRequired('llmDescriptor.authorization')) {
<div class="llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<span class="llm-param-key llm-param-key-label">Authorization</span>
<button type="button" class="llm-edit-btn" title="Edit authorization" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('authorization', $event)">
<i class="bi bi-pen"></i>
</button>
</div>
<span class="llm-param-value">{{ authorization || '-' }}</span>
<span class="llm-param-value">{{ field.value }}</span>
</div>
}
</div>
}
@if (hasMainContent()) {
<div class="llm-param-block">
<div class="llm-param-row-head">
<div class="llm-param-key">Prompt</div>
<button type="button" class="llm-edit-btn" title="Edit prompt" (pointerdown)="$event.stopPropagation()" (click)="openPromptEditor($event)">
<div class="llm-param-key">{{ mainContentLabel() }}</div>
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + mainContentLabel().toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor($event)">
<i class="bi bi-pen"></i>
</button>
</div>
<div class="llm-param-text">
@if (!promptParts.length) {
@if (!mainContentParts().length) {
-
} @else {
@for (part of promptParts; track $index) {
@for (part of mainContentParts(); track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
@ -132,6 +155,7 @@
}
</div>
</div>
}
</div>
@if (localEditorOpen) {
@ -142,7 +166,12 @@
<label>{{ localEditorLabel }}</label>
@if (localEditorLoading) {
<input type="text" [ngModel]="localEditorValue" placeholder="Loading..." disabled (pointerdown)="$event.stopPropagation()" />
} @else if (localEditorKey !== 'name' && localEditorOptions.length) {
} @else if (localEditorType === 'boolean') {
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
<option value="true">true</option>
<option value="false">false</option>
</select>
} @else if (localEditorOptions.length) {
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
<option value="">Select {{ localEditorLabel | lowercase }}...</option>
@for (option of localEditorOptions; track option) {
@ -150,7 +179,12 @@
}
</select>
} @else {
<input type="text" [(ngModel)]="localEditorValue" [attr.maxlength]="localEditorKey === 'name' ? 20 : null" [placeholder]="localEditorKey === 'name' ? 'Max 20 characters' : (localEditorKey === 'authorization' ? 'Token / key...' : 'Value...')" (pointerdown)="$event.stopPropagation()" />
<input
[type]="localEditorType === 'number' || localEditorType === 'integer' ? 'number' : 'text'"
[(ngModel)]="localEditorValue"
[attr.maxlength]="localEditorMaxLength"
[placeholder]="localEditorMaxLength ? 'Max ' + localEditorMaxLength + ' characters' : 'Value...'"
(pointerdown)="$event.stopPropagation()" />
}
</div>
<div class="llm-modal-actions">

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LLMNodeComponent } from './llm';
import { GenericNodeComponent } from './generic-node';
describe('LLMNodeComponent', () => {
let component: LLMNodeComponent;
let fixture: ComponentFixture<LLMNodeComponent>;
describe('GenericNodeComponent', () => {
let component: GenericNodeComponent;
let fixture: ComponentFixture<GenericNodeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LLMNodeComponent]
imports: [GenericNodeComponent]
})
.compileComponents();
fixture = TestBed.createComponent(LLMNodeComponent);
fixture = TestBed.createComponent(GenericNodeComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});

View File

@ -0,0 +1,770 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
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 { BlocksService } from '@services/blocks/blocks';
import { firstValueFrom } from 'rxjs';
import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
type FieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown';
type RetrieverDependency = {
key: string;
path: string;
};
type EditableFieldDefinition = {
path: string;
label: string;
type: FieldType;
retrieverKey: string | null;
retrieverDependsOn: RetrieverDependency[];
ui: {
widget: 'textarea' | null;
acceptVariableAsPlaceholder: boolean;
placeholder?: string;
tip?: string;
rows?: number;
};
};
type EditableFieldView = {
path: string;
label: string;
value: string;
};
type EditableFieldGroupView = {
key: string;
legend: string;
fields: EditableFieldView[];
};
@Component({
selector: 'app-generic-node',
imports: [CommonModule, FormsModule, ReteModule],
templateUrl: './generic-node.html',
styleUrl: './generic-node.css',
host: {
'data-testid': 'node'
}
})
export class GenericNodeComponent {
private settingsDialog = inject(NodeSettingsDialogService);
private editorState = inject(EditorStateHolder);
private fieldRetreiver = inject(FieldRetreiver);
private blocksService = inject(BlocksService);
private cdr = inject(ChangeDetectorRef);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@HostBinding('class.selected') get selected() {
return this.data.selected;
}
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
parameterFields: EditableFieldView[] = [];
parameterFieldGroups: EditableFieldGroupView[] = [];
name = 'noName';
localEditorOpen = false;
localEditorPath: string | null = null;
localEditorLabel = '';
localEditorValue = '';
localEditorOptions: string[] = [];
localEditorLoading = false;
localEditorType: FieldType = 'string';
localEditorMaxLength: number | null = null;
missingRequiredParams: string[] = [];
private blockSchema: Record<string, any> | null = null;
private editableFieldDefinitions: EditableFieldDefinition[] = [];
private schemaRequirements: SchemaRequirements = { required: [], conditional: [] };
private conditionalRequiredByPath = new Map<string, boolean>();
private refreshingConditionalRequirements = false;
ngOnInit() {
this.outputs = [];
this.inputs = [];
this.parameterFields = [];
this.parameterFieldGroups = [];
Object.entries(this.data.outputs).forEach(([key, output]) => {
this.outputs.push({ key, socket: (output as any).socket });
});
Object.entries(this.data.inputs).forEach(([key, input]) => {
this.inputs.push({ key, socket: (input as any).socket });
});
const config = this.ensureBlockConfiguration();
this.name = this.toStringOrNull(config['name']) || this.name;
this.refreshValidationState();
this.refreshParameterFields();
void this.loadSchemaContext();
}
ngAfterViewInit() {
this.rendered();
}
async openNameEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.localEditorPath = 'name';
this.localEditorLabel = 'Name';
this.localEditorType = 'string';
this.localEditorMaxLength = 20;
this.localEditorValue = this.name ?? '';
this.localEditorOptions = [];
this.localEditorLoading = false;
this.localEditorOpen = true;
}
async openParameterEditor(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const definition = this.editableFieldDefinitions.find((field) => field.path === path);
if (!definition) return;
if (definition.ui.widget === 'textarea') {
const currentValue = this.valueToEditorString(
this.getByPath(this.blockConfiguration ?? {}, definition.path),
definition.type
);
await this.openTextareaEditor(definition.path, definition.label, currentValue, definition.ui);
return;
}
this.localEditorPath = definition.path;
this.localEditorLabel = definition.label;
this.localEditorType = definition.type;
this.localEditorMaxLength = null;
this.localEditorValue = this.valueToEditorString(this.getByPath(this.blockConfiguration ?? {}, definition.path), definition.type);
this.localEditorOptions = [];
this.localEditorLoading = !!definition.retrieverKey;
this.localEditorOpen = true;
if (definition.retrieverKey) {
await this.loadLocalEditorOptions(definition);
}
}
closeSimpleParamEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.localEditorOpen = false;
this.localEditorPath = null;
this.localEditorLabel = '';
this.localEditorOptions = [];
this.localEditorLoading = false;
this.localEditorType = 'string';
this.localEditorMaxLength = null;
}
saveSimpleParamEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.localEditorPath) return;
const config = this.ensureBlockConfiguration();
if (this.localEditorPath === 'name') {
const nameValue = this.localEditorValue.trim().slice(0, 20);
config['name'] = nameValue;
this.name = nameValue || this.name;
} else {
const parsedValue = this.parseEditorValue(this.localEditorValue, this.localEditorType);
this.setByPath(config, this.localEditorPath, parsedValue);
}
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.closeSimpleParamEditor();
}
async openMainContentEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const contentKey = this.mainContentKey();
if (!contentKey) return;
const contentLabel = this.mainContentLabel();
const ui = this.getFieldUiMeta(contentKey);
const currentValue = String(this.getByPath(this.blockConfiguration ?? {}, contentKey) ?? '');
await this.openTextareaEditor(contentKey, contentLabel, currentValue, ui);
}
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
const deleteNode = this.data?.data?.deleteNode;
if (typeof deleteNode === 'function') {
await deleteNode();
}
}
isHumanNode(): boolean {
return this.blockType === 'HumanInteractionBlock';
}
nodeTitle(): string {
return this.isHumanNode() ? 'Human Task' : 'LLM Node';
}
mainContentLabel(): string {
const contentKey = this.mainContentKey();
if (!contentKey) return 'Content';
return this.pathToLabel(contentKey);
}
hasMainContent(): boolean {
return !!this.mainContentKey();
}
mainContentParts(): { text: string; isDynamicInput: boolean }[] {
const contentKey = this.mainContentKey();
if (!contentKey) return [];
const content = this.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 [{ text: content, isDynamicInput: false }];
}
formatDynamicInputToken(token: string): string {
const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/);
return match ? match[1] : token;
}
private get blockConfiguration(): Record<string, any> | null {
return this.data?.data?.specificConfiguration ?? null;
}
private get blockType(): string | null {
const typeName = this.data?.data?.typeName;
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
}
private ensureBlockConfiguration(): Record<string, any> {
if (!this.data?.data) {
this.data.data = {};
}
if (!this.data.data.specificConfiguration) {
this.data.data.specificConfiguration = {};
}
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;
this.editorState.updateData(flow.data);
}
private async loadSchemaContext() {
const type = this.blockType;
if (!type) return;
const blockType = await this.blocksService.getBlockType(type);
this.blockSchema = (blockType?.schema ?? null) as Record<string, any> | null;
this.schemaRequirements = extractSchemaRequirements(this.blockSchema);
this.editableFieldDefinitions = this.buildEditableFieldDefinitions(this.blockSchema);
await this.refreshConditionalRequirements();
this.refreshParameterFields();
this.refreshValidationState();
}
private async openTextareaEditor(
path: string,
label: string,
initialValue: string,
ui: { placeholder?: string; tip?: string; rows?: number }
) {
const result = await this.settingsDialog.open({
title: `${label} for "${this.name}"`,
fields: [
{
key: path,
label,
type: 'textarea',
rows: ui.rows ?? 12,
placeholder: ui.placeholder ?? '',
tip: ui.tip
}
],
initial: { [path]: initialValue }
});
if (!result) return;
const config = this.ensureBlockConfiguration();
this.setByPath(config, path, String(result[path] ?? ''));
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
}
private buildEditableFieldDefinitions(schema: Record<string, any> | null): EditableFieldDefinition[] {
if (!schema) return [];
const definitions: EditableFieldDefinition[] = [];
const seen = new Set<string>();
const contentKey = this.mainContentKey();
const walk = (node: Record<string, any>, pathPrefix: string) => {
const resolved = this.resolveRef(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 path = pathPrefix ? `${pathPrefix}.${key}` : key;
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path);
continue;
}
if (key === 'type' || key === 'name' || key.startsWith('__')) continue;
if (contentKey && path === contentKey) continue;
if (seen.has(path)) continue;
seen.add(path);
definitions.push({
path,
label: this.pathToLabel(path),
type: this.toFieldType(childResolved?.type),
retrieverKey: this.toRetrieverKey(childResolved),
retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix),
ui: this.toFieldUiMeta(childResolved)
});
}
};
walk(schema, '');
return definitions;
}
private toFieldUiMeta(schema: Record<string, any> | null | undefined) {
const rawWidget = typeof schema?.['x-ui-widget'] === 'string'
? String(schema['x-ui-widget']).toLowerCase().trim()
: '';
const normalizedWidget: 'textarea' | null =
rawWidget === 'textarea' || rawWidget === 'text-area' ? 'textarea' : null;
const placeholder = typeof schema?.['x-ui-placeholder'] === 'string'
? String(schema['x-ui-placeholder'])
: undefined;
const tip = typeof schema?.['x-ui-tip'] === 'string'
? String(schema['x-ui-tip'])
: undefined;
const rowsRaw = schema?.['x-ui-rows'];
const rows = typeof rowsRaw === 'number' && Number.isFinite(rowsRaw) && rowsRaw > 0
? Math.trunc(rowsRaw)
: undefined;
const acceptVariableAsPlaceholder = schema?.['x-ui-accept-variable-as-placeholder'] === true;
return {
widget: normalizedWidget,
acceptVariableAsPlaceholder,
placeholder,
tip,
rows
};
}
private getFieldUiMeta(path: string) {
return this.toFieldUiMeta(this.resolveFieldSchema(path));
}
private resolveFieldSchema(path: string): Record<string, any> | null {
const root = this.blockSchema;
if (!root) return null;
let current: Record<string, any> | null = root;
for (const segment of path.split('.')) {
if (!current) return null;
const resolved = this.resolveRef(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);
}
return current;
}
private toFieldType(type: unknown): FieldType {
if (type === 'string') return 'string';
if (type === 'number') return 'number';
if (type === 'integer') return 'integer';
if (type === 'boolean') return 'boolean';
return 'unknown';
}
private toRetrieverKey(schema: Record<string, any> | null | undefined): string | null {
if (!schema || typeof schema !== 'object') return null;
const retrieverName = schema['x-retriever-name'];
return typeof retrieverName === 'string' && retrieverName.length > 0 ? retrieverName : null;
}
private toRetrieverDependsOn(schema: Record<string, any> | null | undefined, pathPrefix: string): RetrieverDependency[] {
if (!schema || typeof schema !== 'object') return [];
const raw = Array.isArray(schema['x-retriever-depends-on'])
? (schema['x-retriever-depends-on'] as unknown[])
: [];
return raw
.filter((dep): dep is string => typeof dep === 'string' && dep.length > 0)
.map((dep) => ({
key: dep,
path: pathPrefix ? `${pathPrefix}.${dep}` : dep
}));
}
private async loadLocalEditorOptions(definition: EditableFieldDefinition) {
const blockType = this.blockType;
if (!blockType || !definition.retrieverKey) {
this.localEditorLoading = false;
return;
}
const context: Record<string, string> = {};
for (const dep of definition.retrieverDependsOn) {
const value = this.getByPath(this.blockConfiguration ?? {}, dep.path);
context[dep.key] = value == null ? '' : String(value);
}
try {
const options = await firstValueFrom(
this.fieldRetreiver.retrieveValues(blockType, definition.retrieverKey, context)
);
this.localEditorOptions = options ?? [];
} catch {
this.localEditorOptions = [];
} finally {
this.localEditorLoading = false;
}
}
private refreshParameterFields() {
const config = this.blockConfiguration ?? {};
const grouped = new Map<string, EditableFieldView[]>();
const rootFields: EditableFieldView[] = [];
if (this.editableFieldDefinitions.length) {
const orderedFields = this.editableFieldDefinitions.map((definition) => {
const value = this.getByPath(config, definition.path);
return {
path: definition.path,
label: definition.label,
value: this.valueToDisplayString(value)
};
});
for (const field of orderedFields) {
const parentPath = this.parentPath(field.path);
if (!parentPath) {
rootFields.push(field);
continue;
}
if (!grouped.has(parentPath)) {
grouped.set(parentPath, []);
}
grouped.get(parentPath)!.push(field);
}
this.parameterFields = rootFields;
this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({
key,
legend: this.pathToLabel(key),
fields
}));
this.refreshView();
return;
}
const contentKey = this.mainContentKey();
const fallbackFields = this.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)
}));
for (const field of fallbackFields) {
const parentPath = this.parentPath(field.path);
if (!parentPath) {
rootFields.push(field);
continue;
}
if (!grouped.has(parentPath)) {
grouped.set(parentPath, []);
}
grouped.get(parentPath)!.push(field);
}
this.parameterFields = rootFields;
this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({
key,
legend: this.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';
}
if (value == null) return '';
if (typeof value === 'string') return value;
return String(value);
}
private parseEditorValue(value: string, type: FieldType): unknown {
const trimmed = value.trim();
if (type === 'boolean') {
return trimmed === 'true';
}
if (type === 'number' || type === 'integer') {
const numeric = Number(trimmed);
if (!Number.isFinite(numeric)) return 0;
return type === 'integer' ? Math.trunc(numeric) : numeric;
}
return value;
}
private setByPath(target: Record<string, any>, path: string, value: unknown) {
const keys = path.split('.').filter(Boolean);
if (!keys.length) return;
let current: Record<string, any> = target;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const next = current[key];
if (next == null || typeof next !== 'object' || Array.isArray(next)) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
private mainContentKey(): string | null {
const config = this.blockConfiguration ?? {};
if (Object.prototype.hasOwnProperty.call(config, 'actionDescription')) return 'actionDescription';
if (Object.prototype.hasOwnProperty.call(config, 'prompt')) return 'prompt';
if (this.blockSchema?.['properties'] && typeof this.blockSchema['properties'] === 'object') {
const props = this.blockSchema['properties'] as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(props, 'actionDescription')) return 'actionDescription';
if (Object.prototype.hasOwnProperty.call(props, 'prompt')) return 'prompt';
}
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;
return (acc as Record<string, unknown>)[key];
}, source);
}
private isMissingValue(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;
return false;
}
private refreshValidationState() {
const config = this.blockConfiguration ?? {};
const requiredFields = [
...this.schemaRequirements.required,
...this.schemaRequirements.conditional.filter((field) => this.conditionalRequiredByPath.get(field.path))
].filter((field) => field.path !== 'name');
this.missingRequiredParams = requiredFields
.filter((field) => this.isMissingValue(this.getByPath(config, field.path)))
.map((field) => field.label);
if (!this.refreshingConditionalRequirements) {
void this.refreshConditionalRequirements();
}
this.refreshView();
}
private async refreshConditionalRequirements() {
if (!this.schemaRequirements.conditional.length) return;
const type = this.blockType;
if (!type) return;
this.refreshingConditionalRequirements = true;
let changed = false;
for (const field of this.schemaRequirements.conditional) {
const required = await this.fetchConditionalRequirement(type, field);
if (this.conditionalRequiredByPath.get(field.path) !== required) {
this.conditionalRequiredByPath.set(field.path, required);
changed = true;
}
}
this.refreshingConditionalRequirements = false;
if (changed) {
this.refreshValidationState();
}
}
private async fetchConditionalRequirement(blockType: string, field: ConditionalRequiredField) {
const context: Record<string, string> = {};
for (const dep of field.dependsOn) {
const value = this.getByPath(this.blockConfiguration ?? {}, dep.path);
context[dep.key] = typeof value === 'string' ? value : '';
}
try {
return await firstValueFrom(
this.fieldRetreiver.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 {
this.cdr.detectChanges();
} catch {
// Node may have been removed while async validation was running.
}
});
}
}

View File

@ -1,430 +0,0 @@
.hi-node {
position: relative;
width: 292px;
border-radius: 16px;
border: 1px solid #f7d7a7;
background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%);
box-shadow: 0 10px 24px rgba(124, 45, 18, 0.14);
color: #431407;
overflow: visible;
}
.hi-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: linear-gradient(135deg, #f59e0b 0%, #ea580c 100%);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
border-bottom: 1px solid #fdba74;
}
.hi-icon {
width: 30px;
height: 30px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fffbeb;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
.hi-header-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.hi-subtitle-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.hi-header-actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
}
.hi-warning {
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid #fecaca;
background: #dc2626;
color: #fff7ed;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 0 0 2px rgba(127, 29, 29, 0.18);
}
.hi-title {
color: #fffbeb;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
}
.hi-subtitle {
color: #fff7ed;
font-size: 11px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.hi-name-edit-btn {
border: none;
background: rgba(255, 255, 255, 0.2);
color: #fff7ed;
border-radius: 999px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.hi-name-edit-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.hi-warning-wrap {
position: relative;
margin-left: 0;
}
.hi-delete-btn {
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid rgba(127, 29, 29, 0.45);
background: rgba(127, 29, 29, 0.2);
color: #fff1f2;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s ease;
}
.hi-delete-btn:hover {
background: rgba(127, 29, 29, 0.35);
}
.hi-warning-tooltip {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
max-width: 240px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fff1f2;
color: #7f1d1d;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
padding: 8px;
z-index: 80;
opacity: 0;
visibility: hidden;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
}
.hi-warning-wrap:hover .hi-warning-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.hi-warning-title {
font-size: 11px;
font-weight: 700;
margin-bottom: 4px;
}
.hi-warning-item {
font-size: 11px;
}
.hi-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hi-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.hi-column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.hi-column-title {
font-size: 11px;
font-weight: 700;
color: #9a3412;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.hi-column-title-right {
text-align: right;
}
.hi-row {
display: grid;
align-items: center;
min-width: 0;
height: 24px;
}
.hi-row-input {
grid-template-columns: 16px 1fr;
gap: 6px;
}
.hi-row-output {
grid-template-columns: 1fr 16px;
gap: 6px;
}
.hi-socket {
position: relative;
z-index: 20;
}
.hi-socket-right {
justify-self: end;
}
.hi-pill {
height: 24px;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
padding: 0 8px;
display: inline-flex;
align-items: center;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.hi-pill-input {
color: #7c2d12;
background: #ffedd5;
border: 1px solid #fed7aa;
justify-content: flex-start;
}
.hi-pill-output {
color: #9f1239;
background: #fff1f2;
border: 1px solid #fecdd3;
justify-content: flex-end;
}
.hi-panel {
border: 1px solid #fdba74;
border-radius: 10px;
background: #fff7ed;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.hi-panel-simulated {
border-color: #f9a8d4;
background: #fff1f2;
}
.hi-panel-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hi-panel-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #c2410c;
font-weight: 700;
}
.hi-panel-text {
font-size: 11px;
color: #431407;
white-space: pre-wrap;
word-break: break-word;
}
.hi-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.hi-meta-chip {
border: 1px solid #fdba74;
background: #fff;
border-radius: 7px;
padding: 4px 6px;
color: #7c2d12;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
overflow: hidden;
}
.hi-meta-chip-wide {
grid-column: 1 / -1;
}
.hi-meta-chip-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
min-width: 0;
}
.hi-meta-chip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
line-height: 1.15;
}
.hi-meta-chip-value {
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hi-edit-btn {
border: none;
background: transparent;
color: #9a3412;
border-radius: 999px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s ease, background-color 0.15s ease;
}
.hi-edit-btn:hover {
color: #ea580c;
background: rgba(234, 88, 12, 0.12);
}
.hi-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(124, 45, 18, 0.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
}
.hi-modal {
width: min(360px, calc(100vw - 24px));
border-radius: 12px;
border: 1px solid #fdba74;
background: #fff;
box-shadow: 0 16px 40px rgba(124, 45, 18, 0.25);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hi-modal-title {
font-size: 13px;
font-weight: 700;
color: #7c2d12;
}
.hi-modal-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.hi-modal-field label {
font-size: 11px;
font-weight: 600;
color: #9a3412;
}
.hi-modal-field input,
.hi-modal-field select {
border: 1px solid #fdba74;
border-radius: 8px;
font-size: 12px;
color: #431407;
padding: 7px 8px;
}
.hi-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.hi-btn {
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
}
.hi-btn-ghost {
border-color: #fdba74;
color: #9a3412;
background: #fff;
}
.hi-btn-primary {
background: #ea580c;
color: #fff;
}

View File

@ -1,152 +0,0 @@
<div class="hi-node">
<div class="hi-header cursor-move">
<div class="hi-icon">
<i class="bi bi-person-check-fill"></i>
</div>
<div class="hi-header-content">
<span class="hi-title">Human Task</span>
<div class="hi-subtitle-row">
<span class="hi-subtitle">{{ name }}</span>
<button type="button" class="hi-name-edit-btn" title="Edit name" (pointerdown)="$event.stopPropagation()" (click)="openNameEditor($event)">
<i class="bi bi-pencil-square"></i>
</button>
</div>
</div>
<div class="hi-header-actions">
@if (missingRequiredParams.length) {
<div class="hi-warning-wrap">
<div class="hi-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="hi-warning-tooltip">
<div class="hi-warning-title">Missing required fields</div>
@for (missing of missingRequiredParams; track missing) {
<div class="hi-warning-item">{{ missing }}</div>
}
</div>
</div>
}
<button type="button" class="hi-delete-btn" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="hi-body">
<div class="hi-columns">
<div class="hi-column">
<div class="hi-column-title">Inputs</div>
@for (input of inputs; track input.key) {
<div class="hi-row hi-row-input">
<div
refComponent
class="hi-socket"
[data]="{
type: 'socket',
side: 'input',
key: input.key,
nodeId: data.id,
payload: input.socket
}"
[emit]="emit">
</div>
<span class="hi-pill hi-pill-input">{{ input.key }}</span>
</div>
}
</div>
<div class="hi-column">
<div class="hi-column-title hi-column-title-right">Outputs</div>
@for (output of outputs; track output.key) {
<div class="hi-row hi-row-output">
<span class="hi-pill hi-pill-output">{{ output.key }}</span>
<div
refComponent
class="hi-socket hi-socket-right"
[data]="{
type: 'socket',
side: 'output',
key: output.key,
nodeId: data.id,
payload: output.socket
}"
[emit]="emit">
</div>
</div>
}
</div>
</div>
<div class="hi-panel">
<div class="hi-panel-title-row">
<div class="hi-panel-title">Action</div>
<button type="button" class="hi-edit-btn" title="Edit action" (pointerdown)="$event.stopPropagation()" (click)="openActionEditor($event)">
<i class="bi bi-pen"></i>
</button>
</div>
<div class="hi-panel-text">{{ actionDescription || '-' }}</div>
</div>
<div class="hi-panel hi-panel-simulated">
<div class="hi-panel-title">Simulated with</div>
<div class="hi-meta">
<span class="hi-meta-chip">
<span class="hi-meta-chip-head">
Provider
<button type="button" class="hi-edit-btn" title="Edit provider" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('provider', $event)">
<i class="bi bi-pen"></i>
</button>
</span>
<span class="hi-meta-chip-value">{{ provider || '-' }}</span>
</span>
<span class="hi-meta-chip">
<span class="hi-meta-chip-head">
Model
<button type="button" class="hi-edit-btn" title="Edit model" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('model', $event)">
<i class="bi bi-pen"></i>
</button>
</span>
<span class="hi-meta-chip-value">{{ model || '-' }}</span>
</span>
@if (isConditionallyRequired('llmDescriptor.authorization')) {
<span class="hi-meta-chip hi-meta-chip-wide">
<span class="hi-meta-chip-head">
<span class="hi-meta-chip-label">Authorization</span>
<button type="button" class="hi-edit-btn" title="Edit authorization" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('authorization', $event)">
<i class="bi bi-pen"></i>
</button>
</span>
<span class="hi-meta-chip-value">{{ authorization || '-' }}</span>
</span>
}
</div>
</div>
</div>
@if (localEditorOpen) {
<div class="hi-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">
<div class="hi-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div class="hi-modal-title">Edit {{ localEditorLabel }}</div>
<div class="hi-modal-field">
<label>{{ localEditorLabel }}</label>
@if (localEditorLoading) {
<input type="text" [ngModel]="localEditorValue" placeholder="Loading..." disabled (pointerdown)="$event.stopPropagation()" />
} @else if (localEditorKey !== 'name' && localEditorOptions.length) {
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
<option value="">Select {{ localEditorLabel | lowercase }}...</option>
@for (option of localEditorOptions; track option) {
<option [value]="option">{{ option }}</option>
}
</select>
} @else {
<input type="text" [(ngModel)]="localEditorValue" [attr.maxlength]="localEditorKey === 'name' ? 20 : null" [placeholder]="localEditorKey === 'name' ? 'Max 20 characters' : (localEditorKey === 'authorization' ? 'Token / key...' : 'Value...')" (pointerdown)="$event.stopPropagation()" />
}
</div>
<div class="hi-modal-actions">
<button type="button" class="hi-btn hi-btn-ghost" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">Cancel</button>
<button type="button" class="hi-btn hi-btn-primary" (pointerdown)="$event.stopPropagation()" (click)="saveSimpleParamEditor($event)">Save</button>
</div>
</div>
</div>
}
</div>

View File

@ -1,338 +0,0 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
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 { BlocksService } from '@services/blocks/blocks';
import { firstValueFrom } from 'rxjs';
import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
@Component({
selector: 'app-human-interaction-node',
imports: [CommonModule, FormsModule, ReteModule],
templateUrl: './human-interaction.html',
styleUrl: './human-interaction.css',
host: {
'data-testid': 'node'
}
})
export class HumanInteractionNodeComponent {
private settingsDialog = inject(NodeSettingsDialogService);
private editorState = inject(EditorStateHolder);
private fieldRetreiver = inject(FieldRetreiver);
private blocksService = inject(BlocksService);
private cdr = inject(ChangeDetectorRef);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@HostBinding('class.selected') get selected() {
return this.data.selected;
}
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
name = 'Human Interaction';
actionDescription = '';
provider: string | null = null;
model: string | null = null;
authorization: string | null = null;
localEditorOpen = false;
localEditorKey: 'provider' | 'model' | 'authorization' | 'name' | null = null;
localEditorLabel = '';
localEditorValue = '';
localEditorOptions: string[] = [];
localEditorLoading = false;
missingRequiredParams: string[] = [];
private schemaRequirements: SchemaRequirements = { required: [], conditional: [] };
private conditionalRequiredByPath = new Map<string, boolean>();
private refreshingConditionalRequirements = false;
ngOnInit() {
this.outputs = [];
this.inputs = [];
for (const [key, output] of Object.entries(this.data.outputs ?? {})) {
this.outputs.push({ key, socket: (output as any).socket });
}
for (const [key, input] of Object.entries(this.data.inputs ?? {})) {
this.inputs.push({ key, socket: (input as any).socket });
}
const config = this.data?.data?.specificConfiguration ?? {};
this.name = (config?.name as string) || this.name;
this.actionDescription = (config?.actionDescription as string) || '';
this.provider = (config?.llmDescriptor?.provider as string) || null;
this.model = (config?.llmDescriptor?.model as string) || null;
this.authorization = (config?.llmDescriptor?.authorization as string) || null;
this.refreshValidationState();
void this.loadSchemaRequirements();
}
ngAfterViewInit() {
this.rendered();
}
async openSimpleParamEditor(key: 'provider' | 'model' | 'authorization' | 'name', event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (key === 'authorization' && !this.isConditionallyRequired('llmDescriptor.authorization')) {
return;
}
this.localEditorKey = key;
this.localEditorLabel =
key === 'provider' ? 'Provider' :
key === 'model' ? 'Model' :
key === 'authorization' ? 'Authorization' : 'Name';
this.localEditorValue =
key === 'provider' ? (this.provider ?? '') :
key === 'model' ? (this.model ?? '') :
key === 'authorization' ? (this.authorization ?? '') :
(this.name ?? '');
this.localEditorOptions = [];
this.localEditorLoading = key === 'provider' || key === 'model';
this.localEditorOpen = true;
if (key === 'provider' || key === 'model') {
await this.loadSimpleEditorOptions();
}
}
closeSimpleParamEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.localEditorOpen = false;
this.localEditorKey = null;
this.localEditorOptions = [];
this.localEditorLoading = false;
}
saveSimpleParamEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.localEditorKey) return;
const config = this.ensureBlockConfiguration();
const value = this.localEditorValue.trim();
if (this.localEditorKey === 'provider') {
const descriptor = (config['llmDescriptor'] ??= {});
descriptor['provider'] = value;
this.provider = value || null;
this.model = null;
this.authorization = null;
descriptor['model'] = '';
descriptor['authorization'] = '';
}
if (this.localEditorKey === 'model') {
const descriptor = (config['llmDescriptor'] ??= {});
descriptor['model'] = value;
this.model = value || null;
}
if (this.localEditorKey === 'authorization') {
const descriptor = (config['llmDescriptor'] ??= {});
descriptor['authorization'] = value;
this.authorization = value || null;
}
if (this.localEditorKey === 'name') {
const nameValue = value.slice(0, 20);
config['name'] = nameValue;
this.name = nameValue || this.name;
}
this.refreshValidationState();
this.markFlowDirty();
this.localEditorOpen = false;
this.localEditorKey = null;
this.localEditorOptions = [];
this.localEditorLoading = false;
}
async openActionEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const result = await this.settingsDialog.open({
title: `Action for "${this.name}"`,
fields: [
{
key: 'actionDescription',
label: 'Action description',
type: 'textarea',
rows: 8,
placeholder: 'Describe the human task...'
}
],
initial: { actionDescription: this.actionDescription ?? '' }
});
if (!result) return;
const config = this.ensureBlockConfiguration();
config['actionDescription'] = String(result['actionDescription'] ?? '');
this.actionDescription = config['actionDescription'] || '';
this.refreshValidationState();
this.markFlowDirty();
}
openNameEditor(event?: Event) {
this.openSimpleParamEditor('name', event);
}
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
const deleteNode = this.data?.data?.deleteNode;
if (typeof deleteNode === 'function') {
await deleteNode();
}
}
private ensureBlockConfiguration(): Record<string, any> {
if (!this.data?.data) {
this.data.data = {};
}
if (!this.data.data.specificConfiguration) {
this.data.data.specificConfiguration = {};
}
return this.data.data.specificConfiguration;
}
private markFlowDirty() {
const flow = this.editorState.currentFlow();
if (!flow) return;
this.editorState.updateData(flow.data);
}
private async loadSimpleEditorOptions() {
const key = this.localEditorKey;
const blockType = this.blockType;
if (!key || !blockType) {
this.localEditorLoading = false;
return;
}
const retrieverKey = key === 'provider' ? 'providers' : 'models';
const context = key === 'model' && this.provider
? { provider: this.provider }
: undefined;
try {
const options = await firstValueFrom(
this.fieldRetreiver.retrieveValues(blockType, retrieverKey, context)
);
this.localEditorOptions = options ?? [];
} catch {
this.localEditorOptions = [];
} finally {
this.localEditorLoading = false;
}
}
private get blockType(): string | null {
const typeName = this.data?.data?.typeName;
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
}
private refreshValidationState() {
const config = this.data?.data?.specificConfiguration ?? {};
const requiredFields = [
...this.schemaRequirements.required,
...this.schemaRequirements.conditional.filter((field) => this.conditionalRequiredByPath.get(field.path))
].filter((field) => field.path !== 'name');
this.missingRequiredParams = requiredFields
.filter((field) => this.isMissingValue(this.getByPath(config, field.path)))
.map((field) => field.label);
if (!this.refreshingConditionalRequirements) {
void this.refreshConditionalRequirements();
}
this.refreshView();
}
private getByPath(source: Record<string, any>, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc == null || typeof acc !== 'object') return undefined;
return (acc as Record<string, unknown>)[key];
}, source);
}
private isMissingValue(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;
return false;
}
private async loadSchemaRequirements() {
const type = this.blockType;
if (!type) return;
const blockType = await this.blocksService.getBlockType(type);
const schema = (blockType?.schema ?? null) as Record<string, unknown> | null;
this.schemaRequirements = extractSchemaRequirements(schema);
await this.refreshConditionalRequirements();
this.refreshValidationState();
}
private async refreshConditionalRequirements() {
if (!this.schemaRequirements.conditional.length) return;
const type = this.blockType;
if (!type) return;
this.refreshingConditionalRequirements = true;
let changed = false;
for (const field of this.schemaRequirements.conditional) {
const required = await this.fetchConditionalRequirement(type, field);
if (this.conditionalRequiredByPath.get(field.path) !== required) {
this.conditionalRequiredByPath.set(field.path, required);
changed = true;
}
}
this.refreshingConditionalRequirements = false;
if (changed) {
this.refreshValidationState();
}
}
private async fetchConditionalRequirement(blockType: string, field: ConditionalRequiredField) {
const context: Record<string, string> = {};
for (const dep of field.dependsOn) {
const value = this.getByPath(this.data?.data?.specificConfiguration ?? {}, dep.path);
context[dep.key] = typeof value === 'string' ? value : '';
}
try {
return await firstValueFrom(
this.fieldRetreiver.isFieldRequired(blockType, field.retrieverKey, context)
);
} catch {
return false;
}
}
isConditionallyRequired(path: string) {
return !!this.conditionalRequiredByPath.get(path);
}
private refreshView() {
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while async validation was running.
}
});
}
}

View File

@ -1,31 +0,0 @@
<div class="cursor-move user-select-none rounded-xl bg-emerald-100 shadow-lg border border-gray-200 overflow-visible">
<!-- header/row relativa -->
<div class="relative w-full p-3 pt-0 flex items-center justify-between">
<button
type="button"
class="node-delete-btn"
title="Delete node"
(pointerdown)="$event.stopPropagation()"
(click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>
<div class="grid grid-cols-4 h-full">
<div class="col-span-4 text-ls mb-2 p-1 rounded-md text-green-700 font-bold">
{{ data.label }}
</div>
<div class="col-span-3 align-middle h-full flex justify-center flex-col">
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 inset-ring inset-ring-green-600/20">Text</span>
</div>
<div class="align-middle h-full flex flex-col justify-center items-end">
<div refComponent class="absolute right-1" [data]="{
type: 'socket',
side: 'output',
key: outputKey,
nodeId: data.id,
payload: outputSocket
}" [emit]="emit"></div>
</div>
</div>
</div>
</div>

View File

@ -1,59 +0,0 @@
.io-card {
height: 60px;
width: 100px;
transform: skew(-20deg);
background-color: rgb(163, 216, 163);
}
.body {
transform: skew(20deg);
}
.title {
text-align: center;
font-size: 20px;
font-weight: bolder;
margin: 0;
padding: 0;
font-size: 13px;
padding: 0;
color: white;
font-weight: bolder;
}
.output {
text-align: right;
}
.input {
text-align: left;
}
.output-socket {
text-align: right; margin-right: -1px; display: inline-block;
}
.input-socket {
text-align: left; margin-left: -1px; display: inline-block;
}
.node-delete-btn {
position: absolute;
right: 4px;
top: 4px;
width: 22px;
height: 22px;
border: none;
border-radius: 999px;
background: rgba(185, 28, 28, 0.12);
color: #991b1b;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 20;
}
.node-delete-btn:hover {
background: rgba(185, 28, 28, 0.2);
}

View File

@ -1,58 +0,0 @@
import {
Component,
Input,
HostBinding,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { ReteModule } from "rete-angular-plugin/21";
import { ClassicPreset } from "rete";
@Component({
templateUrl: "./input-node.component.html",
styleUrls: ["./input-node.component.scss"],
imports: [CommonModule, ReteModule],
host: {
"data-testid": "node"
}
})
export class InputNodeComponent {
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@HostBinding("class.selected") get selected() {
return this.data.selected;
}
outputKey!: string;
outputSocket!: ClassicPreset.Socket;
ngOnInit() {
const entries = Object.entries(this.data.outputs);
const [key, output] = entries[0];
console.log("InputNodeComponent ngOnInit", key, output);
this.outputKey = key;
this.outputSocket = (output as any).socket;
}
ngAfterViewInit() {
this.rendered();
}
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
const deleteNode = this.data?.data?.deleteNode;
if (typeof deleteNode === 'function') {
await deleteNode();
}
}
}

View File

@ -1,415 +0,0 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
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 { BlocksService } from '@services/blocks/blocks';
import { firstValueFrom } from 'rxjs';
import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
@Component({
selector: 'app-llm',
imports: [CommonModule, FormsModule, ReteModule],
templateUrl: './llm.html',
styleUrl: './llm.css',
host: {
"data-testid": "node"
}
})
export class LLMNodeComponent {
private settingsDialog = inject(NodeSettingsDialogService);
private editorState = inject(EditorStateHolder);
private fieldRetreiver = inject(FieldRetreiver);
private blocksService = inject(BlocksService);
private cdr = inject(ChangeDetectorRef);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@HostBinding("class.selected") get selected() {
return this.data.selected;
}
outputs : { key:string, socket: ClassicPreset.Socket }[] = [];
inputs: { key:string, socket: ClassicPreset.Socket } [] = [];
parameterEntries: { key: string; value: string }[] = [];
prompt: string | null = null;
promptParts: { text: string; isDynamicInput: boolean }[] = [];
provider: string | null = null;
model: string | null = null;
authorization: string | null = null;
name: string = 'noName';
localEditorOpen = false;
localEditorKey: 'provider' | 'model' | 'authorization' | 'name' | null = null;
localEditorLabel = '';
localEditorValue = '';
localEditorOptions: string[] = [];
localEditorLoading = false;
missingRequiredParams: string[] = [];
private schemaRequirements: SchemaRequirements = { required: [], conditional: [] };
private conditionalRequiredByPath = new Map<string, boolean>();
private refreshingConditionalRequirements = false;
ngOnInit() {
this.outputs = [];
this.inputs = [];
this.parameterEntries = [];
this.prompt = null;
this.promptParts = [];
this.provider = null;
this.model = null;
const outputEntries = Object.entries(this.data.outputs);
outputEntries.forEach(([key, output]) => {
this.outputs.push({ key, socket: (output as any).socket });
});
const inputEntries = Object.entries(this.data.inputs);
inputEntries.forEach(([inKey, input]) => {
this.inputs.push({ key: inKey, socket: (input as any).socket });
});
const config = this.ensureBlockConfiguration();
this.name = this.toStringOrNull(config['name']) || this.name;
this.prompt = this.toStringOrNull(config['prompt']);
this.promptParts = this.splitPromptParts(this.prompt);
this.provider = this.toStringOrNull(config['llmDescriptor']?.['provider']);
this.model = this.toStringOrNull(config['llmDescriptor']?.['model']);
this.authorization = this.toStringOrNull(config['llmDescriptor']?.['authorization']);
Object.entries(config)
.filter(([key]) => !['prompt', 'llmDescriptor'].includes(key))
.forEach(([key, value]) => {
if (value == null || typeof value === 'object') return;
this.parameterEntries.push({ key, value: String(value) });
});
this.refreshValidationState();
void this.loadSchemaRequirements();
}
ngAfterViewInit() {
this.rendered();
}
async openSimpleParamEditor(key: 'provider' | 'model' | 'authorization' | 'name', event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (key === 'authorization' && !this.isConditionallyRequired('llmDescriptor.authorization')) {
return;
}
this.localEditorKey = key;
this.localEditorLabel =
key === 'provider' ? 'Provider' :
key === 'model' ? 'Model' :
key === 'authorization' ? 'Authorization' : 'Name';
this.localEditorValue =
key === 'provider' ? (this.provider ?? '') :
key === 'model' ? (this.model ?? '') :
key === 'authorization' ? (this.authorization ?? '') :
(this.name ?? '');
this.localEditorOptions = [];
this.localEditorLoading = key === 'provider' || key === 'model';
this.localEditorOpen = true;
if (key === 'provider' || key === 'model') {
await this.loadSimpleEditorOptions();
}
}
closeSimpleParamEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.localEditorOpen = false;
this.localEditorKey = null;
this.localEditorOptions = [];
this.localEditorLoading = false;
}
saveSimpleParamEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.localEditorKey) return;
const config = this.ensureBlockConfiguration();
const value = this.localEditorValue.trim();
if (this.localEditorKey === 'provider') {
const descriptor = (config['llmDescriptor'] ??= {});
descriptor['provider'] = value;
this.provider = value || null;
this.model = null;
this.authorization = null;
descriptor['model'] = '';
descriptor['authorization'] = '';
}
if (this.localEditorKey === 'model') {
const descriptor = (config['llmDescriptor'] ??= {});
descriptor['model'] = value;
this.model = value || null;
}
if (this.localEditorKey === 'authorization') {
const descriptor = (config['llmDescriptor'] ??= {});
descriptor['authorization'] = value;
this.authorization = value || null;
}
if (this.localEditorKey === 'name') {
const nameValue = value.slice(0, 20);
config['name'] = nameValue;
this.name = nameValue || this.name;
}
this.refreshValidationState();
this.markFlowDirty();
this.localEditorOpen = false;
this.localEditorKey = null;
this.localEditorOptions = [];
this.localEditorLoading = false;
}
async openPromptEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const result = await this.settingsDialog.open({
title: `Prompt for "${this.name}"`,
fields: [
{
key: 'prompt',
label: 'Prompt',
type: 'textarea',
rows: 12,
placeholder: 'Write prompt...',
tip: 'You can use ${{inputName}} to add dynamic inputs.'
}
],
initial: { prompt: this.prompt ?? '' }
});
if (!result) return;
const config = this.ensureBlockConfiguration();
config['prompt'] = String(result['prompt'] ?? '');
this.prompt = config['prompt'] || null;
this.promptParts = this.splitPromptParts(this.prompt);
this.refreshValidationState();
this.markFlowDirty();
}
openNameEditor(event?: Event) {
this.openSimpleParamEditor('name', event);
}
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
const deleteNode = this.data?.data?.deleteNode;
if (typeof deleteNode === 'function') {
await deleteNode();
}
}
private get blockConfiguration(): Record<string, any> | null {
return this.data?.data?.specificConfiguration ?? null;
}
private ensureBlockConfiguration(): Record<string, any> {
if (!this.data?.data) {
this.data.data = {};
}
if (!this.data.data.specificConfiguration) {
this.data.data.specificConfiguration = {};
}
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;
this.editorState.updateData(flow.data);
}
private async loadSimpleEditorOptions() {
const key = this.localEditorKey;
const blockType = this.blockType;
if (!key || !blockType) {
this.localEditorLoading = false;
return;
}
const retrieverKey = key === 'provider' ? 'providers' : 'models';
const context = key === 'model' && this.provider
? { provider: this.provider }
: undefined;
try {
const options = await firstValueFrom(
this.fieldRetreiver.retrieveValues(blockType, retrieverKey, context)
);
this.localEditorOptions = options ?? [];
} catch {
this.localEditorOptions = [];
} finally {
this.localEditorLoading = false;
}
}
private get blockType(): string | null {
const typeName = this.data?.data?.typeName;
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
}
private refreshValidationState() {
const config = this.blockConfiguration ?? {};
const requiredFields = [
...this.schemaRequirements.required,
...this.schemaRequirements.conditional.filter((field) => this.conditionalRequiredByPath.get(field.path))
].filter((field) => field.path !== 'name');
this.missingRequiredParams = requiredFields
.filter((field) => this.isMissingValue(this.getByPath(config, field.path)))
.map((field) => field.label);
if (!this.refreshingConditionalRequirements) {
void this.refreshConditionalRequirements();
}
this.refreshView();
}
private getByPath(source: Record<string, any>, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc == null || typeof acc !== 'object') return undefined;
return (acc as Record<string, unknown>)[key];
}, source);
}
private isMissingValue(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;
return false;
}
private async loadSchemaRequirements() {
const type = this.blockType;
if (!type) return;
const blockType = await this.blocksService.getBlockType(type);
const schema = (blockType?.schema ?? null) as Record<string, unknown> | null;
this.schemaRequirements = extractSchemaRequirements(schema);
await this.refreshConditionalRequirements();
this.refreshValidationState();
}
private async refreshConditionalRequirements() {
if (!this.schemaRequirements.conditional.length) return;
const type = this.blockType;
if (!type) return;
this.refreshingConditionalRequirements = true;
let changed = false;
for (const field of this.schemaRequirements.conditional) {
const required = await this.fetchConditionalRequirement(type, field);
if (this.conditionalRequiredByPath.get(field.path) !== required) {
this.conditionalRequiredByPath.set(field.path, required);
changed = true;
}
}
this.refreshingConditionalRequirements = false;
if (changed) {
this.refreshValidationState();
}
}
private async fetchConditionalRequirement(blockType: string, field: ConditionalRequiredField) {
const context: Record<string, string> = {};
for (const dep of field.dependsOn) {
const value = this.getByPath(this.blockConfiguration ?? {}, dep.path);
context[dep.key] = typeof value === 'string' ? value : '';
}
try {
return await firstValueFrom(
this.fieldRetreiver.isFieldRequired(blockType, field.retrieverKey, context)
);
} catch {
return false;
}
}
isConditionallyRequired(path: string) {
return !!this.conditionalRequiredByPath.get(path);
}
private splitPromptParts(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;
}
formatDynamicInputToken(token: string): string {
const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/);
return match ? match[1] : token;
}
private refreshView() {
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while async validation was running.
}
});
}
}

View File

@ -1,33 +0,0 @@
<div class="cursor-move user-select-none rounded-xl bg-red-100 shadow-lg border border-gray-200 overflow-visible">
<!-- header/row relativa -->
<div class="relative w-full p-3 pt-0 flex items-center justify-between">
<button
type="button"
class="node-delete-btn"
title="Delete node"
(pointerdown)="$event.stopPropagation()"
(click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>
<div class="grid grid-cols-4 h-full">
<div class="col-span-4 text-ls mb-2 p-1 rounded-md text-red-700 font-bold">
{{ data.label }}
</div>
<div class="align-middle h-full flex flex-col justify-center items-end">
<div refComponent class="absolute left-1"
[data]="{
type: 'socket',
side: 'input',
key: inputKey,
nodeId: data.id,
payload: inputSocket
}" [emit]="emit"></div>
</div>
<div class="col-span-3 align-middle h-full flex justify-center flex-col">
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 inset-ring inset-ring-red-600/20">Text</span>
</div>
</div>
</div>
</div>

View File

@ -1,43 +0,0 @@
.io-card {
height: 60px;
width: 100px;
transform: skew(-20deg);
background-color: rgb(238, 173, 161);
}
.body {
transform: skew(20deg);
}
.title {
text-align: center ;
font-size: 20px;
font-weight:bolder;
margin: 0;
padding: 0;
font-size: 13px;
padding: 0;
color:white;
font-weight: bolder;
}
.node-delete-btn {
position: absolute;
right: 4px;
top: 4px;
width: 22px;
height: 22px;
border: none;
border-radius: 999px;
background: rgba(185, 28, 28, 0.12);
color: #991b1b;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 20;
}
.node-delete-btn:hover {
background: rgba(185, 28, 28, 0.2);
}

View File

@ -1,57 +0,0 @@
import {
Component,
Input,
HostBinding,
} from "@angular/core";
import { ClassicPreset } from "rete";
import { CommonModule } from "@angular/common";
import { ReteModule } from "rete-angular-plugin/21";
@Component({
templateUrl: "./output-node.component.html",
styleUrls: ["./output-node.component.scss"],
imports: [CommonModule, ReteModule],
host: {
"data-testid": "node"
}
})
export class OutputNodeComponent {
@Input() data!: ClassicPreset.Node;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@HostBinding("class.selected") get selected() {
return this.data.selected;
}
inputKey!: string;
inputSocket!: ClassicPreset.Socket;
ngAfterViewInit() {
this.rendered();
}
ngOnInit(): void {
const entries = Object.entries(this.data.inputs);
const [key, input] = entries[0];
console.log("OutputNodeComponent ngOnInit", key, input);
this.inputKey = key;
this.inputSocket = (input as any).socket;
}
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
const deleteNode = (this.data as any)?.data?.deleteNode;
if (typeof deleteNode === 'function') {
await deleteNode();
}
}
}

View File

@ -0,0 +1,411 @@
.llm-node {
position: relative;
width: 280px;
user-select: none;
-webkit-user-select: none;
border-radius: 16px;
border: 1px solid #dbe2ea;
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
overflow: visible;
color: #0f172a;
}
.llm-node--human {
border-color: #f7d7a7;
background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%);
box-shadow: 0 10px 24px rgba(124, 45, 18, 0.14);
color: #431407;
}
.llm-node-error {
border: 2px solid #dc2626;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.2), 0 10px 24px rgba(15, 23, 42, 0.12);
}
.llm-node-warning {
border: 2px solid #eab308;
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.22), 0 10px 24px rgba(15, 23, 42, 0.12);
}
.llm-error-alert-wrap,
.llm-warning-alert-wrap {
position: absolute;
top: -12px;
z-index: 120;
}
.llm-error-alert-wrap {
right: -12px;
}
.llm-warning-alert-wrap {
left: -12px;
}
.llm-error-alert,
.llm-warning-alert {
width: 26px;
height: 26px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.llm-error-alert {
border: 1px solid #fecaca;
background: #dc2626;
color: #fff7ed;
box-shadow: 0 0 0 3px rgba(127, 29, 29, 0.2);
}
.llm-warning-alert {
border: 1px solid #fde68a;
background: #eab308;
color: #422006;
box-shadow: 0 0 0 3px rgba(202, 138, 4, 0.22);
}
.llm-error-tooltip,
.llm-warning-list-tooltip {
position: absolute;
top: calc(100% + 8px);
min-width: 220px;
max-width: 320px;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
padding: 8px;
z-index: 130;
opacity: 0;
visibility: hidden;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
}
.llm-error-tooltip {
right: 0;
border: 1px solid #fecaca;
background: #fff1f2;
color: #7f1d1d;
}
.llm-warning-list-tooltip {
left: 0;
border: 1px solid #fde68a;
background: #fef9c3;
color: #713f12;
}
.llm-error-alert-wrap:hover .llm-error-tooltip,
.llm-warning-alert-wrap:hover .llm-warning-list-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.llm-error-title,
.llm-warning-list-title {
font-size: 11px;
font-weight: 700;
margin-bottom: 4px;
}
.llm-error-item,
.llm-warning-list-item {
font-size: 11px;
line-height: 1.3;
}
.llm-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #e8edf4;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.llm-node--human .llm-header {
background: linear-gradient(135deg, #f59e0b 0%, #ea580c 100%);
border-bottom-color: #fdba74;
}
.llm-icon {
width: 32px;
height: 32px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.28);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.llm-icon-image {
width: 24px;
height: 16px;
object-fit: contain;
}
.llm-header-content {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.2;
}
.llm-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
color: #eff6ff;
}
.llm-subtitle {
font-size: 11px;
color: #dbeafe;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.llm-body {
padding: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.llm-column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.llm-column-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
}
.llm-column-title-right {
text-align: right;
}
.llm-row {
display: grid;
align-items: center;
height: 24px;
min-width: 0;
}
.llm-row-input {
grid-template-columns: 16px 1fr;
gap: 6px;
}
.llm-row-output {
grid-template-columns: 1fr 16px;
gap: 6px;
}
.llm-socket {
position: relative;
z-index: 20;
}
.llm-socket-right {
justify-self: end;
}
.llm-pill {
height: 24px;
border-radius: 7px;
border: 1px solid transparent;
font-size: 11px;
font-weight: 600;
padding: 0 8px;
display: inline-flex;
align-items: center;
min-width: 0;
}
.llm-pill-input {
justify-content: flex-start;
color: #166534;
background: #ecfdf3;
border-color: #bbf7d0;
gap: 4px;
overflow: visible;
}
.llm-pill-output {
justify-content: flex-end;
color: #9f1239;
background: #fff1f2;
border-color: #fecdd3;
}
.llm-input-info-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.llm-input-info {
border: none;
background: transparent;
color: #166534;
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: help;
}
.llm-input-tooltip {
position: absolute;
left: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
min-width: 140px;
max-width: 260px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #ffffff;
color: #0f172a;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
font-size: 11px;
line-height: 1.3;
padding: 6px 8px;
white-space: pre-wrap;
word-break: break-word;
z-index: 80;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.llm-input-info-wrap:hover .llm-input-tooltip,
.llm-input-info-wrap:focus-within .llm-input-tooltip {
opacity: 1;
visibility: visible;
}
.llm-params {
margin: 0 12px 12px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
display: flex;
flex-direction: column;
gap: 8px;
}
.llm-param-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.llm-param-groups {
display: flex;
flex-direction: column;
gap: 8px;
}
.llm-param-fieldset {
margin: 0;
padding: 8px;
border: 1px solid #cbd5e1;
border-radius: 0;
background: transparent;
}
.llm-param-legend {
margin: 0;
padding: 0 4px;
font-size: 10px;
font-weight: 700;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.03em;
display: table;
max-width: 100%;
white-space: nowrap;
float: none;
width: auto;
margin-bottom: 0;
}
.llm-param-chip {
border: 1px solid #dbeafe;
background: #eff6ff;
border-radius: 8px;
padding: 6px 8px;
display: flex;
flex-direction: column;
min-width: 0;
}
.llm-param-row-head {
display: flex;
align-items: center;
min-width: 0;
}
.llm-param-key {
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.llm-param-value {
font-size: 11px;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.llm-param-block {
border: 1px solid #dbe2ea;
background: #ffffff;
border-radius: 8px;
padding: 8px;
}
.llm-param-text {
font-size: 11px;
color: #1e293b;
white-space: pre-wrap;
word-break: break-word;
margin-top: 4px;
line-height: 1.45;
}
.llm-inline-token {
display: inline-flex;
align-items: center;
padding: 0 6px;
min-height: 18px;
margin: 0 2px;
border-radius: 6px;
border: 1px solid #bbf7d0;
background: #ecfdf3;
color: #166534;
font-size: 10px;
font-weight: 600;
}

View File

@ -0,0 +1,156 @@
<div class="llm-node"
[class.llm-node--human]="isHumanNode()"
[class.llm-node-error]="hasExecutionErrors()"
[class.llm-node-warning]="hasExecutionWarnings() && !hasExecutionErrors()">
@if (hasExecutionErrors()) {
<div class="llm-error-alert-wrap">
<div class="llm-error-alert">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="llm-error-tooltip">
<div class="llm-error-title">Execution errors</div>
@for (error of executionErrors(); track $index) {
<div class="llm-error-item">{{ error }}</div>
}
</div>
</div>
}
@if (hasExecutionWarnings()) {
<div class="llm-warning-alert-wrap">
<div class="llm-warning-alert">
<i class="bi bi-exclamation-circle-fill"></i>
</div>
<div class="llm-warning-list-tooltip">
<div class="llm-warning-list-title">Execution warnings</div>
@for (warning of executionWarnings(); track $index) {
<div class="llm-warning-list-item">{{ warning }}</div>
}
</div>
</div>
}
<div class="llm-header cursor-move">
<div class="llm-icon">
@if (isHumanNode()) {
<i class="bi bi-person-check-fill"></i>
} @else {
<img src="llm_node.png" alt="LLM" class="llm-icon-image" />
}
</div>
<div class="llm-header-content">
<span class="llm-title">{{ nodeTitle() }}</span>
<div class="llm-subtitle-row">
<span class="llm-subtitle">{{ name }}</span>
</div>
</div>
</div>
<div class="llm-body">
<div class="llm-column">
<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 (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>
</div>
}
</div>
<div class="llm-column">
<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>
</div>
}
</div>
</div>
<div class="llm-params">
@if (parameterFieldGroups.length) {
<div class="llm-param-groups">
@for (group of parameterFieldGroups; track group.key) {
<fieldset class="llm-param-fieldset">
<legend class="llm-param-legend">{{ group.legend }}</legend>
<div class="llm-param-grid">
@for (field of group.fields; track field.path) {
<div class="llm-param-chip">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
</div>
<span class="llm-param-value">{{ field.value }}</span>
</div>
}
</div>
</fieldset>
}
</div>
}
@if (parameterFields.length) {
<div class="llm-param-grid">
@for (field of parameterFields; track field.path) {
<div class="llm-param-chip">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
</div>
<span class="llm-param-value">{{ field.value }}</span>
</div>
}
</div>
}
@if (hasMainContent()) {
<div class="llm-param-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ mainContentLabel() }}</div>
</div>
<div class="llm-param-text">
@for (part of mainContentParts(); track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
</div>
</div>
}
</div>
</div>

View File

@ -0,0 +1,379 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { BlocksService } from '@services/blocks/blocks';
type DisplayField = {
path: string;
label: string;
value: string;
};
type DisplayFieldGroup = {
key: string;
legend: string;
fields: DisplayField[];
};
type MainContentView = {
path: string;
label: string;
parts: { text: string; isDynamicInput: boolean }[];
};
@Component({
selector: 'app-task-step-node',
imports: [CommonModule, ReteModule],
templateUrl: './task-step-node.html',
styleUrl: './task-step-node.css',
host: {
'data-testid': 'task-step-node'
}
})
export class TaskStepNodeComponent {
private blocksService = inject(BlocksService);
private cdr = inject(ChangeDetectorRef);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@HostBinding('class.selected') get selected() {
return this.data.selected;
}
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
parameterFields: DisplayField[] = [];
parameterFieldGroups: DisplayFieldGroup[] = [];
name = 'Step';
mainContent: MainContentView | null = null;
private blockSchema: Record<string, any> | null = null;
private variablePlaceholderPaths = new Set<string>();
ngOnInit() {
this.outputs = [];
this.inputs = [];
this.parameterFields = [];
this.parameterFieldGroups = [];
Object.entries(this.data.outputs).forEach(([key, output]) => {
this.outputs.push({ key, socket: (output as any).socket });
});
Object.entries(this.data.inputs).forEach(([key, input]) => {
this.inputs.push({ key, socket: (input as any).socket });
});
this.rebuildDisplayState();
void this.loadSchemaContext();
}
private rebuildDisplayState() {
const config = this.blockConfiguration ?? {};
this.name = this.toStringOrNull(config['name']) ?? this.name;
const primitiveEntries = this.flattenPrimitiveValues(config);
const contentEntry = this.pickMainContentEntry(primitiveEntries);
this.mainContent = contentEntry
? {
path: contentEntry.path,
label: this.pathToLabel(contentEntry.path),
parts: this.toMainContentParts(contentEntry.path, String(contentEntry.value))
}
: null;
const grouped = new Map<string, DisplayField[]>();
const rootFields: DisplayField[] = [];
const orderedFields = primitiveEntries
.filter((entry) => !['name', 'type'].includes(entry.path))
.filter((entry) => entry.path !== contentEntry?.path)
.map((entry) => ({
path: entry.path,
label: this.pathToLabel(entry.path),
value: this.valueToDisplayString(entry.value)
}));
for (const field of orderedFields) {
const parentPath = this.parentPath(field.path);
if (!parentPath) {
rootFields.push(field);
continue;
}
if (!grouped.has(parentPath)) {
grouped.set(parentPath, []);
}
grouped.get(parentPath)!.push(field);
}
this.parameterFields = rootFields;
this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({
key,
legend: this.pathToLabel(key),
fields
}));
this.refreshView();
}
ngAfterViewInit() {
this.rendered();
}
isHumanNode(): boolean {
return this.blockType === 'HumanInteractionBlock';
}
nodeTitle(): string {
const type = this.blockType;
if (!type) return 'Task Step';
return type
.replace(/Block$/, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.trim();
}
hasMainContent(): boolean {
return (this.mainContent?.parts.length ?? 0) > 0;
}
mainContentLabel(): string {
return this.mainContent?.label ?? 'Content';
}
mainContentParts(): { text: string; isDynamicInput: boolean }[] {
return this.mainContent?.parts ?? [];
}
formatDynamicInputToken(token: string): string {
const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/);
return match ? match[1] : token;
}
executionInputTooltip(inputName: string): string | null {
const values = this.blockConfiguration?.['__executionInputs'] as Record<string, unknown> | undefined;
if (!values || !Object.prototype.hasOwnProperty.call(values, inputName)) return null;
const value = values[inputName];
if (value === undefined) return null;
if (typeof value === 'string') return value.trim().length > 0 ? value : null;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
executionErrors(): string[] {
return this.getExecutionMessages('__executionErrors');
}
hasExecutionErrors(): boolean {
return this.executionErrors().length > 0;
}
executionWarnings(): string[] {
return this.getExecutionMessages('__executionWarnings');
}
hasExecutionWarnings(): boolean {
return this.executionWarnings().length > 0;
}
private get blockConfiguration(): Record<string, any> | null {
return this.data?.data?.specificConfiguration ?? null;
}
private get blockType(): string | null {
const typeName = this.data?.data?.typeName;
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))
.filter((entry) => typeof entry.value === 'string')
.map((entry) => ({ ...entry, text: String(entry.value).trim() }))
.filter((entry) => entry.text.length > 0);
if (!candidates.length) return null;
const placeholderCandidates = candidates.filter((entry) =>
this.variablePlaceholderPaths.has(entry.path)
);
const scope = placeholderCandidates.length ? placeholderCandidates : candidates;
scope.sort((a, b) => {
if (b.text.length !== a.text.length) return b.text.length - a.text.length;
return a.path.localeCompare(b.path);
});
const chosen = scope[0];
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 [];
return values.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
}
private toMainContentParts(path: string, value: string): { text: string; isDynamicInput: boolean }[] {
if (this.variablePlaceholderPaths.has(path)) {
return this.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;
const blockType = await this.blocksService.getBlockType(type);
this.blockSchema = (blockType?.schema ?? null) as Record<string, any> | null;
this.variablePlaceholderPaths = this.extractVariablePlaceholderPaths(this.blockSchema);
this.rebuildDisplayState();
}
private extractVariablePlaceholderPaths(schema: Record<string, any> | null): Set<string> {
const paths = new Set<string>();
if (!schema) return paths;
const walk = (node: Record<string, any>, pathPrefix: string) => {
const resolved = this.resolveRef(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 path = pathPrefix ? `${pathPrefix}.${key}` : key;
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path);
continue;
}
const rawWidget = typeof childResolved?.['x-ui-widget'] === 'string'
? String(childResolved['x-ui-widget']).toLowerCase().trim()
: '';
const isTextarea = rawWidget === 'textarea' || rawWidget === 'text-area';
const acceptsVariable = childResolved?.['x-ui-accept-variable-as-placeholder'] === true;
if (isTextarea && acceptsVariable) {
paths.add(path);
}
}
};
walk(schema, '');
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 {
this.cdr.detectChanges();
} catch {
// Node may have been removed while async updates were running.
}
});
}
}

View File

@ -15,6 +15,8 @@ import { firstValueFrom } from 'rxjs';
export class ReteEditor implements OnChanges, OnDestroy {
readonly flowData = input.required<FlowData>();
readonly flowId = input.required<string>();
readonly readonly = input<boolean>(false);
readonly nodeView = input<'editor' | 'execution'>('editor');
constructor(
private injector: Injector,
@ -62,6 +64,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
}
async onDrop(event: DragEvent) {
if (this.readonly()) return;
event.preventDefault();
const payload = event.dataTransfer?.getData(BLOCK_TYPE_DRAG_MIME);
if (!payload || !this.rete) return;
@ -96,26 +99,30 @@ export class ReteEditor implements OnChanges, OnDestroy {
this.rete = undefined;
host.innerHTML = '';
const rete = await createEditor(host, this.injector, this.flowData());
const rete = await createEditor(host, this.injector, this.flowData(), {
nodeView: this.nodeView()
});
if (currentVersion !== this.loadVersion) {
rete.area.destroy();
return;
}
this.rete = rete;
rete.editor.addPipe((context) => {
if (this.dirtyEventTypes.has(context.type)) {
this.markFlowChanged(rete, context);
}
return context;
});
if (!this.readonly()) {
rete.editor.addPipe((context) => {
if (this.dirtyEventTypes.has(context.type)) {
this.markFlowChanged(rete, context);
}
return context;
});
rete.area.addPipe((context: any) => {
if (context?.type === 'nodetranslated') {
this.markFlowChanged(rete, context);
}
return context;
});
rete.area.addPipe((context: any) => {
if (context?.type === 'nodetranslated') {
this.markFlowChanged(rete, context);
}
return context;
});
}
}
private getDropPosition(event: DragEvent) {
@ -142,7 +149,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
}
private markFlowChanged(rete: ReteEditorInstance, context: any) {
if (this.readonly()) return;
this.syncNodePositionFromContext(rete, context);
if (this.flowState.currentFlow()?.id !== this.flowId()) return;
const updatedData = exportGraph(rete.editor);
this.flowState.updateData(updatedData);
this.flowChanged.emit(updatedData);

View File

@ -2,3 +2,55 @@
display: block;
height: 100%;
}
.execution-rete-readonly {
pointer-events: auto;
}
:host ::ng-deep .execution-rete-readonly :is(
.llm-socket,
.hi-socket,
.input-socket,
.output-socket
) {
pointer-events: none;
}
:host ::ng-deep .execution-rete-readonly .llm-input-info,
:host ::ng-deep .execution-rete-readonly .hi-input-info,
:host ::ng-deep .execution-rete-readonly .llm-input-info-wrap,
:host ::ng-deep .execution-rete-readonly .hi-input-info-wrap {
pointer-events: auto;
}
:host ::ng-deep .execution-rete-readonly :is(
.node-delete-btn,
.llm-delete-btn,
.hi-delete-btn,
.llm-edit-btn,
.hi-edit-btn,
.llm-name-edit-btn,
.hi-name-edit-btn
) {
display: none !important;
}
.context-title {
font-size: 11px;
color: #64748b;
font-weight: 600;
margin-bottom: 4px;
}
.context-json {
margin: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: #0f172a;
font-size: 11px;
line-height: 1.35;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
}

View File

@ -1,29 +1,63 @@
<section class="h-full p-4">
@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">
<h2 class="text-base font-semibold text-slate-900">{{ execution()!.title }}</h2>
<div class="px-5 py-4 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-5 grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="bg-slate-50 border border-slate-200 rounded-md p-3">
<div class="text-xs text-slate-500">Flow</div>
<div class="text-sm font-medium text-slate-800">{{ execution()!.flowName }}</div>
</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="text-xs text-slate-500">Status</div>
<div class="text-sm font-medium text-slate-800">{{ execution()!.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="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="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="text-xs text-slate-500">Duration</div>
<div class="text-sm font-medium text-slate-800">{{ execution()!.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="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 p-5 pt-0 overflow-auto">
<div class="h-full rounded-md border border-dashed border-slate-300 bg-slate-50 text-slate-500 text-sm flex items-center justify-center">
Execution details viewer placeholder
<div class="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-3 gap-4 p-4">
<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">
<app-rete-editor
[flowId]="execution()!.id"
[flowData]="executionFlowData()"
[nodeView]="'execution'"
[readonly]="true">
</app-rete-editor>
</div>
</div>
<div class="border border-slate-200 rounded-md bg-white min-h-0 overflow-auto">
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-slate-50">Execution Context</div>
<div class="p-3 space-y-3">
<div>
<div class="context-title">Result</div>
<pre class="context-json">{{ execution()!.context.result | json }}</pre>
</div>
<div>
<div class="context-title">Waiting Steps</div>
<pre class="context-json">{{ execution()!.context.waitingSteps | json }}</pre>
</div>
<div>
<div class="context-title">Execution Result</div>
<pre class="context-json">{{ execution()!.context.executionResult | json }}</pre>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,13 +1,129 @@
import { CommonModule } from '@angular/common';
import { Component, input } from '@angular/core';
import { TaskExecutionListItem } from '@shared/tasks-executions-list/tasks-executions-list';
import { Component, computed, input } from '@angular/core';
import { FlowData } from '@models/flow';
import { TaskExecution, TaskExecutionStep } from '@models/task-execution';
import { ReteEditor } from '@shared/rete-editor/rete-editor';
@Component({
selector: 'app-task-execution-viewer',
imports: [CommonModule],
imports: [CommonModule, ReteEditor],
templateUrl: './task-execution-viewer.html',
styleUrl: './task-execution-viewer.css',
})
export class TaskExecutionViewerComponent {
readonly execution = input<TaskExecutionListItem | null>(null);
readonly execution = input<TaskExecution | null>(null);
readonly stepsArray = computed(() =>
Object.values(this.execution()?.context.steps ?? {})
);
readonly executionFlowData = computed<FlowData>(() => {
const contextInputs = this.execution()?.context.inputs ?? {};
const contextErrors = this.execution()?.context.errors ?? {};
const contextWarnings = this.execution()?.context.warnings ?? {};
const steps = this.stepsArray();
const blocks = steps.map((step, index) => ({
...step.block,
specificConfiguration: {
...(step.block.specificConfiguration ?? {}),
__executionInputs: this.getExecutionInputValues(step, contextInputs),
__executionErrors: this.getExecutionErrors(step.id, contextErrors),
__executionWarnings: this.getExecutionWarnings(step.id, contextWarnings)
},
position: step.block.position ?? {
x: 120 + (index % 3) * 340,
y: 100 + Math.floor(index / 3) * 220
}
}));
const connections = this.inferConnections(steps);
return { blocks, connections };
});
readonly durationMs = computed(() => {
const context = this.execution()?.context;
if (!context?.startTime || !context?.endTime) return null;
return Math.max(0, context.endTime - context.startTime);
});
private inferConnections(steps: TaskExecutionStep[]) {
const connections: FlowData['connections'] = [];
for (const targetStep of steps) {
for (const input of targetStep.inputs ?? []) {
if (!input.registered) continue;
const candidates = steps
.filter((step) => step.id !== targetStep.id)
.flatMap((sourceStep) =>
(sourceStep.outputs ?? [])
.filter((output) => output.connected && output.descriptor.type === input.descriptor.type)
.map((output) => ({
sourceStep,
sourceOutputName: output.descriptor.name
}))
);
if (candidates.length !== 1) continue;
const source = candidates[0];
const id = `${source.sourceStep.id}_${source.sourceOutputName}_${targetStep.id}_${input.descriptor.name}`;
if (connections.some((connection) => connection.id === id)) continue;
connections.push({
id,
sourceId: source.sourceStep.block.id,
sourceName: source.sourceOutputName,
targetId: targetStep.block.id,
targetName: input.descriptor.name
});
}
}
return connections;
}
private getExecutionInputValues(
step: TaskExecutionStep,
contextInputs: Record<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const input of step.inputs ?? []) {
const inputName = input.descriptor?.name;
if (!inputName) continue;
const key = `${step.id}:${inputName}`;
if (Object.prototype.hasOwnProperty.call(contextInputs, key)) {
result[inputName] = contextInputs[key];
continue;
}
if (input.set || input.registered || input.value != null) {
result[inputName] = input.value;
}
}
return result;
}
private getExecutionErrors(
stepId: string,
contextErrors: Record<string, unknown>
): string[] {
const raw = contextErrors[stepId];
if (typeof raw === 'string' && raw.trim().length > 0) return [raw];
if (Array.isArray(raw)) {
return raw.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
}
return [];
}
private getExecutionWarnings(
stepId: string,
contextWarnings: Record<string, string>
): string[] {
const raw = contextWarnings[stepId];
return raw && raw.trim().length > 0 ? [raw] : [];
}
}

View File

@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { OrderEvent, OrderField, Ordering, orderDirType } from '@shared/ordering/ordering';
import { OrderViewState } from '@utilities/list-state-holder';
export type TaskExecutionStatus = 'RUNNING' | 'COMPLETED' | 'FAILED' | 'QUEUED';
export type TaskExecutionStatus = 'RUNNING' | 'COMPLETED' | 'FAILED' | 'ERROR' | 'QUEUED';
export type TaskExecutionListItem = {
id: string;
@ -92,7 +92,7 @@ export class TasksExecutionsListComponent {
statusBadgeClass(status: TaskExecutionStatus) {
if (status === 'RUNNING') return 'bg-blue-100 text-blue-700 border-blue-200';
if (status === 'COMPLETED') return 'bg-emerald-100 text-emerald-700 border-emerald-200';
if (status === 'FAILED') return 'bg-rose-100 text-rose-700 border-rose-200';
if (status === 'FAILED' || status === 'ERROR') return 'bg-rose-100 text-rose-700 border-rose-200';
return 'bg-slate-100 text-slate-700 border-slate-200';
}
}

View File

@ -57,3 +57,15 @@
</div>
</div>
@if (flow()) {
<div class="px-4 py-2 bg-white border-b border-gray-200 text-xs text-slate-600 flex flex-wrap items-center gap-x-4 gap-y-1">
<span><span class="text-slate-400">Author:</span> {{ flow()!.author }}</span>
<span><span class="text-slate-400">Visibility:</span> {{ flow()!.visibility }}</span>
<span><span class="text-slate-400">Created:</span> {{ flow()!.createdAt | date:'dd/MM/yyyy HH:mm' }}</span>
<span><span class="text-slate-400">Updated:</span> {{ flow()!.updatedAt | date:'dd/MM/yyyy HH:mm' }}</span>
@if (flow()!.description) {
<span class="truncate max-w-[420px]"><span class="text-slate-400">Description:</span> {{ flow()!.description }}</span>
}
</div>
}

View File

@ -1,9 +1,10 @@
import { CommonModule } from '@angular/common';
import { Component, computed, ElementRef, inject, ViewChild } from '@angular/core';
import { EditorStateHolder } from '@stores/flow-editor';
@Component({
selector: 'app-title-toolbar',
imports: [],
imports: [CommonModule],
templateUrl: './title-toolbar.html',
styleUrl: './title-toolbar.css',
})
@ -12,8 +13,9 @@ export class TitleToolbar {
@ViewChild('titleInput') myInputRef!: ElementRef;
editorState: EditorStateHolder = inject(EditorStateHolder);
flow = computed(() => this.editorState.currentFlow());
title = computed(() => {
const flow = this.editorState.currentFlow();
const flow = this.flow();
return flow ? flow.name : 'No Flow Opened';
});

View File

@ -57,6 +57,9 @@ export class EditorStateHolder {
}
updateData(data: FlowData) {
const current = this.currentFlow();
if (!current) return;
this.currentFlow.update(flow => {
if (!flow) return flow;
return { ...flow, data };

View File

@ -6,12 +6,10 @@ import {
Presets as ConnectionPresets
} from "rete-connection-plugin";
import { AngularPlugin, Presets, AngularArea2D } from "rete-angular-plugin/21";
import { InputNodeComponent } from "@shared/nodes/input/input-node.component";
import { OutputNodeComponent } from "@shared/nodes/output/output-node.component";
import { HFNode, HFSchemes } from "@models/nodes";
import { FlowBlock, FlowData } from "@models/flow";
import { LLMNodeComponent } from "@shared/nodes/llm/llm";
import { HumanInteractionNodeComponent } from "@shared/nodes/human-interaction/human-interaction";
import { GenericNodeComponent } from "@shared/nodes/generic-node/generic-node";
import { TaskStepNodeComponent } from "@shared/nodes/task-step-node/task-step-node";
import { CustomSocket } from "@shared/custom-socket/custom-socket";
type AreaExtra = AngularArea2D<HFSchemes>;
@ -25,13 +23,15 @@ export type ReteEditorInstance = {
export async function createEditor(
container: HTMLElement,
injector: Injector,
flowData: FlowData
flowData: FlowData,
options?: { nodeView?: "editor" | "execution" }
): Promise<ReteEditorInstance> {
const editor = new NodeEditor<HFSchemes>();
const area = new AreaPlugin<HFSchemes, AreaExtra>(container);
const connection = new ConnectionPlugin<HFSchemes, AreaExtra>();
const render = new AngularPlugin<HFSchemes, AreaExtra>({ injector });
const nodeView = options?.nodeView ?? "editor";
@ -39,25 +39,17 @@ export async function createEditor(
Presets.classic.setup({
customize: {
node(context) {
if (context.payload.label === "Input") {
return InputNodeComponent;
}
if (context.payload.label === "Output") {
return OutputNodeComponent;
}
if (context.payload.label === "HumanInteractionBlock") {
return HumanInteractionNodeComponent;
}
return LLMNodeComponent;
return nodeView === "execution" ? TaskStepNodeComponent : GenericNodeComponent;
},
socket(context: any) {
// rete-angular passes only `payload` to socket component props,
// so we persist side on the payload to style input/output sockets.
// rete-angular passes only `payload` to the socket component.
// Build a per-render payload copy to avoid mutating shared socket objects.
const socketPayload = context?.payload;
const socketSide = context?.side;
if (socketPayload && (socketSide === "input" || socketSide === "output")) {
(socketPayload as any).__hfSide = socketSide;
}
const socketSide = context?.side === "output" ? "output" : "input";
context.payload = {
...(socketPayload ?? {}),
__hfSide: socketSide
};
return CustomSocket;
}
},
@ -204,4 +196,3 @@ function toNodeLabel(typeName: string) {
if (typeName === "OutputBlock") return "Output";
return typeName;
}