Refactoring StreamProcessor -> ReasoningHandler
| @@ -269,7 +269,7 @@ import { initSettingsSearch } from './scripts/setting-search.js'; | ||
| 269 | 269 | import { initBulkEdit } from './scripts/bulk-edit.js'; |
| 270 | 270 | import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js'; |
| 271 | 271 | import { getContext } from './scripts/st-context.js'; |
| 272 | 272 | import { extractReasoningFromData, initReasoning, isHiddenReasoningModelPromptReasoning, PromptReasoningReasoningHandler, updateReasoningUI } from './scripts/reasoning.js'; |
| 273 | 273 | |
| 274 | 274 | // API OBJECT FOR EXTERNAL WIRING |
| 275 | 275 | globalThis.SillyTavern = { |
| @@ -3128,10 +3128,6 @@ class StreamingProcessor { | ||
| 3128 | 3128 | this.messageTimerDom = null; |
| 3129 | 3129 | /** @type {HTMLElement} */ |
| 3130 | 3130 | this.messageTokenCounterDom = null; |
| 3131 | - /** @type {HTMLElement} */ | |
| 3132 | - this.messageReasoningDom = null; | |
| 3133 | - /** @type {HTMLElement} */ | |
| 3134 | - this.messageReasoningHeaderDom = null; | |
| 3135 | 3131 | /** @type {HTMLTextAreaElement} */ |
| 3136 | 3132 | this.sendTextarea = document.querySelector('#send_textarea'); |
| 3137 | 3133 | this.type = type; |
| @@ -3147,20 +3143,8 @@ class StreamingProcessor { | ||
| 3147 | 3143 | /** @type {import('./scripts/logprobs.js').TokenLogprobs[]} */ |
| 3148 | 3144 | this.messageLogprobs = []; |
| 3149 | 3145 | this.toolCalls = []; |
| 3150 | - this.reasoning = ''; | |
| 3146 | + // Initialize reasoning in its own handler | |
| 3151 | - /** @type {Date} */ | |
| 3147 | + this.reasoningHandler = new ReasoningHandler(type, timeStarted); | |
| 3152 | - this.reasoningStartTime = null; | |
| 3153 | - /** @type {Date} */ | |
| 3154 | - this.reasoningEndTime = null; | |
| 3155 | - this.isHiddenReasoning = isHiddenReasoningModel(); | |
| 3156 | - } | |
| 3157 | - | |
| 3158 | - /** @type {() => number} Reasoning duration in milliseconds */ | |
| 3159 | - #reasoningDuration() { | |
| 3160 | - if (this.reasoningStartTime && this.reasoningEndTime) { | |
| 3161 | - return (this.reasoningEndTime.getTime() - this.reasoningStartTime.getTime()); | |
| 3162 | - } | |
| 3163 | - return null; | |
| 3164 | 3148 | } |
| 3165 | 3149 | |
| 3166 | 3150 | #checkDomElements(messageId) { |
| @@ -3169,11 +3153,8 @@ class StreamingProcessor { | ||
| 3169 | 3153 | this.messageTextDom = this.messageDom?.querySelector('.mes_text'); |
| 3170 | 3154 | this.messageTimerDom = this.messageDom?.querySelector('.mes_timer'); |
| 3171 | 3155 | this.messageTokenCounterDom = this.messageDom?.querySelector('.tokenCounterDisplay'); |
| 3172 | - this.messageReasoningDom = this.messageDom?.querySelector('.mes_reasoning'); | |
| 3173 | - this.messageReasoningHeaderDom = this.messageDom?.querySelector('.mes_reasoning_header_title'); | |
| 3174 | 3156 | } |
| 3175 | - | |
| 3157 | + this.reasoningHandler.checkDomElements(messageId); | |
| 3176 | - this.messageDom.classList.toggle('reasoning_hidden', this.isHiddenReasoning); | |
| 3177 | 3158 | } |
| 3178 | 3159 | |
| 3179 | 3160 | #updateMessageBlockVisibility() { |
| @@ -3184,19 +3165,11 @@ class StreamingProcessor { | ||
| 3184 | 3165 | } |
| 3185 | 3166 | |
| 3186 | 3167 | showMessageButtons(messageId) { |
| 3187 | - if (messageId == -1) { | |
| 3188 | - return; | |
| 3189 | - } | |
| 3190 | - | |
| 3191 | 3168 | showStopButton(); |
| 3192 | 3169 | $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'none' }); |
| 3193 | 3170 | } |
| 3194 | 3171 | |
| 3195 | 3172 | hideMessageButtons(messageId) { |
| 3196 | - if (messageId == -1) { | |
| 3197 | - return; | |
| 3198 | - } | |
| 3199 | - | |
| 3200 | 3173 | hideStopButton(); |
| 3201 | 3174 | $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'flex' }); |
| 3202 | 3175 | } |
| @@ -3207,14 +3180,12 @@ class StreamingProcessor { | ||
| 3207 | 3180 | if (this.type == 'impersonate') { |
| 3208 | 3181 | this.sendTextarea.value = ''; |
| 3209 | 3182 | this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true })); |
| 3210 | 3183 | } else { |
| 3211 | - else { | |
| 3212 | 3184 | await saveReply(this.type, text, true, '', [], ''); |
| 3213 | 3185 | messageId = chat.length - 1; |
| 3214 | 3186 | this.#checkDomElements(messageId); |
| 3215 | 3187 | this.showMessageButtons(messageId); |
| 3216 | 3188 | } |
| 3217 | - | |
| 3218 | 3189 | hideSwipeButtons(); |
| 3219 | 3190 | scrollChatToBottom(); |
| 3220 | 3191 | return messageId; |
| @@ -3232,11 +3203,9 @@ class StreamingProcessor { | ||
| 3232 | 3203 | |
| 3233 | 3204 | let processedText = cleanUpMessage(text, isImpersonate, isContinue, !isFinal, this.stoppingStrings); |
| 3234 | 3205 | |
| 3235 | - // Predict unbalanced asterisks / quotes during streaming | |
| 3236 | 3206 | const charsToBalance = ['*', '"', '```']; |
| 3237 | 3207 | for (const char of charsToBalance) { |
| 3238 | 3208 | if (!isFinal && isOdd(countOccurrences(processedText, char))) { |
| 3239 | - // Add character at the end to balance it | |
| 3240 | 3209 | const separator = char.length > 1 ? '\n' : ''; |
| 3241 | 3210 | processedText = processedText.trimEnd() + separator + char; |
| 3242 | 3211 | } |
| @@ -3245,48 +3214,24 @@ class StreamingProcessor { | ||
| 3245 | 3214 | if (isImpersonate) { |
| 3246 | 3215 | this.sendTextarea.value = processedText; |
| 3247 | 3216 | this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true })); |
| 3248 | 3217 | } else { |
| 3249 | - else { | |
| 3250 | 3218 | const mesChanged = chat[messageId]['mes'] !== processedText; |
| 3251 | - | |
| 3252 | 3219 | this.#checkDomElements(messageId); |
| 3253 | 3220 | this.#updateMessageBlockVisibility(); |
| 3254 | 3221 | const currentTime = new Date(); |
| 3255 | 3222 | chat[messageId]['mes'] = processedText; |
| 3256 | 3223 | chat[messageId]['gen_started'] = this.timeStarted; |
| 3257 | 3224 | chat[messageId]['gen_finished'] = currentTime; |
| 3258 | - | |
| 3259 | 3225 | if (!chat[messageId]['extra']) { |
| 3260 | 3226 | chat[messageId]['extra'] = {}; |
| 3261 | 3227 | } |
| 3262 | 3228 | |
| 3263 | - if (this.reasoning || this.isHiddenReasoning) { | |
| 3229 | + // Update reasoning | |
| 3264 | - const reasoning = power_user.trim_spaces ? this.reasoning.trim() : this.reasoning; | |
| 3230 | + await this.reasoningHandler.process(messageId, mesChanged, currentTime); | |
| 3265 | - const reasoningChanged = chat[messageId]['extra']['reasoning'] !== reasoning; | |
| 3266 | - chat[messageId]['extra']['reasoning'] = reasoning; | |
| 3267 | - | |
| 3268 | - if ((this.isHiddenReasoning || reasoningChanged) && this.reasoningStartTime === null) { | |
| 3269 | - this.reasoningStartTime = this.timeStarted; | |
| 3270 | - } | |
| 3271 | - if ((this.isHiddenReasoning || !reasoningChanged) && mesChanged && this.reasoningStartTime !== null && this.reasoningEndTime === null) { | |
| 3272 | - this.reasoningEndTime = currentTime; | |
| 3273 | - await eventSource.emit(event_types.STREAM_REASONING_DONE, this.reasoning, this.#reasoningDuration); | |
| 3274 | - } | |
| 3275 | - await this.#updateReasoningTime(messageId); | |
| 3276 | - | |
| 3277 | - if (this.messageReasoningDom instanceof HTMLElement) { | |
| 3278 | - const formattedReasoning = messageFormatting(this.reasoning, '', false, false, messageId, {}, true); | |
| 3279 | - this.messageReasoningDom.innerHTML = formattedReasoning; | |
| 3280 | - } | |
| 3281 | - if (this.messageDom instanceof HTMLElement) { | |
| 3282 | - this.messageDom.classList.add('reasoning'); | |
| 3283 | - } | |
| 3284 | - } | |
| 3285 | 3231 | |
| 3286 | - // Don't waste time calculating token count for streaming | |
| 3232 | + // Token count update. | |
| 3287 | 3233 | const tokenCountText = (this.reasoningHandler.reasoning || '') + processedText; |
| 3288 | 3234 | const currentTokenCount = isFinal && power_user.message_token_count_enabled ? getTokenCount(tokenCountText, 0) : 0; |
| 3289 | - | |
| 3290 | 3235 | if (currentTokenCount) { |
| 3291 | 3236 | chat[messageId]['extra']['token_count'] = currentTokenCount; |
| 3292 | 3237 | if (this.messageTokenCounterDom instanceof HTMLElement) { |
| @@ -3312,7 +3257,7 @@ class StreamingProcessor { | ||
| 3312 | 3257 | this.messageTextDom.innerHTML = formattedText; |
| 3313 | 3258 | } |
| 3314 | 3259 | |
| 3315 | 3260 | const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.#reasoningDurationreasoningHandler.getDuration()); |
| 3316 | 3261 | if (this.messageTimerDom instanceof HTMLElement) { |
| 3317 | 3262 | this.messageTimerDom.textContent = timePassed.timerValue; |
| 3318 | 3263 | this.messageTimerDom.title = timePassed.timerTitle; |
| @@ -3326,23 +3271,12 @@ class StreamingProcessor { | ||
| 3326 | 3271 | } |
| 3327 | 3272 | } |
| 3328 | 3273 | |
| 3329 | - async #updateReasoningTime(messageId, { forceEnd = false } = {}) { | |
| 3330 | - const duration = this.#reasoningDuration(); | |
| 3331 | - chat[messageId]['extra']['reasoning_duration'] = duration; | |
| 3332 | - updateReasoningUI(this.messageDom, this.reasoning, duration, { forceEnd: forceEnd }); | |
| 3333 | - } | |
| 3334 | - | |
| 3335 | 3274 | async onFinishStreaming(messageId, text) { |
| 3336 | 3275 | this.hideMessageButtons(this.messageId); |
| 3337 | 3276 | await this.onProgressStreaming(messageId, text, true); |
| 3338 | 3277 | addCopyToCodeBlocks($(`#chat .mes[mesid="${messageId}"]`)); |
| 3339 | 3278 | |
| 3340 | - // Ensure reasoning finish time is recorded if not already | |
| 3279 | + await this.reasoningHandler.finish(messageId); | |
| 3341 | - if (this.reasoningStartTime !== null && this.reasoningEndTime === null) { | |
| 3342 | - this.reasoningEndTime = new Date(); | |
| 3343 | - await eventSource.emit(event_types.STREAM_REASONING_DONE, this.reasoning, this.#reasoningDuration); | |
| 3344 | - await this.#updateReasoningTime(messageId, { forceEnd: true }); | |
| 3345 | - } | |
| 3346 | 3280 | |
| 3347 | 3281 | if (Array.isArray(this.swipes) && this.swipes.length > 0) { |
| 3348 | 3282 | const message = chat[messageId]; |
| @@ -3443,7 +3377,8 @@ class StreamingProcessor { | ||
| 3443 | 3377 | if (logprobs) { |
| 3444 | 3378 | this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs])); |
| 3445 | 3379 | } |
| 3446 | - this.reasoning = getRegexedString(state?.reasoning ?? '', regex_placement.REASONING); | |
| 3380 | + // Get the updated reasoning string into the handler | |
| 3381 | + this.reasoningHandler.updateReasoning(state?.reasoning ?? ''); | |
| 3447 | 3382 | await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text); |
| 3448 | 3383 | await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text)); |
| 3449 | 3384 | } |
| @@ -151,7 +151,7 @@ export function updateReasoningUI(messageIdOrElement, reasoning = null, reasonin | ||
| 151 | 151 | * @param {object} [options={}] Options for the function |
| 152 | 152 | * @param {boolean} [options.forceEnd=false] If true, there will be no "Thinking..." when no duration exists |
| 153 | 153 | */ |
| 154 | 154 | export function updateReasoningTimeUI(element, duration, { forceEnd = false } = {}) { |
| 155 | 155 | if (duration) { |
| 156 | 156 | const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 }); |
| 157 | 157 | const secondsStr = moment.duration(duration).asSeconds(); |
| @@ -163,6 +163,160 @@ export function updateReasoningTimeUI(element, duration, { forceEnd = false } = | ||
| 163 | 163 | } |
| 164 | 164 | } |
| 165 | 165 | |
| 166 | +/** @enum {string} */ | |
| 167 | +export const ReasoningState = { | |
| 168 | + None: 'none', | |
| 169 | + Thinking: 'thinking', | |
| 170 | + Done: 'done', | |
| 171 | + Hidden: 'hidden', | |
| 172 | +}; | |
| 173 | + | |
| 174 | +/** | |
| 175 | + * Handles reasoning-specific logic and DOM updates for messages. | |
| 176 | + * Used inside the @see {StreamingProcessor} | |
| 177 | + */ | |
| 178 | +export class ReasoningHandler { | |
| 179 | + #isHidden; | |
| 180 | + | |
| 181 | + /** | |
| 182 | + * @param {string} type - The streaming type | |
| 183 | + * @param {Date} timeStarted - When the generation started | |
| 184 | + */ | |
| 185 | + constructor(type, timeStarted) { | |
| 186 | + /** @type {ReasoningState} The current state of the reasoning process */ | |
| 187 | + this.state = ReasoningState.None; | |
| 188 | + /** @type {string} The reasoning output */ | |
| 189 | + this.reasoning = ''; | |
| 190 | + /** @type {Date} When the reasoning started */ | |
| 191 | + this.startTime = null; | |
| 192 | + /** @type {Date} When the reasoning ended */ | |
| 193 | + this.endTime = null; | |
| 194 | + | |
| 195 | + /** @type {string} Generation type (normal, continue, impersonation, etc) */ | |
| 196 | + this.type = type; | |
| 197 | + /** @type {Date} Initial starting time of the generation */ | |
| 198 | + this.initialTime = timeStarted; | |
| 199 | + | |
| 200 | + /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */ | |
| 201 | + this.#isHidden = isHiddenReasoningModel(); | |
| 202 | + | |
| 203 | + // Cached DOM elements for reasoning | |
| 204 | + /** @type {HTMLElement} Main message DOM element `.mes` */ | |
| 205 | + this.messageDom = null; | |
| 206 | + /** @type {HTMLElement} Reasoning details DOM element `.mes_reasoning_details` */ | |
| 207 | + this.messageReasoningDetailsDom = null; | |
| 208 | + /** @type {HTMLElement} Reasoning content DOM element `.mes_reasoning_content` */ | |
| 209 | + this.messageReasoningContentDom = null; | |
| 210 | + /** @type {HTMLElement} Reasoning header DOM element `.mes_reasoning_header` */ | |
| 211 | + this.messageReasoningHeaderDom = null; | |
| 212 | + } | |
| 213 | + | |
| 214 | + /** | |
| 215 | + * Gets the duration of the reasoning in milliseconds. | |
| 216 | + * @returns {number|null} The duration in milliseconds, or null if the start or end time is not set. | |
| 217 | + */ | |
| 218 | + getDuration() { | |
| 219 | + if (this.startTime && this.endTime) { | |
| 220 | + return this.endTime.getTime() - this.startTime.getTime(); | |
| 221 | + } | |
| 222 | + return null; | |
| 223 | + } | |
| 224 | + | |
| 225 | + /** | |
| 226 | + * Finds and caches reasoning-related DOM elements for the given message. | |
| 227 | + * @param {number} messageId The message ID | |
| 228 | + */ | |
| 229 | + checkDomElements(messageId) { | |
| 230 | + // Make sure we reset dom elements if we are checking for a different message (shouldn't happen, but be sure) | |
| 231 | + if (this.messageDom !== null && this.messageDom.getAttribute('mesid') !== messageId.toString()) { | |
| 232 | + this.messageDom = null; | |
| 233 | + } | |
| 234 | + | |
| 235 | + // Cache the DOM elements once | |
| 236 | + if (this.messageDom === null) { | |
| 237 | + this.messageDom = document.querySelector(`#chat .mes[mesid="${messageId}"]`); | |
| 238 | + this.messageReasoningDetailsDom = this.messageDom.querySelector('.mes_reasoning_details'); | |
| 239 | + this.messageReasoningContentDom = this.messageDom.querySelector('.mes_reasoning'); | |
| 240 | + this.messageReasoningHeaderDom = this.messageDom.querySelector('.mes_reasoning_header_title'); | |
| 241 | + // Update the DOM with the current reasoning state. | |
| 242 | + this.messageDom.dataset.state = this.state; | |
| 243 | + this.messageDom.classList.toggle('reasoning_hidden', this.#isHidden); | |
| 244 | + } | |
| 245 | + | |
| 246 | + // Update main DOM state | |
| 247 | + this.#updateDomState(); | |
| 248 | + } | |
| 249 | + | |
| 250 | + #updateDomState() { | |
| 251 | + this.messageDom.dataset.state = this.state; | |
| 252 | + this.messageDom.classList.toggle('reasoning_hidden', this.#isHidden); | |
| 253 | + } | |
| 254 | + | |
| 255 | + updateReasoning(reasoning = null) { | |
| 256 | + reasoning = reasoning ?? this.reasoning; | |
| 257 | + this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING); | |
| 258 | + } | |
| 259 | + | |
| 260 | + /** | |
| 261 | + * Processes and updates reasoning info for the message. | |
| 262 | + * @param {number} messageId - The ID of the message. | |
| 263 | + * @param {boolean} mesChanged - True if the message text changed. | |
| 264 | + * @param {Date} currentTime - The current time. | |
| 265 | + */ | |
| 266 | + async process(messageId, mesChanged, currentTime) { | |
| 267 | + if (!this.reasoning && !this.#isHidden) return; | |
| 268 | + | |
| 269 | + this.updateReasoning(); | |
| 270 | + | |
| 271 | + // Ensure the chat extra exists. | |
| 272 | + if (!chat[messageId]['extra']) { | |
| 273 | + chat[messageId]['extra'] = {}; | |
| 274 | + } | |
| 275 | + const extra = chat[messageId]['extra']; | |
| 276 | + const finalReasoning = power_user.trim_spaces ? this.reasoning.trim() : this.reasoning; | |
| 277 | + const reasoningChanged = extra['reasoning'] !== finalReasoning; | |
| 278 | + extra['reasoning'] = finalReasoning; | |
| 279 | + | |
| 280 | + if ((this.#isHidden || reasoningChanged) && this.startTime === null) { | |
| 281 | + this.startTime = this.initialTime; | |
| 282 | + } | |
| 283 | + if ((this.#isHidden || !reasoningChanged) && mesChanged && this.startTime !== null && this.endTime === null) { | |
| 284 | + this.endTime = currentTime; | |
| 285 | + await eventSource.emit(event_types.STREAM_REASONING_DONE, finalReasoning, () => this.getDuration()); | |
| 286 | + } | |
| 287 | + await this.updateTime(messageId); | |
| 288 | + if (this.messageReasoningContentDom instanceof HTMLElement) { | |
| 289 | + const formattedReasoning = messageFormatting(finalReasoning, '', false, false, messageId, {}, true); | |
| 290 | + this.messageReasoningContentDom.innerHTML = formattedReasoning; | |
| 291 | + } | |
| 292 | + if (this.messageDom instanceof HTMLElement) { | |
| 293 | + this.messageDom.classList.add('reasoning'); | |
| 294 | + } | |
| 295 | + } | |
| 296 | + | |
| 297 | + async finish(messageId) { | |
| 298 | + // Make sure the finish time is recorded if a reasoning was in process and it wasn't ended correctly during streaming | |
| 299 | + if (this.startTime !== null && this.endTime === null) { | |
| 300 | + this.endTime = new Date(); | |
| 301 | + const finalReasoning = power_user.trim_spaces ? this.reasoning.trim() : this.reasoning; | |
| 302 | + await eventSource.emit(event_types.STREAM_REASONING_DONE, finalReasoning, () => this.getDuration()); | |
| 303 | + await this.updateTime(messageId); | |
| 304 | + } | |
| 305 | + } | |
| 306 | + | |
| 307 | + /** | |
| 308 | + * Updates the reasoning duration in the UI. | |
| 309 | + * @param {number} messageId - The ID of the message | |
| 310 | + * @param {object} [options={}] - Optional argument | |
| 311 | + * @param {boolean} [options.forceEnd=false] - If true, there will be no "Thinking..." when no duration exists | |
| 312 | + */ | |
| 313 | + async updateTime(messageId, { forceEnd = false } = {}) { | |
| 314 | + const duration = this.getDuration(); | |
| 315 | + chat[messageId]['extra']['reasoning_duration'] = duration; | |
| 316 | + updateReasoningUI(this.messageDom, this.reasoning, duration, { forceEnd }); | |
| 317 | + } | |
| 318 | +} | |
| 319 | + | |
| 166 | 320 | /** |
| 167 | 321 | * Helper class for adding reasoning to messages. |
| 168 | 322 | * Keeps track of the number of reasoning additions. |