aboutsummaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts
diff options
context:
space:
mode:
authorMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
committerMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
commitb333b06772c89d96aacb5490d6a219fba7c09cc6 (patch)
tree211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts
downloadllmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts1487
1 files changed, 1487 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts
new file mode 100644
index 0000000..879b2f3
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts
@@ -0,0 +1,1487 @@
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);