Chat mode, phase 1: grounded streaming chat over your notes
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.
This commit is contained in:
parent
6b6a6ea158
commit
756a6257a2
46 changed files with 3840 additions and 56 deletions
208
lib/ollama.ts
208
lib/ollama.ts
|
|
@ -168,45 +168,7 @@ export async function ollamaChat(options: ChatOptions): Promise<ChatResult> {
|
|||
|
||||
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) },
|
||||
);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
if (classifyOllamaFailure(response.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: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
throw new OllamaError(
|
||||
"throttled",
|
||||
"Ollama is temporarily rate limiting requests. Try again in a moment.",
|
||||
{ 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) },
|
||||
);
|
||||
throwForStatus(response.status, text, model);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
|
|
@ -226,6 +188,174 @@ export async function ollamaChat(options: ChatOptions): Promise<ChatResult> {
|
|||
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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue