From e51d9a2facb178993b61ae057c73727167e72f49 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 19 Jan 2026 17:15:58 +0100 Subject: [PATCH] added rete.js --- package-lock.json | 18 +++- package.json | 3 + src/app/layouts/flow-editor/flow-editor.html | 3 +- src/app/layouts/flow-editor/flow-editor.ts | 7 +- src/app/models/test.ts | 100 ++++++++++++++++++ .../main/editor-sidebar/editor-sidebar.css | 2 +- .../main/editor-sidebar/editor-sidebar.html | 22 ++-- .../main/editor-sidebar/editor-sidebar.ts | 1 + .../nodes/input/input-node.component.html | 22 ++++ .../nodes/input/input-node.component.scss | 38 +++++++ .../nodes/input/input-node.component.ts | 47 ++++++++ .../nodes/output/output-node.component.html | 21 ++++ .../nodes/output/output-node.component.scss | 22 ++++ .../nodes/output/output-node.component.ts | 50 +++++++++ src/app/shared/rete-editor/rete-editor.css | 0 src/app/shared/rete-editor/rete-editor.html | 2 + .../shared/rete-editor/rete-editor.spec.ts | 23 ++++ src/app/shared/rete-editor/rete-editor.ts | 23 ++++ src/app/utilities/rete-editor.ts | 88 +++++++++++++++ 19 files changed, 476 insertions(+), 16 deletions(-) create mode 100644 src/app/models/test.ts create mode 100644 src/app/shared/nodes/input/input-node.component.html create mode 100644 src/app/shared/nodes/input/input-node.component.scss create mode 100644 src/app/shared/nodes/input/input-node.component.ts create mode 100644 src/app/shared/nodes/output/output-node.component.html create mode 100644 src/app/shared/nodes/output/output-node.component.scss create mode 100644 src/app/shared/nodes/output/output-node.component.ts create mode 100644 src/app/shared/rete-editor/rete-editor.css create mode 100644 src/app/shared/rete-editor/rete-editor.html create mode 100644 src/app/shared/rete-editor/rete-editor.spec.ts create mode 100644 src/app/shared/rete-editor/rete-editor.ts create mode 100644 src/app/utilities/rete-editor.ts diff --git a/package-lock.json b/package-lock.json index 0825001..670417d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-angular": "^0.561.0", + "rete": "^2.0.6", "rete-angular-plugin": "^2.6.0", + "rete-area-plugin": "^2.1.5", + "rete-connection-plugin": "^2.0.5", "rete-render-utils": "^2.0.3", "rxjs": "~7.8.0", "tailwind-merge": "^3.4.0", @@ -8241,7 +8244,6 @@ "integrity": "sha512-kPmlKCGFES2VWtY7Y7SCB8ZeXRMsgX5deza9cu4OwmfM/ZUimd461kC3hRyccoyVxE4POlHUx0gg2jcGfusHFg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" } @@ -8270,7 +8272,6 @@ "resolved": "https://registry.npmjs.org/rete-area-plugin/-/rete-area-plugin-2.1.5.tgz", "integrity": "sha512-iquEvwkQlcsO4cmgM3Z37TG0AWaE536dfA+lCJAze5YJzVx4RBaViUCqdB4dUA/utSytpBCkiDC4D3ztM9akGQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -8278,6 +8279,19 @@ "rete": "^2.0.0" } }, + "node_modules/rete-connection-plugin": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/rete-connection-plugin/-/rete-connection-plugin-2.0.5.tgz", + "integrity": "sha512-KFtlOyEJRc0y9STVgo2T+t+j9u5fxiTxbyzPbMCm0uqncb3b8d2ABDIzvWoNo5zQAh2Oz/OvlUovupbzrGzpSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "peerDependencies": { + "rete": "^2.0.1", + "rete-area-plugin": "^2.0.0" + } + }, "node_modules/rete-render-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/rete-render-utils/-/rete-render-utils-2.0.3.tgz", diff --git a/package.json b/package.json index 6cacd68..e7ee28d 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-angular": "^0.561.0", + "rete": "^2.0.6", "rete-angular-plugin": "^2.6.0", + "rete-area-plugin": "^2.1.5", + "rete-connection-plugin": "^2.0.5", "rete-render-utils": "^2.0.3", "rxjs": "~7.8.0", "tailwind-merge": "^3.4.0", diff --git a/src/app/layouts/flow-editor/flow-editor.html b/src/app/layouts/flow-editor/flow-editor.html index 4603227..a8db7c9 100644 --- a/src/app/layouts/flow-editor/flow-editor.html +++ b/src/app/layouts/flow-editor/flow-editor.html @@ -15,7 +15,8 @@
-
+
+
} @else { diff --git a/src/app/layouts/flow-editor/flow-editor.ts b/src/app/layouts/flow-editor/flow-editor.ts index f7f3064..6dc7df9 100644 --- a/src/app/layouts/flow-editor/flow-editor.ts +++ b/src/app/layouts/flow-editor/flow-editor.ts @@ -1,12 +1,12 @@ -import { Component, inject } from '@angular/core'; -import { Flow } from '@models/flow'; +import { Component, inject} from '@angular/core'; import { EditorSidebar } from "@pages/main/editor-sidebar/editor-sidebar"; import { EditorStateHolder } from '@stores/flow-editor'; import { TitleToolbar } from "@shared/title-toolbar/title-toolbar"; +import { ReteEditor } from "@shared/rete-editor/rete-editor"; @Component({ selector: 'app-flow-editor', - imports: [EditorSidebar, TitleToolbar], + imports: [EditorSidebar, TitleToolbar, ReteEditor], templateUrl: './flow-editor.html', styleUrl: './flow-editor.css', }) @@ -15,4 +15,5 @@ export class FlowEditor { private editorState: EditorStateHolder = inject(EditorStateHolder); flow = this.editorState.currentFlow; + } diff --git a/src/app/models/test.ts b/src/app/models/test.ts new file mode 100644 index 0000000..ed8b2c5 --- /dev/null +++ b/src/app/models/test.ts @@ -0,0 +1,100 @@ +export const flowTest = { + "id": "testFlow", + "createdBy": "lucio.lelii", + "name": "Test Flow", + "description": "This is a test flow", + "nodes": [ + { + "key": "1328196f-5ddd-43b0-a702-2d1376970c70", + "name": "Input Node", + "position": null, + "parameters": null, + "nodeDefinition": { + "category": "Input", + "runtimeParameters": null, + "fixedParameters": null, + "inputs": {}, + "outputs": { + "phrase": { + "type": "TEXT", + "multiple": false + } + }, + "simulable": false, + "name": "Phrase" + } + }, + { + "key": "f142511e-f410-4974-9354-3a1e056e056f", + "name": "translator to french", + "position": null, + "parameters": { + "LLMProvider": "OllamaTestProvider", + "LLMModel": "sam860/gemma3:270m" + }, + "nodeDefinition": { + "category": "Execution", + "executor": "genericAIExecutorTest", + "inputMappers": { + "prompt": { + "type": "translator", + "translation": "translate the following phrase : \"${{phrase}}\" in french, return only the translation" + } + }, + "outputMappers": { + "translated": { + "type": "direct", + "fieldName": "response" + } + }, + "name": "French Translator", + "runtimeParameters": null, + "fixedParameters": null, + "inputs": { + "phrase": { + "type": "TEXT", + "multiple": false + } + }, + "outputs": { + "translated": { + "type": "TEXT", + "multiple": false + } + } + } + }, + { + "key": "e3488c96-b55b-4763-bc25-54d3d3755d85", + "name": "Output Node", + "position": null, + "parameters": null, + "nodeDefinition": { + "category": "Output", + "runtimeParameters": null, + "fixedParameters": null, + "outputs": {}, + "inputs": { + "translated": { + "type": "TEXT", + "multiple": false + } + }, + "name": "Translated Phrase" + } + } + ], + "connections": [ + { + "key": "18739497-51e6-4d09-be40-398c3601a326", + "from": "1328196f-5ddd-43b0-a702-2d1376970c70:phrase", + "to": "f142511e-f410-4974-9354-3a1e056e056f:phrase" + }, + { + "key": "f88d9442-4a9f-4b4b-ac3f-9794dd0bf6db", + "from": "f142511e-f410-4974-9354-3a1e056e056f:translated", + "to": "e3488c96-b55b-4763-bc25-54d3d3755d85:translated" + } + ], + "public": false +}; diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.css b/src/app/pages/main/editor-sidebar/editor-sidebar.css index a791298..c9fda45 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.css +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.css @@ -1,3 +1,3 @@ .enabled:hover { background-color: var(--color-gray-100) ; -} \ No newline at end of file +} diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.html b/src/app/pages/main/editor-sidebar/editor-sidebar.html index 4d04a53..b0b3956 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.html +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.html @@ -1,23 +1,26 @@ - +
\ No newline at end of file diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.ts b/src/app/pages/main/editor-sidebar/editor-sidebar.ts index 86571e8..8292c6a 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.ts +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.ts @@ -35,6 +35,7 @@ export class EditorSidebar { collapse() { this.collapsed.set(true); + this.open = null; } createNewFlow() { diff --git a/src/app/shared/nodes/input/input-node.component.html b/src/app/shared/nodes/input/input-node.component.html new file mode 100644 index 0000000..1fa9877 --- /dev/null +++ b/src/app/shared/nodes/input/input-node.component.html @@ -0,0 +1,22 @@ +
+
+
+
+
+ +

Input

+
+
diff --git a/src/app/shared/nodes/input/input-node.component.scss b/src/app/shared/nodes/input/input-node.component.scss new file mode 100644 index 0000000..37ec2c8 --- /dev/null +++ b/src/app/shared/nodes/input/input-node.component.scss @@ -0,0 +1,38 @@ +.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; +} \ No newline at end of file diff --git a/src/app/shared/nodes/input/input-node.component.ts b/src/app/shared/nodes/input/input-node.component.ts new file mode 100644 index 0000000..800daa3 --- /dev/null +++ b/src/app/shared/nodes/input/input-node.component.ts @@ -0,0 +1,47 @@ +import { + Component, + Input, + HostBinding, + ChangeDetectorRef, + OnChanges +} from "@angular/core"; +import { ClassicPreset } from "rete"; +import { CommonModule, KeyValue } from "@angular/common"; +import { ReteModule } from "rete-angular-plugin/21"; + +@Component({ + templateUrl: "./input-node.component.html", + styleUrls: ["./input-node.component.scss"], + imports: [CommonModule, ReteModule], + host: { + "data-testid": "input-node" + } +}) +export class InputNodeComponent implements OnChanges { + @Input() data!: ClassicPreset.Node; + @Input() emit!: (data: any) => void; + @Input() rendered!: () => void; + + seed = 0; + + @HostBinding("class.selected") get selected() { + return this.data.selected; + } + + constructor(private cdr: ChangeDetectorRef) { + this.cdr.detach(); + } + + ngOnChanges(): void { + this.cdr.detectChanges(); + requestAnimationFrame(() => this.rendered()); + this.seed++; // force render sockets + } + + sortByIndex( + a: KeyValue, + b: KeyValue +): number { + return (a.value?.index ?? 0) - (b.value?.index ?? 0); +} +} diff --git a/src/app/shared/nodes/output/output-node.component.html b/src/app/shared/nodes/output/output-node.component.html new file mode 100644 index 0000000..9d19f27 --- /dev/null +++ b/src/app/shared/nodes/output/output-node.component.html @@ -0,0 +1,21 @@ +
+
+

Output

+
+
+
+
+
diff --git a/src/app/shared/nodes/output/output-node.component.scss b/src/app/shared/nodes/output/output-node.component.scss new file mode 100644 index 0000000..a6694b2 --- /dev/null +++ b/src/app/shared/nodes/output/output-node.component.scss @@ -0,0 +1,22 @@ +.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; +} diff --git a/src/app/shared/nodes/output/output-node.component.ts b/src/app/shared/nodes/output/output-node.component.ts new file mode 100644 index 0000000..c6be339 --- /dev/null +++ b/src/app/shared/nodes/output/output-node.component.ts @@ -0,0 +1,50 @@ +import { + Component, + Input, + HostBinding, + ChangeDetectorRef, + OnChanges +} from "@angular/core"; +import { ClassicPreset } from "rete"; +import { CommonModule, KeyValue } 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": "output-node" + } +}) +export class OutputNodeComponent implements OnChanges { + @Input() data!: ClassicPreset.Node; + @Input() emit!: (data: any) => void; + @Input() rendered!: () => void; + + seed = 0; + + @HostBinding("class.selected") get selected() { + return this.data.selected; + } + + constructor(private cdr: ChangeDetectorRef) { + this.cdr.detach(); + } + + ngOnChanges(): void { + this.cdr.detectChanges(); + requestAnimationFrame(() => this.rendered()); + this.seed++; // force render sockets + } + + sortByIndex< + N extends object, + T extends KeyValue + >(a: T, b: T) { + const ai = a.value.index || 0; + const bi = b.value.index || 0; + + return ai - bi; + } +} diff --git a/src/app/shared/rete-editor/rete-editor.css b/src/app/shared/rete-editor/rete-editor.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/rete-editor/rete-editor.html b/src/app/shared/rete-editor/rete-editor.html new file mode 100644 index 0000000..ad6bdc1 --- /dev/null +++ b/src/app/shared/rete-editor/rete-editor.html @@ -0,0 +1,2 @@ +
+
\ No newline at end of file diff --git a/src/app/shared/rete-editor/rete-editor.spec.ts b/src/app/shared/rete-editor/rete-editor.spec.ts new file mode 100644 index 0000000..46e1440 --- /dev/null +++ b/src/app/shared/rete-editor/rete-editor.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ReteEditor } from './rete-editor'; + +describe('ReteEditor', () => { + let component: ReteEditor; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ReteEditor] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ReteEditor); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts new file mode 100644 index 0000000..e38fcf1 --- /dev/null +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -0,0 +1,23 @@ +import { Component, ElementRef, Injector, ViewChild } from '@angular/core'; +import { createEditor } from '@utilities/rete-editor'; + +@Component({ + selector: 'app-rete-editor', + imports: [], + templateUrl: './rete-editor.html', + styleUrl: './rete-editor.css', +}) +export class ReteEditor { + + constructor(private injector: Injector) {} + + @ViewChild("editor") container!: ElementRef; + + ngAfterViewInit(): void { + const el = this.container.nativeElement; + + if (el) { + createEditor(el, this.injector); + } + } +} diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts new file mode 100644 index 0000000..d93d9b5 --- /dev/null +++ b/src/app/utilities/rete-editor.ts @@ -0,0 +1,88 @@ +import { Injector } from "@angular/core"; +import { NodeEditor, GetSchemes, ClassicPreset } from "rete"; +import { AreaPlugin, AreaExtensions } from "rete-area-plugin"; +import { + ConnectionPlugin, + Presets as ConnectionPresets +} from "rete-connection-plugin"; +import { AngularPlugin, Presets, AngularArea2D, NodeComponent } from "rete-angular-plugin/21"; +import { InputNodeComponent } from "@shared/nodes/input/input-node.component"; +import { OutputNodeComponent } from "@shared/nodes/output/output-node.component"; +import { flowTest } from "@models/test"; + +type Schemes = GetSchemes< + ClassicPreset.Node, + ClassicPreset.Connection +>; +type AreaExtra = AngularArea2D; + +export async function createEditor(container: HTMLElement, injector: Injector, flowData?: any) { + const socket = new ClassicPreset.Socket("socket"); + + const editor = new NodeEditor(); + const area = new AreaPlugin(container); + const connection = new ConnectionPlugin(); + const render = new AngularPlugin({ injector }); + + AreaExtensions.selectableNodes(area, AreaExtensions.selector(), { + accumulating: AreaExtensions.accumulateOnCtrl() + }); + + render.addPreset( + Presets.classic.setup({ + customize: { + node(context) { + if (context.payload.label === "Input") { + return InputNodeComponent; + } + if (context.payload.label === "Output") { + return OutputNodeComponent; + } + + return NodeComponent; + + } + }, + }) + ); + + connection.addPreset(ConnectionPresets.classic.setup()); + + editor.use(area); + area.use(connection); + area.use(render); + + AreaExtensions.simpleNodesOrder(area); + + if (flowTest) { + for (const nodeData of flowTest.nodes) { + const node = new ClassicPreset.Node(nodeData.nodeDefinition?.category || "Undefined"); + node.id = nodeData.key; + Object.entries(nodeData.nodeDefinition?.outputs).forEach(([output, def]) => { + node.addOutput(output, + new ClassicPreset.Output( + socket, output + ) + ); + }); + Object.entries(nodeData.nodeDefinition?.inputs).forEach(([input, def]) => { + console.log("Adding input", input); + node.addInput(input, + new ClassicPreset.Input( + socket, input + ) + ); + }); + + await editor.addNode(node); + /*if (nodeData.position != null) + area.translate(node.id, { x: nodeData.position.x, y: nodeData.position.y });*/ + } + } + + //await editor.addConnection(new ClassicPreset.Connection(a, "a", b, "b")); + + AreaExtensions.zoomAt(area, editor.getNodes()); + + return () => area.destroy(); +}