Merge pull request #3484 from SillyTavern/reasoning-parsing-streaming Implement reasoning parsing during streaming, based on provided prefix/suffix
Signed| @@ -3223,6 +3223,7 @@ class StreamingProcessor { | ||
| 3223 | 3223 | |
| 3224 | 3224 | // Update reasoning |
| 3225 | 3225 | await this.reasoningHandler.process(messageId, mesChanged); |
| 3226 | + processedText = chat[messageId]['mes']; | |
| 3226 | 3227 | |
| 3227 | 3228 | // Token count update. |
| 3228 | 3229 | const tokenCountText = this.reasoningHandler.reasoning + processedText; |
| @@ -3373,7 +3374,7 @@ class StreamingProcessor { | ||
| 3373 | 3374 | this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs])); |
| 3374 | 3375 | } |
| 3375 | 3376 | // Get the updated reasoning string into the handler |
| 3376 | 3377 | this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning ?? ''); |
| 3377 | 3378 | await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text); |
| 3378 | 3379 | await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text)); |
| 3379 | 3380 | } |
| @@ -3,7 +3,7 @@ import { | ||
| 3 | 3 | } from '../lib.js'; |
| 4 | 4 | import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js'; |
| 5 | 5 | import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; |
| 6 | 6 | import { getCurrentLocale, t, translate } from './i18n.js'; |
| 7 | 7 | import { MacrosParser } from './macros.js'; |
| 8 | 8 | import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js'; |
| 9 | 9 | import { Popup } from './popup.js'; |
| @@ -14,7 +14,19 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom | ||
| 14 | 14 | import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; |
| 15 | 15 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 16 | 16 | import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js'; |
| 17 | 17 | import { copyText, escapeRegex, isFalseBoolean, setDatasetProperty, trimSpaces } from './utils.js'; |
| 18 | + | |
| 19 | +/** | |
| 20 | + * Enum representing the type of the reasoning for a message (where it came from) | |
| 21 | + * @enum {string} | |
| 22 | + * @readonly | |
| 23 | + */ | |
| 24 | +export const ReasoningType = { | |
| 25 | + Model: 'model', | |
| 26 | + Parsed: 'parsed', | |
| 27 | + Manual: 'manual', | |
| 28 | + Edited: 'edited', | |
| 29 | +}; | |
| 18 | 30 | |
| 19 | 31 | /** |
| 20 | 32 | * Gets a message from a jQuery element. |
| @@ -130,7 +142,12 @@ export const ReasoningState = { | ||
| 130 | 142 | * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates. |
| 131 | 143 | */ |
| 132 | 144 | export class ReasoningHandler { |
| 145 | + /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */ | |
| 133 | 146 | #isHiddenReasoningModel; |
| 147 | + /** @type {boolean} True if the handler is currently handling a manual parse of reasoning blocks */ | |
| 148 | + #isParsingReasoning = false; | |
| 149 | + /** @type {number?} When reasoning is being parsed manually, and the reasoning has ended, this will be the index at which the actual messages starts */ | |
| 150 | + #parsingReasoningMesStartIndex = null; | |
| 134 | 151 | |
| 135 | 152 | /** |
| 136 | 153 | * @param {Date?} [timeStarted=null] - When the generation started |
| @@ -138,6 +155,8 @@ export class ReasoningHandler { | ||
| 138 | 155 | constructor(timeStarted = null) { |
| 139 | 156 | /** @type {ReasoningState} The current state of the reasoning process */ |
| 140 | 157 | this.state = ReasoningState.None; |
| 158 | + /** @type {ReasoningType?} The type of the reasoning (where it came from) */ | |
| 159 | + this.type = null; | |
| 141 | 160 | /** @type {string} The reasoning output */ |
| 142 | 161 | this.reasoning = ''; |
| 143 | 162 | /** @type {Date} When the reasoning started */ |
| @@ -148,7 +167,6 @@ export class ReasoningHandler { | ||
| 148 | 167 | /** @type {Date} Initial starting time of the generation */ |
| 149 | 168 | this.initialTime = timeStarted ?? new Date(); |
| 150 | 169 | |
| 151 | - /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */ | |
| 152 | 170 | this.#isHiddenReasoningModel = isHiddenReasoningModel(); |
| 153 | 171 | |
| 154 | 172 | // Cached DOM elements for reasoning |
| @@ -195,6 +213,7 @@ export class ReasoningHandler { | ||
| 195 | 213 | this.state = ReasoningState.Hidden; |
| 196 | 214 | } |
| 197 | 215 | |
| 216 | + this.type = extra?.reasoning_type; | |
| 198 | 217 | this.reasoning = extra?.reasoning ?? ''; |
| 199 | 218 | |
| 200 | 219 | if (this.state !== ReasoningState.None) { |
| @@ -209,6 +228,7 @@ export class ReasoningHandler { | ||
| 209 | 228 | // Make sure reset correctly clears all relevant states |
| 210 | 229 | if (reset) { |
| 211 | 230 | this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None; |
| 231 | + this.type = null; | |
| 212 | 232 | this.reasoning = ''; |
| 213 | 233 | this.initialTime = new Date(); |
| 214 | 234 | this.startTime = null; |
| @@ -238,18 +258,19 @@ export class ReasoningHandler { | ||
| 238 | 258 | * Updates the reasoning text/string for a message. |
| 239 | 259 | * |
| 240 | 260 | * @param {number} messageId - The ID of the message to update |
| 241 | 261 | * @param {string?} [reasoning=null] - The reasoning text to update - If null or empty, uses the current reasoning |
| 242 | 262 | * @param {Object} [options={}] - Optional arguments |
| 243 | 263 | * @param {boolean} [options.persist=false] - Whether to persist the reasoning to the message object |
| 264 | + * @param {boolean} [options.allowReset=false] - Whether to allow empty reasoning provided to reset the reasoning, instead of just taking the existing one | |
| 244 | 265 | * @returns {boolean} - Returns true if the reasoning was changed, otherwise false |
| 245 | 266 | */ |
| 246 | 267 | updateReasoning(messageId, reasoning = null, { persist = false, allowReset = false } = {}) { |
| 247 | 268 | if (messageId == -1 || !chat[messageId]) { |
| 248 | 269 | return false; |
| 249 | 270 | } |
| 250 | 271 | |
| 251 | 272 | reasoning = allowReset ? reasoning ?? this.reasoning : reasoning || this.reasoning; |
| 252 | 273 | reasoning = power_user.trim_spaces ? reasoning.trimtrimSpaces() : reasoning); |
| 253 | 274 | |
| 254 | 275 | // Ensure the chat extra exists |
| 255 | 276 | if (!chat[messageId].extra) { |
| @@ -260,10 +281,13 @@ export class ReasoningHandler { | ||
| 260 | 281 | const reasoningChanged = extra.reasoning !== reasoning; |
| 261 | 282 | this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING); |
| 262 | 283 | |
| 284 | + this.type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model; | |
| 285 | + | |
| 263 | 286 | if (persist) { |
| 264 | 287 | // Build and save the reasoning data to message extras |
| 265 | 288 | extra.reasoning = this.reasoning; |
| 266 | 289 | extra.reasoning_duration = this.getDuration(); |
| 290 | + extra.reasoning_type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model; | |
| 267 | 291 | } |
| 268 | 292 | |
| 269 | 293 | return reasoningChanged; |
| @@ -280,7 +304,10 @@ export class ReasoningHandler { | ||
| 280 | 304 | * @returns {Promise<void>} |
| 281 | 305 | */ |
| 282 | 306 | async process(messageId, mesChanged) { |
| 283 | - if (!this.reasoning && !this.#isHiddenReasoningModel) return; | |
| 307 | + mesChanged = this.#autoParseReasoningFromMessage(messageId, mesChanged); | |
| 308 | + | |
| 309 | + if (!this.reasoning && !this.#isHiddenReasoningModel) | |
| 310 | + return; | |
| 284 | 311 | |
| 285 | 312 | // Ensure reasoning string is updated and regexes are applied correctly |
| 286 | 313 | const reasoningChanged = this.updateReasoning(messageId, null, { persist: true }); |
| @@ -295,6 +322,53 @@ export class ReasoningHandler { | ||
| 295 | 322 | } |
| 296 | 323 | } |
| 297 | 324 | |
| 325 | + #autoParseReasoningFromMessage(messageId, mesChanged) { | |
| 326 | + if (!power_user.reasoning.auto_parse) | |
| 327 | + return; | |
| 328 | + if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) | |
| 329 | + return mesChanged; | |
| 330 | + | |
| 331 | + /** @type {{ mes: string, [key: string]: any}} */ | |
| 332 | + const message = chat[messageId]; | |
| 333 | + if (!message) return mesChanged; | |
| 334 | + | |
| 335 | + // If we are done with reasoning parse, we just split the message correctly so the reasoning doesn't show up inside of it. | |
| 336 | + if (this.#parsingReasoningMesStartIndex) { | |
| 337 | + message.mes = trimSpaces(message.mes.slice(this.#parsingReasoningMesStartIndex)); | |
| 338 | + return mesChanged; | |
| 339 | + } | |
| 340 | + | |
| 341 | + if (this.state === ReasoningState.None) { | |
| 342 | + // If streamed message starts with the opening, cut it out and put all inside reasoning | |
| 343 | + if (message.mes.startsWith(power_user.reasoning.prefix) && message.mes.length > power_user.reasoning.prefix.length) { | |
| 344 | + this.#isParsingReasoning = true; | |
| 345 | + | |
| 346 | + // Manually set starting state here, as we might already have received the ending suffix | |
| 347 | + this.state = ReasoningState.Thinking; | |
| 348 | + this.startTime = this.initialTime; | |
| 349 | + } | |
| 350 | + } | |
| 351 | + | |
| 352 | + if (!this.#isParsingReasoning) | |
| 353 | + return mesChanged; | |
| 354 | + | |
| 355 | + // If we are in manual parsing mode, all currently streaming mes tokens will go the the reasoning block | |
| 356 | + const originalMes = message.mes; | |
| 357 | + this.reasoning = originalMes.slice(power_user.reasoning.prefix.length); | |
| 358 | + message.mes = ''; | |
| 359 | + | |
| 360 | + // If the reasoning contains the ending suffix, we cut that off and continue as message streaming | |
| 361 | + if (this.reasoning.includes(power_user.reasoning.suffix)) { | |
| 362 | + this.reasoning = this.reasoning.slice(0, this.reasoning.indexOf(power_user.reasoning.suffix)); | |
| 363 | + this.#parsingReasoningMesStartIndex = originalMes.indexOf(power_user.reasoning.suffix) + power_user.reasoning.suffix.length; | |
| 364 | + message.mes = trimSpaces(originalMes.slice(this.#parsingReasoningMesStartIndex)); | |
| 365 | + this.#isParsingReasoning = false; | |
| 366 | + } | |
| 367 | + | |
| 368 | + // Only return the original mesChanged value if we haven't cut off the complete message | |
| 369 | + return message.mes.length ? mesChanged : false; | |
| 370 | + } | |
| 371 | + | |
| 298 | 372 | /** |
| 299 | 373 | * Completes the reasoning process for a message. |
| 300 | 374 | * |
| @@ -337,9 +411,10 @@ export class ReasoningHandler { | ||
| 337 | 411 | // Update states to the relevant DOM elements |
| 338 | 412 | setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null); |
| 339 | 413 | setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state); |
| 414 | + setDatasetProperty(this.messageReasoningDetailsDom, 'type', this.type); | |
| 340 | 415 | |
| 341 | 416 | // Update the reasoning message |
| 342 | 417 | const reasoning = power_user.trim_spaces ? this.reasoning.trimtrimSpaces() : this.reasoning); |
| 343 | 418 | const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true); |
| 344 | 419 | this.messageReasoningContentDom.innerHTML = displayReasoning; |
| 345 | 420 | |
| @@ -394,17 +469,14 @@ export class ReasoningHandler { | ||
| 394 | 469 | const element = this.messageReasoningHeaderDom; |
| 395 | 470 | const duration = this.getDuration(); |
| 396 | 471 | let data = null; |
| 472 | + let title = ''; | |
| 397 | 473 | if (duration) { |
| 398 | 474 | const durationStrseconds = moment.duration(duration).locale(getCurrentLocale()).humanizeasSeconds({ s: 50, ss: 3 }); |
| 399 | - const secondsStr = moment.duration(duration).asSeconds(); | |
| 400 | 475 | |
| 401 | - const span = document.createElement('span'); | |
| 476 | + const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 }); | |
| 402 | 477 | spanelement.titletextContent = t`Thought for ${secondsStrdurationStr} seconds`; |
| 403 | 478 | span.textContentdata = durationStrString(seconds); |
| 404 | - | |
| 479 | + title = `${seconds} seconds`; | |
| 405 | - element.textContent = t`Thought for `; | |
| 406 | - element.appendChild(span); | |
| 407 | - data = String(secondsStr); | |
| 408 | 480 | } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) { |
| 409 | 481 | element.textContent = t`Thought for some time`; |
| 410 | 482 | data = 'unknown'; |
| @@ -413,6 +485,12 @@ export class ReasoningHandler { | ||
| 413 | 485 | data = null; |
| 414 | 486 | } |
| 415 | 487 | |
| 488 | + if (this.type !== ReasoningType.Model) { | |
| 489 | + title += ` [${translate(this.type)}]`; | |
| 490 | + title = title.trim(); | |
| 491 | + } | |
| 492 | + element.title = title; | |
| 493 | + | |
| 416 | 494 | setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data); |
| 417 | 495 | setDatasetProperty(element, 'duration', data); |
| 418 | 496 | } |
| @@ -574,11 +652,16 @@ function registerReasoningSlashCommands() { | ||
| 574 | 652 | callback: async (args, value) => { |
| 575 | 653 | const messageId = !isNaN(Number(args.at)) ? Number(args.at) : chat.length - 1; |
| 576 | 654 | const message = chat[messageId]; |
| 577 | 655 | if (!message?.extra) { |
| 578 | 656 | return ''; |
| 579 | 657 | } |
| 658 | + // Make sure the message has an extra object | |
| 659 | + if (!message.extra || typeof message.extra !== 'object') { | |
| 660 | + message.extra = {}; | |
| 661 | + } | |
| 580 | 662 | |
| 581 | 663 | message.extra.reasoning = String(value ?? ''); |
| 664 | + message.extra.reasoning_type = ReasoningType.Manual; | |
| 582 | 665 | await saveChatConditional(); |
| 583 | 666 | |
| 584 | 667 | closeMessageEditor('reasoning'); |
| @@ -748,6 +831,7 @@ function setReasoningEventHandlers() { | ||
| 748 | 831 | const textarea = messageBlock.find('.reasoning_edit_textarea'); |
| 749 | 832 | const reasoning = getRegexedString(String(textarea.val()), regex_placement.REASONING, { isEdit: true }); |
| 750 | 833 | message.extra.reasoning = reasoning; |
| 834 | + message.extra.reasoning_type = message.extra.reasoning_type ? ReasoningType.Edited : ReasoningType.Manual; | |
| 751 | 835 | await saveChatConditional(); |
| 752 | 836 | updateMessageBlock(messageId, message); |
| 753 | 837 | textarea.remove(); |
| @@ -808,6 +892,8 @@ function setReasoningEventHandlers() { | ||
| 808 | 892 | return; |
| 809 | 893 | } |
| 810 | 894 | message.extra.reasoning = ''; |
| 895 | + delete message.extra.reasoning_type; | |
| 896 | + delete message.extra.reasoning_duration; | |
| 811 | 897 | await saveChatConditional(); |
| 812 | 898 | updateMessageBlock(messageId, message); |
| 813 | 899 | const textarea = messageBlock.find('.reasoning_edit_textarea'); |
| @@ -868,9 +954,9 @@ function parseReasoningFromString(str, { strict = true } = {}) { | ||
| 868 | 954 | return ''; |
| 869 | 955 | }); |
| 870 | 956 | |
| 871 | 957 | if (didReplace && power_user.trim_spaces) { |
| 872 | 958 | reasoning = reasoning.trimtrimSpaces(reasoning); |
| 873 | 959 | content = content.trimtrimSpaces(content); |
| 874 | 960 | } |
| 875 | 961 | |
| 876 | 962 | return { reasoning, content }; |
| @@ -899,6 +985,11 @@ function registerReasoningAppEvents() { | ||
| 899 | 985 | return null; |
| 900 | 986 | } |
| 901 | 987 | |
| 988 | + if (message.extra?.reasoning) { | |
| 989 | + console.debug('[Reasoning] Message already has reasoning', idx); | |
| 990 | + return null; | |
| 991 | + } | |
| 992 | + | |
| 902 | 993 | const parsedReasoning = parseReasoningFromString(message.mes); |
| 903 | 994 | |
| 904 | 995 | // No reasoning block found |
| @@ -916,6 +1007,7 @@ function registerReasoningAppEvents() { | ||
| 916 | 1007 | // If reasoning was found, add it to the message |
| 917 | 1008 | if (parsedReasoning.reasoning) { |
| 918 | 1009 | message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING); |
| 1010 | + message.extra.reasoning_type = ReasoningType.Parsed; | |
| 919 | 1011 | } |
| 920 | 1012 | |
| 921 | 1013 | // Update the message text if it was changed |
| @@ -8,7 +8,7 @@ import { | ||
| 8 | 8 | import { getContext } from './extensions.js'; |
| 9 | 9 | import { characters, getRequestHeaders, this_chid } from '../script.js'; |
| 10 | 10 | import { isMobile } from './RossAscends-mods.js'; |
| 11 | 11 | import { collapseNewlines, power_user } from './power-user.js'; |
| 12 | 12 | import { debounce_timeout } from './constants.js'; |
| 13 | 13 | import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; |
| 14 | 14 | import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; |
| @@ -677,6 +677,19 @@ export function sortByCssOrder(a, b) { | ||
| 677 | 677 | } |
| 678 | 678 | |
| 679 | 679 | /** |
| 680 | + * Trims leading and trailing whitespace from the input string based on a configuration setting. | |
| 681 | + * @param {string} input - The string to be trimmed | |
| 682 | + * @returns {string} The trimmed string if trimming is enabled; otherwise, returns the original string | |
| 683 | + */ | |
| 684 | + | |
| 685 | +export function trimSpaces(input) { | |
| 686 | + if (!input || typeof input !== 'string') { | |
| 687 | + return input; | |
| 688 | + } | |
| 689 | + return power_user.trim_spaces ? input.trim() : input; | |
| 690 | +} | |
| 691 | + | |
| 692 | +/** | |
| 680 | 693 | * Trims a string to the end of a nearest sentence. |
| 681 | 694 | * @param {string} input The string to trim. |
| 682 | 695 | * @returns {string} The trimmed string. |