Skip to content

Commit d754b2c

Browse files
committed
refactor: Chat state management
Consolidates loading state management by using a global `isLoading` store synchronized with individual conversation states. This change ensures proper reactivity and avoids potential race conditions when updating the UI based on the loading status of different conversations. It also improves the accuracy of statistics displayed. Additionally, slots service methods are updated to use conversation IDs for per-conversation state management, avoiding global state pollution.
1 parent 9bfceda commit d754b2c

File tree

6 files changed

+103
-53
lines changed

6 files changed

+103
-53
lines changed

tools/server/webui/src/lib/components/app/chat/ChatProcessingInfo.svelte

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,16 @@
22
import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
33
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
44
import { slotsService } from '$lib/services/slots';
5-
import {
6-
isConversationLoading,
7-
activeMessages,
8-
activeConversation
9-
} from '$lib/stores/chat.svelte';
5+
import { isLoading, activeMessages, activeConversation } from '$lib/stores/chat.svelte';
106
import { config } from '$lib/stores/settings.svelte';
117
128
const processingState = useProcessingState();
139
1410
let processingDetails = $derived(processingState.getProcessingDetails());
1511
16-
// Check if the current active conversation is loading (for per-conversation UI state)
17-
let isCurrentConversationLoading = $derived(
18-
activeConversation() ? isConversationLoading(activeConversation()!.id) : false
19-
);
12+
// Use global isLoading which is kept in sync with active conversation's loading state
13+
// This ensures proper reactivity since isLoading is a $state variable
14+
let isCurrentConversationLoading = $derived(isLoading());
2015
2116
let showSlotsInfo = $derived(isCurrentConversationLoading || config().keepStatsVisible);
2217
@@ -37,14 +32,13 @@
3732
});
3833
3934
$effect(() => {
40-
activeConversation();
41-
35+
const conversation = activeConversation();
4236
const messages = activeMessages() as DatabaseMessage[];
4337
const keepStatsVisible = config().keepStatsVisible;
4438
45-
if (keepStatsVisible) {
39+
if (keepStatsVisible && conversation) {
4640
if (messages.length === 0) {
47-
slotsService.clearState();
41+
slotsService.clearConversationState(conversation.id);
4842
return;
4943
}
5044
@@ -56,15 +50,18 @@
5650
foundTimingData = true;
5751
5852
slotsService
59-
.updateFromTimingData({
60-
prompt_n: message.timings.prompt_n || 0,
61-
predicted_n: message.timings.predicted_n || 0,
62-
predicted_per_second:
63-
message.timings.predicted_n && message.timings.predicted_ms
64-
? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
65-
: 0,
66-
cache_n: message.timings.cache_n || 0
67-
})
53+
.updateFromTimingData(
54+
{
55+
prompt_n: message.timings.prompt_n || 0,
56+
predicted_n: message.timings.predicted_n || 0,
57+
predicted_per_second:
58+
message.timings.predicted_n && message.timings.predicted_ms
59+
? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
60+
: 0,
61+
cache_n: message.timings.cache_n || 0
62+
},
63+
conversation.id
64+
)
6865
.catch((error) => {
6966
console.warn('Failed to update processing state from stored timings:', error);
7067
});
@@ -73,7 +70,7 @@
7370
}
7471
7572
if (!foundTimingData) {
76-
slotsService.clearState();
73+
slotsService.clearConversationState(conversation.id);
7774
}
7875
}
7976
});

tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
dismissErrorDialog,
2727
errorDialog,
2828
isLoading,
29-
isConversationLoading,
3029
sendMessage,
3130
stopGeneration
3231
} from '$lib/stores/chat.svelte';
@@ -84,10 +83,9 @@
8483
let activeErrorDialog = $derived(errorDialog());
8584
let isServerLoading = $derived(serverLoading());
8685
87-
// Check if the current active conversation is loading (for per-conversation UI state)
88-
let isCurrentConversationLoading = $derived(
89-
activeConversation() ? isConversationLoading(activeConversation()!.id) : false
90-
);
86+
// Use global isLoading which is kept in sync with active conversation's loading state
87+
// This ensures proper reactivity since isLoading is a $state variable
88+
let isCurrentConversationLoading = $derived(isLoading());
9189
9290
async function handleDeleteConfirm() {
9391
const conversation = activeConversation();

tools/server/webui/src/lib/services/chat.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ export class ChatService {
199199
onChunk,
200200
onComplete,
201201
onError,
202-
options.onReasoningChunk
202+
options.onReasoningChunk,
203+
conversationId
203204
);
204205
} else {
205206
return this.handleNonStreamResponse(response, onComplete, onError);
@@ -243,14 +244,13 @@ export class ChatService {
243244
}
244245

245246
/**
246-
* Handles streaming response from the chat completion API.
247-
* Processes server-sent events and extracts content chunks from the stream.
248-
*
249-
* @param response - The fetch Response object containing the streaming data
247+
* Handles streaming response from the chat completion API
248+
* @param response - The Response object from the fetch request
250249
* @param onChunk - Optional callback invoked for each content chunk received
251250
* @param onComplete - Optional callback invoked when the stream is complete with full response
252251
* @param onError - Optional callback invoked if an error occurs during streaming
253252
* @param onReasoningChunk - Optional callback invoked for each reasoning content chunk
253+
* @param conversationId - Optional conversation ID for per-conversation state tracking
254254
* @returns {Promise<void>} Promise that resolves when streaming is complete
255255
* @throws {Error} if the stream cannot be read or parsed
256256
*/
@@ -263,7 +263,8 @@ export class ChatService {
263263
timings?: ChatMessageTimings
264264
) => void,
265265
onError?: (error: Error) => void,
266-
onReasoningChunk?: (chunk: string) => void
266+
onReasoningChunk?: (chunk: string) => void,
267+
conversationId?: string
267268
): Promise<void> {
268269
const reader = response.body?.getReader();
269270

@@ -305,7 +306,7 @@ export class ChatService {
305306
const promptProgress = parsed.prompt_progress;
306307

307308
if (timings || promptProgress) {
308-
this.updateProcessingState(timings, promptProgress);
309+
this.updateProcessingState(timings, promptProgress, conversationId);
309310

310311
// Store the latest timing data
311312
if (timings) {
@@ -612,7 +613,8 @@ export class ChatService {
612613

613614
private updateProcessingState(
614615
timings?: ChatMessageTimings,
615-
promptProgress?: ChatMessagePromptProgress
616+
promptProgress?: ChatMessagePromptProgress,
617+
conversationId?: string
616618
): void {
617619
// Calculate tokens per second from timing data
618620
const tokensPerSecond =
@@ -622,13 +624,16 @@ export class ChatService {
622624

623625
// Update slots service with timing data (async but don't wait)
624626
slotsService
625-
.updateFromTimingData({
626-
prompt_n: timings?.prompt_n || 0,
627-
predicted_n: timings?.predicted_n || 0,
628-
predicted_per_second: tokensPerSecond,
629-
cache_n: timings?.cache_n || 0,
630-
prompt_progress: promptProgress
631-
})
627+
.updateFromTimingData(
628+
{
629+
prompt_n: timings?.prompt_n || 0,
630+
predicted_n: timings?.predicted_n || 0,
631+
predicted_per_second: tokensPerSecond,
632+
cache_n: timings?.cache_n || 0,
633+
prompt_progress: promptProgress
634+
},
635+
conversationId
636+
)
632637
.catch((error) => {
633638
console.warn('Failed to update processing state:', error);
634639
});

tools/server/webui/src/lib/services/slots.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,18 @@ export class SlotsService {
278278
/**
279279
* Get current processing state
280280
* Returns the last known state from timing data, or null if no data available
281+
* If activeConversationId is set, returns state for that conversation
281282
*/
282283
async getCurrentState(): Promise<ApiProcessingState | null> {
284+
// If we have an active conversation, return its state
285+
if (this.activeConversationId) {
286+
const conversationState = this.conversationStates.get(this.activeConversationId);
287+
if (conversationState) {
288+
return conversationState;
289+
}
290+
}
291+
292+
// Fallback to global state
283293
if (this.lastKnownState) {
284294
return this.lastKnownState;
285295
}

tools/server/webui/src/lib/stores/chat.svelte.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ class ChatStore {
9999
this.activeConversation = conversation;
100100
this.activeMessages = [];
101101

102+
// Set this conversation as active for statistics display
103+
slotsService.setActiveConversation(conversation.id);
104+
105+
// Sync global isLoading state - new conversation is not loading
106+
const isConvLoading = this.isConversationLoading(conversation.id);
107+
this.isLoading = isConvLoading;
108+
109+
// Clear global currentResponse state - new conversation has no streaming
110+
this.currentResponse = '';
111+
102112
await goto(`#/chat/${conversation.id}`);
103113

104114
return conversation.id;
@@ -122,6 +132,14 @@ class ChatStore {
122132
// Set this conversation as active for statistics display
123133
slotsService.setActiveConversation(convId);
124134

135+
// Sync global isLoading state with this conversation's loading state
136+
const isConvLoading = this.isConversationLoading(convId);
137+
this.isLoading = isConvLoading;
138+
139+
// Sync global currentResponse state with this conversation's streaming state
140+
const streamingState = this.getConversationStreaming(convId);
141+
this.currentResponse = streamingState?.response || '';
142+
125143
if (conversation.currNode) {
126144
const allMessages = await DatabaseStore.getConversationMessages(convId);
127145
this.activeMessages = filterByLeafNodeId(
@@ -299,12 +317,17 @@ class ChatStore {
299317
private setConversationLoading(convId: string, loading: boolean): void {
300318
if (loading) {
301319
this.conversationLoadingStates.set(convId, true);
320+
// Update global isLoading only if this is the active conversation
321+
if (this.activeConversation?.id === convId) {
322+
this.isLoading = true;
323+
}
302324
} else {
303325
this.conversationLoadingStates.delete(convId);
304-
}
305-
// Update global isLoading for backward compatibility (active conversation only)
306-
if (this.activeConversation?.id === convId) {
307-
this.isLoading = loading;
326+
// Only update global isLoading if we're clearing the active conversation's loading state
327+
// This prevents background conversations from affecting the UI
328+
if (this.activeConversation?.id === convId) {
329+
this.isLoading = false;
330+
}
308331
}
309332
}
310333

@@ -446,9 +469,14 @@ class ChatStore {
446469

447470
this.updateMessageAtIndex(messageIndex, localUpdateData);
448471

449-
await DatabaseStore.updateCurrentNode(this.activeConversation!.id, assistantMessage.id);
450-
this.activeConversation!.currNode = assistantMessage.id;
451-
await this.refreshActiveMessages();
472+
// Update database with the message's conversation ID (not activeConversation which may have changed)
473+
await DatabaseStore.updateCurrentNode(assistantMessage.convId, assistantMessage.id);
474+
475+
// Only update activeConversation.currNode if this is still the active conversation
476+
if (this.activeConversation?.id === assistantMessage.convId) {
477+
this.activeConversation.currNode = assistantMessage.id;
478+
await this.refreshActiveMessages();
479+
}
452480

453481
if (onComplete) {
454482
await onComplete(streamedContent);
@@ -603,6 +631,7 @@ class ChatStore {
603631
}
604632

605633
this.errorDialogState = null;
634+
606635
// Set loading state for this specific conversation
607636
this.setConversationLoading(this.activeConversation.id, true);
608637
this.clearConversationStreaming(this.activeConversation.id);
@@ -1248,15 +1277,21 @@ class ChatStore {
12481277
}
12491278

12501279
/**
1251-
* Clears the active conversation and resets state
1280+
* Clears the active conversation and messages
12521281
* Used when navigating away from chat or starting fresh
12531282
* Note: Does not stop ongoing streaming to allow background completion
12541283
*/
12551284
clearActiveConversation(): void {
12561285
this.activeConversation = null;
12571286
this.activeMessages = [];
1258-
// Don't clear currentResponse or isLoading to allow streaming to continue in background
1259-
// The streaming will complete and save to database automatically
1287+
1288+
// Clear global UI state since there's no active conversation
1289+
// Background streaming will continue but won't affect the UI
1290+
this.isLoading = false;
1291+
this.currentResponse = '';
1292+
1293+
// Clear active conversation in slots service
1294+
slotsService.setActiveConversation(null);
12601295
}
12611296

12621297
/** Refreshes active messages based on currNode after branch navigation */

tools/server/webui/src/routes/chat/[id]/+page.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
if (chatId && chatId !== currentChatId) {
1717
currentChatId = chatId;
1818
19+
// Skip loading if this conversation is already active (e.g., just created)
20+
if (activeConversation()?.id === chatId) {
21+
return;
22+
}
23+
1924
(async () => {
2025
const success = await chatStore.loadConversation(chatId);
2126

0 commit comments

Comments
 (0)