Freezmo: self-hosted notes-to-activities study app
Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable feed of AI-generated activities (flashcards, multiple choice, true/false, fill-in-the-blank, matching, ordering, short answer) using the user's own Ollama models. Highlights: - All AI calls run in server route handlers; the browser never talks to Ollama. - Bring-your-own-key mode (default): the key lives only in the browser and is sent per-request; server, local-daemon and shared-key modes also supported. - Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF page rendering for the vision model; notes stay in their original language. - ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based light + dark themes, folder organisation, PWA (manifest, SW, offline page). - Strict JSON validation with zod + auto-repair, rate limiting, and request guards (cross-origin, body-size, API-key sanitising). - setup.sh bootstraps deps, .env.local and a production build. Gitignore excludes .env*, the generated pdf.js worker and local scratch files.
This commit is contained in:
parent
3baa0fa8ce
commit
3ea2cea100
83 changed files with 10909 additions and 165 deletions
327
lib/ollama.ts
Normal file
327
lib/ollama.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
/**
|
||||
* Minimal server-side Ollama HTTP client.
|
||||
*
|
||||
* Works with both supported setups:
|
||||
* 1. a local daemon (http://localhost:11434) that proxies :cloud models
|
||||
* after `ollama signin`
|
||||
* 2. the direct cloud API (https://ollama.com) with a Bearer API key
|
||||
*
|
||||
* This module must only ever be imported from server code (route handlers).
|
||||
*/
|
||||
|
||||
import { getServerConfig, resolveModelName, type OllamaAuth } from "./config";
|
||||
|
||||
export type OllamaMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
/** Base64 strings (no data: prefix) or full data URLs; Ollama accepts both. */
|
||||
images?: string[];
|
||||
};
|
||||
|
||||
export type OllamaErrorKind =
|
||||
| "unreachable"
|
||||
| "timeout"
|
||||
| "auth"
|
||||
| "no_credits"
|
||||
| "model_missing"
|
||||
| "http"
|
||||
| "bad_response";
|
||||
|
||||
export class OllamaError extends Error {
|
||||
kind: OllamaErrorKind;
|
||||
status?: number;
|
||||
detail?: string;
|
||||
|
||||
constructor(kind: OllamaErrorKind, message: string, options?: { status?: number; detail?: string }) {
|
||||
super(message);
|
||||
this.name = "OllamaError";
|
||||
this.kind = kind;
|
||||
this.status = options?.status;
|
||||
this.detail = options?.detail;
|
||||
}
|
||||
}
|
||||
|
||||
export function friendlyErrorMessage(error: unknown, baseUrl?: string): string {
|
||||
if (error instanceof OllamaError) {
|
||||
switch (error.kind) {
|
||||
case "unreachable":
|
||||
return `Ollama isn't reachable at ${baseUrl ?? "the configured URL"}. Check your settings and make sure the daemon is running.`;
|
||||
case "timeout":
|
||||
return "Ollama took too long to answer. Try fewer/smaller images, a faster model, or retry.";
|
||||
case "auth":
|
||||
return "Ollama rejected the request. If you are using https://ollama.com, check OLLAMA_API_KEY (create one at ollama.com/settings/keys).";
|
||||
case "no_credits":
|
||||
return error.message;
|
||||
case "model_missing":
|
||||
return error.message;
|
||||
default:
|
||||
return error.message || "Ollama returned an unexpected response.";
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Something went wrong while talking to Ollama.";
|
||||
}
|
||||
|
||||
type ChatOptions = {
|
||||
model: string;
|
||||
messages: OllamaMessage[];
|
||||
/** Credentials for this request (browser key, server key or local daemon). */
|
||||
auth: OllamaAuth;
|
||||
/** Ask Ollama to constrain the output to valid JSON. */
|
||||
json?: boolean;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
export type ChatResult = {
|
||||
content: string;
|
||||
model: string;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
external?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const onAbort = () => controller.abort();
|
||||
external?.addEventListener("abort", onAbort);
|
||||
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
external?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ollamaChat(options: ChatOptions): Promise<ChatResult> {
|
||||
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: false,
|
||||
};
|
||||
if (options.json) body.format = "json";
|
||||
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(() => "");
|
||||
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) },
|
||||
);
|
||||
}
|
||||
throw new OllamaError(
|
||||
"http",
|
||||
`Ollama responded with HTTP ${response.status}.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new OllamaError("bad_response", "Ollama sent a response that wasn't JSON.");
|
||||
}
|
||||
|
||||
const content = extractContent(payload);
|
||||
if (content === null) {
|
||||
throw new OllamaError("bad_response", "Ollama sent a response with no message content.", {
|
||||
detail: JSON.stringify(payload).slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
return { content, model, raw: payload };
|
||||
}
|
||||
|
||||
function extractContent(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const message = record.message;
|
||||
if (message && typeof message === "object") {
|
||||
const content = (message as Record<string, unknown>).content;
|
||||
if (typeof content === "string") return content;
|
||||
}
|
||||
if (typeof record.response === "string") return record.response;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type OllamaModelInfo = { name: string; model: string; size?: number };
|
||||
|
||||
/** List the models known to the given Ollama instance (or the cloud). */
|
||||
export async function ollamaListModels(auth: OllamaAuth, timeoutMs?: number): Promise<OllamaModelInfo[]> {
|
||||
const config = getServerConfig();
|
||||
const { baseUrl, apiKey } = auth;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(
|
||||
`${baseUrl}/api/tags`,
|
||||
{ headers, cache: "no-store" },
|
||||
timeoutMs ?? config.healthTimeoutMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OllamaError("timeout", "Timed out while listing Ollama models.");
|
||||
}
|
||||
throw new OllamaError("unreachable", `Could not connect to Ollama at ${baseUrl}.`, {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new OllamaError("auth", "Ollama refused the request (unauthorised).", {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
throw new OllamaError("http", `Ollama responded with HTTP ${response.status} while listing models.`, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { models?: Array<{ name?: string; model?: string; size?: number }> }
|
||||
| null;
|
||||
|
||||
return (payload?.models ?? []).map((entry) => ({
|
||||
name: entry.name ?? entry.model ?? "unknown",
|
||||
model: entry.model ?? entry.name ?? "unknown",
|
||||
size: entry.size,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* API key verification */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type KeyProbe = {
|
||||
/** true = accepted, false = rejected, null = could not tell. */
|
||||
valid: boolean | null;
|
||||
reason?: "invalid" | "no_credits" | "model_missing" | "unreachable";
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type CachedProbe = KeyProbe & { at: number };
|
||||
|
||||
const probeCacheStore = globalThis as unknown as { __freezmoKeyProbes?: Map<string, CachedProbe> };
|
||||
const probeCache: Map<string, CachedProbe> = (probeCacheStore.__freezmoKeyProbes ??= new Map());
|
||||
const PROBE_TTL_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* Model listing is public on ollama.com, so the only trustworthy way to check
|
||||
* a key is a real (tiny) chat request: 1 output token, cached for a few minutes.
|
||||
* Interpreted statuses:
|
||||
* 200 key works
|
||||
* 401 / 403 key rejected
|
||||
* 402 key works but the plan has no access to that model
|
||||
* 404 model unknown — the key isn't the problem
|
||||
*/
|
||||
export async function ollamaVerifyKey(auth: OllamaAuth, model: string, timeoutMs = 20_000): Promise<KeyProbe> {
|
||||
if (!auth.apiKey) return { valid: null };
|
||||
if (process.env.OLLAMA_VERIFY_KEY?.trim().toLowerCase() === "false") return { valid: null };
|
||||
|
||||
const cacheKey = `${auth.baseUrl}|${model}|${hashKey(auth.apiKey)}|${auth.authMode}`;
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < PROBE_TTL_MS) {
|
||||
return { valid: cached.valid, reason: cached.reason, detail: cached.detail };
|
||||
}
|
||||
|
||||
const resolved = resolveModelName(model, auth.directCloud);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(
|
||||
`${auth.baseUrl}/api/chat`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
model: resolved,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
stream: false,
|
||||
options: { num_predict: 1 },
|
||||
}),
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
} catch {
|
||||
return { valid: null, reason: "unreachable" };
|
||||
}
|
||||
|
||||
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
||||
let probe: KeyProbe;
|
||||
if (response.ok) probe = { valid: true };
|
||||
else if (response.status === 401 || response.status === 403) probe = { valid: false, reason: "invalid", detail };
|
||||
else if (response.status === 402) probe = { valid: true, reason: "no_credits", detail };
|
||||
else if (response.status === 404) probe = { valid: null, reason: "model_missing", detail };
|
||||
else probe = { valid: null, detail: `HTTP ${response.status}` };
|
||||
|
||||
probeCache.set(cacheKey, { ...probe, at: Date.now() });
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** The key itself is never kept — only a hash, so the cache can't leak it. */
|
||||
function hashKey(key: string): string {
|
||||
// Small non-cryptographic hash is enough to bucket cache entries.
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash ^= key.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue