diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index 803f00b..89de989 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -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([ + { + 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([ { - 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(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); diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 5d83df4..a6f93a1 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -64,7 +64,6 @@ export type FlowBlockConfiguration = export type LLMDescriptor = { provider: string; model: string; - authorization?: string; }; export type LLMBlockConfiguration = { diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts new file mode 100644 index 0000000..d7f5fe7 --- /dev/null +++ b/src/app/models/task-execution.ts @@ -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; + result: Record; + startTime?: number | null; + endTime?: number | null; + errors: Record; + warnings: Record; + steps: Record; + status: ExecutionStatus; + waitingSteps: string[]; + executionResult: Record; +}; + +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; +}; diff --git a/src/app/services/blocks/blocks-call.fake.ts b/src/app/services/blocks/blocks-call.fake.ts index 6c3855a..038e49f 100644 --- a/src/app/services/blocks/blocks-call.fake.ts +++ b/src/app/services/blocks/blocks-call.fake.ts @@ -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 } ]; diff --git a/src/app/services/flows/flows-call.fake.ts b/src/app/services/flows/flows-call.fake.ts index aad3550..f1da227 100644 --- a/src/app/services/flows/flows-call.fake.ts +++ b/src/app/services/flows/flows-call.fake.ts @@ -112,7 +112,7 @@ const testDataFlow ={ "type" : "HumanInteractiveBlockConfiguration", "name" : "interactive", "actionDescription" : "Answer the question in input", - "llmDescriptor" : { + "simulateWith" : { "provider" : "testProvider", "model" : "testModel" }, diff --git a/src/app/services/retreiver/field-retreiver-call.fake.ts b/src/app/services/retreiver/field-retreiver-call.fake.ts index 15a594b..1b1678d 100644 --- a/src/app/services/retreiver/field-retreiver-call.fake.ts +++ b/src/app/services/retreiver/field-retreiver-call.fake.ts @@ -31,19 +31,11 @@ export class FieldRetreiverCallServiceFake extends FieldRetreiverCallServiceBase } override isFieldRequired( - blockType: string, - key: string, + _blockType: string, + _key: string, context?: Record ): Observable { - if (key !== "authorization") { - return of(false); - } - - const provider = context?.["provider"] ?? ""; - if (blockType === "LLMBlock") { - return of(provider === "Anthropic"); - } - + void context; return of(false); } } diff --git a/src/app/shared/custom-socket/custom-socket.ts b/src/app/shared/custom-socket/custom-socket.ts index 69f93a5..77259f9 100644 --- a/src/app/shared/custom-socket/custom-socket.ts +++ b/src/app/shared/custom-socket/custom-socket.ts @@ -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()); } } diff --git a/src/app/shared/flows-list/flow-item/flow-item.html b/src/app/shared/flows-list/flow-item/flow-item.html index a979ba9..667309d 100644 --- a/src/app/shared/flows-list/flow-item/flow-item.html +++ b/src/app/shared/flows-list/flow-item/flow-item.html @@ -23,15 +23,15 @@ -
+
{{ flow().createdAt | date:'dd/MM/yyyy' }}
diff --git a/src/app/shared/nodes/llm/llm.css b/src/app/shared/nodes/generic-node/generic-node.css similarity index 62% rename from src/app/shared/nodes/llm/llm.css rename to src/app/shared/nodes/generic-node/generic-node.css index b13c443..35490f0 100644 --- a/src/app/shared/nodes/llm/llm.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -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; diff --git a/src/app/shared/nodes/llm/llm.html b/src/app/shared/nodes/generic-node/generic-node.html similarity index 60% rename from src/app/shared/nodes/llm/llm.html rename to src/app/shared/nodes/generic-node/generic-node.html index f6e897a..6365c43 100644 --- a/src/app/shared/nodes/llm/llm.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -1,10 +1,15 @@ -
+
+ @if (isHumanNode()) { + + } @else { LLM + }
- LLM Node + {{ nodeTitle() }}
{{ name }}
- {{ input.key }} + + {{ input.key }} +
}
@@ -77,52 +84,68 @@
+ @if (parameterFieldGroups.length) { +
+ @for (group of parameterFieldGroups; track group.key) { +
+ {{ group.legend }} +
+ @for (field of group.fields; track field.path) { +
+
+ {{ field.label }} + +
+ {{ field.value }} +
+ } +
+
+ } +
+ } + + @if (parameterFields.length) {
+ @for (field of parameterFields; track field.path) {
- Provider -
- {{ provider || '-' }} -
- -
-
- Model - -
- {{ model || '-' }} -
- - @if (isConditionallyRequired('llmDescriptor.authorization')) { -
-
- Authorization - -
- {{ authorization || '-' }} + {{ field.value }}
}
+ } + @if (hasMainContent()) {
-
Prompt
-
- @if (!promptParts.length) { + @if (!mainContentParts().length) { - } @else { - @for (part of promptParts; track $index) { + @for (part of mainContentParts(); track $index) { @if (part.isDynamicInput) { {{ formatDynamicInputToken(part.text) }} } @else { @@ -132,6 +155,7 @@ }
+ }
@if (localEditorOpen) { @@ -142,7 +166,12 @@ @if (localEditorLoading) { - } @else if (localEditorKey !== 'name' && localEditorOptions.length) { + } @else if (localEditorType === 'boolean') { + + } @else if (localEditorOptions.length) { } @else { - + }
diff --git a/src/app/shared/nodes/llm/llm.spec.ts b/src/app/shared/nodes/generic-node/generic-node.spec.ts similarity index 53% rename from src/app/shared/nodes/llm/llm.spec.ts rename to src/app/shared/nodes/generic-node/generic-node.spec.ts index 3888e46..88ab141 100644 --- a/src/app/shared/nodes/llm/llm.spec.ts +++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts @@ -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; +describe('GenericNodeComponent', () => { + let component: GenericNodeComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LLMNodeComponent] + imports: [GenericNodeComponent] }) .compileComponents(); - fixture = TestBed.createComponent(LLMNodeComponent); + fixture = TestBed.createComponent(GenericNodeComponent); component = fixture.componentInstance; await fixture.whenStable(); }); diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts new file mode 100644 index 0000000..a7b1cd0 --- /dev/null +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -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 | null = null; + private editableFieldDefinitions: EditableFieldDefinition[] = []; + private schemaRequirements: SchemaRequirements = { required: [], conditional: [] }; + private conditionalRequiredByPath = new Map(); + 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 | 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 { + 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 | 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 | null): EditableFieldDefinition[] { + if (!schema) return []; + + const definitions: EditableFieldDefinition[] = []; + const seen = new Set(); + const contentKey = this.mainContentKey(); + + const walk = (node: Record, pathPrefix: string) => { + const resolved = this.resolveRef(node, schema); + if (!resolved || typeof resolved !== 'object') return; + + const properties = resolved.properties as Record | undefined; + if (!properties) return; + + for (const [key, childSchema] of Object.entries(properties)) { + const childResolved = this.resolveRef(childSchema as Record, schema); + const path = pathPrefix ? `${pathPrefix}.${key}` : key; + const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; + + if (hasChildren) { + walk(childResolved as Record, 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 | 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 | null { + const root = this.blockSchema; + if (!root) return null; + + let current: Record | null = root; + for (const segment of path.split('.')) { + if (!current) return null; + const resolved = this.resolveRef(current, root); + const properties = resolved?.properties as Record | undefined; + if (!properties || !properties[segment]) return null; + current = this.resolveRef(properties[segment] as Record, 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 | 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 | 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 = {}; + 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(); + 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, prefix = ''): Array<{ path: string; value: unknown }> { + const entries: Array<{ path: string; value: unknown }> = []; + for (const [key, value] of Object.entries(source ?? {})) { + const path = prefix ? `${prefix}.${key}` : key; + if (key.startsWith('__')) continue; + if (value != null && typeof value === 'object' && !Array.isArray(value)) { + entries.push(...this.flattenPrimitiveValues(value as Record, path)); + } else { + entries.push({ path, value }); + } + } + return entries; + } + + private valueToDisplayString(value: unknown): string { + if (value == null) return '-'; + if (typeof value === 'string') return value.trim().length ? value : '-'; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return value.length ? JSON.stringify(value) : '-'; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + private valueToEditorString(value: unknown, type: FieldType): string { + if (type === 'boolean') { + return value === true ? 'true' : 'false'; + } + 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, path: string, value: unknown) { + const keys = path.split('.').filter(Boolean); + if (!keys.length) return; + + let current: Record = 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; + 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, path: string): unknown { + return path.split('.').reduce((acc, key) => { + if (acc == null || typeof acc !== 'object') return undefined; + return (acc as Record)[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 = {}; + 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, root: Record) { + if (!node || typeof node !== 'object') return node; + const ref = node['$ref']; + if (typeof ref !== 'string' || !ref.startsWith('#/')) return node; + + const path = ref.slice(2).split('/'); + let current: any = root; + for (const segment of path) { + current = current?.[segment]; + if (current == null) return node; + } + return current; + } + + private splitTemplatedTextParts(prompt: string | null): { text: string; isDynamicInput: boolean }[] { + if (!prompt) return []; + + const parts: { text: string; isDynamicInput: boolean }[] = []; + const re = /\$\{\{[^}]+\}\}/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = re.exec(prompt)) !== null) { + if (match.index > lastIndex) { + parts.push({ + text: prompt.slice(lastIndex, match.index), + isDynamicInput: false + }); + } + + parts.push({ + text: match[0], + isDynamicInput: true + }); + lastIndex = re.lastIndex; + } + + if (lastIndex < prompt.length) { + parts.push({ + text: prompt.slice(lastIndex), + isDynamicInput: false + }); + } + + return parts; + } + + private refreshView() { + queueMicrotask(() => { + try { + this.cdr.detectChanges(); + } catch { + // Node may have been removed while async validation was running. + } + }); + } +} diff --git a/src/app/shared/nodes/human-interaction/human-interaction.css b/src/app/shared/nodes/human-interaction/human-interaction.css deleted file mode 100644 index 96f697e..0000000 --- a/src/app/shared/nodes/human-interaction/human-interaction.css +++ /dev/null @@ -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; -} diff --git a/src/app/shared/nodes/human-interaction/human-interaction.html b/src/app/shared/nodes/human-interaction/human-interaction.html deleted file mode 100644 index 58cf3ca..0000000 --- a/src/app/shared/nodes/human-interaction/human-interaction.html +++ /dev/null @@ -1,152 +0,0 @@ -
-
-
- -
-
- Human Task -
- {{ name }} - -
-
-
- @if (missingRequiredParams.length) { -
-
- -
-
-
Missing required fields
- @for (missing of missingRequiredParams; track missing) { -
{{ missing }}
- } -
-
- } - -
-
- -
-
-
-
Inputs
- @for (input of inputs; track input.key) { -
-
-
- {{ input.key }} -
- } -
- -
-
Outputs
- @for (output of outputs; track output.key) { -
- {{ output.key }} -
-
-
- } -
-
- -
-
-
Action
- -
-
{{ actionDescription || '-' }}
-
- -
-
Simulated with
-
- - - Provider - - - {{ provider || '-' }} - - - - Model - - - {{ model || '-' }} - - @if (isConditionallyRequired('llmDescriptor.authorization')) { - - - Authorization - - - {{ authorization || '-' }} - - } -
-
-
- - @if (localEditorOpen) { -
-
-
Edit {{ localEditorLabel }}
-
- - @if (localEditorLoading) { - - } @else if (localEditorKey !== 'name' && localEditorOptions.length) { - - } @else { - - } -
-
- - -
-
-
- } -
diff --git a/src/app/shared/nodes/human-interaction/human-interaction.ts b/src/app/shared/nodes/human-interaction/human-interaction.ts deleted file mode 100644 index 6d5377f..0000000 --- a/src/app/shared/nodes/human-interaction/human-interaction.ts +++ /dev/null @@ -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(); - 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 { - 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, path: string): unknown { - return path.split('.').reduce((acc, key) => { - if (acc == null || typeof acc !== 'object') return undefined; - return (acc as Record)[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 | 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 = {}; - 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. - } - }); - } -} diff --git a/src/app/shared/nodes/input/input-node.component.html b/src/app/shared/nodes/input/input-node.component.html deleted file mode 100644 index a2b4006..0000000 --- a/src/app/shared/nodes/input/input-node.component.html +++ /dev/null @@ -1,31 +0,0 @@ -
- -
- -
-
- {{ data.label }} -
-
- Text -
-
-
-
-
-
- -
diff --git a/src/app/shared/nodes/input/input-node.component.scss b/src/app/shared/nodes/input/input-node.component.scss deleted file mode 100644 index 0edb29c..0000000 --- a/src/app/shared/nodes/input/input-node.component.scss +++ /dev/null @@ -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); -} diff --git a/src/app/shared/nodes/input/input-node.component.ts b/src/app/shared/nodes/input/input-node.component.ts deleted file mode 100644 index f2bd53f..0000000 --- a/src/app/shared/nodes/input/input-node.component.ts +++ /dev/null @@ -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(); - } - } - -} diff --git a/src/app/shared/nodes/llm/llm.ts b/src/app/shared/nodes/llm/llm.ts deleted file mode 100644 index 9d5d23f..0000000 --- a/src/app/shared/nodes/llm/llm.ts +++ /dev/null @@ -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(); - 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 | null { - return this.data?.data?.specificConfiguration ?? null; - } - - private ensureBlockConfiguration(): Record { - 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, path: string): unknown { - return path.split('.').reduce((acc, key) => { - if (acc == null || typeof acc !== 'object') return undefined; - return (acc as Record)[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 | 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 = {}; - 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. - } - }); - } - -} diff --git a/src/app/shared/nodes/output/output-node.component.html b/src/app/shared/nodes/output/output-node.component.html deleted file mode 100644 index 3d7c70b..0000000 --- a/src/app/shared/nodes/output/output-node.component.html +++ /dev/null @@ -1,33 +0,0 @@ -
- -
- -
-
- {{ data.label }} -
-
-
-
-
- Text -
- -
-
-
- diff --git a/src/app/shared/nodes/output/output-node.component.scss b/src/app/shared/nodes/output/output-node.component.scss deleted file mode 100644 index 934b42b..0000000 --- a/src/app/shared/nodes/output/output-node.component.scss +++ /dev/null @@ -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); -} diff --git a/src/app/shared/nodes/output/output-node.component.ts b/src/app/shared/nodes/output/output-node.component.ts deleted file mode 100644 index 2a173a3..0000000 --- a/src/app/shared/nodes/output/output-node.component.ts +++ /dev/null @@ -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(); - } - } -} diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css new file mode 100644 index 0000000..f08bb9f --- /dev/null +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -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; +} diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html new file mode 100644 index 0000000..81f3360 --- /dev/null +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -0,0 +1,156 @@ +
+ @if (hasExecutionErrors()) { +
+
+ +
+
+
Execution errors
+ @for (error of executionErrors(); track $index) { +
{{ error }}
+ } +
+
+ } + @if (hasExecutionWarnings()) { +
+
+ +
+
+
Execution warnings
+ @for (warning of executionWarnings(); track $index) { +
{{ warning }}
+ } +
+
+ } + +
+
+ @if (isHumanNode()) { + + } @else { + LLM + } +
+
+ {{ nodeTitle() }} +
+ {{ name }} +
+
+
+ +
+
+
Inputs
+ @for (input of inputs; track input.key) { +
+
+
+ + {{ input.key }} + @if (executionInputTooltip(input.key); as inputTooltip) { + + + {{ inputTooltip }} + + } + +
+ } +
+ +
+
Outputs
+ @for (output of outputs; track output.key) { +
+ {{ output.key }} +
+
+
+ } +
+
+ +
+ @if (parameterFieldGroups.length) { +
+ @for (group of parameterFieldGroups; track group.key) { +
+ {{ group.legend }} +
+ @for (field of group.fields; track field.path) { +
+
+ {{ field.label }} +
+ {{ field.value }} +
+ } +
+
+ } +
+ } + + @if (parameterFields.length) { +
+ @for (field of parameterFields; track field.path) { +
+
+ {{ field.label }} +
+ {{ field.value }} +
+ } +
+ } + + @if (hasMainContent()) { +
+
+
{{ mainContentLabel() }}
+
+
+ @for (part of mainContentParts(); track $index) { + @if (part.isDynamicInput) { + {{ formatDynamicInputToken(part.text) }} + } @else { + {{ part.text }} + } + } +
+
+ } +
+
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts new file mode 100644 index 0000000..0387ee3 --- /dev/null +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -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 | null = null; + private variablePlaceholderPaths = new Set(); + + 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(); + 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 | 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 | 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, prefix = ''): Array<{ path: string; value: unknown }> { + const entries: Array<{ path: string; value: unknown }> = []; + for (const [key, value] of Object.entries(source ?? {})) { + const path = prefix ? `${prefix}.${key}` : key; + if (key.startsWith('__')) continue; + if (value != null && typeof value === 'object' && !Array.isArray(value)) { + entries.push(...this.flattenPrimitiveValues(value as Record, path)); + } else { + entries.push({ path, value }); + } + } + return entries; + } + + private pickMainContentEntry(entries: Array<{ path: string; value: unknown }>) { + const candidates = entries + .filter((entry) => !['name', 'type'].includes(entry.path)) + .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 | null; + this.variablePlaceholderPaths = this.extractVariablePlaceholderPaths(this.blockSchema); + this.rebuildDisplayState(); + } + + private extractVariablePlaceholderPaths(schema: Record | null): Set { + const paths = new Set(); + if (!schema) return paths; + + const walk = (node: Record, pathPrefix: string) => { + const resolved = this.resolveRef(node, schema); + if (!resolved || typeof resolved !== 'object') return; + + const properties = resolved.properties as Record | undefined; + if (!properties) return; + + for (const [key, childSchema] of Object.entries(properties)) { + const childResolved = this.resolveRef(childSchema as Record, schema); + const path = pathPrefix ? `${pathPrefix}.${key}` : key; + const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; + if (hasChildren) { + walk(childResolved as Record, 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, root: Record) { + if (!node || typeof node !== 'object') return node; + const ref = node['$ref']; + if (typeof ref !== 'string' || !ref.startsWith('#/')) return node; + + const path = ref.slice(2).split('/'); + let current: any = root; + for (const segment of path) { + current = current?.[segment]; + if (current == null) return node; + } + return current; + } + + private refreshView() { + queueMicrotask(() => { + try { + this.cdr.detectChanges(); + } catch { + // Node may have been removed while async updates were running. + } + }); + } + +} diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index 2e6bf34..4451aef 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -15,6 +15,8 @@ import { firstValueFrom } from 'rxjs'; export class ReteEditor implements OnChanges, OnDestroy { readonly flowData = input.required(); readonly flowId = input.required(); + readonly readonly = input(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); diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.css b/src/app/shared/task-execution-viewer/task-execution-viewer.css index 478abc9..780064d 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.css +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css @@ -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; +} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index 0e993a4..cdb58bb 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -1,29 +1,63 @@
@if (execution()) {
-
-

{{ execution()!.title }}

+
+

{{ execution()!.name }}

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

-
-
-
Flow
-
{{ execution()!.flowName }}
-
+
Status
-
{{ execution()!.status }}
+
{{ execution()!.context.status }}
+
+
+
Start
+
{{ execution()!.context.startTime ? (execution()!.context.startTime | date:'dd/MM/yyyy HH:mm:ss') : '-' }}
+
+
+
End
+
{{ execution()!.context.endTime ? (execution()!.context.endTime | date:'dd/MM/yyyy HH:mm:ss') : '-' }}
Duration
-
{{ execution()!.duration || '-' }}
+
{{ durationMs() != null ? durationMs() + ' ms' : '-' }}
+
+
+
Steps
+
{{ stepsArray().length }}
-
-
- Execution details viewer placeholder +
+
+
Execution Graph (read-only)
+
+ + +
+
+ +
+
Execution Context
+
+
+
Result
+
{{ execution()!.context.result | json }}
+
+
+
Waiting Steps
+
{{ execution()!.context.waitingSteps | json }}
+
+
+
Execution Result
+
{{ execution()!.context.executionResult | json }}
+
+
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 331da3b..86f7e4a 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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(null); + readonly execution = input(null); + + readonly stepsArray = computed(() => + Object.values(this.execution()?.context.steps ?? {}) + ); + + readonly executionFlowData = computed(() => { + 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 + ): Record { + const result: Record = {}; + + 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[] { + 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[] { + const raw = contextWarnings[stepId]; + return raw && raw.trim().length > 0 ? [raw] : []; + } } diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts index 105a663..19bcd9e 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts @@ -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'; } } diff --git a/src/app/shared/title-toolbar/title-toolbar.html b/src/app/shared/title-toolbar/title-toolbar.html index 2067dc2..e59458f 100644 --- a/src/app/shared/title-toolbar/title-toolbar.html +++ b/src/app/shared/title-toolbar/title-toolbar.html @@ -57,3 +57,15 @@
+ +@if (flow()) { +
+ Author: {{ flow()!.author }} + Visibility: {{ flow()!.visibility }} + Created: {{ flow()!.createdAt | date:'dd/MM/yyyy HH:mm' }} + Updated: {{ flow()!.updatedAt | date:'dd/MM/yyyy HH:mm' }} + @if (flow()!.description) { + Description: {{ flow()!.description }} + } +
+} diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index 0f877f1..3a351d3 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -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'; }); diff --git a/src/app/stores/flow-editor.ts b/src/app/stores/flow-editor.ts index 4b07fa4..eca1e83 100644 --- a/src/app/stores/flow-editor.ts +++ b/src/app/stores/flow-editor.ts @@ -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 }; diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index c99e5e7..c7e1ab6 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -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; @@ -25,13 +23,15 @@ export type ReteEditorInstance = { export async function createEditor( container: HTMLElement, injector: Injector, - flowData: FlowData + flowData: FlowData, + options?: { nodeView?: "editor" | "execution" } ): Promise { const editor = new NodeEditor(); const area = new AreaPlugin(container); const connection = new ConnectionPlugin(); const render = new AngularPlugin({ 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; } -