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
This commit is contained in:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -0,0 +1,219 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user