From 8ff368fd809312bd567011fe6aac0d92362f56ff Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Mon, 11 May 2026 17:38:12 -0600 Subject: [PATCH] clean up implementation --- static/messages.js | 23 ++++++++++++++--------- static/style.css | 5 +++-- tests/test_smooth_text_fade.py | 10 ++++++++-- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/static/messages.js b/static/messages.js index de00c253..3e758359 100644 --- a/static/messages.js +++ b/static/messages.js @@ -524,6 +524,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const _STREAM_FADE_MS=140; const _STREAM_FADE_WAVE_MS=320; const _STREAM_FADE_MAX_STAGGER_MS=520; + const _streamFadeEnabledForStream=window._fadeTextEffect===true; // rAF-throttled rendering: buffer tokens, render at most once per frame let _renderPending=false; @@ -703,7 +704,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _renderPending=false; } function _shouldUseStreamFade(){ - return window._fadeTextEffect===true; + return _streamFadeEnabledForStream; } function _streamFadeSkipNode(node){ if(!node||node.nodeType!==1) return false; @@ -718,8 +719,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ targetText=String(targetText||''); const now=performance.now(); if(!targetText){ + const hadVisible=!!_streamFadeVisibleText; _resetStreamFadeState(); - return {text:'', caughtUp:true}; + return {text:'', caughtUp:true, changed:hadVisible}; } if(!_streamFadeVisibleText||!targetText.startsWith(_streamFadeVisibleText)){ // Markdown/tool stripping can rewrite the visible prefix. Reset safely rather than @@ -730,7 +732,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFadeLastTickMs=now; _streamFadeStartedAt=now; } - if(_streamFadeVisibleText===targetText) return {text:_streamFadeVisibleText,caughtUp:true}; + if(_streamFadeVisibleText===targetText) return {text:_streamFadeVisibleText,caughtUp:true,changed:false}; const remaining=targetText.slice(_streamFadeVisibleText.length); const backlogWords=_streamFadeWordCountOf(remaining); @@ -778,7 +780,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // fade for these waves so fast output stays visibly animated. const burstFloor=backlogWords>=120?24:backlogWords>=60?18:backlogWords>=30?12:wordsPerSecond>=300?8:wordsPerSecond>=220?6:0; if(burstFloor>0) wordsToReveal=Math.max(wordsToReveal, Math.min(burstFloor, backlogWords)); - if(wordsToReveal<1){_streamFadeLastRevealCount=0;return {text:_streamFadeVisibleText,caughtUp:false};} + if(wordsToReveal<1){_streamFadeLastRevealCount=0;return {text:_streamFadeVisibleText,caughtUp:false,changed:false};} _streamFadeLastRevealCount=Math.min(wordsToReveal, backlogWords); _streamFadeWordCarry=Math.max(0,_streamFadeWordCarry-wordsToReveal); @@ -792,11 +794,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(cut<=0) cut=Math.min(remaining.length,4); _streamFadeVisibleText+=remaining.slice(0,cut); if(_streamFadeVisibleText.length>targetText.length) _streamFadeVisibleText=targetText; - return {text:_streamFadeVisibleText,caughtUp:_streamFadeVisibleText===targetText}; + return {text:_streamFadeVisibleText,caughtUp:_streamFadeVisibleText===targetText,changed:true}; } function _renderStreamingFadeMarkdown(displayText){ if(!assistantBody) return true; const next=_streamFadeNextText(displayText); + if(!next.changed) return next.caughtUp; const html=renderMd ? renderMd(next.text||'') : esc(next.text||''); assistantBody.innerHTML=html; assistantBody.classList.add('stream-fade-active'); @@ -835,7 +838,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ while((match=wordRe.exec(value))){ if(match.index>last) frag.appendChild(document.createTextNode(value.slice(last,match.index))); wordIndex+=1; - const span=document.createElement('span'); if(!_streamFadeWordBornAt[wordIndex]){ const newWordOffset=Math.max(wordIndex-_streamFadeWordCount-1,0); // High-speed output should feel like a continuous animated sweep, not @@ -847,14 +849,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,_streamFadeWordBornAt[wordIndex]+fadeMs); const ageMs=now-_streamFadeWordBornAt[wordIndex]; const isAnimating=ageMs #mainLogs{display:flex;} /* OpenWebUI-style streaming word fade (opt-in via Settings → Preferences). Opacity-only fade; high-speed streams use a longer JS-driven wave duration. */ -.stream-fade-active .stream-fade-word{display:inline;will-change:opacity;} -.stream-fade-word.is-new{animation:stream-fade-word-in var(--stream-fade-ms,140ms) ease-out both;} +.stream-fade-active .stream-fade-word{display:inline;} +.stream-fade-word.is-new{animation:stream-fade-word-in var(--stream-fade-ms,140ms) cubic-bezier(.2,.7,.2,1) both;will-change:opacity;} @keyframes stream-fade-word-in{from{opacity:0;}to{opacity:1;}} +@media (prefers-reduced-motion: reduce){.stream-fade-word.is-new{animation:none;will-change:auto;}} [data-live-assistant="1"]:last-child .msg-body.stream-fade-active > :last-child::after, [data-live-assistant="1"]:last-child .msg-body.stream-fade-active:not(:has(> *))::after{display:none;content:none;} diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index 3ab4ffbe..5a8e0bc8 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -84,6 +84,8 @@ let _streamFadeLastArrivalMs=0; let _streamFadeArrivalWps=0; let _streamFadeLatestAnimationEndAt=0; let _streamFadeLastRevealCount=0; +let _streamFadeWordCount=0; +let _streamFadeWordBornAt=[]; const _STREAM_FADE_MS=140; const _STREAM_FADE_WAVE_MS=320; const _STREAM_FADE_MAX_STAGGER_MS=520; @@ -193,6 +195,7 @@ def test_fade_renderer_uses_playout_buffer_and_markdown_rerender(): render_block, [ "_streamFadeNextText(displayText)", + "if(!next.changed) return next.caughtUp", "renderMd ? renderMd(next.text||'')", "stream-fade-active", "_wrapStreamingFadeWords(assistantBody)", @@ -210,6 +213,7 @@ def test_fade_animation_state_survives_markdown_rerenders(): "ageMs", "animationDelay", "--stream-fade-ms", + "span.className='stream-fade-word is-new'", "_streamFadeLatestAnimationEndAt", "_streamFadeWordBornAt.length=wordIndex+1", ], @@ -244,13 +248,15 @@ def test_fade_css_animates_words_and_hides_live_cursor(): [ "@keyframes stream-fade-word-in", ".stream-fade-word.is-new", - "var(--stream-fade-ms,140ms) ease-out", + "var(--stream-fade-ms,140ms) cubic-bezier(.2,.7,.2,1)", + "prefers-reduced-motion: reduce", ".msg-body.stream-fade-active > :last-child::after", "display:none", "content:none", ], ) - assert "prefers-reduced-motion: reduce" not in fade_css + assert ".stream-fade-active .stream-fade-word{display:inline;}" in fade_css + assert ".stream-fade-active .stream-fade-word{display:inline;will-change:opacity;}" not in fade_css def test_stream_fade_next_text_executes_and_advances_playout():