mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
e292d83282
commit
6d166f43d8
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
34
desktop/src/lib/composerAttachments.test.ts
Normal file
34
desktop/src/lib/composerAttachments.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
95
desktop/src/lib/composerAttachments.ts
Normal file
95
desktop/src/lib/composerAttachments.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user