mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +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()
|
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', () => {
|
it('does NOT render cards while streaming', () => {
|
||||||
render(
|
render(
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
|
|||||||
@ -47,7 +47,11 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
|
|||||||
)
|
)
|
||||||
|
|
||||||
const outputTargets = useMemo(
|
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],
|
[content, isStreaming, sessionId, workDir],
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -75,7 +79,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
|
|||||||
streaming={isStreaming}
|
streaming={isStreaming}
|
||||||
onLinkClick={sessionId ? handleLinkClick : undefined}
|
onLinkClick={sessionId ? handleLinkClick : undefined}
|
||||||
/>
|
/>
|
||||||
{!isStreaming && <InlineImageGallery text={content} />}
|
{!isStreaming && <InlineImageGallery text={content} sessionId={sessionId} workDir={workDir} />}
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
|
<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 { useMemo, useState } from 'react'
|
||||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||||
import { getBaseUrl } from '../../api/client'
|
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
|
const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|bmp|avif|ico)$/i
|
||||||
|
|
||||||
@ -35,20 +38,67 @@ function fileName(filePath: string): string {
|
|||||||
return filePath.split('/').pop() || filePath
|
return filePath.split('/').pop() || filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type GalleryImage = {
|
||||||
text: string
|
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 [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
const imagePaths = useMemo(() => extractImagePaths(text), [text])
|
const imagePaths = useMemo(() => extractImagePaths(text), [text])
|
||||||
|
|
||||||
const images = useMemo(
|
const images = useMemo<GalleryImage[]>(() => {
|
||||||
() => imagePaths.map((p) => ({ src: fileUrl(p), name: fileName(p) })),
|
// 1. Absolute paths (legacy behavior) — served via /api/filesystem/file.
|
||||||
[imagePaths],
|
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
|
if (images.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user