397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
/**
|
|
* chatStore - Reactive State Store for Chat Operations
|
|
*
|
|
* This store contains ONLY reactive state ($state, $derived).
|
|
* All business logic is delegated to ChatClient.
|
|
*
|
|
* **Architecture & Relationships:**
|
|
* - **ChatClient**: Business logic facade (streaming, message ops, branching)
|
|
* - **ChatService**: Stateless API layer (sendMessage)
|
|
* - **chatStore** (this): Reactive state for UI components
|
|
*
|
|
* **Responsibilities:**
|
|
* - Hold reactive state for UI binding
|
|
* - Provide getters for computed values
|
|
* - Expose setters for ChatClient to update state
|
|
* - Forward method calls to ChatClient
|
|
*
|
|
* @see ChatClient in clients/chat/ for business logic
|
|
* @see ChatService in services/chat.ts for API operations
|
|
*/
|
|
|
|
import { SvelteMap } from 'svelte/reactivity';
|
|
import { browser } from '$app/environment';
|
|
import { chatClient } from '$lib/clients';
|
|
import type {
|
|
ApiProcessingState,
|
|
ErrorDialogState,
|
|
DatabaseMessage,
|
|
DatabaseMessageExtra
|
|
} from '$lib/types';
|
|
import { MessageRole, MessageType } from '$lib/enums';
|
|
|
|
class ChatStore {
|
|
activeProcessingState = $state<ApiProcessingState | null>(null);
|
|
currentResponse = $state('');
|
|
errorDialogState = $state<ErrorDialogState | null>(null);
|
|
isLoading = $state(false);
|
|
chatLoadingStates = new SvelteMap<string, boolean>();
|
|
chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
|
|
private abortControllers = new SvelteMap<string, AbortController>();
|
|
private processingStates = new SvelteMap<string, ApiProcessingState | null>();
|
|
private activeConversationId = $state<string | null>(null);
|
|
private isStreamingActive = $state(false);
|
|
private isEditModeActive = $state(false);
|
|
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
|
|
pendingEditMessageId = $state<string | null>(null);
|
|
|
|
// Draft preservation for navigation (e.g., when adding system prompt from welcome page)
|
|
private _pendingDraftMessage = $state<string>('');
|
|
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
|
|
|
|
constructor() {
|
|
if (browser) {
|
|
chatClient.setStoreCallbacks({
|
|
setChatLoading: (convId, loading) => this.setChatLoading(convId, loading),
|
|
setChatStreaming: (convId, response, messageId) =>
|
|
this.setChatStreaming(convId, response, messageId),
|
|
clearChatStreaming: (convId) => this.clearChatStreaming(convId),
|
|
getChatStreaming: (convId) => this.getChatStreaming(convId),
|
|
setProcessingState: (convId, state) => this.setProcessingState(convId, state),
|
|
getProcessingState: (convId) => this.getProcessingState(convId),
|
|
setActiveProcessingConversation: (convId) => this.setActiveProcessingConversation(convId),
|
|
setStreamingActive: (active) => this.setStreamingActive(active),
|
|
showErrorDialog: (state) => this.showErrorDialog(state),
|
|
getAbortController: (convId) => this.getOrCreateAbortController(convId),
|
|
abortRequest: (convId) => this.abortRequest(convId),
|
|
setPendingEditMessageId: (messageId) => (this.pendingEditMessageId = messageId),
|
|
getActiveConversationId: () => this.activeConversationId,
|
|
getCurrentResponse: () => this.currentResponse
|
|
});
|
|
}
|
|
}
|
|
|
|
private setChatLoading(convId: string, loading: boolean): void {
|
|
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
|
|
if (loading) {
|
|
this.chatLoadingStates.set(convId, true);
|
|
if (conversationsStore.activeConversation?.id === convId) this.isLoading = true;
|
|
} else {
|
|
this.chatLoadingStates.delete(convId);
|
|
if (conversationsStore.activeConversation?.id === convId) this.isLoading = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
private setChatStreaming(convId: string, response: string, messageId: string): void {
|
|
this.chatStreamingStates.set(convId, { response, messageId });
|
|
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
|
|
if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response;
|
|
});
|
|
}
|
|
|
|
private clearChatStreaming(convId: string): void {
|
|
this.chatStreamingStates.delete(convId);
|
|
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
|
|
if (conversationsStore.activeConversation?.id === convId) this.currentResponse = '';
|
|
});
|
|
}
|
|
|
|
private getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
|
|
return this.chatStreamingStates.get(convId);
|
|
}
|
|
|
|
syncLoadingStateForChat(convId: string): void {
|
|
this.isLoading = this.chatLoadingStates.get(convId) || false;
|
|
const streamingState = this.chatStreamingStates.get(convId);
|
|
this.currentResponse = streamingState?.response || '';
|
|
|
|
// If there's an active stream for this conversation, update the message content
|
|
// This ensures streaming content is visible when switching back to a conversation
|
|
if (streamingState?.response && streamingState?.messageId) {
|
|
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
|
|
const idx = conversationsStore.findMessageIndex(streamingState.messageId);
|
|
if (idx !== -1) {
|
|
conversationsStore.updateMessageAtIndex(idx, { content: streamingState.response });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
clearUIState(): void {
|
|
this.isLoading = false;
|
|
this.currentResponse = '';
|
|
}
|
|
|
|
setActiveProcessingConversation(conversationId: string | null): void {
|
|
this.activeConversationId = conversationId;
|
|
|
|
if (conversationId) {
|
|
this.activeProcessingState = this.processingStates.get(conversationId) || null;
|
|
} else {
|
|
this.activeProcessingState = null;
|
|
}
|
|
}
|
|
|
|
getProcessingState(conversationId: string): ApiProcessingState | null {
|
|
return this.processingStates.get(conversationId) || null;
|
|
}
|
|
|
|
private setProcessingState(conversationId: string, state: ApiProcessingState | null): void {
|
|
if (state === null) {
|
|
this.processingStates.delete(conversationId);
|
|
} else {
|
|
this.processingStates.set(conversationId, state);
|
|
}
|
|
|
|
if (conversationId === this.activeConversationId) {
|
|
this.activeProcessingState = state;
|
|
}
|
|
}
|
|
|
|
clearProcessingState(conversationId: string): void {
|
|
this.processingStates.delete(conversationId);
|
|
|
|
if (conversationId === this.activeConversationId) {
|
|
this.activeProcessingState = null;
|
|
}
|
|
}
|
|
|
|
getActiveProcessingState(): ApiProcessingState | null {
|
|
return this.activeProcessingState;
|
|
}
|
|
|
|
getCurrentProcessingStateSync(): ApiProcessingState | null {
|
|
return this.activeProcessingState;
|
|
}
|
|
|
|
private setStreamingActive(active: boolean): void {
|
|
this.isStreamingActive = active;
|
|
}
|
|
|
|
isStreaming(): boolean {
|
|
return this.isStreamingActive;
|
|
}
|
|
|
|
private getOrCreateAbortController(convId: string): AbortController {
|
|
let controller = this.abortControllers.get(convId);
|
|
if (!controller || controller.signal.aborted) {
|
|
controller = new AbortController();
|
|
this.abortControllers.set(convId, controller);
|
|
}
|
|
return controller;
|
|
}
|
|
|
|
private abortRequest(convId?: string): void {
|
|
if (convId) {
|
|
const controller = this.abortControllers.get(convId);
|
|
if (controller) {
|
|
controller.abort();
|
|
this.abortControllers.delete(convId);
|
|
}
|
|
} else {
|
|
for (const controller of this.abortControllers.values()) {
|
|
controller.abort();
|
|
}
|
|
this.abortControllers.clear();
|
|
}
|
|
}
|
|
|
|
private showErrorDialog(state: ErrorDialogState | null): void {
|
|
this.errorDialogState = state;
|
|
}
|
|
|
|
dismissErrorDialog(): void {
|
|
this.errorDialogState = null;
|
|
}
|
|
|
|
clearEditMode(): void {
|
|
this.isEditModeActive = false;
|
|
this.addFilesHandler = null;
|
|
}
|
|
|
|
isEditing(): boolean {
|
|
return this.isEditModeActive;
|
|
}
|
|
|
|
setEditModeActive(handler: (files: File[]) => void): void {
|
|
this.isEditModeActive = true;
|
|
this.addFilesHandler = handler;
|
|
}
|
|
|
|
getAddFilesHandler(): ((files: File[]) => void) | null {
|
|
return this.addFilesHandler;
|
|
}
|
|
|
|
clearPendingEditMessageId(): void {
|
|
this.pendingEditMessageId = null;
|
|
}
|
|
|
|
savePendingDraft(message: string, files: ChatUploadedFile[]): void {
|
|
this._pendingDraftMessage = message;
|
|
this._pendingDraftFiles = [...files];
|
|
}
|
|
|
|
consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
|
|
if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const draft = {
|
|
message: this._pendingDraftMessage,
|
|
files: [...this._pendingDraftFiles]
|
|
};
|
|
|
|
this._pendingDraftMessage = '';
|
|
this._pendingDraftFiles = [];
|
|
|
|
return draft;
|
|
}
|
|
|
|
hasPendingDraft(): boolean {
|
|
return Boolean(this._pendingDraftMessage) || this._pendingDraftFiles.length > 0;
|
|
}
|
|
|
|
getAllLoadingChats(): string[] {
|
|
return Array.from(this.chatLoadingStates.keys());
|
|
}
|
|
|
|
getAllStreamingChats(): string[] {
|
|
return Array.from(this.chatStreamingStates.keys());
|
|
}
|
|
|
|
getChatStreamingPublic(convId: string): { response: string; messageId: string } | undefined {
|
|
return this.getChatStreaming(convId);
|
|
}
|
|
|
|
isChatLoadingPublic(convId: string): boolean {
|
|
return this.chatLoadingStates.get(convId) || false;
|
|
}
|
|
|
|
async addMessage(
|
|
role: MessageRole,
|
|
content: string,
|
|
type: MessageType = MessageType.TEXT,
|
|
parent: string = '-1',
|
|
extras?: DatabaseMessageExtra[]
|
|
): Promise<DatabaseMessage | null> {
|
|
return chatClient.addMessage(role, content, type, parent, extras);
|
|
}
|
|
|
|
async addSystemPrompt(): Promise<void> {
|
|
return chatClient.addSystemPrompt();
|
|
}
|
|
|
|
async removeSystemPromptPlaceholder(messageId: string): Promise<boolean> {
|
|
return chatClient.removeSystemPromptPlaceholder(messageId);
|
|
}
|
|
|
|
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
|
|
return chatClient.sendMessage(content, extras);
|
|
}
|
|
|
|
async stopGeneration(): Promise<void> {
|
|
return chatClient.stopGeneration();
|
|
}
|
|
|
|
async stopGenerationForChat(convId: string): Promise<void> {
|
|
return chatClient.stopGenerationForChat(convId);
|
|
}
|
|
|
|
async updateMessage(messageId: string, newContent: string): Promise<void> {
|
|
return chatClient.updateMessage(messageId, newContent);
|
|
}
|
|
|
|
async regenerateMessage(messageId: string): Promise<void> {
|
|
return chatClient.regenerateMessage(messageId);
|
|
}
|
|
|
|
async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
|
|
return chatClient.regenerateMessageWithBranching(messageId, modelOverride);
|
|
}
|
|
|
|
async getDeletionInfo(messageId: string): Promise<{
|
|
totalCount: number;
|
|
userMessages: number;
|
|
assistantMessages: number;
|
|
messageTypes: string[];
|
|
}> {
|
|
return chatClient.getDeletionInfo(messageId);
|
|
}
|
|
|
|
async deleteMessage(messageId: string): Promise<void> {
|
|
return chatClient.deleteMessage(messageId);
|
|
}
|
|
|
|
async continueAssistantMessage(messageId: string): Promise<void> {
|
|
return chatClient.continueAssistantMessage(messageId);
|
|
}
|
|
|
|
async editAssistantMessage(
|
|
messageId: string,
|
|
newContent: string,
|
|
shouldBranch: boolean
|
|
): Promise<void> {
|
|
return chatClient.editAssistantMessage(messageId, newContent, shouldBranch);
|
|
}
|
|
|
|
async editUserMessagePreserveResponses(
|
|
messageId: string,
|
|
newContent: string,
|
|
newExtras?: DatabaseMessageExtra[]
|
|
): Promise<void> {
|
|
return chatClient.editUserMessagePreserveResponses(messageId, newContent, newExtras);
|
|
}
|
|
|
|
async editMessageWithBranching(
|
|
messageId: string,
|
|
newContent: string,
|
|
newExtras?: DatabaseMessageExtra[]
|
|
): Promise<void> {
|
|
return chatClient.editMessageWithBranching(messageId, newContent, newExtras);
|
|
}
|
|
|
|
updateProcessingStateFromTimings(
|
|
timingData: {
|
|
prompt_n: number;
|
|
prompt_ms?: number;
|
|
predicted_n: number;
|
|
predicted_per_second: number;
|
|
cache_n: number;
|
|
prompt_progress?: {
|
|
total: number;
|
|
cache: number;
|
|
processed: number;
|
|
time_ms: number;
|
|
};
|
|
},
|
|
conversationId?: string
|
|
): void {
|
|
chatClient.updateProcessingStateFromTimings(timingData, conversationId);
|
|
}
|
|
|
|
restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void {
|
|
chatClient.restoreProcessingStateFromMessages(messages, conversationId);
|
|
}
|
|
|
|
getConversationModel(messages: DatabaseMessage[]): string | null {
|
|
return chatClient.getConversationModel(messages);
|
|
}
|
|
}
|
|
|
|
export const chatStore = new ChatStore();
|
|
|
|
// State access functions (getters only - use chatStore.method() for actions)
|
|
export const activeProcessingState = () => chatStore.activeProcessingState;
|
|
export const currentResponse = () => chatStore.currentResponse;
|
|
export const errorDialog = () => chatStore.errorDialogState;
|
|
export const getAddFilesHandler = () => chatStore.getAddFilesHandler();
|
|
export const getAllLoadingChats = () => chatStore.getAllLoadingChats();
|
|
export const getAllStreamingChats = () => chatStore.getAllStreamingChats();
|
|
export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId);
|
|
export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId);
|
|
export const isChatStreaming = () => chatStore.isStreaming();
|
|
export const isEditing = () => chatStore.isEditing();
|
|
export const isLoading = () => chatStore.isLoading;
|
|
export const pendingEditMessageId = () => chatStore.pendingEditMessageId;
|