fix(chat): preview local image attachments

Show local image-path attachments as UI previews while keeping preview URLs out of the websocket payload.

Co-Authored-By: Claude GPT-5.5 <noreply@anthropic.com>
This commit is contained in:
你的姓名 2026-06-13 22:54:30 +08:00
parent c5becca3c9
commit 0136fbb9ff
8 changed files with 237 additions and 10 deletions

View File

@ -66,6 +66,25 @@ describe('AttachmentGallery', () => {
expect(onRemove).toHaveBeenCalledWith('selection-1')
})
it('renders image attachments from previewUrl without using the local path as img src', () => {
const view = render(
<AttachmentGallery
variant="message"
attachments={[{
id: 'image-preview',
type: 'image',
name: 'chart.png',
path: 'C:\\Users\\Ada\\Pictures\\chart.png',
previewUrl: 'http://127.0.0.1:3456/api/filesystem/file?path=C%3A%5CUsers%5CAda%5CPictures%5Cchart.png',
}]}
/>,
)
const image = view.getByAltText('chart.png')
expect(image).toHaveAttribute('src', 'http://127.0.0.1:3456/api/filesystem/file?path=C%3A%5CUsers%5CAda%5CPictures%5Cchart.png')
expect(image).not.toHaveAttribute('src', 'C:\\Users\\Ada\\Pictures\\chart.png')
})
it('shows a compact element chip for annotated selection images and exposes the note on hover', () => {
const view = render(
<AttachmentGallery

View File

@ -16,7 +16,7 @@ import { sessionsApi, type SessionGitInfo } from '../../api/sessions'
import { agentsApi } from '../../api/agents'
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
import { ModelSelector } from '../controls/ModelSelector'
import type { AttachmentRef } from '../../types/chat'
import type { AttachmentRef, DisplayAttachmentRef } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
import { ComposerDropOverlay } from './ComposerDropOverlay'
import { ProjectContextChip } from '../shared/ProjectContextChip'
@ -682,6 +682,18 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
note: attachment.note,
quote: attachment.quote,
}))
const visibleUploadAttachmentPayload: DisplayAttachmentRef[] = attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name,
path: attachment.path,
data: attachment.data,
previewUrl: attachment.previewUrl,
mimeType: attachment.mimeType,
lineStart: attachment.lineStart,
lineEnd: attachment.lineEnd,
note: attachment.note,
quote: attachment.quote,
}))
const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences
.filter((reference) => reference.kind !== 'chat-selection')
.map((reference) => ({
@ -694,8 +706,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
note: reference.note,
quote: reference.quote,
}))
const visibleAttachmentPayload: AttachmentRef[] = [
...uploadAttachmentPayload,
const visibleAttachmentPayload: DisplayAttachmentRef[] = [
...visibleUploadAttachmentPayload,
...workspaceReferences.map((reference) => ({
type: 'file' as const,
name: reference.name,

View File

@ -1,10 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setBaseUrl } from '../api/client'
import { browserHost } from './desktopHost/browserHost'
import { pathToComposerAttachment, selectNativeFileAttachments } from './composerAttachments'
describe('composer attachment payloads', () => {
afterEach(() => {
Reflect.deleteProperty(window, 'desktopHost')
setBaseUrl('http://127.0.0.1:3456')
})
it('keeps many selected desktop project files as paths instead of request-body data', () => {
@ -37,6 +39,33 @@ describe('composer attachment payloads', () => {
expect(pathOnlyAttachments.every((attachment) => attachment.path && !attachment.data)).toBe(true)
})
it('creates safe preview URLs for native image paths with spaces', () => {
setBaseUrl('http://127.0.0.1:4567')
const attachment = pathToComposerAttachment('C:\\Users\\Ada Lovelace\\Pictures\\chart final.PNG')
expect(attachment).toMatchObject({
name: 'chart final.PNG',
type: 'image',
path: 'C:\\Users\\Ada Lovelace\\Pictures\\chart final.PNG',
mimeType: 'image/png',
previewUrl: 'http://127.0.0.1:4567/api/filesystem/file?path=C%3A%5CUsers%5CAda%20Lovelace%5CPictures%5Cchart%20final.PNG',
})
expect(attachment.previewUrl).not.toContain('file://')
})
it('keeps non-image native paths as file chips without preview URLs', () => {
const attachment = pathToComposerAttachment('/workspace/notes.txt')
expect(attachment).toMatchObject({
name: 'notes.txt',
type: 'file',
path: '/workspace/notes.txt',
})
expect(attachment.previewUrl).toBeUndefined()
expect(attachment.data).toBeUndefined()
})
it('selects native file attachments through the injected desktop host', async () => {
const open = vi.fn().mockResolvedValue(['/workspace/a.txt', '/workspace/b.log'])
window.desktopHost = {

View File

@ -1,3 +1,4 @@
import { getApiUrl } from '../api/client'
import { isDesktopRuntime } from './desktopRuntime'
import { getDesktopHost } from './desktopHost'
@ -16,21 +17,51 @@ export type ComposerAttachment = {
quote?: string
}
const IMAGE_PATH_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
const IMAGE_PATH_MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
}
function nextAttachmentId() {
return `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
function getPathExtension(filePath: string): string {
const fileName = getFileNameFromPath(filePath)
const dotIndex = fileName.lastIndexOf('.')
return dotIndex >= 0 ? fileName.slice(dotIndex + 1).toLowerCase() : ''
}
export function isPreviewableImagePath(filePath: string): boolean {
return IMAGE_PATH_EXTENSIONS.has(getPathExtension(filePath))
}
export function getFilesystemPreviewUrl(filePath: string): string {
return getApiUrl(`/api/filesystem/file?path=${encodeURIComponent(filePath)}`)
}
function getImageMimeTypeForPath(filePath: string): string | undefined {
return IMAGE_PATH_MIME_TYPES[getPathExtension(filePath)]
}
export function getFileNameFromPath(filePath: string): string {
const normalized = filePath.replace(/[\\/]+$/g, '')
return normalized.split(/[\\/]/).filter(Boolean).pop() || filePath
}
export function pathToComposerAttachment(filePath: string): ComposerAttachment {
const isImage = isPreviewableImagePath(filePath)
return {
id: nextAttachmentId(),
name: getFileNameFromPath(filePath),
type: 'file',
type: isImage ? 'image' : 'file',
path: filePath,
mimeType: isImage ? getImageMimeTypeForPath(filePath) : undefined,
previewUrl: isImage ? getFilesystemPreviewUrl(filePath) : undefined,
}
}

View File

@ -41,7 +41,7 @@ import {
replaceSlashCommand,
resolveSlashUiAction,
} from '../components/chat/composerUtils'
import type { AttachmentRef } from '../types/chat'
import type { AttachmentRef, DisplayAttachmentRef } from '../types/chat'
import type { PermissionMode } from '../types/settings'
import type { SlashCommandOption } from '../components/chat/composerUtils'
import { WelcomeTaskCards, type WelcomeTaskCard } from '../components/welcome/WelcomeTaskCards'
@ -344,8 +344,16 @@ export function EmptySession() {
data: attachment.data,
mimeType: attachment.mimeType,
}))
const displayAttachmentPayload: DisplayAttachmentRef[] = attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name,
path: attachment.path,
data: attachment.data,
previewUrl: attachment.previewUrl,
mimeType: attachment.mimeType,
}))
if (text || attachmentPayload.length > 0) {
sendMessage(sessionId, text, attachmentPayload)
sendMessage(sessionId, text, attachmentPayload, { displayAttachments: displayAttachmentPayload })
}
setInput('')
setAttachments([])

View File

@ -1682,6 +1682,103 @@ describe('chatStore history mapping', () => {
)
})
it('keeps previewUrl on optimistic display attachments but strips it from websocket payload', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'idle' }),
},
})
useChatStore.getState().sendMessage(
TEST_SESSION_ID,
'look at this',
[{
type: 'image',
name: 'chart.png',
path: 'C:\\Users\\Ada\\Pictures\\chart.png',
previewUrl: 'http://127.0.0.1:3456/api/filesystem/file?path=C%3A%5CUsers%5CAda%5CPictures%5Cchart.png',
mimeType: 'image/png',
} as any],
{
displayAttachments: [{
type: 'image',
name: 'chart.png',
path: 'C:\\Users\\Ada\\Pictures\\chart.png',
previewUrl: 'http://127.0.0.1:3456/api/filesystem/file?path=C%3A%5CUsers%5CAda%5CPictures%5Cchart.png',
mimeType: 'image/png',
}],
},
)
const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
expect(messages[0]).toMatchObject({
type: 'user_text',
attachments: [{
type: 'image',
name: 'chart.png',
path: 'C:\\Users\\Ada\\Pictures\\chart.png',
previewUrl: 'http://127.0.0.1:3456/api/filesystem/file?path=C%3A%5CUsers%5CAda%5CPictures%5Cchart.png',
mimeType: 'image/png',
}],
})
expect(sendMock).toHaveBeenCalledWith(
TEST_SESSION_ID,
{
type: 'user_message',
content: 'look at this',
attachments: [{
type: 'image',
name: 'chart.png',
path: 'C:\\Users\\Ada\\Pictures\\chart.png',
data: undefined,
mimeType: 'image/png',
isDirectory: undefined,
lineStart: undefined,
lineEnd: undefined,
note: undefined,
quote: undefined,
}],
},
)
expect(JSON.stringify(sendMock.mock.calls[0]?.[1])).not.toContain('previewUrl')
})
it('preserves queued display previewUrl while stripping it when the queue drains', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'thinking' }),
},
})
useChatStore.getState().enqueueMessage(
TEST_SESSION_ID,
'queued image',
[{ type: 'image', name: 'queued.png', path: '/tmp/queued.png', previewUrl: 'http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fqueued.png' } as any],
{ displayAttachments: [{ type: 'image', name: 'queued.png', path: '/tmp/queued.png', previewUrl: 'http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fqueued.png' }] },
)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messageQueue?.[0]?.displayAttachments?.[0]).toMatchObject({
previewUrl: 'http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fqueued.png',
})
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[TEST_SESSION_ID]: {
...state.sessions[TEST_SESSION_ID]!,
chatState: 'idle',
},
},
}))
useChatStore.getState().drainMessageQueue(TEST_SESSION_ID)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]).toMatchObject({
type: 'user_text',
attachments: [{ previewUrl: 'http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fqueued.png' }],
})
expect(JSON.stringify(sendMock.mock.calls.at(-1)?.[1])).not.toContain('previewUrl')
})
it('stores server-materialized attachment prefixes for rewind matching', () => {
useChatStore.setState({
sessions: {

View File

@ -27,6 +27,7 @@ import type {
ChatState,
ComputerUsePermissionRequest,
ComputerUsePermissionResponse,
DisplayAttachmentRef,
GoalEventAction,
MemoryEventFile,
UIAttachment,
@ -63,7 +64,7 @@ export type QueuedMessage = {
content: string
attachments?: AttachmentRef[]
displayContent?: string
displayAttachments?: AttachmentRef[]
displayAttachments?: DisplayAttachmentRef[]
createdAt: number
}
@ -152,7 +153,7 @@ type ChatStore = {
sessionId: string,
content: string,
attachments?: AttachmentRef[],
options?: { displayContent?: string; displayAttachments?: AttachmentRef[]; hideDisplayContent?: boolean },
options?: { displayContent?: string; displayAttachments?: DisplayAttachmentRef[]; hideDisplayContent?: boolean },
) => void
respondToPermission: (
sessionId: string,
@ -191,7 +192,7 @@ type ChatStore = {
sessionId: string,
content: string,
attachments?: AttachmentRef[],
options?: { displayContent?: string; displayAttachments?: AttachmentRef[] },
options?: { displayContent?: string; displayAttachments?: DisplayAttachmentRef[] },
) => void
removeQueuedMessage: (sessionId: string, queuedId: string) => void
updateQueuedMessage: (sessionId: string, queuedId: string, content: string) => void
@ -1065,6 +1066,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
? ''
: options?.displayContent?.trim() || content.trim()
const modelFacingContent = buildModelContent(content, attachments)
const serverAttachments = stripPreviewUrlsFromAttachments(attachments)
const visibleAttachments = options?.displayAttachments ?? attachments
const uiAttachments: UIAttachment[] | undefined =
visibleAttachments && visibleAttachments.length > 0
@ -1073,6 +1075,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
name: a.name || a.path || a.mimeType || a.type,
path: a.path,
data: a.data,
previewUrl: getDisplayAttachmentPreviewUrl(a),
mimeType: a.mimeType,
lineStart: a.lineStart,
lineEnd: a.lineEnd,
@ -1173,7 +1176,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
// 方案3: count this as a real user turn so rapid re-compaction (thrash)
// can be told apart from compactions spread across genuine work.
noteUserTurnForCompactionThrash(sessionId)
wsManager.send(sessionId, { type: 'user_message', content, attachments })
wsManager.send(sessionId, { type: 'user_message', content, attachments: serverAttachments })
},
enqueueMessage: (sessionId, content, attachments, options) => {
@ -2285,6 +2288,28 @@ export const useChatStore = create<ChatStore>((set, get) => ({
},
}))
function getDisplayAttachmentPreviewUrl(attachment: AttachmentRef | DisplayAttachmentRef): string | undefined {
return 'previewUrl' in attachment && typeof attachment.previewUrl === 'string'
? attachment.previewUrl
: undefined
}
function stripPreviewUrlsFromAttachments(attachments?: AttachmentRef[]): AttachmentRef[] | undefined {
if (!attachments) return undefined
return attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name,
path: attachment.path,
data: attachment.data,
mimeType: attachment.mimeType,
isDirectory: attachment.isDirectory,
lineStart: attachment.lineStart,
lineEnd: attachment.lineEnd,
note: attachment.note,
quote: attachment.quote,
}))
}
function updateOptimisticSessionTitle(sessionId: string, content: string): void {
const title = deriveSessionTitle(content)
if (!title) return

View File

@ -41,11 +41,17 @@ export type AttachmentRef = {
quote?: string
}
export type DisplayAttachmentRef = AttachmentRef & {
/** UI-only preview URL. Never send to the websocket/server/model payload. */
previewUrl?: string
}
export type UIAttachment = {
type: 'file' | 'image'
name: string
path?: string
data?: string
previewUrl?: string
mimeType?: string
isDirectory?: boolean
lineStart?: number