mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
ac0cb4d0a0
commit
0c24a5da3e
@ -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
|
||||
|
||||
@ -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" />
|
||||
)}
|
||||
|
||||
84
desktop/src/components/chat/InlineImageGallery.test.tsx
Normal file
84
desktop/src/components/chat/InlineImageGallery.test.tsx
Normal 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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user