Chat mode, phase 1: grounded streaming chat over your notes

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.
This commit is contained in:
Martin Dvořák 2026-10-11 18:32:11 +00:00
commit 756a6257a2
46 changed files with 3840 additions and 56 deletions

View file

@ -367,6 +367,36 @@ export function buildRefineUserPrompt(input: {
.join("\n");
}
/* ------------------------------------------------------------------ */
/* Chat */
/* ------------------------------------------------------------------ */
export const CHAT_SYSTEM_PROMPT = `You are Freezmo's chat tutor. You talk with a student about their own study notes and help them understand, review and quiz themselves.
Rules:
1. The attached notes are the primary source. Ground your answer in them and prefer them over general knowledge.
2. If the notes do not cover something, say so plainly first ("Your notes don't cover this"), then answer from general knowledge and label that part clearly as general knowledge — never blur the two.
3. Never claim the notes say something they do not, and never attribute invented facts to the notes.
4. Answer in the language the student writes in (not necessarily the app's language), unless they ask for another language.
5. Be a concise, friendly teacher: short paragraphs, lists when they help, no filler, no long preambles. If the student asks to be quizzed, ask one question at a time and wait for their answer before checking it.
6. Markdown is supported (lists, tables, \`code\`, formulas as $...$). Keep tables narrow enough for a phone.`;
export function buildChatSystemPrompt(input: {
context: { deckTitle: string; section: string; text: string }[];
}): string {
if (input.context.length === 0) {
return [
CHAT_SYSTEM_PROMPT,
"",
"No notes are attached to this conversation: the student chose general chat. Answer from general knowledge and, when it matters, remind them they can scope the chat to their notes.",
].join("\n");
}
const blocks = input.context
.map((chunk) => `### ${chunk.deckTitle} — ${chunk.section}\n${chunk.text}`)
.join("\n\n");
return [CHAT_SYSTEM_PROMPT, "", "The student's notes (primary source):", "", blocks].join("\n");
}
/* ------------------------------------------------------------------ */
/* Retry helper */
/* ------------------------------------------------------------------ */