From 845c155faa5085eff744c46147bef8570b1fae16 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 1 Oct 2026 22:32:50 +0800 Subject: [PATCH] fix(client): document image load tag contract and drop URL.parse --- apps/web/tests/markdown-image-refresh.e2e.ts | 1 + packages/client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/markdown/MarkdownDelegate.tsx | 5 ++++- .../ui-primitives/src/markdown/render.tsx | 17 ++++++++++++----- .../markdown-image-entries.client.spec.tsx | 7 +++++++ 7 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/markdown-image-refresh.e2e.ts b/apps/web/tests/markdown-image-refresh.e2e.ts index 1f096a0f98..bade81cd1e 100644 --- a/apps/web/tests/markdown-image-refresh.e2e.ts +++ b/apps/web/tests/markdown-image-refresh.e2e.ts @@ -82,5 +82,6 @@ describe('web e2e: rewritten Markdown image files', () => { await page.getByText('Second chart', { exact: true }).last().waitFor() await expect.poll(width, { timeout: 10_000 }).toBe(240) expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) }, 60_000) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index f5bfa48f42..f0d3082bd9 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: ca5a411dc37b6603 - zh: 3ca6fe1a23326a05 + en: 76e9bd2f60ef7e13 + zh: 9b200ef2e50d2535 /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 5d49ee16c6..a1386fbc5b 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. 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` 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, and every remount requests the file again. 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 ac77ac6946..a6e380fc1a 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` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为重写后的非 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` 渲染不可信的 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/MarkdownDelegate.tsx b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx index 681e8f5608..837d9a191c 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx @@ -11,7 +11,10 @@ export type MarkdownExternalLinkHandler = (href: string) => void /** Navigation capabilities supplied by the nearest Markdown owner. */ export interface MarkdownDelegate { - /** Image previews for decoded local paths in this owner's workspace. */ + /** + * Image previews for decoded local paths in this owner's workspace. The renderer + * appends a per-mount `load` query parameter to parsable non-data, non-blob URLs. + */ readonly fileImages?: { resolve: (path: string) => string | undefined labels: ImageLightboxLabels & { open: string; loading: string; failed: string } diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index eeb2fc788b..a6caeace83 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -98,6 +98,7 @@ function vocabularyImageUrl(url: string): string | undefined { * caller. * @param url - The authored markdown destination. * @param pathImages - Rewriting vocabulary, when the render pass has one. + * @param load - The mounted image's load number for rewritten URLs. * @returns The displayable image URL, or undefined. */ function imageSource(url: string, pathImages: MarkdownPathImages | undefined, load: number): string | undefined { @@ -108,6 +109,8 @@ function imageSource(url: string, pathImages: MarkdownPathImages | undefined, lo return allowed === undefined ? undefined : freshImageUrl(allowed, load) } +// A module counter, not useId: useId derives from tree position, so images at +// the same position in two sessions would share a URL. let imageLoads = 0 const nextImageLoad = (): number => ++imageLoads @@ -116,12 +119,14 @@ const nextImageLoad = (): number => ++imageLoads * 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. + * @param load - Number allocated once per mounted image or file link. + * @returns The URL with a `load` query parameter; unparsable URLs stay unchanged. */ function freshImageUrl(url: string, load: number): string { - const fresh = URL.parse(url) - if (fresh === null || fresh.protocol === 'data:' || fresh.protocol === 'blob:') return url + let fresh: URL + try { fresh = new URL(url) } + catch { return url } // Only an unparsable owner-supplied URL throws; it is emitted as before. + if (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 @@ -178,7 +183,9 @@ export interface MarkdownPathImages { * Resolve one authored image destination. * @param value - The destination exactly as the markdown author wrote it. * @returns A displayable absolute URL, or undefined when the destination - * names no displayable image — it then stays inert alt text. + * names no displayable image — it then stays inert alt text. The renderer + * appends a per-mount `load` query parameter to results other than data and + * blob URLs. */ resolve(value: string): string | undefined } diff --git a/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx index 3379a4f5e3..86d05fce48 100644 --- a/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx @@ -133,6 +133,13 @@ it('keeps an unavailable image link navigable without showing a hover preview', expect(openFile).toHaveBeenCalledWith('relative.png', undefined) }) +it('emits an unparsable file image URL unchanged', () => { + render( 'relative.png' }}> + + ) + expect(screen.getByRole('img').getAttribute('src')).toBe('relative.png') +}) + it('labels empty-alt image activation and retains a localized failure description', () => { mount('![](image.png)') expect(screen.getByRole('button', { name: 'View full image' })).toBeTruthy()