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 @@
4911 <small data-i18n="Smooth Streaming">4911 <small data-i18n="Smooth Streaming">
4912 Smooth Streaming4912 Smooth Streaming
4913 </small>4913 </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>
4915 </div>4914 </div>
4916 <div id="smooth_streaming_speed_control" class="flexBasis100p wide100p">4915 <div id="smooth_streaming_speed_control" class="flexBasis100p wide100p">
4917 <input type="range" id="smooth_streaming_speed" name="smooth_streaming_speed" min="0" max="100" step="10" value="50">4916 <input type="range" id="smooth_streaming_speed" name="smooth_streaming_speed" min="0" max="100" step="10" value="50">
@@ -4922,6 +4921,11 @@
4922 </div>4921 </div>
4923 </div>4922 </div>
4924 </label>4923 </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
4926 <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">4930 <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">
4927 <input id="play_message_sound" type="checkbox" />4931 <input id="play_message_sound" type="checkbox" />
public/script.js+6 -1
@@ -267,6 +267,7 @@ import { clearItemizedPrompts, deleteItemizedPrompts, findItemizedPromptSet, ini
267import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';267import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
268import { event_types, eventSource } from './scripts/events.js';268import { event_types, eventSource } from './scripts/events.js';
269import { initAccessibility } from './scripts/a11y.js';269import { initAccessibility } from './scripts/a11y.js';
270import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
270271
271// API OBJECT FOR EXTERNAL WIRING272// API OBJECT FOR EXTERNAL WIRING
272globalThis.SillyTavern = {273globalThis.SillyTavern = {
@@ -2905,7 +2906,11 @@ class StreamingProcessor {
2905 false,2906 false,
2906 );2907 );
2907 if (this.messageTextDom instanceof HTMLElement) {2908 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 }
2909 }2914 }
29102915
2911 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration(), this.timeToFirstToken);2916 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 = {
137 streaming_fps: 30,137 streaming_fps: 30,
138 smooth_streaming: false,138 smooth_streaming: false,
139 smooth_streaming_speed: 50,139 smooth_streaming_speed: 50,
140 stream_fade_in: false,
140141
141 fast_ui_mode: true,142 fast_ui_mode: true,
142 avatar_style: avatar_styles.ROUND,143 avatar_style: avatar_styles.ROUND,
@@ -1685,6 +1686,8 @@ export async function loadPowerUserSettings(settings, data) {
1685 $('#smooth_streaming').prop('checked', power_user.smooth_streaming);1686 $('#smooth_streaming').prop('checked', power_user.smooth_streaming);
1686 $('#smooth_streaming_speed').val(power_user.smooth_streaming_speed);1687 $('#smooth_streaming_speed').val(power_user.smooth_streaming_speed);
16871688
1689 $('#stream_fade_in').prop('checked', power_user.stream_fade_in);
1690
1688 $('#font_scale').val(power_user.font_scale);1691 $('#font_scale').val(power_user.font_scale);
1689 $('#font_scale_counter').val(power_user.font_scale);1692 $('#font_scale_counter').val(power_user.font_scale);
16901693
@@ -3493,6 +3496,11 @@ jQuery(() => {
3493 saveSettingsDebounced();3496 saveSettingsDebounced();
3494 });3497 });
34953498
3499 $('#stream_fade_in').on('input', function () {
3500 power_user.stream_fade_in = !!$(this).prop('checked');
3501 saveSettingsDebounced();
3502 });
3503
3496 $('input[name="font_scale"]').on('input', async function (e, data) {3504 $('input[name="font_scale"]').on('input', async function (e, data) {
3497 const applyMode = data?.forced ? 'forced' : 'normal';3505 const applyMode = data?.forced ? 'forced' : 'normal';
3498 power_user.font_scale = Number($(this).val());3506 power_user.font_scale = Number($(this).val());
public/scripts/reasoning.js+7 -1
@@ -15,6 +15,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
15import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';15import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
16import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';16import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
17import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';17import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
18import { applyStreamFadeIn } from './util/stream-fadein.js';
18import { copyText, escapeRegex, isFalseBoolean, isTrueBoolean, setDatasetProperty, trimSpaces } from './utils.js';19import { copyText, escapeRegex, isFalseBoolean, isTrueBoolean, setDatasetProperty, trimSpaces } from './utils.js';
1920
20/**21/**
@@ -495,7 +496,12 @@ export class ReasoningHandler {
495 // Update the reasoning message496 // Update the reasoning message
496 const reasoning = trimSpaces(this.reasoningDisplayText ?? this.reasoning);497 const reasoning = trimSpaces(this.reasoningDisplayText ?? this.reasoning);
497 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);498 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
500 // Update tooltip for hidden reasoning edit506 // Update tooltip for hidden reasoning edit
501 /** @type {HTMLElement} */507 /** @type {HTMLElement} */
public/scripts/util/stream-fadein.js+69 -0
@@ -0,0 +1,69 @@
1import { 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 */
7export 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 */
17export 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 */
65export 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 {
599 max-height: var(--doc-height);599 max-height: var(--doc-height);
600}600}
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
602.mes .mes_timer,610.mes .mes_timer,
603.mes .mesIDDisplay,611.mes .mesIDDisplay,
604.mes .tokenCounterDisplay {612.mes .tokenCounterDisplay {