diff --git a/static/messages.js b/static/messages.js index 85087d64..69de54f1 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2122,6 +2122,17 @@ 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); + } // 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 @@ -3789,8 +3800,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 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). @@ -4639,7 +4652,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){ @@ -4695,10 +4708,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=>{