fix: keep desktop attachments path-only

Desktop file attachment selection was inlining every selected file as a data URL before sending the websocket message. Large multi-file sends could inflate the renderer request body by tens of megabytes before the server had a chance to materialize uploads.

Route Tauri file selection through the native dialog so desktop sends absolute paths, while preserving browser fallback data URLs for H5. Cover both active-session and draft-session composers plus a payload-size regression case.

Constraint: Browser/H5 cannot rely on local absolute file paths, so the existing FileReader fallback remains for non-Tauri runtimes.
Rejected: Raise websocket/body limits | would keep renderer memory pressure and still send file bytes unnecessarily.
Confidence: high
Scope-risk: narrow
Directive: Do not reintroduce FileReader data URLs for Tauri desktop file-picker attachments without measuring websocket payload size.
Tested: cd desktop && bun run test src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx
Tested: /tmp complex-project reproduction showed 12x3MB old inline payload at 50,333,174 bytes versus path-only payload around 1-2KB
Not-tested: Full desktop lint/build due unrelated existing Sidebar.tsx type errors in the dirty worktree
Related: https://github.com/NanmiCoder/cc-haha/issues/444
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 10:55:35 +08:00
parent e292d83282
commit 6d166f43d8
6 changed files with 311 additions and 76 deletions

View File

@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({
search: vi.fn(),
browse: vi.fn(),
wsSend: vi.fn(),
dialogOpen: vi.fn(),
}))
vi.mock('../../api/sessions', () => ({
@ -51,6 +52,10 @@ vi.mock('../../api/websocket', () => ({
},
}))
vi.mock('@tauri-apps/plugin-dialog', () => ({
open: mocks.dialogOpen,
}))
vi.mock('../../hooks/useMobileViewport', () => ({
useMobileViewport: () => viewportMocks.isMobile,
}))
@ -113,6 +118,7 @@ describe('ChatInput file mentions', () => {
beforeEach(() => {
vi.clearAllMocks()
delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
viewportMocks.isMobile = false
useSettingsStore.setState({ locale: 'en' })
useChatStore.setState(initialChatState, true)
@ -612,6 +618,53 @@ describe('ChatInput file mentions', () => {
})
})
it('uses native desktop file paths instead of inlining selected files', async () => {
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
})
mocks.dialogOpen.mockResolvedValueOnce([
'/Users/nanmi/tmp/large-a.log',
'C:\\Users\\Nanmi\\Desktop\\large-b.zip',
])
render(<ChatInput compact />)
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.click(screen.getByText('Add files or photos'))
expect(await screen.findByText('large-a.log')).toBeInTheDocument()
expect(await screen.findByText('large-b.zip')).toBeInTheDocument()
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: 'analyze these',
selectionStart: 'analyze these'.length,
},
})
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: 'analyze these',
attachments: [
expect.objectContaining({
type: 'file',
name: 'large-a.log',
path: '/Users/nanmi/tmp/large-a.log',
data: undefined,
}),
expect.objectContaining({
type: 'file',
name: 'large-b.zip',
path: 'C:\\Users\\Nanmi\\Desktop\\large-b.zip',
data: undefined,
}),
],
})
})
it('uses larger icon-only mobile action buttons for browser H5 access', async () => {
viewportMocks.isMobile = true
mocks.search.mockResolvedValueOnce({

View File

@ -31,23 +31,15 @@ import {
} from './composerUtils'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import {
filesToComposerAttachments,
selectNativeFileAttachments,
type ComposerAttachment,
} from '../../lib/composerAttachments'
type GitInfo = SessionGitInfo
type Attachment = {
id: string
name: string
type: 'image' | 'file'
path?: string
mimeType?: string
previewUrl?: string
data?: string
isDirectory?: boolean
lineStart?: number
lineEnd?: number
note?: string
quote?: string
}
type Attachment = ComposerAttachment
type ComposerDraft = {
input: string
@ -710,31 +702,43 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (!hasImage) return
}
const appendFiles = useCallback((files: FileList | File[]) => {
void filesToComposerAttachments(files)
.then((nextAttachments) => {
if (nextAttachments.length === 0) return
setComposerAttachments((prev) => [...prev, ...nextAttachments])
})
.catch((error) => {
console.warn('[attachments] Failed to read selected files', error)
})
}, [setComposerAttachments])
const openAttachmentPicker = useCallback(() => {
if (!isTauriRuntime()) {
fileInputRef.current?.click()
setPlusMenuOpen(false)
return
}
void selectNativeFileAttachments()
.then((nativeAttachments) => {
if (nativeAttachments) {
if (nativeAttachments.length > 0) {
setComposerAttachments((prev) => [...prev, ...nativeAttachments])
}
return
}
fileInputRef.current?.click()
})
.finally(() => setPlusMenuOpen(false))
}, [setComposerAttachments])
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
if (isMemberSession) return
const files = event.target.files
if (!files) return
Array.from(files).forEach((file) => {
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const isImage = file.type.startsWith('image/')
const reader = new FileReader()
reader.onload = () => {
setComposerAttachments((prev) => [
...prev,
{
id,
name: file.name,
type: isImage ? 'image' : 'file',
mimeType: file.type || undefined,
previewUrl: isImage ? (reader.result as string) : undefined,
data: reader.result as string,
},
])
}
reader.readAsDataURL(file)
})
appendFiles(files)
event.target.value = ''
}
@ -743,8 +747,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (isMemberSession) return
const files = event.dataTransfer.files
if (files.length > 0) {
const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
handleFileSelect(fakeEvent)
appendFiles(files)
}
}
@ -983,10 +986,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{plusMenuOpen && (
<div className={`absolute bottom-full left-0 z-50 mb-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)] ${isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'}`}>
<button
onClick={() => {
fileInputRef.current?.click()
setPlusMenuOpen(false)
}}
onClick={openAttachmentPicker}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { pathToComposerAttachment } from './composerAttachments'
describe('composer attachment payloads', () => {
it('keeps many selected desktop project files as paths instead of request-body data', () => {
const projectRoot = '/tmp/cc-haha-issue-444-regression'
const files = Array.from({ length: 12 }, (_, index) => (
`${projectRoot}/assets/large-${index + 1}.bin`
))
const oldInlineAttachments = files.map((filePath) => ({
type: 'file',
name: filePath.split('/').pop(),
data: `data:application/octet-stream;base64,${'A'.repeat(256 * 1024)}`,
mimeType: 'application/octet-stream',
}))
const oldInlinePayload = JSON.stringify({
type: 'user_message',
content: 'analyze these files',
attachments: oldInlineAttachments,
})
const pathOnlyAttachments = files.map(pathToComposerAttachment)
const pathOnlyPayload = JSON.stringify({
type: 'user_message',
content: 'analyze these files',
attachments: pathOnlyAttachments,
})
expect(oldInlinePayload.length).toBeGreaterThan(3 * 1024 * 1024)
expect(pathOnlyPayload.length).toBeLessThan(3 * 1024)
expect(pathOnlyAttachments.every((attachment) => attachment.path && !attachment.data)).toBe(true)
})
})

View File

@ -0,0 +1,95 @@
import { isTauriRuntime } from './desktopRuntime'
export type ComposerAttachment = {
id: string
name: string
type: 'image' | 'file'
path?: string
mimeType?: string
previewUrl?: string
data?: string
isDirectory?: boolean
lineStart?: number
lineEnd?: number
note?: string
quote?: string
}
function nextAttachmentId() {
return `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export function getFileNameFromPath(filePath: string): string {
const normalized = filePath.replace(/[\\/]+$/g, '')
return normalized.split(/[\\/]/).filter(Boolean).pop() || filePath
}
export function pathToComposerAttachment(filePath: string): ComposerAttachment {
return {
id: nextAttachmentId(),
name: getFileNameFromPath(filePath),
type: 'file',
path: filePath,
}
}
export async function selectNativeFileAttachments(): Promise<ComposerAttachment[] | null> {
if (!isTauriRuntime()) return null
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
multiple: true,
directory: false,
})
const paths = normalizeDialogSelection(selected)
return paths.map(pathToComposerAttachment)
} catch (error) {
console.warn('[attachments] Native file picker failed; falling back to browser file input', error)
return null
}
}
export async function filesToComposerAttachments(files: FileList | File[]): Promise<ComposerAttachment[]> {
const entries = Array.from(files)
const attachments = await Promise.all(entries.map(fileToComposerAttachment))
return attachments.filter((attachment): attachment is ComposerAttachment => !!attachment)
}
function normalizeDialogSelection(selected: string | string[] | null): string[] {
if (!selected) return []
const paths = Array.isArray(selected) ? selected : [selected]
return paths.filter((filePath) => typeof filePath === 'string' && filePath.length > 0)
}
function getNativeFilePath(file: File): string | undefined {
const path = (file as File & { path?: unknown }).path
return typeof path === 'string' && path.length > 0 ? path : undefined
}
async function fileToComposerAttachment(file: File): Promise<ComposerAttachment | null> {
const nativePath = isTauriRuntime() ? getNativeFilePath(file) : undefined
if (nativePath) {
return pathToComposerAttachment(nativePath)
}
const isImage = file.type.startsWith('image/')
const data = await readFileAsDataUrl(file)
return {
id: nextAttachmentId(),
name: file.name,
type: isImage ? 'image' : 'file',
mimeType: file.type || undefined,
previewUrl: isImage ? data : undefined,
data,
}
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error ?? new Error(`Failed to read ${file.name}`))
reader.readAsDataURL(file)
})
}

View File

@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
wsOnMessage: vi.fn(),
wsSend: vi.fn(),
wsDisconnect: vi.fn(),
dialogOpen: vi.fn(),
isMobile: false,
isTauriRuntime: false,
}))
@ -61,6 +62,10 @@ vi.mock('../lib/desktopRuntime', () => ({
isTauriRuntime: () => mocks.isTauriRuntime,
}))
vi.mock('@tauri-apps/plugin-dialog', () => ({
open: mocks.dialogOpen,
}))
vi.mock('../components/shared/DirectoryPicker', () => ({
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
<button type="button" aria-label="Pick project" data-value={value} onClick={() => onChange('/workspace/project')}>
@ -301,6 +306,49 @@ describe('EmptySession', () => {
])
})
it('uses native desktop file paths for draft attachments', async () => {
mocks.isTauriRuntime = true
mocks.dialogOpen.mockResolvedValueOnce([
'C:\\Users\\Nanmi\\Desktop\\huge-a.log',
'/Users/nanmi/tmp/huge-b.zip',
])
render(<EmptySession />)
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.click(screen.getByText('Add files or photos'))
expect(await screen.findByText('huge-a.log')).toBeInTheDocument()
expect(await screen.findByText('huge-b.zip')).toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'check these files', selectionStart: 'check these files'.length },
})
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({})
})
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: 'check these files',
attachments: [
expect.objectContaining({
type: 'file',
name: 'huge-a.log',
path: 'C:\\Users\\Nanmi\\Desktop\\huge-a.log',
data: undefined,
}),
expect.objectContaining({
type: 'file',
name: 'huge-b.zip',
path: '/Users/nanmi/tmp/huge-b.zip',
data: undefined,
}),
],
})
})
it('starts in a selected non-Git project without showing a repository warning', async () => {
mocks.getRepositoryContext.mockResolvedValueOnce(notGitRepositoryContext())

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ApiError } from '../api/client'
import { skillsApi } from '../api/skills'
import { useTranslation } from '../i18n'
@ -17,6 +17,11 @@ import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/Fi
import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel'
import { useMobileViewport } from '../hooks/useMobileViewport'
import { isTauriRuntime } from '../lib/desktopRuntime'
import {
filesToComposerAttachments,
selectNativeFileAttachments,
type ComposerAttachment,
} from '../lib/composerAttachments'
import {
FALLBACK_SLASH_COMMANDS,
findSlashToken,
@ -28,15 +33,7 @@ import {
import type { AttachmentRef } from '../types/chat'
import type { SlashCommandOption } from '../components/chat/composerUtils'
type Attachment = {
id: string
name: string
type: 'image' | 'file'
path?: string
mimeType?: string
previewUrl?: string
data?: string
}
type Attachment = ComposerAttachment
type Translate = ReturnType<typeof useTranslation>
@ -441,30 +438,42 @@ export function EmptySession() {
if (!hasImage) return
}
const appendFiles = useCallback((files: FileList | File[]) => {
void filesToComposerAttachments(files)
.then((nextAttachments) => {
if (nextAttachments.length === 0) return
setAttachments((prev) => [...prev, ...nextAttachments])
})
.catch((error) => {
console.warn('[attachments] Failed to read selected files', error)
})
}, [])
const openAttachmentPicker = useCallback(() => {
if (!isTauriRuntime()) {
fileInputRef.current?.click()
setPlusMenuOpen(false)
return
}
void selectNativeFileAttachments()
.then((nativeAttachments) => {
if (nativeAttachments) {
if (nativeAttachments.length > 0) {
setAttachments((prev) => [...prev, ...nativeAttachments])
}
return
}
fileInputRef.current?.click()
})
.finally(() => setPlusMenuOpen(false))
}, [])
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (!files) return
Array.from(files).forEach((file) => {
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const isImage = file.type.startsWith('image/')
const reader = new FileReader()
reader.onload = () => {
setAttachments((prev) => [
...prev,
{
id,
name: file.name,
type: isImage ? 'image' : 'file',
mimeType: file.type || undefined,
previewUrl: isImage ? (reader.result as string) : undefined,
data: reader.result as string,
},
])
}
reader.readAsDataURL(file)
})
appendFiles(files)
event.target.value = ''
}
@ -472,8 +481,7 @@ export function EmptySession() {
event.preventDefault()
const files = event.dataTransfer.files
if (files.length > 0) {
const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
handleFileSelect(fakeEvent)
appendFiles(files)
}
}
@ -683,10 +691,7 @@ export function EmptySession() {
isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'
}`}>
<button
onClick={() => {
fileInputRef.current?.click()
setPlusMenuOpen(false)
}}
onClick={openAttachmentPicker}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>