Refactoring StreamProcessor -> ReasoningHandler

d8eeab0c0068665bb7626c164a5bf7b6fa71ecdf

Wolfsblvt <wolfsblvt@gmail.com>

2 files changed, +169 -80Showing whitespace changes
public/script.js+14 -79
@@ -269,7 +269,7 @@ import { initSettingsSearch } from './scripts/setting-search.js';
269import { initBulkEdit } from './scripts/bulk-edit.js';269import { initBulkEdit } from './scripts/bulk-edit.js';
270import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js';270import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js';
271import { getContext } from './scripts/st-context.js';271import { getContext } from './scripts/st-context.js';
272import { extractReasoningFromData, initReasoning, isHiddenReasoningModel, PromptReasoning, updateReasoningUI } from './scripts/reasoning.js';272import { extractReasoningFromData, initReasoning, PromptReasoning, ReasoningHandler, updateReasoningUI } from './scripts/reasoning.js';
273273
274// API OBJECT FOR EXTERNAL WIRING274// API OBJECT FOR EXTERNAL WIRING
275globalThis.SillyTavern = {275globalThis.SillyTavern = {
@@ -3128,10 +3128,6 @@ class StreamingProcessor {
3128 this.messageTimerDom = null;3128 this.messageTimerDom = null;
3129 /** @type {HTMLElement} */3129 /** @type {HTMLElement} */
3130 this.messageTokenCounterDom = null;3130 this.messageTokenCounterDom = null;
3131 /** @type {HTMLElement} */
3132 this.messageReasoningDom = null;
3133 /** @type {HTMLElement} */
3134 this.messageReasoningHeaderDom = null;
3135 /** @type {HTMLTextAreaElement} */3131 /** @type {HTMLTextAreaElement} */
3136 this.sendTextarea = document.querySelector('#send_textarea');3132 this.sendTextarea = document.querySelector('#send_textarea');
3137 this.type = type;3133 this.type = type;
@@ -3147,20 +3143,8 @@ class StreamingProcessor {
3147 /** @type {import('./scripts/logprobs.js').TokenLogprobs[]} */3143 /** @type {import('./scripts/logprobs.js').TokenLogprobs[]} */
3148 this.messageLogprobs = [];3144 this.messageLogprobs = [];
3149 this.toolCalls = [];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 }
31653149
3166 #checkDomElements(messageId) {3150 #checkDomElements(messageId) {
@@ -3169,11 +3153,8 @@ class StreamingProcessor {
3169 this.messageTextDom = this.messageDom?.querySelector('.mes_text');3153 this.messageTextDom = this.messageDom?.querySelector('.mes_text');
3170 this.messageTimerDom = this.messageDom?.querySelector('.mes_timer');3154 this.messageTimerDom = this.messageDom?.querySelector('.mes_timer');
3171 this.messageTokenCounterDom = this.messageDom?.querySelector('.tokenCounterDisplay');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 }
31753157 this.reasoningHandler.checkDomElements(messageId);
3176 this.messageDom.classList.toggle('reasoning_hidden', this.isHiddenReasoning);
3177 }3158 }
31783159
3179 #updateMessageBlockVisibility() {3160 #updateMessageBlockVisibility() {
@@ -3184,19 +3165,11 @@ class StreamingProcessor {
3184 }3165 }
31853166
3186 showMessageButtons(messageId) {3167 showMessageButtons(messageId) {
3187 if (messageId == -1) {
3188 return;
3189 }
3190
3191 showStopButton();3168 showStopButton();
3192 $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'none' });3169 $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'none' });
3193 }3170 }
31943171
3195 hideMessageButtons(messageId) {3172 hideMessageButtons(messageId) {
3196 if (messageId == -1) {
3197 return;
3198 }
3199
3200 hideStopButton();3173 hideStopButton();
3201 $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'flex' });3174 $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'flex' });
3202 }3175 }
@@ -3207,14 +3180,12 @@ class StreamingProcessor {
3207 if (this.type == 'impersonate') {3180 if (this.type == 'impersonate') {
3208 this.sendTextarea.value = '';3181 this.sendTextarea.value = '';
3209 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));3182 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
3210 }3183 } else {
3211 else {
3212 await saveReply(this.type, text, true, '', [], '');3184 await saveReply(this.type, text, true, '', [], '');
3213 messageId = chat.length - 1;3185 messageId = chat.length - 1;
3214 this.#checkDomElements(messageId);3186 this.#checkDomElements(messageId);
3215 this.showMessageButtons(messageId);3187 this.showMessageButtons(messageId);
3216 }3188 }
3217
3218 hideSwipeButtons();3189 hideSwipeButtons();
3219 scrollChatToBottom();3190 scrollChatToBottom();
3220 return messageId;3191 return messageId;
@@ -3232,11 +3203,9 @@ class StreamingProcessor {
32323203
3233 let processedText = cleanUpMessage(text, isImpersonate, isContinue, !isFinal, this.stoppingStrings);3204 let processedText = cleanUpMessage(text, isImpersonate, isContinue, !isFinal, this.stoppingStrings);
32343205
3235 // Predict unbalanced asterisks / quotes during streaming
3236 const charsToBalance = ['*', '"', '```'];3206 const charsToBalance = ['*', '"', '```'];
3237 for (const char of charsToBalance) {3207 for (const char of charsToBalance) {
3238 if (!isFinal && isOdd(countOccurrences(processedText, char))) {3208 if (!isFinal && isOdd(countOccurrences(processedText, char))) {
3239 // Add character at the end to balance it
3240 const separator = char.length > 1 ? '\n' : '';3209 const separator = char.length > 1 ? '\n' : '';
3241 processedText = processedText.trimEnd() + separator + char;3210 processedText = processedText.trimEnd() + separator + char;
3242 }3211 }
@@ -3245,48 +3214,24 @@ class StreamingProcessor {
3245 if (isImpersonate) {3214 if (isImpersonate) {
3246 this.sendTextarea.value = processedText;3215 this.sendTextarea.value = processedText;
3247 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));3216 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
3248 }3217 } else {
3249 else {
3250 const mesChanged = chat[messageId]['mes'] !== processedText;3218 const mesChanged = chat[messageId]['mes'] !== processedText;
3251
3252 this.#checkDomElements(messageId);3219 this.#checkDomElements(messageId);
3253 this.#updateMessageBlockVisibility();3220 this.#updateMessageBlockVisibility();
3254 const currentTime = new Date();3221 const currentTime = new Date();
3255 chat[messageId]['mes'] = processedText;3222 chat[messageId]['mes'] = processedText;
3256 chat[messageId]['gen_started'] = this.timeStarted;3223 chat[messageId]['gen_started'] = this.timeStarted;
3257 chat[messageId]['gen_finished'] = currentTime;3224 chat[messageId]['gen_finished'] = currentTime;
3258
3259 if (!chat[messageId]['extra']) {3225 if (!chat[messageId]['extra']) {
3260 chat[messageId]['extra'] = {};3226 chat[messageId]['extra'] = {};
3261 }3227 }
32623228
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 }
32853231
3286 // Don't waste time calculating token count for streaming3232 // Token count update.
3287 const tokenCountText = (this.reasoning || '') + processedText;3233 const tokenCountText = this.reasoningHandler.reasoning + processedText;
3288 const currentTokenCount = isFinal && power_user.message_token_count_enabled ? getTokenCount(tokenCountText, 0) : 0;3234 const currentTokenCount = isFinal && power_user.message_token_count_enabled ? getTokenCount(tokenCountText, 0) : 0;
3289
3290 if (currentTokenCount) {3235 if (currentTokenCount) {
3291 chat[messageId]['extra']['token_count'] = currentTokenCount;3236 chat[messageId]['extra']['token_count'] = currentTokenCount;
3292 if (this.messageTokenCounterDom instanceof HTMLElement) {3237 if (this.messageTokenCounterDom instanceof HTMLElement) {
@@ -3312,7 +3257,7 @@ class StreamingProcessor {
3312 this.messageTextDom.innerHTML = formattedText;3257 this.messageTextDom.innerHTML = formattedText;
3313 }3258 }
33143259
3315 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.#reasoningDuration());3260 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration());
3316 if (this.messageTimerDom instanceof HTMLElement) {3261 if (this.messageTimerDom instanceof HTMLElement) {
3317 this.messageTimerDom.textContent = timePassed.timerValue;3262 this.messageTimerDom.textContent = timePassed.timerValue;
3318 this.messageTimerDom.title = timePassed.timerTitle;3263 this.messageTimerDom.title = timePassed.timerTitle;
@@ -3326,23 +3271,12 @@ class StreamingProcessor {
3326 }3271 }
3327 }3272 }
33283273
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 async onFinishStreaming(messageId, text) {3274 async onFinishStreaming(messageId, text) {
3336 this.hideMessageButtons(this.messageId);3275 this.hideMessageButtons(this.messageId);
3337 await this.onProgressStreaming(messageId, text, true);3276 await this.onProgressStreaming(messageId, text, true);
3338 addCopyToCodeBlocks($(`#chat .mes[mesid="${messageId}"]`));3277 addCopyToCodeBlocks($(`#chat .mes[mesid="${messageId}"]`));
33393278
3340 // Ensure reasoning finish time is recorded if not already3279 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 }
33463280
3347 if (Array.isArray(this.swipes) && this.swipes.length > 0) {3281 if (Array.isArray(this.swipes) && this.swipes.length > 0) {
3348 const message = chat[messageId];3282 const message = chat[messageId];
@@ -3443,7 +3377,8 @@ class StreamingProcessor {
3443 if (logprobs) {3377 if (logprobs) {
3444 this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));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 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);3382 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
3448 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));3383 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
3449 }3384 }
public/scripts/reasoning.js+155 -1
@@ -151,7 +151,7 @@ export function updateReasoningUI(messageIdOrElement, reasoning = null, reasonin
151 * @param {object} [options={}] Options for the function151 * @param {object} [options={}] Options for the function
152 * @param {boolean} [options.forceEnd=false] If true, there will be no "Thinking..." when no duration exists152 * @param {boolean} [options.forceEnd=false] If true, there will be no "Thinking..." when no duration exists
153 */153 */
154export function updateReasoningTimeUI(element, duration, { forceEnd = false } = {}) {154function updateReasoningTimeUI(element, duration, { forceEnd = false } = {}) {
155 if (duration) {155 if (duration) {
156 const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });156 const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });
157 const secondsStr = moment.duration(duration).asSeconds();157 const secondsStr = moment.duration(duration).asSeconds();
@@ -163,6 +163,160 @@ export function updateReasoningTimeUI(element, duration, { forceEnd = false } =
163 }163 }
164}164}
165165
166/** @enum {string} */
167export 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 */
178export 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 * Helper class for adding reasoning to messages.321 * Helper class for adding reasoning to messages.
168 * Keeps track of the number of reasoning additions.322 * Keeps track of the number of reasoning additions.