| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683 |
- import { DatabaseStore } from '$lib/stores/database';
- import { chatService, slotsService } from '$lib/services';
- import { config } from '$lib/stores/settings.svelte';
- import { normalizeModelName } from '$lib/utils/model-names';
- import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/utils/branching';
- 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';
- /**
- * ChatStore - Central state management for chat conversations and AI interactions
- *
- * This store manages the complete chat experience including:
- * - Conversation lifecycle (create, load, delete, update)
- * - Message management with branching support for conversation trees
- * - Real-time AI response streaming with reasoning content support
- * - File attachment handling and processing
- * - Context error management and recovery
- * - Database persistence through DatabaseStore integration
- *
- * **Architecture & Relationships:**
- * - **ChatService**: Handles low-level API communication with AI models
- * - ChatStore orchestrates ChatService for streaming responses
- * - ChatService provides abort capabilities and error handling
- * - ChatStore manages the UI state while ChatService handles network layer
- *
- * - **DatabaseStore**: Provides persistent storage for conversations and messages
- * - ChatStore uses DatabaseStore for all CRUD operations
- * - Maintains referential integrity for conversation trees
- * - Handles message branching and parent-child relationships
- *
- * - **SlotsService**: Monitors server resource usage during AI generation
- * - ChatStore coordinates slots polling during streaming
- * - Provides real-time feedback on server capacity
- *
- * **Key Features:**
- * - Reactive state management using Svelte 5 runes ($state)
- * - Conversation branching for exploring different response paths
- * - Streaming AI responses with real-time content updates
- * - File attachment support (images, PDFs, text files, audio)
- * - Partial response saving when generation is interrupted
- * - Message editing with automatic response regeneration
- */
- class ChatStore {
- activeConversation = $state<DatabaseConversation | null>(null);
- activeMessages = $state<DatabaseMessage[]>([]);
- conversations = $state<DatabaseConversation[]>([]);
- currentResponse = $state('');
- 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() {
- if (browser) {
- this.initialize();
- }
- }
- /**
- * Initializes the chat store by loading conversations from the database
- * Sets up the initial state and loads existing conversations
- */
- async initialize(): Promise<void> {
- try {
- await this.loadConversations();
- this.isInitialized = true;
- } catch (error) {
- console.error('Failed to initialize chat store:', error);
- }
- }
- /**
- * Loads all conversations from the database
- * Refreshes the conversations list from persistent storage
- */
- async loadConversations(): Promise<void> {
- this.conversations = await DatabaseStore.getAllConversations();
- }
- /**
- * Creates a new conversation and navigates to it
- * @param name - Optional name for the conversation, defaults to timestamped name
- * @returns The ID of the created conversation
- */
- async createConversation(name?: string): Promise<string> {
- const conversationName = name || `Chat ${new Date().toLocaleString()}`;
- const conversation = await DatabaseStore.createConversation(conversationName);
- this.conversations.unshift(conversation);
- 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;
- }
- /**
- * Loads a specific conversation and its messages
- * @param convId - The conversation ID to load
- * @returns True if conversation was loaded successfully, false otherwise
- */
- async loadConversation(convId: string): Promise<boolean> {
- try {
- const conversation = await DatabaseStore.getConversation(convId);
- if (!conversation) {
- return false;
- }
- 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(
- allMessages,
- conversation.currNode,
- false
- ) as DatabaseMessage[];
- } else {
- // Load all messages for conversations without currNode (backward compatibility)
- this.activeMessages = await DatabaseStore.getConversationMessages(convId);
- }
- return true;
- } catch (error) {
- console.error('Failed to load conversation:', error);
- return false;
- }
- }
- /**
- * Adds a new message to the active conversation
- * @param role - The role of the message sender (user/assistant)
- * @param content - The message content
- * @param type - The message type, defaults to 'text'
- * @param parent - Parent message ID, defaults to '-1' for auto-detection
- * @param extras - Optional extra data (files, attachments, etc.)
- * @returns The created message or null if failed
- */
- async addMessage(
- role: ChatRole,
- content: string,
- type: ChatMessageType = 'text',
- parent: string = '-1',
- extras?: DatabaseMessageExtra[]
- ): Promise<DatabaseMessage | null> {
- if (!this.activeConversation) {
- console.error('No active conversation when trying to add message');
- return null;
- }
- try {
- let parentId: string | null = null;
- if (parent === '-1') {
- if (this.activeMessages.length > 0) {
- parentId = this.activeMessages[this.activeMessages.length - 1].id;
- } else {
- const allMessages = await DatabaseStore.getConversationMessages(
- this.activeConversation.id
- );
- const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
- if (!rootMessage) {
- const rootId = await DatabaseStore.createRootMessage(this.activeConversation.id);
- parentId = rootId;
- } else {
- parentId = rootMessage.id;
- }
- }
- } else {
- parentId = parent;
- }
- const message = await DatabaseStore.createMessageBranch(
- {
- convId: this.activeConversation.id,
- role,
- content,
- type,
- timestamp: Date.now(),
- thinking: '',
- children: [],
- extra: extras
- },
- parentId
- );
- this.activeMessages.push(message);
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, message.id);
- this.activeConversation.currNode = message.id;
- this.updateConversationTimestamp();
- return message;
- } catch (error) {
- console.error('Failed to add message:', error);
- return null;
- }
- }
- /**
- * Gets API options from current configuration settings
- * Converts settings store values to API-compatible format
- * @returns API options object for chat completion requests
- */
- private getApiOptions(): Record<string, unknown> {
- const currentConfig = config();
- const hasValue = (value: unknown): boolean =>
- value !== undefined && value !== null && value !== '';
- const apiOptions: Record<string, unknown> = {
- stream: true,
- timings_per_token: true
- };
- if (hasValue(currentConfig.temperature)) {
- apiOptions.temperature = Number(currentConfig.temperature);
- }
- if (hasValue(currentConfig.max_tokens)) {
- apiOptions.max_tokens = Number(currentConfig.max_tokens);
- }
- if (hasValue(currentConfig.dynatemp_range)) {
- apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range);
- }
- if (hasValue(currentConfig.dynatemp_exponent)) {
- apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent);
- }
- if (hasValue(currentConfig.top_k)) {
- apiOptions.top_k = Number(currentConfig.top_k);
- }
- if (hasValue(currentConfig.top_p)) {
- apiOptions.top_p = Number(currentConfig.top_p);
- }
- if (hasValue(currentConfig.min_p)) {
- apiOptions.min_p = Number(currentConfig.min_p);
- }
- if (hasValue(currentConfig.xtc_probability)) {
- apiOptions.xtc_probability = Number(currentConfig.xtc_probability);
- }
- if (hasValue(currentConfig.xtc_threshold)) {
- apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold);
- }
- if (hasValue(currentConfig.typ_p)) {
- apiOptions.typ_p = Number(currentConfig.typ_p);
- }
- if (hasValue(currentConfig.repeat_last_n)) {
- apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n);
- }
- if (hasValue(currentConfig.repeat_penalty)) {
- apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty);
- }
- if (hasValue(currentConfig.presence_penalty)) {
- apiOptions.presence_penalty = Number(currentConfig.presence_penalty);
- }
- if (hasValue(currentConfig.frequency_penalty)) {
- apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty);
- }
- if (hasValue(currentConfig.dry_multiplier)) {
- apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier);
- }
- if (hasValue(currentConfig.dry_base)) {
- apiOptions.dry_base = Number(currentConfig.dry_base);
- }
- if (hasValue(currentConfig.dry_allowed_length)) {
- apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length);
- }
- if (hasValue(currentConfig.dry_penalty_last_n)) {
- apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n);
- }
- if (currentConfig.samplers) {
- apiOptions.samplers = currentConfig.samplers;
- }
- if (currentConfig.custom) {
- apiOptions.custom = currentConfig.custom;
- }
- 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
- * @param assistantMessage - The assistant message to stream content into
- * @param onComplete - Optional callback when streaming completes
- * @param onError - Optional callback when an error occurs
- */
- private async streamChatCompletion(
- allMessages: DatabaseMessage[],
- assistantMessage: DatabaseMessage,
- onComplete?: (content: string) => Promise<void>,
- onError?: (error: Error) => void
- ): Promise<void> {
- let streamedContent = '';
- let streamedReasoningContent = '';
- let resolvedModel: string | null = null;
- let modelPersisted = false;
- const recordModel = (modelName: string, persistImmediately = true): void => {
- const normalizedModel = normalizeModelName(modelName);
- if (!normalizedModel || normalizedModel === resolvedModel) {
- return;
- }
- resolvedModel = normalizedModel;
- const messageIndex = this.findMessageIndex(assistantMessage.id);
- this.updateMessageAtIndex(messageIndex, { model: normalizedModel });
- if (persistImmediately && !modelPersisted) {
- modelPersisted = true;
- DatabaseStore.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(
- (error) => {
- console.error('Failed to persist model name:', error);
- modelPersisted = false;
- resolvedModel = null;
- }
- );
- }
- };
- slotsService.startStreaming();
- slotsService.setActiveConversation(assistantMessage.convId);
- await chatService.sendMessage(
- allMessages,
- {
- ...this.getApiOptions(),
- onChunk: (chunk: string) => {
- streamedContent += chunk;
- this.setConversationStreaming(
- assistantMessage.convId,
- streamedContent,
- assistantMessage.id
- );
- const messageIndex = this.findMessageIndex(assistantMessage.id);
- this.updateMessageAtIndex(messageIndex, {
- content: streamedContent
- });
- },
- onReasoningChunk: (reasoningChunk: string) => {
- streamedReasoningContent += reasoningChunk;
- const messageIndex = this.findMessageIndex(assistantMessage.id);
- this.updateMessageAtIndex(messageIndex, { thinking: streamedReasoningContent });
- },
- onModel: (modelName: string) => {
- recordModel(modelName);
- },
- 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
- };
- if (resolvedModel && !modelPersisted) {
- updateData.model = resolvedModel;
- modelPersisted = true;
- }
- await DatabaseStore.updateMessage(assistantMessage.id, updateData);
- const messageIndex = this.findMessageIndex(assistantMessage.id);
- const localUpdateData: { timings?: ChatMessageTimings; model?: string } = {
- timings: timings
- };
- if (updateData.model) {
- localUpdateData.model = updateData.model;
- }
- this.updateMessageAtIndex(messageIndex, localUpdateData);
- await DatabaseStore.updateCurrentNode(assistantMessage.convId, assistantMessage.id);
- if (this.activeConversation?.id === assistantMessage.convId) {
- this.activeConversation.currNode = assistantMessage.id;
- await this.refreshActiveMessages();
- }
- if (onComplete) {
- await onComplete(streamedContent);
- }
- this.setConversationLoading(assistantMessage.convId, false);
- this.clearConversationStreaming(assistantMessage.convId);
- slotsService.clearConversationState(assistantMessage.convId);
- },
- onError: (error: Error) => {
- slotsService.stopStreaming();
- if (this.isAbortError(error)) {
- this.setConversationLoading(assistantMessage.convId, false);
- this.clearConversationStreaming(assistantMessage.convId);
- slotsService.clearConversationState(assistantMessage.convId);
- return;
- }
- console.error('Streaming error:', error);
- this.setConversationLoading(assistantMessage.convId, false);
- this.clearConversationStreaming(assistantMessage.convId);
- slotsService.clearConversationState(assistantMessage.convId);
- const messageIndex = this.activeMessages.findIndex(
- (m: DatabaseMessage) => m.id === assistantMessage.id
- );
- 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);
- }
- }
- },
- assistantMessage.convId
- );
- }
- /**
- * Checks if an error is an abort error (user cancelled operation)
- * @param error - The error to check
- * @returns True if the error is an abort error
- */
- private isAbortError(error: unknown): boolean {
- 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
- * @returns The index of the message, or -1 if not found
- */
- private findMessageIndex(messageId: string): number {
- return this.activeMessages.findIndex((m) => m.id === messageId);
- }
- /**
- * Updates a message at a specific index with partial data
- * @param index - The index of the message to update
- * @param updates - Partial message data to update
- */
- private updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
- if (index !== -1) {
- Object.assign(this.activeMessages[index], updates);
- }
- }
- /**
- * Creates a new assistant message in the database
- * @param parentId - Optional parent message ID, defaults to '-1'
- * @returns The created assistant message or null if failed
- */
- private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> {
- if (!this.activeConversation) return null;
- return await DatabaseStore.createMessageBranch(
- {
- convId: this.activeConversation.id,
- type: 'text',
- role: 'assistant',
- content: '',
- timestamp: Date.now(),
- thinking: '',
- children: [],
- model: null
- },
- parentId || null
- );
- }
- /**
- * Updates conversation lastModified timestamp and moves it to top of list
- * Ensures recently active conversations appear first in the sidebar
- */
- private updateConversationTimestamp(): void {
- if (!this.activeConversation) return;
- const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
- if (chatIndex !== -1) {
- this.conversations[chatIndex].lastModified = Date.now();
- const updatedConv = this.conversations.splice(chatIndex, 1)[0];
- this.conversations.unshift(updatedConv);
- }
- }
- /**
- * Sends a new message and generates AI response
- * @param content - The message content to send
- * @param extras - Optional extra data (files, attachments, etc.)
- */
- async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
- 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;
- if (!this.activeConversation) {
- await this.createConversation();
- isNewConversation = true;
- }
- if (!this.activeConversation) {
- console.error('No active conversation available for sending message');
- return;
- }
- this.errorDialogState = null;
- this.setConversationLoading(this.activeConversation.id, true);
- this.clearConversationStreaming(this.activeConversation.id);
- let userMessage: DatabaseMessage | null = null;
- try {
- userMessage = await this.addMessage('user', content, 'text', '-1', extras);
- if (!userMessage) {
- throw new Error('Failed to add user message');
- }
- if (isNewConversation && content) {
- const title = content.trim();
- await this.updateConversationName(this.activeConversation.id, title);
- }
- const assistantMessage = await this.createAssistantMessage(userMessage.id);
- if (!assistantMessage) {
- throw new Error('Failed to create assistant message');
- }
- this.activeMessages.push(assistantMessage);
- const conversationContext = this.activeMessages.slice(0, -1);
- await this.streamChatCompletion(conversationContext, assistantMessage);
- } catch (error) {
- if (this.isAbortError(error)) {
- this.setConversationLoading(this.activeConversation!.id, false);
- return;
- }
- console.error('Failed to send message:', error);
- this.setConversationLoading(this.activeConversation!.id, false);
- if (!this.errorDialogState) {
- if (error instanceof Error) {
- const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
- this.showErrorDialog(dialogType, error.message);
- } else {
- this.showErrorDialog('server', 'Unknown error occurred while sending message');
- }
- }
- }
- }
- /**
- * Stops the current message generation
- * Aborts ongoing requests and saves partial response if available
- */
- async stopGeneration(): Promise<void> {
- if (!this.activeConversation) return;
- const convId = this.activeConversation.id;
- await this.savePartialResponseIfNeeded(convId);
- slotsService.stopStreaming();
- chatService.abort(convId);
- this.setConversationLoading(convId, false);
- this.clearConversationStreaming(convId);
- slotsService.clearConversationState(convId);
- }
- /**
- * Gracefully stops generation and saves partial response
- */
- async gracefulStop(): Promise<void> {
- if (!this.isLoading) return;
- slotsService.stopStreaming();
- chatService.abort();
- await this.savePartialResponseIfNeeded();
- this.conversationLoadingStates.clear();
- this.conversationStreamingStates.clear();
- this.isLoading = false;
- this.currentResponse = '';
- }
- /**
- * Saves partial response if generation was interrupted
- * Preserves user's partial content and timing data when generation is stopped early
- */
- 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 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 {
- const updateData: {
- content: string;
- thinking?: string;
- timings?: ChatMessageTimings;
- } = {
- content: streamingState.response
- };
- if (lastMessage.thinking?.trim()) {
- updateData.thinking = lastMessage.thinking;
- }
- const lastKnownState = await slotsService.getCurrentState();
- if (lastKnownState) {
- updateData.timings = {
- prompt_n: lastKnownState.promptTokens || 0,
- predicted_n: lastKnownState.tokensDecoded || 0,
- cache_n: lastKnownState.cacheTokens || 0,
- predicted_ms:
- lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded
- ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000
- : undefined
- };
- }
- await DatabaseStore.updateMessage(lastMessage.id, updateData);
- lastMessage.content = this.currentResponse;
- if (updateData.thinking !== undefined) {
- lastMessage.thinking = updateData.thinking;
- }
- if (updateData.timings) {
- lastMessage.timings = updateData.timings;
- }
- } catch (error) {
- lastMessage.content = this.currentResponse;
- console.error('Failed to save partial response:', error);
- }
- } else {
- console.error('Last message is not an assistant message');
- }
- }
- /**
- * Updates a user message and regenerates the assistant response
- * @param messageId - The ID of the message to update
- * @param newContent - The new content for the message
- */
- async updateMessage(messageId: string, newContent: string): Promise<void> {
- if (!this.activeConversation) return;
- if (this.isLoading) {
- this.stopGeneration();
- }
- try {
- const messageIndex = this.findMessageIndex(messageId);
- if (messageIndex === -1) {
- console.error('Message not found for update');
- return;
- }
- const messageToUpdate = this.activeMessages[messageIndex];
- const originalContent = messageToUpdate.content;
- if (messageToUpdate.role !== 'user') {
- console.error('Only user messages can be edited');
- return;
- }
- const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
- const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
- const isFirstUserMessage =
- rootMessage && messageToUpdate.parent === rootMessage.id && messageToUpdate.role === 'user';
- this.updateMessageAtIndex(messageIndex, { content: newContent });
- await DatabaseStore.updateMessage(messageId, { content: newContent });
- if (isFirstUserMessage && newContent.trim()) {
- await this.updateConversationTitleWithConfirmation(
- this.activeConversation.id,
- newContent.trim(),
- this.titleUpdateConfirmationCallback
- );
- }
- const messagesToRemove = this.activeMessages.slice(messageIndex + 1);
- for (const message of messagesToRemove) {
- await DatabaseStore.deleteMessage(message.id);
- }
- this.activeMessages = this.activeMessages.slice(0, messageIndex + 1);
- this.updateConversationTimestamp();
- this.setConversationLoading(this.activeConversation.id, true);
- this.clearConversationStreaming(this.activeConversation.id);
- try {
- const assistantMessage = await this.createAssistantMessage();
- if (!assistantMessage) {
- throw new Error('Failed to create assistant message');
- }
- this.activeMessages.push(assistantMessage);
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, assistantMessage.id);
- this.activeConversation.currNode = assistantMessage.id;
- await this.streamChatCompletion(
- this.activeMessages.slice(0, -1),
- assistantMessage,
- undefined,
- () => {
- const editedMessageIndex = this.findMessageIndex(messageId);
- this.updateMessageAtIndex(editedMessageIndex, { content: originalContent });
- }
- );
- } catch (regenerateError) {
- console.error('Failed to regenerate response:', regenerateError);
- this.setConversationLoading(this.activeConversation!.id, false);
- const messageIndex = this.findMessageIndex(messageId);
- this.updateMessageAtIndex(messageIndex, { content: originalContent });
- }
- } catch (error) {
- if (this.isAbortError(error)) {
- return;
- }
- console.error('Failed to update message:', error);
- }
- }
- /**
- * Regenerates an assistant message with a new response
- * @param messageId - The ID of the assistant message to regenerate
- */
- async regenerateMessage(messageId: string): Promise<void> {
- if (!this.activeConversation || this.isLoading) return;
- try {
- const messageIndex = this.findMessageIndex(messageId);
- if (messageIndex === -1) {
- console.error('Message not found for regeneration');
- return;
- }
- const messageToRegenerate = this.activeMessages[messageIndex];
- if (messageToRegenerate.role !== 'assistant') {
- console.error('Only assistant messages can be regenerated');
- return;
- }
- const messagesToRemove = this.activeMessages.slice(messageIndex);
- for (const message of messagesToRemove) {
- await DatabaseStore.deleteMessage(message.id);
- }
- this.activeMessages = this.activeMessages.slice(0, messageIndex);
- this.updateConversationTimestamp();
- this.setConversationLoading(this.activeConversation.id, true);
- this.clearConversationStreaming(this.activeConversation.id);
- try {
- const parentMessageId =
- this.activeMessages.length > 0
- ? this.activeMessages[this.activeMessages.length - 1].id
- : null;
- const assistantMessage = await this.createAssistantMessage(parentMessageId);
- if (!assistantMessage) {
- throw new Error('Failed to create assistant message');
- }
- this.activeMessages.push(assistantMessage);
- const conversationContext = this.activeMessages.slice(0, -1);
- await this.streamChatCompletion(conversationContext, assistantMessage);
- } catch (regenerateError) {
- console.error('Failed to regenerate response:', regenerateError);
- this.setConversationLoading(this.activeConversation!.id, false);
- }
- } catch (error) {
- if (this.isAbortError(error)) return;
- console.error('Failed to regenerate message:', error);
- }
- }
- /**
- * Updates the name of a conversation
- * @param convId - The conversation ID to update
- * @param name - The new name for the conversation
- */
- async updateConversationName(convId: string, name: string): Promise<void> {
- try {
- await DatabaseStore.updateConversation(convId, { name });
- const convIndex = this.conversations.findIndex((c) => c.id === convId);
- if (convIndex !== -1) {
- this.conversations[convIndex].name = name;
- }
- if (this.activeConversation?.id === convId) {
- this.activeConversation.name = name;
- }
- } catch (error) {
- console.error('Failed to update conversation name:', error);
- }
- }
- /**
- * Sets the callback function for title update confirmations
- * @param callback - Function to call when confirmation is needed
- */
- setTitleUpdateConfirmationCallback(
- callback: (currentTitle: string, newTitle: string) => Promise<boolean>
- ): void {
- this.titleUpdateConfirmationCallback = callback;
- }
- /**
- * Updates conversation title with optional confirmation dialog based on settings
- * @param convId - The conversation ID to update
- * @param newTitle - The new title content
- * @param onConfirmationNeeded - Callback when user confirmation is needed
- * @returns Promise<boolean> - True if title was updated, false if cancelled
- */
- async updateConversationTitleWithConfirmation(
- convId: string,
- newTitle: string,
- onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
- ): Promise<boolean> {
- try {
- const currentConfig = config();
- if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
- const conversation = await DatabaseStore.getConversation(convId);
- if (!conversation) return false;
- const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
- if (!shouldUpdate) return false;
- }
- await this.updateConversationName(convId, newTitle);
- return true;
- } catch (error) {
- console.error('Failed to update conversation title with confirmation:', error);
- return false;
- }
- }
- /**
- * Downloads a conversation as JSON file
- * @param convId - The conversation ID to download
- */
- async downloadConversation(convId: string): Promise<void> {
- if (!this.activeConversation || this.activeConversation.id !== convId) {
- // Load the conversation if not currently active
- const conversation = await DatabaseStore.getConversation(convId);
- if (!conversation) return;
- const messages = await DatabaseStore.getConversationMessages(convId);
- const conversationData = {
- conv: conversation,
- messages
- };
- this.triggerDownload(conversationData);
- } else {
- // Use current active conversation data
- const conversationData: ExportedConversations = {
- conv: this.activeConversation!,
- messages: this.activeMessages
- };
- this.triggerDownload(conversationData);
- }
- }
- /**
- * Triggers file download in browser
- * @param data - Data to download (expected: { conv: DatabaseConversation, messages: DatabaseMessage[] })
- * @param filename - Optional filename
- */
- private triggerDownload(data: ExportedConversations, filename?: string): void {
- const conversation =
- 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
- if (!conversation) {
- console.error('Invalid data: missing conversation');
- return;
- }
- const conversationName = conversation.name ? conversation.name.trim() : '';
- const convId = conversation.id || 'unknown';
- const truncatedSuffix = conversationName
- .toLowerCase()
- .replace(/[^a-z0-9]/gi, '_')
- .replace(/_+/g, '_')
- .substring(0, 20);
- const downloadFilename = filename || `conversation_${convId}_${truncatedSuffix}.json`;
- const conversationJson = JSON.stringify(data, null, 2);
- const blob = new Blob([conversationJson], {
- type: 'application/json'
- });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = downloadFilename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- }
- /**
- * Exports all conversations with their messages as a JSON file
- * Returns the list of exported conversations
- */
- async exportAllConversations(): Promise<DatabaseConversation[]> {
- try {
- const allConversations = await DatabaseStore.getAllConversations();
- if (allConversations.length === 0) {
- throw new Error('No conversations to export');
- }
- const allData: ExportedConversations = await Promise.all(
- allConversations.map(async (conv) => {
- const messages = await DatabaseStore.getConversationMessages(conv.id);
- return { conv, messages };
- })
- );
- const blob = new Blob([JSON.stringify(allData, null, 2)], {
- type: 'application/json'
- });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- toast.success(`All conversations (${allConversations.length}) prepared for download`);
- return allConversations;
- } catch (err) {
- console.error('Failed to export conversations:', err);
- throw err;
- }
- }
- /**
- * Imports conversations from a JSON file.
- * Supports both single conversation (object) and multiple conversations (array).
- * Uses DatabaseStore for safe, encapsulated data access
- * Returns the list of imported conversations
- */
- async importConversations(): Promise<DatabaseConversation[]> {
- return new Promise((resolve, reject) => {
- const input = document.createElement('input');
- input.type = 'file';
- input.accept = '.json';
- input.onchange = async (e) => {
- const file = (e.target as HTMLInputElement)?.files?.[0];
- if (!file) {
- reject(new Error('No file selected'));
- return;
- }
- try {
- const text = await file.text();
- const parsedData = JSON.parse(text);
- let importedData: ExportedConversations;
- if (Array.isArray(parsedData)) {
- importedData = parsedData;
- } else if (
- parsedData &&
- typeof parsedData === 'object' &&
- 'conv' in parsedData &&
- 'messages' in parsedData
- ) {
- // Single conversation object
- importedData = [parsedData];
- } else {
- throw new Error(
- 'Invalid file format: expected array of conversations or single conversation object'
- );
- }
- const result = await DatabaseStore.importConversations(importedData);
- // Refresh UI
- await this.loadConversations();
- toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
- // Extract the conversation objects from imported data
- const importedConversations = importedData.map((item) => item.conv);
- resolve(importedConversations);
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : 'Unknown error';
- console.error('Failed to import conversations:', err);
- toast.error('Import failed', {
- description: message
- });
- reject(new Error(`Import failed: ${message}`));
- }
- };
- input.click();
- });
- }
- /**
- * Deletes a conversation and all its messages
- * @param convId - The conversation ID to delete
- */
- async deleteConversation(convId: string): Promise<void> {
- try {
- await DatabaseStore.deleteConversation(convId);
- this.conversations = this.conversations.filter((c) => c.id !== convId);
- if (this.activeConversation?.id === convId) {
- this.activeConversation = null;
- this.activeMessages = [];
- await goto(`?new_chat=true#/`);
- }
- } catch (error) {
- console.error('Failed to delete conversation:', error);
- }
- }
- /**
- * Gets information about what messages will be deleted when deleting a specific message
- * @param messageId - The ID of the message to be deleted
- * @returns Object with deletion info including count and types of messages
- */
- async getDeletionInfo(messageId: string): Promise<{
- totalCount: number;
- userMessages: number;
- assistantMessages: number;
- messageTypes: string[];
- }> {
- if (!this.activeConversation) {
- return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] };
- }
- const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
- const descendants = findDescendantMessages(allMessages, messageId);
- const allToDelete = [messageId, ...descendants];
- const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));
- let userMessages = 0;
- let assistantMessages = 0;
- const messageTypes: string[] = [];
- for (const msg of messagesToDelete) {
- if (msg.role === 'user') {
- userMessages++;
- if (!messageTypes.includes('user message')) messageTypes.push('user message');
- } else if (msg.role === 'assistant') {
- assistantMessages++;
- if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
- }
- }
- return {
- totalCount: allToDelete.length,
- userMessages,
- assistantMessages,
- messageTypes
- };
- }
- /**
- * Deletes a message and all its descendants, updating conversation path if needed
- * @param messageId - The ID of the message to delete
- */
- async deleteMessage(messageId: string): Promise<void> {
- try {
- if (!this.activeConversation) return;
- // Get all messages to find siblings before deletion
- const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
- const messageToDelete = allMessages.find((m) => m.id === messageId);
- if (!messageToDelete) {
- console.error('Message to delete not found');
- return;
- }
- // Check if the deleted message is in the current conversation path
- const currentPath = filterByLeafNodeId(
- allMessages,
- this.activeConversation.currNode || '',
- false
- );
- const isInCurrentPath = currentPath.some((m) => m.id === messageId);
- // If the deleted message is in the current path, we need to update currNode
- if (isInCurrentPath && messageToDelete.parent) {
- // Find all siblings (messages with same parent)
- const siblings = allMessages.filter(
- (m) => m.parent === messageToDelete.parent && m.id !== messageId
- );
- if (siblings.length > 0) {
- // Find the latest sibling (highest timestamp)
- const latestSibling = siblings.reduce((latest, sibling) =>
- sibling.timestamp > latest.timestamp ? sibling : latest
- );
- // Find the leaf node for this sibling branch to get the complete conversation path
- const leafNodeId = findLeafNode(allMessages, latestSibling.id);
- // Update conversation to use the leaf node of the latest remaining sibling
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, leafNodeId);
- this.activeConversation.currNode = leafNodeId;
- } else {
- // No siblings left, navigate to parent if it exists
- if (messageToDelete.parent) {
- const parentLeafId = findLeafNode(allMessages, messageToDelete.parent);
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, parentLeafId);
- this.activeConversation.currNode = parentLeafId;
- }
- }
- }
- // Use cascading deletion to remove the message and all its descendants
- await DatabaseStore.deleteMessageCascading(this.activeConversation.id, messageId);
- // Refresh active messages to show the updated branch
- await this.refreshActiveMessages();
- // Update conversation timestamp
- this.updateConversationTimestamp();
- } catch (error) {
- console.error('Failed to delete message:', error);
- }
- }
- /**
- * 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.isLoading = false;
- this.currentResponse = '';
- slotsService.setActiveConversation(null);
- }
- /** Refreshes active messages based on currNode after branch navigation */
- async refreshActiveMessages(): Promise<void> {
- if (!this.activeConversation) return;
- const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
- if (allMessages.length === 0) {
- this.activeMessages = [];
- return;
- }
- const leafNodeId =
- this.activeConversation.currNode ||
- allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
- const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
- this.activeMessages.length = 0;
- this.activeMessages.push(...currentPath);
- }
- /**
- * Navigates to a specific sibling branch by updating currNode and refreshing messages
- * @param siblingId - The sibling message ID to navigate to
- */
- async navigateToSibling(siblingId: string): Promise<void> {
- if (!this.activeConversation) return;
- // Get the current first user message before navigation
- const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
- const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
- const currentFirstUserMessage = this.activeMessages.find(
- (m) => m.role === 'user' && m.parent === rootMessage?.id
- );
- const currentLeafNodeId = findLeafNode(allMessages, siblingId);
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
- this.activeConversation.currNode = currentLeafNodeId;
- await this.refreshActiveMessages();
- // Only show title dialog if we're navigating between different first user message siblings
- if (rootMessage && this.activeMessages.length > 0) {
- // Find the first user message in the new active path
- const newFirstUserMessage = this.activeMessages.find(
- (m) => m.role === 'user' && m.parent === rootMessage.id
- );
- // Only show dialog if:
- // 1. We have a new first user message
- // 2. It's different from the previous one (different ID or content)
- // 3. The new message has content
- if (
- newFirstUserMessage &&
- newFirstUserMessage.content.trim() &&
- (!currentFirstUserMessage ||
- newFirstUserMessage.id !== currentFirstUserMessage.id ||
- newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
- ) {
- await this.updateConversationTitleWithConfirmation(
- this.activeConversation.id,
- newFirstUserMessage.content.trim(),
- this.titleUpdateConfirmationCallback
- );
- }
- }
- }
- /**
- * Edits an assistant message with optional branching
- * @param messageId - The ID of the assistant message to edit
- * @param newContent - The new content for the message
- * @param shouldBranch - Whether to create a branch or replace in-place
- */
- async editAssistantMessage(
- messageId: string,
- newContent: string,
- shouldBranch: boolean
- ): Promise<void> {
- if (!this.activeConversation || this.isLoading) return;
- try {
- const messageIndex = this.findMessageIndex(messageId);
- if (messageIndex === -1) {
- console.error('Message not found for editing');
- return;
- }
- const messageToEdit = this.activeMessages[messageIndex];
- if (messageToEdit.role !== 'assistant') {
- console.error('Only assistant messages can be edited with this method');
- return;
- }
- if (shouldBranch) {
- const newMessage = await DatabaseStore.createMessageBranch(
- {
- convId: messageToEdit.convId,
- type: messageToEdit.type,
- timestamp: Date.now(),
- role: messageToEdit.role,
- content: newContent,
- thinking: messageToEdit.thinking || '',
- children: [],
- model: messageToEdit.model // Preserve original model info when branching
- },
- messageToEdit.parent!
- );
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, newMessage.id);
- this.activeConversation.currNode = newMessage.id;
- } else {
- await DatabaseStore.updateMessage(messageToEdit.id, {
- content: newContent,
- timestamp: Date.now()
- });
- this.updateMessageAtIndex(messageIndex, {
- content: newContent,
- timestamp: Date.now()
- });
- }
- this.updateConversationTimestamp();
- await this.refreshActiveMessages();
- } catch (error) {
- console.error('Failed to edit assistant message:', error);
- }
- }
- /**
- * Edits a message by creating a new branch with the edited content
- * @param messageId - The ID of the message to edit
- * @param newContent - The new content for the message
- */
- async editMessageWithBranching(messageId: string, newContent: string): Promise<void> {
- if (!this.activeConversation || this.isLoading) return;
- try {
- const messageIndex = this.findMessageIndex(messageId);
- if (messageIndex === -1) {
- console.error('Message not found for editing');
- return;
- }
- const messageToEdit = this.activeMessages[messageIndex];
- if (messageToEdit.role !== 'user') {
- console.error('Only user messages can be edited');
- return;
- }
- // Check if this is the first user message in the conversation
- // First user message is one that has the root message as its parent
- const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
- const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
- const isFirstUserMessage =
- rootMessage && messageToEdit.parent === rootMessage.id && messageToEdit.role === 'user';
- let parentId = messageToEdit.parent;
- if (parentId === undefined || parentId === null) {
- const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
- if (rootMessage) {
- parentId = rootMessage.id;
- } else {
- console.error('No root message found for editing');
- return;
- }
- }
- const newMessage = await DatabaseStore.createMessageBranch(
- {
- convId: messageToEdit.convId,
- type: messageToEdit.type,
- timestamp: Date.now(),
- role: messageToEdit.role,
- content: newContent,
- thinking: messageToEdit.thinking || '',
- children: [],
- extra: messageToEdit.extra ? JSON.parse(JSON.stringify(messageToEdit.extra)) : undefined,
- model: messageToEdit.model // Preserve original model info when branching
- },
- parentId
- );
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, newMessage.id);
- this.activeConversation.currNode = newMessage.id;
- this.updateConversationTimestamp();
- // 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,
- newContent.trim(),
- this.titleUpdateConfirmationCallback
- );
- }
- await this.refreshActiveMessages();
- if (messageToEdit.role === 'user') {
- await this.generateResponseForMessage(newMessage.id);
- }
- } catch (error) {
- console.error('Failed to edit message with branching:', error);
- }
- }
- /**
- * Regenerates an assistant message by creating a new branch with a new response
- * @param messageId - The ID of the assistant message to regenerate
- */
- async regenerateMessageWithBranching(messageId: string): Promise<void> {
- if (!this.activeConversation || this.isLoading) return;
- try {
- const messageIndex = this.findMessageIndex(messageId);
- if (messageIndex === -1) {
- console.error('Message not found for regeneration');
- return;
- }
- const messageToRegenerate = this.activeMessages[messageIndex];
- if (messageToRegenerate.role !== 'assistant') {
- console.error('Only assistant messages can be regenerated');
- return;
- }
- // Find parent message in all conversation messages, not just active path
- const conversationMessages = await DatabaseStore.getConversationMessages(
- this.activeConversation.id
- );
- const parentMessage = conversationMessages.find((m) => m.id === messageToRegenerate.parent);
- if (!parentMessage) {
- console.error('Parent message not found for regeneration');
- return;
- }
- this.setConversationLoading(this.activeConversation.id, true);
- this.clearConversationStreaming(this.activeConversation.id);
- const newAssistantMessage = await DatabaseStore.createMessageBranch(
- {
- convId: this.activeConversation.id,
- type: 'text',
- timestamp: Date.now(),
- role: 'assistant',
- content: '',
- thinking: '',
- children: [],
- model: null
- },
- parentMessage.id
- );
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, newAssistantMessage.id);
- this.activeConversation.currNode = newAssistantMessage.id;
- this.updateConversationTimestamp();
- await this.refreshActiveMessages();
- const allConversationMessages = await DatabaseStore.getConversationMessages(
- this.activeConversation.id
- );
- const conversationPath = filterByLeafNodeId(
- allConversationMessages,
- parentMessage.id,
- false
- ) as DatabaseMessage[];
- await this.streamChatCompletion(conversationPath, newAssistantMessage);
- } catch (error) {
- if (this.isAbortError(error)) return;
- console.error('Failed to regenerate message with branching:', error);
- this.setConversationLoading(this.activeConversation!.id, false);
- }
- }
- /**
- * Generates a new assistant response for a given user message
- * @param userMessageId - ID of user message to respond to
- */
- private async generateResponseForMessage(userMessageId: string): Promise<void> {
- if (!this.activeConversation) return;
- this.errorDialogState = null;
- this.setConversationLoading(this.activeConversation.id, true);
- this.clearConversationStreaming(this.activeConversation.id);
- try {
- // Get conversation path up to the user message
- const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
- const conversationPath = filterByLeafNodeId(
- allMessages,
- userMessageId,
- false
- ) as DatabaseMessage[];
- // Create new assistant message branch
- const assistantMessage = await DatabaseStore.createMessageBranch(
- {
- convId: this.activeConversation.id,
- type: 'text',
- timestamp: Date.now(),
- role: 'assistant',
- content: '',
- thinking: '',
- children: [],
- model: null
- },
- userMessageId
- );
- // Add assistant message to active messages immediately for UI reactivity
- this.activeMessages.push(assistantMessage);
- // Stream response to new assistant message
- await this.streamChatCompletion(conversationPath, assistantMessage);
- } catch (error) {
- console.error('Failed to generate response:', error);
- 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();
- export const conversations = () => chatStore.conversations;
- export const activeConversation = () => chatStore.activeConversation;
- export const activeMessages = () => chatStore.activeMessages;
- export const isLoading = () => chatStore.isLoading;
- export const currentResponse = () => chatStore.currentResponse;
- export const isInitialized = () => chatStore.isInitialized;
- export const errorDialog = () => chatStore.errorDialogState;
- export const createConversation = chatStore.createConversation.bind(chatStore);
- export const downloadConversation = chatStore.downloadConversation.bind(chatStore);
- export const exportAllConversations = chatStore.exportAllConversations.bind(chatStore);
- export const importConversations = chatStore.importConversations.bind(chatStore);
- export const deleteConversation = chatStore.deleteConversation.bind(chatStore);
- export const sendMessage = chatStore.sendMessage.bind(chatStore);
- export const dismissErrorDialog = chatStore.dismissErrorDialog.bind(chatStore);
- export const gracefulStop = chatStore.gracefulStop.bind(chatStore);
- // Branching operations
- export const refreshActiveMessages = chatStore.refreshActiveMessages.bind(chatStore);
- export const navigateToSibling = chatStore.navigateToSibling.bind(chatStore);
- export const editAssistantMessage = chatStore.editAssistantMessage.bind(chatStore);
- export const editMessageWithBranching = chatStore.editMessageWithBranching.bind(chatStore);
- export const regenerateMessageWithBranching =
- chatStore.regenerateMessageWithBranching.bind(chatStore);
- export const deleteMessage = chatStore.deleteMessage.bind(chatStore);
- export const getDeletionInfo = chatStore.getDeletionInfo.bind(chatStore);
- export const updateConversationName = chatStore.updateConversationName.bind(chatStore);
- export const setTitleUpdateConfirmationCallback =
- chatStore.setTitleUpdateConfirmationCallback.bind(chatStore);
- 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();
|