From ceb3c752ff7510424b95c78c7beb630cbb10db57 Mon Sep 17 00:00:00 2001 From: fretuks Date: Mon, 21 Sep 2026 19:50:16 +0200 Subject: [PATCH] show reasoning in frontend --- README.md | 2 ++ public/app/api.js | 1 + public/script.js | 8 +++++ public/style.css | 20 +++++++++++ src/db/repositories.js | 21 ++++++----- src/db/schema.js | 1 + src/routes/chats.js | 1 + src/routes/personas.js | 10 ++++-- src/services/chatMessageHelpers.js | 1 + src/services/chatService.js | 48 +++++++++++++++++--------- src/services/modelService.js | 30 +++++++++------- src/services/workspaceImportService.js | 1 + 12 files changed, 104 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 3c771e8..3ec3a97 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ The server is configured around an Ollama-compatible API surface: `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 refactor adds explicit upstream timeout handling, cached model listing, and `GET /health` for degraded-state visibility. ## Schema Notes diff --git a/public/app/api.js b/public/app/api.js index 2427d81..9684c8b 100644 --- a/public/app/api.js +++ b/public/app/api.js @@ -58,6 +58,7 @@ export async function stream(url, data, handlers = {}) { const event = eventLine.slice(6).trim(); const payload = JSON.parse(dataLine.slice(5).trim()); 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); diff --git a/public/script.js b/public/script.js index 9fe6260..de6e2a9 100644 --- a/public/script.js +++ b/public/script.js @@ -1,5 +1,6 @@ import {del, get, post, put, stream} from "./app/api.js?v=20260909-account-controls"; import {appendSources, createWebSearchToggle} from "./app/webSearch.js"; +import {updateReasoning} from "./app/reasoning.js"; import {closeChatDrawer, openChatDrawer} from "./app/chatDrawer.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"; @@ -741,6 +742,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options } msgDiv.append(header, body); + if (!isMessageUser) updateReasoning(msgDiv, message.thinking); if (!isMessageUser) appendSources(msgDiv, message.sources); if (message.deliveryStatus === "failed" && message.errorMessage) { const error = document.createElement("p"); @@ -1832,7 +1834,13 @@ async function sendMessage() { loadingLabel.textContent = status === "searching" ? "Searching the web..." : sourceCount === 0 ? "No web results found. Generating response..." : "Generating response..."; }, + onThinking: ({fullThinking}) => { + updateReasoning(loadingMsg, fullThinking, true); + loadingLabel.textContent = "Thinking…"; + }, onChunk: ({fullReply}) => { + const reasoningSummary = loadingMsg.querySelector(".msg-reasoning summary"); + if (reasoningSummary) reasoningSummary.textContent = "Reasoning"; if (loadingBody) { loadingShell?.classList.add("hidden"); loadingBody.classList.add("has-content"); diff --git a/public/style.css b/public/style.css index 243dad4..9249b54 100644 --- a/public/style.css +++ b/public/style.css @@ -1,5 +1,25 @@ /* Elsewhere design system. */ +.msg-reasoning { + margin: 0 0 12px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-soft); + color: var(--text-soft); + font-size: .875rem; +} +.msg-reasoning summary { cursor: pointer; font-weight: 600; } +.msg-reasoning summary:focus-visible { outline: 2px solid var(--brand); outline-offset: 4px; } +.msg-reasoning-content { + margin-top: 10px; + max-height: 320px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.6; +} + /* ---------- Foundation ---------- */ :root { color-scheme: light; diff --git a/src/db/repositories.js b/src/db/repositories.js index fffcc2d..20a0bfd 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -195,8 +195,9 @@ export function createRepositories(db, config) { insertChatMessage: db.prepare("INSERT INTO chat_messages (chat_id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), updateChatMessageDelivery: db.prepare("UPDATE chat_messages SET delivery_status = ?, error_message = ? WHERE id = ? AND chat_id = ?"), updateChatMessageSources: db.prepare("UPDATE chat_messages SET sources = ? WHERE id = ? AND chat_id = ?"), + updateChatMessageThinking: db.prepare("UPDATE chat_messages SET thinking = ? WHERE id = ? AND chat_id = ?"), getChatMessages: db.prepare(` - SELECT sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at + SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at FROM chat_messages WHERE chat_id = ? ORDER BY id @@ -236,7 +237,7 @@ export function createRepositories(db, config) { JOIN branch child ON child.parent_message_id = parent.id ) SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, - retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources + retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking FROM branch WHERE delivery_status = 'complete' ORDER BY id @@ -252,7 +253,7 @@ export function createRepositories(db, config) { JOIN branch child ON child.parent_message_id = parent.id ) SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, - retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources + retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking FROM branch ORDER BY id `), getSiblingChatMessages: db.prepare(` @@ -271,13 +272,13 @@ export function createRepositories(db, config) { SELECT cm.* FROM chat_messages cm JOIN descendants d ON d.id = cm.id ORDER BY cm.id DESC LIMIT 1 `), getChatMessageById: db.prepare(` - SELECT sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at + SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at FROM chat_messages WHERE id = ? AND chat_id = ? `), getChatMessageByIndex: db.prepare(` - SELECT sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at + SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at FROM chat_messages WHERE chat_id = ? ORDER BY id @@ -738,7 +739,7 @@ export function createRepositories(db, config) { return statements.deleteUser.run(username); }); - const completeChatTurnTransaction = db.transaction((chatId, userMessageId, assistantContent, retryState, modelName, sources) => { + const completeChatTurnTransaction = db.transaction((chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking) => { const assistantResult = statements.insertChatMessage.run( chatId, "bot", @@ -754,6 +755,7 @@ export function createRepositories(db, config) { ); statements.updateChatMessageDelivery.run("complete", null, userMessageId, chatId); if (sources) statements.updateChatMessageSources.run(JSON.stringify(sources), assistantResult.lastInsertRowid, chatId); + if (thinking) statements.updateChatMessageThinking.run(thinking, assistantResult.lastInsertRowid, chatId); statements.setChatActiveLeafById.run(assistantResult.lastInsertRowid, chatId); return assistantResult; }); @@ -866,6 +868,7 @@ export function createRepositories(db, config) { ); if (message.sourceId != null) importedMessageIds.set(message.sourceId, Number(result.lastInsertRowid)); if (message.sources) statements.updateChatMessageSources.run(JSON.stringify(message.sources), result.lastInsertRowid, chatId); + if (message.thinking) statements.updateChatMessageThinking.run(message.thinking, result.lastInsertRowid, chatId); } statements.updateChatGenerationSettings.run( chat.preferred_model ?? null, @@ -1015,8 +1018,10 @@ export function createRepositories(db, config) { insertBranchChatMessageTransaction(chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId), updateChatMessageDelivery: (chatId, messageId, deliveryStatus, errorMessage = null) => statements.updateChatMessageDelivery.run(deliveryStatus, errorMessage, messageId, chatId), - completeChatTurn: (chatId, userMessageId, assistantContent, retryState, modelName = null, sources = null) => - completeChatTurnTransaction(chatId, userMessageId, assistantContent, retryState, modelName, sources), + completeChatTurn: (chatId, userMessageId, assistantContent, retryState, modelName = null, sources = null, thinking = null) => + completeChatTurnTransaction(chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking), + updateChatMessageThinking: (chatId, messageId, thinking) => + statements.updateChatMessageThinking.run(thinking, messageId, chatId), updateChatMessageSources: (chatId, messageId, sources) => statements.updateChatMessageSources.run(JSON.stringify(sources), messageId, chatId), failChatTurn: (chatId, userMessageId, errorMessage, parentMessageId = null) => diff --git a/src/db/schema.js b/src/db/schema.js index bf4e450..f98a26c 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -274,6 +274,7 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) { const chatMessageColumns = db.prepare("PRAGMA table_info(chat_messages)").all(); ensureColumn("chat_messages", chatMessageColumns, "sources", "ALTER TABLE chat_messages ADD COLUMN sources TEXT"); + ensureColumn("chat_messages", chatMessageColumns, "thinking", "ALTER TABLE chat_messages ADD COLUMN thinking TEXT"); const hadParentMessageId = chatMessageColumns.some((column) => column.name === "parent_message_id"); ensureColumn("chat_messages", chatMessageColumns, "model_name", "ALTER TABLE chat_messages ADD COLUMN model_name TEXT"); ensureColumn("chat_messages", chatMessageColumns, "retry_variants", "ALTER TABLE chat_messages ADD COLUMN retry_variants TEXT"); diff --git a/src/routes/chats.js b/src/routes/chats.js index 863cd21..c43e8b7 100644 --- a/src/routes/chats.js +++ b/src/routes/chats.js @@ -447,6 +447,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf message: req.validatedBody.message, model: req.validatedBody.model, onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}), + onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}), webSearch: req.validatedBody.webSearch, onStatus: (status) => sendEvent("status", status), signal: generationController.signal diff --git a/src/routes/personas.js b/src/routes/personas.js index 6fd3e0b..f5681be 100644 --- a/src/routes/personas.js +++ b/src/routes/personas.js @@ -254,7 +254,9 @@ export function createPersonasRouter({repositories, chatService, modelService, c repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary); chat = repositories.getChat(chat.id, user); try { + let thinking = ""; const opener = await chatService.generateRoleplayOpener({ + onThinking: (_chunk, fullThinking) => { thinking = fullThinking; }, selectedModel: "qwen3:14b", assistantPersona: persona, userPersona, @@ -264,12 +266,13 @@ export function createPersonasRouter({repositories, chatService, modelService, c if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) { return res.json({persona, chat, existing: false, generatedInitialMessage: false}); } - chatService.insertChatMessage(chat.id, "bot", opener, { + const inserted = chatService.insertChatMessage(chat.id, "bot", opener, { retryVariants: [opener], retryActiveIndex: 0, retryRetriesUsed: 0, retryPromptMessageId: null }); + if (thinking) repositories.updateChatMessageThinking(chat.id, Number(inserted.lastInsertRowid), thinking); repositories.touchChat(chat.id, user); return res.json({persona, chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener}); } catch { @@ -307,7 +310,9 @@ export function createPersonasRouter({repositories, chatService, modelService, c chat = repositories.getChat(chat.id, user); try { + let thinking = ""; const opener = await chatService.generateRoleplayOpener({ + onThinking: (_chunk, fullThinking) => { thinking = fullThinking; }, selectedModel: model, assistantPersona, userPersona, @@ -317,12 +322,13 @@ export function createPersonasRouter({repositories, chatService, modelService, c if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) { return res.json({chat, existing: false, generatedInitialMessage: false, degraded: true}); } - chatService.insertChatMessage(chat.id, "bot", opener, { + const inserted = chatService.insertChatMessage(chat.id, "bot", opener, { retryVariants: [opener], retryActiveIndex: 0, retryRetriesUsed: 0, retryPromptMessageId: null }); + if (thinking) repositories.updateChatMessageThinking(chat.id, Number(inserted.lastInsertRowid), thinking); repositories.touchChat(chat.id, user); return res.json({chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener}); } catch (error) { diff --git a/src/services/chatMessageHelpers.js b/src/services/chatMessageHelpers.js index 6709230..fbecaa3 100644 --- a/src/services/chatMessageHelpers.js +++ b/src/services/chatMessageHelpers.js @@ -41,6 +41,7 @@ export function formatChatMessage(row) { id: row.id, role: row.role, content: row.content, + ...(row.thinking ? {thinking: row.thinking} : {}), ...(row.sources ? {sources: parseSources(row.sources)} : {}), modelName: row.model_name || null, retryVariants, diff --git a/src/services/chatService.js b/src/services/chatService.js index 6b56b80..dfdcdcb 100644 --- a/src/services/chatService.js +++ b/src/services/chatService.js @@ -168,7 +168,7 @@ export function createChatService(repositories, modelService, config, webSearchP return repositories.createChat(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary); }; - const generateRoleplayOpener = async ({selectedModel = "qwen3:14b", assistantPersona, userPersona, scenarioPrompt, sceneSummary}) => { + const generateRoleplayOpener = async ({selectedModel = "qwen3:14b", assistantPersona, userPersona, scenarioPrompt, sceneSummary, onThinking = null}) => { const baseMessages = [ {role: "system", content: buildPersonaPrompt(assistantPersona)}, ...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []), @@ -176,11 +176,12 @@ export function createChatService(repositories, modelService, config, webSearchP {role: "user", content: buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})} ]; - let opener = await modelService.generateReply(selectedModel, baseMessages); + let opener = await modelService.generateReply(selectedModel, baseMessages, {onThinking}); if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) { return opener; } + onThinking?.("", ""); return modelService.generateReply(selectedModel, [ ...baseMessages, {role: "assistant", content: opener || ""}, @@ -188,15 +189,16 @@ export function createChatService(repositories, modelService, config, webSearchP role: "user", content: "Rewrite this opening. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, actions, or labels like 'User:'/'You:'. Stop before the user's reply." } - ]); + ], {onThinking}); }; - const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona, signal = null, generation = {}}) => { - let reply = await modelService.generateReply(selectedModel, messagesPayload, {signal, generation}); + const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona, signal = null, generation = {}, onThinking = null}) => { + let reply = await modelService.generateReply(selectedModel, messagesPayload, {signal, generation, onThinking}); if (reply && !containsUserVoiceInRoleplayOpener(reply, userPersona)) { return reply; } + onThinking?.("", ""); reply = await modelService.generateReply(selectedModel, [ ...messagesPayload, {role: "assistant", content: reply || ""}, @@ -204,7 +206,7 @@ export function createChatService(repositories, modelService, config, webSearchP 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." } - ], {signal, generation}); + ], {signal, generation, onThinking}); if (!reply || containsUserVoiceInRoleplayOpener(reply, userPersona)) { return null; @@ -283,7 +285,9 @@ export function createChatService(repositories, modelService, config, webSearchP if (!activePersona || !session.scenario_summary) { return {error: "No previous user prompt found for retry"}; } + let thinking = ""; const opener = await generateRoleplayOpener({ + onThinking: (_chunk, fullThinking) => { thinking = fullThinking; }, selectedModel, assistantPersona: activePersona, userPersona: activeUserPersona, @@ -293,7 +297,7 @@ export function createChatService(repositories, modelService, config, webSearchP if (!opener || containsUserVoiceInRoleplayOpener(opener, activeUserPersona)) { return {error: "Failed to regenerate message"}; } - return {fullReply: opener, promptMessageId: null}; + return {fullReply: opener, thinking, promptMessageId: null}; } const {messagesPayload} = buildConversationContext({ @@ -306,15 +310,17 @@ export function createChatService(repositories, modelService, config, webSearchP }); const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle); + let thinking = ""; + const onThinking = (_chunk, fullThinking) => { thinking = fullThinking; }; const fullReply = activePersona - ? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation}) - : await modelService.generateReply(selectedModel, styledPayload, {signal, generation}); + ? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking}) + : await modelService.generateReply(selectedModel, styledPayload, {signal, generation, onThinking}); if (!fullReply) return {error: "Failed to regenerate message"}; - return {fullReply, promptMessageId: promptMessage.id}; + return {fullReply, thinking, promptMessageId: promptMessage.id}; }; - const generateChatTurn = ({user, chatId, message, model, webSearch = false, onStatus = null, onChunk = null, signal = null, parentMessageId = undefined}) => + const generateChatTurn = ({user, chatId, message, model, webSearch = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined}) => withChatGenerationLock(chatId, async () => { await (summaryQueues.get(chatId) || Promise.resolve()); const session = repositories.getChat(chatId, user); @@ -335,6 +341,11 @@ export function createChatService(repositories, modelService, config, webSearchP const userMessageId = Number(inserted?.lastInsertRowid); try { let sources = null; + let thinking = ""; + const captureThinking = (chunk, fullThinking) => { + thinking = fullThinking; + onThinking?.(chunk, fullThinking); + }; let payload; const fullReply = await withActiveGeneration(chatId, signal, async (activeSignal) => { if (webSearch) { @@ -375,11 +386,12 @@ export function createChatService(repositories, modelService, config, webSearchP messagesPayload: payload.messagesPayload, userPersona: payload.activeUserPersona, signal: activeSignal, - generation + generation, + onThinking: captureThinking }) : onChunk - ? modelService.streamReply(effectiveModel, payload.messagesPayload, onChunk, {signal: activeSignal, generation}) - : modelService.generateReply(effectiveModel, payload.messagesPayload, {signal: activeSignal, generation}); + ? modelService.streamReply(effectiveModel, payload.messagesPayload, onChunk, {signal: activeSignal, generation, onThinking: captureThinking}) + : modelService.generateReply(effectiveModel, payload.messagesPayload, {signal: activeSignal, generation, onThinking: captureThinking}); }); if (!fullReply) { @@ -394,7 +406,7 @@ export function createChatService(repositories, modelService, config, webSearchP retryRetriesUsed: 0, retryPromptMessageId: null }; - repositories.completeChatTurn(chatId, userMessageId, fullReply, retryState, effectiveModel, sources); + repositories.completeChatTurn(chatId, userMessageId, fullReply, retryState, effectiveModel, sources, thinking); repositories.touchChat(chatId, user); scheduleContextSummaryUpdate({user, chatId, selectedModel: effectiveModel}); return {reply: fullReply, userMessageId, ...(sources ? {sources} : {})}; @@ -471,6 +483,7 @@ export function createChatService(repositories, modelService, config, webSearchP ); parentMessageId = Number(inserted.lastInsertRowid); if (sourceMessage.sources) repositories.updateChatMessageSources(clonedChat.id, parentMessageId, parseSources(sourceMessage.sources)); + if (sourceMessage.thinking) repositories.updateChatMessageThinking(clonedChat.id, parentMessageId, sourceMessage.thinking); } } catch (error) { repositories.deleteChat(clonedChat.id, user); @@ -498,8 +511,8 @@ export function createChatService(repositories, modelService, config, webSearchP async sendChatMessage({user, chatId, message, model, webSearch = false}) { return generateChatTurn({user, chatId, message, model, webSearch}); }, - async streamChatMessage({user, chatId, message, model, webSearch = false, onStatus, onChunk, signal = null}) { - return generateChatTurn({user, chatId, message, model, webSearch, onStatus, onChunk, signal}); + async streamChatMessage({user, chatId, message, model, webSearch = false, onStatus, onChunk, onThinking, signal = null}) { + return generateChatTurn({user, chatId, message, model, webSearch, onStatus, onChunk, onThinking, signal}); }, async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) { return withChatGenerationLock(chatId, async () => { @@ -532,6 +545,7 @@ export function createChatService(repositories, modelService, config, webSearchP chatId, "bot", retryResult.fullReply, retryState, model, "complete", null, freshTarget.parent_message_id ); if (freshTarget.sources) repositories.updateChatMessageSources(chatId, Number(inserted.lastInsertRowid), parseSources(freshTarget.sources)); + if (retryResult.thinking) repositories.updateChatMessageThinking(chatId, Number(inserted.lastInsertRowid), retryResult.thinking); repositories.updateChatContextSummary(chatId, user, null, 0); repositories.touchChat(chatId, user); const message = formatChatMessage(repositories.getChatMessage(chatId, Number(inserted.lastInsertRowid))); diff --git a/src/services/modelService.js b/src/services/modelService.js index ffd581f..bb9cd10 100644 --- a/src/services/modelService.js +++ b/src/services/modelService.js @@ -74,7 +74,7 @@ export function createModelService(config, overrides = {}) { } }; - const readStreamingReply = async (response, onChunk = null) => { + const readStreamingReply = async (response, onChunk = null, onThinking = null) => { if (!response.body) { throw new ModelServiceError("MODEL_BAD_RESPONSE", "Model backend returned no body.", 502); } @@ -83,6 +83,18 @@ export function createModelService(config, overrides = {}) { const decoder = new TextDecoder(); let buffer = ""; let fullReply = ""; + let fullThinking = ""; + const consume = (line) => { + const data = JSON.parse(line); + if (data.message?.thinking) { + fullThinking += data.message.thinking; + onThinking?.(data.message.thinking, fullThinking); + } + if (data.message?.content) { + fullReply += data.message.content; + onChunk?.(data.message.content, fullReply); + } + }; while (true) { const {value, done} = await reader.read(); @@ -93,20 +105,12 @@ export function createModelService(config, overrides = {}) { for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - const data = JSON.parse(trimmed); - if (data.message?.content) { - fullReply += data.message.content; - if (onChunk) onChunk(data.message.content, fullReply); - } + consume(trimmed); } } if (buffer.trim()) { - const data = JSON.parse(buffer.trim()); - if (data.message?.content) { - fullReply += data.message.content; - if (onChunk) onChunk(data.message.content, fullReply); - } + consume(buffer.trim()); } return fullReply.trim(); @@ -146,7 +150,7 @@ export function createModelService(config, overrides = {}) { throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502); } - return readStreamingReply(response); + return readStreamingReply(response, null, options.onThinking); }, options.signal); lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt}; return fullReply; @@ -184,7 +188,7 @@ export function createModelService(config, overrides = {}) { throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502); } - return readStreamingReply(response, onChunk); + return readStreamingReply(response, onChunk, options.onThinking); }, options.signal); lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null, latencyMs: Date.now() - startedAt}; return fullReply; diff --git a/src/services/workspaceImportService.js b/src/services/workspaceImportService.js index 5671085..a0f25e6 100644 --- a/src/services/workspaceImportService.js +++ b/src/services/workspaceImportService.js @@ -85,6 +85,7 @@ function validateMessage(message, chatIndex, messageIndex, errors) { role: role === "assistant" ? "bot" : role, content, model_name: optionalText(message.model_name || message.modelName), + thinking: optionalText(message.thinking), ...(message.sources ? {sources: parseSources(message.sources)} : {}), retryVariants, retryActiveIndex: Number.isInteger(Number(message.retryActiveIndex ?? message.retry_active_index))