Merge pull request #2099 into stage-358

feat: add opt-in streaming text fade (dobby-d-elf, off-by-default)
This commit is contained in:
Hermes Agent
2026-05-14 21:27:52 +00:00
8 changed files with 811 additions and 175 deletions
+2
View File
@@ -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",
+2
View File
@@ -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;
+18
View File
@@ -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: '조정(중간 수정)',
+7
View File
@@ -1004,6 +1004,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Displays tokens per second in assistant message headers while streaming and after a response completes. Off by default.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsFadeTextEffect" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_fade_text_effect">Fade text effect</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_fade_text_effect">Fade newly streamed words in while the assistant is responding. Similar to OpenWebUI; off by default for maximum performance.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSimplifiedToolCalling" style="width:15px;height:15px;accent-color:var(--accent)">
+469 -172
View File
@@ -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 `![alt](javascript:...)` 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<value.length) frag.appendChild(document.createTextNode(value.slice(last)));
parent.appendChild(frag);
};
renderer.set_attr=(data,attr,value)=>{
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();
+11 -3
View File
@@ -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();
+9
View File
@@ -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;}
+293
View File
@@ -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)