mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '조정(중간 수정)',
|
||||
|
||||
@@ -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
@@ -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<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
@@ -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();
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user