1import { DatabaseService, ChatService } from '$lib/services';
   2import { conversationsStore } from '$lib/stores/conversations.svelte';
   3import { config } from '$lib/stores/settings.svelte';
   4import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
   5import {
   6	selectedModelName,
   7	modelsStore,
   8	selectedModelContextSize
   9} from '$lib/stores/models.svelte';
  10import {
  11	normalizeModelName,
  12	filterByLeafNodeId,
  13	findDescendantMessages,
  14	findLeafNode
  15} from '$lib/utils';
  16import { SvelteMap } from 'svelte/reactivity';
  17import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
  18
  19/**
  20 * chatStore - Active AI interaction and streaming state management
  21 *
  22 * **Terminology - Chat vs Conversation:**
  23 * - **Chat**: The active interaction space with the Chat Completions API. Represents the
  24 *   real-time streaming session, loading states, and UI visualization of AI communication.
  25 *   A "chat" is ephemeral - it exists only while the user is actively interacting with the AI.
  26 * - **Conversation**: The persistent database entity storing all messages and metadata.
  27 *   Managed by conversationsStore, conversations persist across sessions and page reloads.
  28 *
  29 * This store manages all active AI interactions including real-time streaming, response
  30 * generation, and per-chat loading states. It handles the runtime layer between UI and
  31 * AI backend, supporting concurrent streaming across multiple conversations.
  32 *
  33 * **Architecture & Relationships:**
  34 * - **chatStore** (this class): Active AI session and streaming management
  35 *   - Manages real-time AI response streaming via ChatService
  36 *   - Tracks per-chat loading and streaming states for concurrent sessions
  37 *   - Handles message operations (send, edit, regenerate, branch)
  38 *   - Coordinates with conversationsStore for persistence
  39 *
  40 * - **conversationsStore**: Provides conversation data and message arrays for chat context
  41 * - **ChatService**: Low-level API communication with llama.cpp server
  42 * - **DatabaseService**: Message persistence and retrieval
  43 *
  44 * **Key Features:**
  45 * - **AI Streaming**: Real-time token streaming with abort support
  46 * - **Concurrent Chats**: Independent loading/streaming states per conversation
  47 * - **Message Branching**: Edit, regenerate, and branch conversation trees
  48 * - **Error Handling**: Timeout and server error recovery with user feedback
  49 * - **Graceful Stop**: Save partial responses when stopping generation
  50 *
  51 * **State Management:**
  52 * - Global `isLoading` and `currentResponse` for active chat UI
  53 * - `chatLoadingStates` Map for per-conversation streaming tracking
  54 * - `chatStreamingStates` Map for per-conversation streaming content
  55 * - `processingStates` Map for per-conversation processing state (timing/context info)
  56 * - Automatic state sync when switching between conversations
  57 */
  58class ChatStore {
  59	// ─────────────────────────────────────────────────────────────────────────────
  60	// State
  61	// ─────────────────────────────────────────────────────────────────────────────
  62
  63	activeProcessingState = $state<ApiProcessingState | null>(null);
  64	currentResponse = $state('');
  65	errorDialogState = $state<{
  66		type: 'timeout' | 'server';
  67		message: string;
  68		contextInfo?: { n_prompt_tokens: number; n_ctx: number };
  69	} | null>(null);
  70	isLoading = $state(false);
  71	chatLoadingStates = new SvelteMap<string, boolean>();
  72	chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
  73	private abortControllers = new SvelteMap<string, AbortController>();
  74	private processingStates = new SvelteMap<string, ApiProcessingState | null>();
  75	private activeConversationId = $state<string | null>(null);
  76	private isStreamingActive = $state(false);
  77	private isEditModeActive = $state(false);
  78	private addFilesHandler: ((files: File[]) => void) | null = $state(null);
  79
  80	// ─────────────────────────────────────────────────────────────────────────────
  81	// Loading State
  82	// ─────────────────────────────────────────────────────────────────────────────
  83
  84	private setChatLoading(convId: string, loading: boolean): void {
  85		if (loading) {
  86			this.chatLoadingStates.set(convId, true);
  87			if (conversationsStore.activeConversation?.id === convId) this.isLoading = true;
  88		} else {
  89			this.chatLoadingStates.delete(convId);
  90			if (conversationsStore.activeConversation?.id === convId) this.isLoading = false;
  91		}
  92	}
  93
  94	private isChatLoading(convId: string): boolean {
  95		return this.chatLoadingStates.get(convId) || false;
  96	}
  97
  98	private setChatStreaming(convId: string, response: string, messageId: string): void {
  99		this.chatStreamingStates.set(convId, { response, messageId });
 100		if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response;
 101	}
 102
 103	private clearChatStreaming(convId: string): void {
 104		this.chatStreamingStates.delete(convId);
 105		if (conversationsStore.activeConversation?.id === convId) this.currentResponse = '';
 106	}
 107
 108	private getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
 109		return this.chatStreamingStates.get(convId);
 110	}
 111
 112	syncLoadingStateForChat(convId: string): void {
 113		this.isLoading = this.isChatLoading(convId);
 114		const streamingState = this.getChatStreaming(convId);
 115		this.currentResponse = streamingState?.response || '';
 116	}
 117
 118	/**
 119	 * Clears global UI state without affecting background streaming.
 120	 * Used when navigating to empty/new chat while other chats stream in background.
 121	 */
 122	clearUIState(): void {
 123		this.isLoading = false;
 124		this.currentResponse = '';
 125	}
 126
 127	// ─────────────────────────────────────────────────────────────────────────────
 128	// Processing State
 129	// ─────────────────────────────────────────────────────────────────────────────
 130
 131	/**
 132	 * Set the active conversation for statistics display
 133	 */
 134	setActiveProcessingConversation(conversationId: string | null): void {
 135		this.activeConversationId = conversationId;
 136
 137		if (conversationId) {
 138			this.activeProcessingState = this.processingStates.get(conversationId) || null;
 139		} else {
 140			this.activeProcessingState = null;
 141		}
 142	}
 143
 144	/**
 145	 * Get processing state for a specific conversation
 146	 */
 147	getProcessingState(conversationId: string): ApiProcessingState | null {
 148		return this.processingStates.get(conversationId) || null;
 149	}
 150
 151	/**
 152	 * Clear processing state for a specific conversation
 153	 */
 154	clearProcessingState(conversationId: string): void {
 155		this.processingStates.delete(conversationId);
 156
 157		if (conversationId === this.activeConversationId) {
 158			this.activeProcessingState = null;
 159		}
 160	}
 161
 162	/**
 163	 * Get the current processing state for the active conversation (reactive)
 164	 * Returns the direct reactive state for UI binding
 165	 */
 166	getActiveProcessingState(): ApiProcessingState | null {
 167		return this.activeProcessingState;
 168	}
 169
 170	/**
 171	 * Updates processing state with timing data from streaming response
 172	 */
 173	updateProcessingStateFromTimings(
 174		timingData: {
 175			prompt_n: number;
 176			prompt_ms?: number;
 177			predicted_n: number;
 178			predicted_per_second: number;
 179			cache_n: number;
 180			prompt_progress?: ChatMessagePromptProgress;
 181		},
 182		conversationId?: string
 183	): void {
 184		const processingState = this.parseTimingData(timingData);
 185
 186		if (processingState === null) {
 187			console.warn('Failed to parse timing data - skipping update');
 188			return;
 189		}
 190
 191		const targetId = conversationId || this.activeConversationId;
 192		if (targetId) {
 193			this.processingStates.set(targetId, processingState);
 194
 195			if (targetId === this.activeConversationId) {
 196				this.activeProcessingState = processingState;
 197			}
 198		}
 199	}
 200
 201	/**
 202	 * Get current processing state (sync version for reactive access)
 203	 */
 204	getCurrentProcessingStateSync(): ApiProcessingState | null {
 205		return this.activeProcessingState;
 206	}
 207
 208	/**
 209	 * Restore processing state from last assistant message timings
 210	 * Call this when keepStatsVisible is enabled and we need to show last known stats
 211	 */
 212	restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void {
 213		for (let i = messages.length - 1; i >= 0; i--) {
 214			const message = messages[i];
 215			if (message.role === 'assistant' && message.timings) {
 216				const restoredState = this.parseTimingData({
 217					prompt_n: message.timings.prompt_n || 0,
 218					prompt_ms: message.timings.prompt_ms,
 219					predicted_n: message.timings.predicted_n || 0,
 220					predicted_per_second:
 221						message.timings.predicted_n && message.timings.predicted_ms
 222							? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
 223							: 0,
 224					cache_n: message.timings.cache_n || 0
 225				});
 226
 227				if (restoredState) {
 228					this.processingStates.set(conversationId, restoredState);
 229
 230					if (conversationId === this.activeConversationId) {
 231						this.activeProcessingState = restoredState;
 232					}
 233
 234					return;
 235				}
 236			}
 237		}
 238	}
 239
 240	// ─────────────────────────────────────────────────────────────────────────────
 241	// Streaming
 242	// ─────────────────────────────────────────────────────────────────────────────
 243
 244	/**
 245	 * Start streaming session tracking
 246	 */
 247	startStreaming(): void {
 248		this.isStreamingActive = true;
 249	}
 250
 251	/**
 252	 * Stop streaming session tracking
 253	 */
 254	stopStreaming(): void {
 255		this.isStreamingActive = false;
 256	}
 257
 258	/**
 259	 * Check if currently in a streaming session
 260	 */
 261	isStreaming(): boolean {
 262		return this.isStreamingActive;
 263	}
 264
 265	private getContextTotal(): number {
 266		const activeState = this.getActiveProcessingState();
 267
 268		if (activeState && activeState.contextTotal > 0) {
 269			return activeState.contextTotal;
 270		}
 271
 272		if (isRouterMode()) {
 273			const modelContextSize = selectedModelContextSize();
 274			if (modelContextSize && modelContextSize > 0) {
 275				return modelContextSize;
 276			}
 277		}
 278
 279		const propsContextSize = contextSize();
 280		if (propsContextSize && propsContextSize > 0) {
 281			return propsContextSize;
 282		}
 283
 284		return DEFAULT_CONTEXT;
 285	}
 286
 287	private parseTimingData(timingData: Record<string, unknown>): ApiProcessingState | null {
 288		const promptTokens = (timingData.prompt_n as number) || 0;
 289		const promptMs = (timingData.prompt_ms as number) || undefined;
 290		const predictedTokens = (timingData.predicted_n as number) || 0;
 291		const tokensPerSecond = (timingData.predicted_per_second as number) || 0;
 292		const cacheTokens = (timingData.cache_n as number) || 0;
 293		const promptProgress = timingData.prompt_progress as
 294			| {
 295					total: number;
 296					cache: number;
 297					processed: number;
 298					time_ms: number;
 299			  }
 300			| undefined;
 301
 302		const contextTotal = this.getContextTotal();
 303		const currentConfig = config();
 304		const outputTokensMax = currentConfig.max_tokens || -1;
 305
 306		// Note: for timings data, the n_prompt does NOT include cache tokens
 307		const contextUsed = promptTokens + cacheTokens + predictedTokens;
 308		const outputTokensUsed = predictedTokens;
 309
 310		// Note: for prompt progress, the "processed" DOES include cache tokens
 311		// we need to exclude them to get the real prompt tokens processed count
 312		const progressCache = promptProgress?.cache || 0;
 313		const progressActualDone = (promptProgress?.processed ?? 0) - progressCache;
 314		const progressActualTotal = (promptProgress?.total ?? 0) - progressCache;
 315		const progressPercent = promptProgress
 316			? Math.round((progressActualDone / progressActualTotal) * 100)
 317			: undefined;
 318
 319		return {
 320			status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
 321			tokensDecoded: predictedTokens,
 322			tokensRemaining: outputTokensMax - predictedTokens,
 323			contextUsed,
 324			contextTotal,
 325			outputTokensUsed,
 326			outputTokensMax,
 327			hasNextToken: predictedTokens > 0,
 328			tokensPerSecond,
 329			temperature: currentConfig.temperature ?? 0.8,
 330			topP: currentConfig.top_p ?? 0.95,
 331			speculative: false,
 332			progressPercent,
 333			promptProgress,
 334			promptTokens,
 335			promptMs,
 336			cacheTokens
 337		};
 338	}
 339
 340	/**
 341	 * Gets the model used in a conversation based on the latest assistant message.
 342	 * Returns the model from the most recent assistant message that has a model field set.
 343	 *
 344	 * @param messages - Array of messages to search through
 345	 * @returns The model name or null if no model found
 346	 */
 347	getConversationModel(messages: DatabaseMessage[]): string | null {
 348		// Search backwards through messages to find most recent assistant message with model
 349		for (let i = messages.length - 1; i >= 0; i--) {
 350			const message = messages[i];
 351			if (message.role === 'assistant' && message.model) {
 352				return message.model;
 353			}
 354		}
 355		return null;
 356	}
 357
 358	// ─────────────────────────────────────────────────────────────────────────────
 359	// Error Handling
 360	// ─────────────────────────────────────────────────────────────────────────────
 361
 362	private isAbortError(error: unknown): boolean {
 363		return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
 364	}
 365
 366	private showErrorDialog(
 367		type: 'timeout' | 'server',
 368		message: string,
 369		contextInfo?: { n_prompt_tokens: number; n_ctx: number }
 370	): void {
 371		this.errorDialogState = { type, message, contextInfo };
 372	}
 373
 374	dismissErrorDialog(): void {
 375		this.errorDialogState = null;
 376	}
 377
 378	// ─────────────────────────────────────────────────────────────────────────────
 379	// Message Operations
 380	// ─────────────────────────────────────────────────────────────────────────────
 381
 382	/**
 383	 * Finds a message by ID and optionally validates its role.
 384	 * Returns message and index, or null if not found or role doesn't match.
 385	 */
 386	private getMessageByIdWithRole(
 387		messageId: string,
 388		expectedRole?: ChatRole
 389	): { message: DatabaseMessage; index: number } | null {
 390		const index = conversationsStore.findMessageIndex(messageId);
 391		if (index === -1) return null;
 392
 393		const message = conversationsStore.activeMessages[index];
 394		if (expectedRole && message.role !== expectedRole) return null;
 395
 396		return { message, index };
 397	}
 398
 399	async addMessage(
 400		role: ChatRole,
 401		content: string,
 402		type: ChatMessageType = 'text',
 403		parent: string = '-1',
 404		extras?: DatabaseMessageExtra[]
 405	): Promise<DatabaseMessage | null> {
 406		const activeConv = conversationsStore.activeConversation;
 407		if (!activeConv) {
 408			console.error('No active conversation when trying to add message');
 409			return null;
 410		}
 411
 412		try {
 413			let parentId: string | null = null;
 414
 415			if (parent === '-1') {
 416				const activeMessages = conversationsStore.activeMessages;
 417				if (activeMessages.length > 0) {
 418					parentId = activeMessages[activeMessages.length - 1].id;
 419				} else {
 420					const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
 421					const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
 422					if (!rootMessage) {
 423						parentId = await DatabaseService.createRootMessage(activeConv.id);
 424					} else {
 425						parentId = rootMessage.id;
 426					}
 427				}
 428			} else {
 429				parentId = parent;
 430			}
 431
 432			const message = await DatabaseService.createMessageBranch(
 433				{
 434					convId: activeConv.id,
 435					role,
 436					content,
 437					type,
 438					timestamp: Date.now(),
 439					thinking: '',
 440					toolCalls: '',
 441					children: [],
 442					extra: extras
 443				},
 444				parentId
 445			);
 446
 447			conversationsStore.addMessageToActive(message);
 448			await conversationsStore.updateCurrentNode(message.id);
 449			conversationsStore.updateConversationTimestamp();
 450
 451			return message;
 452		} catch (error) {
 453			console.error('Failed to add message:', error);
 454			return null;
 455		}
 456	}
 457
 458	private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> {
 459		const activeConv = conversationsStore.activeConversation;
 460		if (!activeConv) return null;
 461
 462		return await DatabaseService.createMessageBranch(
 463			{
 464				convId: activeConv.id,
 465				type: 'text',
 466				role: 'assistant',
 467				content: '',
 468				timestamp: Date.now(),
 469				thinking: '',
 470				toolCalls: '',
 471				children: [],
 472				model: null
 473			},
 474			parentId || null
 475		);
 476	}
 477
 478	private async streamChatCompletion(
 479		allMessages: DatabaseMessage[],
 480		assistantMessage: DatabaseMessage,
 481		onComplete?: (content: string) => Promise<void>,
 482		onError?: (error: Error) => void,
 483		modelOverride?: string | null
 484	): Promise<void> {
 485		// Ensure model props are cached before streaming (for correct n_ctx in processing info)
 486		if (isRouterMode()) {
 487			const modelName = modelOverride || selectedModelName();
 488			if (modelName && !modelsStore.getModelProps(modelName)) {
 489				await modelsStore.fetchModelProps(modelName);
 490			}
 491		}
 492
 493		let streamedContent = '';
 494		let streamedReasoningContent = '';
 495		let streamedToolCallContent = '';
 496		let resolvedModel: string | null = null;
 497		let modelPersisted = false;
 498
 499		const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
 500			if (!modelName) return;
 501			const normalizedModel = normalizeModelName(modelName);
 502			if (!normalizedModel || normalizedModel === resolvedModel) return;
 503			resolvedModel = normalizedModel;
 504			const messageIndex = conversationsStore.findMessageIndex(assistantMessage.id);
 505			conversationsStore.updateMessageAtIndex(messageIndex, { model: normalizedModel });
 506			if (persistImmediately && !modelPersisted) {
 507				modelPersisted = true;
 508				DatabaseService.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(() => {
 509					modelPersisted = false;
 510					resolvedModel = null;
 511				});
 512			}
 513		};
 514
 515		this.startStreaming();
 516		this.setActiveProcessingConversation(assistantMessage.convId);
 517
 518		const abortController = this.getOrCreateAbortController(assistantMessage.convId);
 519
 520		await ChatService.sendMessage(
 521			allMessages,
 522			{
 523				...this.getApiOptions(),
 524				...(modelOverride ? { model: modelOverride } : {}),
 525				onChunk: (chunk: string) => {
 526					streamedContent += chunk;
 527					this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
 528					const idx = conversationsStore.findMessageIndex(assistantMessage.id);
 529					conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
 530				},
 531				onReasoningChunk: (reasoningChunk: string) => {
 532					streamedReasoningContent += reasoningChunk;
 533					const idx = conversationsStore.findMessageIndex(assistantMessage.id);
 534					conversationsStore.updateMessageAtIndex(idx, { thinking: streamedReasoningContent });
 535				},
 536				onToolCallChunk: (toolCallChunk: string) => {
 537					const chunk = toolCallChunk.trim();
 538					if (!chunk) return;
 539					streamedToolCallContent = chunk;
 540					const idx = conversationsStore.findMessageIndex(assistantMessage.id);
 541					conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
 542				},
 543				onModel: (modelName: string) => recordModel(modelName),
 544				onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
 545					const tokensPerSecond =
 546						timings?.predicted_ms && timings?.predicted_n
 547							? (timings.predicted_n / timings.predicted_ms) * 1000
 548							: 0;
 549					this.updateProcessingStateFromTimings(
 550						{
 551							prompt_n: timings?.prompt_n || 0,
 552							prompt_ms: timings?.prompt_ms,
 553							predicted_n: timings?.predicted_n || 0,
 554							predicted_per_second: tokensPerSecond,
 555							cache_n: timings?.cache_n || 0,
 556							prompt_progress: promptProgress
 557						},
 558						assistantMessage.convId
 559					);
 560				},
 561				onComplete: async (
 562					finalContent?: string,
 563					reasoningContent?: string,
 564					timings?: ChatMessageTimings,
 565					toolCallContent?: string
 566				) => {
 567					this.stopStreaming();
 568
 569					const updateData: Record<string, unknown> = {
 570						content: finalContent || streamedContent,
 571						thinking: reasoningContent || streamedReasoningContent,
 572						toolCalls: toolCallContent || streamedToolCallContent,
 573						timings
 574					};
 575					if (resolvedModel && !modelPersisted) {
 576						updateData.model = resolvedModel;
 577					}
 578					await DatabaseService.updateMessage(assistantMessage.id, updateData);
 579
 580					const idx = conversationsStore.findMessageIndex(assistantMessage.id);
 581					const uiUpdate: Partial<DatabaseMessage> = {
 582						content: updateData.content as string,
 583						toolCalls: updateData.toolCalls as string
 584					};
 585					if (timings) uiUpdate.timings = timings;
 586					if (resolvedModel) uiUpdate.model = resolvedModel;
 587
 588					conversationsStore.updateMessageAtIndex(idx, uiUpdate);
 589					await conversationsStore.updateCurrentNode(assistantMessage.id);
 590
 591					if (onComplete) await onComplete(streamedContent);
 592					this.setChatLoading(assistantMessage.convId, false);
 593					this.clearChatStreaming(assistantMessage.convId);
 594					this.clearProcessingState(assistantMessage.convId);
 595
 596					if (isRouterMode()) {
 597						modelsStore.fetchRouterModels().catch(console.error);
 598					}
 599				},
 600				onError: (error: Error) => {
 601					this.stopStreaming();
 602
 603					if (this.isAbortError(error)) {
 604						this.setChatLoading(assistantMessage.convId, false);
 605						this.clearChatStreaming(assistantMessage.convId);
 606						this.clearProcessingState(assistantMessage.convId);
 607
 608						return;
 609					}
 610
 611					console.error('Streaming error:', error);
 612
 613					this.setChatLoading(assistantMessage.convId, false);
 614					this.clearChatStreaming(assistantMessage.convId);
 615					this.clearProcessingState(assistantMessage.convId);
 616
 617					const idx = conversationsStore.findMessageIndex(assistantMessage.id);
 618
 619					if (idx !== -1) {
 620						const failedMessage = conversationsStore.removeMessageAtIndex(idx);
 621						if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error);
 622					}
 623
 624					const contextInfo = (
 625						error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
 626					).contextInfo;
 627
 628					this.showErrorDialog(
 629						error.name === 'TimeoutError' ? 'timeout' : 'server',
 630						error.message,
 631						contextInfo
 632					);
 633
 634					if (onError) onError(error);
 635				}
 636			},
 637			assistantMessage.convId,
 638			abortController.signal
 639		);
 640	}
 641
 642	async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
 643		if (!content.trim() && (!extras || extras.length === 0)) return;
 644		const activeConv = conversationsStore.activeConversation;
 645		if (activeConv && this.isChatLoading(activeConv.id)) return;
 646
 647		let isNewConversation = false;
 648		if (!activeConv) {
 649			await conversationsStore.createConversation();
 650			isNewConversation = true;
 651		}
 652		const currentConv = conversationsStore.activeConversation;
 653		if (!currentConv) return;
 654
 655		this.errorDialogState = null;
 656		this.setChatLoading(currentConv.id, true);
 657		this.clearChatStreaming(currentConv.id);
 658
 659		try {
 660			if (isNewConversation) {
 661				const rootId = await DatabaseService.createRootMessage(currentConv.id);
 662				const currentConfig = config();
 663				const systemPrompt = currentConfig.systemMessage?.toString().trim();
 664
 665				if (systemPrompt) {
 666					const systemMessage = await DatabaseService.createSystemMessage(
 667						currentConv.id,
 668						systemPrompt,
 669						rootId
 670					);
 671
 672					conversationsStore.addMessageToActive(systemMessage);
 673				}
 674			}
 675
 676			const userMessage = await this.addMessage('user', content, 'text', '-1', extras);
 677			if (!userMessage) throw new Error('Failed to add user message');
 678			if (isNewConversation && content)
 679				await conversationsStore.updateConversationName(currentConv.id, content.trim());
 680
 681			const assistantMessage = await this.createAssistantMessage(userMessage.id);
 682
 683			if (!assistantMessage) throw new Error('Failed to create assistant message');
 684
 685			conversationsStore.addMessageToActive(assistantMessage);
 686			await this.streamChatCompletion(
 687				conversationsStore.activeMessages.slice(0, -1),
 688				assistantMessage
 689			);
 690		} catch (error) {
 691			if (this.isAbortError(error)) {
 692				this.setChatLoading(currentConv.id, false);
 693				return;
 694			}
 695			console.error('Failed to send message:', error);
 696			this.setChatLoading(currentConv.id, false);
 697			if (!this.errorDialogState) {
 698				const dialogType =
 699					error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'server';
 700				const contextInfo = (
 701					error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
 702				).contextInfo;
 703
 704				this.showErrorDialog(
 705					dialogType,
 706					error instanceof Error ? error.message : 'Unknown error',
 707					contextInfo
 708				);
 709			}
 710		}
 711	}
 712
 713	async stopGeneration(): Promise<void> {
 714		const activeConv = conversationsStore.activeConversation;
 715
 716		if (!activeConv) return;
 717
 718		await this.stopGenerationForChat(activeConv.id);
 719	}
 720
 721	async stopGenerationForChat(convId: string): Promise<void> {
 722		await this.savePartialResponseIfNeeded(convId);
 723
 724		this.stopStreaming();
 725		this.abortRequest(convId);
 726		this.setChatLoading(convId, false);
 727		this.clearChatStreaming(convId);
 728		this.clearProcessingState(convId);
 729	}
 730
 731	/**
 732	 * Gets or creates an AbortController for a conversation
 733	 */
 734	private getOrCreateAbortController(convId: string): AbortController {
 735		let controller = this.abortControllers.get(convId);
 736		if (!controller || controller.signal.aborted) {
 737			controller = new AbortController();
 738			this.abortControllers.set(convId, controller);
 739		}
 740		return controller;
 741	}
 742
 743	/**
 744	 * Aborts any ongoing request for a conversation
 745	 */
 746	private abortRequest(convId?: string): void {
 747		if (convId) {
 748			const controller = this.abortControllers.get(convId);
 749			if (controller) {
 750				controller.abort();
 751				this.abortControllers.delete(convId);
 752			}
 753		} else {
 754			for (const controller of this.abortControllers.values()) {
 755				controller.abort();
 756			}
 757			this.abortControllers.clear();
 758		}
 759	}
 760
 761	private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
 762		const conversationId = convId || conversationsStore.activeConversation?.id;
 763
 764		if (!conversationId) return;
 765
 766		const streamingState = this.chatStreamingStates.get(conversationId);
 767
 768		if (!streamingState || !streamingState.response.trim()) return;
 769
 770		const messages =
 771			conversationId === conversationsStore.activeConversation?.id
 772				? conversationsStore.activeMessages
 773				: await conversationsStore.getConversationMessages(conversationId);
 774
 775		if (!messages.length) return;
 776
 777		const lastMessage = messages[messages.length - 1];
 778
 779		if (lastMessage?.role === 'assistant') {
 780			try {
 781				const updateData: { content: string; thinking?: string; timings?: ChatMessageTimings } = {
 782					content: streamingState.response
 783				};
 784				if (lastMessage.thinking?.trim()) updateData.thinking = lastMessage.thinking;
 785				const lastKnownState = this.getProcessingState(conversationId);
 786				if (lastKnownState) {
 787					updateData.timings = {
 788						prompt_n: lastKnownState.promptTokens || 0,
 789						prompt_ms: lastKnownState.promptMs,
 790						predicted_n: lastKnownState.tokensDecoded || 0,
 791						cache_n: lastKnownState.cacheTokens || 0,
 792						predicted_ms:
 793							lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded
 794								? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000
 795								: undefined
 796					};
 797				}
 798
 799				await DatabaseService.updateMessage(lastMessage.id, updateData);
 800
 801				lastMessage.content = this.currentResponse;
 802
 803				if (updateData.thinking) lastMessage.thinking = updateData.thinking;
 804
 805				if (updateData.timings) lastMessage.timings = updateData.timings;
 806			} catch (error) {
 807				lastMessage.content = this.currentResponse;
 808				console.error('Failed to save partial response:', error);
 809			}
 810		}
 811	}
 812
 813	async updateMessage(messageId: string, newContent: string): Promise<void> {
 814		const activeConv = conversationsStore.activeConversation;
 815		if (!activeConv) return;
 816		if (this.isLoading) this.stopGeneration();
 817
 818		const result = this.getMessageByIdWithRole(messageId, 'user');
 819		if (!result) return;
 820		const { message: messageToUpdate, index: messageIndex } = result;
 821		const originalContent = messageToUpdate.content;
 822
 823		try {
 824			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
 825			const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
 826			const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id;
 827
 828			conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
 829			await DatabaseService.updateMessage(messageId, { content: newContent });
 830
 831			if (isFirstUserMessage && newContent.trim()) {
 832				await conversationsStore.updateConversationTitleWithConfirmation(
 833					activeConv.id,
 834					newContent.trim(),
 835					conversationsStore.titleUpdateConfirmationCallback
 836				);
 837			}
 838
 839			const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
 840
 841			for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
 842
 843			conversationsStore.sliceActiveMessages(messageIndex + 1);
 844			conversationsStore.updateConversationTimestamp();
 845
 846			this.setChatLoading(activeConv.id, true);
 847			this.clearChatStreaming(activeConv.id);
 848
 849			const assistantMessage = await this.createAssistantMessage();
 850
 851			if (!assistantMessage) throw new Error('Failed to create assistant message');
 852
 853			conversationsStore.addMessageToActive(assistantMessage);
 854
 855			await conversationsStore.updateCurrentNode(assistantMessage.id);
 856			await this.streamChatCompletion(
 857				conversationsStore.activeMessages.slice(0, -1),
 858				assistantMessage,
 859				undefined,
 860				() => {
 861					conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), {
 862						content: originalContent
 863					});
 864				}
 865			);
 866		} catch (error) {
 867			if (!this.isAbortError(error)) console.error('Failed to update message:', error);
 868		}
 869	}
 870
 871	// ─────────────────────────────────────────────────────────────────────────────
 872	// Regeneration
 873	// ─────────────────────────────────────────────────────────────────────────────
 874
 875	async regenerateMessage(messageId: string): Promise<void> {
 876		const activeConv = conversationsStore.activeConversation;
 877		if (!activeConv || this.isLoading) return;
 878
 879		const result = this.getMessageByIdWithRole(messageId, 'assistant');
 880		if (!result) return;
 881		const { index: messageIndex } = result;
 882
 883		try {
 884			const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
 885			for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
 886			conversationsStore.sliceActiveMessages(messageIndex);
 887			conversationsStore.updateConversationTimestamp();
 888
 889			this.setChatLoading(activeConv.id, true);
 890			this.clearChatStreaming(activeConv.id);
 891
 892			const parentMessageId =
 893				conversationsStore.activeMessages.length > 0
 894					? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
 895					: undefined;
 896			const assistantMessage = await this.createAssistantMessage(parentMessageId);
 897			if (!assistantMessage) throw new Error('Failed to create assistant message');
 898			conversationsStore.addMessageToActive(assistantMessage);
 899			await this.streamChatCompletion(
 900				conversationsStore.activeMessages.slice(0, -1),
 901				assistantMessage
 902			);
 903		} catch (error) {
 904			if (!this.isAbortError(error)) console.error('Failed to regenerate message:', error);
 905			this.setChatLoading(activeConv?.id || '', false);
 906		}
 907	}
 908
 909	async getDeletionInfo(messageId: string): Promise<{
 910		totalCount: number;
 911		userMessages: number;
 912		assistantMessages: number;
 913		messageTypes: string[];
 914	}> {
 915		const activeConv = conversationsStore.activeConversation;
 916		if (!activeConv)
 917			return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] };
 918		const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
 919		const descendants = findDescendantMessages(allMessages, messageId);
 920		const allToDelete = [messageId, ...descendants];
 921		const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));
 922		let userMessages = 0,
 923			assistantMessages = 0;
 924		const messageTypes: string[] = [];
 925		for (const msg of messagesToDelete) {
 926			if (msg.role === 'user') {
 927				userMessages++;
 928				if (!messageTypes.includes('user message')) messageTypes.push('user message');
 929			} else if (msg.role === 'assistant') {
 930				assistantMessages++;
 931				if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
 932			}
 933		}
 934		return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes };
 935	}
 936
 937	async deleteMessage(messageId: string): Promise<void> {
 938		const activeConv = conversationsStore.activeConversation;
 939		if (!activeConv) return;
 940		try {
 941			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
 942			const messageToDelete = allMessages.find((m) => m.id === messageId);
 943			if (!messageToDelete) return;
 944
 945			const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false);
 946			const isInCurrentPath = currentPath.some((m) => m.id === messageId);
 947
 948			if (isInCurrentPath && messageToDelete.parent) {
 949				const siblings = allMessages.filter(
 950					(m) => m.parent === messageToDelete.parent && m.id !== messageId
 951				);
 952
 953				if (siblings.length > 0) {
 954					const latestSibling = siblings.reduce((latest, sibling) =>
 955						sibling.timestamp > latest.timestamp ? sibling : latest
 956					);
 957					await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id));
 958				} else if (messageToDelete.parent) {
 959					await conversationsStore.updateCurrentNode(
 960						findLeafNode(allMessages, messageToDelete.parent)
 961					);
 962				}
 963			}
 964			await DatabaseService.deleteMessageCascading(activeConv.id, messageId);
 965			await conversationsStore.refreshActiveMessages();
 966
 967			conversationsStore.updateConversationTimestamp();
 968		} catch (error) {
 969			console.error('Failed to delete message:', error);
 970		}
 971	}
 972
 973	// ─────────────────────────────────────────────────────────────────────────────
 974	// Editing
 975	// ─────────────────────────────────────────────────────────────────────────────
 976
 977	clearEditMode(): void {
 978		this.isEditModeActive = false;
 979		this.addFilesHandler = null;
 980	}
 981
 982	async continueAssistantMessage(messageId: string): Promise<void> {
 983		const activeConv = conversationsStore.activeConversation;
 984		if (!activeConv || this.isLoading) return;
 985
 986		const result = this.getMessageByIdWithRole(messageId, 'assistant');
 987		if (!result) return;
 988		const { message: msg, index: idx } = result;
 989
 990		if (this.isChatLoading(activeConv.id)) return;
 991
 992		try {
 993			this.errorDialogState = null;
 994			this.setChatLoading(activeConv.id, true);
 995			this.clearChatStreaming(activeConv.id);
 996
 997			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
 998			const dbMessage = allMessages.find((m) => m.id === messageId);
 999
1000			if (!dbMessage) {
1001				this.setChatLoading(activeConv.id, false);
1002
1003				return;
1004			}
1005
1006			const originalContent = dbMessage.content;
1007			const originalThinking = dbMessage.thinking || '';
1008
1009			const conversationContext = conversationsStore.activeMessages.slice(0, idx);
1010			const contextWithContinue = [
1011				...conversationContext,
1012				{ role: 'assistant' as const, content: originalContent }
1013			];
1014
1015			let appendedContent = '',
1016				appendedThinking = '',
1017				hasReceivedContent = false;
1018
1019			const abortController = this.getOrCreateAbortController(msg.convId);
1020
1021			await ChatService.sendMessage(
1022				contextWithContinue,
1023				{
1024					...this.getApiOptions(),
1025
1026					onChunk: (chunk: string) => {
1027						hasReceivedContent = true;
1028						appendedContent += chunk;
1029						const fullContent = originalContent + appendedContent;
1030						this.setChatStreaming(msg.convId, fullContent, msg.id);
1031						conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
1032					},
1033
1034					onReasoningChunk: (reasoningChunk: string) => {
1035						hasReceivedContent = true;
1036						appendedThinking += reasoningChunk;
1037						conversationsStore.updateMessageAtIndex(idx, {
1038							thinking: originalThinking + appendedThinking
1039						});
1040					},
1041
1042					onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
1043						const tokensPerSecond =
1044							timings?.predicted_ms && timings?.predicted_n
1045								? (timings.predicted_n / timings.predicted_ms) * 1000
1046								: 0;
1047						this.updateProcessingStateFromTimings(
1048							{
1049								prompt_n: timings?.prompt_n || 0,
1050								prompt_ms: timings?.prompt_ms,
1051								predicted_n: timings?.predicted_n || 0,
1052								predicted_per_second: tokensPerSecond,
1053								cache_n: timings?.cache_n || 0,
1054								prompt_progress: promptProgress
1055							},
1056							msg.convId
1057						);
1058					},
1059
1060					onComplete: async (
1061						finalContent?: string,
1062						reasoningContent?: string,
1063						timings?: ChatMessageTimings
1064					) => {
1065						const fullContent = originalContent + (finalContent || appendedContent);
1066						const fullThinking = originalThinking + (reasoningContent || appendedThinking);
1067						await DatabaseService.updateMessage(msg.id, {
1068							content: fullContent,
1069							thinking: fullThinking,
1070							timestamp: Date.now(),
1071							timings
1072						});
1073						conversationsStore.updateMessageAtIndex(idx, {
1074							content: fullContent,
1075							thinking: fullThinking,
1076							timestamp: Date.now(),
1077							timings
1078						});
1079						conversationsStore.updateConversationTimestamp();
1080						this.setChatLoading(msg.convId, false);
1081						this.clearChatStreaming(msg.convId);
1082						this.clearProcessingState(msg.convId);
1083					},
1084
1085					onError: async (error: Error) => {
1086						if (this.isAbortError(error)) {
1087							if (hasReceivedContent && appendedContent) {
1088								await DatabaseService.updateMessage(msg.id, {
1089									content: originalContent + appendedContent,
1090									thinking: originalThinking + appendedThinking,
1091									timestamp: Date.now()
1092								});
1093								conversationsStore.updateMessageAtIndex(idx, {
1094									content: originalContent + appendedContent,
1095									thinking: originalThinking + appendedThinking,
1096									timestamp: Date.now()
1097								});
1098							}
1099							this.setChatLoading(msg.convId, false);
1100							this.clearChatStreaming(msg.convId);
1101							this.clearProcessingState(msg.convId);
1102							return;
1103						}
1104						console.error('Continue generation error:', error);
1105						conversationsStore.updateMessageAtIndex(idx, {
1106							content: originalContent,
1107							thinking: originalThinking
1108						});
1109						await DatabaseService.updateMessage(msg.id, {
1110							content: originalContent,
1111							thinking: originalThinking
1112						});
1113						this.setChatLoading(msg.convId, false);
1114						this.clearChatStreaming(msg.convId);
1115						this.clearProcessingState(msg.convId);
1116						this.showErrorDialog(
1117							error.name === 'TimeoutError' ? 'timeout' : 'server',
1118							error.message
1119						);
1120					}
1121				},
1122				msg.convId,
1123				abortController.signal
1124			);
1125		} catch (error) {
1126			if (!this.isAbortError(error)) console.error('Failed to continue message:', error);
1127			if (activeConv) this.setChatLoading(activeConv.id, false);
1128		}
1129	}
1130
1131	async editAssistantMessage(
1132		messageId: string,
1133		newContent: string,
1134		shouldBranch: boolean
1135	): Promise<void> {
1136		const activeConv = conversationsStore.activeConversation;
1137		if (!activeConv || this.isLoading) return;
1138
1139		const result = this.getMessageByIdWithRole(messageId, 'assistant');
1140		if (!result) return;
1141		const { message: msg, index: idx } = result;
1142
1143		try {
1144			if (shouldBranch) {
1145				const newMessage = await DatabaseService.createMessageBranch(
1146					{
1147						convId: msg.convId,
1148						type: msg.type,
1149						timestamp: Date.now(),
1150						role: msg.role,
1151						content: newContent,
1152						thinking: msg.thinking || '',
1153						toolCalls: msg.toolCalls || '',
1154						children: [],
1155						model: msg.model
1156					},
1157					msg.parent!
1158				);
1159				await conversationsStore.updateCurrentNode(newMessage.id);
1160			} else {
1161				await DatabaseService.updateMessage(msg.id, { content: newContent });
1162				await conversationsStore.updateCurrentNode(msg.id);
1163				conversationsStore.updateMessageAtIndex(idx, {
1164					content: newContent
1165				});
1166			}
1167			conversationsStore.updateConversationTimestamp();
1168			await conversationsStore.refreshActiveMessages();
1169		} catch (error) {
1170			console.error('Failed to edit assistant message:', error);
1171		}
1172	}
1173
1174	async editUserMessagePreserveResponses(
1175		messageId: string,
1176		newContent: string,
1177		newExtras?: DatabaseMessageExtra[]
1178	): Promise<void> {
1179		const activeConv = conversationsStore.activeConversation;
1180		if (!activeConv) return;
1181
1182		const result = this.getMessageByIdWithRole(messageId, 'user');
1183		if (!result) return;
1184		const { message: msg, index: idx } = result;
1185
1186		try {
1187			const updateData: Partial<DatabaseMessage> = {
1188				content: newContent
1189			};
1190
1191			// Update extras if provided (including empty array to clear attachments)
1192			// Deep clone to avoid Proxy objects from Svelte reactivity
1193			if (newExtras !== undefined) {
1194				updateData.extra = JSON.parse(JSON.stringify(newExtras));
1195			}
1196
1197			await DatabaseService.updateMessage(messageId, updateData);
1198			conversationsStore.updateMessageAtIndex(idx, updateData);
1199
1200			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
1201			const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
1202
1203			if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) {
1204				await conversationsStore.updateConversationTitleWithConfirmation(
1205					activeConv.id,
1206					newContent.trim(),
1207					conversationsStore.titleUpdateConfirmationCallback
1208				);
1209			}
1210			conversationsStore.updateConversationTimestamp();
1211		} catch (error) {
1212			console.error('Failed to edit user message:', error);
1213		}
1214	}
1215
1216	async editMessageWithBranching(
1217		messageId: string,
1218		newContent: string,
1219		newExtras?: DatabaseMessageExtra[]
1220	): Promise<void> {
1221		const activeConv = conversationsStore.activeConversation;
1222		if (!activeConv || this.isLoading) return;
1223
1224		let result = this.getMessageByIdWithRole(messageId, 'user');
1225
1226		if (!result) {
1227			result = this.getMessageByIdWithRole(messageId, 'system');
1228		}
1229
1230		if (!result) return;
1231		const { message: msg } = result;
1232
1233		try {
1234			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
1235			const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
1236			const isFirstUserMessage =
1237				msg.role === 'user' && rootMessage && msg.parent === rootMessage.id;
1238
1239			const parentId = msg.parent || rootMessage?.id;
1240			if (!parentId) return;
1241
1242			// Use newExtras if provided, otherwise copy existing extras
1243			// Deep clone to avoid Proxy objects from Svelte reactivity
1244			const extrasToUse =
1245				newExtras !== undefined
1246					? JSON.parse(JSON.stringify(newExtras))
1247					: msg.extra
1248						? JSON.parse(JSON.stringify(msg.extra))
1249						: undefined;
1250
1251			const newMessage = await DatabaseService.createMessageBranch(
1252				{
1253					convId: msg.convId,
1254					type: msg.type,
1255					timestamp: Date.now(),
1256					role: msg.role,
1257					content: newContent,
1258					thinking: msg.thinking || '',
1259					toolCalls: msg.toolCalls || '',
1260					children: [],
1261					extra: extrasToUse,
1262					model: msg.model
1263				},
1264				parentId
1265			);
1266			await conversationsStore.updateCurrentNode(newMessage.id);
1267			conversationsStore.updateConversationTimestamp();
1268
1269			if (isFirstUserMessage && newContent.trim()) {
1270				await conversationsStore.updateConversationTitleWithConfirmation(
1271					activeConv.id,
1272					newContent.trim(),
1273					conversationsStore.titleUpdateConfirmationCallback
1274				);
1275			}
1276			await conversationsStore.refreshActiveMessages();
1277
1278			if (msg.role === 'user') {
1279				await this.generateResponseForMessage(newMessage.id);
1280			}
1281		} catch (error) {
1282			console.error('Failed to edit message with branching:', error);
1283		}
1284	}
1285
1286	async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
1287		const activeConv = conversationsStore.activeConversation;
1288		if (!activeConv || this.isLoading) return;
1289		try {
1290			const idx = conversationsStore.findMessageIndex(messageId);
1291			if (idx === -1) return;
1292			const msg = conversationsStore.activeMessages[idx];
1293			if (msg.role !== 'assistant') return;
1294
1295			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
1296			const parentMessage = allMessages.find((m) => m.id === msg.parent);
1297			if (!parentMessage) return;
1298
1299			this.setChatLoading(activeConv.id, true);
1300			this.clearChatStreaming(activeConv.id);
1301
1302			const newAssistantMessage = await DatabaseService.createMessageBranch(
1303				{
1304					convId: activeConv.id,
1305					type: 'text',
1306					timestamp: Date.now(),
1307					role: 'assistant',
1308					content: '',
1309					thinking: '',
1310					toolCalls: '',
1311					children: [],
1312					model: null
1313				},
1314				parentMessage.id
1315			);
1316			await conversationsStore.updateCurrentNode(newAssistantMessage.id);
1317			conversationsStore.updateConversationTimestamp();
1318			await conversationsStore.refreshActiveMessages();
1319
1320			const conversationPath = filterByLeafNodeId(
1321				allMessages,
1322				parentMessage.id,
1323				false
1324			) as DatabaseMessage[];
1325			// Use modelOverride if provided, otherwise use the original message's model
1326			// If neither is available, don't pass model (will use global selection)
1327			const modelToUse = modelOverride || msg.model || undefined;
1328			await this.streamChatCompletion(
1329				conversationPath,
1330				newAssistantMessage,
1331				undefined,
1332				undefined,
1333				modelToUse
1334			);
1335		} catch (error) {
1336			if (!this.isAbortError(error))
1337				console.error('Failed to regenerate message with branching:', error);
1338			this.setChatLoading(activeConv?.id || '', false);
1339		}
1340	}
1341
1342	private async generateResponseForMessage(userMessageId: string): Promise<void> {
1343		const activeConv = conversationsStore.activeConversation;
1344
1345		if (!activeConv) return;
1346
1347		this.errorDialogState = null;
1348		this.setChatLoading(activeConv.id, true);
1349		this.clearChatStreaming(activeConv.id);
1350
1351		try {
1352			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
1353			const conversationPath = filterByLeafNodeId(
1354				allMessages,
1355				userMessageId,
1356				false
1357			) as DatabaseMessage[];
1358			const assistantMessage = await DatabaseService.createMessageBranch(
1359				{
1360					convId: activeConv.id,
1361					type: 'text',
1362					timestamp: Date.now(),
1363					role: 'assistant',
1364					content: '',
1365					thinking: '',
1366					toolCalls: '',
1367					children: [],
1368					model: null
1369				},
1370				userMessageId
1371			);
1372			conversationsStore.addMessageToActive(assistantMessage);
1373			await this.streamChatCompletion(conversationPath, assistantMessage);
1374		} catch (error) {
1375			console.error('Failed to generate response:', error);
1376			this.setChatLoading(activeConv.id, false);
1377		}
1378	}
1379
1380	getAddFilesHandler(): ((files: File[]) => void) | null {
1381		return this.addFilesHandler;
1382	}
1383
1384	public getAllLoadingChats(): string[] {
1385		return Array.from(this.chatLoadingStates.keys());
1386	}
1387
1388	public getAllStreamingChats(): string[] {
1389		return Array.from(this.chatStreamingStates.keys());
1390	}
1391
1392	public getChatStreamingPublic(
1393		convId: string
1394	): { response: string; messageId: string } | undefined {
1395		return this.getChatStreaming(convId);
1396	}
1397
1398	public isChatLoadingPublic(convId: string): boolean {
1399		return this.isChatLoading(convId);
1400	}
1401
1402	isEditing(): boolean {
1403		return this.isEditModeActive;
1404	}
1405
1406	setEditModeActive(handler: (files: File[]) => void): void {
1407		this.isEditModeActive = true;
1408		this.addFilesHandler = handler;
1409	}
1410
1411	// ─────────────────────────────────────────────────────────────────────────────
1412	// Utilities
1413	// ─────────────────────────────────────────────────────────────────────────────
1414
1415	private getApiOptions(): Record<string, unknown> {
1416		const currentConfig = config();
1417		const hasValue = (value: unknown): boolean =>
1418			value !== undefined && value !== null && value !== '';
1419
1420		const apiOptions: Record<string, unknown> = { stream: true, timings_per_token: true };
1421
1422		// Model selection (required in ROUTER mode)
1423		if (isRouterMode()) {
1424			const modelName = selectedModelName();
1425			if (modelName) apiOptions.model = modelName;
1426		}
1427
1428		// Config options needed by ChatService
1429		if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage;
1430		if (currentConfig.disableReasoningFormat) apiOptions.disableReasoningFormat = true;
1431
1432		if (hasValue(currentConfig.temperature))
1433			apiOptions.temperature = Number(currentConfig.temperature);
1434		if (hasValue(currentConfig.max_tokens))
1435			apiOptions.max_tokens = Number(currentConfig.max_tokens);
1436		if (hasValue(currentConfig.dynatemp_range))
1437			apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range);
1438		if (hasValue(currentConfig.dynatemp_exponent))
1439			apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent);
1440		if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k);
1441		if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p);
1442		if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p);
1443		if (hasValue(currentConfig.xtc_probability))
1444			apiOptions.xtc_probability = Number(currentConfig.xtc_probability);
1445		if (hasValue(currentConfig.xtc_threshold))
1446			apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold);
1447		if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p);
1448		if (hasValue(currentConfig.repeat_last_n))
1449			apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n);
1450		if (hasValue(currentConfig.repeat_penalty))
1451			apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty);
1452		if (hasValue(currentConfig.presence_penalty))
1453			apiOptions.presence_penalty = Number(currentConfig.presence_penalty);
1454		if (hasValue(currentConfig.frequency_penalty))
1455			apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty);
1456		if (hasValue(currentConfig.dry_multiplier))
1457			apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier);
1458		if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base);
1459		if (hasValue(currentConfig.dry_allowed_length))
1460			apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length);
1461		if (hasValue(currentConfig.dry_penalty_last_n))
1462			apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n);
1463		if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers;
1464		if (currentConfig.backend_sampling)
1465			apiOptions.backend_sampling = currentConfig.backend_sampling;
1466		if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
1467
1468		return apiOptions;
1469	}
1470}
1471
1472export const chatStore = new ChatStore();
1473
1474export const activeProcessingState = () => chatStore.activeProcessingState;
1475export const clearEditMode = () => chatStore.clearEditMode();
1476export const currentResponse = () => chatStore.currentResponse;
1477export const errorDialog = () => chatStore.errorDialogState;
1478export const getAddFilesHandler = () => chatStore.getAddFilesHandler();
1479export const getAllLoadingChats = () => chatStore.getAllLoadingChats();
1480export const getAllStreamingChats = () => chatStore.getAllStreamingChats();
1481export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId);
1482export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId);
1483export const isChatStreaming = () => chatStore.isStreaming();
1484export const isEditing = () => chatStore.isEditing();
1485export const isLoading = () => chatStore.isLoading;
1486export const setEditModeActive = (handler: (files: File[]) => void) =>
1487	chatStore.setEditModeActive(handler);