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 }