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
220 lines
6.6 KiB
TypeScript
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();
|