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
Signed| @@ -4911,7 +4911,6 @@ | |||
| 4911 | <small data-i18n="Smooth Streaming"> | 4911 | <small data-i18n="Smooth Streaming"> |
| 4912 | Smooth Streaming | 4912 | 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> | ||
| 4925 | 4929 | ||
| 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" /> |
| @@ -267,6 +267,7 @@ import { clearItemizedPrompts, deleteItemizedPrompts, findItemizedPromptSet, ini | |||
| 267 | import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js'; | 267 | import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js'; |
| 268 | import { event_types, eventSource } from './scripts/events.js'; | 268 | import { event_types, eventSource } from './scripts/events.js'; |
| 269 | import { initAccessibility } from './scripts/a11y.js'; | 269 | import { initAccessibility } from './scripts/a11y.js'; |
| 270 | import { applyStreamFadeIn } from './scripts/util/stream-fadein.js'; | ||
| 270 | 271 | ||
| 271 | // API OBJECT FOR EXTERNAL WIRING | 272 | // API OBJECT FOR EXTERNAL WIRING |
| 272 | globalThis.SillyTavern = { | 273 | globalThis.SillyTavern = { |
| @@ -2905,8 +2906,12 @@ class StreamingProcessor { | |||
| 2905 | false, | 2906 | false, |
| 2906 | ); | 2907 | ); |
| 2907 | if (this.messageTextDom instanceof HTMLElement) { | 2908 | if (this.messageTextDom instanceof HTMLElement) { |
| 2909 | if (power_user.stream_fade_in) { | ||
| 2910 | applyStreamFadeIn(this.messageTextDom, formattedText); | ||
| 2911 | } else { | ||
| 2908 | this.messageTextDom.innerHTML = formattedText; | 2912 | this.messageTextDom.innerHTML = formattedText; |
| 2909 | } | 2913 | } |
| 2914 | } | ||
| 2910 | 2915 | ||
| 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); |
| 2912 | if (this.messageTimerDom instanceof HTMLElement) { | 2917 | if (this.messageTimerDom instanceof HTMLElement) { |
| @@ -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, | ||
| 140 | 141 | ||
| 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); |
| 1687 | 1688 | ||
| 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); |
| 1690 | 1693 | ||
| @@ -3493,6 +3496,11 @@ jQuery(() => { | |||
| 3493 | saveSettingsDebounced(); | 3496 | saveSettingsDebounced(); |
| 3494 | }); | 3497 | }); |
| 3495 | 3498 | ||
| 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()); |
| @@ -15,6 +15,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom | |||
| 15 | import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; | 15 | import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; |
| 16 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | 16 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 17 | import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js'; | 17 | import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js'; |
| 18 | import { applyStreamFadeIn } from './util/stream-fadein.js'; | ||
| 18 | import { copyText, escapeRegex, isFalseBoolean, isTrueBoolean, setDatasetProperty, trimSpaces } from './utils.js'; | 19 | import { copyText, escapeRegex, isFalseBoolean, isTrueBoolean, setDatasetProperty, trimSpaces } from './utils.js'; |
| 19 | 20 | ||
| 20 | /** | 21 | /** |
| @@ -495,7 +496,12 @@ export class ReasoningHandler { | |||
| 495 | // Update the reasoning message | 496 | // 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); |
| 499 | |||
| 500 | if (power_user.stream_fade_in) { | ||
| 501 | applyStreamFadeIn(this.messageReasoningContentDom, displayReasoning); | ||
| 502 | } else { | ||
| 498 | this.messageReasoningContentDom.innerHTML = displayReasoning; | 503 | this.messageReasoningContentDom.innerHTML = displayReasoning; |
| 504 | } | ||
| 499 | 505 | ||
| 500 | // Update tooltip for hidden reasoning edit | 506 | // Update tooltip for hidden reasoning edit |
| 501 | /** @type {HTMLElement} */ | 507 | /** @type {HTMLElement} */ |
| @@ -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 | } | ||
| @@ -599,6 +599,14 @@ input[type='checkbox']:focus-visible { | |||
| 599 | max-height: var(--doc-height); | 599 | max-height: var(--doc-height); |
| 600 | } | 600 | } |
| 601 | 601 | ||
| 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 { |