/** * chatStore - Reactive State Store for Chat Operations * * Manages chat lifecycle, streaming, message operations, and processing state. * * **Architecture & Relationships:** * - **ChatService**: Stateless API layer (sendMessage, streaming) * - **chatStore** (this): Reactive state + business logic * - **conversationsStore**: Conversation persistence and navigation * * @see ChatService in services/chat.service.ts for API operations */ import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { DatabaseService } from '$lib/services/database.service'; import { ChatService } from '$lib/services/chat.service'; import { streamIdentity } from '$lib/utils/stream-identity'; import { getAuthHeaders } from '$lib/utils/api-headers'; import { conversationsStore } from '$lib/stores/conversations.svelte'; import { config } from '$lib/stores/settings.svelte'; import { agenticStore } from '$lib/stores/agentic.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte'; import { contextSize, isRouterMode } from '$lib/stores/server.svelte'; import { selectedModelName, modelsStore, selectedModelContextSize } from '$lib/stores/models.svelte'; import { normalizeModelName, filterByLeafNodeId, findDescendantMessages, findLeafNode, findMessageById, isAbortError, generateConversationTitle } from '$lib/utils'; import { classifyContinueIntent } from '$lib/utils/agentic'; import { MAX_INACTIVE_CONVERSATION_STATES, INACTIVE_CONVERSATION_STATE_MAX_AGE_MS, SYSTEM_MESSAGE_PLACEHOLDER, TITLE_GENERATION } from '$lib/constants'; import type { ChatMessageTimings, ChatMessagePromptProgress, ChatStreamCallbacks, ErrorDialogState } from '$lib/types/chat'; import type { ApiChatMessageData, ApiProcessingState, ApiStreamSession, DatabaseMessage, DatabaseMessageExtra } from '$lib/types'; import { ContinueIntentKind, ErrorDialogType, MessageRole, MessageType, StreamConnectionState } from '$lib/enums'; interface ConversationStateEntry { lastAccessed: number; } class ChatStore { activeProcessingState = $state(null); currentResponse = $state(''); errorDialogState = $state(null); isLoading = $state(false); // true while the active conversation streams reasoning content but no visible content yet isReasoning = $state(false); // resumable stream connection state for the active conversation // streaming -> bytes flowing normally, resuming -> waiting on /v1/stream/:id reconnect, lost -> unrecoverable streamConnectionState = $state(StreamConnectionState.STREAMING); chatLoadingStates = new SvelteMap(); chatReasoningStates = new SvelteMap(); chatStreamingStates = new SvelteMap< string, { response: string; messageId: string; model?: string | null } >(); // convs that the backend reports as having a running session, populated by the global sync // at app mount and on visibilitychange. it does not overlap with chatLoadingStates which // tracks inferences driven by this browser, both are unioned to feed the sidebar spinners private remoteRunningConvs = new SvelteSet(); // per conv attach lifecycle, used to derive the global streaming flag without flipping it // off when one conv finishes while another is still streaming. mirrors chatLoadingStates // in scope but tracks the attach + tee replay path specifically private attachingConvs = new SvelteSet(); // in-flight discoverActiveStream guard, keyed by conv id private discoveringConvs = new SvelteSet(); private abortControllers = new SvelteMap(); private preEncodeAbortController: AbortController | null = null; private processingStates = new SvelteMap(); private conversationStateTimestamps = new SvelteMap(); private activeConversationId = $state(null); private isStreamingActive = $state(false); private isEditModeActive = $state(false); private addFilesHandler: ((files: File[]) => void) | null = $state(null); pendingEditMessageId = $state(null); private messageUpdateCallback: | ((messageId: string, updates: Partial) => void) | null = null; private _pendingDraftMessage = $state(''); private _pendingDraftFiles = $state([]); /** Reactive: queued pending messages for non-agentic streaming */ private _pendingMessages = new SvelteMap< string, { content: string; extras?: DatabaseMessageExtra[] } >(); private setChatLoading(convId: string, loading: boolean): void { this.touchConversationState(convId); if (loading) { this.chatLoadingStates.set(convId, true); if (convId === conversationsStore.activeConversation?.id) this.isLoading = true; } else { this.chatLoadingStates.delete(convId); if (convId === conversationsStore.activeConversation?.id) this.isLoading = false; this.setChatReasoning(convId, false); // the local pipe is the authoritative observer of session end: when it finishes (clean // onComplete or explicit Stop), the backend session is finalized too, so we drop the // sidebar hint for this conv right away instead of waiting for the next visibilitychange // snapshot. without this the spinner ghosts until the user toggles the tab this.remoteRunningConvs.delete(convId); } } private setChatReasoning(convId: string, reasoning: boolean): void { if (reasoning) { this.chatReasoningStates.set(convId, true); if (convId === conversationsStore.activeConversation?.id) this.isReasoning = true; } else { this.chatReasoningStates.delete(convId); if (convId === conversationsStore.activeConversation?.id) this.isReasoning = false; } } private setChatStreaming( convId: string, response: string, messageId: string, model?: string | null ): void { this.touchConversationState(convId); this.chatStreamingStates.set(convId, { response, messageId, model: model ?? this.chatStreamingStates.get(convId)?.model }); if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response; } private clearChatStreaming(convId: string, messageId?: string): void { // session aware: a stale generation must not wipe a newer one's streaming state on the // same conversation, that would drop the frozen stop identity and stop the wrong session if (messageId !== undefined) { const cur = this.chatStreamingStates.get(convId); if (cur && cur.messageId !== messageId) return; } this.chatStreamingStates.delete(convId); if (convId === conversationsStore.activeConversation?.id) this.currentResponse = ''; } private getChatStreaming(convId: string): { response: string; messageId: string } | undefined { return this.chatStreamingStates.get(convId); } syncLoadingStateForChat(convId: string): void { this.isLoading = this.chatLoadingStates.get(convId) || false; this.isReasoning = this.chatReasoningStates.get(convId) || false; const s = this.chatStreamingStates.get(convId); this.currentResponse = s?.response || ''; this.isStreamingActive = s !== undefined; this.setActiveProcessingConversation(convId); // Sync streaming content to activeMessages so UI displays current content if (s?.response && s?.messageId) { const idx = conversationsStore.findMessageIndex(s.messageId); if (idx !== -1) { conversationsStore.updateMessageAtIndex(idx, { content: s.response }); } } } /** * Server side stream discovery, split in three pieces: * * probeServerStream(convId) -> hits POST /v1/streams/lookup with the conv id, returns the session to attach * to or null. Pure read, no side effect, no UI lock. Safe to fire in parallel with anything. * * attachServerStream(convId) -> flips the spinner immediately, fetches the replay stream * from byte 0, finds the assistant slot to splice into (creates a placeholder if the conv has * no assistant message yet, for cross device or fresh local DB cases), and pipes the SSE bytes * into the message via handleStreamResponse. * * discoverActiveStream(convId) -> probe + attach in one call. Used by callers that do not need * to overlap the probe with other async work. * * The mount of the chat page in +page.svelte calls probeServerStream in parallel with * loadConversation, then attachServerStream once both have settled. This gives the earliest * possible time to spinner and avoids racing against an empty activeMessages array. */ async probeServerStream(convId: string): Promise { if (!convId) return null; let listResp: Response; try { // POST the one conv id we are probing listResp = await fetch(`./v1/streams/lookup`, { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ conversation_ids: [convId] }) }); } catch (e) { console.warn('probeServerStream fetch failed:', e); return null; } if (!listResp.ok) { console.warn(`probeServerStream got HTTP ${listResp.status} for conv ${convId}`); return null; } let sessions: ApiStreamSession[]; try { sessions = (await listResp.json()) as ApiStreamSession[]; } catch (e) { console.warn('probeServerStream JSON parse failed:', e); return null; } return ChatService.selectActiveStream(sessions); } async attachServerStream(convId: string, streamId?: string): Promise { if (!convId) return; if (this.chatStreamingStates.has(convId)) return; // flip the spinner immediately, the user sees activity as soon as the conv becomes active. // the global isStreamingActive flag is derived from attachingConvs.size, so adding here // turns it on, and removing in unlock only turns it off when this is the last attach this.setChatLoading(convId, true); this.attachingConvs.add(convId); this.setStreamingActive(true); // only set the active processing conv if we are looking at it, otherwise a background // attach would steal the indicator from the conv the user is currently viewing if (convId === conversationsStore.activeConversation?.id) { this.setActiveProcessingConversation(convId); } const unlock = () => { this.attachingConvs.delete(convId); // flip the global flag off only when no other conv is still attaching if (this.attachingConvs.size === 0) { this.setStreamingActive(false); } this.setChatLoading(convId, false); this.clearChatStreaming(convId); }; // fetch the replay stream from byte 0, rebuild the assistant message from scratch. // resolve the server side identity, fall back to streamIdentity when the caller does not // pass a streamId. probeServerStream returns the full id (with ::model suffix when present) const id = streamId || streamIdentity(convId, selectedModelName()); let response: Response; try { response = await fetch(`./v1/stream/${encodeURIComponent(id)}?from=0`, { headers: getAuthHeaders() }); } catch (e) { console.error('attachServerStream replay fetch failed:', e); unlock(); return; } if (!response.ok) { console.warn(`attachServerStream replay got HTTP ${response.status} for conv ${convId}`); unlock(); return; } // load the target conversation messages by id, not via the active store. when multiple // attaches run in parallel the active store may reflect another conv and writing through // its index mixes content across convs (CoT flicker, message bleed). by going through the // DB we stay isolated, and only mirror into the active store when the attached conv is // the one currently displayed let messages: DatabaseMessage[]; try { messages = await DatabaseService.getConversationMessages(convId); } catch (e) { console.error('attachServerStream load messages failed:', e); unlock(); return; } // locate the slot to splice into, create a placeholder assistant message if there is none. // we use the conv-scoped findLastAssistantIdx helpers, they only depend on the array let targetIdx = this.findLastAssistantIdx(messages); if (targetIdx === -1) { const lastUserIdx = this.findLastUserIdx(messages); if (lastUserIdx === -1) { console.warn( `attachServerStream: conv ${convId} has no user or assistant message, cannot splice` ); unlock(); return; } try { const placeholder = await DatabaseService.createMessageBranch( { convId, role: MessageRole.ASSISTANT, content: '', type: MessageType.TEXT, timestamp: Date.now(), parent: messages[lastUserIdx].id, children: [], toolCalls: '' } as Omit, messages[lastUserIdx].id ); messages = [...messages, placeholder]; targetIdx = messages.length - 1; // only push into the active store when this conv is the one displayed right now if (convId === conversationsStore.activeConversation?.id) { conversationsStore.addMessageToActive(placeholder); } } catch (e) { console.error('attachServerStream placeholder creation failed:', e); unlock(); return; } } if (targetIdx === -1) { unlock(); return; } const targetMessage = messages[targetIdx]; const targetMessageId = targetMessage.id; // when the assistant slot already has content, the running session is a continue or // another append flow and its buffer holds only the appended deltas. preserve the prefix // and let the replay add to it. when the slot is empty the session buffer holds the whole // message so we wipe and rebuild from byte 0 const existingContent = targetMessage.content ?? ''; const existingReasoning = targetMessage.reasoningContent ?? ''; const isAppendMode = existingContent.length > 0; // helper: write to the active store only when the attached conv is currently displayed. // the lookup by message id is robust to reordering of activeMessages, two parallel attaches // can no longer step on each other's indices const writeActive = (updates: Partial) => { if (convId !== conversationsStore.activeConversation?.id) { return; } const liveIdx = conversationsStore.findMessageIndex(targetMessageId); if (liveIdx === -1) return; conversationsStore.updateMessageAtIndex(liveIdx, updates); }; if (!isAppendMode) { writeActive({ content: '', reasoningContent: undefined }); } // extract the model suffix, the resume calls in handleStreamResponse must reuse the model // the session was tagged with, not the live dropdown const sepIdx = id.indexOf('::'); const attachedModel: string | null = sepIdx === -1 ? null : id.slice(sepIdx + 2); this.setChatStreaming(convId, existingContent, targetMessageId, attachedModel); const abortController = this.getOrCreateAbortController(convId); let streamedContent = ''; let streamedReasoningContent = ''; const cleanup = () => { unlock(); this.setProcessingState(convId, null); }; try { await ChatService.handleStreamResponse( response, (chunk: string) => { streamedContent += chunk; const displayed = isAppendMode ? existingContent + streamedContent : streamedContent; writeActive({ content: displayed }); this.setChatStreaming(convId, displayed, targetMessageId); }, async ( finalContent?: string, reasoningContent?: string, timings?: ChatMessageTimings, toolCalls?: string ) => { const streamed = streamedContent || finalContent || ''; const streamedR = streamedReasoningContent || reasoningContent || ''; const content = isAppendMode ? existingContent + streamed : streamed; const reasoning = isAppendMode ? existingReasoning + streamedR : streamedR; // the DB write is the source of truth, mirror to the active store only when // the conv is currently displayed await DatabaseService.updateMessage(targetMessageId, { content, reasoningContent: reasoning || undefined, toolCalls: toolCalls || '', timings }); writeActive({ content, reasoningContent: reasoning || undefined, timings }); cleanup(); }, (err: Error) => { console.error('attachServerStream pipe error:', err); cleanup(); }, (chunk: string) => { streamedReasoningContent += chunk; const displayed = isAppendMode ? existingReasoning + streamedReasoningContent : streamedReasoningContent; writeActive({ reasoningContent: displayed }); }, undefined, undefined, undefined, undefined, convId, abortController.signal, (connState: StreamConnectionState) => { if (convId === conversationsStore.activeConversation?.id) { this.streamConnectionState = connState; } }, attachedModel ); } catch (e) { console.error('attachServerStream pipe crashed:', e); cleanup(); } } async discoverActiveStream(convId: string): Promise { if (!convId) return; if (this.chatStreamingStates.has(convId)) return; if (this.chatLoadingStates.get(convId)) return; // concurrency guard: another discover may already be running for this conv (typical race // between mount and visibilitychange on tab switch). a second concurrent fetch on the same // /v1/stream/ would duplicate every byte into the DB message, this guard bounces it if (this.discoveringConvs.has(convId)) return; this.discoveringConvs.add(convId); try { // the model is frozen at POST time, rebuild the exact conv::model identity from the // persisted state so the lookup key matches what the server stored. null means a single // model conv with no ::suffix, only guess from the dropdown with no persisted state const localState = ChatService.getStreamState(convId); const streamId = ChatService.resumeStreamIdentity(convId, localState, selectedModelName()); // primary path: ask the server which sessions exist for this identity const serverTarget = await this.probeServerStream(streamId); if (serverTarget) { // pass the full server side identity (may carry a ::model suffix) so the GET routes // straight to the owning session, no probe or fan out await this.attachServerStream(convId, serverTarget.conversation_id); return; } // fallback: local state remembers an interrupted byte offset for this conv, the server may // still have a live session matching that identity (we just lost the bytes mid stream). retry // with the frozen identity, the server probe inside attachServerStream tells us if it exists if (!localState) { return; } await this.attachServerStream(convId, streamId); // if attachServerStream failed (session gone, TTL expired), clear the local state to avoid retrying forever if (!this.chatStreamingStates.has(convId) && !this.chatLoadingStates.get(convId)) { ChatService.clearStreamState(convId); } } finally { this.discoveringConvs.delete(convId); } } private findLastAssistantIdx(messages: DatabaseMessage[]): number { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === MessageRole.ASSISTANT) return i; } return -1; } private findLastUserIdx(messages: DatabaseMessage[]): number { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === MessageRole.USER) return i; } return -1; } clearUIState(): void { this.isLoading = false; this.currentResponse = ''; this.isStreamingActive = false; } setActiveProcessingConversation(conversationId: string | null): void { this.activeConversationId = conversationId; this.activeProcessingState = conversationId ? this.processingStates.get(conversationId) || null : null; } getProcessingState(conversationId: string): ApiProcessingState | null { return this.processingStates.get(conversationId) || null; } private setProcessingState(conversationId: string, state: ApiProcessingState | null): void { if (state === null) this.processingStates.delete(conversationId); else this.processingStates.set(conversationId, state); if (conversationId === this.activeConversationId) this.activeProcessingState = state; } clearProcessingState(conversationId: string): void { this.processingStates.delete(conversationId); if (conversationId === this.activeConversationId) this.activeProcessingState = null; } getActiveProcessingState(): ApiProcessingState | null { return this.activeProcessingState; } getCurrentProcessingStateSync(): ApiProcessingState | null { return this.activeProcessingState; } private setStreamingActive(active: boolean): void { this.isStreamingActive = active; } isStreaming(): boolean { return this.isStreamingActive; } private getOrCreateAbortController(convId: string): AbortController { let c = this.abortControllers.get(convId); if (!c || c.signal.aborted) { c = new AbortController(); this.abortControllers.set(convId, c); } return c; } private abortRequest(convId?: string): void { if (convId) { const c = this.abortControllers.get(convId); if (c) { c.abort(); this.abortControllers.delete(convId); } } else { for (const c of this.abortControllers.values()) c.abort(); this.abortControllers.clear(); } } /** * Abort the current agentic flow signal without clearing loading state. * Used by "Send immediately" to force the agentic loop to exit so that * the pending steering message can be re-sent. */ abortCurrentFlow(convId: string): void { const c = this.abortControllers.get(convId); if (c) { c.abort(); this.abortControllers.delete(convId); } } private showErrorDialog(state: ErrorDialogState | null): void { this.errorDialogState = state; } dismissErrorDialog(): void { this.errorDialogState = null; } clearEditMode(): void { this.isEditModeActive = false; this.addFilesHandler = null; } isEditing(): boolean { return this.isEditModeActive; } setEditModeActive(handler: (files: File[]) => void): void { this.isEditModeActive = true; this.addFilesHandler = handler; } getAddFilesHandler(): ((files: File[]) => void) | null { return this.addFilesHandler; } clearPendingEditMessageId(): void { this.pendingEditMessageId = null; } savePendingDraft(message: string, files: ChatUploadedFile[]): void { this._pendingDraftMessage = message; this._pendingDraftFiles = [...files]; } consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null { if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) return null; const d = { message: this._pendingDraftMessage, files: [...this._pendingDraftFiles] }; this._pendingDraftMessage = ''; this._pendingDraftFiles = []; return d; } hasPendingDraft(): boolean { return Boolean(this._pendingDraftMessage) || this._pendingDraftFiles.length > 0; } getAllLoadingChats(): string[] { // union of local (this browser is piping) and remote (backend reports a running session // for this conv but no local pipe yet) sources. the sidebar shows one spinner per entry const out = new SvelteSet(this.chatLoadingStates.keys()); for (const id of this.remoteRunningConvs) { out.add(id); } return Array.from(out); } getAllStreamingChats(): string[] { return Array.from(this.chatStreamingStates.keys()); } /** * Resync the remote running convs set from the backend. Called by the layout at mount and on * visibilitychange, no polling. A snapshot semantic: the set is replaced wholesale, stale entries * for sessions that finalized while the browser was elsewhere are dropped naturally. */ async syncRemoteRunningStreams(): Promise { // the conversations store loads from IndexedDB asynchronously, the +layout onMount caller // fires before that finishes. read ids straight from the DB so the result does not depend // on the store init race, and the sidebar spinners light up at first paint for every conv // the user owns even if it has not been hydrated into the store yet let ids: string[]; try { const all = await DatabaseService.getAllConversations(); ids = all.map((c) => c.id).filter((id) => !!id); } catch (e) { console.warn('syncRemoteRunningStreams DB read failed:', e); return; } // only ask about conv ids the user already owns if (ids.length === 0) { for (const id of Array.from(this.remoteRunningConvs)) { this.remoteRunningConvs.delete(id); } return; } // rebuild the frozen conv::model identity per conv so a session started with a model still // matches. the server response is mapped back to the bare id below for the sidebar set const lookupIds = ids.map((id) => ChatService.resumeStreamIdentity(id, ChatService.getStreamState(id), null) ); let sessions: ApiStreamSession[]; try { const resp = await fetch('./v1/streams/lookup', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ conversation_ids: lookupIds }) }); if (!resp.ok) return; const body = (await resp.json()) as unknown; if (!Array.isArray(body)) return; sessions = body as ApiStreamSession[]; } catch (e) { console.warn('syncRemoteRunningStreams fetch failed:', e); return; } const running = new SvelteSet(); for (const s of sessions) { if (s && !s.is_done && typeof s.conversation_id === 'string' && s.conversation_id) { // strip the optional ::model suffix, the sidebar set is keyed by the bare conv id const sepIdx = s.conversation_id.indexOf('::'); const bareId = sepIdx === -1 ? s.conversation_id : s.conversation_id.slice(0, sepIdx); running.add(bareId); } } for (const id of Array.from(this.remoteRunningConvs)) { if (!running.has(id)) { this.remoteRunningConvs.delete(id); } } for (const id of running) { this.remoteRunningConvs.add(id); } } getChatStreamingPublic(convId: string): { response: string; messageId: string } | undefined { return this.getChatStreaming(convId); } isChatLoadingPublic(convId: string): boolean { return this.chatLoadingStates.get(convId) || false; } isChatReasoningPublic(convId: string): boolean { return this.chatReasoningStates.get(convId) || false; } private isChatLoadingInternal(convId: string): boolean { return this.chatLoadingStates.has(convId) || this.chatStreamingStates.has(convId); } hasPendingMessage(convId: string): boolean { return this._pendingMessages.has(convId); } pendingMessageContent(convId: string): string | null { return this._pendingMessages.get(convId)?.content ?? null; } pendingMessageExtras(convId: string): DatabaseMessageExtra[] | undefined { return this._pendingMessages.get(convId)?.extras; } injectPendingMessage(convId: string, content: string, extras?: DatabaseMessageExtra[]): void { this._pendingMessages.set(convId, { content, extras }); } clearPendingMessage(convId: string): void { this._pendingMessages.delete(convId); } consumePendingMessage( convId: string ): { content: string; extras?: DatabaseMessageExtra[] } | null { const msg = this._pendingMessages.get(convId); if (!msg) return null; this._pendingMessages.delete(convId); return msg; } private touchConversationState(convId: string): void { this.conversationStateTimestamps.set(convId, { lastAccessed: Date.now() }); } cleanupOldConversationStates(activeConversationIds?: string[]): number { const now = Date.now(); const activeIdsList = activeConversationIds ?? []; const preserveIds = this.activeConversationId ? [...activeIdsList, this.activeConversationId] : activeIdsList; const allConvIds = [ ...new Set([ ...this.chatLoadingStates.keys(), ...this.chatStreamingStates.keys(), ...this.abortControllers.keys(), ...this.processingStates.keys(), ...this.conversationStateTimestamps.keys() ]) ]; const cleanupCandidates: Array<{ convId: string; lastAccessed: number }> = []; for (const convId of allConvIds) { if (preserveIds.includes(convId)) continue; if (this.chatLoadingStates.get(convId)) continue; if (this.chatStreamingStates.has(convId)) continue; const ts = this.conversationStateTimestamps.get(convId); cleanupCandidates.push({ convId, lastAccessed: ts?.lastAccessed ?? 0 }); } cleanupCandidates.sort((a, b) => a.lastAccessed - b.lastAccessed); let cleanedUp = 0; for (const { convId, lastAccessed } of cleanupCandidates) { if ( cleanupCandidates.length - cleanedUp > MAX_INACTIVE_CONVERSATION_STATES || now - lastAccessed > INACTIVE_CONVERSATION_STATE_MAX_AGE_MS ) { this.cleanupConversationState(convId); cleanedUp++; } } return cleanedUp; } private cleanupConversationState(convId: string): void { const c = this.abortControllers.get(convId); if (c && !c.signal.aborted) c.abort(); this.chatLoadingStates.delete(convId); this.chatStreamingStates.delete(convId); this.abortControllers.delete(convId); this.processingStates.delete(convId); this.conversationStateTimestamps.delete(convId); } getTrackedConversationCount(): number { return new Set([ ...this.chatLoadingStates.keys(), ...this.chatStreamingStates.keys(), ...this.abortControllers.keys(), ...this.processingStates.keys() ]).size; } private getMessageByIdWithRole( messageId: string, expectedRole?: MessageRole ): { message: DatabaseMessage; index: number } | null { const index = conversationsStore.findMessageIndex(messageId); if (index === -1) return null; const message = conversationsStore.activeMessages[index]; if (expectedRole && message.role !== expectedRole) return null; return { message, index }; } async addMessage( role: MessageRole, content: string, type: MessageType = MessageType.TEXT, parent: string = '-1', extras?: DatabaseMessageExtra[] ): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv) throw new Error('No active conversation'); let parentId: string | null = null; if (parent === '-1') { const am = conversationsStore.activeMessages; if (am.length > 0) parentId = am[am.length - 1].id; else { const all = await conversationsStore.getConversationMessages(activeConv.id); const r = all.find((m) => m.parent === null && m.type === 'root'); parentId = r ? r.id : await DatabaseService.createRootMessage(activeConv.id); } } else parentId = parent; const message = await DatabaseService.createMessageBranch( { convId: activeConv.id, role, content, type, timestamp: Date.now(), toolCalls: '', children: [], extra: extras }, parentId ); conversationsStore.addMessageToActive(message); await conversationsStore.updateCurrentNode(message.id); conversationsStore.updateConversationTimestamp(); return message; } async addSystemPrompt(): Promise { let activeConv = conversationsStore.activeConversation; if (!activeConv) { await conversationsStore.createConversation(); activeConv = conversationsStore.activeConversation; } if (!activeConv) return; try { const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); const rootId = rootMessage ? rootMessage.id : await DatabaseService.createRootMessage(activeConv.id); const existingSystemMessage = allMessages.find( (m) => m.role === MessageRole.SYSTEM && m.parent === rootId ); if (existingSystemMessage) { this.pendingEditMessageId = existingSystemMessage.id; if (!conversationsStore.activeMessages.some((m) => m.id === existingSystemMessage.id)) conversationsStore.activeMessages.unshift(existingSystemMessage); return; } const am = conversationsStore.activeMessages; const firstActiveMessage = am.find((m) => m.parent === rootId); const systemMessage = await DatabaseService.createSystemMessage( activeConv.id, SYSTEM_MESSAGE_PLACEHOLDER, rootId ); if (firstActiveMessage) { await DatabaseService.updateMessage(firstActiveMessage.id, { parent: systemMessage.id }); await DatabaseService.updateMessage(systemMessage.id, { children: [firstActiveMessage.id] }); const updatedRootChildren = rootMessage ? rootMessage.children.filter((id: string) => id !== firstActiveMessage.id) : []; await DatabaseService.updateMessage(rootId, { children: [ ...updatedRootChildren.filter((id: string) => id !== systemMessage.id), systemMessage.id ] }); const firstMsgIndex = conversationsStore.findMessageIndex(firstActiveMessage.id); if (firstMsgIndex !== -1) conversationsStore.updateMessageAtIndex(firstMsgIndex, { parent: systemMessage.id }); } conversationsStore.activeMessages.unshift(systemMessage); this.pendingEditMessageId = systemMessage.id; conversationsStore.updateConversationTimestamp(); } catch (error) { console.error('Failed to add system prompt:', error); } } async removeSystemPromptPlaceholder(messageId: string): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv) return false; try { const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const systemMessage = findMessageById(allMessages, messageId); if (!systemMessage || systemMessage.role !== MessageRole.SYSTEM) return false; const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); if (!rootMessage) return false; if (allMessages.length === 2 && systemMessage.children.length === 0) { await conversationsStore.deleteConversation(activeConv.id); return true; } for (const childId of systemMessage.children) { await DatabaseService.updateMessage(childId, { parent: rootMessage.id }); const childIndex = conversationsStore.findMessageIndex(childId); if (childIndex !== -1) conversationsStore.updateMessageAtIndex(childIndex, { parent: rootMessage.id }); } await DatabaseService.updateMessage(rootMessage.id, { children: [ ...rootMessage.children.filter((id: string) => id !== messageId), ...systemMessage.children ] }); await DatabaseService.deleteMessage(messageId); const systemIndex = conversationsStore.findMessageIndex(messageId); if (systemIndex !== -1) conversationsStore.activeMessages.splice(systemIndex, 1); conversationsStore.updateConversationTimestamp(); return false; } catch (error) { console.error('Failed to remove system prompt placeholder:', error); return false; } } private async createAssistantMessage(parentId?: string): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv) throw new Error('No active conversation'); return await DatabaseService.createMessageBranch( { convId: activeConv.id, type: MessageType.TEXT, role: MessageRole.ASSISTANT, content: '', timestamp: Date.now(), toolCalls: '', children: [], model: null }, parentId || null ); } async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise { if (!content.trim() && (!extras || extras.length === 0)) return; const activeConv = conversationsStore.activeConversation; // If agentic loop is running, inject as a steering message instead of starting a new flow if (activeConv && agenticStore.isRunning(activeConv.id)) { agenticStore.injectSteeringMessage(activeConv.id, content, extras); return; } // If non-agentic streaming is active, queue as a pending message to send after completion if (activeConv && this.isChatLoadingInternal(activeConv.id)) { this.injectPendingMessage(activeConv.id, content, extras); return; } // Cancel any in-flight pre-encode request this.cancelPreEncode(); // Consume MCP resource attachments - converts them to extras and clears the live store const resourceExtras = mcpStore.consumeResourceAttachmentsAsExtras(); const allExtras = resourceExtras.length > 0 ? [...(extras || []), ...resourceExtras] : extras; let isNewConversation = false; if (!activeConv) { await conversationsStore.createConversation(); isNewConversation = true; } const currentConv = conversationsStore.activeConversation; if (!currentConv) return; this.showErrorDialog(null); this.setChatLoading(currentConv.id, true); this.clearChatStreaming(currentConv.id); try { let parentIdForUserMessage: string | undefined; if (isNewConversation) { const rootId = await DatabaseService.createRootMessage(currentConv.id); const currentConfig = config(); const systemPrompt = currentConfig.systemMessage?.toString().trim(); if (systemPrompt) { const systemMessage = await DatabaseService.createSystemMessage( currentConv.id, systemPrompt, rootId ); conversationsStore.addMessageToActive(systemMessage); parentIdForUserMessage = systemMessage.id; } else parentIdForUserMessage = rootId; } const userMessage = await this.addMessage( MessageRole.USER, content, MessageType.TEXT, parentIdForUserMessage ?? '-1', allExtras ); if (isNewConversation && content) await conversationsStore.updateConversationName( currentConv.id, generateConversationTitle(content, Boolean(config().titleGenerationUseFirstLine)) ); const assistantMessage = await this.createAssistantMessage(userMessage.id); conversationsStore.addMessageToActive(assistantMessage); await this.streamChatCompletion( conversationsStore.activeMessages.slice(0, -1), assistantMessage, undefined, undefined, undefined, config().titleGenerationUseLLM && isNewConversation ? content : undefined ); } catch (error) { if (isAbortError(error)) { this.setChatLoading(currentConv.id, false); return; } console.error('Failed to send message:', error); this.setChatLoading(currentConv.id, false); const dialogType = error instanceof Error && error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER; const contextInfo = ( error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } ).contextInfo; this.showErrorDialog({ type: dialogType, message: error instanceof Error ? error.message : 'Unknown error', contextInfo }); } } private async streamChatCompletion( allMessages: DatabaseMessage[], assistantMessage: DatabaseMessage, onComplete?: (content: string) => Promise, onError?: (error: Error) => void, modelOverride?: string | null, firstUserMessageContent?: string ): Promise { // the ::model suffix in the stream identity is only for router mode, where it routes to the // owning child. in single-model mode the identity stays the bare conv id so that attach, stop // and reattach all agree, regardless of fresh send vs regenerate passing a resolved model let effectiveModel: string | null | undefined = undefined; if (isRouterMode()) { const conversationModel = this.getConversationModel(allMessages); effectiveModel = modelOverride || selectedModelName() || conversationModel; } if (isRouterMode() && effectiveModel) { if (!modelsStore.getModelProps(effectiveModel)) await modelsStore.fetchModelProps(effectiveModel); } // Mutable state for the current message being streamed let currentMessageId = assistantMessage.id; let streamedContent = ''; let streamedReasoningContent = ''; let resolvedModel: string | null = null; let modelPersisted = false; const convId = assistantMessage.convId; // Tracks the last message created in this flow. Used as the parent for the next // turn's assistant message so createAssistantMessage does not have to read // conversationsStore.activeMessages, which may belong to a different conversation // after the user navigates while the loop is still running. let lastCreatedInFlow = currentMessageId; // freeze the POST identity from t0 so a stop cancels with the exact session key, // never a stale or empty model resolved later this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel); const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => { if (!modelName) return; const n = normalizeModelName(modelName); if (!n || n === resolvedModel) return; resolvedModel = n; const idx = conversationsStore.findMessageIndex(currentMessageId); conversationsStore.updateMessageAtIndex(idx, { model: n }); if (persistImmediately && !modelPersisted) { modelPersisted = true; DatabaseService.updateMessage(currentMessageId, { model: n }).catch(() => { modelPersisted = false; resolvedModel = null; }); } }; let completionIdRecorded = false; const recordCompletionId = (id: string): void => { if (!id || completionIdRecorded) return; completionIdRecorded = true; const idx = conversationsStore.findMessageIndex(currentMessageId); conversationsStore.updateMessageAtIndex(idx, { completionId: id }); DatabaseService.updateMessage(currentMessageId, { completionId: id }).catch(() => { completionIdRecorded = false; }); }; const updateStreamingUI = () => { this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel); const idx = conversationsStore.findMessageIndex(currentMessageId); conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); }; const cleanupStreamingState = () => { this.setStreamingActive(false); this.setChatLoading(convId, false); this.clearChatStreaming(convId, currentMessageId); this.setProcessingState(convId, null); }; this.setStreamingActive(true); this.setActiveProcessingConversation(convId); const abortController = this.getOrCreateAbortController(convId); const streamCallbacks: ChatStreamCallbacks = { onChunk: (chunk: string) => { streamedContent += chunk; updateStreamingUI(); this.setChatReasoning(convId, false); }, onReasoningChunk: (chunk: string) => { streamedReasoningContent += chunk; // mark streaming state so a stop mid-thinking can persist the partial reasoning this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel); const idx = conversationsStore.findMessageIndex(currentMessageId); conversationsStore.updateMessageAtIndex(idx, { reasoningContent: streamedReasoningContent }); this.setChatReasoning(convId, true); }, onToolCallsStreaming: (toolCalls) => { const idx = conversationsStore.findMessageIndex(currentMessageId); conversationsStore.updateMessageAtIndex(idx, { toolCalls: JSON.stringify(toolCalls) }); }, onAttachments: (messageId: string, extras: DatabaseMessageExtra[]) => { if (!extras.length) return; const idx = conversationsStore.findMessageIndex(messageId); if (idx === -1) return; const msg = conversationsStore.activeMessages[idx]; const updatedExtras = [...(msg.extra || []), ...extras]; conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras }); DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error); }, onModel: (modelName: string) => recordModel(modelName), onCompletionId: (id: string) => recordCompletionId(id), onTurnComplete: (intermediateTimings: ChatMessageTimings) => { // Update the first assistant message with cumulative agentic timings const idx = conversationsStore.findMessageIndex(assistantMessage.id); conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings }); }, onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { const tokensPerSecond = timings?.predicted_ms && timings?.predicted_n ? (timings.predicted_n / timings.predicted_ms) * 1000 : 0; this.updateProcessingStateFromTimings( { prompt_n: timings?.prompt_n || 0, prompt_ms: timings?.prompt_ms, predicted_n: timings?.predicted_n || 0, predicted_per_second: tokensPerSecond, cache_n: timings?.cache_n || 0, prompt_progress: promptProgress }, convId ); }, onAssistantTurnComplete: async ( content: string, reasoningContent: string | undefined, timings: ChatMessageTimings | undefined, toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined ) => { const updateData: Record = { content, reasoningContent: reasoningContent || undefined, toolCalls: toolCalls ? JSON.stringify(toolCalls) : '', timings }; if (resolvedModel && !modelPersisted) updateData.model = resolvedModel; await DatabaseService.updateMessage(currentMessageId, updateData); const idx = conversationsStore.findMessageIndex(currentMessageId); const uiUpdate: Partial = { content, reasoningContent: reasoningContent || undefined, toolCalls: toolCalls ? JSON.stringify(toolCalls) : '' }; if (timings) uiUpdate.timings = timings; if (resolvedModel) uiUpdate.model = resolvedModel; // touch the active ui array and node pointer only when this conversation // is displayed; otherwise persist the node move straight to the db so a // foreign conv's currNode stays untouched if (conversationsStore.activeConversation?.id === convId) { conversationsStore.updateMessageAtIndex(idx, uiUpdate); await conversationsStore.updateCurrentNode(currentMessageId); } else { await DatabaseService.updateCurrentNode(convId, currentMessageId); } }, createToolResultMessage: async ( toolCallId: string, content: string, extras?: DatabaseMessageExtra[] ) => { const msg = await DatabaseService.createMessageBranch( { convId, type: MessageType.TEXT, role: MessageRole.TOOL, content, toolCallId, timestamp: Date.now(), toolCalls: '', children: [], extra: extras }, currentMessageId ); // mirror into the active store and move the node pointer only when this // conversation is displayed; otherwise persist the node move straight to // the db for the owning conv so a foreign conv's currNode stays untouched if (conversationsStore.activeConversation?.id === convId) { conversationsStore.addMessageToActive(msg); await conversationsStore.updateCurrentNode(msg.id); } else { await DatabaseService.updateCurrentNode(convId, msg.id); } lastCreatedInFlow = msg.id; return msg; }, createAssistantMessage: async () => { // Reset streaming state for new message streamedContent = ''; streamedReasoningContent = ''; const msg = await DatabaseService.createMessageBranch( { convId, type: MessageType.TEXT, role: MessageRole.ASSISTANT, content: '', timestamp: Date.now(), toolCalls: '', children: [], model: resolvedModel }, lastCreatedInFlow ); if (conversationsStore.activeConversation?.id === convId) { conversationsStore.addMessageToActive(msg); } currentMessageId = msg.id; lastCreatedInFlow = msg.id; return msg; }, onFlowComplete: (finalTimings?: ChatMessageTimings) => { if (finalTimings) { const idx = conversationsStore.findMessageIndex(assistantMessage.id); conversationsStore.updateMessageAtIndex(idx, { timings: finalTimings }); DatabaseService.updateMessage(assistantMessage.id, { timings: finalTimings }).catch(console.error); } cleanupStreamingState(); if (onComplete) onComplete(streamedContent); if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error); // Pre-encode conversation in KV cache for faster next turn if (config().preEncodeConversation) { this.triggerPreEncode( allMessages, assistantMessage, streamedContent, effectiveModel, !!config().excludeReasoningFromContext ); } }, onError: async (error: Error) => { this.setStreamingActive(false); if (isAbortError(error)) { cleanupStreamingState(); // If aborted with a pending message (e.g. "Send immediately"), re-send it const pending = this.consumePendingMessage(convId); if (pending) { this.sendMessage(pending.content, pending.extras); } return; } console.error('Streaming error:', error); // keep whatever was streamed so far, the message stays in memory and in DB await this.savePartialResponseIfNeeded(convId); cleanupStreamingState(); this.clearPendingMessage(convId); const contextInfo = ( error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } ).contextInfo; this.showErrorDialog({ type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER, message: error.message, contextInfo }); if (onError) onError(error); } }; const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides; { const agenticResult = await agenticStore.runAgenticFlow({ conversationId: convId, messages: allMessages, options: { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) }, callbacks: streamCallbacks, signal: abortController.signal, perChatOverrides }); if (agenticResult.handled) { // Generate LLM based title for new conversations after agentic flow completes if (firstUserMessageContent) { await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId); } // Check if there's a pending steering message to re-send const pending = agenticStore.consumePendingSteeringMessage(convId); if (pending) { await this.sendMessage(pending.content, pending.extras); } return; } } await ChatService.sendMessage( allMessages, { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}), stream: true, onChunk: streamCallbacks.onChunk, onReasoningChunk: streamCallbacks.onReasoningChunk, onModel: streamCallbacks.onModel, onCompletionId: streamCallbacks.onCompletionId, onTimings: streamCallbacks.onTimings, onConnectionState: (state: StreamConnectionState) => { if (convId === conversationsStore.activeConversation?.id) { this.streamConnectionState = state; } }, onComplete: async ( finalContent?: string, reasoningContent?: string, timings?: ChatMessageTimings, toolCalls?: string ) => { const content = streamedContent || finalContent || ''; const reasoning = streamedReasoningContent || reasoningContent; const updateData: Record = { content, reasoningContent: reasoning || undefined, toolCalls: toolCalls || '', timings }; if (resolvedModel && !modelPersisted) updateData.model = resolvedModel; await DatabaseService.updateMessage(currentMessageId, updateData); const idx = conversationsStore.findMessageIndex(currentMessageId); const uiUpdate: Partial = { content, reasoningContent: reasoning || undefined, toolCalls: toolCalls || '' }; if (timings) uiUpdate.timings = timings; if (resolvedModel) uiUpdate.model = resolvedModel; conversationsStore.updateMessageAtIndex(idx, uiUpdate); await conversationsStore.updateCurrentNode(currentMessageId); cleanupStreamingState(); if (onComplete) await onComplete(content); if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error); // Generate LLM based title for new conversations (avoids stale reference // issue when user switches conversations while streaming) if (firstUserMessageContent) { await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId); } // Check if there's a pending message queued during streaming const pending = this.consumePendingMessage(convId); if (pending) { await this.sendMessage(pending.content, pending.extras); } }, onError: streamCallbacks.onError }, convId, abortController.signal ); } async stopGeneration(): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv) return; await this.stopGenerationForChat(activeConv.id); } async stopGenerationForChat(convId: string): Promise { await this.savePartialResponseIfNeeded(convId); this.setStreamingActive(false); // tell the server to stop the generation, not just drop the HTTP socket. without this the // detached drain keeps producing tokens until eos or max_tokens. use the frozen identity // captured when the session started, not the live dropdown const streamStateForStop = this.chatStreamingStates.get(convId); const modelForStop = streamStateForStop?.model; void ChatService.cancelServerStream(convId, modelForStop); this.abortRequest(convId); this.setChatLoading(convId, false); this.clearChatStreaming(convId); this.setProcessingState(convId, null); this.clearPendingMessage(convId); } private async generateTitleWithLLM( userContent: string, assistantContent: string, convId: string ): Promise { const effectiveModel = isRouterMode() && selectedModelName() ? selectedModelName() : undefined; const configValue = config(); const titlePromptTemplate = typeof configValue.titleGenerationPrompt === 'string' && configValue.titleGenerationPrompt.trim() ? configValue.titleGenerationPrompt : TITLE_GENERATION.DEFAULT_PROMPT; const titlePrompt = titlePromptTemplate .replace('{{USER}}', String(userContent || '')) .replace('{{ASSISTANT}}', String(assistantContent || '')); const titleMessage: ApiChatMessageData = { role: MessageRole.USER, content: titlePrompt }; const titleResponse = await ChatService.generateTitle(titleMessage, effectiveModel); if (!titleResponse) { return; } let cleanTitle = titleResponse.trim(); cleanTitle = cleanTitle .replace(TITLE_GENERATION.PREFIX_PATTERN, '') .replace(TITLE_GENERATION.QUOTE_PATTERN, '') .trim(); if (!cleanTitle || cleanTitle.length < TITLE_GENERATION.MIN_LENGTH) { const firstLine = userContent.split('\n').find((l) => l.trim().length > 0); cleanTitle = firstLine ? firstLine.trim() : TITLE_GENERATION.FALLBACK; } if (cleanTitle && cleanTitle.length >= TITLE_GENERATION.MIN_LENGTH) { await conversationsStore.updateConversationName(convId, cleanTitle); } } private async savePartialResponseIfNeeded(convId?: string): Promise { const conversationId = convId || conversationsStore.activeConversation?.id; if (!conversationId) return; const streamingState = this.getChatStreaming(conversationId); if (!streamingState) return; const messages = conversationId === conversationsStore.activeConversation?.id ? conversationsStore.activeMessages : await conversationsStore.getConversationMessages(conversationId); if (!messages.length) return; const lastMessage = messages[messages.length - 1]; if (lastMessage?.role !== MessageRole.ASSISTANT) return; const partialContent = streamingState.response; const partialReasoning = lastMessage.reasoningContent || ''; // nothing to persist when both content and reasoning are empty (e.g. stop before any token) if (!partialContent.trim() && !partialReasoning.trim()) return; try { const updateData: { content: string; reasoningContent?: string; timings?: ChatMessageTimings; } = { content: partialContent }; if (partialReasoning) { updateData.reasoningContent = partialReasoning; } const lastKnownState = this.getProcessingState(conversationId); if (lastKnownState) { updateData.timings = { prompt_n: lastKnownState.promptTokens || 0, prompt_ms: lastKnownState.promptMs, predicted_n: lastKnownState.tokensDecoded || 0, cache_n: lastKnownState.cacheTokens || 0, predicted_ms: lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000 : undefined }; } await DatabaseService.updateMessage(lastMessage.id, updateData); lastMessage.content = partialContent; if (updateData.timings) lastMessage.timings = updateData.timings; } catch (error) { lastMessage.content = partialContent; console.error('Failed to save partial response:', error); } } async updateMessage(messageId: string, newContent: string): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv) return; if (this.isChatLoadingInternal(activeConv.id)) await this.stopGeneration(); const result = this.getMessageByIdWithRole(messageId, MessageRole.USER); if (!result) return; const { message: messageToUpdate, index: messageIndex } = result; const originalContent = messageToUpdate.content; try { const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id; conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent }); await DatabaseService.updateMessage(messageId, { content: newContent }); if (isFirstUserMessage && newContent.trim()) await conversationsStore.updateConversationTitleWithConfirmation( activeConv.id, generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) ); const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1); for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); conversationsStore.sliceActiveMessages(messageIndex + 1); conversationsStore.updateConversationTimestamp(); this.setChatLoading(activeConv.id, true); this.clearChatStreaming(activeConv.id); const assistantMessage = await this.createAssistantMessage(); conversationsStore.addMessageToActive(assistantMessage); await conversationsStore.updateCurrentNode(assistantMessage.id); await this.streamChatCompletion( conversationsStore.activeMessages.slice(0, -1), assistantMessage, undefined, () => { conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), { content: originalContent }); } ); } catch (error) { if (!isAbortError(error)) console.error('Failed to update message:', error); } } async regenerateMessage(messageId: string): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; this.cancelPreEncode(); const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT); if (!result) return; const { index: messageIndex } = result; try { const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex); for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); conversationsStore.sliceActiveMessages(messageIndex); conversationsStore.updateConversationTimestamp(); this.setChatLoading(activeConv.id, true); this.clearChatStreaming(activeConv.id); const parentMessageId = conversationsStore.activeMessages.length > 0 ? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id : undefined; const assistantMessage = await this.createAssistantMessage(parentMessageId); conversationsStore.addMessageToActive(assistantMessage); await this.streamChatCompletion( conversationsStore.activeMessages.slice(0, -1), assistantMessage ); } catch (error) { if (!isAbortError(error)) console.error('Failed to regenerate message:', error); this.setChatLoading(activeConv?.id || '', false); } } async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; this.cancelPreEncode(); try { const idx = conversationsStore.findMessageIndex(messageId); if (idx === -1) return; const msg = conversationsStore.activeMessages[idx]; if (msg.role !== MessageRole.ASSISTANT) return; const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const parentMessage = findMessageById(allMessages, msg.parent); if (!parentMessage) return; this.setChatLoading(activeConv.id, true); this.clearChatStreaming(activeConv.id); const newAssistantMessage = await DatabaseService.createMessageBranch( { convId: msg.convId, type: msg.type, timestamp: Date.now(), role: msg.role, content: '', toolCalls: '', children: [], model: null }, parentMessage.id ); await conversationsStore.updateCurrentNode(newAssistantMessage.id); conversationsStore.updateConversationTimestamp(); await conversationsStore.refreshActiveMessages(); const conversationPath = filterByLeafNodeId( allMessages, parentMessage.id, false ) as DatabaseMessage[]; const modelToUse = modelOverride || msg.model || undefined; await this.streamChatCompletion( conversationPath, newAssistantMessage, undefined, undefined, modelToUse ); } catch (error) { if (!isAbortError(error)) console.error('Failed to regenerate message with branching:', error); this.setChatLoading(activeConv?.id || '', false); } } async getDeletionInfo(messageId: string): Promise<{ totalCount: number; userMessages: number; assistantMessages: number; messageTypes: string[]; }> { const activeConv = conversationsStore.activeConversation; if (!activeConv) return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] }; const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const messageToDelete = findMessageById(allMessages, messageId); // For system messages, don't count descendants as they will be preserved (reparented to root) if (messageToDelete?.role === MessageRole.SYSTEM) { const messagesToDelete = allMessages.filter((m) => m.id === messageId); let userMessages = 0, assistantMessages = 0; const messageTypes: string[] = []; for (const msg of messagesToDelete) { if (msg.role === MessageRole.USER) { userMessages++; if (!messageTypes.includes('user message')) messageTypes.push('user message'); } else if (msg.role === MessageRole.ASSISTANT) { assistantMessages++; if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response'); } } return { totalCount: 1, userMessages, assistantMessages, messageTypes }; } const descendants = findDescendantMessages(allMessages, messageId); const allToDelete = [messageId, ...descendants]; const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id)); let userMessages = 0, assistantMessages = 0; const messageTypes: string[] = []; for (const msg of messagesToDelete) { if (msg.role === MessageRole.USER) { userMessages++; if (!messageTypes.includes('user message')) messageTypes.push('user message'); } else if (msg.role === MessageRole.ASSISTANT) { assistantMessages++; if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response'); } } return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes }; } async deleteMessage(messageId: string): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv) return; try { const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const messageToDelete = findMessageById(allMessages, messageId); if (!messageToDelete) return; const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false); const isInCurrentPath = currentPath.some((m) => m.id === messageId); if (isInCurrentPath && messageToDelete.parent) { const siblings = allMessages.filter( (m) => m.parent === messageToDelete.parent && m.id !== messageId ); if (siblings.length > 0) { const latestSibling = siblings.reduce((latest, sibling) => sibling.timestamp > latest.timestamp ? sibling : latest ); await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id)); } else if (messageToDelete.parent) { await conversationsStore.updateCurrentNode( findLeafNode(allMessages, messageToDelete.parent) ); } } await DatabaseService.deleteMessageCascading(activeConv.id, messageId); await conversationsStore.refreshActiveMessages(); conversationsStore.updateConversationTimestamp(); } catch (error) { console.error('Failed to delete message:', error); } } /** * Open a fresh assistant turn anchored at the last tool result of a resolved * agentic round and let streamChatCompletion route through runAgenticFlow. * Used by continueAssistantMessage when classifyContinueIntent returns * next_turn, meaning the target assistant already has its tool_calls paired * with trailing tool results and the next thing to generate is a brand new * turn rather than a token level continuation. */ private async continueAsNextAgenticTurn(anchorIndex: number): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv) return; const anchor = conversationsStore.activeMessages[anchorIndex]; if (!anchor) return; this.cancelPreEncode(); this.setChatLoading(activeConv.id, true); this.clearChatStreaming(activeConv.id); try { const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const anchorMessage = findMessageById(allMessages, anchor.id); if (!anchorMessage) { this.setChatLoading(activeConv.id, false); return; } const newAssistantMessage = await DatabaseService.createMessageBranch( { convId: activeConv.id, type: MessageType.TEXT, timestamp: Date.now(), role: MessageRole.ASSISTANT, content: '', toolCalls: '', children: [], model: null }, anchorMessage.id ); await conversationsStore.updateCurrentNode(newAssistantMessage.id); conversationsStore.updateConversationTimestamp(); await conversationsStore.refreshActiveMessages(); const conversationPath = filterByLeafNodeId( allMessages, anchorMessage.id, false ) as DatabaseMessage[]; await this.streamChatCompletion(conversationPath, newAssistantMessage); } catch (error) { if (!isAbortError(error)) console.error('Failed to continue agentic turn:', error); this.setChatLoading(activeConv.id, false); } } async continueAssistantMessage(messageId: string): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT); if (!result) return; const { message: msg, index: idx } = result; // Decide which resume path applies. tool_calls without tool results can // not be resumed mid sequence by continue_final_message, branch instead. // tool_calls already paired with tool results need a fresh next turn, // not a token level continuation of the target assistant. const intent = classifyContinueIntent(conversationsStore.activeMessages, idx); if (intent.kind === ContinueIntentKind.RERUN_TURN) { return this.regenerateMessageWithBranching(messageId); } if (intent.kind === ContinueIntentKind.NEXT_TURN) { return this.continueAsNextAgenticTurn(intent.truncateAfter); } try { this.showErrorDialog(null); this.setChatLoading(activeConv.id, true); this.clearChatStreaming(activeConv.id); const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const dbMessage = findMessageById(allMessages, messageId); if (!dbMessage) { this.setChatLoading(activeConv.id, false); return; } const originalContent = dbMessage.content; const originalReasoning = dbMessage.reasoningContent || ''; // Hand the persisted DatabaseMessage straight to sendMessage so its // internal converter preserves tool_calls and extras when present. // Reconstructing a bare {role, content} here would drop those fields // and break continue_final_message for messages with tool calls. const contextWithContinue = conversationsStore.activeMessages.slice(0, idx + 1); let appendedContent = ''; let appendedReasoning = ''; let hasReceivedContent = false; const updateStreamingContent = (fullContent: string) => { this.setChatStreaming(msg.convId, fullContent, msg.id); // resolve the row by id on every write, switching to another conv mid continue makes // this a no op instead of writing positionally into the now displayed conversation conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), { content: fullContent }); }; const abortController = this.getOrCreateAbortController(msg.convId); await ChatService.sendMessage( contextWithContinue, { ...this.getApiOptions(), continueFinalMessage: true, onConnectionState: (state: StreamConnectionState) => { if (msg.convId === conversationsStore.activeConversation?.id) { this.streamConnectionState = state; } }, onChunk: (chunk: string) => { appendedContent += chunk; hasReceivedContent = true; updateStreamingContent(originalContent + appendedContent); this.setChatReasoning(msg.convId, false); }, onCompletionId: (id: string) => { if (!id) return; // refresh the message id so a later skip targets the live slot after a continue conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), { completionId: id }); DatabaseService.updateMessage(msg.id, { completionId: id }).catch(() => {}); }, onReasoningChunk: (chunk: string) => { appendedReasoning += chunk; hasReceivedContent = true; // mark streaming state so a stop mid-thinking can persist the partial reasoning this.setChatStreaming(msg.convId, originalContent + appendedContent, msg.id); conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), { reasoningContent: originalReasoning + appendedReasoning }); this.setChatReasoning(msg.convId, true); }, onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { const tokensPerSecond = timings?.predicted_ms && timings?.predicted_n ? (timings.predicted_n / timings.predicted_ms) * 1000 : 0; this.updateProcessingStateFromTimings( { prompt_n: timings?.prompt_n || 0, prompt_ms: timings?.prompt_ms, predicted_n: timings?.predicted_n || 0, predicted_per_second: tokensPerSecond, cache_n: timings?.cache_n || 0, prompt_progress: promptProgress }, msg.convId ); }, onComplete: async ( finalContent?: string, reasoningContent?: string, timings?: ChatMessageTimings ) => { const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || ''; const finalAppendedReasoning = hasReceivedContent ? appendedReasoning : reasoningContent || ''; const fullContent = originalContent + finalAppendedContent; const fullReasoning = originalReasoning + finalAppendedReasoning || undefined; await DatabaseService.updateMessage(msg.id, { content: fullContent, reasoningContent: fullReasoning, timestamp: Date.now(), timings }); conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), { content: fullContent, reasoningContent: fullReasoning, timestamp: Date.now(), timings }); conversationsStore.updateConversationTimestamp(); this.setChatLoading(msg.convId, false); this.clearChatStreaming(msg.convId); this.setProcessingState(msg.convId, null); }, onError: async (error: Error) => { if (isAbortError(error)) { if (hasReceivedContent && appendedContent) { await DatabaseService.updateMessage(msg.id, { content: originalContent + appendedContent, reasoningContent: originalReasoning + appendedReasoning || undefined, timestamp: Date.now() }); conversationsStore.updateMessageAtIndex( conversationsStore.findMessageIndex(msg.id), { content: originalContent + appendedContent, reasoningContent: originalReasoning + appendedReasoning || undefined, timestamp: Date.now() } ); } this.setChatLoading(msg.convId, false); this.clearChatStreaming(msg.convId); this.setProcessingState(msg.convId, null); return; } console.error('Continue generation error:', error); // keep whatever was appended so far, the message stays in memory and in DB await DatabaseService.updateMessage(msg.id, { content: originalContent + appendedContent, reasoningContent: originalReasoning + appendedReasoning || undefined, timestamp: Date.now() }); conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), { content: originalContent + appendedContent, reasoningContent: originalReasoning + appendedReasoning || undefined, timestamp: Date.now() }); this.setChatLoading(msg.convId, false); this.clearChatStreaming(msg.convId); this.setProcessingState(msg.convId, null); this.showErrorDialog({ type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER, message: error.message }); } }, msg.convId, abortController.signal ); } catch (error) { if (!isAbortError(error)) console.error('Failed to continue message:', error); if (activeConv) this.setChatLoading(activeConv.id, false); } } async editAssistantMessage( messageId: string, newContent: string, shouldBranch: boolean ): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT); if (!result) return; const { message: msg, index: idx } = result; try { if (shouldBranch) { const newMessage = await DatabaseService.createMessageBranch( { convId: msg.convId, type: msg.type, timestamp: Date.now(), role: msg.role, content: newContent, toolCalls: msg.toolCalls || '', children: [], model: msg.model }, msg.parent! ); await conversationsStore.updateCurrentNode(newMessage.id); } else { await DatabaseService.updateMessage(msg.id, { content: newContent }); conversationsStore.updateMessageAtIndex(idx, { content: newContent }); } conversationsStore.updateConversationTimestamp(); await conversationsStore.refreshActiveMessages(); } catch (error) { console.error('Failed to edit assistant message:', error); } } async editUserMessagePreserveResponses( messageId: string, newContent: string, newExtras?: DatabaseMessageExtra[] ): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv) return; const result = this.getMessageByIdWithRole(messageId, MessageRole.USER); if (!result) return; const { message: msg, index: idx } = result; try { const updateData: Partial = { content: newContent }; if (newExtras !== undefined) updateData.extra = JSON.parse(JSON.stringify(newExtras)); await DatabaseService.updateMessage(messageId, updateData); conversationsStore.updateMessageAtIndex(idx, updateData); const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) { await conversationsStore.updateConversationTitleWithConfirmation( activeConv.id, generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) ); } conversationsStore.updateConversationTimestamp(); } catch (error) { console.error('Failed to edit user message:', error); } } async editMessageWithBranching( messageId: string, newContent: string, newExtras?: DatabaseMessageExtra[] ): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; let result = this.getMessageByIdWithRole(messageId, MessageRole.USER); if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM); if (!result) return; const { message: msg, index: idx } = result; try { const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); const isFirstUserMessage = msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id; const extrasToUse = newExtras !== undefined ? JSON.parse(JSON.stringify(newExtras)) : msg.extra ? JSON.parse(JSON.stringify(msg.extra)) : undefined; let messageIdForResponse: string; const dbMsg = findMessageById(allMessages, msg.id); const hasChildren = dbMsg ? dbMsg.children.length > 0 : msg.children.length > 0; if (!hasChildren) { // No responses after this message — update in place instead of branching const updates: Partial = { content: newContent, timestamp: Date.now(), extra: extrasToUse }; await DatabaseService.updateMessage(msg.id, updates); conversationsStore.updateMessageAtIndex(idx, updates); messageIdForResponse = msg.id; } else { // Has children — create a new branch as sibling const parentId = msg.parent || rootMessage?.id; if (!parentId) return; const newMessage = await DatabaseService.createMessageBranch( { convId: msg.convId, type: msg.type, timestamp: Date.now(), role: msg.role, content: newContent, toolCalls: msg.toolCalls || '', children: [], extra: extrasToUse, model: msg.model }, parentId ); await conversationsStore.updateCurrentNode(newMessage.id); messageIdForResponse = newMessage.id; } conversationsStore.updateConversationTimestamp(); if (isFirstUserMessage && newContent.trim()) await conversationsStore.updateConversationTitleWithConfirmation( activeConv.id, generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) ); await conversationsStore.refreshActiveMessages(); if (msg.role === MessageRole.USER) await this.generateResponseForMessage(messageIdForResponse); } catch (error) { console.error('Failed to edit message with branching:', error); } } private async generateResponseForMessage(userMessageId: string): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv) return; this.showErrorDialog(null); this.setChatLoading(activeConv.id, true); this.clearChatStreaming(activeConv.id); try { const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const conversationPath = filterByLeafNodeId( allMessages, userMessageId, false ) as DatabaseMessage[]; const assistantMessage = await DatabaseService.createMessageBranch( { convId: activeConv.id, type: MessageType.TEXT, timestamp: Date.now(), role: MessageRole.ASSISTANT, content: '', toolCalls: '', children: [], model: null }, userMessageId ); conversationsStore.addMessageToActive(assistantMessage); await this.streamChatCompletion(conversationPath, assistantMessage); } catch (error) { console.error('Failed to generate response:', error); this.setChatLoading(activeConv.id, false); } } private getContextTotal(): number | null { const activeConvId = this.activeConversationId; const activeState = activeConvId ? this.getProcessingState(activeConvId) : null; if (activeState && typeof activeState.contextTotal === 'number' && activeState.contextTotal > 0) return activeState.contextTotal; if (isRouterMode()) { const modelContextSize = selectedModelContextSize(); if (typeof modelContextSize === 'number' && modelContextSize > 0) { return modelContextSize; } } else { const propsContextSize = contextSize(); if (typeof propsContextSize === 'number' && propsContextSize > 0) { return propsContextSize; } } return null; } updateProcessingStateFromTimings( timingData: { prompt_n: number; prompt_ms?: number; predicted_n: number; predicted_per_second: number; cache_n: number; prompt_progress?: ChatMessagePromptProgress; }, conversationId?: string ): void { const processingState = this.parseTimingData(timingData); if (processingState === null) { console.warn('Failed to parse timing data - skipping update'); return; } const targetId = conversationId || this.activeConversationId; if (targetId) { this.setProcessingState(targetId, processingState); } } private parseTimingData(timingData: Record): ApiProcessingState | null { const promptTokens = (timingData.prompt_n as number) || 0, promptMs = (timingData.prompt_ms as number) || undefined, predictedTokens = (timingData.predicted_n as number) || 0, tokensPerSecond = (timingData.predicted_per_second as number) || 0, cacheTokens = (timingData.cache_n as number) || 0; const promptProgress = timingData.prompt_progress as | { total: number; cache: number; processed: number; time_ms: number } | undefined; const contextTotal = this.getContextTotal(); const currentConfig = config(); const outputTokensMax = currentConfig.max_tokens || -1; const contextUsed = promptTokens + cacheTokens + predictedTokens, outputTokensUsed = predictedTokens; const progressCache = promptProgress?.cache || 0, progressActualDone = (promptProgress?.processed ?? 0) - progressCache, progressActualTotal = (promptProgress?.total ?? 0) - progressCache; const progressPercent = promptProgress ? Math.round((progressActualDone / progressActualTotal) * 100) : undefined; return { status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle', tokensDecoded: predictedTokens, tokensRemaining: outputTokensMax - predictedTokens, contextUsed, contextTotal, outputTokensUsed, outputTokensMax, hasNextToken: predictedTokens > 0, tokensPerSecond, temperature: currentConfig.temperature ?? 0.8, topP: currentConfig.top_p ?? 0.95, speculative: false, progressPercent, promptProgress, promptTokens, promptMs, cacheTokens }; } restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role === MessageRole.ASSISTANT && message.timings) { const restoredState = this.parseTimingData({ prompt_n: message.timings.prompt_n || 0, prompt_ms: message.timings.prompt_ms, predicted_n: message.timings.predicted_n || 0, predicted_per_second: message.timings.predicted_n && message.timings.predicted_ms ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000 : 0, cache_n: message.timings.cache_n || 0 }); if (restoredState) { this.setProcessingState(conversationId, restoredState); return; } } } } getConversationModel(messages: DatabaseMessage[]): string | null { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role === MessageRole.ASSISTANT && message.model) return message.model; } return null; } private getApiOptions(): Record { const currentConfig = config(); const hasValue = (value: unknown): boolean => value !== undefined && value !== null && value !== ''; const apiOptions: Record = { stream: true, timings_per_token: true }; if (isRouterMode()) { const modelName = selectedModelName(); if (modelName) apiOptions.model = modelName; } if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage; if (currentConfig.disableReasoningParsing) apiOptions.disableReasoningParsing = true; if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true; apiOptions.enableThinking = conversationsStore.getThinkingEnabled(); apiOptions.reasoningEffort = conversationsStore.getReasoningEffort(); if (hasValue(currentConfig.temperature)) apiOptions.temperature = Number(currentConfig.temperature); if (hasValue(currentConfig.max_tokens)) apiOptions.max_tokens = Number(currentConfig.max_tokens); if (hasValue(currentConfig.dynatemp_range)) apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range); if (hasValue(currentConfig.dynatemp_exponent)) apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent); if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k); if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p); if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p); if (hasValue(currentConfig.xtc_probability)) apiOptions.xtc_probability = Number(currentConfig.xtc_probability); if (hasValue(currentConfig.xtc_threshold)) apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold); if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p); if (hasValue(currentConfig.repeat_last_n)) apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n); if (hasValue(currentConfig.repeat_penalty)) apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty); if (hasValue(currentConfig.presence_penalty)) apiOptions.presence_penalty = Number(currentConfig.presence_penalty); if (hasValue(currentConfig.frequency_penalty)) apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty); if (hasValue(currentConfig.dry_multiplier)) apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier); if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base); if (hasValue(currentConfig.dry_allowed_length)) apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length); if (hasValue(currentConfig.dry_penalty_last_n)) apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n); if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers; apiOptions.backend_sampling = currentConfig.backend_sampling; if (currentConfig.customJson) apiOptions.custom = currentConfig.customJson; return apiOptions; } private cancelPreEncode(): void { if (this.preEncodeAbortController) { this.preEncodeAbortController.abort(); this.preEncodeAbortController = null; } } private async triggerPreEncode( allMessages: DatabaseMessage[], assistantMessage: DatabaseMessage, assistantContent: string, model?: string | null, excludeReasoning?: boolean ): Promise { this.cancelPreEncode(); this.preEncodeAbortController = new AbortController(); const signal = this.preEncodeAbortController.signal; try { const allIdle = await ChatService.areAllSlotsIdle(model, signal); if (!allIdle || signal.aborted) return; const messagesWithAssistant: DatabaseMessage[] = [ ...allMessages, { ...assistantMessage, content: assistantContent } ]; await ChatService.preEncode(messagesWithAssistant, model, excludeReasoning, signal); } catch (err) { if (!isAbortError(err)) { console.warn('[ChatStore] Pre-encode failed:', err); } } } } export const chatStore = new ChatStore(); export const activeProcessingState = () => chatStore.activeProcessingState; export const currentResponse = () => chatStore.currentResponse; export const errorDialog = () => chatStore.errorDialogState; export const getAddFilesHandler = () => chatStore.getAddFilesHandler(); export const getAllLoadingChats = () => chatStore.getAllLoadingChats(); export const getAllStreamingChats = () => chatStore.getAllStreamingChats(); export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId); export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId); export const isChatStreaming = () => chatStore.isStreaming(); export const isEditing = () => chatStore.isEditing(); export const isLoading = () => chatStore.isLoading; export const isReasoning = () => chatStore.isReasoning; export const pendingEditMessageId = () => chatStore.pendingEditMessageId; export const chatHasPendingMessage = (convId: string) => chatStore.hasPendingMessage(convId); export const chatPendingMessageContent = (convId: string) => chatStore.pendingMessageContent(convId); export const chatPendingMessageExtras = (convId: string) => chatStore.pendingMessageExtras(convId); export const chatClearPendingMessage = (convId: string) => chatStore.clearPendingMessage(convId); export const chatInjectPendingMessage = ( convId: string, content: string, extras?: DatabaseMessageExtra[] ) => chatStore.injectPendingMessage(convId, content, extras);