Files
llama.cpp/tools/ui/src/lib/stores/chat.svelte.ts
T
Pascal fd41bf65a2 server: return 400 instead of 500 on validation error with X-Conversation-Id (#25760)
* server: return 400 instead of 500 on validation error with X-Conversation-Id

set_req() attaches the spipe as soon as the header is present, before the request
body is parsed. When params validation throws, set_next() never runs and next_orig
stays empty, so on_complete() called it and crashed with std::bad_function_call,
turning the prepared 400 JSON into a generic 500.

on_complete() now treats an empty next_orig as "streaming never started" and evicts
the session installed by set_req(), so a failed request leaves nothing behind for
discovery or replay. This also covers valid requests that carry the header but do
not stream, which previously left an empty finalized session in the map until the
GC TTL.

* ui: do not send the backend_sampling placeholder

On a fresh profile the syncable settings hold the empty string placeholder meaning
"let the server decide". Every neighbor field goes through the hasValue() guard
that filters it, except backend_sampling, which sent the placeholder verbatim and
made every default settings completion fail validation.

Guard the field with hasValue() like its neighbors. hasValue(false) is true, so an
explicit false still reaches the server and the intent of #18781 (send both true
and false) is preserved. Only the placeholder is filtered.
2026-07-21 17:47:54 +02:00

2501 lines
90 KiB
TypeScript

/**
* 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,
ReasoningEffort,
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.
*
* Any tool calls captured mid-stream are dropped before the abort so the
* pending message (or a manual follow-up) does not re-send a half-received
* tool call with invalid JSON arguments to the server. Mirrors what the
* Stop button already does through stopGenerationForChat.
*/
async abortCurrentFlow(convId: string): Promise<void> {
await this.savePartialResponseIfNeeded(convId);
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;
},
updateToolResultMessage: async (
messageId: string,
content: string,
extras?: DatabaseMessageExtra[]
) => {
// Persist latest content + merged extras; mirror into the active
// store so the chat view sees live updates for streaming tools
// (e.g. exec_shell_command). The existing tool message node
// pointer stays put - the renderer is already scoped to it.
const updates: Partial<DatabaseMessage> = { content };
if (extras) {
const idx = conversationsStore.findMessageIndex(messageId);
const existing = idx >= 0 ? (conversationsStore.activeMessages[idx]?.extra ?? []) : [];
const merged = [...existing, ...extras];
updates.extra = merged;
}
if (conversationsStore.activeConversation?.id === convId) {
const idx = conversationsStore.findMessageIndex(messageId);
if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, updates);
}
await DatabaseService.updateMessage(messageId, updates);
},
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.getAllMcpServerOverrides();
{
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 || '';
// snapshot the streamed tool calls before clearing so we still know whether
// anything was captured when deciding to skip the DB write below
const hadPartialToolCalls = !!lastMessage.toolCalls?.trim();
// nothing to persist when content, reasoning, and streamed tool calls are all empty
// (e.g. stop before any token). otherwise drop the partial tool call and write whatever
// was streamed: incomplete arguments (truncated JSON, missing closing quote) would
// otherwise be re-sent to the server on the next turn and rejected.
if (!partialContent.trim() && !partialReasoning.trim() && !hadPartialToolCalls) return;
try {
const updateData: {
content?: string;
reasoningContent?: string;
toolCalls?: string;
timings?: ChatMessageTimings;
} = {
toolCalls: ''
};
if (partialContent.trim()) updateData.content = partialContent;
if (partialReasoning.trim()) 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;
// mirror the drop into the in-memory message so the next request sent via
// sendMessage (queued pending, Send immediately, or manual follow-up) reads
// the cleared value, not whatever the streaming widget had been showing
lastMessage.toolCalls = '';
if (updateData.timings) lastMessage.timings = updateData.timings;
} catch (error) {
lastMessage.content = partialContent;
lastMessage.toolCalls = '';
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.updateConversationName(
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.updateConversationName(
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.updateConversationName(
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();
const effort = conversationsStore.getReasoningEffort();
if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort;
if (hasValue(currentConfig.temperature))
apiOptions.temperature = Number(currentConfig.temperature);
if (hasValue(currentConfig.max_tokens))
apiOptions.max_tokens = Number(currentConfig.max_tokens);
if (hasValue(currentConfig.dynatemp_range))
apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range);
if (hasValue(currentConfig.dynatemp_exponent))
apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent);
if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k);
if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p);
if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p);
if (hasValue(currentConfig.xtc_probability))
apiOptions.xtc_probability = Number(currentConfig.xtc_probability);
if (hasValue(currentConfig.xtc_threshold))
apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold);
if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p);
if (hasValue(currentConfig.repeat_last_n))
apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n);
if (hasValue(currentConfig.repeat_penalty))
apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty);
if (hasValue(currentConfig.presence_penalty))
apiOptions.presence_penalty = Number(currentConfig.presence_penalty);
if (hasValue(currentConfig.frequency_penalty))
apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty);
if (hasValue(currentConfig.dry_multiplier))
apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier);
if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base);
if (hasValue(currentConfig.dry_allowed_length))
apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length);
if (hasValue(currentConfig.dry_penalty_last_n))
apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n);
if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers;
if (hasValue(currentConfig.backend_sampling))
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);