added field retriever service

improved node design
This commit is contained in:
Lucio Lelii 2026-03-02 15:58:05 +01:00
parent 68635649f8
commit 79b85e2bd5
52 changed files with 1591 additions and 234 deletions

View File

@ -2,7 +2,7 @@ import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
export const authGuard: CanActivateFn = (route, state) => {
export const authGuard: CanActivateFn = (_route, _state) => {
const authService = inject(Authorization);
const router = inject(Router);
if (authService.isLoggedIn()) {

View File

@ -16,7 +16,7 @@
<app-title-toolbar></app-title-toolbar>
<!-- editor canvas -->
<div class="flex-1 h-full">
<app-rete-editor></app-rete-editor>
<app-rete-editor [flowId]="flow()!.id" [flowData]="flow()!.data"></app-rete-editor>
</div>
</div>
} @else {
@ -30,4 +30,4 @@
<!-- <app-right-panel></app-right-panel> -->
</div>
</div>
</div>

View File

@ -20,11 +20,15 @@ export type FlowData = {
export type BlockTypeName = "HumanInteractionBlock" | "LLMBlock" | "SourceBlock" | string;
export type BlockTypeSchema = Record<string, unknown> | null;
export type BlockType = {
userInteractive: boolean;
blockConfigurationClass: string | null;
type: BlockTypeName;
description: string;
name: BlockTypeName;
userInteractive: boolean;
configurationType: string | null;
configurationClass: string | null;
schema: BlockTypeSchema;
};
export type FlowBlock = {

View File

@ -1,6 +1,6 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from "@angular/router";
import { Router, RouterLink } from "@angular/router";
import { FormUtility } from '@utilities/form-utility';
import { Authorization } from '@services/authorization/authorization';
import { Field, form, required } from '@angular/forms/signals';

View File

@ -1,11 +1,10 @@
import { Component, computed, effect, ElementRef, inject, signal, ViewChild, viewChild } from '@angular/core';
import { Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { form, Field, minLength, email, required, validate, maxLength, disabled } from '@angular/forms/signals'
import { Router, RouterLink } from '@angular/router';
import { UserRegistration } from '@models/user';
import { Authorization } from '@services/authorization/authorization';
import { FormUtility } from '@utilities/form-utility';
import { max, min } from 'rxjs';
@Component({

View File

@ -1,20 +1,15 @@
import { HttpClient } from "@angular/common/http";
import { AuthorizationCallServiceBase } from "./authorization-call.base";
import { inject } from "@angular/core";
import { User, UserRegistration } from "@models/user";
import { Observable } from "rxjs";
export class AuthorizationCallService extends AuthorizationCallServiceBase {
private readonly httpClient = inject(HttpClient);
override login(username: string, password: string): Observable<User> {
override login(_username: string, _password: string): Observable<User> {
throw new Error("Method not implemented.");
}
override register(userRegistration: UserRegistration): Observable<void> {
override register(_userRegistration: UserRegistration): Observable<void> {
throw new Error("Method not implemented.");
}
}
}

View File

@ -1,4 +1,4 @@
import { effect, Injectable, signal } from '@angular/core';
import { Injectable, signal } from '@angular/core';
import { User, UserRegistration } from '@models/user';
import { AuthorizationCallServiceBase } from './authorization-call.base';
import { environment } from '@environment';

View File

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

View File

@ -4,45 +4,274 @@ 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"
{
"type": "HumanInteractionBlock",
"description": "A block that requires human interaction",
"userInteractive": true,
"configurationType": "HumanInteractiveBlockConfiguration",
"configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration",
"schema": {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "HumanInteractiveBlockConfiguration",
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"HumanInteractiveBlockConfiguration"
],
"default": "HumanInteractiveBlockConfiguration"
},
"name": {
"type": "string"
},
"actionDescription": {
"type": "string"
},
"llmDescriptor": {
"$ref": "#/definitions/LLMDescriptor"
},
"inputAsList": {
"type": "boolean"
},
"outputAsList": {
"type": "boolean"
}
},
"required": [
"type",
"name",
"actionDescription",
"llmDescriptor",
"inputAsList",
"outputAsList"
],
"definitions": {
"LLMDescriptor": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"x-retriever-name": "providers",
"x-retriever-url": "/retriever/{blockType}/providers",
"x-retriever-owner": "LLMDescriptor"
},
"model": {
"type": "string",
"x-retriever-name": "models",
"x-retriever-url": "/retriever/{blockType}/models",
"x-retriever-owner": "LLMDescriptor",
"x-retriever-depends-on": [
"provider"
]
},
"authorization": {
"type": "string"
}
}
}
}
}
];
},
{
"type": "LLMBlock",
"description": "This type represents a LLM node in the workflow manager",
"userInteractive": false,
"configurationType": "LLMBlockConfiguration",
"configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration",
"schema": {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "LLMBlockConfiguration",
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"LLMBlockConfiguration"
],
"default": "LLMBlockConfiguration"
},
"name": {
"type": "string"
},
"llmDescriptor": {
"$ref": "#/definitions/LLMDescriptor"
},
"prompt": {
"type": "string"
}
},
"required": [
"type",
"name",
"llmDescriptor",
"prompt"
],
"definitions": {
"LLMDescriptor": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"x-retriever-name": "providers",
"x-retriever-url": "/retriever/{blockType}/providers",
"x-retriever-owner": "LLMDescriptor"
},
"model": {
"type": "string",
"x-retriever-name": "models",
"x-retriever-url": "/retriever/{blockType}/models",
"x-retriever-owner": "LLMDescriptor",
"x-retriever-depends-on": [
"provider"
]
},
"authorization": {
"type": "string"
}
}
}
}
}
},
{
"type": "SourceBlock",
"description": "This type represents a source node in the workflow manager",
"userInteractive": true,
"configurationType": null,
"configurationClass": null,
"schema": null
}
]
override retrieveAllBlocksTypes(): Observable<BlockType[]> {
return of(this.blockTypes);
}
override createNewBlock(configuration: any): Observable<FlowBlock> {
const typeName = configuration?.typeName ?? "LLMBlock";
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
const descriptor = this.blockTypes.find((b) => b.type === blockType);
const typeName = descriptor?.type ?? blockType ?? "LLMBlock";
const schema = descriptor?.schema as Record<string, any> | null;
const specificConfiguration = schema
? this.buildObjectFromSchema(schema, schema)
: {};
if (typeof specificConfiguration === "object" && specificConfiguration != null) {
if (!("name" in specificConfiguration)) {
(specificConfiguration as any).name = typeName;
}
}
const io = this.defaultIOForBlockType(typeName);
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 ?? {},
sink: typeName === "HumanInteractionBlock",
name: typeName,
position: undefined,
inputs: io.inputs,
outputs: io.outputs,
specificConfiguration,
typeName
};
return of(block);
}
override updateBlock(blockId: string, configuration: any): Observable<FlowBlock> {
const typeName = configuration?.typeName ?? "LLMBlock";
const io = this.defaultIOForBlockType(typeName);
const block: FlowBlock = {
id: blockId,
sink: typeName === "HumanInteractionBlock",
name: configuration?.name ?? typeName,
position: configuration?.position,
inputs: configuration?.inputs ?? io.inputs,
outputs: configuration?.outputs ?? io.outputs,
specificConfiguration: configuration?.specificConfiguration ?? {},
typeName
};
return of(block);
}
private defaultIOForBlockType(typeName: string) {
if (typeName === "SourceBlock") {
return {
inputs: [],
outputs: [{ name: "output", type: "TEXT", multiple: false }]
};
}
if (typeName === "HumanInteractionBlock") {
return {
inputs: [{ name: "input", type: "TEXT", multiple: false }],
outputs: [{ name: "output", type: "TEXT", multiple: false }]
};
}
return {
inputs: [{ name: "input", type: "TEXT", multiple: false }],
outputs: [{ name: "output", type: "TEXT", multiple: false }]
};
}
private buildObjectFromSchema(node: any, root: any): any {
const resolved = this.resolveRef(node, root);
if (!resolved || typeof resolved !== "object") return {};
if (resolved.type === "object" || resolved.properties) {
const result: Record<string, any> = {};
const props = resolved.properties ?? {};
for (const [key, propSchema] of Object.entries(props)) {
result[key] = this.buildValueFromSchema(propSchema, root);
}
return result;
}
return this.buildValueFromSchema(resolved, root);
}
private buildValueFromSchema(node: any, root: any): any {
const resolved = this.resolveRef(node, root);
if (!resolved || typeof resolved !== "object") return null;
if (Object.prototype.hasOwnProperty.call(resolved, "default")) {
return resolved.default;
}
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
return resolved.enum[0];
}
const type = resolved.type;
if (type === "string") return "";
if (type === "boolean") return false;
if (type === "number" || type === "integer") return 0;
if (type === "array") return [];
if (type === "object" || resolved.properties) {
return this.buildObjectFromSchema(resolved, root);
}
return null;
}
private resolveRef(node: any, root: any): any {
if (!node || typeof node !== "object") return node;
if (!node.$ref) return node;
const ref = node.$ref as string;
if (!ref.startsWith("#/")) return node;
const path = ref.slice(2).split("/");
let current: any = root;
for (const segment of path) {
current = current?.[segment];
if (current == null) return node;
}
return current;
}
}

View File

@ -7,7 +7,11 @@ export class BlocksCallService extends BlocksCallServiceBase {
throw new Error("Method not implemented.");
}
override createNewBlock(configuration: any): Observable<FlowBlock> {
override createEmptyBlock(_blockType: string): Observable<FlowBlock> {
throw new Error("Method not implemented.");
}
override updateBlock(_blockId: string, _configuration: any): Observable<FlowBlock> {
throw new Error("Method not implemented.");
}
}

View File

@ -1,6 +1,6 @@
import { Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType } from '@models/flow';
import { BlockType, BlockTypeName } from '@models/flow';
import { BlocksCallServiceBase } from './block-call.base';
import { catchError, throwError } from 'rxjs';
@ -29,10 +29,19 @@ export class BlocksService {
});
}
createNewBlock(configuration: any) {
return this.blocksCallService.createNewBlock(configuration).pipe(
createEmptyBlock(blockType: BlockTypeName) {
return this.blocksCallService.createEmptyBlock(blockType).pipe(
catchError((err) => {
console.error('Create block failed', err);
console.error('Create empty block failed', err);
return throwError(() => err);
})
);
}
updateBlock(blockId: string, configuration: any) {
return this.blocksCallService.updateBlock(blockId, configuration).pipe(
catchError((err) => {
console.error('Update block failed', err);
return throwError(() => err);
})
);

View File

@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { ConfirmDialog } from './confirm-dialog';
import { ConfirmDialogService } from './confirm-dialog';
describe('ConfirmDialog', () => {
let service: ConfirmDialog;
describe('ConfirmDialogService', () => {
let service: ConfirmDialogService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ConfirmDialog);
service = TestBed.inject(ConfirmDialogService);
});
it('should be created', () => {

View File

@ -1,6 +1,6 @@
import { Flow, FlowData, FlowVisibility } from "@models/flow";
import { Flow, FlowVisibility } from "@models/flow";
import { FlowsCallServiceBase } from "./flows-call.base";
import { BehaviorSubject, Observable, of } from "rxjs";
import { Observable, of } from "rxjs";
import { Authorization } from "@services/authorization/authorization";
import { inject } from "@angular/core";
@ -45,8 +45,9 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
export function flowFromJson(raw: any): Flow {
const flowId = raw.id ?? raw.name ?? crypto.randomUUID();
return {
id: raw.id,
id: flowId,
author: raw.author,
visibility: raw.visibility as FlowVisibility,
createdAt: new Date(raw.createdAt),
@ -62,6 +63,7 @@ export function flowFromJson(raw: any): Flow {
const testDataFlow ={
"id": "testFlow",
"name" : "Test Flow",
"visibility": "PRIVATE",
"author": "lucio",
@ -127,4 +129,3 @@ const testDataFlow ={
"targetName" : "input"
} ]
};

View File

@ -3,20 +3,20 @@ import { FlowsCallServiceBase } from "./flows-call.base";
import { Observable } from "rxjs";
export class FlowsCallService extends FlowsCallServiceBase {
override getFlowById(flowId: string): Observable<Flow> {
override getFlowById(_flowId: string): Observable<Flow> {
throw new Error("Method not implemented.");
}
override createNewFlow(name?: string): Observable<Flow> {
override createNewFlow(_name?: string): Observable<Flow> {
throw new Error("Method not implemented.");
}
override deleteFlow(flowId: string): Observable<void> {
override deleteFlow(_flowId: string): Observable<void> {
throw new Error("Method not implemented.");
}
override retrieveAllFlows(): Observable<Flow[]> {
throw new Error("Method not implemented.");
}
override updateFlow(flow: Flow): Observable<void> {
override updateFlow(_flow: Flow): Observable<void> {
throw new Error("Method not implemented.");
}
}
}

View File

@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { Flows } from './flows';
import { FlowsService } from './flows';
describe('Flows', () => {
let service: Flows;
describe('FlowsService', () => {
let service: FlowsService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(Flows);
service = TestBed.inject(FlowsService);
});
it('should be created', () => {

View File

@ -2,7 +2,7 @@ import { Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { Flow } from '@models/flow';
import { FlowsCallServiceBase } from './flows-call.base';
import { BehaviorSubject, catchError, combineLatest, Observable, switchMap, tap, throwError } from 'rxjs';
import { catchError, combineLatest, Observable, switchMap, tap, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',

View File

@ -0,0 +1,9 @@
import { Observable } from "rxjs";
export abstract class FieldRetreiverCallServiceBase {
abstract retrieveValues(
blockType: string,
key: string,
context?: Record<string, string>
): Observable<string[]>;
}

View File

@ -0,0 +1,32 @@
import { Observable, of } from "rxjs";
import { FieldRetreiverCallServiceBase } from "./field-retreiver-call.base";
export class FieldRetreiverCallServiceFake extends FieldRetreiverCallServiceBase {
private readonly providersByBlockType: Record<string, string[]> = {
LLMBlock: ["OpenAI", "Anthropic", "OllamaTestProvider"],
HumanInteractionBlock: ["OpenAI", "OllamaTestProvider"]
};
private readonly modelsByProvider: Record<string, string[]> = {
OpenAI: ["gpt-4.1-mini", "gpt-4.1"],
Anthropic: ["claude-3-5-sonnet", "claude-3-7-sonnet"],
OllamaTestProvider: ["sam860/gemma3:270m", "llama3.2:3b"]
};
override retrieveValues(
blockType: string,
key: string,
context?: Record<string, string>
): Observable<string[]> {
if (key === "providers") {
return of(this.providersByBlockType[blockType] ?? []);
}
if (key === "models") {
const provider = context?.["provider"];
return of(provider ? (this.modelsByProvider[provider] ?? []) : []);
}
return of([]);
}
}

View File

@ -0,0 +1,22 @@
import { HttpClient, HttpParams } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import { Observable } from "rxjs";
import { FieldRetreiverCallServiceBase } from "./field-retreiver-call.base";
export class FieldRetreiverCallService extends FieldRetreiverCallServiceBase {
private readonly http = inject(HttpClient);
override retrieveValues(
blockType: string,
key: string,
context?: Record<string, string>
): Observable<string[]> {
const url = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}`;
let params = new HttpParams();
for (const [ctxKey, ctxValue] of Object.entries(context ?? {})) {
params = params.set(ctxKey, ctxValue);
}
return this.http.get<string[]>(url, { params });
}
}

View File

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

View File

@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { environment } from '@environment';
import { catchError, throwError } from 'rxjs';
import { FieldRetreiverCallServiceBase } from './field-retreiver-call.base';
@Injectable({
providedIn: 'root',
})
export class FieldRetreiver {
fieldRetreiverCallService: FieldRetreiverCallServiceBase = new environment.fieldRetreiverCallService();
retrieveValues(
blockType: string,
key: string,
context?: Record<string, string>
) {
return this.fieldRetreiverCallService.retrieveValues(blockType, key, context).pipe(
catchError((err) => {
console.error('Field retrieval failed', err);
return throwError(() => err);
})
);
}
}

View File

@ -33,13 +33,13 @@
</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) {
@for (block of filteredBlocks(); track block.type) {
<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>
<span class="font-medium text-slate-700 min-w-0 truncate">{{ block.type }}</span>
</div>
<p class="text-xs text-slate-500 mt-1">{{ block.description }}</p>
<span class="text-xs px-2 py-0.5 rounded"

View File

@ -1,7 +1,6 @@
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';
@ -55,29 +54,14 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
const term = this.searchTerm().toLowerCase();
return blocks.filter((b) =>
b.name.toLowerCase().includes(term) || b.description.toLowerCase().includes(term)
b.type.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);
event.dataTransfer.setData('text/plain', block.type);
}
}

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfirmDialog } from './confirm-dialog';
import { ConfirmDialogHostComponent } from './confirm-dialog';
describe('ConfirmDialog', () => {
let component: ConfirmDialog;
let fixture: ComponentFixture<ConfirmDialog>;
describe('ConfirmDialogHostComponent', () => {
let component: ConfirmDialogHostComponent;
let fixture: ComponentFixture<ConfirmDialogHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConfirmDialog]
imports: [ConfirmDialogHostComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ConfirmDialog);
fixture = TestBed.createComponent(ConfirmDialogHostComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});

View File

@ -1,4 +1,4 @@
import { Component, inject, Input } from '@angular/core';
import { Component, inject } from '@angular/core';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
@Component({

View File

@ -1,5 +1,4 @@
import { Component, AfterViewInit, Input, HostListener, HostBinding, ChangeDetectorRef } from "@angular/core";
import { ReteModule } from "rete-angular-plugin/21";
import { Component, Input, HostBinding, ChangeDetectorRef } from "@angular/core";
@Component({
template: ``,

View File

@ -1,12 +1,23 @@
<div class="group border
px-3 py-2 cursor-pointer w-full
px-3 py-2 w-full
transition
hover:shadow-[0_0_12px_rgba(99,102,241,0.6)]
bg-indigo-50 border-indigo-200 select-none" (click)="toggleDetails()" (dblclick)="open()">
select-none"
[class.cursor-pointer]="flow().id !== openedFlowId()"
[class.cursor-default]="flow().id === openedFlowId()"
[class.bg-indigo-50]="flow().id !== openedFlowId()"
[class.border-indigo-200]="flow().id !== openedFlowId()"
[class.bg-emerald-50]="flow().id === openedFlowId()"
[class.border-emerald-300]="flow().id === openedFlowId()"
[class.ring-1]="flow().id === openedFlowId()"
[class.ring-emerald-200]="flow().id === openedFlowId()"
(click)="flow().id !== openedFlowId() && open()">
<!-- Header -->
<div class="flex items-center justify-between text-sm ">
<div class="flex items-center gap-2 text-indigo-700 font-medium">
<div class="flex items-center gap-2 font-medium"
[class.text-indigo-700]="flow().id !== openedFlowId()"
[class.text-emerald-700]="flow().id === openedFlowId()">
<i class="bi" [ngClass]="{
'bi-globe': flow().visibility === 'PUBLIC',
'bi-lock-fill': flow().visibility === 'PRIVATE'
@ -17,9 +28,14 @@
{{ flow().name }}
</div>
<i class="bi bi-chevron-down text-xs text-indigo-400
transition-transform duration-200" [class.rotate-180]="expanded()">
</i>
<button
type="button"
class="p-0 m-0 border-0 bg-transparent text-indigo-400 hover:text-indigo-600"
(click)="$event.stopPropagation(); toggleDetails()"
title="Details">
<i class="bi bi-chevron-down text-xs transition-transform duration-200" [class.rotate-180]="expanded()">
</i>
</button>
</div>
<!-- Dettagli -->
@ -74,4 +90,4 @@
</div>
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, EventEmitter, inject, input, model, output, signal, untracked } from '@angular/core';
import { Component, computed, effect, inject, input, model, signal } from '@angular/core';
import { Flow } from '@models/flow';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FlowsService } from '@services/flows/flows';
import { EditorStateHolder } from '@stores/flow-editor';
@ -13,6 +14,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
export class FlowItem {
private editorState = inject(EditorStateHolder);
private confirm = inject(ConfirmDialogService);
private flowsService = inject(FlowsService);
@ -34,9 +36,17 @@ export class FlowItem {
});
}
open() {
async open() {
console.log('Opening flow:', this.flow());
this.editorState.openDocument(this.flow());
if (this.editorState.isDirty() && this.openedFlowId() !== this.flow().id) {
const confirmed = await this.confirm.open(
'You have unsaved changes in the current flow. Open another flow anyway?'
);
if (!confirmed) return;
await this.editorState.openDocument(this.flow(), { skipDirtyCheck: true });
return;
}
await this.editorState.openDocument(this.flow());
}
clone() {
@ -48,7 +58,14 @@ export class FlowItem {
});
}
remove() {
async remove() {
const confirmed = await this.confirm.open(
this.editorState.isDirty()
? 'You have unsaved changes in the current flow. Delete this flow anyway?'
: 'Are you sure you want to delete this flow?'
);
if (!confirmed) return;
this.flowsService.deleteFlow(this.flow().id).subscribe({
next: () => {
console.log('Flow deleted:', this.flow().id);
@ -69,4 +86,3 @@ export class FlowItem {
}
}
}

View File

@ -1,5 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject, model, output, signal, Signal, WritableSignal } from '@angular/core';
import { Component, computed, effect, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { Flow, FlowVisibility } from '@models/flow';
import { FlowsService } from '@services/flows/flows';
import { FlowItem } from './flow-item/flow-item';

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, input, Input, Output } from '@angular/core';
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-group-holder',

View File

@ -0,0 +1,391 @@
.hi-node {
position: relative;
width: 292px;
border-radius: 16px;
border: 1px solid #f7d7a7;
background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%);
box-shadow: 0 10px 24px rgba(124, 45, 18, 0.14);
color: #431407;
overflow: visible;
}
.hi-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: linear-gradient(135deg, #f59e0b 0%, #ea580c 100%);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
border-bottom: 1px solid #fdba74;
}
.hi-icon {
width: 30px;
height: 30px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fffbeb;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
.hi-header-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.hi-subtitle-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.hi-warning {
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid #fecaca;
background: #dc2626;
color: #fff7ed;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 0 0 2px rgba(127, 29, 29, 0.18);
}
.hi-title {
color: #fffbeb;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
}
.hi-subtitle {
color: #fff7ed;
font-size: 11px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.hi-name-edit-btn {
border: none;
background: rgba(255, 255, 255, 0.2);
color: #fff7ed;
border-radius: 999px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.hi-name-edit-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.hi-warning-wrap {
position: relative;
margin-left: auto;
}
.hi-warning-tooltip {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
max-width: 240px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fff1f2;
color: #7f1d1d;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
padding: 8px;
z-index: 80;
opacity: 0;
visibility: hidden;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
}
.hi-warning-wrap:hover .hi-warning-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.hi-warning-title {
font-size: 11px;
font-weight: 700;
margin-bottom: 4px;
}
.hi-warning-item {
font-size: 11px;
}
.hi-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hi-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.hi-column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.hi-column-title {
font-size: 11px;
font-weight: 700;
color: #9a3412;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.hi-column-title-right {
text-align: right;
}
.hi-row {
display: grid;
align-items: center;
min-width: 0;
height: 24px;
}
.hi-row-input {
grid-template-columns: 16px 1fr;
gap: 6px;
}
.hi-row-output {
grid-template-columns: 1fr 16px;
gap: 6px;
}
.hi-socket {
position: relative;
z-index: 20;
}
.hi-socket-right {
justify-self: end;
}
.hi-pill {
height: 24px;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
padding: 0 8px;
display: inline-flex;
align-items: center;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.hi-pill-input {
color: #7c2d12;
background: #ffedd5;
border: 1px solid #fed7aa;
justify-content: flex-start;
}
.hi-pill-output {
color: #9f1239;
background: #fff1f2;
border: 1px solid #fecdd3;
justify-content: flex-end;
}
.hi-panel {
border: 1px solid #fdba74;
border-radius: 10px;
background: #fff7ed;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.hi-panel-simulated {
border-color: #f9a8d4;
background: #fff1f2;
}
.hi-panel-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hi-panel-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #c2410c;
font-weight: 700;
}
.hi-panel-text {
font-size: 11px;
color: #431407;
white-space: pre-wrap;
word-break: break-word;
}
.hi-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.hi-meta-chip {
border: 1px solid #fdba74;
background: #fff;
border-radius: 7px;
padding: 4px 6px;
color: #7c2d12;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.hi-meta-chip-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.hi-meta-chip-value {
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hi-edit-btn {
border: none;
background: transparent;
color: #9a3412;
border-radius: 999px;
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;
}
.hi-edit-btn:hover {
color: #ea580c;
background: rgba(234, 88, 12, 0.12);
}
.hi-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(124, 45, 18, 0.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
}
.hi-modal {
width: min(360px, calc(100vw - 24px));
border-radius: 12px;
border: 1px solid #fdba74;
background: #fff;
box-shadow: 0 16px 40px rgba(124, 45, 18, 0.25);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hi-modal-title {
font-size: 13px;
font-weight: 700;
color: #7c2d12;
}
.hi-modal-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.hi-modal-field label {
font-size: 11px;
font-weight: 600;
color: #9a3412;
}
.hi-modal-field input,
.hi-modal-field select {
border: 1px solid #fdba74;
border-radius: 8px;
font-size: 12px;
color: #431407;
padding: 7px 8px;
}
.hi-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.hi-btn {
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
}
.hi-btn-ghost {
border-color: #fdba74;
color: #9a3412;
background: #fff;
}
.hi-btn-primary {
background: #ea580c;
color: #fff;
}

View File

@ -0,0 +1,136 @@
<div class="hi-node">
<div class="hi-header cursor-move">
<div class="hi-icon">
<i class="bi bi-person-check-fill"></i>
</div>
<div class="hi-header-content">
<span class="hi-title">Human Task</span>
<div class="hi-subtitle-row">
<span class="hi-subtitle">{{ name }}</span>
<button type="button" class="hi-name-edit-btn" title="Edit name" (pointerdown)="$event.stopPropagation()" (click)="openNameEditor($event)">
<i class="bi bi-pencil-square"></i>
</button>
</div>
</div>
@if (missingRequiredParams.length) {
<div class="hi-warning-wrap">
<div class="hi-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="hi-warning-tooltip">
<div class="hi-warning-title">Missing required fields</div>
@for (missing of missingRequiredParams; track missing) {
<div class="hi-warning-item">{{ missing }}</div>
}
</div>
</div>
}
</div>
<div class="hi-body">
<div class="hi-columns">
<div class="hi-column">
<div class="hi-column-title">Inputs</div>
@for (input of inputs; track input.key) {
<div class="hi-row hi-row-input">
<div
refComponent
class="hi-socket"
[data]="{
type: 'socket',
side: 'input',
key: input.key,
nodeId: data.id,
payload: input.socket
}"
[emit]="emit">
</div>
<span class="hi-pill hi-pill-input">{{ input.key }}</span>
</div>
}
</div>
<div class="hi-column">
<div class="hi-column-title hi-column-title-right">Outputs</div>
@for (output of outputs; track output.key) {
<div class="hi-row hi-row-output">
<span class="hi-pill hi-pill-output">{{ output.key }}</span>
<div
refComponent
class="hi-socket hi-socket-right"
[data]="{
type: 'socket',
side: 'output',
key: output.key,
nodeId: data.id,
payload: output.socket
}"
[emit]="emit">
</div>
</div>
}
</div>
</div>
<div class="hi-panel">
<div class="hi-panel-title-row">
<div class="hi-panel-title">Action</div>
<button type="button" class="hi-edit-btn" title="Edit action" (pointerdown)="$event.stopPropagation()" (click)="openActionEditor($event)">
<i class="bi bi-pen"></i>
</button>
</div>
<div class="hi-panel-text">{{ actionDescription || '-' }}</div>
</div>
<div class="hi-panel hi-panel-simulated">
<div class="hi-panel-title">Simulated with</div>
<div class="hi-meta">
<span class="hi-meta-chip">
<span class="hi-meta-chip-head">
Provider
<button type="button" class="hi-edit-btn" title="Edit provider" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('provider', $event)">
<i class="bi bi-pen"></i>
</button>
</span>
<span class="hi-meta-chip-value">{{ provider || '-' }}</span>
</span>
<span class="hi-meta-chip">
<span class="hi-meta-chip-head">
Model
<button type="button" class="hi-edit-btn" title="Edit model" (pointerdown)="$event.stopPropagation()" (click)="openSimpleParamEditor('model', $event)">
<i class="bi bi-pen"></i>
</button>
</span>
<span class="hi-meta-chip-value">{{ model || '-' }}</span>
</span>
</div>
</div>
</div>
@if (localEditorOpen) {
<div class="hi-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">
<div class="hi-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div class="hi-modal-title">Edit {{ localEditorLabel }}</div>
<div class="hi-modal-field">
<label>{{ localEditorLabel }}</label>
@if (localEditorLoading) {
<input type="text" [ngModel]="localEditorValue" placeholder="Loading..." disabled (pointerdown)="$event.stopPropagation()" />
} @else if (localEditorKey !== 'name' && localEditorOptions.length) {
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
<option value="">Select {{ localEditorLabel | lowercase }}...</option>
@for (option of localEditorOptions; track option) {
<option [value]="option">{{ option }}</option>
}
</select>
} @else {
<input type="text" [(ngModel)]="localEditorValue" [attr.maxlength]="localEditorKey === 'name' ? 20 : null" [placeholder]="localEditorKey === 'name' ? 'Max 20 characters' : 'Value...'" (pointerdown)="$event.stopPropagation()" />
}
</div>
<div class="hi-modal-actions">
<button type="button" class="hi-btn hi-btn-ghost" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">Cancel</button>
<button type="button" class="hi-btn hi-btn-primary" (pointerdown)="$event.stopPropagation()" (click)="saveSimpleParamEditor($event)">Save</button>
</div>
</div>
</div>
}
</div>

View File

@ -0,0 +1,232 @@
import { CommonModule } from '@angular/common';
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';
import { EditorStateHolder } from '@stores/flow-editor';
import { FieldRetreiver } from '@services/retreiver/field-retreiver';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-human-interaction-node',
imports: [CommonModule, FormsModule, ReteModule],
templateUrl: './human-interaction.html',
styleUrl: './human-interaction.css',
host: {
'data-testid': 'node'
}
})
export class HumanInteractionNodeComponent {
private settingsDialog = inject(NodeSettingsDialogService);
private editorState = inject(EditorStateHolder);
private fieldRetreiver = inject(FieldRetreiver);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@HostBinding('class.selected') get selected() {
return this.data.selected;
}
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
name = 'Human Interaction';
actionDescription = '';
provider: string | null = null;
model: string | null = null;
localEditorOpen = false;
localEditorKey: 'provider' | 'model' | 'name' | null = null;
localEditorLabel = '';
localEditorValue = '';
localEditorOptions: string[] = [];
localEditorLoading = false;
missingRequiredParams: string[] = [];
ngOnInit() {
this.outputs = [];
this.inputs = [];
for (const [key, output] of Object.entries(this.data.outputs ?? {})) {
this.outputs.push({ key, socket: (output as any).socket });
}
for (const [key, input] of Object.entries(this.data.inputs ?? {})) {
this.inputs.push({ key, socket: (input as any).socket });
}
const config = this.data?.data?.specificConfiguration ?? {};
this.name = (config?.name as string) || this.name;
this.actionDescription = (config?.actionDescription as string) || '';
this.provider = (config?.llmDescriptor?.provider as string) || null;
this.model = (config?.llmDescriptor?.model as string) || null;
this.refreshValidationState();
}
ngAfterViewInit() {
this.rendered();
}
async openSimpleParamEditor(key: 'provider' | 'model' | 'name', event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.localEditorKey = key;
this.localEditorLabel =
key === 'provider' ? 'Provider' :
key === 'model' ? 'Model' : 'Name';
this.localEditorValue =
key === 'provider' ? (this.provider ?? '') :
key === 'model' ? (this.model ?? '') :
(this.name ?? '');
this.localEditorOptions = [];
this.localEditorLoading = key !== 'name';
this.localEditorOpen = true;
if (key !== 'name') {
await this.loadSimpleEditorOptions();
}
}
closeSimpleParamEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.localEditorOpen = false;
this.localEditorKey = null;
this.localEditorOptions = [];
this.localEditorLoading = false;
}
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;
this.model = null;
descriptor['model'] = '';
}
if (this.localEditorKey === 'model') {
this.model = value || null;
}
if (this.localEditorKey === 'name') {
const nameValue = value.slice(0, 20);
config['name'] = nameValue;
this.name = nameValue || this.name;
}
this.refreshValidationState();
this.markFlowDirty();
this.localEditorOpen = false;
this.localEditorKey = null;
this.localEditorOptions = [];
this.localEditorLoading = false;
}
async openActionEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const result = await this.settingsDialog.open({
title: `Action for "${this.name}"`,
fields: [
{
key: 'actionDescription',
label: 'Action description',
type: 'textarea',
rows: 8,
placeholder: 'Describe the human task...'
}
],
initial: { actionDescription: this.actionDescription ?? '' }
});
if (!result) return;
const config = this.ensureBlockConfiguration();
config['actionDescription'] = String(result['actionDescription'] ?? '');
this.actionDescription = config['actionDescription'] || '';
this.refreshValidationState();
this.markFlowDirty();
}
openNameEditor(event?: Event) {
this.openSimpleParamEditor('name', event);
}
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 markFlowDirty() {
const flow = this.editorState.currentFlow();
if (!flow) return;
this.editorState.updateData(flow.data);
}
private async loadSimpleEditorOptions() {
const key = this.localEditorKey;
const blockType = this.blockType;
if (!key || !blockType) {
this.localEditorLoading = false;
return;
}
const retrieverKey = key === 'provider' ? 'providers' : 'models';
const context = key === 'model' && this.provider
? { provider: this.provider }
: undefined;
try {
const options = await firstValueFrom(
this.fieldRetreiver.retrieveValues(blockType, retrieverKey, context)
);
this.localEditorOptions = options ?? [];
} catch {
this.localEditorOptions = [];
} finally {
this.localEditorLoading = false;
}
}
private get blockType(): string | null {
const typeName = this.data?.data?.typeName;
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
}
private refreshValidationState() {
const config = this.data?.data?.specificConfiguration ?? {};
const requiredFields: Array<{ path: string; label: string }> = [
{ path: 'actionDescription', label: 'Action description' },
{ path: 'llmDescriptor.provider', label: 'Provider' },
{ path: 'llmDescriptor.model', label: 'Model' }
];
this.missingRequiredParams = requiredFields
.filter((field) => this.isMissingValue(this.getByPath(config, field.path)))
.map((field) => field.label);
}
private getByPath(source: Record<string, any>, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc == null || typeof acc !== 'object') return undefined;
return (acc as Record<string, unknown>)[key];
}, source);
}
private isMissingValue(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;
return false;
}
}

View File

@ -3,7 +3,7 @@ import {
Input,
HostBinding,
} from "@angular/core";
import { CommonModule, KeyValue } from "@angular/common";
import { CommonModule } from "@angular/common";
import { ReteModule } from "rete-angular-plugin/21";
import { ClassicPreset } from "rete";

View File

@ -1,4 +1,5 @@
.llm-node {
position: relative;
width: 280px;
border-radius: 16px;
border: 1px solid #dbe2ea;
@ -44,6 +45,27 @@
line-height: 1.2;
}
.llm-subtitle-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.llm-warning {
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid #fecaca;
background: #dc2626;
color: #fff7ed;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 0 0 2px rgba(127, 29, 29, 0.18);
}
.llm-title {
font-size: 13px;
font-weight: 700;
@ -59,6 +81,66 @@
overflow: hidden;
}
.llm-name-edit-btn {
border: none;
background: rgba(255, 255, 255, 0.2);
color: #eff6ff;
border-radius: 999px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.llm-name-edit-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.llm-warning-wrap {
position: relative;
margin-left: auto;
}
.llm-warning-tooltip {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
max-width: 240px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fff1f2;
color: #7f1d1d;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
padding: 8px;
z-index: 80;
opacity: 0;
visibility: hidden;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
}
.llm-warning-wrap:hover .llm-warning-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.llm-warning-title {
font-size: 11px;
font-weight: 700;
margin-bottom: 4px;
}
.llm-warning-item {
font-size: 11px;
}
.llm-body {
padding: 12px;
display: grid;
@ -319,6 +401,7 @@
}
.llm-modal-field input,
.llm-modal-field select,
.llm-modal-field textarea {
border: 1px solid #cbd5e1;
border-radius: 8px;

View File

@ -5,8 +5,26 @@
</div>
<div class="llm-header-content">
<span class="llm-title">LLM Node</span>
<span class="llm-subtitle">{{ name }}</span>
<div class="llm-subtitle-row">
<span class="llm-subtitle">{{ name }}</span>
<button type="button" class="llm-name-edit-btn" title="Edit name" (pointerdown)="$event.stopPropagation()" (click)="openNameEditor($event)">
<i class="bi bi-pencil-square"></i>
</button>
</div>
</div>
@if (missingRequiredParams.length) {
<div class="llm-warning-wrap">
<div class="llm-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="llm-warning-tooltip">
<div class="llm-warning-title">Missing required fields</div>
@for (missing of missingRequiredParams; track missing) {
<div class="llm-warning-item">{{ missing }}</div>
}
</div>
</div>
}
</div>
<div class="llm-body">
@ -54,10 +72,6 @@
</div>
<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">
@ -109,7 +123,18 @@
<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()" />
@if (localEditorLoading) {
<input type="text" [ngModel]="localEditorValue" placeholder="Loading..." disabled (pointerdown)="$event.stopPropagation()" />
} @else if (localEditorKey !== 'name' && localEditorOptions.length) {
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
<option value="">Select {{ localEditorLabel | lowercase }}...</option>
@for (option of localEditorOptions; track option) {
<option [value]="option">{{ option }}</option>
}
</select>
} @else {
<input type="text" [(ngModel)]="localEditorValue" [attr.maxlength]="localEditorKey === 'name' ? 20 : null" [placeholder]="localEditorKey === 'name' ? 'Max 20 characters' : '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>

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Llm } from './llm';
import { LLMNodeComponent } from './llm';
describe('Llm', () => {
let component: Llm;
let fixture: ComponentFixture<Llm>;
describe('LLMNodeComponent', () => {
let component: LLMNodeComponent;
let fixture: ComponentFixture<LLMNodeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Llm]
imports: [LLMNodeComponent]
})
.compileComponents();
fixture = TestBed.createComponent(Llm);
fixture = TestBed.createComponent(LLMNodeComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});

View File

@ -4,6 +4,9 @@ import { FormsModule } from '@angular/forms';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { EditorStateHolder } from '@stores/flow-editor';
import { FieldRetreiver } from '@services/retreiver/field-retreiver';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-llm',
@ -17,6 +20,8 @@ import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialo
export class LLMNodeComponent {
private settingsDialog = inject(NodeSettingsDialogService);
private editorState = inject(EditorStateHolder);
private fieldRetreiver = inject(FieldRetreiver);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -39,9 +44,12 @@ export class LLMNodeComponent {
model: string | null = null;
name: string = 'noName';
localEditorOpen = false;
localEditorKey: 'provider' | 'model' | null = null;
localEditorKey: 'provider' | 'model' | 'name' | null = null;
localEditorLabel = '';
localEditorValue = '';
localEditorOptions: string[] = [];
localEditorLoading = false;
missingRequiredParams: string[] = [];
ngOnInit() {
this.outputs = [];
@ -80,19 +88,31 @@ export class LLMNodeComponent {
if (value == null || typeof value === 'object') return;
this.parameterEntries.push({ key, value: String(value) });
});
this.refreshValidationState();
}
ngAfterViewInit() {
this.rendered();
}
openSimpleParamEditor(key: 'provider' | 'model', event?: Event) {
async openSimpleParamEditor(key: 'provider' | 'model' | 'name', event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.localEditorKey = key;
this.localEditorLabel = key === 'provider' ? 'Provider' : 'Model';
this.localEditorValue = key === 'provider' ? (this.provider ?? '') : (this.model ?? '');
this.localEditorLabel =
key === 'provider' ? 'Provider' :
key === 'model' ? 'Model' : 'Name';
this.localEditorValue =
key === 'provider' ? (this.provider ?? '') :
key === 'model' ? (this.model ?? '') :
(this.name ?? '');
this.localEditorOptions = [];
this.localEditorLoading = key !== 'name';
this.localEditorOpen = true;
if (key !== 'name') {
await this.loadSimpleEditorOptions();
}
}
closeSimpleParamEditor(event?: Event) {
@ -100,6 +120,8 @@ export class LLMNodeComponent {
event?.stopPropagation();
this.localEditorOpen = false;
this.localEditorKey = null;
this.localEditorOptions = [];
this.localEditorLoading = false;
}
saveSimpleParamEditor(event?: Event) {
@ -114,13 +136,25 @@ export class LLMNodeComponent {
if (this.localEditorKey === 'provider') {
this.provider = value || null;
this.model = null;
descriptor['model'] = '';
}
if (this.localEditorKey === 'model') {
this.model = value || null;
}
if (this.localEditorKey === 'name') {
const nameValue = value.slice(0, 20);
config['name'] = nameValue;
this.name = nameValue || this.name;
}
this.refreshValidationState();
this.markFlowDirty();
this.localEditorOpen = false;
this.localEditorKey = null;
this.localEditorOptions = [];
this.localEditorLoading = false;
}
async openPromptEditor(event?: Event) {
@ -146,6 +180,12 @@ export class LLMNodeComponent {
config['prompt'] = String(result['prompt'] ?? '');
this.prompt = config['prompt'] || null;
this.promptParts = this.splitPromptParts(this.prompt);
this.refreshValidationState();
this.markFlowDirty();
}
openNameEditor(event?: Event) {
this.openSimpleParamEditor('name', event);
}
private get blockConfiguration(): Record<string, any> | null {
@ -167,6 +207,68 @@ export class LLMNodeComponent {
return null;
}
private markFlowDirty() {
const flow = this.editorState.currentFlow();
if (!flow) return;
this.editorState.updateData(flow.data);
}
private async loadSimpleEditorOptions() {
const key = this.localEditorKey;
const blockType = this.blockType;
if (!key || !blockType) {
this.localEditorLoading = false;
return;
}
const retrieverKey = key === 'provider' ? 'providers' : 'models';
const context = key === 'model' && this.provider
? { provider: this.provider }
: undefined;
try {
const options = await firstValueFrom(
this.fieldRetreiver.retrieveValues(blockType, retrieverKey, context)
);
this.localEditorOptions = options ?? [];
} catch {
this.localEditorOptions = [];
} finally {
this.localEditorLoading = false;
}
}
private get blockType(): string | null {
const typeName = this.data?.data?.typeName;
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
}
private refreshValidationState() {
const config = this.blockConfiguration ?? {};
const requiredFields: Array<{ path: string; label: string }> = [
{ path: 'prompt', label: 'Prompt' },
{ path: 'llmDescriptor.provider', label: 'Provider' },
{ path: 'llmDescriptor.model', label: 'Model' }
];
this.missingRequiredParams = requiredFields
.filter((field) => this.isMissingValue(this.getByPath(config, field.path)))
.map((field) => field.label);
}
private getByPath(source: Record<string, any>, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc == null || typeof acc !== 'object') return undefined;
return (acc as Record<string, unknown>)[key];
}, source);
}
private isMissingValue(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;
return false;
}
private splitPromptParts(prompt: string | null): { text: string; isDynamicInput: boolean }[] {
if (!prompt) return [];

View File

@ -0,0 +1,10 @@
:host ::ng-deep [data-testid="node"] {
user-select: none;
-webkit-user-select: none;
}
:host ::ng-deep [data-testid="node"] input,
:host ::ng-deep [data-testid="node"] textarea {
user-select: text;
-webkit-user-select: text;
}

View File

@ -1,8 +1,10 @@
import { Component, ElementRef, Injector, output, ViewChild } from '@angular/core';
import { Component, ElementRef, Injector, input, OnChanges, OnDestroy, output, SimpleChanges, ViewChild } from '@angular/core';
import { BlockType, FlowBlock, FlowData } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
import { EditorStateHolder } from '@stores/flow-editor';
import { addBlockToEditor, createEditor, ReteEditorInstance } from '@utilities/rete-editor';
import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance } from '@utilities/rete-editor';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-rete-editor',
@ -10,37 +12,48 @@ import { addBlockToEditor, createEditor, ReteEditorInstance } from '@utilities/r
templateUrl: './rete-editor.html',
styleUrl: './rete-editor.css',
})
export class ReteEditor {
export class ReteEditor implements OnChanges, OnDestroy {
readonly flowData = input.required<FlowData>();
readonly flowId = input.required<string>();
flowData: FlowData;
constructor(private injector: Injector, private flowState: EditorStateHolder ) {
this.flowData = this.flowState.currentFlow()!.data;
}
constructor(
private injector: Injector,
private flowState: EditorStateHolder,
private blocksService: BlocksService
) {}
@ViewChild("editor") container!: ElementRef;
private rete?: ReteEditorInstance;
private viewReady = false;
private loadVersion = 0;
private readonly dirtyEventTypes = new Set([
'nodecreated',
'noderemoved',
'connectioncreated',
'connectionremoved'
]);
flowChanged = output<any>();
ngAfterViewInit(): void {
const el = this.container.nativeElement;
this.viewReady = true;
void this.reloadEditor();
}
if (el) {
createEditor(el, this.injector, this.flowData).then((rete) => {
this.rete = rete;
rete.editor.addPipe(
(context) => {
this.flowChanged.emit({});
return context;
}
);
});
ngOnChanges(changes: SimpleChanges): void {
if (!this.viewReady) return;
if (changes['flowId']) {
void this.reloadEditor();
}
}
ngOnDestroy(): void {
this.rete?.area.destroy();
this.rete = undefined;
}
onDragOver(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer) {
@ -55,10 +68,54 @@ export class ReteEditor {
const blockType: BlockType = JSON.parse(payload);
const position = this.getDropPosition(event);
const newBlock = this.createBlockFromType(blockType, position);
let newBlock: FlowBlock;
try {
newBlock = await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type));
} catch (error) {
console.error('Failed to create empty block', error);
return;
}
newBlock = {
...newBlock,
position
};
await addBlockToEditor(this.rete.editor, this.rete.area, newBlock, position);
this.flowChanged.emit({});
const updatedData = exportGraph(this.rete.editor);
this.flowState.updateData(updatedData);
this.flowChanged.emit(updatedData);
}
private async reloadEditor() {
const host = this.container?.nativeElement as HTMLElement | undefined;
if (!host) return;
const currentVersion = ++this.loadVersion;
this.rete?.area.destroy();
this.rete = undefined;
host.innerHTML = '';
const rete = await createEditor(host, this.injector, this.flowData());
if (currentVersion !== this.loadVersion) {
rete.area.destroy();
return;
}
this.rete = rete;
rete.editor.addPipe((context) => {
if (this.dirtyEventTypes.has(context.type)) {
this.markFlowChanged(rete, context);
}
return context;
});
rete.area.addPipe((context: any) => {
if (context?.type === 'nodetranslated') {
this.markFlowChanged(rete, context);
}
return context;
});
}
private getDropPosition(event: DragEvent) {
@ -71,52 +128,23 @@ export class ReteEditor {
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;
private syncNodePositionFromContext(rete: ReteEditorInstance, context: any) {
if (context?.type !== 'nodetranslated') return;
if (blockType.name === 'SourceBlock') {
return {
...common,
inputs: [],
outputs: [{ name: 'output', type: 'TEXT', multiple: false }],
specificConfiguration: {}
};
}
const movedNode = rete.editor.getNode(context?.data?.id) as any;
const pos = context?.data?.position;
if (!movedNode?.data || !pos) return;
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: ''
}
movedNode.data = {
...movedNode.data,
position: { x: pos.x, y: pos.y }
};
}
private markFlowChanged(rete: ReteEditorInstance, context: any) {
this.syncNodePositionFromContext(rete, context);
const updatedData = exportGraph(rete.editor);
this.flowState.updateData(updatedData);
this.flowChanged.emit(updatedData);
}
}

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, ElementRef, HostBinding, Input } from "@angular/core";
import { ChangeDetectorRef, Component, HostBinding, Input } from "@angular/core";
import { ReteModule } from "rete-angular-plugin/21";
@Component({
@ -41,4 +41,4 @@ export class InputSocket {
requestAnimationFrame(() => this.rendered());
}
}
}

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, HostBinding, HostListener, Input } from '@angular/core';
import { ChangeDetectorRef, Component, HostBinding, Input } from '@angular/core';
import { ReteModule } from 'rete-angular-plugin/21';
@Component({

View File

@ -9,15 +9,13 @@
<h2 class="m-0 text-sm font-semibold text-gray-800">
<input type="text" class="border-0 text-sm font-semibold text-gray-800
focus:ring-0 p-0 m-0 box-border
min-w-[4ch] max-w-[16ch]
w-52 max-w-52
bg-transparent"
[value]="title()"
(blur)="changeTitle(titleInput.value)"
(keydown.enter)="changeTitle(titleInput.value); $event.target.blur()"
[style.width.ch]="titleWidth()"
maxlength="20"
size="20"
(input)="tempTitle.set(titleInput.value)"
#titleInput
/>
</h2>

View File

@ -1,4 +1,4 @@
import { Component, computed, ElementRef, inject, linkedSignal, ViewChild, WritableSignal } from '@angular/core';
import { Component, computed, ElementRef, inject, ViewChild } from '@angular/core';
import { EditorStateHolder } from '@stores/flow-editor';
@Component({
@ -17,22 +17,10 @@ export class TitleToolbar {
return flow ? flow.name : 'No Flow Opened';
});
tempTitle: WritableSignal<string | null> = linkedSignal(() =>null);
titleWidth = computed(() => {
const temp = this.tempTitle();
if (temp !== null) {
return Math.max(temp.length-1, 4)
}
const len = this.title().length || 4;
return Math.min(len, 20)-2;
});
notSaved = computed(() => this.editorState.isDirty());
changeTitle(value: string) {
this.tempTitle.set(null);
const trimmed = value.trim();
if (value === this.title()) return;
if (trimmed.length < 4) {
@ -40,7 +28,6 @@ export class TitleToolbar {
return;
}
console.log('Updating flow title to:', trimmed);
const flow = this.editorState.currentFlow()!;
this.editorState.updateFlowTitle(trimmed );
}

View File

@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { EditorStateHolder as Editor } from './editor';
import { EditorStateHolder } from './flow-editor';
describe('Editor', () => {
let service: Editor;
describe('EditorStateHolder', () => {
let service: EditorStateHolder;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(Editor);
service = TestBed.inject(EditorStateHolder);
});
it('should be created', () => {

View File

@ -2,7 +2,6 @@ import { computed, inject, Injectable, signal } from '@angular/core';
import { Flow, FlowData } from '@models/flow';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FlowsService } from '@services/flows/flows';
import { FlowsCallService } from '@services/flows/flows-call';
import { tap } from 'rxjs';
@Injectable({ providedIn: 'root' })
@ -27,8 +26,8 @@ export class EditorStateHolder {
undoEnabled = computed(() => this.previousDataStack.length > 1);
/** Intent: open document */
async openDocument(doc: Flow): Promise<boolean> {
if (this.isDirty()) {
async openDocument(doc: Flow, options?: { skipDirtyCheck?: boolean }): Promise<boolean> {
if (this.isDirty() && !options?.skipDirtyCheck) {
const confirmed = await this.confirm.open(
'You have unsaved changes. Open another document?'
);
@ -104,4 +103,4 @@ export class EditorStateHolder {
}
}
}

View File

@ -1,4 +1,4 @@
import { Injectable, Signal, signal } from '@angular/core';
import { Injectable } from '@angular/core';
import { ListView, OrderViewState } from '@utilities/list-state-holder';

View File

@ -1,5 +1,4 @@
import { NgModel } from "@angular/forms";
import { FieldState } from "@angular/forms/signals";
import { FieldState } from "@angular/forms/signals";
export class FormUtility {
isInvalid(input: FieldState<any>) {

View File

@ -1,4 +1,4 @@
import { inject, signal, Signal } from "@angular/core";
import { inject, Signal } from "@angular/core";
import { ListState } from "@stores/list-state";
export class OrderViewState {
@ -35,4 +35,4 @@ export class ListStateViewHolder<T> {
this.state = new ListView<T>();
}
}
}

View File

@ -11,6 +11,7 @@ import { OutputNodeComponent } from "@shared/nodes/output/output-node.component"
import { HFNode, HFSchemes } from "@models/nodes";
import { FlowBlock, FlowData } from "@models/flow";
import { LLMNodeComponent } from "@shared/nodes/llm/llm";
import { HumanInteractionNodeComponent } from "@shared/nodes/human-interaction/human-interaction";
import { CustomSocket } from "@shared/custom-socket/custom-socket";
type AreaExtra = AngularArea2D<HFSchemes>;
@ -44,6 +45,9 @@ export async function createEditor(
if (context.payload.label === "Output") {
return OutputNodeComponent;
}
if (context.payload.label === "HumanInteractionBlock") {
return HumanInteractionNodeComponent;
}
return LLMNodeComponent;
},
socket() {
@ -190,4 +194,3 @@ function toNodeLabel(typeName: string) {
return typeName;
}

View File

@ -1,11 +1,13 @@
import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake";
import { BlocksCallServiceFake } from "@services/blocks/blocks-call.fake";
import { FlowsCallServiceFake } from "@services/flows/flows-call.fake";
import { FieldRetreiverCallServiceFake } from "@services/retreiver/field-retreiver-call.fake";
export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
authorizationCallService: AuthorizationCallFakeService, // Assign the appropriate service here
flowsCallService: FlowsCallServiceFake,
blocksCallService: BlocksCallServiceFake
blocksCallService: BlocksCallServiceFake,
fieldRetreiverCallService: FieldRetreiverCallServiceFake
};

View File

@ -1,11 +1,13 @@
import { AuthorizationCallService } from "@services/authorization/authorization-call";
import { BlocksCallService } from "@services/blocks/blocks-call";
import { FlowsCallService } from "@services/flows/flows-call";
import { FieldRetreiverCallService } from "@services/retreiver/field-retreiver-call";
export const environment = {
apiUrl: '/api',
production: true,
authorizationCallService: AuthorizationCallService,
flowsCallService: FlowsCallService,
blocksCallService: BlocksCallService
blocksCallService: BlocksCallService,
fieldRetreiverCallService: FieldRetreiverCallService
};