fix: defer streaming KaTeX for pending equations
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Streaming KaTeX render passes now skip parser-owned equation placeholders that may still be receiving text, preventing long equations from being marked rendered before the final parser flush completes. (#2976)
|
||||
|
||||
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1067,7 +1067,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(_streamingKatexTimer) return;
|
||||
_streamingKatexTimer=setTimeout(()=>{
|
||||
_streamingKatexTimer=null;
|
||||
if(assistantBody&&typeof renderKatexBlocks==='function') renderKatexBlocks(assistantBody);
|
||||
if(assistantBody&&typeof renderKatexBlocks==='function') renderKatexBlocks(assistantBody,{streaming:true});
|
||||
},150);
|
||||
}
|
||||
// Helper: feed new displayText delta to the smd parser.
|
||||
|
||||
20
static/ui.js
20
static/ui.js
@@ -7754,8 +7754,25 @@ function renderMermaidBlocks(container){
|
||||
let _katexLoading=false;
|
||||
let _katexReady=false;
|
||||
|
||||
function renderKatexBlocks(container){
|
||||
function _isStreamingEquationPending(el,root){
|
||||
const tagName=(el&&el.tagName||'').toLowerCase();
|
||||
if(tagName!=='equation-block'&&tagName!=='equation-inline') return false;
|
||||
// streaming-markdown fills custom equation elements while the parser owns the
|
||||
// open node. If the equation is currently the last descendant of the live
|
||||
// assistant body, we cannot tell whether more TeX is still coming. Skip it
|
||||
// during live debounce passes so a partial source is not permanently marked
|
||||
// data-rendered before the final parser_end flush.
|
||||
let node=el;
|
||||
while(node&&node!==root){
|
||||
if(node.nextSibling) return false;
|
||||
node=node.parentNode;
|
||||
}
|
||||
return Boolean(node===root);
|
||||
}
|
||||
|
||||
function renderKatexBlocks(container,options){
|
||||
const root=container||document;
|
||||
const streaming=Boolean(options&&options.streaming);
|
||||
const blocks=root.querySelectorAll(
|
||||
'.katex-block:not([data-rendered]),.katex-inline:not([data-rendered]),'+
|
||||
'equation-block:not([data-rendered]),equation-inline:not([data-rendered])'
|
||||
@@ -7779,6 +7796,7 @@ function renderKatexBlocks(container){
|
||||
return;
|
||||
}
|
||||
blocks.forEach(el=>{
|
||||
if(streaming&&_isStreamingEquationPending(el,root)) return;
|
||||
el.dataset.rendered='true';
|
||||
const src=el.textContent||'';
|
||||
const tagName=(el.tagName||'').toLowerCase();
|
||||
|
||||
63
tests/test_katex_streaming.py
Normal file
63
tests/test_katex_streaming.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Regression coverage for streaming KaTeX rendering (#2976)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_function(src: str, name: str) -> str:
|
||||
marker = f"function {name}"
|
||||
start = src.find(marker)
|
||||
assert start >= 0, f"{name} not found"
|
||||
brace = src.find("{", start)
|
||||
assert brace >= 0, f"{name} body not found"
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
while i < len(src) and depth:
|
||||
ch = src[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
assert depth == 0, f"{name} body did not close"
|
||||
return src[start:i]
|
||||
|
||||
|
||||
def test_streaming_katex_scheduler_marks_live_pass_as_streaming():
|
||||
"""The live 150ms KaTeX debounce must identify streaming passes.
|
||||
|
||||
Without the explicit streaming flag, renderKatexBlocks() cannot distinguish a
|
||||
live parser-owned <equation-block> that is still being filled from a settled
|
||||
DOM node, so it may mark partial math as data-rendered permanently.
|
||||
"""
|
||||
fn = _extract_function(MESSAGES_JS, "_scheduleStreamingKatex")
|
||||
assert "renderKatexBlocks(assistantBody,{streaming:true})" in fn
|
||||
|
||||
|
||||
def test_render_katex_blocks_skips_pending_streaming_equation_before_rendered_flag():
|
||||
"""Streaming equation placeholders must be skipped before data-rendered.
|
||||
|
||||
The guard has to run before `el.dataset.rendered='true'`; otherwise a long
|
||||
equation that is still receiving text becomes permanently ineligible for the
|
||||
final complete KaTeX render.
|
||||
"""
|
||||
fn = _extract_function(UI_JS, "renderKatexBlocks")
|
||||
assert "function _isStreamingEquationPending" in UI_JS
|
||||
pending_idx = fn.find("_isStreamingEquationPending")
|
||||
rendered_idx = fn.find("el.dataset.rendered='true'")
|
||||
assert pending_idx != -1, "renderKatexBlocks must check pending streaming equations"
|
||||
assert rendered_idx != -1, "renderKatexBlocks must still set data-rendered when rendering"
|
||||
assert pending_idx < rendered_idx, "pending guard must run before data-rendered is set"
|
||||
|
||||
|
||||
def test_final_katex_render_keeps_default_non_streaming_path():
|
||||
"""Final renderKatexBlocks() calls must still render all math placeholders."""
|
||||
fn = _extract_function(UI_JS, "renderKatexBlocks")
|
||||
assert "const streaming=Boolean" in fn
|
||||
assert "if(streaming&&_isStreamingEquationPending" in fn
|
||||
done_fn = _extract_function(MESSAGES_JS, "_smdEndParser")
|
||||
assert "renderKatexBlocks" not in done_fn, "done rendering remains in done handler after parser_end"
|
||||
@@ -11,7 +11,7 @@ def test_live_smd_writes_schedule_incremental_katex_rendering():
|
||||
assert "let _streamingKatexTimer=null" in MESSAGES_JS
|
||||
assert "function _scheduleStreamingKatex()" in MESSAGES_JS
|
||||
assert "setTimeout(()=>{" in MESSAGES_JS
|
||||
assert "renderKatexBlocks(assistantBody)" in MESSAGES_JS
|
||||
assert "renderKatexBlocks(assistantBody,{streaming:true})" in MESSAGES_JS
|
||||
|
||||
smd_write_idx = MESSAGES_JS.index("function _smdWrite(displayText, fade=false){")
|
||||
done_idx = MESSAGES_JS.index("source.addEventListener('done'")
|
||||
@@ -28,8 +28,9 @@ def test_streaming_katex_timer_is_cleared_when_smd_parser_ends():
|
||||
|
||||
|
||||
def test_katex_renderer_scans_live_and_settled_unrendered_nodes_under_container():
|
||||
assert "function renderKatexBlocks(container){" in UI_JS
|
||||
assert "function renderKatexBlocks(container,options){" in UI_JS
|
||||
assert "const root=container||document;" in UI_JS
|
||||
assert "const streaming=Boolean(options&&options.streaming);" in UI_JS
|
||||
assert ".katex-block:not([data-rendered]),.katex-inline:not([data-rendered])," in UI_JS
|
||||
assert "equation-block:not([data-rendered]),equation-inline:not([data-rendered])" in UI_JS
|
||||
assert "const tagName=(el.tagName||'').toLowerCase();" in UI_JS
|
||||
|
||||
Reference in New Issue
Block a user