Streaming fade-in animation (#4568) * [wip] Add stream fade-in effect for message text and corresponding CSS animation * Avoid using temporary element during text segmentation * Add fade in toggle * Skip whitespace-only nodes * Fade-in reasoning stream * Fix case when segmenter is not supported

dd2011f5be66ac5b691fd6424ca5f05dbc86a5a8

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

Signed
6 files changed, +103 -3Ignore whitespace
public/index.html+5 -1
@@ -4911,7 +4911,6 @@
49114911 <small data-i18n="Smooth Streaming">
49124912 Smooth Streaming
49134913 </small>
4914- <i class="fa-solid fa-flask" data-i18n="[title]Experimental feature. May not work for all backends." title="Experimental feature. May not work for all backends."></i>
49154914 </div>
49164915 <div id="smooth_streaming_speed_control" class="flexBasis100p wide100p">
49174916 <input type="range" id="smooth_streaming_speed" name="smooth_streaming_speed" min="0" max="100" step="10" value="50">
@@ -4922,6 +4921,11 @@
49224921 </div>
49234922 </div>
49244923 </label>
4924+ <label class="checkbox_label" for="stream_fade_in" title="Fade in streamed text when it appears, instead of it just popping in." data-i18n="[title]Fade in streamed text when it appears, instead of it just popping in">
4925+ <input id="stream_fade_in" type="checkbox" />
4926+ <small data-i18n="Stream Fade-In">Stream Fade-In</small>
4927+ <i class="fa-solid fa-flask" data-i18n="[title]Experimental feature. May not work for all backends." title="Experimental feature. May not work for all backends."></i>
4928+ </label>
49254929
49264930 <label for="play_message_sound" class="checkbox_label" title="Play a sound when a message generation finishes." data-i18n="[title]Play a sound when a message generation finishes">
49274931 <input id="play_message_sound" type="checkbox" />
public/script.js+6 -1
@@ -267,6 +267,7 @@ import { clearItemizedPrompts, deleteItemizedPrompts, findItemizedPromptSet, ini
267267import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
268268import { event_types, eventSource } from './scripts/events.js';
269269import { initAccessibility } from './scripts/a11y.js';
270+import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
270271
271272// API OBJECT FOR EXTERNAL WIRING
272273globalThis.SillyTavern = {
@@ -2905,7 +2906,11 @@ class StreamingProcessor {
29052906 false,
29062907 );
29072908 if (this.messageTextDom instanceof HTMLElement) {
2908- this.messageTextDom.innerHTML = formattedText;
2909+ if (power_user.stream_fade_in) {
2910+ applyStreamFadeIn(this.messageTextDom, formattedText);
2911+ } else {
2912+ this.messageTextDom.innerHTML = formattedText;
2913+ }
29092914 }
29102915
29112916 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration(), this.timeToFirstToken);
public/scripts/power-user.js+8 -0
@@ -137,6 +137,7 @@ export const power_user = {
137137 streaming_fps: 30,
138138 smooth_streaming: false,
139139 smooth_streaming_speed: 50,
140+ stream_fade_in: false,
140141
141142 fast_ui_mode: true,
142143 avatar_style: avatar_styles.ROUND,
@@ -1685,6 +1686,8 @@ export async function loadPowerUserSettings(settings, data) {
16851686 $('#smooth_streaming').prop('checked', power_user.smooth_streaming);
16861687 $('#smooth_streaming_speed').val(power_user.smooth_streaming_speed);
16871688
1689+ $('#stream_fade_in').prop('checked', power_user.stream_fade_in);
1690+
16881691 $('#font_scale').val(power_user.font_scale);
16891692 $('#font_scale_counter').val(power_user.font_scale);
16901693
@@ -3493,6 +3496,11 @@ jQuery(() => {
34933496 saveSettingsDebounced();
34943497 });
34953498
3499+ $('#stream_fade_in').on('input', function () {
3500+ power_user.stream_fade_in = !!$(this).prop('checked');
3501+ saveSettingsDebounced();
3502+ });
3503+
34963504 $('input[name="font_scale"]').on('input', async function (e, data) {
34973505 const applyMode = data?.forced ? 'forced' : 'normal';
34983506 power_user.font_scale = Number($(this).val());
public/scripts/reasoning.js+7 -1
@@ -15,6 +15,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
1515import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
1616import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
1717import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
18+import { applyStreamFadeIn } from './util/stream-fadein.js';
1819import { copyText, escapeRegex, isFalseBoolean, isTrueBoolean, setDatasetProperty, trimSpaces } from './utils.js';
1920
2021/**
@@ -495,7 +496,12 @@ export class ReasoningHandler {
495496 // Update the reasoning message
496497 const reasoning = trimSpaces(this.reasoningDisplayText ?? this.reasoning);
497498 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);
498- this.messageReasoningContentDom.innerHTML = displayReasoning;
499+
500+ if (power_user.stream_fade_in) {
501+ applyStreamFadeIn(this.messageReasoningContentDom, displayReasoning);
502+ } else {
503+ this.messageReasoningContentDom.innerHTML = displayReasoning;
504+ }
499505
500506 // Update tooltip for hidden reasoning edit
501507 /** @type {HTMLElement} */
public/scripts/util/stream-fadein.js+69 -0
@@ -0,0 +1,69 @@
1+import { morphdom } from '../../lib.js';
2+
3+/**
4+ * Check if the current browser supports native segmentation function.
5+ * @returns {boolean} True if the Segmenter is supported by the current browser.
6+ */
7+export function isSegmenterSupported() {
8+ return typeof Intl.Segmenter === 'function';
9+}
10+
11+/**
12+ * Segment text in the given HTML content using Intl.Segmenter.
13+ * @param {HTMLElement} htmlElement Target HTML element
14+ * @param {string} htmlContent HTML content to segment
15+ * @param {'word'|'grapheme'|'sentence'} [granularity='word'] Text split granularity
16+ */
17+export function segmentTextInElement(htmlElement, htmlContent, granularity = 'word') {
18+ htmlElement.innerHTML = htmlContent;
19+
20+ if (!isSegmenterSupported()) {
21+ return;
22+ }
23+
24+ // TODO: Support more locales, make granularity configurable.
25+ const segmenter = new Intl.Segmenter('en-US', { granularity });
26+ const textNodes = [];
27+ const walker = document.createTreeWalker(htmlElement, NodeFilter.SHOW_TEXT);
28+ while (walker.nextNode()) {
29+ const textNode = /** @type {Text} */ (walker.currentNode);
30+
31+ // Skip ancestors of code/pre
32+ if (textNode.parentElement && textNode.parentElement.closest('pre, code')) {
33+ continue;
34+ }
35+
36+ // Skip text nodes that are empty or only whitespace
37+ if (/^\s*$/.test(textNode.data)) {
38+ continue;
39+ }
40+
41+ textNodes.push(textNode);
42+ }
43+
44+ // Split every text node into segments using spans
45+ for (const textNode of textNodes) {
46+ const fragment = document.createDocumentFragment();
47+ const segments = segmenter.segment(textNode.data);
48+ for (const segment of segments) {
49+ // TODO: Apply a different class for different segment length/content?
50+ // For now, just use a single class for all segments.
51+ const span = document.createElement('span');
52+ span.innerText = segment.segment;
53+ span.className = 'text_segment';
54+ fragment.appendChild(span);
55+ }
56+ textNode.replaceWith(fragment);
57+ }
58+}
59+
60+/**
61+ * Apply stream fade-in effect to the given message text element by morphing its content.
62+ * @param {HTMLElement} messageTextElement Message text element
63+ * @param {string} htmlContent New HTML content to apply
64+ */
65+export function applyStreamFadeIn(messageTextElement, htmlContent) {
66+ const targetElement = /** @type {HTMLElement} */ (messageTextElement.cloneNode());
67+ segmentTextInElement(targetElement, htmlContent);
68+ morphdom(messageTextElement, targetElement);
69+}
public/style.css+8 -0
@@ -599,6 +599,14 @@ input[type='checkbox']:focus-visible {
599599 max-height: var(--doc-height);
600600}
601601
602+.mes_reasoning_details[data-state="thinking"] .mes_reasoning .text_segment,
603+.mes_text .text_segment {
604+ animation-name: fade-in;
605+ animation-timing-function: ease-in-out;
606+ /* Not using variables for duration as they are zeroed when reduced motion is enabled */
607+ animation-duration: 300ms;
608+}
609+
602610.mes .mes_timer,
603611.mes .mesIDDisplay,
604612.mes .tokenCounterDisplay {