modified node structure and parameter
This commit is contained in:
parent
25edbbd34c
commit
68635649f8
|
|
@ -1,2 +1,3 @@
|
|||
<router-outlet />
|
||||
<app-confirm-dialog-host></app-confirm-dialog-host>
|
||||
<app-node-settings-dialog-host></app-node-settings-dialog-host>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const BLOCK_TYPE_DRAG_MIME = "application/x-hf-block-type";
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue