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

Entry points (no sixth tab): Ask in the Library header (desktop button,
phone icon), Ask about this deck in the deck menu, Ask a question on
study/homework results (compact result travels as context), and a Chat
row in the desktop sidebar. Phones get a full-screen route with back +
New chat; the tab bar steps aside.

- Client-side retrieval: chunk scoped decks (~800 tokens), BM25 against
  the question + recent turns, top chunks under ~7k tokens, each
  labelled with deck + section. Notes come from the deck's stored
  sourceText (already kept for Regenerate); decks without it fall back
  to generated content and the UI says so.
- Stateless server: /api/chat validates against the CHAT_MODELS
  allowlist, trims the history to ~6k tokens, caps context at ~9k and
  streams token/thinking/done/error NDJSON. /api/chat/extract reduces
  attachments to text through the existing vision step.
- UI: user bubbles + full-width assistant text, streaming with stop,
  auto-scroll that pauses with a jump-to-bottom button, safe markdown
  (React nodes, no HTML injection; KaTeX dynamically imported only when
  a message has math), Thinking row for reasoning, copy/regenerate/
  edit-and-resend, model + context pickers (last model remembered),
  composer with visualViewport keyboard handling, safe-area, 16px
  textarea, Enter-to-send on desktop.
- Usage-limit failures keep the draft and show the shared message with
  Settings/Ollama actions; stopping maps the reader abort to a clean
  stopped state; offline disables sending with a reason.
- Settings: Keep notes text for chat toggle; deck menu: Delete notes
  text. RATE_LIMIT_CHAT + CHAT_MODELS in .env.example and README.
- Tests: chat suite (63 checks, stub Ollama) wired into run-all.
  Full set: 12/12 suites, 487 checks. SW -> freezmo-v32.

Also: tests/model-compare.mjs (diagnostic) benchmarks two models on the
same notes (latency, repair rounds, activity types) so the gpt-oss vs
gemma default can be decided on numbers; defaults unchanged.
This commit is contained in:
Martin Dvořák 2026-10-11 18:32:11 +00:00
commit 756a6257a2
46 changed files with 3840 additions and 56 deletions

View file

@ -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.

View file

@ -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()`);

View file

@ -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;
}

193
app/api/chat/route.ts Normal file
View file

@ -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<Uint8Array>({
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",
},
});
}

View file

@ -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) {

32
app/chat/page.tsx Normal file
View file

@ -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 (
<ApiKeyGate>
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<main className="flex h-dvh min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader className="hidden" />
<Suspense fallback={null}>
<ChatScreen />
</Suspense>
</main>
</div>
</ApiKeyGate>
);
}

View file

@ -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({
<ShareIcon className="size-4 text-[var(--text-subtle)]" />
{t("deck.share")}
</button>
<Link
href={`/chat?deck=${deck.id}`}
className={menuRow}
>
<ChatIcon className="size-4 text-[var(--text-subtle)]" />
{t("chat.askAboutDeck")}
</Link>
<button
type="button"
className={menuRow}
@ -388,6 +395,21 @@ export function DeckCard({
{t("deck.regenerate")}
</button>
{hasSource ? (
<button
type="button"
className={menuRow}
onClick={() => {
setMenuOpen(false);
deleteNotesText(deck.id);
showToast({ message: t("chat.notesDeleted") });
}}
>
<XCircleIcon className="size-4 text-[var(--text-subtle)]" />
{t("chat.deleteNotes")}
</button>
) : null}
{/* Deleting is reversible: no confirm, just an Undo toast. */}
<button
type="button"

View file

@ -7,7 +7,7 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac
import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard";
import { EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
import { ChatIcon, EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
import { LinkLoadingScreen } from "@/components/LinkLoadingScreen";
import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, EmptyState, ProgressBar } from "@/components/ui";
@ -195,6 +195,10 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
{/* Desktop keeps Import above the tabs; phones get a small icon
button in the tab row instead. */}
<div className="hidden flex-wrap items-center gap-2 sm:flex">
<Link href="/chat" className="touch-target inline-flex items-center gap-2 rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-2.5 text-sm font-semibold text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]">
<ChatIcon className="size-4" />
{t("chat.ask")}
</Link>
<Button
variant="secondary"
size="md"
@ -236,6 +240,14 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
<span className="seg-label relative z-10">{t("tabHomework")}</span>
</Link>
</div>
<Link
href="/chat"
aria-label={t("chat.ask")}
title={t("chat.ask")}
className="touch-target grid size-11 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)] sm:hidden"
>
<ChatIcon className="size-5" />
</Link>
<button
type="button"
onClick={() => setSheetOpen(true)}

View file

@ -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}
</p>
) : null}
<AskButton
className="w-full"
seed={{
deckId: deck.id,
label: t("chat.seedResults"),
text: [
`${t("chat.seedResults")}: ${deck.title} — ${t("results.gotOf", { correct, total, retry: "" })}`,
...missed.map((activity) => `${promptFor(activity)} — ${t("results.answer")} ${answerFor(activity, t)}`),
].join("\n"),
}}
/>
<div className="grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
<DownloadIcon className="size-4" /> {t("results.export")}

View file

@ -391,6 +391,17 @@ export function SettingsDialog({
/>
</button>
</div>
{/* chat grounding: may chat read the notes text? */}
<ToggleRow
label={t("settings.chatNotes")}
note={t("settings.chatNotesNote")}
checked={settings.chatNotes}
onChange={() => {
saveSettings({ chatNotes: !settings.chatNotes });
haptic("toggle");
}}
/>
</section>
{/* ------------------------------------------- data & sync */}
@ -709,6 +720,16 @@ export function SettingsDialog({
) : null}
</section>
{/* ------------------------------------------- chat grounding */}
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<ToggleRow
label={t("settings.chatNotes")}
note={t("settings.chatNotesNote")}
checked={settings.chatNotes}
onChange={() => saveSettings({ chatNotes: !settings.chatNotes })}
/>
</section>
{/* ------------------------------------------- data & sync */}
<LearningSection />
<DataSection />
@ -947,3 +968,44 @@ function ModelRow({
</div>
);
}
/** Boolean toggle row used by the chat-grounding setting. */
function ToggleRow({
label,
note,
checked,
onChange,
}: {
label: string;
note: string;
checked: boolean;
onChange: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3">
<span className="min-w-0">
<span className="block text-[13px] font-semibold text-[var(--text)]">{label}</span>
<span className="block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
</span>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={onChange}
className={cn(
"touch-target relative h-7 w-12 shrink-0 rounded-full border transition-colors",
checked ? "grad-ice border-transparent" : "border-[var(--hairline)] bg-[var(--surface-sunken)]",
)}
>
<span
aria-hidden
className={cn(
"absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-white shadow transition-[left] duration-200",
checked ? "left-6" : "left-0.5",
)}
/>
</button>
</div>
);
}

View file

@ -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 ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
</Link>
{/* chat with your notes --------------------------------------------- */}
<Link
href="/chat"
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("chat.nav")}
>
<ChatIcon className="size-4 shrink-0" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("chat.nav")}</span> : null}
</Link>
<nav aria-label={t("sidebar.library")} className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{collapsed ? (
<button

View file

@ -25,9 +25,10 @@ export function TabBar() {
const { t } = useI18n();
const reduceMotion = useReducedMotion();
/* The wizards keep their own bottom action bar (Cancel / Next / Freeze) —
the flow is deliberately focused, so the tab bar steps aside there. */
const inWizard = pathname === "/create" || pathname === "/homework";
/* The wizards keep their own bottom action bar (Cancel / Next / Freeze) and
chat keeps its own composer — the flow is deliberately focused, so the tab
bar steps aside there. */
const inWizard = pathname === "/create" || pathname === "/homework" || pathname === "/chat";
const item =
"relative flex min-h-[52px] min-w-0 flex-1 flex-col items-center justify-center gap-1 py-2 text-[10.5px] font-medium leading-none transition-colors";

View file

@ -0,0 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { ChatIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { setChatSeed, type ChatSeed } from "@/lib/chat-seed";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
/**
* "Ask a question" entry point for results screens: hands the compact result
* summary to the chat (sessionStorage, one-shot) and opens it.
*/
export function AskButton({ seed, className }: { seed: ChatSeed; className?: string }) {
const { t } = useI18n();
const router = useRouter();
return (
<Button
variant="secondary"
className={cn("touch-target", className)}
onClick={() => {
setChatSeed(seed);
router.push("/chat");
}}
>
<ChatIcon className="size-4" />
{t("chat.askQuestion")}
</Button>
);
}

View file

@ -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<ChatScope>(() =>
deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" },
);
const [seed, setSeed] = useState<ChatSeed | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
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<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const extrasRef = useRef<Record<string, ExtraChunk[]>>({});
/* 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<ChatModelChoice[]>(() => {
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 (
<div className="relative flex h-full min-h-0 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4">
<button
type="button"
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
aria-label={t("chat.back")}
title={t("chat.back")}
className="grid size-10 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
>
<ChevronLeftIcon className="size-5" />
</button>
<div className="min-w-0 flex-1">
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{t("chat.title")}</h1>
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
{scopeText}
{seed ? ` · ${seed.label}` : ""}
</p>
</div>
<ThemeToggle className="hidden lg:grid" />
<button
type="button"
onClick={newChat}
aria-label={t("chat.new")}
title={t("chat.new")}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-4" />
<span className="hidden sm:inline">{t("chat.new")}</span>
</button>
</header>
<div className="relative min-h-0 flex-1">
<div ref={scrollRef} onScroll={onScroll} className="h-full overflow-y-auto overscroll-contain">
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
{messages.length === 0 ? (
<div className="space-y-5 py-6">
<div className="text-center">
<ChatIcon className="mx-auto size-10 text-accent-400" />
<h2 className="mt-3 text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("chat.emptyTitle")}</h2>
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
{t("chat.emptyHint")}
</p>
</div>
{missingNotes ? (
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
{t("chat.oldDecksNote")}
</p>
) : null}
<div className="flex flex-wrap justify-center gap-2">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
disabled={!online || busy}
onClick={() => 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}
</button>
))}
</div>
</div>
) : (
<>
{seed ? (
<div className="flex items-center gap-2 rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-3 py-2 text-[12px] text-[var(--text-subtle)]">
<span className="truncate">
{t("chat.fromLabel")}: {seed.label}
</span>
</div>
) : null}
{messages.map((message) => (
<MessageRow
key={message.id}
message={message}
streaming={busy && message.id === lastAssistantId}
isLastAssistant={message.id === lastAssistantId}
isLastUser={message.id === lastUserId}
busy={busy}
onRegenerate={regenerate}
onEdit={editLast}
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
/>
))}
</>
)}
<div className="h-2" aria-hidden />
</div>
</div>
{!pinned && messages.length > 0 ? (
<button
type="button"
onClick={() => {
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"
>
<ChevronDownIcon className="size-5" />
</button>
) : null}
</div>
{error ? (
<div
role="alert"
className="mx-3 mb-2 shrink-0 rounded-2xl border border-rose-300/60 bg-rose-50/80 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
>
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{error.message}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{messages.some((message) => message.role === "user") ? (
<Button size="sm" variant="secondary" onClick={retryLast}>
<RefreshIcon className="size-4" /> {t("chat.retry")}
</Button>
) : null}
<ModelErrorActions kind={error.kind} />
</div>
</div>
) : null}
<div className="relative shrink-0">
<Composer
value={draft}
onChange={setDraft}
onSend={() => 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))}
/>
<ModelPicker
open={modelOpen}
onClose={() => setModelOpen(false)}
models={chatModels}
value={activeModel}
onChange={(next) => {
setModel(next);
storeChatModel(next);
}}
/>
<ContextPicker
open={scopeOpen}
onClose={() => setScopeOpen(false)}
scope={scope}
onChange={setScope}
decks={decks}
folders={folders}
/>
</div>
{settings.chatNotes === false && scope.kind !== "none" ? (
<p className="border-t border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-4 py-2 text-[11.5px] text-[var(--text-subtle)]">
{t("chat.notesOffNote")}{" "}
<Link href="/" className="underline decoration-dotted">
{t("chat.notesOffLink")}
</Link>
</p>
) : null}
</div>
);
}

View file

@ -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<HTMLTextAreaElement>(null);
const fileInput = useRef<HTMLInputElement>(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 (
<div className="border-t border-[var(--glass-border)] bg-[var(--glass-bg)] px-3 pb-safe pt-2 backdrop-blur-xl">
{attachments.length > 0 ? (
<ul className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((entry, index) => (
<li
key={`${entry.attachment.name}-${index}`}
className="flex items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
>
<span className="max-w-[160px] truncate">{entry.attachment.name}</span>
<span className="text-[var(--text-subtle)]">{entry.attachment.kind === "photo" ? t("chat.attachPhoto") : t("chat.attachDoc")}</span>
<button
type="button"
aria-label={t("chat.attachRemove")}
title={t("chat.attachRemove")}
onClick={() => onRemoveAttachment(index)}
className="grid size-6 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<XIcon className="size-3.5" />
</button>
</li>
))}
</ul>
) : null}
<div className="flex items-end gap-2">
<button
type="button"
aria-label={t("chat.attach")}
title={t("chat.attach")}
disabled={attachBusy}
onClick={() => 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 ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
</button>
<input
ref={fileInput}
type="file"
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onAttachFile(file);
event.target.value = "";
}}
/>
<textarea
ref={textarea}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || isPhone) return;
if (event.nativeEvent.isComposing) return;
event.preventDefault();
if (canSend) onSend();
}}
rows={1}
placeholder={t("chat.placeholder")}
aria-label={t("chat.placeholder")}
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60"
/>
{busy ? (
<button
type="button"
onClick={onStop}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<StopIcon className="size-5" />
</button>
) : (
<button
type="button"
onClick={onSend}
disabled={!canSend}
aria-label={t("chat.send")}
title={t("chat.send")}
className={cn(
"grid size-10 shrink-0 place-items-center rounded-2xl transition",
canSend ? "grad-ice text-[#06111f]" : "border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-subtle)] opacity-60",
)}
>
<SendIcon className="size-5" />
</button>
)}
</div>
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
<button
type="button"
onClick={onOpenScope}
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
>
<LayersIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{scopeLabel}</span>
</button>
<button
type="button"
onClick={onOpenModel}
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
>
<ChatIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{modelLabel}</span>
</button>
{!online ? (
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
{t("chat.offlineNote")}
</span>
) : null}
</div>
</div>
);
}

View file

@ -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<string>();
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,
) => (
<button
type="button"
aria-pressed={scope.kind === value}
onClick={() =>
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)]",
)}
>
<span className="shrink-0 text-[var(--text-muted)]">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-semibold text-[var(--text)]">{title}</span>
<span className="mt-0.5 block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
</span>
{scope.kind === value ? <CheckCircleIcon className="size-5 shrink-0 text-accent-500" /> : null}
</button>
);
const body = (
<div className="space-y-2">
{modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), <LayersIcon className="size-5" />)}
{modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), <FolderIcon className="size-5" />)}
{scope.kind === "decks" ? (
<div className="max-h-[38dvh] space-y-1 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2">
{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 (
<button
key={folder.id}
type="button"
onClick={() => 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)]"
>
<span
className={cn(
"grid size-4 place-items-center rounded-[0.35rem] border",
allSelected ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
)}
aria-hidden
>
{allSelected ? <CheckCircleIcon className="size-3" /> : null}
</span>
<FolderIcon className="size-3.5" />
<span className="truncate">{folder.name}</span>
<span className="ml-auto text-[11px] font-normal text-[var(--text-subtle)]">
{plural(inFolder.length, "chat.scope.deckCount")}
</span>
</button>
);
})}
{decks.map((deck) => {
const active = selected.has(deck.id);
return (
<button
key={deck.id}
type="button"
onClick={() => toggleDeck(deck.id)}
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left transition hover:bg-[var(--hover)]"
>
<span
className={cn(
"grid size-4 shrink-0 place-items-center rounded-[0.35rem] border",
active ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
)}
aria-hidden
>
{active ? <CheckCircleIcon className="size-3" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-[var(--text)]">{deck.title}</span>
<span className="block text-[11px] text-[var(--text-subtle)]">
{deckHasNotes(deck) ? t("chat.scope.hasNotes") : t("chat.scope.generatedOnly")}
</span>
</span>
</button>
);
})}
</div>
) : null}
{modeRow("none", t("chat.scope.none"), t("chat.scope.noneNote"), <span className="text-[18px]">💬</span>)}
</div>
);
if (isPhone) {
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2">
<div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
{body}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</div>
</div>
);
}
return (
<div
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
role="dialog"
aria-label={t("chat.scope.title")}
>
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.scope.title")}
</h2>
{body}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</div>
);
}

View file

@ -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 (
<button
type="button"
aria-label={t("chat.copy")}
title={state === "copied" ? t("chat.copied") : state === "failed" ? t("chat.copyFailed") : t("chat.copy")}
onClick={async () => {
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" ? (
<CheckCircleIcon className="size-4 text-emerald-500" />
) : state === "failed" ? (
<XCircleIcon className="size-4 text-rose-500" />
) : (
<CopyIcon className="size-4" />
)}
</button>
);
}

View file

@ -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 <strong key={key}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("__") && part.endsWith("__") && part.length > 4) {
return <strong key={key}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
return (
<code key={key} className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 font-mono text-[0.85em]">
{part.slice(1, -1)}
</code>
);
}
if (part.startsWith("$$") && part.endsWith("$$") && part.length > 4) {
return <MathSpan key={key} tex={part.slice(2, -2).trim()} display />;
}
if (part.startsWith("$") && part.endsWith("$") && part.length > 2) {
return <MathSpan key={key} tex={part.slice(1, -1)} />;
}
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(part);
if (link) {
const href = link[2];
if (/^https?:\/\//i.test(href)) {
return (
<a
key={key}
href={href}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-accent-600 underline decoration-accent-400/50 underline-offset-2 dark:text-accent-300"
>
{link[1]}
</a>
);
}
return <span key={key}>{link[1]}</span>;
}
if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
return <em key={key}>{part.slice(1, -1)}</em>;
}
if (part.startsWith("_") && part.endsWith("_") && part.length > 2) {
return <em key={key}>{part.slice(1, -1)}</em>;
}
return <span key={key}>{part}</span>;
});
}
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 (
<Tag key={key} className="text-[15px] font-semibold text-[var(--text)]">
{inlineNodes(block.text, key)}
</Tag>
);
}
case "list": {
const Tag = block.ordered ? "ol" : "ul";
return (
<Tag key={key} className={block.ordered ? "list-decimal space-y-1 pl-5" : "list-disc space-y-1 pl-5"}>
{block.items.map((item, itemIndex) => (
<li
key={`${key}-${itemIndex}`}
className={item.depth > 0 ? "ml-4 list-[circle]" : undefined}
>
{inlineNodes(item.text, `${key}-${itemIndex}`)}
</li>
))}
</Tag>
);
}
case "code":
return (
<div key={key} className="group relative">
<pre className="overflow-x-auto rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-3 text-[12.5px] leading-relaxed">
<code className="font-mono">{block.code}</code>
</pre>
<div className="absolute right-2 top-2 opacity-90">
<CopyIconButton text={block.code} />
</div>
</div>
);
case "table":
return (
<div key={key} className="overflow-x-auto">
<table className="w-full min-w-[280px] border-collapse text-[13px]">
<thead>
<tr>
{block.head.map((cell, cellIndex) => (
<th
key={`${key}-h-${cellIndex}`}
className="border-b border-[var(--hairline)] px-2 py-1.5 text-left font-semibold text-[var(--text)]"
>
{inlineNodes(cell, `${key}-h-${cellIndex}`)}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={`${key}-r-${rowIndex}`}>
{block.head.map((_, cellIndex) => (
<td key={`${key}-r-${rowIndex}-${cellIndex}`} className="border-b border-[var(--hairline-soft)] px-2 py-1.5 align-top">
{inlineNodes(row[cellIndex] ?? "", `${key}-r-${rowIndex}-${cellIndex}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
case "quote":
return (
<blockquote key={key} className="border-l-2 border-[var(--hairline)] pl-3 text-[var(--text-muted)]">
{inlineNodes(block.lines.join("\n"), key)}
</blockquote>
);
case "math":
return <MathSpan key={key} tex={block.tex} display />;
case "hr":
return <hr key={key} className="border-[var(--hairline-soft)]" />;
default:
return (
<p key={key} className="whitespace-pre-wrap break-words">
{inlineNodes(block.lines.join("\n"), key)}
</p>
);
}
}
export const Markdown = memo(function Markdown({ text }: { text: string }) {
const blocks = useMemo(() => parseBlocks(text), [text]);
return <div className="chat-md space-y-3 text-[15px] leading-relaxed text-[var(--text)]">{blocks.map(renderBlock)}</div>;
});

View file

@ -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<HTMLSpanElement>(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 (
<span
ref={ref}
className={display ? "my-2 block overflow-x-auto text-center" : "inline-block max-w-full align-middle"}
aria-label={tex}
/>
);
}

View file

@ -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 (
<div className="flex flex-col items-end gap-1">
<div className="max-w-[88%] rounded-3xl rounded-br-lg border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5">
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{message.content}</p>
{message.attachments && message.attachments.length > 0 ? (
<p className="mt-1.5 text-[11.5px] text-[var(--text-subtle)]">
{message.attachments.map((attachment) => attachment.name).join(" · ")}
</p>
) : null}
</div>
{isLastUser && !busy && onEdit ? (
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 rounded-lg px-1.5 py-1 text-[11.5px] text-[var(--text-subtle)] transition hover:text-[var(--text)]"
>
<PencilIcon className="size-3.5" />
{t("chat.edit")}
</button>
) : null}
</div>
);
}
return (
<div className="space-y-2">
{message.thinking ? <ThinkingRow thinking={message.thinking} streaming={streaming && !message.content} /> : null}
{message.content ? (
<Markdown text={message.content} />
) : streaming ? (
<span className="flex items-center gap-1 py-1" role="status" aria-label={t("chat.thinking")}>
{[0, 1, 2].map((dot) => (
<span
key={dot}
className="size-1.5 animate-bounce rounded-full bg-[var(--text-subtle)] motion-reduce:animate-none motion-reduce:opacity-60"
style={{ animationDelay: `${dot * 120}ms` }}
/>
))}
</span>
) : null}
{message.error ? (
<div
role="alert"
className="rounded-2xl border border-rose-300/60 bg-rose-50/70 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
>
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{message.error.message}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{onRetry ? (
<Button size="sm" variant="secondary" onClick={onRetry}>
<RefreshIcon className="size-4" /> {t("chat.retry")}
</Button>
) : null}
<ModelErrorActions kind={message.error.kind} />
</div>
</div>
) : null}
{!streaming && message.content && !message.error ? (
<div className="flex flex-wrap items-center gap-2">
<CopyIconButton text={message.content} />
{isLastAssistant && !busy && onRegenerate ? (
<button
type="button"
onClick={onRegenerate}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<RefreshIcon className="size-3.5" />
{t("chat.regenerate")}
</button>
) : null}
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
</div>
) : null}
{message.sources && message.sources.length > 0 && !streaming ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] uppercase tracking-wide text-[var(--text-subtle)]">{t("chat.sources")}</span>
{message.sources.slice(0, 5).map((source, index) => (
<Link
key={`${source.deckId}-${source.section}-${index}`}
href={`/deck/${source.deckId}`}
className="max-w-[220px] truncate rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2 py-1 text-[11.5px] text-[var(--text-muted)] transition hover:text-[var(--text)]"
title={`${source.deckTitle} — ${source.section}`}
>
{source.deckTitle} · {source.section}
</Link>
))}
</div>
) : null}
</div>
);
}

View file

@ -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 = (
<ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain">
{models.map((model) => {
const selected = model.name === value;
const unavailable = model.available === false;
return (
<li key={model.name}>
<button
type="button"
disabled={unavailable}
aria-pressed={selected}
onClick={() => {
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",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-[var(--text)]">{model.name}</span>
<span className="mt-0.5 flex items-center gap-2 text-[11.5px] text-[var(--text-subtle)]">
<span className="rounded-md border border-[var(--hairline-soft)] px-1.5 py-0.5">{badge(model)}</span>
{model.available === true ? (
<span className="text-emerald-600 dark:text-emerald-300">{t("chat.modelAvailable")}</span>
) : model.available === false ? (
<span className="text-amber-600 dark:text-amber-300">{t("chat.modelUnavailable")}</span>
) : null}
</span>
</span>
{selected ? <CheckCircleIcon className="size-5 shrink-0 text-accent-500" /> : null}
</button>
</li>
);
})}
</ul>
);
if (isPhone) {
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2">
<div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>
{rows}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
</div>
</div>
);
}
return (
<div
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
role="dialog"
aria-label={t("chat.modelTitle")}
>
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.modelTitle")}
</h2>
{rows}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
</div>
);
}

View file

@ -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 (
<div className="rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)]">
<button
type="button"
onClick={() => 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)]"
>
<SparklesIcon className={cn("size-3.5 shrink-0", streaming && "animate-pulse")} />
<span>{streaming ? t("chat.thinking") : t("chat.thought")}</span>
<ChevronDownIcon className={cn("ml-auto size-4 transition-transform", open && "rotate-180")} />
</button>
{open ? (
<div className="whitespace-pre-wrap break-words px-3 pb-3 text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
{thinking}
</div>
) : null}
</div>
);
}

View file

@ -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({
</article>
<div className="flex flex-wrap items-start gap-2">
<AskButton
seed={{
label: record.title,
text: [
`${t("chat.seedAssignment")}:`,
record.sourceText,
"",
`${t("chat.seedAttempt")}:`,
record.attempt ?? "",
"",
`${t("chat.seedVerdict")}:`,
record.verdict,
...(record.wrong.length > 0
? ["", `${t("chat.seedToFix")}:`, ...record.wrong.map((entry) => `- ${entry.issue}: ${entry.why} → ${entry.fix}`)]
: []),
"",
`${t("chat.seedCorrected")}:`,
corrected,
].join("\n"),
}}
/>
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
{t("homework.followup.redo")}
</Button>

View file

@ -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({
<p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.guide.solutionNote")}</p>
<div className="flex flex-wrap items-start gap-2">
<CopyButton text={solution} />
<AskButton
seed={{
label: record.title,
text: [
`${t("chat.seedAssignment")}:`,
record.sourceText,
"",
`${t("chat.seedSteps")}:`,
...steps.map((step, index) => `${index + 1}. ${step.title}${step.explanation ? ` — ${step.explanation}` : ""}`),
].join("\n"),
}}
/>
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
{t("homework.followup.redo")}
</Button>

View file

@ -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({
<Button variant="secondary" className="touch-target" onClick={() => void shareHomework({ ...record, answer })}>
{t("deck.share")}
</Button>
<AskButton
seed={{
label: record.title,
text: [
`${t("chat.seedAssignment")}:`,
record.sourceText,
"",
`${t("chat.seedAnswer")}:`,
answer,
].join("\n"),
}}
/>
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
{t("homework.followup.redo")}
</Button>

View file

@ -316,6 +316,70 @@ export function AddToHomeIcon({ className }: IconProps) {
);
}
/* --- Chat --------------------------------------------------------------- */
export function ChatIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="M20 12a7.5 7.5 0 0 1-7.5 7.5H8.2L4 22v-4.6A7.5 7.5 0 0 1 12.5 4.5 7.5 7.5 0 0 1 20 12Z" />
<path d="M9 10.5h7M9 13.8h4.5" />
</Svg>
);
}
export function SendIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="M12 19V5" />
<path d="m6 11 6-6 6 6" />
</Svg>
);
}
export function StopIcon({ className }: IconProps) {
return (
<Svg className={className}>
<rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor" stroke="none" />
</Svg>
);
}
export function CopyIcon({ className }: IconProps) {
return (
<Svg className={className}>
<rect x="9" y="9" width="11" height="11" rx="2.5" />
<path d="M5.5 15H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v.5" />
</Svg>
);
}
export function PaperclipIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="m20 11.5-7.8 7.8a5 5 0 0 1-7-7l8.3-8.3a3.3 3.3 0 0 1 4.7 4.7l-8.3 8.3a1.7 1.7 0 0 1-2.4-2.4l7.4-7.4" />
</Svg>
);
}
export function PencilIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="M4 20h4.2L20 8.2a2.1 2.1 0 0 0-3-3L5.2 17 4 20Z" />
<path d="m14.5 6.7 2.8 2.8" />
</Svg>
);
}
export function HistoryIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="M4.5 12a7.5 7.5 0 1 1 2.2 5.3" />
<path d="M4.5 12H2m2.5 0 2.3-2.3M4.5 12l2.3 2.3" />
<path d="M12 8.5V12l2.4 1.6" />
</Svg>
);
}
export function ChromeIcon({ className }: IconProps) {
return (
<Svg className={className}>

105
lib/chat-client.ts Normal file
View file

@ -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<void> {
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<string> {
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();
}

258
lib/chat-context.ts Normal file
View file

@ -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<string>();
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<string, number>();
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<string, number>();
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 };
}

43
lib/chat-seed.ts Normal file
View file

@ -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;
}
}

30
lib/chat-trim.ts Normal file
View file

@ -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<T extends { role: "user" | "assistant"; content: string }>(
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;
}

83
lib/chat.ts Normal file
View file

@ -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}`;
}

View file

@ -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<void> {
@ -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;

View file

@ -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<string>();
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"),

View file

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

View file

@ -168,45 +168,7 @@ export async function ollamaChat(options: ChatOptions): Promise<ChatResult> {
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<ChatResult> {
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<ChatStreamResult> {
const config = getServerConfig();
const { baseUrl, apiKey, directCloud } = options.auth;
const model = resolveModelName(options.model, directCloud);
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const body: Record<string, unknown> = {
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<string, unknown>;
if (typeof record.error === "string" && record.error) {
throw new OllamaError("http", record.error.slice(0, 300));
}
const message = record.message as Record<string, unknown> | 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<string, unknown>;

View file

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

View file

@ -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);

View file

@ -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 },

26
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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`;

View file

@ -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

423
tests/chat-test.mjs Normal file
View file

@ -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();

141
tests/model-compare.mjs Normal file
View file

@ -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.");

View file

@ -22,6 +22,7 @@ const SUITES = [
"phase1-test",
"phase2-test",
"phase3-test",
"chat-test",
"review-test",
];

6
types/globals.d.ts vendored Normal file
View file

@ -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";