- @for (block of filteredBlocks(); track block.name) {
+ @for (block of filteredBlocks(); track block.type) {
- {{ block.name }}
+ {{ block.type }}
{{ block.description }}
{
const term = this.searchTerm().toLowerCase();
return blocks.filter((b) =>
- b.name.toLowerCase().includes(term) || b.description.toLowerCase().includes(term)
+ b.type.toLowerCase().includes(term) || b.description.toLowerCase().includes(term)
);
});
- onOrderChanged(event: OrderEvent) {
- const { orderBy, orderDir } = event;
- this.view.order = { orderBy, orderDir };
- const blocks = this.filteredBlocks();
- if (!orderBy) return blocks;
-
- return blocks.sort((a, b) => {
- const aValue = (a as any)[orderBy];
- const bValue = (b as any)[orderBy];
- if (aValue < bValue) return orderDir === 'asc' ? -1 : 1;
- if (aValue > bValue) return orderDir === 'asc' ? 1 : -1;
- return 0;
- });
- }
-
onDragStart(event: DragEvent, block: BlockType) {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(BLOCK_TYPE_DRAG_MIME, JSON.stringify(block));
- event.dataTransfer.setData('text/plain', block.name);
+ event.dataTransfer.setData('text/plain', block.type);
}
}
diff --git a/src/app/shared/confirm-dialog/confirm-dialog.spec.ts b/src/app/shared/confirm-dialog/confirm-dialog.spec.ts
index 85ecf02..0665973 100644
--- a/src/app/shared/confirm-dialog/confirm-dialog.spec.ts
+++ b/src/app/shared/confirm-dialog/confirm-dialog.spec.ts
@@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ConfirmDialog } from './confirm-dialog';
+import { ConfirmDialogHostComponent } from './confirm-dialog';
-describe('ConfirmDialog', () => {
- let component: ConfirmDialog;
- let fixture: ComponentFixture;
+describe('ConfirmDialogHostComponent', () => {
+ let component: ConfirmDialogHostComponent;
+ let fixture: ComponentFixture;
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [ConfirmDialog]
+ imports: [ConfirmDialogHostComponent]
})
.compileComponents();
- fixture = TestBed.createComponent(ConfirmDialog);
+ fixture = TestBed.createComponent(ConfirmDialogHostComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
diff --git a/src/app/shared/confirm-dialog/confirm-dialog.ts b/src/app/shared/confirm-dialog/confirm-dialog.ts
index 305a068..89d2aab 100644
--- a/src/app/shared/confirm-dialog/confirm-dialog.ts
+++ b/src/app/shared/confirm-dialog/confirm-dialog.ts
@@ -1,4 +1,4 @@
-import { Component, inject, Input } from '@angular/core';
+import { Component, inject } from '@angular/core';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
@Component({
diff --git a/src/app/shared/custom-socket/custom-socket.ts b/src/app/shared/custom-socket/custom-socket.ts
index 8b66db8..b1536da 100644
--- a/src/app/shared/custom-socket/custom-socket.ts
+++ b/src/app/shared/custom-socket/custom-socket.ts
@@ -1,5 +1,4 @@
-import { Component, AfterViewInit, Input, HostListener, HostBinding, ChangeDetectorRef } from "@angular/core";
-import { ReteModule } from "rete-angular-plugin/21";
+import { Component, Input, HostBinding, ChangeDetectorRef } from "@angular/core";
@Component({
template: ``,
diff --git a/src/app/shared/flows-list/flow-item/flow-item.html b/src/app/shared/flows-list/flow-item/flow-item.html
index 643fcc0..e5f1bfb 100644
--- a/src/app/shared/flows-list/flow-item/flow-item.html
+++ b/src/app/shared/flows-list/flow-item/flow-item.html
@@ -1,12 +1,23 @@
+ select-none"
+ [class.cursor-pointer]="flow().id !== openedFlowId()"
+ [class.cursor-default]="flow().id === openedFlowId()"
+ [class.bg-indigo-50]="flow().id !== openedFlowId()"
+ [class.border-indigo-200]="flow().id !== openedFlowId()"
+ [class.bg-emerald-50]="flow().id === openedFlowId()"
+ [class.border-emerald-300]="flow().id === openedFlowId()"
+ [class.ring-1]="flow().id === openedFlowId()"
+ [class.ring-emerald-200]="flow().id === openedFlowId()"
+ (click)="flow().id !== openedFlowId() && open()">
-
+
-
-
+
@@ -74,4 +90,4 @@
-
\ No newline at end of file
+
diff --git a/src/app/shared/flows-list/flow-item/flow-item.ts b/src/app/shared/flows-list/flow-item/flow-item.ts
index 86c38c6..60c901a 100644
--- a/src/app/shared/flows-list/flow-item/flow-item.ts
+++ b/src/app/shared/flows-list/flow-item/flow-item.ts
@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
-import { Component, computed, effect, EventEmitter, inject, input, model, output, signal, untracked } from '@angular/core';
+import { Component, computed, effect, inject, input, model, signal } from '@angular/core';
import { Flow } from '@models/flow';
+import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FlowsService } from '@services/flows/flows';
import { EditorStateHolder } from '@stores/flow-editor';
@@ -13,6 +14,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
export class FlowItem {
private editorState = inject(EditorStateHolder);
+ private confirm = inject(ConfirmDialogService);
private flowsService = inject(FlowsService);
@@ -34,9 +36,17 @@ export class FlowItem {
});
}
- open() {
+ async open() {
console.log('Opening flow:', this.flow());
- this.editorState.openDocument(this.flow());
+ if (this.editorState.isDirty() && this.openedFlowId() !== this.flow().id) {
+ const confirmed = await this.confirm.open(
+ 'You have unsaved changes in the current flow. Open another flow anyway?'
+ );
+ if (!confirmed) return;
+ await this.editorState.openDocument(this.flow(), { skipDirtyCheck: true });
+ return;
+ }
+ await this.editorState.openDocument(this.flow());
}
clone() {
@@ -48,7 +58,14 @@ export class FlowItem {
});
}
- remove() {
+ async remove() {
+ const confirmed = await this.confirm.open(
+ this.editorState.isDirty()
+ ? 'You have unsaved changes in the current flow. Delete this flow anyway?'
+ : 'Are you sure you want to delete this flow?'
+ );
+ if (!confirmed) return;
+
this.flowsService.deleteFlow(this.flow().id).subscribe({
next: () => {
console.log('Flow deleted:', this.flow().id);
@@ -69,4 +86,3 @@ export class FlowItem {
}
}
}
-
diff --git a/src/app/shared/flows-list/flows-list.ts b/src/app/shared/flows-list/flows-list.ts
index 9c280b5..ceebfcd 100644
--- a/src/app/shared/flows-list/flows-list.ts
+++ b/src/app/shared/flows-list/flows-list.ts
@@ -1,5 +1,4 @@
-import { CommonModule } from '@angular/common';
-import { Component, computed, effect, inject, model, output, signal, Signal, WritableSignal } from '@angular/core';
+import { Component, computed, effect, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { Flow, FlowVisibility } from '@models/flow';
import { FlowsService } from '@services/flows/flows';
import { FlowItem } from './flow-item/flow-item';
diff --git a/src/app/shared/group-holder/group-holder.ts b/src/app/shared/group-holder/group-holder.ts
index 912adb0..49fac0c 100644
--- a/src/app/shared/group-holder/group-holder.ts
+++ b/src/app/shared/group-holder/group-holder.ts
@@ -1,4 +1,4 @@
-import { Component, EventEmitter, input, Input, Output } from '@angular/core';
+import { Component, Input } from '@angular/core';
@Component({
selector: 'app-group-holder',
diff --git a/src/app/shared/nodes/human-interaction/human-interaction.css b/src/app/shared/nodes/human-interaction/human-interaction.css
new file mode 100644
index 0000000..6729331
--- /dev/null
+++ b/src/app/shared/nodes/human-interaction/human-interaction.css
@@ -0,0 +1,391 @@
+.hi-node {
+ position: relative;
+ width: 292px;
+ border-radius: 16px;
+ border: 1px solid #f7d7a7;
+ background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%);
+ box-shadow: 0 10px 24px rgba(124, 45, 18, 0.14);
+ color: #431407;
+ overflow: visible;
+}
+
+.hi-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: linear-gradient(135deg, #f59e0b 0%, #ea580c 100%);
+ border-top-left-radius: 16px;
+ border-top-right-radius: 16px;
+ border-bottom: 1px solid #fdba74;
+}
+
+.hi-icon {
+ width: 30px;
+ height: 30px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: #fffbeb;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ flex-shrink: 0;
+}
+
+.hi-header-content {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.hi-subtitle-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.hi-warning {
+ width: 24px;
+ height: 24px;
+ border-radius: 999px;
+ border: 1px solid #fecaca;
+ background: #dc2626;
+ color: #fff7ed;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ box-shadow: 0 0 0 2px rgba(127, 29, 29, 0.18);
+}
+
+.hi-title {
+ color: #fffbeb;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.hi-subtitle {
+ color: #fff7ed;
+ font-size: 11px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.hi-name-edit-btn {
+ border: none;
+ background: rgba(255, 255, 255, 0.2);
+ color: #fff7ed;
+ border-radius: 999px;
+ width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background-color 0.15s ease;
+}
+
+.hi-name-edit-btn:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.hi-warning-wrap {
+ position: relative;
+ margin-left: auto;
+}
+
+.hi-warning-tooltip {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ min-width: 180px;
+ max-width: 240px;
+ border: 1px solid #fecaca;
+ border-radius: 8px;
+ background: #fff1f2;
+ color: #7f1d1d;
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
+ padding: 8px;
+ z-index: 80;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-2px);
+ transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
+}
+
+.hi-warning-wrap:hover .hi-warning-tooltip {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.hi-warning-title {
+ font-size: 11px;
+ font-weight: 700;
+ margin-bottom: 4px;
+}
+
+.hi-warning-item {
+ font-size: 11px;
+}
+
+
+.hi-body {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.hi-columns {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.hi-column {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.hi-column-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: #9a3412;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.hi-column-title-right {
+ text-align: right;
+}
+
+.hi-row {
+ display: grid;
+ align-items: center;
+ min-width: 0;
+ height: 24px;
+}
+
+.hi-row-input {
+ grid-template-columns: 16px 1fr;
+ gap: 6px;
+}
+
+.hi-row-output {
+ grid-template-columns: 1fr 16px;
+ gap: 6px;
+}
+
+.hi-socket {
+ position: relative;
+ z-index: 20;
+}
+
+.hi-socket-right {
+ justify-self: end;
+}
+
+.hi-pill {
+ height: 24px;
+ border-radius: 7px;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 0 8px;
+ display: inline-flex;
+ align-items: center;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.hi-pill-input {
+ color: #7c2d12;
+ background: #ffedd5;
+ border: 1px solid #fed7aa;
+ justify-content: flex-start;
+}
+
+.hi-pill-output {
+ color: #9f1239;
+ background: #fff1f2;
+ border: 1px solid #fecdd3;
+ justify-content: flex-end;
+}
+
+.hi-panel {
+ border: 1px solid #fdba74;
+ border-radius: 10px;
+ background: #fff7ed;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.hi-panel-simulated {
+ border-color: #f9a8d4;
+ background: #fff1f2;
+}
+
+.hi-panel-title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.hi-panel-title {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: #c2410c;
+ font-weight: 700;
+}
+
+.hi-panel-text {
+ font-size: 11px;
+ color: #431407;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.hi-meta {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+
+.hi-meta-chip {
+ border: 1px solid #fdba74;
+ background: #fff;
+ border-radius: 7px;
+ padding: 4px 6px;
+ color: #7c2d12;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.hi-meta-chip-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 4px;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.hi-meta-chip-value {
+ font-size: 10px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.hi-edit-btn {
+ border: none;
+ background: transparent;
+ color: #9a3412;
+ border-radius: 999px;
+ width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: color 0.15s ease, background-color 0.15s ease;
+}
+
+.hi-edit-btn:hover {
+ color: #ea580c;
+ background: rgba(234, 88, 12, 0.12);
+}
+
+.hi-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(124, 45, 18, 0.4);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 12px;
+}
+
+.hi-modal {
+ width: min(360px, calc(100vw - 24px));
+ border-radius: 12px;
+ border: 1px solid #fdba74;
+ background: #fff;
+ box-shadow: 0 16px 40px rgba(124, 45, 18, 0.25);
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.hi-modal-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: #7c2d12;
+}
+
+.hi-modal-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.hi-modal-field label {
+ font-size: 11px;
+ font-weight: 600;
+ color: #9a3412;
+}
+
+.hi-modal-field input,
+.hi-modal-field select {
+ border: 1px solid #fdba74;
+ border-radius: 8px;
+ font-size: 12px;
+ color: #431407;
+ padding: 7px 8px;
+}
+
+.hi-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.hi-btn {
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 12px;
+ font-weight: 600;
+ border: 1px solid transparent;
+ cursor: pointer;
+}
+
+.hi-btn-ghost {
+ border-color: #fdba74;
+ color: #9a3412;
+ background: #fff;
+}
+
+.hi-btn-primary {
+ background: #ea580c;
+ color: #fff;
+}
diff --git a/src/app/shared/nodes/human-interaction/human-interaction.html b/src/app/shared/nodes/human-interaction/human-interaction.html
new file mode 100644
index 0000000..941f2ea
--- /dev/null
+++ b/src/app/shared/nodes/human-interaction/human-interaction.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
Inputs
+ @for (input of inputs; track input.key) {
+
+ }
+
+
+
+
Outputs
+ @for (output of outputs; track output.key) {
+
+ }
+
+
+
+
+
+
{{ actionDescription || '-' }}
+
+
+
+
Simulated with
+
+
+
+ Provider
+
+
+ {{ provider || '-' }}
+
+
+
+ Model
+
+
+ {{ model || '-' }}
+
+
+
+
+
+ @if (localEditorOpen) {
+
+
+
Edit {{ localEditorLabel }}
+
+
+ @if (localEditorLoading) {
+
+ } @else if (localEditorKey !== 'name' && localEditorOptions.length) {
+
+ } @else {
+
+ }
+
+
+
+
+
+
+
+ }
+
diff --git a/src/app/shared/nodes/human-interaction/human-interaction.ts b/src/app/shared/nodes/human-interaction/human-interaction.ts
new file mode 100644
index 0000000..bf5e4af
--- /dev/null
+++ b/src/app/shared/nodes/human-interaction/human-interaction.ts
@@ -0,0 +1,232 @@
+import { CommonModule } from '@angular/common';
+import { Component, HostBinding, inject, Input } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ClassicPreset } from 'rete';
+import { ReteModule } from 'rete-angular-plugin/21';
+import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
+import { EditorStateHolder } from '@stores/flow-editor';
+import { FieldRetreiver } from '@services/retreiver/field-retreiver';
+import { firstValueFrom } from 'rxjs';
+
+@Component({
+ selector: 'app-human-interaction-node',
+ imports: [CommonModule, FormsModule, ReteModule],
+ templateUrl: './human-interaction.html',
+ styleUrl: './human-interaction.css',
+ host: {
+ 'data-testid': 'node'
+ }
+})
+export class HumanInteractionNodeComponent {
+ private settingsDialog = inject(NodeSettingsDialogService);
+ private editorState = inject(EditorStateHolder);
+ private fieldRetreiver = inject(FieldRetreiver);
+
+ @Input() data!: any;
+ @Input() emit!: (data: any) => void;
+ @Input() rendered!: () => void;
+
+ @HostBinding('class.selected') get selected() {
+ return this.data.selected;
+ }
+
+ outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
+ inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
+
+ name = 'Human Interaction';
+ actionDescription = '';
+ provider: string | null = null;
+ model: string | null = null;
+ localEditorOpen = false;
+ localEditorKey: 'provider' | 'model' | 'name' | null = null;
+ localEditorLabel = '';
+ localEditorValue = '';
+ localEditorOptions: string[] = [];
+ localEditorLoading = false;
+ missingRequiredParams: string[] = [];
+
+ ngOnInit() {
+ this.outputs = [];
+ this.inputs = [];
+
+ for (const [key, output] of Object.entries(this.data.outputs ?? {})) {
+ this.outputs.push({ key, socket: (output as any).socket });
+ }
+
+ for (const [key, input] of Object.entries(this.data.inputs ?? {})) {
+ this.inputs.push({ key, socket: (input as any).socket });
+ }
+
+ const config = this.data?.data?.specificConfiguration ?? {};
+ this.name = (config?.name as string) || this.name;
+ this.actionDescription = (config?.actionDescription as string) || '';
+ this.provider = (config?.llmDescriptor?.provider as string) || null;
+ this.model = (config?.llmDescriptor?.model as string) || null;
+ this.refreshValidationState();
+ }
+
+ ngAfterViewInit() {
+ this.rendered();
+ }
+
+ async openSimpleParamEditor(key: 'provider' | 'model' | 'name', event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.localEditorKey = key;
+ this.localEditorLabel =
+ key === 'provider' ? 'Provider' :
+ key === 'model' ? 'Model' : 'Name';
+ this.localEditorValue =
+ key === 'provider' ? (this.provider ?? '') :
+ key === 'model' ? (this.model ?? '') :
+ (this.name ?? '');
+ this.localEditorOptions = [];
+ this.localEditorLoading = key !== 'name';
+ this.localEditorOpen = true;
+ if (key !== 'name') {
+ await this.loadSimpleEditorOptions();
+ }
+ }
+
+ closeSimpleParamEditor(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.localEditorOpen = false;
+ this.localEditorKey = null;
+ this.localEditorOptions = [];
+ this.localEditorLoading = false;
+ }
+
+ saveSimpleParamEditor(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ if (!this.localEditorKey) return;
+
+ const config = this.ensureBlockConfiguration();
+ const descriptor = (config['llmDescriptor'] ??= {});
+ const value = this.localEditorValue.trim();
+ descriptor[this.localEditorKey] = value;
+
+ if (this.localEditorKey === 'provider') {
+ this.provider = value || null;
+ this.model = null;
+ descriptor['model'] = '';
+ }
+ if (this.localEditorKey === 'model') {
+ this.model = value || null;
+ }
+ if (this.localEditorKey === 'name') {
+ const nameValue = value.slice(0, 20);
+ config['name'] = nameValue;
+ this.name = nameValue || this.name;
+ }
+
+ this.refreshValidationState();
+ this.markFlowDirty();
+ this.localEditorOpen = false;
+ this.localEditorKey = null;
+ this.localEditorOptions = [];
+ this.localEditorLoading = false;
+ }
+
+ async openActionEditor(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ const result = await this.settingsDialog.open({
+ title: `Action for "${this.name}"`,
+ fields: [
+ {
+ key: 'actionDescription',
+ label: 'Action description',
+ type: 'textarea',
+ rows: 8,
+ placeholder: 'Describe the human task...'
+ }
+ ],
+ initial: { actionDescription: this.actionDescription ?? '' }
+ });
+ if (!result) return;
+
+ const config = this.ensureBlockConfiguration();
+ config['actionDescription'] = String(result['actionDescription'] ?? '');
+ this.actionDescription = config['actionDescription'] || '';
+ this.refreshValidationState();
+ this.markFlowDirty();
+ }
+
+ openNameEditor(event?: Event) {
+ this.openSimpleParamEditor('name', event);
+ }
+
+ private ensureBlockConfiguration(): Record
{
+ if (!this.data?.data) {
+ this.data.data = {};
+ }
+ if (!this.data.data.specificConfiguration) {
+ this.data.data.specificConfiguration = {};
+ }
+ return this.data.data.specificConfiguration;
+ }
+
+ private markFlowDirty() {
+ const flow = this.editorState.currentFlow();
+ if (!flow) return;
+ this.editorState.updateData(flow.data);
+ }
+
+ private async loadSimpleEditorOptions() {
+ const key = this.localEditorKey;
+ const blockType = this.blockType;
+ if (!key || !blockType) {
+ this.localEditorLoading = false;
+ return;
+ }
+
+ const retrieverKey = key === 'provider' ? 'providers' : 'models';
+ const context = key === 'model' && this.provider
+ ? { provider: this.provider }
+ : undefined;
+
+ try {
+ const options = await firstValueFrom(
+ this.fieldRetreiver.retrieveValues(blockType, retrieverKey, context)
+ );
+ this.localEditorOptions = options ?? [];
+ } catch {
+ this.localEditorOptions = [];
+ } finally {
+ this.localEditorLoading = false;
+ }
+ }
+
+ private get blockType(): string | null {
+ const typeName = this.data?.data?.typeName;
+ return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
+ }
+
+ private refreshValidationState() {
+ const config = this.data?.data?.specificConfiguration ?? {};
+ const requiredFields: Array<{ path: string; label: string }> = [
+ { path: 'actionDescription', label: 'Action description' },
+ { path: 'llmDescriptor.provider', label: 'Provider' },
+ { path: 'llmDescriptor.model', label: 'Model' }
+ ];
+
+ this.missingRequiredParams = requiredFields
+ .filter((field) => this.isMissingValue(this.getByPath(config, field.path)))
+ .map((field) => field.label);
+ }
+
+ private getByPath(source: Record, path: string): unknown {
+ return path.split('.').reduce((acc, key) => {
+ if (acc == null || typeof acc !== 'object') return undefined;
+ return (acc as Record)[key];
+ }, source);
+ }
+
+ private isMissingValue(value: unknown): boolean {
+ if (value == null) return true;
+ if (typeof value === 'string') return value.trim().length === 0;
+ return false;
+ }
+}
diff --git a/src/app/shared/nodes/input/input-node.component.ts b/src/app/shared/nodes/input/input-node.component.ts
index 3017b9a..430f97a 100644
--- a/src/app/shared/nodes/input/input-node.component.ts
+++ b/src/app/shared/nodes/input/input-node.component.ts
@@ -3,7 +3,7 @@ import {
Input,
HostBinding,
} from "@angular/core";
-import { CommonModule, KeyValue } from "@angular/common";
+import { CommonModule } from "@angular/common";
import { ReteModule } from "rete-angular-plugin/21";
import { ClassicPreset } from "rete";
diff --git a/src/app/shared/nodes/llm/llm.css b/src/app/shared/nodes/llm/llm.css
index 4852ae0..ca440cc 100644
--- a/src/app/shared/nodes/llm/llm.css
+++ b/src/app/shared/nodes/llm/llm.css
@@ -1,4 +1,5 @@
.llm-node {
+ position: relative;
width: 280px;
border-radius: 16px;
border: 1px solid #dbe2ea;
@@ -44,6 +45,27 @@
line-height: 1.2;
}
+.llm-subtitle-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.llm-warning {
+ width: 24px;
+ height: 24px;
+ border-radius: 999px;
+ border: 1px solid #fecaca;
+ background: #dc2626;
+ color: #fff7ed;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ box-shadow: 0 0 0 2px rgba(127, 29, 29, 0.18);
+}
+
.llm-title {
font-size: 13px;
font-weight: 700;
@@ -59,6 +81,66 @@
overflow: hidden;
}
+.llm-name-edit-btn {
+ border: none;
+ background: rgba(255, 255, 255, 0.2);
+ color: #eff6ff;
+ border-radius: 999px;
+ width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background-color 0.15s ease;
+}
+
+.llm-name-edit-btn:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.llm-warning-wrap {
+ position: relative;
+ margin-left: auto;
+}
+
+.llm-warning-tooltip {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ min-width: 180px;
+ max-width: 240px;
+ border: 1px solid #fecaca;
+ border-radius: 8px;
+ background: #fff1f2;
+ color: #7f1d1d;
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
+ padding: 8px;
+ z-index: 80;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-2px);
+ transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
+}
+
+.llm-warning-wrap:hover .llm-warning-tooltip {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.llm-warning-title {
+ font-size: 11px;
+ font-weight: 700;
+ margin-bottom: 4px;
+}
+
+.llm-warning-item {
+ font-size: 11px;
+}
+
+
.llm-body {
padding: 12px;
display: grid;
@@ -319,6 +401,7 @@
}
.llm-modal-field input,
+.llm-modal-field select,
.llm-modal-field textarea {
border: 1px solid #cbd5e1;
border-radius: 8px;
diff --git a/src/app/shared/nodes/llm/llm.html b/src/app/shared/nodes/llm/llm.html
index ff51385..e44dd0d 100644
--- a/src/app/shared/nodes/llm/llm.html
+++ b/src/app/shared/nodes/llm/llm.html
@@ -5,8 +5,26 @@
+ @if (missingRequiredParams.length) {
+