diff --git a/public/app/modelCapabilities.js b/public/app/modelCapabilities.js new file mode 100644 index 0000000..3dc8745 --- /dev/null +++ b/public/app/modelCapabilities.js @@ -0,0 +1,6 @@ +// Conservative allowlist for models supporting the boolean think API. +// Unknown models remain usable for normal chat. +export function supportsReasoning(model) { + const id = String(typeof model === "string" ? model : model?.model || model?.name || "").toLowerCase().split("/").at(-1); + return /^(qwen3|deepseek-r1|deepseek-v3\.1)(:|$)/.test(id); +} diff --git a/public/app/reasoning.js b/public/app/reasoning.js new file mode 100644 index 0000000..745c07d --- /dev/null +++ b/public/app/reasoning.js @@ -0,0 +1,57 @@ +import {supportsReasoning} from "./modelCapabilities.js"; + +export function createReasoningToggle(button) { + let reasoningEnabled = false; + let supported = false; + let busy = false; + const render = () => { + button.disabled = !supported || busy; + button.setAttribute("aria-pressed", String(reasoningEnabled)); + button.title = !supported ? "This model does not support reasoning" + : reasoningEnabled ? "Reasoning enabled" : "Enable reasoning"; + button.setAttribute("aria-label", button.title); + }; + button.addEventListener("click", () => { + if (!supported || busy) return; + reasoningEnabled = !reasoningEnabled; + render(); + }); + render(); + return { + get enabled() { return reasoningEnabled; }, + reset() { reasoningEnabled = false; render(); }, + setModel(model) { supported = supportsReasoning(model); if (!supported) reasoningEnabled = false; render(); }, + setBusy(value) { busy = value; render(); } + }; +} + +export function updateReasoning(messageElement, thinking, streaming = false) { + let details = messageElement.querySelector(".msg-reasoning"); + if (!thinking) { + details?.remove(); + return; + } + if (!details) { + details = document.createElement("details"); + details.className = "msg-reasoning"; + details.open = streaming; + const summary = document.createElement("summary"); + const content = document.createElement("div"); + content.className = "msg-reasoning-content"; + details.append(summary, content); + messageElement.insertBefore(details, messageElement.querySelector(".msg-content")); + } + details.querySelector("summary").textContent = streaming ? "Reasoning · Thinking…" : "Reasoning"; + const content = details.querySelector(".msg-reasoning-content"); + const follow = content.scrollHeight - content.scrollTop - content.clientHeight < 40; + content.textContent = thinking; + if (streaming && follow) content.scrollTop = content.scrollHeight; +} + +export function finishReasoning(messageElement) { + const details = messageElement.querySelector(".msg-reasoning"); + if (!details || details.dataset.complete) return; + details.dataset.complete = "true"; + details.open = false; + details.querySelector("summary").textContent = "Reasoning · Completed"; +} diff --git a/test/modelStreaming.test.js b/test/modelStreaming.test.js new file mode 100644 index 0000000..b0f0551 --- /dev/null +++ b/test/modelStreaming.test.js @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {createServer} from "node:http"; +import {createModelService} from "../src/services/modelService.js"; +import {createApp} from "../src/app.js"; +import {stream} from "../public/app/api.js"; + +const config = { + modelApiBaseUrl: "http://fixture", modelRequestTimeoutMs: 20, + modelConnectTimeoutMs: 1000, modelInactivityTimeoutMs: 1000, + modelMaxGenerationMs: 3000, modelUnloadAfterMs: 60000 +}; +const line = data => new TextEncoder().encode(JSON.stringify(data) + "\n"); + +test("active tokens outlive the legacy total timeout and arrive before completion", async () => { + let finish; + let first; + const firstChunk = new Promise(resolve => { first = resolve; }); + let interval; + const service = createModelService(config, {fetch: async () => new Response(new ReadableStream({ + start(controller) { + interval = setInterval(() => controller.enqueue(line({message: {content: "x"}})), 15); + finish = () => { clearInterval(interval); controller.enqueue(line({done: true})); controller.close(); }; + }, + cancel() { clearInterval(interval); } + }))}); + let completed = false; + const pending = service.streamReply("qwen3:14b", [], () => first()).then(reply => { completed = true; return reply; }); + try { + await firstChunk; + assert.equal(completed, false); + await new Promise(resolve => setTimeout(resolve, 80)); + assert.equal(completed, false); + finish(); + assert.ok((await pending).length >= 2); + } finally { clearInterval(interval); } +}); + +test("connection, inactivity and overall deadlines abort generation", async (t) => { + for (const kind of ["connection", "inactivity", "overall"]) await t.test(kind, async () => { + let cancelled = false; + let interval; + const service = createModelService({ + ...config, + modelConnectTimeoutMs: kind === "connection" ? 25 : 1000, + modelInactivityTimeoutMs: kind === "inactivity" ? 25 : 1000, + modelMaxGenerationMs: kind === "overall" ? 70 : 1000 + }, {fetch: async (_url, {signal}) => { + if (kind === "connection") return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { cancelled = true; reject(new DOMException("Aborted", "AbortError")); }); + }); + return new Response(new ReadableStream({ + start(controller) { + if (kind === "overall") interval = setInterval(() => controller.enqueue(line({message: {content: "x"}})), 10); + }, + cancel() { cancelled = true; clearInterval(interval); } + })); + }}); + await assert.rejects(service.streamReply("qwen3:14b", [], () => {}), {code: "MODEL_TIMEOUT"}); + assert.equal(cancelled, true); + }); +}); + +test("stream validation rejects truncated output, upstream errors and invalid fields", async () => { + for (const body of [ + '{"message":{"content":"partial"}}\n', + '{"error":"internal upstream details"}\n', + '{"message":{"content":42},"done":true}\n', + '{broken}\n' + ]) { + const service = createModelService(config, {fetch: async () => new Response(body)}); + await assert.rejects(service.generateReply("qwen3:14b", [])); + } + const service = createModelService(config, {fetch: async () => { throw new Error("Connection refused"); }}); + await assert.rejects(service.generateReply("qwen3:14b", []), {code: "MODEL_UNAVAILABLE"}); +}); + +test("SSE EOF is a failure rather than successful completion", async (t) => { + const original = globalThis.fetch; + t.after(() => { globalThis.fetch = original; }); + globalThis.fetch = async () => new Response('event: chunk\ndata: {"fullReply":"partial"}\n\n'); + const chunks = []; + await assert.rejects(stream("/fixture", {}, {onChunk: data => chunks.push(data.fullReply)}), /interrupted/); + assert.deepEqual(chunks, ["partial"]); +}); + +test("browser disconnect aborts Ollama and incomplete output is not persisted", async (t) => { + let signal; + let releaseAbort; + const aborted = new Promise(resolve => { releaseAbort = resolve; }); + const app = createApp({config: { + dbPath: ":memory:", testMode: true, sessionSecret: "test-only", + chatSummaryUpdateEveryMessages: 0, structuredRequestLoggingEnabled: false, + eventLoopLagMonitorEnabled: false, slowRequestLoggingEnabled: false + }, modelOverrides: {fetch: async (_url, options) => { + signal = options.signal; + signal.addEventListener("abort", releaseAbort, {once: true}); + return new Response(new ReadableStream({start(controller) { + controller.enqueue(line({message: {content: "partial"}})); + }})); + }}}); + const server = createServer(app); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + await new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }); + app.locals.close(); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const registered = await fetch(base + "/register", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({username: "disconnect", password: "test-password"})}); + const cookie = registered.headers.get("set-cookie").split(";")[0]; + const headers = {"Content-Type": "application/json", Cookie: cookie}; + const chat = await (await fetch(base + "/chats", {method: "POST", headers, body: '{"title":"Disconnect"}'})).json(); + const controller = new AbortController(); + const response = await fetch(base + "/chat/stream", { + method: "POST", headers, signal: controller.signal, + body: JSON.stringify({chatId: chat.chat.id, message: "Hello"}) + }); + const reader = response.body.getReader(); + let received = ""; + while (!received.includes("event: chunk")) received += new TextDecoder().decode((await reader.read()).value); + controller.abort(); + await Promise.race([aborted, new Promise((_resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Upstream was not aborted")), 1000); + timer.unref(); + })]); + assert.equal(signal.aborted, true); + assert.equal(app.locals.db.prepare("SELECT COUNT(*) AS count FROM chat_messages WHERE role = 'bot'").get().count, 0); +}); diff --git a/test/reasoning.test.js b/test/reasoning.test.js new file mode 100644 index 0000000..1dd51ff --- /dev/null +++ b/test/reasoning.test.js @@ -0,0 +1,190 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {createServer} from "node:http"; +import {createApp} from "../src/app.js"; +import {createModelService} from "../src/services/modelService.js"; +import {stream} from "../public/app/api.js"; +import {createReasoningToggle, finishReasoning, updateReasoning} from "../public/app/reasoning.js"; + +test("thinking streams separately and survives reload, retry, branch and import", async (t) => { + const requests = []; + let searches = 0; + const searchServer = createServer((_req, res) => { + searches++; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({results: [{title: "Latest news", url: "https://example.org/news", content: "Current news report"}]})); + }); + await new Promise(resolve => searchServer.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise(resolve => { searchServer.closeAllConnections(); searchServer.close(resolve); })); + const modelService = createModelService({ + modelApiBaseUrl: "http://fixture", modelRequestTimeoutMs: 1000, modelUnloadAfterMs: 60000 + }, {fetch: async (_url, options) => { + requests.push(JSON.parse(options.body)); + const chunks = [ + '{"message":{"thinking":"Consider "}}\n{"message":{"thi', + 'nking":"carefully.","content":""}}\n', + '{"message":{"content":"Answer"},"done":true}' + ]; + return new Response(new ReadableStream({start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); + controller.close(); + }})); + }}); + const app = createApp({modelService, config: { + dbPath: ":memory:", testMode: true, sessionSecret: "test-only", + searxngUrl: `http://127.0.0.1:${searchServer.address().port}`, + chatSummaryUpdateEveryMessages: 0, eventLoopLagMonitorEnabled: false, + structuredRequestLoggingEnabled: false, slowRequestLoggingEnabled: false + }}); + const server = createServer(app); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + await new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }); + app.locals.close(); + }); + const base = `http://127.0.0.1:${server.address().port}`; + let cookie; + const api = async (path, body) => { + const response = await fetch(base + path, { + method: body ? "POST" : "GET", + headers: {"Content-Type": "application/json", ...(cookie ? {Cookie: cookie} : {})}, + body: body ? JSON.stringify(body) : undefined + }); + if (response.headers.get("set-cookie")) cookie = response.headers.get("set-cookie").split(";")[0]; + assert.equal(response.status, 200); + return response.json(); + }; + await api("/register", {username: "thinker", password: "test-password"}); + const chatId = (await api("/chats", {title: "Reasoning"})).chat.id; + const response = await fetch(base + "/chat/stream", { + method: "POST", headers: {"Content-Type": "application/json", Cookie: cookie}, + body: JSON.stringify({chatId, message: "Hello", reasoning: true}) + }); + const wire = await response.text(); + const events = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(wire); + try { + await stream("/fixture", {}, { + onThinking: data => events.push(["thinking", data.fullThinking]), + onChunk: data => events.push(["answer", data.fullReply]) + }); + } finally { globalThis.fetch = originalFetch; } + assert.deepEqual(events, [["thinking", "Consider "], ["thinking", "Consider carefully."], ["answer", "Answer"]]); + const bot = (await api(`/chats/${chatId}/messages`)).messages.at(-1); + assert.equal(bot.thinking, "Consider carefully."); + assert.equal(bot.content, "Answer"); + const retry = await api(`/chats/${chatId}/messages/${bot.id}/retry`, {}); + assert.equal(retry.message.thinking, undefined); + const branch = await api(`/chats/${chatId}/messages/${bot.id}/branch`, {confirmed: true}); + assert.equal(branch.messages.at(-1).thinking, bot.thinking); + const exported = await api(`/exports/chats/${chatId}?format=json`); + assert.equal(exported.messages.find(message => message.id === bot.id).thinking, bot.thinking); + const {workspace} = await api("/exports/workspace"); + await api("/imports/workspace", {workspace, allowDuplicates: true}); + assert.ok(app.locals.db.prepare("SELECT id FROM chat_messages WHERE thinking = ?").all(bot.thinking).length >= 4); + await api("/chat", {chatId, message: "Continue"}); + for (const request of requests) { + assert.ok(request.messages.every(message => !message.content.includes("Consider carefully."))); + assert.equal(request.stream, true); + assert.equal(request.keep_alive, "30m"); + } + assert.equal(requests[0].think, true); + assert.equal(requests.at(-1).think, false); + for (const webSearch of [false, true]) for (const reasoning of [false, true]) { + const response = await fetch(base + "/chat/stream", { + method: "POST", headers: {"Content-Type": "application/json", Cookie: cookie}, + body: JSON.stringify({chatId, message: "What is the latest news today?", model: "qwen3:14b", webSearch, reasoning}) + }); + const wire = await response.text(); + assert.match(wire, /event: done/); + assert.equal(wire.includes("event: thinking"), reasoning); + assert.equal(wire.includes('"status":"searching"'), webSearch); + assert.equal(wire.includes('"sources":['), webSearch); + assert.equal(requests.at(-1).think, reasoning); + assert.equal(requests.at(-1).model, "qwen3:14b"); + const saved = (await api(`/chats/${chatId}/messages`)).messages.at(-1); + assert.equal(saved.thinking, reasoning ? bot.thinking : undefined); + assert.equal(Boolean(saved.sources?.length), webSearch); + } + assert.equal(searches, 2); + await api("/chat", {chatId, message: "Hello", model: "mistral:latest", reasoning: true}); + assert.equal(requests.at(-1).think, false); + assert.equal((await api(`/chats/${chatId}/messages`)).messages.at(-1).thinking, undefined); + const persona = (await api("/personas", {personaType: "assistant", name: "Guide", details: "Patient and direct"})).persona; + const roleplay = await api("/roleplays/start", {assistantPersonaId: persona.id, model: "qwen3:14b"}); + assert.equal(requests.at(-1).think, false); + const roleplayResponse = await fetch(base + "/chat/stream", { + method: "POST", headers: {"Content-Type": "application/json", Cookie: cookie}, + body: JSON.stringify({chatId: roleplay.chat.id, message: "Hello Guide", model: "qwen3:14b", reasoning: true}) + }); + const roleplayWire = await roleplayResponse.text(); + assert.match(roleplayWire, /event: thinking/); + assert.match(roleplayWire, /event: chunk/); + assert.match(roleplayWire, /event: done/); + assert.ok(requests.at(-1).messages.some(message => message.content.includes("Guide"))); + assert.equal(requests.at(-1).think, true); +}); + +test("reasoning disclosure is collapsed, preserves open state and renders text safely", (t) => { + class Element { + constructor(tag) { this.tag = tag; this.children = []; this.open = false; this.dataset = {}; } + append(...children) { this.children.push(...children); } + insertBefore(child, before) { this.children.splice(this.children.indexOf(before), 0, child); } + querySelector(selector) { + for (const child of this.children) { + if (selector === child.tag || selector === "." + child.className) return child; + const nested = child.querySelector(selector); + if (nested) return nested; + } + return null; + } + } + const original = globalThis.document; + globalThis.document = {createElement: tag => new Element(tag)}; + t.after(() => { globalThis.document = original; }); + const message = new Element("article"); + const body = new Element("div"); + body.className = "msg-content"; + message.append(body); + updateReasoning(message, ""); + assert.equal(message.children.length, 1); + updateReasoning(message, "", true); + const details = message.children[0]; + assert.equal(details.open, true); + assert.equal(details.children[1].textContent, ""); + details.open = true; + updateReasoning(message, "Complete"); + assert.equal(details.open, true); + assert.equal(details.children[0].textContent, "Reasoning"); + assert.equal(message.children.length, 2); + finishReasoning(message); + assert.equal(details.open, false); + details.open = true; + finishReasoning(message); + assert.equal(details.open, true); +}); + +test("reasoning toggle defaults off, resets, and rejects unsupported models", () => { + const button = {setAttribute() {}, addEventListener(_event, callback) { this.click = callback; }}; + const toggle = createReasoningToggle(button); + toggle.setModel("qwen3:14b"); + assert.equal(toggle.enabled, false); + assert.equal(button.title, "Enable reasoning"); + button.click(); + assert.equal(toggle.enabled, true); + assert.equal(button.title, "Reasoning enabled"); + toggle.setBusy(true); + button.click(); + assert.equal(toggle.enabled, true); + toggle.reset(); + assert.equal(toggle.enabled, false); + toggle.setBusy(false); + button.click(); + toggle.setModel("mistral:latest"); + assert.equal(toggle.enabled, false); + assert.equal(button.disabled, true); + assert.equal(button.title, "This model does not support reasoning"); + button.click(); + assert.equal(toggle.enabled, false); +});