Merge pull request #3386 from SillyTavern/reasoning-parse Reasoning blocks auto-parsing
Signed| @@ -472,6 +472,11 @@ label[for="trim_spaces"]:has(input:checked) i.warning { | ||
| 472 | 472 | display: none; |
| 473 | 473 | } |
| 474 | 474 | |
| 475 | +label[for="trim_spaces"]:not(:has(input:checked)) small { | |
| 476 | + color: var(--warning); | |
| 477 | + opacity: 1; | |
| 478 | +} | |
| 479 | + | |
| 475 | 480 | #claude_function_prefill_warning { |
| 476 | 481 | display: none; |
| 477 | 482 | color: red; |
| @@ -3791,6 +3791,12 @@ | ||
| 3791 | 3791 | <span data-i18n="Reasoning">Reasoning</span> |
| 3792 | 3792 | </h4> |
| 3793 | 3793 | <div> |
| 3794 | + <label class="checkbox_label" for="reasoning_auto_parse" title="Automatically parse reasoning blocks from main content between the reasoning prefix/suffix. Both fields must be defined and non-empty." data-i18n="[title]reasoning_auto_parse"> | |
| 3795 | + <input id="reasoning_auto_parse" type="checkbox" /> | |
| 3796 | + <small data-i18n="Auto-Parse Reasoning"> | |
| 3797 | + Auto-Parse Reasoning | |
| 3798 | + </small> | |
| 3799 | + </label> | |
| 3794 | 3800 | <label class="checkbox_label" 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"> |
| 3795 | 3801 | <input id="reasoning_add_to_prompts" type="checkbox" /> |
| 3796 | 3802 | <small data-i18n="Add Reasoning to Prompts"> |
| @@ -170,6 +170,7 @@ import { | ||
| 170 | 170 | toggleDrawer, |
| 171 | 171 | isElementInViewport, |
| 172 | 172 | copyText, |
| 173 | + escapeHtml, | |
| 173 | 174 | } from './scripts/utils.js'; |
| 174 | 175 | import { debounce_timeout } from './scripts/constants.js'; |
| 175 | 176 | |
| @@ -2067,6 +2068,17 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san | ||
| 2067 | 2068 | mes = mes.replaceAll('<', '<').replaceAll('>', '>'); |
| 2068 | 2069 | } |
| 2069 | 2070 | |
| 2071 | + // Make sure reasoning strings are always shown, even if they include "<" or ">" | |
| 2072 | + [power_user.reasoning.prefix, power_user.reasoning.suffix].forEach((reasoningString) => { | |
| 2073 | + if (!reasoningString || !reasoningString.trim().length) { | |
| 2074 | + return; | |
| 2075 | + } | |
| 2076 | + // Only replace the first occurrence of the reasoning string | |
| 2077 | + if (mes.includes(reasoningString)) { | |
| 2078 | + mes = mes.replace(reasoningString, escapeHtml(reasoningString)); | |
| 2079 | + } | |
| 2080 | + }); | |
| 2081 | + | |
| 2070 | 2082 | if (!isSystem) { |
| 2071 | 2083 | // Save double quotes in tags as a special character to prevent them from being encoded |
| 2072 | 2084 | if (!power_user.encode_tags) { |
| @@ -3209,7 +3221,7 @@ class StreamingProcessor { | ||
| 3209 | 3221 | } |
| 3210 | 3222 | |
| 3211 | 3223 | if (this.reasoning) { |
| 3212 | 3224 | chat[messageId]['extra']['reasoning'] = power_user.trim_spaces ? this.reasoning.trim() : this.reasoning; |
| 3213 | 3225 | if (this.messageReasoningDom instanceof HTMLElement) { |
| 3214 | 3226 | const formattedReasoning = messageFormatting(this.reasoning, '', false, false, messageId, {}, true); |
| 3215 | 3227 | this.messageReasoningDom.innerHTML = formattedReasoning; |
| @@ -4778,6 +4790,10 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | ||
| 4778 | 4790 | messageChunk = cleanUpMessage(getMessage, isImpersonate, isContinue, false); |
| 4779 | 4791 | reasoning = getRegexedString(reasoning, regex_placement.REASONING); |
| 4780 | 4792 | |
| 4793 | + if (power_user.trim_spaces) { | |
| 4794 | + reasoning = reasoning.trim(); | |
| 4795 | + } | |
| 4796 | + | |
| 4781 | 4797 | if (isContinue) { |
| 4782 | 4798 | getMessage = continue_mag + getMessage; |
| 4783 | 4799 | } |
| @@ -254,6 +254,7 @@ let power_user = { | ||
| 254 | 254 | }, |
| 255 | 255 | |
| 256 | 256 | reasoning: { |
| 257 | + auto_parse: false, | |
| 257 | 258 | add_to_prompts: false, |
| 258 | 259 | prefix: '<think>\n', |
| 259 | 260 | suffix: '\n</think>', |
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | import { chat, closeMessageEditor, event_types, eventSource, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js'; |
| 2 | 2 | import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; |
| 3 | 3 | import { t } from './i18n.js'; |
| 4 | 4 | import { MacrosParser } from './macros.js'; |
| @@ -8,7 +8,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js'; | ||
| 8 | 8 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; |
| 9 | 9 | import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 10 | 10 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 11 | 11 | import { copyText, escapeRegex, isFalseBoolean } from './utils.js'; |
| 12 | 12 | |
| 13 | 13 | /** |
| 14 | 14 | * Gets a message from a jQuery element. |
| @@ -106,6 +106,12 @@ function loadReasoningSettings() { | ||
| 106 | 106 | power_user.reasoning.max_additions = Number($(this).val()); |
| 107 | 107 | saveSettingsDebounced(); |
| 108 | 108 | }); |
| 109 | + | |
| 110 | + $('#reasoning_auto_parse').prop('checked', power_user.reasoning.auto_parse); | |
| 111 | + $('#reasoning_auto_parse').on('change', function () { | |
| 112 | + power_user.reasoning.auto_parse = !!$(this).prop('checked'); | |
| 113 | + saveSettingsDebounced(); | |
| 114 | + }); | |
| 109 | 115 | } |
| 110 | 116 | |
| 111 | 117 | function registerReasoningSlashCommands() { |
| @@ -161,6 +167,49 @@ function registerReasoningSlashCommands() { | ||
| 161 | 167 | return message.extra.reasoning; |
| 162 | 168 | }, |
| 163 | 169 | })); |
| 170 | + | |
| 171 | + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| 172 | + name: 'reasoning-parse', | |
| 173 | + returns: 'reasoning string', | |
| 174 | + helpString: t`Extracts the reasoning block from a string using the Reasoning Formatting settings.`, | |
| 175 | + namedArgumentList: [ | |
| 176 | + SlashCommandNamedArgument.fromProps({ | |
| 177 | + name: 'regex', | |
| 178 | + description: 'Whether to apply regex scripts to the reasoning content.', | |
| 179 | + typeList: [ARGUMENT_TYPE.BOOLEAN], | |
| 180 | + defaultValue: 'true', | |
| 181 | + isRequired: false, | |
| 182 | + enumProvider: commonEnumProviders.boolean('trueFalse'), | |
| 183 | + }), | |
| 184 | + ], | |
| 185 | + unnamedArgumentList: [ | |
| 186 | + SlashCommandArgument.fromProps({ | |
| 187 | + description: 'input string', | |
| 188 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 189 | + }), | |
| 190 | + ], | |
| 191 | + callback: (args, value) => { | |
| 192 | + if (!value) { | |
| 193 | + return ''; | |
| 194 | + } | |
| 195 | + | |
| 196 | + if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) { | |
| 197 | + toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`); | |
| 198 | + return String(value); | |
| 199 | + } | |
| 200 | + | |
| 201 | + const parsedReasoning = parseReasoningFromString(String(value)); | |
| 202 | + | |
| 203 | + if (!parsedReasoning) { | |
| 204 | + return ''; | |
| 205 | + } | |
| 206 | + | |
| 207 | + const applyRegex = !isFalseBoolean(String(args.regex ?? '')); | |
| 208 | + return applyRegex | |
| 209 | + ? getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING) | |
| 210 | + : parsedReasoning.reasoning; | |
| 211 | + }, | |
| 212 | + })); | |
| 164 | 213 | } |
| 165 | 214 | |
| 166 | 215 | function registerReasoningMacros() { |
| @@ -290,9 +339,101 @@ function setReasoningEventHandlers(){ | ||
| 290 | 339 | }); |
| 291 | 340 | } |
| 292 | 341 | |
| 342 | +/** | |
| 343 | + * Parses reasoning from a string using the power user reasoning settings. | |
| 344 | + * @typedef {Object} ParsedReasoning | |
| 345 | + * @property {string} reasoning Reasoning block | |
| 346 | + * @property {string} content Message content | |
| 347 | + * @param {string} str Content of the message | |
| 348 | + * @returns {ParsedReasoning|null} Parsed reasoning block and message content | |
| 349 | + */ | |
| 350 | +function parseReasoningFromString(str) { | |
| 351 | + // Both prefix and suffix must be defined | |
| 352 | + if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) { | |
| 353 | + return null; | |
| 354 | + } | |
| 355 | + | |
| 356 | + try { | |
| 357 | + const regex = new RegExp(`${escapeRegex(power_user.reasoning.prefix)}(.*?)${escapeRegex(power_user.reasoning.suffix)}`, 's'); | |
| 358 | + | |
| 359 | + let didReplace = false; | |
| 360 | + let reasoning = ''; | |
| 361 | + let content = String(str).replace(regex, (_match, captureGroup) => { | |
| 362 | + didReplace = true; | |
| 363 | + reasoning = captureGroup; | |
| 364 | + return ''; | |
| 365 | + }); | |
| 366 | + | |
| 367 | + if (didReplace && power_user.trim_spaces) { | |
| 368 | + reasoning = reasoning.trim(); | |
| 369 | + content = content.trim(); | |
| 370 | + } | |
| 371 | + | |
| 372 | + return { reasoning, content }; | |
| 373 | + } catch (error) { | |
| 374 | + console.error('[Reasoning] Error parsing reasoning block', error); | |
| 375 | + return null; | |
| 376 | + } | |
| 377 | +} | |
| 378 | + | |
| 379 | +function registerReasoningAppEvents() { | |
| 380 | + eventSource.makeFirst(event_types.MESSAGE_RECEIVED, (/** @type {number} */ idx) => { | |
| 381 | + if (!power_user.reasoning.auto_parse) { | |
| 382 | + return; | |
| 383 | + } | |
| 384 | + | |
| 385 | + console.debug('[Reasoning] Auto-parsing reasoning block for message', idx); | |
| 386 | + const message = chat[idx]; | |
| 387 | + | |
| 388 | + if (!message) { | |
| 389 | + console.warn('[Reasoning] Message not found', idx); | |
| 390 | + return null; | |
| 391 | + } | |
| 392 | + | |
| 393 | + if (!message.mes || message.mes === '...') { | |
| 394 | + console.debug('[Reasoning] Message content is empty or a placeholder', idx); | |
| 395 | + return null; | |
| 396 | + } | |
| 397 | + | |
| 398 | + const parsedReasoning = parseReasoningFromString(message.mes); | |
| 399 | + | |
| 400 | + // No reasoning block found | |
| 401 | + if (!parsedReasoning) { | |
| 402 | + return; | |
| 403 | + } | |
| 404 | + | |
| 405 | + // Make sure the message has an extra object | |
| 406 | + if (!message.extra || typeof message.extra !== 'object') { | |
| 407 | + message.extra = {}; | |
| 408 | + } | |
| 409 | + | |
| 410 | + const contentUpdated = !!parsedReasoning.reasoning || parsedReasoning.content !== message.mes; | |
| 411 | + | |
| 412 | + // If reasoning was found, add it to the message | |
| 413 | + if (parsedReasoning.reasoning) { | |
| 414 | + message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING); | |
| 415 | + } | |
| 416 | + | |
| 417 | + // Update the message text if it was changed | |
| 418 | + if (parsedReasoning.content !== message.mes) { | |
| 419 | + message.mes = parsedReasoning.content; | |
| 420 | + } | |
| 421 | + | |
| 422 | + // Find if a message already exists in DOM and must be updated | |
| 423 | + if (contentUpdated) { | |
| 424 | + const messageRendered = document.querySelector(`.mes[mesid="${idx}"]`) !== null; | |
| 425 | + if (messageRendered) { | |
| 426 | + console.debug('[Reasoning] Updating message block', idx); | |
| 427 | + updateMessageBlock(idx, message); | |
| 428 | + } | |
| 429 | + } | |
| 430 | + }); | |
| 431 | +} | |
| 432 | + | |
| 293 | 433 | export function initReasoning() { |
| 294 | 434 | loadReasoningSettings(); |
| 295 | 435 | setReasoningEventHandlers(); |
| 296 | 436 | registerReasoningSlashCommands(); |
| 297 | 437 | registerReasoningMacros(); |
| 438 | + registerReasoningAppEvents(); | |
| 298 | 439 | } |