diff --git a/.gitattributes b/.gitattributes
index e9e15ba7b..8a835e52c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -25,3 +25,6 @@ docker/compose/* text eol=lf
# Apple tools (codesign, plutil) read these: LF only, wherever the repository is cloned
*.plist text eol=lf
+
+# vendored KaTeX (serve/web/vendor): the files are hashed byte for byte (PROVENANCE.json), never convert their line endings.
+serve/web/vendor/** -text
diff --git a/serve/test_web_math.js b/serve/test_web_math.js
new file mode 100644
index 000000000..76ad9e39e
--- /dev/null
+++ b/serve/test_web_math.js
@@ -0,0 +1,170 @@
+// Chat math (serve/web/math.js + math-adapter.js + the vendored KaTeX), run in Node.
+// node serve/test_web_math.js (serve/test_web_math.py runs it too and skips when node is missing)
+//
+// 1. where a formula starts and ends (prices, code, links, half-written formulas);
+// 2. the Markdown step: the model's text is escaped, a formula's source only ever reaches the page escaped;
+// 3. drawing: a stub adapter and a stub DOM (order, cache, stale answers, errors, size limit);
+// 4. safety: the REAL vendored KaTeX with the adapter's options, fed hostile formulas.
+"use strict";
+const assert = require("node:assert/strict");
+const path = require("node:path");
+
+const web = path.join(__dirname, "web");
+const math = require(path.join(web, "math.js"));
+const {split} = math;
+const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
+const unesc = (s) => s.replace(/&(amp|lt|gt|quot|#39);/g, (m, k) => ({amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'"}[k]));
+const sources = (text) => split(text).filter((p) => p.source !== undefined).map((p) => [p.source, p.display]);
+
+const cases = {
+ split_delimiters() {
+ assert.deepEqual(sources("a $x^2$ b"), [["x^2", false]]);
+ assert.deepEqual(sources("a \\(x+1\\) b"), [["x+1", false]]);
+ assert.deepEqual(sources("a $$\\int_0^1 f$$ b"), [["\\int_0^1 f", true]]);
+ assert.deepEqual(sources("a \\[\\sum_i i\\] b"), [["\\sum_i i", true]]);
+ assert.deepEqual(sources("$$\nx = 1\n$$"), [["\nx = 1\n", true]], "display math may span lines");
+ assert.deepEqual(sources("$a$ and $b$"), [["a", false], ["b", false]]);
+ // the text around a formula is kept whole
+ assert.equal(split("a $x$ b").map((p) => p.raw).join(""), "a $x$ b");
+ },
+ split_not_math() {
+ for (const text of ["It costs $5.", "$5 and $10", "from $5 to $10 each", "$ 5 $", "a $ b", "price: $1,000", "cost $5 USD, $6 EUR",
+ "no formula here", "\\$x\\$", "a \\( unfinished", "100$"]) {
+ assert.deepEqual(sources(text), [], text);
+ }
+ assert.deepEqual(sources("`$x$` and ```\n$$y$$\n```"), [], "code is code");
+ assert.deepEqual(sources("```js\nlet a = '$x$'\n```\n$z$"), [["z", false]], "a closed fence ends");
+ assert.deepEqual(sources("~~~\n\\(a\\)\n~~~"), [], "tilde fences too");
+ assert.deepEqual(sources("[x](https://example.com/$a$/b) $c$"), [["c", false]], "a link target is not math");
+ },
+ split_streaming() {
+ const parts = split("see $$x^2 + ");
+ assert.equal(parts.length, 2);
+ assert.equal(parts[1].incomplete, true, "a formula still being written is plain text");
+ assert.equal(parts[1].source, undefined);
+ assert.equal(split("```\n$$never closed").filter((p) => p.source !== undefined).length, 0, "an open fence swallows the rest");
+ },
+ markdown_escapes_everything() {
+ const fmt = (t) => `
${esc(t)}
`;
+ const html = math.markdown("hi there $x\"'$ and ", fmt);
+ assert.ok(html.includes("<b>there</b>") && html.includes("<script>"), "the model's own tags are escaped");
+ assert.ok(!/}", "a < b > c \\& d \" '",
+ "\\text{ }", "\\mathrm{javascript:alert(1)}", "x_{}"]) {
+ const html = draw(src);
+ // the output may contain the text, but only as text: no tag the model wrote exists
+ assert.ok(!/<(img|script|svg|iframe|a)\b/i.test(html.replace(/]*>[\s\S]*?<\/annotation>/g, "")), src + " -> " + html);
+ const tags = (html.match(/<[a-zA-Z][^>]*>/g) || []).join(" ");
+ assert.ok(!/\son\w+\s*=|javascript:|\ssrc\s*=|\shref\s*=/i.test(tags), "no event handler, link or source in any tag: " + src);
+ }
+ // the TeX source is kept in an as escaped text, never as markup
+ const kept = draw(" ");
+ assert.ok(!kept.includes(" draw("\\def\\a{\\a\\a}\\a"), /expansion|Too many/i, "a macro loop is cut");
+ const rule = mathml(draw("\\rule{100em}{100em}"));
+ assert.ok(rule.includes("20em") && !rule.includes("100em"), "a huge rule is cut to maxSize");
+ // macros are not shared between formulas
+ draw("\\gdef\\foo{1}\\foo");
+ assert.throws(() => draw("\\foo"), /Undefined control sequence/);
+ // and a good formula still draws
+ assert.match(draw("\\frac{a}{b} + \\sqrt{x}"), /class="katex"/);
+ },
+};
+
+(async () => {
+ const name = process.argv[2];
+ const run = name ? [[name, cases[name]]] : Object.entries(cases);
+ if (name && !cases[name]) throw new Error("unknown test " + name);
+ for (const [n, fn] of run) { await fn(); console.log("ok " + n); }
+})().catch((e) => { console.error(e); process.exitCode = 1; });
diff --git a/serve/test_web_math.py b/serve/test_web_math.py
new file mode 100644
index 000000000..2c5a2b83b
--- /dev/null
+++ b/serve/test_web_math.py
@@ -0,0 +1,107 @@
+"""Chat math on the served page (serve/web/math.js, math-adapter.js, vendor/katex): opt-in, offline, no scripts from the model."""
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import shutil
+import subprocess
+import unittest
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+from serve.frontend import ChatTemplate
+from serve.server import ByteTokenizer, MockEngine, Service, serve
+
+WEB = Path(__file__).parent / "web"
+KATEX = WEB / "vendor" / "katex"
+
+
+@unittest.skipUnless(shutil.which("node"), "node is not installed")
+class MathInNode(unittest.TestCase):
+ def test_delimiters_escaping_drawing_and_hostile_formulas(self):
+ r = subprocess.run([shutil.which("node"), str(Path(__file__).with_name("test_web_math.js"))],
+ capture_output=True, text=True, timeout=120)
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+
+class Vendored(unittest.TestCase):
+ def test_the_files_are_the_ones_in_provenance(self):
+ prov = json.loads((KATEX / "PROVENANCE.json").read_text(encoding="utf-8"))
+ self.assertEqual((prov["package"], prov["version"], prov["license"]), ("katex", "0.19.0", "MIT"))
+ self.assertTrue(prov["integrity"].startswith("sha512-"))
+ on_disk = {p.relative_to(KATEX).as_posix() for p in KATEX.rglob("*") if p.is_file() and p.name != "PROVENANCE.json"}
+ self.assertEqual(on_disk, set(prov["sha256"]), "a file was added or removed without PROVENANCE.json")
+ for name, digest in prov["sha256"].items():
+ with self.subTest(name=name):
+ self.assertEqual(hashlib.sha256((KATEX / name).read_bytes()).hexdigest(), digest)
+ self.assertTrue((KATEX / "LICENSE").read_text(encoding="utf-8").startswith("The MIT License"))
+
+ def test_it_works_offline(self):
+ css = (KATEX / "dist" / "katex.min.css").read_text(encoding="utf-8")
+ fonts = set(re.findall(r"url\(fonts/([\w-]+\.woff2)\)", css))
+ self.assertEqual(len(fonts), 20)
+ for font in fonts:
+ self.assertTrue((KATEX / "dist" / "fonts" / font).is_file(), font)
+ # no address outside this server in anything that loads on the page
+ for f in (WEB / "math.js", WEB / "math-adapter.js", KATEX / "dist" / "katex.min.css"):
+ self.assertNotRegex(f.read_text(encoding="utf-8"), r"https?://(?!www\.w3\.org)", f.name)
+ self.assertNotIn("@import", css)
+
+ def test_the_page_loads_nothing_until_math_is_on(self):
+ html = (WEB / "index.html").read_text(encoding="utf-8")
+ js = (WEB / "app.js").read_text(encoding="utf-8")
+ self.assertIn('src="web/math.js"', html)
+ self.assertIn('src="web/math-adapter.js"', html)
+ self.assertNotIn("katex", html.lower().replace("math-adapter", ""), "KaTeX is not named in the page: the adapter loads it")
+ self.assertIn("math: false", js, "off by default")
+ self.assertIn('id="s-math"', html)
+ self.assertIn('aria-checked="false"', re.search(r']*id="s-math"[^>]*>', html).group(0))
+ adapter = re.sub(r"^\s*//.*$", "", (WEB / "math-adapter.js").read_text(encoding="utf-8"), flags=re.M) # the code, not its comments
+ self.assertIn("trust: false", adapter)
+ self.assertNotIn("innerHTML", adapter)
+ self.assertNotIn("renderToString", adapter, "KaTeX builds DOM nodes; no HTML string goes into the page")
+
+
+class Served(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ tok = ByteTokenizer()
+ cls.svc = Service(MockEngine(tok, "ok"), tok, ChatTemplate(Path(__file__).parent / "chat_template.jinja"))
+ cls.httpd = serve(cls.svc, port=0)
+ cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.httpd.shutdown()
+ cls.httpd.server_close()
+
+ def get(self, path):
+ try:
+ with urllib.request.urlopen(self.base + path, timeout=10) as r:
+ return r.status, r.headers.get("Content-Type", ""), r.read()
+ except urllib.error.HTTPError as e:
+ return e.code, e.headers.get("Content-Type", ""), e.read()
+
+ def test_scripts_style_and_fonts_are_served(self):
+ for path, want in (("/web/math.js", "javascript"), ("/web/math-adapter.js", "javascript"),
+ ("/web/vendor/katex/dist/katex.min.js", "javascript"),
+ ("/web/vendor/katex/dist/katex.min.css", "text/css"),
+ ("/web/vendor/katex/dist/fonts/KaTeX_Main-Regular.woff2", "font/woff2")):
+ with self.subTest(path=path):
+ code, ctype, body = self.get(path)
+ self.assertEqual(code, 200)
+ self.assertIn(want, ctype)
+ self.assertTrue(body)
+
+ def test_nothing_else_below_vendor(self):
+ for path in ("/web/vendor/katex/PROVENANCE.json", "/web/vendor/katex/LICENSE", "/web/vendor/katex/dist/fonts/x.ttf",
+ "/web/vendor/katex/../../app.js", "/web/vendor/", "/web/vendor/katex/dist/fonts",
+ "/web/vendor/katex/dist/missing.js"):
+ with self.subTest(path=path):
+ self.assertEqual(self.get(path)[0], 404)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/serve/web/app.css b/serve/web/app.css
index 1191b0418..e899121be 100644
--- a/serve/web/app.css
+++ b/serve/web/app.css
@@ -221,3 +221,10 @@ main { flex: 1; min-height: 0; display: flex; }
.img-results figure{margin:0}
.img-results img{max-width:100%;border-radius:var(--st-r-lg);display:block}
.img-results figcaption{font-size:var(--st-fs-sm, 13px);color:var(--st-ink-2, inherit);margin-top:4px}
+
+/* Math (serve/web/math.js): a formula never widens the chat; a long display formula scrolls inside its own box. */
+.chat-math { overflow-wrap: normal; }
+.chat-math--display { display: block; text-align: center; overflow-x: auto; max-width: 100%; margin: .6em 0; padding: .25em 0; }
+.chat-math--display > * { margin: 0 !important; }
+.chat-math[data-math-error] { white-space: pre-wrap; }
+.chat-math .katex { font-size: 1.08em; }
diff --git a/serve/web/app.js b/serve/web/app.js
index b3448d92b..ef81c9bb4 100644
--- a/serve/web/app.js
+++ b/serve/web/app.js
@@ -573,7 +573,7 @@ function markdown(text) {
// fields: the server picks the card's set for the request's thinking state (thinking 1.0 / 0.95 / 20, not thinking
// 0.7 / 0.8 / 20 with a presence penalty). Before 0.1.45 the page sent 0.6 itself; a stored 0.6 with the other two
// values untouched counts as "not set" too.
-const DEFAULTS = {thinking: "high", temperature: 1.0, top_p: 0.95, top_k: 20, max: "", seed: "", show: true, esp: true, mcp: true};
+const DEFAULTS = {thinking: "high", temperature: 1.0, top_p: 0.95, top_k: 20, max: "", seed: "", show: true, esp: true, mcp: true, math: false};
const OLD_DEFAULT_TEMPERATURE = 0.6;
function samplingUntouched(s) {
const t = +s.temperature;
@@ -658,16 +658,20 @@ function toolHtml(t, k) {
`${icon("chevron", "st-icon st-icon--sm st-chev")}${body}
`;
}
// the answer's text with the tool blocks where the model called them
+// math (opt-in, serve/web/math.js): formulas become placeholders before the Markdown runs and are drawn after it
+function answerMarkdown(text) {
+ return settings.math ? StrataMath.markdown(text, markdown) : markdown(text);
+}
function answerHtml(m) {
- if (!m.tools || !m.tools.length) return markdown(m.text || "");
+ if (!m.tools || !m.tools.length) return answerMarkdown(m.text || "");
let html = "", pos = 0;
m.tools.forEach((t, k) => {
const at = Math.min(Math.max(t.at || 0, pos), m.text.length);
- if (at > pos) html += markdown(m.text.slice(pos, at));
+ if (at > pos) html += answerMarkdown(m.text.slice(pos, at));
pos = at;
html += toolHtml(t, k);
});
- return html + markdown(m.text.slice(pos));
+ return html + answerMarkdown(m.text.slice(pos));
}
// a tool event from the stream (the `strata_mcp` field of a chunk)
function onTool(m, x) {
@@ -704,6 +708,7 @@ function updateAssistant(el, m, streaming) {
bubble.innerHTML = m.reasoning ? `Writing ` : ` `;
} else {
bubble.innerHTML = answerHtml(m);
+ if (settings.math) StrataMath.hydrate(bubble);
if (streaming) bubble.classList.add("cursor"); else bubble.classList.remove("cursor");
}
el.querySelector(".meta-text").textContent = m.meta || (streaming ? "" : m.stopped ? "Stopped" : "");
@@ -1013,6 +1018,7 @@ function loadDrawer(s = settings) {
for (const b of $("s-thinking").children) b.setAttribute("aria-checked", String(b.dataset.v === s.thinking));
$("s-temp").value = s.temperature; $("s-topp").value = s.top_p; $("s-topk").value = s.top_k;
$("s-max").value = s.max; $("s-seed").value = s.seed;
+ $("s-math").setAttribute("aria-checked", String(!!s.math));
$("s-show").setAttribute("aria-checked", String(!!s.show));
$("s-esp").setAttribute("aria-checked", String(s.esp !== false));
$("esp-row").hidden = !projectionLoaded();
@@ -1064,6 +1070,7 @@ function outputs() {
}
for (const b of $("s-thinking").children) b.onclick = () => { for (const x of $("s-thinking").children) x.setAttribute("aria-checked", String(x === b)); outputs(); };
for (const id of ["s-temp", "s-topp", "s-topk"]) $(id).oninput = outputs;
+$("s-math").onclick = () => $("s-math").setAttribute("aria-checked", String($("s-math").getAttribute("aria-checked") !== "true"));
$("s-show").onclick = () => $("s-show").setAttribute("aria-checked", String($("s-show").getAttribute("aria-checked") !== "true"));
$("s-esp").onclick = () => $("s-esp").setAttribute("aria-checked", String($("s-esp").getAttribute("aria-checked") !== "true"));
$("s-mcp").onclick = () => $("s-mcp").setAttribute("aria-checked", String($("s-mcp").getAttribute("aria-checked") !== "true"));
@@ -1073,10 +1080,16 @@ $("s-apply").onclick = async () => {
const sel = [...$("s-thinking").children].find((b) => b.getAttribute("aria-checked") === "true");
settings = {thinking: sel ? sel.dataset.v : "high", temperature: +$("s-temp").value, top_p: +$("s-topp").value,
top_k: +$("s-topk").value, max: $("s-max").value.trim(), seed: $("s-seed").value.trim(),
+ math: $("s-math").getAttribute("aria-checked") === "true",
show: $("s-show").getAttribute("aria-checked") === "true",
esp: $("s-esp").getAttribute("aria-checked") === "true",
mcp: $("s-mcp").getAttribute("aria-checked") === "true"};
store.set("sampling", settings);
+ StrataMath.reset(); // math on or off: every answer is drawn again
+ for (const el of $("chat").querySelectorAll(".st-msg--assistant")) {
+ const m = messages[+el.dataset.i];
+ updateAssistant(el, m, !!busy && busy.msg === m); // the element send() holds stays the same while an answer streams
+ }
const share = $("s-share").getAttribute("aria-checked") === "true";
openDrawer(false);
if (share || sharedOn) {
diff --git a/serve/web/index.html b/serve/web/index.html
index 0fdb4e6ed..f08271ba1 100644
--- a/serve/web/index.html
+++ b/serve/web/index.html
@@ -248,6 +248,9 @@
Seed empty = random
+ Render math draw equations in answers as formulas (works offline)
+
Show thinkingexpanded while it streams
Use tools from MCP servers
+
+