From 3b97c7b3186b316fc5ef5cfdce1afa428c93f388 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 15:54:10 +0800 Subject: [PATCH 1/5] fix(web): compress session logs by default --- .../2026-07-19-zstandard-jsonl-session-logs.i18n.yaml | 4 ++-- .../2026-07-19-zstandard-jsonl-session-logs.md | 2 +- .../2026-07-19-zstandard-jsonl-session-logs.zh.md | 2 +- packages/host/runtime/src/boot.ts | 2 +- packages/host/runtime/tests/host-runtime.spec.ts | 7 +++++++ 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml index 51d3aa867d..ba5bae421d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.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 -2026-07-19-zstandard-jsonl-session-logs.md: ccfc81dd47504e6a9e9b19cda7c4b9fc40accecc -2026-07-19-zstandard-jsonl-session-logs.zh.md: de5436a6eaefcb45e52e0ff4fea8592c7efcd127 +2026-07-19-zstandard-jsonl-session-logs.md: 74430624c771a265fb281e588e28733bc55d3eb6 +2026-07-19-zstandard-jsonl-session-logs.zh.md: b22275d1a7c54a743b11f4396318dd87e4f5b42a diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md index ccfc81dd47..74430624c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md @@ -36,7 +36,7 @@ EOF inside the final frame is a recoverable torn tail. Node's decoder is given t ### Consumers and verification -The CLI, ACP, and stdio app bundles expose symmetric `persistenceCompression` pass-through configuration. Snapshot recording and replay compositions select `'none'` explicitly because committed fixtures are raw JSONL inputs to replay and normalization; ordinary runtime compositions use the compressed default. +The CLI, ACP, and stdio app bundles expose symmetric `persistenceCompression` pass-through configuration. The web host assembly and ordinary app compositions omit the option and use the compressed default. Snapshot recording and replay compositions select `'none'` explicitly because committed fixtures are raw JSONL inputs to replay and normalization. The shared persistence and coordinator contracts run against both encodings. Backend tests cover standard framing and checksum interoperability, header-only listing, append rollback, encoding mismatch rejection, complete-frame corruption, and final-frame tears through headers, blocks, and checksum trailers. Default runtime, built-bin, headless, ACP, and Python smokes assert the compressed suffix and Zstandard magic or decode the header; raw-content tests opt out explicitly. diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md index de5436a6ea..b22275d1a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md @@ -36,7 +36,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量 ### 消费方与验证 -CLI、ACP 与 stdio 应用包公开对称的 `persistenceCompression` 透传配置。快照录制与回放组合显式选择 `'none'`,因为提交的 fixture 是回放与规范化过程使用的原始 JSONL 输入;普通运行时组合使用压缩默认值。 +CLI、ACP 与 stdio 应用包公开对称的 `persistenceCompression` 透传配置。web 宿主装配与普通应用组合省略该选项并使用压缩默认值。快照录制与回放组合显式选择 `'none'`,因为提交的 fixture 是回放与规范化过程使用的原始 JSONL 输入。 共享持久化契约与协调器契约会针对两种编码运行。后端测试覆盖标准帧与校验和互操作性、仅头部列举、追加回滚、编码不匹配拒绝、完整帧损坏,以及横跨头部、块和校验和尾部的最终帧撕裂。默认运行时、构建后二进制、headless、ACP 与 Python 冒烟测试会断言压缩后缀与 Zstandard 魔数,或解码头部;读取原始内容的测试则显式退出压缩。 diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index ca40b9f8b3..2e03c769b5 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -95,7 +95,7 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(TaskService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, {}) - await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot, compression: 'none' }) + await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) await ctx.plugin(LocalBashExecutor, {}) // Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml + // the agent-spine bundle) so web sessions get the same coding-agent tool diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index f3a2dc9a82..51e7438b0e 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -93,6 +93,13 @@ describe('bootHost / startHost', () => { await handle.dispose() }) + it('uses the JSONL backend compressed default', async () => { + const handle: HostHandle = await bootHost({ persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-zstd-')) }) + const session = handle.ctx.sessions.create() + expect(handle.ctx.sessionPersistence.locate(session.header)?.path).toMatch(/\.jsonl\.zstd$/) + await handle.dispose() + }) + it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => { const running = await boot() expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' }) From 7d751d38944195fa591002122ec6986063542f7e Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 23 Jul 2026 16:04:32 +0800 Subject: [PATCH 2/5] test(web): provide workspace context config --- packages/host/runtime/tests/host-runtime.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index 4aac2960d6..ffde9641f0 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -105,7 +105,10 @@ describe('bootHost / startHost', () => { }) it('uses the JSONL backend compressed default', async () => { - const handle: HostHandle = await bootHost({ persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-zstd-')) }) + const handle: HostHandle = await bootHost({ + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-zstd-')), + workspaceContext: false, + }) const session = handle.ctx.sessions.create() expect(handle.ctx.sessionPersistence.locate(session.header)?.path).toMatch(/\.jsonl\.zstd$/) await handle.dispose() From 6f624c67c4d63247905e17b18b459771073f4d65 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 00:39:27 +0800 Subject: [PATCH 3/5] feat(web): render assistant Markdown --- ...026-07-23-web-assistant-markdown.i18n.yaml | 6 + .../2026-07-23-web-assistant-markdown.md | 35 +++ .../2026-07-23-web-assistant-markdown.zh.md | 35 +++ apps/web/tests/smoke-fixture.e2e.ts | 20 ++ .../client/connection/src/client/fixture.ts | 44 ++- .../src/client/chat/AssistantMarkdown.tsx | 4 +- .../ui-conversation/tests/chat-view.spec.tsx | 38 +++ packages/client/ui-primitives/README.md | 7 +- packages/client/ui-primitives/package.json | 4 +- packages/client/ui-primitives/src/index.ts | 1 + .../src/markdown/MarkdownText.module.css | 123 +++++++++ .../src/markdown/MarkdownText.tsx | 64 +++++ .../src/markdown/MessageText.tsx | 2 +- .../ui-primitives/tests/markdown.spec.tsx | 85 +++++- pnpm-lock.yaml | 258 ++++++++++++++++++ 15 files changed, 712 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md create mode 100644 packages/client/ui-primitives/src/markdown/MarkdownText.module.css create mode 100644 packages/client/ui-primitives/src/markdown/MarkdownText.tsx 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 new file mode 100644 index 0000000000..1ff9ecac7d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-23-web-assistant-markdown.md: ce98a16fa43e2743c18826ee7f2344c38e7c70e7 +2026-07-23-web-assistant-markdown.zh.md: 0d6fd2f9e6b91f76830586ecf4c29774e5d6978a 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 new file mode 100644 index 0000000000..ce98a16fa4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -0,0 +1,35 @@ +# Agent Note: Safe assistant Markdown in the Web conversation + +Status: implemented + +English | [中文](2026-07-23-web-assistant-markdown.zh.md) + +## Problem + +The Web conversation preserves assistant Markdown source through session events, history replay, and streaming accumulation, but its terminal text primitive renders that source literally. Changing the shared primitive would also format user and steering messages, while parsing in the runtime would mix presentation state into the React-free session projection. + +## Decision + +`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. + +`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 `dangerouslySetInnerHTML`, raw-HTML parsing, or syntax highlighting. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser is part of the initial browser bundle. + +## Untrusted output policy + +Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. + +The renderer uses existing `--dsw-*` typography and color tokens. Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. + +## Alternatives considered + +**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. + +**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored surfaces remain literal until the product chooses that behavior explicitly. + +**Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. + +**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies. + +## 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. The initial Web shell grows by the Markdown parser and GFM runtime, and future extensions such as syntax highlighting or remote media require a separate bundle and security decision. 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 new file mode 100644 index 0000000000..0d6fd2f9e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Web 对话中安全的 assistant Markdown + +Status: implemented + +[English](2026-07-23-web-assistant-markdown.md) | 中文 + +## 问题 + +Web 对话通过会话事件、历史回放与流式累积保留 assistant Markdown 源文本,但其最末端的文本原语会按字面渲染源文本。若修改共享原语,用户消息与 steering(中途引导)消息也会被格式化;若在运行时中解析,则会把呈现状态混入不依赖 React 的会话投影。 + +## 决策 + +`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 + +`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它支持 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,但不使用 `dangerouslySetInnerHTML`,不解析原始 HTML,也不进行语法高亮。`ui-primitives` 显式声明该依赖;由于这一纯库由 Web shell 预置,解析器会成为初始浏览器 bundle 的一部分。 + +## 不受信任输出策略 + +assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。 + +渲染器使用现有的 `--dsw-*` 排版与颜色 token。围栏代码块与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 + +## 考虑过的替代方案 + +**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。 + +**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。 + +**将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 + +**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 + +## 后果 + +assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。初始 Web shell 的体积会因加入 Markdown 解析器与 GFM 运行时而增大;语法高亮或远程媒体等后续扩展需要另行作出 bundle 与安全决策。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 3fccde9a79..6c196fe922 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -196,6 +196,26 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1) }) + it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream')) + await page.getByRole('button', { name: 'New Session', exact: true }).click() + const input = page.locator('textarea[placeholder]') + await input.waitFor({ timeout: 15_000 }) + await input.fill('render markdown') + await page.getByRole('button', { name: '发送' }).click() + + const streaming = page.locator('[data-streaming="true"]') + await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 }) + await streaming.waitFor({ state: 'detached', timeout: 15_000 }) + + const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' }) + expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1') + expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1) + const external = page.getByRole('link', { name: 'DeepSeek' }) + expect(await external.getAttribute('target')).toBe('_blank') + expect(await external.getAttribute('rel')).toBe('noopener noreferrer') + }) + it('stayed clean: no page errors across the whole load chain', () => { expect(pageErrors).toEqual([]) }) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index bbf4809466..20312b41a3 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -24,6 +24,28 @@ function text(t: string): ContentBlock[] { return [{ type: 'text', text: t }] } +const MARKDOWN_FIXTURE = [ + '# Markdown fixture', + '', + 'Assistant output renders **strong text**, *emphasis*, and `inline code`.', + '', + '- first item', + ' - nested item', + '', + '| Surface | State |', + '| --- | --- |', + '| history | rendered |', + '| streaming | stable |', + '', + '[DeepSeek](https://www.deepseek.com)', + '', + '```ts', + 'const markdown = true', + '```', +].join('\n') + +const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)' + function sid(id: string): SessionId { return id as SessionId } @@ -40,7 +62,13 @@ function buildAlphaLog(): SessionEvent[] { } for (let turn = 0; turn < 60; turn++) { push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) - push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } }) + push({ + type: 'user/message', surfaceOp: 'append', + data: { + content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), + source: { kind: 'user' }, + }, + }) if (turn % 9 === 4) { push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) } @@ -49,7 +77,7 @@ function buildAlphaLog(): SessionEvent[] { const withReasoning = turn % 3 === 1 const blocks: ContentBlock[] = [] if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` }) - blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` }) + blocks.push({ type: 'text', text: turn === 59 ? MARKDOWN_FIXTURE : `回答 ${turn}:这是 fixture 生成的历史回复正文。` }) if (withTool) { const callId = `fx-call-${turn}` blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock) @@ -343,8 +371,8 @@ export function createFixtureApi(): ApiProxy { const step = 0 append(id, { type: 'step/start', data: { turn, step } }) append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) - /* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */ - const pieces = replyText.match(/.{1,6}/gu) ?? [replyText] + /* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */ + const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText] let i = 0 const finish = (aborted: boolean): void => { replays.delete(id) @@ -410,7 +438,13 @@ export function createFixtureApi(): ApiProxy { setRunning(id, true) append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } }) - startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`) + startReply( + id, + turn, + userText === 'render markdown' + ? MARKDOWN_FIXTURE + : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, + ) return ok(request, { accepted: true as const }) }, cancel: (request) => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 7bb9a22e3e..90eb3e3bee 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -7,7 +7,7 @@ import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' -import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
{blocks.map((block, i) => { switch (block.kind) { - case 'text': return + case 'text': return case 'reasoning': return // Tool-call heads render as tool rows in the chat view's grouping pass. case 'tool-call': return null diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 4d5cd923d6..45c3b3f6ca 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -157,6 +157,44 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) + it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { + const markdown = '# Rendered\n\n- **one**\n- `two`' + const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) + const view = render() + expect(view.container.querySelectorAll('h1')).toHaveLength(1) + const literal = view.getByText((_content, element) => ( + element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown + )) + expect(literal.querySelector('h1')).toBeNull() + + act(() => { + h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } }) + }) + expect(view.container.querySelectorAll('h1')).toHaveLength(2) + expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered') + + act(() => { + h.set({ + nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)], + partial: null, + }) + }) + expect(view.container.querySelectorAll('h1')).toHaveLength(2) + expect(view.container.querySelector('[data-streaming="true"]')).toBeNull() + + act(() => { + h.set({ + nodes: [ + user(1, markdown), + assistant(2, markdown), + { ...assistant(3, markdown), interrupted: true }, + ], + }) + }) + expect(view.getByText('已停止')).toBeTruthy() + expect(view.container.querySelectorAll('h1')).toHaveLength(2) + }) + it('streaming partial frames re-render only the tail (Profiler count)', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')], diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 29883df236..da6382be4b 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -1,6 +1,10 @@ # @deepseek-ai/dsh-client-ui-primitives -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/JsonBlock). Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8. + +## Markdown rendering + +`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. ## Model Experience @@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **MessageText renders plain text** — markdown support swaps this component's internals later; consumers must not assume block structure. diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 2d23ecd330..fda27fdd4d 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -21,7 +21,9 @@ "license": "BSD-3-Clause", "dependencies": { "clsx": "^2.0.0", - "react": "^18.2.0" + "react": "^18.2.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index daea4202b3..b1a7a9b1ac 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -15,5 +15,6 @@ export type { MenuItem } from './Menu.tsx' export { ConnectionBanner } from './ConnectionBanner.tsx' export { FishLogo } from './FishLogo.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' +export { MarkdownText } from './markdown/MarkdownText.tsx' export { MessageText } from './markdown/MessageText.tsx' export * from './icons/index.tsx' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css new file mode 100644 index 0000000000..36b1dc2b55 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -0,0 +1,123 @@ +.markdown { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; + overflow-wrap: anywhere; + font: var(--dsw-font-markdown-base); +} + +.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) { + margin: 0; +} + +.markdown h1 { + font: var(--dsw-font-markdown-h1); +} + +.markdown h2 { + font: var(--dsw-font-markdown-h2); +} + +.markdown h3 { + font: var(--dsw-font-markdown-h3); +} + +.markdown :where(h4, h5, h6) { + font: var(--dsw-font-markdown-h4); +} + +.markdown :where(strong, th) { + font-weight: var(--dsw-font-markdown-base-strong-font-weight); +} + +.markdown :where(ul, ol) { + padding-inline-start: 24px; +} + +.markdown li + li { + margin-block-start: 4px; +} + +.markdown li > :where(ul, ol) { + margin-block-start: 4px; +} + +.markdown blockquote { + padding-inline-start: 12px; + border-inline-start: 3px solid var(--dsw-alias-markdown-citation); + color: var(--dsw-alias-label-secondary); +} + +.markdown a { + color: var(--dsw-alias-state-business-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.markdown :not(pre) > code { + padding: 2px 4px; + border-radius: 4px; + background: var(--dsw-alias-markdown-inline-code); + font: var(--dsw-font-markdown-code); +} + +.markdown pre { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + padding: 12px 16px; + border-radius: 8px; + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block); +} + +.markdown pre code { + padding: 0; + background: transparent; + font: inherit; + overflow-wrap: normal; + word-break: normal; + white-space: pre; +} + +.markdown hr { + width: 100%; + border: 0; + border-block-start: 1px solid var(--dsw-alias-markdown-citation); +} + +.markdown input[type='checkbox'] { + margin: 0 8px 0 0; + accent-color: var(--dsw-alias-state-business-primary); +} + +.tableScroll { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; +} + +.tableScroll table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + font: var(--dsw-font-markdown-table); +} + +.tableScroll :where(th, td) { + padding: 6px 12px; + border: 1px solid var(--dsw-alias-markdown-citation); + text-align: start; + white-space: nowrap; +} + +.tableScroll th { + background: var(--dsw-alias-markdown-code-block-banner); + font: var(--dsw-font-markdown-table-head); +} + +.imageAlt { + color: var(--dsw-alias-label-tertiary); + font-style: italic; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx new file mode 100644 index 0000000000..425e3969ab --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -0,0 +1,64 @@ +import ReactMarkdown from 'react-markdown' +import type { Components, UrlTransform } from 'react-markdown' +import remarkGfm from 'remark-gfm' +import css from './MarkdownText.module.css' + +const remarkPlugins = [remarkGfm] + +function sanitizeUrl(url: string): string { + try { + switch (new URL(url).protocol) { + case 'http:': + case 'https:': + case 'mailto:': + return url + default: + return '' + } + } catch { + return '' + } +} + +const safeUrl: UrlTransform = url => sanitizeUrl(url) + +const components: Components = { + a: ({ href = '', children }) => { + const safeHref = sanitizeUrl(href) + if (safeHref === '') return <>{children} + const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) + return ( + + {children} + + ) + }, + img: ({ alt = '' }) => {alt}, + table: ({ children }) => ( +
+ {children}
+
+ ), +} + +/** + * Render untrusted assistant-authored Markdown as semantic React elements. + * @param props - Markdown source text preserved by the session projection. + * @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled. + */ +export function MarkdownText({ text }: { text: string }) { + return ( +
+ + {text} + +
+ ) +} diff --git a/packages/client/ui-primitives/src/markdown/MessageText.tsx b/packages/client/ui-primitives/src/markdown/MessageText.tsx index e9fa76d823..cafe9ab3c0 100644 --- a/packages/client/ui-primitives/src/markdown/MessageText.tsx +++ b/packages/client/ui-primitives/src/markdown/MessageText.tsx @@ -1,4 +1,4 @@ -// MessageText: the single text-block rendering point (Markdown support later = swap this component's internals, zero card-structure changes). +// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText. import css from './MessageText.module.css' diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 6cfb5f4e8e..4e1dc292d4 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -1,14 +1,93 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('MessageText', () => { it('renders the text verbatim', () => { - const { container } = render() - expect(container.textContent).toBe('line1\nline2') + const { container } = render() + expect(container.textContent).toBe('# line1\n`line2`') + expect(container.querySelector('h1')).toBeNull() + }) +}) + +describe('MarkdownText', () => { + it('renders CommonMark and GFM elements as semantic DOM', () => { + const markdown = [ + '# Heading', + '', + 'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ', + 'Hard break.', + '', + '> Quote', + '', + '- parent', + ' - child', + '', + '1. first', + '2. second', + '', + '- [x] done', + '- [ ] pending', + '', + '| Name | Value |', + '| --- | --- |', + '| alpha | beta |', + '', + '---', + '', + '```ts', + 'const answer = 42', + '```', + '', + '', + ].join('\n') + const { container } = render() + + expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy() + expect(container.querySelector('strong')?.textContent).toBe('strong') + expect(container.querySelector('em')?.textContent).toBe('emphasis') + expect(container.querySelector('del')?.textContent).toBe('deleted') + expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote') + expect(container.querySelectorAll('ul')).toHaveLength(3) + expect(container.querySelector('ol')).not.toBeNull() + expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2) + expect(container.querySelector('table')?.textContent).toContain('alphabeta') + expect(container.querySelector('hr')).not.toBeNull() + expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42') + expect(container.querySelector('br')).not.toBeNull() + expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank') + expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() + }) + + it('neutralizes raw HTML, unsafe or relative links, and remote images', () => { + const markdown = [ + '', + '', + '[script](javascript:alert(1)) [relative](/settings)', + '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)', + '![remote diagram](https://example.com/private.png)', + ].join('\n\n') + const { container } = render() + + expect(container.querySelector('script')).toBeNull() + expect(container.querySelector('img')).toBeNull() + const neutralized = [...container.querySelectorAll('p')] + .find(paragraph => paragraph.textContent === 'script relative') + expect(neutralized?.querySelector('a')).toBeNull() + expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull() + expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer') + expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank') + expect(screen.getByText('remote diagram')).toBeTruthy() + }) + + it('keeps incomplete streaming Markdown renderable', () => { + const { container } = render() + expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy() + expect(container.querySelectorAll('li')).toHaveLength(2) + expect(screen.getByText('**unfinished')).toBeTruthy() }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a0cb4be16..66b9e9478a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -614,6 +614,12 @@ importers: react: specifier: ^18.2.0 version: 18.3.1 + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@18.3.31)(react@18.3.1) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6472,6 +6478,9 @@ packages: '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -6537,6 +6546,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -6830,6 +6842,9 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -6918,6 +6933,9 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -7383,6 +7401,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -7611,6 +7632,9 @@ packages: hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -7631,6 +7655,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -7682,6 +7709,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -7697,6 +7727,15 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -7709,6 +7748,13 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -8099,6 +8145,15 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -8416,6 +8471,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -8550,6 +8608,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -8582,6 +8646,18 @@ packages: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -8791,6 +8867,12 @@ packages: strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -8853,6 +8935,9 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -8969,6 +9054,9 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -11090,6 +11178,10 @@ snapshots: '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} '@types/geojson@7946.0.16': {} @@ -11154,6 +11246,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.21': {} @@ -11529,6 +11623,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -11609,6 +11705,8 @@ snapshots: character-entities@2.0.2: {} + character-reference-invalid@2.0.1: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -12159,6 +12257,8 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -12433,6 +12533,26 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.5 @@ -12451,6 +12571,8 @@ snapshots: html-escaper@2.0.2: {} + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} http-errors@2.0.1: @@ -12499,6 +12621,8 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + internmap@1.0.1: {} internmap@2.0.3: {} @@ -12507,6 +12631,15 @@ snapshots: ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -12515,6 +12648,10 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -12934,6 +13071,45 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -13401,6 +13577,16 @@ snapshots: pako@1.0.11: {} + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -13523,6 +13709,24 @@ snapshots: react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 18.3.31 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 18.3.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-refresh@0.17.0: {} react@18.3.1: @@ -13560,6 +13764,40 @@ snapshots: '@eslint-community/regexpp': 4.12.2 refa: 0.12.1 + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} @@ -13844,6 +14082,14 @@ snapshots: dependencies: anynum: 1.0.0 + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + stylis@4.4.0: {} superjson@2.2.6: @@ -13891,6 +14137,8 @@ snapshots: trim-lines@3.0.1: {} + trough@2.2.0: {} + ts-algebra@2.0.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -13984,6 +14232,16 @@ snapshots: undici@7.28.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 From 0b0492e8ae402698b1c480fcd607b5963e64d1fa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 19:23:35 +0800 Subject: [PATCH 4/5] test(web): align Markdown smoke with master --- apps/web/tests/smoke-fixture.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 6c196fe922..4e489b264f 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -198,7 +198,7 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => { onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream')) - await page.getByRole('button', { name: 'New Session', exact: true }).click() + await page.getByRole('button', { name: 'New session', exact: true }).click() const input = page.locator('textarea[placeholder]') await input.waitFor({ timeout: 15_000 }) await input.fill('render markdown') From d8051f82f61d606675f44ac79c262b2063d65431 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 19:29:23 +0800 Subject: [PATCH 5/5] test(client): cover Markdown fixture reply --- packages/client/connection/tests/fixture.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index c50921a44d..c9c90f1ae0 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -113,7 +113,7 @@ describe('createFixtureApi', () => { const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } }) // Real prompt: replay starts (running flips true), cancel freezes it. - const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] })) + const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] })) expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks await api.sessions.cancel(req({ sessionId: id }))