feat: Implement inactive chat conversation state cleanup
This commit is contained in:
parent
2281ac50c6
commit
d43895d706
|
|
@ -29,6 +29,14 @@ import type {
|
||||||
DatabaseMessageExtra
|
DatabaseMessageExtra
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import { MessageRole, MessageType } from '$lib/enums';
|
import { MessageRole, MessageType } from '$lib/enums';
|
||||||
|
import {
|
||||||
|
MAX_INACTIVE_CONVERSATION_STATES,
|
||||||
|
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS
|
||||||
|
} from '$lib/constants/cache';
|
||||||
|
|
||||||
|
interface ConversationStateEntry {
|
||||||
|
lastAccessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
class ChatStore {
|
class ChatStore {
|
||||||
activeProcessingState = $state<ApiProcessingState | null>(null);
|
activeProcessingState = $state<ApiProcessingState | null>(null);
|
||||||
|
|
@ -39,6 +47,9 @@ class ChatStore {
|
||||||
chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
|
chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
|
||||||
private abortControllers = new SvelteMap<string, AbortController>();
|
private abortControllers = new SvelteMap<string, AbortController>();
|
||||||
private processingStates = new SvelteMap<string, ApiProcessingState | null>();
|
private processingStates = new SvelteMap<string, ApiProcessingState | null>();
|
||||||
|
|
||||||
|
/** Track when each conversation state was last accessed for cleanup */
|
||||||
|
private conversationStateTimestamps = new SvelteMap<string, ConversationStateEntry>();
|
||||||
private activeConversationId = $state<string | null>(null);
|
private activeConversationId = $state<string | null>(null);
|
||||||
private isStreamingActive = $state(false);
|
private isStreamingActive = $state(false);
|
||||||
private isEditModeActive = $state(false);
|
private isEditModeActive = $state(false);
|
||||||
|
|
@ -72,6 +83,7 @@ class ChatStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setChatLoading(convId: string, loading: boolean): void {
|
private setChatLoading(convId: string, loading: boolean): void {
|
||||||
|
this.touchConversationState(convId);
|
||||||
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
|
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
this.chatLoadingStates.set(convId, true);
|
this.chatLoadingStates.set(convId, true);
|
||||||
|
|
@ -84,6 +96,7 @@ class ChatStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setChatStreaming(convId: string, response: string, messageId: string): void {
|
private setChatStreaming(convId: string, response: string, messageId: string): void {
|
||||||
|
this.touchConversationState(convId);
|
||||||
this.chatStreamingStates.set(convId, { response, messageId });
|
this.chatStreamingStates.set(convId, { response, messageId });
|
||||||
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
|
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
|
||||||
if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response;
|
if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response;
|
||||||
|
|
@ -268,6 +281,106 @@ class ChatStore {
|
||||||
return this.chatLoadingStates.get(convId) || false;
|
return this.chatLoadingStates.get(convId) || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last accessed timestamp for a conversation.
|
||||||
|
*/
|
||||||
|
private touchConversationState(convId: string): void {
|
||||||
|
this.conversationStateTimestamps.set(convId, { lastAccessed: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up states for old/inactive conversations to prevent memory bloat.
|
||||||
|
* This removes loading states, streaming states, abort controllers, and processing states
|
||||||
|
* for conversations that haven't been accessed recently.
|
||||||
|
*
|
||||||
|
* @param activeConversationIds - Set of conversation IDs that should not be cleaned up
|
||||||
|
* @returns Number of conversation states cleaned up
|
||||||
|
*/
|
||||||
|
cleanupOldConversationStates(activeConversationIds?: string[]): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const activeIdsList = activeConversationIds ?? [];
|
||||||
|
|
||||||
|
// Always preserve the currently active conversation
|
||||||
|
const preserveIds = this.activeConversationId
|
||||||
|
? [...activeIdsList, this.activeConversationId]
|
||||||
|
: activeIdsList;
|
||||||
|
|
||||||
|
// Collect all unique conversation IDs that have any state
|
||||||
|
const allConvIdsArray = [
|
||||||
|
...this.chatLoadingStates.keys(),
|
||||||
|
...this.chatStreamingStates.keys(),
|
||||||
|
...this.abortControllers.keys(),
|
||||||
|
...this.processingStates.keys(),
|
||||||
|
...this.conversationStateTimestamps.keys()
|
||||||
|
];
|
||||||
|
const allConvIds = [...new Map(allConvIdsArray.map((id) => [id, true])).keys()];
|
||||||
|
|
||||||
|
// Filter to candidates for cleanup (not active, not currently loading/streaming)
|
||||||
|
const cleanupCandidates: Array<{ convId: string; lastAccessed: number }> = [];
|
||||||
|
|
||||||
|
for (const convId of allConvIds) {
|
||||||
|
// Never clean up active conversations or those with active streams
|
||||||
|
if (preserveIds.includes(convId)) continue;
|
||||||
|
if (this.chatLoadingStates.get(convId)) continue;
|
||||||
|
if (this.chatStreamingStates.has(convId)) continue;
|
||||||
|
|
||||||
|
const timestamp = this.conversationStateTimestamps.get(convId);
|
||||||
|
const lastAccessed = timestamp?.lastAccessed ?? 0;
|
||||||
|
|
||||||
|
cleanupCandidates.push({ convId, lastAccessed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by last accessed (oldest first)
|
||||||
|
cleanupCandidates.sort((a, b) => a.lastAccessed - b.lastAccessed);
|
||||||
|
|
||||||
|
let cleanedUp = 0;
|
||||||
|
|
||||||
|
for (const { convId, lastAccessed } of cleanupCandidates) {
|
||||||
|
// Clean up if:
|
||||||
|
// 1. Too many inactive states, OR
|
||||||
|
// 2. State is older than max age
|
||||||
|
const tooManyStates = cleanupCandidates.length - cleanedUp > MAX_INACTIVE_CONVERSATION_STATES;
|
||||||
|
const tooOld = now - lastAccessed > INACTIVE_CONVERSATION_STATE_MAX_AGE_MS;
|
||||||
|
|
||||||
|
if (tooManyStates || tooOld) {
|
||||||
|
this.cleanupConversationState(convId);
|
||||||
|
cleanedUp++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all state for a specific conversation.
|
||||||
|
*/
|
||||||
|
private cleanupConversationState(convId: string): void {
|
||||||
|
// Abort any pending request
|
||||||
|
const controller = this.abortControllers.get(convId);
|
||||||
|
if (controller && !controller.signal.aborted) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all state
|
||||||
|
this.chatLoadingStates.delete(convId);
|
||||||
|
this.chatStreamingStates.delete(convId);
|
||||||
|
this.abortControllers.delete(convId);
|
||||||
|
this.processingStates.delete(convId);
|
||||||
|
this.conversationStateTimestamps.delete(convId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of tracked conversation states (for debugging/monitoring).
|
||||||
|
*/
|
||||||
|
getTrackedConversationCount(): number {
|
||||||
|
return new Set([
|
||||||
|
...this.chatLoadingStates.keys(),
|
||||||
|
...this.chatStreamingStates.keys(),
|
||||||
|
...this.abortControllers.keys(),
|
||||||
|
...this.processingStates.keys()
|
||||||
|
]).size;
|
||||||
|
}
|
||||||
|
|
||||||
async addMessage(
|
async addMessage(
|
||||||
role: MessageRole,
|
role: MessageRole,
|
||||||
content: string,
|
content: string,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue