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

@ -898,6 +898,87 @@ const en: Record<string, string> = {
"doc.readingSlides": "Reading the slides…",
"doc.readingOpen": "Reading the OpenDocument file…",
"doc.readingText": "Reading the text file…",
/* chat ------------------------------------------------------------ */
"chat.nav": "Chat",
"chat.title": "Chat",
"chat.ask": "Ask",
"chat.askAboutDeck": "Ask about this deck",
"chat.askQuestion": "Ask a question",
"chat.back": "Back",
"chat.new": "New chat",
"chat.placeholder": "Ask about your notes…",
"chat.send": "Send",
"chat.stop": "Stop",
"chat.copy": "Copy",
"chat.copied": "Copied",
"chat.copyFailed": "Copy failed",
"chat.edit": "Edit and resend",
"chat.regenerate": "Regenerate",
"chat.retry": "Retry",
"chat.thinking": "Thinking…",
"chat.thought": "Thinking",
"chat.sources": "Sources",
"chat.jumpToBottom": "Jump to the newest",
"chat.offlineNote": "Offline — sending is paused",
"chat.emptyTitle": "Ask your notes anything",
"chat.emptyHint": "Answers come from your own decks. Pick what the chat may read with the context chip below.",
"chat.oldDecksNote": "Older decks only have the generated study content. Re-add the notes for better answers.",
"chat.fromLabel": "From",
"chat.notesOffNote": "Chat is set to ignore notes text.",
"chat.notesOffLink": "Turn it back on in Settings",
"chat.attach": "Attach photos or documents",
"chat.attachPhoto": "photo",
"chat.attachDoc": "document",
"chat.attachRemove": "Remove attachment",
"chat.attachFailed": "That attachment could not be read.",
"chat.attachNoText": "No text found in that file — try a photo instead.",
"chat.attachEmpty": "Nothing readable was found in that attachment.",
"chat.attachmentLabel": "Attachment",
"chat.modelTitle": "Chat model",
"chat.modelText": "Text",
"chat.modelVision": "Vision",
"chat.modelBoth": "Text + vision",
"chat.modelAvailable": "available",
"chat.modelUnavailable": "not found",
"chat.modelNote": "Photos and documents are transcribed first, so any of these works.",
"chat.scope.title": "What may chat read?",
"chat.scope.all": "All notes",
"chat.scope.allNote": "Every deck's notes can ground the answers.",
"chat.scope.pick": "Selected decks",
"chat.scope.pickNote": "Choose folders or single decks below.",
"chat.scope.none": "No notes",
"chat.scope.noneNote": "General chat — the model answers from general knowledge only.",
"chat.scope.decks.one": "{n} deck",
"chat.scope.decks.few": "{n} decks",
"chat.scope.decks.many": "{n} decks",
"chat.scope.deckCount.one": "{n} deck",
"chat.scope.deckCount.few": "{n} decks",
"chat.scope.deckCount.many": "{n} decks",
"chat.scope.hasNotes": "notes text kept",
"chat.scope.generatedOnly": "generated content only",
"chat.scope.note": "Only what you pick here is sent as context — the API key never leaves this browser.",
"chat.suggest.explain": "Explain the main idea of {deck}",
"chat.suggest.quiz": "Quiz me on {deck}",
"chat.suggest.summarize": "Summarize my notes",
"chat.suggest.quizAll": "Quiz me on my decks",
"chat.suggest.studyFirst": "What should I study first?",
"chat.suggest.general": "Help me understand a topic",
"chat.suggest.explainTopic": "Explain a topic step by step",
"chat.seedResults": "Study results",
"chat.seedAssignment": "Assignment",
"chat.seedAnswer": "Answer",
"chat.seedAttempt": "My attempt",
"chat.seedVerdict": "Verdict",
"chat.seedToFix": "To fix",
"chat.seedCorrected": "Corrected version",
"chat.seedSteps": "Steps",
"chat.notesDeleted": "Notes text deleted",
"chat.deleteNotes": "Delete notes text",
"settings.chatNotes": "Keep notes text for chat",
"settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.",
"server.chatModelNotAllowed": "That chat model is not on the server's allowlist.",
"server.chatNeedsQuestion": "The conversation must end with a user message.",
};
const cs: Record<string, string> = {
@ -1737,6 +1818,87 @@ const cs: Record<string, string> = {
"doc.readingSlides": "Čtu snímky…",
"doc.readingOpen": "Čtu soubor OpenDocument…",
"doc.readingText": "Čtu textový soubor…",
/* chat ------------------------------------------------------------ */
"chat.nav": "Chat",
"chat.title": "Chat",
"chat.ask": "Zeptat se",
"chat.askAboutDeck": "Zeptat se na tuto sadu",
"chat.askQuestion": "Zeptat se na něco",
"chat.back": "Zpět",
"chat.new": "Nový chat",
"chat.placeholder": "Zeptej se na svoje poznámky…",
"chat.send": "Odeslat",
"chat.stop": "Zastavit",
"chat.copy": "Kopírovat",
"chat.copied": "Zkopírováno",
"chat.copyFailed": "Kopírování se nepovedlo",
"chat.edit": "Upravit a poslat znovu",
"chat.regenerate": "Vygenerovat znovu",
"chat.retry": "Zkusit znovu",
"chat.thinking": "Přemýšlím…",
"chat.thought": "Myšlenkový postup",
"chat.sources": "Zdroje",
"chat.jumpToBottom": "Skočit na nejnovější",
"chat.offlineNote": "Offline — posílání je pozastavené",
"chat.emptyTitle": "Zeptej se svých poznámek na cokoli",
"chat.emptyHint": "Odpovědi vycházejí z tvých sad. Pomocí chipu s kontextem níže vyber, co smí chat číst.",
"chat.oldDecksNote": "Starší sady mají jen vygenerovaný obsah. Přidej znovu poznámky a odpovědi budou lepší.",
"chat.fromLabel": "Z",
"chat.notesOffNote": "Chat má vypnuté používání textu poznámek.",
"chat.notesOffLink": "Zapnout v nastavení",
"chat.attach": "Přiložit fotky nebo dokumenty",
"chat.attachPhoto": "fotka",
"chat.attachDoc": "dokument",
"chat.attachRemove": "Odebrat přílohu",
"chat.attachFailed": "Tuto přílohu se nepovedlo přečíst.",
"chat.attachNoText": "V souboru nebyl žádný text — zkus místo toho fotku.",
"chat.attachEmpty": "V příloze nebylo nic čitelného.",
"chat.attachmentLabel": "Příloha",
"chat.modelTitle": "Model chatu",
"chat.modelText": "Text",
"chat.modelVision": "Obrázky",
"chat.modelBoth": "Text + obrázky",
"chat.modelAvailable": "dostupný",
"chat.modelUnavailable": "nenalezen",
"chat.modelNote": "Fotky a dokumenty se nejdřív přepíšou na text, takže funguje kterýkoli z nich.",
"chat.scope.title": "Co smí chat číst?",
"chat.scope.all": "Všechny poznámky",
"chat.scope.allNote": "Odpovědi mohou vycházet z poznámek všech sad.",
"chat.scope.pick": "Vybrané sady",
"chat.scope.pickNote": "Níže vyber složky nebo jednotlivé sady.",
"chat.scope.none": "Bez poznámek",
"chat.scope.noneNote": "Obecný chat — model odpovídá jen z obecných znalostí.",
"chat.scope.decks.one": "{n} sada",
"chat.scope.decks.few": "{n} sady",
"chat.scope.decks.many": "{n} sad",
"chat.scope.deckCount.one": "{n} sada",
"chat.scope.deckCount.few": "{n} sady",
"chat.scope.deckCount.many": "{n} sad",
"chat.scope.hasNotes": "text poznámek uložen",
"chat.scope.generatedOnly": "jen vygenerovaný obsah",
"chat.scope.note": "Jako kontext se posílá jen to, co tady vybereš — API klíč nikdy neopustí tento prohlížeč.",
"chat.suggest.explain": "Vysvětli hlavní myšlenku sady {deck}",
"chat.suggest.quiz": "Vyzkoušej mě ze sady {deck}",
"chat.suggest.summarize": "Shrň moje poznámky",
"chat.suggest.quizAll": "Vyzkoušej mě ze všech sad",
"chat.suggest.studyFirst": "Co mám studovat jako první?",
"chat.suggest.general": "Pomoz mi pochopit nějaké téma",
"chat.suggest.explainTopic": "Vysvětli téma krok za krokem",
"chat.seedResults": "Výsledky studia",
"chat.seedAssignment": "Zadání",
"chat.seedAnswer": "Odpověď",
"chat.seedAttempt": "Můj postup",
"chat.seedVerdict": "Hodnocení",
"chat.seedToFix": "Co opravit",
"chat.seedCorrected": "Opravená verze",
"chat.seedSteps": "Postup",
"chat.notesDeleted": "Text poznámek smazán",
"chat.deleteNotes": "Smazat text poznámek",
"settings.chatNotes": "Uchovávat text poznámek pro chat",
"settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.",
"server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.",
"server.chatNeedsQuestion": "Konverzace musí končit zprávou uživatele.",
};
/** Used by tests/tools that want the raw tables. */