show reasoning in frontend and some other stuff
This commit is contained in:
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user