Persist assistant sessions per flow and auto-scroll latest messages

This commit is contained in:
Lucio Lelii 2026-04-24 12:18:40 +02:00
parent a4e78cb269
commit 72720ef292
3 changed files with 291 additions and 7 deletions

View File

@ -1,5 +1,5 @@
<aside class="assistant-shell">
<div class="assistant-scroll">
<div #assistantScroll class="assistant-scroll">
<header class="assistant-header">
<div>
<p class="assistant-eyebrow">Workflow Copilot</p>

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
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';
@ -17,6 +17,7 @@ import {
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';
@ -28,10 +29,16 @@ import { finalize, interval, Subscription, switchMap, take } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlowAssistant implements OnInit, OnDestroy {
@ViewChild('assistantScroll') assistantScrollElement?: ElementRef<HTMLDivElement>;
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<AssistantConfig | null>(null);
readonly sessionState = signal<AssistantSessionState | null>(null);
@ -137,11 +144,39 @@ export class FlowAssistant implements OnInit, OnDestroy {
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();
}
@ -153,14 +188,17 @@ export class FlowAssistant implements OnInit, OnDestroy {
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() {
@ -176,6 +214,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
content
}
]);
this.persistSnapshot();
this.assistant.sendMessage(sessionId, { message: content }).pipe(
take(1)
@ -187,6 +226,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
status: 'QUEUED',
phase: 'queued'
});
this.persistSnapshot();
this.startPolling(callId, sessionId);
},
error: (err) => {
@ -230,7 +270,9 @@ export class FlowAssistant implements OnInit, OnDestroy {
this.modelsError.set('No assistant model is available.');
return;
}
void this.openSession(selectedModel);
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);
@ -242,7 +284,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
});
}
private async openSession(model: string) {
private async openSession(model: string, flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)) {
this.stopPolling();
this.currentCall.set(null);
this.localMessages.set([]);
@ -254,6 +296,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
).subscribe({
next: (session) => {
this.applySessionState(session);
this.persistSnapshot(flowKey);
},
error: (err) => {
console.error('Assistant session creation failed', err);
@ -269,6 +312,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
).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);
@ -289,6 +333,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
next: (session) => {
this.applySessionState(session);
this.currentCall.set(null);
this.persistSnapshot();
if (failureMessage) {
this.pushLocalAssistantMessage(failureMessage);
}
@ -301,7 +346,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
});
}
private applySessionState(session: AssistantSessionState) {
private applySessionState(session: AssistantSessionState, options?: { clearLocalMessages?: boolean; syncDraftToEditor?: boolean }) {
const normalizedSession = session.messages.length
? session
: {
@ -311,8 +356,13 @@ export class FlowAssistant implements OnInit, OnDestroy {
this.sessionState.set(normalizedSession);
this.selectedModel.set(normalizedSession.selectedModel || this.selectedModel());
this.localMessages.set([]);
this.syncDraftToEditor(normalizedSession.currentDraftFlow);
if (options?.clearLocalMessages !== false) {
this.localMessages.set([]);
}
if (options?.syncDraftToEditor !== false) {
this.syncDraftToEditor(normalizedSession.currentDraftFlow);
}
this.persistSnapshot();
}
private syncDraftToEditor(draft: AssistantDraftPayload | null) {
@ -326,6 +376,11 @@ export class FlowAssistant implements OnInit, OnDestroy {
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.');
@ -363,6 +418,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
content
}
]);
this.persistSnapshot();
}
private systemWelcomeMessage(): AssistantChatMessage {
@ -375,6 +431,103 @@ export class FlowAssistant implements OnInit, OnDestroy {
};
}
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;

View File

@ -0,0 +1,131 @@
import { Injectable } from '@angular/core';
import { AssistantCallState, AssistantChatMessage, AssistantSessionState } from '@models/assistant';
export type AssistantSessionSnapshot = {
sessionId: string | null;
selectedModel: string;
prompt: string;
modelPickerOpen: boolean;
quickPromptsOpen: boolean;
localMessages: AssistantChatMessage[];
currentCall: AssistantCallState | null;
sessionState: AssistantSessionState | null;
};
@Injectable({ providedIn: 'root' })
export class AssistantSessionStore {
private static readonly NO_FLOW_KEY = '__assistant:no-flow__';
private static readonly STORAGE_KEY = 'assistant-session-store:v1';
private static readonly STORAGE_TARGETS: Array<'localStorage' | 'sessionStorage'> = ['localStorage', 'sessionStorage'];
private readonly snapshots = new Map<string, AssistantSessionSnapshot>();
constructor() {
this.hydrateFromStorage();
}
flowKey(flowId: string | null | undefined): string {
return flowId && flowId.length > 0 ? flowId : AssistantSessionStore.NO_FLOW_KEY;
}
getSnapshot(flowKey: string): AssistantSessionSnapshot | null {
const snapshot = this.snapshots.get(flowKey);
return snapshot ? structuredClone(snapshot) : null;
}
setSnapshot(flowKey: string, snapshot: AssistantSessionSnapshot) {
this.snapshots.set(flowKey, structuredClone(snapshot));
this.persistToStorage();
}
cloneSnapshot(fromFlowKey: string, toFlowKey: string) {
const snapshot = this.snapshots.get(fromFlowKey);
if (!snapshot) return;
this.snapshots.set(toFlowKey, structuredClone(snapshot));
this.persistToStorage();
}
private hydrateFromStorage() {
const raw = this.readStoredPayload();
if (!raw) return;
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') return;
const root = parsed as Record<string, unknown>;
const snapshots = root['snapshots'];
if (!snapshots || typeof snapshots !== 'object') return;
for (const [flowKey, rawSnapshot] of Object.entries(snapshots as Record<string, unknown>)) {
const normalized = this.normalizeSnapshot(rawSnapshot);
if (!normalized) continue;
this.snapshots.set(flowKey, normalized);
}
} catch {
this.clearStoredPayload();
}
}
private normalizeSnapshot(rawSnapshot: unknown): AssistantSessionSnapshot | null {
if (!rawSnapshot || typeof rawSnapshot !== 'object') return null;
const snapshot = rawSnapshot as Record<string, unknown>;
return {
sessionId: typeof snapshot['sessionId'] === 'string' ? snapshot['sessionId'] : null,
selectedModel: typeof snapshot['selectedModel'] === 'string' ? snapshot['selectedModel'] : '',
prompt: typeof snapshot['prompt'] === 'string' ? snapshot['prompt'] : '',
modelPickerOpen: snapshot['modelPickerOpen'] === true,
quickPromptsOpen: snapshot['quickPromptsOpen'] !== false,
localMessages: Array.isArray(snapshot['localMessages'])
? (snapshot['localMessages'] as AssistantChatMessage[])
: [],
currentCall: snapshot['currentCall'] && typeof snapshot['currentCall'] === 'object'
? (snapshot['currentCall'] as AssistantCallState)
: null,
sessionState: snapshot['sessionState'] && typeof snapshot['sessionState'] === 'object'
? (snapshot['sessionState'] as AssistantSessionState)
: null
};
}
private persistToStorage() {
if (typeof window === 'undefined') return;
const payload = JSON.stringify({
snapshots: Object.fromEntries(this.snapshots.entries())
});
for (const target of AssistantSessionStore.STORAGE_TARGETS) {
try {
window[target].setItem(AssistantSessionStore.STORAGE_KEY, payload);
} catch {
// Ignore storage write errors (quota, private mode, unavailable storage).
}
}
}
private readStoredPayload(): string | null {
if (typeof window === 'undefined') return null;
for (const target of AssistantSessionStore.STORAGE_TARGETS) {
try {
const value = window[target].getItem(AssistantSessionStore.STORAGE_KEY);
if (value) return value;
} catch {
// Ignore storage read errors and keep trying the next target.
}
}
return null;
}
private clearStoredPayload() {
if (typeof window === 'undefined') return;
for (const target of AssistantSessionStore.STORAGE_TARGETS) {
try {
window[target].removeItem(AssistantSessionStore.STORAGE_KEY);
} catch {
// Ignore storage cleanup errors.
}
}
}
}