freezmo/lib/client-api.ts
Martin Dvorak 4372423167 Bring-your-own keys for cloud providers (OpenAI-compatible)
Settings -> Providers takes keys for OpenAI, Anthropic (their official
OpenAI-compatibility layer), DeepSeek, Groq, OpenRouter and any custom
OpenAI-compatible endpoint. One adapter (lib/openai-compat) covers
listing, streaming chat, JSON generation and vision, and every model
flow can run on a provider: the chat picker lists each enabled
provider's models (labelled, remembered), and a "Study generation"
choice moves decks, homework, grading and attachment transcription onto
a provider model.

Keys stay in this browser exactly like the Ollama one
(freezmo.providers.v1): never stored server-side, never in a backup or
the sync payload, forwarded only with the request that needs them. The
provider test asserts the key reaches the provider while the routing
header stays on the app side.

New suite tests/providers-test.mjs (13 checks) stubs both protocols:
model listing, chat streaming, study generation and the Settings flow.
Privacy policy updated (en+cs). Contrast 0 findings, axe clean, mobile
126/126, chat 102/102, wizard 95/95, bubbles 34/34, regression 44/44,
i18n 13/13, native 33/33. Version 0.4.7, SW -> freezmo-v72.
2026-10-14 06:29:39 +00:00

516 lines
18 KiB
TypeScript

"use client";
import { localeHeader, t } from "./i18n";
import type { Activity, Difficulty, HomeworkMode, HomeworkStep } from "./schemas";
import { API_KEY_HEADER, authHeaders } from "./settings";
import { providerHeaders, providersStore } from "./providers-client";
export type GenerateInput = {
images?: string[];
text?: string;
difficulty: Difficulty;
count?: number;
focus?: string;
language?: string;
mode?: "create" | "more";
existing?: { title: string; types: string[]; count: number };
};
export type StreamedDeck = {
title: string;
subject?: string;
summary?: string;
language?: string;
sourceText: string;
difficulty: Difficulty;
activities: Activity[];
};
export type GenerateProgress = {
stage: "vision" | "generation" | "done";
message: string;
};
/** No response at all for this long: the run is stuck, not slow. */
const RESPONSE_GUARD_MS = 45_000;
/** No bytes for this long while streaming: the model stopped talking. The
* routes heartbeat every 15s, so 90s of silence really is silence. */
const STREAM_GUARD_MS = 90_000;
type StreamEvent =
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
| { type: "deck"; deck: StreamedDeck; meta: Record<string, unknown> }
| { type: "error"; error: string; kind?: string }
| { type: "ping" };
export class ApiError extends Error {
kind: string;
constructor(message: string, kind = "unknown") {
super(message);
this.name = "ApiError";
this.kind = kind;
}
}
/**
* Reads an NDJSON response line by line and hands each parsed event to the
* callback. Malformed lines are skipped (a half-written line must never break
* the stream), and the trailing buffer is flushed at the end.
*/
export async function consumeNdjson(
response: Response,
handleEvent: (event: unknown) => void,
options: { stallMs?: number } = {},
): Promise<void> {
if (!response.body) throw new ApiError(t("api.empty"), "bad_response");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const stallMs = options.stallMs ?? 0;
const flush = (line: string) => {
if (!line.trim()) return;
try {
handleEvent(JSON.parse(line));
} catch {
/* ignore malformed lines */
}
};
while (true) {
let chunk: ReadableStreamReadResult<Uint8Array>;
let timer: ReturnType<typeof setTimeout> | null = null;
try {
if (stallMs > 0) {
const stalled = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new ApiError(t("api.stalled"), "timeout")), stallMs);
});
chunk = await Promise.race([reader.read(), stalled]);
} else {
chunk = await reader.read();
}
} catch (error) {
// A connection that dropped mid-run used to leak a raw DOMException, and
// the wizard answered with its generic "check your Ollama settings"
// card for what was really a network blip. Classify everything here.
void reader.cancel().catch(() => {});
if (error instanceof ApiError) throw error;
if (error instanceof Error && error.name === "AbortError") {
throw new ApiError(t("api.cancelled"), "aborted");
}
throw new ApiError(t("api.streamLost"), "network");
} finally {
if (timer) clearTimeout(timer);
}
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
flush(buffer.slice(0, newline));
buffer = buffer.slice(newline + 1);
newline = buffer.indexOf("\n");
}
}
flush(buffer);
}
/**
* Calls POST /api/generate and consumes the NDJSON progress stream.
* Progress events arrive between the vision and generation steps.
*/
export async function generateDeck(
input: GenerateInput,
options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {},
): Promise<{ deck: StreamedDeck; meta: Record<string, unknown> }> {
// A provider picked in Settings → Providers runs the generation instead of
// the server's Ollama; its key travels with the request only.
const generation = providersStore.getSnapshot().generation;
// A run that goes quiet is worse than one that fails: the wizard used to sit
// on its loading screen until the server's own 2x deadline (ten minutes by
// default) when the model hung. The caller's signal is forwarded, and a
// no-response guard covers the "nothing at all" phase.
const controller = new AbortController();
const forwarded = options.signal;
let headersTimedOut = false;
if (forwarded) {
if (forwarded.aborted) controller.abort();
else forwarded.addEventListener("abort", () => controller.abort(), { once: true });
}
const headersTimer = setTimeout(() => {
headersTimedOut = true;
controller.abort();
}, RESPONSE_GUARD_MS);
let response: Response;
try {
response = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(
generation ? { ...input, textModel: generation.textModel, visionModel: generation.visionModel } : input,
),
signal: controller.signal,
});
} catch {
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
throw new ApiError(t("api.unreachable"), "network");
} finally {
clearTimeout(headersTimer);
}
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 deck: StreamedDeck | null = null;
let meta: Record<string, unknown> = {};
let streamError: ApiError | null = null;
let sawDeck = false;
await consumeNdjson(response, (raw) => {
const event = raw as StreamEvent;
if (event.type === "stage") {
options.onProgress?.({ stage: event.stage, message: event.message });
} else if (event.type === "deck") {
deck = event.deck;
meta = event.meta ?? {};
sawDeck = true;
options.onProgress?.({ stage: "done", message: t("api.almost") });
} else if (event.type === "error") {
streamError = new ApiError(event.error, event.kind);
}
}, { stallMs: STREAM_GUARD_MS });
if (streamError) throw streamError;
if (!deck) {
throw new ApiError(
sawDeck
? t("api.incomplete")
: t("api.noDeck"),
"bad_response",
);
}
const valid = (deck as StreamedDeck).activities?.filter(
(activity) => activity && typeof activity.type === "string" && typeof activity.id === "string",
);
if (!valid || valid.length === 0) {
throw new ApiError(t("api.noActivities"), "bad_response");
}
return { deck: { ...(deck as StreamedDeck), activities: valid }, meta };
}
/* ------------------------------------------------------------------ */
/* Make my homework */
/* ------------------------------------------------------------------ */
export type HomeworkInput = {
mode: HomeworkMode;
images?: string[];
text?: string;
/** Mode "check": the student's own attempt at the assignment. */
solution?: string;
/** Mode "check": photos of the attempt (handwritten work, scanned pages). */
solutionImages?: string[];
language?: string;
};
export type HomeworkProgress = {
stage: "vision" | "generation" | "done";
message: string;
};
export type StreamedHomework =
| { mode: "make"; title: string; language?: string; answer: string; sourceText?: string }
| { mode: "guide"; title: string; language?: string; steps: HomeworkStep[]; sourceText?: string }
| {
mode: "check";
title: string;
language?: string;
verdict: string;
correct: string[];
wrong: Array<{ issue: string; why: string; fix: string }>;
corrected: string;
sourceText?: string;
};
type HomeworkStreamEvent =
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
| { type: "homework"; homework: StreamedHomework; meta: Record<string, unknown> }
| { type: "error"; error: string; kind?: string }
| { type: "ping" };
/**
* Calls POST /api/homework and consumes the NDJSON progress stream. Same shape
* as generateDeck: stage events first, then one final payload.
*/
export async function generateHomework(
input: HomeworkInput,
options: { onProgress?: (progress: HomeworkProgress) => void; signal?: AbortSignal } = {},
): Promise<{ homework: StreamedHomework; meta: Record<string, unknown> }> {
const generation = providersStore.getSnapshot().generation;
// Same guards as generateDeck: no infinite loading screen on a hung model.
const controller = new AbortController();
const forwarded = options.signal;
let headersTimedOut = false;
if (forwarded) {
if (forwarded.aborted) controller.abort();
else forwarded.addEventListener("abort", () => controller.abort(), { once: true });
}
const headersTimer = setTimeout(() => {
headersTimedOut = true;
controller.abort();
}, RESPONSE_GUARD_MS);
let response: Response;
try {
response = await fetch("/api/homework", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(
generation ? { ...input, textModel: generation.textModel, visionModel: generation.visionModel } : input,
),
signal: controller.signal,
});
} catch {
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
throw new ApiError(t("api.unreachable"), "network");
} finally {
clearTimeout(headersTimer);
}
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 homework: StreamedHomework | null = null;
let meta: Record<string, unknown> = {};
let streamError: ApiError | null = null;
await consumeNdjson(response, (raw) => {
const event = raw as HomeworkStreamEvent;
if (event.type === "stage") {
options.onProgress?.({ stage: event.stage, message: event.message });
} else if (event.type === "homework") {
homework = event.homework;
meta = event.meta ?? {};
options.onProgress?.({ stage: "done", message: t("api.almost") });
} else if (event.type === "error") {
streamError = new ApiError(event.error, event.kind);
}
}, { stallMs: STREAM_GUARD_MS });
if (streamError) throw streamError;
if (!homework) {
throw new ApiError(t("api.noHomework"), "bad_response");
}
const result = homework as StreamedHomework;
if (result.mode === "make") {
if (!result.answer?.trim()) throw new ApiError(t("api.noHomework"), "bad_response");
} else if (result.mode === "check") {
if (!result.corrected?.trim() || !result.verdict?.trim()) {
throw new ApiError(t("api.noHomework"), "bad_response");
}
} else {
const steps = result.steps?.filter((step) => step && typeof step.title === "string");
if (!steps || steps.length < 2) throw new ApiError(t("api.noHomework"), "bad_response");
// Checkpoints must look like real activities; drop anything malformed
// rather than crash the guide.
const cleaned = steps.map((step) => ({
...step,
checkpoint:
step.checkpoint && typeof step.checkpoint.type === "string" && typeof step.checkpoint.id === "string"
? step.checkpoint
: undefined,
}));
return { homework: { ...result, steps: cleaned }, meta };
}
return { homework: result, meta };
}
/** Follow-up on a finished result: shorter, more detail, or explain again. */
export async function refineHomework(input: {
instruction: "shorter" | "more" | "explain";
text: string;
context?: string;
signal?: AbortSignal;
}): Promise<string> {
const generation = providersStore.getSnapshot().generation;
let response: Response;
try {
response = await fetch("/api/homework/refine", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(
generation
? { instruction: input.instruction, text: input.text, context: input.context, model: generation.textModel }
: { instruction: input.instruction, text: input.text, 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");
}
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("api.noHomework"), "bad_response");
return body.text.trim();
}
export async function gradeAnswer(input: {
question: string;
referenceAnswer: string;
keyPoints: string[];
userAnswer: string;
language?: string;
signal?: AbortSignal;
}): Promise<{ correct: boolean; score: number; feedback: string; missing: string[] }> {
const generation = providersStore.getSnapshot().generation;
let response: Response;
try {
response = await fetch("/api/grade", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(generation ? { ...input, model: generation.textModel } : input),
signal: input.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new ApiError(t("api.gradingCancelled"), "aborted");
}
throw new ApiError(t("api.unreachableShort"), "network");
}
const body = (await response.json().catch(() => null)) as
| { correct: boolean; score: number; feedback: string; missing?: string[]; error?: string; kind?: string }
| null;
if (!response.ok || !body || body.error) {
throw new ApiError(body?.error ?? t("api.gradingFailed", { status: response.status }), body?.kind);
}
return {
correct: Boolean(body.correct),
score: Math.max(0, Math.min(100, Math.round(body.score))),
feedback: body.feedback,
missing: body.missing ?? [],
};
}
export type HealthModel = {
configured: string;
resolved: string;
available: boolean | null;
note?: string;
};
export type HealthChatModel = {
name: string;
badge: "text" | "vision" | "both";
available: boolean | null;
};
export type Health = {
ok: boolean;
reachable: boolean;
mode: "local-daemon" | "direct-cloud";
authMode: "user-key" | "server" | "local";
needsKey: boolean;
keyAccepted: boolean | null;
baseUrl: string;
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;
kind?: string;
checkedAt: string;
};
export async function fetchHealth(): Promise<Health> {
let response: Response;
try {
response = await fetch("/api/health", { cache: "no-store", headers: { ...authHeaders(), ...localeHeader() } });
} catch {
throw new ApiError(t("api.unreachableShort"), "network");
}
const body = (await response.json().catch(() => null)) as (Partial<Health> & { error?: string; kind?: string }) | null;
if (!response.ok) {
throw new ApiError(
body?.error ?? t("api.healthFailed", { status: response.status }),
body?.kind ?? "http",
);
}
// Guard against anything that isn't a health payload (e.g. a proxy error
// page) so consumers can rely on the shape.
if (!body || typeof body !== "object" || !body.vision || !body.text) {
throw new ApiError(t("api.healthUnexpected"), "bad_response");
}
return body as Health;
}
export type KeyCheck =
| { result: "valid" }
| { result: "invalid"; message: string }
| { result: "offline"; message: string };
/**
* Checks a *draft* key before it is saved anywhere, by asking our own
* `/api/health` route (which performs a real one-token chat probe against
* Ollama — model listings alone prove nothing). The key travels in the
* request header for this single call and is never stored server-side.
*/
export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
let response: Response;
try {
response = await fetch("/api/health", {
cache: "no-store",
headers: { [API_KEY_HEADER]: apiKey, ...localeHeader() },
});
} catch {
return {
result: "offline",
message: t("api.unreachable"),
};
}
const body = (await response.json().catch(() => null)) as Health | null;
if (!response.ok || !body) {
return {
result: "offline",
message: t("api.serverUnexpected"),
};
}
if (body.keyAccepted === false) {
return {
result: "invalid",
message:
body.error ?? t("api.keyRejected"),
};
}
if (body.keyAccepted === true) return { result: "valid" };
if (!body.reachable) {
return {
result: "offline",
message:
body.error ?? t("api.ollamaUnreachable"),
};
}
// Reachable, but the probe was inconclusive (usually a model-availability
// matter rather than a bad key). Let the user in — Settings shows details.
return { result: "valid" };
}