added task step part and make nodes generic
This commit is contained in:
parent
b77ee47306
commit
36167f13ac
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ export type FlowBlockConfiguration =
|
|||
export type LLMDescriptor = {
|
||||
provider: string;
|
||||
model: string;
|
||||
authorization?: string;
|
||||
};
|
||||
|
||||
export type LLMBlockConfiguration = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ const testDataFlow ={
|
|||
"type" : "HumanInteractiveBlockConfiguration",
|
||||
"name" : "interactive",
|
||||
"actionDescription" : "Answer the question in input",
|
||||
"llmDescriptor" : {
|
||||
"simulateWith" : {
|
||||
"provider" : "testProvider",
|
||||
"model" : "testModel"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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">
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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] : [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue