TdAI / llama.cpp /tools /ui /src /lib /stores /chat.svelte.ts
tda45's picture
Upload folder using huggingface_hub (part 8)
15c3607 verified
Raw
History Blame Contribute Delete
90.3 kB
/**
* 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<ApiProcessingState | null>(null);
currentResponse = $state('');
errorDialogState = $state<ErrorDialogState | null>(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>(StreamConnectionState.STREAMING);
chatLoadingStates = new SvelteMap<string, boolean>();
chatReasoningStates = new SvelteMap<string, boolean>();
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<string>();
// 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<string>();
// in-flight discoverActiveStream guard, keyed by conv id
private discoveringConvs = new SvelteSet<string>();
private abortControllers = new SvelteMap<string, AbortController>();
private preEncodeAbortController: AbortController | null = null;
private processingStates = new SvelteMap<string, ApiProcessingState | null>();
private conversationStateTimestamps = new SvelteMap<string, ConversationStateEntry>();
private activeConversationId = $state<string | null>(null);
private isStreamingActive = $state(false);
private isEditModeActive = $state(false);
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
pendingEditMessageId = $state<string | null>(null);
private messageUpdateCallback:
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
| null = null;
private _pendingDraftMessage = $state<string>('');
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
/** 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<ApiStreamSession | null> {
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<void> {
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<DatabaseMessage, 'id'>,
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<DatabaseMessage>) => {
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<void> {
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/<id> 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<string>(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<void> {
// 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<string>();
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<DatabaseMessage> {
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<void> {
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<boolean> {
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<DatabaseMessage> {
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<void> {
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<void>,
onError?: (error: Error) => void,
modelOverride?: string | null,
firstUserMessageContent?: string
): Promise<void> {
// 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<string, unknown> = {
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<DatabaseMessage> = {
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<string, unknown> = {
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<DatabaseMessage> = {
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<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
await this.stopGenerationForChat(activeConv.id);
}
async stopGenerationForChat(convId: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<DatabaseMessage> = { 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<void> {
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<DatabaseMessage> = {
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<void> {
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<string, unknown>): 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<string, unknown> {
const currentConfig = config();
const hasValue = (value: unknown): boolean =>
value !== undefined && value !== null && value !== '';
const apiOptions: Record<string, unknown> = { stream: true, timings_per_token: true };
if (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<void> {
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);