From 78d8527a8526d11c4ef450bf9a84b675d0b87d28 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 11 Mar 2026 12:44:31 +0100 Subject: [PATCH] Update assistant session UI and fix structural node reconnection --- src/app/layouts/flow-editor/flow-editor.css | 72 +++ src/app/layouts/flow-editor/flow-editor.html | 23 + src/app/layouts/flow-editor/flow-editor.ts | 10 +- src/app/models/assistant.ts | 54 +- .../services/assistant/assistant-call.base.ts | 40 +- .../services/assistant/assistant-call.fake.ts | 253 +++++++--- src/app/services/assistant/assistant-call.ts | 222 ++++++--- src/app/services/assistant/assistant.ts | 49 +- .../shared/flow-assistant/flow-assistant.css | 85 +++- .../shared/flow-assistant/flow-assistant.html | 34 +- .../shared/flow-assistant/flow-assistant.ts | 466 ++++++++++-------- .../shared/nodes/generic-node/generic-node.ts | 7 + src/app/shared/rete-editor/rete-editor.ts | 3 + src/app/utilities/rete-editor.ts | 49 +- src/environments/environment.staging.ts | 2 +- 15 files changed, 966 insertions(+), 403 deletions(-) diff --git a/src/app/layouts/flow-editor/flow-editor.css b/src/app/layouts/flow-editor/flow-editor.css index de950ea..7a874d7 100644 --- a/src/app/layouts/flow-editor/flow-editor.css +++ b/src/app/layouts/flow-editor/flow-editor.css @@ -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; + } } diff --git a/src/app/layouts/flow-editor/flow-editor.html b/src/app/layouts/flow-editor/flow-editor.html index ead59d2..766dba2 100644 --- a/src/app/layouts/flow-editor/flow-editor.html +++ b/src/app/layouts/flow-editor/flow-editor.html @@ -26,5 +26,28 @@ } + @if (assistantEnabled) { +
+ + + @if (assistantOpen()) { + + } @else { +
+ +
AI
+ +
+ } +
+ } + diff --git a/src/app/layouts/flow-editor/flow-editor.ts b/src/app/layouts/flow-editor/flow-editor.ts index 0a5d386..7f14fae 100644 --- a/src/app/layouts/flow-editor/flow-editor.ts +++ b/src/app/layouts/flow-editor/flow-editor.ts @@ -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); + } + } diff --git a/src/app/models/assistant.ts b/src/app/models/assistant.ts index c0e3e59..a9baaa5 100644 --- a/src/app/models/assistant.ts +++ b/src/app/models/assistant.ts @@ -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; +}; diff --git a/src/app/services/assistant/assistant-call.base.ts b/src/app/services/assistant/assistant-call.base.ts index ae77a65..67d753f 100644 --- a/src/app/services/assistant/assistant-call.base.ts +++ b/src/app/services/assistant/assistant-call.base.ts @@ -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; + abstract getConfig(): Observable; - abstract createDraft(request: { - userPrompt: string; - model: string; - maxRepairAttempts?: number; - }): Observable; + abstract listModels(retrieverUrl: string): Observable; - abstract refineDraft(request: { - userPrompt: string; + abstract createSession(request: { model: string; - maxRepairAttempts?: number; - flow: AssistantDraftPayload; - }): Observable; + }): Observable; - abstract fixDraft(request: { - userPrompt: string; - model: string; - maxRepairAttempts?: number; - flow: AssistantDraftPayload; - validationErrors?: AssistantValidationIssue[]; - }): Observable; + abstract sendMessage(sessionId: string, request: { + message: string; + }): Observable<{ callId: string }>; - abstract explainDraft(request: { - userPrompt: string; - model: string; - flow: AssistantDraftPayload; - }): Observable; + abstract getCall(callId: string): Observable; + + abstract getSession(sessionId: string): Observable; } diff --git a/src/app/services/assistant/assistant-call.fake.ts b/src/app/services/assistant/assistant-call.fake.ts index 15e1979..7c0cd59 100644 --- a/src/app/services/assistant/assistant-call.fake.ts +++ b/src/app/services/assistant/assistant-call.fake.ts @@ -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 { - 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 { - 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(); + private readonly calls = new Map(); + + override getConfig(): Observable { 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 { - 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 { + return of(this.models); + } - if (/review|approval|human/i.test(request.userPrompt)) { - addHumanReviewTail(next.flow); + override createSession(request: { + model: string; + }): Observable { + 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 { + 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 { - 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 { + 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 { - 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 + } + ]; } } diff --git a/src/app/services/assistant/assistant-call.ts b/src/app/services/assistant/assistant-call.ts index 5d39652..dd4e4cf 100644 --- a/src/app/services/assistant/assistant-call.ts +++ b/src/app/services/assistant/assistant-call.ts @@ -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 { + override getConfig(): Observable { return this.http - .get(`${environment.apiUrl}/retriever/LLM/models`, { - params: { provider: 'InternalOllama' } - }) - .pipe( - map((raw) => (Array.isArray(raw) ? raw : []).map((item) => String(item))) - ); + .get(`${environment.apiUrl}/assistant/config`) + .pipe(map((raw) => mapAssistantConfig(raw))); } - override createDraft(request: { - userPrompt: string; - model: string; - maxRepairAttempts?: number; - }): Observable { + override listModels(retrieverUrl: string): Observable { + const resolvedUrl = resolveAssistantUrl(retrieverUrl); return this.http - .post(`${environment.apiUrl}/assistant/flows/draft`, request) - .pipe(map((raw) => mapAssistantFlowResponse(raw))); + .get(resolvedUrl) + .pipe(map((raw) => mapModelList(raw))); } - override refineDraft(request: { - userPrompt: string; + override createSession(request: { model: string; - maxRepairAttempts?: number; - flow: AssistantDraftPayload; - }): Observable { + }): Observable { return this.http - .post(`${environment.apiUrl}/assistant/flows/refine`, request) - .pipe(map((raw) => mapAssistantFlowResponse(raw))); + .post(`${environment.apiUrl}/assistant/sessions`, request) + .pipe(map((raw) => mapAssistantSessionState(raw))); } - override fixDraft(request: { - userPrompt: string; - model: string; - maxRepairAttempts?: number; - flow: AssistantDraftPayload; - validationErrors?: AssistantValidationIssue[]; - }): Observable { + override sendMessage(sessionId: string, request: { + message: string; + }): Observable<{ callId: string }> { + const encodedId = encodeURIComponent(sessionId); return this.http - .post(`${environment.apiUrl}/assistant/flows/fix`, request) - .pipe(map((raw) => mapAssistantFlowResponse(raw))); + .post(`${environment.apiUrl}/assistant/sessions/${encodedId}/messages`, request) + .pipe(map((raw) => mapSendMessageResponse(raw))); } - override explainDraft(request: { - userPrompt: string; - model: string; - flow: AssistantDraftPayload; - }): Observable { + override getCall(callId: string): Observable { + const encodedId = encodeURIComponent(callId); return this.http - .post(`${environment.apiUrl}/assistant/flows/explain`, request) - .pipe(map((raw) => mapAssistantExplainResponse(raw))); + .get(`${environment.apiUrl}/assistant/calls/${encodedId}`) + .pipe(map((raw) => mapAssistantCallState(raw))); + } + + override getSession(sessionId: string): Observable { + const encodedId = encodeURIComponent(sessionId); + return this.http + .get(`${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; - 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)['values'] + ?? (raw as Record)['items'] + ?? (raw as Record)['data'] + ?? (raw as Record)['result'] + ?? []) as unknown[]) + : []; + + if (!Array.isArray(candidate)) return []; + + return candidate.map((item) => { + if (typeof item === 'string') return item; + const value = (item ?? {}) as Record; + return String(value['name'] ?? value['model'] ?? value['value'] ?? ''); + }).filter((item) => item.length > 0); +} + +function mapSendMessageResponse(raw: unknown): { callId: string } { const value = (raw ?? {}) as Record; 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; + 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; + + 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; + 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; const flow = (value['flow'] ?? {}) as Record; 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(); +} diff --git a/src/app/services/assistant/assistant.ts b/src/app/services/assistant/assistant.ts index 99e6540..fadc891 100644 --- a/src/app/services/assistant/assistant.ts +++ b/src/app/services/assistant/assistant.ts @@ -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); } } diff --git a/src/app/shared/flow-assistant/flow-assistant.css b/src/app/shared/flow-assistant/flow-assistant.css index e285ab1..095e810 100644 --- a/src/app/shared/flow-assistant/flow-assistant.css +++ b/src/app/shared/flow-assistant/flow-assistant.css @@ -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; diff --git a/src/app/shared/flow-assistant/flow-assistant.html b/src/app/shared/flow-assistant/flow-assistant.html index 2c73400..8996d94 100644 --- a/src/app/shared/flow-assistant/flow-assistant.html +++ b/src/app/shared/flow-assistant/flow-assistant.html @@ -5,11 +5,35 @@

Workflow Copilot

Assistant

- @if (currentIntent()) { - {{ currentIntent() }} - } + @if (activePhaseLabel()) { +
+ {{ activePhaseLabel() }} +
+ } + + @if (assistantBusy()) { +
+
+ +
+
{{ busyHeadline() }}
+

{{ busyDetail() }}

+
+
+ @if (activePhase() !== 'explaining') { +
+ @for (step of progressSteps; track step.phase; let index = $index) { + + {{ step.label }} + + } +
+ } +
+ } +
@@ -72,7 +96,7 @@
- @for (message of messages(); track message.id) { + @for (message of displayedMessages(); track message.id) {
{{ message.role }}

{{ message.content }}

@@ -98,7 +122,7 @@ @if (assistantBusy()) {
assistant
-

Working on the workflow...

+

{{ busyHeadline() }}

}
diff --git a/src/app/shared/flow-assistant/flow-assistant.ts b/src/app/shared/flow-assistant/flow-assistant.ts index 060216b..bd4e4e6 100644 --- a/src/app/shared/flow-assistant/flow-assistant.ts +++ b/src/app/shared/flow-assistant/flow-assistant.ts @@ -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 | null = null; + readonly assistantConfig = signal(null); + readonly sessionState = signal(null); + readonly currentCall = signal(null); + readonly localMessages = signal([]); readonly models = signal([]); readonly modelsLoading = signal(false); readonly modelsError = signal(null); - readonly selectedModel = signal(''); - readonly modelPickerOpen = signal(true); - readonly assistantBusy = signal(false); - readonly currentIntent = signal(null); - readonly lastValidationErrors = signal([]); + readonly sessionLoading = signal(false); readonly prompt = signal(''); + readonly selectedModel = signal(''); + readonly modelPickerOpen = signal(false); readonly quickPromptsOpen = signal(true); - readonly messages = signal([ - { - 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(() => { + 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) { - 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(); + } } diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index c653718..1bbfe2d 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -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. } diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index bc95da3..1ecb17a 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -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) { diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 5ea85f2..f7e1e17 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -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), diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index 9b8f5e9..aeae932 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -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,