show reasoning in frontend and some other stuff

This commit is contained in:
2026-09-21 20:24:40 +02:00
parent ceb3c752ff
commit fd76a79aaf
12 changed files with 220 additions and 89 deletions
+14 -4
View File
@@ -33,9 +33,13 @@ Node.js 22 or newer is required.
- `CHAT_LIMIT`: per-account maximum chat sessions. - `CHAT_LIMIT`: per-account maximum chat sessions.
- `CHAT_HISTORY_LIMIT`: number of recent messages included in model prompts. - `CHAT_HISTORY_LIMIT`: number of recent messages included in model prompts.
- `MODEL_API_BASE_URL`: base URL of the remote model backend. The server expects `/chat` and `/tags`. - `MODEL_API_BASE_URL`: base URL of the remote model backend. The server expects `/chat` and `/tags`.
- `MODEL_REQUEST_TIMEOUT_MS`: timeout for chat generation requests. - `MODEL_REQUEST_TIMEOUT_MS`: legacy fallback for the inactivity timeout (default 180000 ms).
- `MODEL_CONNECT_TIMEOUT_MS`: time to receive Ollama response headers (default 30000 ms).
- `MODEL_INACTIVITY_TIMEOUT_MS`: maximum silence between received stream chunks (default 180000 ms).
- `MODEL_MAX_GENERATION_MS`: overall generation safety limit (default 600000 ms).
- `OLLAMA_KEEP_ALIVE`: Ollama model residency after a request (default `30m`).
- `MODEL_LIST_TIMEOUT_MS`: timeout for model list requests. - `MODEL_LIST_TIMEOUT_MS`: timeout for model list requests.
- `MODEL_UNLOAD_AFTER_MS`: delay before an idle model unload is attempted. - `MODEL_UNLOAD_AFTER_MS`: delay before idle local request bookkeeping is cleared. Model unloading is managed by Ollama through `OLLAMA_KEEP_ALIVE`.
- `MODELS_CACHE_TTL_MS`: model list cache TTL. - `MODELS_CACHE_TTL_MS`: model list cache TTL.
- `AUTH_RATE_LIMIT_*`: rate-limit window and thresholds for registration/login. - `AUTH_RATE_LIMIT_*`: rate-limit window and thresholds for registration/login.
- `CHAT_RATE_LIMIT_*`: rate-limit window and thresholds for chat and retry endpoints. - `CHAT_RATE_LIMIT_*`: rate-limit window and thresholds for chat and retry endpoints.
@@ -46,12 +50,18 @@ Node.js 22 or newer is required.
The server is configured around an Ollama-compatible API surface: The server is configured around an Ollama-compatible API surface:
- `POST /chat`: accepts `{ model, messages, stream: true }` and returns newline-delimited JSON stream chunks with `message.content`. - `POST /chat`: accepts `{ model, messages, stream: true, think: boolean, keep_alive: "30m" }` and returns newline-delimited JSON chunks with optional `message.content` / `message.thinking`, ending in `done: true`.
- `GET /tags`: returns the available model list. - `GET /tags`: returns the available model list.
`qwen3:14b` is the default model for chat and roleplay. The model selector loads available models from `/tags` and selects `qwen3:14b` when available (otherwise the first returned model). Existing chats retain their saved model selection. `qwen3:14b` is the default model for chat and roleplay. The model selector loads available models from `/tags` and selects `qwen3:14b` when available (otherwise the first returned model). Existing chats retain their saved model selection.
Chat replies display Ollama's optional `message.thinking` output in a collapsed “Reasoning” section above the answer, updated through SSE `thinking` events during generation. This text is stored separately from the answer and excluded from conversation prompts. An additive SQLite migration preserves it when reopening chats; exports, imports and branches preserve it too. Models without thinking output show no section. The composer has independent Web Search and Reasoning toggles, both off by default and reset on server acceptance. It sends `{message, chatId, model, webSearch, reasoning}` using streamed fetch to `POST /chat/stream`. Existing SSE events carry `status` (accepted/searching/generating), `thinking` (chunk/fullThinking), `chunk` (chunk/fullReply), `done` (reply/sources), and `error`. Final answers stream incrementally, including persona responses. Persona validation still runs; a corrective pass resets provisional output before streaming its replacement. Only the validated complete answer is persisted.
Every Ollama request explicitly sets `stream: true`, `think`, and `keep_alive`. Reasoning is requested and forwarded only when `reasoning === true` and the model supports the boolean thinking API. The shared conservative capability check supports Qwen3, DeepSeek R1 and DeepSeek v3.1 model IDs; unknown models, Qwen coder variants and models requiring thinking levels have the toggle disabled. Internal calls, roleplay openers and regeneration default to `think: false`. There is no extra model call to obtain reasoning.
Reasoning appears only after actual thinking text arrives, expanded during generation and collapsed when the answer begins. Users can reopen it; stored messages start collapsed. The optional nullable `chat_messages.thinking` field remains separate from answer content and sources, is excluded from future prompts and logs, and survives reloads, exports, imports and branches. Old messages remain valid.
Ollama reads reset the inactivity deadline on each received chunk; the overall safety limit still applies to active streams. Client disconnects and the existing Stop action abort upstream generation. Missing `done: true`, invalid streams, timeouts and upstream failures use the existing error path and never save a partial assistant response as complete. Browser EOF without a terminal SSE event is likewise an interruption. The existing UI discards temporary output on failure.
The refactor adds explicit upstream timeout handling, cached model listing, and `GET /health` for degraded-state visibility. The refactor adds explicit upstream timeout handling, cached model listing, and `GET /health` for degraded-state visibility.
+41 -20
View File
@@ -17,12 +17,20 @@ export const post = (url, data) => request(url, data, "POST");
export const put = (url, data) => request(url, data, "PUT"); export const put = (url, data) => request(url, data, "PUT");
export const del = (url) => request(url, null, "DELETE"); export const del = (url) => request(url, null, "DELETE");
export async function stream(url, data, handlers = {}) { /**
* @typedef {{event: "status", data: {status: "accepted"|"searching"|"generating", sourceCount?: number}}
* | {event: "chunk", data: {chunk: string, fullReply: string}}
* | {event: "thinking", data: {chunk: string, fullThinking: string}}
* | {event: "done", data: {reply: string, sources?: import("./webSearch.js").WebSource[]}}
* | {event: "error", data: {error: string, code?: string}}} ChatStreamEvent
*/
export async function stream(url, data, handlers = {}, signal = undefined) {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify(data || {}) body: JSON.stringify(data || {}),
signal
}); });
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
@@ -43,25 +51,38 @@ export async function stream(url, data, handlers = {}) {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
let terminal = false;
while (true) { try {
const {value, done} = await reader.read(); while (true) {
if (done) break; const {value, done} = await reader.read();
buffer += decoder.decode(value, {stream: true}); if (done) break;
const parts = buffer.split("\n\n"); buffer += decoder.decode(value, {stream: true});
buffer = parts.pop() || ""; const parts = buffer.split(/\r?\n\r?\n/);
for (const part of parts) { buffer = parts.pop() || "";
const lines = part.split("\n"); for (const part of parts) {
const eventLine = lines.find((line) => line.startsWith("event:")); const lines = part.split("\n");
const dataLine = lines.find((line) => line.startsWith("data:")); const eventLine = lines.find((line) => line.startsWith("event:"));
if (!eventLine || !dataLine) continue; const dataLine = lines.find((line) => line.startsWith("data:"));
const event = eventLine.slice(6).trim(); if (!eventLine || !dataLine) continue;
const payload = JSON.parse(dataLine.slice(5).trim()); const event = eventLine.slice(6).trim();
if (event === "chunk" && handlers.onChunk) handlers.onChunk(payload); const payload = JSON.parse(dataLine.slice(5).trim());
if (event === "thinking" && handlers.onThinking) handlers.onThinking(payload); if (!payload || typeof payload !== "object") throw new Error("Invalid response stream.");
if (event === "status" && handlers.onStatus) handlers.onStatus(payload); const textFields = {chunk: "fullReply", thinking: "fullThinking", status: "status", done: "reply", error: "error"};
if (event === "done" && handlers.onDone) handlers.onDone(payload); if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream.");
if (event === "error" && handlers.onError) handlers.onError(payload); if (terminal) continue;
if (event === "chunk" && handlers.onChunk) handlers.onChunk(payload);
if (event === "thinking" && handlers.onThinking) handlers.onThinking(payload);
if (event === "status" && handlers.onStatus) handlers.onStatus(payload);
if (event === "done" && handlers.onDone) handlers.onDone(payload);
if (event === "error" && handlers.onError) handlers.onError(payload);
if (event === "done" || event === "error") terminal = true;
}
if (terminal) break;
} }
if (!terminal) throw new Error("Response was interrupted. Please try again.");
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
/** @typedef {{title: string, url: string, snippet: string}} WebSource */ /** @typedef {{title: string, url: string, snippet: string}} WebSource */
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean}} ChatRequest */ /** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
/** @typedef {{role: string, content: string, sources?: WebSource[]}} ChatMessage */ /** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */
export function createWebSearchToggle(button) { export function createWebSearchToggle(button) {
let enabled = false; let enabled = false;
+3
View File
@@ -200,6 +200,9 @@
<button id="webSearch" class="icon-button" type="button" aria-label="Search the web" title="Search the web" aria-pressed="false"> <button id="webSearch" class="icon-button" type="button" aria-label="Search the web" title="Search the web" aria-pressed="false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/></svg>
</button> </button>
<button id="reasoning" class="icon-button" type="button" aria-label="Enable reasoning" title="Enable reasoning" aria-pressed="false" disabled>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 18V5a3 3 0 0 0-5.8-1A4 4 0 0 0 3 10a4 4 0 0 0 1 7.8A4 4 0 0 0 12 18ZM12 5a3 3 0 0 1 5.8-1A4 4 0 0 1 21 10a4 4 0 0 1-1 7.8A4 4 0 0 1 12 18M8 8a4 4 0 0 1-5 2m13-2a4 4 0 0 0 5 2M8 14a4 4 0 0 0-4 4m12-4a4 4 0 0 1 4 4"/></svg>
</button>
<label class="visually-hidden" for="msgInput">Message</label> <label class="visually-hidden" for="msgInput">Message</label>
<textarea id="msgInput" rows="1" placeholder="Type your message..."></textarea> <textarea id="msgInput" rows="1" placeholder="Type your message..."></textarea>
<button id="send" type="button" aria-label="Send message"></button> <button id="send" type="button" aria-label="Send message"></button>
+28 -11
View File
@@ -1,6 +1,6 @@
import {del, get, post, put, stream} from "./app/api.js?v=20260909-account-controls"; import {del, get, post, put, stream} from "./app/api.js?v=20260909-account-controls";
import {appendSources, createWebSearchToggle} from "./app/webSearch.js"; import {appendSources, createWebSearchToggle} from "./app/webSearch.js";
import {updateReasoning} from "./app/reasoning.js"; import {createReasoningToggle, finishReasoning, updateReasoning} from "./app/reasoning.js";
import {closeChatDrawer, openChatDrawer} from "./app/chatDrawer.js?v=20260909-account-controls"; import {closeChatDrawer, openChatDrawer} from "./app/chatDrawer.js?v=20260909-account-controls";
import {onboardingPrompts, requestedChatId} from "./app/constants.js?v=20260909-account-controls"; import {onboardingPrompts, requestedChatId} from "./app/constants.js?v=20260909-account-controls";
import {getModelProfile} from "./app/modelProfiles.js?v=20260909-account-controls"; import {getModelProfile} from "./app/modelProfiles.js?v=20260909-account-controls";
@@ -106,6 +106,7 @@ import {
const headerModelButton = document.getElementById("headerModelButton"); const headerModelButton = document.getElementById("headerModelButton");
const webSearchButton = document.getElementById("webSearch"); const webSearchButton = document.getElementById("webSearch");
const webSearchToggle = createWebSearchToggle(webSearchButton); const webSearchToggle = createWebSearchToggle(webSearchButton);
const reasoningToggle = createReasoningToggle(document.getElementById("reasoning"));
let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = ""; let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = "";
let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = []; let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = [];
@@ -911,6 +912,7 @@ function renderDrawerSummary() {
} }
function renderModelSection() { function renderModelSection() {
reasoningToggle.setModel(modelSelect.value);
updateModelHelp(); updateModelHelp();
return modelSelect?.selectedOptions[0]?.textContent || ""; return modelSelect?.selectedOptions[0]?.textContent || "";
} }
@@ -1361,6 +1363,7 @@ function setLoadingState(loading, overlayOptions = null) {
isProcessing = loading; isProcessing = loading;
msgInput.disabled = loading; msgInput.disabled = loading;
webSearchButton.disabled = loading; webSearchButton.disabled = loading;
reasoningToggle.setBusy(loading);
sendBtn.classList.toggle("loading", loading); sendBtn.classList.toggle("loading", loading);
messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => { messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => {
button.disabled = loading || button.dataset.retryLimitReached === "true"; button.disabled = loading || button.dataset.retryLimitReached === "true";
@@ -1811,6 +1814,7 @@ async function sendMessage() {
const message = msgInput.value.trim(); const message = msgInput.value.trim();
if (!message) return; if (!message) return;
const webSearch = webSearchToggle.enabled; const webSearch = webSearchToggle.enabled;
const reasoning = reasoningToggle.enabled;
if (!activeChatId) await createNewChat(); if (!activeChatId) await createNewChat();
const currentChat = getChatById(activeChatId); const currentChat = getChatById(activeChatId);
if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") { if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") {
@@ -1825,33 +1829,44 @@ async function sendMessage() {
const loadingBody = loadingMsg.querySelector(".msg-content"); const loadingBody = loadingMsg.querySelector(".msg-content");
const loadingShell = loadingMsg.querySelector(".loading-shell"); const loadingShell = loadingMsg.querySelector(".loading-shell");
const loadingLabel = loadingMsg.querySelector(".loading-copy strong"); const loadingLabel = loadingMsg.querySelector(".loading-copy strong");
if (webSearch) loadingLabel.textContent = "Searching the web..."; loadingLabel.textContent = webSearch ? "Searching the web..." : "Generating response...";
try { try {
let streamError = null; let streamError = null;
await stream("/chat/stream", {message, model: modelSelect.value, chatId: activeChatId, webSearch}, { await stream("/chat/stream", {message, model: modelSelect.value, chatId: activeChatId, webSearch, reasoning}, {
onStatus: ({status, sourceCount}) => { onStatus: ({status, sourceCount}) => {
webSearchToggle.reset(); if (status === "accepted") {
webSearchToggle.reset();
reasoningToggle.reset();
return;
}
loadingLabel.textContent = status === "searching" ? "Searching the web..." loadingLabel.textContent = status === "searching" ? "Searching the web..."
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response..."; : sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
}, },
onThinking: ({fullThinking}) => { onThinking: ({fullThinking}) => {
if (!reasoning) return;
const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
updateReasoning(loadingMsg, fullThinking, true); updateReasoning(loadingMsg, fullThinking, true);
loadingLabel.textContent = "Thinking…"; loadingShell?.classList.toggle("hidden", Boolean(fullThinking));
if (follow) scrollMessagesToBottom();
}, },
onChunk: ({fullReply}) => { onChunk: ({fullReply}) => {
const reasoningSummary = loadingMsg.querySelector(".msg-reasoning summary"); const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
if (reasoningSummary) reasoningSummary.textContent = "Reasoning"; if (fullReply) finishReasoning(loadingMsg);
if (loadingBody) { if (loadingBody) {
loadingShell?.classList.add("hidden"); loadingShell?.classList.toggle("hidden", Boolean(fullReply));
loadingLabel.textContent = "Generating response...";
loadingBody.classList.add("has-content"); loadingBody.classList.add("has-content");
setMessageContent(loadingBody, fullReply); setMessageContent(loadingBody, fullReply);
} }
if (follow) scrollMessagesToBottom();
}, },
onDone: () => { webSearchToggle.reset(); }, onDone: () => { webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg); },
onError: ({error}) => { onError: ({error}) => {
streamError = error || "Failed to generate reply."; streamError = error || "Failed to generate reply.";
} }
}); });
const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
const previousScrollTop = messagesDiv.scrollTop;
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
if (streamError) { if (streamError) {
if (webSearch) { if (webSearch) {
@@ -1865,15 +1880,17 @@ async function sendMessage() {
await setActiveChat(activeChatId); await setActiveChat(activeChatId);
await loadSummary(); await loadSummary();
markOnboardingCompleted(); markOnboardingCompleted();
if (!followOutput) requestAnimationFrame(() => { messagesDiv.scrollTop = previousScrollTop; });
maybeShowPersonaNudge(); maybeShowPersonaNudge();
setNotice("Reply received.", "success"); setNotice("Reply received.", "success");
} catch { } catch (error) {
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
if (webSearch) { if (webSearch) {
msgInput.value = message; msgInput.value = message;
resizeComposerInput(); resizeComposerInput();
} }
addMessage("[Error: Network error]"); setNotice("Network error while sending message.", "error"); const errorMessage = error?.message || "Network error while sending message.";
addMessage(`[Error: ${errorMessage}]`); setNotice(errorMessage, "error");
} finally { } finally {
setLoadingState(false); msgInput.focus(); updateChatActionState(); setLoadingState(false); msgInput.focus(); updateChatActionState();
} }
+5 -3
View File
@@ -456,9 +456,11 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
.input-meta, .input-container { width: min(940px, 100%); margin-inline: auto; } .input-meta, .input-container { width: min(940px, 100%); margin-inline: auto; }
.input-meta { margin-bottom: 5px; padding-left: 10px; } .input-meta { margin-bottom: 5px; padding-left: 10px; }
.composer-hint { margin: 0; color: var(--text-muted); font-size: .72rem; } .composer-hint { margin: 0; color: var(--text-muted); font-size: .72rem; }
.input-container { padding: 7px; display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px; grid-template-areas: "search message send"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); } .input-container { padding: 7px; display: grid; grid-template-columns: 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "search reasoning message send"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); }
#webSearch { grid-area: search; width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; } #webSearch, #reasoning { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; }
#webSearch[aria-pressed="true"] { color: var(--brand-strong); background: var(--brand-soft); border-color: var(--brand); box-shadow: inset 0 0 0 1px var(--brand); } #webSearch { grid-area: search; }
#reasoning { grid-area: reasoning; }
#webSearch[aria-pressed="true"], #reasoning[aria-pressed="true"] { color: var(--brand-strong); background: var(--brand-soft); border-color: var(--brand); box-shadow: inset 0 0 0 1px var(--brand); }
.msg-sources { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--border); min-width: 0; font-size: .8rem; } .msg-sources { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--border); min-width: 0; font-size: .8rem; }
.msg-sources-heading { color: var(--text-muted); margin-bottom: 6px; } .msg-sources-heading { color: var(--text-muted); margin-bottom: 6px; }
.msg-source { display: flex; min-width: 0; flex-direction: column; gap: 2px; margin-top: 6px; } .msg-source { display: flex; min-width: 0; flex-direction: column; gap: 2px; margin-top: 6px; }
+2
View File
@@ -31,6 +31,8 @@ const files = [
"public/market.js", "public/market.js",
"public/app/api.js", "public/app/api.js",
"public/app/webSearch.js", "public/app/webSearch.js",
"public/app/reasoning.js",
"public/app/modelCapabilities.js",
"public/app/chatDrawer.js", "public/app/chatDrawer.js",
"public/app/constants.js", "public/app/constants.js",
"public/app/dom.js", "public/app/dom.js",
+4
View File
@@ -54,6 +54,10 @@ export function createConfig(overrides = {}) {
chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5), chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5),
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api", modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
modelRequestTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), modelRequestTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000),
modelConnectTimeoutMs: Math.max(1, int(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000)),
modelInactivityTimeoutMs: Math.max(1, int(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000))),
modelMaxGenerationMs: Math.max(1, int(process.env.MODEL_MAX_GENERATION_MS, 600000)),
ollamaKeepAlive: process.env.OLLAMA_KEEP_ALIVE || "30m",
searxngUrl: process.env.SEARXNG_URL || "", searxngUrl: process.env.SEARXNG_URL || "",
webSearchTimeoutMs: Math.max(1, int(process.env.WEB_SEARCH_TIMEOUT_MS, 8000)), webSearchTimeoutMs: Math.max(1, int(process.env.WEB_SEARCH_TIMEOUT_MS, 8000)),
modelListTimeoutMs: int(process.env.MODEL_LIST_TIMEOUT_MS, 4000), modelListTimeoutMs: int(process.env.MODEL_LIST_TIMEOUT_MS, 4000),
+7 -3
View File
@@ -22,7 +22,8 @@ const sendMessageValidator = (body) => {
if (!chatId) return {error: "chatId is required"}; if (!chatId) return {error: "chatId is required"};
if (!message) return {error: "message is required"}; if (!message) return {error: "message is required"};
if (body.webSearch !== undefined && typeof body.webSearch !== "boolean") return {error: "webSearch must be a boolean"}; if (body.webSearch !== undefined && typeof body.webSearch !== "boolean") return {error: "webSearch must be a boolean"};
return {value: {message, chatId, model: String(body?.model || "qwen3:14b"), webSearch: body.webSearch === true}}; if (body.reasoning !== undefined && typeof body.reasoning !== "boolean") return {error: "reasoning must be a boolean"};
return {value: {message, chatId, model: String(body?.model || "qwen3:14b"), webSearch: body.webSearch === true, reasoning: body.reasoning === true}};
}; };
const organizationValidator = (body) => ({ const organizationValidator = (body) => ({
@@ -236,7 +237,8 @@ export function createChatsRouter({repositories, chatService, modelService, conf
targetMessage, targetMessage,
content: req.validatedBody.message, content: req.validatedBody.message,
model: req.validatedBody.model, model: req.validatedBody.model,
webSearch: req.validatedBody.webSearch webSearch: req.validatedBody.webSearch,
reasoning: req.validatedBody.reasoning
}); });
if (result.error) return res.status(result.status).json({error: result.error}); if (result.error) return res.status(result.status).json({error: result.error});
return res.json(result); return res.json(result);
@@ -431,7 +433,8 @@ export function createChatsRouter({repositories, chatService, modelService, conf
res.writeHead(200, { res.writeHead(200, {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform", "Cache-Control": "no-cache, no-transform",
Connection: "keep-alive" Connection: "keep-alive",
"X-Accel-Buffering": "no"
}); });
const sendEvent = (event, data) => { const sendEvent = (event, data) => {
@@ -449,6 +452,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}), onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}),
onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}), onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}),
webSearch: req.validatedBody.webSearch, webSearch: req.validatedBody.webSearch,
reasoning: req.validatedBody.reasoning,
onStatus: (status) => sendEvent("status", status), onStatus: (status) => sendEvent("status", status),
signal: generationController.signal signal: generationController.signal
}); });
+25 -14
View File
@@ -19,6 +19,7 @@ import {
} from "./chatMessageHelpers.js"; } from "./chatMessageHelpers.js";
import {SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, selectSearchResults, MAX_CANDIDATES} from "./webSearchService.js"; import {SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, selectSearchResults, MAX_CANDIDATES} from "./webSearchService.js";
import {supportsReasoning} from "../../public/app/modelCapabilities.js";
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config)) { export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config)) {
const chatGenerationQueues = new Map(); const chatGenerationQueues = new Map();
@@ -192,21 +193,25 @@ export function createChatService(repositories, modelService, config, webSearchP
], {onThinking}); ], {onThinking});
}; };
const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona, signal = null, generation = {}, onThinking = null}) => { const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona, signal = null, generation = {}, onThinking = null, onChunk = null, reasoning = false}) => {
let reply = await modelService.generateReply(selectedModel, messagesPayload, {signal, generation, onThinking}); const generate = (messages) => onChunk
? modelService.streamReply(selectedModel, messages, onChunk, {signal, generation, onThinking, reasoning})
: modelService.generateReply(selectedModel, messages, {signal, generation, onThinking, reasoning});
let reply = await generate(messagesPayload);
if (reply && !containsUserVoiceInRoleplayOpener(reply, userPersona)) { if (reply && !containsUserVoiceInRoleplayOpener(reply, userPersona)) {
return reply; return reply;
} }
onThinking?.("", ""); onThinking?.("", "");
reply = await modelService.generateReply(selectedModel, [ onChunk?.("", "");
reply = await generate([
...messagesPayload, ...messagesPayload,
{role: "assistant", content: reply || ""}, {role: "assistant", content: reply || ""},
{ {
role: "user", role: "user",
content: "Rewrite your last reply. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, or actions. Stop before the user's reply." content: "Rewrite your last reply. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, or actions. Stop before the user's reply."
} }
], {signal, generation, onThinking}); ]);
if (!reply || containsUserVoiceInRoleplayOpener(reply, userPersona)) { if (!reply || containsUserVoiceInRoleplayOpener(reply, userPersona)) {
return null; return null;
@@ -320,7 +325,7 @@ export function createChatService(repositories, modelService, config, webSearchP
return {fullReply, thinking, promptMessageId: promptMessage.id}; return {fullReply, thinking, promptMessageId: promptMessage.id};
}; };
const generateChatTurn = ({user, chatId, message, model, webSearch = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined}) => const generateChatTurn = ({user, chatId, message, model, webSearch = false, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined}) =>
withChatGenerationLock(chatId, async () => { withChatGenerationLock(chatId, async () => {
await (summaryQueues.get(chatId) || Promise.resolve()); await (summaryQueues.get(chatId) || Promise.resolve());
const session = repositories.getChat(chatId, user); const session = repositories.getChat(chatId, user);
@@ -337,17 +342,22 @@ export function createChatService(repositories, modelService, config, webSearchP
return {error: "Branch point not found", status: 404}; return {error: "Branch point not found", status: 404};
} }
const {model: effectiveModel, generation} = getGenerationSettings(session, model); const {model: effectiveModel, generation} = getGenerationSettings(session, model);
const useReasoning = reasoning === true && supportsReasoning(effectiveModel);
signal?.throwIfAborted();
const inserted = repositories.insertBranchChatMessage(chatId, "user", message, null, null, "pending", null, effectiveParentId); const inserted = repositories.insertBranchChatMessage(chatId, "user", message, null, null, "pending", null, effectiveParentId);
const userMessageId = Number(inserted?.lastInsertRowid); const userMessageId = Number(inserted?.lastInsertRowid);
try { try {
let sources = null; let sources = null;
let thinking = ""; let thinking = "";
const captureThinking = (chunk, fullThinking) => { const captureThinking = (chunk, fullThinking) => {
if (!useReasoning) return;
thinking = fullThinking; thinking = fullThinking;
onThinking?.(chunk, fullThinking); onThinking?.(chunk, fullThinking);
}; };
let payload; let payload;
const fullReply = await withActiveGeneration(chatId, signal, async (activeSignal) => { const fullReply = await withActiveGeneration(chatId, signal, async (activeSignal) => {
activeSignal.throwIfAborted();
onStatus?.({status: "accepted"});
if (webSearch) { if (webSearch) {
const searchDecision = decideWebSearch(message); const searchDecision = decideWebSearch(message);
let searchQuery = null; let searchQuery = null;
@@ -364,7 +374,6 @@ export function createChatService(repositories, modelService, config, webSearchP
candidateCount = candidates.length; candidateCount = candidates.length;
sources = selectSearchResults(message, candidates, searchDecision); sources = selectSearchResults(message, candidates, searchDecision);
selectedCount = sources.length; selectedCount = sources.length;
onStatus?.({status: "generating", sourceCount: selectedCount});
} }
if (config.structuredRequestLoggingEnabled) { if (config.structuredRequestLoggingEnabled) {
console.log(JSON.stringify({ console.log(JSON.stringify({
@@ -379,6 +388,7 @@ export function createChatService(repositories, modelService, config, webSearchP
})); }));
} }
} }
onStatus?.({status: "generating", ...(sources !== null ? {sourceCount: sources.length} : {})});
payload = buildConversationContext({user, chatId, session, message: null, sources}); payload = buildConversationContext({user, chatId, session, message: null, sources});
return payload.activePersona return payload.activePersona
? generateValidatedRoleplayReply({ ? generateValidatedRoleplayReply({
@@ -387,18 +397,19 @@ export function createChatService(repositories, modelService, config, webSearchP
userPersona: payload.activeUserPersona, userPersona: payload.activeUserPersona,
signal: activeSignal, signal: activeSignal,
generation, generation,
onThinking: captureThinking onThinking: captureThinking,
onChunk,
reasoning: useReasoning
}) })
: onChunk : onChunk
? modelService.streamReply(effectiveModel, payload.messagesPayload, onChunk, {signal: activeSignal, generation, onThinking: captureThinking}) ? modelService.streamReply(effectiveModel, payload.messagesPayload, onChunk, {signal: activeSignal, generation, onThinking: captureThinking, reasoning: useReasoning})
: modelService.generateReply(effectiveModel, payload.messagesPayload, {signal: activeSignal, generation, onThinking: captureThinking}); : modelService.generateReply(effectiveModel, payload.messagesPayload, {signal: activeSignal, generation, onThinking: captureThinking, reasoning: useReasoning});
}); });
if (!fullReply) { if (!fullReply) {
repositories.failChatTurn(chatId, userMessageId, "Failed to generate a valid character reply", webSearch ? userMessageId : effectiveParentId); repositories.failChatTurn(chatId, userMessageId, "Failed to generate a valid character reply", webSearch ? userMessageId : effectiveParentId);
return {error: "Failed to generate a valid character reply", status: 500}; return {error: "Failed to generate a valid character reply", status: 500};
} }
if (payload.activePersona && onChunk) onChunk(fullReply, fullReply);
const retryState = { const retryState = {
retryVariants: [fullReply], retryVariants: [fullReply],
@@ -508,11 +519,11 @@ export function createChatService(repositories, modelService, config, webSearchP
containsUserVoiceInRoleplayOpener, containsUserVoiceInRoleplayOpener,
persistChatMessageRetryState, persistChatMessageRetryState,
buildMessagesPayload: buildConversationContext, buildMessagesPayload: buildConversationContext,
async sendChatMessage({user, chatId, message, model, webSearch = false}) { async sendChatMessage({user, chatId, message, model, webSearch = false, reasoning = false}) {
return generateChatTurn({user, chatId, message, model, webSearch}); return generateChatTurn({user, chatId, message, model, webSearch, reasoning});
}, },
async streamChatMessage({user, chatId, message, model, webSearch = false, onStatus, onChunk, onThinking, signal = null}) { async streamChatMessage({user, chatId, message, model, webSearch = false, reasoning = false, onStatus, onChunk, onThinking, signal = null}) {
return generateChatTurn({user, chatId, message, model, webSearch, onStatus, onChunk, onThinking, signal}); return generateChatTurn({user, chatId, message, model, webSearch, reasoning, onStatus, onChunk, onThinking, signal});
}, },
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) { async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) {
return withChatGenerationLock(chatId, async () => { return withChatGenerationLock(chatId, async () => {
+84 -28
View File
@@ -1,4 +1,4 @@
import {execFile} from "child_process"; import {supportsReasoning} from "../../public/app/modelCapabilities.js";
class ModelServiceError extends Error { class ModelServiceError extends Error {
constructor(code, message, status = 500) { constructor(code, message, status = 500) {
@@ -41,9 +41,8 @@ export function createModelService(config, overrides = {}) {
if (state.timer) clearTimeout(state.timer); if (state.timer) clearTimeout(state.timer);
state.timer = setTimeout(() => { state.timer = setTimeout(() => {
if (state.activeRequests > 0) return; if (state.activeRequests > 0) return;
execFile("ollama", ["stop", model], () => { // Ollama owns unloading through keep_alive; only clear local bookkeeping.
modelState.delete(model); modelState.delete(model);
});
}, config.modelUnloadAfterMs); }, config.modelUnloadAfterMs);
state.timer.unref?.(); state.timer.unref?.();
}; };
@@ -74,7 +73,37 @@ export function createModelService(config, overrides = {}) {
} }
}; };
const readStreamingReply = async (response, onChunk = null, onThinking = null) => { const withGenerationTimeout = async (callback, externalSignal) => {
const controller = new AbortController();
let timedOut = false;
let idleTimer;
const expire = () => { timedOut = true; controller.abort(); };
const touch = (ms = config.modelInactivityTimeoutMs ?? config.modelRequestTimeoutMs ?? 180000) => {
clearTimeout(idleTimer);
idleTimer = setTimeout(expire, ms);
};
const abort = () => controller.abort();
if (externalSignal?.aborted) abort();
else externalSignal?.addEventListener("abort", abort, {once: true});
touch(config.modelConnectTimeoutMs ?? 30000);
const maxTimer = setTimeout(expire, config.modelMaxGenerationMs ?? 600000);
try {
controller.signal.throwIfAborted();
return await callback(controller.signal, touch);
} catch (error) {
if (controller.signal.aborted) {
throw new ModelServiceError(timedOut ? "MODEL_TIMEOUT" : "MODEL_CANCELLED",
timedOut ? "Model backend timed out." : "Model request was cancelled.", timedOut ? 504 : 499);
}
throw error;
} finally {
clearTimeout(idleTimer);
clearTimeout(maxTimer);
externalSignal?.removeEventListener("abort", abort);
}
};
const readStreamingReply = async (response, onChunk = null, onThinking = null, touch = () => {}, signal = null) => {
if (!response.body) { if (!response.body) {
throw new ModelServiceError("MODEL_BAD_RESPONSE", "Model backend returned no body.", 502); throw new ModelServiceError("MODEL_BAD_RESPONSE", "Model backend returned no body.", 502);
} }
@@ -84,9 +113,18 @@ export function createModelService(config, overrides = {}) {
let buffer = ""; let buffer = "";
let fullReply = ""; let fullReply = "";
let fullThinking = ""; let fullThinking = "";
let completed = false;
const consume = (line) => { const consume = (line) => {
const data = JSON.parse(line); const data = JSON.parse(line);
if (data.message?.thinking) { if (!data || typeof data !== "object" || data.error) {
throw new ModelServiceError("MODEL_BAD_RESPONSE", "Model backend returned an invalid stream.", 502);
}
for (const field of ["content", "thinking"]) {
if (data.message?.[field] != null && typeof data.message[field] !== "string") {
throw new ModelServiceError("MODEL_BAD_RESPONSE", "Model backend returned an invalid stream.", 502);
}
}
if (data.message?.thinking && onThinking) {
fullThinking += data.message.thinking; fullThinking += data.message.thinking;
onThinking?.(data.message.thinking, fullThinking); onThinking?.(data.message.thinking, fullThinking);
} }
@@ -94,29 +132,43 @@ export function createModelService(config, overrides = {}) {
fullReply += data.message.content; fullReply += data.message.content;
onChunk?.(data.message.content, fullReply); onChunk?.(data.message.content, fullReply);
} }
if (data.done === true) completed = true;
}; };
while (true) { const cancel = () => { void reader.cancel().catch(() => {}); };
const {value, done} = await reader.read(); signal?.addEventListener("abort", cancel, {once: true});
if (done) break; try {
buffer += decoder.decode(value, {stream: true}); while (!completed) {
const lines = buffer.split("\n"); signal?.throwIfAborted();
buffer = lines.pop() || ""; const {value, done} = await reader.read();
for (const line of lines) { signal?.throwIfAborted();
const trimmed = line.trim(); if (done) break;
if (!trimmed) continue; touch();
consume(trimmed); buffer += decoder.decode(value, {stream: true});
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
consume(trimmed);
if (completed) break;
}
} }
}
if (buffer.trim()) { buffer += decoder.decode();
consume(buffer.trim()); if (!completed && buffer.trim()) {
consume(buffer.trim());
}
if (!completed) throw new ModelServiceError("MODEL_INTERRUPTED", "Model response was interrupted.", 502);
return fullReply.trim();
} finally {
signal?.removeEventListener("abort", cancel);
await reader.cancel().catch(() => {});
reader.releaseLock();
} }
return fullReply.trim();
}; };
const buildRequestBody = (model, messagesPayload, generation = {}) => { const buildRequestBody = (model, messagesPayload, generation = {}, reasoning = false) => {
const options = {}; const options = {};
if (generation.temperature != null) options.temperature = generation.temperature; if (generation.temperature != null) options.temperature = generation.temperature;
if (generation.contextLength != null) options.num_ctx = generation.contextLength; if (generation.contextLength != null) options.num_ctx = generation.contextLength;
@@ -127,6 +179,8 @@ export function createModelService(config, overrides = {}) {
// retries, roleplay openers, and summaries) receives runtime date context. // retries, roleplay openers, and summaries) receives runtime date context.
messages: [buildRuntimeDateContext(), ...messagesPayload], messages: [buildRuntimeDateContext(), ...messagesPayload],
stream: true, stream: true,
think: reasoning === true && supportsReasoning(model),
keep_alive: config.ollamaKeepAlive ?? "30m",
...(Object.keys(options).length ? {options} : {}) ...(Object.keys(options).length ? {options} : {})
}; };
}; };
@@ -138,11 +192,11 @@ export function createModelService(config, overrides = {}) {
try { try {
const startedAt = Date.now(); const startedAt = Date.now();
const fullReply = await withTimeout(config.modelRequestTimeoutMs, async (signal) => { const fullReply = await withGenerationTimeout(async (signal, touch) => {
const response = await fetchImpl(`${config.modelApiBaseUrl}/chat`, { const response = await fetchImpl(`${config.modelApiBaseUrl}/chat`, {
method: "POST", method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify(buildRequestBody(model, messagesPayload, options.generation)), body: JSON.stringify(buildRequestBody(model, messagesPayload, options.generation, options.reasoning)),
signal signal
}); });
@@ -150,7 +204,8 @@ export function createModelService(config, overrides = {}) {
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502); throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
} }
return readStreamingReply(response, null, options.onThinking); touch();
return readStreamingReply(response, null, options.reasoning === true && supportsReasoning(model) ? options.onThinking : null, touch, signal);
}, options.signal); }, options.signal);
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt}; lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt};
return fullReply; return fullReply;
@@ -176,11 +231,11 @@ export function createModelService(config, overrides = {}) {
try { try {
const startedAt = Date.now(); const startedAt = Date.now();
const fullReply = await withTimeout(config.modelRequestTimeoutMs, async (signal) => { const fullReply = await withGenerationTimeout(async (signal, touch) => {
const response = await fetchImpl(`${config.modelApiBaseUrl}/chat`, { const response = await fetchImpl(`${config.modelApiBaseUrl}/chat`, {
method: "POST", method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify(buildRequestBody(model, messagesPayload, options.generation)), body: JSON.stringify(buildRequestBody(model, messagesPayload, options.generation, options.reasoning)),
signal signal
}); });
@@ -188,7 +243,8 @@ export function createModelService(config, overrides = {}) {
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502); throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
} }
return readStreamingReply(response, onChunk, options.onThinking); touch();
return readStreamingReply(response, onChunk, options.reasoning === true && supportsReasoning(model) ? options.onThinking : null, touch, signal);
}, options.signal); }, options.signal);
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt}; lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt};
return fullReply; return fullReply;
+5 -4
View File
@@ -82,7 +82,7 @@ test("every Ollama request includes dynamic server date context", async () => {
}, { }, {
fetch: async (_url, options) => { fetch: async (_url, options) => {
requestBody = JSON.parse(options.body); requestBody = JSON.parse(options.body);
return new Response('{"message":{"content":"ok"}}\n', {status: 200}); return new Response('{"message":{"content":"ok"},"done":true}\n', {status: 200});
} }
}); });
assert.equal(await modelService.generateReply("test:model", [{role: "user", content: "hello"}]), "ok"); assert.equal(await modelService.generateReply("test:model", [{role: "user", content: "hello"}]), "ok");
@@ -192,9 +192,10 @@ test("chat search integrates with streaming, persistence, personas, settings, re
const [event, data] = block.split("\n"); const [event, data] = block.split("\n");
events.push({event: event.slice(7), data: JSON.parse(data.slice(6))}); events.push({event: event.slice(7), data: JSON.parse(data.slice(6))});
} }
assert.deepEqual(events.map((event) => event.event), ["status", "status", "chunk", "chunk", "done"]); assert.deepEqual(events.map((event) => event.event), ["status", "status", "status", "chunk", "chunk", "done"]);
assert.equal(events[0].data.status, "searching"); assert.equal(events[0].data.status, "accepted");
assert.deepEqual(events[1].data, {status: "generating", sourceCount: 1}); assert.equal(events[1].data.status, "searching");
assert.deepEqual(events[2].data, {status: "generating", sourceCount: 1});
assert.deepEqual(events.at(-1).data.sources, [source]); assert.deepEqual(events.at(-1).data.sources, [source]);
assert.equal(searchCount, 1); assert.equal(searchCount, 1);
assert.equal(lastSearchQuery, "major news events September 10, 2025 2025-09-10"); assert.equal(lastSearchQuery, "major news events September 10, 2025 2025-09-10");