Refactoring StreamProcessor -> ReasoningHandler

d8eeab0c0068665bb7626c164a5bf7b6fa71ecdf

Wolfsblvt <wolfsblvt@gmail.com>

2 files changed, +169 -80Ignore whitespace
public/script.js+14 -79
@@ -269,7 +269,7 @@ import { initSettingsSearch } from './scripts/setting-search.js';
269269import { initBulkEdit } from './scripts/bulk-edit.js';
270270import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js';
271271import { getContext } from './scripts/st-context.js';
272272import { extractReasoningFromData, initReasoning, isHiddenReasoningModelPromptReasoning, PromptReasoningReasoningHandler, updateReasoningUI } from './scripts/reasoning.js';
273273
274274// API OBJECT FOR EXTERNAL WIRING
275275globalThis.SillyTavern = {
@@ -3128,10 +3128,6 @@ class StreamingProcessor {
31283128 this.messageTimerDom = null;
31293129 /** @type {HTMLElement} */
31303130 this.messageTokenCounterDom = null;
3131- /** @type {HTMLElement} */
3132- this.messageReasoningDom = null;
3133- /** @type {HTMLElement} */
3134- this.messageReasoningHeaderDom = null;
31353131 /** @type {HTMLTextAreaElement} */
31363132 this.sendTextarea = document.querySelector('#send_textarea');
31373133 this.type = type;
@@ -3147,20 +3143,8 @@ class StreamingProcessor {
31473143 /** @type {import('./scripts/logprobs.js').TokenLogprobs[]} */
31483144 this.messageLogprobs = [];
31493145 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;
31643148 }
31653149
31663150 #checkDomElements(messageId) {
@@ -3169,11 +3153,8 @@ class StreamingProcessor {
31693153 this.messageTextDom = this.messageDom?.querySelector('.mes_text');
31703154 this.messageTimerDom = this.messageDom?.querySelector('.mes_timer');
31713155 this.messageTokenCounterDom = this.messageDom?.querySelector('.tokenCounterDisplay');
3172- this.messageReasoningDom = this.messageDom?.querySelector('.mes_reasoning');
3173- this.messageReasoningHeaderDom = this.messageDom?.querySelector('.mes_reasoning_header_title');
31743156 }
3175-
3157+ this.reasoningHandler.checkDomElements(messageId);
3176- this.messageDom.classList.toggle('reasoning_hidden', this.isHiddenReasoning);
31773158 }
31783159
31793160 #updateMessageBlockVisibility() {
@@ -3184,19 +3165,11 @@ class StreamingProcessor {
31843165 }
31853166
31863167 showMessageButtons(messageId) {
3187- if (messageId == -1) {
3188- return;
3189- }
3190-
31913168 showStopButton();
31923169 $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'none' });
31933170 }
31943171
31953172 hideMessageButtons(messageId) {
3196- if (messageId == -1) {
3197- return;
3198- }
3199-
32003173 hideStopButton();
32013174 $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'flex' });
32023175 }
@@ -3207,14 +3180,12 @@ class StreamingProcessor {
32073180 if (this.type == 'impersonate') {
32083181 this.sendTextarea.value = '';
32093182 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
32103183 } else {
3211- else {
32123184 await saveReply(this.type, text, true, '', [], '');
32133185 messageId = chat.length - 1;
32143186 this.#checkDomElements(messageId);
32153187 this.showMessageButtons(messageId);
32163188 }
3217-
32183189 hideSwipeButtons();
32193190 scrollChatToBottom();
32203191 return messageId;
@@ -3232,11 +3203,9 @@ class StreamingProcessor {
32323203
32333204 let processedText = cleanUpMessage(text, isImpersonate, isContinue, !isFinal, this.stoppingStrings);
32343205
3235- // Predict unbalanced asterisks / quotes during streaming
32363206 const charsToBalance = ['*', '"', '```'];
32373207 for (const char of charsToBalance) {
32383208 if (!isFinal && isOdd(countOccurrences(processedText, char))) {
3239- // Add character at the end to balance it
32403209 const separator = char.length > 1 ? '\n' : '';
32413210 processedText = processedText.trimEnd() + separator + char;
32423211 }
@@ -3245,48 +3214,24 @@ class StreamingProcessor {
32453214 if (isImpersonate) {
32463215 this.sendTextarea.value = processedText;
32473216 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
32483217 } else {
3249- else {
32503218 const mesChanged = chat[messageId]['mes'] !== processedText;
3251-
32523219 this.#checkDomElements(messageId);
32533220 this.#updateMessageBlockVisibility();
32543221 const currentTime = new Date();
32553222 chat[messageId]['mes'] = processedText;
32563223 chat[messageId]['gen_started'] = this.timeStarted;
32573224 chat[messageId]['gen_finished'] = currentTime;
3258-
32593225 if (!chat[messageId]['extra']) {
32603226 chat[messageId]['extra'] = {};
32613227 }
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 streaming
3232+ // Token count update.
32873233 const tokenCountText = (this.reasoningHandler.reasoning || '') + processedText;
32883234 const currentTokenCount = isFinal && power_user.message_token_count_enabled ? getTokenCount(tokenCountText, 0) : 0;
3289-
32903235 if (currentTokenCount) {
32913236 chat[messageId]['extra']['token_count'] = currentTokenCount;
32923237 if (this.messageTokenCounterDom instanceof HTMLElement) {
@@ -3312,7 +3257,7 @@ class StreamingProcessor {
33123257 this.messageTextDom.innerHTML = formattedText;
33133258 }
33143259
33153260 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.#reasoningDurationreasoningHandler.getDuration());
33163261 if (this.messageTimerDom instanceof HTMLElement) {
33173262 this.messageTimerDom.textContent = timePassed.timerValue;
33183263 this.messageTimerDom.title = timePassed.timerTitle;
@@ -3326,23 +3271,12 @@ class StreamingProcessor {
33263271 }
33273272 }
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-
33353274 async onFinishStreaming(messageId, text) {
33363275 this.hideMessageButtons(this.messageId);
33373276 await this.onProgressStreaming(messageId, text, true);
33383277 addCopyToCodeBlocks($(`#chat .mes[mesid="${messageId}"]`));
33393278
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- }
33463280
33473281 if (Array.isArray(this.swipes) && this.swipes.length > 0) {
33483282 const message = chat[messageId];
@@ -3443,7 +3377,8 @@ class StreamingProcessor {
34433377 if (logprobs) {
34443378 this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));
34453379 }
3446- this.reasoning = getRegexedString(state?.reasoning ?? '', regex_placement.REASONING);
3380+ // Get the updated reasoning string into the handler
3381+ this.reasoningHandler.updateReasoning(state?.reasoning ?? '');
34473382 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
34483383 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
34493384 }
public/scripts/reasoning.js+155 -1
@@ -151,7 +151,7 @@ export function updateReasoningUI(messageIdOrElement, reasoning = null, reasonin
151151 * @param {object} [options={}] Options for the function
152152 * @param {boolean} [options.forceEnd=false] If true, there will be no "Thinking..." when no duration exists
153153 */
154154export function updateReasoningTimeUI(element, duration, { forceEnd = false } = {}) {
155155 if (duration) {
156156 const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });
157157 const secondsStr = moment.duration(duration).asSeconds();
@@ -163,6 +163,160 @@ export function updateReasoningTimeUI(element, duration, { forceEnd = false } =
163163 }
164164}
165165
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+
166320/**
167321 * Helper class for adding reasoning to messages.
168322 * Keeps track of the number of reasoning additions.