Skip to content

Commit 02a6536

Browse files
committed
feat: Per-conversation loading states and tracking streaming stats
1 parent 1bb4f43 commit 02a6536

File tree

6 files changed

+340
-161
lines changed

6 files changed

+340
-161
lines changed

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,32 @@
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 { isLoading, activeMessages, activeConversation } from '$lib/stores/chat.svelte';
5+
import {
6+
isConversationLoading,
7+
activeMessages,
8+
activeConversation
9+
} from '$lib/stores/chat.svelte';
610
import { config } from '$lib/stores/settings.svelte';
711
812
const processingState = useProcessingState();
913
1014
let processingDetails = $derived(processingState.getProcessingDetails());
1115
12-
let showSlotsInfo = $derived(isLoading() || config().keepStatsVisible);
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+
);
20+
21+
let showSlotsInfo = $derived(isCurrentConversationLoading || config().keepStatsVisible);
1322
1423
$effect(() => {
1524
const keepStatsVisible = config().keepStatsVisible;
1625
17-
if (keepStatsVisible || isLoading()) {
26+
if (keepStatsVisible || isCurrentConversationLoading) {
1827
processingState.startMonitoring();
1928
}
2029
21-
if (!isLoading() && !keepStatsVisible) {
30+
if (!isCurrentConversationLoading && !keepStatsVisible) {
2231
setTimeout(() => {
2332
if (!config().keepStatsVisible) {
2433
processingState.stopMonitoring();

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
dismissErrorDialog,
2727
errorDialog,
2828
isLoading,
29+
isConversationLoading,
2930
sendMessage,
3031
stopGeneration
3132
} from '$lib/stores/chat.svelte';
@@ -83,6 +84,11 @@
8384
let activeErrorDialog = $derived(errorDialog());
8485
let isServerLoading = $derived(serverLoading());
8586
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+
);
91+
8692
async function handleDeleteConfirm() {
8793
const conversation = activeConversation();
8894
if (conversation) {
@@ -254,7 +260,7 @@
254260
});
255261
256262
$effect(() => {
257-
if (isLoading() && autoScrollEnabled) {
263+
if (isCurrentConversationLoading && autoScrollEnabled) {
258264
scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
259265
} else if (scrollInterval) {
260266
clearInterval(scrollInterval);
@@ -305,7 +311,7 @@
305311

306312
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
307313
<ChatForm
308-
isLoading={isLoading()}
314+
isLoading={isCurrentConversationLoading}
309315
onFileRemove={handleFileRemove}
310316
onFileUpload={handleFileUpload}
311317
onSend={handleSendMessage}
@@ -348,7 +354,7 @@
348354

349355
<div in:fly={{ y: 10, duration: 250, delay: 300 }}>
350356
<ChatForm
351-
isLoading={isLoading()}
357+
isLoading={isCurrentConversationLoading}
352358
onFileRemove={handleFileRemove}
353359
onFileUpload={handleFileUpload}
354360
onSend={handleSendMessage}

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

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { slotsService } from './slots';
2929
* - Request lifecycle management (abort, cleanup)
3030
*/
3131
export class ChatService {
32-
private abortController: AbortController | null = null;
32+
private abortControllers: Map<string, AbortController> = new Map();
3333

3434
/**
3535
* Sends a chat completion request to the llama.cpp server.
@@ -43,7 +43,8 @@ export class ChatService {
4343
*/
4444
async sendMessage(
4545
messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
46-
options: SettingsChatServiceOptions = {}
46+
options: SettingsChatServiceOptions = {},
47+
conversationId?: string
4748
): Promise<string | void> {
4849
const {
4950
stream,
@@ -79,9 +80,17 @@ export class ChatService {
7980

8081
const currentConfig = config();
8182

82-
// Cancel any ongoing request and create a new abort controller
83-
this.abort();
84-
this.abortController = new AbortController();
83+
// Create or get abort controller for this conversation
84+
const requestId = conversationId || 'default';
85+
86+
// Cancel any existing request for this conversation
87+
if (this.abortControllers.has(requestId)) {
88+
this.abortControllers.get(requestId)?.abort();
89+
}
90+
91+
// Create new abort controller for this conversation
92+
const abortController = new AbortController();
93+
this.abortControllers.set(requestId, abortController);
8594

8695
// Convert database messages with attachments to API format if needed
8796
const normalizedMessages: ApiChatMessageData[] = messages
@@ -172,7 +181,7 @@ export class ChatService {
172181
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
173182
},
174183
body: JSON.stringify(requestBody),
175-
signal: this.abortController.signal
184+
signal: abortController.signal
176185
});
177186

178187
if (!response.ok) {
@@ -227,6 +236,9 @@ export class ChatService {
227236
onError(userFriendlyError);
228237
}
229238
throw userFriendlyError;
239+
} finally {
240+
// Clean up the abort controller for this conversation
241+
this.abortControllers.delete(requestId);
230242
}
231243
}
232244

@@ -520,10 +532,20 @@ export class ChatService {
520532
*
521533
* @public
522534
*/
523-
public abort(): void {
524-
if (this.abortController) {
525-
this.abortController.abort();
526-
this.abortController = null;
535+
public abort(conversationId?: string): void {
536+
if (conversationId) {
537+
// Abort specific conversation
538+
const abortController = this.abortControllers.get(conversationId);
539+
if (abortController) {
540+
abortController.abort();
541+
this.abortControllers.delete(conversationId);
542+
}
543+
} else {
544+
// Abort all conversations
545+
for (const controller of this.abortControllers.values()) {
546+
controller.abort();
547+
}
548+
this.abortControllers.clear();
527549
}
528550
}
529551

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

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export class SlotsService {
3737
private callbacks: Set<(state: ApiProcessingState | null) => void> = new Set();
3838
private isStreamingActive: boolean = false;
3939
private lastKnownState: ApiProcessingState | null = null;
40+
// Track per-conversation streaming states and timing data
41+
private conversationStates: Map<string, ApiProcessingState | null> = new Map();
42+
private activeConversationId: string | null = null;
4043

4144
/**
4245
* Start streaming session tracking
@@ -75,6 +78,65 @@ export class SlotsService {
7578
return this.isStreamingActive;
7679
}
7780

81+
/**
82+
* Set the active conversation for statistics display
83+
*/
84+
setActiveConversation(conversationId: string | null): void {
85+
this.activeConversationId = conversationId;
86+
// Update display to show stats for the active conversation
87+
this.notifyCallbacks();
88+
}
89+
90+
/**
91+
* Update processing state for a specific conversation
92+
*/
93+
updateConversationState(conversationId: string, state: ApiProcessingState | null): void {
94+
this.conversationStates.set(conversationId, state);
95+
96+
// If this is the active conversation, update the display
97+
if (conversationId === this.activeConversationId) {
98+
this.lastKnownState = state;
99+
this.notifyCallbacks();
100+
}
101+
}
102+
103+
/**
104+
* Get processing state for a specific conversation
105+
*/
106+
getConversationState(conversationId: string): ApiProcessingState | null {
107+
return this.conversationStates.get(conversationId) || null;
108+
}
109+
110+
/**
111+
* Clear state for a specific conversation
112+
*/
113+
clearConversationState(conversationId: string): void {
114+
this.conversationStates.delete(conversationId);
115+
116+
// If this was the active conversation, clear display
117+
if (conversationId === this.activeConversationId) {
118+
this.lastKnownState = null;
119+
this.notifyCallbacks();
120+
}
121+
}
122+
123+
/**
124+
* Notify all callbacks with current state
125+
*/
126+
private notifyCallbacks(): void {
127+
const currentState = this.activeConversationId
128+
? this.conversationStates.get(this.activeConversationId) || null
129+
: this.lastKnownState;
130+
131+
for (const callback of this.callbacks) {
132+
try {
133+
callback(currentState);
134+
} catch (error) {
135+
console.error('Error in slots service callback:', error);
136+
}
137+
}
138+
}
139+
78140
/**
79141
* @deprecated Polling is no longer used - timing data comes from ChatService streaming response
80142
* This method logs a warning if called to help identify outdated usage
@@ -100,13 +162,16 @@ export class SlotsService {
100162
/**
101163
* Updates processing state with timing data from ChatService streaming response
102164
*/
103-
async updateFromTimingData(timingData: {
104-
prompt_n: number;
105-
predicted_n: number;
106-
predicted_per_second: number;
107-
cache_n: number;
108-
prompt_progress?: ChatMessagePromptProgress;
109-
}): Promise<void> {
165+
async updateFromTimingData(
166+
timingData: {
167+
prompt_n: number;
168+
predicted_n: number;
169+
predicted_per_second: number;
170+
cache_n: number;
171+
prompt_progress?: ChatMessagePromptProgress;
172+
},
173+
conversationId?: string
174+
): Promise<void> {
110175
const processingState = await this.parseCompletionTimingData(timingData);
111176

112177
// Only update if we successfully parsed the state
@@ -115,14 +180,13 @@ export class SlotsService {
115180
return;
116181
}
117182

118-
this.lastKnownState = processingState;
119-
120-
for (const callback of this.callbacks) {
121-
try {
122-
callback(processingState);
123-
} catch (error) {
124-
console.error('Error in timing callback:', error);
125-
}
183+
if (conversationId) {
184+
// Update per-conversation state
185+
this.updateConversationState(conversationId, processingState);
186+
} else {
187+
// Fallback to global state for backward compatibility
188+
this.lastKnownState = processingState;
189+
this.notifyCallbacks();
126190
}
127191
}
128192

0 commit comments

Comments
 (0)