mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +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(),
|
search: vi.fn(),
|
||||||
browse: vi.fn(),
|
browse: vi.fn(),
|
||||||
wsSend: vi.fn(),
|
wsSend: vi.fn(),
|
||||||
|
dialogOpen: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../api/sessions', () => ({
|
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', () => ({
|
vi.mock('../../hooks/useMobileViewport', () => ({
|
||||||
useMobileViewport: () => viewportMocks.isMobile,
|
useMobileViewport: () => viewportMocks.isMobile,
|
||||||
}))
|
}))
|
||||||
@ -113,6 +118,7 @@ describe('ChatInput file mentions', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
|
||||||
viewportMocks.isMobile = false
|
viewportMocks.isMobile = false
|
||||||
useSettingsStore.setState({ locale: 'en' })
|
useSettingsStore.setState({ locale: 'en' })
|
||||||
useChatStore.setState(initialChatState, true)
|
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 () => {
|
it('uses larger icon-only mobile action buttons for browser H5 access', async () => {
|
||||||
viewportMocks.isMobile = true
|
viewportMocks.isMobile = true
|
||||||
mocks.search.mockResolvedValueOnce({
|
mocks.search.mockResolvedValueOnce({
|
||||||
|
|||||||
@ -31,23 +31,15 @@ import {
|
|||||||
} from './composerUtils'
|
} from './composerUtils'
|
||||||
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||||
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
||||||
|
import {
|
||||||
|
filesToComposerAttachments,
|
||||||
|
selectNativeFileAttachments,
|
||||||
|
type ComposerAttachment,
|
||||||
|
} from '../../lib/composerAttachments'
|
||||||
|
|
||||||
type GitInfo = SessionGitInfo
|
type GitInfo = SessionGitInfo
|
||||||
|
|
||||||
type Attachment = {
|
type Attachment = 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
|
|
||||||
}
|
|
||||||
|
|
||||||
type ComposerDraft = {
|
type ComposerDraft = {
|
||||||
input: string
|
input: string
|
||||||
@ -710,31 +702,43 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
if (!hasImage) return
|
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>) => {
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (isMemberSession) return
|
if (isMemberSession) return
|
||||||
const files = event.target.files
|
const files = event.target.files
|
||||||
if (!files) return
|
if (!files) return
|
||||||
|
|
||||||
Array.from(files).forEach((file) => {
|
appendFiles(files)
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -743,8 +747,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
if (isMemberSession) return
|
if (isMemberSession) return
|
||||||
const files = event.dataTransfer.files
|
const files = event.dataTransfer.files
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
|
appendFiles(files)
|
||||||
handleFileSelect(fakeEvent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -983,10 +986,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
{plusMenuOpen && (
|
{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]'}`}>
|
<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
|
<button
|
||||||
onClick={() => {
|
onClick={openAttachmentPicker}
|
||||||
fileInputRef.current?.click()
|
|
||||||
setPlusMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
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>
|
<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(),
|
wsOnMessage: vi.fn(),
|
||||||
wsSend: vi.fn(),
|
wsSend: vi.fn(),
|
||||||
wsDisconnect: vi.fn(),
|
wsDisconnect: vi.fn(),
|
||||||
|
dialogOpen: vi.fn(),
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
isTauriRuntime: false,
|
isTauriRuntime: false,
|
||||||
}))
|
}))
|
||||||
@ -61,6 +62,10 @@ vi.mock('../lib/desktopRuntime', () => ({
|
|||||||
isTauriRuntime: () => mocks.isTauriRuntime,
|
isTauriRuntime: () => mocks.isTauriRuntime,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/plugin-dialog', () => ({
|
||||||
|
open: mocks.dialogOpen,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('../components/shared/DirectoryPicker', () => ({
|
vi.mock('../components/shared/DirectoryPicker', () => ({
|
||||||
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
|
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
|
||||||
<button type="button" aria-label="Pick project" data-value={value} onClick={() => onChange('/workspace/project')}>
|
<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 () => {
|
it('starts in a selected non-Git project without showing a repository warning', async () => {
|
||||||
mocks.getRepositoryContext.mockResolvedValueOnce(notGitRepositoryContext())
|
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 { ApiError } from '../api/client'
|
||||||
import { skillsApi } from '../api/skills'
|
import { skillsApi } from '../api/skills'
|
||||||
import { useTranslation } from '../i18n'
|
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 { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel'
|
||||||
import { useMobileViewport } from '../hooks/useMobileViewport'
|
import { useMobileViewport } from '../hooks/useMobileViewport'
|
||||||
import { isTauriRuntime } from '../lib/desktopRuntime'
|
import { isTauriRuntime } from '../lib/desktopRuntime'
|
||||||
|
import {
|
||||||
|
filesToComposerAttachments,
|
||||||
|
selectNativeFileAttachments,
|
||||||
|
type ComposerAttachment,
|
||||||
|
} from '../lib/composerAttachments'
|
||||||
import {
|
import {
|
||||||
FALLBACK_SLASH_COMMANDS,
|
FALLBACK_SLASH_COMMANDS,
|
||||||
findSlashToken,
|
findSlashToken,
|
||||||
@ -28,15 +33,7 @@ import {
|
|||||||
import type { AttachmentRef } from '../types/chat'
|
import type { AttachmentRef } from '../types/chat'
|
||||||
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
||||||
|
|
||||||
type Attachment = {
|
type Attachment = ComposerAttachment
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
type: 'image' | 'file'
|
|
||||||
path?: string
|
|
||||||
mimeType?: string
|
|
||||||
previewUrl?: string
|
|
||||||
data?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Translate = ReturnType<typeof useTranslation>
|
type Translate = ReturnType<typeof useTranslation>
|
||||||
|
|
||||||
@ -441,30 +438,42 @@ export function EmptySession() {
|
|||||||
if (!hasImage) return
|
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 handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = event.target.files
|
const files = event.target.files
|
||||||
if (!files) return
|
if (!files) return
|
||||||
|
|
||||||
Array.from(files).forEach((file) => {
|
appendFiles(files)
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -472,8 +481,7 @@ export function EmptySession() {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const files = event.dataTransfer.files
|
const files = event.dataTransfer.files
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
|
appendFiles(files)
|
||||||
handleFileSelect(fakeEvent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -683,10 +691,7 @@ export function EmptySession() {
|
|||||||
isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'
|
isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'
|
||||||
}`}>
|
}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={openAttachmentPicker}
|
||||||
fileInputRef.current?.click()
|
|
||||||
setPlusMenuOpen(false)
|
|
||||||
}}
|
|
||||||
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)]"
|
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>
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user