diff --git a/src/app/app.html b/src/app/app.html index 8c47805..3da2d51 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,2 +1,3 @@ + diff --git a/src/app/app.ts b/src/app/app.ts index a46483b..2da7761 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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' }) diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index aed2285..56b2e9f 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -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; } - diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.html b/src/app/pages/main/editor-sidebar/editor-sidebar.html index 6947974..a069cf1 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.html +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.html @@ -42,11 +42,10 @@ } @case ('blocks') { -
-
Input
-
Transform
-
Output
-
+ +
} } @@ -55,4 +54,4 @@ } - \ No newline at end of file + diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.ts b/src/app/pages/main/editor-sidebar/editor-sidebar.ts index 8292c6a..34e12b8 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.ts +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.ts @@ -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...'); + } } diff --git a/src/app/services/blocks/block-call.base.ts b/src/app/services/blocks/block-call.base.ts new file mode 100644 index 0000000..2af4dbb --- /dev/null +++ b/src/app/services/blocks/block-call.base.ts @@ -0,0 +1,10 @@ +import { BlockType, FlowBlock } from "@models/flow"; +import { Observable } from "rxjs"; + +export abstract class BlocksCallServiceBase { + + abstract retrieveAllBlocksTypes() : Observable; + + abstract createNewBlock(configuration : any) : Observable; + +} \ No newline at end of file diff --git a/src/app/services/blocks/blocks-call.fake.ts b/src/app/services/blocks/blocks-call.fake.ts new file mode 100644 index 0000000..ff72b2a --- /dev/null +++ b/src/app/services/blocks/blocks-call.fake.ts @@ -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 { + return of(this.blockTypes); + } + + override createNewBlock(configuration: any): Observable { + 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); + } +} diff --git a/src/app/services/blocks/blocks-call.ts b/src/app/services/blocks/blocks-call.ts new file mode 100644 index 0000000..c793580 --- /dev/null +++ b/src/app/services/blocks/blocks-call.ts @@ -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 { + throw new Error("Method not implemented."); + } + + override createNewBlock(configuration: any): Observable { + throw new Error("Method not implemented."); + } +} diff --git a/src/app/services/blocks/blocks.spec.ts b/src/app/services/blocks/blocks.spec.ts new file mode 100644 index 0000000..b6c9524 --- /dev/null +++ b/src/app/services/blocks/blocks.spec.ts @@ -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(); + }); +}); diff --git a/src/app/services/blocks/blocks.ts b/src/app/services/blocks/blocks.ts new file mode 100644 index 0000000..4bc6de2 --- /dev/null +++ b/src/app/services/blocks/blocks.ts @@ -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([]); + + 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); + }) + ); + } +} diff --git a/src/app/services/dialogs/node-settings-dialog.ts b/src/app/services/dialogs/node-settings-dialog.ts new file mode 100644 index 0000000..7d9c778 --- /dev/null +++ b/src/app/services/dialogs/node-settings-dialog.ts @@ -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; + +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 { + 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); + } +} diff --git a/src/app/shared/blocks-list/block-drag.ts b/src/app/shared/blocks-list/block-drag.ts new file mode 100644 index 0000000..e49b810 --- /dev/null +++ b/src/app/shared/blocks-list/block-drag.ts @@ -0,0 +1 @@ +export const BLOCK_TYPE_DRAG_MIME = "application/x-hf-block-type"; diff --git a/src/app/shared/blocks-list/blocks-list.css b/src/app/shared/blocks-list/blocks-list.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/blocks-list/blocks-list.html b/src/app/shared/blocks-list/blocks-list.html new file mode 100644 index 0000000..d340f5f --- /dev/null +++ b/src/app/shared/blocks-list/blocks-list.html @@ -0,0 +1,57 @@ +@if (loading()) { +
+
+ Loading... +
+
+} @else { +
+
+
+ + + + + +
+
+ + + + @if (filteredBlocks().length === 0) { +
+ No blocks found +
+ } @else { +
+ @for (block of filteredBlocks(); track block.name) { +
+
+ {{ block.name }} +
+

{{ block.description }}

+ + {{ block.userInteractive ? 'Interactive' : 'Automatic' }} + +
+ } +
+ } +
+} diff --git a/src/app/shared/blocks-list/blocks-list.spec.ts b/src/app/shared/blocks-list/blocks-list.spec.ts new file mode 100644 index 0000000..81c73dd --- /dev/null +++ b/src/app/shared/blocks-list/blocks-list.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BlocksList } from './blocks-list'; + +describe('BlocksList', () => { + let component: BlocksList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BlocksList] + }) + .compileComponents(); + + fixture = TestBed.createComponent(BlocksList); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/blocks-list/blocks-list.ts b/src/app/shared/blocks-list/blocks-list.ts new file mode 100644 index 0000000..73060e2 --- /dev/null +++ b/src/app/shared/blocks-list/blocks-list.ts @@ -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 { + + searchTerm = model(''); + + private blocksService = inject(BlocksService); + + loading: WritableSignal = signal(true); + + /* + get orderView() { + const existingState = this.view; + return existingState.order; + }*/ + + blockTypes?: Signal; + + 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); + } +} diff --git a/src/app/shared/node-settings-dialog/node-settings-dialog.html b/src/app/shared/node-settings-dialog/node-settings-dialog.html new file mode 100644 index 0000000..20ddc63 --- /dev/null +++ b/src/app/shared/node-settings-dialog/node-settings-dialog.html @@ -0,0 +1,68 @@ +@if (state()) { +
+
+ +
+
+

{{ state()!.title }}

+ +
+ +
+ @for (field of fields; track field.key) { + + } +
+ +
+ + +
+
+
+} diff --git a/src/app/shared/node-settings-dialog/node-settings-dialog.ts b/src/app/shared/node-settings-dialog/node-settings-dialog.ts new file mode 100644 index 0000000..b6aa5b8 --- /dev/null +++ b/src/app/shared/node-settings-dialog/node-settings-dialog.ts @@ -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; + } +} diff --git a/src/app/shared/nodes/llm/llm.css b/src/app/shared/nodes/llm/llm.css index bf1ce3c..4852ae0 100644 --- a/src/app/shared/nodes/llm/llm.css +++ b/src/app/shared/nodes/llm/llm.css @@ -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; } diff --git a/src/app/shared/nodes/llm/llm.html b/src/app/shared/nodes/llm/llm.html index b749766..ff51385 100644 --- a/src/app/shared/nodes/llm/llm.html +++ b/src/app/shared/nodes/llm/llm.html @@ -1,72 +1,122 @@ -
- -
-
- LLM +
+
+
+ LLM +
+
+ LLM Node + {{ name }}
- LLM Node
- -
- - -
- - -
+
+
+
Inputs
+ @for (input of inputs; track input.key) { +
+
+
+ {{ input.key }} +
+ }
- - - {{ inputKey }} - - +
+
Outputs
+ @for (output of outputs; track output.key) { +
+ {{ output.key }} +
+
+
+ } +
- - -
- - - - {{ outputKey }} - - - -
+
+
+
Parameters
+
+
+
+ Provider + +
+ {{ provider || '-' }} +
+ +
+
+ Model + +
+ {{ model || '-' }} +
+
+ +
+
+
Prompt
+ +
+
+ @if (!promptParts.length) { + - + } @else { + @for (part of promptParts; track $index) { + @if (part.isDynamicInput) { + {{ formatDynamicInputToken(part.text) }} + } @else { + {{ part.text }} + } + } + } +
+
+ @if (localEditorOpen) { +
+
+
Edit {{ localEditorLabel }}
+
+ + +
+
+ + +
+
+
+ } +
- - -
\ No newline at end of file diff --git a/src/app/shared/nodes/llm/llm.ts b/src/app/shared/nodes/llm/llm.ts index 5fc2c8b..a99aed9 100644 --- a/src/app/shared/nodes/llm/llm.ts +++ b/src/app/shared/nodes/llm/llm.ts @@ -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 | null { + return this.data?.data?.specificConfiguration ?? null; + } + + private ensureBlockConfiguration(): Record { + 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; + } } diff --git a/src/app/shared/rete-editor/rete-editor.html b/src/app/shared/rete-editor/rete-editor.html index ad6bdc1..2a19579 100644 --- a/src/app/shared/rete-editor/rete-editor.html +++ b/src/app/shared/rete-editor/rete-editor.html @@ -1,2 +1,2 @@ -
-
\ No newline at end of file +
+
diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index 9a00b69..d51ca36 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -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(); @@ -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: '' + } + }; + } } diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index a97abdf..6cf978c 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -14,8 +14,18 @@ import { LLMNodeComponent } from "@shared/nodes/llm/llm"; import { CustomSocket } from "@shared/custom-socket/custom-socket"; type AreaExtra = AngularArea2D; +const editorSockets = new WeakMap, Map>(); -export async function createEditor(container: HTMLElement, injector: Injector, flowData: FlowData) { +export type ReteEditorInstance = { + editor: NodeEditor; + area: AreaPlugin; +}; + +export async function createEditor( + container: HTMLElement, + injector: Injector, + flowData: FlowData +): Promise { const editor = new NodeEditor(); const area = new AreaPlugin(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) { @@ -110,38 +120,45 @@ export function exportGraph(editor: NodeEditor) { }; } -async function loadFlowData(editor: NodeEditor, flowData: FlowData) { - if (!flowData.blocks?.length) return; +export async function addBlockToEditor( + editor: NodeEditor, + area: AreaPlugin, + 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(); - 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, + area: AreaPlugin, + flowData: FlowData +) { + if (!flowData.blocks?.length) return; const nodeMapping = new Map(); 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, flowData: FlowData) { ); } } + +function getSocket(editor: NodeEditor, type: string) { + if (!editorSockets.has(editor)) { + editorSockets.set(editor, new Map()); + } + 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; +} - diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 2a3fc0c..83b8ee4 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -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 }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 49efb80..7becd89 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -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 };