From 6b95118abc0081764463055b649e743ea4b2de90 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 27 Nov 2025 11:11:45 +0100 Subject: [PATCH] refactor: Processing state reactivity --- .../ChatScreenProcessingInfo.svelte | 65 ++++------- .../lib/hooks/use-processing-state.svelte.ts | 95 ++++++---------- .../webui/src/lib/stores/chat.svelte.ts | 107 ++++++++---------- 3 files changed, 101 insertions(+), 166 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte index 237595cb71..105b01178b 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte @@ -4,9 +4,10 @@ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { isLoading, + isChatStreaming, clearProcessingState, - updateProcessingStateFromTimings, - setActiveProcessingConversation + setActiveProcessingConversation, + restoreProcessingStateFromMessages } from '$lib/stores/chat.svelte'; import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte'; import { config } from '$lib/stores/settings.svelte'; @@ -14,34 +15,39 @@ const processingState = useProcessingState(); let isCurrentConversationLoading = $derived(isLoading()); + let isStreaming = $derived(isChatStreaming()); + let hasProcessingData = $derived(processingState.processingState !== null); let processingDetails = $derived(processingState.getProcessingDetails()); - let showSlotsInfo = $derived(isCurrentConversationLoading || config().keepStatsVisible); - // Sync active processing conversation with currently viewed conversation + let showProcessingInfo = $derived( + isCurrentConversationLoading || isStreaming || config().keepStatsVisible || hasProcessingData + ); + $effect(() => { const conversation = activeConversation(); - // Use untrack to prevent creating reactive dependencies on state updates + untrack(() => setActiveProcessingConversation(conversation?.id ?? null)); }); - // Track loading state reactively by checking if conversation ID is in loading conversations array $effect(() => { const keepStatsVisible = config().keepStatsVisible; + const shouldMonitor = keepStatsVisible || isCurrentConversationLoading || isStreaming; - if (keepStatsVisible || isCurrentConversationLoading) { - untrack(() => processingState.startMonitoring()); + if (shouldMonitor) { + processingState.startMonitoring(); } - if (!isCurrentConversationLoading && !keepStatsVisible) { - setTimeout(() => { - if (!config().keepStatsVisible) { + if (!isCurrentConversationLoading && !isStreaming && !keepStatsVisible) { + const timeout = setTimeout(() => { + if (!config().keepStatsVisible && !isChatStreaming()) { processingState.stopMonitoring(); } }, PROCESSING_INFO_TIMEOUT); + + return () => clearTimeout(timeout); } }); - // Update processing state from stored timings $effect(() => { const conversation = activeConversation(); const messages = activeMessages() as DatabaseMessage[]; @@ -49,47 +55,18 @@ if (keepStatsVisible && conversation) { if (messages.length === 0) { - // Use untrack to prevent creating reactive dependencies on state updates untrack(() => clearProcessingState(conversation.id)); return; } - // Search backwards through messages to find most recent assistant message with timing data - // Using reverse iteration for performance - avoids array copy and stops at first match - let foundTimingData = false; - - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message.role === 'assistant' && message.timings) { - foundTimingData = true; - - // Use untrack to prevent creating reactive dependencies on state updates - untrack(() => - updateProcessingStateFromTimings( - { - prompt_n: message.timings!.prompt_n || 0, - predicted_n: message.timings!.predicted_n || 0, - predicted_per_second: - message.timings!.predicted_n && message.timings!.predicted_ms - ? (message.timings!.predicted_n / message.timings!.predicted_ms) * 1000 - : 0, - cache_n: message.timings!.cache_n || 0 - }, - conversation.id - ) - ); - break; - } - } - - if (!foundTimingData) { - untrack(() => clearProcessingState(conversation.id)); + if (!isCurrentConversationLoading && !isStreaming) { + untrack(() => restoreProcessingStateFromMessages(messages, conversation.id)); } } }); -
+
{#each processingDetails as detail (detail)} {detail} diff --git a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts b/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts index d774c2fa04..ec4d9d9cc3 100644 --- a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts +++ b/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts @@ -1,8 +1,4 @@ -import { - subscribeToProcessingState, - getCurrentProcessingState, - isChatStreaming -} from '$lib/stores/chat.svelte'; +import { activeProcessingState } from '$lib/stores/chat.svelte'; import { config } from '$lib/stores/settings.svelte'; export interface UseProcessingStateReturn { @@ -10,7 +6,7 @@ export interface UseProcessingStateReturn { getProcessingDetails(): string[]; getProcessingMessage(): string; shouldShowDetails(): boolean; - startMonitoring(): Promise; + startMonitoring(): void; stopMonitoring(): void; } @@ -18,93 +14,71 @@ export interface UseProcessingStateReturn { * useProcessingState - Reactive processing state hook * * This hook provides reactive access to the processing state of the server. - * It subscribes to timing data updates from ChatStore and provides + * It directly reads from ChatStore's reactive state and provides * formatted processing details for UI display. * * **Features:** - * - Real-time processing state monitoring + * - Real-time processing state via direct reactive state binding * - Context and output token tracking * - Tokens per second calculation - * - Graceful degradation when slots endpoint unavailable - * - Automatic cleanup on component unmount + * - Automatic updates when streaming data arrives + * - Supports multiple concurrent conversations * * @returns Hook interface with processing state and control methods */ export function useProcessingState(): UseProcessingStateReturn { let isMonitoring = $state(false); - let processingState = $state(null); let lastKnownState = $state(null); - let unsubscribe: (() => void) | null = null; - async function startMonitoring(): Promise { - if (isMonitoring) return; - - isMonitoring = true; - - unsubscribe = subscribeToProcessingState((state) => { - processingState = state; - if (state) { - lastKnownState = state; - } else { - lastKnownState = null; - } - }); - - try { - const currentState = await getCurrentProcessingState(); - - if (currentState) { - processingState = currentState; - lastKnownState = currentState; - } - - // Check if streaming is active for UI purposes - if (isChatStreaming()) { - // Streaming is active, state will be updated via subscription - } - } catch (error) { - console.warn('Failed to start processing state monitoring:', error); - // Continue without monitoring - graceful degradation + // Derive processing state reactively from ChatStore's direct state + const processingState = $derived.by(() => { + if (!isMonitoring) { + return lastKnownState; } + // Read directly from the reactive state export + return activeProcessingState(); + }); + + // Track last known state for keepStatsVisible functionality + $effect(() => { + if (processingState && isMonitoring) { + lastKnownState = processingState; + } + }); + + function startMonitoring(): void { + if (isMonitoring) return; + isMonitoring = true; } function stopMonitoring(): void { if (!isMonitoring) return; - isMonitoring = false; - // Only clear processing state if keepStatsVisible is disabled - // This preserves the last known state for display when stats should remain visible + // Only clear last known state if keepStatsVisible is disabled const currentConfig = config(); if (!currentConfig.keepStatsVisible) { - processingState = null; - } else if (lastKnownState) { - // Keep the last known state visible when keepStatsVisible is enabled - processingState = lastKnownState; - } - - if (unsubscribe) { - unsubscribe(); - unsubscribe = null; + lastKnownState = null; } } function getProcessingMessage(): string { - if (!processingState) { + const state = processingState; + if (!state) { return 'Processing...'; } - switch (processingState.status) { + switch (state.status) { case 'initializing': return 'Initializing...'; case 'preparing': - if (processingState.progressPercent !== undefined) { - return `Processing (${processingState.progressPercent}%)`; + if (state.progressPercent !== undefined) { + return `Processing (${state.progressPercent}%)`; } return 'Preparing response...'; case 'generating': - if (processingState.tokensDecoded > 0) { - return `Generating... (${processingState.tokensDecoded} tokens)`; + if (state.tokensDecoded > 0) { + return `Generating... (${state.tokensDecoded} tokens)`; } return 'Generating...'; default: @@ -157,7 +131,8 @@ export function useProcessingState(): UseProcessingStateReturn { } function shouldShowDetails(): boolean { - return processingState !== null && processingState.status !== 'idle'; + const state = processingState; + return state !== null && state.status !== 'idle'; } return { diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index a253a269d9..15b164a619 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -5,7 +5,7 @@ import { config } from '$lib/stores/settings.svelte'; import { contextSize } from '$lib/stores/server.svelte'; import { normalizeModelName } from '$lib/utils/model-names'; import { filterByLeafNodeId, findDescendantMessages, findLeafNode } from '$lib/utils/branching'; -import { SvelteMap, SvelteSet } from 'svelte/reactivity'; +import { SvelteMap } from 'svelte/reactivity'; import { DEFAULT_CONTEXT } from '$lib/constants/default-context'; import type { ChatMessageTimings, @@ -55,6 +55,7 @@ import type { DatabaseMessage, DatabaseMessageExtra } from '$lib/types/database' * - Automatic state sync when switching between conversations */ class ChatStore { + activeProcessingState = $state(null); currentResponse = $state(''); errorDialogState = $state<{ type: 'timeout' | 'server'; message: string } | null>(null); isLoading = $state(false); @@ -66,10 +67,8 @@ class ChatStore { // Processing state tracking - per-conversation timing/context info private processingStates = new SvelteMap(); - private processingCallbacks = new SvelteSet<(state: ApiProcessingState | null) => void>(); private activeConversationId = $state(null); private isStreamingActive = $state(false); - private lastKnownProcessingState = $state(null); // ============ API Options ============ @@ -190,7 +189,12 @@ class ChatStore { */ setActiveProcessingConversation(conversationId: string | null): void { this.activeConversationId = conversationId; - this.notifyProcessingCallbacks(); + + if (conversationId) { + this.activeProcessingState = this.processingStates.get(conversationId) || null; + } else { + this.activeProcessingState = null; + } } /** @@ -207,24 +211,16 @@ class ChatStore { this.processingStates.delete(conversationId); if (conversationId === this.activeConversationId) { - this.lastKnownProcessingState = null; - this.notifyProcessingCallbacks(); + this.activeProcessingState = null; } } /** - * Subscribe to processing state changes + * Get the current processing state for the active conversation (reactive) + * Returns the direct reactive state for UI binding */ - subscribeToProcessingState(callback: (state: ApiProcessingState | null) => void): () => void { - this.processingCallbacks.add(callback); - - if (this.lastKnownProcessingState) { - callback(this.lastKnownProcessingState); - } - - return () => { - this.processingCallbacks.delete(callback); - }; + getActiveProcessingState(): ApiProcessingState | null { + return this.activeProcessingState; } /** @@ -247,36 +243,28 @@ class ChatStore { return; } - if (conversationId) { - this.processingStates.set(conversationId, processingState); + const targetId = conversationId || this.activeConversationId; + if (targetId) { + this.processingStates.set(targetId, processingState); - if (conversationId === this.activeConversationId) { - this.lastKnownProcessingState = processingState; - this.notifyProcessingCallbacks(); + if (targetId === this.activeConversationId) { + this.activeProcessingState = processingState; } - } else { - this.lastKnownProcessingState = processingState; - this.notifyProcessingCallbacks(); } } /** - * Get current processing state + * Get current processing state (sync version for reactive access) */ - async getCurrentProcessingState(): Promise { - if (this.activeConversationId) { - const conversationState = this.processingStates.get(this.activeConversationId); - if (conversationState) { - return conversationState; - } - } + getCurrentProcessingStateSync(): ApiProcessingState | null { + return this.activeProcessingState; + } - if (this.lastKnownProcessingState) { - return this.lastKnownProcessingState; - } - - // Try to restore from last assistant message - const messages = conversationsStore.activeMessages; + /** + * Restore processing state from last assistant message timings + * Call this when keepStatsVisible is enabled and we need to show last known stats + */ + restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role === 'assistant' && message.timings) { @@ -291,32 +279,23 @@ class ChatStore { }); if (restoredState) { - this.lastKnownProcessingState = restoredState; - return restoredState; + this.processingStates.set(conversationId, restoredState); + + if (conversationId === this.activeConversationId) { + this.activeProcessingState = restoredState; + } + + return; } } } - - return null; - } - - private notifyProcessingCallbacks(): void { - const currentState = this.activeConversationId - ? this.processingStates.get(this.activeConversationId) || null - : this.lastKnownProcessingState; - - for (const callback of this.processingCallbacks) { - try { - callback(currentState); - } catch (error) { - console.error('Error in processing state callback:', error); - } - } } private getContextTotal(): number { - if (this.lastKnownProcessingState && this.lastKnownProcessingState.contextTotal > 0) { - return this.lastKnownProcessingState.contextTotal; + const activeState = this.getActiveProcessingState(); + + if (activeState && activeState.contextTotal > 0) { + return activeState.contextTotal; } const propsContextSize = contextSize(); @@ -734,7 +713,7 @@ class ChatStore { content: streamingState.response }; if (lastMessage.thinking?.trim()) updateData.thinking = lastMessage.thinking; - const lastKnownState = await this.getCurrentProcessingState(); + const lastKnownState = this.getCurrentProcessingStateSync(); if (lastKnownState) { updateData.timings = { prompt_n: lastKnownState.promptTokens || 0, @@ -1323,9 +1302,13 @@ export const syncLoadingStateForChat = chatStore.syncLoadingStateForChat.bind(ch export const clearUIState = chatStore.clearUIState.bind(chatStore); // Processing state (timing/context info) -export const subscribeToProcessingState = chatStore.subscribeToProcessingState.bind(chatStore); export const getProcessingState = chatStore.getProcessingState.bind(chatStore); -export const getCurrentProcessingState = chatStore.getCurrentProcessingState.bind(chatStore); +export const getActiveProcessingState = chatStore.getActiveProcessingState.bind(chatStore); +export const activeProcessingState = () => chatStore.activeProcessingState; +export const getCurrentProcessingStateSync = + chatStore.getCurrentProcessingStateSync.bind(chatStore); +export const restoreProcessingStateFromMessages = + chatStore.restoreProcessingStateFromMessages.bind(chatStore); export const clearProcessingState = chatStore.clearProcessingState.bind(chatStore); export const updateProcessingStateFromTimings = chatStore.updateProcessingStateFromTimings.bind(chatStore);