From 51494c9cbeb247664fe772ea4cb69e2d0e461664 Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 5 Aug 2026 14:08:58 +0800 Subject: [PATCH 1/5] fix(ui): render common TeX math delimiters --- THIRD_PARTY_NOTICES.md | 5 + packages/client/ui-primitives/package.json | 5 + .../src/markdown/MarkdownText.tsx | 7 +- .../src/markdown/remarkMathCompatibility.ts | 275 ++++++++++++++++++ pnpm-lock.yaml | 15 + 5 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8b04f25504..cf3ca72099 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -63,6 +63,11 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | +| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | +| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT | +| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT | +| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 49d6c2c2b6..683ca7a93b 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -27,6 +27,11 @@ "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0", + "micromark-extension-math": "^3.1.0", + "micromark-factory-space": "^2.0.1", + "micromark-util-character": "^2.1.1", + "micromark-util-symbol": "^2.0.1", + "micromark-util-types": "^2.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 72e9168661..6450de55e2 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -5,11 +5,16 @@ import rehypeKatex from 'rehype-katex' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { CodeBlock } from './CodeBlock.tsx' +import { remarkMathCompatibility } from './remarkMathCompatibility.ts' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' const streamingRemarkPlugins = [remarkGfm] -const settledRemarkPlugins = [remarkGfm, remarkMath] +const settledRemarkPlugins = [ + remarkGfm, + remarkMathCompatibility, + remarkMath, +] const settledRehypePlugins = [rehypeKatex] function sanitizeUrl(url: string): string { diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts new file mode 100644 index 0000000000..16fefccad6 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts @@ -0,0 +1,275 @@ +import { factorySpace } from 'micromark-factory-space' +import type {} from 'micromark-extension-math' +import { markdownLineEnding } from 'micromark-util-character' +import { codes, constants, types } from 'micromark-util-symbol' +import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark-util-types' + +// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback. + +interface RemarkProcessor { + data(): { micromarkExtensions?: Extension[] } +} + +const previousBackslash: Previous = function (code) { + return code !== codes.backslash || this.events.at(-1)?.[1].type === types.characterEscape +} + +const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { + const self = this + + return start + + function start(code: number | null): State | undefined { + if (code !== codes.backslash) return nok(code) + effects.enter('mathText') + effects.enter('mathTextSequence') + effects.consume(code) + return open + } + + function open(code: number | null): State | undefined { + if (code !== codes.leftParenthesis) return nok(code) + effects.consume(code) + effects.exit('mathTextSequence') + return between + } + + function between(code: number | null): State | undefined { + if (code === codes.eof) return nok(code) + if (code === codes.backslash && self.previous !== codes.backslash) { + return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, dataStart)(code) + } + if (markdownLineEnding(code)) { + effects.enter(types.lineEnding) + effects.consume(code) + effects.exit(types.lineEnding) + return between + } + return dataStart(code) + } + + function dataStart(code: number | null): State | undefined { + effects.enter('mathTextData') + effects.consume(code) + return data + } + + function data(code: number | null): State | undefined { + if (code === codes.eof || code === codes.backslash || markdownLineEnding(code)) { + effects.exit('mathTextData') + return between(code) + } + effects.consume(code) + return data + } + + function close(code: number | null): State | undefined { + effects.exit('mathText') + return ok(code) + } + + function tokenizeClose(closeEffects: Parameters[0], closeOk: State, closeNok: State): State { + return slash + + function slash(code: number | null): State | undefined { + if (code !== codes.backslash) return closeNok(code) + closeEffects.enter('mathTextSequence') + closeEffects.consume(code) + return parenthesis + } + + function parenthesis(code: number | null): State | undefined { + if (code !== codes.rightParenthesis) return closeNok(code) + closeEffects.consume(code) + closeEffects.exit('mathTextSequence') + return closeOk + } + } +} + +function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct { + const tokenize: Tokenizer = function (effects, ok, nok) { + const self = this + const tail = self.events.at(-1) + const initialSize = tail?.[1].type === types.linePrefix + ? tail[2].sliceSerialize(tail[1], true).length + : 0 + + return start + + function start(code: number | null): State | undefined { + if (code !== marker) return nok(code) + effects.enter('mathFlow') + effects.enter('mathFlowFence') + effects.enter('mathFlowFenceSequence') + effects.consume(code) + return open + } + + function open(code: number | null): State | undefined { + if (code !== openMarker) return nok(code) + effects.consume(code) + effects.exit('mathFlowFenceSequence') + effects.exit('mathFlowFence') + return marker === codes.dollarSign ? afterDollarOpen : content + } + + function afterDollarOpen(code: number | null): State | undefined { + return code === codes.dollarSign ? nok(code) : content(code) + } + + function content(code: number | null): State | undefined { + if (code === codes.eof) return nok(code) + if (code === marker && (marker !== codes.backslash || self.previous !== codes.backslash)) { + return effects.attempt({ partial: true, tokenize: tokenizeClosingFence }, closed, markerValueStart)(code) + } + if (markdownLineEnding(code)) { + return multiline + ? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code) + : nok(code) + } + return valueStart(code) + } + + function afterContinuation(code: number | null): State | undefined { + return effects.attempt( + { partial: true, tokenize: tokenizeClosingFence }, + closed, + initialSize + ? factorySpace(effects, content, types.linePrefix, initialSize + 1) + : content, + )(code) + } + + function valueStart(code: number | null): State | undefined { + effects.enter('mathFlowValue') + effects.consume(code) + return value + } + + function markerValueStart(code: number | null): State | undefined { + effects.enter('mathFlowValue') + effects.consume(code) + return valueAfterMarker + } + + function valueAfterMarker(code: number | null): State | undefined { + if (code === marker) { + effects.consume(code) + return value + } + return value(code) + } + + function value(code: number | null): State | undefined { + if (code === codes.eof || code === marker || markdownLineEnding(code)) { + effects.exit('mathFlowValue') + return content(code) + } + effects.consume(code) + return value + } + + function closed(code: number | null): State | undefined { + effects.exit('mathFlow') + return ok(code) + } + + function tokenizeClosingFence( + closeEffects: Parameters[0], + closeOk: State, + closeNok: State, + ): State { + return factorySpace(closeEffects, sequenceStart, types.linePrefix, constants.tabSize) + + function sequenceStart(code: number | null): State | undefined { + if (code !== marker) return closeNok(code) + closeEffects.enter('mathFlowFence') + closeEffects.enter('mathFlowFenceSequence') + closeEffects.consume(code) + return sequenceEnd + } + + function sequenceEnd(code: number | null): State | undefined { + if (code !== closeMarker) return closeNok(code) + closeEffects.consume(code) + closeEffects.exit('mathFlowFenceSequence') + return factorySpace(closeEffects, after, types.whitespace) + } + + function after(code: number | null): State | undefined { + if (code !== codes.eof && !markdownLineEnding(code)) return closeNok(code) + closeEffects.exit('mathFlowFence') + return closeOk(code) + } + } + } + + return { + concrete: true, + name: marker === codes.dollarSign ? 'sameLineDollarMathFlow' : 'backslashMathFlow', + tokenize, + } +} + +const tokenizeNonLazyContinuation: Tokenizer = function (effects, ok, nok) { + const self = this + + return start + + function start(code: number | null): State | undefined { + if (code === codes.eof) return ok(code) + if (!markdownLineEnding(code)) return nok(code) + effects.enter(types.lineEnding) + effects.consume(code) + effects.exit(types.lineEnding) + return lineStart + } + + function lineStart(code: number | null): State | undefined { + return self.parser.lazy[self.now().line] ? nok(code) : ok(code) + } +} + +const nonLazyContinuation: Construct = { + partial: true, + tokenize: tokenizeNonLazyContinuation, +} + +const backslashMathText: Construct = { + name: 'backslashMathText', + previous: previousBackslash, + tokenize: tokenizeBackslashMathText, +} + +const backslashMathFlow = createMathFlow( + codes.backslash, + codes.leftSquareBracket, + codes.rightSquareBracket, + true, +) + +const sameLineDollarMathFlow = createMathFlow( + codes.dollarSign, + codes.dollarSign, + codes.dollarSign, + false, +) + +const backslashMath: Extension = { + flow: { + [codes.backslash]: backslashMathFlow, + [codes.dollarSign]: sameLineDollarMathFlow, + }, + text: { [codes.backslash]: backslashMathText }, +} + +/** + * Add TeX backslash delimiters and same-line display-dollar blocks to remark. + * @returns Nothing. + */ +export function remarkMathCompatibility(this: RemarkProcessor): undefined { + const data = this.data() + const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = []) + extensions.push(backslashMath) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56f2dd154..fea066e912 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1697,6 +1697,21 @@ importers: micromark-extension-gfm: specifier: ^3.0.0 version: 3.0.0 + micromark-extension-math: + specifier: ^3.1.0 + version: 3.1.0 + micromark-factory-space: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-character: + specifier: ^2.1.1 + version: 2.1.1 + micromark-util-symbol: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-types: + specifier: ^2.0.2 + version: 2.0.2 react: specifier: ^18.2.0 version: 18.3.1 From d68ee2fb0eb78a5d305915d43f92301e4fb47e0a Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 5 Aug 2026 14:19:19 +0800 Subject: [PATCH 2/5] test(web): cover TeX math rendering --- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 4 +- .../2026-07-23-web-assistant-markdown.zh.md | 4 +- apps/web/tests/math-rendering.e2e.ts | 127 ++++++++++++++++++ .../snapshots/math-rendering/ui.expected.md | 47 +++++++ apps/web/tsconfig.json | 1 + .../src/markdown/remarkMathCompatibility.ts | 7 + .../ui-primitives/tests/markdown.spec.tsx | 87 ++++++++++++ tsconfig.host.json | 1 + 9 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 apps/web/tests/math-rendering.e2e.ts create mode 100644 apps/web/tests/snapshots/math-rendering/ui.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 656a52d300..f8326927b9 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: d5074e6090699229f5c43dd93eef0fdfbfedab76 -2026-07-23-web-assistant-markdown.zh.md: 31f0fd6835c9921f544f4b6217a0c834dff79859 +2026-07-23-web-assistant-markdown.md: 347ab223c970a345ca5d7abea0edc0bf9cce6324 +2026-07-23-web-assistant-markdown.zh.md: 6898dd009373d893d3d049b83963234461f04959 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index d5074e6090..347ab223c9 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -14,7 +14,7 @@ The Web conversation preserves assistant Markdown source through session events, `MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. -Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Citation pills, KaTeX, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers are out of scope until matching product DOM exists; GFM task lists keep native checkboxes. +Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\\(...\\)`, `\\[...\\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle. @@ -38,4 +38,4 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen ## Consequences -Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, and shiki allowlist; cite/math/anchor/thinking-small surfaces remain deferred. +Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 31f0fd6835..6898dd0093 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -14,7 +14,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 -视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。引用胶囊、KaTeX、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记均不在范围内,直至存在匹配的产品 DOM;GFM 任务列表继续使用原生复选框。 +视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math` 和 `rehype-katex` 渲染 KaTeX;`remarkMathCompatibility` 将 `\\(...\\)`、`\\[...\\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。 @@ -38,4 +38,4 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 ## 后果 -assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时与 shiki 允许列表;cite/math/anchor/thinking-small 表层仍暂缓。 +assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。 diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts new file mode 100644 index 0000000000..f491d63b6a --- /dev/null +++ b/apps/web/tests/math-rendering.e2e.ts @@ -0,0 +1,127 @@ +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/math-rendering', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/math-rendering/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'math-rendering-web-e2e' +const DONE = 'MATH_RENDERING_DONE' + +/** Build a settled assistant reply that exercises every supported math delimiter. */ +function mathFixture(): string { + const session = Session.create(SessionId('math-rendering-source')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Render this mathematical proof.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Math rendering', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## Math rendering', + '', + 'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).', + '', + '\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]', + '', + '$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$', + '', + '| Symbol | Value |', + '| --- | --- |', + '| $\\theta$ | \\(\\frac{1}{5}\\) |', + '', + DONE, + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: settled Markdown math rendering', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, mathFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('renders the settled reply without KaTeX errors', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) + await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) + expect(await page.locator('.katex-error').count()).toBe(0) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md new file mode 100644 index 0000000000..f8503988b9 --- /dev/null +++ b/apps/web/tests/snapshots/math-rendering/ui.expected.md @@ -0,0 +1,47 @@ +- banner: + - navigation "Session hierarchy": + - button "Math rendering" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Render this mathematical proof. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- heading "Math rendering" [level=2] +- paragraph: + - text: Inline dollar + - math: θ + - text: and backslash + - math: 1 5 + - text: . +- math: π 4 < θ < π 2 +- math: θ ∈ ( π 4 , π 2 ) . (1) +- table: + - rowgroup: + - row "Symbol Value": + - columnheader "Symbol" + - columnheader "Value" + - rowgroup: + - row: + - cell: + - math: θ + - cell: + - math: 1 5 +- paragraph: MATH_RENDERING_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}}Ran for {{duration}} +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model": + - text: Select model + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ecb4f0db6c..bba4e50137 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -48,6 +48,7 @@ "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/markdown-images.e2e.ts", + "tests/math-rendering.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts index 16fefccad6..15e6c2b359 100644 --- a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts +++ b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts @@ -11,6 +11,7 @@ interface RemarkProcessor { } const previousBackslash: Previous = function (code) { + /* v8 ignore next -- micromark calls previous after an event has been emitted. */ return code !== codes.backslash || this.events.at(-1)?.[1].type === types.characterEscape } @@ -20,6 +21,7 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { return start function start(code: number | null): State | undefined { + /* v8 ignore next -- the text construct is dispatched only for a backslash. */ if (code !== codes.backslash) return nok(code) effects.enter('mathText') effects.enter('mathTextSequence') @@ -72,6 +74,7 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { return slash function slash(code: number | null): State | undefined { + /* v8 ignore next -- this partial construct is attempted only at a backslash. */ if (code !== codes.backslash) return closeNok(code) closeEffects.enter('mathTextSequence') closeEffects.consume(code) @@ -98,6 +101,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, return start function start(code: number | null): State | undefined { + /* v8 ignore next -- the flow construct is dispatched only for its marker. */ if (code !== marker) return nok(code) effects.enter('mathFlow') effects.enter('mathFlowFence') @@ -124,6 +128,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, return effects.attempt({ partial: true, tokenize: tokenizeClosingFence }, closed, markerValueStart)(code) } if (markdownLineEnding(code)) { + /* v8 ignore next -- micromark gives same-line dollar flow to remark-math before this continuation branch. */ return multiline ? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code) : nok(code) @@ -218,7 +223,9 @@ const tokenizeNonLazyContinuation: Tokenizer = function (effects, ok, nok) { return start function start(code: number | null): State | undefined { + /* v8 ignore next -- continuation constructs are attempted only after a line ending. */ if (code === codes.eof) return ok(code) + /* v8 ignore next -- continuation constructs are attempted only after a line ending. */ if (!markdownLineEnding(code)) return nok(code) effects.enter(types.lineEnding) effects.consume(code) diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 10af104e5a..ea2abc2b7c 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -1,7 +1,9 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' +import type { Extension } from 'micromark-util-types' import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts' afterEach(cleanup) @@ -171,6 +173,91 @@ describe('MarkdownText', () => { expect(container.querySelector('a')).toBeNull() }) + it('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => { + const source = [ + 'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).', + '', + '\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]', + '', + '$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$', + '', + '| Symbol | Value |', + '| --- | --- |', + '| $\\theta$ | \\(\\frac{1}{5}\\) |', + ].join('\n') + const { container } = render() + + expect(container.querySelectorAll('.katex')).toHaveLength(6) + expect(container.querySelectorAll('.katex-display')).toHaveLength(2) + expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}') + expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent) + .toContain('\\tag{1}') + expect(container.querySelector('.katex-error')).toBeNull() + expect(container.querySelector('table .katex')).not.toBeNull() + }) + + it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => { + const cases = [ + { + source: '\\(\\alpha \\, \\beta\\)', + math: 1, + display: 0, + }, + { + source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)', + math: 1, + display: 0, + }, + { + source: '> \\[\n> \\frac{1}{5}\n> \\]', + math: 1, + display: 1, + }, + { + source: '- \\[\n \\frac{1}{5}\n \\]', + math: 1, + display: 1, + }, + ] + + for (const item of cases) { + const rendered = render() + expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math) + expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display) + expect(rendered.container.querySelector('.katex-error')).toBeNull() + rendered.unmount() + } + + const literal = render() + expect(literal.container.querySelectorAll('.katex')).toHaveLength(1) + expect(literal.container.querySelector('.katex-display')).toBeNull() + expect(literal.container.textContent).toContain('[x') + expect(literal.container.textContent).toContain('xxx trailing') + }) + + it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => { + const sources = [ + '$$\n\\theta\n$$', + '$$$\\theta$$$', + ' \\[\n \\theta\n \\]', + '\\(\\theta', + '> \\[\nnot a quoted continuation\n\\]', + ] + + for (const source of sources) { + const rendered = render() + expect(rendered.container.querySelector('.katex-error')).toBeNull() + rendered.unmount() + } + }) + + it('registers the compatibility extension on a bare remark processor', () => { + const data: { micromarkExtensions?: Extension[] } = {} + remarkMathCompatibility.call({ data: () => data }) + + expect(data.micromarkExtensions).toHaveLength(1) + }) + it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => { const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial' const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$' diff --git a/tsconfig.host.json b/tsconfig.host.json index c3eac58af5..cdc5705013 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -35,6 +35,7 @@ "apps/web/tests/web-search-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", + "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", From 3b8656857749386ea77a3315ebd79e149e829a1a Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 5 Aug 2026 14:28:16 +0800 Subject: [PATCH 3/5] test(ui): keep TeX delimiters out of code fences --- packages/client/ui-primitives/tests/markdown.spec.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index ea2abc2b7c..c34f8227c3 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -251,6 +251,15 @@ describe('MarkdownText', () => { } }) + it('leaves TeX-looking fenced code literal', () => { + const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```' + const { container } = render() + + expect(container.querySelector('.katex')).toBeNull() + expect(container.querySelector('pre code')?.textContent).toContain('\\[\\frac{1}{5}\\]') + expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$') + }) + it('registers the compatibility extension on a bare remark processor', () => { const data: { micromarkExtensions?: Extension[] } = {} remarkMathCompatibility.call({ data: () => data }) From 7c90422f345688ca84c65acdd86e24decc55782b Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 5 Aug 2026 15:06:38 +0800 Subject: [PATCH 4/5] fix(ui): harden TeX delimiter parsing --- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 2 +- .../2026-07-23-web-assistant-markdown.zh.md | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/markdown/remarkMathCompatibility.ts | 84 +++++++++++++++++-- .../ui-primitives/tests/markdown.spec.tsx | 35 ++++++++ 8 files changed, 118 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index f8326927b9..2a56348913 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: 347ab223c970a345ca5d7abea0edc0bf9cce6324 -2026-07-23-web-assistant-markdown.zh.md: 6898dd009373d893d3d049b83963234461f04959 +2026-07-23-web-assistant-markdown.md: 8a8778351911bcb3448366c718aa124c4a89de58 +2026-07-23-web-assistant-markdown.zh.md: 2ac024e24ff95b4eb296112562c93f343b832187 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 347ab223c9..8a87783519 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -14,7 +14,7 @@ The Web conversation preserves assistant Markdown source through session events, `MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. -Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\\(...\\)`, `\\[...\\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. +Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 6898dd0093..2ac024e24f 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -14,7 +14,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 -视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math` 和 `rehype-katex` 渲染 KaTeX;`remarkMathCompatibility` 将 `\\(...\\)`、`\\[...\\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 +视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math` 和 `rehype-katex` 渲染 KaTeX;`remarkMathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。 diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 8c597efd5a..f6787f8fff 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 00e9560f43c83e1edc61c185a4fc562c6c923e8b -README.zh.md: 21226ab211106b7722139828762605cb71a4b498 +README.md: 3286e6da6020fa31ca13658e091a5b12dc57d28b +README.zh.md: e3d8b6ac869d33850945db088472d9c1958ab0b2 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 00e9560f43..3286e6da60 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 21226ab211..e3d8b6ac86 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts index 15e6c2b359..c9dfc9a5d3 100644 --- a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts +++ b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts @@ -16,8 +16,6 @@ const previousBackslash: Previous = function (code) { } const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { - const self = this - return start function start(code: number | null): State | undefined { @@ -38,8 +36,8 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { function between(code: number | null): State | undefined { if (code === codes.eof) return nok(code) - if (code === codes.backslash && self.previous !== codes.backslash) { - return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, dataStart)(code) + if (code === codes.backslash) { + return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, afterCloseAttempt)(code) } if (markdownLineEnding(code)) { effects.enter(types.lineEnding) @@ -50,10 +48,22 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { return dataStart(code) } + function afterCloseAttempt(code: number | null): State | undefined { + return effects.check({ partial: true, tokenize: tokenizeOpen }, nok, dataStart)(code) + } + function dataStart(code: number | null): State | undefined { effects.enter('mathTextData') effects.consume(code) - return data + return code === codes.backslash ? afterDataBackslash : data + } + + function afterDataBackslash(code: number | null): State | undefined { + if (code === codes.backslash) { + effects.consume(code) + return data + } + return data(code) } function data(code: number | null): State | undefined { @@ -88,11 +98,31 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { return closeOk } } + + function tokenizeOpen(openEffects: Parameters[0], openOk: State, openNok: State): State { + return slash + + function slash(code: number | null): State | undefined { + /* v8 ignore next -- the opening check follows a failed close attempt at a backslash. */ + if (code !== codes.backslash) return openNok(code) + openEffects.enter(types.chunkString) + openEffects.consume(code) + return parenthesis + } + + function parenthesis(code: number | null): State | undefined { + if (code !== codes.leftParenthesis) return openNok(code) + openEffects.consume(code) + openEffects.exit(types.chunkString) + return openOk + } + } } function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct { const tokenize: Tokenizer = function (effects, ok, nok) { const self = this + let oddBackslashRun = false const tail = self.events.at(-1) const initialSize = tail?.[1].type === types.linePrefix ? tail[2].sliceSerialize(tail[1], true).length @@ -124,11 +154,14 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, function content(code: number | null): State | undefined { if (code === codes.eof) return nok(code) - if (code === marker && (marker !== codes.backslash || self.previous !== codes.backslash)) { - return effects.attempt({ partial: true, tokenize: tokenizeClosingFence }, closed, markerValueStart)(code) + if (code === marker && (marker !== codes.dollarSign || !oddBackslashRun)) { + return effects.attempt( + { partial: true, tokenize: tokenizeClosingFence }, + closed, + afterClosingFenceAttempt, + )(code) } if (markdownLineEnding(code)) { - /* v8 ignore next -- micromark gives same-line dollar flow to remark-math before this continuation branch. */ return multiline ? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code) : nok(code) @@ -136,6 +169,12 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, return valueStart(code) } + function afterClosingFenceAttempt(code: number | null): State | undefined { + return marker === codes.backslash + ? effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code) + : markerValueStart(code) + } + function afterContinuation(code: number | null): State | undefined { return effects.attempt( { partial: true, tokenize: tokenizeClosingFence }, @@ -148,12 +187,14 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, function valueStart(code: number | null): State | undefined { effects.enter('mathFlowValue') + oddBackslashRun = code === codes.backslash effects.consume(code) return value } function markerValueStart(code: number | null): State | undefined { effects.enter('mathFlowValue') + oddBackslashRun = false effects.consume(code) return valueAfterMarker } @@ -171,6 +212,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, effects.exit('mathFlowValue') return content(code) } + oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false effects.consume(code) return value } @@ -208,6 +250,29 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, return closeOk(code) } } + + function tokenizeOpeningFence( + openEffects: Parameters[0], + openOk: State, + openNok: State, + ): State { + return sequenceStart + + function sequenceStart(code: number | null): State | undefined { + /* v8 ignore next -- the opening check follows a failed close attempt at the marker. */ + if (code !== marker) return openNok(code) + openEffects.enter(types.chunkString) + openEffects.consume(code) + return sequenceEnd + } + + function sequenceEnd(code: number | null): State | undefined { + if (code !== openMarker) return openNok(code) + openEffects.consume(code) + openEffects.exit(types.chunkString) + return openOk + } + } } return { @@ -272,7 +337,8 @@ const backslashMath: Extension = { } /** - * Add TeX backslash delimiters and same-line display-dollar blocks to remark. + * Add TeX backslash delimiters and same-line display-dollar blocks for remark-math. + * The same processor must register remark-math to compile the emitted math tokens. * @returns Nothing. */ export function remarkMathCompatibility(this: RemarkProcessor): undefined { diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index c34f8227c3..057e807b1d 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -207,6 +207,13 @@ describe('MarkdownText', () => { source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)', math: 1, display: 0, + value: '\\frac{1}{5}\n+\\frac{1}{7}', + }, + { + source: '\\[a\\\\\nb\\]', + math: 1, + display: 1, + value: 'a\\\\\nb', }, { source: '> \\[\n> \\frac{1}{5}\n> \\]', @@ -225,6 +232,9 @@ describe('MarkdownText', () => { expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math) expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display) expect(rendered.container.querySelector('.katex-error')).toBeNull() + if ('value' in item) { + expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value) + } rendered.unmount() } @@ -239,8 +249,10 @@ describe('MarkdownText', () => { const sources = [ '$$\n\\theta\n$$', '$$$\\theta$$$', + '$$a$b\nc', ' \\[\n \\theta\n \\]', '\\(\\theta', + '\\[\n\\[', '> \\[\nnot a quoted continuation\n\\]', ] @@ -251,6 +263,29 @@ describe('MarkdownText', () => { } }) + it('renders escaped dollars and even backslash pairs before closing fences', () => { + const source = [ + String.raw`$$100\$$$`, + '', + String.raw`\(a\\\)`, + '', + String.raw`\[b\\\]`, + ].join('\n') + const { container } = render() + const values = [...container.querySelectorAll('annotation')].map(node => node.textContent) + + expect(values).toEqual([String.raw`100\$`, String.raw`a\\`, String.raw`b\\`]) + expect(container.querySelector('.katex-error')).toBeNull() + }) + + it('bounds fallback work for repeated unclosed backslash delimiters', () => { + const startedAt = performance.now() + const { container } = render() + + expect(performance.now() - startedAt).toBeLessThan(1_000) + expect(container.querySelector('.katex')).toBeNull() + }) + it('leaves TeX-looking fenced code literal', () => { const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```' const { container } = render() From 09a2e120f3c5a6f300f3d7a3999a6dd3c3f0edd4 Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 5 Aug 2026 15:11:47 +0800 Subject: [PATCH 5/5] test(ui): strengthen TeX parser boundaries --- .../src/markdown/remarkMathCompatibility.ts | 9 +++- .../ui-primitives/tests/markdown.spec.tsx | 52 ++++++++++++++----- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts index c9dfc9a5d3..dcd8c32362 100644 --- a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts +++ b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts @@ -1,3 +1,5 @@ +/** Extend upstream dollar-only math syntax with TeX delimiters while reusing its token vocabulary. */ + import { factorySpace } from 'micromark-factory-space' import type {} from 'micromark-extension-math' import { markdownLineEnding } from 'micromark-util-character' @@ -11,8 +13,11 @@ interface RemarkProcessor { } const previousBackslash: Previous = function (code) { - /* v8 ignore next -- micromark calls previous after an event has been emitted. */ - return code !== codes.backslash || this.events.at(-1)?.[1].type === types.characterEscape + if (code !== codes.backslash) return true + const tail = this.events.at(-1) + /* v8 ignore next -- a previous code necessarily has a preceding event. */ + if (tail === undefined) return false + return tail[1].type === types.characterEscape } const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 057e807b1d..7a858c1199 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -203,6 +203,12 @@ describe('MarkdownText', () => { math: 1, display: 0, }, + { + source: String.raw`\\\(x\)`, + math: 1, + display: 0, + value: 'x', + }, { source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)', math: 1, @@ -238,31 +244,51 @@ describe('MarkdownText', () => { rendered.unmount() } - const literal = render() - expect(literal.container.querySelectorAll('.katex')).toHaveLength(1) + const literal = render() + expect(literal.container.querySelectorAll('.katex')).toHaveLength(0) expect(literal.container.querySelector('.katex-display')).toBeNull() expect(literal.container.textContent).toContain('[x') - expect(literal.container.textContent).toContain('xxx trailing') }) it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => { - const sources = [ - '$$\n\\theta\n$$', - '$$$\\theta$$$', - '$$a$b\nc', - ' \\[\n \\theta\n \\]', - '\\(\\theta', - '\\[\n\\[', - '> \\[\nnot a quoted continuation\n\\]', + const cases = [ + { source: '$$\n\\theta\n$$', math: 1, display: 1 }, + { source: '$$$\\theta$$$', math: 1, display: 0 }, + { source: '$$a$b\nc', math: 0, display: 0 }, + { source: ' \\[\n \\theta\n \\]', math: 1, display: 1 }, + { source: '\\(\\theta', math: 0, display: 0 }, + { source: String.raw`\(a\\)`, math: 0, display: 0 }, + { source: '\\[\n\\[', math: 0, display: 0 }, + { source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 }, ] - for (const source of sources) { - const rendered = render() + for (const item of cases) { + const rendered = render() + expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math) + expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display) expect(rendered.container.querySelector('.katex-error')).toBeNull() rendered.unmount() } }) + it('lets display math interrupt an open paragraph', () => { + for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) { + const rendered = render() + expect(rendered.container.querySelectorAll('p')).toHaveLength(1) + expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1) + rendered.unmount() + } + }) + + it('leaves a dollar block with trailing text to upstream inline math', () => { + const { container } = render() + + expect(container.querySelectorAll('.katex')).toHaveLength(1) + expect(container.querySelector('.katex-display')).toBeNull() + expect(container.querySelector('annotation')?.textContent).toBe('x') + expect(container.textContent).toContain('trailing') + }) + it('renders escaped dollars and even backslash pairs before closing fences', () => { const source = [ String.raw`$$100\$$$`,