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

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