Update assistant session UI and fix structural node reconnection

This commit is contained in:
Lucio Lelii 2026-03-11 12:44:31 +01:00
parent 4c6a50f9a2
commit 78d8527a85
15 changed files with 966 additions and 403 deletions

View File

@ -24,6 +24,7 @@
.flow-editor-assistant {
display: flex;
position: relative;
flex: 0 0 380px;
width: 380px;
min-width: 320px;
@ -32,6 +33,71 @@
min-height: 0;
background: #fff;
overflow: hidden;
transition: width 180ms ease, flex-basis 180ms ease, min-width 180ms ease;
}
.flow-editor-assistant-collapsed {
flex: 0 0 72px;
width: 72px;
min-width: 72px;
}
.flow-editor-assistant-toggle {
position: absolute;
top: 12px;
right: 12px;
z-index: 4;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
color: #0f172a;
font-size: 12px;
font-weight: 700;
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.1);
}
.flow-editor-assistant-collapsed .flow-editor-assistant-toggle {
right: 50%;
transform: translateX(50%);
padding: 10px;
}
.flow-editor-assistant-collapsed .flow-editor-assistant-toggle span {
display: none;
}
.flow-editor-assistant-rail {
display: flex;
flex: 1 1 auto;
min-height: 0;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
width: 100%;
background:
radial-gradient(circle at top, rgba(251, 191, 36, 0.22), transparent 28%),
linear-gradient(180deg, #fffdf7 0%, #f8fafc 100%);
color: #0f172a;
}
.flow-editor-assistant-rail-icon,
.flow-editor-assistant-rail-wand {
font-size: 18px;
color: #0f766e;
}
.flow-editor-assistant-rail-text {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.flow-editor-empty {
@ -71,4 +137,10 @@
min-width: 0;
height: 42vh;
}
.flow-editor-assistant-collapsed {
width: 72px;
min-width: 72px;
height: 42vh;
}
}

View File

@ -26,5 +26,28 @@
}
</main>
@if (assistantEnabled) {
<section class="flow-editor-assistant" [class.flow-editor-assistant-collapsed]="!assistantOpen()">
<button
type="button"
class="flow-editor-assistant-toggle"
(click)="toggleAssistant()"
[attr.aria-label]="assistantOpen() ? 'Collapse assistant' : 'Expand assistant'">
<i class="bi" [class.bi-layout-sidebar-inset-reverse]="assistantOpen()" [class.bi-stars]="!assistantOpen()"></i>
<span>{{ assistantOpen() ? 'Hide' : 'AI' }}</span>
</button>
@if (assistantOpen()) {
<app-flow-assistant></app-flow-assistant>
} @else {
<div class="flow-editor-assistant-rail">
<i class="bi bi-stars flow-editor-assistant-rail-icon" aria-hidden="true"></i>
<div class="flow-editor-assistant-rail-text">AI</div>
<i class="bi bi-magic flow-editor-assistant-rail-wand" aria-hidden="true"></i>
</div>
}
</section>
}
</div>
</div>

View File

@ -1,13 +1,14 @@
import { Component, inject} from '@angular/core';
import { Component, inject, signal } from '@angular/core';
import { environment } from '@environment';
import { EditorSidebar } from "@pages/main/editor-sidebar/editor-sidebar";
import { EditorStateHolder } from '@stores/flow-editor';
import { FlowAssistant } from '@shared/flow-assistant/flow-assistant';
import { TitleToolbar } from "@shared/title-toolbar/title-toolbar";
import { ReteEditor } from "@shared/rete-editor/rete-editor";
@Component({
selector: 'app-flow-editor',
imports: [EditorSidebar, TitleToolbar, ReteEditor],
imports: [EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant],
templateUrl: './flow-editor.html',
styleUrl: './flow-editor.css',
})
@ -16,6 +17,11 @@ export class FlowEditor {
private editorState: EditorStateHolder = inject(EditorStateHolder);
assistantEnabled = environment.assistantEnabled;
assistantOpen = signal(true);
flow = this.editorState.currentFlow;
toggleAssistant() {
this.assistantOpen.update((value) => !value);
}
}

View File

@ -13,20 +13,6 @@ export type AssistantValidationIssue = {
path?: string;
};
export type AssistantFlowResponse = {
flow: AssistantDraftPayload;
valid: boolean;
validationErrors: AssistantValidationIssue[];
warnings: string[];
assistantRationale?: string;
repairAttempts?: number;
};
export type AssistantExplainResponse = {
explanation: string;
warnings: string[];
};
export type AssistantChatMessage = {
id: string;
role: 'user' | 'assistant' | 'system';
@ -43,3 +29,43 @@ export type AssistantEditorDraft = {
data: FlowData;
status: FlowStatus;
};
export type AssistantConfig = {
provider?: string;
defaultModel: string;
availableModelsRetrieverUrl: string;
};
export type AssistantCallStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED';
export type AssistantCallPhase =
| 'queued'
| 'routing'
| 'planning'
| 'configuring_blocks'
| 'connecting_blocks'
| 'validating'
| 'fixing'
| 'explaining'
| 'completed'
| 'failed';
export type AssistantCallState = {
id: string;
sessionId: string;
status: AssistantCallStatus;
phase: AssistantCallPhase;
progressMessage?: string;
intent?: AssistantIntent | null;
errorMessage?: string;
};
export type AssistantSessionState = {
id: string;
owner?: string;
selectedModel: string;
messages: AssistantChatMessage[];
currentDraftFlow: AssistantDraftPayload | null;
lastValidationErrors: AssistantValidationIssue[];
lastCallId: string | null;
};

View File

@ -1,38 +1,24 @@
import {
AssistantDraftPayload,
AssistantExplainResponse,
AssistantFlowResponse,
AssistantValidationIssue
AssistantCallState,
AssistantConfig,
AssistantSessionState
} from '@models/assistant';
import { Observable } from 'rxjs';
export abstract class AssistantCallServiceBase {
abstract listModels(): Observable<string[]>;
abstract getConfig(): Observable<AssistantConfig>;
abstract createDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
}): Observable<AssistantFlowResponse>;
abstract listModels(retrieverUrl: string): Observable<string[]>;
abstract refineDraft(request: {
userPrompt: string;
abstract createSession(request: {
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
}): Observable<AssistantFlowResponse>;
}): Observable<AssistantSessionState>;
abstract fixDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
validationErrors?: AssistantValidationIssue[];
}): Observable<AssistantFlowResponse>;
abstract sendMessage(sessionId: string, request: {
message: string;
}): Observable<{ callId: string }>;
abstract explainDraft(request: {
userPrompt: string;
model: string;
flow: AssistantDraftPayload;
}): Observable<AssistantExplainResponse>;
abstract getCall(callId: string): Observable<AssistantCallState>;
abstract getSession(sessionId: string): Observable<AssistantSessionState>;
}

View File

@ -1,93 +1,208 @@
import {
AssistantCallPhase,
AssistantCallState,
AssistantChatMessage,
AssistantConfig,
AssistantDraftPayload,
AssistantExplainResponse,
AssistantFlowResponse,
AssistantSessionState,
AssistantValidationIssue
} from '@models/assistant';
import { Observable, of } from 'rxjs';
import { AssistantCallServiceBase } from './assistant-call.base';
export class AssistantCallServiceFake extends AssistantCallServiceBase {
override listModels(): Observable<string[]> {
return of([
'llama3.1:8b',
'qwen2.5:7b',
'mistral:7b'
]);
}
type FakeCallRecord = {
id: string;
sessionId: string;
content: string;
phaseIndex: number;
phases: AssistantCallPhase[];
completed: boolean;
failed: boolean;
};
override createDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
}): Observable<AssistantFlowResponse> {
const invalid = /invalid|broken|errore|error/i.test(request.userPrompt);
const flow = buildTicketFlow(request.model);
export class AssistantCallServiceFake extends AssistantCallServiceBase {
private readonly models = ['llama3.1:8b', 'qwen2.5:7b', 'mistral:7b'];
private readonly sessions = new Map<string, AssistantSessionState>();
private readonly calls = new Map<string, FakeCallRecord>();
override getConfig(): Observable<AssistantConfig> {
return of({
flow: {
name: /classif/i.test(request.userPrompt) ? 'Ticket classification with urgent review' : 'Assistant generated flow',
description: `Draft generated from prompt: ${request.userPrompt}`,
flow
},
valid: !invalid,
validationErrors: invalid ? [{ message: 'The draft contains unresolved validation issues.' }] : [],
warnings: ['Fake assistant response'],
assistantRationale: 'Uses an input block, an LLM classifier, a conditional router, and a human review branch.',
repairAttempts: invalid ? 1 : 0
defaultModel: this.models[0],
availableModelsRetrieverUrl: '/fake/assistant/models'
});
}
override refineDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
}): Observable<AssistantFlowResponse> {
const next = structuredClone(request.flow);
next.name = request.flow.name || 'Refined flow';
next.description = `${request.flow.description ?? 'Refined draft'}\n\nRefinement: ${request.userPrompt}`.trim();
override listModels(_retrieverUrl: string): Observable<string[]> {
return of(this.models);
}
if (/review|approval|human/i.test(request.userPrompt)) {
addHumanReviewTail(next.flow);
override createSession(request: {
model: string;
}): Observable<AssistantSessionState> {
const session: AssistantSessionState = {
id: crypto.randomUUID(),
selectedModel: request.model,
messages: [
{
id: crypto.randomUUID(),
role: 'system',
content: 'Select a model, then ask me to create, refine, fix, or explain a workflow.'
}
],
currentDraftFlow: null,
lastValidationErrors: [],
lastCallId: null
};
this.sessions.set(session.id, session);
return of(structuredClone(session));
}
override sendMessage(sessionId: string, request: {
message: string;
}): Observable<{ callId: string }> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Assistant session ${sessionId} not found`);
}
session.messages = [
...session.messages,
{
id: crypto.randomUUID(),
role: 'user',
content: request.message
}
];
const callId = crypto.randomUUID();
const phases = this.buildPhases(request.message);
this.calls.set(callId, {
id: callId,
sessionId,
content: request.message,
phaseIndex: 0,
phases,
completed: false,
failed: false
});
session.lastCallId = callId;
return of({ callId });
}
override getCall(callId: string): Observable<AssistantCallState> {
const call = this.calls.get(callId);
if (!call) {
throw new Error(`Assistant call ${callId} not found`);
}
if (!call.completed && !call.failed) {
if (call.phaseIndex < call.phases.length - 1) {
call.phaseIndex += 1;
} else {
call.completed = true;
this.applyCallResult(call);
}
}
const phase = call.completed
? 'completed'
: call.failed
? 'failed'
: call.phases[call.phaseIndex];
return of({
flow: next,
valid: true,
validationErrors: [],
warnings: ['Fake assistant response'],
assistantRationale: 'Applied the requested refinement incrementally without replacing the whole graph.',
repairAttempts: 0
id: call.id,
sessionId: call.sessionId,
status: call.failed
? 'FAILED'
: call.completed
? 'COMPLETED'
: call.phaseIndex === 0
? 'QUEUED'
: 'RUNNING',
phase,
errorMessage: call.failed ? 'Fake assistant call failed.' : undefined
});
}
override fixDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
validationErrors?: AssistantValidationIssue[];
}): Observable<AssistantFlowResponse> {
const next = structuredClone(request.flow);
return of({
flow: next,
valid: true,
validationErrors: [],
warnings: ['Fake assistant response'],
assistantRationale: 'Recomputed the draft and cleared the reported validation problems.',
repairAttempts: 1
});
override getSession(sessionId: string): Observable<AssistantSessionState> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Assistant session ${sessionId} not found`);
}
return of(structuredClone(session));
}
override explainDraft(request: {
userPrompt: string;
model: string;
flow: AssistantDraftPayload;
}): Observable<AssistantExplainResponse> {
return of({
explanation: `This flow "${request.flow.name}" receives a ticket, classifies urgency with an LLM, routes urgent cases to a human reviewer, and auto-forwards non-urgent cases.`,
warnings: ['Fake assistant response']
});
private buildPhases(content: string): AssistantCallPhase[] {
const normalized = content.toLowerCase();
if (/explain|what does|why/.test(normalized)) {
return ['routing', 'explaining'];
}
if (/fix|repair|invalid|error|bug/.test(normalized)) {
return ['routing', 'planning', 'configuring_blocks', 'connecting_blocks', 'validating', 'fixing'];
}
return ['routing', 'planning', 'configuring_blocks', 'connecting_blocks', 'validating'];
}
private applyCallResult(call: FakeCallRecord) {
const session = this.sessions.get(call.sessionId);
if (!session) return;
const content = call.content.toLowerCase();
if (/explain|what does|why/.test(content)) {
session.messages = [
...session.messages,
{
id: crypto.randomUUID(),
role: 'assistant',
content: session.currentDraftFlow
? `This flow "${session.currentDraftFlow.name}" receives input, classifies urgency, routes urgent cases to a human, and completes the non-urgent path automatically.`
: 'There is no draft flow in the session yet.'
}
];
return;
}
let draft = session.currentDraftFlow;
let validationErrors: AssistantValidationIssue[] = [];
let assistantMessage = 'I updated the workflow based on your request.';
if (!draft || /create|new flow|from scratch/.test(content)) {
draft = {
name: 'Ticket classification with urgent review',
description: `Draft generated from prompt: ${call.content}`,
flow: buildTicketFlow(session.selectedModel)
};
assistantMessage = 'I created a new workflow draft.';
} else if (/review|approval|human/.test(content)) {
draft = structuredClone(draft);
addHumanReviewTail(draft.flow);
assistantMessage = 'I updated the current workflow based on your request.';
} else if (/fix|repair|invalid|error|bug/.test(content)) {
draft = structuredClone(draft);
validationErrors = [];
assistantMessage = 'I fixed the current workflow.';
}
if (/invalid|broken/.test(content)) {
validationErrors = [{ message: 'The draft contains unresolved validation issues.' }];
assistantMessage = 'I created an initial draft, but it still needs corrections.';
}
session.currentDraftFlow = draft;
session.lastValidationErrors = validationErrors;
session.messages = [
...session.messages,
{
id: crypto.randomUUID(),
role: 'assistant',
content: `${assistantMessage} ${validationErrors.length ? 'The draft still has validation errors.' : 'The draft is valid.'}`.trim(),
warnings: ['Fake assistant response'],
validationErrors
}
];
}
}

View File

@ -1,9 +1,14 @@
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import {
AssistantCallPhase,
AssistantCallState,
AssistantCallStatus,
AssistantChatMessage,
AssistantConfig,
AssistantDraftPayload,
AssistantExplainResponse,
AssistantFlowResponse,
AssistantIntent,
AssistantSessionState,
AssistantValidationIssue
} from '@models/assistant';
import { environment } from '@environment';
@ -13,87 +18,142 @@ import { AssistantCallServiceBase } from './assistant-call.base';
export class AssistantCallService extends AssistantCallServiceBase {
private readonly http = inject(HttpClient);
override listModels(): Observable<string[]> {
override getConfig(): Observable<AssistantConfig> {
return this.http
.get<unknown[]>(`${environment.apiUrl}/retriever/LLM/models`, {
params: { provider: 'InternalOllama' }
})
.pipe(
map((raw) => (Array.isArray(raw) ? raw : []).map((item) => String(item)))
);
.get<unknown>(`${environment.apiUrl}/assistant/config`)
.pipe(map((raw) => mapAssistantConfig(raw)));
}
override createDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
}): Observable<AssistantFlowResponse> {
override listModels(retrieverUrl: string): Observable<string[]> {
const resolvedUrl = resolveAssistantUrl(retrieverUrl);
return this.http
.post<unknown>(`${environment.apiUrl}/assistant/flows/draft`, request)
.pipe(map((raw) => mapAssistantFlowResponse(raw)));
.get<unknown>(resolvedUrl)
.pipe(map((raw) => mapModelList(raw)));
}
override refineDraft(request: {
userPrompt: string;
override createSession(request: {
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
}): Observable<AssistantFlowResponse> {
}): Observable<AssistantSessionState> {
return this.http
.post<unknown>(`${environment.apiUrl}/assistant/flows/refine`, request)
.pipe(map((raw) => mapAssistantFlowResponse(raw)));
.post<unknown>(`${environment.apiUrl}/assistant/sessions`, request)
.pipe(map((raw) => mapAssistantSessionState(raw)));
}
override fixDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
validationErrors?: AssistantValidationIssue[];
}): Observable<AssistantFlowResponse> {
override sendMessage(sessionId: string, request: {
message: string;
}): Observable<{ callId: string }> {
const encodedId = encodeURIComponent(sessionId);
return this.http
.post<unknown>(`${environment.apiUrl}/assistant/flows/fix`, request)
.pipe(map((raw) => mapAssistantFlowResponse(raw)));
.post<unknown>(`${environment.apiUrl}/assistant/sessions/${encodedId}/messages`, request)
.pipe(map((raw) => mapSendMessageResponse(raw)));
}
override explainDraft(request: {
userPrompt: string;
model: string;
flow: AssistantDraftPayload;
}): Observable<AssistantExplainResponse> {
override getCall(callId: string): Observable<AssistantCallState> {
const encodedId = encodeURIComponent(callId);
return this.http
.post<unknown>(`${environment.apiUrl}/assistant/flows/explain`, request)
.pipe(map((raw) => mapAssistantExplainResponse(raw)));
.get<unknown>(`${environment.apiUrl}/assistant/calls/${encodedId}`)
.pipe(map((raw) => mapAssistantCallState(raw)));
}
override getSession(sessionId: string): Observable<AssistantSessionState> {
const encodedId = encodeURIComponent(sessionId);
return this.http
.get<unknown>(`${environment.apiUrl}/assistant/sessions/${encodedId}`)
.pipe(map((raw) => mapAssistantSessionState(raw)));
}
}
function mapAssistantFlowResponse(raw: unknown): AssistantFlowResponse {
function mapAssistantConfig(raw: unknown): AssistantConfig {
const value = (raw ?? {}) as Record<string, unknown>;
const payload = mapAssistantDraftPayload(value['flow']);
return {
flow: payload,
valid: value['valid'] !== false,
validationErrors: mapValidationIssues(value['validationErrors']),
warnings: Array.isArray(value['warnings']) ? value['warnings'].map((item) => String(item)) : [],
assistantRationale: typeof value['assistantRationale'] === 'string' ? value['assistantRationale'] : undefined,
repairAttempts: typeof value['repairAttempts'] === 'number' ? value['repairAttempts'] : undefined
provider: typeof value['provider'] === 'string' ? value['provider'] : undefined,
defaultModel: String(value['defaultModel'] ?? ''),
availableModelsRetrieverUrl: String(value['availableModelsRetrieverUrl'] ?? '')
};
}
function mapAssistantExplainResponse(raw: unknown): AssistantExplainResponse {
function mapModelList(raw: unknown): string[] {
const candidate = Array.isArray(raw)
? raw
: raw && typeof raw === 'object'
? (((raw as Record<string, unknown>)['values']
?? (raw as Record<string, unknown>)['items']
?? (raw as Record<string, unknown>)['data']
?? (raw as Record<string, unknown>)['result']
?? []) as unknown[])
: [];
if (!Array.isArray(candidate)) return [];
return candidate.map((item) => {
if (typeof item === 'string') return item;
const value = (item ?? {}) as Record<string, unknown>;
return String(value['name'] ?? value['model'] ?? value['value'] ?? '');
}).filter((item) => item.length > 0);
}
function mapSendMessageResponse(raw: unknown): { callId: string } {
const value = (raw ?? {}) as Record<string, unknown>;
return {
explanation: typeof value['explanation'] === 'string'
? value['explanation']
: typeof value['assistantRationale'] === 'string'
? value['assistantRationale']
: '',
warnings: Array.isArray(value['warnings']) ? value['warnings'].map((item) => String(item)) : []
callId: String(value['callId'] ?? value['id'] ?? '')
};
}
function mapAssistantDraftPayload(raw: unknown): AssistantDraftPayload {
function mapAssistantCallState(raw: unknown): AssistantCallState {
const value = (raw ?? {}) as Record<string, unknown>;
const status = mapCallStatus(value['status']);
return {
id: String(value['id'] ?? value['callId'] ?? ''),
sessionId: String(value['sessionId'] ?? ''),
status,
phase: mapCallPhase(value['phase']),
progressMessage: typeof value['progressMessage'] === 'string' ? value['progressMessage'] : undefined,
intent: mapIntent(value['intent']),
errorMessage: typeof value['errorMessage'] === 'string'
? value['errorMessage']
: typeof value['message'] === 'string' && status === 'FAILED'
? value['message']
: undefined
};
}
function mapAssistantSessionState(raw: unknown): AssistantSessionState {
const value = (raw ?? {}) as Record<string, unknown>;
return {
id: String(value['id'] ?? value['sessionId'] ?? ''),
owner: typeof value['owner'] === 'string' ? value['owner'] : undefined,
selectedModel: String(value['selectedModel'] ?? value['model'] ?? ''),
messages: mapAssistantMessages(value['messages']),
currentDraftFlow: mapAssistantDraftPayload(
value['currentDraftFlow'] ?? value['currentFlow'] ?? value['currentDraft'] ?? value['flow']
),
lastValidationErrors: mapValidationIssues(value['lastValidationErrors'] ?? value['validationErrors']),
lastCallId: value['lastCallId'] == null ? null : String(value['lastCallId'])
};
}
function mapAssistantMessages(raw: unknown): AssistantChatMessage[] {
if (!Array.isArray(raw)) return [];
return raw.map((item) => {
const value = (item ?? {}) as Record<string, unknown>;
const normalizedRole = typeof value['role'] === 'string' ? value['role'].toLowerCase() : 'user';
const role = normalizedRole === 'assistant' || normalizedRole === 'system' ? normalizedRole : 'user';
return {
id: String(value['id'] ?? crypto.randomUUID()),
role,
content: String(value['content'] ?? value['message'] ?? ''),
warnings: Array.isArray(value['warnings']) ? value['warnings'].map((warning) => String(warning)) : [],
validationErrors: mapValidationIssues(value['validationErrors'])
} as AssistantChatMessage;
});
}
function mapAssistantDraftPayload(raw: unknown): AssistantDraftPayload | null {
if (!raw || typeof raw !== 'object') return null;
const value = raw as Record<string, unknown>;
const flow = (value['flow'] ?? {}) as Record<string, unknown>;
return {
name: String(value['name'] ?? 'Assistant Draft'),
@ -120,3 +180,55 @@ function mapValidationIssues(raw: unknown): AssistantValidationIssue[] {
};
});
}
function mapCallStatus(raw: unknown): AssistantCallStatus {
const normalized = typeof raw === 'string' ? raw.toUpperCase() : '';
if (normalized === 'QUEUED' || normalized === 'RUNNING' || normalized === 'FAILED') return normalized;
return 'COMPLETED';
}
function mapCallPhase(raw: unknown): AssistantCallPhase {
const normalized = typeof raw === 'string' ? raw.toLowerCase() : '';
switch (normalized) {
case 'queued':
case 'routing':
case 'planning':
case 'configuring_blocks':
case 'connecting_blocks':
case 'validating':
case 'fixing':
case 'explaining':
case 'completed':
case 'failed':
return normalized;
default:
return 'queued';
}
}
function mapIntent(raw: unknown): AssistantIntent | null {
const normalized = typeof raw === 'string' ? raw.toLowerCase() : '';
switch (normalized) {
case 'draft':
case 'refine':
case 'fix':
case 'explain':
return normalized;
default:
return null;
}
}
function resolveAssistantUrl(url: string): string {
if (!url) return url;
if (/^https?:\/\//i.test(url)) return url;
const apiBase = environment.apiUrl;
if (/^https?:\/\//i.test(apiBase)) {
return new URL(url, `${apiBase.replace(/\/+$/, '')}/`).toString();
}
const normalizedBase = apiBase.startsWith('/') ? apiBase : `/${apiBase}`;
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return new URL(url, `${origin}${normalizedBase.replace(/\/+$/, '')}/`).toString();
}

View File

@ -1,10 +1,4 @@
import { Injectable } from '@angular/core';
import {
AssistantDraftPayload,
AssistantExplainResponse,
AssistantFlowResponse,
AssistantValidationIssue
} from '@models/assistant';
import { environment } from '@environment';
import { AssistantCallServiceBase } from './assistant-call.base';
@ -14,42 +8,27 @@ import { AssistantCallServiceBase } from './assistant-call.base';
export class AssistantService {
private readonly assistantCall: AssistantCallServiceBase = new environment.assistantCallService();
listModels() {
return this.assistantCall.listModels();
getConfig() {
return this.assistantCall.getConfig();
}
createDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
}) {
return this.assistantCall.createDraft(request);
listModels(retrieverUrl: string) {
return this.assistantCall.listModels(retrieverUrl);
}
refineDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
}) {
return this.assistantCall.refineDraft(request);
createSession(request: { model: string }) {
return this.assistantCall.createSession(request);
}
fixDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
validationErrors?: AssistantValidationIssue[];
}) {
return this.assistantCall.fixDraft(request);
sendMessage(sessionId: string, request: { message: string }) {
return this.assistantCall.sendMessage(sessionId, request);
}
explainDraft(request: {
userPrompt: string;
model: string;
flow: AssistantDraftPayload;
}) {
return this.assistantCall.explainDraft(request);
getCall(callId: string) {
return this.assistantCall.getCall(callId);
}
getSession(sessionId: string) {
return this.assistantCall.getSession(sessionId);
}
}

View File

@ -25,8 +25,7 @@
.assistant-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
justify-content: flex-start;
}
.assistant-eyebrow {
@ -56,6 +55,11 @@
text-transform: uppercase;
}
.assistant-intent-row {
display: flex;
justify-content: flex-start;
}
.assistant-card {
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.24);
@ -64,6 +68,77 @@
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
}
.assistant-busy-card {
border-color: rgba(15, 118, 110, 0.28);
background:
linear-gradient(180deg, rgba(240, 253, 250, 0.96), rgba(248, 250, 252, 0.96));
}
.assistant-busy-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.assistant-busy-spinner {
width: 18px;
height: 18px;
flex: 0 0 auto;
margin-top: 2px;
border-radius: 999px;
border: 2px solid rgba(15, 118, 110, 0.18);
border-top-color: #0f766e;
animation: assistant-spin 0.85s linear infinite;
}
.assistant-busy-title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.assistant-busy-copy {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.45;
color: #475569;
}
.assistant-progress {
display: grid;
gap: 6px;
margin-top: 12px;
}
.assistant-progress-step {
position: relative;
display: block;
padding-left: 18px;
font-size: 12px;
color: #64748b;
}
.assistant-progress-step::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 8px;
height: 8px;
border-radius: 999px;
background: #cbd5e1;
}
.assistant-progress-step-active {
color: #0f172a;
font-weight: 600;
}
.assistant-progress-step-active::before {
background: #0f766e;
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
}
.assistant-copy,
.assistant-meta,
.assistant-error,
@ -275,6 +350,12 @@
filter: brightness(1.05);
}
@keyframes assistant-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 1100px) {
.assistant-shell {
border-left: 0;

View File

@ -5,11 +5,35 @@
<p class="assistant-eyebrow">Workflow Copilot</p>
<h2 class="assistant-title">Assistant</h2>
</div>
@if (currentIntent()) {
<span class="assistant-intent">{{ currentIntent() }}</span>
}
</header>
@if (activePhaseLabel()) {
<div class="assistant-intent-row">
<span class="assistant-intent">{{ activePhaseLabel() }}</span>
</div>
}
@if (assistantBusy()) {
<section class="assistant-card assistant-busy-card">
<div class="assistant-busy-row">
<span class="assistant-busy-spinner" aria-hidden="true"></span>
<div>
<div class="assistant-busy-title">{{ busyHeadline() }}</div>
<p class="assistant-busy-copy">{{ busyDetail() }}</p>
</div>
</div>
@if (activePhase() !== 'explaining') {
<div class="assistant-progress">
@for (step of progressSteps; track step.phase; let index = $index) {
<span class="assistant-progress-step" [class.assistant-progress-step-active]="index <= activeProgressIndex()">
{{ step.label }}
</span>
}
</div>
}
</section>
}
<section class="assistant-card assistant-model-card">
<div class="assistant-section-head">
<div>
@ -72,7 +96,7 @@
</section>
<section class="assistant-thread">
@for (message of messages(); track message.id) {
@for (message of displayedMessages(); track message.id) {
<article class="assistant-message" [class.assistant-message-user]="message.role === 'user'" [class.assistant-message-system]="message.role === 'system'">
<div class="assistant-message-role">{{ message.role }}</div>
<p class="assistant-message-body">{{ message.content }}</p>
@ -98,7 +122,7 @@
@if (assistantBusy()) {
<article class="assistant-message assistant-message-system">
<div class="assistant-message-role">assistant</div>
<p class="assistant-message-body">Working on the workflow...</p>
<p class="assistant-message-body">{{ busyHeadline() }}</p>
</article>
}
</section>

View File

@ -1,13 +1,14 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { environment } from '@environment';
import {
AssistantCallPhase,
AssistantCallState,
AssistantChatMessage,
AssistantConfig,
AssistantDraftPayload,
AssistantEditorDraft,
AssistantFlowResponse,
AssistantIntent,
AssistantValidationIssue
AssistantSessionState
} from '@models/assistant';
import { Flow } from '@models/flow';
import { AssistantService } from '@services/assistant/assistant';
@ -21,32 +22,81 @@ import { finalize, take } from 'rxjs';
templateUrl: './flow-assistant.html',
styleUrl: './flow-assistant.css'
})
export class FlowAssistant implements OnInit {
export class FlowAssistant implements OnInit, OnDestroy {
private readonly assistant = inject(AssistantService);
private readonly editorState = inject(EditorStateHolder);
private readonly authorization = inject(Authorization);
private pollTick: ReturnType<typeof setInterval> | null = null;
readonly assistantConfig = signal<AssistantConfig | null>(null);
readonly sessionState = signal<AssistantSessionState | null>(null);
readonly currentCall = signal<AssistantCallState | null>(null);
readonly localMessages = signal<AssistantChatMessage[]>([]);
readonly models = signal<string[]>([]);
readonly modelsLoading = signal(false);
readonly modelsError = signal<string | null>(null);
readonly selectedModel = signal('');
readonly modelPickerOpen = signal(true);
readonly assistantBusy = signal(false);
readonly currentIntent = signal<AssistantIntent | null>(null);
readonly lastValidationErrors = signal<AssistantValidationIssue[]>([]);
readonly sessionLoading = signal(false);
readonly prompt = signal('');
readonly selectedModel = signal('');
readonly modelPickerOpen = signal(false);
readonly quickPromptsOpen = signal(true);
readonly messages = signal<AssistantChatMessage[]>([
{
id: crypto.randomUUID(),
role: 'system',
content: 'Select a model, then ask me to create, refine, fix, or explain a workflow.'
}
]);
readonly initialSystemMessage: AssistantChatMessage = {
id: 'assistant-system-welcome',
role: 'system',
content: 'Select a model, then ask me to create, refine, fix, or explain a workflow.'
};
readonly displayedMessages = computed(() => {
const baseMessages = this.sessionState()?.messages?.length
? this.sessionState()!.messages
: [this.initialSystemMessage];
return [...baseMessages, ...this.localMessages()];
});
readonly assistantBusy = computed(() => {
const status = this.currentCall()?.status;
return this.sessionLoading() || status === 'QUEUED' || status === 'RUNNING';
});
readonly activePhase = computed<AssistantCallPhase | null>(() => {
const call = this.currentCall();
return call ? call.phase : null;
});
readonly activePhaseLabel = computed(() => {
const phase = this.activePhase();
return phase ? this.phaseText(phase) : null;
});
readonly callProgressMessage = computed(() => this.currentCall()?.progressMessage ?? '');
readonly busyHeadline = computed(() => {
const phase = this.activePhase();
if (!phase) {
return this.sessionLoading() ? 'Preparing assistant session...' : '';
}
return this.phaseText(phase);
});
readonly busyDetail = computed(() => {
if (this.sessionLoading()) {
return 'Loading assistant configuration, models, and chat session...';
}
if (!this.currentCall()) return '';
return this.callProgressMessage() || 'The backend may perform multiple internal steps before returning the updated conversation and flow.';
});
readonly progressSteps = [
{ phase: 'queued', label: 'Queued...' },
{ phase: 'routing', label: 'Routing your request...' },
{ phase: 'planning', label: 'Planning workflow blocks...' },
{ phase: 'configuring_blocks', label: 'Configuring blocks...' },
{ phase: 'connecting_blocks', label: 'Connecting blocks...' },
{ phase: 'validating', label: 'Validating flow...' },
{ phase: 'fixing', label: 'Repairing invalid flow...' },
{ phase: 'explaining', label: 'Explaining current flow...' }
] as const;
readonly activeProgressIndex = computed(() => {
const phase = this.activePhase();
if (!phase) return -1;
return this.progressSteps.findIndex((step) => step.phase === phase);
});
readonly currentFlow = this.editorState.currentFlow;
readonly draftDirty = this.editorState.isDirty;
readonly currentDraft = computed(() => this.toAssistantDraft(this.currentFlow()));
readonly currentDraft = computed(() => this.sessionState()?.currentDraftFlow ?? null);
readonly starterPrompts = [
'Create a flow that classifies incoming tickets and sends urgent ones to a human',
'Modify the current flow to add a review step after the LLM block',
@ -55,11 +105,17 @@ export class FlowAssistant implements OnInit {
];
ngOnInit(): void {
this.loadModels();
this.bootstrapAssistant();
}
ngOnDestroy(): void {
this.stopPolling();
}
selectModel(model: string) {
if (!model || model === this.selectedModel()) return;
this.selectedModel.set(model);
void this.openSession(model);
}
useStarter(prompt: string) {
@ -75,178 +131,188 @@ export class FlowAssistant implements OnInit {
}
submitPrompt() {
const userPrompt = this.prompt().trim();
if (!userPrompt || this.assistantBusy() || !this.selectedModel()) return;
const content = this.prompt().trim();
const sessionId = this.sessionState()?.id;
if (!content || this.assistantBusy() || !sessionId) return;
this.prompt.set('');
this.pushMessage({
role: 'user',
content: userPrompt
});
const clarification = this.maybeClarify(userPrompt);
if (clarification) {
this.pushMessage({
role: 'assistant',
content: clarification
});
return;
}
const intent = this.determineIntent(userPrompt);
this.currentIntent.set(intent);
this.assistantBusy.set(true);
if (intent === 'explain') {
const draft = this.currentDraft();
if (!draft) {
this.pushMessage({
role: 'assistant',
content: 'Open or generate a flow first, then I can explain it in detail.',
intent
});
this.assistantBusy.set(false);
return;
this.localMessages.set([
{
id: crypto.randomUUID(),
role: 'user',
content
}
]);
this.assistant.explainDraft({
userPrompt,
model: this.selectedModel(),
flow: draft
}).pipe(
take(1),
finalize(() => this.assistantBusy.set(false))
).subscribe({
next: (response) => {
this.pushMessage({
role: 'assistant',
content: response.explanation || 'I analyzed the current flow.',
intent,
warnings: response.warnings
});
},
error: (err) => this.pushAssistantError(err, intent)
});
return;
}
const draft = this.currentDraft();
const maxRepairAttempts = intent === 'fix' ? 2 : 1;
const request$ = intent === 'draft' || !draft
? this.assistant.createDraft({
userPrompt,
model: this.selectedModel(),
maxRepairAttempts
})
: intent === 'fix'
? this.assistant.fixDraft({
userPrompt,
model: this.selectedModel(),
maxRepairAttempts,
flow: draft,
validationErrors: this.lastValidationErrors()
})
: this.assistant.refineDraft({
userPrompt,
model: this.selectedModel(),
maxRepairAttempts,
flow: draft
});
request$.pipe(
take(1),
finalize(() => this.assistantBusy.set(false))
this.assistant.sendMessage(sessionId, { message: content }).pipe(
take(1)
).subscribe({
next: (response) => void this.applyAssistantFlowResponse(response, intent),
error: (err) => this.pushAssistantError(err, intent)
next: ({ callId }) => {
this.currentCall.set({
id: callId,
sessionId,
status: 'QUEUED',
phase: 'queued'
});
this.startPolling(callId, sessionId);
},
error: (err) => {
console.error('Assistant send message failed', err);
this.pushLocalAssistantMessage('The assistant request failed.');
}
});
}
private loadModels() {
this.modelsLoading.set(true);
private bootstrapAssistant() {
this.sessionLoading.set(true);
this.modelsError.set(null);
this.assistant.getConfig().pipe(
take(1)
).subscribe({
next: (config) => {
this.assistantConfig.set(config);
this.loadModelsAndSession(config);
},
error: (err) => {
console.error('Assistant config loading failed', err);
this.sessionLoading.set(false);
this.modelsError.set('Unable to load assistant configuration.');
}
});
}
this.assistant.listModels().pipe(
private loadModelsAndSession(config: AssistantConfig) {
this.modelsLoading.set(true);
const resolvedUrl = this.resolveAssistantModelsUrl(config.availableModelsRetrieverUrl);
this.assistant.listModels(config.availableModelsRetrieverUrl).pipe(
take(1),
finalize(() => this.modelsLoading.set(false))
).subscribe({
next: (models) => {
this.models.set(models);
if (!this.selectedModel() && models.length) {
this.selectedModel.set(models[0]);
const selectedModel = config.defaultModel || models[0] || '';
this.selectedModel.set(selectedModel);
if (!selectedModel) {
this.sessionLoading.set(false);
this.modelsError.set('No assistant model is available.');
return;
}
void this.openSession(selectedModel);
},
error: (err) => {
console.error('Assistant model loading failed', err);
this.modelsError.set('Unable to load internal assistant models.');
this.sessionLoading.set(false);
this.modelsError.set(
`Unable to load assistant models from ${resolvedUrl}.`
);
}
});
}
private determineIntent(prompt: string): AssistantIntent {
const normalized = prompt.toLowerCase();
const hasDraft = !!this.currentDraft();
private async openSession(model: string) {
this.stopPolling();
this.currentCall.set(null);
this.localMessages.set([]);
this.sessionLoading.set(true);
if (/explain|what does|spiega|cosa fa|why/i.test(normalized)) return 'explain';
if (/fix|repair|problem|invalid|error|errore|bug/i.test(normalized)) return 'fix';
if (!hasDraft) return 'draft';
if (/create new|new flow|from scratch|nuovo flow/i.test(normalized)) return 'draft';
return 'refine';
this.assistant.createSession({ model }).pipe(
take(1),
finalize(() => this.sessionLoading.set(false))
).subscribe({
next: (session) => {
this.applySessionState(session);
},
error: (err) => {
console.error('Assistant session creation failed', err);
this.pushLocalAssistantMessage('Unable to create an assistant session.');
}
});
}
private maybeClarify(prompt: string): string | null {
const normalized = prompt.trim();
if (normalized.split(/\s+/).length >= 4) return null;
if (this.currentDraft()) return null;
return 'The request is too short to generate a useful flow. Tell me in one sentence what the workflow should do.';
private startPolling(callId: string, sessionId: string) {
this.stopPolling();
this.pollTick = setInterval(() => {
this.assistant.getCall(callId).pipe(
take(1)
).subscribe({
next: (callState) => {
this.currentCall.set(callState);
if (callState.status === 'COMPLETED' || callState.status === 'FAILED') {
this.stopPolling();
void this.reloadSession(sessionId, callState.status === 'FAILED' ? callState.errorMessage : undefined);
}
},
error: (err) => {
console.error('Assistant call polling failed', err);
this.stopPolling();
this.pushLocalAssistantMessage('Polling the assistant call failed.');
}
});
}, 500);
}
private async applyAssistantFlowResponse(response: AssistantFlowResponse, intent: AssistantIntent) {
this.lastValidationErrors.set(response.validationErrors);
private async reloadSession(sessionId: string, failureMessage?: string) {
this.assistant.getSession(sessionId).pipe(
take(1)
).subscribe({
next: (session) => {
this.applySessionState(session);
this.currentCall.set(null);
if (failureMessage) {
this.pushLocalAssistantMessage(failureMessage);
}
},
error: (err) => {
console.error('Assistant session refresh failed', err);
this.currentCall.set(null);
this.pushLocalAssistantMessage('Unable to refresh the assistant session.');
}
});
}
private applySessionState(session: AssistantSessionState) {
const normalizedSession = session.messages.length
? session
: {
...session,
messages: [this.initialSystemMessage]
};
this.sessionState.set(normalizedSession);
this.selectedModel.set(normalizedSession.selectedModel || this.selectedModel());
this.localMessages.set([]);
this.syncDraftToEditor(normalizedSession.currentDraftFlow);
}
private syncDraftToEditor(draft: AssistantDraftPayload | null) {
if (!draft) return;
const currentFlow = this.currentFlow();
const nextFlow = this.toEditorFlow(response, currentFlow, intent);
const isReplacingDocument = intent === 'draft' && currentFlow?.id !== nextFlow.id;
if (isReplacingDocument) {
const opened = await this.editorState.openDocument(nextFlow);
if (!opened) {
this.pushMessage({
role: 'assistant',
content: 'The new draft is ready, but I did not load it because the current flow has unsaved changes.',
intent
});
return;
}
const nextFlow = this.toEditorFlow(draft, currentFlow);
if (currentFlow?.id) {
this.editorState.loadAssistantFlow(nextFlow, { markDirty: true });
return;
}
this.editorState.loadAssistantFlow(nextFlow, { markDirty: true });
const summary = [
response.assistantRationale || this.defaultAssistantSummary(intent, response.valid),
response.valid ? 'The draft is valid.' : 'The draft still has validation errors.'
].filter(Boolean).join(' ');
this.pushMessage({
role: 'assistant',
content: summary,
intent,
warnings: response.warnings,
validationErrors: response.validationErrors
void this.editorState.openDocument(nextFlow, { skipDirtyCheck: false }).then((opened) => {
if (!opened) {
this.pushLocalAssistantMessage('The draft is ready, but I did not load it because the current flow has unsaved changes.');
return;
}
this.editorState.loadAssistantFlow(nextFlow, { markDirty: true });
});
}
private toEditorFlow(response: AssistantFlowResponse, currentFlow: Flow | null, intent: AssistantIntent): Flow {
const shouldReuseCurrentId = !!currentFlow && intent !== 'draft';
const nextId = shouldReuseCurrentId
? currentFlow!.id
: `${EditorStateHolder.ASSISTANT_DRAFT_PREFIX}${crypto.randomUUID()}`;
private toEditorFlow(draft: AssistantDraftPayload, currentFlow: Flow | null): Flow {
const nextId = currentFlow?.id ?? `${EditorStateHolder.ASSISTANT_DRAFT_PREFIX}${crypto.randomUUID()}`;
return {
id: nextId,
name: response.flow.name,
description: response.flow.description,
data: response.flow.flow,
status: response.valid ? 'EXECUTABLE' : 'DRAFT',
name: draft.name,
description: draft.description,
data: draft.flow,
status: 'DRAFT',
visibility: currentFlow?.visibility ?? 'PRIVATE',
author: currentFlow?.author ?? this.authorization.loggedInUser()?.username ?? 'assistant',
createdAt: currentFlow?.createdAt ?? new Date(),
@ -256,45 +322,61 @@ export class FlowAssistant implements OnInit {
};
}
private toAssistantDraft(flow: Flow | null): AssistantDraftPayload | null {
if (!flow) return null;
return {
name: flow.name,
description: flow.description,
flow: flow.data
};
}
private defaultAssistantSummary(intent: AssistantIntent, valid: boolean): string {
if (intent === 'draft') {
return valid
? 'I created a new workflow draft.'
: 'I created an initial draft, but it still needs corrections.';
}
if (intent === 'fix') {
return valid
? 'I fixed the current workflow.'
: 'I tried to fix the workflow, but there are still unresolved issues.';
}
return 'I updated the current workflow based on your request.';
}
private pushAssistantError(err: unknown, intent: AssistantIntent) {
console.error('Assistant request failed', err);
this.pushMessage({
role: 'assistant',
content: 'The assistant request failed.',
intent
});
}
private pushMessage(message: Omit<AssistantChatMessage, 'id'>) {
this.messages.update((messages) => [
...messages,
private pushLocalAssistantMessage(content: string) {
const filtered = this.localMessages().filter((message) => message.role !== 'assistant');
this.localMessages.set([
...filtered,
{
...message,
id: crypto.randomUUID()
id: crypto.randomUUID(),
role: 'assistant',
content
}
]);
}
private stopPolling() {
if (this.pollTick) {
clearInterval(this.pollTick);
this.pollTick = null;
}
}
private phaseText(phase: AssistantCallPhase): string {
switch (phase) {
case 'queued':
return 'Queued...';
case 'routing':
return 'Routing your request...';
case 'planning':
return 'Planning workflow blocks...';
case 'configuring_blocks':
return 'Configuring blocks...';
case 'connecting_blocks':
return 'Connecting blocks...';
case 'validating':
return 'Validating flow...';
case 'fixing':
return 'Repairing invalid flow...';
case 'explaining':
return 'Explaining current flow...';
case 'completed':
return 'Finalizing flow...';
case 'failed':
return 'Assistant request failed.';
}
}
private resolveAssistantModelsUrl(url: string): string {
if (!url) return url;
if (/^https?:\/\//i.test(url)) return url;
const apiBase = environment.apiUrl;
if (/^https?:\/\//i.test(apiBase)) {
return new URL(url, `${apiBase.replace(/\/+$/, '')}/`).toString();
}
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const normalizedBase = apiBase.startsWith('/') ? apiBase : `/${apiBase}`;
return new URL(url, `${origin}${normalizedBase.replace(/\/+$/, '')}/`).toString();
}
}

View File

@ -901,6 +901,13 @@ export class GenericNodeComponent {
queueMicrotask(() => {
try {
this.cdr.detectChanges();
requestAnimationFrame(() => {
try {
this.rendered();
} catch {
// Node may have been removed while async refresh was running.
}
});
} catch {
// Node may have been removed while async validation was running.
}

View File

@ -166,6 +166,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
...movedNode.data,
position: { x: pos.x, y: pos.y }
};
// Keep socket anchors and connection paths visually in sync while dragging.
void rete.area.update('node', movedNode.id);
}
private markFlowChanged(rete: ReteEditorInstance, context: any, loadedFlowId: string, loadedVersion: number) {

View File

@ -135,9 +135,56 @@ export async function addBlockToEditor(
};
const replaceWithCreatedBlock = async (createdBlock: FlowBlock) => {
if (!editor.getNode(node.id)) return;
const previousConnections = editor.getConnections()
.filter((connection) => connection.source === node.id || connection.target === node.id)
.map((connection) => ({
id: connection.id,
source: connection.source,
sourceOutput: connection.sourceOutput,
target: connection.target,
targetInput: connection.targetInput
}));
const currentPosition = (node.data?.position ?? position ?? createdBlock.position) as { x: number; y: number } | undefined;
for (const connection of previousConnections) {
await editor.removeConnection(connection.id);
}
await editor.removeNode(node.id);
await addBlockToEditor(editor, area, { ...createdBlock, position: currentPosition }, currentPosition);
const replacementNode = await addBlockToEditor(
editor,
area,
{ ...createdBlock, position: currentPosition },
currentPosition
);
if (!replacementNode) return;
const replacementOutputNames = new Set(Object.keys(replacementNode.outputs));
const replacementInputNames = new Set(Object.keys(replacementNode.inputs));
for (const connection of previousConnections) {
const sourceNode = connection.source === node.id
? replacementNode
: editor.getNode(connection.source);
const targetNode = connection.target === node.id
? replacementNode
: editor.getNode(connection.target);
if (!sourceNode || !targetNode) continue;
const sourceOutput = connection.source === node.id
? connection.sourceOutput
: connection.sourceOutput;
const targetInput = connection.target === node.id
? connection.targetInput
: connection.targetInput;
if (connection.source === node.id && !replacementOutputNames.has(sourceOutput)) continue;
if (connection.target === node.id && !replacementInputNames.has(targetInput)) continue;
await editor.addConnection(
new ClassicPreset.Connection(sourceNode as HFNode, sourceOutput, targetNode as HFNode, targetInput)
);
}
};
node.data = {
...cloneValue(block),

View File

@ -8,7 +8,7 @@ import { TaskExecutionsCallService } from "@services/task-executions/task-execut
export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
assistantEnabled: false,
assistantEnabled: true,
assistantCallService: AssistantCallService,
authorizationCallService: AuthorizationCallService,
flowsCallService: FlowsCallService,