import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, OnDestroy, OnInit, signal, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { environment } from '@environment'; import { AssistantCallPhase, AssistantCallState, AssistantChatMessage, AssistantConfig, AssistantDraftPayload, AssistantSessionState } from '@models/assistant'; import { Flow } from '@models/flow'; import { AssistantService } from '@services/assistant/assistant'; import { Authorization } from '@services/authorization/authorization'; import { AssistantSessionStore } from '@stores/assistant-session-store'; import { EditorStateHolder } from '@stores/flow-editor'; import { finalize, interval, Subscription, switchMap, take } from 'rxjs'; @Component({ selector: 'app-flow-assistant', imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule], templateUrl: './flow-assistant.html', styleUrl: './flow-assistant.css', changeDetection: ChangeDetectionStrategy.OnPush }) export class FlowAssistant implements OnInit, OnDestroy { @ViewChild('assistantScroll') assistantScrollElement?: ElementRef; private readonly assistant = inject(AssistantService); private readonly editorState = inject(EditorStateHolder); private readonly authorization = inject(Authorization); private readonly sessionStore = inject(AssistantSessionStore); private pollSubscription: Subscription | null = null; private initialized = false; private activeFlowKey: string | null = null; private lastAutoScrollKey = ''; 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 sessionLoading = signal(false); readonly prompt = signal(''); readonly selectedModel = signal(''); readonly modelPickerOpen = signal(false); readonly quickPromptsOpen = signal(true); readonly editorHasOpenFlow = computed(() => !!this.currentFlow()); readonly editorHasNonEmptyFlow = computed(() => this.hasMeaningfulFlow(this.currentFlow())); readonly sessionHasDraft = computed(() => !!this.currentDraft()); readonly canOfferCreate = computed(() => !this.editorHasNonEmptyFlow() && !this.sessionHasDraft()); readonly canOfferFix = computed(() => !this.canOfferCreate() && (this.sessionState()?.lastValidationErrors?.length ?? 0) > 0); readonly assistantModeLabel = computed(() => this.canOfferCreate() ? 'Create with assistant' : 'Refine with assistant'); readonly assistantModeDescription = computed(() => { if (this.canOfferCreate()) { return 'No flow is open, so the assistant is in create mode and can draft a new workflow.'; } if (this.canOfferFix()) { return 'A flow is already attached, so use the assistant to refine, fix, or explain it.'; } return 'A flow is already open or attached to this session, so create mode is not offered here.'; }); readonly promptPlaceholder = computed(() => this.canOfferCreate() ? 'Ask the assistant to create a new workflow' : 'Ask the assistant to refine, fix, or explain the current workflow' ); readonly starterPrompts = computed(() => { if (this.canOfferCreate()) { return [ 'Create a flow that classifies incoming tickets and sends urgent ones to a human', 'Create a flow that downloads a file, indexes it, and then queries it', 'Create a recruiter workflow that reviews a CV and produces a final assessment' ]; } const prompts = [ 'Modify the current flow to add a review step after the LLM block', 'Explain this flow and describe what each branch does' ]; if (this.canOfferFix()) { prompts.unshift('Fix the problems in this flow'); } return prompts; }); readonly displayedMessages = computed(() => { const baseMessages = this.sessionState()?.messages?.length ? this.sessionState()!.messages : [this.systemWelcomeMessage()]; 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 currentDraft = computed(() => this.sessionState()?.currentDraftFlow ?? null); constructor() { effect(() => { const flowKey = this.resolveFlowKey(this.currentFlow()?.id ?? null); if (!this.initialized || this.activeFlowKey === flowKey) return; const previousFlowKey = this.activeFlowKey; if (previousFlowKey) { this.persistSnapshot(previousFlowKey); } this.activeFlowKey = flowKey; void this.restoreSessionForFlow(flowKey); }); effect(() => { const messages = this.displayedMessages(); const lastMessageId = messages[messages.length - 1]?.id ?? ''; const busy = this.assistantBusy(); const nextKey = `${messages.length}:${lastMessageId}:${busy ? '1' : '0'}`; if (nextKey === this.lastAutoScrollKey) return; this.lastAutoScrollKey = nextKey; this.scheduleScrollToLatestMessage(); }); } ngOnInit(): void { this.bootstrapAssistant(); } ngOnDestroy(): void { if (this.activeFlowKey) { this.persistSnapshot(this.activeFlowKey); } this.stopPolling(); } selectModel(model: string) { if (!model || model === this.selectedModel()) return; this.selectedModel.set(model); void this.openSession(model); } useStarter(prompt: string) { this.prompt.set(prompt); this.persistSnapshot(); } toggleQuickPrompts() { this.quickPromptsOpen.update((value) => !value); this.persistSnapshot(); } toggleModelPicker() { this.modelPickerOpen.update((value) => !value); this.persistSnapshot(); } submitPrompt() { const content = this.prompt().trim(); const sessionId = this.sessionState()?.id; if (!content || this.assistantBusy() || !sessionId) return; this.prompt.set(''); this.localMessages.set([ { id: crypto.randomUUID(), role: 'user', content } ]); this.persistSnapshot(); this.assistant.sendMessage(sessionId, { message: content }).pipe( take(1) ).subscribe({ next: ({ callId }) => { this.currentCall.set({ id: callId, sessionId, status: 'QUEUED', phase: 'queued' }); this.persistSnapshot(); this.startPolling(callId, sessionId); }, error: (err) => { console.error('Assistant send message failed', err); this.pushLocalAssistantMessage('The assistant request failed.'); } }); } 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.'); } }); } 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); 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; } this.activeFlowKey = this.resolveFlowKey(this.currentFlow()?.id ?? null); this.initialized = true; void this.restoreSessionForFlow(this.activeFlowKey); }, error: (err) => { console.error('Assistant model loading failed', err); this.sessionLoading.set(false); this.modelsError.set( `Unable to load assistant models from ${resolvedUrl}.` ); } }); } private async openSession(model: string, flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)) { this.stopPolling(); this.currentCall.set(null); this.localMessages.set([]); this.sessionLoading.set(true); this.assistant.createSession({ model }).pipe( take(1), finalize(() => this.sessionLoading.set(false)) ).subscribe({ next: (session) => { this.applySessionState(session); this.persistSnapshot(flowKey); }, error: (err) => { console.error('Assistant session creation failed', err); this.pushLocalAssistantMessage('Unable to create an assistant session.'); } }); } private startPolling(callId: string, sessionId: string) { this.stopPolling(); this.pollSubscription = interval(500).pipe( switchMap(() => this.assistant.getCall(callId)) ).subscribe({ next: (callState) => { this.currentCall.set(callState); this.persistSnapshot(); 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.'); } }); } private async reloadSession(sessionId: string, failureMessage?: string) { this.assistant.getSession(sessionId).pipe( take(1) ).subscribe({ next: (session) => { this.applySessionState(session); this.currentCall.set(null); this.persistSnapshot(); 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, options?: { clearLocalMessages?: boolean; syncDraftToEditor?: boolean }) { const normalizedSession = session.messages.length ? session : { ...session, messages: [this.systemWelcomeMessage()] }; this.sessionState.set(normalizedSession); this.selectedModel.set(normalizedSession.selectedModel || this.selectedModel()); if (options?.clearLocalMessages !== false) { this.localMessages.set([]); } if (options?.syncDraftToEditor !== false) { this.syncDraftToEditor(normalizedSession.currentDraftFlow); } this.persistSnapshot(); } private syncDraftToEditor(draft: AssistantDraftPayload | null) { if (!draft) return; const currentFlow = this.currentFlow(); const nextFlow = this.toEditorFlow(draft, currentFlow); if (currentFlow?.id) { this.editorState.loadAssistantFlow(nextFlow, { markDirty: true }); return; } const currentFlowKey = this.activeFlowKey ?? this.resolveFlowKey(currentFlow?.id ?? null); const nextFlowKey = this.resolveFlowKey(nextFlow.id); this.persistSnapshot(currentFlowKey); this.sessionStore.cloneSnapshot(currentFlowKey, nextFlowKey); 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(draft: AssistantDraftPayload, currentFlow: Flow | null): Flow { const nextId = currentFlow?.id ?? `${EditorStateHolder.ASSISTANT_DRAFT_PREFIX}${crypto.randomUUID()}`; return { id: nextId, 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(), updatedAt: new Date(), published: currentFlow?.published, finalized: currentFlow?.finalized }; } private pushLocalAssistantMessage(content: string) { const filtered = this.localMessages().filter((message) => message.role !== 'assistant'); this.localMessages.set([ ...filtered, { id: crypto.randomUUID(), role: 'assistant', content } ]); this.persistSnapshot(); } private systemWelcomeMessage(): AssistantChatMessage { return { id: 'assistant-system-welcome', role: 'system', content: this.canOfferCreate() ? 'Select a model, then ask me to create a new workflow.' : 'Select a model, then ask me to refine, fix, or explain the current workflow.' }; } private async restoreSessionForFlow(flowKey: string) { this.stopPolling(); this.currentCall.set(null); const snapshot = this.sessionStore.getSnapshot(flowKey); if (!snapshot) { this.prompt.set(''); this.modelPickerOpen.set(false); this.quickPromptsOpen.set(true); this.sessionState.set(null); this.localMessages.set([]); const model = this.selectedModel(); if (!model) { this.sessionLoading.set(false); return; } void this.openSession(model, flowKey); return; } this.prompt.set(snapshot.prompt); this.modelPickerOpen.set(snapshot.modelPickerOpen); this.quickPromptsOpen.set(snapshot.quickPromptsOpen); this.localMessages.set(snapshot.localMessages); this.currentCall.set(null); if (snapshot.selectedModel) { this.selectedModel.set(snapshot.selectedModel); } if (snapshot.sessionState) { this.sessionState.set(snapshot.sessionState); } else { this.sessionState.set(null); } if (!snapshot.sessionId) { const model = this.selectedModel(); if (!model) { this.sessionLoading.set(false); return; } void this.openSession(model, flowKey); return; } this.sessionLoading.set(true); this.assistant.getSession(snapshot.sessionId).pipe( take(1), finalize(() => this.sessionLoading.set(false)) ).subscribe({ next: (session) => { this.applySessionState(session, { clearLocalMessages: false, syncDraftToEditor: !this.currentFlow()?.id }); }, error: (err) => { console.error('Assistant session restore failed', err); const model = this.selectedModel(); if (!model) return; void this.openSession(model, flowKey); } }); } private resolveFlowKey(flowId: string | null | undefined): string { return this.sessionStore.flowKey(flowId); } private persistSnapshot(flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)) { this.sessionStore.setSnapshot(flowKey, { sessionId: this.sessionState()?.id ?? null, selectedModel: this.selectedModel(), prompt: this.prompt(), modelPickerOpen: this.modelPickerOpen(), quickPromptsOpen: this.quickPromptsOpen(), localMessages: this.localMessages(), currentCall: this.currentCall(), sessionState: this.sessionState() }); } private scheduleScrollToLatestMessage() { queueMicrotask(() => { if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => this.scrollToLatestMessage()); return; } setTimeout(() => this.scrollToLatestMessage(), 0); }); } private scrollToLatestMessage() { const container = this.assistantScrollElement?.nativeElement; if (!container) return; container.scrollTop = container.scrollHeight; } private stopPolling() { this.pollSubscription?.unsubscribe(); this.pollSubscription = 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(); } private hasMeaningfulFlow(flow: Flow | null): boolean { if (!flow) return false; const data = flow.data; return (data.blocks?.length ?? 0) > 0 || (data.containers?.length ?? 0) > 0 || (data.connections?.length ?? 0) > 0 || (data.dependencies?.length ?? 0) > 0; } }