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..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: 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 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..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 (`复制` / `复制成功`). 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..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` 提供语言横幅与复制控件(`复制` / `复制成功`)。引用胶囊、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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5b619ae893..f61278a39f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 | 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 54e4fe11d1..6fe1fe20ac 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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", diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index dabb138264..68f2f87258 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: d4e6c2508f07f4832f2e12a836c2d447b656421e -README.zh.md: 76dfbebd5e9db494b49d65a2528977b7ac9fed15 +README.md: 03e7e3649fd0913fb48579aa87634153f67f5baf +README.zh.md: 090ecc34e8d514e38853de8ed52e82d3bf019b43 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index d4e6c2508f..03e7e3649f 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 76dfbebd5e..090ecc34e8 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)。 ## 终端输出 @@ -44,6 +44,6 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 -- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 +- **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。 - **面向用户的文案经 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。 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..dcd8c32362 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts @@ -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[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[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[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[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) +} diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 10af104e5a..7a858c1199 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,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() + + 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() + 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() + 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() + 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\$$$`, + '', + 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() + + 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$$' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06c34b6752..8655baacee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tsconfig.host.json b/tsconfig.host.json index 7703100678..0847ad36ca 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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",