From 3b0ad0aad4953f1b1aea9f9e59a1eee4a684b718 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 1 Oct 2026 22:12:35 +0800 Subject: [PATCH 1/5] fix(client): reload overwritten local Markdown images in new renders Browsers reuse a same-URL image within one document despite no-store, so a newly rendered reply showing an overwritten file kept the earlier bytes. Tag each mounted rewritten local image URL with a per-mount load number. --- apps/web/tests/markdown-image-refresh.e2e.ts | 86 +++++++++++++++++++ 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 | 2 +- .../ui-primitives/src/markdown/render.tsx | 32 ++++++- .../markdown-image-entries.client.spec.tsx | 7 +- .../markdown-path-images.client.spec.tsx | 25 ++++-- tsconfig.host.json | 1 + 9 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 apps/web/tests/markdown-image-refresh.e2e.ts diff --git a/apps/web/tests/markdown-image-refresh.e2e.ts b/apps/web/tests/markdown-image-refresh.e2e.ts new file mode 100644 index 0000000000..1f096a0f98 --- /dev/null +++ b/apps/web/tests/markdown-image-refresh.e2e.ts @@ -0,0 +1,86 @@ +// A rewritten local image file shows its current bytes in a newly rendered Markdown image. +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +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 { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts' + +const ALT = 'Rewritten chart' +const chart = (width: number, color: string): string => + `` + +/** Build one closed session whose reply shows `{{cwd}}/chart.svg`. */ +function chartFixture(title: string): string { + const session = Session.create(SessionId('markdown-image-refresh-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Draw the chart.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { title, messageSeqs: [user.seq], source: { kind: 'fallback' } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + stream: [], + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: `${title}\n\n![${ALT}]({{cwd}}/chart.svg)` }], + 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}}', isSeeded: false, delegationDepth: 0, + } + return [JSON.stringify(header), ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: WEB_FIXTURE_TIME + event.seq * 1_000 })), ''].join('\n') +} + +describe('web e2e: rewritten Markdown image files', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await writeFile(join(scaffold.workspaceCwd, 'chart.svg'), chart(120, 'red')) + await seedSession(scaffold, chartFixture('First chart'), 'markdown-image-refresh-first', undefined, + { createdAt: WEB_FIXTURE_TIME - 120_000 }) + await seedSession(scaffold, chartFixture('Second chart'), 'markdown-image-refresh-second', undefined, + { createdAt: WEB_FIXTURE_TIME - 60_000 }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('loads the current file for an image mounted after the file changes', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-image-refresh')) + const width = () => page.getByRole('img', { name: ALT }).evaluate(element => (element as HTMLImageElement).naturalWidth) + await page.getByRole('treeitem').first().click() + // Seeded sessions list newest first under the expanded workspace. + await page.getByRole('treeitem').nth(2).click() + await page.getByText('First chart', { exact: true }).last().waitFor() + await expect.poll(width, { timeout: 10_000 }).toBe(120) + + await writeFile(join(scaffold.workspaceCwd, 'chart.svg'), chart(240, 'blue')) + await page.getByRole('treeitem').nth(1).click() + await page.getByText('Second chart', { exact: true }).last().waitFor() + await expect.poll(width, { timeout: 10_000 }).toBe(240) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 5b5e00638b..6f5229ee59 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -136,6 +136,7 @@ "tests/fork-mid-turn.e2e.ts", "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", + "tests/markdown-image-refresh.e2e.ts", "tests/markdown-file-links.e2e.ts", "tests/reference-composer.e2e.ts", "tests/markdown-wide-table.e2e.ts", diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 7e76a25bf6..f5bfa48f42 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -24,8 +24,8 @@ en: f0e54915b896af5a zh: 2047520fd321be90 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: d79e20306593c4bf - zh: 5c0f1100e2cd6fcc + en: ca5a411dc37b6603 + zh: 3ca6fe1a23326a05 /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: 4aef65dea11de083 zh: abc22cd7c65cdf38 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4f7a1ef892..5d49ee16c6 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -95,7 +95,7 @@ The catalog above lists what each export is for; this section covers the behavio The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file` route; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. Reference labels inherit the consumer’s wrapping policy: long names wrap within a bubble, while queue previews retain their single-line layout. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected. +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file` route; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a rewritten non-data, non-blob URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. Reference labels inherit the consumer’s wrapping policy: long names wrap within a bubble, while queue previews retain their single-line layout. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected. `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e3e49703c0..ac77ac6946 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -95,7 +95,7 @@ kind: "package-library" 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。引用名称继承调用方的换行规则:长名称在气泡内换行,排队预览保持单行布局。传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为重写后的非 data、非 blob URL 追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。引用名称继承调用方的换行规则:长名称在气泡内换行,排队预览保持单行布局。传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index cdb9afcbda..eeb2fc788b 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -100,11 +100,31 @@ function vocabularyImageUrl(url: string): string | undefined { * @param pathImages - Rewriting vocabulary, when the render pass has one. * @returns The displayable image URL, or undefined. */ -function imageSource(url: string, pathImages: MarkdownPathImages | undefined): string | undefined { +function imageSource(url: string, pathImages: MarkdownPathImages | undefined, load: number): string | undefined { const remote = remoteImageUrl(sanitizeUrl(normalizeUri(url))) if (remote !== undefined) return remote const rewritten = pathImages?.resolve(url) - return rewritten === undefined ? undefined : vocabularyImageUrl(rewritten) + const allowed = rewritten === undefined ? undefined : vocabularyImageUrl(rewritten) + return allowed === undefined ? undefined : freshImageUrl(allowed, load) +} + +let imageLoads = 0 +const nextImageLoad = (): number => ++imageLoads + +/** + * Tag a rewritten local image URL with its mount's load number. Browsers reuse + * a same-URL image within one document despite `no-store`, so without the tag + * a newly rendered image of a rewritten file would show the earlier bytes. + * @param url - Absolute rewritten image URL; data and blob URLs stay unchanged. + * @param load - Number allocated once per mounted image. + * @returns The URL with a `load` query parameter. + */ +function freshImageUrl(url: string, load: number): string { + const fresh = URL.parse(url) + if (fresh === null || fresh.protocol === 'data:' || fresh.protocol === 'blob:') return url + // Appending preserves the resolver's existing query encoding. + fresh.search = `${fresh.search === '' ? '?' : `${fresh.search}&`}load=${load}` + return fresh.href } /** Link/image reference targets collected from a document (first definition per identifier wins, as in CommonMark). */ @@ -584,10 +604,12 @@ function MarkdownFileLink({ file, glyph, children }: { readonly children: ReactNode[] }): ReactNode { const { openFile, fileImages } = useMarkdownDelegate() + const [load] = useState(nextImageLoad) if (openFile === undefined) return <>{children} // Pure-image anchors already contain their preview and keep one navigation target. const preview = glyph && classifyLinkPath(file.path) === 'image' ? fileImages : undefined - const src = preview?.resolve(file.path) + const resolved = preview?.resolve(file.path) + const src = resolved === undefined ? undefined : freshImageUrl(resolved, load) const anchor = (