chat: lazy-render reasoning_content into a collapsible thinking block

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
<details class="chat-thinking"> 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.
This commit is contained in:
russell@unturf.com 2026-05-25 14:49:33 -04:00
parent a7f71b5a8b
commit 889c02fccb
No known key found for this signature in database

View file

@ -93,8 +93,16 @@ function addCodeBlockCopyButtons(element) {
// Initialize chat history (will be populated on first use) // Initialize chat history (will be populated on first use)
export let chatHistory = []; export let chatHistory = [];
// Generator function to send a message to the LLM and yield responses // Generator function to send a message to the LLM and yield responses.
export async function* sendMessage(message) { //
// 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 // Initialize chat history if empty
if (chatHistory.length === 0) { if (chatHistory.length === 0) {
chatHistory = await initializeChatHistory(); chatHistory = await initializeChatHistory();
@ -175,7 +183,12 @@ export async function* sendMessage(message) {
try { try {
const parsedData = JSON.parse(jsonData); 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) { if (content) {
yield content; yield content;
} }
@ -204,8 +217,46 @@ export async function handleUserInput() {
const responseContent = document.createElement("span"); const responseContent = document.createElement("span");
aiResponseParagraph.appendChild(responseContent); 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 = ""; 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 <summary> toggle.
if (thinkingDetails && thinkingDetails.open) {
thinkingDetails.open = false;
const summary = thinkingDetails.querySelector("summary");
if (summary) summary.textContent = "thinking (click to expand)";
}
accumulatedContent += chunk; accumulatedContent += chunk;
const parsedChunk = marked.parse(accumulatedContent); const parsedChunk = marked.parse(accumulatedContent);
responseContent.innerHTML = parsedChunk; responseContent.innerHTML = parsedChunk;
@ -240,7 +291,7 @@ export async function handleUserInput() {
playPauseButton.textContent = "Processing..."; playPauseButton.textContent = "Processing...";
playPauseButton.disabled = true; // Disable button while processing playPauseButton.disabled = true; // Disable button while processing
const mainVoiceSelect = document.getElementById("read-page-voice"); 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); const result = await speakText(accumulatedContent, selectedVoice, 0.9);
aiAudio = result.audio; aiAudio = result.audio;
aiBlob = result.blob; aiBlob = result.blob;
@ -286,8 +337,10 @@ export function getChatHistory() {
return chatHistory; return chatHistory;
} }
// Generator function to send a message with custom history (doesn't modify global chatHistory) // Generator function to send a message with custom history (doesn't modify global chatHistory).
export async function* sendMessageWithCustomHistory(customHistory) { // 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) // Get API configuration (custom or default)
const apiConfig = await getAPIConfig(); const apiConfig = await getAPIConfig();
@ -372,7 +425,12 @@ export async function* sendMessageWithCustomHistory(customHistory) {
try { try {
const parsed = JSON.parse(data); 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) { if (content) {
yield content; yield content;
} }