Enable per-conversation loading states to allow having parallel conversations (#16327)

* feat: Per-conversation loading states and tracking streaming stats

* chore: update webui build output

* 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.

* feat: Adds loading indicator to conversation items

* chore: update webui build output

* fix: Fix aborting chat streaming

Improves the chat stream abortion process by ensuring that partial responses are saved before the abort signal is sent.

This avoids a race condition where the onError callback could clear the streaming state before the partial response is saved. Additionally, the stream reading loop and callbacks are now checked for abort signals to prevent further processing after abortion.

* refactor: Remove redundant comments

* chore: build webui static output

* refactor: Cleanup

* chore: update webui build output

* chore: update webui build output

* fix: Conversation loading indicator for regenerating messages

* chore: update webui static build

* feat: Improve configuration

* feat: Install `http-server` as dev dependency to not need to rely on `npx` in CI
This commit is contained in:
Aleksander Grygier
2025-10-20 12:41:13 +02:00
committed by GitHub
parent 06332e2867
commit 13f2cfad41
13 changed files with 993 additions and 244 deletions

View File

@@ -6,6 +6,7 @@ import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/u
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { SvelteMap } from 'svelte/reactivity';
import type { ExportedConversations } from '$lib/types/database';
/**
@@ -50,6 +51,8 @@ class ChatStore {
errorDialogState = $state<{ type: 'timeout' | 'server'; message: string } | null>(null);
isInitialized = $state(false);
isLoading = $state(false);
conversationLoadingStates = new SvelteMap<string, boolean>();
conversationStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
constructor() {
@@ -94,6 +97,13 @@ class ChatStore {
this.activeConversation = conversation;
this.activeMessages = [];
slotsService.setActiveConversation(conversation.id);
const isConvLoading = this.isConversationLoading(conversation.id);
this.isLoading = isConvLoading;
this.currentResponse = '';
await goto(`#/chat/${conversation.id}`);
return conversation.id;
@@ -114,6 +124,14 @@ class ChatStore {
this.activeConversation = conversation;
slotsService.setActiveConversation(convId);
const isConvLoading = this.isConversationLoading(convId);
this.isLoading = isConvLoading;
const streamingState = this.getConversationStreaming(convId);
this.currentResponse = streamingState?.response || '';
if (conversation.currNode) {
const allMessages = await DatabaseStore.getConversationMessages(convId);
this.activeMessages = filterByLeafNodeId(
@@ -285,6 +303,47 @@ class ChatStore {
return apiOptions;
}
/**
* Helper methods for per-conversation loading state management
*/
private setConversationLoading(convId: string, loading: boolean): void {
if (loading) {
this.conversationLoadingStates.set(convId, true);
if (this.activeConversation?.id === convId) {
this.isLoading = true;
}
} else {
this.conversationLoadingStates.delete(convId);
if (this.activeConversation?.id === convId) {
this.isLoading = false;
}
}
}
private isConversationLoading(convId: string): boolean {
return this.conversationLoadingStates.get(convId) || false;
}
private setConversationStreaming(convId: string, response: string, messageId: string): void {
this.conversationStreamingStates.set(convId, { response, messageId });
if (this.activeConversation?.id === convId) {
this.currentResponse = response;
}
}
private clearConversationStreaming(convId: string): void {
this.conversationStreamingStates.delete(convId);
if (this.activeConversation?.id === convId) {
this.currentResponse = '';
}
}
private getConversationStreaming(
convId: string
): { response: string; messageId: string } | undefined {
return this.conversationStreamingStates.get(convId);
}
/**
* Handles streaming chat completion with the AI model
* @param allMessages - All messages in the conversation
@@ -325,125 +384,132 @@ class ChatStore {
};
slotsService.startStreaming();
slotsService.setActiveConversation(assistantMessage.convId);
await chatService.sendMessage(allMessages, {
...this.getApiOptions(),
await chatService.sendMessage(
allMessages,
{
...this.getApiOptions(),
onChunk: (chunk: string) => {
streamedContent += chunk;
this.currentResponse = streamedContent;
onChunk: (chunk: string) => {
streamedContent += chunk;
this.setConversationStreaming(
assistantMessage.convId,
streamedContent,
assistantMessage.id
);
captureModelIfNeeded();
const messageIndex = this.findMessageIndex(assistantMessage.id);
this.updateMessageAtIndex(messageIndex, {
content: streamedContent
});
},
captureModelIfNeeded();
const messageIndex = this.findMessageIndex(assistantMessage.id);
this.updateMessageAtIndex(messageIndex, {
content: streamedContent
});
},
onReasoningChunk: (reasoningChunk: string) => {
streamedReasoningContent += reasoningChunk;
onReasoningChunk: (reasoningChunk: string) => {
streamedReasoningContent += reasoningChunk;
captureModelIfNeeded();
captureModelIfNeeded();
const messageIndex = this.findMessageIndex(assistantMessage.id);
const messageIndex = this.findMessageIndex(assistantMessage.id);
this.updateMessageAtIndex(messageIndex, { thinking: streamedReasoningContent });
},
this.updateMessageAtIndex(messageIndex, { thinking: streamedReasoningContent });
},
onComplete: async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings
) => {
slotsService.stopStreaming();
onComplete: async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings
) => {
slotsService.stopStreaming();
const updateData: {
content: string;
thinking: string;
timings?: ChatMessageTimings;
model?: string;
} = {
content: finalContent || streamedContent,
thinking: reasoningContent || streamedReasoningContent,
timings: timings
};
const updateData: {
content: string;
thinking: string;
timings?: ChatMessageTimings;
model?: string;
} = {
content: finalContent || streamedContent,
thinking: reasoningContent || streamedReasoningContent,
timings: timings
};
const capturedModel = captureModelIfNeeded(false);
const capturedModel = captureModelIfNeeded(false);
if (capturedModel) {
updateData.model = capturedModel;
}
if (capturedModel) {
updateData.model = capturedModel;
}
await DatabaseStore.updateMessage(assistantMessage.id, updateData);
await DatabaseStore.updateMessage(assistantMessage.id, updateData);
const messageIndex = this.findMessageIndex(assistantMessage.id);
const messageIndex = this.findMessageIndex(assistantMessage.id);
const localUpdateData: { timings?: ChatMessageTimings; model?: string } = {
timings: timings
};
const localUpdateData: { timings?: ChatMessageTimings; model?: string } = {
timings: timings
};
if (updateData.model) {
localUpdateData.model = updateData.model;
}
if (updateData.model) {
localUpdateData.model = updateData.model;
}
this.updateMessageAtIndex(messageIndex, localUpdateData);
this.updateMessageAtIndex(messageIndex, localUpdateData);
await DatabaseStore.updateCurrentNode(this.activeConversation!.id, assistantMessage.id);
this.activeConversation!.currNode = assistantMessage.id;
await this.refreshActiveMessages();
await DatabaseStore.updateCurrentNode(assistantMessage.convId, assistantMessage.id);
if (onComplete) {
await onComplete(streamedContent);
}
if (this.activeConversation?.id === assistantMessage.convId) {
this.activeConversation.currNode = assistantMessage.id;
await this.refreshActiveMessages();
}
this.isLoading = false;
this.currentResponse = '';
},
if (onComplete) {
await onComplete(streamedContent);
}
onError: (error: Error) => {
slotsService.stopStreaming();
this.setConversationLoading(assistantMessage.convId, false);
this.clearConversationStreaming(assistantMessage.convId);
slotsService.clearConversationState(assistantMessage.convId);
},
if (error.name === 'AbortError' || error instanceof DOMException) {
this.isLoading = false;
this.currentResponse = '';
return;
}
onError: (error: Error) => {
slotsService.stopStreaming();
console.error('Streaming error:', error);
this.isLoading = false;
this.currentResponse = '';
if (this.isAbortError(error)) {
this.setConversationLoading(assistantMessage.convId, false);
this.clearConversationStreaming(assistantMessage.convId);
slotsService.clearConversationState(assistantMessage.convId);
return;
}
const messageIndex = this.activeMessages.findIndex(
(m: DatabaseMessage) => m.id === assistantMessage.id
);
console.error('Streaming error:', error);
this.setConversationLoading(assistantMessage.convId, false);
this.clearConversationStreaming(assistantMessage.convId);
slotsService.clearConversationState(assistantMessage.convId);
if (messageIndex !== -1) {
const [failedMessage] = this.activeMessages.splice(messageIndex, 1);
const messageIndex = this.activeMessages.findIndex(
(m: DatabaseMessage) => m.id === assistantMessage.id
);
if (failedMessage) {
DatabaseStore.deleteMessage(failedMessage.id).catch((cleanupError) => {
console.error('Failed to remove assistant message after error:', cleanupError);
});
if (messageIndex !== -1) {
const [failedMessage] = this.activeMessages.splice(messageIndex, 1);
if (failedMessage) {
DatabaseStore.deleteMessage(failedMessage.id).catch((cleanupError) => {
console.error('Failed to remove assistant message after error:', cleanupError);
});
}
}
const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
this.showErrorDialog(dialogType, error.message);
if (onError) {
onError(error);
}
}
const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
this.showErrorDialog(dialogType, error.message);
if (onError) {
onError(error);
}
}
});
}
private showErrorDialog(type: 'timeout' | 'server', message: string): void {
this.errorDialogState = { type, message };
}
dismissErrorDialog(): void {
this.errorDialogState = null;
},
assistantMessage.convId
);
}
/**
@@ -455,6 +521,14 @@ class ChatStore {
return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
}
private showErrorDialog(type: 'timeout' | 'server', message: string): void {
this.errorDialogState = { type, message };
}
dismissErrorDialog(): void {
this.errorDialogState = null;
}
/**
* Finds the index of a message in the active messages array
* @param messageId - The message ID to find
@@ -519,7 +593,12 @@ class ChatStore {
* @param extras - Optional extra data (files, attachments, etc.)
*/
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
if ((!content.trim() && (!extras || extras.length === 0)) || this.isLoading) return;
if (!content.trim() && (!extras || extras.length === 0)) return;
if (this.activeConversation && this.isConversationLoading(this.activeConversation.id)) {
console.log('Cannot send message: current conversation is already processing a message');
return;
}
let isNewConversation = false;
@@ -534,8 +613,9 @@ class ChatStore {
}
this.errorDialogState = null;
this.isLoading = true;
this.currentResponse = '';
this.setConversationLoading(this.activeConversation.id, true);
this.clearConversationStreaming(this.activeConversation.id);
let userMessage: DatabaseMessage | null = null;
@@ -546,7 +626,6 @@ class ChatStore {
throw new Error('Failed to add user message');
}
// If this is a new conversation, update the title with the first user prompt
if (isNewConversation && content) {
const title = content.trim();
await this.updateConversationName(this.activeConversation.id, title);
@@ -559,19 +638,18 @@ class ChatStore {
}
this.activeMessages.push(assistantMessage);
// Don't update currNode until after streaming completes to maintain proper conversation path
const conversationContext = this.activeMessages.slice(0, -1);
await this.streamChatCompletion(conversationContext, assistantMessage);
} catch (error) {
if (this.isAbortError(error)) {
this.isLoading = false;
this.setConversationLoading(this.activeConversation!.id, false);
return;
}
console.error('Failed to send message:', error);
this.isLoading = false;
this.setConversationLoading(this.activeConversation!.id, false);
if (!this.errorDialogState) {
if (error instanceof Error) {
const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
@@ -587,12 +665,19 @@ class ChatStore {
* Stops the current message generation
* Aborts ongoing requests and saves partial response if available
*/
stopGeneration(): void {
async stopGeneration(): Promise<void> {
if (!this.activeConversation) return;
const convId = this.activeConversation.id;
await this.savePartialResponseIfNeeded(convId);
slotsService.stopStreaming();
chatService.abort();
this.savePartialResponseIfNeeded();
this.isLoading = false;
this.currentResponse = '';
chatService.abort(convId);
this.setConversationLoading(convId, false);
this.clearConversationStreaming(convId);
slotsService.clearConversationState(convId);
}
/**
@@ -604,6 +689,9 @@ class ChatStore {
slotsService.stopStreaming();
chatService.abort();
await this.savePartialResponseIfNeeded();
this.conversationLoadingStates.clear();
this.conversationStreamingStates.clear();
this.isLoading = false;
this.currentResponse = '';
}
@@ -612,12 +700,23 @@ class ChatStore {
* Saves partial response if generation was interrupted
* Preserves user's partial content and timing data when generation is stopped early
*/
private async savePartialResponseIfNeeded(): Promise<void> {
if (!this.currentResponse.trim() || !this.activeMessages.length) {
private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
const conversationId = convId || this.activeConversation?.id;
if (!conversationId) return;
const streamingState = this.conversationStreamingStates.get(conversationId);
if (!streamingState || !streamingState.response.trim()) {
return;
}
const lastMessage = this.activeMessages[this.activeMessages.length - 1];
const messages =
conversationId === this.activeConversation?.id
? this.activeMessages
: await DatabaseStore.getConversationMessages(conversationId);
if (!messages.length) return;
const lastMessage = messages[messages.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
try {
@@ -626,7 +725,7 @@ class ChatStore {
thinking?: string;
timings?: ChatMessageTimings;
} = {
content: this.currentResponse
content: streamingState.response
};
if (lastMessage.thinking?.trim()) {
@@ -640,7 +739,6 @@ class ChatStore {
prompt_n: lastKnownState.promptTokens || 0,
predicted_n: lastKnownState.tokensDecoded || 0,
cache_n: lastKnownState.cacheTokens || 0,
// We don't have ms data from the state, but we can estimate
predicted_ms:
lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded
? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000
@@ -701,7 +799,6 @@ class ChatStore {
this.updateMessageAtIndex(messageIndex, { content: newContent });
await DatabaseStore.updateMessage(messageId, { content: newContent });
// If this is the first user message, update the conversation title with confirmation if needed
if (isFirstUserMessage && newContent.trim()) {
await this.updateConversationTitleWithConfirmation(
this.activeConversation.id,
@@ -718,8 +815,8 @@ class ChatStore {
this.activeMessages = this.activeMessages.slice(0, messageIndex + 1);
this.updateConversationTimestamp();
this.isLoading = true;
this.currentResponse = '';
this.setConversationLoading(this.activeConversation.id, true);
this.clearConversationStreaming(this.activeConversation.id);
try {
const assistantMessage = await this.createAssistantMessage();
@@ -742,7 +839,7 @@ class ChatStore {
);
} catch (regenerateError) {
console.error('Failed to regenerate response:', regenerateError);
this.isLoading = false;
this.setConversationLoading(this.activeConversation!.id, false);
const messageIndex = this.findMessageIndex(messageId);
this.updateMessageAtIndex(messageIndex, { content: originalContent });
@@ -784,8 +881,8 @@ class ChatStore {
this.activeMessages = this.activeMessages.slice(0, messageIndex);
this.updateConversationTimestamp();
this.isLoading = true;
this.currentResponse = '';
this.setConversationLoading(this.activeConversation.id, true);
this.clearConversationStreaming(this.activeConversation.id);
try {
const parentMessageId =
@@ -806,7 +903,7 @@ class ChatStore {
await this.streamChatCompletion(conversationContext, assistantMessage);
} catch (regenerateError) {
console.error('Failed to regenerate response:', regenerateError);
this.isLoading = false;
this.setConversationLoading(this.activeConversation!.id, false);
}
} catch (error) {
if (this.isAbortError(error)) return;
@@ -862,7 +959,6 @@ class ChatStore {
try {
const currentConfig = config();
// Only ask for confirmation if the setting is enabled and callback is provided
if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
const conversation = await DatabaseStore.getConversation(convId);
if (!conversation) return false;
@@ -1170,14 +1266,16 @@ class ChatStore {
}
/**
* Clears the active conversation and resets state
* Clears the active conversation and messages
* Used when navigating away from chat or starting fresh
* Note: Does not stop ongoing streaming to allow background completion
*/
clearActiveConversation(): void {
this.activeConversation = null;
this.activeMessages = [];
this.currentResponse = '';
this.isLoading = false;
this.currentResponse = '';
slotsService.setActiveConversation(null);
}
/** Refreshes active messages based on currNode after branch navigation */
@@ -1419,8 +1517,8 @@ class ChatStore {
return;
}
this.isLoading = true;
this.currentResponse = '';
this.setConversationLoading(this.activeConversation.id, true);
this.clearConversationStreaming(this.activeConversation.id);
const newAssistantMessage = await DatabaseStore.createMessageBranch(
{
@@ -1454,7 +1552,7 @@ class ChatStore {
if (this.isAbortError(error)) return;
console.error('Failed to regenerate message with branching:', error);
this.isLoading = false;
this.setConversationLoading(this.activeConversation!.id, false);
}
}
@@ -1466,8 +1564,8 @@ class ChatStore {
if (!this.activeConversation) return;
this.errorDialogState = null;
this.isLoading = true;
this.currentResponse = '';
this.setConversationLoading(this.activeConversation.id, true);
this.clearConversationStreaming(this.activeConversation.id);
try {
// Get conversation path up to the user message
@@ -1499,9 +1597,30 @@ class ChatStore {
await this.streamChatCompletion(conversationPath, assistantMessage);
} catch (error) {
console.error('Failed to generate response:', error);
this.isLoading = false;
this.setConversationLoading(this.activeConversation!.id, false);
}
}
/**
* Public methods for accessing per-conversation states
*/
public isConversationLoadingPublic(convId: string): boolean {
return this.isConversationLoading(convId);
}
public getConversationStreamingPublic(
convId: string
): { response: string; messageId: string } | undefined {
return this.getConversationStreaming(convId);
}
public getAllLoadingConversations(): string[] {
return Array.from(this.conversationLoadingStates.keys());
}
public getAllStreamingConversations(): string[] {
return Array.from(this.conversationStreamingStates.keys());
}
}
export const chatStore = new ChatStore();
@@ -1541,3 +1660,11 @@ export function stopGeneration() {
chatStore.stopGeneration();
}
export const messages = () => chatStore.activeMessages;
// Per-conversation state access
export const isConversationLoading = (convId: string) =>
chatStore.isConversationLoadingPublic(convId);
export const getConversationStreaming = (convId: string) =>
chatStore.getConversationStreamingPublic(convId);
export const getAllLoadingConversations = () => chatStore.getAllLoadingConversations();
export const getAllStreamingConversations = () => chatStore.getAllStreamingConversations();