modified node structure and parameter

This commit is contained in:
Lucio Lelii 2026-02-27 21:56:06 +01:00
parent 25edbbd34c
commit 68635649f8
26 changed files with 1262 additions and 173 deletions

View File

@ -1,2 +1,3 @@
<router-outlet />
<app-confirm-dialog-host></app-confirm-dialog-host>
<app-node-settings-dialog-host></app-node-settings-dialog-host>

View File

@ -1,10 +1,11 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ConfirmDialogHostComponent } from '@shared/confirm-dialog/confirm-dialog';
import { NodeSettingsDialogHostComponent } from '@shared/node-settings-dialog/node-settings-dialog';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ConfirmDialogHostComponent],
imports: [RouterOutlet, ConfirmDialogHostComponent, NodeSettingsDialogHostComponent],
templateUrl: './app.html',
styleUrl: './app.css'
})

View File

@ -18,6 +18,15 @@ export type FlowData = {
connections: FlowBlockConnection[];
};
export type BlockTypeName = "HumanInteractionBlock" | "LLMBlock" | "SourceBlock" | string;
export type BlockType = {
userInteractive: boolean;
blockConfigurationClass: string | null;
description: string;
name: BlockTypeName;
};
export type FlowBlock = {
id: string;
sink: boolean;
@ -26,7 +35,7 @@ export type FlowBlock = {
inputs: FlowPort[];
outputs: FlowPort[];
specificConfiguration: FlowBlockConfiguration;
typeName: "LLMBlock" | "HumanInteractionBlock" | string;
typeName: BlockTypeName;
};
export type FlowPort = {
@ -83,4 +92,3 @@ export type IConnectionModel = {
targetNode: string;
targetField: string;
}

View File

@ -42,11 +42,10 @@
}
@case ('blocks') {
<app-group-holder title="Blocks" icon="bi-boxes">
<div class="space-y-2 overflow-auto">
<div class="block-item">Input</div>
<div class="block-item">Transform</div>
<div class="block-item">Output</div>
</div>
<i ngProjectAs="header-button"
class="bi bi-plus-circle-fill text-blue-600 hover:cursor-pointer hover:text-blue-400 text-lg"
(click)="createNewBlock()"></i>
<app-blocks-list></app-blocks-list>
</app-group-holder>
}
}
@ -55,4 +54,4 @@
</div>
}
</aside>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { Component, computed, inject, signal } from '@angular/core';
import { GroupHolder } from '@shared/group-holder/group-holder';
import { FlowsList } from '@shared/flows-list/flows-list';
import { BlocksList } from '@shared/blocks-list/blocks-list';
import { EditorStateHolder } from '@stores/flow-editor';
import { CommonModule } from '@angular/common';
import { FlowsService } from '@services/flows/flows';
@ -10,7 +11,7 @@ type OpenedId = 'flows' | 'blocks';
@Component({
selector: 'app-editor-sidebar',
imports: [GroupHolder, FlowsList, CommonModule],
imports: [GroupHolder, FlowsList, BlocksList, CommonModule],
templateUrl: './editor-sidebar.html',
styleUrl: './editor-sidebar.css',
providers:[ListState]
@ -50,5 +51,9 @@ export class EditorSidebar {
},
});
}
createNewBlock() {
console.log('Creating new block...');
}
}

View File

@ -0,0 +1,10 @@
import { BlockType, FlowBlock } from "@models/flow";
import { Observable } from "rxjs";
export abstract class BlocksCallServiceBase {
abstract retrieveAllBlocksTypes() : Observable<BlockType[]>;
abstract createNewBlock(configuration : any) : Observable<FlowBlock>;
}

View File

@ -0,0 +1,48 @@
import { BlockType, FlowBlock } from "@models/flow";
import { Observable, of } from "rxjs";
import { BlocksCallServiceBase } from "./block-call.base";
export class BlocksCallServiceFake extends BlocksCallServiceBase {
private readonly blockTypes: BlockType[] = [
{
userInteractive: true,
blockConfigurationClass:
"it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration",
description: "A block that requires human interaction",
name: "HumanInteractionBlock"
},
{
userInteractive: false,
blockConfigurationClass:
"it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration",
description: "This type represents a LLM node in the workflow manager",
name: "LLMBlock"
},
{
userInteractive: true,
blockConfigurationClass: null,
description: "This type represents a source node in the workflow manager",
name: "SourceBlock"
}
];
override retrieveAllBlocksTypes(): Observable<BlockType[]> {
return of(this.blockTypes);
}
override createNewBlock(configuration: any): Observable<FlowBlock> {
const typeName = configuration?.typeName ?? "LLMBlock";
const block: FlowBlock = {
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
sink: false,
name: configuration?.name ?? "new-block",
position: configuration?.position,
inputs: configuration?.inputs ?? [],
outputs: configuration?.outputs ?? [],
specificConfiguration: configuration?.specificConfiguration ?? {},
typeName
};
return of(block);
}
}

View File

@ -0,0 +1,13 @@
import { BlockType, FlowBlock } from "@models/flow";
import { Observable } from "rxjs";
import { BlocksCallServiceBase } from "./block-call.base";
export class BlocksCallService extends BlocksCallServiceBase {
override retrieveAllBlocksTypes(): Observable<BlockType[]> {
throw new Error("Method not implemented.");
}
override createNewBlock(configuration: any): Observable<FlowBlock> {
throw new Error("Method not implemented.");
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { BlocksService } from './blocks';
describe('BlocksService', () => {
let service: BlocksService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(BlocksService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
import { Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType } from '@models/flow';
import { BlocksCallServiceBase } from './block-call.base';
import { catchError, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class BlocksService {
blocksCallService: BlocksCallServiceBase = new environment.blocksCallService();
toInit: boolean = true;
private _blockTypes = signal<BlockType[]>([]);
async getAllBlocksTypes() {
if (this.toInit) {
this.refresh();
this.toInit = false;
}
return this._blockTypes.asReadonly();
}
refresh() {
this.blocksCallService.retrieveAllBlocksTypes().subscribe((blockTypes) => {
this._blockTypes.set(blockTypes);
});
}
createNewBlock(configuration: any) {
return this.blocksCallService.createNewBlock(configuration).pipe(
catchError((err) => {
console.error('Create block failed', err);
return throwError(() => err);
})
);
}
}

View File

@ -0,0 +1,57 @@
import { Injectable, signal } from "@angular/core";
export type NodeSettingFieldType = "text" | "textarea" | "select" | "checkbox";
export type NodeSettingOption = {
label: string;
value: string;
};
export type NodeSettingField = {
key: string;
label: string;
type: NodeSettingFieldType;
placeholder?: string;
tip?: string;
rows?: number;
required?: boolean;
options?: NodeSettingOption[];
};
export type NodeSettingsValues = Record<string, string | boolean>;
export type NodeSettingsDialogInput = {
title?: string;
fields: NodeSettingField[];
initial?: NodeSettingsValues;
};
@Injectable({ providedIn: "root" })
export class NodeSettingsDialogService {
private _state = signal<{
title: string;
fields: NodeSettingField[];
initial: NodeSettingsValues;
resolve: (value: NodeSettingsValues | null) => void;
} | null>(null);
readonly state = this._state.asReadonly();
open(input: NodeSettingsDialogInput): Promise<NodeSettingsValues | null> {
return new Promise((resolve) => {
this._state.set({
title: input.title ?? "Node Settings",
fields: input.fields,
initial: input.initial ?? {},
resolve
});
});
}
close(value: NodeSettingsValues | null) {
const state = this._state();
if (!state) return;
state.resolve(value);
this._state.set(null);
}
}

View File

@ -0,0 +1 @@
export const BLOCK_TYPE_DRAG_MIME = "application/x-hf-block-type";

View File

@ -0,0 +1,57 @@
@if (loading()) {
<div class="flex items-center justify-center h-32">
<div class="spinner-border text-indigo-500" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
} @else {
<div class="h-full flex flex-col w-full min-w-0 overflow-x-hidden">
<div class="w-full max-w-sm py-2 h-15">
<div class="relative flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="absolute w-5 h-5 top-2.5 left-2.5 text-slate-600">
<path fill-rule="evenodd"
d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z"
clip-rule="evenodd" />
</svg>
<input
class="w-full bg-transparent placeholder:text-slate-400 text-slate-700 text-sm border border-slate-200 rounded-md pl-10 pr-3 py-2 transition duration-300 ease focus:outline-none focus:border-slate-400 hover:border-slate-300 shadow-sm focus:shadow"
placeholder="Search blocks..." [(ngModel)]="searchTerm" />
</div>
</div>
<!--
<div class="flex py-2 text-sm w-full min-w-0 items-center text-center justify-center h-18 overflow-x-hidden">
<app-ordering [orderFields]="orderFields" [orderView]="orderView" (orderChanged)="onOrderChanged($event)"></app-ordering>
</div>
-->
@if (filteredBlocks().length === 0) {
<div class="text-gray-400 text-center mt-10 flex flex-col flex-1 pt-10">
No blocks found
</div>
} @else {
<div class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 h-full py-2 gap-2 flex flex-col items-stretch max-h-[calc(100vh-320px)]">
@for (block of filteredBlocks(); track block.name) {
<div
class="border px-3 py-2 w-full min-w-0 bg-slate-50 border-slate-200 overflow-hidden cursor-grab active:cursor-grabbing"
draggable="true"
(dragstart)="onDragStart($event, block)">
<div class="flex items-center justify-between gap-2 text-sm min-w-0">
<span class="font-medium text-slate-700 min-w-0 truncate">{{ block.name }}</span>
</div>
<p class="text-xs text-slate-500 mt-1">{{ block.description }}</p>
<span class="text-xs px-2 py-0.5 rounded"
[class.bg-amber-100]="block.userInteractive"
[class.text-amber-700]="block.userInteractive"
[class.bg-sky-100]="!block.userInteractive"
[class.text-sky-700]="!block.userInteractive">
{{ block.userInteractive ? 'Interactive' : 'Automatic' }}
</span>
</div>
}
</div>
}
</div>
}

View File

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

View File

@ -0,0 +1,83 @@
import { Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { BlockType } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { OrderEvent, OrderField, Ordering } from '@shared/ordering/ordering';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
import { FormsModule } from '@angular/forms';
import { BLOCK_TYPE_DRAG_MIME } from './block-drag';
@Component({
selector: 'app-blocks-list',
imports: [FormsModule],
templateUrl: './blocks-list.html',
styleUrl: './blocks-list.css',
})
export class BlocksList extends ListStateViewHolder<BlockType> {
searchTerm = model<string>('');
private blocksService = inject(BlocksService);
loading: WritableSignal<boolean> = signal(true);
/*
get orderView() {
const existingState = this.view;
return existingState.order;
}*/
blockTypes?: Signal<BlockType[]>;
constructor() {
super('blocksList', {
defaultOrder: { orderBy: 'name', orderDir: 'asc' } as OrderViewState
});
}
ngOnInit() {
const existingState = this.view;
if (existingState.list) {
this.blockTypes = existingState.list;
this.loading.set(false);
return;
}
this.blocksService.getAllBlocksTypes().then((blockTypesSignal) => {
this.blockTypes = blockTypesSignal;
this.loading.set(false);
this.view.list = this.blockTypes;
});
}
filteredBlocks = computed(() => {
const blocks = this.blockTypes ? this.blockTypes() : [];
if (!blocks) return [];
const term = this.searchTerm().toLowerCase();
return blocks.filter((b) =>
b.name.toLowerCase().includes(term) || b.description.toLowerCase().includes(term)
);
});
onOrderChanged(event: OrderEvent) {
const { orderBy, orderDir } = event;
this.view.order = { orderBy, orderDir };
const blocks = this.filteredBlocks();
if (!orderBy) return blocks;
return blocks.sort((a, b) => {
const aValue = (a as any)[orderBy];
const bValue = (b as any)[orderBy];
if (aValue < bValue) return orderDir === 'asc' ? -1 : 1;
if (aValue > bValue) return orderDir === 'asc' ? 1 : -1;
return 0;
});
}
onDragStart(event: DragEvent, block: BlockType) {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(BLOCK_TYPE_DRAG_MIME, JSON.stringify(block));
event.dataTransfer.setData('text/plain', block.name);
}
}

View File

@ -0,0 +1,68 @@
@if (state()) {
<div class="fixed inset-0 z-9999">
<div class="absolute inset-0 bg-black/50" (click)="cancel($event)"></div>
<div class="absolute inset-6 rounded-xl bg-white shadow-2xl border border-slate-200 p-5 flex flex-col gap-4 overflow-auto">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-900">{{ state()!.title }}</h3>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancel($event)">Close</button>
</div>
<div class="grid gap-4">
@for (field of fields; track field.key) {
<label class="text-sm font-medium text-slate-700">
{{ field.label }}
@switch (field.type) {
@case ('textarea') {
<textarea
class="form-control mt-1"
[rows]="field.rows ?? 8"
[placeholder]="field.placeholder ?? ''"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)">
</textarea>
}
@case ('select') {
<select
class="form-select mt-1"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)">
@for (option of field.options ?? []; track option.value) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
}
@case ('checkbox') {
<div class="mt-2">
<input
class="form-check-input"
type="checkbox"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)" />
</div>
}
@default {
<input
class="form-control mt-1"
type="text"
[placeholder]="field.placeholder ?? ''"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)" />
}
}
@if (field.tip) {
<small class="text-red-600 mt-1 block">{{ field.tip }}</small>
}
</label>
}
</div>
<div class="mt-auto flex justify-end gap-2">
<button type="button" class="btn btn-outline-secondary" (click)="cancel($event)">Cancel</button>
<button type="button" class="btn btn-primary" (click)="save($event)">Save</button>
</div>
</div>
</div>
}

View File

@ -0,0 +1,63 @@
import { Component, effect, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
NodeSettingField,
NodeSettingsDialogService,
NodeSettingsValues
} from '@services/dialogs/node-settings-dialog';
@Component({
selector: 'app-node-settings-dialog-host',
standalone: true,
imports: [FormsModule],
templateUrl: './node-settings-dialog.html'
})
export class NodeSettingsDialogHostComponent {
private dialog = inject(NodeSettingsDialogService);
state = this.dialog.state;
draft: NodeSettingsValues = {};
fields: NodeSettingField[] = [];
constructor() {
effect(() => {
const state = this.state();
if (!state) return;
this.fields = state.fields;
this.draft = this.buildDraft(state.fields, state.initial);
});
}
cancel(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.dialog.close(null);
}
save(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.dialog.close({ ...this.draft });
}
setFieldValue(key: string, value: string | boolean) {
this.draft[key] = value;
}
private buildDraft(fields: NodeSettingField[], initial: NodeSettingsValues): NodeSettingsValues {
const values: NodeSettingsValues = {};
for (const field of fields) {
const initialValue = initial[field.key];
if (initialValue !== undefined) {
values[field.key] = initialValue;
continue;
}
if (field.type === 'checkbox') {
values[field.key] = false;
} else {
values[field.key] = '';
}
}
return values;
}
}

View File

@ -1,80 +1,355 @@
/* 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;
.llm-node {
width: 280px;
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;
}
/* Header */
.header {
.llm-header {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
padding: 10px 12px;
background: linear-gradient(135deg, #7c3aed, #6366f1); /* violet → indigo */
color: white;
border-bottom: 1px solid #e8edf4;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
/* Icona LLM */
.icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.2);
.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;
}
.icon img {
width: 18px;
height: 18px;
.llm-icon-image {
width: 24px;
height: 16px;
object-fit: contain;
}
/* Titolo */
.title {
font-size: 14px;
.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;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Body */
.body {
padding: 12px;
.llm-pill-input {
justify-content: flex-start;
color: #166534;
background: #ecfdf3;
border-color: #bbf7d0;
}
.llm-pill-output {
justify-content: flex-end;
color: #9f1239;
background: #fff1f2;
border-color: #fecdd3;
}
.llm-params {
margin: 0 12px 12px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Riga input/output */
.io {
.llm-params-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Label IO */
.label {
font-size: 12px;
color: #374151; /* gray-700 */
.llm-params-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
}
/* Socket */
.socket {
width: 12px;
height: 12px;
.llm-param-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.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;
justify-content: space-between;
gap: 6px;
}
.llm-edit-btn {
border: none;
background: transparent;
color: #64748b;
border-radius: 999px;
background: #6366f1;
border: 2px solid white;
box-shadow: 0 0 0 1px #6366f1;
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, transform 0.15s ease;
}
.llm-edit-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.1);
transform: translateY(-1px);
}
.llm-edit-btn:active {
transform: translateY(0);
}
.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;
}
.llm-param-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.llm-param-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
border: 1px solid #dbe2ea;
background: #ffffff;
border-radius: 8px;
padding: 6px 8px;
}
.llm-param-key {
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.llm-param-value {
font-size: 11px;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.llm-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
}
.llm-modal {
width: min(360px, calc(100vw - 24px));
border-radius: 12px;
border: 1px solid #dbe2ea;
background: #ffffff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.25);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.llm-modal-title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.llm-modal-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.llm-modal-field label {
font-size: 11px;
font-weight: 600;
color: #475569;
}
.llm-modal-field input,
.llm-modal-field textarea {
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 12px;
color: #0f172a;
padding: 7px 8px;
resize: vertical;
}
.llm-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.llm-btn {
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
}
/* Hover */
.socket:hover {
transform: scale(1.15);
.llm-btn-ghost {
border-color: #cbd5e1;
color: #475569;
background: #ffffff;
}
.llm-btn-primary {
background: #2563eb;
color: #ffffff;
}

View File

@ -1,72 +1,122 @@
<div
class=" relative user-select-none w-55 rounded-2xl bg-white border border-gray-200 shadow-lg overflow-visible font-sans">
<!-- Header -->
<div class="cursor-move flex items-center gap-2 px-3 py-2 bg-linear-to-br from-violet-600 to-indigo-500 text-white rounded-t-2xl">
<div class="w-12 h-8 rounded-lg bg-white/20 flex items-center justify-center">
<img src="llm_node.png" alt="LLM" class="w-12 h-8" />
<div class="llm-node">
<div class="llm-header cursor-move">
<div class="llm-icon">
<img src="llm_node.png" alt="LLM" class="llm-icon-image" />
</div>
<div class="llm-header-content">
<span class="llm-title">LLM Node</span>
<span class="llm-subtitle">{{ name }}</span>
</div>
<span class="text-sm font-semibold truncate">LLM Node</span>
</div>
<!-- Body -->
<div class="pt-3 pb-3 grid grid-cols-2 gap-4 w-full items-start">
<!-- COLONNA INPUT (sinistra) -->
<div class="grid grid-cols-[16px_1fr] items-center h-6 w-full">
<!-- Socket input -->
<div
refComponent
class="relative z-20"
[data]="{
type: 'socket',
side: 'input',
key: inputKey,
nodeId: data.id,
payload: inputSocket
}"
[emit]="emit">
<div class="llm-body">
<div class="llm-column">
<div class="llm-column-title">Inputs</div>
@for (input of inputs; track input.key) {
<div class="llm-row llm-row-input">
<div
refComponent
class="llm-socket"
[data]="{
type: 'socket',
side: 'input',
key: input.key,
nodeId: data.id,
payload: input.socket
}"
[emit]="emit">
</div>
<span class="llm-pill llm-pill-input">{{ input.key }}</span>
</div>
}
</div>
<!-- Label input -->
<span
class="inline-flex items-center justify-start px-2 h-6 w-full
text-xs font-medium
bg-emerald-50 text-emerald-700 border border-emerald-200">
{{ inputKey }}
</span>
<div class="llm-column">
<div class="llm-column-title llm-column-title-right">Outputs</div>
@for (output of outputs; track output.key) {
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output">{{ output.key }}</span>
<div
refComponent
class="llm-socket llm-socket-right"
[data]="{
type: 'socket',
side: 'output',
key: output.key,
nodeId: data.id,
payload: output.socket
}"
[emit]="emit">
</div>
</div>
}
</div>
</div>
<!-- COLONNA OUTPUT (destra) -->
<div class="grid grid-cols-[1fr_16px] items-center h-6 w-full">
<!-- Label output -->
<span
class="inline-flex items-center justify-end px-2 h-6 w-full
text-xs font-medium
bg-red-50 text-red-700 border border-red-200">
{{ outputKey }}
</span>
<!-- Socket output -->
<div
refComponent
class="relative z-20 justify-self-end"
[data]="{
type: 'socket',
side: 'output',
key: outputKey,
nodeId: data.id,
payload: outputSocket
}"
[emit]="emit">
<div class="llm-params">
<div class="llm-params-head">
<div class="llm-params-title">Parameters</div>
</div>
<div class="llm-param-grid">
<div class="llm-param-chip">
<div class="llm-param-row-head">
<span class="llm-param-key">Provider</span>
<button type="button" class="llm-edit-btn" title="Edit provider" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('provider', $event)">
<i class="bi bi-pen"></i>
</button>
</div>
<span class="llm-param-value">{{ provider || '-' }}</span>
</div>
<div class="llm-param-chip">
<div class="llm-param-row-head">
<span class="llm-param-key">Model</span>
<button type="button" class="llm-edit-btn" title="Edit model" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('model', $event)">
<i class="bi bi-pen"></i>
</button>
</div>
<span class="llm-param-value">{{ model || '-' }}</span>
</div>
</div>
<div class="llm-param-block">
<div class="llm-param-row-head">
<div class="llm-param-key">Prompt</div>
<button type="button" class="llm-edit-btn" title="Edit prompt" (pointerdown)="$event.stopPropagation()" (click)="openPromptEditor($event)">
<i class="bi bi-pen"></i>
</button>
</div>
<div class="llm-param-text">
@if (!promptParts.length) {
-
} @else {
@for (part of promptParts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
}
</div>
</div>
</div>
@if (localEditorOpen) {
<div class="llm-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">
<div class="llm-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div class="llm-modal-title">Edit {{ localEditorLabel }}</div>
<div class="llm-modal-field">
<label>{{ localEditorLabel }}</label>
<input type="text" [(ngModel)]="localEditorValue" placeholder="Value..." (pointerdown)="$event.stopPropagation()" />
</div>
<div class="llm-modal-actions">
<button type="button" class="llm-btn llm-btn-ghost" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">Cancel</button>
<button type="button" class="llm-btn llm-btn-primary" (pointerdown)="$event.stopPropagation()" (click)="saveSimpleParamEditor($event)">Save</button>
</div>
</div>
</div>
}
</div>
</div>

View File

@ -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<string, any> | null {
return this.data?.data?.specificConfiguration ?? null;
}
private ensureBlockConfiguration(): Record<string, any> {
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;
}
}

View File

@ -1,2 +1,2 @@
<div class="h-full w-full" #editor>
</div>
<div class="h-full w-full" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
</div>

View File

@ -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<any>();
@ -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: ''
}
};
}
}

View File

@ -14,8 +14,18 @@ import { LLMNodeComponent } from "@shared/nodes/llm/llm";
import { CustomSocket } from "@shared/custom-socket/custom-socket";
type AreaExtra = AngularArea2D<HFSchemes>;
const editorSockets = new WeakMap<NodeEditor<HFSchemes>, Map<string, ClassicPreset.Socket>>();
export async function createEditor(container: HTMLElement, injector: Injector, flowData: FlowData) {
export type ReteEditorInstance = {
editor: NodeEditor<HFSchemes>;
area: AreaPlugin<HFSchemes, AreaExtra>;
};
export async function createEditor(
container: HTMLElement,
injector: Injector,
flowData: FlowData
): Promise<ReteEditorInstance> {
const editor = new NodeEditor<HFSchemes>();
const area = new AreaPlugin<HFSchemes, AreaExtra>(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<HFSchemes>) {
@ -110,38 +120,45 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
};
}
async function loadFlowData(editor: NodeEditor<HFSchemes>, flowData: FlowData) {
if (!flowData.blocks?.length) return;
export async function addBlockToEditor(
editor: NodeEditor<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,
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<string, ClassicPreset.Socket>();
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<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,
flowData: FlowData
) {
if (!flowData.blocks?.length) return;
const nodeMapping = new Map<string, any>();
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<HFSchemes>, flowData: FlowData) {
);
}
}
function getSocket(editor: NodeEditor<HFSchemes>, type: string) {
if (!editorSockets.has(editor)) {
editorSockets.set(editor, new Map<string, ClassicPreset.Socket>());
}
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;
}

View File

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

View File

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