Files
pablan/frontend/src/lib/chat/state.svelte.ts
T
ProfessorNovaandClaude Opus 5 784b76baf7 Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 09:21:37 +02:00

220 lines
6.6 KiB
TypeScript

// 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<string | null>(null);
retrieval = $state<RetrievalProgress | null>(null);
messages = $state<ChatMessage[]>([]);
streaming = $state(false);
error = $state<string | null>(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<typeof setTimeout> | null = null;
/** Shared with the sidebar — same list, one fetch. */
get conversations() {
return conversationStore.items;
}
async loadConversations(): Promise<void> {
await conversationStore.load();
}
async open(id: string): Promise<void> {
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<void> {
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<void> {
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<StreamEvent>,
question = ''
): Promise<void> {
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();