Merge PR #5466 from ai-ag2026: reduce O(n^2) streaming render to O(n) (#5455)

This commit is contained in:
nesquena-hermes
2026-07-03 21:04:16 +00:00
3 changed files with 128 additions and 7 deletions
+89 -6
View File
@@ -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};
+9
View File
@@ -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');
+30 -1
View File
@@ -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.