diff --git a/public/llm_node.png b/public/llm_node.png new file mode 100644 index 0000000..6b17510 Binary files /dev/null and b/public/llm_node.png differ diff --git a/src/app/models/nodes.ts b/src/app/models/nodes.ts index 7bce8e4..758780a 100644 --- a/src/app/models/nodes.ts +++ b/src/app/models/nodes.ts @@ -2,9 +2,7 @@ import { GetSchemes, ClassicPreset } from "rete"; import { INodeModel } from "./flow"; export type HFNode = ClassicPreset.Node & { - data?: { - value: INodeModel; - }; + data: INodeModel; }; export type HFConnection = ClassicPreset.Connection; diff --git a/src/app/shared/nodes/input/input-node.component.html b/src/app/shared/nodes/input/input-node.component.html index f42c5a9..afff0bc 100644 --- a/src/app/shared/nodes/input/input-node.component.html +++ b/src/app/shared/nodes/input/input-node.component.html @@ -1,21 +1,17 @@ -
-
-

- {{ data.label }} -

+
+ +
+

{{ data.label }}

-
-
-
+
-
+
\ 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 index 468f31d..3017b9a 100644 --- a/src/app/shared/nodes/input/input-node.component.ts +++ b/src/app/shared/nodes/input/input-node.component.ts @@ -2,11 +2,9 @@ import { Component, Input, HostBinding, - ChangeDetectorRef, } from "@angular/core"; import { CommonModule, KeyValue } from "@angular/common"; import { ReteModule } from "rete-angular-plugin/21"; -import { HFNode } from "@models/nodes"; import { ClassicPreset } from "rete"; @Component({ @@ -22,7 +20,6 @@ export class InputNodeComponent { @Input() emit!: (data: any) => void; @Input() rendered!: () => void; - seed = 0; @HostBinding("class.selected") get selected() { return this.data.selected; @@ -35,6 +32,8 @@ export class InputNodeComponent { 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; } diff --git a/src/app/shared/nodes/llm/llm.css b/src/app/shared/nodes/llm/llm.css new file mode 100644 index 0000000..bf1ce3c --- /dev/null +++ b/src/app/shared/nodes/llm/llm.css @@ -0,0 +1,80 @@ +/* Nodo base */ +.node { + width: 220px; + border-radius: 14px; + background: #ffffff; + border: 1px solid #e5e7eb; /* gray-200 */ + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); + font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + overflow: hidden; +} + +/* Header */ +.header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: linear-gradient(135deg, #7c3aed, #6366f1); /* violet → indigo */ + color: white; +} + +/* Icona LLM */ +.icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; +} + +.icon img { + width: 18px; + height: 18px; +} + +/* Titolo */ +.title { + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +/* Body */ +.body { + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Riga input/output */ +.io { + display: flex; + align-items: center; + justify-content: space-between; +} + +/* Label IO */ +.label { + font-size: 12px; + color: #374151; /* gray-700 */ +} + +/* Socket */ +.socket { + width: 12px; + height: 12px; + border-radius: 999px; + background: #6366f1; + border: 2px solid white; + box-shadow: 0 0 0 1px #6366f1; + cursor: pointer; +} + +/* Hover */ +.socket:hover { + transform: scale(1.15); +} diff --git a/src/app/shared/nodes/llm/llm.html b/src/app/shared/nodes/llm/llm.html new file mode 100644 index 0000000..67c6148 --- /dev/null +++ b/src/app/shared/nodes/llm/llm.html @@ -0,0 +1,46 @@ +
+ +
+
+ LLM +
+ LLM Node +
+ + +
+ +
+ +
+ +
+ {{ inputKey }} +
+ +
+ +
+
+ {{ outputKey }} +
+ +
+
+ +
+ +
\ No newline at end of file diff --git a/src/app/shared/nodes/llm/llm.spec.ts b/src/app/shared/nodes/llm/llm.spec.ts new file mode 100644 index 0000000..b7a6c7d --- /dev/null +++ b/src/app/shared/nodes/llm/llm.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Llm } from './llm'; + +describe('Llm', () => { + let component: Llm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Llm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Llm); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/nodes/llm/llm.ts b/src/app/shared/nodes/llm/llm.ts new file mode 100644 index 0000000..d99fea1 --- /dev/null +++ b/src/app/shared/nodes/llm/llm.ts @@ -0,0 +1,50 @@ +import { CommonModule } from '@angular/common'; +import { Component, HostBinding, Input } from '@angular/core'; +import { ClassicPreset } from 'rete'; +import { ReteModule } from 'rete-angular-plugin/21'; + +@Component({ + selector: 'app-llm', + imports: [CommonModule, ReteModule], + templateUrl: './llm.html', + styleUrl: './llm.css', + host: { + "data-testid": "node" + } +}) +export class LLMNodeComponent { + + @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; + + inputKey!: string; + inputSocket!: ClassicPreset.Socket; + + ngOnInit() { + const outputEntries = Object.entries(this.data.outputs); + const [outKey, output] = outputEntries[0]; + + this.outputKey = outKey; + this.outputSocket = (output as any).socket; + + const inputEntries = Object.entries(this.data.inputs); + const [inKey, input] = inputEntries[0]; + + this.inputKey = inKey; + this.inputSocket = (input as any).socket; + } + + ngAfterViewInit() { + this.rendered(); + } + +} diff --git a/src/app/shared/nodes/output/output-node.component.html b/src/app/shared/nodes/output/output-node.component.html index 41d53da..c61f33b 100644 --- a/src/app/shared/nodes/output/output-node.component.html +++ b/src/app/shared/nodes/output/output-node.component.html @@ -1,20 +1,15 @@ -
-
-
-
+ +
+
-
-

- {{ data.label }} -

+ }" [emit]="emit">
+

{{ data.label }}

+ +
-
+
\ No newline at end of file diff --git a/src/app/shared/nodes/output/output-node.component.ts b/src/app/shared/nodes/output/output-node.component.ts index 546ba4b..e003974 100644 --- a/src/app/shared/nodes/output/output-node.component.ts +++ b/src/app/shared/nodes/output/output-node.component.ts @@ -2,13 +2,10 @@ import { Component, Input, HostBinding, - ChangeDetectorRef, - OnChanges } from "@angular/core"; import { ClassicPreset } from "rete"; -import { CommonModule, KeyValue } from "@angular/common"; +import { CommonModule } from "@angular/common"; import { ReteModule } from "rete-angular-plugin/21"; -import { HFNode } from "@models/nodes"; @Component({ templateUrl: "./output-node.component.html", @@ -23,8 +20,6 @@ export class OutputNodeComponent { @Input() emit!: (data: any) => void; @Input() rendered!: () => void; - seed = 0; - @HostBinding("class.selected") get selected() { return this.data.selected; } @@ -40,6 +35,8 @@ export class OutputNodeComponent { 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; diff --git a/src/app/shared/sockets/input/input.ts b/src/app/shared/sockets/input/input.ts new file mode 100644 index 0000000..230d3af --- /dev/null +++ b/src/app/shared/sockets/input/input.ts @@ -0,0 +1,35 @@ +import { Component, ElementRef, HostBinding, Input } from "@angular/core"; +import { ReteModule } from "rete-angular-plugin/21"; + +@Component({ + standalone: true, + imports: [ReteModule], + host: { + refComponent: '', + class: ` + cursor-pointer relative -left-1 w-[15px] h-[15px] flex items-center justify-center rounded-full bg-green-500 border-2 border-white + shadow-[0_0_0_1px_rgb(34,197,94)] + ` + }, + template: `` +}) +export class InputSocket { + @Input() data!: any; + @Input() emit!: (data: any) => void; + @Input() rendered!: () => void; + + @HostBinding("title") get title() { + return this.data?.name ?? ""; + } + + ngOnInit(): void { + console.log("InputSocket ngOnInit", this.data); + } + + + ngAfterViewInit() { + requestAnimationFrame(() => { + this.rendered?.(); + }); +} +} \ No newline at end of file diff --git a/src/app/shared/sockets/output/output.ts b/src/app/shared/sockets/output/output.ts new file mode 100644 index 0000000..9656d71 --- /dev/null +++ b/src/app/shared/sockets/output/output.ts @@ -0,0 +1,34 @@ +import { Component, ElementRef, HostBinding, Input } from '@angular/core'; +import { ReteModule } from 'rete-angular-plugin/21'; + +@Component({ + standalone: true, + imports: [ReteModule], + host: { + refComponent: '', + class: ` + cursor-pointer relative -right-1 w-[15px] h-[15px] flex items-center justify-center rounded-full bg-indigo-500 border-2 border-white + shadow-[0_0_0_1px_rgb(99,102,241)] + ` + }, + template: `` +}) +export class OutputSocket { + @Input() data!: any; + @Input() emit!: (data: any) => void; + @Input() rendered!: () => void; + + @HostBinding("title") get title() { + return this.data?.name ?? ""; + } + + ngOnInit(): void { + console.log("OutputSocket ngOnInit", this.data); + } + + ngAfterViewInit() { + requestAnimationFrame(() => { + this.rendered?.(); + }); +} +} \ No newline at end of file diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 0ae0128..7bcf869 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -1,4 +1,4 @@ -import { Injector } from "@angular/core"; +import { Injector, Input } from "@angular/core"; import { NodeEditor, GetSchemes, ClassicPreset } from "rete"; import { AreaPlugin, AreaExtensions } from "rete-area-plugin"; import { @@ -8,8 +8,11 @@ import { 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 { HFSchemes } from "@models/nodes"; -import { FlowData } from "@models/flow"; +import { HFNode, HFSchemes } from "@models/nodes"; +import { FlowData, INodeModel } from "@models/flow"; +import { LLMNodeComponent } from "@shared/nodes/llm/llm"; +import { OutputSocket } from "@shared/sockets/output/output"; +import { InputSocket } from "@shared/sockets/input/input"; type Schemes = GetSchemes< ClassicPreset.Node, @@ -19,8 +22,8 @@ type AreaExtra = AngularArea2D; export async function createEditor(container: HTMLElement, injector: Injector, flowData: FlowData) { - const editor = new NodeEditor(); - const area = new AreaPlugin(container); + const editor = new NodeEditor(); + const area = new AreaPlugin(container); const connection = new ConnectionPlugin(); const render = new AngularPlugin({ injector }); @@ -38,11 +41,13 @@ export async function createEditor(container: HTMLElement, injector: Injector, f if (context.payload.label === "Output") { return OutputNodeComponent; } - - return NodeComponent; - + return LLMNodeComponent; }, - + socket(context) { + const side = context.side; + return side === "input" ? InputSocket : OutputSocket; + + } }, }) ); @@ -63,15 +68,17 @@ export async function createEditor(container: HTMLElement, injector: Injector, f if (flowData) await loadFlowData(editor, flowData); - AreaExtensions.zoomAt(area, editor.getNodes()); - - return editor; } export function exportGraph(editor: NodeEditor) { - const nodes = editor.getNodes().map(schema => ({ + const nodes: INodeModel[] = editor.getNodes().map(node => ({ + key: node.data.key, + name: node.data.name, + position: node.data.position, + nodeDefinition: node.data.nodeDefinition, + parameters: node.data.parameters, })); @@ -99,7 +106,9 @@ async function loadFlowData(editor: NodeEditor, flowData: FlowData) { const socket = new ClassicPreset.Socket("socket"); const nodeMapping = new Map(); for (const nodeData of flowData.nodes) { - const node = new ClassicPreset.Node(nodeData.nodeDefinition?.category || "Undefined"); + if (!nodeData.nodeDefinition) continue; + const node: HFNode = new ClassicPreset.Node(nodeData.nodeDefinition.category) as HFNode; + node.data = nodeData; nodeMapping.set(nodeData.key, node.id); Object.entries(nodeData.nodeDefinition?.outputs).forEach(([output, def]) => { console.log("Adding output", output); @@ -123,9 +132,14 @@ async function loadFlowData(editor: NodeEditor, flowData: FlowData) { area.translate(node.id, { x: nodeData.position.x, y: nodeData.position.y });*/ } - for (const connections of flowData.connections) { { - const targetNode = editor.getNode(nodeMapping.get(connections.targetNode) || ""); - const node = editor.getNode(nodeMapping.get(connections.sourceNode) || ""); + for (const connections of flowData.connections) { + { + if (!nodeMapping.has(connections.sourceNode) || !nodeMapping.has(connections.targetNode)) { + console.warn("Cannot create connection, node not found", connections); + continue; + } + const targetNode = editor.getNode(nodeMapping.get(connections.targetNode)!) as HFNode; + const node = editor.getNode(nodeMapping.get(connections.sourceNode)!) as HFNode; if (node && targetNode) { console.log("Creating connection from", node.id, "to", targetNode.id); const connection = new ClassicPreset.Connection(