freezmo/lib/config.ts
Martin Dvorak e4a8e0ba01 Phase 2: learning engine — history, SRS review, mistakes, stats, exam, reminders
Answer history and schedules
- Every answer is recorded (deck, activity, topic, correct/wrong, time)
  in a versioned IndexedDB log (freezmo-learning v1, stores answers +
  srs), mirrored in memory for synchronous React reads and capped at
  20k answers (oldest pruned). StudyFeed records as it grades.
- Light SM-2 schedule per activity: 1 day, 3 days, then interval x
  ease; wrong answers return in ten minutes with the ease nudged down.

Review, mistakes, stats
- Library 'Smart review' card: due count, mistakes count (latest
  answer wrong — clears itself when answered correctly), today's goal
  progress. Review sessions freeze their item list at start.
- New /review (due across decks, ?mistakes=1 global or ?deck= per
  deck) built on a shared SessionFeed with the study feed's snap/keyboard
  mechanics, wake lock and a results slide.
- New /stats: day streak, five-week calendar, accuracy per subject and
  per deck, weakest topics (>=3 answers), daily-goal progress.
- Daily goal configurable in Settings (10/20/30/50).

Exam mode
- New /exam: pick decks, question count, optional timer; no hints
  (a NoHints context hides hint rows and the self-check escape);
  results with score, per-deck and per-topic breakdown; wrong answers
  land in the mistakes set through the normal history.

Reminders (conditional)
- VAPID-gated: GET/POST/DELETE /api/push and POST /api/push/test with
  the standard route pattern, RATE_LIMIT_PUSH, zod, auth before body.
  Subscriptions stored server-side as endpoint + keys only (PUSH_DIR).
  UI hidden entirely without keys; iOS install note; service worker
  push/notificationclick handlers; scripts/send-reminders.mjs for a
  cron/systemd timer; web-push added as the one server dependency.

Verified: new phase-2 suite 42/42 (history/SRS shapes from a real study
run, mistakes clearing, review session, stats math, exam timer and
breakdowns, VAPID-less hidden state plus the configured
subscribe/validate/test/delete path against a second instance) plus
regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116,
animation 8/8, native 35/35, phase1 38/38, i18n 25/25. Key parity
709/709. Lint + build clean. SW freezmo-v26.
2026-10-10 14:15:21 +00:00

219 lines
7.5 KiB
TypeScript

/**
* Server-side configuration, read from the environment on every request so
* that editing `.env.local` only requires a dev-server restart.
*
* Never import this into a client component: it would leak the API key.
*/
import { sanitiseApiKey } from "./http-guard";
import { translate, type Locale } from "./i18n-dict";
import path from "node:path";
export type AuthMode = "user-key" | "server" | "local";
export type ServerConfig = {
baseUrl: string;
apiKey: string;
authMode: AuthMode;
visionModel: string;
textModel: string;
/** True when talking straight to https://ollama.com (needs an API key). */
directCloud: boolean;
timeoutMs: number;
healthTimeoutMs: number;
/** `false` sends `think: false`; `null` omits the field entirely. */
think: boolean | null;
defaultActivityCount: number;
rateLimits: {
windowMs: number;
generate: number;
grade: number;
homework: number;
health: number;
sync: number;
push: number;
};
sync: {
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
dir: string;
/** Per-blob cap in bytes (SYNC_MAX_BYTES). */
maxBytes: number;
/** Total cap for the whole directory in bytes (SYNC_TOTAL_BYTES). */
totalBytes: number;
};
push: {
/** VAPID keys — reminders are hidden entirely when either is empty. */
publicKey: string;
privateKey: string;
subject: string;
/** Where push subscriptions live (PUSH_DIR). */
dir: string;
};
};
/** What a single request should use to talk to Ollama. */
export type OllamaAuth = {
baseUrl: string;
apiKey: string;
directCloud: boolean;
authMode: AuthMode;
};
function positiveInt(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
}
export function getServerConfig(): ServerConfig {
const baseUrl = trimTrailingSlash(
process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434",
);
const apiKey = process.env.OLLAMA_API_KEY?.trim() || "";
const rawAuthMode = process.env.OLLAMA_AUTH?.trim().toLowerCase();
const authMode: AuthMode =
rawAuthMode === "local" || rawAuthMode === "server" || rawAuthMode === "user-key"
? rawAuthMode
: rawAuthMode === "none"
? "local"
: "user-key";
let directCloud = false;
try {
const host = new URL(baseUrl).hostname.toLowerCase();
directCloud = host === "ollama.com" || host.endsWith(".ollama.com");
} catch {
directCloud = false;
}
const thinkRaw = process.env.OLLAMA_THINK?.trim().toLowerCase();
const think =
thinkRaw === "false" ? false : thinkRaw === "true" ? true : null;
const count = Number.parseInt(
process.env.FREEZMO_DEFAULT_ACTIVITY_COUNT ?? "",
10,
);
return {
baseUrl,
apiKey,
authMode,
visionModel: process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud",
textModel: process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud",
directCloud,
timeoutMs: Number.parseInt(process.env.OLLAMA_TIMEOUT_MS ?? "", 10) || 300_000,
healthTimeoutMs:
Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000,
think,
defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 20) : 10,
rateLimits: {
windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000),
generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12),
grade: positiveInt(process.env.RATE_LIMIT_GRADE, 40),
// Homework is one to two model calls, like a deck.
homework: positiveInt(process.env.RATE_LIMIT_HOMEWORK, 12),
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
// Sync is cheap (no model calls) but shouldn't be hammered.
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
// Push subscription changes and test sends.
push: positiveInt(process.env.RATE_LIMIT_PUSH, 30),
},
sync: {
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"),
maxBytes: positiveInt(process.env.SYNC_MAX_BYTES, 12 * 1024 * 1024),
totalBytes: positiveInt(process.env.SYNC_TOTAL_BYTES, 1024 * 1024 * 1024),
},
push: {
publicKey: process.env.VAPID_PUBLIC_KEY?.trim() || "",
privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || "",
subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost",
dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"),
},
};
}
/**
* Human-readable explanation for every auth configuration problem, so the UI
* never has to show a bare "unauthorised".
*/
const AUTH_ERROR_KEYS: Record<"missing_key" | "server_key_missing" | "local_with_cloud_url", string> = {
missing_key: "server.authMissingKey",
server_key_missing: "server.authServerKeyMissing",
local_with_cloud_url: "server.authLocalCloud",
};
export function authErrorMessage(locale: Locale, error: keyof typeof AUTH_ERROR_KEYS): string {
return translate(locale, AUTH_ERROR_KEYS[error]);
}
export function authErrorKind(error: "missing_key" | "server_key_missing" | "local_with_cloud_url"): string {
if (error === "missing_key") return "needs_key";
return "config";
}
/** Header a browser uses to hand over its own key for a single request. */
export const API_KEY_HEADER = "x-ollama-key";
/**
* Works out which credentials a request may use.
*
* - `user-key` (default): the key must come from the browser. Nothing is
* stored server-side, ever — it is used for this request and dropped.
* - `server`: the key comes from `.env.local` (single-user installs).
* - `local`: neither; a local daemon signed in with `ollama signin`.
*/
export function resolveAuth(
config: ServerConfig,
request: Request,
): { auth: OllamaAuth } | { error: "missing_key" | "server_key_missing" | "local_with_cloud_url" } {
const headerKey = sanitiseApiKey(request.headers.get(API_KEY_HEADER));
if (config.authMode === "local") {
// A daemon without sign-in can't talk to the cloud API: catch the
// misconfiguration here instead of returning a confusing 401 later.
if (config.directCloud) return { error: "local_with_cloud_url" };
return {
auth: { baseUrl: config.baseUrl, apiKey: "", directCloud: config.directCloud, authMode: "local" },
};
}
if (config.authMode === "server") {
if (!config.apiKey) return { error: "server_key_missing" };
return {
auth: { baseUrl: config.baseUrl, apiKey: config.apiKey, directCloud: config.directCloud, authMode: "server" },
};
}
if (!headerKey) return { error: "missing_key" };
return {
auth: { baseUrl: config.baseUrl, apiKey: headerKey, directCloud: config.directCloud, authMode: "user-key" },
};
}
/**
* Cloud models are addressed as `<model>:cloud` (or `<model>-cloud`) by a
* local daemon, but the direct cloud API uses the plain library name
* (`gemma4:31b-cloud` -> `gemma4:31b`, `glm-5.3-flash:cloud` -> `glm-5.3-flash`).
* Normalise so the same .env works in both modes.
*/
export function resolveModelName(model: string, directCloud: boolean): string {
if (!directCloud) return model;
if (model.endsWith(":cloud")) return model.slice(0, -":cloud".length);
if (model.endsWith("-cloud")) return model.slice(0, -"-cloud".length);
return model;
}
/** Strip local/daemon routing suffixes so model names can be compared. */
export function canonicalModelName(model: string): string {
return model
.replace(/:cloud$/, "")
.replace(/-cloud$/, "")
.toLowerCase();
}