From 756a6257a27ae4761483291405c59680676cfe04 Mon Sep 17 00:00:00 2001
From: Martin Dvorak
Date: Sun, 11 Oct 2026 18:32:11 +0000
Subject: [PATCH] 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.
---
.env.example | 9 +
README.md | 95 +++-
app/api/chat/extract/route.ts | 107 ++++
app/api/chat/route.ts | 193 ++++++++
app/api/health/route.ts | 21 +
app/chat/page.tsx | 32 ++
components/DeckCard.tsx | 26 +-
components/LibraryScreen.tsx | 14 +-
components/ResultsScreen.tsx | 12 +
components/SettingsDialog.tsx | 62 +++
components/Sidebar.tsx | 11 +
components/TabBar.tsx | 7 +-
components/chat/AskButton.tsx | 32 ++
components/chat/ChatScreen.tsx | 520 ++++++++++++++++++++
components/chat/Composer.tsx | 180 +++++++
components/chat/ContextPicker.tsx | 199 ++++++++
components/chat/CopyIconButton.tsx | 39 ++
components/chat/Markdown.tsx | 316 ++++++++++++
components/chat/MathSpan.tsx | 41 ++
components/chat/MessageRow.tsx | 132 +++++
components/chat/ModelPicker.tsx | 115 +++++
components/chat/ThinkingRow.tsx | 36 ++
components/homework/HomeworkCheckResult.tsx | 22 +
components/homework/HomeworkGuide.tsx | 13 +
components/homework/HomeworkResult.tsx | 13 +
components/icons.tsx | 64 +++
lib/chat-client.ts | 105 ++++
lib/chat-context.ts | 258 ++++++++++
lib/chat-seed.ts | 43 ++
lib/chat-trim.ts | 30 ++
lib/chat.ts | 83 ++++
lib/client-api.ts | 10 +-
lib/config.ts | 28 +-
lib/i18n-dict.ts | 162 ++++++
lib/ollama.ts | 208 ++++++--
lib/prompts.ts | 30 ++
lib/settings.ts | 13 +-
lib/storage.ts | 14 +
package-lock.json | 26 +
package.json | 1 +
public/sw.js | 2 +-
tests/README.md | 1 +
tests/chat-test.mjs | 423 ++++++++++++++++
tests/model-compare.mjs | 141 ++++++
tests/run-all.mjs | 1 +
types/globals.d.ts | 6 +
46 files changed, 3840 insertions(+), 56 deletions(-)
create mode 100644 app/api/chat/extract/route.ts
create mode 100644 app/api/chat/route.ts
create mode 100644 app/chat/page.tsx
create mode 100644 components/chat/AskButton.tsx
create mode 100644 components/chat/ChatScreen.tsx
create mode 100644 components/chat/Composer.tsx
create mode 100644 components/chat/ContextPicker.tsx
create mode 100644 components/chat/CopyIconButton.tsx
create mode 100644 components/chat/Markdown.tsx
create mode 100644 components/chat/MathSpan.tsx
create mode 100644 components/chat/MessageRow.tsx
create mode 100644 components/chat/ModelPicker.tsx
create mode 100644 components/chat/ThinkingRow.tsx
create mode 100644 lib/chat-client.ts
create mode 100644 lib/chat-context.ts
create mode 100644 lib/chat-seed.ts
create mode 100644 lib/chat-trim.ts
create mode 100644 lib/chat.ts
create mode 100644 tests/chat-test.mjs
create mode 100644 tests/model-compare.mjs
create mode 100644 types/globals.d.ts
diff --git a/.env.example b/.env.example
index 857ae98..eed2f74 100644
--- a/.env.example
+++ b/.env.example
@@ -36,6 +36,13 @@ VISION_MODEL=gemma4:31b-cloud
# Paid/credit: glm-5.3-flash:cloud, kimi-k3:cloud.
TEXT_MODEL=gpt-oss:120b-cloud
+# CHAT MODELS (optional) -------------------------------------------------------
+# The allowlist the chat model picker offers, comma-separated. The server never
+# accepts a model name outside this list. The first entry is the default.
+# When unset, the configured TEXT_MODEL and VISION_MODEL are used.
+# Example: CHAT_MODELS=gemma4:31b-cloud,gpt-oss:120b-cloud
+# CHAT_MODELS=
+
# RATE LIMITS (per client IP, sliding window, in-memory) ----------------------
# Generation decks are expensive, so they are limited tightly by default.
# RATE_LIMIT_WINDOW_MS=300000
@@ -49,6 +56,8 @@ TEXT_MODEL=gpt-oss:120b-cloud
# RATE_LIMIT_SYNC=60
# Push subscription changes and test sends.
# RATE_LIMIT_PUSH=30
+# Chat turns (one model call each; users send them in bursts).
+# RATE_LIMIT_CHAT=60
# PUSH REMINDERS (optional) ----------------------------------------------------
# Web Push reminders only appear in the UI when BOTH VAPID keys are set.
diff --git a/README.md b/README.md
index d72c95c..0f78f7a 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ no database, no data leaving your machine except the requests you send to Ollama
- [Install as an app (PWA)](#install-as-an-app-pwa)
- [Security notes](#security-notes)
- [How it works](#how-it-works)
+- [Chat](#chat)
- [Project structure](#project-structure)
- [API routes](#api-routes)
- [Scripts](#scripts)
@@ -65,6 +66,11 @@ no database, no data leaving your machine except the requests you send to Ollama
study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping
the active tab scrolls to the top; an offline pill appears and generation is disabled with
a reason until the connection is back.
+- **Chat with your notes**: an LLM-style conversation grounded in your decks. Ask from the
+ Library, a deck's menu, or a result; pick the scope (all notes, folders/decks, or none) and the
+ model; photos and documents are transcribed first, so any model works. Answers stream with stop,
+ copy, regenerate and edit-and-resend, render safe markdown + math, and hide reasoning behind a
+ **Thinking** row. See [Chat](#chat).
- **Learning engine**: every answer is recorded (deck, activity, topic, correct/wrong, time) in an
IndexedDB log, and each activity gets a light **SM-2 schedule** — correct answers come back after
1 day, then 3, then interval × ease; wrong ones after ten minutes. The Library shows a **Smart
@@ -441,6 +447,7 @@ client IP with a sliding window. Everything is configurable in `.env.local`:
| `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` |
| `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` |
| `POST /api/homework/refine` | 30 per 5 minutes | `RATE_LIMIT_REFINE` |
+| `POST /api/chat`, `POST /api/chat/extract` | 60 per 5 minutes | `RATE_LIMIT_CHAT` |
| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` |
| `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` |
| `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` |
@@ -455,6 +462,10 @@ Caveats, stated honestly: counters live **in memory**, so they reset on restart
between multiple instances or Node workers. That is fine for a self-hosted single-instance app; if
you put Freezmo behind a reverse proxy, keep the proxy's own limits in place too.
+Chat has its own allowlist: `CHAT_MODELS` (comma-separated) lists what the model chip may offer,
+with the first entry as the default; when unset it falls back to `TEXT_MODEL` and `VISION_MODEL`.
+The server rejects any model name outside that list.
+
Model names are normalised automatically: when the base URL points at `ollama.com`, Freezmo strips
the local routing suffix (`gemma4:31b-cloud` → `gemma4:31b`, `gpt-oss:120b-cloud` → `gpt-oss:120b`)
because the cloud API expects plain library names, while a local daemon wants the `-cloud` form.
@@ -672,6 +683,47 @@ Limits and behaviour worth knowing:
---
+## Chat
+
+Chat mode is an LLM-style conversation that knows your notes — ask about a deck, get quizzed, or
+have a step explained without leaving the app.
+
+- **Entry points.** The Library header has **Ask** (a labelled button on desktop, an icon on
+ phones), every deck's menu has **Ask about this deck** (opens scoped to that deck), the desktop
+ sidebar has a **Chat** row, and study/homework results have **Ask a question** (the compact
+ result travels along as extra context). On phones the chat is a full-screen route with a back
+ button and **New chat** — the tab bar steps aside so the composer gets the space. Desktop keeps
+ the sidebar.
+- **Grounded answers, retrieved in the browser.** Chat reads each deck's stored notes text
+ (`sourceText` — the same text *Regenerate* uses, already kept and size-capped per deck). The
+ client chunks the scoped decks (~800 tokens per chunk), ranks the chunks against your question
+ and the recent turns with a small BM25 scorer, and sends only the top chunks (~7k tokens, each
+ labelled with its deck and section). The server stays stateless: the chosen context and the
+ conversation window travel with every request, exactly like the API key. Decks without notes text
+ fall back to their generated study content, and the chat says so.
+- **Scope.** The context chip picks **All notes**, **selected folders/decks** or **No notes**
+ (general chat); opening from a deck defaults to that deck. A Settings toggle (**Keep notes text
+ for chat**) makes chat ignore the notes text, and each deck's menu has **Delete notes text** to
+ remove it from that deck entirely.
+- **Models.** The model chip lists the server's allowlist (`CHAT_MODELS`, defaulting to the
+ configured text and vision models) with text/vision badges and availability from the health
+ check; the last choice is remembered. The server validates every request against the same
+ allowlist and never accepts a model name from the client.
+- **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first
+ (plain text), so any chat model works — text-only included.
+- **Streaming and controls.** Tokens stream with a caret and a stop button; auto-scroll pauses when
+ you scroll up and a jump-to-bottom button appears; answers render safe markdown (lists, tables,
+ code blocks with copy — no HTML injection) and KaTeX loads dynamically only when a message
+ contains math; reasoning models' thinking is hidden behind a collapsible **Thinking** row and
+ never mixed into the answer. Every answer offers copy / regenerate, the last question edit and
+ resend. The composer auto-grows at 16px, sends on Enter on desktop (newline on phones), tracks
+ the on-screen keyboard through `visualViewport` and respects reduced motion.
+- **Privacy.** Notes text and conversations stay in this browser; nothing is stored server-side.
+ The API key still only ever travels in the `x-ollama-key` header to Freezmo's own route for that
+ single request.
+
+---
+
## Project structure
```
@@ -684,15 +736,19 @@ app/
create/page.tsx upload screen
deck/[id]/page.tsx study feed for a saved deck
homework/page.tsx “Make my homework” (assignment in, homework or guide out)
+ chat/page.tsx chat mode (full-screen on phones, sidebar on desktop)
api/generate/route.ts images/text in → NDJSON progress → deck JSON out
api/homework/route.ts assignment in → NDJSON progress → homework/guide JSON out
api/grade/route.ts short-answer grading
+ api/chat/route.ts grounded chat turns → NDJSON token/thinking stream
+ api/chat/extract/route.ts chat attachments → plain text (vision step)
api/health/route.ts Ollama reachability + model availability
components/
activities/ one component per activity type + shared shell
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx
homework/ make-my-homework screens (input, result, guide feed, history)
+ chat/ chat screen, composer, message rows, markdown + math, pickers
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx
InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
@@ -717,6 +773,11 @@ lib/
clipboard.ts copy helper with a non-secure-context fallback
image.ts client-side validation, resize and compression
client-api.ts browser client for the API routes (NDJSON reader)
+ chat.ts chat types + last-model memory
+ chat-context.ts client-side chunking + BM25 retrieval over the scoped decks
+ chat-client.ts streaming chat client + attachment extraction
+ chat-seed.ts one-shot hand-off from results into the chat
+ chat-trim.ts token budgeting + the sliding history window (server + client)
xp.ts utils.ts deck-ops.ts confetti.ts
public/
sw.js service worker (production only)
@@ -740,7 +801,9 @@ scripts/
| `POST /api/homework` | `{ mode: "make" \| "guide" \| "check", images?: string[], text?, solution?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` |
| `POST /api/homework/refine` | `{ instruction: "shorter" \| "more" \| "explain", text, context?, language? }` | `{ text }` — one rewritten piece, plain text |
| `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` |
-| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` |
+| `POST /api/chat` | `{ model?, messages: [{role,content}], context?: [{deckId,deckTitle,section,text}] }` | NDJSON stream: `{type:"token"}`, `{type:"thinking"}`, `{type:"done",model}`, `{type:"error"}` |
+| `POST /api/chat/extract` | `{ images?: string[], text? }` | `{ text }` — attachments reduced to plain text via the vision step |
+| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, chatModels, latencyMs, modelCount, error }` |
| `POST /api/sync` | `{ id, envelope }` (opaque AES-GCM blob) | `{ updatedAt }` — 413 over `SYNC_MAX_BYTES`, 507 over `SYNC_TOTAL_BYTES` |
| `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 |
| `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) |
@@ -749,7 +812,7 @@ scripts/
| `DELETE /api/push?endpoint=` | – | `{ ok, count }` |
| `POST /api/push/test` | `{ endpoint }` | `{ ok: true }` or 502 when the push service refuses |
-All four routes accept an optional **`x-ollama-key`** header (the browser's own key; required in
+The model routes accept an optional **`x-ollama-key`** header (the browser's own key; required in
`user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers
`kind: "auth"` (or `kind: "no_credits"` for HTTP 402), and exceeding a rate limit answers **429**
with `Retry-After`. Keys are used for the single request and discarded.
@@ -1072,8 +1135,8 @@ Things I chose while building, with the reasoning:
(native), i18n, the freeze loading screen, Smart Review, and phases 1–3 (backup/sync/usage
limits, the learning engine, homework check/follow-ups). Suites that need a stub Ollama or
VAPID keys start their own `next start` instance on a spare port, verify the port is free and
- kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Eleven suites,
- 424 checks.
+ kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Twelve suites,
+ 487 checks.
46. **The ice orbs are rising bubbles now, composited and cheaper to read through.** The glossy
orbs (the glacier blobs stay exactly as they were) rise from below the bottom edge to above the
@@ -1096,6 +1159,30 @@ Things I chose while building, with the reasoning:
tab-hidden parking) plus the 16-run axe contrast matrix (360/390/1280 × dark/light × EN/CS).
`tests/bubble-perf.mjs` keeps the measurement repeatable.
+47. **Chat grounds on the notes that were already there, and the server stays stateless.** The
+ reconnaissance before this feature found the extracted notes text already kept per deck
+ (`sourceText`, size-capped, used by Regenerate), so the "store it in IndexedDB" branch of the
+ brief didn't apply — chat reads the deck record instead, with the generated study content as
+ the documented fallback for old or imported decks. Retrieval is client-side and dependency-free:
+ ~800-token chunks, BM25 (k1 = 1.2, b = 0.75, stopwords, diacritics folded) scored against the
+ question plus the last turns, top chunks under a ~7k-token budget, each labelled with deck and
+ section. The request carries the chosen context and the conversation window (like the key
+ does); the server trims the window to ~6k tokens, caps the context at ~9k, validates the model
+ against `CHAT_MODELS` and streams `token` / `thinking` / `done` / `error` NDJSON. Reasoning is a
+ separate event, so it can be hidden behind the Thinking row and never mixed into the answer.
+ Attachments reuse the vision step (`/api/chat/extract`) so text-only chat models work. Markdown
+ is a small React-node parser (no HTML injection, no dependency); KaTeX is dynamically imported
+ only when a message contains math and writes into its container imperatively — there is still
+ no `dangerouslySetInnerHTML` anywhere in the app. Stopping mid-stream aborts the upstream call;
+ the abort arrives as a reader rejection and is mapped back to a clean "stopped" state instead of
+ an error card. Usage-limit failures keep the draft and show the shared message with
+ Settings/Ollama actions in an inline banner. The Settings toggle "Keep notes text for chat"
+ makes chat ignore notes text, and a deck's menu can delete the notes text outright. Verified by
+ the chat suite (63 checks against a stub Ollama: validation and the allowlist, context travel,
+ the history window, thinking separation, usage-limit mapping, attachment extraction, entry
+ points, streaming, stop, markdown + KaTeX, the model and scope pickers, edit/regenerate,
+ offline, attachments and reduced motion).
+
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
flow and a 2-step homework flow that share the exact state and handlers with the desktop
cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`);
diff --git a/app/api/chat/extract/route.ts b/app/api/chat/extract/route.ts
new file mode 100644
index 0000000..4b93cfa
--- /dev/null
+++ b/app/api/chat/extract/route.ts
@@ -0,0 +1,107 @@
+import { z } from "zod";
+
+import { extractNotes, notesToText } from "@/lib/ai";
+import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config";
+import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
+import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
+import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
+import { translate } from "@/lib/i18n-dict";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 300;
+
+/** Same ceiling as deck generation: up to 16 resized photos. */
+const MAX_BODY_BYTES = 33554432;
+const MAX_IMAGE_CHARS = 8_000_000;
+
+const requestSchema = z.object({
+ images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
+ text: z.string().max(120_000).optional(),
+});
+
+/**
+ * Turns chat attachments (photos, scanned pages, pasted document text) into
+ * plain text with the existing vision pipeline, so any chat model — including
+ * text-only ones — can use them. Never stores anything.
+ */
+export async function POST(request: Request) {
+ const locale = requestLocale(request);
+ const crossOrigin = rejectCrossOrigin(request);
+ if (crossOrigin) return crossOrigin;
+ const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
+ if (oversized) return oversized;
+
+ const config = getServerConfig();
+
+ const limit = rateLimit(`chat:${clientKey(request)}`, config.rateLimits.chat, config.rateLimits.windowMs);
+ if (!limit.allowed) return rateLimitResponse(limit, locale);
+
+ const auth = resolveAuth(config, request);
+ if ("error" in auth) {
+ return Response.json(
+ { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
+ { status: 401 },
+ );
+ }
+
+ let payload: unknown;
+ try {
+ payload = await request.json();
+ } catch {
+ return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
+ }
+
+ const parsed = requestSchema.safeParse(payload);
+ if (!parsed.success) {
+ return Response.json(
+ { error: translate(locale, "server.invalidRequest", { detail: parsed.error.issues[0]?.message ?? "" }) },
+ { status: 400 },
+ );
+ }
+
+ const images = (parsed.data.images ?? []).map(stripDataUrlPrefix).filter(Boolean);
+ const text = parsed.data.text?.trim() ?? "";
+ if (images.length === 0 && !text) {
+ return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 });
+ }
+
+ const abort = new AbortController();
+ const timer = setTimeout(() => abort.abort(), config.timeoutMs);
+ try {
+ if (images.length === 0) {
+ // Plain text needs no model call at all.
+ return Response.json({ text: text.slice(0, 48_000) });
+ }
+ const extraction = await extractNotes({
+ model: config.visionModel,
+ repairModel: config.textModel,
+ auth: auth.auth,
+ images,
+ text: text || undefined,
+ signal: abort.signal,
+ });
+ const extracted = notesToText(extraction.notes).slice(0, 48_000);
+ if (!extracted.trim()) {
+ return Response.json({ error: translate(locale, "chat.attachEmpty"), kind: "bad_response" }, { status: 422 });
+ }
+ return Response.json({ text: extracted });
+ } catch (error) {
+ console.error("[api/chat/extract]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
+ return Response.json(
+ {
+ error: friendlyErrorMessage(error, auth.auth.baseUrl, locale),
+ kind: error instanceof OllamaError ? error.kind : "unknown",
+ },
+ { status: 502 },
+ );
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+function stripDataUrlPrefix(value: string): string {
+ const commaIndex = value.indexOf(",");
+ if (value.startsWith("data:") && commaIndex !== -1) return value.slice(commaIndex + 1);
+ return value;
+}
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
new file mode 100644
index 0000000..9e03022
--- /dev/null
+++ b/app/api/chat/route.ts
@@ -0,0 +1,193 @@
+import { z } from "zod";
+
+import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
+import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama";
+import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
+import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
+import { translate } from "@/lib/i18n-dict";
+import { buildChatSystemPrompt } from "@/lib/prompts";
+import { estimateTokens, trimHistory } from "@/lib/chat-trim";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 300;
+
+/** Context chunks (~7k tokens) plus a trimmed window plus JSON overhead. */
+const MAX_BODY_BYTES = 1_000_000;
+
+/** Hard cap on the attached context, whatever the client sends. */
+const CONTEXT_TOKEN_BUDGET = 9000;
+
+const requestSchema = z.object({
+ model: z.string().min(1).max(120).optional(),
+ messages: z
+ .array(
+ z.object({
+ role: z.enum(["user", "assistant"]),
+ content: z.string().min(1).max(20_000),
+ }),
+ )
+ .min(1)
+ .max(60),
+ context: z
+ .array(
+ z.object({
+ deckId: z.string().max(120),
+ deckTitle: z.string().max(200),
+ section: z.string().max(200),
+ text: z.string().min(1).max(12_000),
+ }),
+ )
+ .max(40)
+ .optional(),
+});
+
+type StreamEvent =
+ | { type: "token"; text: string }
+ | { type: "thinking"; text: string }
+ | { type: "done"; model: string }
+ | { type: "error"; error: string; kind?: string };
+
+function encoder() {
+ const enc = new TextEncoder();
+ return (event: StreamEvent) => enc.encode(`${JSON.stringify(event)}\n`);
+}
+
+export async function POST(request: Request) {
+ const locale = requestLocale(request);
+ const crossOrigin = rejectCrossOrigin(request);
+ if (crossOrigin) return crossOrigin;
+ const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
+ if (oversized) return oversized;
+
+ const config = getServerConfig();
+
+ const limit = rateLimit(`chat:${clientKey(request)}`, config.rateLimits.chat, config.rateLimits.windowMs);
+ if (!limit.allowed) return rateLimitResponse(limit, locale);
+
+ const auth = resolveAuth(config, request);
+ if ("error" in auth) {
+ return Response.json(
+ { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
+ { status: 401 },
+ );
+ }
+
+ let payload: unknown;
+ try {
+ payload = await request.json();
+ } catch {
+ return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
+ }
+
+ const parsed = requestSchema.safeParse(payload);
+ if (!parsed.success) {
+ return Response.json(
+ {
+ error: translate(locale, "server.invalidRequest", {
+ detail: parsed.error.issues
+ .slice(0, 3)
+ .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
+ .join("; "),
+ }),
+ },
+ { status: 400 },
+ );
+ }
+
+ const { messages, context } = parsed.data;
+
+ // The model must come from the server's allowlist — never from the client.
+ const allowed = config.chatModels.length > 0 ? config.chatModels : [config.textModel];
+ const model = parsed.data.model ?? allowed[0];
+ if (!allowed.some((entry) => entry.toLowerCase() === model.toLowerCase())) {
+ return Response.json(
+ { error: translate(locale, "server.chatModelNotAllowed"), kind: "bad_model" },
+ { status: 400 },
+ );
+ }
+
+ const last = messages[messages.length - 1];
+ if (last.role !== "user") {
+ return Response.json({ error: translate(locale, "server.chatNeedsQuestion") }, { status: 400 });
+ }
+
+ // Cap the context, then trim the conversation window.
+ const keptContext: typeof context = [];
+ let contextTokens = 0;
+ for (const chunk of context ?? []) {
+ const cost = estimateTokens(chunk.text);
+ if (contextTokens + cost > CONTEXT_TOKEN_BUDGET) break;
+ keptContext.push(chunk);
+ contextTokens += cost;
+ }
+ const history = trimHistory(messages);
+
+ const system = buildChatSystemPrompt({
+ context: keptContext.map((chunk) => ({
+ deckTitle: chunk.deckTitle,
+ section: chunk.section,
+ text: chunk.text,
+ })),
+ });
+
+ const encode = encoder();
+ const abort = new AbortController();
+ const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs);
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ const send = (event: StreamEvent) => {
+ try {
+ controller.enqueue(encode(event));
+ } catch {
+ /* client went away */
+ }
+ };
+
+ try {
+ const result = await ollamaChatStream({
+ model,
+ auth: auth.auth,
+ messages: [{ role: "system", content: system }, ...history],
+ signal: abort.signal,
+ onToken: (text) => send({ type: "token", text }),
+ onThinking: (text) => send({ type: "thinking", text }),
+ });
+ send({ type: "done", model: resolveModelName(result.model, auth.auth.directCloud) });
+ } catch (error) {
+ // A client-side stop aborts the upstream call: nothing to report.
+ if (abort.signal.aborted && request.signal.aborted) {
+ /* stopped by the user */
+ } else {
+ const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale);
+ console.error("[api/chat]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
+ send({
+ type: "error",
+ error: message,
+ kind: error instanceof OllamaError ? error.kind : "unknown",
+ });
+ }
+ } finally {
+ clearTimeout(globalTimer);
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ }
+ },
+ cancel() {
+ clearTimeout(globalTimer);
+ abort.abort();
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "application/x-ndjson; charset=utf-8",
+ "Cache-Control": "no-store, no-transform",
+ Connection: "keep-alive",
+ },
+ });
+}
diff --git a/app/api/health/route.ts b/app/api/health/route.ts
index a013da8..6f6aa2a 100644
--- a/app/api/health/route.ts
+++ b/app/api/health/route.ts
@@ -14,6 +14,12 @@ export type ModelHealth = {
note?: string;
};
+export type ChatModelHealth = {
+ name: string;
+ badge: "text" | "vision" | "both";
+ available: boolean | null;
+};
+
export type HealthResponse = {
ok: boolean;
reachable: boolean;
@@ -27,6 +33,8 @@ export type HealthResponse = {
hasApiKey: boolean;
vision: ModelHealth;
text: ModelHealth;
+ /** The chat model allowlist (CHAT_MODELS) with availability from the list. */
+ chatModels: ChatModelHealth[];
latencyMs: number | null;
modelCount: number | null;
error: string | null;
@@ -44,6 +52,15 @@ export async function GET(request: Request) {
const started = Date.now();
const resolvedAuth = resolveAuth(config, request);
+ const badgeFor = (name: string): ChatModelHealth["badge"] => {
+ const canonical = canonicalModelName(name);
+ const isText = canonical === canonicalModelName(config.textModel);
+ const isVision = canonical === canonicalModelName(config.visionModel);
+ if (isText && isVision) return "both";
+ if (isVision) return "vision";
+ return "text";
+ };
+
const health: HealthResponse = {
ok: false,
reachable: false,
@@ -63,6 +80,7 @@ export async function GET(request: Request) {
resolved: resolveModelName(config.textModel, config.directCloud),
available: null,
},
+ chatModels: config.chatModels.map((name) => ({ name, badge: badgeFor(name), available: null })),
latencyMs: null,
modelCount: null,
error: null,
@@ -121,6 +139,9 @@ export async function GET(request: Request) {
: translate(locale, "server.healthModelNoteCloud", { url: config.baseUrl });
}
}
+ for (const entry of health.chatModels) {
+ entry.available = names.has(canonicalModelName(entry.name));
+ }
health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false);
if (!health.ok && !health.error) {
diff --git a/app/chat/page.tsx b/app/chat/page.tsx
new file mode 100644
index 0000000..7e6a95e
--- /dev/null
+++ b/app/chat/page.tsx
@@ -0,0 +1,32 @@
+import { Suspense } from "react";
+
+import { ApiKeyGate } from "@/components/ApiKeyGate";
+import { AppHeader } from "@/components/AppHeader";
+import { Sidebar, SidebarOffset } from "@/components/Sidebar";
+import { ChatScreen } from "@/components/chat/ChatScreen";
+
+export const metadata = {
+ title: "Chat",
+};
+
+/**
+ * Chat mode. Phones get a full-screen conversation (the tab bar steps aside,
+ * see TabBar); desktop keeps the sidebar. The hidden AppHeader is only there
+ * to mount the settings dialog the sidebar opens.
+ */
+export default function ChatPage() {
+ return (
+
+
+
+ );
+}
diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx
index 8c01438..e42fce4 100644
--- a/components/DeckCard.tsx
+++ b/components/DeckCard.tsx
@@ -12,12 +12,12 @@ import { useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { shareDeck } from "@/lib/share";
-import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
+import { appendActivities, deleteDeck, deleteNotesText, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
import { showToast } from "@/lib/toast";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone";
import { cn, formatDate, truncate } from "@/lib/utils";
-import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon } from "@/components/icons";
+import { ChatIcon, ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon, XCircleIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
type Busy = null | "more" | "regenerate";
@@ -378,6 +378,13 @@ export function DeckCard({
{t("deck.share")}
+
+
+ {t("chat.askAboutDeck")}
+
+ {hasSource ? (
+ {
+ setMenuOpen(false);
+ deleteNotesText(deck.id);
+ showToast({ message: t("chat.notesDeleted") });
+ }}
+ >
+
+ {t("chat.deleteNotes")}
+
+ ) : null}
+
{/* Deleting is reversible: no confirm, just an Undo toast. */}
+
+
+ {t("chat.ask")}
+
{t("tabHomework")}
+
+
+
setSheetOpen(true)}
diff --git a/components/ResultsScreen.tsx b/components/ResultsScreen.tsx
index 78e7191..49f7481 100644
--- a/components/ResultsScreen.tsx
+++ b/components/ResultsScreen.tsx
@@ -11,6 +11,7 @@ import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons";
import { Button, Chip } from "@/components/ui";
+import { AskButton } from "@/components/chat/AskButton";
import type { AttemptResult } from "./StudyFeed";
@@ -193,6 +194,17 @@ export function ResultsScreen({
{regenerateError}
) : null}
+ `${promptFor(activity)} — ${t("results.answer")} ${answerFor(activity, t)}`),
+ ].join("\n"),
+ }}
+ />
downloadDeck(deck)}>
{t("results.export")}
diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx
index 5259959..d014aec 100644
--- a/components/SettingsDialog.tsx
+++ b/components/SettingsDialog.tsx
@@ -391,6 +391,17 @@ export function SettingsDialog({
/>
+
+ {/* chat grounding: may chat read the notes text? */}
+ {
+ saveSettings({ chatNotes: !settings.chatNotes });
+ haptic("toggle");
+ }}
+ />
{/* ------------------------------------------- data & sync */}
@@ -709,6 +720,16 @@ export function SettingsDialog({
) : null}
+ {/* ------------------------------------------- chat grounding */}
+
+ saveSettings({ chatNotes: !settings.chatNotes })}
+ />
+
+
{/* ------------------------------------------- data & sync */}
@@ -947,3 +968,44 @@ function ModelRow({
);
}
+
+/** Boolean toggle row used by the chat-grounding setting. */
+function ToggleRow({
+ label,
+ note,
+ checked,
+ onChange,
+}: {
+ label: string;
+ note: string;
+ checked: boolean;
+ onChange: () => void;
+}) {
+ return (
+
+
+ {label}
+ {note}
+
+
+
+
+
+ );
+}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
index 0ed7791..78424e9 100644
--- a/components/Sidebar.tsx
+++ b/components/Sidebar.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import {
+ ChatIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
@@ -137,6 +138,16 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
{!collapsed ? {t("homework.nav")} : null}
+ {/* chat with your notes --------------------------------------------- */}
+
+
+ {!collapsed ? {t("chat.nav")} : null}
+
+
{collapsed ? (
{
+ setChatSeed(seed);
+ router.push("/chat");
+ }}
+ >
+
+ {t("chat.askQuestion")}
+
+ );
+}
diff --git a/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx
new file mode 100644
index 0000000..930752b
--- /dev/null
+++ b/components/chat/ChatScreen.tsx
@@ -0,0 +1,520 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useReducedMotion } from "framer-motion";
+import Link from "next/link";
+
+import { Composer, type PendingAttachment } from "./Composer";
+import { ContextPicker } from "./ContextPicker";
+import { MessageRow } from "./MessageRow";
+import { ModelPicker } from "./ModelPicker";
+import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, PlusIcon, RefreshIcon } from "@/components/icons";
+import { ModelErrorActions } from "@/components/ModelErrorActions";
+import { ThemeToggle } from "@/components/ThemeToggle";
+import { Button } from "@/components/ui";
+import { ApiError } from "@/lib/client-api";
+import { extractChatAttachment, streamChat } from "@/lib/chat-client";
+import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
+import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
+import {
+ chatMessageId,
+ readStoredChatModel,
+ storeChatModel,
+ type ChatAttachment,
+ type ChatMessage,
+ type ChatModelChoice,
+ type ChatScope,
+} from "@/lib/chat";
+import { extractDocument } from "@/lib/documents";
+import { folderStore } from "@/lib/folders";
+import { isImageFile, prepareImage } from "@/lib/image";
+import { useI18n } from "@/lib/i18n";
+import { showToast } from "@/lib/toast";
+import { deckStore } from "@/lib/storage";
+import { settingsStore } from "@/lib/settings";
+import { useHealth } from "@/lib/use-health";
+import { useOnline } from "@/lib/use-online";
+
+type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string };
+
+/**
+ * Chat mode: an LLM-style conversation that knows the user's notes. The
+ * retrieval runs here in the browser (BM25 over the scoped decks), and only
+ * the top chunks plus the recent turns travel with each request.
+ */
+export function ChatScreen() {
+ const { t, plural } = useI18n();
+ const online = useOnline();
+ const reduceMotion = useReducedMotion();
+ const router = useRouter();
+ const search = useSearchParams();
+
+ const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
+ const folders = useSyncExternalStore(folderStore.subscribe, folderStore.getSnapshot, folderStore.getServerSnapshot);
+ const settings = useSyncExternalStore(settingsStore.subscribe, settingsStore.getSnapshot, settingsStore.getServerSnapshot);
+ const { health } = useHealth();
+
+ const deckParam = search.get("deck");
+
+ const [scope, setScope] = useState(() =>
+ deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" },
+ );
+ const [seed, setSeed] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [draft, setDraft] = useState("");
+ const [attachments, setAttachments] = useState([]);
+ const [attachBusy, setAttachBusy] = useState(false);
+ const [model, setModel] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [pinned, setPinned] = useState(true);
+ const [scopeOpen, setScopeOpen] = useState(false);
+ const [modelOpen, setModelOpen] = useState(false);
+ const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
+
+ const abortRef = useRef(null);
+ const scrollRef = useRef(null);
+ const pinnedRef = useRef(true);
+ const extrasRef = useRef>({});
+
+ /* Hand-offs from a deck menu / results screen. */
+ useEffect(() => {
+ const taken = takeChatSeed();
+ if (!taken) return;
+ setSeed(taken);
+ if (taken.deckId) setScope({ kind: "decks", deckIds: [taken.deckId] });
+ }, []);
+
+ useEffect(() => {
+ setModel(readStoredChatModel() ?? "");
+ }, []);
+
+ /* Keyboard: keep the composer above the on-screen keyboard. */
+ useEffect(() => {
+ const viewport = window.visualViewport;
+ if (!viewport) return;
+ const apply = () => {
+ const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
+ document.documentElement.style.setProperty("--chat-keyboard", `${Math.round(inset)}px`);
+ };
+ apply();
+ viewport.addEventListener("resize", apply);
+ viewport.addEventListener("scroll", apply);
+ return () => {
+ viewport.removeEventListener("resize", apply);
+ viewport.removeEventListener("scroll", apply);
+ document.documentElement.style.removeProperty("--chat-keyboard");
+ };
+ }, []);
+
+ const chatModels = useMemo(() => {
+ const fromHealth = health?.chatModels ?? [];
+ const list =
+ fromHealth.length > 0
+ ? fromHealth
+ : [
+ { name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
+ { name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
+ ];
+ return list.filter((entry) => entry.name);
+ }, [health]);
+
+ const activeModel = useMemo(() => {
+ if (model && chatModels.some((entry) => entry.name === model)) return model;
+ return chatModels[0]?.name ?? model;
+ }, [model, chatModels]);
+
+ const scopeText = useMemo(() => {
+ if (scope.kind === "all") return t("chat.scope.all");
+ if (scope.kind === "none") return t("chat.scope.none");
+ return plural(scope.deckIds.length, "chat.scope.decks");
+ }, [scope, t, plural]);
+
+ const scopedDecks = useMemo(() => {
+ if (scope.kind === "none") return [];
+ if (scope.kind === "all") return decks;
+ return decks.filter((deck) => scope.deckIds.includes(deck.id));
+ }, [decks, scope]);
+
+ const missingNotes = useMemo(
+ () => (settings.chatNotes ? scopedDecks.some((deck) => !deckHasNotes(deck)) : false),
+ [scopedDecks, settings.chatNotes],
+ );
+
+ const suggestions = useMemo(() => {
+ const chosen = scope.kind === "decks" ? scopedDecks : [];
+ if (chosen.length === 1) {
+ return [
+ t("chat.suggest.explain", { deck: chosen[0].title }),
+ t("chat.suggest.quiz", { deck: chosen[0].title }),
+ t("chat.suggest.summarize"),
+ ];
+ }
+ if (scope.kind === "none") return [t("chat.suggest.general"), t("chat.suggest.explainTopic")];
+ return [t("chat.suggest.summarize"), t("chat.suggest.quizAll"), t("chat.suggest.studyFirst")];
+ }, [scope, scopedDecks, t]);
+
+ const scrollToBottom = useCallback(
+ (smooth = false) => {
+ const element = scrollRef.current;
+ if (!element) return;
+ element.scrollTo({ top: element.scrollHeight, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
+ },
+ [reduceMotion],
+ );
+
+ useEffect(() => {
+ if (pinnedRef.current) scrollToBottom(false);
+ }, [messages, scrollToBottom]);
+
+ const onScroll = () => {
+ const element = scrollRef.current;
+ if (!element) return;
+ const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
+ const next = distance < 80;
+ pinnedRef.current = next;
+ setPinned(next);
+ };
+
+ const extraFor = (text: string, pending: PendingAttachment[]): ExtraChunk[] => {
+ const chunks: ExtraChunk[] = [];
+ if (seed) chunks.push({ deckId: seed.deckId ?? "seed", deckTitle: seed.label, section: seed.label, text: seed.text });
+ if (pending.length > 0) {
+ chunks.push({
+ deckId: "attachment",
+ deckTitle: t("chat.attachmentLabel"),
+ section: t("chat.attachmentLabel"),
+ text: pending.map((entry) => `${entry.attachment.name}\n${entry.text}`).join("\n\n").slice(0, 24_000),
+ });
+ }
+ void text;
+ return chunks;
+ };
+
+ const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => {
+ const lastUser = history[history.length - 1];
+ const context = buildChatContext({
+ decks,
+ scope,
+ question: lastUser.content,
+ history: history.slice(0, -1),
+ useNotes: settings.chatNotes,
+ extraChunks,
+ });
+ const sources = context.chunks.map(({ deckId, deckTitle, section }) => ({ deckId, deckTitle, section }));
+ const assistantId = chatMessageId("a");
+
+ setMessages([...history, { id: assistantId, role: "assistant", content: "", sources, at: Date.now() }]);
+ setBusy(true);
+ pinnedRef.current = true;
+ setPinned(true);
+ const controller = new AbortController();
+ abortRef.current = controller;
+ let produced = false;
+
+ try {
+ await streamChat({
+ model: activeModel || undefined,
+ messages: history.map((message) => ({ role: message.role, content: message.content })),
+ context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })),
+ signal: controller.signal,
+ onToken: (text) => {
+ produced = true;
+ setMessages((previous) =>
+ previous.map((message) =>
+ message.id === assistantId ? { ...message, content: message.content + text } : message,
+ ),
+ );
+ },
+ onThinking: (text) => {
+ setMessages((previous) =>
+ previous.map((message) =>
+ message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
+ ),
+ );
+ },
+ onDone: (modelName) => {
+ setMessages((previous) =>
+ previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
+ );
+ },
+ });
+ } catch (error) {
+ const apiError = error instanceof ApiError ? error : new ApiError(String(error), "unknown");
+ if (apiError.kind === "aborted") {
+ // Stopped by the user: keep whatever streamed in.
+ } else {
+ setError({ message: apiError.message, kind: apiError.kind });
+ if (!produced) {
+ // Nothing arrived: take the placeholder away and give the text back.
+ setMessages(history);
+ setDraft(lastUser.content);
+ } else {
+ setMessages((previous) =>
+ previous.map((message) =>
+ message.id === assistantId
+ ? { ...message, error: { message: apiError.message, kind: apiError.kind } }
+ : message,
+ ),
+ );
+ }
+ }
+ } finally {
+ abortRef.current = null;
+ setBusy(false);
+ }
+ };
+
+ const send = (text?: string) => {
+ const content = (text ?? draft).trim();
+ if (!content || busy || !online) return;
+ const pending = attachments;
+ const userMessage: ChatMessage = {
+ id: chatMessageId("u"),
+ role: "user",
+ content,
+ attachments: pending.length > 0 ? pending.map((entry) => entry.attachment) : undefined,
+ at: Date.now(),
+ };
+ const extras = extraFor(content, pending);
+ extrasRef.current[userMessage.id] = extras;
+ const history = [...messages, userMessage];
+ setError(null);
+ setMessages(history);
+ setDraft("");
+ setAttachments([]);
+ void run(history, extras);
+ };
+
+ const retryLast = () => {
+ const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
+ if (lastUserIndex < 0) return;
+ const history = messages.slice(0, lastUserIndex + 1);
+ setMessages(history);
+ void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
+ };
+
+ const regenerate = () => {
+ const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
+ if (lastUserIndex < 0) return;
+ const history = messages.slice(0, lastUserIndex + 1);
+ setMessages(history);
+ void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
+ };
+
+ const editLast = () => {
+ const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
+ if (lastUserIndex < 0) return;
+ setDraft(messages[lastUserIndex].content);
+ setMessages(messages.slice(0, lastUserIndex));
+ };
+
+ const newChat = () => {
+ abortRef.current?.abort();
+ setMessages([]);
+ setSeed(null);
+ setAttachments([]);
+ setDraft("");
+ };
+
+ const handleAttach = async (file: File) => {
+ setAttachBusy(true);
+ try {
+ if (isImageFile(file)) {
+ const prepared = await prepareImage(file);
+ const text = await extractChatAttachment({ images: [prepared.dataUrl] });
+ const attachment: ChatAttachment = { name: file.name || t("chat.attachPhoto"), kind: "photo", chars: text.length };
+ setAttachments((previous) => [...previous, { attachment, text }]);
+ } else {
+ const document = await extractDocument(file);
+ if (!document.text.trim()) {
+ showToast({ message: t("chat.attachNoText") });
+ } else {
+ const attachment: ChatAttachment = { name: document.name, kind: "document", chars: document.text.length };
+ setAttachments((previous) => [...previous, { attachment, text: document.text }]);
+ }
+ }
+ } catch (error) {
+ showToast({ message: error instanceof ApiError ? error.message : t("chat.attachFailed") });
+ } finally {
+ setAttachBusy(false);
+ }
+ };
+
+ const lastAssistantId = [...messages].reverse().find((message) => message.role === "assistant")?.id;
+ const lastUserId = [...messages].reverse().find((message) => message.role === "user")?.id;
+ const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
+
+ return (
+
+
+
+
+
+
+ {messages.length === 0 ? (
+
+
+
+
{t("chat.emptyTitle")}
+
+ {t("chat.emptyHint")}
+
+
+ {missingNotes ? (
+
+ {t("chat.oldDecksNote")}
+
+ ) : null}
+
+ {suggestions.map((suggestion) => (
+ send(suggestion)}
+ className="max-w-full rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-left text-[13px] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-55"
+ >
+ {suggestion}
+
+ ))}
+
+
+ ) : (
+ <>
+ {seed ? (
+
+
+ {t("chat.fromLabel")}: {seed.label}
+
+
+ ) : null}
+ {messages.map((message) => (
+
+ ))}
+ >
+ )}
+
+
+
+
+ {!pinned && messages.length > 0 ? (
+
{
+ pinnedRef.current = true;
+ setPinned(true);
+ scrollToBottom(true);
+ }}
+ aria-label={t("chat.jumpToBottom")}
+ title={t("chat.jumpToBottom")}
+ className="glass-panel absolute bottom-4 left-1/2 grid size-10 -translate-x-1/2 place-items-center rounded-full text-[var(--text-muted)] shadow-lg"
+ >
+
+
+ ) : null}
+
+
+ {error ? (
+
+
{error.message}
+
+ {messages.some((message) => message.role === "user") ? (
+
+ {t("chat.retry")}
+
+ ) : null}
+
+
+
+ ) : null}
+
+
+ send()}
+ onStop={() => abortRef.current?.abort()}
+ busy={busy}
+ online={online}
+ scopeLabel={scopeText}
+ modelLabel={activeModel || t("chat.modelTitle")}
+ onOpenScope={() => setScopeOpen(true)}
+ onOpenModel={() => setModelOpen(true)}
+ onAttachFile={(file) => void handleAttach(file)}
+ attachBusy={attachBusy}
+ attachments={attachments}
+ onRemoveAttachment={(index) => setAttachments((previous) => previous.filter((_, i) => i !== index))}
+ />
+ setModelOpen(false)}
+ models={chatModels}
+ value={activeModel}
+ onChange={(next) => {
+ setModel(next);
+ storeChatModel(next);
+ }}
+ />
+ setScopeOpen(false)}
+ scope={scope}
+ onChange={setScope}
+ decks={decks}
+ folders={folders}
+ />
+
+
+ {settings.chatNotes === false && scope.kind !== "none" ? (
+
+ {t("chat.notesOffNote")}{" "}
+
+ {t("chat.notesOffLink")}
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/chat/Composer.tsx b/components/chat/Composer.tsx
new file mode 100644
index 0000000..3a4e9d5
--- /dev/null
+++ b/components/chat/Composer.tsx
@@ -0,0 +1,180 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
+import { Spinner } from "@/components/ui";
+import { useI18n } from "@/lib/i18n";
+import { useIsPhone } from "@/lib/use-phone";
+import type { ChatAttachment } from "@/lib/chat";
+import { cn } from "@/lib/utils";
+
+export type PendingAttachment = { attachment: ChatAttachment; text: string };
+
+/**
+ * The pinned composer: auto-growing 16px textarea, send/stop, attach, and the
+ * context + model chips. Enter sends on desktop, newline on phones; the
+ * keyboard inset is handled by the screen around it.
+ */
+export function Composer({
+ value,
+ onChange,
+ onSend,
+ onStop,
+ busy,
+ online,
+ scopeLabel,
+ modelLabel,
+ onOpenScope,
+ onOpenModel,
+ onAttachFile,
+ attachBusy,
+ attachments,
+ onRemoveAttachment,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ onSend: () => void;
+ onStop: () => void;
+ busy: boolean;
+ online: boolean;
+ scopeLabel: string;
+ modelLabel: string;
+ onOpenScope: () => void;
+ onOpenModel: () => void;
+ onAttachFile: (file: File) => void;
+ attachBusy: boolean;
+ attachments: PendingAttachment[];
+ onRemoveAttachment: (index: number) => void;
+}) {
+ const { t } = useI18n();
+ const isPhone = useIsPhone();
+ const textarea = useRef(null);
+ const fileInput = useRef(null);
+
+ useEffect(() => {
+ const element = textarea.current;
+ if (!element) return;
+ element.style.height = "auto";
+ element.style.height = `${Math.min(element.scrollHeight, 180)}px`;
+ }, [value]);
+
+ const canSend = online && !busy && value.trim().length > 0;
+
+ return (
+
+ {attachments.length > 0 ? (
+
+ {attachments.map((entry, index) => (
+
+ {entry.attachment.name}
+ {entry.attachment.kind === "photo" ? t("chat.attachPhoto") : t("chat.attachDoc")}
+ onRemoveAttachment(index)}
+ className="grid size-6 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
+ >
+
+
+
+ ))}
+
+ ) : null}
+
+
+
fileInput.current?.click()}
+ className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
+ >
+ {attachBusy ? : }
+
+
{
+ const file = event.target.files?.[0];
+ if (file) onAttachFile(file);
+ event.target.value = "";
+ }}
+ />
+
+
+
+
+
+
+ {scopeLabel}
+
+
+
+ {modelLabel}
+
+ {!online ? (
+
+ {t("chat.offlineNote")}
+
+ ) : null}
+
+
+ );
+}
diff --git a/components/chat/ContextPicker.tsx b/components/chat/ContextPicker.tsx
new file mode 100644
index 0000000..f1d525f
--- /dev/null
+++ b/components/chat/ContextPicker.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useEffect } from "react";
+
+import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
+import { useI18n } from "@/lib/i18n";
+import { useIsPhone } from "@/lib/use-phone";
+import { deckHasNotes } from "@/lib/chat-context";
+import type { ChatScope } from "@/lib/chat";
+import type { Deck, Folder } from "@/lib/schemas";
+import { cn } from "@/lib/utils";
+
+/**
+ * Scope picker for the context chip: All notes · selected folders/decks ·
+ * None (general chat). Only what is selected here can ever travel as context.
+ */
+export function ContextPicker({
+ open,
+ onClose,
+ scope,
+ onChange,
+ decks,
+ folders,
+}: {
+ open: boolean;
+ onClose: () => void;
+ scope: ChatScope;
+ onChange: (scope: ChatScope) => void;
+ decks: Deck[];
+ folders: Folder[];
+}) {
+ const { t, plural } = useI18n();
+ const isPhone = useIsPhone();
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set();
+
+ const choose = (next: ChatScope) => {
+ onChange(next);
+ if (next.kind !== "decks") onClose();
+ };
+
+ const toggleDeck = (deckId: string) => {
+ const next = new Set(selected);
+ if (next.has(deckId)) next.delete(deckId);
+ else next.add(deckId);
+ onChange({ kind: "decks", deckIds: [...next] });
+ };
+
+ const toggleFolder = (folderId: string) => {
+ const inFolder = decks.filter((deck) => deck.folderId === folderId).map((deck) => deck.id);
+ const allSelected = inFolder.length > 0 && inFolder.every((id) => selected.has(id));
+ const next = new Set(selected);
+ for (const id of inFolder) {
+ if (allSelected) next.delete(id);
+ else next.add(id);
+ }
+ onChange({ kind: "decks", deckIds: [...next] });
+ };
+
+ const modeRow = (
+ value: ChatScope["kind"],
+ title: string,
+ note: string,
+ icon: React.ReactNode,
+ ) => (
+
+ choose(
+ value === "all"
+ ? { kind: "all" }
+ : value === "none"
+ ? { kind: "none" }
+ : { kind: "decks", deckIds: selected.size > 0 ? [...selected] : decks.map((deck) => deck.id) },
+ )
+ }
+ className={cn(
+ "flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
+ scope.kind === value
+ ? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
+ : "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
+ )}
+ >
+ {icon}
+
+ {title}
+ {note}
+
+ {scope.kind === value ? : null}
+
+ );
+
+ const body = (
+
+ {modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"),
)}
+ {modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"),
)}
+ {scope.kind === "decks" ? (
+
+ {folders.map((folder) => {
+ const inFolder = decks.filter((deck) => deck.folderId === folder.id);
+ if (inFolder.length === 0) return null;
+ const allSelected = inFolder.every((deck) => selected.has(deck.id));
+ return (
+ toggleFolder(folder.id)}
+ className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-[12.5px] font-semibold text-[var(--text-muted)] transition hover:bg-[var(--hover)]"
+ >
+
+ {allSelected ? : null}
+
+
+ {folder.name}
+
+ {plural(inFolder.length, "chat.scope.deckCount")}
+
+
+ );
+ })}
+ {decks.map((deck) => {
+ const active = selected.has(deck.id);
+ return (
+ toggleDeck(deck.id)}
+ className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left transition hover:bg-[var(--hover)]"
+ >
+
+ {active ? : null}
+
+
+ {deck.title}
+
+ {deckHasNotes(deck) ? t("chat.scope.hasNotes") : t("chat.scope.generatedOnly")}
+
+
+
+ );
+ })}
+
+ ) : null}
+ {modeRow("none", t("chat.scope.none"), t("chat.scope.noneNote"),
💬 )}
+
+ );
+
+ if (isPhone) {
+ return (
+
+
+
+
+
{t("chat.scope.title")}
+ {body}
+
{t("chat.scope.note")}
+
+
+ );
+ }
+
+ return (
+
+
+ {t("chat.scope.title")}
+
+ {body}
+
{t("chat.scope.note")}
+
+ );
+}
diff --git a/components/chat/CopyIconButton.tsx b/components/chat/CopyIconButton.tsx
new file mode 100644
index 0000000..0cf85d5
--- /dev/null
+++ b/components/chat/CopyIconButton.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useState } from "react";
+
+import { CheckCircleIcon, CopyIcon, XCircleIcon } from "@/components/icons";
+import { copyText } from "@/lib/clipboard";
+import { useI18n } from "@/lib/i18n";
+import { cn } from "@/lib/utils";
+
+/** Small square copy control used by chat message actions and code blocks. */
+export function CopyIconButton({ text, className }: { text: string; className?: string }) {
+ const { t } = useI18n();
+ const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
+
+ return (
+ {
+ const ok = await copyText(text);
+ setState(ok ? "copied" : "failed");
+ window.setTimeout(() => setState("idle"), 1800);
+ }}
+ className={cn(
+ "grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
+ className,
+ )}
+ >
+ {state === "copied" ? (
+
+ ) : state === "failed" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/components/chat/Markdown.tsx b/components/chat/Markdown.tsx
new file mode 100644
index 0000000..1840dc5
--- /dev/null
+++ b/components/chat/Markdown.tsx
@@ -0,0 +1,316 @@
+"use client";
+
+import { memo, useMemo } from "react";
+
+import { CopyIconButton } from "./CopyIconButton";
+import { MathSpan } from "./MathSpan";
+
+/**
+ * Safe, dependency-free markdown for chat answers: lists, tables, code blocks
+ * (with a copy button), quotes, headings, inline code/bold/italic/links and
+ * $...$ math. It parses to React nodes — nothing is ever set as HTML.
+ */
+
+type Block =
+ | { type: "p"; lines: string[] }
+ | { type: "heading"; level: number; text: string }
+ | { type: "list"; ordered: boolean; items: { text: string; depth: number }[] }
+ | { type: "code"; lang: string; code: string }
+ | { type: "table"; head: string[]; rows: string[][] }
+ | { type: "quote"; lines: string[] }
+ | { type: "math"; tex: string }
+ | { type: "hr" };
+
+const FENCE = /^(```|~~~)\s*([\w+-]*)\s*$/;
+const HEADING = /^(#{1,6})\s+(.*)$/;
+const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
+const QUOTE = /^>\s?(.*)$/;
+const HR = /^\s*([-*_])\1{2,}\s*$/;
+
+function isTableSeparator(line: string): boolean {
+ return /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes("-");
+}
+
+function splitTableRow(line: string): string[] {
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
+ return trimmed.split("|").map((cell) => cell.trim());
+}
+
+export function parseBlocks(text: string): Block[] {
+ const blocks: Block[] = [];
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
+ let index = 0;
+
+ const flushParagraph = (collected: string[]) => {
+ const joined = collected.join("\n").trim();
+ if (joined) blocks.push({ type: "p", lines: collected });
+ };
+
+ let paragraph: string[] = [];
+
+ while (index < lines.length) {
+ const line = lines[index];
+
+ const fence = FENCE.exec(line);
+ if (fence) {
+ flushParagraph(paragraph);
+ paragraph = [];
+ const lang = fence[2] ?? "";
+ const code: string[] = [];
+ index += 1;
+ while (index < lines.length && !FENCE.test(lines[index])) {
+ code.push(lines[index]);
+ index += 1;
+ }
+ index += 1; // closing fence (or end of stream)
+ blocks.push({ type: "code", lang, code: code.join("\n") });
+ continue;
+ }
+
+ if (HR.test(line)) {
+ flushParagraph(paragraph);
+ paragraph = [];
+ blocks.push({ type: "hr" });
+ index += 1;
+ continue;
+ }
+
+ const heading = HEADING.exec(line);
+ if (heading) {
+ flushParagraph(paragraph);
+ paragraph = [];
+ blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
+ index += 1;
+ continue;
+ }
+
+ // $$ ... $$ on its own line(s)
+ if (line.trim().startsWith("$$")) {
+ flushParagraph(paragraph);
+ paragraph = [];
+ const trimmed = line.trim();
+ if (trimmed.length > 4 && trimmed.endsWith("$$")) {
+ blocks.push({ type: "math", tex: trimmed.slice(2, -2).trim() });
+ index += 1;
+ continue;
+ }
+ const math: string[] = [trimmed.slice(2)];
+ index += 1;
+ while (index < lines.length && !lines[index].trim().endsWith("$$")) {
+ math.push(lines[index]);
+ index += 1;
+ }
+ if (index < lines.length) {
+ math.push(lines[index].trim().slice(0, -2));
+ index += 1;
+ }
+ blocks.push({ type: "math", tex: math.join("\n").trim() });
+ continue;
+ }
+
+ const quote = QUOTE.exec(line);
+ if (quote) {
+ flushParagraph(paragraph);
+ paragraph = [];
+ const collected = [quote[1]];
+ index += 1;
+ while (index < lines.length) {
+ const next = QUOTE.exec(lines[index]);
+ if (!next) break;
+ collected.push(next[1]);
+ index += 1;
+ }
+ blocks.push({ type: "quote", lines: collected });
+ continue;
+ }
+
+ const item = LIST_ITEM.exec(line);
+ if (item) {
+ flushParagraph(paragraph);
+ paragraph = [];
+ const ordered = /\d/.test(item[2]);
+ const items: { text: string; depth: number }[] = [];
+ while (index < lines.length) {
+ const next = LIST_ITEM.exec(lines[index]);
+ if (!next) break;
+ const depth = Math.min(2, Math.floor(next[1].replace(/\t/g, " ").length / 2));
+ items.push({ text: next[3], depth });
+ index += 1;
+ }
+ blocks.push({ type: "list", ordered, items });
+ continue;
+ }
+
+ // Table: header row followed by a separator row.
+ if (line.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
+ flushParagraph(paragraph);
+ paragraph = [];
+ const head = splitTableRow(line);
+ index += 2;
+ const rows: string[][] = [];
+ while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
+ rows.push(splitTableRow(lines[index]));
+ index += 1;
+ }
+ blocks.push({ type: "table", head, rows });
+ continue;
+ }
+
+ if (!line.trim()) {
+ flushParagraph(paragraph);
+ paragraph = [];
+ index += 1;
+ continue;
+ }
+
+ paragraph.push(line);
+ index += 1;
+ }
+ flushParagraph(paragraph);
+ return blocks;
+}
+
+const INLINE =
+ /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\[[^\]]+\]\([^)\s]+\)|\$\$[^$]+\$\$|\$[^$\n]+\$|\*[^*\n]+\*|_[^_\n]+_)/g;
+
+function inlineNodes(text: string, keyPrefix: string): React.ReactNode[] {
+ return text.split(INLINE).map((part, index) => {
+ const key = `${keyPrefix}-${index}`;
+ if (!part) return null;
+ if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
+ return {part.slice(2, -2)} ;
+ }
+ if (part.startsWith("__") && part.endsWith("__") && part.length > 4) {
+ return {part.slice(2, -2)} ;
+ }
+ if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
+ return (
+
+ {part.slice(1, -1)}
+
+ );
+ }
+ if (part.startsWith("$$") && part.endsWith("$$") && part.length > 4) {
+ return ;
+ }
+ if (part.startsWith("$") && part.endsWith("$") && part.length > 2) {
+ return ;
+ }
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(part);
+ if (link) {
+ const href = link[2];
+ if (/^https?:\/\//i.test(href)) {
+ return (
+
+ {link[1]}
+
+ );
+ }
+ return {link[1]} ;
+ }
+ if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
+ return {part.slice(1, -1)} ;
+ }
+ if (part.startsWith("_") && part.endsWith("_") && part.length > 2) {
+ return {part.slice(1, -1)} ;
+ }
+ return {part} ;
+ });
+}
+
+function renderBlock(block: Block, index: number) {
+ const key = `b-${index}`;
+ switch (block.type) {
+ case "heading": {
+ const Tag = (block.level <= 2 ? "h3" : "h4") as "h3" | "h4";
+ return (
+
+ {inlineNodes(block.text, key)}
+
+ );
+ }
+ case "list": {
+ const Tag = block.ordered ? "ol" : "ul";
+ return (
+
+ {block.items.map((item, itemIndex) => (
+ 0 ? "ml-4 list-[circle]" : undefined}
+ >
+ {inlineNodes(item.text, `${key}-${itemIndex}`)}
+
+ ))}
+
+ );
+ }
+ case "code":
+ return (
+
+
+ {block.code}
+
+
+
+
+
+ );
+ case "table":
+ return (
+
+
+
+
+ {block.head.map((cell, cellIndex) => (
+
+ {inlineNodes(cell, `${key}-h-${cellIndex}`)}
+
+ ))}
+
+
+
+ {block.rows.map((row, rowIndex) => (
+
+ {block.head.map((_, cellIndex) => (
+
+ {inlineNodes(row[cellIndex] ?? "", `${key}-r-${rowIndex}-${cellIndex}`)}
+
+ ))}
+
+ ))}
+
+
+
+ );
+ case "quote":
+ return (
+
+ {inlineNodes(block.lines.join("\n"), key)}
+
+ );
+ case "math":
+ return ;
+ case "hr":
+ return ;
+ default:
+ return (
+
+ {inlineNodes(block.lines.join("\n"), key)}
+
+ );
+ }
+}
+
+export const Markdown = memo(function Markdown({ text }: { text: string }) {
+ const blocks = useMemo(() => parseBlocks(text), [text]);
+ return {blocks.map(renderBlock)}
;
+});
diff --git a/components/chat/MathSpan.tsx b/components/chat/MathSpan.tsx
new file mode 100644
index 0000000..6a54232
--- /dev/null
+++ b/components/chat/MathSpan.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+/**
+ * Math rendering, loaded only when a message actually contains math: KaTeX is
+ * imported dynamically (its CSS with it) the first time a span mounts and
+ * writes into this span imperatively — no HTML injection anywhere.
+ */
+export function MathSpan({ tex, display = false }: { tex: string; display?: boolean }) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const [katex] = await Promise.all([import("katex"), import("katex/dist/katex.min.css")]);
+ if (cancelled || !ref.current) return;
+ katex.default.render(tex, ref.current, {
+ displayMode: display,
+ throwOnError: false,
+ strict: false,
+ trust: false,
+ });
+ } catch {
+ if (!cancelled && ref.current) ref.current.textContent = tex;
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [tex, display]);
+
+ return (
+
+ );
+}
diff --git a/components/chat/MessageRow.tsx b/components/chat/MessageRow.tsx
new file mode 100644
index 0000000..0a8004b
--- /dev/null
+++ b/components/chat/MessageRow.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import Link from "next/link";
+
+import { CopyIconButton } from "./CopyIconButton";
+import { Markdown } from "./Markdown";
+import { ThinkingRow } from "./ThinkingRow";
+import { Button } from "@/components/ui";
+import { ModelErrorActions } from "@/components/ModelErrorActions";
+import { PencilIcon, RefreshIcon } from "@/components/icons";
+import { useI18n } from "@/lib/i18n";
+import type { ChatMessage } from "@/lib/chat";
+
+/**
+ * One turn: user bubbles on the right, assistant answers as full-width text
+ * (no bubble) with the Thinking row, sources, actions and error recovery.
+ */
+export function MessageRow({
+ message,
+ streaming,
+ isLastAssistant,
+ isLastUser,
+ busy,
+ onRegenerate,
+ onEdit,
+ onRetry,
+}: {
+ message: ChatMessage;
+ streaming?: boolean;
+ isLastAssistant?: boolean;
+ isLastUser?: boolean;
+ busy?: boolean;
+ onRegenerate?: () => void;
+ onEdit?: () => void;
+ onRetry?: () => void;
+}) {
+ const { t } = useI18n();
+
+ if (message.role === "user") {
+ return (
+
+
+
{message.content}
+ {message.attachments && message.attachments.length > 0 ? (
+
+ {message.attachments.map((attachment) => attachment.name).join(" · ")}
+
+ ) : null}
+
+ {isLastUser && !busy && onEdit ? (
+
+
+ {t("chat.edit")}
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+ {message.thinking ?
: null}
+ {message.content ? (
+
+ ) : streaming ? (
+
+ {[0, 1, 2].map((dot) => (
+
+ ))}
+
+ ) : null}
+
+ {message.error ? (
+
+
{message.error.message}
+
+ {onRetry ? (
+
+ {t("chat.retry")}
+
+ ) : null}
+
+
+
+ ) : null}
+
+ {!streaming && message.content && !message.error ? (
+
+
+ {isLastAssistant && !busy && onRegenerate ? (
+
+
+ {t("chat.regenerate")}
+
+ ) : null}
+ {message.model ? {message.model} : null}
+
+ ) : null}
+
+ {message.sources && message.sources.length > 0 && !streaming ? (
+
+ {t("chat.sources")}
+ {message.sources.slice(0, 5).map((source, index) => (
+
+ {source.deckTitle} · {source.section}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/components/chat/ModelPicker.tsx b/components/chat/ModelPicker.tsx
new file mode 100644
index 0000000..5f40b53
--- /dev/null
+++ b/components/chat/ModelPicker.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useEffect } from "react";
+
+import { CheckCircleIcon } from "@/components/icons";
+import { useI18n } from "@/lib/i18n";
+import { useIsPhone } from "@/lib/use-phone";
+import type { ChatModelChoice } from "@/lib/chat";
+import { cn } from "@/lib/utils";
+
+/**
+ * Model chip picker: a bottom sheet on phones, a small dropdown on desktop.
+ * The list is the server's CHAT_MODELS allowlist (from the health check) with
+ * text/vision badges and availability; picking one remembers it.
+ */
+export function ModelPicker({
+ open,
+ onClose,
+ models,
+ value,
+ onChange,
+}: {
+ open: boolean;
+ onClose: () => void;
+ models: ChatModelChoice[];
+ value: string;
+ onChange: (model: string) => void;
+}) {
+ const { t } = useI18n();
+ const isPhone = useIsPhone();
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ const badge = (model: ChatModelChoice) =>
+ model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
+
+ const rows = (
+
+ {models.map((model) => {
+ const selected = model.name === value;
+ const unavailable = model.available === false;
+ return (
+
+ {
+ onChange(model.name);
+ onClose();
+ }}
+ className={cn(
+ "flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
+ selected
+ ? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
+ : "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
+ unavailable && "opacity-55",
+ )}
+ >
+
+ {model.name}
+
+ {badge(model)}
+ {model.available === true ? (
+ {t("chat.modelAvailable")}
+ ) : model.available === false ? (
+ {t("chat.modelUnavailable")}
+ ) : null}
+
+
+ {selected ? : null}
+
+
+ );
+ })}
+
+ );
+
+ if (isPhone) {
+ return (
+
+
+
+
+
{t("chat.modelTitle")}
+ {rows}
+
{t("chat.modelNote")}
+
+
+ );
+ }
+
+ return (
+
+
+ {t("chat.modelTitle")}
+
+ {rows}
+
{t("chat.modelNote")}
+
+ );
+}
diff --git a/components/chat/ThinkingRow.tsx b/components/chat/ThinkingRow.tsx
new file mode 100644
index 0000000..1a1d06c
--- /dev/null
+++ b/components/chat/ThinkingRow.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useState } from "react";
+
+import { ChevronDownIcon, SparklesIcon } from "@/components/icons";
+import { useI18n } from "@/lib/i18n";
+import { cn } from "@/lib/utils";
+
+/**
+ * Reasoning models stream `thinking` separately from the answer. It is hidden
+ * by default behind this row — the answer itself is never the raw reasoning.
+ */
+export function ThinkingRow({ thinking, streaming }: { thinking: string; streaming?: boolean }) {
+ const { t } = useI18n();
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
setOpen((value) => !value)}
+ aria-expanded={open}
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
+ >
+
+ {streaming ? t("chat.thinking") : t("chat.thought")}
+
+
+ {open ? (
+
+ {thinking}
+
+ ) : null}
+
+ );
+}
diff --git a/components/homework/HomeworkCheckResult.tsx b/components/homework/HomeworkCheckResult.tsx
index ad3ea2f..a6ab5fc 100644
--- a/components/homework/HomeworkCheckResult.tsx
+++ b/components/homework/HomeworkCheckResult.tsx
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Button } from "@/components/ui";
+import { AskButton } from "@/components/chat/AskButton";
import { useI18n } from "@/lib/i18n";
import type { HomeworkCheckRecord } from "@/lib/schemas";
@@ -97,6 +98,27 @@ export function HomeworkCheckResult({
+
0
+ ? ["", `${t("chat.seedToFix")}:`, ...record.wrong.map((entry) => `- ${entry.issue}: ${entry.why} → ${entry.fix}`)]
+ : []),
+ "",
+ `${t("chat.seedCorrected")}:`,
+ corrected,
+ ].join("\n"),
+ }}
+ />
{t("homework.followup.redo")}
diff --git a/components/homework/HomeworkGuide.tsx b/components/homework/HomeworkGuide.tsx
index 0cc7996..b3a5d62 100644
--- a/components/homework/HomeworkGuide.tsx
+++ b/components/homework/HomeworkGuide.tsx
@@ -7,6 +7,7 @@ import { ActivityRenderer } from "@/components/activities";
import { PromptCard, QuestionText } from "@/components/activities/shared";
import { XIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
+import { AskButton } from "@/components/chat/AskButton";
import { assembleSolution } from "@/lib/homework";
import { useI18n } from "@/lib/i18n";
import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas";
@@ -283,6 +284,18 @@ export function HomeworkGuide({
{t("homework.guide.solutionNote")}
+
`${index + 1}. ${step.title}${step.explanation ? ` — ${step.explanation}` : ""}`),
+ ].join("\n"),
+ }}
+ />
{t("homework.followup.redo")}
diff --git a/components/homework/HomeworkResult.tsx b/components/homework/HomeworkResult.tsx
index 8804242..6ead81c 100644
--- a/components/homework/HomeworkResult.tsx
+++ b/components/homework/HomeworkResult.tsx
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Button } from "@/components/ui";
+import { AskButton } from "@/components/chat/AskButton";
import { useI18n } from "@/lib/i18n";
import type { HomeworkMakeRecord } from "@/lib/schemas";
import { shareHomework } from "@/lib/share";
@@ -54,6 +55,18 @@ export function HomeworkResult({
void shareHomework({ ...record, answer })}>
{t("deck.share")}
+
{t("homework.followup.redo")}
diff --git a/components/icons.tsx b/components/icons.tsx
index 02882a3..ca12837 100644
--- a/components/icons.tsx
+++ b/components/icons.tsx
@@ -316,6 +316,70 @@ export function AddToHomeIcon({ className }: IconProps) {
);
}
+/* --- Chat --------------------------------------------------------------- */
+
+export function ChatIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function SendIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function StopIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function CopyIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function PaperclipIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function PencilIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function HistoryIcon({ className }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
export function ChromeIcon({ className }: IconProps) {
return (
diff --git a/lib/chat-client.ts b/lib/chat-client.ts
new file mode 100644
index 0000000..cc2075a
--- /dev/null
+++ b/lib/chat-client.ts
@@ -0,0 +1,105 @@
+"use client";
+
+import { ApiError, consumeNdjson } from "./client-api";
+import { localeHeader, t } from "./i18n";
+import { authHeaders } from "./settings";
+import type { ChatSource } from "./chat";
+
+/**
+ * Client for the streaming chat route. The conversation window and the chosen
+ * context chunks travel with every request (the server never stores either);
+ * the API key only ever goes to our own route in its usual header.
+ */
+
+export type ChatStreamEvent =
+ | { type: "token"; text: string }
+ | { type: "thinking"; text: string }
+ | { type: "done"; model: string }
+ | { type: "error"; error: string; kind?: string };
+
+export type StreamChatInput = {
+ model?: string;
+ messages: { role: "user" | "assistant"; content: string }[];
+ context: (ChatSource & { text: string })[];
+ signal?: AbortSignal;
+ onToken: (text: string) => void;
+ onThinking: (text: string) => void;
+ onDone: (model: string) => void;
+};
+
+export async function streamChat(input: StreamChatInput): Promise {
+ let response: Response;
+ try {
+ response = await fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
+ body: JSON.stringify({
+ model: input.model || undefined,
+ messages: input.messages,
+ context: input.context,
+ }),
+ signal: input.signal,
+ });
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new ApiError(t("api.cancelled"), "aborted");
+ }
+ throw new ApiError(t("api.unreachable"), "network");
+ }
+
+ if (!response.ok) {
+ const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
+ throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
+ }
+
+ let streamError: ApiError | null = null;
+ try {
+ await consumeNdjson(response, (raw) => {
+ const event = raw as ChatStreamEvent;
+ if (event.type === "token") input.onToken(event.text);
+ else if (event.type === "thinking") input.onThinking(event.text);
+ else if (event.type === "done") input.onDone(event.model);
+ else if (event.type === "error") streamError = new ApiError(event.error, event.kind);
+ });
+ } catch (error) {
+ // Aborting while the body is being read rejects the reader with an
+ // AbortError, not a fetch rejection — treat it as a user stop.
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new ApiError(t("api.cancelled"), "aborted");
+ }
+ throw error;
+ }
+
+ if (streamError) throw streamError;
+}
+
+/**
+ * Turns attached photos (and any client-extracted document text) into plain
+ * text through the existing vision pipeline, so the chat model itself never
+ * needs to be multimodal.
+ */
+export async function extractChatAttachment(
+ input: { images?: string[]; text?: string },
+ options: { signal?: AbortSignal } = {},
+): Promise {
+ let response: Response;
+ try {
+ response = await fetch("/api/chat/extract", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
+ body: JSON.stringify(input),
+ signal: options.signal,
+ });
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new ApiError(t("api.cancelled"), "aborted");
+ }
+ throw new ApiError(t("api.unreachable"), "network");
+ }
+ const body = (await response.json().catch(() => null)) as { text?: string; error?: string; kind?: string } | null;
+ if (!response.ok) {
+ throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
+ }
+ if (!body?.text?.trim()) throw new ApiError(t("chat.attachEmpty"), "bad_response");
+ return body.text.trim();
+}
diff --git a/lib/chat-context.ts b/lib/chat-context.ts
new file mode 100644
index 0000000..bdba161
--- /dev/null
+++ b/lib/chat-context.ts
@@ -0,0 +1,258 @@
+"use client";
+
+import type { ChatMessage, ChatScope, ChatSource } from "./chat";
+import { estimateTokens } from "./chat-trim";
+import type { Deck } from "./schemas";
+
+/**
+ * Client-side, dependency-free retrieval over the user's own notes.
+ *
+ * The server stays stateless: the chat route never reads the notes itself.
+ * This module chunks the scoped decks (~800 tokens per chunk), ranks the
+ * chunks against the question plus the recent turns with a small BM25 scorer
+ * and returns the top chunks under a token budget, each labelled with its
+ * deck and section. Only what comes back from here is sent to the model.
+ */
+
+export type ContextChunk = ChatSource & { text: string; score: number };
+
+const CHUNK_CHARS = 3200; // ≈ 800 tokens
+const CHUNK_OVERLAP = 320;
+const DEFAULT_BUDGET_TOKENS = 7000;
+const MAX_CHUNKS = 24;
+const K1 = 1.2;
+const B = 0.75;
+
+export function deckHasNotes(deck: Deck): boolean {
+ return Boolean(deck.sourceText?.trim());
+}
+
+/**
+ * Older decks (or imported ones) may have no notes text at all. Their
+ * generated content is the fallback — the UI says so explicitly.
+ */
+export function generatedDeckText(deck: Deck): string {
+ const lines: string[] = [];
+ if (deck.title) lines.push(`Deck: ${deck.title}`);
+ if (deck.subject) lines.push(`Subject: ${deck.subject}`);
+ if (deck.summary) lines.push(`Summary: ${deck.summary}`);
+ const topics = new Set();
+ for (const activity of deck.activities) {
+ if (activity.topic) topics.add(activity.topic);
+ }
+ if (topics.size) lines.push(`Topics: ${[...topics].join(", ")}`);
+ lines.push("Generated study content:");
+ for (const activity of deck.activities) {
+ lines.push(...activityToLines(activity));
+ }
+ return lines.join("\n");
+}
+
+function activityToLines(activity: Deck["activities"][number]): string[] {
+ const out: string[] = [];
+ switch (activity.type) {
+ case "multiple_choice":
+ out.push(`- Q: ${activity.question} → ${activity.options[activity.correctIndex] ?? ""}`);
+ break;
+ case "true_false":
+ out.push(`- ${activity.statement} → ${activity.answer ? "true" : "false"}`);
+ break;
+ case "fill_blank":
+ out.push(`- ${activity.sentence} → ${activity.answer}`);
+ break;
+ case "matching":
+ out.push(`- ${activity.instruction}`);
+ for (const pair of activity.pairs) out.push(` · ${pair.term} → ${pair.definition}`);
+ break;
+ case "ordering":
+ out.push(`- ${activity.instruction}: ${activity.items.join(" → ")}`);
+ break;
+ case "short_answer":
+ out.push(`- Q: ${activity.question} → ${activity.referenceAnswer}`);
+ break;
+ case "flashcard":
+ out.push(`- ${activity.front} → ${activity.back}`);
+ break;
+ }
+ out.push(` (${activity.explanation})`);
+ return out;
+}
+
+/**
+ * Splits a deck's text into chunks. `### name` headings (added by the document
+ * extractor) become section labels; anything else is numbered.
+ */
+function chunkText(text: string): { section: string; text: string }[] {
+ const sections: { section: string; text: string }[] = [];
+ let currentSection = "Notes";
+ let current: string[] = [];
+ const flush = () => {
+ const joined = current.join("\n").trim();
+ if (joined) sections.push({ section: currentSection, text: joined });
+ current = [];
+ };
+ for (const line of text.split("\n")) {
+ const heading = /^#{1,4}\s+(.+)$/.exec(line.trim());
+ if (heading) {
+ flush();
+ currentSection = heading[1].trim().slice(0, 120) || "Notes";
+ continue;
+ }
+ current.push(line);
+ }
+ flush();
+
+ const chunks: { section: string; text: string }[] = [];
+ for (const section of sections) {
+ if (section.text.length <= CHUNK_CHARS) {
+ chunks.push(section);
+ continue;
+ }
+ let start = 0;
+ let part = 1;
+ while (start < section.text.length) {
+ const end = Math.min(section.text.length, start + CHUNK_CHARS);
+ const slice = section.text.slice(start, end).trim();
+ if (slice) {
+ chunks.push({
+ section: section.section === "Notes" ? `Part ${part}` : `${section.section} · ${part}`,
+ text: slice,
+ });
+ }
+ part += 1;
+ if (end >= section.text.length) break;
+ start = end - CHUNK_OVERLAP;
+ }
+ }
+ return chunks;
+}
+
+function tokenise(text: string): string[] {
+ return text
+ .toLowerCase()
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
+ .split(/\s+/)
+ .filter((word) => word.length > 2 && !STOPWORDS.has(word));
+}
+
+const STOPWORDS = new Set([
+ "the", "and", "for", "are", "was", "were", "with", "that", "this", "from",
+ "have", "has", "had", "not", "you", "your", "what", "when", "where", "which",
+ "how", "why", "who", "can", "could", "would", "should", "about", "into",
+ "than", "then", "them", "they", "there", "here", "does", "did", "doing",
+ "jak", "kdy", "kde", "kter", "která", "které", "který", "což", "podle",
+ "jako", "jsou", "byl", "byla", "bylo", "být", "mám", "máte", "může",
+ "proč", "proto", "tak", "také", "nebo", "ale", "ani", "už", "ještě",
+]);
+
+/**
+ * BM25 over the chunks. The query is the new question plus the last few turns,
+ * so follow-ups like "and why?" still retrieve the right material.
+ */
+function rank(chunks: { section: string; text: string }[], query: string): number[] {
+ const terms = [...new Set(tokenise(query))];
+ if (terms.length === 0) return chunks.map(() => 0);
+ const docs = chunks.map((chunk) => tokenise(chunk.text));
+ const lengths = docs.map((doc) => doc.length);
+ const avg = lengths.reduce((sum, value) => sum + value, 0) / Math.max(1, lengths.length);
+ const df = new Map();
+ for (const doc of docs) {
+ for (const term of new Set(doc)) df.set(term, (df.get(term) ?? 0) + 1);
+ }
+ const N = docs.length;
+ return docs.map((doc, index) => {
+ const counts = new Map();
+ for (const term of doc) counts.set(term, (counts.get(term) ?? 0) + 1);
+ let score = 0;
+ for (const term of terms) {
+ const freq = counts.get(term);
+ if (!freq) continue;
+ const docFreq = df.get(term) ?? 0;
+ const idf = Math.log(1 + (N - docFreq + 0.5) / (docFreq + 0.5));
+ const norm = K1 * (1 - B + (B * lengths[index]) / Math.max(1, avg));
+ score += (idf * (freq * (K1 + 1))) / (freq + norm);
+ }
+ return score;
+ });
+}
+
+export type ChatContextResult = {
+ chunks: ContextChunk[];
+ /** Decks in scope whose notes text is missing (fallback content was used). */
+ missingNotes: string[];
+ scopedDecks: Deck[];
+};
+
+/**
+ * Builds the context for one request: scoped decks → chunks → BM25 top-k under
+ * the token budget. `extraChunks` (e.g. a study result handed over from the
+ * results screen) are always kept and come first.
+ */
+export function buildChatContext(input: {
+ decks: Deck[];
+ scope: ChatScope;
+ question: string;
+ history: ChatMessage[];
+ useNotes: boolean;
+ extraChunks?: { deckId: string; deckTitle: string; section: string; text: string }[];
+ budgetTokens?: number;
+}): ChatContextResult {
+ const scopedDecks =
+ input.scope.kind === "none"
+ ? []
+ : input.scope.kind === "all"
+ ? input.decks.filter((deck) => deck.activities.length > 0)
+ : input.decks.filter((deck) => input.scope.kind === "decks" && input.scope.deckIds.includes(deck.id));
+
+ const budget = input.budgetTokens ?? DEFAULT_BUDGET_TOKENS;
+ const chunks: ContextChunk[] = [];
+ const missingNotes: string[] = [];
+ let used = 0;
+
+ const push = (chunk: ContextChunk) => {
+ const cost = estimateTokens(chunk.text);
+ if (used + cost > budget) return false;
+ chunks.push(chunk);
+ used += cost;
+ return true;
+ };
+
+ for (const extra of input.extraChunks ?? []) {
+ push({ ...extra, score: Number.POSITIVE_INFINITY });
+ }
+
+ const recent = input.history
+ .slice(-4)
+ .map((message) => message.content)
+ .join("\n");
+ const query = `${input.question}\n${recent}`;
+
+ for (const deck of scopedDecks) {
+ const hasNotes = deckHasNotes(deck);
+ if (!hasNotes) missingNotes.push(deck.id);
+ const text = hasNotes && input.useNotes ? deck.sourceText : generatedDeckText(deck);
+ const parts = chunkText(text);
+ const scores = rank(parts, query);
+ const ranked = parts
+ .map((part, index) => ({ part, score: scores[index] }))
+ .sort((a, b) => b.score - a.score);
+ const top = ranked.filter((entry) => entry.score > 0).slice(0, 6);
+ const ordered = top.length > 0 ? top : ranked.slice(0, 2);
+ for (const entry of ordered) {
+ if (chunks.length >= MAX_CHUNKS) break;
+ if (!push({
+ deckId: deck.id,
+ deckTitle: deck.title,
+ section: entry.part.section,
+ text: entry.part.text,
+ score: entry.score,
+ })) {
+ break;
+ }
+ }
+ }
+
+ return { chunks, missingNotes, scopedDecks };
+}
diff --git a/lib/chat-seed.ts b/lib/chat-seed.ts
new file mode 100644
index 0000000..a3b85e0
--- /dev/null
+++ b/lib/chat-seed.ts
@@ -0,0 +1,43 @@
+"use client";
+
+/**
+ * Hand-off from a study/homework result into the chat: the compact result
+ * summary and the deck it belongs to travel in sessionStorage and the chat
+ * picks them up on mount, pre-scoped. One-shot, like the deck seed.
+ */
+
+export type ChatSeed = {
+ /** Deck to scope the chat to (when the result belongs to one). */
+ deckId?: string;
+ /** Human label for the context banner ("Study results"). */
+ label: string;
+ /** Compact text sent as an extra context chunk with the first request. */
+ text: string;
+};
+
+const KEY = "freezmo.chat.seed.v1";
+
+export function setChatSeed(seed: ChatSeed): void {
+ try {
+ window.sessionStorage.setItem(KEY, JSON.stringify(seed));
+ } catch {
+ /* private mode */
+ }
+}
+
+export function takeChatSeed(): ChatSeed | null {
+ try {
+ const raw = window.sessionStorage.getItem(KEY);
+ if (!raw) return null;
+ window.sessionStorage.removeItem(KEY);
+ const parsed = JSON.parse(raw) as ChatSeed;
+ if (!parsed || typeof parsed.text !== "string" || !parsed.text.trim()) return null;
+ return {
+ deckId: typeof parsed.deckId === "string" ? parsed.deckId : undefined,
+ label: typeof parsed.label === "string" && parsed.label.trim() ? parsed.label : "Study result",
+ text: parsed.text.slice(0, 6000),
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/lib/chat-trim.ts b/lib/chat-trim.ts
new file mode 100644
index 0000000..8ac6c0f
--- /dev/null
+++ b/lib/chat-trim.ts
@@ -0,0 +1,30 @@
+/**
+ * Pure helpers shared by the chat route (server) and the chat UI (client):
+ * token budgeting and the sliding history window. No directive, no deps, so
+ * both sides can import it.
+ */
+
+/** Rough token estimate (≈4 chars per token) — good enough for budgeting. */
+export function estimateTokens(text: string): number {
+ return Math.ceil(text.length / 4);
+}
+
+/** How much conversation history the sliding window keeps (≈6k tokens). */
+export const HISTORY_TOKEN_BUDGET = 6000;
+
+/** Keeps the most recent turns that fit the budget (sliding window). */
+export function trimHistory(
+ messages: T[],
+ budget = HISTORY_TOKEN_BUDGET,
+): T[] {
+ const kept: T[] = [];
+ let used = 0;
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ const cost = estimateTokens(message.content);
+ if (kept.length > 0 && used + cost > budget) break;
+ kept.unshift(message);
+ used += cost;
+ }
+ return kept;
+}
diff --git a/lib/chat.ts b/lib/chat.ts
new file mode 100644
index 0000000..7c0f0a0
--- /dev/null
+++ b/lib/chat.ts
@@ -0,0 +1,83 @@
+"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}`;
+}
diff --git a/lib/client-api.ts b/lib/client-api.ts
index 2f14d3b..52c63df 100644
--- a/lib/client-api.ts
+++ b/lib/client-api.ts
@@ -49,7 +49,7 @@ export class ApiError extends Error {
* callback. Malformed lines are skipped (a half-written line must never break
* the stream), and the trailing buffer is flushed at the end.
*/
-async function consumeNdjson(
+export async function consumeNdjson(
response: Response,
handleEvent: (event: unknown) => void,
): Promise {
@@ -333,6 +333,12 @@ export type HealthModel = {
note?: string;
};
+export type HealthChatModel = {
+ name: string;
+ badge: "text" | "vision" | "both";
+ available: boolean | null;
+};
+
export type Health = {
ok: boolean;
reachable: boolean;
@@ -344,6 +350,8 @@ export type Health = {
hasApiKey: boolean;
vision: HealthModel;
text: HealthModel;
+ /** Chat model allowlist; optional so older servers/mocks stay valid. */
+ chatModels?: HealthChatModel[];
latencyMs: number | null;
modelCount: number | null;
error: string | null;
diff --git a/lib/config.ts b/lib/config.ts
index b3bcbce..6b5674f 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -17,6 +17,8 @@ export type ServerConfig = {
authMode: AuthMode;
visionModel: string;
textModel: string;
+ /** Allowlist for the chat model picker (CHAT_MODELS). Never client-supplied. */
+ chatModels: string[];
/** True when talking straight to https://ollama.com (needs an API key). */
directCloud: boolean;
timeoutMs: number;
@@ -33,6 +35,7 @@ export type ServerConfig = {
refine: number;
sync: number;
push: number;
+ chat: number;
};
sync: {
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
@@ -100,12 +103,31 @@ export function getServerConfig(): ServerConfig {
10,
);
+ const visionModel = process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud";
+ const textModel = process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud";
+
+ // Chat model allowlist: CHAT_MODELS wins, otherwise the configured text and
+ // vision models. Dedupe on the canonical name so `x` and `x:cloud` collapse.
+ const requestedChatModels = (process.env.CHAT_MODELS ?? "")
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ const chatModels: string[] = [];
+ const seenModels = new Set();
+ for (const entry of requestedChatModels.length > 0 ? requestedChatModels : [textModel, visionModel]) {
+ const canonical = canonicalModelName(entry);
+ if (seenModels.has(canonical)) continue;
+ seenModels.add(canonical);
+ chatModels.push(entry);
+ }
+
return {
baseUrl,
apiKey,
authMode,
- visionModel: process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud",
- textModel: process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud",
+ visionModel,
+ textModel,
+ chatModels,
directCloud,
timeoutMs: Number.parseInt(process.env.OLLAMA_TIMEOUT_MS ?? "", 10) || 300_000,
healthTimeoutMs:
@@ -125,6 +147,8 @@ export function getServerConfig(): ServerConfig {
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
// Push subscription changes and test sends.
push: positiveInt(process.env.RATE_LIMIT_PUSH, 30),
+ // Chat turns are one model call each, but users send them in bursts.
+ chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60),
},
sync: {
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"),
diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts
index a5c0d6b..b4a9c69 100644
--- a/lib/i18n-dict.ts
+++ b/lib/i18n-dict.ts
@@ -898,6 +898,87 @@ const en: Record = {
"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 = {
@@ -1737,6 +1818,87 @@ const cs: Record = {
"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. */
diff --git a/lib/ollama.ts b/lib/ollama.ts
index 35a2e19..31b4abd 100644
--- a/lib/ollama.ts
+++ b/lib/ollama.ts
@@ -168,45 +168,7 @@ export async function ollamaChat(options: ChatOptions): Promise {
if (!response.ok) {
const text = await response.text().catch(() => "");
- if (response.status === 401 || response.status === 403) {
- throw new OllamaError("auth", "Ollama refused the request (unauthorised).", {
- status: response.status,
- detail: text.slice(0, 500),
- });
- }
- if (response.status === 402) {
- throw new OllamaError(
- "no_credits",
- `Your Ollama account can't use "${model}": this model is not included in your plan. Add usage credits at ollama.com/settings, or switch to a free-tier model in .env.local.`,
- { status: response.status, detail: text.slice(0, 500) },
- );
- }
- if (response.status === 404) {
- throw new OllamaError(
- "model_missing",
- `Ollama could not find the model "${model}". Run \`ollama pull ${model}\` or pick another model in .env.local.`,
- { status: response.status, detail: text.slice(0, 500) },
- );
- }
- if (response.status === 429) {
- if (classifyOllamaFailure(response.status, text) === "usage_limit") {
- throw new OllamaError(
- "usage_limit",
- "Your free monthly Ollama usage has run out. Upgrade your Ollama account, or create a new one and use the new API key.",
- { status: response.status, detail: text.slice(0, 500) },
- );
- }
- throw new OllamaError(
- "throttled",
- "Ollama is temporarily rate limiting requests. Try again in a moment.",
- { status: response.status, detail: text.slice(0, 500) },
- );
- }
- throw new OllamaError(
- "http",
- `Ollama responded with HTTP ${response.status}.`,
- { status: response.status, detail: text.slice(0, 500) },
- );
+ throwForStatus(response.status, text, model);
}
let payload: unknown;
@@ -226,6 +188,174 @@ export async function ollamaChat(options: ChatOptions): Promise {
return { content, model, raw: payload };
}
+/**
+ * Maps a non-2xx Ollama response to the right `OllamaError`. Shared by the
+ * buffered and streaming chat calls so both classify 429s identically.
+ */
+function throwForStatus(status: number, text: string, model: string): never {
+ if (status === 401 || status === 403) {
+ throw new OllamaError("auth", "Ollama refused the request (unauthorised).", {
+ status,
+ detail: text.slice(0, 500),
+ });
+ }
+ if (status === 402) {
+ throw new OllamaError(
+ "no_credits",
+ `Your Ollama account can't use "${model}": this model is not included in your plan. Add usage credits at ollama.com/settings, or switch to a free-tier model in .env.local.`,
+ { status, detail: text.slice(0, 500) },
+ );
+ }
+ if (status === 404) {
+ throw new OllamaError(
+ "model_missing",
+ `Ollama could not find the model "${model}". Run \`ollama pull ${model}\` or pick another model in .env.local.`,
+ { status, detail: text.slice(0, 500) },
+ );
+ }
+ if (status === 429) {
+ if (classifyOllamaFailure(status, text) === "usage_limit") {
+ throw new OllamaError(
+ "usage_limit",
+ "Your free monthly Ollama usage has run out. Upgrade your Ollama account, or create a new one and use the new API key.",
+ { status, detail: text.slice(0, 500) },
+ );
+ }
+ throw new OllamaError(
+ "throttled",
+ "Ollama is temporarily rate limiting requests. Try again in a moment.",
+ { status, detail: text.slice(0, 500) },
+ );
+ }
+ throw new OllamaError("http", `Ollama responded with HTTP ${status}.`, {
+ status,
+ detail: text.slice(0, 500),
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Streaming chat */
+/* ------------------------------------------------------------------ */
+
+export type ChatStreamResult = {
+ content: string;
+ thinking: string;
+ model: string;
+};
+
+type StreamOptions = ChatOptions & {
+ onToken?: (text: string) => void;
+ onThinking?: (text: string) => void;
+};
+
+/**
+ * Streaming counterpart of `ollamaChat`: POSTs with `stream: true` and hands
+ * each delta to the callbacks. Reasoning models deliver `message.thinking`
+ * separately — it is returned apart from the answer and never mixed in.
+ */
+export async function ollamaChatStream(options: StreamOptions): Promise {
+ const config = getServerConfig();
+ const { baseUrl, apiKey, directCloud } = options.auth;
+ const model = resolveModelName(options.model, directCloud);
+
+ const headers: Record = { "Content-Type": "application/json" };
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
+
+ const body: Record = {
+ model,
+ messages: options.messages,
+ stream: true,
+ };
+ if (typeof options.temperature === "number") {
+ body.options = { temperature: options.temperature };
+ }
+ if (config.think !== null) body.think = config.think;
+
+ let response: Response;
+ try {
+ response = await fetchWithTimeout(
+ `${baseUrl}/api/chat`,
+ { method: "POST", headers, body: JSON.stringify(body), cache: "no-store" },
+ options.timeoutMs ?? config.timeoutMs,
+ options.signal,
+ );
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new OllamaError("timeout", "The request to Ollama timed out.");
+ }
+ throw new OllamaError("unreachable", `Could not connect to Ollama at ${baseUrl}.`, {
+ detail: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throwForStatus(response.status, text, model);
+ }
+ if (!response.body) {
+ throw new OllamaError("bad_response", "Ollama sent an empty stream.");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let content = "";
+ let thinking = "";
+
+ const handleLine = (line: string) => {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+ let event: unknown;
+ try {
+ event = JSON.parse(trimmed);
+ } catch {
+ return; // a half-written line must never break the stream
+ }
+ if (!event || typeof event !== "object") return;
+ const record = event as Record;
+ if (typeof record.error === "string" && record.error) {
+ throw new OllamaError("http", record.error.slice(0, 300));
+ }
+ const message = record.message as Record | undefined;
+ const delta = typeof message?.content === "string" ? message.content : "";
+ const reason = typeof message?.thinking === "string" ? message.thinking : "";
+ if (reason) {
+ thinking += reason;
+ options.onThinking?.(reason);
+ }
+ if (delta) {
+ content += delta;
+ options.onToken?.(delta);
+ }
+ };
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ let newline = buffer.indexOf("\n");
+ while (newline !== -1) {
+ handleLine(buffer.slice(0, newline));
+ buffer = buffer.slice(newline + 1);
+ newline = buffer.indexOf("\n");
+ }
+ }
+ handleLine(buffer);
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new OllamaError("timeout", "The request to Ollama timed out.");
+ }
+ throw error;
+ }
+
+ if (!content.trim()) {
+ throw new OllamaError("bad_response", "Ollama finished the stream without any answer text.");
+ }
+
+ return { content, thinking, model };
+}
+
function extractContent(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record;
diff --git a/lib/prompts.ts b/lib/prompts.ts
index 4a32065..d9dda68 100644
--- a/lib/prompts.ts
+++ b/lib/prompts.ts
@@ -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 */
/* ------------------------------------------------------------------ */
diff --git a/lib/settings.ts b/lib/settings.ts
index 3bcc1c0..7260c7d 100644
--- a/lib/settings.ts
+++ b/lib/settings.ts
@@ -24,11 +24,17 @@ export type BrowserSettings = {
haptics: boolean;
/** How many answers count as a finished day in the Library/stats. */
dailyGoal: number;
+ /**
+ * Whether chat may ground its answers in the deck's stored notes text
+ * (`sourceText`). On by default; off means chat only sees the generated
+ * study content.
+ */
+ chatNotes: boolean;
};
const DEFAULT_GOAL = 20;
-const EMPTY: BrowserSettings = { apiKey: "", haptics: true, dailyGoal: DEFAULT_GOAL };
+const EMPTY: BrowserSettings = { apiKey: "", haptics: true, dailyGoal: DEFAULT_GOAL, chatNotes: true };
let cached: BrowserSettings | null = null;
const listeners = new Set<() => void>();
@@ -44,6 +50,7 @@ function read(): BrowserSettings {
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "",
haptics: parsed.haptics !== false,
dailyGoal: Number.isFinite(goal) ? Math.min(500, Math.max(1, goal)) : DEFAULT_GOAL,
+ chatNotes: parsed.chatNotes !== false,
};
} catch {
return EMPTY;
@@ -53,10 +60,10 @@ function read(): BrowserSettings {
function write(next: BrowserSettings): void {
cached = next;
try {
- if (next.apiKey || next.haptics === false || next.dailyGoal !== DEFAULT_GOAL) {
+ if (next.apiKey || next.haptics === false || next.dailyGoal !== DEFAULT_GOAL || next.chatNotes === false) {
window.localStorage.setItem(
STORAGE_KEY,
- JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics, dailyGoal: next.dailyGoal }),
+ JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics, dailyGoal: next.dailyGoal, chatNotes: next.chatNotes }),
);
} else {
window.localStorage.removeItem(STORAGE_KEY);
diff --git a/lib/storage.ts b/lib/storage.ts
index e2169c4..a96d419 100644
--- a/lib/storage.ts
+++ b/lib/storage.ts
@@ -123,6 +123,20 @@ export function deleteDeck(id: string): void {
write(deckStore.getSnapshot().filter((deck) => deck.id !== id));
}
+/**
+ * Removes the stored notes text from a deck (the deck and its activities stay).
+ * Used by "Delete notes text" — the notes live in the deck record because
+ * Regenerate needs them; chat reads them from here too.
+ */
+export function deleteNotesText(id: string): void {
+ ensureLoaded();
+ write(
+ deckStore.getSnapshot().map((deck) =>
+ deck.id === id ? { ...deck, sourceText: "", updatedAt: Date.now() } : deck,
+ ),
+ );
+}
+
export function recordAttempt(
id: string,
result: { scorePercent: number; correct: number; total: number },
diff --git a/package-lock.json b/package-lock.json
index 9acb571..478f492 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"canvas-confetti": "^1.9.4",
"framer-motion": "^12.23.24",
"jszip": "^3.10.2",
+ "katex": "^0.19.0",
"mammoth": "^1.13.0",
"next": "15.5.27",
"pdfjs-dist": "^6.4.299",
@@ -2914,6 +2915,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/commander": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4881,6 +4891,22 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/katex": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz",
+ "integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^15.0.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
diff --git a/package.json b/package.json
index 34a5047..75f9a7a 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"canvas-confetti": "^1.9.4",
"framer-motion": "^12.23.24",
"jszip": "^3.10.2",
+ "katex": "^0.19.0",
"mammoth": "^1.13.0",
"next": "15.5.27",
"pdfjs-dist": "^6.4.299",
diff --git a/public/sw.js b/public/sw.js
index 1b29377..4b11395 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
-const CACHE_VERSION = "freezmo-v31";
+const CACHE_VERSION = "freezmo-v32";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
diff --git a/tests/README.md b/tests/README.md
index af0b300..f4151bd 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -37,6 +37,7 @@ prints a per-suite summary at the end.
| `phase1-test.mjs` | Backup export/import, persistence row, nudge, encrypted sync (enable/join/push/delete + API validation), usage-limit handling (client + a stub Ollama). |
| `phase2-test.mjs` | Answer history + SRS from a real study run, review/mistakes, stats, exam mode, push reminders hidden without VAPID and validated with it. |
| `phase3-test.mjs` | Check my work (API + phone/desktop UI), follow-ups, guide step explain, turn-into-a-study-deck. |
+| `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion. |
## Extra instances
diff --git a/tests/chat-test.mjs b/tests/chat-test.mjs
new file mode 100644
index 0000000..35619dd
--- /dev/null
+++ b/tests/chat-test.mjs
@@ -0,0 +1,423 @@
+/**
+ * Chat mode (phase 1): the streaming API (validation, allowlist, context
+ * travel, history window, thinking separation, usage-limit mapping), the
+ * attachment extraction route, and the phone/desktop UI (entry points, scope,
+ * streaming, stop, model picker, context picker, edit/regenerate, markdown,
+ * math, sources, offline, reduced motion).
+ *
+ * Runs against a stub Ollama, so no real model is needed.
+ */
+import http from "node:http";
+
+import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
+
+const { check, finish } = makeChecker();
+const browser = await launchBrowser();
+
+const STUB_PORT = 11997;
+const APP_PORT = 3212;
+const APP = `http://127.0.0.1:${APP_PORT}`;
+
+/* ------------------------------------------------------------------ */
+/* Stub Ollama */
+/* ------------------------------------------------------------------ */
+
+let mode = "ok"; // ok | thinking | slow | limit
+let lastChat = null;
+
+const ANSWER = [
+ "**Rayleigh** scattering makes the sky blue.",
+ "",
+ "- short wavelengths scatter more",
+ "- violet is mostly absorbed",
+ "",
+ "| Layer | Effect |",
+ "| --- | --- |",
+ "| Troposphere | blue |",
+ "",
+ "```js",
+ 'const sky = "blue";',
+ "```",
+ "",
+ "Mass-energy: $E = mc^2$ done.",
+].join("\n");
+
+const stub = http.createServer((request, response) => {
+ if (request.url?.startsWith("/api/tags")) {
+ response.writeHead(200, { "Content-Type": "application/json" });
+ response.end(JSON.stringify({ models: [{ name: "stub-text" }, { name: "stub-vision" }] }));
+ return;
+ }
+ let raw = "";
+ request.on("data", (chunk) => (raw += chunk));
+ request.on("end", () => {
+ let body = {};
+ try {
+ body = JSON.parse(raw || "{}");
+ } catch {
+ /* ignore */
+ }
+ if (request.url?.startsWith("/api/chat")) {
+ const system = String(body.messages?.[0]?.content ?? "");
+ // Vision/extraction step (JSON mode).
+ if (body.format === "json" || system.includes("transcriber")) {
+ response.writeHead(200, { "Content-Type": "application/json" });
+ response.end(
+ JSON.stringify({
+ message: {
+ content: JSON.stringify({
+ title: "Photo notes",
+ subject: "Biology",
+ language: "English",
+ notes: "EXTRACTED-PHOTO-MARKER: photosynthesis needs light and water.",
+ keyConcepts: ["Photosynthesis"],
+ }),
+ },
+ }),
+ );
+ return;
+ }
+
+ lastChat = body;
+ if (mode === "limit") {
+ response.writeHead(429, { "Content-Type": "application/json" });
+ response.end(JSON.stringify({ error: "You have reached your monthly usage limit. Upgrade your plan." }));
+ return;
+ }
+
+ response.writeHead(200, { "Content-Type": "application/x-ndjson" });
+ const chunks = mode === "slow" ? ["Slow ", "answer ", "still ", "coming"] : [ANSWER.slice(0, 24), ANSWER.slice(24, 80), ANSWER.slice(80)];
+ let index = 0;
+ const writeChunk = () => {
+ response.write(JSON.stringify({ message: { content: chunks[index] }, done: false }) + "\n");
+ index += 1;
+ };
+ // Slow mode: the first chunk lands at once so a stop test has content.
+ if (mode === "slow") writeChunk();
+ const timer = setInterval(
+ () => {
+ if (index < chunks.length) {
+ if (index === 0 && mode === "thinking") {
+ response.write(JSON.stringify({ message: { thinking: "REASONING-MARKER let me think…" }, done: false }) + "\n");
+ }
+ writeChunk();
+ } else {
+ response.write(JSON.stringify({ message: { content: "" }, done: true }) + "\n");
+ response.end();
+ clearInterval(timer);
+ }
+ },
+ mode === "slow" ? 700 : 30,
+ );
+ // `res` close fires when the client aborts or the response ends; `req`
+ // close would fire immediately after the request body.
+ response.on("close", () => clearInterval(timer));
+ return;
+ }
+ response.writeHead(404);
+ response.end();
+ });
+});
+await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
+
+check("stub app port is free", !(await portInUse(APP_PORT)));
+const app = spawnApp(APP_PORT, {
+ OLLAMA_BASE_URL: `http://127.0.0.1:${STUB_PORT}`,
+ OLLAMA_AUTH: "local",
+ OLLAMA_VERIFY_KEY: "false",
+ CHAT_MODELS: "stub-text,stub-vision",
+});
+
+const DECK_NOTES = {
+ ...DECK,
+ sourceText: "FOTOSYNTEZA-MARKER: plants convert light, water and CO2 into sugar. Chlorophyll absorbs red and blue light.",
+};
+
+async function chatPost(payload) {
+ const response = await fetch(`${APP}/api/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ const events = response.headers.get("content-type")?.includes("ndjson")
+ ? (await response.text())
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line))
+ : null;
+ const body = events ? null : await response.json().catch(() => null);
+ return { status: response.status, events, body };
+}
+
+try {
+ const up = await waitForHttp(`${APP}/api/health`);
+ check("stub app instance is up", up);
+
+ if (up) {
+ /* 1. API: validation + allowlist ---------------------------------- */
+ const badModel = await chatPost({ model: "evil-model", messages: [{ role: "user", content: "hi" }] });
+ check("api: model outside the allowlist → 400 bad_model", badModel.status === 400 && badModel.body?.kind === "bad_model", JSON.stringify(badModel.body));
+
+ const empty = await chatPost({ messages: [] });
+ check("api: empty messages → 400", empty.status === 400);
+
+ const wrongLast = await chatPost({ messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] });
+ check("api: last message must be from the user → 400", wrongLast.status === 400);
+
+ /* 2. API: streaming + context travel + thinking -------------------- */
+ const ok = await chatPost({
+ messages: [{ role: "user", content: "Why is the sky blue?" }],
+ context: [{ deckId: "seed_deck", deckTitle: "Sky deck", section: "Notes", text: "RAYLEIGH-CONTEXT-MARKER scattering notes." }],
+ });
+ const tokens = (ok.events ?? []).filter((event) => event.type === "token").map((event) => event.text).join("");
+ const done = (ok.events ?? []).find((event) => event.type === "done");
+ check("api: streams tokens and a done event", ok.status === 200 && tokens.includes("Rayleigh") && done?.model === "stub-text", done?.model);
+ check("api: thinking never mixes into the answer", !tokens.includes("REASONING-MARKER"));
+ check(
+ "api: system prompt carries the chosen context + the rules",
+ lastChat?.messages?.[0]?.content?.includes("RAYLEIGH-CONTEXT-MARKER") &&
+ lastChat?.messages?.[0]?.content?.includes("primary source"),
+ );
+ check("api: the conversation window follows the system prompt", lastChat?.messages?.[1]?.role === "user");
+
+ mode = "thinking";
+ const thinking = await chatPost({ messages: [{ role: "user", content: "Think first" }] });
+ const thinkingEvents = (thinking.events ?? []).filter((event) => event.type === "thinking").map((event) => event.text).join("");
+ const thinkingTokens = (thinking.events ?? []).filter((event) => event.type === "token").map((event) => event.text).join("");
+ check("api: reasoning streams separately", thinkingEvents.includes("REASONING-MARKER") && !thinkingTokens.includes("REASONING-MARKER"));
+ mode = "ok";
+
+ /* 3. API: history window + context cap ----------------------------- */
+ const longHistory = Array.from({ length: 60 }, (_, index) => ({
+ role: index % 2 === 0 ? "user" : "assistant",
+ content: `Message ${index}: ${"x".repeat(600)}`,
+ }));
+ longHistory[59] = { role: "user", content: `Last question ${"y".repeat(600)}` };
+ await chatPost({ messages: longHistory });
+ const sentCount = lastChat?.messages?.length ?? 0;
+ check("api: sliding window trims the history", sentCount > 1 && sentCount < 61, `${sentCount} messages reached the model`);
+
+ const manyChunks = Array.from({ length: 40 }, (_, index) => ({
+ deckId: `deck_${index}`,
+ deckTitle: `Deck ${index}`,
+ section: "Notes",
+ text: `CHUNK-${index} ${"z".repeat(11_500)}`,
+ }));
+ await chatPost({ messages: [{ role: "user", content: "question" }], context: manyChunks });
+ const systemLength = String(lastChat?.messages?.[0]?.content ?? "").length;
+ check("api: context is capped well below what was sent", systemLength > 1000 && systemLength < 60_000, `${systemLength} chars`);
+ check("api: the first (best) chunk survives the cap", String(lastChat?.messages?.[0]?.content ?? "").includes("CHUNK-0"));
+
+ /* 4. API: general chat + usage limit ------------------------------- */
+ const general = await chatPost({ messages: [{ role: "user", content: "hello" }], context: [] });
+ check("api: no context → general-chat system prompt", String(lastChat?.messages?.[0]?.content ?? "").includes("No notes are attached") && general.status === 200);
+
+ mode = "limit";
+ const limited = await chatPost({ messages: [{ role: "user", content: "hi" }] });
+ const limitEvent = (limited.events ?? []).find((event) => event.type === "error");
+ check("api: monthly usage 429 → usage_limit error event", limitEvent?.kind === "usage_limit", JSON.stringify(limitEvent).slice(0, 90));
+ mode = "ok";
+
+ /* 5. API: attachment extraction ------------------------------------ */
+ const extracted = await fetch(`${APP}/api/chat/extract`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ images: ["data:image/png;base64," + "A".repeat(64)] }),
+ });
+ const extractedBody = await extracted.json();
+ check("extract: photo → vision text", extracted.status === 200 && extractedBody.text?.includes("EXTRACTED-PHOTO-MARKER"));
+
+ const plain = await fetch(`${APP}/api/chat/extract`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "pasted text stays as is" }),
+ });
+ const plainBody = await plain.json();
+ check("extract: plain text needs no model call", plain.status === 200 && plainBody.text === "pasted text stays as is");
+
+ const emptyExtract = await fetch(`${APP}/api/chat/extract`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ check("extract: nothing attached → 400", emptyExtract.status === 400);
+
+ /* 6. UI: entry points ---------------------------------------------- */
+ const desktop = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
+ await seed(desktop.page, { decks: [DECK_NOTES] });
+ await desktop.page.goto(APP, { waitUntil: "networkidle" });
+ await desktop.page.waitForTimeout(900);
+ const askLink = desktop.page.getByRole("link", { name: "Ask", exact: true });
+ check("desktop: Library has an Ask link", (await askLink.count()) === 1 && (await askLink.getAttribute("href")) === "/chat");
+ check("desktop: sidebar has a Chat row", (await desktop.page.locator('aside a[href="/chat"]').count()) === 1);
+ await askLink.click();
+ await desktop.page.waitForTimeout(1200);
+ check("desktop: chat route opens with the sidebar", desktop.page.url().endsWith("/chat") && (await desktop.page.locator("aside").count()) >= 1);
+ check("desktop: no tab bar on chat", (await desktop.page.locator("nav.tabbar").count()) === 0);
+ await desktop.context.close();
+
+ const phone = await makePage(browser);
+ await seed(phone.page, { decks: [DECK_NOTES] });
+ await phone.page.goto(APP, { waitUntil: "networkidle" });
+ await phone.page.waitForTimeout(900);
+ const askButton = phone.page.getByRole("link", { name: "Ask", exact: true });
+ check("phone: Library has an Ask button", (await askButton.count()) === 1 && (await askButton.getAttribute("href")) === "/chat");
+ await phone.page.getByRole("button", { name: "Deck actions" }).click();
+ await phone.page.waitForTimeout(300);
+ const askDeck = phone.page.getByRole("link", { name: "Ask about this deck" });
+ check("phone: deck menu has Ask about this deck", (await askDeck.count()) === 1 && (await askDeck.getAttribute("href"))?.includes("/chat?deck=seed_deck"));
+ await askDeck.click();
+ await phone.page.waitForTimeout(1400);
+ check("phone: chat opens scoped to the deck", phone.page.url().includes("deck=seed_deck") && (await phone.page.getByText("1 deck").count()) >= 1);
+ check("phone: no tab bar on chat", (await phone.page.locator("nav.tabbar").count()) === 0);
+ check("phone: back button present", (await phone.page.getByRole("button", { name: "Back" }).count()) === 1);
+
+ /* 7. UI: streaming + markdown + math + sources ---------------------- */
+ check("chat: empty state with suggestions", await phone.page.getByText("Ask your notes anything").isVisible());
+ check("chat: deck suggestion present", (await phone.page.getByRole("button", { name: /Summarize my notes/ }).count()) === 1);
+ const context = phone.page.locator(".chat-md").first();
+ await phone.page.getByRole("button", { name: /Summarize my notes/ }).click();
+ await phone.page.waitForTimeout(2500);
+ check("chat: user bubble appears", (await phone.page.getByText("Summarize my notes").count()) >= 1);
+ check("chat: assistant answer streams in", await context.getByText("scattering makes the sky blue").isVisible());
+ check("chat: markdown bold + list render", (await context.locator("strong").count()) >= 1 && (await context.locator("li").count()) >= 2);
+ check("chat: table renders", (await context.locator("table").count()) === 1);
+ check(
+ "chat: code block + copy button",
+ (await context.locator("pre").count()) === 1 && (await context.getByRole("button", { name: "Copy" }).count()) >= 1,
+ );
+ check("chat: math renders through KaTeX", (await phone.page.locator(".katex").count()) >= 1);
+ check("chat: sources chip links to the deck", (await phone.page.locator('a[href="/deck/seed_deck"]').count()) >= 1);
+ check("chat: context reached the model", String(lastChat?.messages?.[0]?.content ?? "").includes("FOTOSYNTEZA-MARKER"));
+
+ /* 8. UI: model picker + context picker ------------------------------ */
+ await phone.page.getByRole("button", { name: "stub-text" }).click();
+ await phone.page.waitForTimeout(400);
+ check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1);
+ await phone.page.getByRole("button", { name: /stub-vision/ }).click();
+ await phone.page.waitForTimeout(400);
+ check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
+ check(
+ "model: choice is remembered",
+ (await phone.page.evaluate(() => localStorage.getItem("freezmo.chat.model.v1"))) === "stub-vision",
+ );
+ await phone.page.reload({ waitUntil: "networkidle" });
+ await phone.page.waitForTimeout(1200);
+ check("model: remembered across reloads", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
+
+ await phone.page.getByRole("button", { name: "1 deck" }).click();
+ await phone.page.waitForTimeout(400);
+ check("scope: picker opens with the modes", (await phone.page.getByText("All notes").count()) >= 1 && (await phone.page.getByText("No notes").count()) >= 1);
+ await phone.page.getByRole("button", { name: /No notes/ }).first().click();
+ await phone.page.waitForTimeout(400);
+ check("scope: chip follows the choice", (await phone.page.getByRole("button", { name: "No notes" }).count()) === 1);
+
+ /* 9. UI: thinking row ----------------------------------------------- */
+ mode = "thinking";
+ await phone.page.getByRole("textbox").fill("Think about it");
+ await phone.page.getByRole("button", { name: "Send", exact: true }).click();
+ await phone.page.waitForTimeout(2500);
+ const thinkingRow = phone.page.getByRole("button", { name: /Thinking/ }).last();
+ check("thinking: row appears", (await thinkingRow.count()) >= 1);
+ check("thinking: reasoning hidden by default", (await phone.page.getByText("REASONING-MARKER").count()) === 0);
+ await thinkingRow.click();
+ await phone.page.waitForTimeout(300);
+ check("thinking: expanding reveals it", (await phone.page.getByText(/REASONING-MARKER/).count()) >= 1);
+ mode = "ok";
+
+ /* 10. UI: stop mid-stream ------------------------------------------- */
+ mode = "slow";
+ await phone.page.getByRole("textbox").fill("Slow one");
+ await phone.page.getByRole("button", { name: "Send", exact: true }).click();
+ await phone.page.waitForTimeout(1700);
+ check("stop: stop button appears while streaming", (await phone.page.getByRole("button", { name: "Stop" }).count()) === 1);
+ await phone.page.getByRole("button", { name: "Stop" }).click();
+ await phone.page.waitForTimeout(700);
+ check("stop: send button returns", (await phone.page.getByRole("button", { name: "Send", exact: true }).count()) === 1);
+ const partial = await phone.page.locator(".chat-md").last().innerText().catch(() => "");
+ // Next's route announcer is also role=alert — only real error cards count.
+ const alerts = await phone.page.locator('[role="alert"]:not(#__next-route-announcer__)').count();
+ check("stop: partial answer kept, no error card", partial.includes("Slow") && alerts === 0, JSON.stringify(partial.slice(0, 30)));
+ mode = "ok";
+
+ /* 11. UI: regenerate + edit ----------------------------------------- */
+ const lastAssistantActions = phone.page.locator("div").filter({ has: phone.page.getByRole("button", { name: "Regenerate" }) });
+ check("actions: regenerate offered on the last answer", (await phone.page.getByRole("button", { name: "Regenerate" }).count()) >= 1);
+ const before = await phone.page.locator(".chat-md").count();
+ await phone.page.getByRole("button", { name: "Regenerate" }).last().click();
+ await phone.page.waitForTimeout(2200);
+ check("actions: regenerate streams a fresh answer", (await phone.page.locator(".chat-md").count()) === before);
+ void lastAssistantActions;
+ check("actions: edit offered on the last user message", (await phone.page.getByRole("button", { name: "Edit and resend" }).count()) === 1);
+ await phone.page.getByRole("button", { name: "Edit and resend" }).click();
+ await phone.page.waitForTimeout(400);
+ check("actions: edit restores the text into the composer", (await phone.page.getByRole("textbox").inputValue()) === "Slow one");
+
+ /* 12. UI: usage limit + offline ------------------------------------- */
+ mode = "limit";
+ await phone.page.getByRole("textbox").fill("Will hit the limit");
+ await phone.page.getByRole("button", { name: "Send", exact: true }).click();
+ await phone.page.waitForTimeout(2000);
+ check("limit: error card shown", (await phone.page.getByRole("alert").count()) >= 1 && (await phone.page.getByText(/free monthly Ollama usage/i).count()) >= 1);
+ check("limit: Settings + Ollama actions offered", (await phone.page.getByRole("button", { name: /Open settings/ }).count()) >= 1 && (await phone.page.getByRole("button", { name: /Ollama/ }).count()) >= 1);
+ check("limit: the draft is kept for a retry", (await phone.page.getByRole("textbox").inputValue()) === "Will hit the limit");
+ mode = "ok";
+
+ await phone.context.setOffline(true);
+ await phone.page.waitForTimeout(500);
+ check("offline: send disabled with a reason", (await phone.page.getByRole("button", { name: "Send", exact: true }).isDisabled()) && (await phone.page.getByText(/Offline — sending is paused/).count()) === 1);
+ await phone.context.setOffline(false);
+ await phone.page.waitForTimeout(400);
+
+ /* 13. UI: attachment ------------------------------------------------- */
+ await phone.page.getByRole("button", { name: "Attach photos or documents" }).click();
+ await phone.page.locator('input[type="file"]').setInputFiles({
+ name: "notes.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ "base64",
+ ),
+ });
+ await phone.page.waitForTimeout(1800);
+ check("attach: chip appears after extraction", (await phone.page.getByText("notes.png").count()) >= 1);
+ await phone.page.getByRole("textbox").fill("What does the photo say?");
+ await phone.page.getByRole("button", { name: "Send", exact: true }).click();
+ await phone.page.waitForTimeout(2200);
+ check("attach: extracted text travels as context", String(lastChat?.messages?.[0]?.content ?? "").includes("EXTRACTED-PHOTO-MARKER"));
+
+ /* 14. UI: new chat + old-decks note --------------------------------- */
+ await phone.page.getByRole("button", { name: "New chat" }).click();
+ await phone.page.waitForTimeout(500);
+ check("new chat: clears the conversation", (await phone.page.locator(".chat-md").count()) === 0 && (await phone.page.getByText("Ask your notes anything").count()) === 1);
+
+ const oldDeck = { ...DECK, id: "old_deck", title: "Imported deck", sourceText: "" };
+ const oldPage = await makePage(browser);
+ await seed(oldPage.page, { decks: [oldDeck] });
+ await oldPage.page.goto(`${APP}/chat?deck=old_deck`, { waitUntil: "networkidle" });
+ await oldPage.page.waitForTimeout(1200);
+ check("old decks: the fallback notice is shown", (await oldPage.page.getByText(/Older decks only have the generated study content/).count()) === 1);
+ check("old decks: scope chip scoped to it", (await oldPage.page.getByText("1 deck").count()) >= 1);
+ await oldPage.context.close();
+
+ /* 15. reduced motion still works ------------------------------------ */
+ const reduced = await makePage(browser, { reducedMotion: "reduce" });
+ await seed(reduced.page, { decks: [DECK_NOTES] });
+ await reduced.page.goto(`${APP}/chat`, { waitUntil: "networkidle" });
+ await reduced.page.waitForTimeout(1000);
+ await reduced.page.getByRole("textbox").fill("Reduced motion question");
+ await reduced.page.getByRole("button", { name: "Send", exact: true }).click();
+ await reduced.page.waitForTimeout(2500);
+ check("reduced motion: chat still answers", (await reduced.page.locator(".chat-md").count()) >= 1);
+ await reduced.context.close();
+
+ await phone.context.close();
+ }
+} finally {
+ app.stop();
+ stub.close();
+ await browser.close();
+}
+
+finish();
diff --git a/tests/model-compare.mjs b/tests/model-compare.mjs
new file mode 100644
index 0000000..875dfd7
--- /dev/null
+++ b/tests/model-compare.mjs
@@ -0,0 +1,141 @@
+/**
+ * Model A/B benchmark (diagnostic, not part of `npm run test:e2e`).
+ *
+ * Answers "is model X a better fit than model Y for Freezmo's strict-JSON
+ * pipeline?" with numbers: it runs the same notes through two throwaway
+ * production instances (one per model), times them and reports how often the
+ * model needed the JSON repair round.
+ *
+ * OLLAMA_API_KEY=... node tests/model-compare.mjs
+ * OLLAMA_API_KEY=... node tests/model-compare.mjs gemma4:31b-cloud gpt-oss:120b-cloud
+ *
+ * The key travels only to the spawned local instances (server auth mode) and
+ * is never printed, stored or committed. Uses your Ollama quota: 2 generate
+ * calls + 1 grade call per model.
+ */
+import { spawnApp, waitForHttp, portInUse } from "./lib.mjs";
+
+const MODELS = process.argv.slice(2);
+const [MODEL_A, MODEL_B] = MODELS.length >= 2 ? MODELS : ["gpt-oss:120b-cloud", "gemma4:31b-cloud"];
+const API_KEY = process.env.OLLAMA_API_KEY?.trim();
+const BASE_URL = process.env.OLLAMA_BASE_URL?.trim() || "https://ollama.com";
+
+if (!API_KEY) {
+ console.error("Set OLLAMA_API_KEY (your Ollama key) for this command — it is never printed.");
+ process.exit(1);
+}
+
+const NOTES = `Rayleigh scattering
+Light from the sun hits molecules in the atmosphere. Short wavelengths (blue and violet) scatter much more strongly than long ones (red). This is why a clear daytime sky is blue.
+Sunsets
+At sunset the light travels through much more atmosphere, so almost all the blue light is scattered away and only the red end of the spectrum reaches our eyes.
+Why not violet?
+Violet scatters even more than blue, but our eyes are far less sensitive to violet, and some of it is absorbed in the upper atmosphere.
+Interesting: the sky on the Moon is black because there is no atmosphere to scatter light.`;
+
+const GRADE_INPUT = {
+ question: "Why is a clear daytime sky blue?",
+ referenceAnswer: "Short wavelengths scatter more strongly than long ones.",
+ keyPoints: ["Rayleigh scattering", "short wavelengths scatter more", "molecules in the atmosphere"],
+ userAnswer: "Because blue light scatters more than red in the atmosphere.",
+};
+
+async function timed(label, run) {
+ const started = Date.now();
+ try {
+ const value = await run();
+ return { label, ms: Date.now() - started, value };
+ } catch (error) {
+ return { label, ms: Date.now() - started, error: error instanceof Error ? error.message : String(error) };
+ }
+}
+
+async function generateOnce(port, index) {
+ const response = await fetch(`http://127.0.0.1:${port}/api/generate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: NOTES, difficulty: "medium", count: 8 }),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 120)}`);
+ const events = (await response.text())
+ .trim()
+ .split("\n")
+ .map((line) => JSON.parse(line));
+ const deckEvent = events.find((event) => event.type === "deck");
+ const errorEvent = events.find((event) => event.type === "error");
+ if (errorEvent) throw new Error(errorEvent.error);
+ if (!deckEvent) throw new Error("no deck event");
+ return {
+ run: index,
+ activities: deckEvent.deck.activities.length,
+ repaired: Boolean(deckEvent.meta?.repaired),
+ requested: deckEvent.meta?.requested ?? null,
+ types: deckEvent.deck.activities.map((activity) => activity.type),
+ sample: deckEvent.deck.activities[0]?.question ?? deckEvent.deck.activities[0]?.front ?? "",
+ };
+}
+
+async function gradeOnce(port) {
+ const response = await fetch(`http://127.0.0.1:${port}/api/grade`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(GRADE_INPUT),
+ });
+ const body = await response.json().catch(() => null);
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
+ return { correct: body.correct, score: body.score, feedback: String(body.feedback ?? "").slice(0, 70) };
+}
+
+async function bench(model, port) {
+ if (await portInUse(port)) throw new Error(`port ${port} is busy`);
+ const app = spawnApp(port, {
+ OLLAMA_AUTH: "server",
+ OLLAMA_API_KEY: API_KEY,
+ OLLAMA_BASE_URL: BASE_URL,
+ OLLAMA_VERIFY_KEY: "false",
+ TEXT_MODEL: model,
+ VISION_MODEL: model,
+ });
+ try {
+ const up = await waitForHttp(`http://127.0.0.1:${port}/api/health`);
+ if (!up) throw new Error("instance did not start");
+ const rows = [];
+ for (const index of [1, 2]) rows.push(await timed(`generate #${index}`, () => generateOnce(port, index)));
+ rows.push(await timed("grade", () => gradeOnce(port)));
+ return { model, rows };
+ } finally {
+ app.stop();
+ }
+}
+
+console.log(`Comparing ${MODEL_A} vs ${MODEL_B} against ${BASE_URL}\n`);
+const results = [];
+for (const [model, port] of [
+ [MODEL_A, 3210],
+ [MODEL_B, 3211],
+]) {
+ console.log(`--- ${model} ---`);
+ const result = await bench(model, port);
+ for (const row of result.rows) {
+ if (row.error) console.log(` ${row.label}: ERROR after ${row.ms} ms — ${row.error}`);
+ else if (row.label.startsWith("generate")) {
+ console.log(
+ ` ${row.label}: ${row.ms} ms | ${row.value.activities} activities | repaired: ${row.value.repaired ? "YES" : "no"} | types: ${row.value.types.join(", ")}`,
+ );
+ console.log(` first: ${row.value.sample.slice(0, 90)}`);
+ } else {
+ console.log(` ${row.label}: ${row.ms} ms | correct: ${row.value.correct} | score: ${row.value.score} | ${row.value.feedback}`);
+ }
+ }
+ results.push(result);
+}
+
+console.log("\nSummary");
+for (const result of results) {
+ const generates = result.rows.filter((row) => row.label.startsWith("generate") && !row.error);
+ const repaired = generates.filter((row) => row.value.repaired).length;
+ const times = generates.map((row) => row.ms);
+ const average = times.length ? Math.round(times.reduce((sum, value) => sum + value, 0) / times.length) : 0;
+ console.log(` ${result.model}: avg generate ${average} ms | repairs ${repaired}/${generates.length} | errors ${result.rows.filter((row) => row.error).length}`);
+}
+console.log("\nRead it as: fewer repairs + sensible activity types + acceptable latency = the better pipeline fit.");
diff --git a/tests/run-all.mjs b/tests/run-all.mjs
index b2a44a9..8c8dcce 100644
--- a/tests/run-all.mjs
+++ b/tests/run-all.mjs
@@ -22,6 +22,7 @@ const SUITES = [
"phase1-test",
"phase2-test",
"phase3-test",
+ "chat-test",
"review-test",
];
diff --git a/types/globals.d.ts b/types/globals.d.ts
new file mode 100644
index 0000000..232c3b5
--- /dev/null
+++ b/types/globals.d.ts
@@ -0,0 +1,6 @@
+/**
+ * Ambient declarations for assets Next.js bundles but TypeScript doesn't know
+ * about. KaTeX's stylesheet is imported dynamically next to the library so it
+ * only loads when a message actually contains math.
+ */
+declare module "*.css";