diff --git a/api/config.py b/api/config.py
index e94e5dc3..da132ead 100644
--- a/api/config.py
+++ b/api/config.py
@@ -3926,6 +3926,7 @@ _SETTINGS_DEFAULTS = {
"send_key": "enter", # 'enter' or 'ctrl+enter'
"show_token_usage": False, # show input/output token badge below assistant messages
"show_tps": False, # show tokens-per-second chip in assistant message headers
+ "fade_text_effect": False, # animate newly streamed words with a lightweight fade-in effect
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
"check_for_updates": True, # check if webui/agent repos are behind upstream
@@ -4055,6 +4056,7 @@ _SETTINGS_BOOL_KEYS = {
"onboarding_completed",
"show_token_usage",
"show_tps",
+ "fade_text_effect",
"show_cli_sessions",
"sync_to_insights",
"check_for_updates",
diff --git a/static/boot.js b/static/boot.js
index 137f684c..d5feda14 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -1376,6 +1376,7 @@ function applyBotName(){
window._sendKey=s.send_key||'enter';
window._showTokenUsage=!!s.show_token_usage;
window._showTps=!!s.show_tps;
+ window._fadeTextEffect=!!s.fade_text_effect;
window._showCliSessions=!!s.show_cli_sessions;
window._soundEnabled=!!s.sound_enabled;
window._notificationsEnabled=!!s.notifications_enabled;
@@ -1413,6 +1414,7 @@ function applyBotName(){
window._sendKey='enter';
window._showTokenUsage=false;
window._showTps=false;
+ window._fadeTextEffect=false;
window._showCliSessions=false;
window._soundEnabled=false;
window._notificationsEnabled=false;
diff --git a/static/i18n.js b/static/i18n.js
index 321e617c..db0ca5b0 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -244,6 +244,8 @@ const LOCALES = {
busy_interrupt_confirm: 'Interrupted — sending new message',
settings_label_busy_input_mode: 'Busy input mode',
settings_desc_busy_input_mode: 'Controls what happens when you send a message while the agent is running. Queue waits; Interrupt cancels and starts fresh; Steer injects a correction mid-turn without interrupting (falls back to queue when agent or stream unavailable).',
+ settings_label_fade_text_effect: 'Fade text effect',
+ settings_desc_fade_text_effect: 'Fade newly streamed words in while the assistant is responding. Similar to OpenWebUI; off by default for maximum performance.',
settings_busy_input_mode_queue: 'Queue follow-up',
settings_busy_input_mode_interrupt: 'Interrupt current turn',
settings_busy_input_mode_steer: 'Steer (mid-turn correction)',
@@ -2598,6 +2600,8 @@ const LOCALES = {
busy_interrupt_confirm: '中断 — 新しいメッセージを送信中',
settings_label_busy_input_mode: 'ビジー時の入力モード',
settings_desc_busy_input_mode: 'エージェント実行中にメッセージを送信した時の動作を制御します。Queue は待機、Interrupt はキャンセルして再開、Steer は中断せずにターン中に修正を注入します (エージェントやストリームが利用不可ならキューにフォールバック)。',
+ settings_label_fade_text_effect: 'テキストのフェード効果',
+ settings_desc_fade_text_effect: 'アシスタントの応答中に新しくストリーミングされた単語をフェードインします。OpenWebUI に似た表示です。最大パフォーマンスのため既定ではオフです。',
settings_busy_input_mode_queue: 'フォローアップをキュー',
settings_busy_input_mode_interrupt: '現在のターンを中断',
settings_busy_input_mode_steer: 'ステア (ターン中の修正)',
@@ -3732,6 +3736,8 @@ const LOCALES = {
busy_interrupt_confirm: 'Прервано — отправка нового сообщения',
settings_label_busy_input_mode: 'Режим ввода при занятости',
settings_desc_busy_input_mode: 'Определяет поведение при отправке сообщения во время работы агента. Очередь ждёт; Прерывание отменяет и начинает заново; Steer внедряет коррекцию без прерывания.',
+ settings_label_fade_text_effect: 'Эффект плавного появления текста',
+ settings_desc_fade_text_effect: 'Плавно показывает новые слова во время ответа ассистента. Похоже на OpenWebUI; по умолчанию выключено для максимальной производительности.',
settings_busy_input_mode_queue: 'Поставить в очередь',
settings_busy_input_mode_interrupt: 'Прервать текущий оборот',
settings_busy_input_mode_steer: 'Steer (прерывание + отправка)',
@@ -4881,6 +4887,8 @@ const LOCALES = {
busy_interrupt_confirm: 'Interrumpido \u2014 enviando nuevo mensaje',
settings_label_busy_input_mode: 'Modo de entrada ocupada',
settings_desc_busy_input_mode: 'Controla qué sucede al enviar mensajes mientras el agente está activo. Cola espera; Interrumpir cancela y empieza de nuevo; Steer inyecta una corrección sin interrumpir (usa cola si el agente no está disponible).',
+ settings_label_fade_text_effect: 'Efecto de desvanecimiento de texto',
+ settings_desc_fade_text_effect: 'Hace aparecer gradualmente las palabras nuevas mientras el asistente responde. Similar a OpenWebUI; desactivado por defecto para máximo rendimiento.',
settings_busy_input_mode_queue: 'Poner en cola',
settings_busy_input_mode_interrupt: 'Interrumpir turno actual',
settings_busy_input_mode_steer: 'Steer (corrección a mitad de turno)',
@@ -5969,6 +5977,8 @@ const LOCALES = {
busy_interrupt_confirm: 'Unterbrochen \u2014 neue Nachricht wird gesendet',
settings_label_busy_input_mode: 'Eingabemodus bei Besch\u00e4ftigung',
settings_desc_busy_input_mode: 'Steuert, was passiert, wenn Sie w\u00e4hrend der Agentenaktivit\u00e4t eine Nachricht senden. Warteschlange wartet; Unterbrechen bricht ab und startet neu; Steer f\u00fcgt eine Korrektur ein ohne zu unterbrechen.',
+ settings_label_fade_text_effect: 'Text-Fade-Effekt',
+ settings_desc_fade_text_effect: 'Blendet neu gestreamte Wörter während der Antwort des Assistenten sanft ein. Ähnlich wie OpenWebUI; für maximale Leistung standardmäßig deaktiviert.',
settings_busy_input_mode_queue: 'In Warteschlange einreihen',
settings_busy_input_mode_interrupt: 'Aktuellen Durchgang unterbrechen',
settings_busy_input_mode_steer: 'Steer (Korrektur ohne Unterbrechung)',
@@ -7104,6 +7114,8 @@ const LOCALES = {
busy_interrupt_confirm: '已中断 — 正在发送新消息',
settings_label_busy_input_mode: '忙碌输入模式',
settings_desc_busy_input_mode: '控制在代理运行时发送消息的行为。队列等待;中断取消并重新开始;Steer中途注入纠正,不中断。',
+ settings_label_fade_text_effect: '文本淡入效果',
+ settings_desc_fade_text_effect: '在助手回复时让新流式输出的词语淡入显示。类似 OpenWebUI;为获得最佳性能默认关闭。',
settings_busy_input_mode_queue: '加入队列',
settings_busy_input_mode_interrupt: '中断当前回合',
settings_busy_input_mode_steer: 'Steer(中断 + 发送)',
@@ -8758,6 +8770,8 @@ const LOCALES = {
busy_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u767c\u9001\u65b0\u8a0a\u606f',
settings_label_busy_input_mode: '\u5fd9\u788c\u8f38\u5165\u6a21\u5f0f',
settings_desc_busy_input_mode: '\u63a7\u5236\u5728\u4ee3\u7406\u904b\u884c\u6642\u767c\u9001\u8a0a\u606f\u7684\u884c\u70ba\u3002\u4f47\u5217\u7b49\u5f85\uff1b\u4e2d\u65b7\u53d6\u6d88\u4e26\u91cd\u65b0\u958b\u59cb\uff1bSteer\u4e2d\u9014\u6ce8\u5165\u7d3a\u6b63\uff0c\u4e0d\u4e2d\u65b7\u3002',
+ settings_label_fade_text_effect: '文字淡入效果',
+ settings_desc_fade_text_effect: '在助理回覆時讓新串流輸出的詞語淡入顯示。類似 OpenWebUI;為獲得最佳效能預設關閉。',
settings_busy_input_mode_queue: '\u52a0\u5165\u4f47\u5217',
settings_busy_input_mode_interrupt: '\u4e2d\u65ad\u7576\u524d\u56de\u5408',
settings_busy_input_mode_steer: 'Steer\uff08\u4e2d\u9014\u7d3a\u6b63\uff09',
@@ -9343,6 +9357,8 @@ const LOCALES = {
busy_interrupt_confirm: 'Interrompido — enviando nova mensagem',
settings_label_busy_input_mode: 'Modo de input ocupado',
settings_desc_busy_input_mode: 'Controla o que acontece ao enviar mensagem com agente rodando. Fila espera; Interromper cancela; Steer injeta correção.',
+ settings_label_fade_text_effect: 'Efeito de fade no texto',
+ settings_desc_fade_text_effect: 'Faz novas palavras aparecerem gradualmente enquanto o assistente responde. Similar ao OpenWebUI; desativado por padrão para melhor desempenho.',
settings_busy_input_mode_queue: 'Enfileirar follow-up',
settings_busy_input_mode_interrupt: 'Interromper turno atual',
settings_busy_input_mode_steer: 'Steer (correção no meio do turno)',
@@ -10403,6 +10419,8 @@ const LOCALES = {
busy_interrupt_confirm: 'Interrupted — sending new message',
settings_label_busy_input_mode: '작업 중 입력 방식',
settings_desc_busy_input_mode: '에이전트가 실행 중일 때 메시지를 보내면 어떻게 처리할지 제어합니다. 대기는 다음 차례까지 기다리고, 중단은 현재 작업을 취소하고 새로 시작하며, 조정은 현재 작업을 중단하지 않고 중간 수정 사항을 전달합니다(에이전트 또는 스트림을 사용할 수 없으면 대기로 전환).',
+ settings_label_fade_text_effect: '텍스트 페이드 효과',
+ settings_desc_fade_text_effect: '어시스턴트가 응답하는 동안 새로 스트리밍되는 단어를 부드럽게 표시합니다. OpenWebUI와 비슷하며, 최대 성능을 위해 기본값은 꺼짐입니다.',
settings_busy_input_mode_queue: '후속 메시지 대기',
settings_busy_input_mode_interrupt: '현재 작업 중단',
settings_busy_input_mode_steer: '조정(중간 수정)',
diff --git a/static/index.html b/static/index.html
index c3a37bfb..4a33e1a2 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1004,6 +1004,13 @@
Displays tokens per second in assistant message headers while streaming and after a response completes. Off by default.
+
+
+
+ Fade text effect
+
+
Fade newly streamed words in while the assistant is responding. Similar to OpenWebUI; off by default for maximum performance.
+
diff --git a/static/messages.js b/static/messages.js
index b424ad36..66a3c90d 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -586,6 +586,26 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// the final answer or the response to render twice.
let _streamFinalized=false;
let _pendingRafHandle=null;
+ let _streamFadeVisibleText='';
+ let _streamFadeLastTickMs=0;
+ let _streamFadeWordCarry=0;
+ let _streamFadeStartedAt=0;
+ let _streamFadeLastTargetWords=0;
+ let _streamFadeLastArrivalMs=0;
+ let _streamFadeArrivalWps=0;
+ let _streamFadeLatestAnimationEndAt=0;
+ let _streamFadeAppendOffset=0;
+ let _streamFadeVisibleWords=0;
+ let _streamFadeHoldUntilMs=0;
+ let _streamFadeCurrentMs=200;
+ let _streamFadeReduceMotionMql=null;
+ let _streamFadeReduceMotion=false;
+ let _streamFadeReduceMotionOnChange=null;
+ const _STREAM_FADE_MS=200;
+ const _STREAM_FADE_MAX_MS=350;
+ const _STREAM_FADE_STAGGER_MS=16;
+ const _STREAM_FADE_DONE_MAX_MS=320;
+ const _streamFadeEnabledForStream=window._fadeTextEffect===true;
// rAF-throttled rendering: buffer tokens, render at most once per frame
let _renderPending=false;
@@ -677,11 +697,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
// Helper: create (or recreate) the smd parser bound to a given DOM element.
// Called when assistantBody is first created and after each tool-call segment reset.
- function _smdNewParser(el){
+ function _smdNewParser(el, fade=false){
_smdWrittenLen=0;
_smdWrittenText='';
if(!window.smd){_smdParser=null;return;}
- const renderer=window.smd.default_renderer(el);
+ const renderer=fade ? _streamFadeRenderer(el) : window.smd.default_renderer(el);
_smdParser=window.smd.parser(renderer);
}
// Helper: end the current smd parser (flushes remaining state) and null it out.
@@ -698,7 +718,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
// Helper: feed new displayText delta to the smd parser.
// Only feeds chars beyond what has already been written (_smdWrittenLen).
- function _smdWrite(displayText){
+ function _smdWrite(displayText, fade=false){
if(!_smdParser||!window.smd) return;
displayText=String(displayText||'');
// Self-heal desyncs: if displayText no longer starts with what we've already
@@ -709,7 +729,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdWrittenLen=0;
_smdWrittenText='';
if(assistantBody) assistantBody.innerHTML='';
- _smdNewParser(assistantBody);
+ _smdNewParser(assistantBody,fade);
if(!_smdParser) return;
}
const delta=displayText.slice(_smdWrittenText.length);
@@ -717,15 +737,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
try{window.smd.parser_write(_smdParser,delta);}catch(_){}
_smdWrittenLen=displayText.length;
_smdWrittenText=displayText;
- // streaming-markdown does NOT sanitize URL schemes — `[click](javascript:...)`
- // and `` survive as href/src. Strip any unsafe schemes
- // from anchors/images that were just added to the live DOM. The existing
- // renderMd() path filters these via its http(s)-only regex; we need a matching
- // guard here so the live-stream path isn't an XSS vector for agent-echoed
- // prompt-injection content. The final renderMessages() call at `done` uses
- // renderMd which is already safe, but during streaming the user could click
- // a malicious link before that replacement happens.
- if(assistantBody){_sanitizeSmdLinks(assistantBody);}
+ // streaming-markdown does NOT sanitize URL schemes. The default live path
+ // scans after writes; fade mode blocks unsafe href/src in its renderer.set_attr.
+ if(assistantBody&&!fade){_sanitizeSmdLinks(assistantBody);}
}
// Allowed URL schemes for anchors and images rendered from agent-streamed markdown.
// Matches the effective allowlist of renderMd() (http/https via regex + relative).
@@ -743,12 +757,269 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('src');n.setAttribute('data-blocked-scheme','1');}
}
}
+
+ function _resetStreamFadeState(){
+ _streamFadeVisibleText='';
+ _streamFadeLastTickMs=0;
+ _streamFadeWordCarry=0;
+ _streamFadeStartedAt=0;
+ _streamFadeLastTargetWords=0;
+ _streamFadeLastArrivalMs=0;
+ _streamFadeArrivalWps=0;
+ _streamFadeLatestAnimationEndAt=0;
+ _streamFadeAppendOffset=0;
+ _streamFadeVisibleWords=0;
+ _streamFadeHoldUntilMs=0;
+ _streamFadeCurrentMs=_STREAM_FADE_MS;
+ }
+ function _cancelAnimationFramePendingStreamRender(){
+ if(_pendingRafHandle===null) return;
+ cancelAnimationFrame(_pendingRafHandle);
+ clearTimeout(_pendingRafHandle);
+ _pendingRafHandle=null;
+ _renderPending=false;
+ }
+ function _shouldUseStreamFade(){
+ return _streamFadeEnabledForStream;
+ }
+ function _streamFadeSkipNode(node){
+ if(!node||node.nodeType!==1) return false;
+ const tag=(node.tagName||'').toLowerCase();
+ return tag==='pre'||tag==='code'||tag==='script'||tag==='style'||tag==='textarea'||tag==='svg'||tag==='math';
+ }
+ function _streamFadeReduceMotionEnabled(){
+ if(!window.matchMedia) return false;
+ if(!_streamFadeReduceMotionMql){
+ _streamFadeReduceMotionMql=window.matchMedia('(prefers-reduced-motion: reduce)');
+ _streamFadeReduceMotion=!!_streamFadeReduceMotionMql.matches;
+ _streamFadeReduceMotionOnChange=e=>{_streamFadeReduceMotion=!!e.matches;};
+ try{_streamFadeReduceMotionMql.addEventListener('change',_streamFadeReduceMotionOnChange);}
+ catch(_){try{_streamFadeReduceMotionMql.addListener(_streamFadeReduceMotionOnChange);}catch(_){}}
+ }
+ return _streamFadeReduceMotion;
+ }
+ function _streamFadeCleanupReduceMotionListener(){
+ if(!_streamFadeReduceMotionMql||!_streamFadeReduceMotionOnChange) return;
+ try{_streamFadeReduceMotionMql.removeEventListener('change',_streamFadeReduceMotionOnChange);}
+ catch(_){try{_streamFadeReduceMotionMql.removeListener(_streamFadeReduceMotionOnChange);}catch(_){}}
+ _streamFadeReduceMotionMql=null;
+ _streamFadeReduceMotionOnChange=null;
+ }
+ function _streamFadeBindCleanup(el){
+ if(!el||el._streamFadeCleanupBound) return;
+ el._streamFadeCleanupBound=true;
+ el.addEventListener('animationend',e=>{
+ const span=e.target;
+ if(!span||!span.classList||!span.classList.contains('stream-fade-word')) return;
+ span.replaceWith(document.createTextNode(span.textContent||''));
+ });
+ }
+ function _streamFadeRenderer(el){
+ _streamFadeBindCleanup(el);
+ const renderer=window.smd.default_renderer(el);
+ const baseAddText=renderer.add_text;
+ const baseSetAttr=renderer.set_attr;
+ renderer.add_text=(data,text)=>{
+ const parent=data&&data.nodes&&data.nodes[data.index];
+ if(!parent||_streamFadeSkipNode(parent)){baseAddText(data,text);return;}
+ const frag=document.createDocumentFragment();
+ const wordRe=/(\S+)(\s*)/g;
+ const value=String(text||'');
+ const reduceMotion=_streamFadeReduceMotionEnabled();
+ const appendStartedAt=performance.now();
+ let last=0, match, changed=false;
+ while((match=wordRe.exec(value))){
+ if(match.index>last) frag.appendChild(document.createTextNode(value.slice(last,match.index)));
+ if(reduceMotion){
+ frag.appendChild(document.createTextNode(match[1]));
+ if(match[2]) frag.appendChild(document.createTextNode(match[2]));
+ last=match.index+match[0].length;
+ changed=true;
+ continue;
+ }
+ const span=document.createElement('span');
+ span.className='stream-fade-word is-new';
+ const fadeMs=_streamFadeCurrentMs||_STREAM_FADE_MS;
+ const delayMs=_streamFadeAppendOffset*_STREAM_FADE_STAGGER_MS;
+ span.style.animationDelay=delayMs+'ms';
+ if(fadeMs!==_STREAM_FADE_MS) span.style.setProperty('--stream-fade-ms',fadeMs+'ms');
+ span.textContent=match[1];
+ frag.appendChild(span);
+ _streamFadeAppendOffset+=1;
+ _streamFadeLatestAnimationEndAt=Math.max(_streamFadeLatestAnimationEndAt,appendStartedAt+delayMs+fadeMs);
+ if(match[2]) frag.appendChild(document.createTextNode(match[2]));
+ last=match.index+match[0].length;
+ changed=true;
+ }
+ if(!changed){baseAddText(data,text);return;}
+ if(last{
+ const isHref=window.smd&&attr===window.smd.HREF;
+ const isSrc=window.smd&&attr===window.smd.SRC;
+ if((isHref||isSrc)&&!_SMD_SAFE_URL_RE.test(String(value||''))){
+ const node=data&&data.nodes&&data.nodes[data.index];
+ if(node&&node.setAttribute) node.setAttribute('data-blocked-scheme','1');
+ return;
+ }
+ baseSetAttr(data,attr,value);
+ };
+ return renderer;
+ }
+ function _streamFadeWordCountOf(text){
+ const m=String(text||'').match(/\S+/g);
+ return m?m.length:0;
+ }
+ function _streamFadePauseAfter(text, paragraphBreakIndex){
+ if(paragraphBreakIndex>=0) return 90;
+ const trimmed=String(text||'').trimEnd();
+ if(/[.!?]["')\]]*$/.test(trimmed)) return 45;
+ if(/[:;]["')\]]*$/.test(trimmed)) return 30;
+ return 0;
+ }
+ function _streamFadeNextText(targetText){
+ targetText=String(targetText||'');
+ const now=performance.now();
+ if(!targetText){
+ const hadVisible=!!_streamFadeVisibleText;
+ _resetStreamFadeState();
+ return {text:'', caughtUp:true, changed:hadVisible};
+ }
+ if(!_streamFadeVisibleText||!targetText.startsWith(_streamFadeVisibleText)){
+ // Markdown/tool stripping can rewrite the visible prefix. Reset safely rather than
+ // trying to animate across incompatible strings or stale word birth timestamps.
+ _resetStreamFadeState();
+ }
+ if(!_streamFadeLastTickMs){
+ _streamFadeLastTickMs=now;
+ _streamFadeStartedAt=now;
+ }
+ if(_streamFadeVisibleText===targetText) return {text:_streamFadeVisibleText,caughtUp:true,changed:false};
+
+ const remaining=targetText.slice(_streamFadeVisibleText.length);
+ const backlogWords=_streamFadeWordCountOf(remaining);
+ const targetWords=_streamFadeVisibleWords+backlogWords;
+ const elapsedMs=Math.max(16,Math.min(120,now-_streamFadeLastTickMs));
+ _streamFadeLastTickMs=now;
+
+ // OpenWebUI fades the actual arriving tokens, so long/fast responses naturally
+ // appear to accelerate. Hermes has a playout buffer, so track incoming word
+ // velocity and play out faster than it instead of using a metronomic cadence.
+ // LLM telemetry is usually tokens/sec, but the UI reveals words. A fixed word
+ // cadence can look stuck even when token throughput is high, so combine:
+ // 1) live target-word arrival velocity, 2) backlog pressure, 3) time ramp.
+ if(!_streamFadeLastArrivalMs){
+ _streamFadeLastArrivalMs=now;
+ _streamFadeLastTargetWords=targetWords;
+ } else if(targetWords>_streamFadeLastTargetWords){
+ const arrivalElapsedMs=Math.max(16, now-_streamFadeLastArrivalMs);
+ const instantArrivalWps=(targetWords-_streamFadeLastTargetWords)*1000/arrivalElapsedMs;
+ // EWMA smooths bursty token chunks without hiding sustained fast output.
+ _streamFadeArrivalWps=_streamFadeArrivalWps
+ ? (_streamFadeArrivalWps*0.65 + instantArrivalWps*0.35)
+ : instantArrivalWps;
+ _streamFadeLastArrivalMs=now;
+ _streamFadeLastTargetWords=targetWords;
+ } else if(targetWords<_streamFadeLastTargetWords){
+ _streamFadeLastTargetWords=targetWords;
+ _streamFadeLastArrivalMs=now;
+ _streamFadeArrivalWps=0;
+ }
+
+ if(now<_streamFadeHoldUntilMs){
+ return {text:_streamFadeVisibleText,caughtUp:false,changed:false};
+ }
+
+ const streamAgeSeconds=Math.max(0, (now-(_streamFadeStartedAt||now))/1000);
+ const baseWps=22 + Math.min(streamAgeSeconds*2.5, 28); // 22 → 50 wps over long answers
+ const arrivalWps=_streamFadeArrivalWps ? Math.min(_streamFadeArrivalWps*1.05 + 8, 160) : 0;
+ const backlogWps=backlogWords>0 ? Math.min(22 + backlogWords*1.1, 160) : 0;
+ const wordsPerSecond=Math.min(160, Math.max(baseWps, arrivalWps, backlogWps));
+ const speedFadeRatio=Math.max(0,Math.min(1,(wordsPerSecond-50)/(160-50)));
+ _streamFadeCurrentMs=Math.round(_STREAM_FADE_MS+(_STREAM_FADE_MAX_MS-_STREAM_FADE_MS)*speedFadeRatio);
+
+ _streamFadeWordCarry+=elapsedMs*wordsPerSecond/1000;
+ if(!_streamFadeVisibleText) _streamFadeWordCarry=Math.max(_streamFadeWordCarry,1);
+ let wordsToReveal=Math.floor(_streamFadeWordCarry);
+ // At very high throughput, cap each frame to a small readable wave. Sustained
+ // playback still catches up, but whole paragraphs no longer pop in at once.
+ const waveCap=backlogWords>=160?3:2;
+ wordsToReveal=Math.min(wordsToReveal,waveCap,backlogWords);
+ if(wordsToReveal<1) return {text:_streamFadeVisibleText,caughtUp:false,changed:false};
+ _streamFadeWordCarry=Math.max(0,_streamFadeWordCarry-wordsToReveal);
+
+ let cut=0;
+ const wordRe=/(\s*\S+\s*)/g;
+ let match;
+ while(wordsToReveal>0&&(match=wordRe.exec(remaining))){
+ cut=wordRe.lastIndex;
+ wordsToReveal-=1;
+ }
+ if(cut<=0) cut=Math.min(remaining.length,4);
+ const chunk=remaining.slice(0,cut);
+ const paragraphMatch=chunk.match(/\n\s*\n/);
+ const paragraphBreak=paragraphMatch ? paragraphMatch.index : -1;
+ if(paragraphMatch) cut=paragraphBreak+paragraphMatch[0].length;
+ const revealed=remaining.slice(0,cut);
+ _streamFadeVisibleText+=revealed;
+ _streamFadeVisibleWords+=_streamFadeWordCountOf(revealed);
+ const pauseMs=_streamFadePauseAfter(revealed,paragraphBreak);
+ if(pauseMs) _streamFadeHoldUntilMs=now+pauseMs;
+ if(_streamFadeVisibleText.length>targetText.length) _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;
+ assistantBody.classList.add('stream-fade-active');
+ if(!_smdParser&&window.smd){
+ if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;}
+ _smdNewParser(assistantBody,true);
+ }
+ if(_smdParser){
+ _streamFadeAppendOffset=0;
+ _smdWrite(next.text,true);
+ }else{
+ assistantBody.innerHTML=renderMd ? renderMd(next.text||'') : esc(next.text||'');
+ _sanitizeSmdLinks(assistantBody);
+ }
+ return next.caughtUp;
+ }
+ function _streamFadeCurrentDisplayText(){
+ const parsed=_parseStreamState();
+ return segmentStart===0
+ ? parsed.displayText
+ : _stripXmlToolCalls(assistantText.slice(segmentStart));
+ }
+ function _drainStreamFadeBeforeDone(onDone){
+ const step=()=>{
+ if(!assistantBody){onDone();return;}
+ const target=_streamFadeCurrentDisplayText();
+ const caughtUp=_renderStreamingFadeMarkdown(target);
+ scrollIfPinned();
+ if(caughtUp){
+ // parser_end can flush pending markdown text; include that final text in
+ // the fade wait instead of replacing it immediately in renderMessages().
+ if(_smdParser) _smdEndParser();
+ // Let the last released words visibly finish their stagger + fade before
+ // the final renderMessages() DOM replacement removes the live spans.
+ const remainingAnimationMs=Math.max(_STREAM_FADE_MS, _streamFadeLatestAnimationEndAt-performance.now());
+ setTimeout(onDone, Math.min(remainingAnimationMs, _STREAM_FADE_DONE_MAX_MS));
+ return;
+ }
+ setTimeout(()=>requestAnimationFrame(step), 33);
+ };
+ step();
+ }
function _resetAssistantSegment(){
assistantRow=null;
assistantBody=null;
segmentStart=assistantText.length;
_freshSegment=true;
_smdEndParser();
+ _resetStreamFadeState();
}
let _lastRenderMs=0;
@@ -777,30 +1048,40 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const displayText = segmentStart===0
? parsed.displayText // first segment: uses think-tag stripping
: _stripXmlToolCalls(assistantText.slice(segmentStart));
- if(!_smdParser&&window.smd){
- // On reconnect: prior content in assistantBody came from a different smd parser run.
- // Clear it and start fresh — renderMessages() on done will restore the full content.
- if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;}
- _smdNewParser(assistantBody);
- }
- if(_smdParser){
- _smdWrite(displayText);
+ if(_shouldUseStreamFade()){
+ const caughtUp=_renderStreamingFadeMarkdown(displayText);
+ if(!caughtUp&&!_streamFinalized){
+ setTimeout(()=>_scheduleRender(), 33);
+ }
} else {
- // Fallback: smd not loaded yet, reconnect session, or smd unavailable — use renderMd
- // for every live segment. Without this, the first segment inserts raw
- // parsed.displayText and users see unformatted markdown until done.
- const fallbackText = segmentStart===0
- ? parsed.displayText
- : _stripXmlToolCalls(assistantText.slice(segmentStart));
- assistantBody.innerHTML = renderMd ? renderMd(fallbackText) : esc(fallbackText);
+ assistantBody.classList.remove('stream-fade-active');
+ _resetStreamFadeState();
+ if(!_smdParser&&window.smd){
+ // On reconnect: prior content in assistantBody came from a different smd parser run.
+ // Clear it and start fresh — renderMessages() on done will restore the full content.
+ if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;}
+ _smdNewParser(assistantBody);
+ }
+ if(_smdParser){
+ _smdWrite(displayText);
+ } else {
+ // Fallback: smd not loaded yet, reconnect session, or smd unavailable — use renderMd
+ // for every live segment. Without this, the first segment inserts raw
+ // parsed.displayText and users see unformatted markdown until done.
+ const fallbackText = segmentStart===0
+ ? parsed.displayText
+ : _stripXmlToolCalls(assistantText.slice(segmentStart));
+ assistantBody.innerHTML = renderMd ? renderMd(fallbackText) : esc(fallbackText);
+ }
}
}
scrollIfPinned();
};
- if(sinceLastMs>=66){
+ const frameIntervalMs=_shouldUseStreamFade()?33:66;
+ if(sinceLastMs>=frameIntervalMs){
_pendingRafHandle=requestAnimationFrame(_doRender);
} else {
- _pendingRafHandle=setTimeout(()=>requestAnimationFrame(_doRender), 66-sinceLastMs);
+ _pendingRafHandle=setTimeout(()=>requestAnimationFrame(_doRender), frameIntervalMs-sinceLastMs);
}
}
@@ -821,6 +1102,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// terminal handlers) address it without needing a reset here.
source.addEventListener('token',e=>{
+ if(_terminalStateReached||_streamFinalized) return;
if(!S.session||S.session.session_id!==activeSid) return;
const d=JSON.parse(e.data);
assistantText+=d.text;
@@ -832,6 +1114,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
source.addEventListener('interim_assistant',e=>{
+ if(_terminalStateReached||_streamFinalized) return;
if(!S.session||S.session.session_id!==activeSid) return;
const d=JSON.parse(e.data);
const visible=String(d&&d.text?d.text:'').trim();
@@ -852,6 +1135,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
source.addEventListener('reasoning',e=>{
+ if(_terminalStateReached||_streamFinalized) return;
const d=JSON.parse(e.data);
reasoningText += d.text || '';
liveReasoningText += d.text || '';
@@ -1019,153 +1303,163 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('done',e=>{
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
- // Bug A fix: cancel any pending rAF and mark stream finalized before
- // the DOM is settled by renderMessages, so no trailing token/reasoning rAF
- // can reintroduce a stale thinking card or duplicate content.
- _streamFinalized=true;
- if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
- if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
- // Finalize smd parser — flushes any remaining buffered markdown state
- // and runs Prism + copy buttons on the live segment before the DOM is replaced
- if(assistantBody){
- const _finBody=assistantBody;
- _smdEndParser();
- requestAnimationFrame(()=>{
- if(typeof highlightCode==='function') highlightCode(_finBody);
- if(typeof addCopyButtons==='function') addCopyButtons(_finBody);
- if(typeof renderKatexBlocks==='function') renderKatexBlocks();
- });
- } else {
- _smdEndParser();
- }
- const d=JSON.parse(e.data);
- const isActiveSession=_isSessionCurrentPane(activeSid);
- const isSessionViewed=_isSessionActivelyViewed(activeSid);
- const completedSession=d.session||{session_id:activeSid};
- const completedSid=completedSession.session_id||activeSid;
- if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){
- _markSessionCompletionUnread(completedSid, completedSession.message_count);
- }
- _clearOwnerInflightState();
- if(typeof _markSessionCompletedInList==='function'){
- _markSessionCompletedInList(completedSession, activeSid);
- }
- _clearApprovalForOwner();
- _clearClarifyForOwner('terminal');
- const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function')
- ? _shouldFollowMessagesOnDomReplace()
- : (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200)));
- if(isActiveSession){
- S.activeStreamId=null;
- }
- if(isActiveSession){
- // Capture previous session totals BEFORE overwriting S.session with the new
- // cumulative values from the done event. prevIn/prevOut are the totals as of
- // the start of this turn; curIn/curOut are the full post-turn totals — the
- // delta is the per-turn usage for #1159.
- const _prevIn=(S.session&&S.session.input_tokens)||0;
- const _prevOut=(S.session&&S.session.output_tokens)||0;
- const _prevCost=(S.session&&S.session.estimated_cost)||0;
- S.session=d.session;S.messages=d.session.messages||[];if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
- if(S.session&&S.session.session_id){
- localStorage.setItem('hermes-webui-session',S.session.session_id);
- if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
+ const _doneData=JSON.parse(e.data);
+ const _finishDone=()=>{
+ // Bug A fix: cancel any pending rAF and mark stream finalized before
+ // the DOM is settled by renderMessages, so no trailing token/reasoning rAF
+ // can reintroduce a stale thinking card or duplicate content.
+ _streamFinalized=true;
+ _cancelAnimationFramePendingStreamRender();
+ _streamFadeCleanupReduceMotionListener();
+ if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
+ // Finalize smd parser — flushes any remaining buffered markdown state
+ // and runs Prism + copy buttons on the live segment before the DOM is replaced
+ if(assistantBody){
+ const _finBody=assistantBody;
+ _smdEndParser();
+ requestAnimationFrame(()=>{
+ if(typeof highlightCode==='function') highlightCode(_finBody);
+ if(typeof addCopyButtons==='function') addCopyButtons(_finBody);
+ if(typeof renderKatexBlocks==='function') renderKatexBlocks();
+ });
+ } else {
+ _smdEndParser();
}
- if(
- window._compressionUi&&window._compressionUi.automatic&&
- window._compressionUi.sessionId===activeSid&&
- d.session&&d.session.session_id
- ){
- window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};
+ const d=_doneData;
+ const isActiveSession=_isSessionCurrentPane(activeSid);
+ const isSessionViewed=_isSessionActivelyViewed(activeSid);
+ const completedSession=d.session||{session_id:activeSid};
+ const completedSid=completedSession.session_id||activeSid;
+ if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){
+ _markSessionCompletionUnread(completedSid, completedSession.message_count);
}
- // Find the last assistant message once for both reasoning persistence and timestamp
- const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
- // Persist reasoning trace so thinking card survives page reload
- if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText;
- // Stamp _ts on the last assistant message if it has no timestamp
- if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000;
- if(d.usage){
- S.lastUsage=d.usage;_syncCtxIndicator(d.usage);
- // #503 — compute per-turn cost delta and attach to last assistant message
- if(lastAsst){
- const prevIn=_prevIn;
- const prevOut=_prevOut;
- const prevCost=_prevCost;
- const curIn=d.usage.input_tokens||0;
- const curOut=d.usage.output_tokens||0;
- const curCost=d.usage.estimated_cost||0;
- // Only set delta if values actually increased (skip no-op turns)
- if(curIn>prevIn||curOut>prevOut){
- lastAsst._turnUsage={
- input_tokens:Math.max(0,curIn-prevIn),
- output_tokens:Math.max(0,curOut-prevOut),
- estimated_cost:Math.max(0,curCost-prevCost),
- };
- }
- if(typeof d.usage.duration_seconds==='number'){
- lastAsst._turnDuration=d.usage.duration_seconds;
- }
- if(typeof d.usage.tps==='number'&&d.usage.tps>0){
- lastAsst._turnTps=d.usage.tps;
- }
- if(d.usage.gateway_routing){
- lastAsst._gatewayRouting=d.usage.gateway_routing;
- if(S.session)S.session.gateway_routing=d.usage.gateway_routing;
- if(S.session&&Array.isArray(S.session.gateway_routing_history))S.session.gateway_routing_history.push(d.usage.gateway_routing);
- else if(S.session)S.session.gateway_routing_history=[d.usage.gateway_routing];
+ _clearOwnerInflightState();
+ if(typeof _markSessionCompletedInList==='function'){
+ _markSessionCompletedInList(completedSession, activeSid);
+ }
+ _clearApprovalForOwner();
+ _clearClarifyForOwner('terminal');
+ const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function')
+ ? _shouldFollowMessagesOnDomReplace()
+ : (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200)));
+ if(isActiveSession){
+ S.activeStreamId=null;
+ }
+ if(isActiveSession){
+ // Capture previous session totals BEFORE overwriting S.session with the new
+ // cumulative values from the done event. prevIn/prevOut are the totals as of
+ // the start of this turn; curIn/curOut are the full post-turn totals — the
+ // delta is the per-turn usage for #1159.
+ const _prevIn=(S.session&&S.session.input_tokens)||0;
+ const _prevOut=(S.session&&S.session.output_tokens)||0;
+ const _prevCost=(S.session&&S.session.estimated_cost)||0;
+ S.session=d.session;S.messages=d.session.messages||[];if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
+ if(S.session&&S.session.session_id){
+ localStorage.setItem('hermes-webui-session',S.session.session_id);
+ if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
+ }
+ if(
+ window._compressionUi&&window._compressionUi.automatic&&
+ window._compressionUi.sessionId===activeSid&&
+ d.session&&d.session.session_id
+ ){
+ window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};
+ }
+ // Find the last assistant message once for both reasoning persistence and timestamp
+ const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
+ // Persist reasoning trace so thinking card survives page reload
+ if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText;
+ // Stamp _ts on the last assistant message if it has no timestamp
+ if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000;
+ if(d.usage){
+ S.lastUsage=d.usage;_syncCtxIndicator(d.usage);
+ // #503 — compute per-turn cost delta and attach to last assistant message
+ if(lastAsst){
+ const prevIn=_prevIn;
+ const prevOut=_prevOut;
+ const prevCost=_prevCost;
+ const curIn=d.usage.input_tokens||0;
+ const curOut=d.usage.output_tokens||0;
+ const curCost=d.usage.estimated_cost||0;
+ // Only set delta if values actually increased (skip no-op turns)
+ if(curIn>prevIn||curOut>prevOut){
+ lastAsst._turnUsage={
+ input_tokens:Math.max(0,curIn-prevIn),
+ output_tokens:Math.max(0,curOut-prevOut),
+ estimated_cost:Math.max(0,curCost-prevCost),
+ };
+ }
+ if(typeof d.usage.duration_seconds==='number'){
+ lastAsst._turnDuration=d.usage.duration_seconds;
+ }
+ if(typeof d.usage.tps==='number'&&d.usage.tps>0){
+ lastAsst._turnTps=d.usage.tps;
+ }
+ if(d.usage.gateway_routing){
+ lastAsst._gatewayRouting=d.usage.gateway_routing;
+ if(S.session)S.session.gateway_routing=d.usage.gateway_routing;
+ if(S.session&&Array.isArray(S.session.gateway_routing_history))S.session.gateway_routing_history.push(d.usage.gateway_routing);
+ else if(S.session)S.session.gateway_routing_history=[d.usage.gateway_routing];
+ }
}
}
+ if(d.session.tool_calls&&d.session.tool_calls.length){
+ S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
+ } else {
+ S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true}));
+ }
+ if(typeof _copyActivityDisclosureState==='function'&&lastAsst){
+ const assistantIdx=S.messages.indexOf(lastAsst);
+ if(assistantIdx>=0) _copyActivityDisclosureState('live:'+streamId, 'assistant:'+assistantIdx);
+ }
+ if(uploaded.length){
+ const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
+ if(lastUser)lastUser.attachments=uploaded;
+ }
+ if(_latestGoalStatus&&_latestGoalStatus.message){
+ S.messages.push({
+ role:'assistant',
+ content:String(_latestGoalStatus.message),
+ _ts:Date.now()/1000,
+ _goalStatus:true,
+ _transient:true,
+ });
+ }
+ clearLiveToolCards();
+ S.busy=false;
+ // No-reply guard (#373): if agent returned nothing, show inline error
+ if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
+ if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
+ syncTopbar();renderMessages({preserveScroll:true});
+ if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
+ loadDir('.');
+ // TTS auto-read: speak the last assistant response if enabled (#499)
+ if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
}
- if(d.session.tool_calls&&d.session.tool_calls.length){
- S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
- } else {
- S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true}));
- }
- if(typeof _copyActivityDisclosureState==='function'&&lastAsst){
- const assistantIdx=S.messages.indexOf(lastAsst);
- if(assistantIdx>=0) _copyActivityDisclosureState('live:'+streamId, 'assistant:'+assistantIdx);
- }
- if(uploaded.length){
- const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
- if(lastUser)lastUser.attachments=uploaded;
- }
- if(_latestGoalStatus&&_latestGoalStatus.message){
- S.messages.push({
- role:'assistant',
- content:String(_latestGoalStatus.message),
- _ts:Date.now()/1000,
- _goalStatus:true,
- _transient:true,
+ if(isActiveSession&&_pendingGoalContinuation&&typeof queueSessionMessage==='function'){
+ const _goalNext=_pendingGoalContinuation;
+ _pendingGoalContinuation=null;
+ queueSessionMessage(_goalNext.sid,{
+ text:_goalNext.text,
+ files:[],
+ model:_goalNext.model,
+ model_provider:_goalNext.model_provider,
+ profile:_goalNext.profile,
});
+ if(typeof updateQueueBadge==='function')updateQueueBadge(_goalNext.sid);
}
- clearLiveToolCards();
- S.busy=false;
- // No-reply guard (#373): if agent returned nothing, show inline error
- if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
- if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
- syncTopbar();renderMessages({preserveScroll:true});
- if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
- loadDir('.');
- // TTS auto-read: speak the last assistant response if enabled (#499)
- if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
+ if(isActiveSession) _queueDrainSid=activeSid;
+ renderSessionList();
+ _setActivePaneIdleIfOwner();
+ playNotificationSound();
+ sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
+ };
+ if(_shouldUseStreamFade()&&assistantBody){
+ _cancelAnimationFramePendingStreamRender();
+ _drainStreamFadeBeforeDone(_finishDone);
+ return;
}
- if(isActiveSession&&_pendingGoalContinuation&&typeof queueSessionMessage==='function'){
- const _goalNext=_pendingGoalContinuation;
- _pendingGoalContinuation=null;
- queueSessionMessage(_goalNext.sid,{
- text:_goalNext.text,
- files:[],
- model:_goalNext.model,
- model_provider:_goalNext.model_provider,
- profile:_goalNext.profile,
- });
- if(typeof updateQueueBadge==='function')updateQueueBadge(_goalNext.sid);
- }
- if(isActiveSession) _queueDrainSid=activeSid;
- renderSessionList();
- _setActivePaneIdleIfOwner();
- playNotificationSound();
- sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
+ _finishDone();
});
source.addEventListener('stream_end',e=>{
@@ -1267,7 +1561,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
- if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
+ _cancelAnimationFramePendingStreamRender();
+ _streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
@@ -1356,7 +1651,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
- if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
+ _cancelAnimationFramePendingStreamRender();
+ _streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
source.close();
@@ -1450,7 +1746,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// cannot fire after renderMessages() has settled the DOM with the error message.
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
- if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
+ _cancelAnimationFramePendingStreamRender();
+ _streamFadeCleanupReduceMotionListener();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_closeSource();
diff --git a/static/panels.js b/static/panels.js
index 5ce1417a..42123ba5 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -5142,6 +5142,8 @@ function _preferencesPayloadFromUi(){
if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
const showTpsCb=$('settingsShowTps');
if(showTpsCb) payload.show_tps=showTpsCb.checked;
+ const fadeTextCb=$('settingsFadeTextEffect');
+ if(fadeTextCb) payload.fade_text_effect=fadeTextCb.checked;
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb) payload.simplified_tool_calling=simplifiedToolCb.checked;
const apiRedactCb=$('settingsApiRedact');
@@ -5210,6 +5212,7 @@ async function _autosavePreferencesSettings(payload){
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
if(typeof renderMessages==='function') renderMessages();
}
+ if(payload&&Object.prototype.hasOwnProperty.call(payload,'fade_text_effect')) window._fadeTextEffect=!!payload.fade_text_effect;
if(payload&&payload.show_tps!==undefined){
window._showTps=!!(saved&&saved.show_tps);
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
@@ -5377,6 +5380,8 @@ async function loadSettingsPanel(){
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const showTpsCb=$('settingsShowTps');
if(showTpsCb){showTpsCb.checked=!!settings.show_tps;showTpsCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
+ const fadeTextCb=$('settingsFadeTextEffect');
+ if(fadeTextCb){fadeTextCb.checked=!!settings.fade_text_effect;window._fadeTextEffect=fadeTextCb.checked;fadeTextCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const apiRedactCb=$('settingsApiRedact');
@@ -6125,10 +6130,11 @@ function _setSettingsAuthButtonsVisible(active){
}
function _applySavedSettingsUi(saved, body, opts){
- const {sendKey,showTokenUsage,showTps,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
+ const {sendKey,showTokenUsage,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
window._showTps=showTps;
+ window._fadeTextEffect=!!fadeTextEffect;
window._showCliSessions=showCliSessions;
window._soundEnabled=body.sound_enabled;
window._notificationsEnabled=body.notifications_enabled;
@@ -6222,6 +6228,7 @@ async function saveSettings(andClose){
const sendKey=($('settingsSendKey')||{}).value;
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
const showTps=!!($('settingsShowTps')||{}).checked;
+ const fadeTextEffect=!!($('settingsFadeTextEffect')||{}).checked;
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
const pw=($('settingsPassword')||{}).value;
const theme=($('settingsTheme')||{}).value||'dark';
@@ -6241,6 +6248,7 @@ async function saveSettings(andClose){
body.language=language;
body.show_token_usage=showTokenUsage;
body.show_tps=showTps;
+ body.fade_text_effect=fadeTextEffect;
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
body.show_cli_sessions=showCliSessions;
@@ -6267,7 +6275,7 @@ async function saveSettings(andClose){
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
}
}
- _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
+ _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
_settingsDirty=false;
_resetSettingsPanelState();
@@ -6286,7 +6294,7 @@ async function saveSettings(andClose){
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
}
}
- _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
+ _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
showToast(t('settings_saved'));
_settingsDirty=false;
_resetSettingsPanelState();
diff --git a/static/style.css b/static/style.css
index 2bff54d9..d4e93324 100644
--- a/static/style.css
+++ b/static/style.css
@@ -3888,3 +3888,12 @@ main.main.showing-logs > #mainLogs{display:flex;}
.log-line-debug{color:var(--muted);opacity:.75;}
.logs-empty,.logs-hint{margin:8px 14px;padding:12px;border:1px solid var(--border);border-radius:8px;color:var(--muted);background:var(--surface);white-space:normal;font-family:var(--font-ui,system-ui,sans-serif);font-size:12px;}
.logs-hint.warn{color:#f59e0b;border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.08);}
+
+/* OpenWebUI-style streaming word fade (opt-in via Settings → Preferences).
+ Opacity-only fade with JS-paced word/paragraph reveal. */
+.stream-fade-active .stream-fade-word{display:inline;}
+.stream-fade-word.is-new{animation:stream-fade-word-in var(--stream-fade-ms,240ms) cubic-bezier(.2,.7,.2,1) both;will-change:opacity;}
+@keyframes stream-fade-word-in{0%{opacity:0;}45%{opacity:.45;}100%{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
new file mode 100644
index 00000000..ee5f8ff7
--- /dev/null
+++ b/tests/test_smooth_text_fade.py
@@ -0,0 +1,293 @@
+import re
+import subprocess
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8")
+INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
+PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
+MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
+BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
+STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
+I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
+
+FADE_SETTING = "fade_text_effect"
+FADE_CHECKBOX_ID = "settingsFadeTextEffect"
+FADE_RUNTIME_FLAG = "window._fadeTextEffect"
+FADE_LABEL_KEY = "settings_label_fade_text_effect"
+FADE_DESC_KEY = "settings_desc_fade_text_effect"
+
+
+def function_block(src: str, name: str) -> str:
+ marker = re.search(rf"(^|\n)\s*(?:async\s+)?function\s+{re.escape(name)}\(", src)
+ assert marker is not None, f"{name}() not found"
+ start = marker.start()
+ brace = src.find("{", marker.end())
+ assert brace != -1, f"{name}() opening brace not found"
+
+ depth = 0
+ in_string = None
+ escape = False
+ for i in range(brace, len(src)):
+ ch = src[i]
+ if in_string:
+ if escape:
+ escape = False
+ elif ch == "\\":
+ escape = True
+ elif ch == in_string:
+ in_string = None
+ continue
+ if ch in "'`\"":
+ in_string = ch
+ continue
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ return src[start : i + 1]
+ raise AssertionError(f"{name}() closing brace not found")
+
+
+def assert_contains_all(src: str, snippets: list[str]) -> None:
+ for snippet in snippets:
+ assert snippet in src
+
+
+def fade_helper_script(performance_stub: str = "{_t:0,now(){return this._t;}}") -> str:
+ helpers = "\n".join(
+ function_block(MESSAGES_JS, name)
+ for name in [
+ "_streamFadeWordCountOf",
+ "_streamFadePauseAfter",
+ "_resetStreamFadeState",
+ "_streamFadeNextText",
+ ]
+ )
+ return f"""
+let _streamFadeVisibleText='';
+let _streamFadeLastTickMs=0;
+let _streamFadeWordCarry=0;
+let _streamFadeStartedAt=0;
+let _streamFadeLastTargetWords=0;
+let _streamFadeLastArrivalMs=0;
+let _streamFadeArrivalWps=0;
+let _streamFadeLatestAnimationEndAt=0;
+let _streamFadeAppendOffset=0;
+let _streamFadeVisibleWords=0;
+let _streamFadeHoldUntilMs=0;
+let _streamFadeCurrentMs=200;
+const _STREAM_FADE_MS=200;
+const _STREAM_FADE_MAX_MS=350;
+const _STREAM_FADE_STAGGER_MS=16;
+const _STREAM_FADE_DONE_MAX_MS=320;
+const performance={performance_stub};
+{helpers}
+"""
+
+
+def run_node(script: str) -> subprocess.CompletedProcess[str]:
+ result = subprocess.run(
+ ["node", "-e", script],
+ cwd=REPO,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ return result
+
+
+def test_fade_text_effect_setting_is_wired_through_backend_and_startup():
+ bool_keys = CONFIG_PY[CONFIG_PY.index("_SETTINGS_BOOL_KEYS") : CONFIG_PY.index("# Language codes")]
+ assert f'"{FADE_SETTING}": False' in CONFIG_PY
+ assert f'"{FADE_SETTING}"' in bool_keys
+ assert f"{FADE_RUNTIME_FLAG}=!!s.{FADE_SETTING}" in BOOT_JS
+ assert f"{FADE_RUNTIME_FLAG}=false" in BOOT_JS
+
+
+def test_preferences_ui_exposes_and_saves_fade_text_effect():
+ assert f'id="{FADE_CHECKBOX_ID}"' in INDEX_HTML
+ assert f'data-i18n="{FADE_LABEL_KEY}"' in INDEX_HTML
+ assert f'data-i18n="{FADE_DESC_KEY}"' in INDEX_HTML
+ assert FADE_LABEL_KEY in I18N_JS
+ assert FADE_DESC_KEY in I18N_JS
+
+ payload_block = function_block(PANELS_JS, "_preferencesPayloadFromUi")
+ assert_contains_all(payload_block, [f"$('{FADE_CHECKBOX_ID}')", f"payload.{FADE_SETTING}="])
+
+ load_block = function_block(PANELS_JS, "loadSettingsPanel")
+ fade_load = load_block[load_block.index(f"$('{FADE_CHECKBOX_ID}')") :]
+ assert_contains_all(
+ fade_load[:700],
+ [f"settings.{FADE_SETTING}", FADE_RUNTIME_FLAG, "addEventListener('change',_schedulePreferencesAutosave"],
+ )
+
+ autosave_block = function_block(PANELS_JS, "_autosavePreferencesSettings")
+ assert_contains_all(autosave_block, [FADE_SETTING, f"{FADE_RUNTIME_FLAG}=!!payload.{FADE_SETTING}"])
+
+ save_block = function_block(PANELS_JS, "saveSettings")
+ assert_contains_all(save_block, [FADE_CHECKBOX_ID, f"body.{FADE_SETTING}", "fadeTextEffect"])
+
+ apply_block = function_block(PANELS_JS, "_applySavedSettingsUi")
+ assert_contains_all(apply_block, ["fadeTextEffect", f"{FADE_RUNTIME_FLAG}=!!fadeTextEffect"])
+
+
+def test_stream_fade_uses_incremental_renderer_without_changing_default_path():
+ block = function_block(MESSAGES_JS, "_scheduleRender")
+ render_block = function_block(MESSAGES_JS, "_renderStreamingFadeMarkdown")
+ renderer_block = function_block(MESSAGES_JS, "_streamFadeRenderer")
+ cleanup_block = function_block(MESSAGES_JS, "_streamFadeBindCleanup")
+
+ assert_contains_all(
+ block,
+ [
+ "_renderStreamingFadeMarkdown(displayText)",
+ "_smdWrite(displayText)",
+ "?33:66",
+ ],
+ )
+ assert_contains_all(
+ render_block,
+ [
+ "_streamFadeNextText(displayText)",
+ "if(!next.changed) return next.caughtUp",
+ "_smdNewParser(assistantBody,true)",
+ "_smdWrite(next.text,true)",
+ "stream-fade-active",
+ ],
+ )
+ assert "renderMd ? renderMd(next.text||'')" in render_block
+ assert_contains_all(
+ renderer_block,
+ [
+ "span.className='stream-fade-word is-new'",
+ "_streamFadeReduceMotionEnabled()",
+ "const appendStartedAt=performance.now()",
+ "--stream-fade-ms",
+ "renderer.set_attr",
+ "data-blocked-scheme",
+ "_streamFadeLatestAnimationEndAt",
+ ],
+ )
+ assert_contains_all(
+ cleanup_block,
+ ["animationend", "span.replaceWith(document.createTextNode"],
+ )
+ assert "_wrapStreamingFadeWords" not in MESSAGES_JS
+
+
+def test_stream_fade_css_is_opacity_only_and_hides_live_cursor():
+ fade_css = STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :]
+ assert "filter:" not in STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :].split(
+ "[data-live-assistant", 1
+ )[0]
+ assert "translateY" not in STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :].split(
+ "[data-live-assistant", 1
+ )[0]
+ assert_contains_all(
+ fade_css,
+ [
+ "@keyframes stream-fade-word-in",
+ ".stream-fade-word.is-new",
+ "var(--stream-fade-ms,240ms) cubic-bezier(.2,.7,.2,1)",
+ "prefers-reduced-motion: reduce",
+ ".msg-body.stream-fade-active > :last-child::after",
+ "display:none",
+ "content:none",
+ ],
+ )
+ assert ".stream-fade-active .stream-fade-word{display:inline;}" in fade_css
+
+
+def test_stream_fade_reduced_motion_listener_is_cleaned_up_on_terminal_paths():
+ assert "_streamFadeReduceMotionOnChange" in MESSAGES_JS
+ assert "function _streamFadeCleanupReduceMotionListener()" in MESSAGES_JS
+ assert "removeEventListener('change',_streamFadeReduceMotionOnChange)" in MESSAGES_JS
+ assert "removeListener(_streamFadeReduceMotionOnChange)" in MESSAGES_JS
+ assert MESSAGES_JS.count("_streamFadeCleanupReduceMotionListener();") >= 4
+
+
+def test_stream_fade_duration_scales_up_with_playback_speed():
+ script = (
+ fade_helper_script()
+ + r"""
+const words=Array.from({length:260},(_,i)=>'w'+i).join(' ');
+performance._t += 33;
+let out=_streamFadeNextText('slow start');
+if(!out.changed) throw new Error('expected initial reveal');
+if(_streamFadeCurrentMs !== 200) throw new Error(`expected base fade 200ms, got ${_streamFadeCurrentMs}`);
+for(let frame=0;frame<20&&_streamFadeCurrentMs<350;frame++){
+ performance._t += 120;
+ out=_streamFadeNextText(words);
+}
+if(_streamFadeCurrentMs !== 350) throw new Error(`expected max fade 350ms, got ${_streamFadeCurrentMs}`);
+"""
+ )
+ run_node(script)
+
+
+def test_stream_fade_playout_handles_fast_models_without_paragraph_pops():
+ script = (
+ fade_helper_script()
+ + r"""
+const words=Array.from({length:240},(_,i)=>'w'+i);
+let shown=0;
+let targetCount=0;
+for(let frame=0;frame<240;frame++){
+ performance._t += 16;
+ // Simulate sustained fast generation: ~40 words/sec arriving.
+ targetCount = Math.min(words.length, Math.floor(performance._t/1000*40));
+ const out=_streamFadeNextText(words.slice(0,targetCount).join(' '));
+ shown=(out.text.match(/\S+/g)||[]).length;
+}
+const backlog=targetCount-shown;
+if(shown < 145) throw new Error(`too slow: shown=${shown} target=${targetCount} backlog=${backlog} arrivalWps=${_streamFadeArrivalWps}`);
+if(backlog > 15) throw new Error(`did not catch up: shown=${shown} target=${targetCount} backlog=${backlog} arrivalWps=${_streamFadeArrivalWps}`);
+const huge=Array.from({length:500},(_,i)=>'b'+i).join(' ');
+let previous=0;
+for(let frame=0;frame<40;frame++){
+ performance._t += 16;
+ const out=_streamFadeNextText(huge);
+ const shown=(out.text.match(/\S+/g)||[]).length;
+ const revealed=shown-previous;
+ previous=shown;
+ if(revealed>3) throw new Error(`revealed too much in one frame: ${revealed}`);
+}
+if(previous<50) throw new Error(`too slow under large backlog: ${previous}`);
+"""
+ )
+ run_node(script)
+
+
+def test_stream_fade_respects_sentence_and_paragraph_boundaries():
+ script = (
+ fade_helper_script()
+ + r"""
+const target='alpha beta gamma\n\nsecond paragraph starts here\n\nthird paragraph starts here';
+performance._t += 200;
+let out=_streamFadeNextText(target);
+const breaks=(out.text.match(/\n\s*\n/g)||[]).length;
+if(breaks>1) throw new Error(`revealed multiple paragraph breaks: ${JSON.stringify(out.text)}`);
+_resetStreamFadeState();
+const pausedTarget='alpha beta.\n\nsecond paragraph starts here';
+out={text:''};
+for(let frame=0;frame<8&&!out.text.includes('.');frame++){
+ performance._t += 33;
+ out=_streamFadeNextText(pausedTarget);
+}
+if(!out.text.includes('.')) throw new Error(`expected first sentence: ${JSON.stringify(out.text)}`);
+const held=_streamFadeNextText(pausedTarget);
+if(held.changed) throw new Error('expected sentence pause to hold next reveal');
+performance._t += 50;
+for(let frame=0;frame<8&&!out.text.includes('\n\n');frame++){
+ performance._t += 33;
+ out=_streamFadeNextText(pausedTarget);
+}
+if(!out.text.includes('\n\n')) throw new Error(`expected paragraph break: ${JSON.stringify(out.text)}`);
+const afterBreak=_streamFadeNextText(pausedTarget);
+if(afterBreak.changed) throw new Error('expected paragraph pause to hold next reveal');
+"""
+ )
+ run_node(script)