added thinking hide option. see #636

This commit is contained in:
mic 2026-04-20 22:58:02 +02:00
parent 85afdb0a77
commit 565134ecbd
10 changed files with 169 additions and 16 deletions

View file

@ -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.", "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": "" "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" : { "prefs_OptionText_max_prompt_length" : {
"message": "Max prompt length", "message": "Max prompt length",
"description": "" "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.", "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": "" "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": { "anthropic_empty_apikey": {
"message": "You've not added an API Key for the Claude API. Please insert one in the options page.", "message": "You've not added an API Key for the Claude API. Please insert one in the options page.",
"description": "" "description": ""

View file

@ -73,7 +73,7 @@ if (worker) {
const integration_prefix = integration; const integration_prefix = integration;
const options_config = integration_options_config[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) { for (const key in options_config) {
prefsToGet[`${integration_prefix}_${key}`] = prefs_default[`${integration_prefix}_${key}`]; prefsToGet[`${integration_prefix}_${key}`] = prefs_default[`${integration_prefix}_${key}`];
} }
@ -115,6 +115,7 @@ if (worker) {
case 'anthropic': llmName = "Claude"; break; case 'anthropic': llmName = "Claude"; break;
} }
messagesArea.setLLMName(llmName); messagesArea.setLLMName(llmName);
messagesArea.setHideThinking(!!prefs_api.hide_thinking);
document.title += " [" + llmName + " | " + decodeURIComponent(prompt_name) + "]"; document.title += " [" + llmName + " | " + decodeURIComponent(prompt_name) + "]";
@ -153,7 +154,8 @@ if (worker) {
anthropic: [ anthropic: [
{ key: 'system_prompt', labelKey: 'Anthropic_System_Prompt', type: 'string' }, { key: 'system_prompt', labelKey: 'Anthropic_System_Prompt', type: 'string' },
{ key: 'max_tokens', labelKey: 'prefs_OptionText_anthropic_max_tokens', type: 'number_gt_zero' }, { 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); messagesArea.handleNewToken(payload.token);
messageInput.setStatusMessage(browser.i18n.getMessage("apiwebchat_receiving_data") + '...'); messageInput.setStatusMessage(browser.i18n.getMessage("apiwebchat_receiving_data") + '...');
break; break;
case 'newThinkingToken':
messagesArea.handleNewThinkingToken(payload.token);
messageInput.setStatusMessage(browser.i18n.getMessage("apiwebchat_receiving_data") + '...');
break;
case 'tokensDone': case 'tokensDone':
await messagesArea.handleTokensDone(promptData); await messagesArea.handleTokensDone(promptData);
messageInput.enableInput(); messageInput.enableInput();

View file

@ -194,6 +194,25 @@ messagesAreaStyle.textContent = `
display: flex; 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 */ /* Dark mode styles */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.added { .added {
@ -202,6 +221,11 @@ messagesAreaStyle.textContent = `
.removed { .removed {
background-color:rgb(90, 0, 0); background-color:rgb(90, 0, 0);
} }
details.thinking-block {
background: #2a2a2a;
color: #bbb;
border-left-color: #555;
}
} }
`; `;
messagesAreaTemplate.content.appendChild(messagesAreaStyle); messagesAreaTemplate.content.appendChild(messagesAreaStyle);
@ -218,6 +242,8 @@ class MessagesArea extends HTMLElement {
constructor() { constructor() {
super(); super();
this.accumulatingMessageEl = null; this.accumulatingMessageEl = null;
this.thinkingAccumulator = '';
this.hideThinking = false;
const shadowRoot = this.attachShadow({ mode: 'open' }); const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(messagesAreaTemplate.content.cloneNode(true)); shadowRoot.appendChild(messagesAreaTemplate.content.cloneNode(true));
@ -248,6 +274,14 @@ class MessagesArea extends HTMLElement {
this.llmName = llmName; this.llmName = llmName;
} }
setHideThinking(val) {
this.hideThinking = !!val;
}
handleNewThinkingToken(token) {
this.thinkingAccumulator += token;
}
async handleTokensDone(promptData = null) { async handleTokensDone(promptData = null) {
this.flushAccumulatingMessage(); this.flushAccumulatingMessage();
await this.addActionButtons(promptData); await this.addActionButtons(promptData);
@ -559,6 +593,32 @@ class MessagesArea extends HTMLElement {
fullText += tokenEl.textContent; fullText += tokenEl.textContent;
}); });
// If an unterminated <think> 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 <think> content is never sent
// through markdown-it or promoted to the final thinking block.
const openThink = fullText.match(/<think>/i);
const closeThink = fullText.match(/<\/think>/i);
if (openThink && !closeThink) {
return;
}
// Extract inline <think>...</think> blocks (Ollama / OpenAI Comp) and strip them from fullText.
let inlineThinking = '';
const thinkRegex = /<think>([\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 (<think> tags)
let combinedThinking = this.thinkingAccumulator;
if (inlineThinking) {
combinedThinking += (combinedThinking ? '\n' : '') + inlineThinking;
}
this.thinkingAccumulator = '';
// Convert Markdown to DOM nodes using the markdown-it library // Convert Markdown to DOM nodes using the markdown-it library
const md = window.markdownit(); const md = window.markdownit();
const html = md.render(fullText); const html = md.render(fullText);
@ -577,6 +637,23 @@ class MessagesArea extends HTMLElement {
this.accumulatingMessageEl.removeChild(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 // Append new nodes
Array.from(doc.body.childNodes).forEach(node => { Array.from(doc.body.childNodes).forEach(node => {
this.accumulatingMessageEl.appendChild(node); this.accumulatingMessageEl.appendChild(node);

View file

@ -49,7 +49,17 @@ Content script `js/lib/diff.js` is injected into ChatGPT pages for diff-view sup
### Anthropic / Claude (`anthropic_api`) ### Anthropic / Claude (`anthropic_api`)
- Module: `js/api/anthropic.js` - Module: `js/api/anthropic.js`
- Worker: `js/workers/model-worker-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 `<think>…</think>` tags. `MessagesArea.flushAccumulatingMessage()` strips these blocks from the rendered text and renders them as a `<details class="thinking-block">` prepended to the answer. If an unterminated `<think>` 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 `<details>` 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 ## Configuration Validation

View file

@ -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 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 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 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: 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_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. | | `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 | | `placeholders_use_default_value` | `false` | Use placeholder defaults when empty |
| `hide_thinking` | `true` | Controls the initial state of the thinking `<details>` 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 | | `max_prompt_length` | `30000` | Max prompt string length |
### Feature Flags ### Feature Flags

View file

@ -27,6 +27,7 @@ export class Anthropic {
system_prompt = ''; system_prompt = '';
temperature = ''; temperature = '';
max_tokens = 4096; max_tokens = 4096;
extended_thinking_budget = 0;
stream = false; stream = false;
constructor({ constructor({
@ -36,6 +37,7 @@ export class Anthropic {
system_prompt = '', system_prompt = '',
temperature = '', temperature = '',
max_tokens = 4096, max_tokens = 4096,
extended_thinking_budget = 0,
stream = false, stream = false,
} = {}) { } = {}) {
this.apiKey = apiKey; this.apiKey = apiKey;
@ -44,6 +46,7 @@ export class Anthropic {
this.system_prompt = system_prompt; this.system_prompt = system_prompt;
this.temperature = temperature; this.temperature = temperature;
this.max_tokens = max_tokens > 0 ? max_tokens : 4096; this.max_tokens = max_tokens > 0 ? max_tokens : 4096;
this.extended_thinking_budget = extended_thinking_budget;
this.stream = stream; this.stream = stream;
} }
@ -97,9 +100,15 @@ export class Anthropic {
stream: this.stream, 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)); // console.log(">>>>>>>>>>>>>>>>> [ThunderAI] Anthropic API request: " + JSON.stringify(claude_body));

View file

@ -31,6 +31,7 @@ let taLog = null;
let conversationHistory = []; let conversationHistory = [];
let assistantResponseAccumulator = ''; let assistantResponseAccumulator = '';
let thinkingAccumulator = '';
self.onmessage = async function(event) { self.onmessage = async function(event) {
if (event.data.type === 'init') { if (event.data.type === 'init') {
@ -83,7 +84,8 @@ self.onmessage = async function(event) {
taLog.log("AI full response [STOPPED]: " + assistantResponseAccumulator); taLog.log("AI full response [STOPPED]: " + assistantResponseAccumulator);
conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator });
assistantResponseAccumulator = ''; assistantResponseAccumulator = '';
postMessage({ type: 'tokensDone' }); postMessage({ type: 'tokensDone', payload: { thinking: thinkingAccumulator } });
thinkingAccumulator = '';
break; break;
} }
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@ -91,7 +93,8 @@ self.onmessage = async function(event) {
taLog.log("AI full response: " + assistantResponseAccumulator); taLog.log("AI full response: " + assistantResponseAccumulator);
conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator });
assistantResponseAccumulator = ''; assistantResponseAccumulator = '';
postMessage({ type: 'tokensDone' }); postMessage({ type: 'tokensDone', payload: { thinking: thinkingAccumulator } });
thinkingAccumulator = '';
break; break;
} }
// lots of low-level Claude response parsing stuff // lots of low-level Claude response parsing stuff
@ -124,7 +127,11 @@ self.onmessage = async function(event) {
// Events handling // Events handling
switch (parsedData.type) { switch (parsedData.type) {
case 'content_block_delta': 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; const token = parsedData.delta.text;
assistantResponseAccumulator += token; assistantResponseAccumulator += token;
postMessage({ type: 'newToken', payload: { token } }); postMessage({ type: 'newToken', payload: { token } });
@ -143,7 +150,8 @@ self.onmessage = async function(event) {
taLog.log("AI full response: " + assistantResponseAccumulator); taLog.log("AI full response: " + assistantResponseAccumulator);
conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator }); conversationHistory.push({ role: 'assistant', content: assistantResponseAccumulator });
assistantResponseAccumulator = ''; assistantResponseAccumulator = '';
postMessage({ type: 'tokensDone' }); postMessage({ type: 'tokensDone', payload: { thinking: thinkingAccumulator } });
thinkingAccumulator = '';
return; // end the loop return; // end the loop
} }
} }

View file

@ -55,7 +55,8 @@ export const integration_options_config = {
version: '2023-06-01', version: '2023-06-01',
max_tokens: 4096, max_tokens: 4096,
system_prompt: '', system_prompt: '',
temperature: '' temperature: '',
extended_thinking_budget: 0
} }
}; };
@ -115,6 +116,7 @@ export const prefs_default = {
chatgpt_web_load_wait_time: 1000, chatgpt_web_load_wait_time: 1000,
dynamic_menu_force_enter: false, dynamic_menu_force_enter: false,
placeholders_use_default_value: false, placeholders_use_default_value: false,
hide_thinking: true,
max_prompt_length: 30000, // max string length for prompt max_prompt_length: 30000, // max string length for prompt
add_tags: false, add_tags: false,
add_tags_maxnum: 3, add_tags_maxnum: 3,

View file

@ -147,6 +147,17 @@
</label> </label>
</td> </td>
</tr> </tr>
<tr>
<td><label>
<span class="opt_title">__MSG_prefs_OptionText_hide_thinking__</span>
</label></td>
<td>
<label>
<input type="checkbox" id="hide_thinking" name="hide_thinking" class="option-input" />
&nbsp;<span>__MSG_prefs_OptionText_hide_thinking_info__</span>
</label>
</td>
</tr>
<tr> <tr>
<td><label> <td><label>
<span class="opt_title">__MSG_prefs_OptionText_composing_plain_text__</span> <span class="opt_title">__MSG_prefs_OptionText_composing_plain_text__</span>

View file

@ -549,6 +549,15 @@ export async function injectConnectionUI({
</label> </label>
</td> </td>
</tr> </tr>
<tr class="conntype_anthropic_api${tr_class ? ` ${tr_class}` : ''}">
<td><span class="opt_title">__MSG_prefs_OptionText_anthropic_extended_thinking_budget__</span></td>
<td>
<label>
<input type="number" id="${modelId_prefix ? `${modelId_prefix}` : ''}anthropic_extended_thinking_budget" name="${modelId_prefix ? `${modelId_prefix}` : ''}anthropic_extended_thinking_budget" class="option-input" />
<br>__MSG_prefs_OptionText_anthropic_extended_thinking_budget_Info__
</label>
</td>
</tr>
`; `;
const template = document.createElement('template'); const template = document.createElement('template');