diff --git a/public/script.js b/public/script.js index 9123ad32b..9e31a9fa1 100644 --- a/public/script.js +++ b/public/script.js @@ -3223,6 +3223,7 @@ class StreamingProcessor { // Update reasoning await this.reasoningHandler.process(messageId, mesChanged); + processedText = chat[messageId]['mes']; // Token count update. const tokenCountText = this.reasoningHandler.reasoning + processedText; @@ -3373,7 +3374,7 @@ class StreamingProcessor { this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs])); } // Get the updated reasoning string into the handler - this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning ?? ''); + this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning); await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text); await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text)); } diff --git a/public/scripts/reasoning.js b/public/scripts/reasoning.js index c354acd94..44927d83f 100644 --- a/public/scripts/reasoning.js +++ b/public/scripts/reasoning.js @@ -3,7 +3,7 @@ import { } from '../lib.js'; import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js'; import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; -import { getCurrentLocale, t } from './i18n.js'; +import { getCurrentLocale, t, translate } from './i18n.js'; import { MacrosParser } from './macros.js'; import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js'; import { Popup } from './popup.js'; @@ -14,7 +14,19 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js'; -import { copyText, escapeRegex, isFalseBoolean, setDatasetProperty } from './utils.js'; +import { copyText, escapeRegex, isFalseBoolean, setDatasetProperty, trimSpaces } from './utils.js'; + +/** + * Enum representing the type of the reasoning for a message (where it came from) + * @enum {string} + * @readonly + */ +export const ReasoningType = { + Model: 'model', + Parsed: 'parsed', + Manual: 'manual', + Edited: 'edited', +}; /** * Gets a message from a jQuery element. @@ -130,7 +142,12 @@ export const ReasoningState = { * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates. */ export class ReasoningHandler { + /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */ #isHiddenReasoningModel; + /** @type {boolean} True if the handler is currently handling a manual parse of reasoning blocks */ + #isParsingReasoning = false; + /** @type {number?} When reasoning is being parsed manually, and the reasoning has ended, this will be the index at which the actual messages starts */ + #parsingReasoningMesStartIndex = null; /** * @param {Date?} [timeStarted=null] - When the generation started @@ -138,6 +155,8 @@ export class ReasoningHandler { constructor(timeStarted = null) { /** @type {ReasoningState} The current state of the reasoning process */ this.state = ReasoningState.None; + /** @type {ReasoningType?} The type of the reasoning (where it came from) */ + this.type = null; /** @type {string} The reasoning output */ this.reasoning = ''; /** @type {Date} When the reasoning started */ @@ -148,7 +167,6 @@ export class ReasoningHandler { /** @type {Date} Initial starting time of the generation */ this.initialTime = timeStarted ?? new Date(); - /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */ this.#isHiddenReasoningModel = isHiddenReasoningModel(); // Cached DOM elements for reasoning @@ -195,6 +213,7 @@ export class ReasoningHandler { this.state = ReasoningState.Hidden; } + this.type = extra?.reasoning_type; this.reasoning = extra?.reasoning ?? ''; if (this.state !== ReasoningState.None) { @@ -209,6 +228,7 @@ export class ReasoningHandler { // Make sure reset correctly clears all relevant states if (reset) { this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None; + this.type = null; this.reasoning = ''; this.initialTime = new Date(); this.startTime = null; @@ -238,18 +258,19 @@ export class ReasoningHandler { * Updates the reasoning text/string for a message. * * @param {number} messageId - The ID of the message to update - * @param {string?} [reasoning=null] - The reasoning text to update - If null, uses the current reasoning + * @param {string?} [reasoning=null] - The reasoning text to update - If null or empty, uses the current reasoning * @param {Object} [options={}] - Optional arguments * @param {boolean} [options.persist=false] - Whether to persist the reasoning to the message object + * @param {boolean} [options.allowReset=false] - Whether to allow empty reasoning provided to reset the reasoning, instead of just taking the existing one * @returns {boolean} - Returns true if the reasoning was changed, otherwise false */ - updateReasoning(messageId, reasoning = null, { persist = false } = {}) { + updateReasoning(messageId, reasoning = null, { persist = false, allowReset = false } = {}) { if (messageId == -1 || !chat[messageId]) { return false; } - reasoning = reasoning ?? this.reasoning; - reasoning = power_user.trim_spaces ? reasoning.trim() : reasoning; + reasoning = allowReset ? reasoning ?? this.reasoning : reasoning || this.reasoning; + reasoning = trimSpaces(reasoning); // Ensure the chat extra exists if (!chat[messageId].extra) { @@ -260,10 +281,13 @@ export class ReasoningHandler { const reasoningChanged = extra.reasoning !== reasoning; this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING); + this.type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model; + if (persist) { // Build and save the reasoning data to message extras extra.reasoning = this.reasoning; extra.reasoning_duration = this.getDuration(); + extra.reasoning_type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model; } return reasoningChanged; @@ -280,7 +304,10 @@ export class ReasoningHandler { * @returns {Promise} */ async process(messageId, mesChanged) { - if (!this.reasoning && !this.#isHiddenReasoningModel) return; + mesChanged = this.#autoParseReasoningFromMessage(messageId, mesChanged); + + if (!this.reasoning && !this.#isHiddenReasoningModel) + return; // Ensure reasoning string is updated and regexes are applied correctly const reasoningChanged = this.updateReasoning(messageId, null, { persist: true }); @@ -295,6 +322,53 @@ export class ReasoningHandler { } } + #autoParseReasoningFromMessage(messageId, mesChanged) { + if (!power_user.reasoning.auto_parse) + return; + if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) + return mesChanged; + + /** @type {{ mes: string, [key: string]: any}} */ + const message = chat[messageId]; + if (!message) return mesChanged; + + // If we are done with reasoning parse, we just split the message correctly so the reasoning doesn't show up inside of it. + if (this.#parsingReasoningMesStartIndex) { + message.mes = trimSpaces(message.mes.slice(this.#parsingReasoningMesStartIndex)); + return mesChanged; + } + + if (this.state === ReasoningState.None) { + // If streamed message starts with the opening, cut it out and put all inside reasoning + if (message.mes.startsWith(power_user.reasoning.prefix) && message.mes.length > power_user.reasoning.prefix.length) { + this.#isParsingReasoning = true; + + // Manually set starting state here, as we might already have received the ending suffix + this.state = ReasoningState.Thinking; + this.startTime = this.initialTime; + } + } + + if (!this.#isParsingReasoning) + return mesChanged; + + // If we are in manual parsing mode, all currently streaming mes tokens will go the the reasoning block + const originalMes = message.mes; + this.reasoning = originalMes.slice(power_user.reasoning.prefix.length); + message.mes = ''; + + // If the reasoning contains the ending suffix, we cut that off and continue as message streaming + if (this.reasoning.includes(power_user.reasoning.suffix)) { + this.reasoning = this.reasoning.slice(0, this.reasoning.indexOf(power_user.reasoning.suffix)); + this.#parsingReasoningMesStartIndex = originalMes.indexOf(power_user.reasoning.suffix) + power_user.reasoning.suffix.length; + message.mes = trimSpaces(originalMes.slice(this.#parsingReasoningMesStartIndex)); + this.#isParsingReasoning = false; + } + + // Only return the original mesChanged value if we haven't cut off the complete message + return message.mes.length ? mesChanged : false; + } + /** * Completes the reasoning process for a message. * @@ -337,9 +411,10 @@ export class ReasoningHandler { // Update states to the relevant DOM elements setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null); setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state); + setDatasetProperty(this.messageReasoningDetailsDom, 'type', this.type); // Update the reasoning message - const reasoning = power_user.trim_spaces ? this.reasoning.trim() : this.reasoning; + const reasoning = trimSpaces(this.reasoning); const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true); this.messageReasoningContentDom.innerHTML = displayReasoning; @@ -394,17 +469,14 @@ export class ReasoningHandler { const element = this.messageReasoningHeaderDom; const duration = this.getDuration(); let data = null; + let title = ''; if (duration) { + const seconds = moment.duration(duration).asSeconds(); + const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 }); - const secondsStr = moment.duration(duration).asSeconds(); - - const span = document.createElement('span'); - span.title = t`${secondsStr} seconds`; - span.textContent = durationStr; - - element.textContent = t`Thought for `; - element.appendChild(span); - data = String(secondsStr); + element.textContent = t`Thought for ${durationStr}`; + data = String(seconds); + title = `${seconds} seconds`; } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) { element.textContent = t`Thought for some time`; data = 'unknown'; @@ -413,6 +485,12 @@ export class ReasoningHandler { data = null; } + if (this.type !== ReasoningType.Model) { + title += ` [${translate(this.type)}]`; + title = title.trim(); + } + element.title = title; + setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data); setDatasetProperty(element, 'duration', data); } @@ -574,11 +652,16 @@ function registerReasoningSlashCommands() { callback: async (args, value) => { const messageId = !isNaN(Number(args.at)) ? Number(args.at) : chat.length - 1; const message = chat[messageId]; - if (!message?.extra) { + if (!message) { return ''; } + // Make sure the message has an extra object + if (!message.extra || typeof message.extra !== 'object') { + message.extra = {}; + } message.extra.reasoning = String(value ?? ''); + message.extra.reasoning_type = ReasoningType.Manual; await saveChatConditional(); closeMessageEditor('reasoning'); @@ -748,6 +831,7 @@ function setReasoningEventHandlers() { const textarea = messageBlock.find('.reasoning_edit_textarea'); const reasoning = getRegexedString(String(textarea.val()), regex_placement.REASONING, { isEdit: true }); message.extra.reasoning = reasoning; + message.extra.reasoning_type = message.extra.reasoning_type ? ReasoningType.Edited : ReasoningType.Manual; await saveChatConditional(); updateMessageBlock(messageId, message); textarea.remove(); @@ -808,6 +892,8 @@ function setReasoningEventHandlers() { return; } message.extra.reasoning = ''; + delete message.extra.reasoning_type; + delete message.extra.reasoning_duration; await saveChatConditional(); updateMessageBlock(messageId, message); const textarea = messageBlock.find('.reasoning_edit_textarea'); @@ -868,9 +954,9 @@ function parseReasoningFromString(str, { strict = true } = {}) { return ''; }); - if (didReplace && power_user.trim_spaces) { - reasoning = reasoning.trim(); - content = content.trim(); + if (didReplace) { + reasoning = trimSpaces(reasoning); + content = trimSpaces(content); } return { reasoning, content }; @@ -899,6 +985,11 @@ function registerReasoningAppEvents() { return null; } + if (message.extra?.reasoning) { + console.debug('[Reasoning] Message already has reasoning', idx); + return null; + } + const parsedReasoning = parseReasoningFromString(message.mes); // No reasoning block found @@ -916,6 +1007,7 @@ function registerReasoningAppEvents() { // If reasoning was found, add it to the message if (parsedReasoning.reasoning) { message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING); + message.extra.reasoning_type = ReasoningType.Parsed; } // Update the message text if it was changed diff --git a/public/scripts/utils.js b/public/scripts/utils.js index b385a760a..90ef3fee4 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -8,7 +8,7 @@ import { import { getContext } from './extensions.js'; import { characters, getRequestHeaders, this_chid } from '../script.js'; import { isMobile } from './RossAscends-mods.js'; -import { collapseNewlines } from './power-user.js'; +import { collapseNewlines, power_user } from './power-user.js'; import { debounce_timeout } from './constants.js'; import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; @@ -676,6 +676,19 @@ export function sortByCssOrder(a, b) { return _a - _b; } +/** + * Trims leading and trailing whitespace from the input string based on a configuration setting. + * @param {string} input - The string to be trimmed + * @returns {string} The trimmed string if trimming is enabled; otherwise, returns the original string + */ + +export function trimSpaces(input) { + if (!input || typeof input !== 'string') { + return input; + } + return power_user.trim_spaces ? input.trim() : input; +} + /** * Trims a string to the end of a nearest sentence. * @param {string} input The string to trim.