fix(ui+pwa): avoid stale Mermaid render errors and bust cached static asset URLs on every release

From PR #1337.

Co-authored-by: Dennis Soong <dso2ng@gmail.com>
This commit is contained in:
nesquena-hermesandDennis Soong committed 2026-04-30 16:18:01 +00:00
1 parent 09e12e3c60
commit fbe84d26e6
10 files changed
+140 -28

No files matched your search

+7 -2
View File
@@ -717,9 +717,12 @@ def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""
if parsed.path in ("/", "/index.html"):
from urllib.parse import quote
from api.updates import WEBUI_VERSION
version_token = quote(WEBUI_VERSION, safe="")
return t(
handler,
_INDEX_HTML_PATH.read_text(encoding="utf-8"),
_INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token),
content_type="text/html; charset=utf-8",
)
@@ -776,9 +779,11 @@ def handle_get(handler, parsed) -> bool:
if sw_path.exists():
# Inject the current git-derived version as the cache name so the
# service worker cache busts automatically on every new deploy.
from urllib.parse import quote
from api.updates import WEBUI_VERSION
version_token = quote(WEBUI_VERSION, safe="")
text = sw_path.read_text(encoding="utf-8").replace(
"__CACHE_VERSION__", WEBUI_VERSION
"__CACHE_VERSION__", version_token
)
data = text.encode("utf-8")
handler.send_response(200)
+12 -12
View File
@@ -47,7 +47,7 @@
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('sw.js').catch(function(err) {
navigator.serviceWorker.register('sw.js?v=__WEBUI_VERSION__').catch(function(err) {
console.warn('[pwa] Service worker registration failed:', err);
});
});
@@ -951,16 +951,16 @@
</div>
</div>
<div class="toast" id="toast"></div>
<script src="static/i18n.js" defer></script>
<script src="static/icons.js" defer></script>
<script src="static/ui.js" defer></script>
<script src="static/workspace.js" defer></script>
<script src="static/terminal.js" defer></script>
<script src="static/sessions.js" defer></script>
<script src="static/commands.js" defer></script>
<script src="static/messages.js" defer></script>
<script src="static/panels.js" defer></script>
<script src="static/onboarding.js" defer></script>
<script src="static/boot.js" defer></script>
<script src="static/i18n.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/icons.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/ui.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/workspace.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/terminal.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/sessions.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/commands.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/messages.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/panels.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/onboarding.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>
</body>
</html>
+4
View File
@@ -64,6 +64,10 @@ self.addEventListener('fetch', (event) => {
// Never intercept cross-origin requests
if (url.origin !== self.location.origin) return;
// Never intercept the service worker script itself. Returning a cached sw.js
// prevents the browser from seeing a new cache version after local patches.
if (url.pathname.endsWith('/sw.js')) return;
// API and streaming endpoints — always go to network.
// The WebUI may be mounted under a subpath such as /hermes/, so API
// requests can look like /hermes/api/sessions rather than /api/sessions.
+17 -5
View File
@@ -1085,12 +1085,17 @@ function renderMd(raw){
const fence_stash=[];
s=s.replace(/```([\s\S]*?)```/g,(_,raw)=>{
const m=raw.match(/^(\w[\w+-]*)\n?([\s\S]*)$/);
if(m&&m[1].trim().toLowerCase()==='mermaid'){
const lang=m?(m[1]||'').trim().toLowerCase():'';
const code=m?m[2]:raw.replace(/^\n?/,'');
const codeLines=code.split('\n');
const firstCodeLine=codeLines.find(line=>line.trim())||'';
const firstMermaidLine=codeLines.map(line=>line.trim()).find(line=>line&&!line.startsWith('%%'))||'';
const looksLikeLineNumberedToolOutput=/^\s*\d+\|/.test(firstCodeLine);
const looksLikeMermaidStart=firstMermaidLine==='---'||/^(graph|flowchart|sequenceDiagram|classDiagram|classDiagram-v2|stateDiagram|stateDiagram-v2|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|quadrantChart|requirementDiagram|C4Context|C4Container|C4Component|C4Dynamic|c4Context|c4Container|c4Component|c4Dynamic|sankey-beta|block-beta|packet-beta|xychart-beta|kanban|architecture-beta)\b/.test(firstMermaidLine);
if(lang==='mermaid'&&!looksLikeLineNumberedToolOutput&&looksLikeMermaidStart){
const id='mermaid-'+Math.random().toString(36).slice(2,10);
_preBlock_stash.push(`<div class="mermaid-block" data-mermaid-id="${id}">${esc(m[2].trim())}</div>`);
_preBlock_stash.push(`<div class="mermaid-block" data-mermaid-id="${id}">${esc(code.trim())}</div>`);
} else {
const lang=m?(m[1]||'').trim().toLowerCase():'';
const code=m?m[2]:raw.replace(/^\n?/,'');
const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';
const langAttr=lang?` class="language-${esc(lang)}"`:'';
// For diff/patch blocks, wrap each line in a colored span
@@ -4130,10 +4135,17 @@ function renderMermaidBlocks(){
const id=block.dataset.mermaidId||('m-'+Math.random().toString(36).slice(2));
try{
const {svg}=await mermaid.render(id,code);
const tmp=document.getElementById('d'+id);
if(tmp) tmp.remove();
block.innerHTML=svg;
block.classList.add('mermaid-rendered');
}catch(e){
// Fall back to showing as a code block
const tmp=document.getElementById('d'+id);
if(tmp) tmp.remove();
// Fall back to showing as a code block. Remove the mermaid marker so a
// later render pass cannot retry this already-failed block.
block.classList.remove('mermaid-block');
block.classList.add('prewrap');
block.innerHTML=`<div class="pre-header">mermaid</div><pre><code>${esc(code)}</code></pre>`;
}
});
+15
View File
@@ -105,6 +105,21 @@ def test_render_katex_blocks_wired_into_raf():
'renderKatexBlocks() not found in any requestAnimationFrame call — math will not render'
def test_mermaid_render_failure_removes_temporary_error_dom():
"""Failed Mermaid renders must not leave Mermaid's body-level syntax-error SVG visible."""
fn_start = UI_JS.find('function renderMermaidBlocks()')
assert fn_start != -1, 'renderMermaidBlocks() function not found in ui.js'
fn = UI_JS[fn_start:fn_start + 2200]
cleanup = "const tmp=document.getElementById('d'+id);\n if(tmp) tmp.remove();"
assert cleanup in fn, (
"renderMermaidBlocks() must remove Mermaid's temporary d<id> container; "
"otherwise rejected renders leave a visible 'Syntax error in text' SVG in every tab."
)
assert fn.count(cleanup) >= 2, (
"Mermaid temporary DOM cleanup must run after both successful and failed renders."
)
# ── index.html ────────────────────────────────────────────────────────────────
def test_katex_css_in_index_html():
+1 -1
View File
@@ -13,7 +13,7 @@ def test_index_contains_onboarding_overlay_markup():
assert 'id="onboardingOverlay"' in html
assert 'id="onboardingBody"' in html
assert 'id="onboardingNextBtn"' in html
assert 'src="static/onboarding.js"' in html
assert 'src="static/onboarding.js?v=__WEBUI_VERSION__"' in html
def test_onboarding_css_rules_exist():
+25
View File
@@ -124,6 +124,16 @@ class TestPWARoutes:
"sw.js route must import and use WEBUI_VERSION for cache busting"
)
def test_sw_route_url_encodes_cache_version(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('"/sw.js"')
assert idx != -1, "routes.py must handle /sw.js"
block = src[idx:idx + 1200]
assert "quote(WEBUI_VERSION, safe=\"\")" in block, (
"sw.js route must URL-encode the injected cache version so unusual git tags "
"cannot break the JavaScript string literal"
)
def test_sw_route_sets_service_worker_allowed(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('"/sw.js"')
@@ -145,6 +155,21 @@ class TestIndexHtmlIntegration:
"index.html must register the service worker"
)
def test_index_uses_version_placeholders_for_static_assets(self):
src = INDEX.read_text(encoding="utf-8")
assert "sw.js?v=__WEBUI_VERSION__" in src
assert "static/ui.js?v=__WEBUI_VERSION__" in src
def test_index_route_url_encodes_asset_version(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('parsed.path in ("/", "/index.html")')
assert idx != -1, "routes.py must handle / and /index.html"
block = src[idx:idx + 800]
assert "quote(WEBUI_VERSION, safe=\"\")" in block, (
"index route must URL-encode the cache-busting version token before "
"injecting it into script src attributes and service worker registration"
)
def test_index_has_ios_pwa_meta_tags(self):
src = INDEX.read_text(encoding="utf-8")
assert "apple-mobile-web-app-capable" in src, (
+45
View File
@@ -503,6 +503,51 @@ class TestBlockquoteEntityEncodedInput:
assert "<pre>" in out, f"Fenced code inside entity-encoded blockquote must render: {out!r}"
class TestMermaidToolOutputGuard:
"""Line-numbered tool excerpts must not be auto-rendered as Mermaid."""
def test_line_numbered_mermaid_fence_renders_as_code_block(self, driver_path):
src = "```mermaid\n23|flowchart TB\n24| A --> B\n```"
out = _render(driver_path, src)
assert 'class="mermaid-block"' not in out, (
f"Line-numbered read_file excerpts are not valid Mermaid and must not auto-render: {out!r}"
)
assert '<div class="pre-header">mermaid</div>' in out
assert '<pre><code class="language-mermaid">' in out
assert '23|flowchart TB' in out
def test_valid_mermaid_fence_still_creates_mermaid_block(self, driver_path):
out = _render(driver_path, "```mermaid\nflowchart TB\n A --> B\n```")
assert 'class="mermaid-block"' in out, (
f"Valid Mermaid fences should still be queued for Mermaid rendering: {out!r}"
)
assert 'flowchart TB' in out
def test_valid_mermaid_c4_fence_still_creates_mermaid_block(self, driver_path):
out = _render(driver_path, "```mermaid\nC4Context\n title System Context\n```")
assert 'class="mermaid-block"' in out, (
f"Valid C4 Mermaid fences should still be queued for Mermaid rendering: {out!r}"
)
assert 'C4Context' in out
def test_valid_mermaid_frontmatter_fence_still_creates_mermaid_block(self, driver_path):
out = _render(driver_path, "```mermaid\n---\ntitle: Demo\n---\nflowchart TB\n A --> B\n```")
assert 'class="mermaid-block"' in out, (
f"Valid Mermaid fences with frontmatter should still be queued for Mermaid rendering: {out!r}"
)
assert 'title: Demo' in out
def test_prose_mention_of_mermaid_fence_renders_as_code_block(self, driver_path):
src = "```mermaid\n` fence should not be auto-rendered too aggressively.\n\nSome prose, not a diagram.\n```"
out = _render(driver_path, src)
assert 'class="mermaid-block"' not in out, (
f"Prose captured by a mermaid fence is not valid Mermaid and must not auto-render: {out!r}"
)
assert '<div class="pre-header">mermaid</div>' in out
assert '<pre><code class="language-mermaid">' in out
assert 'Some prose, not a diagram.' in out
class TestRawPreCodePreservation:
"""Raw <pre><code> HTML from model output should remain structurally intact."""
+6
View File
@@ -29,3 +29,9 @@ def test_service_worker_excludes_subpath_mounted_health_routes_from_cache():
def test_service_worker_documents_api_routes_are_never_cached():
assert "API and streaming endpoints" in SW_SRC
assert "always go to network" in SW_SRC
def test_service_worker_does_not_intercept_its_own_script():
assert "url.pathname.endsWith('/sw.js')" in SW_SRC, (
"service worker must bypass /sw.js so a stale cached worker cannot block cache-version updates"
)
+8 -8
View File
@@ -68,19 +68,19 @@ def test_app_js_no_longer_referenced_in_html(cleanup_test_sessions):
"""index.html must not reference the old monolithic app.js."""
html = get_text("/")
assert 'src="static/app.js"' not in html
# All 6 modules must be present
# All split modules must be present with the server-injected cache-busting version query.
for module in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]:
assert f'src="static/{module}"' in html, f"Missing {module} in index.html"
assert f'src="static/{module}?v=' in html, f"Missing versioned {module} in index.html"
def test_module_load_order_correct(cleanup_test_sessions):
"""ui.js must appear before sessions.js which must appear before boot.js."""
html = get_text("/")
ui_pos = html.find('src="static/ui.js"')
ws_pos = html.find('src="static/workspace.js"')
sess_pos = html.find('src="static/sessions.js"')
msg_pos = html.find('src="static/messages.js"')
panels_pos = html.find('src="static/panels.js"')
boot_pos = html.find('src="static/boot.js"')
ui_pos = html.find('src="static/ui.js?v=')
ws_pos = html.find('src="static/workspace.js?v=')
sess_pos = html.find('src="static/sessions.js?v=')
msg_pos = html.find('src="static/messages.js?v=')
panels_pos = html.find('src="static/panels.js?v=')
boot_pos = html.find('src="static/boot.js?v=')
assert ui_pos < ws_pos < sess_pos < msg_pos < panels_pos < boot_pos
def test_no_duplicate_function_definitions(cleanup_test_sessions):