mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-20 06:30:27 +00:00
This commit is contained in:
+89
-6
@@ -2122,6 +2122,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
function snapshotLiveTurn(){
|
||||
if(typeof snapshotLiveTurnHtmlForSession==='function') snapshotLiveTurnHtmlForSession(activeSid);
|
||||
}
|
||||
// Throttled per-frame variant. snapshotLiveTurnHtmlForSession serializes the
|
||||
// whole (growing) live turn via turn.outerHTML — O(n)/frame -> O(n^2) over a
|
||||
// long answer, and a real GC-pressure source. The snapshot only backs
|
||||
// mid-stream session-switch restore, and the switch path (sessions.js) plus
|
||||
// the stream event boundaries (tool/done) already capture synchronously, so a
|
||||
// coarse trailing snapshot during streaming is sufficient. (#5455 WS2.2)
|
||||
let _snapshotLiveTurnTimer=null;
|
||||
function _throttledSnapshotLiveTurn(){
|
||||
if(_snapshotLiveTurnTimer) return;
|
||||
_snapshotLiveTurnTimer=setTimeout(()=>{_snapshotLiveTurnTimer=null;snapshotLiveTurn();},700);
|
||||
}
|
||||
function _cancelThrottledSnapshotTimer(){
|
||||
if(_snapshotLiveTurnTimer){clearTimeout(_snapshotLiveTurnTimer);_snapshotLiveTurnTimer=null;}
|
||||
}
|
||||
// Throttled variant for token-by-token updates. persistInflightState()
|
||||
// calls saveInflightState() which does JSON.parse + JSON.stringify + write
|
||||
// on the entire inflight map every call. On a fast model at 60 tok/s with
|
||||
@@ -2167,6 +2181,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
function _finalizeStreamEndFallback(source){
|
||||
_clearStreamEndRecovery();
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_cancelThrottledSnapshotTimer();
|
||||
_terminalStateReached=true;
|
||||
_streamFinalized=true;
|
||||
_cancelAnimationFramePendingStreamRender();
|
||||
@@ -2178,6 +2193,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_clearStreamNotificationBackground(activeSid, streamId);
|
||||
_flushReasoningToAnchor();
|
||||
_scheduleAnchorRegistryCleanup();
|
||||
_clearAnchorProseIncrementalNode();
|
||||
_clearApprovalForOwner();
|
||||
_clearClarifyForOwner('terminal');
|
||||
if(_isActiveSession()){
|
||||
@@ -3508,6 +3524,58 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
},null);
|
||||
return _findAnchorActivityEventByLocalId(localId,'token');
|
||||
}
|
||||
// Persistent incremental renderer for anchor-scene live prose rows. The compact
|
||||
// worklog re-renders the whole scene each frame; rendering the growing prose via
|
||||
// renderMd(fullText) every frame is O(n^2) over a long answer. Instead keep a
|
||||
// per-segment smd parser + node (the SAME safe renderer as the main live body)
|
||||
// and feed only the delta, then hand the persistent node back to the ui.js scene
|
||||
// builder. Returns null whenever smd or a stable key is unavailable so the caller
|
||||
// falls back to the full renderMd path — identical structure, just not
|
||||
// incremental. (#5455 WS2.1)
|
||||
const _anchorProseSmdCache = new Map();
|
||||
function _anchorProseIncrementalNode(key, text){
|
||||
if(!window.smd || !key || typeof _safeSmdRenderer!=='function') return null;
|
||||
const value=String(text||'');
|
||||
try{
|
||||
let st=_anchorProseSmdCache.get(key);
|
||||
// Self-heal desyncs (edit/sanitize made the text no longer a pure append):
|
||||
// rebuild the parser+node from scratch, mirroring _smdWrite's guard.
|
||||
if(st && st.writtenText && !value.startsWith(st.writtenText)) st=null;
|
||||
if(!st){
|
||||
const node=document.createElement('div');
|
||||
node.className='assistant-segment';
|
||||
node.setAttribute('data-anchor-scene-prose','1');
|
||||
const body=document.createElement('div');
|
||||
body.className='msg-body';
|
||||
node.appendChild(body);
|
||||
const baseRenderer=_safeSmdRenderer(body);
|
||||
const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer);
|
||||
st={node,parser:window.smd.parser(renderer),writtenText:''};
|
||||
_anchorProseSmdCache.set(key,st);
|
||||
// Bound memory across turns: keys embed the stream id, so stale entries
|
||||
// from finished streams age out here.
|
||||
if(_anchorProseSmdCache.size>32){
|
||||
const oldest=_anchorProseSmdCache.keys().next().value;
|
||||
if(oldest!==key) _anchorProseSmdCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
const delta=value.slice(st.writtenText.length);
|
||||
if(delta){
|
||||
window.smd.parser_write(st.parser,delta);
|
||||
st.writtenText=value;
|
||||
}
|
||||
st.node.dataset.rawText=value;
|
||||
return st.node;
|
||||
}catch(_){
|
||||
_anchorProseSmdCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
window.__anchorProseIncrementalNode=_anchorProseIncrementalNode;
|
||||
function _clearAnchorProseIncrementalNode(){
|
||||
if(typeof window!=='undefined'&&window.__anchorProseIncrementalNode===_anchorProseIncrementalNode) window.__anchorProseIncrementalNode=null;
|
||||
_anchorProseSmdCache.clear();
|
||||
}
|
||||
function _anchorHasReasoningEvents(){
|
||||
const events=_anchorActivityEvents();
|
||||
return !!(events&&events.some(event=>event&&event.source_event_type==='reasoning'));
|
||||
@@ -3741,8 +3809,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// Also handles DSML-prefixed variants from DeepSeek/Bedrock, including
|
||||
// spacing variants like "<|DSML |function_calls" and truncated prefixes.
|
||||
if(!s) return s;
|
||||
const lo=String(s).toLowerCase();
|
||||
if(lo.indexOf('function_calls')===-1 && lo.indexOf('dsml')===-1) return s;
|
||||
// Case-insensitive presence check without allocating a full lowercased copy
|
||||
// of the (growing) text on every call — cuts per-token/per-frame GC pressure.
|
||||
// Equivalent to the previous toLowerCase()+indexOf gate. (#5455 WS2.3)
|
||||
if(!/function_calls|dsml/i.test(String(s))) return s;
|
||||
// Support both plain <function_calls> and DSML-prefixed variants.
|
||||
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls>[\s\S]*?<\/(?:\s*|\s*DSML\s*[||]\s*)?function_calls>/gi,'');
|
||||
// Also remove truncated opening tags (missing closing ">" at stream tail).
|
||||
@@ -4591,7 +4661,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
|
||||
}
|
||||
scrollIfPinned();
|
||||
snapshotLiveTurn();
|
||||
_throttledSnapshotLiveTurn();
|
||||
};
|
||||
const frameIntervalMs=_shouldUseStreamFade()?33:66;
|
||||
if(sinceLastMs>=frameIntervalMs){
|
||||
@@ -4647,10 +4717,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
_completeAutomaticCompressionOnLiveProgress(activeSid);
|
||||
const parsed=_parseStreamState();
|
||||
if(_freshSegment) appendThinking('', _liveThinkingPlacement());
|
||||
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
|
||||
_scheduleRender(parsed);
|
||||
// Once the assistant row exists its creation gate is already satisfied, and
|
||||
// the throttled _doRender re-parses once per frame anyway — so the per-token
|
||||
// full-text parse here is pure waste (O(n)/token -> O(n^2) over the answer).
|
||||
// Still call ensureAssistantRow() every token exactly as before (cheap; it
|
||||
// also starts a new segment on a post-tool _freshSegment). Only the parse is
|
||||
// skipped, and only once the row exists. (#5455 WS2.3)
|
||||
if(assistantRow){
|
||||
ensureAssistantRow();
|
||||
_scheduleRender();
|
||||
}else{
|
||||
const parsed=_parseStreamState();
|
||||
if(String((parsed&&parsed.displayText)||'').trim()) ensureAssistantRow();
|
||||
_scheduleRender(parsed);
|
||||
}
|
||||
});
|
||||
|
||||
source.addEventListener('interim_assistant',e=>{
|
||||
@@ -5012,6 +5093,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_streamFinalized=true;
|
||||
_terminalStateReached=true;
|
||||
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
|
||||
_cancelThrottledSnapshotTimer();
|
||||
const _doneData=JSON.parse(e.data);
|
||||
const _doneEvent=e;
|
||||
const _finishDone=()=>{
|
||||
@@ -5043,6 +5125,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
created_at:d.created_at||null,
|
||||
},_doneEvent);
|
||||
_scheduleAnchorRegistryCleanup();
|
||||
_clearAnchorProseIncrementalNode();
|
||||
const isActiveSession=_isSessionCurrentPane(activeSid);
|
||||
const isSessionViewed=_isSessionActivelyViewed(activeSid);
|
||||
const completedSession=d.session||{session_id:activeSid};
|
||||
|
||||
@@ -10531,6 +10531,15 @@ function _anchorSceneNodeForRow(row, opts){
|
||||
if(row.role==='prose'){
|
||||
const text=String(row.text||'').trim();
|
||||
if(!text) return null;
|
||||
// Incremental live rendering: reuse a persistent smd node fed only the delta
|
||||
// instead of re-parsing the whole growing answer on every streamed frame
|
||||
// (O(n^2) -> O(n)). Settled rows and any failure fall through to the full
|
||||
// renderMd path below, which stays the source of truth for the final DOM.
|
||||
const proseKey=row.local_id||row.row_id||'';
|
||||
if(!settled && proseKey && typeof window.__anchorProseIncrementalNode==='function'){
|
||||
const inc=window.__anchorProseIncrementalNode(proseKey,text);
|
||||
if(inc) return inc;
|
||||
}
|
||||
node=document.createElement('div');
|
||||
node.className='assistant-segment';
|
||||
node.setAttribute('data-anchor-scene-prose','1');
|
||||
|
||||
@@ -1030,10 +1030,39 @@ def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_tes
|
||||
"live answer content should be appended as a segment inside the live turn wrapper"
|
||||
assert "if(!force&&!assistantRow){" in src.replace(' ', ''), \
|
||||
"ensureAssistantRow must still avoid creating the live answer segment when no display text exists yet"
|
||||
assert "if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();" in src, \
|
||||
token_start = src.find("source.addEventListener('token'")
|
||||
interim_start = src.find("source.addEventListener('interim_assistant'", token_start)
|
||||
assert token_start >= 0 and interim_start > token_start
|
||||
token_body = src[token_start:interim_start]
|
||||
compact_token_body = token_body.replace(" ", "").replace("\n", "")
|
||||
assert "if(assistantRow){ensureAssistantRow();_scheduleRender();}" in compact_token_body, \
|
||||
"token handler should skip the per-token full-text parse after the live answer segment exists"
|
||||
assert "constparsed=_parseStreamState();if(String((parsed&&parsed.displayText)||'').trim())ensureAssistantRow();_scheduleRender(parsed);" in compact_token_body, \
|
||||
"token handler must only create the live answer segment once visible answer text starts"
|
||||
|
||||
|
||||
def test_messages_js_stream_perf_cleanup_lifecycle(cleanup_test_sessions):
|
||||
"""#5455 review: throttled snapshot timers and incremental anchor caches tear down at terminal events."""
|
||||
src = (REPO_ROOT / "static/messages.js").read_text()
|
||||
assert "function _cancelThrottledSnapshotTimer()" in src
|
||||
assert "clearTimeout(_snapshotLiveTurnTimer)" in src
|
||||
assert "function _clearAnchorProseIncrementalNode()" in src
|
||||
assert "window.__anchorProseIncrementalNode===_anchorProseIncrementalNode" in src
|
||||
assert "_anchorProseSmdCache.clear();" in src
|
||||
fallback_start = src.find("function _finalizeStreamEndFallback")
|
||||
recovery_start = src.find("async function _runStreamEndRecovery", fallback_start)
|
||||
assert fallback_start >= 0 and recovery_start > fallback_start
|
||||
fallback_body = src[fallback_start:recovery_start]
|
||||
assert "_cancelThrottledSnapshotTimer();" in fallback_body
|
||||
assert "_clearAnchorProseIncrementalNode();" in fallback_body
|
||||
done_start = src.find("source.addEventListener('done'")
|
||||
stream_end_start = src.find("source.addEventListener('stream_end'", done_start)
|
||||
assert done_start >= 0 and stream_end_start > done_start
|
||||
done_body = src[done_start:stream_end_start]
|
||||
assert "_cancelThrottledSnapshotTimer();" in done_body
|
||||
assert "_clearAnchorProseIncrementalNode();" in done_body
|
||||
|
||||
|
||||
def test_messages_js_finalizes_thinking_card_before_tool_card(cleanup_test_sessions):
|
||||
"""R19e: later reasoning after a tool call must render in a fresh Worklog
|
||||
Thinking Card without discarding durable reasoning.
|
||||
|
||||
Reference in New Issue
Block a user