feat(desktop): render relative workspace images inline; stop images doubling as cards

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 18:11:21 +08:00
parent ac0cb4d0a0
commit 0c24a5da3e
4 changed files with 182 additions and 9 deletions

View File

@ -93,6 +93,41 @@ describe('AssistantMessage output-target cards', () => {
expect(screen.getByText('assistantOutputs.kind.markdown')).toBeInTheDocument()
})
it('renders a relative image inline (an <img>) but NOT as an image card', () => {
render(
<AssistantMessage
sessionId="s1"
content={'渲染结果见 outputs/foo/preview_frame.png'}
isStreaming={false}
/>,
)
// Image renders inline through InlineImageGallery (workDir is undefined in this
// test's mock, so the relative path resolves as-is and is served via /preview-fs).
const img = screen.getByRole('img') as HTMLImageElement
expect(img.getAttribute('src')).toBe(
'http://127.0.0.1:4321/preview-fs/s1/outputs/foo/preview_frame.png',
)
// ...and is NOT duplicated as an output-target card.
expect(screen.queryByText('assistantOutputs.kind.image')).toBeNull()
})
it('still renders md/html/localhost cards when those references are present', () => {
render(
<AssistantMessage
sessionId="s1"
content={[
'本地服务 http://localhost:5173/',
'见 [说明](docs/readme.md)',
'页面 [首页](out/index.html)',
].join('\n')}
isStreaming={false}
/>,
)
expect(screen.getByText('assistantOutputs.kind.localhost')).toBeInTheDocument()
expect(screen.getByText('assistantOutputs.kind.markdown')).toBeInTheDocument()
expect(screen.getByText('assistantOutputs.kind.html')).toBeInTheDocument()
})
it('does NOT render cards while streaming', () => {
render(
<AssistantMessage

View File

@ -47,7 +47,11 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
)
const outputTargets = useMemo(
() => (isStreaming || !sessionId ? [] : extractAssistantOutputTargets(content, { workDir })),
() =>
isStreaming || !sessionId
? []
: // Image targets render inline via <InlineImageGallery>; never also as a card.
extractAssistantOutputTargets(content, { workDir }).filter((target) => target.kind !== 'image'),
[content, isStreaming, sessionId, workDir],
)
@ -75,7 +79,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
streaming={isStreaming}
onLinkClick={sessionId ? handleLinkClick : undefined}
/>
{!isStreaming && <InlineImageGallery text={content} />}
{!isStreaming && <InlineImageGallery text={content} sessionId={sessionId} workDir={workDir} />}
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
)}

View File

@ -0,0 +1,84 @@
import '@testing-library/jest-dom'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
// getBaseUrl backs the absolute-path src (/api/filesystem/file).
vi.mock('../../api/client', () => ({
getBaseUrl: () => 'http://127.0.0.1:3456',
}))
// getServerBaseUrl backs the relative-path src (/preview-fs/<sessionId>/...).
vi.mock('../../lib/desktopRuntime', () => ({
getServerBaseUrl: () => 'http://127.0.0.1:4321',
}))
import { InlineImageGallery } from './InlineImageGallery'
function imgSrcs(): string[] {
return screen.getAllByRole('img').map((img) => (img as HTMLImageElement).getAttribute('src') ?? '')
}
describe('InlineImageGallery', () => {
it('renders an absolute image path via /api/filesystem/file (legacy behavior)', () => {
render(<InlineImageGallery text={'see /Users/me/out/result.png done'} />)
const srcs = imgSrcs()
expect(srcs).toHaveLength(1)
expect(srcs[0]).toBe(
'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/Users/me/out/result.png'),
)
})
it('ignores relative workspace images when sessionId is absent', () => {
render(<InlineImageGallery text={'output at outputs/a/frame.png'} />)
expect(screen.queryAllByRole('img')).toHaveLength(0)
})
it('renders a relative workspace image via previewFsUrl when sessionId is provided', () => {
render(
<InlineImageGallery
text={'render saved to outputs/a/frame.png'}
sessionId="s1"
workDir="/w"
/>,
)
const srcs = imgSrcs()
expect(srcs).toHaveLength(1)
expect(srcs[0]).toBe('http://127.0.0.1:4321/preview-fs/s1/outputs/a/frame.png')
})
it('renders both absolute and relative images together', () => {
render(
<InlineImageGallery
text={'abs /Users/me/pics/photo.png and rel outputs/b/chart.png'}
sessionId="s1"
workDir="/w"
/>,
)
const srcs = imgSrcs()
expect(srcs).toEqual([
'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/Users/me/pics/photo.png'),
'http://127.0.0.1:4321/preview-fs/s1/outputs/b/chart.png',
])
})
it('does not render an in-workspace absolute path twice (dedup by basename)', () => {
// The absolute path is INSIDE workDir, so extractAssistantOutputTargets also
// surfaces it as a relative target (frame.png). It must only render once.
render(
<InlineImageGallery
text={'saved /w/outputs/a/frame.png to disk'}
sessionId="s1"
workDir="/w"
/>,
)
const srcs = imgSrcs()
expect(srcs).toHaveLength(1)
expect(srcs[0]).toBe(
'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/w/outputs/a/frame.png'),
)
})
})

View File

@ -1,6 +1,9 @@
import { useMemo, useState } from 'react'
import { ImageGalleryModal } from './ImageGalleryModal'
import { getBaseUrl } from '../../api/client'
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
import { previewFsUrl } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|bmp|avif|ico)$/i
@ -35,19 +38,66 @@ function fileName(filePath: string): string {
return filePath.split('/').pop() || filePath
}
type Props = {
text: string
type GalleryImage = {
src: string
name: string
}
export function InlineImageGallery({ text }: Props) {
type Props = {
text: string
/**
* When provided, relative workspace image paths (e.g. `outputs/foo/frame.png`)
* are also rendered inline, served via `/preview-fs/<sessionId>/...`. Absent
* (ToolResult/ToolCall usage) keeps the legacy absolute-path-only behavior.
*/
sessionId?: string
workDir?: string | null
}
export function InlineImageGallery({ text, sessionId, workDir }: Props) {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const imagePaths = useMemo(() => extractImagePaths(text), [text])
const images = useMemo(
() => imagePaths.map((p) => ({ src: fileUrl(p), name: fileName(p) })),
[imagePaths],
)
const images = useMemo<GalleryImage[]>(() => {
// 1. Absolute paths (legacy behavior) — served via /api/filesystem/file.
const absolute: GalleryImage[] = imagePaths.map((p) => ({ src: fileUrl(p), name: fileName(p) }))
if (!sessionId) {
return absolute
}
// 2. Relative workspace images — only when a sessionId is available so we can
// build a /preview-fs URL. Reuses the sandboxed target extractor instead of
// a bespoke relative-path regex.
const base = getServerBaseUrl()
const relativeTargets = extractAssistantOutputTargets(text, { workDir }).filter(
(target) => target.kind === 'image',
)
// Dedup: an absolute path inside the workspace can be caught by BOTH sources.
// Skip a relative target whose basename already appears among the absolute
// images, and also collapse duplicate relative targets by resolved src.
const absoluteNames = new Set(absolute.map((img) => img.name))
const seenSrc = new Set(absolute.map((img) => img.src))
const relative: GalleryImage[] = []
for (const target of relativeTargets) {
const relPath = target.normalizedPath ?? target.href
const name = fileName(relPath)
if (absoluteNames.has(name)) {
continue
}
const src = previewFsUrl(base, sessionId, relPath)
if (seenSrc.has(src)) {
continue
}
seenSrc.add(src)
relative.push({ src, name })
}
return [...absolute, ...relative]
}, [imagePaths, sessionId, text, workDir])
if (images.length === 0) return null