From e55a904e1d0b534a133cd998cc0918720fff0f5f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 20:26:33 +0800 Subject: [PATCH 1/2] feat(web): render remote Markdown images --- ...026-07-23-web-assistant-markdown.i18n.yaml | 6 +- .../2026-07-23-web-assistant-markdown.md | 4 +- .../2026-07-23-web-assistant-markdown.zh.md | 4 +- ...07-30-web-remote-markdown-images.i18n.yaml | 6 + .../2026-07-30-web-remote-markdown-images.md | 29 +++ ...026-07-30-web-remote-markdown-images.zh.md | 29 +++ apps/web/tests/markdown-images.e2e.ts | 205 ++++++++++++++++++ .../snapshots/markdown-images/ui.expected.md | 33 +++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 3 +- .../src/markdown/MarkdownText.module.css | 11 + .../src/markdown/MarkdownText.tsx | 27 ++- .../ui-primitives/tests/markdown.spec.tsx | 32 ++- tsconfig.host.json | 1 + 16 files changed, 381 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md create mode 100644 apps/web/tests/markdown-images.e2e.ts create mode 100644 apps/web/tests/snapshots/markdown-images/ui.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 1f52492649..656a52d300 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 @@ -1,6 +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: 38d193271d88b3a8f32ba1b191e8a6d432176281 -2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de +# 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 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 38d193271d..d5074e6090 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 @@ -20,7 +20,7 @@ The dependency is explicit in `ui-primitives`; because that pure library is seed ## 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. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). +Assistant-authored link 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 follow the separate [remote-image policy](2026-07-30-web-remote-markdown-images.md). Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. @@ -32,7 +32,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **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. +**Enable raw HTML with sanitization.** Raw HTML has no current product need and would enlarge the executable-content boundary, so it remains disabled rather than adding a sanitizer dependency. Remote images are governed by the later [image policy](2026-07-30-web-remote-markdown-images.md). **Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. 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 be3cd041c6..31f0fd6835 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 @@ -20,7 +20,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 不受信任输出策略 -assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 +assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片遵循独立的[远程图片策略](2026-07-30-web-remote-markdown-images.md)。由于流水线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 @@ -32,7 +32,7 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 -**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 +**通过净化启用原始 HTML。** 原始 HTML 当前没有产品需求,并且会扩大可执行内容边界,因此保持禁用,无需增加净化器依赖。远程图片由后续的[图片策略](2026-07-30-web-remote-markdown-images.md)约束。 **移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml new file mode 100644 index 0000000000..afe776d402 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.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 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md +2026-07-30-web-remote-markdown-images.md: 23dc699aab87597b7b5bfee83d0d745d4798303e +2026-07-30-web-remote-markdown-images.zh.md: 54db9b25e9f558d77c84bb67c05fadea2c9086ac diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md new file mode 100644 index 0000000000..23dc699aab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md @@ -0,0 +1,29 @@ +# Agent Note: Remote Web Markdown images + +Status: implemented + +English | [中文](2026-07-30-web-remote-markdown-images.zh.md) + +## Problem + +Assistant Markdown can name diagrams and screenshots with standard image syntax, but the Web renderer replaces every image with italic alt text. Even absolute HTTP(S) destinations therefore lose ordinary Markdown behavior. + +## Decision + +`MarkdownText` renders absolute HTTP(S) image destinations as lazy, responsive `` elements with asynchronous decoding and `referrerPolicy="no-referrer"`. Relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain the existing alt-text fallback. Raw HTML stays disabled, so an assistant cannot bypass the Markdown image component with a hand-authored ``. + +The image component reuses the renderer's absolute-URL policy without adding a host proxy, local-file route, Session dependency, sanitizer, or image fetcher. Finalized history, streaming output, interrupted partials, and every other `MarkdownText` consumer receive the same behavior. + +## Alternatives considered + +**Keep all images as alt text.** This preserves the smallest network boundary but defeats the product need to inspect network-hosted visual artifacts inline. + +**Proxy remote images through the host.** A proxy could hide the browser's network address from the image origin, but it would make the host perform arbitrary outbound fetches and require a separate redirect, DNS, size, and content policy. Direct HTTP(S) loading keeps that request visible to browser controls; omitting the referrer limits conversation-origin disclosure. + +**Support local paths in the same change.** Web origins cannot directly load host files. A safe implementation needs a separately reviewed authority boundary, so relative paths, absolute local paths, and `file:` URLs remain disabled. + +**Allow `data:` images.** Large data URLs duplicate binary content into durable transcript text. The HTTP(S)-only policy covers the current need without expanding session logs. + +## Consequences + +Assistant replies display remote images during streaming and replay without changing session events or host protocols. Remote origins still observe the image request, client network address, and any credentials that browser policy permits for that origin. Local and unsupported destinations remain inert alt text. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md new file mode 100644 index 0000000000..54db9b25e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 中的远程 Markdown 图片 + +Status: implemented + +[English](2026-07-30-web-remote-markdown-images.md) | 中文 + +## 问题 + +assistant Markdown 可以使用标准图片语法引用图表和截图,但 Web 渲染器会把每张图片替换为斜体替代文本。因此,即使目标地址是绝对 HTTP(S) URL,也无法获得普通的 Markdown 图片行为。 + +## 决策 + +`MarkdownText` 将绝对 HTTP(S) 图片目标地址渲染为延迟加载的响应式 `` 元素,并使用异步解码与 `referrerPolicy="no-referrer"`。相对路径、绝对本地路径、`file:` URL 与不支持的协议继续沿用现有的替代文本回退。原始 HTML 保持禁用,因此 assistant 无法通过手写 `` 绕过 Markdown 图片组件。 + +图片组件复用渲染器的绝对 URL 策略,不新增主机代理、本地文件路由、Session 依赖、净化器或图片抓取器。已完成的历史消息、流式输出、被中断的部分输出以及其他所有 `MarkdownText` 消费方均获得同一行为。 + +## 考虑过的替代方案 + +**将所有图片都保留为替代文本。** 这种方案维持了最小的网络边界,但无法满足在行内查看网络托管的视觉产物这一产品需求。 + +**通过主机代理远程图片。** 代理可以向图片源站隐藏浏览器的网络地址,但这会让主机执行任意出站请求,并且需要单独制定重定向、DNS、大小与内容策略。直接加载 HTTP(S) 图片可让浏览器控制机制继续观察该请求;不发送 referrer 可减少对话来源信息的暴露。 + +**在同一变更中支持本地路径。** Web 源无法直接加载主机文件。安全的实现需要单独评审的权限边界,因此相对路径、绝对本地路径与 `file:` URL 保持禁用。 + +**允许 `data:` 图片。** 大型 data URL 会将二进制内容以文本形式重复写入持久化的 transcript(文本记录)。仅允许 HTTP(S) 的策略足以满足当前需求,且不会扩大会话日志。 + +## 后果 + +assistant 回复会在流式输出与回放期间显示远程图片,且不改变会话事件或主机协议。远程源站仍可观察到图片请求、客户端网络地址,以及浏览器策略允许发送给该源站的任何凭据。本地及不支持的目标地址仍只显示不会发起请求的替代文本。 diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts new file mode 100644 index 0000000000..8dce7f405b --- /dev/null +++ b/apps/web/tests/markdown-images.e2e.ts @@ -0,0 +1,205 @@ +// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session +// assembled through the Session API is seeded cold into the real web +// composition, then a separate image origin proves that the browser receives +// a real network image while local-path Markdown remains inert alt text. +import { createServer, type Server } from 'node:http' +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/markdown-images', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-images/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-images-web-e2e' +const REMOTE_ALT = 'Remote test image' +const LOCAL_ALT = 'Local test image' +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) + +interface ImageOrigin { + server: Server + url: string + requests: Array<{ path: string | undefined; referer: string | undefined }> +} + +/** Start the deterministic remote image origin used by this browser scenario. */ +async function startImageOrigin(): Promise { + const requests: ImageOrigin['requests'] = [] + const server = createServer((request, response) => { + requests.push({ path: request.url, referer: request.headers.referer }) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': PNG.length, + 'content-type': 'image/png', + }) + response.end(PNG) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('image origin did not expose an IP socket') + } + return { + server, + url: `http://127.0.0.1:${String(address.port)}/image.png`, + requests, + } +} + +/** Stop one image origin after the browser and host release their requests. */ +async function stopServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} + +/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ +function markdownImageFixture(remoteUrl: string): string { + const session = new Session(SessionId('markdown-image-source')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the Markdown image policy.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Markdown image policy', + 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: [ + '## Markdown images', + '', + `![${REMOTE_ALT}](${remoteUrl})`, + '', + `![${LOCAL_ALT}](./local-image.png)`, + '', + 'REMOTE_IMAGE_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' } }) + + const header = { + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + } + return [ + JSON.stringify(header), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: remote Markdown image rendering', () => { + let scaffold: WebScaffold + let imageOrigin: ImageOrigin + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + imageOrigin = await startImageOrigin() + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, markdownImageFixture(imageOrigin.url), 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() + await stopServer(imageOrigin.server) + }) + + it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images')) + 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('REMOTE_IMAGE_DONE', { exact: true }).count(), { + timeout: 15_000, + }).toBe(1) + + const image = page.getByRole('img', { name: REMOTE_ALT }) + await image.waitFor({ timeout: 10_000 }) + await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), { + timeout: 10_000, + }).toBeGreaterThan(0) + expect(await image.evaluate((element) => { + const computed = getComputedStyle(element) + return { + borderRadius: computed.borderRadius, + decoding: element.getAttribute('decoding'), + loading: element.getAttribute('loading'), + maxWidth: computed.maxWidth, + referrerPolicy: element.getAttribute('referrerpolicy'), + } + })).toEqual({ + borderRadius: '8px', + decoding: 'async', + loading: 'lazy', + maxWidth: '100%', + referrerPolicy: 'no-referrer', + }) + expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0) + expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) + expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) + + 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/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md new file mode 100644 index 0000000000..cd33a09eb9 --- /dev/null +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -0,0 +1,33 @@ +- banner: + - navigation "Session hierarchy": + - button "Markdown image policy" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Show the Markdown image policy. {{clock}} +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- heading "Markdown images" [level=2] +- paragraph: + - img "Remote test image" +- paragraph: Local test image +- paragraph: REMOTE_IMAGE_DONE +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} +- textbox "Message the agent" +- button "Add attachment": + - img +- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- button "Plan mode off, press to turn on": Plan off +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - 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 7a7f228fb0..ce1f2105af 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -37,6 +37,7 @@ "tests/code-mode-round.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/markdown-images.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts" ], diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5e4b5c078..e24142ebcf 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: 0ef3c20f848b3d331c007911d0837f11cd72c024 -README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299 +README.md: 929d2edd9ddbd4610f84dd901486f74700cbf4e2 +README.zh.md: 5c6df2ce58e274a53b7792597259749f9a45d4ca diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ef3c20f84..929d2edd9d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## 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. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, 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 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 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 af94551bfb..5c6df2ce58 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -6,7 +6,8 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不携带 referrer 的情况下渲染绝对 HTTP(S) 图片;相对路径、绝对本地路径、`file:` URL 与不支持的协议仍保留其替代文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 + ## 终端输出 `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index a189528bc9..03bec019bd 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -224,3 +224,14 @@ color: var(--dsw-alias-label-tertiary); font-style: italic; } + +.image { + display: block; + width: auto; + max-width: 100%; + height: auto; + margin: 0; + border-radius: 8px; + background: var(--dsw-alias-bg-base); + object-fit: contain; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 639f53dbb1..ff6f543ebc 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -24,6 +24,15 @@ function sanitizeUrl(url: string): string { const safeUrl: UrlTransform = url => sanitizeUrl(url) +function remoteImageUrl(url: string): string | undefined { + try { + const protocol = new URL(url).protocol + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + return undefined + } +} + /** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ function buildComponents(streaming: boolean): Components { return { @@ -40,7 +49,20 @@ function buildComponents(streaming: boolean): Components { ) }, - img: ({ alt = '' }) => {alt}, + img: ({ alt = '', src = '' }) => { + const imageSrc = remoteImageUrl(src) + if (imageSrc === undefined) return {alt} + return ( + {alt} + ) + }, table: ({ children }) => (
{children}
@@ -74,7 +96,8 @@ const streamingComponents = buildComponents(true) * Render untrusted assistant-authored Markdown as semantic React elements. * @param props - Markdown source text preserved by the session projection; * `streaming` renders fences plain (highlighting lands on the finalize swap). - * @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled. + * @returns A GFM document with raw HTML, relative destinations, and unsafe + * protocols disabled; absolute HTTP(S) images render directly. */ export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) { return ( diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index b7f665c78a..40295e16cb 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -94,13 +94,35 @@ describe('MarkdownText', () => { expect(done.container.querySelector('pre.shiki')).not.toBeNull() }) - it('neutralizes raw HTML, unsafe or relative links, and remote images', () => { + it('renders absolute HTTP(S) images with bounded presentation', () => { + const markdown = [ + '![secure diagram](https://example.com/secure.png)', + '![plain diagram](http://example.com/plain.png)', + ].join('\n\n') + const { container } = render() + const images = [...container.querySelectorAll('img')] + expect(images.map(image => image.getAttribute('src'))).toEqual([ + 'https://example.com/secure.png', + 'http://example.com/plain.png', + ]) + for (const image of images) { + expect(image.getAttribute('loading')).toBe('lazy') + expect(image.getAttribute('decoding')).toBe('async') + expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') + } + }) + + it('neutralizes raw HTML, unsafe or relative links, and unsupported 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)', + '![relative diagram](private.png)', + '![absolute diagram](/workspace/private.png)', + '![file diagram](file:///workspace/private.png)', + '![script diagram](javascript:alert(1))', + '![mail diagram](mailto:dev@example.com)', ].join('\n\n') const { container } = render() @@ -112,7 +134,11 @@ describe('MarkdownText', () => { 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() + expect(screen.getByText('relative diagram')).toBeTruthy() + expect(screen.getByText('absolute diagram')).toBeTruthy() + expect(screen.getByText('file diagram')).toBeTruthy() + expect(screen.getByText('script diagram')).toBeTruthy() + expect(screen.getByText('mail diagram')).toBeTruthy() }) it('keeps incomplete streaming Markdown renderable', () => { diff --git a/tsconfig.host.json b/tsconfig.host.json index b32c410961..795f5ae8d5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -24,6 +24,7 @@ "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/cli/tests/**/*.ts", From 886c39d30200ed36dc8196bd14a673292bec038d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 16:21:02 +0800 Subject: [PATCH 2/2] test(web): refresh Markdown image snapshot --- .../snapshots/markdown-images/ui.expected.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index cd33a09eb9..76e01397c2 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -5,27 +5,24 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Show the Markdown image policy. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": - - img -- button "编辑": +- button "Branch into a new conversation": - img - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" - paragraph: Local test image - paragraph: REMOTE_IMAGE_DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img