Merge remote-tracking branch 'origin/master' into feat/py-types-code-mode

This commit is contained in:
Chinesezjc
2026-08-05 21:01:58 +08:00
16 changed files with 728 additions and 12 deletions

View File

@@ -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: 8a8778351911bcb3448366c718aa124c4a89de58
2026-07-23-web-assistant-markdown.zh.md: 2ac024e24ff95b4eb296112562c93f343b832187

View File

@@ -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.

View File

@@ -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 变体,以及自定义 □/☑ 任务标记不在范围内,直至存在匹配的产品 DOMGFM 任务列表继续使用原生复选框。
视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 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、anchorthinking-small 表层仍暂缓。

View File

@@ -64,6 +64,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 |

View File

@@ -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<typeof watchConsole>
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)
})

View File

@@ -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

View File

@@ -50,6 +50,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",

View File

@@ -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: d4e6c2508f07f4832f2e12a836c2d447b656421e
README.zh.md: 76dfbebd5e9db494b49d65a2528977b7ac9fed15
README.md: 03e7e3649fd0913fb48579aa87634153f67f5baf
README.zh.md: 090ecc34e8d514e38853de8ed52e82d3bf019b43

View File

@@ -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

View File

@@ -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
## 终端输出
@@ -44,6 +44,6 @@
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层
- **StateDot 没有 `Active` 变体**:支持的状态为 donewarningongoingerror。
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard``copyLabel`/`copiedLabel`)、`TerminalBlock``labels`)、`JsonTree``labels`)、`CodeBlock``copyLabel`/`copiedLabel`)、`MarkdownText``codeLabels`)、`JsonBlock``truncatedLabel`)、`ConnectionBanner``label`)和 `Modal``closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
- **`TerminalBlock` 不是终端模拟器**它渲染已结束或仍在运行的命令输出而不是交互式会话SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token保持字面 rgb。

View File

@@ -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",

View File

@@ -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 {

View File

@@ -0,0 +1,353 @@
/** 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'
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) {
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) {
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')
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) {
return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, afterCloseAttempt)(code)
}
if (markdownLineEnding(code)) {
effects.enter(types.lineEnding)
effects.consume(code)
effects.exit(types.lineEnding)
return between
}
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 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 {
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<Tokenizer>[0], closeOk: State, closeNok: State): State {
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)
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 tokenizeOpen(openEffects: Parameters<Tokenizer>[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
: 0
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')
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.dollarSign || !oddBackslashRun)) {
return effects.attempt(
{ partial: true, tokenize: tokenizeClosingFence },
closed,
afterClosingFenceAttempt,
)(code)
}
if (markdownLineEnding(code)) {
return multiline
? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
: nok(code)
}
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 },
closed,
initialSize
? factorySpace(effects, content, types.linePrefix, initialSize + 1)
: content,
)(code)
}
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
}
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)
}
oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false
effects.consume(code)
return value
}
function closed(code: number | null): State | undefined {
effects.exit('mathFlow')
return ok(code)
}
function tokenizeClosingFence(
closeEffects: Parameters<Tokenizer>[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)
}
}
function tokenizeOpeningFence(
openEffects: Parameters<Tokenizer>[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 {
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 {
/* 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)
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 for remark-math.
* The same processor must register remark-math to compile the emitted math tokens.
* @returns Nothing.
*/
export function remarkMathCompatibility(this: RemarkProcessor): undefined {
const data = this.data()
const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
extensions.push(backslashMath)
}

View File

@@ -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,161 @@ 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(<MarkdownText text={source} />)
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: String.raw`\\\(x\)`,
math: 1,
display: 0,
value: 'x',
},
{
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> \\]',
math: 1,
display: 1,
},
{
source: '- \\[\n \\frac{1}{5}\n \\]',
math: 1,
display: 1,
},
]
for (const item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
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()
}
const literal = render(<MarkdownText text={'\\\\(x\\)\n\n\\[x'} />)
expect(literal.container.querySelectorAll('.katex')).toHaveLength(0)
expect(literal.container.querySelector('.katex-display')).toBeNull()
expect(literal.container.textContent).toContain('[x')
})
it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => {
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 item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
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(<MarkdownText text={source} />)
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(<MarkdownText text="$$x$$ trailing" />)
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\$$$`,
'',
String.raw`\(a\\\)`,
'',
String.raw`\[b\\\]`,
].join('\n')
const { container } = render(<MarkdownText text={source} />)
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(<MarkdownText text={'\\(x '.repeat(6_400)} />)
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(<MarkdownText text={source} />)
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 })
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$$'

15
pnpm-lock.yaml generated
View File

@@ -1700,6 +1700,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

View File

@@ -37,6 +37,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",