From 7f0a53dfb338c49d20ca8beef231d9a466a748e0 Mon Sep 17 00:00:00 2001 From: Kaige-Gao Date: Thu, 24 Sep 2026 14:10:13 +0800 Subject: [PATCH] fix(desktop): render local Markdown images Resolve local image paths through the authenticated dsh-app file route and allow that route in rewritten Markdown images. Cover desktop rendering, previews, encoded filenames, and unsupported origins. Fixes #5105. --- .../expected/markdown-images/ui.expected.md | 3 ++ apps/web/tests/markdown-images.e2e.ts | 6 +++- .../src/client/chat/AssistantMarkdown.tsx | 2 +- ...stant-markdown-path-images.client.spec.tsx | 14 +++++--- .../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 | 3 +- .../markdown-image-entries.client.spec.tsx | 32 +++++++++++++++++++ .../markdown-path-images.client.spec.tsx | 15 +++++++++ .../src/client/markdown/path-images.ts | 2 +- .../tests/markdown-body.client.spec.tsx | 8 +++-- .../tests/markdown-path-images.client.spec.ts | 10 +++++- packages/util/workspace-path/README.i18n.yaml | 4 +-- packages/util/workspace-path/README.md | 2 +- packages/util/workspace-path/README.zh.md | 2 +- packages/util/workspace-path/src/index.ts | 4 +-- .../tests/file-media-url.spec.ts | 18 +++++++++-- 18 files changed, 107 insertions(+), 26 deletions(-) diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 31dee5c21a..e830dad629 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -27,6 +27,9 @@ - paragraph: - 'button "View full image: Encoded path"': - img "Encoded path" +- paragraph: + - 'button "View full image: Encoded filename"': + - img "Encoded filename" - paragraph: Image preview unavailable · Oversized image - paragraph: - 'button "View full image: Outside workspace image"': diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index bc24459a1e..845bc1a23f 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -118,6 +118,8 @@ function markdownImageFixture(remoteUrl: string, outsidePath: string): string { '', '![Encoded path]({{cwd}}/test%20workspace/测试图.png)', '', + '![Encoded filename]({{cwd}}/test%20workspace/测试%23100%25.png)', + '', '![Oversized image]({{cwd}}/oversized.png)', '', `![Outside workspace image](${outsidePath})`, @@ -173,6 +175,7 @@ describe('web e2e: Markdown image rendering', () => { await writeFile(join(scaffold.workspaceCwd, 'local-image.png'), PNG) await mkdir(join(scaffold.workspaceCwd, 'test workspace')) await writeFile(join(scaffold.workspaceCwd, 'test workspace/测试图.png'), PNG) + await writeFile(join(scaffold.workspaceCwd, 'test workspace/测试#100%.png'), PNG) await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image') const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w') try { @@ -250,7 +253,7 @@ describe('web e2e: Markdown image rendering', () => { referrerPolicy: 'no-referrer', }) await expect.poll(() => page.getByRole('img', { name: LOCAL_ALT }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) - for (const name of ['Space path', 'Encoded path']) { + for (const name of ['Space path', 'Encoded path', 'Encoded filename']) { await expect.poll(() => page.getByRole('img', { name, exact: true }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) } expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) @@ -272,6 +275,7 @@ describe('web e2e: Markdown image rendering', () => { [join(scaffold.workspaceCwd, 'valid.png'), 200], [`${scaffold.workspaceCwd}/./local-image.png`, 200], [join(scaffold.workspaceCwd, 'test workspace/测试图.png'), 200], + [join(scaffold.workspaceCwd, 'test workspace/测试#100%.png'), 200], [join(scaffold.workspaceCwd, 'oversized.png'), 413], [join(scaffold.persistenceRoot, 'outside.png'), 200], [join(scaffold.workspaceCwd, 'missing.png'), 404], diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index d6d7c0d3ad..5d97a698f7 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -17,7 +17,7 @@ import css from './AssistantMarkdown.module.css' * Resolve an authored absolute image path against the document's file API. * @param base - canonical `document.baseURI` at render time. * @param value - authored Markdown destination; URL escapes are decoded once. - * @returns an absolute HTTP(S) file-API URL, or undefined for unsupported + * @returns an absolute Web or Desktop file-API URL, or undefined for unsupported * protocols and non-local paths. */ export function localPathMediaUrl(base: string, value: string): string | undefined { diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index 2edebf11b6..eff9fbbe49 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -1,13 +1,13 @@ // @vitest-environment jsdom import { cleanup, render } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AssistantMarkdown, localPathMediaUrl } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../src/client/contract/slots.ts' import type { AssistantBlock } from '../src/client/contract/snapshot.ts' -afterEach(cleanup) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) const t = ((_key: string) => 'label') as ChatViewSlotProps['t'] const renderMessageImages = (() => null) as ChatNodeOwnerProps['renderMessageImages'] @@ -26,15 +26,17 @@ describe('localPathMediaUrl', () => { [BASE, BASE], ['https://127.0.0.1:3080/', 'https://127.0.0.1:3080/'], [MOUNTED_BASE, MOUNTED_BASE], + ['dsh-app://app/', 'dsh-app://app/'], + ['dsh-app://app/index.html', 'dsh-app://app/'], ['http://127.0.0.1:3080/tools/dsh/index.html', MOUNTED_BASE], ]) { expect(localPathMediaUrl(base!, '/tmp/graph.png')).toBe(`${root!}api/file?path=${path}`) } }) - it('keeps non-HTTP transports inert', () => { + it('keeps unsupported application transports inert', () => { expect(localPathMediaUrl('about:blank', '/tmp/graph.png')).toBeUndefined() - expect(localPathMediaUrl('dsh-app://app/', '/tmp/graph.png')).toBeUndefined() + expect(localPathMediaUrl('dsh-app://shell/', '/tmp/graph.png')).toBeUndefined() expect(localPathMediaUrl('file:///app', '/tmp/graph.png')).toBeUndefined() expect(localPathMediaUrl('ws://127.0.0.1:3080/', '/tmp/graph.png')).toBeUndefined() }) @@ -53,7 +55,8 @@ describe('localPathMediaUrl', () => { }) describe('AssistantMarkdown local-path images', () => { - it('renders a local image path in closing prose through the same-origin API', () => { + it.each([BASE, 'dsh-app://app/'])('renders a local image in closing prose through %s', (base) => { + vi.spyOn(document, 'baseURI', 'get').mockReturnValue(base) const { container } = render( { expect(image?.getAttribute('alt')).toBe('diagram') const url = new URL(image?.getAttribute('src') ?? '') expect(url.pathname).toBe('/api/file') + expect(url.protocol).toBe(new URL(base).protocol) expect(url.searchParams.get('path')).toBe('/tmp/graph.png') }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 10f3cbe3a2..bf52803bec 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -24,8 +24,8 @@ en: 8ec1d30f2812f41a zh: 8da521d9aa15b2c3 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: bd4126a74a28d45b - zh: 0578623548ebd71d + en: d79e20306593c4bf + zh: 5c0f1100e2cd6fcc /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 d1d58f679e..82d03a6a90 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -89,7 +89,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. 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. 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 823a0ae3ce..7c123b4e72 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -89,7 +89,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 文本。加载或解码失败后,图片替换为作者的 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` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 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 aefe70f2ec..cdb9afcbda 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -77,11 +77,12 @@ function remoteImageUrl(url: string): string | undefined { } } -/** Protocols a vocabulary-rewritten image destination may carry. */ +/** Rewritten images may use Web media protocols or the Desktop application's file route. */ function vocabularyImageUrl(url: string): string | undefined { try { const protocol = new URL(url).protocol return protocol === 'http:' || protocol === 'https:' || protocol === 'blob:' || protocol === 'data:' + || url.startsWith('dsh-app://app/api/file?') ? url : undefined } catch { 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 1d7a95b692..61626a483e 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 @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' +import { fileMediaUrl } from '@deepseek-ai/dsh-util-workspace-path' import { MarkdownDelegateProvider } from '../src/markdown/MarkdownDelegate.tsx' import { MarkdownText } from './markdown-test-components.tsx' import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts' @@ -16,6 +17,37 @@ function mount(text: string, streaming = false) { return { ...view, openFile } } +it('loads Desktop inline and hover previews through the file route after streaming settles', () => { + vi.useFakeTimers() + const path = '/work/测试 图片#100%.png' + const src = fileMediaUrl('dsh-app://app/', path)! + const images = { resolve: (value: string) => fileMediaUrl('dsh-app://app/', value), labels } + const text = `![图片](<${encodeURI(path).replace('#', '%23')}>)\n\n[打开图片](<${encodeURI(path).replace('#', '%23')}>)` + const openFile = vi.fn() + const content = (streaming: boolean) => + + + const view = render(content(true)) + expect(view.container.querySelector('img')).toBeNull() + view.rerender(content(false)) + expect(screen.getByRole('img').getAttribute('src')).toBe(src) + const trigger = screen.getByRole('button', { name: 'View full image: 图片' }) + trigger.focus() + fireEvent.click(trigger) + expect(screen.getByRole('dialog').querySelector('img')?.getAttribute('src')).toBe(src) + fireEvent.keyDown(window, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(trigger) + const link = screen.getByRole('button', { name: '打开图片' }) + fireEvent.pointerEnter(link.parentElement!) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByAltText(path).getAttribute('src')).toBe(src) + fireEvent.error(screen.getByAltText(path)) + expect(screen.getByText(labels.failed)).toBeTruthy() + fireEvent.click(link) + expect(openFile).toHaveBeenCalledWith(path, undefined) +}) + it('opens inline images in the shared lightbox, restores focus, and never opens the sidebar', () => { const { openFile } = mount('![Compare](<.artifacts/对比 image.png>)') const trigger = screen.getByRole('button', { name: 'View full image: Compare' }) diff --git a/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx index fdad05bbf2..85516cfd0a 100644 --- a/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx @@ -74,6 +74,21 @@ describe('MarkdownText local-path images', () => { } }) + it('accepts only the Desktop file route from a local image resolver', () => { + const src = 'dsh-app://app/api/file?path=%2Ftmp%2Fgraph.png' + const view = render( src }} />) + expect(view.container.querySelector('img')).toBeNull() + view.rerender( src }} />) + expect(view.getByRole('img').getAttribute('src')).toBe(src) + for (const result of ['dsh-app://shell/api/file?path=x', 'dsh-app://app/assets/image.png', + 'dsh-app://app/api/file-other?path=x', 'dsh-app://app.example/api/file?path=x']) { + view.rerender( result }} />) + expect(view.container.querySelector('img')).toBeNull() + } + view.rerender() + expect(view.container.querySelector('img')).toBeNull() + }) + it('leaves remote images untouched even when the vocabulary maps them', () => { const remote = '![remote](https://example.com/a.png)' const pathImages: MarkdownPathImages = { resolve: () => 'https://cdn.example.com/b.png' } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts b/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts index ae7d5c6991..a190b9b6de 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts @@ -6,7 +6,7 @@ import { fileMediaUrl, isAbsoluteWorkspacePath, pathPartsOf } from '@deepseek-ai * @param base - document base URI, including any deployment prefix. * @param documentPath - absolute source path reported by the Host, when available. * @param destination - authored Markdown image URL; query and fragment are not filename components. - * @returns an HTTP(S) file URL, or undefined for unsupported or malformed destinations. + * @returns a Web or Desktop file URL, or undefined for unsupported or malformed destinations. */ export function markdownImageUrl(base: string, documentPath: string | undefined, destination: string): string | undefined { const suffix = destination.search(/[?#]/u) diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx index cd5d9c9b2a..d32748e493 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx @@ -1,13 +1,13 @@ // @vitest-environment jsdom /** Markdown preview uses one accumulated document across page arrivals and EOF. */ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { MarkdownBody, type MarkdownBodyProps } from '../src/client/markdown/MarkdownBody.tsx' import { en, zh } from '../src/client/markdown/locales.ts' import type { DocumentContent } from '../src/client/document/contract.ts' -afterEach(cleanup) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) function content(pageTexts: readonly string[], eof: boolean): DocumentContent { let offset = 1 @@ -33,12 +33,14 @@ function props(value: DocumentContent, t: MarkdownBodyProps['t'] = makeTranslate } describe('MarkdownBody', () => { - it('loads local images beside the Host document and retains external images and failure text', () => { + it.each(['http://localhost/', 'dsh-app://app/'])('loads document images and failure text under %s', (base) => { + vi.spyOn(document, 'baseURI', 'get').mockReturnValue(base) const text = '![relative](images/a.png) ![absolute](/tmp/a.png) ![external](https://example.test/a.png)' const view = render() expect(new URL(view.getByAltText('relative').getAttribute('src')!).searchParams.get('path')) .toBe('/work/guide/images/a.png') expect(new URL(view.getByAltText('absolute').getAttribute('src')!).searchParams.get('path')).toBe('/tmp/a.png') + expect(new URL(view.getByAltText('relative').getAttribute('src')!).protocol).toBe(new URL(base).protocol) expect(view.getByAltText('external').getAttribute('src')).toBe('https://example.test/a.png') fireEvent.error(view.getByAltText('relative')) expect(view.getByText('relative').tagName).toBe('SPAN') diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-path-images.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/markdown-path-images.client.spec.ts index 347105504a..1a2fa11102 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/markdown-path-images.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-path-images.client.spec.ts @@ -37,7 +37,7 @@ describe('Markdown preview image URLs', () => { expect(markdownImageUrl(BASE, '/work/notes.md', destination)).toBeUndefined() }) - it.each(['about:blank', 'dsh-app://app/', 'file:///app', 'ws://localhost/'])( + it.each(['about:blank', 'dsh-app://shell/', 'file:///app', 'ws://localhost/'])( 'keeps the file route unavailable for %s', (base) => { expect(markdownImageUrl(base, '/work/notes.md', 'a.png')).toBeUndefined() }, @@ -47,4 +47,12 @@ describe('Markdown preview image URLs', () => { expect(markdownImageUrl(`${BASE}index.html`, '/work/notes.md', 'a.png')) .toBe(`${BASE}api/file?path=${encodeURIComponent('/work/a.png')}`) }) + + it('resolves relative and absolute images through the Desktop file route', () => { + for (const destination of ['图%20片.png', '/work/guide/图%20片.png']) { + const url = new URL(markdownImageUrl('dsh-app://app/', '/work/guide/notes.md', destination)!) + expect(url.href.split('?')[0]).toBe('dsh-app://app/api/file') + expect(url.searchParams.get('path')).toBe('/work/guide/图 片.png') + } + }) }) diff --git a/packages/util/workspace-path/README.i18n.yaml b/packages/util/workspace-path/README.i18n.yaml index 8bfe770341..65048d95e2 100644 --- a/packages/util/workspace-path/README.i18n.yaml +++ b/packages/util/workspace-path/README.i18n.yaml @@ -15,8 +15,8 @@ en: e8236aa6cbcb7dca zh: 500e57982344c0fa /dsh-util-workspace-path/file-addresses: - en: ea8f0aa7a8263449 - zh: 231dce3a1b7adf6d + en: fa695bca8bd534e0 + zh: d8feffb4794adfcd /dsh-util-workspace-path/known-limitations-and-deferred-work: en: 1e6d448f4f0030c4 zh: c050dd5e470fe47b diff --git a/packages/util/workspace-path/README.md b/packages/util/workspace-path/README.md index 2de3157ee8..815b646983 100644 --- a/packages/util/workspace-path/README.md +++ b/packages/util/workspace-path/README.md @@ -26,7 +26,7 @@ A resource address is `dsh-resource:///…`, and the type — the URI host `sessionFileAddress(sessionId, path)` normalizes `\` to `/` and drops leading `./`, but preserves leading `/` characters. Every id and path segment is component-encoded with `:` kept literal. `fileAddressFor(sessionId, cwd, path)` always builds a Session address: paths inside `cwd` become relative; other absolute paths, including when `cwd` is unknown, stay absolute within that Session address. `absoluteFileAddress(absolutePath)` builds only the Session-less form. `parseFileAddress(address)` checks the exact file-address prefix, ignores query and fragment suffixes, decodes each segment, and returns `{ scope, sessionId, path }` for a Session address or `{ scope, path }` for the Session-less form. Another type or scheme, an unknown scope, a missing id or path, or a malformed escape returns `undefined`. -`fileMediaUrl(base, path)` builds an authenticated file-route URL from an already-decoded absolute path. It preserves literal percent signs and deployment prefixes, and rejects non-HTTP application bases, relative paths, network-path prefixes, and control characters. Markdown owners decode authored URLs before calling it; native file-card paths are passed unchanged. +`fileMediaUrl(base, path)` builds an authenticated file-route URL from an already-decoded absolute path. It accepts HTTP(S) application bases and the Desktop `dsh-app://app/` origin, preserves literal percent signs and deployment prefixes, and rejects other application bases, relative paths, network-path prefixes, and control characters. Markdown owners decode authored URLs before calling it; native file-card paths are passed unchanged. ----- diff --git a/packages/util/workspace-path/README.zh.md b/packages/util/workspace-path/README.zh.md index b46402b0ec..d9840f99e8 100644 --- a/packages/util/workspace-path/README.zh.md +++ b/packages/util/workspace-path/README.zh.md @@ -26,7 +26,7 @@ kind: "package-library" `sessionFileAddress(sessionId, path)` 将 `\` 归一为 `/`,去掉前导 `./`,但保留前导 `/` 字符。id 和每个路径段都做组件编码,`:` 保持字面。`fileAddressFor(sessionId, cwd, path)` 始终构造 Session 地址:`cwd` 内的路径转为相对路径;其他绝对路径(包括 `cwd` 未知时)仍作为该 Session 地址内的绝对路径。`absoluteFileAddress(absolutePath)` 只构造不带 Session 的形式。`parseFileAddress(address)` 检查精确的文件地址前缀、忽略查询与片段后缀、逐段解码,并为 Session 地址返回 `{ scope, sessionId, path }`,为不带 Session 的形式返回 `{ scope, path }`。其他 type 或 scheme、未知作用域、缺 id 或路径,或错误转义都返回 `undefined`。 -`fileMediaUrl(base, path)` 从已解码的绝对路径生成带认证文件路由的 URL,保留字面量百分号与部署前缀,并拒绝非 HTTP 应用基址、相对路径、网络路径前缀和控制字符。Markdown 调用方先解码所写 URL;原生文件卡片路径原样传入。 +`fileMediaUrl(base, path)` 从已解码的绝对路径生成带认证文件路由的 URL,支持 HTTP(S) 应用基址与桌面端 `dsh-app://app/` 来源,保留字面量百分号与部署前缀,并拒绝其他应用基址、相对路径、网络路径前缀和控制字符。Markdown 调用方先解码所写 URL;原生文件卡片路径原样传入。 ----- diff --git a/packages/util/workspace-path/src/index.ts b/packages/util/workspace-path/src/index.ts index 84dfd8885b..f9afdb8e8a 100644 --- a/packages/util/workspace-path/src/index.ts +++ b/packages/util/workspace-path/src/index.ts @@ -113,12 +113,12 @@ export function relativizeToCwd(text: string, cwd: string | undefined): string { /** * Address a decoded absolute file path through the authenticated file route. - * @param base - HTTP(S) application base, including its deployment prefix. + * @param base - HTTP(S) application base, including its deployment prefix, or `dsh-app://app/`. * @param path - Native file path; URL escapes in authored Markdown must already be decoded. * @returns File URL, or undefined for unsupported transports and non-absolute paths. */ export function fileMediaUrl(base: string, path: string): string | undefined { - if (!/^https?:/u.test(base) || !isAbsoluteWorkspacePath(path) + if ((!/^https?:/u.test(base) && !base.startsWith('dsh-app://app/')) || !isAbsoluteWorkspacePath(path) || /^[/\\]{2}/u.test(path) || /[\u0000-\u001f\u007f]/u.test(path)) return undefined return new URL(`api/file?path=${encodeURIComponent(path)}`, base).href } diff --git a/packages/util/workspace-path/tests/file-media-url.spec.ts b/packages/util/workspace-path/tests/file-media-url.spec.ts index 0053373989..d9a03dff39 100644 --- a/packages/util/workspace-path/tests/file-media-url.spec.ts +++ b/packages/util/workspace-path/tests/file-media-url.spec.ts @@ -14,7 +14,19 @@ describe('fileMediaUrl', () => { expect(fileMediaUrl('https://host/', path)).toBeUndefined() }, ) - it('requires an HTTP application base', () => { - expect(fileMediaUrl('file:///app/', '/work/image.png')).toBeUndefined() - }) + it.each(['dsh-app://app/', 'dsh-app://app/index.html'])( + 'serves decoded native paths through the Desktop application %s', (base) => { + for (const path of ['/work/测试 文件#100%.png', 'C:\\work\\测试 文件.png']) { + const url = new URL(fileMediaUrl(base, path)!) + expect(url.href.split('?')[0]).toBe('dsh-app://app/api/file') + expect(url.searchParams.get('path')).toBe(path) + } + }, + ) + it.each(['file:///app/', 'dsh-app://shell/', 'dsh-app://app.example/', 'dsh-app://app:80/', + 'dsh-app://user@app/', 'dsh-app://app@other/', 'about:blank'])( + 'rejects an unsupported application base %s', (base) => { + expect(fileMediaUrl(base, '/work/image.png')).toBeUndefined() + }, + ) })