added rete.js

This commit is contained in:
Lucio Lelii 2026-01-19 17:15:58 +01:00
parent 240de8a6e2
commit e51d9a2fac
19 changed files with 476 additions and 16 deletions

18
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -15,7 +15,8 @@
<div class="flex-1 overflow-hidden">
<app-title-toolbar></app-title-toolbar>
<!-- editor canvas -->
<div class="flex-1 bg-amber-200 h-full">
<div class="flex-1 h-full">
<app-rete-editor></app-rete-editor>
</div>
</div>
} @else {

View File

@ -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;
}

100
src/app/models/test.ts Normal file
View File

@ -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
};

View File

@ -1,3 +1,3 @@
.enabled:hover {
background-color: var(--color-gray-100) ;
}
}

View File

@ -1,23 +1,26 @@
<aside class="h-full flex flex-row
bg-white border-r border-gray-200
transition-all duration-300 p-1" [class.w-82]="!collapsed()" [class.w-14]="collapsed()">
<div
class="transition-all duration-300 ease-in-out overflow-hidden"
[class.w-14]="collapsed()"
[class.w-72]="!collapsed()"
>
<aside class="h-full w-72 bg-white border-r border-gray-200 flex">
<div class="flex flex-col h-full">
<button (click)="collapse()" class="relative
w-6 h-6 rounded-full
bg-white border shadow
flex items-center justify-center
hover:bg-gray-50 z-10" [class.invisible]="collapsed()">
hover:bg-gray-50 z-10" [class.invisible]="open == null">
<i class="bi bi-chevron-left text-xs transition-transform"></i>
</button>
<!-- ICON MODE -->
<div class="flex flex-col gap-4 items-center pt-6 flex-1">
<i class="bi bi-lightning-charge-fill text-indigo-500 text-xl cursor-pointer enabled p-2"
(click)="openSide('flows')"></i>
<i [ngClass]="{'cursor-none bg-gray-200' : open == 'flows'}"
[ngClass]="{'cursor-pointer enabled' : open != 'flows'}"
class="bi bi-lightning-charge-fill text-indigo-500 text-xl p-2"
(click)="open != 'flows' && openSide('flows')"></i>
<i [ngClass]="{'cursor-pointer text-gray-600 enabled' : !blockDisabled()}"
class="bi bi-boxes text-gray-200 text-xl p-2" (click)="!blockDisabled() && openSide('blocks')"></i>
</div>
@ -51,4 +54,5 @@
</div>
}
</aside>
</aside>
</div>

View File

@ -35,6 +35,7 @@ export class EditorSidebar {
collapse() {
this.collapsed.set(true);
this.open = null;
}
createNewFlow() {

View File

@ -0,0 +1,22 @@
<div class="rounded-xl bg-white shadow-md border border-gray-200">
<div class="p-3 flex flex-col items-center gap-2">
<div class="w-full flex justify-end">
<div
class="cursor-pointer"
refComponent
[data]="{
type: 'socket',
side: 'output',
key: data.outputs[0]?.id,
nodeId: data.id,
payload: data.outputs[0]?.socket,
seed: seed
}"
[emit]="emit"
data-testid="output-socket"
></div>
</div>
<p class="text-sm font-semibold text-gray-700">Input</p>
</div>
</div>

View File

@ -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;
}

View File

@ -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<string, { index?: number } | undefined>,
b: KeyValue<string, { index?: number } | undefined>
): number {
return (a.value?.index ?? 0) - (b.value?.index ?? 0);
}
}

View File

@ -0,0 +1,21 @@
<div class="rounded-xl bg-white shadow-md border border-gray-200">
<div class="p-3 flex items-center justify-center">
<p class="text-sm font-semibold text-gray-700">Output</p>
<div class="w-full flex justify-end">
<div
class="cursor-pointer"
refComponent
[data]="{
type: 'socket',
side: 'input',
key: data.inputs[0]?.id,
nodeId: data.id,
payload: data.inputs[0]?.socket,
seed: seed
}"
[emit]="emit"
data-testid="output-socket"
></div>
</div>
</div>
</div>

View File

@ -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;
}

View File

@ -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<string, N & { index?: number }>
>(a: T, b: T) {
const ai = a.value.index || 0;
const bi = b.value.index || 0;
return ai - bi;
}
}

View File

@ -0,0 +1,2 @@
<div class="h-full w-full" #editor>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReteEditor } from './rete-editor';
describe('ReteEditor', () => {
let component: ReteEditor;
let fixture: ComponentFixture<ReteEditor>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReteEditor]
})
.compileComponents();
fixture = TestBed.createComponent(ReteEditor);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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);
}
}
}

View File

@ -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<ClassicPreset.Node, ClassicPreset.Node>
>;
type AreaExtra = AngularArea2D<Schemes>;
export async function createEditor(container: HTMLElement, injector: Injector, flowData?: any) {
const socket = new ClassicPreset.Socket("socket");
const editor = new NodeEditor<Schemes>();
const area = new AreaPlugin<Schemes, AreaExtra>(container);
const connection = new ConnectionPlugin<Schemes, AreaExtra>();
const render = new AngularPlugin<Schemes, AreaExtra>({ 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();
}