From 565134ecbd7beb05d8402341f9200332d2baac68 Mon Sep 17 00:00:00 2001 From: mic Date: Mon, 20 Apr 2026 22:58:02 +0200 Subject: [PATCH] added thinking hide option. see #636 --- _locales/en/messages.json | 20 +++++++ api_webchat/controller.js | 10 +++- api_webchat/messagesArea.js | 83 +++++++++++++++++++++++++++- claude-spec/04-api-integrations.md | 12 +++- claude-spec/05-options.md | 3 +- js/api/anthropic.js | 17 ++++-- js/workers/model-worker-anthropic.js | 16 ++++-- options/mzta-options-default.js | 4 +- options/mzta-options.html | 11 ++++ pages/_lib/connection-ui.js | 9 +++ 10 files changed, 169 insertions(+), 16 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2dcf1779..6f010bf0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -724,6 +724,18 @@ "message": "If checked, the placeholders will be filled with the default values when no value is provided. Otherwise, the placeholders will be kept in place.", "description": "" }, + "prefs_OptionText_hide_thinking": { + "message": "Hide thinking output", + "description": "" + }, + "prefs_OptionText_hide_thinking_info": { + "message": "If checked, reasoning/thinking output produced by the model is completely removed. If unchecked, it is shown in a collapsed block above the answer.", + "description": "" + }, + "prefs_OptionText_thinking_summary": { + "message": "Thinking", + "description": "" + }, "prefs_OptionText_max_prompt_length" : { "message": "Max prompt length", "description": "" @@ -1728,6 +1740,14 @@ "message": "The maximum number of tokens to generate in the completion. The token count of your prompt plus max_tokens cannot exceed the model's context length.", "description": "" }, + "prefs_OptionText_anthropic_extended_thinking_budget": { + "message": "Extended thinking budget (tokens)", + "description": "" + }, + "prefs_OptionText_anthropic_extended_thinking_budget_Info": { + "message": "Maximum tokens the model may spend on extended thinking. Set to 0 to disable extended thinking. When enabled, the temperature value is ignored by the Claude API.", + "description": "" + }, "anthropic_empty_apikey": { "message": "You've not added an API Key for the Claude API. Please insert one in the options page.", "description": "" diff --git a/api_webchat/controller.js b/api_webchat/controller.js index f9973720..473eae04 100644 --- a/api_webchat/controller.js +++ b/api_webchat/controller.js @@ -73,7 +73,7 @@ if (worker) { const integration_prefix = integration; const options_config = integration_options_config[integration]; - let prefsToGet = { do_debug: prefs_default.do_debug }; + let prefsToGet = { do_debug: prefs_default.do_debug, hide_thinking: prefs_default.hide_thinking }; for (const key in options_config) { prefsToGet[`${integration_prefix}_${key}`] = prefs_default[`${integration_prefix}_${key}`]; } @@ -115,6 +115,7 @@ if (worker) { case 'anthropic': llmName = "Claude"; break; } messagesArea.setLLMName(llmName); + messagesArea.setHideThinking(!!prefs_api.hide_thinking); document.title += " [" + llmName + " | " + decodeURIComponent(prompt_name) + "]"; @@ -153,7 +154,8 @@ if (worker) { anthropic: [ { key: 'system_prompt', labelKey: 'Anthropic_System_Prompt', type: 'string' }, { key: 'max_tokens', labelKey: 'prefs_OptionText_anthropic_max_tokens', type: 'number_gt_zero' }, - { key: 'temperature', labelKey: 'prefs_api_temperature', type: 'string' } + { key: 'temperature', labelKey: 'prefs_api_temperature', type: 'string' }, + { key: 'extended_thinking_budget', labelKey: 'prefs_OptionText_anthropic_extended_thinking_budget', type: 'number_gt_zero' } ] }; @@ -238,6 +240,10 @@ worker.onmessage = async function(event) { messagesArea.handleNewToken(payload.token); messageInput.setStatusMessage(browser.i18n.getMessage("apiwebchat_receiving_data") + '...'); break; + case 'newThinkingToken': + messagesArea.handleNewThinkingToken(payload.token); + messageInput.setStatusMessage(browser.i18n.getMessage("apiwebchat_receiving_data") + '...'); + break; case 'tokensDone': await messagesArea.handleTokensDone(promptData); messageInput.enableInput(); diff --git a/api_webchat/messagesArea.js b/api_webchat/messagesArea.js index e2611801..1a9cfdf6 100644 --- a/api_webchat/messagesArea.js +++ b/api_webchat/messagesArea.js @@ -194,6 +194,25 @@ messagesAreaStyle.textContent = ` display: flex; } + /* Thinking block styles */ + details.thinking-block { + border-left: 3px solid #bbb; + background: #f7f7f7; + padding: 0.3em 0.6em; + margin: 0 0 0.6em 0; + font-size: 0.9em; + color: #555; + border-radius: 4px; + } + details.thinking-block > summary { + cursor: pointer; + font-weight: 600; + } + details.thinking-block .thinking-content { + white-space: pre-wrap; + margin-top: 0.3em; + } + /* Dark mode styles */ @media (prefers-color-scheme: dark) { .added { @@ -202,6 +221,11 @@ messagesAreaStyle.textContent = ` .removed { background-color:rgb(90, 0, 0); } + details.thinking-block { + background: #2a2a2a; + color: #bbb; + border-left-color: #555; + } } `; messagesAreaTemplate.content.appendChild(messagesAreaStyle); @@ -218,6 +242,8 @@ class MessagesArea extends HTMLElement { constructor() { super(); this.accumulatingMessageEl = null; + this.thinkingAccumulator = ''; + this.hideThinking = false; const shadowRoot = this.attachShadow({ mode: 'open' }); shadowRoot.appendChild(messagesAreaTemplate.content.cloneNode(true)); @@ -248,6 +274,14 @@ class MessagesArea extends HTMLElement { this.llmName = llmName; } + setHideThinking(val) { + this.hideThinking = !!val; + } + + handleNewThinkingToken(token) { + this.thinkingAccumulator += token; + } + async handleTokensDone(promptData = null) { this.flushAccumulatingMessage(); await this.addActionButtons(promptData); @@ -558,7 +592,33 @@ class MessagesArea extends HTMLElement { this.accumulatingMessageEl.querySelectorAll('.token').forEach(tokenEl => { fullText += tokenEl.textContent; }); - + + // If an unterminated block is present (mid-stream), defer the + // markdown render until the closing tag arrives — tokens stay in the DOM + // as raw fading spans, but the partial content is never sent + // through markdown-it or promoted to the final thinking block. + const openThink = fullText.match(//i); + const closeThink = fullText.match(/<\/think>/i); + if (openThink && !closeThink) { + return; + } + + // Extract inline ... blocks (Ollama / OpenAI Comp) and strip them from fullText. + let inlineThinking = ''; + const thinkRegex = /([\s\S]*?)<\/think>/gi; + let match; + while ((match = thinkRegex.exec(fullText)) !== null) { + inlineThinking += (inlineThinking ? '\n' : '') + match[1]; + } + fullText = fullText.replace(thinkRegex, '').replace(/^\s+/, ''); + + // Combined thinking content: worker-side (Anthropic) + inline ( tags) + let combinedThinking = this.thinkingAccumulator; + if (inlineThinking) { + combinedThinking += (combinedThinking ? '\n' : '') + inlineThinking; + } + this.thinkingAccumulator = ''; + // Convert Markdown to DOM nodes using the markdown-it library const md = window.markdownit(); const html = md.render(fullText); @@ -566,16 +626,33 @@ class MessagesArea extends HTMLElement { this.fullTextHTML += html; // console.log(">>>>>>>>>>>>>>>> flushAccumulatingMessage this.fullTextHTML: " + this.fullTextHTML); - + // Create a new DOM parser const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); convertTextNodeNewlinesToBr(doc.body); - + // Remove existing tokens while (this.accumulatingMessageEl.firstChild) { this.accumulatingMessageEl.removeChild(this.accumulatingMessageEl.firstChild); } + + // Prepend thinking block (if any). hide_thinking controls the initial + // open/collapsed state: true -> collapsed, false -> open. Users can always + // toggle with a click. + if (combinedThinking) { + const details = document.createElement('details'); + details.classList.add('thinking-block'); + if (!this.hideThinking) details.open = true; + const summary = document.createElement('summary'); + summary.textContent = browser.i18n.getMessage('prefs_OptionText_thinking_summary') || 'Thinking'; + const content = document.createElement('div'); + content.classList.add('thinking-content'); + content.textContent = combinedThinking; + details.appendChild(summary); + details.appendChild(content); + this.accumulatingMessageEl.appendChild(details); + } // Append new nodes Array.from(doc.body.childNodes).forEach(node => { diff --git a/claude-spec/04-api-integrations.md b/claude-spec/04-api-integrations.md index 2c72142c..129221fb 100644 --- a/claude-spec/04-api-integrations.md +++ b/claude-spec/04-api-integrations.md @@ -49,7 +49,17 @@ Content script `js/lib/diff.js` is injected into ChatGPT pages for diff-view sup ### Anthropic / Claude (`anthropic_api`) - Module: `js/api/anthropic.js` - Worker: `js/workers/model-worker-anthropic.js` -- Settings keys: `anthropic_api_key`, `anthropic_model`, `anthropic_version`, `anthropic_max_tokens`, `anthropic_system_prompt`, `anthropic_temperature` +- Settings keys: `anthropic_api_key`, `anthropic_model`, `anthropic_version`, `anthropic_max_tokens`, `anthropic_system_prompt`, `anthropic_temperature`, `anthropic_extended_thinking_budget` +- **Extended thinking**: when `anthropic_extended_thinking_budget > 0`, the request body adds `thinking: { type: 'enabled', budget_tokens: N }` and **omits** `temperature` (the Claude API forbids setting temperature with extended thinking). Thinking output arrives in the SSE stream as `content_block_delta` events with `delta.type === 'thinking_delta'` and is forwarded to the webchat UI as `newThinkingToken` messages, captured into a `thinkingAccumulator` in the worker and passed on `tokensDone`. + +## Thinking output in the webchat UI + +Two provider categories emit reasoning/thinking content: + +- **Ollama / OpenAI Compatible**: thinking arrives inline in the normal token stream wrapped in `` tags. `MessagesArea.flushAccumulatingMessage()` strips these blocks from the rendered text and renders them as a `
` prepended to the answer. If an unterminated `` is detected mid-stream, the flush is deferred until the closing tag arrives. +- **Anthropic**: thinking is captured in the worker and posted to the controller as `newThinkingToken`. `MessagesArea` accumulates it and renders the same `
` block on final flush. + +The global `hide_thinking` pref (default `true`) controls the **initial open/collapsed state** of the thinking block: `true` → collapsed, `false` → open. The user can always toggle by clicking. Thinking content is never discarded. Other providers (Google Gemini, OpenAI Responses, ChatGPT Web) are not affected by this UI logic. ## Configuration Validation diff --git a/claude-spec/05-options.md b/claude-spec/05-options.md index 32e1256b..fe78bc7e 100644 --- a/claude-spec/05-options.md +++ b/claude-spec/05-options.md @@ -24,7 +24,7 @@ chatgpt_api_key, chatgpt_model, chatgpt_developer_messages, chatgpt_temperature, ollama_host, ollama_model, ollama_num_ctx, ollama_temperature, ollama_think openai_comp_host, openai_comp_model, openai_comp_api_key, openai_comp_use_v1, openai_comp_chat_name, openai_comp_temperature google_gemini_api_key, google_gemini_model, google_gemini_system_instruction, google_gemini_thinking_budget, google_gemini_temperature -anthropic_api_key, anthropic_model, anthropic_version, anthropic_max_tokens, anthropic_system_prompt, anthropic_temperature +anthropic_api_key, anthropic_model, anthropic_version, anthropic_max_tokens, anthropic_system_prompt, anthropic_temperature, anthropic_extended_thinking_budget ``` Plus the global connection selector: @@ -66,6 +66,7 @@ These are generated programmatically at the bottom of `mzta-options-default.js` | `dynamic_menu_force_enter` | `false` | Force Enter to submit in popup | | `dynamic_menu_order_alphabet` | `true` | Internal migration flag only; no UI. Set to `false` by `migrateMenuOrderAlphabetic()` on first boot after upgrade to bootstrap position-based ordering. See `claude-spec/02-prompts.md` for details. | | `placeholders_use_default_value` | `false` | Use placeholder defaults when empty | +| `hide_thinking` | `true` | Controls the initial state of the thinking `
` block prepended above the answer: `true` = collapsed by default, `false` = open by default. The user can always toggle with a click; thinking content is never discarded. | | `max_prompt_length` | `30000` | Max prompt string length | ### Feature Flags diff --git a/js/api/anthropic.js b/js/api/anthropic.js index 9d44a99e..c330fad6 100644 --- a/js/api/anthropic.js +++ b/js/api/anthropic.js @@ -26,7 +26,8 @@ export class Anthropic { model = ''; system_prompt = ''; temperature = ''; - max_tokens = 4096; + max_tokens = 4096; + extended_thinking_budget = 0; stream = false; constructor({ @@ -36,6 +37,7 @@ export class Anthropic { system_prompt = '', temperature = '', max_tokens = 4096, + extended_thinking_budget = 0, stream = false, } = {}) { this.apiKey = apiKey; @@ -44,6 +46,7 @@ export class Anthropic { this.system_prompt = system_prompt; this.temperature = temperature; this.max_tokens = max_tokens > 0 ? max_tokens : 4096; + this.extended_thinking_budget = extended_thinking_budget; this.stream = stream; } @@ -89,7 +92,7 @@ export class Anthropic { try { - let claude_body = { + let claude_body = { model: this.model, max_tokens: parseInt(this.max_tokens), system: this.system_prompt, @@ -97,9 +100,15 @@ export class Anthropic { stream: this.stream, }; - const tempFloat = parseFloat(this.temperature); + const thinkingBudget = parseInt(this.extended_thinking_budget); + const thinkingEnabled = !Number.isNaN(thinkingBudget) && thinkingBudget > 0; - if(this.temperature != '' && !Number.isNaN(tempFloat)) claude_body.temperature = tempFloat; + if (thinkingEnabled) { + claude_body.thinking = { type: 'enabled', budget_tokens: thinkingBudget }; + } else { + const tempFloat = parseFloat(this.temperature); + if(this.temperature != '' && !Number.isNaN(tempFloat)) claude_body.temperature = tempFloat; + } // console.log(">>>>>>>>>>>>>>>>> [ThunderAI] Anthropic API request: " + JSON.stringify(claude_body)); diff --git a/js/workers/model-worker-anthropic.js b/js/workers/model-worker-anthropic.js index cc1e8fdb..c1fa4d18 100644 --- a/js/workers/model-worker-anthropic.js +++ b/js/workers/model-worker-anthropic.js @@ -31,6 +31,7 @@ let taLog = null; let conversationHistory = []; let assistantResponseAccumulator = ''; +let thinkingAccumulator = ''; self.onmessage = async function(event) { if (event.data.type === 'init') { @@ -83,7 +84,8 @@ self.onmessage = async function(event) { taLog.log("AI full response [STOPPED]: " + assistantResponseAccumulator); conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); assistantResponseAccumulator = ''; - postMessage({ type: 'tokensDone' }); + postMessage({ type: 'tokensDone', payload: { thinking: thinkingAccumulator } }); + thinkingAccumulator = ''; break; } const { done, value } = await reader.read(); @@ -91,7 +93,8 @@ self.onmessage = async function(event) { taLog.log("AI full response: " + assistantResponseAccumulator); conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); assistantResponseAccumulator = ''; - postMessage({ type: 'tokensDone' }); + postMessage({ type: 'tokensDone', payload: { thinking: thinkingAccumulator } }); + thinkingAccumulator = ''; break; } // lots of low-level Claude response parsing stuff @@ -124,7 +127,11 @@ self.onmessage = async function(event) { // Events handling switch (parsedData.type) { case 'content_block_delta': - if (parsedData.delta && parsedData.delta.text) { + if (parsedData.delta && parsedData.delta.type === 'thinking_delta' && typeof parsedData.delta.thinking === 'string') { + const token = parsedData.delta.thinking; + thinkingAccumulator += token; + postMessage({ type: 'newThinkingToken', payload: { token } }); + } else if (parsedData.delta && typeof parsedData.delta.text === 'string') { const token = parsedData.delta.text; assistantResponseAccumulator += token; postMessage({ type: 'newToken', payload: { token } }); @@ -143,7 +150,8 @@ self.onmessage = async function(event) { taLog.log("AI full response: " + assistantResponseAccumulator); conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); assistantResponseAccumulator = ''; - postMessage({ type: 'tokensDone' }); + postMessage({ type: 'tokensDone', payload: { thinking: thinkingAccumulator } }); + thinkingAccumulator = ''; return; // end the loop } } diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index cbc7eb0f..c2207e59 100644 --- a/options/mzta-options-default.js +++ b/options/mzta-options-default.js @@ -55,7 +55,8 @@ export const integration_options_config = { version: '2023-06-01', max_tokens: 4096, system_prompt: '', - temperature: '' + temperature: '', + extended_thinking_budget: 0 } }; @@ -115,6 +116,7 @@ export const prefs_default = { chatgpt_web_load_wait_time: 1000, dynamic_menu_force_enter: false, placeholders_use_default_value: false, + hide_thinking: true, max_prompt_length: 30000, // max string length for prompt add_tags: false, add_tags_maxnum: 3, diff --git a/options/mzta-options.html b/options/mzta-options.html index c31ae21b..870317bb 100644 --- a/options/mzta-options.html +++ b/options/mzta-options.html @@ -147,6 +147,17 @@ + + + + + + + + __MSG_prefs_OptionText_anthropic_extended_thinking_budget__ + + + + `; const template = document.createElement('template');