mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-30 21:50:16 +00:00
Stage 397: PR #2692 — fix(ui): invalidate transcript cache on same-count content changes
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
This commit is contained in:
+53
-8
@@ -5537,14 +5537,9 @@ function renderCompressionUi(){
|
||||
el.style.display='none';
|
||||
}
|
||||
// Session render cache: avoids full markdown+DOM rebuild when switching back
|
||||
// to a session that was already rendered with the same message count.
|
||||
// to a session whose rendered transcript inputs are unchanged.
|
||||
// Keyed by session_id. Only used on cross-session navigation, never for
|
||||
// in-session updates (new messages, edits, stream events).
|
||||
//
|
||||
// Known limitation: cache key is session_id + message count. Edits and retries
|
||||
// that mutate message content without changing the count will serve stale HTML
|
||||
// on back-navigation until the user triggers an in-session update. Acceptable
|
||||
// for the common read-only back-navigation case; not suitable as a general cache.
|
||||
const _sessionHtmlCache=new Map();
|
||||
let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM
|
||||
function clearMessageRenderCache(){
|
||||
@@ -5552,6 +5547,55 @@ function clearMessageRenderCache(){
|
||||
_sessionHtmlCacheSid=null;
|
||||
}
|
||||
|
||||
function _messageRenderCacheSignature(){
|
||||
let hash=2166136261;
|
||||
function add(value){
|
||||
const s=String(value==null?'':value);
|
||||
for(let i=0;i<s.length;i++){
|
||||
hash^=s.charCodeAt(i);
|
||||
hash=Math.imul(hash,16777619)>>>0;
|
||||
}
|
||||
hash^=31;
|
||||
hash=Math.imul(hash,16777619)>>>0;
|
||||
}
|
||||
const messages=Array.isArray(S.messages)?S.messages:[];
|
||||
add(messages.length);
|
||||
for(const m of messages){
|
||||
if(!m||typeof m!=='object'){ add('missing'); continue; }
|
||||
add(m.role);add(m.timestamp);add(m._ts);add(m._error);add(m._statusCard);
|
||||
add(msgContent(m));
|
||||
if(Array.isArray(m.content)){
|
||||
add('content-array');
|
||||
m.content.forEach(part=>{
|
||||
if(!part||typeof part!=='object'){ add(part); return; }
|
||||
add(part.type);add(part.id);add(part.name);add(part.text);add(part.content);
|
||||
});
|
||||
}
|
||||
if(Array.isArray(m.tool_calls)){
|
||||
add('message-tool-calls');add(m.tool_calls.length);
|
||||
m.tool_calls.forEach(tc=>{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.type);add(JSON.stringify(tc&&tc.function||{}));});
|
||||
}
|
||||
if(Array.isArray(m._partial_tool_calls)){
|
||||
add('partial-tool-calls');add(m._partial_tool_calls.length);
|
||||
m._partial_tool_calls.forEach(tc=>{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.snippet);});
|
||||
}
|
||||
if(_messageHasReasoningPayload(m)) add(m.reasoning||m.thinking||m._reasoning||'reasoning');
|
||||
if(Array.isArray(m.attachments)) m.attachments.forEach(a=>add(a&&typeof a==='object'?JSON.stringify(a):a));
|
||||
}
|
||||
const toolCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
|
||||
add('settled-tool-calls');add(toolCalls.length);
|
||||
toolCalls.forEach(tc=>{
|
||||
if(!tc||typeof tc!=='object'){ add(tc); return; }
|
||||
add(tc.tid);add(tc.id);add(tc.name);add(tc.done);add(tc.is_diff);add(tc.assistant_msg_idx);add(tc.snippet);add(JSON.stringify(tc.args||{}));
|
||||
});
|
||||
if(S.session){
|
||||
add(S.session.message_count);add(S.session.updated_at);add(S.session.compression_anchor_visible_idx);
|
||||
add(JSON.stringify(S.session.compression_anchor_message_key||null));
|
||||
add(S.session.compression_anchor_summary||'');
|
||||
}
|
||||
return `${messages.length}:${toolCalls.length}:${hash.toString(16)}`;
|
||||
}
|
||||
|
||||
function _clipCliToolSnippet(text, maxLen=20000){
|
||||
const s=String(text||'');
|
||||
if(s.length<=maxLen) return s;
|
||||
@@ -5698,6 +5742,7 @@ function renderMessages(options){
|
||||
const msgCount=S.messages.length;
|
||||
if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid);
|
||||
const renderWindowSize=_currentMessageRenderWindowSize();
|
||||
const renderSignature=_messageRenderCacheSignature();
|
||||
const hasTransientTranscriptUi=!!(
|
||||
(window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) ||
|
||||
(window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid))
|
||||
@@ -5713,7 +5758,7 @@ function renderMessages(options){
|
||||
// before those cards can be inserted.
|
||||
if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){
|
||||
const cached=_sessionHtmlCache.get(sid);
|
||||
if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize){
|
||||
if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize&&cached.signature===renderSignature){
|
||||
inner.innerHTML=cached.html;
|
||||
_sessionHtmlCacheSid=sid;
|
||||
_wireMessageWindowLoadEarlierButton();
|
||||
@@ -6324,7 +6369,7 @@ function renderMessages(options){
|
||||
const _html=inner.innerHTML;
|
||||
// Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions.
|
||||
if(_html.length<300_000){
|
||||
_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize});
|
||||
_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature});
|
||||
if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
UI_JS = Path("static/ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_session_html_cache_uses_render_signature_not_only_count():
|
||||
assert "function _messageRenderCacheSignature()" in UI_JS
|
||||
assert "const renderSignature=_messageRenderCacheSignature();" in UI_JS
|
||||
assert "cached.signature===renderSignature" in UI_JS
|
||||
assert "signature:renderSignature" in UI_JS
|
||||
|
||||
|
||||
def test_render_signature_tracks_message_content_and_settled_tool_cards():
|
||||
signature_fn = UI_JS[UI_JS.index("function _messageRenderCacheSignature()"):UI_JS.index("function _clipCliToolSnippet")]
|
||||
assert "msgContent(m)" in signature_fn
|
||||
assert "m.tool_calls" in signature_fn
|
||||
assert "m._partial_tool_calls" in signature_fn
|
||||
assert "S.toolCalls" in signature_fn
|
||||
assert "tc.snippet" in signature_fn
|
||||
assert "compression_anchor_summary" in signature_fn
|
||||
|
||||
|
||||
def test_documentation_no_longer_allows_same_count_stale_html():
|
||||
assert "Known limitation: cache key is session_id + message count" not in UI_JS
|
||||
assert "mutate message content without changing the count will serve stale HTML" not in UI_JS
|
||||
@@ -33,7 +33,8 @@ def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_
|
||||
|
||||
def test_window_state_participates_in_cache_and_cached_button_is_rewired():
|
||||
assert "cached.renderWindowSize===renderWindowSize" in UI_JS
|
||||
assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize})" in UI_JS
|
||||
assert "cached.signature===renderSignature" in UI_JS
|
||||
assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature})" in UI_JS
|
||||
assert "function _wireMessageWindowLoadEarlierButton()" in UI_JS
|
||||
assert "_wireMessageWindowLoadEarlierButton();" in UI_JS
|
||||
assert UI_JS.count("_wireMessageWindowLoadEarlierButton();") >= 2
|
||||
|
||||
Reference in New Issue
Block a user