Safetensors
GGUF
Turkish
llama
Llama-3
instruct
finetune
chatml
gpt4
synthetic data
distillation
function calling
json mode
axolotl
roleplaying
chat
Instructions to use tda45/TdAI with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- llama-cpp-python
How to use tda45/TdAI with llama-cpp-python:
# !pip install llama-cpp-python from llama_cpp import Llama llm = Llama.from_pretrained( repo_id="tda45/TdAI", filename="llama.cpp/models/ggml-vocab-aquila.gguf", )
output = llm( "Once upon a time,", max_tokens=512, echo=True ) print(output)
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use tda45/TdAI with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf tda45/TdAI # Run inference directly in the terminal: llama cli -hf tda45/TdAI
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf tda45/TdAI # Run inference directly in the terminal: llama cli -hf tda45/TdAI
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf tda45/TdAI # Run inference directly in the terminal: ./llama-cli -hf tda45/TdAI
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf tda45/TdAI # Run inference directly in the terminal: ./build/bin/llama-cli -hf tda45/TdAI
Use Docker
docker model run hf.co/tda45/TdAI
- LM Studio
- Jan
- Ollama
How to use tda45/TdAI with Ollama:
ollama run hf.co/tda45/TdAI
- Unsloth Studio
How to use tda45/TdAI with Unsloth Studio:
Install Unsloth Studio (macOS, Linux, WSL)
curl -fsSL https://unsloth.ai/install.sh | sh # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for tda45/TdAI to start chatting
Install Unsloth Studio (Windows)
irm https://unsloth.ai/install.ps1 | iex # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for tda45/TdAI to start chatting
Using HuggingFace Spaces for Unsloth
# No setup required # Open https://huggingface.co/spaces/unsloth/studio in your browser # Search for tda45/TdAI to start chatting
- Atomic Chat new
- Docker Model Runner
How to use tda45/TdAI with Docker Model Runner:
docker model run hf.co/tda45/TdAI
- Lemonade
How to use tda45/TdAI with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull tda45/TdAI
Run and chat with the model
lemonade run user.TdAI-{{QUANT_TAG}}List all available models
lemonade list
| /** | |
| * 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); | |