Merge pull request #3484 from SillyTavern/reasoning-parsing-streaming Implement reasoning parsing during streaming, based on provided prefix/suffix

d15d49b295b0f84c8ed1a480043f605e71e768f9

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

Signed
3 files changed, +130 -24Showing whitespace changes
public/script.js+2 -1
@@ -3223,6 +3223,7 @@ class StreamingProcessor {
32233223
3224 // Update reasoning3224 // Update reasoning
3225 await this.reasoningHandler.process(messageId, mesChanged);3225 await this.reasoningHandler.process(messageId, mesChanged);
3226 processedText = chat[messageId]['mes'];
32263227
3227 // Token count update.3228 // Token count update.
3228 const tokenCountText = this.reasoningHandler.reasoning + processedText;3229 const tokenCountText = this.reasoningHandler.reasoning + processedText;
@@ -3373,7 +3374,7 @@ class StreamingProcessor {
3373 this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));3374 this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));
3374 }3375 }
3375 // Get the updated reasoning string into the handler3376 // Get the updated reasoning string into the handler
3376 this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning ?? '');3377 this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning);
3377 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);3378 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
3378 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));3379 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
3379 }3380 }
public/scripts/reasoning.js+114 -22
@@ -3,7 +3,7 @@ import {
3} from '../lib.js';3} from '../lib.js';
4import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js';4import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js';
5import { getRegexedString, regex_placement } from './extensions/regex/engine.js';5import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
6import { getCurrentLocale, t } from './i18n.js';6import { getCurrentLocale, t, translate } from './i18n.js';
7import { MacrosParser } from './macros.js';7import { MacrosParser } from './macros.js';
8import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';8import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';
9import { Popup } from './popup.js';9import { Popup } from './popup.js';
@@ -14,7 +14,19 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
14import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';14import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
15import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';15import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
16import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';16import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
17import { copyText, escapeRegex, isFalseBoolean, setDatasetProperty } from './utils.js';17import { 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 */
24export const ReasoningType = {
25 Model: 'model',
26 Parsed: 'parsed',
27 Manual: 'manual',
28 Edited: 'edited',
29};
1830
19/**31/**
20 * Gets a message from a jQuery element.32 * Gets a message from a jQuery element.
@@ -130,7 +142,12 @@ export const ReasoningState = {
130 * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates.142 * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates.
131 */143 */
132export class ReasoningHandler {144export class ReasoningHandler {
145 /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */
133 #isHiddenReasoningModel;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;
134151
135 /**152 /**
136 * @param {Date?} [timeStarted=null] - When the generation started153 * @param {Date?} [timeStarted=null] - When the generation started
@@ -138,6 +155,8 @@ export class ReasoningHandler {
138 constructor(timeStarted = null) {155 constructor(timeStarted = null) {
139 /** @type {ReasoningState} The current state of the reasoning process */156 /** @type {ReasoningState} The current state of the reasoning process */
140 this.state = ReasoningState.None;157 this.state = ReasoningState.None;
158 /** @type {ReasoningType?} The type of the reasoning (where it came from) */
159 this.type = null;
141 /** @type {string} The reasoning output */160 /** @type {string} The reasoning output */
142 this.reasoning = '';161 this.reasoning = '';
143 /** @type {Date} When the reasoning started */162 /** @type {Date} When the reasoning started */
@@ -148,7 +167,6 @@ export class ReasoningHandler {
148 /** @type {Date} Initial starting time of the generation */167 /** @type {Date} Initial starting time of the generation */
149 this.initialTime = timeStarted ?? new Date();168 this.initialTime = timeStarted ?? new Date();
150169
151 /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */
152 this.#isHiddenReasoningModel = isHiddenReasoningModel();170 this.#isHiddenReasoningModel = isHiddenReasoningModel();
153171
154 // Cached DOM elements for reasoning172 // Cached DOM elements for reasoning
@@ -195,6 +213,7 @@ export class ReasoningHandler {
195 this.state = ReasoningState.Hidden;213 this.state = ReasoningState.Hidden;
196 }214 }
197215
216 this.type = extra?.reasoning_type;
198 this.reasoning = extra?.reasoning ?? '';217 this.reasoning = extra?.reasoning ?? '';
199218
200 if (this.state !== ReasoningState.None) {219 if (this.state !== ReasoningState.None) {
@@ -209,6 +228,7 @@ export class ReasoningHandler {
209 // Make sure reset correctly clears all relevant states228 // Make sure reset correctly clears all relevant states
210 if (reset) {229 if (reset) {
211 this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None;230 this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None;
231 this.type = null;
212 this.reasoning = '';232 this.reasoning = '';
213 this.initialTime = new Date();233 this.initialTime = new Date();
214 this.startTime = null;234 this.startTime = null;
@@ -238,18 +258,19 @@ export class ReasoningHandler {
238 * Updates the reasoning text/string for a message.258 * Updates the reasoning text/string for a message.
239 *259 *
240 * @param {number} messageId - The ID of the message to update260 * @param {number} messageId - The ID of the message to update
241 * @param {string?} [reasoning=null] - The reasoning text to update - If null, uses the current reasoning261 * @param {string?} [reasoning=null] - The reasoning text to update - If null or empty, uses the current reasoning
242 * @param {Object} [options={}] - Optional arguments262 * @param {Object} [options={}] - Optional arguments
243 * @param {boolean} [options.persist=false] - Whether to persist the reasoning to the message object263 * @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 * @returns {boolean} - Returns true if the reasoning was changed, otherwise false265 * @returns {boolean} - Returns true if the reasoning was changed, otherwise false
245 */266 */
246 updateReasoning(messageId, reasoning = null, { persist = false } = {}) {267 updateReasoning(messageId, reasoning = null, { persist = false, allowReset = false } = {}) {
247 if (messageId == -1 || !chat[messageId]) {268 if (messageId == -1 || !chat[messageId]) {
248 return false;269 return false;
249 }270 }
250271
251 reasoning = reasoning ?? this.reasoning;272 reasoning = allowReset ? reasoning ?? this.reasoning : reasoning || this.reasoning;
252 reasoning = power_user.trim_spaces ? reasoning.trim() : reasoning;273 reasoning = trimSpaces(reasoning);
253274
254 // Ensure the chat extra exists275 // Ensure the chat extra exists
255 if (!chat[messageId].extra) {276 if (!chat[messageId].extra) {
@@ -260,10 +281,13 @@ export class ReasoningHandler {
260 const reasoningChanged = extra.reasoning !== reasoning;281 const reasoningChanged = extra.reasoning !== reasoning;
261 this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING);282 this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING);
262283
284 this.type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
285
263 if (persist) {286 if (persist) {
264 // Build and save the reasoning data to message extras287 // Build and save the reasoning data to message extras
265 extra.reasoning = this.reasoning;288 extra.reasoning = this.reasoning;
266 extra.reasoning_duration = this.getDuration();289 extra.reasoning_duration = this.getDuration();
290 extra.reasoning_type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
267 }291 }
268292
269 return reasoningChanged;293 return reasoningChanged;
@@ -280,7 +304,10 @@ export class ReasoningHandler {
280 * @returns {Promise<void>}304 * @returns {Promise<void>}
281 */305 */
282 async process(messageId, mesChanged) {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;
284311
285 // Ensure reasoning string is updated and regexes are applied correctly312 // Ensure reasoning string is updated and regexes are applied correctly
286 const reasoningChanged = this.updateReasoning(messageId, null, { persist: true });313 const reasoningChanged = this.updateReasoning(messageId, null, { persist: true });
@@ -295,6 +322,53 @@ export class ReasoningHandler {
295 }322 }
296 }323 }
297324
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 * Completes the reasoning process for a message.373 * Completes the reasoning process for a message.
300 *374 *
@@ -337,9 +411,10 @@ export class ReasoningHandler {
337 // Update states to the relevant DOM elements411 // Update states to the relevant DOM elements
338 setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null);412 setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null);
339 setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state);413 setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state);
414 setDatasetProperty(this.messageReasoningDetailsDom, 'type', this.type);
340415
341 // Update the reasoning message416 // Update the reasoning message
342 const reasoning = power_user.trim_spaces ? this.reasoning.trim() : this.reasoning;417 const reasoning = trimSpaces(this.reasoning);
343 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);418 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);
344 this.messageReasoningContentDom.innerHTML = displayReasoning;419 this.messageReasoningContentDom.innerHTML = displayReasoning;
345420
@@ -394,17 +469,14 @@ export class ReasoningHandler {
394 const element = this.messageReasoningHeaderDom;469 const element = this.messageReasoningHeaderDom;
395 const duration = this.getDuration();470 const duration = this.getDuration();
396 let data = null;471 let data = null;
472 let title = '';
397 if (duration) {473 if (duration) {
398 const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });474 const seconds = moment.duration(duration).asSeconds();
399 const secondsStr = moment.duration(duration).asSeconds();
400
401 const span = document.createElement('span');
402 span.title = t`${secondsStr} seconds`;
403 span.textContent = durationStr;
404475
405 element.textContent = t`Thought for `;476 const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });
406 element.appendChild(span);477 element.textContent = t`Thought for ${durationStr}`;
407 data = String(secondsStr);478 data = String(seconds);
479 title = `${seconds} seconds`;
408 } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) {480 } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) {
409 element.textContent = t`Thought for some time`;481 element.textContent = t`Thought for some time`;
410 data = 'unknown';482 data = 'unknown';
@@ -413,6 +485,12 @@ export class ReasoningHandler {
413 data = null;485 data = null;
414 }486 }
415487
488 if (this.type !== ReasoningType.Model) {
489 title += ` [${translate(this.type)}]`;
490 title = title.trim();
491 }
492 element.title = title;
493
416 setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data);494 setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data);
417 setDatasetProperty(element, 'duration', data);495 setDatasetProperty(element, 'duration', data);
418 }496 }
@@ -574,11 +652,16 @@ function registerReasoningSlashCommands() {
574 callback: async (args, value) => {652 callback: async (args, value) => {
575 const messageId = !isNaN(Number(args.at)) ? Number(args.at) : chat.length - 1;653 const messageId = !isNaN(Number(args.at)) ? Number(args.at) : chat.length - 1;
576 const message = chat[messageId];654 const message = chat[messageId];
577 if (!message?.extra) {655 if (!message) {
578 return '';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 }
580662
581 message.extra.reasoning = String(value ?? '');663 message.extra.reasoning = String(value ?? '');
664 message.extra.reasoning_type = ReasoningType.Manual;
582 await saveChatConditional();665 await saveChatConditional();
583666
584 closeMessageEditor('reasoning');667 closeMessageEditor('reasoning');
@@ -748,6 +831,7 @@ function setReasoningEventHandlers() {
748 const textarea = messageBlock.find('.reasoning_edit_textarea');831 const textarea = messageBlock.find('.reasoning_edit_textarea');
749 const reasoning = getRegexedString(String(textarea.val()), regex_placement.REASONING, { isEdit: true });832 const reasoning = getRegexedString(String(textarea.val()), regex_placement.REASONING, { isEdit: true });
750 message.extra.reasoning = reasoning;833 message.extra.reasoning = reasoning;
834 message.extra.reasoning_type = message.extra.reasoning_type ? ReasoningType.Edited : ReasoningType.Manual;
751 await saveChatConditional();835 await saveChatConditional();
752 updateMessageBlock(messageId, message);836 updateMessageBlock(messageId, message);
753 textarea.remove();837 textarea.remove();
@@ -808,6 +892,8 @@ function setReasoningEventHandlers() {
808 return;892 return;
809 }893 }
810 message.extra.reasoning = '';894 message.extra.reasoning = '';
895 delete message.extra.reasoning_type;
896 delete message.extra.reasoning_duration;
811 await saveChatConditional();897 await saveChatConditional();
812 updateMessageBlock(messageId, message);898 updateMessageBlock(messageId, message);
813 const textarea = messageBlock.find('.reasoning_edit_textarea');899 const textarea = messageBlock.find('.reasoning_edit_textarea');
@@ -868,9 +954,9 @@ function parseReasoningFromString(str, { strict = true } = {}) {
868 return '';954 return '';
869 });955 });
870956
871 if (didReplace && power_user.trim_spaces) {957 if (didReplace) {
872 reasoning = reasoning.trim();958 reasoning = trimSpaces(reasoning);
873 content = content.trim();959 content = trimSpaces(content);
874 }960 }
875961
876 return { reasoning, content };962 return { reasoning, content };
@@ -899,6 +985,11 @@ function registerReasoningAppEvents() {
899 return null;985 return null;
900 }986 }
901987
988 if (message.extra?.reasoning) {
989 console.debug('[Reasoning] Message already has reasoning', idx);
990 return null;
991 }
992
902 const parsedReasoning = parseReasoningFromString(message.mes);993 const parsedReasoning = parseReasoningFromString(message.mes);
903994
904 // No reasoning block found995 // No reasoning block found
@@ -916,6 +1007,7 @@ function registerReasoningAppEvents() {
916 // If reasoning was found, add it to the message1007 // If reasoning was found, add it to the message
917 if (parsedReasoning.reasoning) {1008 if (parsedReasoning.reasoning) {
918 message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING);1009 message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING);
1010 message.extra.reasoning_type = ReasoningType.Parsed;
919 }1011 }
9201012
921 // Update the message text if it was changed1013 // Update the message text if it was changed
public/scripts/utils.js+14 -1
@@ -8,7 +8,7 @@ import {
8import { getContext } from './extensions.js';8import { getContext } from './extensions.js';
9import { characters, getRequestHeaders, this_chid } from '../script.js';9import { characters, getRequestHeaders, this_chid } from '../script.js';
10import { isMobile } from './RossAscends-mods.js';10import { isMobile } from './RossAscends-mods.js';
11import { collapseNewlines } from './power-user.js';11import { collapseNewlines, power_user } from './power-user.js';
12import { debounce_timeout } from './constants.js';12import { debounce_timeout } from './constants.js';
13import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';13import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
14import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';14import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
@@ -677,6 +677,19 @@ export function sortByCssOrder(a, b) {
677}677}
678678
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
685export 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 * Trims a string to the end of a nearest sentence.693 * Trims a string to the end of a nearest sentence.
681 * @param {string} input The string to trim.694 * @param {string} input The string to trim.
682 * @returns {string} The trimmed string.695 * @returns {string} The trimmed string.