refactor: Processing state reactivity

This commit is contained in:
Aleksander Grygier 2025-11-27 11:11:45 +01:00
parent 2a5922b1f6
commit 6b95118abc
3 changed files with 101 additions and 166 deletions

View File

@ -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));
}
}
});
</script>
<div class="chat-processing-info-container pointer-events-none" class:visible={showSlotsInfo}>
<div class="chat-processing-info-container pointer-events-none" class:visible={showProcessingInfo}>
<div class="chat-processing-info-content">
{#each processingDetails as detail (detail)}
<span class="chat-processing-info-detail pointer-events-auto">{detail}</span>

View File

@ -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<void>;
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<ApiProcessingState | null>(null);
let lastKnownState = $state<ApiProcessingState | null>(null);
let unsubscribe: (() => void) | null = null;
async function startMonitoring(): Promise<void> {
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 {

View File

@ -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<ApiProcessingState | null>(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<string, ApiProcessingState | null>();
private processingCallbacks = new SvelteSet<(state: ApiProcessingState | null) => void>();
private activeConversationId = $state<string | null>(null);
private isStreamingActive = $state(false);
private lastKnownProcessingState = $state<ApiProcessingState | null>(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<ApiProcessingState | null> {
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);