Entry points (no sixth tab): Ask in the Library header (desktop button, phone icon), Ask about this deck in the deck menu, Ask a question on study/homework results (compact result travels as context), and a Chat row in the desktop sidebar. Phones get a full-screen route with back + New chat; the tab bar steps aside. - Client-side retrieval: chunk scoped decks (~800 tokens), BM25 against the question + recent turns, top chunks under ~7k tokens, each labelled with deck + section. Notes come from the deck's stored sourceText (already kept for Regenerate); decks without it fall back to generated content and the UI says so. - Stateless server: /api/chat validates against the CHAT_MODELS allowlist, trims the history to ~6k tokens, caps context at ~9k and streams token/thinking/done/error NDJSON. /api/chat/extract reduces attachments to text through the existing vision step. - UI: user bubbles + full-width assistant text, streaming with stop, auto-scroll that pauses with a jump-to-bottom button, safe markdown (React nodes, no HTML injection; KaTeX dynamically imported only when a message has math), Thinking row for reasoning, copy/regenerate/ edit-and-resend, model + context pickers (last model remembered), composer with visualViewport keyboard handling, safe-area, 16px textarea, Enter-to-send on desktop. - Usage-limit failures keep the draft and show the shared message with Settings/Ollama actions; stopping maps the reader abort to a clean stopped state; offline disables sending with a reason. - Settings: Keep notes text for chat toggle; deck menu: Delete notes text. RATE_LIMIT_CHAT + CHAT_MODELS in .env.example and README. - Tests: chat suite (63 checks, stub Ollama) wired into run-all. Full set: 12/12 suites, 487 checks. SW -> freezmo-v32. Also: tests/model-compare.mjs (diagnostic) benchmarks two models on the same notes (latency, repair rounds, activity types) so the gpt-oss vs gemma default can be decided on numbers; defaults unchanged.
83 lines
2.2 KiB
TypeScript
83 lines
2.2 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Chat types shared by the chat UI, the context builder and the streaming
|
|
* client. The API key rule applies here too: nothing in a chat request may
|
|
* carry the key anywhere except the `x-ollama-key` header of our own route.
|
|
*/
|
|
|
|
export type ChatRole = "user" | "assistant";
|
|
|
|
/** A context chunk that actually travelled with a request. */
|
|
export type ChatSource = {
|
|
deckId: string;
|
|
deckTitle: string;
|
|
section: string;
|
|
};
|
|
|
|
export type ChatAttachment = {
|
|
name: string;
|
|
kind: "photo" | "document" | "text";
|
|
chars: number;
|
|
};
|
|
|
|
export type ChatError = { message: string; kind?: string };
|
|
|
|
export type ChatMessage = {
|
|
id: string;
|
|
role: ChatRole;
|
|
content: string;
|
|
/** Reasoning streamed separately; hidden behind the Thinking row. */
|
|
thinking?: string;
|
|
/** Model that produced an assistant turn. */
|
|
model?: string;
|
|
/** Context chunks sent with this turn (assistant turns display them). */
|
|
sources?: ChatSource[];
|
|
attachments?: ChatAttachment[];
|
|
at: number;
|
|
/** Set when an assistant turn failed; content may hold a partial answer. */
|
|
error?: ChatError;
|
|
};
|
|
|
|
/**
|
|
* What the chat is allowed to ground on. `all` = every deck with notes,
|
|
* `none` = general chat, `decks` = a chosen set.
|
|
*/
|
|
export type ChatScope =
|
|
| { kind: "all" }
|
|
| { kind: "none" }
|
|
| { kind: "decks"; deckIds: string[] };
|
|
|
|
export type ChatModelChoice = {
|
|
name: string;
|
|
badge: "text" | "vision" | "both";
|
|
available: boolean | null;
|
|
};
|
|
|
|
/** Last model choice, remembered across visits. */
|
|
const MODEL_KEY = "freezmo.chat.model.v1";
|
|
|
|
export function readStoredChatModel(): string | null {
|
|
try {
|
|
const value = window.localStorage.getItem(MODEL_KEY);
|
|
return value && value.trim() ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function storeChatModel(model: string): void {
|
|
try {
|
|
window.localStorage.setItem(MODEL_KEY, model);
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
}
|
|
|
|
export function chatMessageId(prefix: string): string {
|
|
const rand =
|
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
? crypto.randomUUID().slice(0, 8)
|
|
: Math.random().toString(36).slice(2, 10);
|
|
return `${prefix}_${Date.now().toString(36)}_${rand}`;
|
|
}
|