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)
|
||||
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 <summary> 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue