Merge pull request #3444 from SillyTavern/hidden-reasoning-tracking Track reasoning duration for models with hidden reasoning

25a2582d15bf63e86ae590e156a21bd6331eeeaf

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
8 files changed, +441 -145Showing whitespace changes
public/index.html+8 -2
@@ -1983,12 +1983,12 @@
19831983 </span>
19841984 </div>
19851985 </div>
19861986 <div class="range-block" data-source="makersuite,deepseek,openrouter">
19871987 <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">
19881988 <input id="openai_show_thoughts" type="checkbox" />
19891989 <span>
19901990 <span data-i18n="Request model reasoning">Request model reasoning</span>
19911991 <i class="opacity50p fa-solid fa-circle-info" title="Gemini 2.0 Thinking / DeepSeek Reasoner"></i>
19921992 </span>
19931993 </label>
19941994 <div class="toggle-description justifyLeft marginBot5">
@@ -3810,6 +3810,12 @@
38103810 Auto-Expand
38113811 </small>
38123812 </label>
3813+ <label class="checkbox_label flex1" for="reasoning_show_hidden" title="Show reasoning time for models with hidden reasoning." data-i18n="[title]reasoning_show_hidden">
3814+ <input id="reasoning_show_hidden" type="checkbox" />
3815+ <small data-i18n="Show Hidden">
3816+ Show Hidden
3817+ </small>
3818+ </label>
38133819 </div>
38143820 <div class="flex-container alignItemsBaseline">
38153821 <label class="checkbox_label flex1" for="reasoning_add_to_prompts" title="Add existing reasoning blocks to prompts. To add a new reasoning block, use the message edit menu." data-i18n="[title]reasoning_add_to_prompts">
public/script.js+43 -108
@@ -269,7 +269,7 @@ import { initSettingsSearch } from './scripts/setting-search.js';
269269import { initBulkEdit } from './scripts/bulk-edit.js';
270270import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js';
271271import { getContext } from './scripts/st-context.js';
272272import { extractReasoningFromData, initReasoning, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningTimeUIupdateReasoningUI } from './scripts/reasoning.js';
273273import { accountStorage } from './scripts/util/AccountStorage.js';
274274
275275// API OBJECT FOR EXTERNAL WIRING
@@ -2224,8 +2224,6 @@ function getMessageFromTemplate({
22242224 isUser,
22252225 avatarImg,
22262226 bias,
2227- reasoning,
2228- reasoningDuration,
22292227 isSystem,
22302228 title,
22312229 timerValue,
@@ -2250,7 +2248,6 @@ function getMessageFromTemplate({
22502248 mes.find('.avatar img').attr('src', avatarImg);
22512249 mes.find('.ch_name .name_text').text(characterName);
22522250 mes.find('.mes_bias').html(bias);
2253- mes.find('.mes_reasoning').html(reasoning);
22542251 mes.find('.timestamp').text(timestamp).attr('title', `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`);
22552252 mes.find('.mesIDDisplay').text(`#${mesId}`);
22562253 tokenCount && mes.find('.tokenCounterDisplay').text(`${tokenCount}t`);
@@ -2258,12 +2255,7 @@ function getMessageFromTemplate({
22582255 timerValue && mes.find('.mes_timer').attr('title', timerTitle).text(timerValue);
22592256 bookmarkLink && updateBookmarkDisplay(mes);
22602257
2261- if (reasoning) {
2258+ updateReasoningUI(mes);
2262- mes.addClass('reasoning');
2263- }
2264- if (reasoningDuration) {
2265- updateReasoningTimeUI(mes.find('.mes_reasoning_header_title')[0], reasoningDuration, { forceEnd: true });
2266- }
22672259
22682260 if (power_user.timestamp_model_icon && extra?.api) {
22692261 insertSVGIcon(mes, extra);
@@ -2285,8 +2277,9 @@ export function updateMessageBlock(messageId, message, { rerenderMessage = true
22852277 const text = message?.extra?.display_text ?? message.mes;
22862278 messageElement.find('.mes_text').html(messageFormatting(text, message.name, message.is_system, message.is_user, messageId, {}, false));
22872279 }
2288- messageElement.find('.mes_reasoning').html(messageFormatting(message.extra?.reasoning ?? '', '', false, false, messageId, {}, true));
2280+
2289- messageElement.toggleClass('reasoning', !!message.extra?.reasoning);
2281+ updateReasoningUI(messageElement);
2282+
22902283 addCopyToCodeBlocks(messageElement);
22912284 appendMediaToMessage(message, messageElement);
22922285}
@@ -2446,7 +2439,6 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
24462439 false,
24472440 );
24482441 const bias = messageFormatting(mes.extra?.bias ?? '', '', false, false, -1, {}, false);
2449- const reasoning = messageFormatting(mes.extra?.reasoning ?? '', '', false, false, chat.indexOf(mes), {}, true);
24502442 let bookmarkLink = mes?.extra?.bookmark_link ?? '';
24512443
24522444 let params = {
@@ -2456,8 +2448,6 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
24562448 isUser: mes.is_user,
24572449 avatarImg: avatarImg,
24582450 bias: bias,
2459- reasoning: reasoning,
2460- reasoningDuration: mes.extra?.reasoning_duration,
24612451 isSystem: isSystem,
24622452 title: title,
24632453 bookmarkLink: bookmarkLink,
@@ -2465,7 +2455,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
24652455 timestamp: timestamp,
24662456 extra: mes.extra,
24672457 tokenCount: mes.extra?.token_count ?? 0,
24682458 ...formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration),
24692459 };
24702460
24712461 const renderedMessage = getMessageFromTemplate(params);
@@ -2517,8 +2507,8 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
25172507 const swipeMessage = chatElement.find(`[mesid="${chat.length - 1}"]`);
25182508 swipeMessage.attr('swipeid', params.swipeId);
25192509 swipeMessage.find('.mes_text').html(messageText).attr('title', title);
2520- swipeMessage.find('.mes_reasoning').html(reasoning);
25212510 swipeMessage.find('.timestamp').text(timestamp).attr('title', `${params.extra.api} - ${params.extra.model}`);
2511+ updateReasoningUI(swipeMessage);
25222512 appendMediaToMessage(mes, swipeMessage);
25232513 if (power_user.timestamp_model_icon && params.extra?.api) {
25242514 insertSVGIcon(swipeMessage, params.extra);
@@ -2585,13 +2575,14 @@ export function formatCharacterAvatar(characterAvatar) {
25852575 * @param {Date} gen_started Date when generation was started
25862576 * @param {Date} gen_finished Date when generation was finished
25872577 * @param {number} tokenCount Number of tokens generated (0 if not available)
2578+ * @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done)
25882579 * @returns {Object} Object containing the formatted timer value and title
25892580 * @example
25902581 * const { timerValue, timerTitle } = formatGenerationTimer(gen_started, gen_finished, tokenCount);
25912582 * console.log(timerValue); // 1.2s
25922583 * console.log(timerTitle); // Generation queued: 12:34:56 7 Jan 2021\nReply received: 12:34:57 7 Jan 2021\nTime to generate: 1.2 seconds\nToken rate: 5 t/s
25932584 */
25942585function formatGenerationTimer(gen_started, gen_finished, tokenCount, reasoningDuration = null) {
25952586 if (!gen_started || !gen_finished) {
25962587 return {};
25972588 }
@@ -2605,8 +2596,9 @@ function formatGenerationTimer(gen_started, gen_finished, tokenCount) {
26052596 `Generation queued: ${start.format(dateFormat)}`,
26062597 `Reply received: ${finish.format(dateFormat)}`,
26072598 `Time to generate: ${seconds} seconds`,
2599+ reasoningDuration > 0 ? `Time to think: ${reasoningDuration / 1000} seconds` : '',
26082600 tokenCount > 0 ? `Token rate: ${Number(tokenCount / seconds).toFixed(1)} t/s` : '',
26092601 ].filter(x => x).join('\n').trim();
26102602
26112603 if (isNaN(seconds) || seconds < 0) {
26122604 return { timerValue: '', timerTitle };
@@ -3125,15 +3117,14 @@ class StreamingProcessor {
31253117 constructor(type, forceName2, timeStarted, continueMessage) {
31263118 this.result = '';
31273119 this.messageId = -1;
3120+ /** @type {HTMLElement} */
31283121 this.messageDom = null;
3122+ /** @type {HTMLElement} */
31293123 this.messageTextDom = null;
3124+ /** @type {HTMLElement} */
31303125 this.messageTimerDom = null;
31313126 /** @type {HTMLElement} */
31323127 this.messageTokenCounterDom = null;
3133- /** @type {HTMLElement} */
3134- this.messageReasoningDom = null;
3135- /** @type {HTMLElement} */
3136- this.messageReasoningHeaderDom = null;
31373128 /** @type {HTMLTextAreaElement} */
31383129 this.sendTextarea = document.querySelector('#send_textarea');
31393130 this.type = type;
@@ -3149,16 +3140,8 @@ class StreamingProcessor {
31493140 /** @type {import('./scripts/logprobs.js').TokenLogprobs[]} */
31503141 this.messageLogprobs = [];
31513142 this.toolCalls = [];
3152- this.reasoning = '';
3143+ // Initialize reasoning in its own handler
31533144 this.reasoningStartTimereasoningHandler = nullnew ReasoningHandler(timeStarted);
3154- this.reasoningEndTime = null;
3155- }
3156-
3157- #reasoningDuration() {
3158- if (this.reasoningStartTime && this.reasoningEndTime) {
3159- return (this.reasoningEndTime - this.reasoningStartTime);
3160- }
3161- return null;
31623145 }
31633146
31643147 #checkDomElements(messageId) {
@@ -3167,9 +3150,8 @@ class StreamingProcessor {
31673150 this.messageTextDom = this.messageDom?.querySelector('.mes_text');
31683151 this.messageTimerDom = this.messageDom?.querySelector('.mes_timer');
31693152 this.messageTokenCounterDom = this.messageDom?.querySelector('.tokenCounterDisplay');
3170- this.messageReasoningDom = this.messageDom?.querySelector('.mes_reasoning');
3171- this.messageReasoningHeaderDom = this.messageDom?.querySelector('.mes_reasoning_header_title');
31723153 }
3154+ this.reasoningHandler.updateDom(messageId);
31733155 }
31743156
31753157 #updateMessageBlockVisibility() {
@@ -3179,22 +3161,12 @@ class StreamingProcessor {
31793161 }
31803162 }
31813163
31823164 showMessageButtonsmarkUIGenStarted(messageId) {
3183- if (messageId == -1) {
3165+ deactivateSendButtons();
3184- return;
3185- }
3186-
3187- showStopButton();
3188- $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'none' });
3189- }
3190-
3191- hideMessageButtons(messageId) {
3192- if (messageId == -1) {
3193- return;
31943166 }
31953167
3196- hideStopButton();
3168+ markUIGenStopped() {
3197- $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'flex' });
3169+ activateSendButtons();
31983170 }
31993171
32003172 async onStartStreaming(text) {
@@ -3203,14 +3175,12 @@ class StreamingProcessor {
32033175 if (this.type == 'impersonate') {
32043176 this.sendTextarea.value = '';
32053177 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
32063178 } else {
3207- else {
32083179 await saveReply(this.type, text, true, '', [], '');
32093180 messageId = chat.length - 1;
32103181 this.#checkDomElements(messageId);
32113182 this.showMessageButtonsmarkUIGenStarted(messageId);
32123183 }
3213-
32143184 hideSwipeButtons();
32153185 scrollChatToBottom();
32163186 return messageId;
@@ -3228,11 +3198,9 @@ class StreamingProcessor {
32283198
32293199 let processedText = cleanUpMessage(text, isImpersonate, isContinue, !isFinal, this.stoppingStrings);
32303200
3231- // Predict unbalanced asterisks / quotes during streaming
32323201 const charsToBalance = ['*', '"', '```'];
32333202 for (const char of charsToBalance) {
32343203 if (!isFinal && isOdd(countOccurrences(processedText, char))) {
3235- // Add character at the end to balance it
32363204 const separator = char.length > 1 ? '\n' : '';
32373205 processedText = processedText.trimEnd() + separator + char;
32383206 }
@@ -3241,47 +3209,24 @@ class StreamingProcessor {
32413209 if (isImpersonate) {
32423210 this.sendTextarea.value = processedText;
32433211 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
32443212 } else {
3245- else {
32463213 const mesChanged = chat[messageId]['mes'] !== processedText;
3247-
32483214 this.#checkDomElements(messageId);
32493215 this.#updateMessageBlockVisibility();
32503216 const currentTime = new Date();
32513217 chat[messageId]['mes'] = processedText;
32523218 chat[messageId]['gen_started'] = this.timeStarted;
32533219 chat[messageId]['gen_finished'] = currentTime;
3254-
32553220 if (!chat[messageId]['extra']) {
32563221 chat[messageId]['extra'] = {};
32573222 }
32583223
3259- if (this.reasoning) {
3224+ // Update reasoning
3260- const reasoning = power_user.trim_spaces ? this.reasoning.trim() : this.reasoning;
3225+ await this.reasoningHandler.process(messageId, mesChanged);
3261- const reasoningChanged = chat[messageId]['extra']['reasoning'] !== reasoning;
3262- chat[messageId]['extra']['reasoning'] = reasoning;
3263-
3264- if (reasoningChanged && this.reasoningStartTime === null) {
3265- this.reasoningStartTime = Date.now();
3266- }
3267- if (!reasoningChanged && mesChanged && this.reasoningStartTime !== null && this.reasoningEndTime === null) {
3268- this.reasoningEndTime = Date.now();
3269- }
3270- await this.#updateReasoningTime(messageId);
32713226
3272- if (this.messageReasoningDom instanceof HTMLElement) {
3227+ // Token count update.
3273- const formattedReasoning = messageFormatting(this.reasoning, '', false, false, messageId, {}, true);
3228+ const tokenCountText = this.reasoningHandler.reasoning + processedText;
3274- this.messageReasoningDom.innerHTML = formattedReasoning;
3275- }
3276- if (this.messageDom instanceof HTMLElement) {
3277- this.messageDom.classList.add('reasoning');
3278- }
3279- }
3280-
3281- // Don't waste time calculating token count for streaming
3282- const tokenCountText = (this.reasoning || '') + processedText;
32833229 const currentTokenCount = isFinal && power_user.message_token_count_enabled ? getTokenCount(tokenCountText, 0) : 0;
3284-
32853230 if (currentTokenCount) {
32863231 chat[messageId]['extra']['token_count'] = currentTokenCount;
32873232 if (this.messageTokenCounterDom instanceof HTMLElement) {
@@ -3307,7 +3252,7 @@ class StreamingProcessor {
33073252 this.messageTextDom.innerHTML = formattedText;
33083253 }
33093254
33103255 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration());
33113256 if (this.messageTimerDom instanceof HTMLElement) {
33123257 this.messageTimerDom.textContent = timePassed.timerValue;
33133258 this.messageTimerDom.title = timePassed.timerTitle;
@@ -3321,23 +3266,12 @@ class StreamingProcessor {
33213266 }
33223267 }
33233268
3324- async #updateReasoningTime(messageId, { forceEnd = false } = {}) {
3325- const duration = this.#reasoningDuration();
3326- chat[messageId]['extra']['reasoning_duration'] = duration;
3327- updateReasoningTimeUI(this.messageReasoningHeaderDom, duration, { forceEnd: forceEnd });
3328- await eventSource.emit(event_types.STREAM_REASONING_DONE, this.reasoning, duration);
3329- }
3330-
33313269 async onFinishStreaming(messageId, text) {
33323270 this.hideMessageButtonsmarkUIGenStopped(this.messageId);
33333271 await this.onProgressStreaming(messageId, text, true);
33343272 addCopyToCodeBlocks($(`#chat .mes[mesid="${messageId}"]`));
33353273
3336- // Ensure reasoning finish time is recorded if not already
3274+ await this.reasoningHandler.finish(messageId);
3337- if (this.reasoningStartTime !== null && this.reasoningEndTime === null) {
3338- this.reasoningEndTime = Date.now();
3339- await this.#updateReasoningTime(messageId, { forceEnd: true });
3340- }
33413275
33423276 if (Array.isArray(this.swipes) && this.swipes.length > 0) {
33433277 const message = chat[messageId];
@@ -3378,7 +3312,7 @@ class StreamingProcessor {
33783312 this.abortController.abort();
33793313 this.isStopped = true;
33803314
33813315 this.hideMessageButtonsmarkUIGenStopped(this.messageId);
33823316 generatedPromptCache = '';
33833317 unblockGeneration();
33843318
@@ -3438,7 +3372,8 @@ class StreamingProcessor {
34383372 if (logprobs) {
34393373 this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));
34403374 }
3441- this.reasoning = getRegexedString(state?.reasoning ?? '', regex_placement.REASONING);
3375+ // Get the updated reasoning string into the handler
3376+ this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning ?? '');
34423377 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
34433378 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
34443379 }
@@ -6195,20 +6130,21 @@ function extractImageFromMessage(getMessage) {
61956130 return { getMessage, image, title };
61966131}
61976132
6133+/**
6134+ * A function mainly used to switch 'generating' state - setting it to false and activating the buttons again
6135+ */
61986136export function activateSendButtons() {
61996137 is_send_press = false;
6200- $('#send_but').removeClass('displayNone');
6201- $('#mes_continue').removeClass('displayNone');
6202- $('#mes_impersonate').removeClass('displayNone');
6203- $('.mes_buttons:last').show();
62046138 hideStopButton();
6139+ delete document.body.dataset.generating;
62056140}
62066141
6142+/**
6143+ * A function mainly used to switch 'generating' state - setting it to true and deactivating the buttons
6144+ */
62076145export function deactivateSendButtons() {
6208- $('#send_but').addClass('displayNone');
6209- $('#mes_continue').addClass('displayNone');
6210- $('#mes_impersonate').addClass('displayNone');
62116146 showStopButton();
6147+ document.body.dataset.generating = 'true';
62126148}
62136149
62146150export function resetChatState() {
@@ -8801,7 +8737,7 @@ const swipe_right = () => {
88018737 // resets the timer
88028738 swipeMessage.find('.mes_timer').html('');
88038739 swipeMessage.find('.tokenCounterDisplay').text('');
8804- swipeMessage.find('.mes_reasoning').html('');
8740+ updateReasoningUI(swipeMessage, { reset: true });
88058741 } else {
88068742 //console.log('showing previously generated swipe candidate, or "..."');
88078743 //console.log('onclick right swipe calling addOneMessage');
@@ -8851,7 +8787,6 @@ const swipe_right = () => {
88518787 if (run_generate && !is_send_press && parseInt(chat[chat.length - 1]['swipe_id']) === chat[chat.length - 1]['swipes'].length) {
88528788 console.debug('caught here 2');
88538789 is_send_press = true;
8854- $('.mes_buttons:last').hide();
88558790 await Generate('swipe');
88568791 } else {
88578792 if (parseInt(chat[chat.length - 1]['swipe_id']) !== chat[chat.length - 1]['swipes'].length) {
public/scripts/openai.js+1 -2
@@ -83,7 +83,6 @@ export {
8383 setOpenAIMessageExamples,
8484 setupChatCompletionPromptManager,
8585 sendOpenAIRequest,
86- getChatCompletionModel,
8786 TokenHandler,
8887 IdentifierNotFoundError,
8988 Message,
@@ -1498,7 +1497,7 @@ async function sendWindowAIRequest(messages, signal, stream) {
14981497 }
14991498}
15001499
15011500export function getChatCompletionModel() {
15021501 switch (oai_settings.chat_completion_source) {
15031502 case chat_completion_sources.CLAUDE:
15041503 return oai_settings.claude_model;
public/scripts/power-user.js+1 -0
@@ -258,6 +258,7 @@ let power_user = {
258258 auto_parse: false,
259259 add_to_prompts: false,
260260 auto_expand: false,
261+ show_hidden: false,
261262 prefix: '<think>\n',
262263 suffix: '\n</think>',
263264 separator: '\n\n',
public/scripts/reasoning.js+350 -21
@@ -1,11 +1,11 @@
11import {
22 moment,
33} from '../lib.js';
44import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js';
55import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
66import { getCurrentLocale, t } from './i18n.js';
77import { MacrosParser } from './macros.js';
88import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';
99import { Popup } from './popup.js';
1010import { power_user } from './power-user.js';
1111import { SlashCommand } from './slash-commands/SlashCommand.js';
@@ -13,7 +13,7 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
1313import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
1414import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
1515import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
1616import { copyText, escapeRegex, isFalseBoolean, setDatasetProperty } from './utils.js';
1717
1818/**
1919 * Gets a message from a jQuery element.
@@ -71,21 +71,333 @@ export function extractReasoningFromData(data) {
7171}
7272
7373/**
74- * Updates the Reasoning controls
74+ * Check if the model supports reasoning, but does not send back the reasoning
7575 * @paramreturns {HTMLElementboolean} elementTrue Theif elementthe tomodel updatesupports reasoning
76- * @param {number?} duration The duration of the reasoning in milliseconds
77- * @param {object} [options={}] Options for the function
78- * @param {boolean} [options.forceEnd=false] If true, there will be no "Thinking..." when no duration exists
7976 */
80-export function updateReasoningTimeUI(element, duration, { forceEnd = false } = {}) {
77+export function isHiddenReasoningModel() {
78+ if (main_api !== 'openai') {
79+ return false;
80+ }
81+
82+ /** @typedef {{ (currentModel: string, supportedModel: string): boolean }} MatchingFunc */
83+ /** @type {Record.<string, MatchingFunc>} */
84+ const FUNCS = {
85+ equals: (currentModel, supportedModel) => currentModel === supportedModel,
86+ startsWith: (currentModel, supportedModel) => currentModel.startsWith(supportedModel),
87+ };
88+
89+ /** @type {{ name: string; func: MatchingFunc; }[]} */
90+ const hiddenReasoningModels = [
91+ { name: 'o1', func: FUNCS.startsWith },
92+ { name: 'o3', func: FUNCS.startsWith },
93+ { name: 'gemini-2.0-flash-thinking-exp', func: FUNCS.startsWith },
94+ { name: 'gemini-2.0-pro-exp', func: FUNCS.startsWith },
95+ ];
96+
97+ const model = getChatCompletionModel();
98+
99+ const isHidden = hiddenReasoningModels.some(({ name, func }) => func(model, name));
100+ return isHidden;
101+}
102+
103+/**
104+ * Updates the Reasoning UI for a specific message
105+ * @param {number|JQuery<HTMLElement>|HTMLElement} messageIdOrElement The message ID or the message element
106+ * @param {Object} [options={}] - Optional arguments
107+ * @param {boolean} [options.reset=false] - Whether to reset state, and not take the current mess properties (for example when swiping)
108+ */
109+export function updateReasoningUI(messageIdOrElement, { reset = false } = {}) {
110+ const handler = new ReasoningHandler();
111+ handler.initHandleMessage(messageIdOrElement, { reset });
112+}
113+
114+
115+/**
116+ * Enum for representing the state of reasoning
117+ * @enum {string}
118+ * @readonly
119+ */
120+export const ReasoningState = {
121+ None: 'none',
122+ Thinking: 'thinking',
123+ Done: 'done',
124+ Hidden: 'hidden',
125+};
126+
127+/**
128+ * Handles reasoning-specific logic and DOM updates for messages.
129+ * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates.
130+ */
131+export class ReasoningHandler {
132+ #isHiddenReasoningModel;
133+
134+ /**
135+ * @param {Date?} [timeStarted=null] - When the generation started
136+ */
137+ constructor(timeStarted = null) {
138+ /** @type {ReasoningState} The current state of the reasoning process */
139+ this.state = ReasoningState.None;
140+ /** @type {string} The reasoning output */
141+ this.reasoning = '';
142+ /** @type {Date} When the reasoning started */
143+ this.startTime = null;
144+ /** @type {Date} When the reasoning ended */
145+ this.endTime = null;
146+
147+ /** @type {Date} Initial starting time of the generation */
148+ this.initialTime = timeStarted ?? new Date();
149+
150+ /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */
151+ this.#isHiddenReasoningModel = isHiddenReasoningModel();
152+
153+ // Cached DOM elements for reasoning
154+ /** @type {HTMLElement} Main message DOM element `.mes` */
155+ this.messageDom = null;
156+ /** @type {HTMLElement} Reasoning details DOM element `.mes_reasoning_details` */
157+ this.messageReasoningDetailsDom = null;
158+ /** @type {HTMLElement} Reasoning content DOM element `.mes_reasoning` */
159+ this.messageReasoningContentDom = null;
160+ /** @type {HTMLElement} Reasoning header DOM element `.mes_reasoning_header_title` */
161+ this.messageReasoningHeaderDom = null;
162+ }
163+
164+ /**
165+ * Initializes the reasoning handler for a specific message.
166+ *
167+ * Can be used to update the DOM elements or read other reasoning states.
168+ * It will internally take the message-saved data and write the states back into the handler, as if during streaming of the message.
169+ * The state will always be either done/hidden or none.
170+ *
171+ * @param {number|JQuery<HTMLElement>|HTMLElement} messageIdOrElement - The message ID or the message element
172+ * @param {Object} [options={}] - Optional arguments
173+ * @param {boolean} [options.reset=false] - Whether to reset state of the handler, and not take the current mess properties (for example when swiping)
174+ */
175+ initHandleMessage(messageIdOrElement, { reset = false } = {}) {
176+ /** @type {HTMLElement} */
177+ const messageElement = typeof messageIdOrElement === 'number'
178+ ? document.querySelector(`#chat [mesid="${messageIdOrElement}"]`)
179+ : messageIdOrElement instanceof HTMLElement
180+ ? messageIdOrElement
181+ : $(messageIdOrElement)[0];
182+ const messageId = Number(messageElement.getAttribute('mesid'));
183+
184+ if (isNaN(messageId)) return;
185+
186+ const extra = chat[messageId].extra;
187+
188+ if (extra.reasoning) {
189+ this.state = ReasoningState.Done;
190+ } else if (extra.reasoning_duration) {
191+ this.state = ReasoningState.Hidden;
192+ }
193+
194+ this.reasoning = extra?.reasoning ?? '';
195+
196+ if (this.state !== ReasoningState.None) {
197+ this.initialTime = new Date(chat[messageId].gen_started);
198+ this.startTime = this.initialTime;
199+ this.endTime = new Date(this.startTime.getTime() + (extra?.reasoning_duration ?? 0));
200+ }
201+
202+ // Prefill main dom element, as message might not have been rendered yet
203+ this.messageDom = messageElement;
204+
205+ // Make sure reset correctly clears all relevant states
206+ if (reset) {
207+ this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None;
208+ this.reasoning = '';
209+ this.initialTime = new Date();
210+ this.startTime = null;
211+ this.endTime = null;
212+ }
213+
214+ this.updateDom(messageId);
215+ }
216+
217+ /**
218+ * Gets the duration of the reasoning in milliseconds.
219+ *
220+ * @returns {number?} The duration in milliseconds, or null if the start or end time is not set
221+ */
222+ getDuration() {
223+ if (this.startTime && this.endTime) {
224+ return this.endTime.getTime() - this.startTime.getTime();
225+ }
226+ return null;
227+ }
228+
229+ /**
230+ * Updates the reasoning text/string for a message.
231+ *
232+ * @param {number} messageId - The ID of the message to update
233+ * @param {string?} [reasoning=null] - The reasoning text to update - If null, uses the current reasoning
234+ * @param {Object} [options={}] - Optional arguments
235+ * @param {boolean} [options.persist=false] - Whether to persist the reasoning to the message object
236+ * @returns {boolean} - Returns true if the reasoning was changed, otherwise false
237+ */
238+ updateReasoning(messageId, reasoning = null, { persist = false } = {}) {
239+ reasoning = reasoning ?? this.reasoning;
240+ reasoning = power_user.trim_spaces ? reasoning.trim() : reasoning;
241+
242+ // Ensure the chat extra exists
243+ if (!chat[messageId].extra) {
244+ chat[messageId].extra = {};
245+ }
246+ const extra = chat[messageId].extra;
247+
248+ const reasoningChanged = extra.reasoning !== reasoning;
249+ this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING);
250+
251+ if (persist) {
252+ // Build and save the reasoning data to message extras
253+ extra.reasoning = this.reasoning;
254+ extra.reasoning_duration = this.getDuration();
255+ }
256+
257+ return reasoningChanged;
258+ }
259+
260+
261+ /**
262+ * Handles processing of reasoning for a message.
263+ *
264+ * This is usually called by the message processor when a message is changed.
265+ *
266+ * @param {number} messageId - The ID of the message to process
267+ * @param {boolean} mesChanged - Whether the message has changed
268+ * @returns {Promise<void>}
269+ */
270+ async process(messageId, mesChanged) {
271+ if (!this.reasoning && !this.#isHiddenReasoningModel) return;
272+
273+ // Ensure reasoning string is updated and regexes are applied correctly
274+ const reasoningChanged = this.updateReasoning(messageId, null, { persist: true });
275+
276+ if ((this.#isHiddenReasoningModel || reasoningChanged) && this.state === ReasoningState.None) {
277+ this.state = ReasoningState.Thinking;
278+ this.startTime = this.initialTime;
279+ }
280+ if ((this.#isHiddenReasoningModel || !reasoningChanged) && mesChanged && this.state === ReasoningState.Thinking) {
281+ this.endTime = new Date();
282+ await this.finish(messageId);
283+ }
284+ }
285+
286+ /**
287+ * Completes the reasoning process for a message.
288+ *
289+ * Records the finish time if it was not set during streaming and updates the reasoning state.
290+ * Emits an event to signal the completion of reasoning and updates the DOM elements accordingly.
291+ *
292+ * @param {number} messageId - The ID of the message to complete reasoning for
293+ * @returns {Promise<void>}
294+ */
295+ async finish(messageId) {
296+ if (this.state === ReasoningState.None) return;
297+
298+ // Make sure the finish time is recorded if a reasoning was in process and it wasn't ended correctly during streaming
299+ if (this.startTime !== null && this.endTime === null) {
300+ this.endTime = new Date();
301+ }
302+
303+ if (this.state === ReasoningState.Thinking) {
304+ this.state = this.#isHiddenReasoningModel ? ReasoningState.Hidden : ReasoningState.Done;
305+ this.updateReasoning(messageId, null, { persist: true });
306+ await eventSource.emit(event_types.STREAM_REASONING_DONE, this.reasoning, this.getDuration(), messageId, this.state);
307+ }
308+
309+ this.updateDom(messageId);
310+ }
311+
312+ /**
313+ * Updates the reasoning UI elements for a message.
314+ *
315+ * Toggles the CSS class, updates states, reasoning message, and duration.
316+ *
317+ * @param {number} messageId - The ID of the message to update
318+ */
319+ updateDom(messageId) {
320+ this.#checkDomElements(messageId);
321+
322+ // Main CSS class to show this message includes reasoning
323+ this.messageDom.classList.toggle('reasoning', this.state !== ReasoningState.None);
324+
325+ // Update states to the relevant DOM elements
326+ setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null);
327+ setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state);
328+
329+ // Update the reasoning message
330+ const reasoning = power_user.trim_spaces ? this.reasoning.trim() : this.reasoning;
331+ const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);
332+ this.messageReasoningContentDom.innerHTML = displayReasoning;
333+
334+ // Update tooltip for hidden reasoning edit
335+ /** @type {HTMLElement} */
336+ const button = this.messageDom.querySelector('.mes_edit_add_reasoning');
337+ button.title = this.state === ReasoningState.Hidden ? t`Hidden reasoning - Add reasoning block` : t`Add reasoning block`;
338+
339+ // Update the reasoning duration in the UI
340+ this.#updateReasoningTimeUI();
341+ }
342+
343+ /**
344+ * Finds and caches reasoning-related DOM elements for the given message.
345+ *
346+ * @param {number} messageId - The ID of the message to cache the DOM elements for
347+ */
348+ #checkDomElements(messageId) {
349+ // Make sure we reset dom elements if we are checking for a different message (shouldn't happen, but be sure)
350+ if (this.messageDom !== null && this.messageDom.getAttribute('mesid') !== messageId.toString()) {
351+ this.messageDom = null;
352+ }
353+
354+ // Cache the DOM elements once
355+ if (this.messageDom === null) {
356+ this.messageDom = document.querySelector(`#chat .mes[mesid="${messageId}"]`);
357+ if (this.messageDom === null) throw new Error('message dom does not exist');
358+ }
359+ if (this.messageReasoningDetailsDom === null) {
360+ this.messageReasoningDetailsDom = this.messageDom.querySelector('.mes_reasoning_details');
361+ }
362+ if (this.messageReasoningContentDom === null) {
363+ this.messageReasoningContentDom = this.messageDom.querySelector('.mes_reasoning');
364+ }
365+ if (this.messageReasoningHeaderDom === null) {
366+ this.messageReasoningHeaderDom = this.messageDom.querySelector('.mes_reasoning_header_title');
367+ }
368+ }
369+
370+ /**
371+ * Updates the reasoning time display in the UI.
372+ *
373+ * Shows the duration in a human-readable format with a tooltip for exact seconds.
374+ * Displays "Thinking..." if still processing, or a generic message otherwise.
375+ */
376+ #updateReasoningTimeUI() {
377+ const element = this.messageReasoningHeaderDom;
378+ const duration = this.getDuration();
379+ let data = null;
81380 if (duration) {
82381 const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });
83382 const secondsStr = moment.duration(duration).asSeconds();
84- element.innerHTML = t`Thought for <span title="${secondsStr} seconds">${durationStr}</span>`;
383+
85- } else if (forceEnd) {
384+ const span = document.createElement('span');
385+ span.title = t`${secondsStr} seconds`;
386+ span.textContent = durationStr;
387+
388+ element.textContent = t`Thought for `;
389+ element.appendChild(span);
390+ data = String(secondsStr);
391+ } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) {
86392 element.textContent = t`Thought for some time`;
393+ data = 'unknown';
87394 } else {
88395 element.textContent = t`Thinking...`;
396+ data = null;
397+ }
398+
399+ setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data);
400+ setDatasetProperty(element, 'duration', data);
89401 }
90402}
91403
@@ -95,7 +407,6 @@ export function updateReasoningTimeUI(element, duration, { forceEnd = false } =
95407 */
96408export class PromptReasoning {
97409 static REASONING_PLACEHOLDER = '\u200B';
98- static REASONING_PLACEHOLDER_REGEX = new RegExp(`${PromptReasoning.REASONING_PLACEHOLDER}$`);
99410
100411 constructor() {
101412 this.counter = 0;
@@ -126,7 +437,7 @@ export class PromptReasoning {
126437 return content;
127438 }
128439
129440 // No reasoning provided or a legacy placeholder
130441 if (!reasoning || reasoning === PromptReasoning.REASONING_PLACEHOLDER) {
131442 return content;
132443 }
@@ -192,6 +503,15 @@ function loadReasoningSettings() {
192503 toggleReasoningAutoExpand();
193504 saveSettingsDebounced();
194505 });
506+ toggleReasoningAutoExpand();
507+
508+ $('#reasoning_show_hidden').prop('checked', power_user.reasoning.show_hidden);
509+ $('#reasoning_show_hidden').on('change', function () {
510+ power_user.reasoning.show_hidden = !!$(this).prop('checked');
511+ $('#chat').attr('data-show-hidden-reasoning', power_user.reasoning.show_hidden ? 'true' : null);
512+ saveSettingsDebounced();
513+ });
514+ $('#chat').attr('data-show-hidden-reasoning', power_user.reasoning.show_hidden ? 'true' : null);
195515}
196516
197517function registerReasoningSlashCommands() {
@@ -211,7 +531,7 @@ function registerReasoningSlashCommands() {
211531 const messageId = !isNaN(parseInt(value.toString())) ? parseInt(value.toString()) : chat.length - 1;
212532 const message = chat[messageId];
213533 const reasoning = String(message?.extra?.reasoning ?? '');
214- return reasoning.replace(PromptReasoning.REASONING_PLACEHOLDER_REGEX, '');
534+ return reasoning;
215535 },
216536 }));
217537
@@ -338,7 +658,7 @@ function setReasoningEventHandlers() {
338658 const textarea = document.createElement('textarea');
339659 const reasoningBlock = messageBlock.find('.mes_reasoning');
340660 textarea.classList.add('reasoning_edit_textarea');
341661 textarea.value = reasoning.replace(PromptReasoning.REASONING_PLACEHOLDER_REGEX, '');
342662 $(textarea).insertBefore(reasoningBlock);
343663
344664 if (!CSS.supports('field-sizing', 'content')) {
@@ -393,10 +713,12 @@ function setReasoningEventHandlers() {
393713 textarea.remove();
394714
395715 messageBlock.find('.mes_reasoning_edit_cancel:visible').trigger('click');
716+
717+ updateReasoningUI(messageBlock);
396718 });
397719
398720 $(document).on('click', '.mes_edit_add_reasoning', async function () {
399721 const { message, messageId, messageBlock } = getMessageFromJquery(this);
400722 if (!message?.extra) {
401723 return;
402724 }
@@ -406,8 +728,16 @@ function setReasoningEventHandlers() {
406728 return;
407729 }
408730
409- message.extra.reasoning = PromptReasoning.REASONING_PLACEHOLDER;
731+ messageBlock.addClass('reasoning');
410- updateMessageBlock(messageId, message, { rerenderMessage: false });
732+
733+ // To make hidden reasoning blocks editable, we just set them to "Done" here already.
734+ // They will be done on save anyway - and on cancel the reasoning block gets rerendered too.
735+ if (messageBlock.attr('data-reasoning-state') === ReasoningState.Hidden) {
736+ messageBlock.attr('data-reasoning-state', ReasoningState.Done);
737+ }
738+
739+ // Open the reasoning area so we can actually edit it
740+ messageBlock.find('.mes_reasoning_details').attr('open', '');
411741 messageBlock.find('.mes_reasoning_edit').trigger('click');
412742 await saveChatConditional();
413743 });
@@ -416,7 +746,7 @@ function setReasoningEventHandlers() {
416746 e.stopPropagation();
417747 e.preventDefault();
418748
419749 const confirm = await Popup.show.confirm(t`Remove Reasoning`, t`Are you sure you want to clear the reasoning?`,<br t`/>Visible message contents will stay intact.`);
420750
421751 if (!confirm) {
422752 return;
@@ -435,7 +765,7 @@ function setReasoningEventHandlers() {
435765
436766 $(document).on('pointerup', '.mes_reasoning_copy', async function () {
437767 const { message } = getMessageFromJquery(this);
438768 const reasoning = String(message?.extra?.reasoning ?? '').replace(PromptReasoning.REASONING_PLACEHOLDER_REGEX, '');
439769
440770 if (!reasoning) {
441771 return;
@@ -553,7 +883,6 @@ function registerReasoningAppEvents() {
553883
554884export function initReasoning() {
555885 loadReasoningSettings();
556- toggleReasoningAutoExpand();
557886 setReasoningEventHandlers();
558887 registerReasoningSlashCommands();
559888 registerReasoningMacros();
public/scripts/utils.js+17 -0
@@ -2059,6 +2059,23 @@ export function toggleDrawer(drawer, expand = true) {
20592059 }
20602060}
20612061
2062+/**
2063+ * Sets or removes a dataset property on an HTMLElement
2064+ *
2065+ * Utility function to make it easier to reset dataset properties on null, without them being "null" as value.
2066+ *
2067+ * @param {HTMLElement} element - The element to modify
2068+ * @param {string} name - The name of the dataset property
2069+ * @param {string|null} value - The value to set - If null, the dataset property will be removed
2070+ */
2071+export function setDatasetProperty(element, name, value) {
2072+ if (value === null) {
2073+ delete element.dataset[name];
2074+ } else {
2075+ element.dataset[name] = value;
2076+ }
2077+}
2078+
20622079export async function fetchFaFile(name) {
20632080 const style = document.createElement('style');
20642081 style.innerHTML = await (await fetch(`/css/${name}`)).text();
public/style.css+21 -5
@@ -410,14 +410,26 @@ input[type='checkbox']:focus-visible {
410410}
411411
412412.mes_bias:empty,
413-.mes_reasoning:empty,
413+.mes:not(.reasoning) .mes_reasoning_details,
414-.mes_reasoning_details:has(.mes_reasoning:empty),
415414.mes_reasoning_details:not([open]) .mes_reasoning_actions,
416415.mes_reasoning_details:has(.reasoning_edit_textarea) .mes_reasoning,
417416.mes_reasoning_details:has(.reasoning_edit_textarea) .mes_reasoning_header,
418-.mes_reasoning_details:not(:has(.reasoning_edit_textarea)) .mes_reasoning_actions .edit_button,
419417.mes_reasoning_details:has(.reasoning_edit_textarea) .mes_reasoning_actions .mes_button:not(.edit_button),
420418.mes_blockmes_reasoning_details:hasnot(.edit_textarea):has(.reasoning_edit_textarea)) .mes_reasoning_actions {.edit_button,
419+.mes_block:has(.edit_textarea):has(.reasoning_edit_textarea) .mes_reasoning_actions,
420+.mes.reasoning:not([data-reasoning-state="hidden"]) .mes_edit_add_reasoning,
421+.mes:has(.mes_reasoning:empty) .mes_reasoning_arrow,
422+.mes:has(.mes_reasoning:empty) .mes_reasoning,
423+.mes:has(.mes_reasoning:empty) .mes_reasoning_copy {
424+ display: none;
425+}
426+
427+.mes[data-reasoning-state="hidden"] .mes_edit_add_reasoning {
428+ background-color: color-mix(in srgb, var(--SmartThemeQuoteColor) 33%, var(--SmartThemeBlurTintColor) 66%);
429+}
430+
431+/** If hidden reasoning should not be shown, we hide all blocks that don't have content */
432+#chat:not([data-show-hidden-reasoning="true"]) .mes:has(.mes_reasoning:empty) .mes_reasoning_details {
421433 display: none;
422434}
423435
@@ -4319,7 +4331,11 @@ input[type="range"]::-webkit-slider-thumb {
43194331 field-sizing: content;
43204332}
43214333
4322-.mes.reasoning .mes_edit_add_reasoning {
4334+body[data-generating="true"] #send_but,
4335+body[data-generating="true"] #mes_continue,
4336+body[data-generating="true"] #mes_impersonate,
4337+body[data-generating="true"] #chat .last_mes .mes_buttons,
4338+body[data-generating="true"] #chat .last_mes .mes_reasoning_actions {
43234339 display: none;
43244340}
43254341
src/endpoints/backends/chat-completions.js+0 -7
@@ -288,7 +288,6 @@ async function sendMakerSuiteRequest(request, response) {
288288
289289 const model = String(request.body.model);
290290 const stream = Boolean(request.body.stream);
291- const showThoughts = Boolean(request.body.include_reasoning);
292291 const isThinking = model.includes('thinking');
293292
294293 const generationConfig = {
@@ -337,12 +336,6 @@ async function sendMakerSuiteRequest(request, response) {
337336 body.systemInstruction = prompt.system_instruction;
338337 }
339338
340- if (isThinking && showThoughts) {
341- generationConfig.thinkingConfig = {
342- includeThoughts: true,
343- };
344- }
345-
346339 return body;
347340 }
348341