English stays the default; Czech is a full translation. Only the app chrome is translated - notes and generated decks keep their own language, which is a core feature, not a setting. i18n - lib/i18n-dict.ts: flat en/cs dictionaries (441 keys each), translate() and translatePlural() with real Czech plural rules (1 sada / 2-4 sady / 5+ sad). English is the default and the fallback for a missing key. - lib/i18n.ts: browser store (freezmo.locale.v1), useI18n() hook, and the x-freezmo-locale header. The hook's t/plural are bound to the locale React is rendering with, so the first client render matches the server HTML and then re-renders in the stored language; module-level t is only for event handlers and non-React helpers. - <html lang> follows the choice (inline script + setLocale). UI - Settings -> Language segmented control (English / Cestina), plus an EN/CS button on the key gate for first run. - Every screen translated: sidebar, header, tab bar, library, deck cards, folder bar, create flow, all seven activities, study chrome, results, settings + health panel, API-key gate and help, install sheet, offline and the deck-not-found page. - Plural-aware counts everywhere (decks, folders, activities, runs, photos, documents, pages, words, wrong attempts). Server - API routes and guards (generate, grade, health, http-guard, rate-limit, ollama error mapping) answer in the request's language, so error cards and the health panel never fall back to English mid-sentence. Fixes - A hydration mismatch when a non-default locale was stored silently dropped the .dark class after hydration; render-time t() is now bound to the hook's locale (UploadScreen.describeExtraction, ResultsScreen.answerFor, DeckCard's folder fallback). - Bumped the service worker cache to freezmo-v4 for this deploy. Tests: i18n 27/27 (gate toggle, persistence, html lang, Czech plurals, settings switch) and a rebuilt regression suite 72/72 (English default, library, create, all 7 activity types, results, settings, install sheet, offline, PWA, security, Czech smoke).
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
/**
|
|
* 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 { DEFAULT_LOCALE, translate, type Locale } from "./i18n-dict";
|
|
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,
|
|
locale: Locale = DEFAULT_LOCALE,
|
|
): string {
|
|
if (error instanceof OllamaError) {
|
|
switch (error.kind) {
|
|
case "unreachable":
|
|
return translate(locale, "server.ollamaUnreachable", {
|
|
url: baseUrl ?? "the configured URL",
|
|
});
|
|
case "timeout":
|
|
return translate(locale, "server.ollamaTimeout");
|
|
case "auth":
|
|
return translate(locale, "server.ollamaAuth");
|
|
case "no_credits":
|
|
return error.message;
|
|
case "model_missing":
|
|
return error.message;
|
|
default:
|
|
return error.message || translate(locale, "server.ollamaUnexpected");
|
|
}
|
|
}
|
|
if (error instanceof Error) return error.message;
|
|
return translate(locale, "server.ollamaGeneric");
|
|
}
|
|
|
|
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);
|
|
}
|