// Chat state as a Svelte 5 runes class (no legacy stores). // // Query (RAG Q&A) only — capture is no longer a conversation; it writes a // Document directly (see $lib/documents/WritingEditor.svelte). import { api } from '$lib/api/client'; import { errorMessage } from '$lib/api/errors'; import { streamMessage, type SourceChunk, type StreamEvent } from '$lib/api/stream'; import { conversationStore } from '$lib/chat/conversations.svelte'; import { m } from '$lib/paraglide/messages'; export type { ConversationSummary } from '$lib/chat/conversations.svelte'; export type ChatMessage = { role: 'user' | 'assistant'; content: string; sources: SourceChunk[]; streaming: boolean; /** The streamed tokens, kept separate so the live view can fade each one * in (see StreamingText); `content` stays the source for the final render. */ tokens: string[]; /** Retrieval found nothing solid — offer to capture the knowledge. */ noAnswer: boolean; /** The question that hit the gap (kept for a future retrieval-aware entry). */ gapQuestion: string; /** No model answered this turn: the `llm_*` code that caused it, and * `sources` is a plain full-text hit list instead of citations. */ fallback: string | null; }; /** Transient query-mode progress; cleared when the turn ends. */ export type RetrievalProgress = { phase: string; count: number | null }; /** How long a turn may stay silent before we say so. Long enough that a * normal local model never trips it, short enough to beat impatience. */ const SLOW_TURN_MS = 12_000; export class ChatState { activeId = $state(null); retrieval = $state(null); messages = $state([]); streaming = $state(false); error = $state(null); /** Nothing has come back for a while: a busy endpoint queues the request * instead of refusing it, so silence is the only symptom the user gets. */ slow = $state(false); #abort: AbortController | null = null; #slowTimer: ReturnType | null = null; /** Shared with the sidebar — same list, one fetch. */ get conversations() { return conversationStore.items; } async loadConversations(): Promise { await conversationStore.load(); } async open(id: string): Promise { this.error = null; this.activeId = id; this.retrieval = null; const { data } = await api.GET('/api/conversations/{conversation_id}', { params: { path: { conversation_id: id } } }); this.messages = (data?.messages ?? []) .filter((message) => message.role === 'user' || message.role === 'assistant') .map((message) => ({ role: message.role as 'user' | 'assistant', content: message.content, sources: message.sources ?? [], streaming: false, tokens: [], noAnswer: false, gapQuestion: '', fallback: message.fallback ?? null })); } startNew(): void { this.activeId = null; this.retrieval = null; this.messages = []; this.error = null; } async remove(id: string): Promise { if (this.activeId === id) { this.startNew(); } await conversationStore.remove(id); } #startSlowTimer(): void { this.#clearSlowTimer(); this.slow = false; this.#slowTimer = setTimeout(() => (this.slow = true), SLOW_TURN_MS); } #clearSlowTimer(): void { if (this.#slowTimer !== null) clearTimeout(this.#slowTimer); this.#slowTimer = null; this.slow = false; } async send(content: string): Promise { if (this.streaming) return; // Claim the turn before awaiting anything: creating the conversation // takes a round trip, and a second call in that window would create // a second conversation (double-click, or two navigation callbacks). this.streaming = true; this.error = null; let conversationId = this.activeId; if (!conversationId) { const { data } = await api.POST('/api/conversations', { body: { mode: 'query' } }); if (!data) { this.error = m.chat_error_start_conversation(); this.streaming = false; return; } conversationId = data.id; this.activeId = data.id; } this.messages.push({ role: 'user', content, sources: [], streaming: false, tokens: [], noAnswer: false, gapQuestion: '', fallback: null }); await this.#stream((signal) => streamMessage(conversationId, content, signal), content); } /** One streaming turn: append an assistant bubble and drain the events. */ async #stream( open: (signal: AbortSignal) => AsyncGenerator, question = '' ): Promise { this.messages.push({ role: 'assistant', content: '', sources: [], streaming: true, tokens: [], noAnswer: false, gapQuestion: '', fallback: null }); const assistant = this.messages[this.messages.length - 1]; this.streaming = true; this.#abort = new AbortController(); this.#startSlowTimer(); try { for await (const event of open(this.#abort.signal)) { if (event.type === 'token') { this.#clearSlowTimer(); assistant.content += event.text; assistant.tokens.push(event.text); } else if (event.type === 'sources') { assistant.sources = event.chunks; } else if (event.type === 'state') { this.retrieval = { phase: event.phase, count: event.count }; if (event.phase === 'no_answer') { assistant.noAnswer = true; assistant.gapQuestion = question; } } else if (event.type === 'error') { this.error = errorMessage(event.code); } else if (event.type === 'fallback') { // Not an error: the turn ends as a plain search the user reads. assistant.fallback = event.code; } } } catch (err) { const aborted = err instanceof DOMException && err.name === 'AbortError'; if (!aborted) { this.error = m.chat_error_connection_lost(); } } finally { this.#clearSlowTimer(); // A turn that failed or was stopped before the first token leaves an // empty speech bubble behind, which reads as a broken reply. A // fallback turn is empty ON PURPOSE — its reply is the source list. if ( !assistant.content && !assistant.fallback && this.messages[this.messages.length - 1] === assistant ) { this.messages.pop(); } assistant.streaming = false; this.streaming = false; this.retrieval = null; this.#abort = null; void this.loadConversations(); } } stop(): void { this.#abort?.abort(); } } /** One instance for the whole app, like `conversationStore`. * * The first message on /chat creates a conversation and the URL moves to * /chat/[id], which unmounts one page component and mounts another. A * per-component state would take the in-flight stream and the messages * already on screen down with it, so the state outlives the route. */ export const chatState = new ChatState();