+
+
+
Inputs
+ @for (input of inputs; track input.key) {
+
+ }
-
-
- {{ inputKey }}
-
-
+
+
Outputs
+ @for (output of outputs; track output.key) {
+
+ }
+
-
-
-
-
-
-
- {{ outputKey }}
-
-
-
-
+
+
+
+
+
+ Provider
+
+
+
{{ provider || '-' }}
+
+
+
+
+ Model
+
+
+
{{ model || '-' }}
+
+
+
+
+
+
+ @if (!promptParts.length) {
+ -
+ } @else {
+ @for (part of promptParts; track $index) {
+ @if (part.isDynamicInput) {
+ {{ formatDynamicInputToken(part.text) }}
+ } @else {
+ {{ part.text }}
+ }
+ }
+ }
+
+
+ @if (localEditorOpen) {
+
+
+
Edit {{ localEditorLabel }}
+
+
+
+
+
+
+
+
+
+
+ }
+
-
-
-
\ No newline at end of file
diff --git a/src/app/shared/nodes/llm/llm.ts b/src/app/shared/nodes/llm/llm.ts
index 5fc2c8b..a99aed9 100644
--- a/src/app/shared/nodes/llm/llm.ts
+++ b/src/app/shared/nodes/llm/llm.ts
@@ -1,11 +1,13 @@
import { CommonModule } from '@angular/common';
-import { Component, HostBinding, Input } from '@angular/core';
+import { 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';
@Component({
selector: 'app-llm',
- imports: [CommonModule, ReteModule],
+ imports: [CommonModule, FormsModule, ReteModule],
templateUrl: './llm.html',
styleUrl: './llm.css',
host: {
@@ -14,6 +16,8 @@ import { ReteModule } from 'rete-angular-plugin/21';
})
export class LLMNodeComponent {
+ private settingsDialog = inject(NodeSettingsDialogService);
+
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@@ -23,32 +27,182 @@ export class LLMNodeComponent {
return this.data.selected;
}
- outputKey!: string;
- outputSocket!: ClassicPreset.Socket;
- inputKey!: string;
- inputSocket!: ClassicPreset.Socket;
+ 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;
+ name: string = 'noName';
+ localEditorOpen = false;
+ localEditorKey: 'provider' | 'model' | null = null;
+ localEditorLabel = '';
+ localEditorValue = '';
ngOnInit() {
- const outputEntries = Object.entries(this.data.outputs);
- const [outKey, output] = outputEntries[0];
+ this.outputs = [];
+ this.inputs = [];
+ this.parameterEntries = [];
+ this.prompt = null;
+ this.promptParts = [];
+ this.provider = null;
+ this.model = null;
- this.outputKey = outKey;
- this.outputSocket = (output as any).socket;
- console.log("output data", this.outputSocket);
+ 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);
- const [inKey, input] = inputEntries[0];
+
+ inputEntries.forEach(([inKey, input]) => {
+ this.inputs.push({ key: inKey, socket: (input as any).socket });
+ });
- this.inputKey = inKey;
- this.inputSocket = (input as any).socket;
- console.log("input data", this.inputSocket);
+ const config = this.blockConfiguration;
+ if (!config) return;
+
+ 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']);
+
+ 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) });
+ });
}
ngAfterViewInit() {
this.rendered();
}
-
+ openSimpleParamEditor(key: 'provider' | 'model', event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.localEditorKey = key;
+ this.localEditorLabel = key === 'provider' ? 'Provider' : 'Model';
+ this.localEditorValue = key === 'provider' ? (this.provider ?? '') : (this.model ?? '');
+ this.localEditorOpen = true;
+ }
+
+ closeSimpleParamEditor(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.localEditorOpen = false;
+ this.localEditorKey = null;
+ }
+
+ saveSimpleParamEditor(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ if (!this.localEditorKey) return;
+
+ const config = this.ensureBlockConfiguration();
+ const descriptor = (config['llmDescriptor'] ??= {});
+ const value = this.localEditorValue.trim();
+ descriptor[this.localEditorKey] = value;
+
+ if (this.localEditorKey === 'provider') {
+ this.provider = value || null;
+ }
+ if (this.localEditorKey === 'model') {
+ this.model = value || null;
+ }
+
+ this.localEditorOpen = false;
+ this.localEditorKey = null;
+ }
+
+ 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);
+ }
+
+ 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 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;
+ }
}
diff --git a/src/app/shared/rete-editor/rete-editor.html b/src/app/shared/rete-editor/rete-editor.html
index ad6bdc1..2a19579 100644
--- a/src/app/shared/rete-editor/rete-editor.html
+++ b/src/app/shared/rete-editor/rete-editor.html
@@ -1,2 +1,2 @@
-
-
\ No newline at end of file
+
+
diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts
index 9a00b69..d51ca36 100644
--- a/src/app/shared/rete-editor/rete-editor.ts
+++ b/src/app/shared/rete-editor/rete-editor.ts
@@ -1,7 +1,8 @@
-import { Component, ElementRef, Injector, Input, input, output, ViewChild } from '@angular/core';
-import { FlowData } from '@models/flow';
+import { Component, ElementRef, Injector, output, ViewChild } from '@angular/core';
+import { BlockType, FlowBlock, FlowData } from '@models/flow';
+import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
import { EditorStateHolder } from '@stores/flow-editor';
-import { createEditor } from '@utilities/rete-editor';
+import { addBlockToEditor, createEditor, ReteEditorInstance } from '@utilities/rete-editor';
@Component({
selector: 'app-rete-editor',
@@ -19,7 +20,8 @@ export class ReteEditor {
@ViewChild("editor") container!: ElementRef;
-
+ private rete?: ReteEditorInstance;
+
flowChanged = output();
@@ -27,16 +29,94 @@ export class ReteEditor {
const el = this.container.nativeElement;
if (el) {
- createEditor(el, this.injector, this.flowData).then(editor => {
- console.log("Rete editor created:", editor);
- editor.addPipe(
+ createEditor(el, this.injector, this.flowData).then((rete) => {
+ this.rete = rete;
+ rete.editor.addPipe(
(context) => {
this.flowChanged.emit({});
return context;
}
- )
+ );
});
}
}
+ onDragOver(event: DragEvent) {
+ event.preventDefault();
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = 'copy';
+ }
+ }
+
+ async onDrop(event: DragEvent) {
+ event.preventDefault();
+ const payload = event.dataTransfer?.getData(BLOCK_TYPE_DRAG_MIME);
+ if (!payload || !this.rete) return;
+
+ const blockType: BlockType = JSON.parse(payload);
+ const position = this.getDropPosition(event);
+ const newBlock = this.createBlockFromType(blockType, position);
+
+ await addBlockToEditor(this.rete.editor, this.rete.area, newBlock, position);
+ this.flowChanged.emit({});
+ }
+
+ private getDropPosition(event: DragEvent) {
+ const host = this.container.nativeElement as HTMLElement;
+ const rect = host.getBoundingClientRect();
+ const transform = this.rete!.area.area.transform;
+
+ const x = (event.clientX - rect.left - transform.x) / transform.k;
+ const y = (event.clientY - rect.top - transform.y) / transform.k;
+ return { x, y };
+ }
+
+ private createBlockFromType(blockType: BlockType, position: { x: number; y: number }): FlowBlock {
+ const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`;
+ const common = {
+ id,
+ sink: false,
+ name: blockType.name,
+ position,
+ typeName: blockType.name
+ } as const;
+
+ if (blockType.name === 'SourceBlock') {
+ return {
+ ...common,
+ inputs: [],
+ outputs: [{ name: 'output', type: 'TEXT', multiple: false }],
+ specificConfiguration: {}
+ };
+ }
+
+ if (blockType.name === 'HumanInteractionBlock') {
+ return {
+ ...common,
+ sink: true,
+ inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
+ outputs: [{ name: 'output', type: 'TEXT', multiple: false }],
+ specificConfiguration: {
+ type: 'HumanInteractiveBlockConfiguration',
+ name: blockType.name,
+ actionDescription: 'Human task',
+ llmDescriptor: { provider: 'default', model: 'default' },
+ inputAsList: false,
+ outputAsList: false
+ }
+ };
+ }
+
+ return {
+ ...common,
+ inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
+ outputs: [{ name: 'output', type: 'TEXT', multiple: false }],
+ specificConfiguration: {
+ type: 'LLMBlockConfiguration',
+ name: blockType.name,
+ llmDescriptor: { provider: 'default', model: 'default' },
+ prompt: ''
+ }
+ };
+ }
}
diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts
index a97abdf..6cf978c 100644
--- a/src/app/utilities/rete-editor.ts
+++ b/src/app/utilities/rete-editor.ts
@@ -14,8 +14,18 @@ import { LLMNodeComponent } from "@shared/nodes/llm/llm";
import { CustomSocket } from "@shared/custom-socket/custom-socket";
type AreaExtra = AngularArea2D;
+const editorSockets = new WeakMap, Map>();
-export async function createEditor(container: HTMLElement, injector: Injector, flowData: FlowData) {
+export type ReteEditorInstance = {
+ editor: NodeEditor;
+ area: AreaPlugin;
+};
+
+export async function createEditor(
+ container: HTMLElement,
+ injector: Injector,
+ flowData: FlowData
+): Promise {
const editor = new NodeEditor();
const area = new AreaPlugin(container);
@@ -59,10 +69,10 @@ export async function createEditor(container: HTMLElement, injector: Injector, f
AreaExtensions.simpleNodesOrder(area);
if (flowData)
- await loadFlowData(editor, flowData);
+ await loadFlowData(editor, area, flowData);
AreaExtensions.zoomAt(area, editor.getNodes());
- return editor;
+ return { editor, area };
}
export function exportGraph(editor: NodeEditor) {
@@ -110,38 +120,45 @@ export function exportGraph(editor: NodeEditor) {
};
}
-async function loadFlowData(editor: NodeEditor, flowData: FlowData) {
- if (!flowData.blocks?.length) return;
+export async function addBlockToEditor(
+ editor: NodeEditor,
+ area: AreaPlugin,
+ block: FlowBlock,
+ position?: { x: number; y: number }
+) {
+ const node = new ClassicPreset.Node(toNodeLabel(block.typeName)) as HFNode;
+ node.data = { ...block, position: position ?? block.position };
- const sockets = new Map();
- const getSocket = (type: string) => {
- if (!sockets.has(type)) sockets.set(type, new ClassicPreset.Socket(type));
- return sockets.get(type)!;
- };
+ for (const output of block.outputs ?? []) {
+ node.addOutput(output.name, new ClassicPreset.Output(getSocket(editor, output.type ?? "ANY")));
+ }
+
+ for (const input of block.inputs ?? []) {
+ node.addInput(input.name, new ClassicPreset.Input(getSocket(editor, input.type ?? "ANY")));
+ }
+
+ await editor.addNode(node);
+
+ const targetPosition = position ?? block.position;
+ if (targetPosition) {
+ await area.translate(node.id, targetPosition);
+ }
+
+ return node;
+}
+
+async function loadFlowData(
+ editor: NodeEditor,
+ area: AreaPlugin,
+ flowData: FlowData
+) {
+ if (!flowData.blocks?.length) return;
const nodeMapping = new Map();
for (const block of flowData.blocks) {
- const nodeLabel =
- block.typeName === "InputBlock"
- ? "Input"
- : block.typeName === "OutputBlock"
- ? "Output"
- : block.typeName;
- const node = new ClassicPreset.Node(nodeLabel) as HFNode;
- node.data = block;
-
+ const node = await addBlockToEditor(editor, area, block, block.position);
nodeMapping.set(block.id, node.id);
-
- for (const output of block.outputs ?? []) {
- node.addOutput(output.name, new ClassicPreset.Output(getSocket(output.type ?? "ANY")));
- }
-
- for (const input of block.inputs ?? []) {
- node.addInput(input.name, new ClassicPreset.Input(getSocket(input.type ?? "ANY")));
- }
-
- await editor.addNode(node);
}
for (const c of flowData.connections ?? []) {
@@ -155,6 +172,22 @@ async function loadFlowData(editor: NodeEditor, flowData: FlowData) {
);
}
}
+
+function getSocket(editor: NodeEditor, type: string) {
+ if (!editorSockets.has(editor)) {
+ editorSockets.set(editor, new Map());
+ }
+ const map = editorSockets.get(editor)!;
+ if (!map.has(type)) {
+ map.set(type, new ClassicPreset.Socket(type));
+ }
+ return map.get(type)!;
+}
+
+function toNodeLabel(typeName: string) {
+ if (typeName === "InputBlock" || typeName === "SourceBlock") return "Input";
+ if (typeName === "OutputBlock") return "Output";
+ return typeName;
+}
-
diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts
index 2a3fc0c..83b8ee4 100644
--- a/src/environments/environment.development.ts
+++ b/src/environments/environment.development.ts
@@ -1,9 +1,11 @@
import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake";
+import { BlocksCallServiceFake } from "@services/blocks/blocks-call.fake";
import { FlowsCallServiceFake } from "@services/flows/flows-call.fake";
export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
authorizationCallService: AuthorizationCallFakeService, // Assign the appropriate service here
- flowsCallService: FlowsCallServiceFake
+ flowsCallService: FlowsCallServiceFake,
+ blocksCallService: BlocksCallServiceFake
};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 49efb80..7becd89 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -1,9 +1,11 @@
import { AuthorizationCallService } from "@services/authorization/authorization-call";
+import { BlocksCallService } from "@services/blocks/blocks-call";
import { FlowsCallService } from "@services/flows/flows-call";
export const environment = {
apiUrl: '/api',
production: true,
authorizationCallService: AuthorizationCallService,
- flowsCallService: FlowsCallService
+ flowsCallService: FlowsCallService,
+ blocksCallService: BlocksCallService
};