mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
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:
parent
c5becca3c9
commit
0136fbb9ff
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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([])
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user