freezmo/lib/chat-client.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

110 lines
4.1 KiB
TypeScript

"use client";
import { ApiError, consumeNdjson } from "./client-api";
import { localeHeader, t } from "./i18n";
import { authHeaders } from "./settings";
import { providerHeaders, providersStore } from "./providers-client";
import type { ChatSource } from "./chat";
/**
* Client for the streaming chat route. The conversation window and the chosen
* context chunks travel with every request (the server never stores either);
* the API key only ever goes to our own route in its usual header.
*/
export type ChatStreamEvent =
| { type: "token"; text: string }
| { type: "thinking"; text: string }
| { type: "done"; model: string }
| { type: "error"; error: string; kind?: string };
export type StreamChatInput = {
model?: string;
/** A cloud provider the model belongs to (Ollama when omitted). */
provider?: string;
messages: { role: "user" | "assistant"; content: string }[];
context: (ChatSource & { text: string })[];
signal?: AbortSignal;
onToken: (text: string) => void;
onThinking: (text: string) => void;
onDone: (model: string) => void;
};
export async function streamChat(input: StreamChatInput): Promise<void> {
let response: Response;
try {
response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(input.provider), ...localeHeader() },
body: JSON.stringify({
model: input.model || undefined,
messages: input.messages,
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");
}
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 streamError: ApiError | null = null;
try {
await consumeNdjson(response, (raw) => {
const event = raw as ChatStreamEvent;
if (event.type === "token") input.onToken(event.text);
else if (event.type === "thinking") input.onThinking(event.text);
else if (event.type === "done") input.onDone(event.model);
else if (event.type === "error") streamError = new ApiError(event.error, event.kind);
});
} catch (error) {
// Aborting while the body is being read rejects the reader with an
// AbortError, not a fetch rejection — treat it as a user stop.
if (error instanceof Error && error.name === "AbortError") {
throw new ApiError(t("api.cancelled"), "aborted");
}
throw error;
}
if (streamError) throw streamError;
}
/**
* Turns attached photos (and any client-extracted document text) into plain
* text through the existing vision pipeline, so the chat model itself never
* needs to be multimodal.
*/
export async function extractChatAttachment(
input: { images?: string[]; text?: string },
options: { signal?: AbortSignal } = {},
): Promise<string> {
// Attachment transcription follows the study-generation provider, if set.
const generation = providersStore.getSnapshot().generation;
let response: Response;
try {
response = await fetch("/api/chat/extract", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(generation ? { ...input, model: generation.visionModel || generation.textModel } : input),
signal: options.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("chat.attachEmpty"), "bad_response");
return body.text.trim();
}