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.
507 lines
17 KiB
TypeScript
507 lines
17 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"
|
|
/** The free monthly Ollama usage is used up — only the user can fix this. */
|
|
| "usage_limit"
|
|
/** Ollama is throttling short-term requests; retrying soon usually works. */
|
|
| "throttled"
|
|
| "model_missing"
|
|
| "http"
|
|
| "bad_response";
|
|
|
|
/**
|
|
* Ollama reports two very different things with HTTP 429:
|
|
* - the free monthly usage is exhausted ("upgrade", "quota", "monthly …")
|
|
* - a short-term rate limit ("too many requests", "try again …")
|
|
* The body is the only signal, so classify it (kept pure for testing).
|
|
*/
|
|
const USAGE_LIMIT_PATTERN =
|
|
/(monthly|per month|this month|quota|usage limit|limit reached|free (plan|tier|usage)|upgrade|billing|subscribe|exceeded your)/i;
|
|
|
|
export function classifyOllamaFailure(status: number, body: string): "usage_limit" | "throttled" | null {
|
|
if (status !== 429) return null;
|
|
return USAGE_LIMIT_PATTERN.test(body) ? "usage_limit" : "throttled";
|
|
}
|
|
|
|
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 "usage_limit":
|
|
return translate(locale, "server.usageLimit");
|
|
case "throttled":
|
|
return translate(locale, "server.ollamaThrottled");
|
|
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(() => "");
|
|
throwForStatus(response.status, text, model);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* 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>;
|
|
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" | "usage_limit" | "throttled" | "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 if (response.status === 429) {
|
|
// The key itself is fine; the account is either out of monthly usage or
|
|
// being throttled for a moment.
|
|
probe =
|
|
classifyOllamaFailure(response.status, detail) === "usage_limit"
|
|
? { valid: true, reason: "usage_limit", detail }
|
|
: { valid: null, reason: "throttled", 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);
|
|
}
|