Render standard TeX in chat messages
Convert standard inline and display delimiters before Markdown while preserving code spans and fences. Render agent-thought math locally so generic process and key/value rendering stays flag-free.
Alessandro committed
Jul 14, 2026 at 15:57 UTC
56e676579de61afc0e807ee8c3ff75e33f636bf3
3 files changed
+108
-17
tests/test_webui_latex_rendering.py
new
+63
@@ -0,0 +1,63 @@
1
+from pathlib import Path
2
+import shutil
3
+import subprocess
4
+
5
+import pytest
6
+
7
+
8
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
9
+MESSAGES_JS = PROJECT_ROOT / "webui" / "js" / "messages.js"
10
+
11
+
12
+def test_standard_latex_delimiters_survive_markdown_preprocessing():
13
+ if not shutil.which("node"):
14
+ pytest.skip("Node.js is required to execute the LaTeX delimiter regression.")
15
+
16
+ source = MESSAGES_JS.read_text(encoding="utf-8")
17
+ converter_start = source.index("function convertLatexDelimiters(")
18
+ converter_end = source.index("\nfunction renderLatexElements", converter_start)
19
+ function_source = source[converter_start:converter_end]
20
+
21
+ script = rf"""
22
+{function_source}
23
+
24
+function assertEqual(actual, expected) {{
25
+ if (actual !== expected) {{
26
+ throw new Error(`Expected ${{JSON.stringify(expected)}}, got ${{JSON.stringify(actual)}}`);
27
+ }}
28
+}}
29
+
30
+const encode = (value) =>
31
+ Array.from(value, (char) => `&#${{char.codePointAt(0)}};`).join('');
32
+
33
+assertEqual(
34
+ convertLatexDelimiters(String.raw`\[x^2 < y & z > 0\]`),
35
+ `<latex data-display="true">${{encode('x^2 < y & z > 0')}}</latex>`,
36
+);
37
+assertEqual(
38
+ convertLatexDelimiters(String.raw`Before \(x+y\) after`),
39
+ `Before <latex>${{encode('x+y')}}</latex> after`,
40
+);
41
+assertEqual(
42
+ convertLatexDelimiters(String.raw`$$\sum_n a_n$$`),
43
+ `<latex data-display="true">${{encode(String.raw`\sum_n a_n`)}}</latex>`,
44
+);
45
+const protectedCode =
46
+ 'Inline `' + String.raw`\(not_math\)` + '` and:\n' +
47
+ '```tex\n' + String.raw`\[also_not_math\]` + '\n```';
48
+assertEqual(convertLatexDelimiters(protectedCode), protectedCode);
49
+"""
50
+ subprocess.run(["node", "--input-type=module", "-e", script], check=True)
51
+
52
+
53
+def test_katex_renderer_uses_text_content_and_display_metadata():
54
+ source = MESSAGES_JS.read_text(encoding="utf-8")
55
+
56
+ assert "if (latex) processedContent = convertLatexDelimiters(processedContent)" in source
57
+ assert "renderLatexElements(contentDiv)" in source
58
+ assert "globalThis.katex.render(element.textContent, element" in source
59
+ assert 'displayMode: element.dataset.display === "true"' in source
60
+ assert "drawKvpsIncremental(stepDetailScroll, kvps)" in source
61
+ assert "drawKvpsIncremental(stepDetailScroll, kvps, latex)" not in source
62
+ assert "if (result.kvpsTable) renderLatexText(result.kvpsTable)" in source
63
+ assert "globalThis.renderMathInElement(container" in source
webui/js/AGENTS.md
+3
@@ -15,6 +15,7 @@
15
- `modals.js` owns the stacked modal shell, `openModal`, `closeModal`, `scrollModal`, footer relocation, backdrop, and modal z-index behavior.
16
- `surfaces.js` owns shared surface registration, right-canvas/modal mode routing, surface modal action rails, and reusable draggable/focus modal chrome.
17
- `initFw.js` owns Alpine bootstrap and custom lifecycle directives such as `x-create`, `x-destroy`, and periodic `x-every-*` hooks.
18
+- `messages.js` owns native message/process-step rendering, safe Markdown and HTML conversion, and KaTeX delimiter handling.
19
- Other modules own focused UI utilities such as modals, messages, safe markdown, shortcuts, TTS/STT, surfaces, and initialization.
20
21
## Local Contracts
@@ -40,6 +41,7 @@
41
- Every `<x-component>` instance must await cached module-load promises before markup is appended so Alpine bindings only run after imported stores exist.
42
- Frontend extension hooks such as `confirm_dialog_after_render` and `get_tool_message_handler` must preserve their mutable context contracts.
43
- Sanitize or safely render user/model-provided HTML and markdown.
44
+- Convert standard TeX delimiters before Markdown parsing without touching inline or fenced code. Keep thought-card math rendering local to the agent-message handler rather than adding math flags to generic process-step or key/value rendering.
45
- Do not expose secrets in localStorage, console logs, URLs, or WebSocket payloads.
46
- Full message snapshots that start at backend log `no` 0 must replace the current message DOM before rendering; incremental snapshots should keep patching existing messages.
47
@@ -59,6 +61,7 @@
61
## Verification
62
63
- Run targeted frontend/WebUI tests when available.
64
+- For message math changes, smoke-test both response Markdown and agent thought cards with inline and display TeX.
65
- Manually smoke-test startup, API calls, WebSocket state sync, and affected UI flows after infrastructure changes.
66
- For modal infrastructure, verify duplicate paths can stack, missing paths stay closable, Escape closes only the top modal, and click-outside requires both mouse down and mouse up on the overlay container.
67
webui/js/messages.js
+42
-17
@@ -696,7 +696,7 @@ export function _drawMessage({
696
const scroller = new Scroller(bodyDiv, { smooth: !isMassRender() });
697
698
// Handle KVPs incrementally
699
- drawKvpsIncremental(bodyDiv, kvps, false);
699
+ drawKvpsIncremental(bodyDiv, kvps);
700
701
// Handle content
702
if (content && content.trim().length > 0) {
@@ -715,6 +715,7 @@ export function _drawMessage({
715
// }
716
717
let processedContent = content;
718
+ if (latex) processedContent = convertLatexDelimiters(processedContent);
719
processedContent = convertImageTags(processedContent);
720
processedContent = convertImgFilePaths(processedContent);
721
processedContent = convertFilePaths(processedContent);
@@ -732,11 +733,7 @@ export function _drawMessage({
733
734
// KaTeX rendering for markdown
735
if (latex) {
735
- contentDiv.querySelectorAll("latex").forEach((element) => {
736
- globalThis.katex.render(element.innerHTML, element, {
737
- throwOnError: false,
738
- });
739
- });
736
+ renderLatexElements(contentDiv);
737
}
738
739
adjustMarkdownRender(contentDiv);
@@ -854,7 +851,7 @@ export function drawMessageAgent({
851
);
852
}
853
857
- return drawProcessStep({
854
+ const result = drawProcessStep({
855
id,
856
title,
857
code: "GEN",
@@ -863,6 +860,8 @@ export function drawMessageAgent({
860
actionButtons,
861
log: arguments[0],
862
});
863
+ if (result.kvpsTable) renderLatexText(result.kvpsTable);
864
+ return result;
865
}
866
867
/**
@@ -1548,7 +1547,7 @@ export function drawMessageError({
1547
return { element };
1548
}
1549
1551
-function drawKvpsIncremental(container, kvps, latex) {
1550
+function drawKvpsIncremental(container, kvps) {
1551
// existing KVPS table
1552
let table = container.querySelector(".msg-kvps");
1553
if (kvps) {
@@ -1644,15 +1643,6 @@ function drawKvpsIncremental(container, kvps, latex) {
1643
const span = document.createElement("p");
1644
span.innerHTML = convertHTML(value);
1645
tdiv.appendChild(span);
1647
-
1648
- // KaTeX rendering for markdown
1649
- if (latex) {
1650
- span.querySelectorAll("latex").forEach((element) => {
1651
- globalThis.katex.render(element.innerHTML, element, {
1652
- throwOnError: false,
1653
- });
1654
- });
1655
- }
1646
}
1647
}
1648
} else {
@@ -1696,6 +1686,41 @@ function convertHTML(str) {
1686
return result;
1687
}
1688
1689
+function convertLatexDelimiters(content) {
1690
+ return content.replace(
1691
+ /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)|\\\[([\s\S]*?)\\\]|\\\(([\s\S]*?)\\\)|\$\$([\s\S]*?)\$\$/g,
1692
+ (match, code, display, inline, dollars) => {
1693
+ if (code) return code;
1694
+ const tex = display ?? inline ?? dollars;
1695
+ const displayAttribute =
1696
+ display !== undefined || dollars !== undefined
1697
+ ? ' data-display="true"'
1698
+ : "";
1699
+ const encodedTex = Array.from(
1700
+ tex.trim(),
1701
+ (char) => `&#${char.codePointAt(0)};`,
1702
+ ).join("");
1703
+ return `<latex${displayAttribute}>${encodedTex}</latex>`;
1704
+ },
1705
+ );
1706
+}
1707
+
1708
+function renderLatexElements(container) {
1709
+ container.querySelectorAll("latex").forEach((element) => {
1710
+ globalThis.katex.render(element.textContent, element, {
1711
+ displayMode: element.dataset.display === "true",
1712
+ throwOnError: false,
1713
+ });
1714
+ });
1715
+}
1716
+
1717
+function renderLatexText(container) {
1718
+ globalThis.renderMathInElement(container, {
1719
+ throwOnError: false,
1720
+ errorCallback: () => {},
1721
+ });
1722
+}
1723
+
1724
function convertImgFilePaths(str) {
1725
return str.replace(/img:\/\//g, "/api/image_get?path=");
1726
}