From 889c02fccb8bc6364dc1a6de82552e98093326a2 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 25 May 2026 14:49:33 -0400 Subject: [PATCH] chat: lazy-render reasoning_content into a collapsible thinking block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qwen3.x / Deepseek-R1 / o1 stream model thinking via delta.reasoning_content during SSE; the final answer arrives later via delta.content. Previously chat.js parsed only delta.content, so any thinking time appeared to the user as a long empty pause followed by a sudden answer. Both generator functions (sendMessage, sendMessageWithCustomHistory) gain an opts.onThinking(text) callback that fires only when the server actually streams reasoning_content. Backward compatible: existing callers ignore the option, see no behaviour change. handleUserInput now passes an onThinking callback that lazy-creates a
block above the answer span on the first reasoning delta. When the first content delta arrives, the thinking block auto-collapses into a "thinking (click to expand)" summary so the answer is the visible focus. If a response never thinks (thinking off, simple prompt, etc.), no thinking DOM element is ever created — no visual artefact at all. Existing enable_thinking:false request bodies kept for now; this change is rendering-only and activates automatically the moment any caller flips thinking on. --- public/src/chat.js | 74 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/public/src/chat.js b/public/src/chat.js index 12e63d8..cdfb7f4 100644 --- a/public/src/chat.js +++ b/public/src/chat.js @@ -93,8 +93,16 @@ function addCodeBlockCopyButtons(element) { // Initialize chat history (will be populated on first use) export let chatHistory = []; -// Generator function to send a message to the LLM and yield responses -export async function* sendMessage(message) { +// Generator function to send a message to the LLM and yield responses. +// +// Yields content deltas as plain strings (backward compatible). +// Reasoning deltas (Qwen3.x thinking blocks etc.) are NOT yielded; +// they are surfaced via opts.onThinking(text) if the caller provides +// a callback. Lazy by design: onThinking only fires when the server +// actually streams delta.reasoning_content, so callers can use it +// to lazy-create a thinking DOM element only when thinking is on. +export async function* sendMessage(message, opts = {}) { + const onThinking = opts.onThinking; // Initialize chat history if empty if (chatHistory.length === 0) { chatHistory = await initializeChatHistory(); @@ -175,7 +183,12 @@ export async function* sendMessage(message) { try { const parsedData = JSON.parse(jsonData); - const content = parsedData.choices[0].delta.content; + const delta = parsedData.choices[0].delta; + const reasoning = delta.reasoning_content; + const content = delta.content; + if (reasoning && onThinking) { + onThinking(reasoning); + } if (content) { yield content; } @@ -204,8 +217,46 @@ export async function handleUserInput() { const responseContent = document.createElement("span"); aiResponseParagraph.appendChild(responseContent); + // Lazy thinking-block state: only materialised if the server actually + // streams delta.reasoning_content. If the model isn't thinking, no + // DOM element is created and no visual artefact appears. + let thinkingDetails = null; + let thinkingBody = null; + let thinkingText = ""; + + const ensureThinkingBlock = () => { + if (thinkingDetails) return; + thinkingDetails = document.createElement("details"); + thinkingDetails.className = "chat-thinking"; + thinkingDetails.open = true; + const summary = document.createElement("summary"); + summary.textContent = "thinking…"; + thinkingDetails.appendChild(summary); + thinkingBody = document.createElement("div"); + thinkingBody.className = "chat-thinking-body"; + thinkingBody.style.opacity = "0.6"; + thinkingBody.style.fontStyle = "italic"; + thinkingBody.style.whiteSpace = "pre-wrap"; + thinkingDetails.appendChild(thinkingBody); + aiResponseParagraph.insertBefore(thinkingDetails, responseContent); + }; + let accumulatedContent = ""; - for await (const chunk of sendMessage(userInput)) { + for await (const chunk of sendMessage(userInput, { + onThinking: (text) => { + ensureThinkingBlock(); + thinkingText += text; + thinkingBody.textContent = thinkingText; + }, + })) { + // First content delta after thinking: collapse the thinking block + // so the final answer is the visible focus. Thinking remains + // accessible behind the toggle. + if (thinkingDetails && thinkingDetails.open) { + thinkingDetails.open = false; + const summary = thinkingDetails.querySelector("summary"); + if (summary) summary.textContent = "thinking (click to expand)"; + } accumulatedContent += chunk; const parsedChunk = marked.parse(accumulatedContent); responseContent.innerHTML = parsedChunk; @@ -240,7 +291,7 @@ export async function handleUserInput() { playPauseButton.textContent = "Processing..."; playPauseButton.disabled = true; // Disable button while processing const mainVoiceSelect = document.getElementById("read-page-voice"); - const selectedVoice = mainVoiceSelect ? mainVoiceSelect.value : "atlas"; + const selectedVoice = mainVoiceSelect ? mainVoiceSelect.value : "foxhop"; const result = await speakText(accumulatedContent, selectedVoice, 0.9); aiAudio = result.audio; aiBlob = result.blob; @@ -286,8 +337,10 @@ export function getChatHistory() { return chatHistory; } -// Generator function to send a message with custom history (doesn't modify global chatHistory) -export async function* sendMessageWithCustomHistory(customHistory) { +// Generator function to send a message with custom history (doesn't modify global chatHistory). +// opts.onThinking(text): optional callback for delta.reasoning_content; see sendMessage docstring. +export async function* sendMessageWithCustomHistory(customHistory, opts = {}) { + const onThinking = opts.onThinking; // Get API configuration (custom or default) const apiConfig = await getAPIConfig(); @@ -372,7 +425,12 @@ export async function* sendMessageWithCustomHistory(customHistory) { try { const parsed = JSON.parse(data); - const content = parsed.choices?.[0]?.delta?.content; + const delta = parsed.choices?.[0]?.delta; + const reasoning = delta?.reasoning_content; + const content = delta?.content; + if (reasoning && onThinking) { + onThinking(reasoning); + } if (content) { yield content; }