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:
parent
6b6a6ea158
commit
756a6257a2
46 changed files with 3840 additions and 56 deletions
105
lib/chat-client.ts
Normal file
105
lib/chat-client.ts
Normal 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
258
lib/chat-context.ts
Normal 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
43
lib/chat-seed.ts
Normal 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
30
lib/chat-trim.ts
Normal 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
83
lib/chat.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
162
lib/i18n-dict.ts
162
lib/i18n-dict.ts
|
|
@ -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. */
|
||||
|
|
|
|||
208
lib/ollama.ts
208
lib/ollama.ts
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue