diff --git a/docs/webui.md b/docs/webui.md index 563a141cc..9c1571b0e 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -500,6 +500,38 @@ implementation accident: The `mermaid` dependency (11.12.1, MIT) is recorded in `release/dependency-licenses.json`. +## Math formulas in Markdown (KaTeX) + +Assistant messages and Markdown file previews also render math formulas, +in the same pipeline as Mermaid diagrams. Three input shapes are math; +every other use of the dollar sign stays prose: + +| Shape | Written as | Rendered as | Backed by | +| --- | --- | --- | --- | +| Inline | `$E=mc^2$` | KaTeX markup inside the paragraph | `webapp/lib/math-renderer.ts` (the marked `webuiMath` inline extension) | +| Display | `$$\frac{a}{b}$$` | A centred block (`.katex-display`) | the same tokenizer, `displayMode: true` | +| Fence | ```` ```math ```` | A centred block, dispatched through the language→renderer registry — the same seam `mermaid` uses, so neither fence language can shadow the other | `webapp/lib/math-renderer.ts` (`registerMathRenderer`) | + +| Aspect | Contract | Backed by | +| --- | --- | --- | +| False positives | A single `$` is math only when a closing `$` exists, the body stays on one line, and the body does not start with a digit: `costs $5 and $10`, `$HOME`, an unclosed `$\frac{` all stay prose | `webapp/lib/math-renderer.ts` (tokenizer guard) | +| Invalid formula | An input KaTeX cannot parse degrades to the **original source as code** — inline/display shapes become `raw`, a ```math fence falls back to the plain codeblock shell (the registry's existing throw path). The rest of the document is unaffected; the page never blanks | `webapp/lib/math-renderer.ts`, `webapp/lib/markdown.ts` (`safeLanguageRenderer`) | +| Sanitiser surface | KaTeX runs with `output: "html"` and emits only `span`, `svg`, `path`. The allowlist admits exactly those tags; `svg` keeps a fixed attribute set (`xmlns`, `width`, `height`, `viewBox`, `preserveAspectRatio`, `class`) with no `href`-like attribute, and ``/MathML stays a DROP tag — which is precisely why HTML-only output is configured. Inline `style` survives only on `span` and only when the value clears `isSafeStyleValue`: no parentheses rules out `url(...)`/`expression(...)`, and `position`/`background`/`behavior` are refused outright | `webapp/lib/markdown.ts` (`ALLOWED_TAGS`, `ALLOWED_ATTRS`, `isSafeStyleValue`) | +| React tree | The style attribute reaches React as a parsed object (`parseInlineStyle`), because React rejects a string `style` prop outright — passing it through would silently drop all KaTeX layout | `webapp/lib/markdown.ts` (`parseInlineStyle`), `components/markdown-html.tsx` | +| Trust | KaTeX `trust` stays `false`: `\href` renders as a red warning text node, never a link, so no URL can enter the DOM through a formula | `webapp/lib/math-renderer.ts` (`KATEX_OPTIONS`) | +| Theme | Formulas are inheriting text plus CSS transforms; they need no per-theme re-render (unlike Mermaid, which repaints on the theme flip) and pick up both themes' text colours from the design tokens | `webapp/styles/katex.css` | +| CSS + fonts | `webapp/styles/katex.css` is vendored from `katex/dist/katex.min.css` (the same version as the `katex` devDependency) with the `@font-face` sources repointed at the vendored fonts in `webapp/public/fonts/katex/` (60 font files + the MIT license notice). The stylesheet is loaded unconditionally from `app/layout.tsx` (~24 KB); fonts are served from `/fonts/katex/…` | `app/layout.tsx`, `webapp/styles/katex.css`, `webapp/public/fonts/katex/` | +| Bundle cost | `katex` JS is in the client bundle, not lazy-loaded the way Mermaid is: the math pipeline is synchronous string rendering (`renderToString`), and an inline `$…$` can appear mid-sentence. Accepted as a known cost; the lazy-load lever exists only if bundle budgets demand it | `webapp/lib/math-renderer.ts` | + +Upgrading `katex` regenerates both halves in the same commit: replace the +files in `webapp/public/fonts/katex/` from the new `dist/fonts/`, and +regenerate the stylesheet with +`sed 's|url(fonts/|url(/fonts/katex/|g' node_modules/katex/dist/katex.min.css +> webapp/styles/katex.css`. + +The `katex` dependency (0.18.7, MIT) is recorded in +`release/dependency-licenses.json`. + ## File preview toolbar and Markdown outline (slice 27) The preview component's header carries three controls, and the Markdown diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index a2c5dc137..44564d830 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -429,6 +429,51 @@ flowchart LR 依赖:`mermaid` 11.12.1(MIT),已登记于 `release/dependency-licenses.json`。 +## Markdown 里的数学公式(KaTeX) + +助手回复和 Markdown 文件预览现在能渲染数学公式,与 Mermaid 图走同一条 +渲染管线。三种写法会被当作公式,其余出现的美元符号一律按普通文本处理: + +| 写法 | 示例 | 效果 | +| --- | --- | --- | +| 行内公式 | `$E=mc^2$` | 段落内排版成公式,随正文流动 | +| 块级公式 | `$$\frac{a}{b}$$` | 居中独立成块显示 | +| 代码块 | 语言位置写 `math` 的代码围栏 | 同块级公式,与 `mermaid` 围栏走同一个分发注册表,互不影响 | + +**你会得到什么** + +- 上述三种写法都排版成真正的数学公式(分数、根号、求和号、矩阵等), + 渲染引擎是 KaTeX(`webapp/lib/math-renderer.ts`)。 +- 公式跟随界面浅色/深色主题,两种主题下对比度都正常——公式就是普通 + 继承文字色的内容,切换主题不需要重画。 +- 排版字体(KaTeX 字体)随应用自带,不依赖系统装没装数学字体。 + +**什么不会误伤** + +- `成本 $5 and $10`、`$HOME`、没写闭合 `$` 的片段——都按普通文本原样 + 显示。单个 `$` 只有在存在闭合 `$`、内容不跨行、且不以数字开头时才 + 被当作公式。 + +**公式写错了会怎样** + +- 该公式**降级为代码样式显示原始写法**(行内公式显示为行内代码,`math` + 代码块显示为普通代码块),原文一个字符都不丢,页面照常渲染,不会 + 白屏(`webapp/lib/markdown.ts` 的降级路径)。 +- 公式里无法夹带链接:`\href` 之类的可信功能默认关闭,只会显示成红色 + 警示文字,不会变成可点的 URL。 + +**限制** + +- 公式排版样式与字体文件随应用打包(样式表约 24 KB,按需加载字体重 + 量很小);JS 渲染库进入前端主包(gzip 约 90 KB),不像 Mermaid 那样 + 懒加载——行内公式可能出现在句子中间,同步渲染是正确性前提。这是 + 记录在案的成本,若前端体积预算吃紧再评估懒加载方案。 +- 升级 KaTeX 版本时需要同步更新两处:`webapp/public/fonts/katex/` 下的 + 字体文件与 `webapp/styles/katex.css`(由 `katex/dist/katex.min.css` 改写 + 字体路径生成,方法记录在英文文档同节)。 + +依赖:`katex` 0.18.7(MIT),已登记于 `release/dependency-licenses.json`。 + ## 文件预览工具栏与 Markdown 大纲(slice 27) 预览组件顶栏新增三个控件,Markdown 预览增加大纲面板。这也是 diff --git a/packages/webui/docs/CAPABILITIES.md b/packages/webui/docs/CAPABILITIES.md index fec826bbf..4c1c4b2db 100644 --- a/packages/webui/docs/CAPABILITIES.md +++ b/packages/webui/docs/CAPABILITIES.md @@ -62,6 +62,7 @@ single index that satisfies the check. | Markdown rendering (headings, lists, code) | ✅ | `webapp/lib/markdown.ts` wrapping the workspace `marked` package (`packages/tui` already depends on it; no CDN) | | Syntax highlighting in code blocks | ✅ | `marked` code renderer (`webapp/lib/markdown.ts`) + CSS classes from `webapp/styles/official-utilities.css` | | Mermaid diagrams in markdown (slice 23) | ✅ | a fenced code block whose language token is `mermaid` renders as a diagram instead of a code block. Fence languages dispatch through the language→renderer registry (`webapp/lib/markdown.ts#registerLanguageRenderer`, lines 107-113); mermaid self-registers on import (`webapp/lib/mermaid-renderer.ts:64-71`), and a renderer for any other fence language plugs into the same seam — the markdown main flow never branches on a language name. The chart library is `import()`-ed on the first diagram of a page (`webapp/components/mermaid-block.tsx:62-66`) and its chunk is served with a one-year immutable cache (`server/lib/static.js:66`); a page without a mermaid fence never requests it. Diagrams follow the light/dark theme (`components/markdown-html.tsx:52-66` re-renders on the `` class flip; `components/mermaid-block.tsx:156-166,223-229` re-initialises mermaid per theme). A syntax error renders a legible failure card — error text plus the original source in a copyable `` (`components/mermaid-block.tsx:285-307`) — while the rest of the document renders normally. Limits: diagrams scale to the column width and scroll when wider (`webapp/styles/mermaid.css:62-80`); CJK labels render via the font stack at `components/mermaid-block.tsx:109`; a diagram is never a heading, so it appears in no heading-derived outline (the fence emits a ``/`` pair, not `h1`-`h6` — `lib/mermaid-renderer.ts:44-57`). Dependency `mermaid` 11.12.1 (MIT) is recorded in `release/dependency-licenses.json`. | +| Math formulas via KaTeX (inline `$…$`, display `$$…$$`, ```` ```math ```` fences) | ✅ | the same pipeline as Mermaid: an inline marked extension (`webuiMath`) tokenises `$…$`/`$$…$$` and the ```math fence dispatches through the language→renderer registry (`webapp/lib/math-renderer.ts`, self-registered on import), so neither fence language can shadow the other. A single `$` is math only with a closing delimiter on one line and a non-digit start — `costs $5 and $10`, `$HOME`, unclosed `$` stay prose. A formula KaTeX cannot parse degrades to the original source as code (inline ``, fence → the plain codeblock shell) — the page never blanks. KaTeX runs with `output: "html"` (emits only `span`/`svg`/`path`, which the sanitiser allowlist admits with a fixed attribute set; ``/MathML stays a DROP tag) and `trust: false` (`\href` renders as red warning text, never a link). Inline `style` survives only on `span` and only when the value clears `isSafeStyleValue` — no parentheses (no `url()`/`expression()`), and `position`/`background`/`behavior` are refused; `components/markdown-html.tsx` converts the style attribute to a React style object (`parseInlineStyle`), since React rejects a string style prop. CSS is vendored at `webapp/styles/katex.css` (from `katex/dist/katex.min.css`, `@font-face` repointed to `/fonts/katex/…`) and loaded from `app/layout.tsx`; fonts (60 files + MIT notice) are vendored at `webapp/public/fonts/katex/`. Formulas are inheriting text — both themes work without a re-render. Known cost: `katex` JS ships in the client bundle rather than lazy-loading like mermaid (the math pipeline is synchronous `renderToString`). Dependency `katex` 0.18.7 (MIT) is recorded in `release/dependency-licenses.json`. Tests: `webapp/test/markdown-math.test.ts`. | | Preview toolbar: refresh / edit / save (slice 27) | ✅ | the preview header carries ↻ refresh (re-read from disk, scroll position restored; a deleted/renamed file keeps the last content and shows an explicit banner — `components/file-preview.tsx` refresh handler), a 预览/编辑 toggle (text previews only, `lib/preview-edit.ts#canEditPreview`; credential-shaped paths must pass an explicit confirmation card first — `editRequiresCredentialConfirm` on the slice-16 predicate), and ✓ save (never automatic; success shows "saved at HH:MM", failure keeps the buffer and states the server's reason). Saves go through `POST /api/fs/write` (`api.ts#saveFsFile`) carrying the `(expectedMtime, expectedSize)` baseline from the load; a drifted baseline answers a conflict card (overwrite / reload) rather than a silent overwrite. The write answers with the **realpath-normalised absolute path** (the shared gate resolves symlinks before anything else — the same slice-16 form every `/api/fs/*` route returns; on macOS a `/var/...` fixture therefore answers `/private/var/...`). | | Markdown outline panel (slice 27) | ✅ | `webapp/components/markdown-toc.tsx` + `webapp/lib/markdown-toc.ts`. The outline is extracted from the RENDERED DOM (`querySelectorAll("h1,…,h6")`), never a second markdown parse; heading ids are assigned onto those nodes (stable slugs, `-2`/`-3` dedupe suffixes — `headingSlug`/`extractOutline`, pinned by `webapp/test/markdown-toc.test.ts`). A click `preventDefault`s the anchor and smooth-scrolls the heading into view via `scrollIntoView`; the panel is sticky within the scroll viewport (max-height pinned to the viewport's height) and the active entry follows the scroll position. Documents without headings render no panel; a heading nested inside a `.mermaid-block` is excluded defensively (`isOutlineHeading`); entries are readable in both themes via design tokens. Below ~300px of content width the outline hides (observing the stable `.file-preview-body` width, not the markdown host — watching the host creates a show/hide feedback loop) rather than squeezing the document. | | Cancel mid-run | ✅ | acp `session/cancel` is sent as a notification, pinned on the cid's active child (`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`). The hard-kill fallback (`/api/stop` → SIGTERM/SIGKILL) is only used when the notification cannot be delivered. The acp session may emit a few extra events before draining. | diff --git a/packages/webui/docs/CAPABILITIES.zh-CN.md b/packages/webui/docs/CAPABILITIES.zh-CN.md index fadb99c4a..845861a30 100644 --- a/packages/webui/docs/CAPABILITIES.zh-CN.md +++ b/packages/webui/docs/CAPABILITIES.zh-CN.md @@ -53,6 +53,7 @@ CI 会对上述每一个名称是否出现在本文档中进行断言 | Markdown 渲染(标题、列表、代码) | ✅ | `lib/marked.min.js` 本地内置(不走 CDN) | | 代码块语法高亮 | ✅ | highlight.js(本地副本) | | Markdown 内 Mermaid 图渲染(slice 23) | ✅ | 代码围栏语言写 `mermaid` 即渲染为图,而非代码块。围栏语言经"语言→渲染器"注册表分发(`webapp/lib/markdown.ts` 的 `registerLanguageRenderer`,107-113 行);mermaid 在模块导入时自注册(`webapp/lib/mermaid-renderer.ts:64-71`),其他语言的渲染器可经同一接缝接入——markdown 主流程不针对语言名写分支。图表库在页面首张图出现时才动态加载(`components/mermaid-block.tsx:62-66`),该文件带一年 immutable 强缓存(`server/lib/static.js:66`);没有 mermaid 围栏的页面完全不加载。图跟随浅色/深色主题(`components/markdown-html.tsx:52-66` 监听 `` class;`components/mermaid-block.tsx:156-166、223-229` 按主题重新初始化)。语法错误时渲染可读的失败卡片——错误信息 + 可复制的原始源码(`components/mermaid-block.tsx:285-307`)——文档其余部分照常渲染。限制:图按列宽缩放、超宽时在卡片内横向滚动(`webapp/styles/mermaid.css:62-80`);中文标签经字体栈正常显示(`components/mermaid-block.tsx:109`);图不产生标题,因此不会进入任何按标题组织的大纲(围栏只产出 ``/`` 占位对,不产出 `h1`-`h6`,见 `lib/mermaid-renderer.ts:44-57`)。依赖 `mermaid` 11.12.1(MIT)已登记于 `release/dependency-licenses.json`。 | +| Markdown 数学公式(KaTeX,行内 `$…$`、块级 `$$…$$`、```` ```math ```` 代码块) | ✅ | 与 Mermaid 同一条管线:行内经 marked 扩展 `webuiMath` 识别 `$…$`/`$$…$$`,`math` 代码块经语言→渲染器注册表分发(`webapp/lib/math-renderer.ts`,导入时自注册),两类围栏互不干扰。单个 `$` 仅在存在同 行闭合定界符、内容不以数字开头时才视为公式——`成本 $5 and $10`、`$HOME`、未闭合的 `$` 均按普通文本显示。公式无法解析时降级为原始写法的代码样式(行内降级 ``,代码块降级为普通代码块),页面绝不白屏。KaTeX 以 `output: "html"` 运行(只产出 `span`/`svg`/`path`,清洗白名单按固定属性集放行;``/MathML 仍为整体丢弃标签),`trust: false`(`\href` 只显示红色警示文字,不会成为链接)。内联 `style` 仅在 `span` 上保留且值须通过 `isSafeStyleValue` 校验——禁止括号(杜绝 `url()`/`expression()`),`position`/`background`/`behavior` 直接拒绝;`components/markdown-html.tsx` 将 style 属性解析为 React 样式对象(`parseInlineStyle`,React 不接受字符串 style)。样式表 vendored 于 `webapp/styles/katex.css`(源自 `katex/dist/katex.min.css`,`@font-face` 指向 `/fonts/katex/…`),由 `app/layout.tsx` 加载;字体(60 个文件 + MIT 许可声明)vendored 于 `webapp/public/fonts/katex/`。公式为继承文字色的内容,深浅主题均正常、无需重渲染。已知成本:`katex` JS 随前端主包加载,不像 mermaid 懒加载(数学管线是同步 `renderToString`)。依赖 `katex` 0.18.7(MIT)已登记于 `release/dependency-licenses.json`。测试:`webapp/test/markdown-math.test.ts`。 | | 运行中取消 | ✅ | acp `session/cancel` 以 notification 形式发送,并钉在该 cid 的活动子进程上(`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`)。只有当 notification 无法投递时,才会走硬杀兜底(`/api/stop` → SIGTERM/SIGKILL)。acp 会话在排空前可能还会再发出几个事件。 | | 回退 / 分叉某条消息 | ⚠ | 引擎已实现 `session/fork` 和 `session/resume`(`MCODE_ACP_CAPABILITIES.fork / .resume = true`),但目前 webui 还没有路由暴露它们——参见 [§13](CAPABILITIES.zh-CN.md#13-要启用--行-mcode-需要增加什么)。 | | 编辑已发送的消息并重新发送 | ❌ | acp 协议未暴露 | diff --git a/packages/webui/package.json b/packages/webui/package.json index 44fbbdf91..f21d60f8c 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -34,6 +34,7 @@ "antd": "5.29.3", "autoprefixer": "10.6.1", "highlight.js": "10.7.3", + "katex": "0.18.7", "marked": "18.0.12", "mermaid": "11.12.1", "next": "14.2.35", @@ -90,7 +91,7 @@ }, { "name": "lan-sharing", - "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in \u2014 the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." + "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in — the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." }, { "name": "token-auth", diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx index dfeab1b2a..b7f13fb56 100644 --- a/packages/webui/webapp/app/layout.tsx +++ b/packages/webui/webapp/app/layout.tsx @@ -11,6 +11,12 @@ import "../styles/mavis-dropdown.css"; import "../styles/desktop-typography.css"; import "../styles/code-preview.css"; import "../styles/mermaid.css"; +// KaTeX stylesheet, vendored from `katex/dist/katex.min.css` (same version as +// the `katex` devDependency) with the @font-face sources repointed at the +// vendored fonts in `public/fonts/katex/`. Loaded unconditionally: it is +// ~24 KB and the formulas' geometry classes must exist before any message +// with math renders. +import "../styles/katex.css"; export const metadata: Metadata = { title: "MiniMax Code", diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index 27863a037..13f32b105 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -6,6 +6,7 @@ import * as api from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import { MarkdownHtml } from "./markdown-html"; import "../lib/mermaid-renderer"; // registers the mermaid language renderer +import "../lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { reportActionError } from "@/lib/action-errors"; import { findSubagentForBlock } from "@/lib/agent-team-lookup"; import { badgeLabelAndGlyph, agentLabel } from "@/lib/i18n-agent-team"; diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index 84b94a370..534eb3150 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import "@/lib/mermaid-renderer"; // registers the mermaid language renderer +import "@/lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { MarkdownHtml } from "@/components/markdown-html"; import { CodeView as IdeCodeView } from "@/components/code-view"; import { diff --git a/packages/webui/webapp/components/markdown-html.tsx b/packages/webui/webapp/components/markdown-html.tsx index cbc41f5c8..e7f7608b5 100644 --- a/packages/webui/webapp/components/markdown-html.tsx +++ b/packages/webui/webapp/components/markdown-html.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { MermaidBlock } from "./mermaid-block"; +import { parseInlineStyle } from "../lib/markdown"; /** * Render pre-sanitised HTML and mount mermaid blocks as real React @@ -157,6 +158,12 @@ function htmlToReact(html: string, theme: "light" | "dark"): ReactNode { const name = attr.name.toLowerCase(); if (name === "class") { props.className = attr.value; + } else if (name === "style") { + // KaTeX layout lives in inline styles, and React requires the + // style prop as an object — a string is rejected with a console + // error and the styles never apply. The value was already vetted + // by the sanitiser's `isSafeStyleValue`; this only reshapes it. + props.style = parseInlineStyle(attr.value); } else { props[attr.name] = attr.value; } diff --git a/packages/webui/webapp/lib/markdown.ts b/packages/webui/webapp/lib/markdown.ts index 69778f1e2..1889907f5 100644 --- a/packages/webui/webapp/lib/markdown.ts +++ b/packages/webui/webapp/lib/markdown.ts @@ -211,6 +211,12 @@ export const ALLOWED_TAGS = new Set([ "p", "br", "hr", "strong", "em", "del", "code", "pre", "blockquote", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "a", "table", "thead", "tbody", "tr", "th", "td", + // KaTeX HTML output is built exclusively from `span`, `svg` and `path` + // (`lib/math-renderer.ts`). `svg` is therefore removed from the DROP set + // below, and its subtree is walked like any other element: a `}$"); + assert.doesNotMatch(html, /
raw
` (`components/mermaid-block.tsx:285-307`) — while the rest of the document renders normally. Limits: diagrams scale to the column width and scroll when wider (`webapp/styles/mermaid.css:62-80`); CJK labels render via the font stack at `components/mermaid-block.tsx:109`; a diagram is never a heading, so it appears in no heading-derived outline (the fence emits a ``/`` pair, not `h1`-`h6` — `lib/mermaid-renderer.ts:44-57`). Dependency `mermaid` 11.12.1 (MIT) is recorded in `release/dependency-licenses.json`. | +| Math formulas via KaTeX (inline `$…$`, display `$$…$$`, ```` ```math ```` fences) | ✅ | the same pipeline as Mermaid: an inline marked extension (`webuiMath`) tokenises `$…$`/`$$…$$` and the ```math fence dispatches through the language→renderer registry (`webapp/lib/math-renderer.ts`, self-registered on import), so neither fence language can shadow the other. A single `$` is math only with a closing delimiter on one line and a non-digit start — `costs $5 and $10`, `$HOME`, unclosed `$` stay prose. A formula KaTeX cannot parse degrades to the original source as code (inline ``, fence → the plain codeblock shell) — the page never blanks. KaTeX runs with `output: "html"` (emits only `span`/`svg`/`path`, which the sanitiser allowlist admits with a fixed attribute set; ``/MathML stays a DROP tag) and `trust: false` (`\href` renders as red warning text, never a link). Inline `style` survives only on `span` and only when the value clears `isSafeStyleValue` — no parentheses (no `url()`/`expression()`), and `position`/`background`/`behavior` are refused; `components/markdown-html.tsx` converts the style attribute to a React style object (`parseInlineStyle`), since React rejects a string style prop. CSS is vendored at `webapp/styles/katex.css` (from `katex/dist/katex.min.css`, `@font-face` repointed to `/fonts/katex/…`) and loaded from `app/layout.tsx`; fonts (60 files + MIT notice) are vendored at `webapp/public/fonts/katex/`. Formulas are inheriting text — both themes work without a re-render. Known cost: `katex` JS ships in the client bundle rather than lazy-loading like mermaid (the math pipeline is synchronous `renderToString`). Dependency `katex` 0.18.7 (MIT) is recorded in `release/dependency-licenses.json`. Tests: `webapp/test/markdown-math.test.ts`. | | Preview toolbar: refresh / edit / save (slice 27) | ✅ | the preview header carries ↻ refresh (re-read from disk, scroll position restored; a deleted/renamed file keeps the last content and shows an explicit banner — `components/file-preview.tsx` refresh handler), a 预览/编辑 toggle (text previews only, `lib/preview-edit.ts#canEditPreview`; credential-shaped paths must pass an explicit confirmation card first — `editRequiresCredentialConfirm` on the slice-16 predicate), and ✓ save (never automatic; success shows "saved at HH:MM", failure keeps the buffer and states the server's reason). Saves go through `POST /api/fs/write` (`api.ts#saveFsFile`) carrying the `(expectedMtime, expectedSize)` baseline from the load; a drifted baseline answers a conflict card (overwrite / reload) rather than a silent overwrite. The write answers with the **realpath-normalised absolute path** (the shared gate resolves symlinks before anything else — the same slice-16 form every `/api/fs/*` route returns; on macOS a `/var/...` fixture therefore answers `/private/var/...`). | | Markdown outline panel (slice 27) | ✅ | `webapp/components/markdown-toc.tsx` + `webapp/lib/markdown-toc.ts`. The outline is extracted from the RENDERED DOM (`querySelectorAll("h1,…,h6")`), never a second markdown parse; heading ids are assigned onto those nodes (stable slugs, `-2`/`-3` dedupe suffixes — `headingSlug`/`extractOutline`, pinned by `webapp/test/markdown-toc.test.ts`). A click `preventDefault`s the anchor and smooth-scrolls the heading into view via `scrollIntoView`; the panel is sticky within the scroll viewport (max-height pinned to the viewport's height) and the active entry follows the scroll position. Documents without headings render no panel; a heading nested inside a `.mermaid-block` is excluded defensively (`isOutlineHeading`); entries are readable in both themes via design tokens. Below ~300px of content width the outline hides (observing the stable `.file-preview-body` width, not the markdown host — watching the host creates a show/hide feedback loop) rather than squeezing the document. | | Cancel mid-run | ✅ | acp `session/cancel` is sent as a notification, pinned on the cid's active child (`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`). The hard-kill fallback (`/api/stop` → SIGTERM/SIGKILL) is only used when the notification cannot be delivered. The acp session may emit a few extra events before draining. | diff --git a/packages/webui/docs/CAPABILITIES.zh-CN.md b/packages/webui/docs/CAPABILITIES.zh-CN.md index fadb99c4a..845861a30 100644 --- a/packages/webui/docs/CAPABILITIES.zh-CN.md +++ b/packages/webui/docs/CAPABILITIES.zh-CN.md @@ -53,6 +53,7 @@ CI 会对上述每一个名称是否出现在本文档中进行断言 | Markdown 渲染(标题、列表、代码) | ✅ | `lib/marked.min.js` 本地内置(不走 CDN) | | 代码块语法高亮 | ✅ | highlight.js(本地副本) | | Markdown 内 Mermaid 图渲染(slice 23) | ✅ | 代码围栏语言写 `mermaid` 即渲染为图,而非代码块。围栏语言经"语言→渲染器"注册表分发(`webapp/lib/markdown.ts` 的 `registerLanguageRenderer`,107-113 行);mermaid 在模块导入时自注册(`webapp/lib/mermaid-renderer.ts:64-71`),其他语言的渲染器可经同一接缝接入——markdown 主流程不针对语言名写分支。图表库在页面首张图出现时才动态加载(`components/mermaid-block.tsx:62-66`),该文件带一年 immutable 强缓存(`server/lib/static.js:66`);没有 mermaid 围栏的页面完全不加载。图跟随浅色/深色主题(`components/markdown-html.tsx:52-66` 监听 `` class;`components/mermaid-block.tsx:156-166、223-229` 按主题重新初始化)。语法错误时渲染可读的失败卡片——错误信息 + 可复制的原始源码(`components/mermaid-block.tsx:285-307`)——文档其余部分照常渲染。限制:图按列宽缩放、超宽时在卡片内横向滚动(`webapp/styles/mermaid.css:62-80`);中文标签经字体栈正常显示(`components/mermaid-block.tsx:109`);图不产生标题,因此不会进入任何按标题组织的大纲(围栏只产出 ``/`` 占位对,不产出 `h1`-`h6`,见 `lib/mermaid-renderer.ts:44-57`)。依赖 `mermaid` 11.12.1(MIT)已登记于 `release/dependency-licenses.json`。 | +| Markdown 数学公式(KaTeX,行内 `$…$`、块级 `$$…$$`、```` ```math ```` 代码块) | ✅ | 与 Mermaid 同一条管线:行内经 marked 扩展 `webuiMath` 识别 `$…$`/`$$…$$`,`math` 代码块经语言→渲染器注册表分发(`webapp/lib/math-renderer.ts`,导入时自注册),两类围栏互不干扰。单个 `$` 仅在存在同 行闭合定界符、内容不以数字开头时才视为公式——`成本 $5 and $10`、`$HOME`、未闭合的 `$` 均按普通文本显示。公式无法解析时降级为原始写法的代码样式(行内降级 ``,代码块降级为普通代码块),页面绝不白屏。KaTeX 以 `output: "html"` 运行(只产出 `span`/`svg`/`path`,清洗白名单按固定属性集放行;``/MathML 仍为整体丢弃标签),`trust: false`(`\href` 只显示红色警示文字,不会成为链接)。内联 `style` 仅在 `span` 上保留且值须通过 `isSafeStyleValue` 校验——禁止括号(杜绝 `url()`/`expression()`),`position`/`background`/`behavior` 直接拒绝;`components/markdown-html.tsx` 将 style 属性解析为 React 样式对象(`parseInlineStyle`,React 不接受字符串 style)。样式表 vendored 于 `webapp/styles/katex.css`(源自 `katex/dist/katex.min.css`,`@font-face` 指向 `/fonts/katex/…`),由 `app/layout.tsx` 加载;字体(60 个文件 + MIT 许可声明)vendored 于 `webapp/public/fonts/katex/`。公式为继承文字色的内容,深浅主题均正常、无需重渲染。已知成本:`katex` JS 随前端主包加载,不像 mermaid 懒加载(数学管线是同步 `renderToString`)。依赖 `katex` 0.18.7(MIT)已登记于 `release/dependency-licenses.json`。测试:`webapp/test/markdown-math.test.ts`。 | | 运行中取消 | ✅ | acp `session/cancel` 以 notification 形式发送,并钉在该 cid 的活动子进程上(`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`)。只有当 notification 无法投递时,才会走硬杀兜底(`/api/stop` → SIGTERM/SIGKILL)。acp 会话在排空前可能还会再发出几个事件。 | | 回退 / 分叉某条消息 | ⚠ | 引擎已实现 `session/fork` 和 `session/resume`(`MCODE_ACP_CAPABILITIES.fork / .resume = true`),但目前 webui 还没有路由暴露它们——参见 [§13](CAPABILITIES.zh-CN.md#13-要启用--行-mcode-需要增加什么)。 | | 编辑已发送的消息并重新发送 | ❌ | acp 协议未暴露 | diff --git a/packages/webui/package.json b/packages/webui/package.json index 44fbbdf91..f21d60f8c 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -34,6 +34,7 @@ "antd": "5.29.3", "autoprefixer": "10.6.1", "highlight.js": "10.7.3", + "katex": "0.18.7", "marked": "18.0.12", "mermaid": "11.12.1", "next": "14.2.35", @@ -90,7 +91,7 @@ }, { "name": "lan-sharing", - "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in \u2014 the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." + "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in — the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." }, { "name": "token-auth", diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx index dfeab1b2a..b7f13fb56 100644 --- a/packages/webui/webapp/app/layout.tsx +++ b/packages/webui/webapp/app/layout.tsx @@ -11,6 +11,12 @@ import "../styles/mavis-dropdown.css"; import "../styles/desktop-typography.css"; import "../styles/code-preview.css"; import "../styles/mermaid.css"; +// KaTeX stylesheet, vendored from `katex/dist/katex.min.css` (same version as +// the `katex` devDependency) with the @font-face sources repointed at the +// vendored fonts in `public/fonts/katex/`. Loaded unconditionally: it is +// ~24 KB and the formulas' geometry classes must exist before any message +// with math renders. +import "../styles/katex.css"; export const metadata: Metadata = { title: "MiniMax Code", diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index 27863a037..13f32b105 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -6,6 +6,7 @@ import * as api from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import { MarkdownHtml } from "./markdown-html"; import "../lib/mermaid-renderer"; // registers the mermaid language renderer +import "../lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { reportActionError } from "@/lib/action-errors"; import { findSubagentForBlock } from "@/lib/agent-team-lookup"; import { badgeLabelAndGlyph, agentLabel } from "@/lib/i18n-agent-team"; diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index 84b94a370..534eb3150 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import "@/lib/mermaid-renderer"; // registers the mermaid language renderer +import "@/lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { MarkdownHtml } from "@/components/markdown-html"; import { CodeView as IdeCodeView } from "@/components/code-view"; import { diff --git a/packages/webui/webapp/components/markdown-html.tsx b/packages/webui/webapp/components/markdown-html.tsx index cbc41f5c8..e7f7608b5 100644 --- a/packages/webui/webapp/components/markdown-html.tsx +++ b/packages/webui/webapp/components/markdown-html.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { MermaidBlock } from "./mermaid-block"; +import { parseInlineStyle } from "../lib/markdown"; /** * Render pre-sanitised HTML and mount mermaid blocks as real React @@ -157,6 +158,12 @@ function htmlToReact(html: string, theme: "light" | "dark"): ReactNode { const name = attr.name.toLowerCase(); if (name === "class") { props.className = attr.value; + } else if (name === "style") { + // KaTeX layout lives in inline styles, and React requires the + // style prop as an object — a string is rejected with a console + // error and the styles never apply. The value was already vetted + // by the sanitiser's `isSafeStyleValue`; this only reshapes it. + props.style = parseInlineStyle(attr.value); } else { props[attr.name] = attr.value; } diff --git a/packages/webui/webapp/lib/markdown.ts b/packages/webui/webapp/lib/markdown.ts index 69778f1e2..1889907f5 100644 --- a/packages/webui/webapp/lib/markdown.ts +++ b/packages/webui/webapp/lib/markdown.ts @@ -211,6 +211,12 @@ export const ALLOWED_TAGS = new Set([ "p", "br", "hr", "strong", "em", "del", "code", "pre", "blockquote", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "a", "table", "thead", "tbody", "tr", "th", "td", + // KaTeX HTML output is built exclusively from `span`, `svg` and `path` + // (`lib/math-renderer.ts`). `svg` is therefore removed from the DROP set + // below, and its subtree is walked like any other element: a `}$"); + assert.doesNotMatch(html, /
`/`` pair, not `h1`-`h6` — `lib/mermaid-renderer.ts:44-57`). Dependency `mermaid` 11.12.1 (MIT) is recorded in `release/dependency-licenses.json`. | +| Math formulas via KaTeX (inline `$…$`, display `$$…$$`, ```` ```math ```` fences) | ✅ | the same pipeline as Mermaid: an inline marked extension (`webuiMath`) tokenises `$…$`/`$$…$$` and the ```math fence dispatches through the language→renderer registry (`webapp/lib/math-renderer.ts`, self-registered on import), so neither fence language can shadow the other. A single `$` is math only with a closing delimiter on one line and a non-digit start — `costs $5 and $10`, `$HOME`, unclosed `$` stay prose. A formula KaTeX cannot parse degrades to the original source as code (inline ``, fence → the plain codeblock shell) — the page never blanks. KaTeX runs with `output: "html"` (emits only `span`/`svg`/`path`, which the sanitiser allowlist admits with a fixed attribute set; ``/MathML stays a DROP tag) and `trust: false` (`\href` renders as red warning text, never a link). Inline `style` survives only on `span` and only when the value clears `isSafeStyleValue` — no parentheses (no `url()`/`expression()`), and `position`/`background`/`behavior` are refused; `components/markdown-html.tsx` converts the style attribute to a React style object (`parseInlineStyle`), since React rejects a string style prop. CSS is vendored at `webapp/styles/katex.css` (from `katex/dist/katex.min.css`, `@font-face` repointed to `/fonts/katex/…`) and loaded from `app/layout.tsx`; fonts (60 files + MIT notice) are vendored at `webapp/public/fonts/katex/`. Formulas are inheriting text — both themes work without a re-render. Known cost: `katex` JS ships in the client bundle rather than lazy-loading like mermaid (the math pipeline is synchronous `renderToString`). Dependency `katex` 0.18.7 (MIT) is recorded in `release/dependency-licenses.json`. Tests: `webapp/test/markdown-math.test.ts`. | | Preview toolbar: refresh / edit / save (slice 27) | ✅ | the preview header carries ↻ refresh (re-read from disk, scroll position restored; a deleted/renamed file keeps the last content and shows an explicit banner — `components/file-preview.tsx` refresh handler), a 预览/编辑 toggle (text previews only, `lib/preview-edit.ts#canEditPreview`; credential-shaped paths must pass an explicit confirmation card first — `editRequiresCredentialConfirm` on the slice-16 predicate), and ✓ save (never automatic; success shows "saved at HH:MM", failure keeps the buffer and states the server's reason). Saves go through `POST /api/fs/write` (`api.ts#saveFsFile`) carrying the `(expectedMtime, expectedSize)` baseline from the load; a drifted baseline answers a conflict card (overwrite / reload) rather than a silent overwrite. The write answers with the **realpath-normalised absolute path** (the shared gate resolves symlinks before anything else — the same slice-16 form every `/api/fs/*` route returns; on macOS a `/var/...` fixture therefore answers `/private/var/...`). | | Markdown outline panel (slice 27) | ✅ | `webapp/components/markdown-toc.tsx` + `webapp/lib/markdown-toc.ts`. The outline is extracted from the RENDERED DOM (`querySelectorAll("h1,…,h6")`), never a second markdown parse; heading ids are assigned onto those nodes (stable slugs, `-2`/`-3` dedupe suffixes — `headingSlug`/`extractOutline`, pinned by `webapp/test/markdown-toc.test.ts`). A click `preventDefault`s the anchor and smooth-scrolls the heading into view via `scrollIntoView`; the panel is sticky within the scroll viewport (max-height pinned to the viewport's height) and the active entry follows the scroll position. Documents without headings render no panel; a heading nested inside a `.mermaid-block` is excluded defensively (`isOutlineHeading`); entries are readable in both themes via design tokens. Below ~300px of content width the outline hides (observing the stable `.file-preview-body` width, not the markdown host — watching the host creates a show/hide feedback loop) rather than squeezing the document. | | Cancel mid-run | ✅ | acp `session/cancel` is sent as a notification, pinned on the cid's active child (`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`). The hard-kill fallback (`/api/stop` → SIGTERM/SIGKILL) is only used when the notification cannot be delivered. The acp session may emit a few extra events before draining. | diff --git a/packages/webui/docs/CAPABILITIES.zh-CN.md b/packages/webui/docs/CAPABILITIES.zh-CN.md index fadb99c4a..845861a30 100644 --- a/packages/webui/docs/CAPABILITIES.zh-CN.md +++ b/packages/webui/docs/CAPABILITIES.zh-CN.md @@ -53,6 +53,7 @@ CI 会对上述每一个名称是否出现在本文档中进行断言 | Markdown 渲染(标题、列表、代码) | ✅ | `lib/marked.min.js` 本地内置(不走 CDN) | | 代码块语法高亮 | ✅ | highlight.js(本地副本) | | Markdown 内 Mermaid 图渲染(slice 23) | ✅ | 代码围栏语言写 `mermaid` 即渲染为图,而非代码块。围栏语言经"语言→渲染器"注册表分发(`webapp/lib/markdown.ts` 的 `registerLanguageRenderer`,107-113 行);mermaid 在模块导入时自注册(`webapp/lib/mermaid-renderer.ts:64-71`),其他语言的渲染器可经同一接缝接入——markdown 主流程不针对语言名写分支。图表库在页面首张图出现时才动态加载(`components/mermaid-block.tsx:62-66`),该文件带一年 immutable 强缓存(`server/lib/static.js:66`);没有 mermaid 围栏的页面完全不加载。图跟随浅色/深色主题(`components/markdown-html.tsx:52-66` 监听 `` class;`components/mermaid-block.tsx:156-166、223-229` 按主题重新初始化)。语法错误时渲染可读的失败卡片——错误信息 + 可复制的原始源码(`components/mermaid-block.tsx:285-307`)——文档其余部分照常渲染。限制:图按列宽缩放、超宽时在卡片内横向滚动(`webapp/styles/mermaid.css:62-80`);中文标签经字体栈正常显示(`components/mermaid-block.tsx:109`);图不产生标题,因此不会进入任何按标题组织的大纲(围栏只产出 ``/`` 占位对,不产出 `h1`-`h6`,见 `lib/mermaid-renderer.ts:44-57`)。依赖 `mermaid` 11.12.1(MIT)已登记于 `release/dependency-licenses.json`。 | +| Markdown 数学公式(KaTeX,行内 `$…$`、块级 `$$…$$`、```` ```math ```` 代码块) | ✅ | 与 Mermaid 同一条管线:行内经 marked 扩展 `webuiMath` 识别 `$…$`/`$$…$$`,`math` 代码块经语言→渲染器注册表分发(`webapp/lib/math-renderer.ts`,导入时自注册),两类围栏互不干扰。单个 `$` 仅在存在同 行闭合定界符、内容不以数字开头时才视为公式——`成本 $5 and $10`、`$HOME`、未闭合的 `$` 均按普通文本显示。公式无法解析时降级为原始写法的代码样式(行内降级 ``,代码块降级为普通代码块),页面绝不白屏。KaTeX 以 `output: "html"` 运行(只产出 `span`/`svg`/`path`,清洗白名单按固定属性集放行;``/MathML 仍为整体丢弃标签),`trust: false`(`\href` 只显示红色警示文字,不会成为链接)。内联 `style` 仅在 `span` 上保留且值须通过 `isSafeStyleValue` 校验——禁止括号(杜绝 `url()`/`expression()`),`position`/`background`/`behavior` 直接拒绝;`components/markdown-html.tsx` 将 style 属性解析为 React 样式对象(`parseInlineStyle`,React 不接受字符串 style)。样式表 vendored 于 `webapp/styles/katex.css`(源自 `katex/dist/katex.min.css`,`@font-face` 指向 `/fonts/katex/…`),由 `app/layout.tsx` 加载;字体(60 个文件 + MIT 许可声明)vendored 于 `webapp/public/fonts/katex/`。公式为继承文字色的内容,深浅主题均正常、无需重渲染。已知成本:`katex` JS 随前端主包加载,不像 mermaid 懒加载(数学管线是同步 `renderToString`)。依赖 `katex` 0.18.7(MIT)已登记于 `release/dependency-licenses.json`。测试:`webapp/test/markdown-math.test.ts`。 | | 运行中取消 | ✅ | acp `session/cancel` 以 notification 形式发送,并钉在该 cid 的活动子进程上(`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`)。只有当 notification 无法投递时,才会走硬杀兜底(`/api/stop` → SIGTERM/SIGKILL)。acp 会话在排空前可能还会再发出几个事件。 | | 回退 / 分叉某条消息 | ⚠ | 引擎已实现 `session/fork` 和 `session/resume`(`MCODE_ACP_CAPABILITIES.fork / .resume = true`),但目前 webui 还没有路由暴露它们——参见 [§13](CAPABILITIES.zh-CN.md#13-要启用--行-mcode-需要增加什么)。 | | 编辑已发送的消息并重新发送 | ❌ | acp 协议未暴露 | diff --git a/packages/webui/package.json b/packages/webui/package.json index 44fbbdf91..f21d60f8c 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -34,6 +34,7 @@ "antd": "5.29.3", "autoprefixer": "10.6.1", "highlight.js": "10.7.3", + "katex": "0.18.7", "marked": "18.0.12", "mermaid": "11.12.1", "next": "14.2.35", @@ -90,7 +91,7 @@ }, { "name": "lan-sharing", - "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in \u2014 the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." + "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in — the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." }, { "name": "token-auth", diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx index dfeab1b2a..b7f13fb56 100644 --- a/packages/webui/webapp/app/layout.tsx +++ b/packages/webui/webapp/app/layout.tsx @@ -11,6 +11,12 @@ import "../styles/mavis-dropdown.css"; import "../styles/desktop-typography.css"; import "../styles/code-preview.css"; import "../styles/mermaid.css"; +// KaTeX stylesheet, vendored from `katex/dist/katex.min.css` (same version as +// the `katex` devDependency) with the @font-face sources repointed at the +// vendored fonts in `public/fonts/katex/`. Loaded unconditionally: it is +// ~24 KB and the formulas' geometry classes must exist before any message +// with math renders. +import "../styles/katex.css"; export const metadata: Metadata = { title: "MiniMax Code", diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index 27863a037..13f32b105 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -6,6 +6,7 @@ import * as api from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import { MarkdownHtml } from "./markdown-html"; import "../lib/mermaid-renderer"; // registers the mermaid language renderer +import "../lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { reportActionError } from "@/lib/action-errors"; import { findSubagentForBlock } from "@/lib/agent-team-lookup"; import { badgeLabelAndGlyph, agentLabel } from "@/lib/i18n-agent-team"; diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index 84b94a370..534eb3150 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import "@/lib/mermaid-renderer"; // registers the mermaid language renderer +import "@/lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { MarkdownHtml } from "@/components/markdown-html"; import { CodeView as IdeCodeView } from "@/components/code-view"; import { diff --git a/packages/webui/webapp/components/markdown-html.tsx b/packages/webui/webapp/components/markdown-html.tsx index cbc41f5c8..e7f7608b5 100644 --- a/packages/webui/webapp/components/markdown-html.tsx +++ b/packages/webui/webapp/components/markdown-html.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { MermaidBlock } from "./mermaid-block"; +import { parseInlineStyle } from "../lib/markdown"; /** * Render pre-sanitised HTML and mount mermaid blocks as real React @@ -157,6 +158,12 @@ function htmlToReact(html: string, theme: "light" | "dark"): ReactNode { const name = attr.name.toLowerCase(); if (name === "class") { props.className = attr.value; + } else if (name === "style") { + // KaTeX layout lives in inline styles, and React requires the + // style prop as an object — a string is rejected with a console + // error and the styles never apply. The value was already vetted + // by the sanitiser's `isSafeStyleValue`; this only reshapes it. + props.style = parseInlineStyle(attr.value); } else { props[attr.name] = attr.value; } diff --git a/packages/webui/webapp/lib/markdown.ts b/packages/webui/webapp/lib/markdown.ts index 69778f1e2..1889907f5 100644 --- a/packages/webui/webapp/lib/markdown.ts +++ b/packages/webui/webapp/lib/markdown.ts @@ -211,6 +211,12 @@ export const ALLOWED_TAGS = new Set([ "p", "br", "hr", "strong", "em", "del", "code", "pre", "blockquote", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "a", "table", "thead", "tbody", "tr", "th", "td", + // KaTeX HTML output is built exclusively from `span`, `svg` and `path` + // (`lib/math-renderer.ts`). `svg` is therefore removed from the DROP set + // below, and its subtree is walked like any other element: a `}$"); + assert.doesNotMatch(html, /
`, fence → the plain codeblock shell) — the page never blanks. KaTeX runs with `output: "html"` (emits only `span`/`svg`/`path`, which the sanitiser allowlist admits with a fixed attribute set; ``/MathML stays a DROP tag) and `trust: false` (`\href` renders as red warning text, never a link). Inline `style` survives only on `span` and only when the value clears `isSafeStyleValue` — no parentheses (no `url()`/`expression()`), and `position`/`background`/`behavior` are refused; `components/markdown-html.tsx` converts the style attribute to a React style object (`parseInlineStyle`), since React rejects a string style prop. CSS is vendored at `webapp/styles/katex.css` (from `katex/dist/katex.min.css`, `@font-face` repointed to `/fonts/katex/…`) and loaded from `app/layout.tsx`; fonts (60 files + MIT notice) are vendored at `webapp/public/fonts/katex/`. Formulas are inheriting text — both themes work without a re-render. Known cost: `katex` JS ships in the client bundle rather than lazy-loading like mermaid (the math pipeline is synchronous `renderToString`). Dependency `katex` 0.18.7 (MIT) is recorded in `release/dependency-licenses.json`. Tests: `webapp/test/markdown-math.test.ts`. | | Preview toolbar: refresh / edit / save (slice 27) | ✅ | the preview header carries ↻ refresh (re-read from disk, scroll position restored; a deleted/renamed file keeps the last content and shows an explicit banner — `components/file-preview.tsx` refresh handler), a 预览/编辑 toggle (text previews only, `lib/preview-edit.ts#canEditPreview`; credential-shaped paths must pass an explicit confirmation card first — `editRequiresCredentialConfirm` on the slice-16 predicate), and ✓ save (never automatic; success shows "saved at HH:MM", failure keeps the buffer and states the server's reason). Saves go through `POST /api/fs/write` (`api.ts#saveFsFile`) carrying the `(expectedMtime, expectedSize)` baseline from the load; a drifted baseline answers a conflict card (overwrite / reload) rather than a silent overwrite. The write answers with the **realpath-normalised absolute path** (the shared gate resolves symlinks before anything else — the same slice-16 form every `/api/fs/*` route returns; on macOS a `/var/...` fixture therefore answers `/private/var/...`). | | Markdown outline panel (slice 27) | ✅ | `webapp/components/markdown-toc.tsx` + `webapp/lib/markdown-toc.ts`. The outline is extracted from the RENDERED DOM (`querySelectorAll("h1,…,h6")`), never a second markdown parse; heading ids are assigned onto those nodes (stable slugs, `-2`/`-3` dedupe suffixes — `headingSlug`/`extractOutline`, pinned by `webapp/test/markdown-toc.test.ts`). A click `preventDefault`s the anchor and smooth-scrolls the heading into view via `scrollIntoView`; the panel is sticky within the scroll viewport (max-height pinned to the viewport's height) and the active entry follows the scroll position. Documents without headings render no panel; a heading nested inside a `.mermaid-block` is excluded defensively (`isOutlineHeading`); entries are readable in both themes via design tokens. Below ~300px of content width the outline hides (observing the stable `.file-preview-body` width, not the markdown host — watching the host creates a show/hide feedback loop) rather than squeezing the document. | | Cancel mid-run | ✅ | acp `session/cancel` is sent as a notification, pinned on the cid's active child (`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`). The hard-kill fallback (`/api/stop` → SIGTERM/SIGKILL) is only used when the notification cannot be delivered. The acp session may emit a few extra events before draining. | diff --git a/packages/webui/docs/CAPABILITIES.zh-CN.md b/packages/webui/docs/CAPABILITIES.zh-CN.md index fadb99c4a..845861a30 100644 --- a/packages/webui/docs/CAPABILITIES.zh-CN.md +++ b/packages/webui/docs/CAPABILITIES.zh-CN.md @@ -53,6 +53,7 @@ CI 会对上述每一个名称是否出现在本文档中进行断言 | Markdown 渲染(标题、列表、代码) | ✅ | `lib/marked.min.js` 本地内置(不走 CDN) | | 代码块语法高亮 | ✅ | highlight.js(本地副本) | | Markdown 内 Mermaid 图渲染(slice 23) | ✅ | 代码围栏语言写 `mermaid` 即渲染为图,而非代码块。围栏语言经"语言→渲染器"注册表分发(`webapp/lib/markdown.ts` 的 `registerLanguageRenderer`,107-113 行);mermaid 在模块导入时自注册(`webapp/lib/mermaid-renderer.ts:64-71`),其他语言的渲染器可经同一接缝接入——markdown 主流程不针对语言名写分支。图表库在页面首张图出现时才动态加载(`components/mermaid-block.tsx:62-66`),该文件带一年 immutable 强缓存(`server/lib/static.js:66`);没有 mermaid 围栏的页面完全不加载。图跟随浅色/深色主题(`components/markdown-html.tsx:52-66` 监听 `` class;`components/mermaid-block.tsx:156-166、223-229` 按主题重新初始化)。语法错误时渲染可读的失败卡片——错误信息 + 可复制的原始源码(`components/mermaid-block.tsx:285-307`)——文档其余部分照常渲染。限制:图按列宽缩放、超宽时在卡片内横向滚动(`webapp/styles/mermaid.css:62-80`);中文标签经字体栈正常显示(`components/mermaid-block.tsx:109`);图不产生标题,因此不会进入任何按标题组织的大纲(围栏只产出 ``/`` 占位对,不产出 `h1`-`h6`,见 `lib/mermaid-renderer.ts:44-57`)。依赖 `mermaid` 11.12.1(MIT)已登记于 `release/dependency-licenses.json`。 | +| Markdown 数学公式(KaTeX,行内 `$…$`、块级 `$$…$$`、```` ```math ```` 代码块) | ✅ | 与 Mermaid 同一条管线:行内经 marked 扩展 `webuiMath` 识别 `$…$`/`$$…$$`,`math` 代码块经语言→渲染器注册表分发(`webapp/lib/math-renderer.ts`,导入时自注册),两类围栏互不干扰。单个 `$` 仅在存在同 行闭合定界符、内容不以数字开头时才视为公式——`成本 $5 and $10`、`$HOME`、未闭合的 `$` 均按普通文本显示。公式无法解析时降级为原始写法的代码样式(行内降级 ``,代码块降级为普通代码块),页面绝不白屏。KaTeX 以 `output: "html"` 运行(只产出 `span`/`svg`/`path`,清洗白名单按固定属性集放行;``/MathML 仍为整体丢弃标签),`trust: false`(`\href` 只显示红色警示文字,不会成为链接)。内联 `style` 仅在 `span` 上保留且值须通过 `isSafeStyleValue` 校验——禁止括号(杜绝 `url()`/`expression()`),`position`/`background`/`behavior` 直接拒绝;`components/markdown-html.tsx` 将 style 属性解析为 React 样式对象(`parseInlineStyle`,React 不接受字符串 style)。样式表 vendored 于 `webapp/styles/katex.css`(源自 `katex/dist/katex.min.css`,`@font-face` 指向 `/fonts/katex/…`),由 `app/layout.tsx` 加载;字体(60 个文件 + MIT 许可声明)vendored 于 `webapp/public/fonts/katex/`。公式为继承文字色的内容,深浅主题均正常、无需重渲染。已知成本:`katex` JS 随前端主包加载,不像 mermaid 懒加载(数学管线是同步 `renderToString`)。依赖 `katex` 0.18.7(MIT)已登记于 `release/dependency-licenses.json`。测试:`webapp/test/markdown-math.test.ts`。 | | 运行中取消 | ✅ | acp `session/cancel` 以 notification 形式发送,并钉在该 cid 的活动子进程上(`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`)。只有当 notification 无法投递时,才会走硬杀兜底(`/api/stop` → SIGTERM/SIGKILL)。acp 会话在排空前可能还会再发出几个事件。 | | 回退 / 分叉某条消息 | ⚠ | 引擎已实现 `session/fork` 和 `session/resume`(`MCODE_ACP_CAPABILITIES.fork / .resume = true`),但目前 webui 还没有路由暴露它们——参见 [§13](CAPABILITIES.zh-CN.md#13-要启用--行-mcode-需要增加什么)。 | | 编辑已发送的消息并重新发送 | ❌ | acp 协议未暴露 | diff --git a/packages/webui/package.json b/packages/webui/package.json index 44fbbdf91..f21d60f8c 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -34,6 +34,7 @@ "antd": "5.29.3", "autoprefixer": "10.6.1", "highlight.js": "10.7.3", + "katex": "0.18.7", "marked": "18.0.12", "mermaid": "11.12.1", "next": "14.2.35", @@ -90,7 +91,7 @@ }, { "name": "lan-sharing", - "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in \u2014 the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." + "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in — the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." }, { "name": "token-auth", diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx index dfeab1b2a..b7f13fb56 100644 --- a/packages/webui/webapp/app/layout.tsx +++ b/packages/webui/webapp/app/layout.tsx @@ -11,6 +11,12 @@ import "../styles/mavis-dropdown.css"; import "../styles/desktop-typography.css"; import "../styles/code-preview.css"; import "../styles/mermaid.css"; +// KaTeX stylesheet, vendored from `katex/dist/katex.min.css` (same version as +// the `katex` devDependency) with the @font-face sources repointed at the +// vendored fonts in `public/fonts/katex/`. Loaded unconditionally: it is +// ~24 KB and the formulas' geometry classes must exist before any message +// with math renders. +import "../styles/katex.css"; export const metadata: Metadata = { title: "MiniMax Code", diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index 27863a037..13f32b105 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -6,6 +6,7 @@ import * as api from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import { MarkdownHtml } from "./markdown-html"; import "../lib/mermaid-renderer"; // registers the mermaid language renderer +import "../lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { reportActionError } from "@/lib/action-errors"; import { findSubagentForBlock } from "@/lib/agent-team-lookup"; import { badgeLabelAndGlyph, agentLabel } from "@/lib/i18n-agent-team"; diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index 84b94a370..534eb3150 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import "@/lib/mermaid-renderer"; // registers the mermaid language renderer +import "@/lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { MarkdownHtml } from "@/components/markdown-html"; import { CodeView as IdeCodeView } from "@/components/code-view"; import { diff --git a/packages/webui/webapp/components/markdown-html.tsx b/packages/webui/webapp/components/markdown-html.tsx index cbc41f5c8..e7f7608b5 100644 --- a/packages/webui/webapp/components/markdown-html.tsx +++ b/packages/webui/webapp/components/markdown-html.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { MermaidBlock } from "./mermaid-block"; +import { parseInlineStyle } from "../lib/markdown"; /** * Render pre-sanitised HTML and mount mermaid blocks as real React @@ -157,6 +158,12 @@ function htmlToReact(html: string, theme: "light" | "dark"): ReactNode { const name = attr.name.toLowerCase(); if (name === "class") { props.className = attr.value; + } else if (name === "style") { + // KaTeX layout lives in inline styles, and React requires the + // style prop as an object — a string is rejected with a console + // error and the styles never apply. The value was already vetted + // by the sanitiser's `isSafeStyleValue`; this only reshapes it. + props.style = parseInlineStyle(attr.value); } else { props[attr.name] = attr.value; } diff --git a/packages/webui/webapp/lib/markdown.ts b/packages/webui/webapp/lib/markdown.ts index 69778f1e2..1889907f5 100644 --- a/packages/webui/webapp/lib/markdown.ts +++ b/packages/webui/webapp/lib/markdown.ts @@ -211,6 +211,12 @@ export const ALLOWED_TAGS = new Set([ "p", "br", "hr", "strong", "em", "del", "code", "pre", "blockquote", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "a", "table", "thead", "tbody", "tr", "th", "td", + // KaTeX HTML output is built exclusively from `span`, `svg` and `path` + // (`lib/math-renderer.ts`). `svg` is therefore removed from the DROP set + // below, and its subtree is walked like any other element: a `}$"); + assert.doesNotMatch(html, /
`/`` 占位对,不产出 `h1`-`h6`,见 `lib/mermaid-renderer.ts:44-57`)。依赖 `mermaid` 11.12.1(MIT)已登记于 `release/dependency-licenses.json`。 | +| Markdown 数学公式(KaTeX,行内 `$…$`、块级 `$$…$$`、```` ```math ```` 代码块) | ✅ | 与 Mermaid 同一条管线:行内经 marked 扩展 `webuiMath` 识别 `$…$`/`$$…$$`,`math` 代码块经语言→渲染器注册表分发(`webapp/lib/math-renderer.ts`,导入时自注册),两类围栏互不干扰。单个 `$` 仅在存在同 行闭合定界符、内容不以数字开头时才视为公式——`成本 $5 and $10`、`$HOME`、未闭合的 `$` 均按普通文本显示。公式无法解析时降级为原始写法的代码样式(行内降级 ``,代码块降级为普通代码块),页面绝不白屏。KaTeX 以 `output: "html"` 运行(只产出 `span`/`svg`/`path`,清洗白名单按固定属性集放行;``/MathML 仍为整体丢弃标签),`trust: false`(`\href` 只显示红色警示文字,不会成为链接)。内联 `style` 仅在 `span` 上保留且值须通过 `isSafeStyleValue` 校验——禁止括号(杜绝 `url()`/`expression()`),`position`/`background`/`behavior` 直接拒绝;`components/markdown-html.tsx` 将 style 属性解析为 React 样式对象(`parseInlineStyle`,React 不接受字符串 style)。样式表 vendored 于 `webapp/styles/katex.css`(源自 `katex/dist/katex.min.css`,`@font-face` 指向 `/fonts/katex/…`),由 `app/layout.tsx` 加载;字体(60 个文件 + MIT 许可声明)vendored 于 `webapp/public/fonts/katex/`。公式为继承文字色的内容,深浅主题均正常、无需重渲染。已知成本:`katex` JS 随前端主包加载,不像 mermaid 懒加载(数学管线是同步 `renderToString`)。依赖 `katex` 0.18.7(MIT)已登记于 `release/dependency-licenses.json`。测试:`webapp/test/markdown-math.test.ts`。 | | 运行中取消 | ✅ | acp `session/cancel` 以 notification 形式发送,并钉在该 cid 的活动子进程上(`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`)。只有当 notification 无法投递时,才会走硬杀兜底(`/api/stop` → SIGTERM/SIGKILL)。acp 会话在排空前可能还会再发出几个事件。 | | 回退 / 分叉某条消息 | ⚠ | 引擎已实现 `session/fork` 和 `session/resume`(`MCODE_ACP_CAPABILITIES.fork / .resume = true`),但目前 webui 还没有路由暴露它们——参见 [§13](CAPABILITIES.zh-CN.md#13-要启用--行-mcode-需要增加什么)。 | | 编辑已发送的消息并重新发送 | ❌ | acp 协议未暴露 | diff --git a/packages/webui/package.json b/packages/webui/package.json index 44fbbdf91..f21d60f8c 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -34,6 +34,7 @@ "antd": "5.29.3", "autoprefixer": "10.6.1", "highlight.js": "10.7.3", + "katex": "0.18.7", "marked": "18.0.12", "mermaid": "11.12.1", "next": "14.2.35", @@ -90,7 +91,7 @@ }, { "name": "lan-sharing", - "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in \u2014 the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." + "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in — the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." }, { "name": "token-auth", diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx index dfeab1b2a..b7f13fb56 100644 --- a/packages/webui/webapp/app/layout.tsx +++ b/packages/webui/webapp/app/layout.tsx @@ -11,6 +11,12 @@ import "../styles/mavis-dropdown.css"; import "../styles/desktop-typography.css"; import "../styles/code-preview.css"; import "../styles/mermaid.css"; +// KaTeX stylesheet, vendored from `katex/dist/katex.min.css` (same version as +// the `katex` devDependency) with the @font-face sources repointed at the +// vendored fonts in `public/fonts/katex/`. Loaded unconditionally: it is +// ~24 KB and the formulas' geometry classes must exist before any message +// with math renders. +import "../styles/katex.css"; export const metadata: Metadata = { title: "MiniMax Code", diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index 27863a037..13f32b105 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -6,6 +6,7 @@ import * as api from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import { MarkdownHtml } from "./markdown-html"; import "../lib/mermaid-renderer"; // registers the mermaid language renderer +import "../lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { reportActionError } from "@/lib/action-errors"; import { findSubagentForBlock } from "@/lib/agent-team-lookup"; import { badgeLabelAndGlyph, agentLabel } from "@/lib/i18n-agent-team"; diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index 84b94a370..534eb3150 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import "@/lib/mermaid-renderer"; // registers the mermaid language renderer +import "@/lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { MarkdownHtml } from "@/components/markdown-html"; import { CodeView as IdeCodeView } from "@/components/code-view"; import { diff --git a/packages/webui/webapp/components/markdown-html.tsx b/packages/webui/webapp/components/markdown-html.tsx index cbc41f5c8..e7f7608b5 100644 --- a/packages/webui/webapp/components/markdown-html.tsx +++ b/packages/webui/webapp/components/markdown-html.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { MermaidBlock } from "./mermaid-block"; +import { parseInlineStyle } from "../lib/markdown"; /** * Render pre-sanitised HTML and mount mermaid blocks as real React @@ -157,6 +158,12 @@ function htmlToReact(html: string, theme: "light" | "dark"): ReactNode { const name = attr.name.toLowerCase(); if (name === "class") { props.className = attr.value; + } else if (name === "style") { + // KaTeX layout lives in inline styles, and React requires the + // style prop as an object — a string is rejected with a console + // error and the styles never apply. The value was already vetted + // by the sanitiser's `isSafeStyleValue`; this only reshapes it. + props.style = parseInlineStyle(attr.value); } else { props[attr.name] = attr.value; } diff --git a/packages/webui/webapp/lib/markdown.ts b/packages/webui/webapp/lib/markdown.ts index 69778f1e2..1889907f5 100644 --- a/packages/webui/webapp/lib/markdown.ts +++ b/packages/webui/webapp/lib/markdown.ts @@ -211,6 +211,12 @@ export const ALLOWED_TAGS = new Set([ "p", "br", "hr", "strong", "em", "del", "code", "pre", "blockquote", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "a", "table", "thead", "tbody", "tr", "th", "td", + // KaTeX HTML output is built exclusively from `span`, `svg` and `path` + // (`lib/math-renderer.ts`). `svg` is therefore removed from the DROP set + // below, and its subtree is walked like any other element: a `}$"); + assert.doesNotMatch(html, /
`,代码块降级为普通代码块),页面绝不白屏。KaTeX 以 `output: "html"` 运行(只产出 `span`/`svg`/`path`,清洗白名单按固定属性集放行;``/MathML 仍为整体丢弃标签),`trust: false`(`\href` 只显示红色警示文字,不会成为链接)。内联 `style` 仅在 `span` 上保留且值须通过 `isSafeStyleValue` 校验——禁止括号(杜绝 `url()`/`expression()`),`position`/`background`/`behavior` 直接拒绝;`components/markdown-html.tsx` 将 style 属性解析为 React 样式对象(`parseInlineStyle`,React 不接受字符串 style)。样式表 vendored 于 `webapp/styles/katex.css`(源自 `katex/dist/katex.min.css`,`@font-face` 指向 `/fonts/katex/…`),由 `app/layout.tsx` 加载;字体(60 个文件 + MIT 许可声明)vendored 于 `webapp/public/fonts/katex/`。公式为继承文字色的内容,深浅主题均正常、无需重渲染。已知成本:`katex` JS 随前端主包加载,不像 mermaid 懒加载(数学管线是同步 `renderToString`)。依赖 `katex` 0.18.7(MIT)已登记于 `release/dependency-licenses.json`。测试:`webapp/test/markdown-math.test.ts`。 | | 运行中取消 | ✅ | acp `session/cancel` 以 notification 形式发送,并钉在该 cid 的活动子进程上(`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`)。只有当 notification 无法投递时,才会走硬杀兜底(`/api/stop` → SIGTERM/SIGKILL)。acp 会话在排空前可能还会再发出几个事件。 | | 回退 / 分叉某条消息 | ⚠ | 引擎已实现 `session/fork` 和 `session/resume`(`MCODE_ACP_CAPABILITIES.fork / .resume = true`),但目前 webui 还没有路由暴露它们——参见 [§13](CAPABILITIES.zh-CN.md#13-要启用--行-mcode-需要增加什么)。 | | 编辑已发送的消息并重新发送 | ❌ | acp 协议未暴露 | diff --git a/packages/webui/package.json b/packages/webui/package.json index 44fbbdf91..f21d60f8c 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -34,6 +34,7 @@ "antd": "5.29.3", "autoprefixer": "10.6.1", "highlight.js": "10.7.3", + "katex": "0.18.7", "marked": "18.0.12", "mermaid": "11.12.1", "next": "14.2.35", @@ -90,7 +91,7 @@ }, { "name": "lan-sharing", - "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in \u2014 the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." + "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in — the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." }, { "name": "token-auth", diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx index dfeab1b2a..b7f13fb56 100644 --- a/packages/webui/webapp/app/layout.tsx +++ b/packages/webui/webapp/app/layout.tsx @@ -11,6 +11,12 @@ import "../styles/mavis-dropdown.css"; import "../styles/desktop-typography.css"; import "../styles/code-preview.css"; import "../styles/mermaid.css"; +// KaTeX stylesheet, vendored from `katex/dist/katex.min.css` (same version as +// the `katex` devDependency) with the @font-face sources repointed at the +// vendored fonts in `public/fonts/katex/`. Loaded unconditionally: it is +// ~24 KB and the formulas' geometry classes must exist before any message +// with math renders. +import "../styles/katex.css"; export const metadata: Metadata = { title: "MiniMax Code", diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index 27863a037..13f32b105 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -6,6 +6,7 @@ import * as api from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import { MarkdownHtml } from "./markdown-html"; import "../lib/mermaid-renderer"; // registers the mermaid language renderer +import "../lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { reportActionError } from "@/lib/action-errors"; import { findSubagentForBlock } from "@/lib/agent-team-lookup"; import { badgeLabelAndGlyph, agentLabel } from "@/lib/i18n-agent-team"; diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index 84b94a370..534eb3150 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import "@/lib/mermaid-renderer"; // registers the mermaid language renderer +import "@/lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { MarkdownHtml } from "@/components/markdown-html"; import { CodeView as IdeCodeView } from "@/components/code-view"; import { diff --git a/packages/webui/webapp/components/markdown-html.tsx b/packages/webui/webapp/components/markdown-html.tsx index cbc41f5c8..e7f7608b5 100644 --- a/packages/webui/webapp/components/markdown-html.tsx +++ b/packages/webui/webapp/components/markdown-html.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { MermaidBlock } from "./mermaid-block"; +import { parseInlineStyle } from "../lib/markdown"; /** * Render pre-sanitised HTML and mount mermaid blocks as real React @@ -157,6 +158,12 @@ function htmlToReact(html: string, theme: "light" | "dark"): ReactNode { const name = attr.name.toLowerCase(); if (name === "class") { props.className = attr.value; + } else if (name === "style") { + // KaTeX layout lives in inline styles, and React requires the + // style prop as an object — a string is rejected with a console + // error and the styles never apply. The value was already vetted + // by the sanitiser's `isSafeStyleValue`; this only reshapes it. + props.style = parseInlineStyle(attr.value); } else { props[attr.name] = attr.value; } diff --git a/packages/webui/webapp/lib/markdown.ts b/packages/webui/webapp/lib/markdown.ts index 69778f1e2..1889907f5 100644 --- a/packages/webui/webapp/lib/markdown.ts +++ b/packages/webui/webapp/lib/markdown.ts @@ -211,6 +211,12 @@ export const ALLOWED_TAGS = new Set([ "p", "br", "hr", "strong", "em", "del", "code", "pre", "blockquote", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "a", "table", "thead", "tbody", "tr", "th", "td", + // KaTeX HTML output is built exclusively from `span`, `svg` and `path` + // (`lib/math-renderer.ts`). `svg` is therefore removed from the DROP set + // below, and its subtree is walked like any other element: a `}$"); + assert.doesNotMatch(html, /