mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-02 16:51:13 +08:00
fix(desktop): support pasted file attachments #1086
This commit is contained in:
parent
8e9c104514
commit
c2774fc170
@ -1,9 +1,12 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||||
import { createElectronHost } from '../src/lib/desktopHost/electronHost'
|
import { createElectronHost } from '../src/lib/desktopHost/electronHost'
|
||||||
import type { DesktopHostUnlisten } from '../src/lib/desktopHost/types'
|
import type { DesktopHostUnlisten } from '../src/lib/desktopHost/types'
|
||||||
import type { ElectronEventChannel, ElectronIpcChannel } from './ipc/channels'
|
import type { ElectronEventChannel, ElectronIpcChannel } from './ipc/channels'
|
||||||
|
|
||||||
const electronHost = createElectronHost({
|
const electronHost = createElectronHost({
|
||||||
|
getPathForFile(file) {
|
||||||
|
return webUtils.getPathForFile(file)
|
||||||
|
},
|
||||||
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T> {
|
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T> {
|
||||||
return ipcRenderer.invoke(channel, payload) as Promise<T>
|
return ipcRenderer.invoke(channel, payload) as Promise<T>
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1292,7 +1292,53 @@ describe('ChatInput file mentions', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores pasted images that finish loading after the prompt was sent', async () => {
|
it('pastes copied desktop files into the active session as path-only attachments', async () => {
|
||||||
|
installElectronFileHost()
|
||||||
|
const copiedFile = new File(['# Project notes'], 'ignored-name.md', { type: 'text/markdown' })
|
||||||
|
Object.defineProperty(copiedFile, 'path', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'C:\\Users\\Nanmi\\Desktop\\project-notes.md',
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ChatInput compact />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||||
|
fireEvent.paste(input, {
|
||||||
|
clipboardData: {
|
||||||
|
files: [],
|
||||||
|
items: [{
|
||||||
|
kind: 'file',
|
||||||
|
type: 'text/markdown',
|
||||||
|
getAsFile: () => copiedFile,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('project-notes.md')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.change(input, {
|
||||||
|
target: {
|
||||||
|
value: 'review this document',
|
||||||
|
selectionStart: 'review this document'.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
|
||||||
|
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
||||||
|
type: 'user_message',
|
||||||
|
content: 'review this document',
|
||||||
|
attachments: [
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'file',
|
||||||
|
name: 'project-notes.md',
|
||||||
|
path: 'C:\\Users\\Nanmi\\Desktop\\project-notes.md',
|
||||||
|
data: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores pasted files that finish loading after the prompt was sent', async () => {
|
||||||
class DeferredFileReader {
|
class DeferredFileReader {
|
||||||
result: string | ArrayBuffer | null = null
|
result: string | ArrayBuffer | null = null
|
||||||
onload: ((event: ProgressEvent<FileReader>) => void) | null = null
|
onload: ((event: ProgressEvent<FileReader>) => void) | null = null
|
||||||
@ -1311,7 +1357,9 @@ describe('ChatInput file mentions', () => {
|
|||||||
|
|
||||||
fireEvent.paste(input, {
|
fireEvent.paste(input, {
|
||||||
clipboardData: {
|
clipboardData: {
|
||||||
|
files: [],
|
||||||
items: [{
|
items: [{
|
||||||
|
kind: 'file',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
getAsFile: () => file,
|
getAsFile: () => file,
|
||||||
}],
|
}],
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import { useMobileViewport } from '../../hooks/useMobileViewport'
|
|||||||
import { isDesktopRuntime } from '../../lib/desktopRuntime'
|
import { isDesktopRuntime } from '../../lib/desktopRuntime'
|
||||||
import {
|
import {
|
||||||
filesToComposerAttachments,
|
filesToComposerAttachments,
|
||||||
|
getDataTransferFiles,
|
||||||
selectNativeFileAttachments,
|
selectNativeFileAttachments,
|
||||||
type ComposerAttachment,
|
type ComposerAttachment,
|
||||||
} from '../../lib/composerAttachments'
|
} from '../../lib/composerAttachments'
|
||||||
@ -835,42 +836,22 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
|
|
||||||
const handlePaste = (event: React.ClipboardEvent) => {
|
const handlePaste = (event: React.ClipboardEvent) => {
|
||||||
if (isMemberSession) return
|
if (isMemberSession) return
|
||||||
const items = event.clipboardData?.items
|
const files = getDataTransferFiles(event.clipboardData)
|
||||||
if (!items) return
|
if (files.length === 0) return
|
||||||
|
|
||||||
let hasImage = false
|
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
|
||||||
const item = items[i]
|
|
||||||
if (!item || !item.type.startsWith('image/')) continue
|
|
||||||
|
|
||||||
hasImage = true
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const file = item.getAsFile()
|
|
||||||
if (!file) continue
|
|
||||||
|
|
||||||
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
||||||
const pasteGeneration = pasteGenerationRef.current
|
const pasteGeneration = pasteGenerationRef.current
|
||||||
const pastedSessionId = activeTabId
|
const pastedSessionId = activeTabId
|
||||||
const reader = new FileReader()
|
void filesToComposerAttachments(files)
|
||||||
reader.onload = () => {
|
.then((nextAttachments) => {
|
||||||
if (pasteGeneration !== pasteGenerationRef.current) return
|
if (pasteGeneration !== pasteGenerationRef.current) return
|
||||||
if (pastedSessionId !== useTabStore.getState().activeTabId) return
|
if (pastedSessionId !== useTabStore.getState().activeTabId) return
|
||||||
setComposerAttachments((prev) => [
|
if (nextAttachments.length === 0) return
|
||||||
...prev,
|
setComposerAttachments((prev) => [...prev, ...nextAttachments])
|
||||||
{
|
})
|
||||||
id,
|
.catch((error) => {
|
||||||
name: `pasted-image-${Date.now()}.png`,
|
console.warn('[attachments] Failed to read pasted files', error)
|
||||||
type: 'image',
|
})
|
||||||
mimeType: file.type || 'image/png',
|
|
||||||
previewUrl: reader.result as string,
|
|
||||||
data: reader.result as string,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasImage) return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const appendFiles = useCallback((files: FileList | File[]) => {
|
const appendFiles = useCallback((files: FileList | File[]) => {
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { browserHost } from './desktopHost/browserHost'
|
import { browserHost } from './desktopHost/browserHost'
|
||||||
import { pathToComposerAttachment, selectNativeFileAttachments } from './composerAttachments'
|
import {
|
||||||
|
filesToComposerAttachments,
|
||||||
|
getDataTransferFiles,
|
||||||
|
pathToComposerAttachment,
|
||||||
|
selectNativeFileAttachments,
|
||||||
|
} from './composerAttachments'
|
||||||
|
|
||||||
describe('composer attachment payloads', () => {
|
describe('composer attachment payloads', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -61,4 +66,61 @@ describe('composer attachment payloads', () => {
|
|||||||
'/workspace/b.log',
|
'/workspace/b.log',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps pasted desktop files as native path attachments across common file types', async () => {
|
||||||
|
const nativePaths = new Map<File, string>()
|
||||||
|
const files = [
|
||||||
|
new File(['# Notes'], 'notes.md', { type: 'text/markdown' }),
|
||||||
|
new File(['pdf'], 'brief.pdf', { type: 'application/pdf' }),
|
||||||
|
new File(['docx'], 'proposal.docx', {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
nativePaths.set(files[0]!, 'C:\\Users\\Nanmi\\Desktop\\notes.md')
|
||||||
|
nativePaths.set(files[1]!, 'C:\\Users\\Nanmi\\Desktop\\brief.pdf')
|
||||||
|
nativePaths.set(files[2]!, 'C:\\Users\\Nanmi\\Desktop\\proposal.docx')
|
||||||
|
window.desktopHost = {
|
||||||
|
...browserHost,
|
||||||
|
kind: 'electron',
|
||||||
|
isDesktop: true,
|
||||||
|
files: {
|
||||||
|
getPathForFile: file => nativePaths.get(file) ?? '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await filesToComposerAttachments(files)
|
||||||
|
|
||||||
|
expect(attachments.map(({ name, path, data }) => ({ name, path, data }))).toEqual([
|
||||||
|
{ name: 'notes.md', path: 'C:\\Users\\Nanmi\\Desktop\\notes.md', data: undefined },
|
||||||
|
{ name: 'brief.pdf', path: 'C:\\Users\\Nanmi\\Desktop\\brief.pdf', data: undefined },
|
||||||
|
{ name: 'proposal.docx', path: 'C:\\Users\\Nanmi\\Desktop\\proposal.docx', data: undefined },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads clipboard files from DataTransfer items when the files list is empty', () => {
|
||||||
|
const markdown = new File(['# Notes'], 'notes.md', { type: 'text/markdown' })
|
||||||
|
const dataTransfer = {
|
||||||
|
files: [],
|
||||||
|
items: [
|
||||||
|
{ kind: 'string', getAsFile: () => null },
|
||||||
|
{ kind: 'file', getAsFile: () => markdown },
|
||||||
|
],
|
||||||
|
} as unknown as DataTransfer
|
||||||
|
|
||||||
|
expect(getDataTransferFiles(dataTransfer)).toEqual([markdown])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps every clipboard item when the browser files list is incomplete', () => {
|
||||||
|
const markdown = new File(['# Notes'], 'notes.md', { type: 'text/markdown' })
|
||||||
|
const spreadsheet = new File(['name,total'], 'budget.csv', { type: 'text/csv' })
|
||||||
|
const dataTransfer = {
|
||||||
|
files: [markdown],
|
||||||
|
items: [
|
||||||
|
{ kind: 'file', getAsFile: () => markdown },
|
||||||
|
{ kind: 'file', getAsFile: () => spreadsheet },
|
||||||
|
],
|
||||||
|
} as unknown as DataTransfer
|
||||||
|
|
||||||
|
expect(getDataTransferFiles(dataTransfer)).toEqual([markdown, spreadsheet])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -44,11 +44,26 @@ export function pathsToComposerAttachments(filePaths: string[]): ComposerAttachm
|
|||||||
|
|
||||||
export function dataTransferHasFiles(dataTransfer: DataTransfer): boolean {
|
export function dataTransferHasFiles(dataTransfer: DataTransfer): boolean {
|
||||||
const types = Array.from(dataTransfer.types ?? [])
|
const types = Array.from(dataTransfer.types ?? [])
|
||||||
return types.includes('Files') || dataTransfer.files.length > 0
|
const items = Array.from(dataTransfer.items ?? [])
|
||||||
|
return (
|
||||||
|
types.includes('Files') ||
|
||||||
|
dataTransfer.files.length > 0 ||
|
||||||
|
items.some((item) => item.kind === 'file')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataTransferFiles(dataTransfer: DataTransfer): File[] {
|
||||||
|
const files = Array.from(dataTransfer.files ?? [])
|
||||||
|
const itemFiles = Array.from(dataTransfer.items ?? []).flatMap((item) => {
|
||||||
|
if (item.kind !== 'file') return []
|
||||||
|
const file = item.getAsFile()
|
||||||
|
return file ? [file] : []
|
||||||
|
})
|
||||||
|
return itemFiles.length > files.length ? itemFiles : files
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dataTransferToComposerAttachments(dataTransfer: DataTransfer): Promise<ComposerAttachment[]> {
|
export async function dataTransferToComposerAttachments(dataTransfer: DataTransfer): Promise<ComposerAttachment[]> {
|
||||||
return filesToComposerAttachments(dataTransfer.files)
|
return filesToComposerAttachments(getDataTransferFiles(dataTransfer))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function selectNativeFileAttachments(): Promise<ComposerAttachment[] | null> {
|
export async function selectNativeFileAttachments(): Promise<ComposerAttachment[] | null> {
|
||||||
@ -81,8 +96,8 @@ function normalizeDialogSelection(selected: string | string[] | null): string[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getNativeFilePath(file: File): string | undefined {
|
function getNativeFilePath(file: File): string | undefined {
|
||||||
const path = (file as File & { path?: unknown }).path
|
const path = getDesktopHost().files.getPathForFile(file)
|
||||||
return typeof path === 'string' && path.length > 0 ? path : undefined
|
return path.length > 0 ? path : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fileToComposerAttachment(file: File): Promise<ComposerAttachment | null> {
|
async function fileToComposerAttachment(file: File): Promise<ComposerAttachment | null> {
|
||||||
|
|||||||
@ -72,6 +72,12 @@ export const browserHost: DesktopHost = {
|
|||||||
unsupported('Writing clipboard text')
|
unsupported('Writing clipboard text')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
files: {
|
||||||
|
getPathForFile(file) {
|
||||||
|
const legacyPath = (file as File & { path?: unknown }).path
|
||||||
|
return typeof legacyPath === 'string' ? legacyPath : ''
|
||||||
|
},
|
||||||
|
},
|
||||||
events: {
|
events: {
|
||||||
async listen() {
|
async listen() {
|
||||||
return noopUnlisten
|
return noopUnlisten
|
||||||
|
|||||||
@ -37,6 +37,19 @@ describe('electron desktop host', () => {
|
|||||||
expect(invoke).toHaveBeenNthCalledWith(2, ELECTRON_IPC_CHANNELS.clipboardWriteText, 'to clipboard')
|
expect(invoke).toHaveBeenNthCalledWith(2, ELECTRON_IPC_CHANNELS.clipboardWriteText, 'to clipboard')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves native paths for renderer File objects through the preload bridge', () => {
|
||||||
|
const file = new File(['# Notes'], 'notes.md', { type: 'text/markdown' })
|
||||||
|
const getPathForFile = vi.fn().mockReturnValue('C:\\Users\\Nanmi\\Desktop\\notes.md')
|
||||||
|
const host = createElectronHost({
|
||||||
|
getPathForFile,
|
||||||
|
invoke: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(host.files.getPathForFile(file)).toBe('C:\\Users\\Nanmi\\Desktop\\notes.md')
|
||||||
|
expect(getPathForFile).toHaveBeenCalledWith(file)
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects invalid preload payloads before invoking Electron IPC', async () => {
|
it('rejects invalid preload payloads before invoking Electron IPC', async () => {
|
||||||
const invoke = vi.fn()
|
const invoke = vi.fn()
|
||||||
const host = createElectronHost({
|
const host = createElectronHost({
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { validateElectronIpcPayload } from '../../../electron/ipc/capabilities'
|
|||||||
|
|
||||||
export type ElectronHostBridge = {
|
export type ElectronHostBridge = {
|
||||||
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T>
|
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T>
|
||||||
|
getPathForFile?(file: File): string
|
||||||
subscribe<T>(
|
subscribe<T>(
|
||||||
channel: ElectronEventChannel,
|
channel: ElectronEventChannel,
|
||||||
handler: (payload: T) => void,
|
handler: (payload: T) => void,
|
||||||
@ -87,6 +88,14 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
|||||||
readText: () => invoke(ELECTRON_IPC_CHANNELS.clipboardReadText),
|
readText: () => invoke(ELECTRON_IPC_CHANNELS.clipboardReadText),
|
||||||
writeText: text => invoke(ELECTRON_IPC_CHANNELS.clipboardWriteText, text),
|
writeText: text => invoke(ELECTRON_IPC_CHANNELS.clipboardWriteText, text),
|
||||||
},
|
},
|
||||||
|
files: {
|
||||||
|
getPathForFile(file) {
|
||||||
|
const nativePath = bridge.getPathForFile?.(file)
|
||||||
|
if (nativePath) return nativePath
|
||||||
|
const legacyPath = (file as File & { path?: unknown }).path
|
||||||
|
return typeof legacyPath === 'string' ? legacyPath : ''
|
||||||
|
},
|
||||||
|
},
|
||||||
events: {
|
events: {
|
||||||
listen: (_eventName, handler) => subscribe(ELECTRON_EVENT_CHANNELS.event, handler),
|
listen: (_eventName, handler) => subscribe(ELECTRON_EVENT_CHANNELS.event, handler),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -212,6 +212,9 @@ export type DesktopHost = {
|
|||||||
readText(): Promise<string>
|
readText(): Promise<string>
|
||||||
writeText(text: string): Promise<void>
|
writeText(text: string): Promise<void>
|
||||||
}
|
}
|
||||||
|
files: {
|
||||||
|
getPathForFile(file: File): string
|
||||||
|
}
|
||||||
events: {
|
events: {
|
||||||
listen<T>(eventName: string, handler: (payload: T) => void): Promise<DesktopHostUnlisten>
|
listen<T>(eventName: string, handler: (payload: T) => void): Promise<DesktopHostUnlisten>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,6 +149,7 @@ import { useTabStore } from '../stores/tabStore'
|
|||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
import { usePluginStore } from '../stores/pluginStore'
|
import { usePluginStore } from '../stores/pluginStore'
|
||||||
import type { RepositoryContextResult } from '../api/sessions'
|
import type { RepositoryContextResult } from '../api/sessions'
|
||||||
|
import { browserHost } from '../lib/desktopHost/browserHost'
|
||||||
|
|
||||||
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
|
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
|
||||||
return {
|
return {
|
||||||
@ -725,6 +726,62 @@ describe('EmptySession', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('pastes copied desktop files into a new-session draft as path attachments', async () => {
|
||||||
|
mocks.isTauriRuntime = true
|
||||||
|
const copiedFile = new File(['{\"name\":\"cc-haha\"}'], 'ignored-name.json', {
|
||||||
|
type: 'application/json',
|
||||||
|
})
|
||||||
|
Object.defineProperty(copiedFile, 'path', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'C:\\Users\\Nanmi\\Desktop\\project-context.json',
|
||||||
|
})
|
||||||
|
window.desktopHost = {
|
||||||
|
...browserHost,
|
||||||
|
kind: 'electron',
|
||||||
|
isDesktop: true,
|
||||||
|
webview: {
|
||||||
|
...browserHost.webview,
|
||||||
|
onDragDropEvent: vi.fn().mockResolvedValue(mocks.webviewUnlisten),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<EmptySession />)
|
||||||
|
|
||||||
|
fireEvent.paste(screen.getByRole('textbox'), {
|
||||||
|
clipboardData: {
|
||||||
|
files: [],
|
||||||
|
items: [{
|
||||||
|
kind: 'file',
|
||||||
|
type: 'application/json',
|
||||||
|
getAsFile: () => copiedFile,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('project-context.json')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {
|
||||||
|
target: { value: 'use this context', selectionStart: 'use this context'.length },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
|
||||||
|
})
|
||||||
|
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
|
||||||
|
type: 'user_message',
|
||||||
|
content: 'use this context',
|
||||||
|
attachments: [
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'file',
|
||||||
|
name: 'project-context.json',
|
||||||
|
path: 'C:\\Users\\Nanmi\\Desktop\\project-context.json',
|
||||||
|
data: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps slash and @ popovers visible above the empty-session drop target', async () => {
|
it('keeps slash and @ popovers visible above the empty-session drop target', async () => {
|
||||||
mocks.search.mockResolvedValueOnce({
|
mocks.search.mockResolvedValueOnce({
|
||||||
currentPath: '/workspace/project',
|
currentPath: '/workspace/project',
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { publicAssetPath } from '../lib/publicAsset'
|
|||||||
import { resolveActiveProviderRuntimeSelection } from '../lib/runtimeSelection'
|
import { resolveActiveProviderRuntimeSelection } from '../lib/runtimeSelection'
|
||||||
import {
|
import {
|
||||||
filesToComposerAttachments,
|
filesToComposerAttachments,
|
||||||
|
getDataTransferFiles,
|
||||||
selectNativeFileAttachments,
|
selectNativeFileAttachments,
|
||||||
type ComposerAttachment,
|
type ComposerAttachment,
|
||||||
} from '../lib/composerAttachments'
|
} from '../lib/composerAttachments'
|
||||||
@ -472,37 +473,18 @@ export function EmptySession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = (event: React.ClipboardEvent) => {
|
const handlePaste = (event: React.ClipboardEvent) => {
|
||||||
const items = event.clipboardData?.items
|
const files = getDataTransferFiles(event.clipboardData)
|
||||||
if (!items) return
|
if (files.length === 0) return
|
||||||
|
|
||||||
let hasImage = false
|
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
|
||||||
const item = items[i]
|
|
||||||
if (!item || !item.type.startsWith('image/')) continue
|
|
||||||
|
|
||||||
hasImage = true
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const file = item.getAsFile()
|
void filesToComposerAttachments(files)
|
||||||
if (!file) continue
|
.then((nextAttachments) => {
|
||||||
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
if (nextAttachments.length === 0) return
|
||||||
const reader = new FileReader()
|
setAttachments((prev) => [...prev, ...nextAttachments])
|
||||||
reader.onload = () => {
|
})
|
||||||
setAttachments((prev) => [
|
.catch((error) => {
|
||||||
...prev,
|
console.warn('[attachments] Failed to read pasted files', error)
|
||||||
{
|
})
|
||||||
id,
|
|
||||||
name: `pasted-image-${Date.now()}.png`,
|
|
||||||
type: 'image',
|
|
||||||
mimeType: file.type || undefined,
|
|
||||||
previewUrl: reader.result as string,
|
|
||||||
data: reader.result as string,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasImage) return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const appendFiles = useCallback((files: FileList | File[]) => {
|
const appendFiles = useCallback((files: FileList | File[]) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user