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:
parent
a7f71b5a8b
commit
889c02fccb
1 changed files with 66 additions and 8 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue