Blame Raw
Cohee · 51ad27fb · · 1662 lines (65.3 KB)
4 contributors
1import {
2 moment,
3} from '../lib.js';
4import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveChatDebounced, saveSettingsDebounced, substituteParams, syncMesToSwipe, updateMessageBlock } from '../script.js';
5import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
6import { getCurrentLocale, t, translate } from './i18n.js';
7import { macros, MacroCategory } from './macros/macro-system.js';
8import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';
9import { Popup } from './popup.js';
10import { performFuzzySearch, power_user } from './power-user.js';
11import { getPresetManager } from './preset-manager.js';
12import { SlashCommand } from './slash-commands/SlashCommand.js';
13import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
14import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
15import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
16import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
17import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
18import { applyStreamFadeIn } from './util/stream-fadein.js';
19import { copyText, escapeRegex, isFalseBoolean, isTrueBoolean, setDatasetProperty, stringToRange, trimSpaces } from './utils.js';
20
21/**
22 * @typedef {object} ReasoningTemplate
23 * @property {string} name - The name of the template
24 * @property {string} prefix - Reasoning prefix
25 * @property {string} suffix - Reasoning suffix
26 * @property {string} separator - Reasoning separator
27 */
28
29/**
30 * @type {ReasoningTemplate[]} List of reasoning templates
31 */
32export const reasoning_templates = [];
33
34export const DEFAULT_REASONING_TEMPLATE = 'Think XML';
35
36/**
37 * @type {Record<string, JQuery<HTMLElement>>} List of UI elements for reasoning settings
38 * @readonly
39 */
40const UI = {
41 $select: $('#reasoning_select'),
42 $suffix: $('#reasoning_suffix'),
43 $prefix: $('#reasoning_prefix'),
44 $separator: $('#reasoning_separator'),
45 $autoParse: $('#reasoning_auto_parse'),
46 $autoExpand: $('#reasoning_auto_expand'),
47 $showHidden: $('#reasoning_show_hidden'),
48 $addToPrompts: $('#reasoning_add_to_prompts'),
49 $maxAdditions: $('#reasoning_max_additions'),
50};
51
52/**
53 * Enum representing the type of the reasoning for a message (where it came from)
54 * @readonly
55 * @enum {string}
56 */
57export const ReasoningType = {
58 Model: 'model',
59 Parsed: 'parsed',
60 Manual: 'manual',
61 Edited: 'edited',
62};
63
64/**
65 * Gets a message from a jQuery element.
66 * @param {Element} element
67 * @returns {{messageId: number, message: object, messageBlock: JQuery<HTMLElement>}}
68 */
69function getMessageFromJquery(element) {
70 const messageBlock = $(element).closest('.mes');
71 const messageId = Number(messageBlock.attr('mesid'));
72 const message = chat[messageId];
73 return { messageId: messageId, message, messageBlock };
74}
75
76/**
77 * Toggles the auto-expand state of reasoning blocks.
78 */
79function toggleReasoningAutoExpand() {
80 const reasoningBlocks = document.querySelectorAll('details.mes_reasoning_details');
81 reasoningBlocks.forEach((block) => {
82 if (block instanceof HTMLDetailsElement) {
83 block.open = power_user.reasoning.auto_expand;
84 }
85 });
86}
87
88/**
89 * Extracts the reasoning from the response data.
90 * @param {object} data Response data
91 * @returns {string} Extracted reasoning
92 */
93export function extractReasoningFromData(data, {
94 mainApi = null,
95 ignoreShowThoughts = false,
96 textGenType = null,
97 chatCompletionSource = null,
98} = {}) {
99 switch (mainApi ?? main_api) {
100 case 'textgenerationwebui':
101 switch (textGenType ?? textgenerationwebui_settings.type) {
102 case textgen_types.OPENROUTER:
103 return data?.choices?.[0]?.reasoning ?? '';
104 case textgen_types.OLLAMA:
105 return data?.thinking ?? '';
106 }
107 break;
108
109 case 'openai':
110 if (!ignoreShowThoughts && !oai_settings.show_thoughts) break;
111
112 switch (chatCompletionSource ?? oai_settings.chat_completion_source) {
113 case chat_completion_sources.DEEPSEEK:
114 return data?.choices?.[0]?.message?.reasoning_content ?? '';
115 case chat_completion_sources.XAI:
116 return data?.choices?.[0]?.message?.reasoning_content ?? '';
117 case chat_completion_sources.OPENROUTER:
118 return data?.choices?.[0]?.message?.reasoning
119 ?? data?.choices?.[0]?.message?.reasoning_content
120 ?? '';
121 case chat_completion_sources.MAKERSUITE:
122 case chat_completion_sources.VERTEXAI:
123 return data?.responseContent?.parts?.filter(part => part.thought)?.map(part => part.text)?.join('\n\n') ?? '';
124 case chat_completion_sources.CLAUDE:
125 return data?.content?.filter(part => part.type === 'thinking')?.map(part => part.thinking)?.join('\n\n') ?? '';
126 case chat_completion_sources.MISTRALAI:
127 return data?.choices?.[0]?.message?.content?.[0]?.thinking?.map(part => part.text)?.filter(x => x)?.join('\n\n') ?? '';
128 case chat_completion_sources.AIMLAPI:
129 case chat_completion_sources.POLLINATIONS:
130 case chat_completion_sources.MOONSHOT:
131 case chat_completion_sources.COMETAPI:
132 case chat_completion_sources.CHUTES:
133 case chat_completion_sources.ELECTRONHUB:
134 case chat_completion_sources.NANOGPT:
135 case chat_completion_sources.SILICONFLOW:
136 case chat_completion_sources.ZAI:
137 case chat_completion_sources.WORKERS_AI:
138 case chat_completion_sources.CUSTOM: {
139 return data?.choices?.[0]?.message?.reasoning_content
140 ?? data?.choices?.[0]?.message?.reasoning
141 ?? '';
142 }
143 }
144 break;
145 }
146
147 return '';
148}
149
150/**
151 * Extracts encrypted reasoning signature from the response data.
152 * These signatures are used to maintain reasoning context across multi-turn conversations.
153 * @param {object} data Response data
154 * @param {object} [options] Optional parameters
155 * @param {string|null} [options.mainApi] Override for main API
156 * @param {string|null} [options.chatCompletionSource] Override for chat completion source
157 * @returns {string?} Encrypted signature of the reasoning text
158 */
159export function extractReasoningSignatureFromData(data, {
160 mainApi = null,
161 chatCompletionSource = null,
162} = {}) {
163 // Only Gemini models use thought signatures (via MakerSuite/VertexAI or OpenRouter)
164 if ((mainApi ?? main_api) !== 'openai') {
165 return null;
166 }
167
168 const source = chatCompletionSource ?? oai_settings.chat_completion_source;
169 const isGemini = source === chat_completion_sources.MAKERSUITE || source === chat_completion_sources.VERTEXAI;
170 const isOpenRouter = source === chat_completion_sources.OPENROUTER;
171
172 if (!isGemini && !isOpenRouter) {
173 return null;
174 }
175
176 // OpenRouter format: reasoning_details array with type "reasoning.encrypted" (exclude tool calls)
177 if (isOpenRouter && Array.isArray(data?.choices?.[0]?.message?.reasoning_details)) {
178 for (const detail of data.choices[0].message.reasoning_details) {
179 if (!/^tool_/.test(detail.id) && detail.type === 'reasoning.encrypted' && detail.data) {
180 return detail.data;
181 }
182 }
183 }
184
185 // Direct Gemini format: Extract from responseContent.parts if available (only text parts)
186 if (isGemini && Array.isArray(data?.responseContent?.parts)) {
187 data.responseContent.parts.forEach((part) => {
188 if (part.thoughtSignature && typeof part.text === 'string') {
189 return part.thoughtSignature;
190 }
191 });
192 }
193
194 return null;
195}
196
197/**
198 * Check if the model supports reasoning, but does not send back the reasoning
199 * @returns {boolean} True if the model supports reasoning
200 */
201export function isHiddenReasoningModel() {
202 if (main_api !== 'openai') {
203 return false;
204 }
205
206 /** @typedef {{ (currentModel: string, supportedModel: string): boolean }} MatchingFunc */
207 /** @type {Record.<string, MatchingFunc>} */
208 const FUNCS = {
209 equals: (currentModel, supportedModel) => currentModel === supportedModel,
210 startsWith: (currentModel, supportedModel) => currentModel.startsWith(supportedModel),
211 };
212
213 /** @type {{ name: string; func: MatchingFunc; }[]} */
214 const hiddenReasoningModels = [
215 { name: 'gpt-4.5', func: FUNCS.startsWith },
216 { name: 'o1', func: FUNCS.startsWith },
217 { name: 'o3', func: FUNCS.startsWith },
218 { name: 'gemini-2.0-flash-thinking-exp', func: FUNCS.startsWith },
219 { name: 'gemini-2.0-pro-exp', func: FUNCS.startsWith },
220 ];
221
222 const model = getChatCompletionModel() || '';
223
224 const isHidden = hiddenReasoningModels.some(({ name, func }) => func(model, name));
225 return isHidden;
226}
227
228/**
229 * Updates the Reasoning UI for a specific message
230 * @param {number|JQuery<HTMLElement>|HTMLElement} messageIdOrElement The message ID or the message element
231 * @param {Object} [options={}] - Optional arguments
232 * @param {boolean} [options.reset=false] - Whether to reset state, and not take the current mess properties (for example when swiping)
233 */
234export function updateReasoningUI(messageIdOrElement, { reset = false } = {}) {
235 const handler = new ReasoningHandler();
236 handler.initHandleMessage(messageIdOrElement, { reset });
237}
238
239
240/**
241 * Enum for representing the state of reasoning
242 * @readonly
243 * @enum {string}
244 */
245export const ReasoningState = {
246 None: 'none',
247 Thinking: 'thinking',
248 Done: 'done',
249 Hidden: 'hidden',
250};
251
252/**
253 * Handles reasoning-specific logic and DOM updates for messages.
254 * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates.
255 */
256export class ReasoningHandler {
257 /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */
258 #isHiddenReasoningModel;
259 /** @type {boolean} True if the handler is currently handling a manual parse of reasoning blocks */
260 #isParsingReasoning = false;
261 /** @type {number?} When reasoning is being parsed manually, and the reasoning has ended, this will be the index at which the actual messages starts */
262 #parsingReasoningMesStartIndex = null;
263
264 /**
265 * @param {Date?} [timeStarted=null] - When the generation started
266 */
267 constructor(timeStarted = null) {
268 /** @type {ReasoningState} The current state of the reasoning process */
269 this.state = ReasoningState.None;
270 /** @type {ReasoningType?} The type of the reasoning (where it came from) */
271 this.type = null;
272 /** @type {string} The reasoning output */
273 this.reasoning = '';
274 /** @type {string?} The reasoning output display in case of translate or other */
275 this.reasoningDisplayText = null;
276 /** @type {Date} When the reasoning started */
277 this.startTime = null;
278 /** @type {Date} When the reasoning ended */
279 this.endTime = null;
280
281 /** @type {Date} Initial starting time of the generation */
282 this.initialTime = timeStarted ?? new Date();
283
284 this.#isHiddenReasoningModel = isHiddenReasoningModel();
285
286 // Cached DOM elements for reasoning
287 /** @type {HTMLElement} Main message DOM element `.mes` */
288 this.messageDom = null;
289 /** @type {HTMLDetailsElement} Reasoning details DOM element `.mes_reasoning_details` */
290 this.messageReasoningDetailsDom = null;
291 /** @type {HTMLElement} Reasoning content DOM element `.mes_reasoning` */
292 this.messageReasoningContentDom = null;
293 /** @type {HTMLElement} Reasoning header DOM element `.mes_reasoning_header_title` */
294 this.messageReasoningHeaderDom = null;
295 }
296
297 /**
298 * Sets the reasoning state when continuing a prompt.
299 * @param {PromptReasoning} promptReasoning Prompt reasoning object
300 */
301 initContinue(promptReasoning) {
302 this.reasoning = promptReasoning.prefixReasoning;
303 this.state = promptReasoning.prefixIncomplete ? ReasoningState.None : ReasoningState.Done;
304 this.startTime = this.initialTime;
305 this.endTime = promptReasoning.prefixDuration ? new Date(this.initialTime.getTime() + promptReasoning.prefixDuration) : null;
306 }
307
308 /**
309 * Initializes the reasoning handler for a specific message.
310 *
311 * Can be used to update the DOM elements or read other reasoning states.
312 * It will internally take the message-saved data and write the states back into the handler, as if during streaming of the message.
313 * The state will always be either done/hidden or none.
314 *
315 * @param {number|JQuery<HTMLElement>|HTMLElement} messageIdOrElement - The message ID or the message element
316 * @param {Object} [options={}] - Optional arguments
317 * @param {boolean} [options.reset=false] - Whether to reset state of the handler, and not take the current mess properties (for example when swiping)
318 */
319 initHandleMessage(messageIdOrElement, { reset = false } = {}) {
320 /** @type {HTMLElement} */
321 const messageElement = typeof messageIdOrElement === 'number'
322 ? document.querySelector(`#chat [mesid="${messageIdOrElement}"]`)
323 : messageIdOrElement instanceof HTMLElement
324 ? messageIdOrElement
325 : $(messageIdOrElement)[0];
326 const messageId = Number(messageElement.getAttribute('mesid'));
327
328 if (isNaN(messageId) || !chat[messageId]) return;
329
330 if (!chat[messageId].extra) {
331 chat[messageId].extra = {};
332 }
333 const extra = chat[messageId].extra;
334
335 if (extra.reasoning) {
336 this.state = ReasoningState.Done;
337 } else if (extra.reasoning_duration) {
338 this.state = ReasoningState.Hidden;
339 }
340
341 this.type = extra?.reasoning_type;
342 this.reasoning = extra?.reasoning ?? '';
343 this.reasoningDisplayText = extra?.reasoning_display_text ?? null;
344
345 if (this.state !== ReasoningState.None) {
346 this.initialTime = new Date(chat[messageId].gen_started);
347 this.startTime = this.initialTime;
348 this.endTime = new Date(this.startTime.getTime() + (extra?.reasoning_duration ?? 0));
349 }
350
351 // Prefill main dom element, as message might not have been rendered yet
352 this.messageDom = messageElement;
353
354 // Make sure reset correctly clears all relevant states
355 if (reset) {
356 this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None;
357 this.type = null;
358 this.reasoning = '';
359 this.reasoningDisplayText = null;
360 this.initialTime = new Date();
361 this.startTime = null;
362 this.endTime = null;
363 }
364
365 this.updateDom(messageId);
366
367 if (power_user.reasoning.auto_expand && this.state !== ReasoningState.Hidden) {
368 this.messageReasoningDetailsDom.open = true;
369 }
370 }
371
372 /**
373 * Gets the duration of the reasoning in milliseconds.
374 *
375 * @returns {number?} The duration in milliseconds, or null if the start or end time is not set
376 */
377 getDuration() {
378 if (this.startTime && this.endTime) {
379 return this.endTime.getTime() - this.startTime.getTime();
380 }
381 return null;
382 }
383
384 /**
385 * Updates the reasoning text/string for a message.
386 *
387 * @param {number} messageId - The ID of the message to update
388 * @param {string?} [reasoning=null] - The reasoning text to update - If null or empty, uses the current reasoning
389 * @param {Object} [options={}] - Optional arguments
390 * @param {boolean} [options.persist=false] - Whether to persist the reasoning to the message object
391 * @param {boolean} [options.allowReset=false] - Whether to allow empty reasoning provided to reset the reasoning, instead of just taking the existing one
392 * @returns {boolean} - Returns true if the reasoning was changed, otherwise false
393 */
394 updateReasoning(messageId, reasoning = null, { persist = false, allowReset = false } = {}) {
395 if (messageId == -1 || !chat[messageId]) {
396 return false;
397 }
398
399 reasoning = allowReset ? reasoning ?? this.reasoning : reasoning || this.reasoning;
400 reasoning = trimSpaces(reasoning);
401
402 // Ensure the chat extra exists
403 if (!chat[messageId].extra) {
404 chat[messageId].extra = {};
405 }
406 const extra = chat[messageId].extra;
407
408 const reasoningChanged = extra.reasoning !== reasoning;
409 this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING);
410
411 this.type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
412
413 if (persist) {
414 // Build and save the reasoning data to message extras
415 extra.reasoning = this.reasoning;
416 extra.reasoning_duration = this.getDuration();
417 extra.reasoning_type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
418 }
419
420 return reasoningChanged;
421 }
422
423
424 /**
425 * Handles processing of reasoning for a message.
426 *
427 * This is usually called by the message processor when a message is changed.
428 *
429 * @param {number} messageId - The ID of the message to process
430 * @param {boolean} mesChanged - Whether the message has changed
431 * @param {PromptReasoning} promptReasoning - Prompt reasoning object
432 * @returns {Promise<void>}
433 */
434 async process(messageId, mesChanged, promptReasoning) {
435 mesChanged = this.#autoParseReasoningFromMessage(messageId, mesChanged, promptReasoning);
436
437 if (!this.reasoning && !this.#isHiddenReasoningModel)
438 return;
439
440 // Ensure reasoning string is updated and regexes are applied correctly
441 const reasoningChanged = this.updateReasoning(messageId, null, { persist: true });
442
443 if ((this.#isHiddenReasoningModel || reasoningChanged) && this.state === ReasoningState.None) {
444 this.state = ReasoningState.Thinking;
445 this.startTime = this.initialTime;
446 }
447 if ((this.#isHiddenReasoningModel || !reasoningChanged) && mesChanged && this.state === ReasoningState.Thinking) {
448 this.endTime = new Date();
449 await this.finish(messageId);
450 }
451 }
452
453 /**
454 * Parse reasoning from a message during streaming.
455 * @param {number} messageId Message ID
456 * @param {boolean} mesChanged Whether the message has changed before reasoning parsing
457 * @param {PromptReasoning} promptReasoning Prompt reasoning object
458 * @returns {boolean} Whether the message has changed after reasoning parsing
459 */
460 #autoParseReasoningFromMessage(messageId, mesChanged, promptReasoning) {
461 if (!power_user.reasoning.auto_parse)
462 return;
463 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix)
464 return mesChanged;
465
466 /** @type {ChatMessage} */
467 const message = chat[messageId];
468 if (!message) return mesChanged;
469
470 const parseTarget = promptReasoning?.prefixIncomplete ? (promptReasoning.prefixReasoningFormatted + message.mes) : message.mes;
471
472 // If we are done with reasoning parse, we just split the message correctly so the reasoning doesn't show up inside of it.
473 if (this.#parsingReasoningMesStartIndex) {
474 message.mes = trimSpaces(parseTarget.slice(this.#parsingReasoningMesStartIndex));
475 return mesChanged;
476 }
477
478 if (this.state === ReasoningState.None || this.#isHiddenReasoningModel) {
479 // If streamed message starts with the opening, cut it out and put all inside reasoning
480 if (parseTarget.startsWith(power_user.reasoning.prefix) && parseTarget.length > power_user.reasoning.prefix.length) {
481 this.#isParsingReasoning = true;
482
483 // Manually set starting state here, as we might already have received the ending suffix
484 this.state = ReasoningState.Thinking;
485 this.startTime = this.startTime ?? this.initialTime;
486 this.endTime = null;
487 }
488 }
489
490 if (!this.#isParsingReasoning)
491 return mesChanged;
492
493 // If we are in manual parsing mode, all currently streaming mes tokens will go to the reasoning block
494 this.reasoning = parseTarget.slice(power_user.reasoning.prefix.length);
495 message.mes = '';
496
497 // If the reasoning contains the ending suffix, we cut that off and continue as message streaming
498 if (this.reasoning.includes(power_user.reasoning.suffix)) {
499 this.reasoning = this.reasoning.slice(0, this.reasoning.indexOf(power_user.reasoning.suffix));
500 this.#parsingReasoningMesStartIndex = parseTarget.indexOf(power_user.reasoning.suffix) + power_user.reasoning.suffix.length;
501 message.mes = trimSpaces(parseTarget.slice(this.#parsingReasoningMesStartIndex));
502 this.#isParsingReasoning = false;
503 }
504
505 // Only return the original mesChanged value if we haven't cut off the complete message
506 return message.mes.length ? mesChanged : false;
507 }
508
509 /**
510 * Completes the reasoning process for a message.
511 *
512 * Records the finish time if it was not set during streaming and updates the reasoning state.
513 * Emits an event to signal the completion of reasoning and updates the DOM elements accordingly.
514 *
515 * @param {number} messageId - The ID of the message to complete reasoning for
516 * @returns {Promise<void>}
517 */
518 async finish(messageId) {
519 if (this.state === ReasoningState.None) return;
520
521 // Make sure the finish time is recorded if a reasoning was in process and it wasn't ended correctly during streaming
522 if (this.startTime !== null && this.endTime === null) {
523 this.endTime = new Date();
524 }
525
526 if (this.state === ReasoningState.Thinking) {
527 this.state = this.#isHiddenReasoningModel ? ReasoningState.Hidden : ReasoningState.Done;
528 this.updateReasoning(messageId, null, { persist: true });
529 await eventSource.emit(event_types.STREAM_REASONING_DONE, this.reasoning, this.getDuration(), messageId, this.state);
530 }
531
532 this.updateDom(messageId);
533 }
534
535 /**
536 * Updates the reasoning UI elements for a message.
537 *
538 * Toggles the CSS class, updates states, reasoning message, and duration.
539 *
540 * @param {number} messageId - The ID of the message to update
541 */
542 updateDom(messageId) {
543 this.#checkDomElements(messageId);
544
545 // Main CSS class to show this message includes reasoning
546 this.messageDom.classList.toggle('reasoning', this.state !== ReasoningState.None);
547
548 // Update states to the relevant DOM elements
549 setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null);
550 setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state);
551 setDatasetProperty(this.messageReasoningDetailsDom, 'type', this.type);
552
553 // Update the reasoning message
554 const reasoning = trimSpaces(this.reasoningDisplayText ?? this.reasoning);
555 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);
556
557 if (power_user.stream_fade_in) {
558 applyStreamFadeIn(this.messageReasoningContentDom, displayReasoning);
559 } else {
560 this.messageReasoningContentDom.innerHTML = displayReasoning;
561 }
562
563 // Update tooltip for hidden reasoning edit
564 /** @type {HTMLElement} */
565 const button = this.messageDom.querySelector('.mes_edit_add_reasoning');
566 button.title = this.state === ReasoningState.Hidden ? t`Hidden reasoning - Add reasoning block` : t`Add reasoning block`;
567
568 // Make sure that hidden reasoning headers are collapsed by default, to not show a useless edit button
569 if (this.state === ReasoningState.Hidden) {
570 this.messageReasoningDetailsDom.open = false;
571 }
572
573 // Update the reasoning duration in the UI
574 this.#updateReasoningTimeUI();
575 }
576
577 /**
578 * Finds and caches reasoning-related DOM elements for the given message.
579 *
580 * @param {number} messageId - The ID of the message to cache the DOM elements for
581 */
582 #checkDomElements(messageId) {
583 // Make sure we reset dom elements if we are checking for a different message (shouldn't happen, but be sure)
584 if (this.messageDom !== null && this.messageDom.getAttribute('mesid') !== messageId.toString()) {
585 this.messageDom = null;
586 }
587
588 // Cache the DOM elements once
589 if (this.messageDom === null) {
590 this.messageDom = document.querySelector(`#chat .mes[mesid="${messageId}"]`);
591 if (this.messageDom === null) throw new Error('message dom does not exist');
592 }
593 if (this.messageReasoningDetailsDom === null) {
594 this.messageReasoningDetailsDom = this.messageDom.querySelector('.mes_reasoning_details');
595 }
596 if (this.messageReasoningContentDom === null) {
597 this.messageReasoningContentDom = this.messageDom.querySelector('.mes_reasoning');
598 }
599 if (this.messageReasoningHeaderDom === null) {
600 this.messageReasoningHeaderDom = this.messageDom.querySelector('.mes_reasoning_header_title');
601 }
602 }
603
604 /**
605 * Updates the reasoning time display in the UI.
606 *
607 * Shows the duration in a human-readable format with a tooltip for exact seconds.
608 * Displays "Thinking..." if still processing, or a generic message otherwise.
609 */
610 #updateReasoningTimeUI() {
611 const element = this.messageReasoningHeaderDom;
612 const duration = this.getDuration();
613 let data = null;
614 let title = '';
615 if (duration) {
616 const seconds = moment.duration(duration).asSeconds();
617
618 const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });
619 element.textContent = t`Thought for ${durationStr}`;
620 data = String(seconds);
621 title = `${seconds} seconds`;
622 } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) {
623 element.textContent = t`Thought for some time`;
624 data = 'unknown';
625 } else {
626 element.textContent = t`Thinking...`;
627 data = null;
628 }
629
630 if (this.type && this.type !== ReasoningType.Model) {
631 title += ` [${translate(this.type)}]`;
632 title = title.trim();
633 }
634 element.title = title;
635
636 setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data);
637 setDatasetProperty(element, 'duration', data);
638 }
639}
640
641/**
642 * Helper class for adding reasoning to messages.
643 * Keeps track of the number of reasoning additions.
644 */
645export class PromptReasoning {
646 /**
647 * An instance initiated during the latest prompt processing.
648 * @type {PromptReasoning}
649 * */
650 static #LATEST = null;
651 /**
652 * @readonly Zero-width space character used as a placeholder for reasoning.
653 * @type {string}
654 */
655 static REASONING_PLACEHOLDER = '\u200B';
656
657 /**
658 * Returns the latest formatted reasoning prefix if the prefix is incomplete.
659 * @returns {string} Formatted reasoning prefix
660 */
661 static getLatestPrefix() {
662 if (!PromptReasoning.#LATEST) {
663 return '';
664 }
665
666 if (!PromptReasoning.#LATEST.prefixIncomplete) {
667 return '';
668 }
669
670 return PromptReasoning.#LATEST.prefixReasoningFormatted;
671 }
672
673 /**
674 * Free the latest reasoning instance.
675 * To be called when the generation has ended or stopped.
676 */
677 static clearLatest() {
678 PromptReasoning.#LATEST = null;
679 }
680
681 constructor() {
682 PromptReasoning.#LATEST = this;
683
684 /** @type {number} */
685 this.counter = 0;
686 /** @type {number} */
687 this.prefixLength = -1;
688 /** @type {string} */
689 this.prefixReasoning = '';
690 /** @type {string} */
691 this.prefixReasoningFormatted = '';
692 /** @type {number?} */
693 this.prefixDuration = null;
694 /** @type {boolean} */
695 this.prefixIncomplete = false;
696 }
697
698 /**
699 * Checks if the limit of reasoning additions has been reached.
700 * @returns {boolean} True if the limit of reasoning additions has been reached, false otherwise.
701 */
702 isLimitReached() {
703 if (!power_user.reasoning.add_to_prompts) {
704 return true;
705 }
706
707 return this.counter >= power_user.reasoning.max_additions;
708 }
709
710 /**
711 * Add reasoning to a message according to the power user settings.
712 * @param {string} content Message content
713 * @param {string} reasoning Message reasoning
714 * @param {boolean} isPrefix Whether this is the last message prefix
715 * @param {number?} duration Duration of the reasoning
716 * @returns {string} Message content with reasoning
717 */
718 addToMessage(content, reasoning, isPrefix, duration) {
719 // Disabled or reached limit of additions
720 if (!isPrefix && (!power_user.reasoning.add_to_prompts || this.counter >= power_user.reasoning.max_additions)) {
721 return content;
722 }
723
724 // No reasoning provided or a legacy placeholder
725 if (!reasoning || reasoning === PromptReasoning.REASONING_PLACEHOLDER) {
726 return content;
727 }
728
729 // Increment the counter
730 this.counter++;
731
732 // Substitute macros in variable parts
733 const prefix = substituteParams(power_user.reasoning.prefix || '');
734 const separator = substituteParams(power_user.reasoning.separator || '');
735 const suffix = substituteParams(power_user.reasoning.suffix || '');
736
737 // Combine parts with reasoning only
738 if (isPrefix && !content) {
739 const formattedReasoning = `${prefix}${reasoning}`;
740 if (isPrefix) {
741 this.prefixReasoning = reasoning;
742 this.prefixReasoningFormatted = formattedReasoning;
743 this.prefixLength = formattedReasoning.length;
744 this.prefixDuration = duration;
745 this.prefixIncomplete = true;
746 }
747 return formattedReasoning;
748 }
749
750 // Combine parts with reasoning and content
751 const formattedReasoning = `${prefix}${reasoning}${suffix}${separator}`;
752 if (isPrefix) {
753 this.prefixReasoning = reasoning;
754 this.prefixReasoningFormatted = formattedReasoning;
755 this.prefixLength = formattedReasoning.length;
756 this.prefixDuration = duration;
757 this.prefixIncomplete = false;
758 }
759 return `${formattedReasoning}${content}`;
760 }
761
762 /**
763 * Removes the reasoning prefix from the content.
764 * @param {string} content Content with the reasoning prefix
765 * @returns {string} Content without the reasoning prefix
766 */
767 removePrefix(content) {
768 if (this.prefixLength > 0) {
769 return content.slice(this.prefixLength);
770 }
771 return content;
772 }
773}
774
775function loadReasoningSettings() {
776 UI.$addToPrompts.prop('checked', power_user.reasoning.add_to_prompts);
777 UI.$addToPrompts.on('change', function () {
778 power_user.reasoning.add_to_prompts = !!$(this).prop('checked');
779 saveSettingsDebounced();
780 });
781
782 UI.$prefix.val(power_user.reasoning.prefix);
783 UI.$prefix.on('input', function () {
784 power_user.reasoning.prefix = String($(this).val());
785 saveSettingsDebounced();
786 });
787
788 UI.$suffix.val(power_user.reasoning.suffix);
789 UI.$suffix.on('input', function () {
790 power_user.reasoning.suffix = String($(this).val());
791 saveSettingsDebounced();
792 });
793
794 UI.$separator.val(power_user.reasoning.separator);
795 UI.$separator.on('input', function () {
796 power_user.reasoning.separator = String($(this).val());
797 saveSettingsDebounced();
798 });
799
800 UI.$maxAdditions.val(power_user.reasoning.max_additions);
801 UI.$maxAdditions.on('input', function () {
802 power_user.reasoning.max_additions = Number($(this).val());
803 saveSettingsDebounced();
804 });
805
806 UI.$autoParse.prop('checked', power_user.reasoning.auto_parse);
807 UI.$autoParse.on('change', function () {
808 power_user.reasoning.auto_parse = !!$(this).prop('checked');
809 saveSettingsDebounced();
810 });
811
812 UI.$autoExpand.prop('checked', power_user.reasoning.auto_expand);
813 UI.$autoExpand.on('change', function () {
814 power_user.reasoning.auto_expand = !!$(this).prop('checked');
815 toggleReasoningAutoExpand();
816 saveSettingsDebounced();
817 });
818 toggleReasoningAutoExpand();
819
820 UI.$showHidden.prop('checked', power_user.reasoning.show_hidden);
821 UI.$showHidden.on('change', function () {
822 power_user.reasoning.show_hidden = !!$(this).prop('checked');
823 $('#chat').attr('data-show-hidden-reasoning', power_user.reasoning.show_hidden ? 'true' : null);
824 saveSettingsDebounced();
825 });
826 $('#chat').attr('data-show-hidden-reasoning', power_user.reasoning.show_hidden ? 'true' : null);
827
828 UI.$select.on('change', async function () {
829 const name = String($(this).val());
830 const template = reasoning_templates.find(p => p.name === name);
831 if (!template) {
832 return;
833 }
834
835 UI.$prefix.val(template.prefix);
836 UI.$suffix.val(template.suffix);
837 UI.$separator.val(template.separator);
838
839 power_user.reasoning.name = name;
840 power_user.reasoning.prefix = template.prefix;
841 power_user.reasoning.suffix = template.suffix;
842 power_user.reasoning.separator = template.separator;
843
844 saveSettingsDebounced();
845 });
846}
847
848function selectReasoningTemplateCallback(args, name) {
849 if (!name) {
850 return power_user.reasoning.name ?? '';
851 }
852
853 const quiet = isTrueBoolean(args?.quiet);
854 const templateNames = reasoning_templates.map(preset => preset.name);
855 let foundName = templateNames.find(x => x.toLowerCase() === name.toLowerCase());
856
857 if (!foundName) {
858 const result = performFuzzySearch('reasoning-templates', templateNames, [], name);
859
860 if (result.length === 0) {
861 !quiet && toastr.warning(`Reasoning template "${name}" not found`);
862 return '';
863 }
864
865 foundName = result[0].item;
866 }
867
868 UI.$select.val(foundName).trigger('change');
869 !quiet && toastr.success(`Reasoning template "${foundName}" selected`);
870 return foundName;
871}
872
873function registerReasoningSlashCommands() {
874 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
875 name: 'reasoning-get',
876 aliases: ['get-reasoning'],
877 returns: ARGUMENT_TYPE.STRING,
878 helpString: t`Get the contents of a reasoning block of a message. Returns an empty string if the message does not have a reasoning block.`,
879 unnamedArgumentList: [
880 SlashCommandArgument.fromProps({
881 description: 'Message ID. If not provided, the message ID of the last message is used.',
882 typeList: ARGUMENT_TYPE.NUMBER,
883 enumProvider: commonEnumProviders.messages(),
884 }),
885 ],
886 callback: (_args, value) => {
887 const messageId = !isNaN(parseInt(value.toString())) ? parseInt(value.toString()) : chat.length - 1;
888 const message = chat[messageId];
889 const reasoning = String(message?.extra?.reasoning ?? '');
890 return reasoning;
891 },
892 }));
893
894 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
895 name: 'reasoning-set',
896 aliases: ['set-reasoning'],
897 returns: ARGUMENT_TYPE.STRING,
898 helpString: t`Set the reasoning block of a message. Returns the reasoning block content.`,
899 namedArgumentList: [
900 SlashCommandNamedArgument.fromProps({
901 name: 'at',
902 description: 'Message ID. If not provided, the message ID of the last message is used.',
903 typeList: ARGUMENT_TYPE.NUMBER,
904 enumProvider: commonEnumProviders.messages(),
905 }),
906 SlashCommandNamedArgument.fromProps({
907 name: 'collapse',
908 description: 'Whether to collapse the reasoning block. (If not provided, uses the default expand setting)',
909 typeList: [ARGUMENT_TYPE.BOOLEAN],
910 enumList: commonEnumProviders.boolean('trueFalse')(),
911 }),
912 ],
913 unnamedArgumentList: [
914 SlashCommandArgument.fromProps({
915 description: 'Reasoning block content.',
916 typeList: ARGUMENT_TYPE.STRING,
917 }),
918 ],
919 callback: async (args, value) => {
920 const messageId = !isNaN(Number(args.at)) ? Number(args.at) : chat.length - 1;
921 const message = chat[messageId];
922 if (!message) {
923 return '';
924 }
925 // Make sure the message has an extra object
926 if (!message.extra || typeof message.extra !== 'object') {
927 message.extra = {};
928 }
929
930 message.extra.reasoning = String(value ?? '');
931 message.extra.reasoning_type = ReasoningType.Manual;
932 await saveChatConditional();
933
934 closeMessageEditor('reasoning');
935 updateMessageBlock(messageId, message);
936
937 if (isTrueBoolean(String(args.collapse))) $(`#chat [mesid="${messageId}"] .mes_reasoning_details`).removeAttr('open');
938 if (isFalseBoolean(String(args.collapse))) $(`#chat [mesid="${messageId}"] .mes_reasoning_details`).attr('open', '');
939 return message.extra.reasoning;
940 },
941 }));
942
943 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
944 name: 'reasoning-parse',
945 aliases: ['parse-reasoning'],
946 returns: 'reasoning string',
947 helpString: t`Extracts the reasoning block from a string using the Reasoning Formatting settings.`,
948 namedArgumentList: [
949 SlashCommandNamedArgument.fromProps({
950 name: 'regex',
951 description: 'Whether to apply regex scripts to the reasoning content.',
952 typeList: [ARGUMENT_TYPE.BOOLEAN],
953 defaultValue: 'true',
954 isRequired: false,
955 enumList: commonEnumProviders.boolean('trueFalse')(),
956 }),
957 SlashCommandNamedArgument.fromProps({
958 name: 'return',
959 description: 'Whether to return the parsed reasoning or the content without reasoning',
960 typeList: [ARGUMENT_TYPE.STRING],
961 defaultValue: 'reasoning',
962 isRequired: false,
963 enumList: [
964 new SlashCommandEnumValue('reasoning', null, enumTypes.enum, enumIcons.reasoning),
965 new SlashCommandEnumValue('content', null, enumTypes.enum, enumIcons.message),
966 ],
967 }),
968 SlashCommandNamedArgument.fromProps({
969 name: 'strict',
970 description: 'Whether to require the reasoning block to be at the beginning of the string (excluding whitespaces).',
971 typeList: [ARGUMENT_TYPE.BOOLEAN],
972 defaultValue: 'true',
973 isRequired: false,
974 enumList: commonEnumProviders.boolean('trueFalse')(),
975 }),
976 ],
977 unnamedArgumentList: [
978 SlashCommandArgument.fromProps({
979 description: 'input string',
980 typeList: [ARGUMENT_TYPE.STRING],
981 }),
982 ],
983 callback: (args, value) => {
984 if (!value || typeof value !== 'string') {
985 return '';
986 }
987
988 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
989 toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`, t`Reasoning Parse`);
990 return value;
991 }
992 if (typeof args.return !== 'string' || !['reasoning', 'content'].includes(args.return)) {
993 toastr.warning(t`Invalid return type '${args.return}', defaulting to 'reasoning'.`, t`Reasoning Parse`);
994 }
995
996 const returnMessage = args.return === 'content';
997
998 const parsedReasoning = parseReasoningFromString(value, { strict: !isFalseBoolean(String(args.strict ?? '')) });
999 if (!parsedReasoning) {
1000 return returnMessage ? value : '';
1001 }
1002
1003 if (returnMessage) {
1004 return parsedReasoning.content;
1005 }
1006
1007 const applyRegex = !isFalseBoolean(String(args.regex ?? ''));
1008 return applyRegex
1009 ? getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING)
1010 : parsedReasoning.reasoning;
1011 },
1012 }));
1013 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1014 name: 'reasoning-format',
1015 aliases: ['format-reasoning'],
1016 returns: 'formatted string',
1017 helpString: t`Formats reasoning and content into a single string using Reasoning Formatting settings. Useful for preparing text that can be parsed with /reasoning-parse.`,
1018 namedArgumentList: [
1019 SlashCommandNamedArgument.fromProps({
1020 name: 'reasoning',
1021 description: 'The reasoning/thinking text to format',
1022 typeList: [ARGUMENT_TYPE.STRING],
1023 isRequired: true,
1024 }),
1025 ],
1026 unnamedArgumentList: [
1027 SlashCommandArgument.fromProps({
1028 description: 'The main content text',
1029 typeList: [ARGUMENT_TYPE.STRING],
1030 isRequired: false,
1031 }),
1032 ],
1033 callback: (args, value) => {
1034 const reasoning = String(args?.reasoning ?? '');
1035 const content = String(value ?? '');
1036
1037 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
1038 toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`, t`Reasoning Format`);
1039 return '';
1040 }
1041
1042 if (!reasoning) {
1043 toastr.warning(t`Reasoning argument is required.`, t`Reasoning Format`);
1044 return '';
1045 }
1046
1047 const { formatted } = formatReasoning(reasoning, content);
1048 return formatted;
1049 },
1050 }));
1051 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1052 name: 'reasoning-template',
1053 aliases: ['reasoning-formatting', 'reasoning-preset'],
1054 callback: selectReasoningTemplateCallback,
1055 returns: 'template name',
1056 namedArgumentList: [
1057 SlashCommandNamedArgument.fromProps({
1058 name: 'quiet',
1059 description: 'Suppress the toast message on template change',
1060 typeList: [ARGUMENT_TYPE.BOOLEAN],
1061 defaultValue: 'false',
1062 enumList: commonEnumProviders.boolean('trueFalse')(),
1063 }),
1064 ],
1065 unnamedArgumentList: [
1066 SlashCommandArgument.fromProps({
1067 description: 'reasoning template name',
1068 typeList: [ARGUMENT_TYPE.STRING],
1069 enumProvider: () => reasoning_templates.map(x => new SlashCommandEnumValue(x.name, null, enumTypes.enum, enumIcons.preset)),
1070 }),
1071 ],
1072 helpString: `
1073 <div>
1074 Selects a reasoning template by name, using fuzzy search to find the closest match.
1075 Gets the current template if no name is provided.
1076 </div>
1077 <div>
1078 <strong>Example:</strong>
1079 <ul>
1080 <li>
1081 <pre><code class="language-stscript">/reasoning-template DeepSeek</code></pre>
1082 </li>
1083 </ul>
1084 </div>
1085 `,
1086 }));
1087
1088 /**
1089 * Gets the reasoning details elements for a message range.
1090 * @param {string} value Unnamed argument value (message ID or range)
1091 * @returns {JQuery<HTMLElement>|null} The reasoning details elements, or null if not found
1092 */
1093 function getReasoningDetailsElements(value) {
1094 const range = value ? stringToRange(String(value), 0, chat.length - 1) : { start: chat.length - 1, end: chat.length - 1 };
1095 if (!range) {
1096 toastr.warning(t`Invalid message ID or range: ${value}`);
1097 return null;
1098 }
1099 const selector = Array.from({ length: range.end - range.start + 1 }, (_, i) =>
1100 `#chat [mesid="${range.start + i}"] .mes_reasoning_details`,
1101 ).join(',');
1102 const details = $(selector);
1103 if (details.length === 0) {
1104 toastr.warning(t`No reasoning blocks found for the specified messages.`);
1105 return null;
1106 }
1107 return details;
1108 }
1109
1110 const reasoningVisibilityArgs = [
1111 SlashCommandArgument.fromProps({
1112 description: 'Message ID or range (e.g. 0-10). If not provided, the last message is used.',
1113 typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE],
1114 enumProvider: commonEnumProviders.messages(),
1115 }),
1116 ];
1117
1118 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1119 name: 'reasoning-collapse',
1120 aliases: ['collapse-reasoning'],
1121 helpString: t`Collapse the reasoning block of a message or range of messages.`,
1122 unnamedArgumentList: reasoningVisibilityArgs,
1123 callback: (_args, value) => {
1124 const details = getReasoningDetailsElements(value.toString());
1125 if (details) details.removeAttr('open');
1126 return '';
1127 },
1128 }));
1129
1130 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1131 name: 'reasoning-expand',
1132 aliases: ['expand-reasoning'],
1133 helpString: t`Expand the reasoning block of a message or range of messages.`,
1134 unnamedArgumentList: reasoningVisibilityArgs,
1135 callback: (_args, value) => {
1136 const details = getReasoningDetailsElements(value.toString());
1137 if (details) details.attr('open', '');
1138 return '';
1139 },
1140 }));
1141
1142 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1143 name: 'reasoning-toggle',
1144 aliases: ['toggle-reasoning'],
1145 helpString: t`Toggle the reasoning block of a message or range of messages. Expanded blocks will be collapsed, and collapsed blocks will be expanded.`,
1146 unnamedArgumentList: reasoningVisibilityArgs,
1147 callback: (_args, value) => {
1148 const details = getReasoningDetailsElements(value.toString());
1149 if (!details) return '';
1150 details.each(function () {
1151 const $el = $(this);
1152 if ($el.attr('open') !== undefined) {
1153 $el.removeAttr('open');
1154 } else {
1155 $el.attr('open', '');
1156 }
1157 });
1158 return '';
1159 },
1160 }));
1161}
1162
1163function registerReasoningMacros() {
1164 macros.register('reasoningPrefix', {
1165 category: MacroCategory.PROMPTS,
1166 description: t`The prefix string used before reasoning blocks`,
1167 handler: () => power_user.reasoning.prefix,
1168 });
1169 macros.register('reasoningSuffix', {
1170 category: MacroCategory.PROMPTS,
1171 description: t`The suffix string used after reasoning blocks`,
1172 handler: () => power_user.reasoning.suffix,
1173 });
1174 macros.register('reasoningSeparator', {
1175 category: MacroCategory.PROMPTS,
1176 description: t`The separator between thinking content and response`,
1177 handler: () => power_user.reasoning.separator,
1178 });
1179}
1180
1181function setReasoningEventHandlers() {
1182 /**
1183 * Updates the reasoning block of a message from a value.
1184 * @param {object} message Message object
1185 * @param {string} value Reasoning value
1186 */
1187 function updateReasoningFromValue(message, value) {
1188 const reasoning = getRegexedString(value, regex_placement.REASONING, { isEdit: true });
1189 message.extra.reasoning = reasoning;
1190 message.extra.reasoning_type = message.extra.reasoning_type ? ReasoningType.Edited : ReasoningType.Manual;
1191 }
1192
1193 $(document).on('click', '.mes_reasoning_details', function (e) {
1194 if (!e.target.closest('.mes_reasoning_actions') && !e.target.closest('.mes_reasoning_header')) {
1195 e.preventDefault();
1196 }
1197 });
1198
1199 $(document).on('click', '.mes_reasoning_header', function (e) {
1200 const details = $(this).closest('.mes_reasoning_details');
1201 // Along with the CSS rules to mark blocks not toggle-able when they are empty, prevent them from actually being toggled, or being edited
1202 if (details.find('.mes_reasoning').is(':empty')) {
1203 e.preventDefault();
1204 return;
1205 }
1206
1207 // If we are in message edit mode and reasoning area is closed, a click opens and edits it
1208 const mes = $(this).closest('.mes');
1209 const mesEditArea = mes.find('#curEditTextarea');
1210 if (mesEditArea.length) {
1211 const summary = $(mes).find('.mes_reasoning_summary');
1212 if (!summary.attr('open')) {
1213 summary.find('.mes_reasoning_edit').trigger('click');
1214 }
1215 }
1216 });
1217
1218 $(document).on('click', '.mes_reasoning_copy', (e) => {
1219 e.stopPropagation();
1220 e.preventDefault();
1221 });
1222
1223 $(document).on('click', '.mes_reasoning_edit', function (e) {
1224 e.stopPropagation();
1225 e.preventDefault();
1226 const { message, messageBlock } = getMessageFromJquery(this);
1227 if (!message?.extra) {
1228 return;
1229 }
1230
1231 const reasoning = String(message?.extra?.reasoning ?? '');
1232 const chatElement = document.getElementById('chat');
1233 const textarea = document.createElement('textarea');
1234 const reasoningBlock = messageBlock.find('.mes_reasoning');
1235 textarea.classList.add('reasoning_edit_textarea');
1236 textarea.value = reasoning;
1237 $(textarea).insertBefore(reasoningBlock);
1238
1239 if (!CSS.supports('field-sizing', 'content')) {
1240 const resetHeight = function () {
1241 const scrollTop = chatElement.scrollTop;
1242 textarea.style.height = '0px';
1243 textarea.style.height = `${textarea.scrollHeight}px`;
1244 chatElement.scrollTop = scrollTop;
1245 };
1246
1247 textarea.addEventListener('input', resetHeight);
1248 resetHeight();
1249 }
1250
1251 textarea.focus();
1252 textarea.setSelectionRange(textarea.value.length, textarea.value.length);
1253
1254 const textareaRect = textarea.getBoundingClientRect();
1255 const chatRect = chatElement.getBoundingClientRect();
1256
1257 // Scroll if textarea bottom is below visible area
1258 if (textareaRect.bottom > chatRect.bottom) {
1259 const scrollOffset = textareaRect.bottom - chatRect.bottom;
1260 chatElement.scrollTop += scrollOffset;
1261 }
1262 });
1263
1264 $(document).on('click', '.mes_reasoning_close_all', function (e) {
1265 e.stopPropagation();
1266 e.preventDefault();
1267
1268 $('.mes_reasoning_details[open] .mes_reasoning_header').trigger('click');
1269 });
1270
1271 $(document).on('click', '.mes_reasoning_edit_done', async function (e) {
1272 e.stopPropagation();
1273 e.preventDefault();
1274 const { message, messageId, messageBlock } = getMessageFromJquery(this);
1275 if (!message?.extra) {
1276 return;
1277 }
1278
1279 const textarea = messageBlock.find('.reasoning_edit_textarea');
1280 let newReasoning = String(textarea.val());
1281 newReasoning = substituteParams(newReasoning);
1282 textarea.remove();
1283 if (newReasoning === message.extra.reasoning) {
1284 return;
1285 }
1286 updateReasoningFromValue(message, newReasoning);
1287 await saveChatConditional();
1288 updateMessageBlock(messageId, message);
1289
1290 messageBlock.find('.mes_edit_done:visible').trigger('click');
1291 await eventSource.emit(event_types.MESSAGE_REASONING_EDITED, messageId);
1292 });
1293
1294 $(document).on('click', '.mes_reasoning_edit_cancel', function (e) {
1295 e.stopPropagation();
1296 e.preventDefault();
1297
1298 const { messageBlock } = getMessageFromJquery(this);
1299 const textarea = messageBlock.find('.reasoning_edit_textarea');
1300 textarea.remove();
1301
1302 messageBlock.find('.mes_reasoning_edit_cancel:visible').trigger('click');
1303
1304 updateReasoningUI(messageBlock);
1305 });
1306
1307 $(document).on('click', '.mes_edit_add_reasoning', async function () {
1308 const { message, messageBlock } = getMessageFromJquery(this);
1309 if (!message?.extra) {
1310 return;
1311 }
1312
1313 if (message.extra.reasoning) {
1314 toastr.info(t`Reasoning already exists.`, t`Edit Message`);
1315 return;
1316 }
1317
1318 messageBlock.addClass('reasoning');
1319
1320 // To make hidden reasoning blocks editable, we just set them to "Done" here already.
1321 // They will be done on save anyway - and on cancel the reasoning block gets rerendered too.
1322 if (messageBlock.attr('data-reasoning-state') === ReasoningState.Hidden) {
1323 messageBlock.attr('data-reasoning-state', ReasoningState.Done);
1324 }
1325
1326 // Open the reasoning area so we can actually edit it
1327 messageBlock.find('.mes_reasoning_details').attr('open', '');
1328 messageBlock.find('.mes_reasoning_edit').trigger('click');
1329 await saveChatConditional();
1330 });
1331
1332 $(document).on('click', '.mes_reasoning_delete', async function (e) {
1333 e.stopPropagation();
1334 e.preventDefault();
1335
1336 const confirm = await Popup.show.confirm(t`Remove Reasoning`, t`Are you sure you want to clear the reasoning?<br />Visible message contents will stay intact.`);
1337
1338 if (!confirm) {
1339 return;
1340 }
1341
1342 const { message, messageId, messageBlock } = getMessageFromJquery(this);
1343 if (!message?.extra) {
1344 return;
1345 }
1346 message.extra.reasoning = '';
1347 delete message.extra.reasoning_type;
1348 delete message.extra.reasoning_duration;
1349 await saveChatConditional();
1350 updateMessageBlock(messageId, message);
1351 const textarea = messageBlock.find('.reasoning_edit_textarea');
1352 textarea.remove();
1353 await eventSource.emit(event_types.MESSAGE_REASONING_DELETED, messageId);
1354 });
1355
1356 $(document).on('pointerup', '.mes_reasoning_copy', async function () {
1357 const { message } = getMessageFromJquery(this);
1358 const reasoning = String(message?.extra?.reasoning ?? '');
1359
1360 if (!reasoning) {
1361 return;
1362 }
1363
1364 await copyText(reasoning);
1365 toastr.info(t`Copied!`, '', { timeOut: 2000 });
1366 });
1367
1368 $(document).on('input', '.reasoning_edit_textarea', function () {
1369 if (!power_user.auto_save_msg_edits) {
1370 return;
1371 }
1372
1373 const { message, messageBlock } = getMessageFromJquery(this);
1374 if (!message?.extra) {
1375 return;
1376 }
1377
1378 updateReasoningFromValue(message, String($(this).val()));
1379 updateReasoningUI(messageBlock);
1380 saveChatDebounced();
1381 });
1382}
1383
1384/**
1385 * Removes reasoning from a string if auto-parsing is enabled.
1386 * @param {string} str Input string
1387 * @returns {string} Output string
1388 */
1389export function removeReasoningFromString(str) {
1390 if (!power_user.reasoning.auto_parse) {
1391 return str;
1392 }
1393
1394 const parsedReasoning = parseReasoningFromString(str);
1395 return parsedReasoning?.content ?? str;
1396}
1397
1398/**
1399 * Returns the reasoning template object from its name
1400 * @param {string} name of the template
1401 * @returns {ReasoningTemplate} the reasoning template object
1402 * @throws {Error}
1403 */
1404export function getReasoningTemplateByName(name) {
1405 const template = reasoning_templates.find(p => p.name === name);
1406 if (!template) throw new Error(`Unknown reasoning template name: "${name}"`);
1407 return template;
1408}
1409
1410/**
1411 * Parses reasoning from a string using the power user reasoning settings or optional template.
1412 * @typedef {Object} ParsedReasoning
1413 * @property {string} reasoning Reasoning block
1414 * @property {string} content Message content
1415 * @param {string} str Content of the message
1416 * @param {Object} options Optional arguments
1417 * @param {boolean} [options.strict=true] Whether the reasoning block **has** to be at the beginning of the provided string (excluding whitespaces), or can be anywhere in it
1418 * @param {ReasoningTemplate} template Optional reasoning template to use instead of power_user.reasoning
1419 * @returns {ParsedReasoning|null} Parsed reasoning block and message content
1420 */
1421export function parseReasoningFromString(str, { strict = true } = {}, template = null) {
1422 template = template ?? power_user.reasoning; // if no template given, use the currently selected template
1423
1424 // Both prefix and suffix must be defined
1425 if (!template.prefix || !template.suffix) {
1426 return null;
1427 }
1428
1429 try {
1430 const regex = new RegExp(`${(strict ? '^\\s*?' : '')}${escapeRegex(template.prefix)}(.*?)${escapeRegex(template.suffix)}`, 's');
1431
1432 let didReplace = false;
1433 let reasoning = '';
1434 let content = String(str).replace(regex, (_match, captureGroup) => {
1435 didReplace = true;
1436 reasoning = captureGroup;
1437 return '';
1438 });
1439
1440 if (didReplace) {
1441 reasoning = trimSpaces(reasoning);
1442 content = trimSpaces(content);
1443 }
1444
1445 return { reasoning, content };
1446 } catch (error) {
1447 console.error('[Reasoning] Error parsing reasoning block', error);
1448 return null;
1449 }
1450}
1451
1452/**
1453 * Formats reasoning and content into a string using the reasoning template.
1454 * This is the inverse of parseReasoningFromString.
1455 * @typedef {Object} FormattedReasoning
1456 * @property {string} formatted The formatted string with reasoning wrapped in prefix/suffix
1457 * @property {string} contentOnly The content without reasoning
1458 * @param {string} reasoning The reasoning/thinking text
1459 * @param {string} content The main content/response text
1460 * @param {ReasoningTemplate} [template=null] Optional template to use. Defaults to power_user.reasoning
1461 * @returns {FormattedReasoning} Object containing both formatted (reasoning + content) and contentOnly
1462 */
1463export function formatReasoning(reasoning, content, template = null) {
1464 template = template ?? power_user.reasoning;
1465
1466 // If no reasoning provided, return content only
1467 if (!reasoning || !template.prefix || !template.suffix) {
1468 return { formatted: content, contentOnly: content };
1469 }
1470
1471 // Substitute macros in template parts
1472 const prefix = substituteParams(template.prefix || '');
1473 const suffix = substituteParams(template.suffix || '');
1474 const separator = substituteParams(template.separator || '');
1475
1476 // Build the formatted string: prefix + reasoning + suffix + separator + content
1477 const formatted = `${prefix}${reasoning}${suffix}${separator}${content}`;
1478
1479 return { formatted, contentOnly: content };
1480}
1481
1482/**
1483 * Parse reasoning in an array of swipe strings if auto-parsing is enabled.
1484 * @param {string[]} swipes Array of swipe strings
1485 * @param {{extra: Partial<ReasoningMessageExtra>}[]} swipeInfoArray Array of swipe info objects
1486 * @param {number?} duration Duration of the reasoning
1487 * @typedef {object} ReasoningMessageExtra Extra reasoning data
1488 * @property {string} reasoning Reasoning block
1489 * @property {number} reasoning_duration Duration of the reasoning block
1490 * @property {string} reasoning_type Type of reasoning block
1491 * @property {string?} reasoning_signature Encrypted signature of the reasoning text
1492 */
1493export function parseReasoningInSwipes(swipes, swipeInfoArray, duration) {
1494 if (!power_user.reasoning.auto_parse) {
1495 return;
1496 }
1497
1498 // Something ain't right, don't parse
1499 if (!Array.isArray(swipes) || !Array.isArray(swipeInfoArray) || swipes.length !== swipeInfoArray.length) {
1500 return;
1501 }
1502
1503 for (let index = 0; index < swipes.length; index++) {
1504 const parsedReasoning = parseReasoningFromString(swipes[index]);
1505 if (parsedReasoning) {
1506 swipes[index] = getRegexedString(parsedReasoning.content, regex_placement.REASONING);
1507 swipeInfoArray[index].extra.reasoning = parsedReasoning.reasoning;
1508 swipeInfoArray[index].extra.reasoning_duration = duration;
1509 swipeInfoArray[index].extra.reasoning_type = ReasoningType.Parsed;
1510 }
1511 }
1512}
1513
1514function registerReasoningAppEvents() {
1515 const eventHandler = (/** @type {string} */ type, /** @type {number} */ idx) => {
1516 if (!power_user.reasoning.auto_parse) {
1517 return;
1518 }
1519
1520 console.debug('[Reasoning] Auto-parsing reasoning block for message', idx);
1521 const prefix = type === event_types.MESSAGE_RECEIVED ? PromptReasoning.getLatestPrefix() : '';
1522 const message = chat[idx];
1523
1524 if (!message) {
1525 console.warn('[Reasoning] Message not found', idx);
1526 return null;
1527 }
1528
1529 if (!message.mes || message.mes === '...') {
1530 console.debug('[Reasoning] Message content is empty or a placeholder', idx);
1531 return null;
1532 }
1533
1534 if (message.extra?.reasoning && !prefix) {
1535 console.debug('[Reasoning] Message already has reasoning', idx);
1536 return null;
1537 }
1538
1539 const parsedReasoning = parseReasoningFromString(prefix + message.mes);
1540
1541 // No reasoning block found
1542 if (!parsedReasoning) {
1543 return;
1544 }
1545
1546 // Make sure the message has an extra object
1547 if (!message.extra || typeof message.extra !== 'object') {
1548 message.extra = {};
1549 }
1550
1551 const contentUpdated = !!parsedReasoning.reasoning || parsedReasoning.content !== message.mes;
1552
1553 // If reasoning was found, add it to the message
1554 if (parsedReasoning.reasoning) {
1555 message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING);
1556 message.extra.reasoning_type = ReasoningType.Parsed;
1557 }
1558
1559 // Update the message text if it was changed
1560 if (parsedReasoning.content !== message.mes) {
1561 message.mes = parsedReasoning.content;
1562 }
1563
1564 if (contentUpdated) {
1565 syncMesToSwipe();
1566 saveChatDebounced();
1567
1568 // Find if a message already exists in DOM and must be updated
1569 const messageRendered = document.querySelector(`.mes[mesid="${idx}"]`) !== null;
1570 if (messageRendered) {
1571 console.debug('[Reasoning] Updating message block', idx);
1572 updateMessageBlock(idx, message);
1573 }
1574 }
1575 };
1576
1577 for (const event of [event_types.MESSAGE_RECEIVED, event_types.MESSAGE_UPDATED]) {
1578 eventSource.on(event, (/** @type {number} */ idx) => eventHandler(event, idx));
1579 }
1580
1581 for (const event of [event_types.GENERATION_STOPPED, event_types.GENERATION_ENDED, event_types.CHAT_CHANGED]) {
1582 eventSource.on(event, () => PromptReasoning.clearLatest());
1583 }
1584
1585 eventSource.makeFirst(event_types.IMPERSONATE_READY, async () => {
1586 if (!power_user.reasoning.auto_parse) {
1587 return;
1588 }
1589
1590 const sendTextArea = /** @type {HTMLTextAreaElement} */ (document.getElementById('send_textarea'));
1591
1592 if (!sendTextArea) {
1593 console.warn('[Reasoning] Send textarea not found');
1594 return;
1595 }
1596
1597 console.debug('[Reasoning] Auto-parsing reasoning block for impersonation');
1598
1599 if (!sendTextArea.value) {
1600 console.debug('[Reasoning] Reasoning is empty, skipping');
1601 return;
1602 }
1603
1604 sendTextArea.value = removeReasoningFromString(sendTextArea.value);
1605 sendTextArea.dispatchEvent(new Event('input', { bubbles: true }));
1606 });
1607}
1608
1609/**
1610 * Loads reasoning templates from the settings data.
1611 * @param {object} data Settings data
1612 * @param {ReasoningTemplate[]} data.reasoning Reasoning templates
1613 * @returns {Promise<void>}
1614 */
1615export async function loadReasoningTemplates(data) {
1616 if (data.reasoning !== undefined) {
1617 reasoning_templates.splice(0, reasoning_templates.length, ...data.reasoning);
1618 }
1619
1620 for (const template of reasoning_templates) {
1621 $('<option>').val(template.name).text(template.name).appendTo(UI.$select);
1622 }
1623
1624 // No template name, need to migrate
1625 if (power_user.reasoning.name === undefined) {
1626 const defaultTemplate = reasoning_templates.find(p => p.name === DEFAULT_REASONING_TEMPLATE);
1627 if (defaultTemplate) {
1628 // If the reasoning settings were modified - migrate them to a custom template
1629 if (power_user.reasoning.prefix !== defaultTemplate.prefix || power_user.reasoning.suffix !== defaultTemplate.suffix || power_user.reasoning.separator !== defaultTemplate.separator) {
1630 /** @type {ReasoningTemplate} */
1631 const data = {
1632 name: '[Migrated] Custom',
1633 prefix: power_user.reasoning.prefix,
1634 suffix: power_user.reasoning.suffix,
1635 separator: power_user.reasoning.separator,
1636 };
1637 await getPresetManager('reasoning')?.savePreset(data.name, data);
1638 power_user.reasoning.name = data.name;
1639 } else {
1640 power_user.reasoning.name = defaultTemplate.name;
1641 }
1642 } else {
1643 // Template not found (deleted or content check skipped - leave blank)
1644 power_user.reasoning.name = '';
1645 }
1646
1647 saveSettingsDebounced();
1648 }
1649
1650 UI.$select.val(power_user.reasoning.name);
1651}
1652
1653/**
1654 * Initializes reasoning settings and event handlers.
1655 */
1656export function initReasoning() {
1657 loadReasoningSettings();
1658 setReasoningEventHandlers();
1659 registerReasoningSlashCommands();
1660 registerReasoningMacros();
1661 registerReasoningAppEvents();
1662}