feat: paste local files into chat attachments
Copied local files previously required the attachment picker. The chat composer now imports one or more supported files directly from paste, sharing parsing progress, limits, and batch rollback with file selection. Text and screenshot paste remain available, and failures preserve drafts. Validation: prior 166 focused tests, typecheck, and lint passed; 28 targeted tests passed again before commit. The prior full suite had 4214 passes, 67 skips, and 6 window-recovery failures; window tests passed 10/10 alone. Native clipboard behavior was verified on Windows; macOS/Linux remain unverified. Release note: 现在可在文件管理器中复制一个或多个支持的本地文件,直接粘贴到聊天输入框添加附件;可查看读取和解析进度,失败时保留草稿与已有附件。
This commit is contained in:
@@ -39,6 +39,68 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ContextManager', () => {
|
||||
it('imports multiple pasted paths with real document parsing and request enrichment', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-pasted-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const paths = [join(directory, 'notes.txt'), join(directory, 'report.docx')]
|
||||
await writeFile(paths[0]!, 'PASTED_TEXT')
|
||||
await writeFile(paths[1]!, zipSync({
|
||||
'word/document.xml': strToU8('<w:document><w:p><w:t>PASTED_DOCX</w:t></w:p></w:document>')
|
||||
}))
|
||||
const manager = new ContextManager()
|
||||
const progress = vi.fn()
|
||||
const attachments = await manager.importFiles(paths, progress)
|
||||
expect(attachments.map(item => item.name)).toEqual(['notes.txt', 'report.docx'])
|
||||
expect(showOpenDialog).not.toHaveBeenCalled()
|
||||
expect(progress.mock.calls.map(([value]) => value)).toEqual([
|
||||
{ phase: 'reading', fileName: 'notes.txt', fileNumber: 1, fileCount: 2 },
|
||||
{ phase: 'reading', fileName: 'report.docx', fileNumber: 2, fileCount: 2 },
|
||||
{ phase: 'parsing', fileName: 'report.docx', fileNumber: 2, fileCount: 2 }
|
||||
])
|
||||
const request = manager.enrichRequest({
|
||||
requestId: crypto.randomUUID(), conversationId: 'pasted-files', prompt: 'Read',
|
||||
contextIds: attachments.map(item => item.id)
|
||||
})
|
||||
expect(request.prompt).toContain('PASTED_TEXT')
|
||||
expect(request.prompt).toContain('PASTED_DOCX')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unsupported.zip', 1, /不支持的文件类型/],
|
||||
['large.txt', 256 * 1024 + 1, /256KB/],
|
||||
['large.pdf', 20 * 1024 * 1024 + 1, /20MB/],
|
||||
['large.png', 12 * 1024 * 1024 + 1, /12MB/]
|
||||
] as const)('rolls back a pasted batch containing %s and preserves earlier attachments', async (name, size, error) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-pasted-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const good = join(directory, 'notes.txt')
|
||||
const bad = join(directory, name)
|
||||
await writeFile(good, 'Retained text')
|
||||
await writeFile(bad, Buffer.alloc(size))
|
||||
const manager = new ContextManager()
|
||||
const [existing] = await manager.importFiles([good])
|
||||
const remove = vi.spyOn(manager, 'remove')
|
||||
await expect(manager.importFiles([good, bad])).rejects.toThrow(error)
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
expect(remove).not.toHaveBeenCalledWith(existing!.id)
|
||||
expect(manager.enrichRequest({
|
||||
requestId: crypto.randomUUID(), conversationId: 'pasted-files', prompt: 'Read',
|
||||
contextIds: [existing!.id]
|
||||
}).prompt).toContain('Retained text')
|
||||
})
|
||||
|
||||
it('uses the upload batch cap and hides filesystem paths in paste failures', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-pasted-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'notes.txt')
|
||||
await writeFile(path, 'Text')
|
||||
const manager = new ContextManager()
|
||||
expect(await manager.importFiles(Array(9).fill(path))).toHaveLength(8)
|
||||
await expect(manager.importFiles([join(directory, 'missing.txt')])).rejects.toThrow(
|
||||
'无法读取所选文件,请检查文件权限和状态'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the tail of a real DOCX larger than the former extracted-text cap', async () => {
|
||||
const content = `${'word text '.repeat(30_000)}DOCX_TAIL`
|
||||
const data = Buffer.from(zipSync({
|
||||
|
||||
@@ -300,8 +300,15 @@ export class ContextManager {
|
||||
return []
|
||||
}
|
||||
|
||||
return this.importFiles(result.filePaths, onProgress)
|
||||
}
|
||||
|
||||
async importFiles(
|
||||
paths: string[],
|
||||
onProgress?: (progress: ContextFileSelectionProgress) => void
|
||||
): Promise<ContextAttachment[]> {
|
||||
const attachments: ContextAttachment[] = []
|
||||
const selectedPaths = result.filePaths.slice(
|
||||
const selectedPaths = paths.slice(
|
||||
0,
|
||||
maximumAttachmentsPerMessage
|
||||
)
|
||||
|
||||
@@ -531,7 +531,7 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
'CommandOrControl+Shift+Space',
|
||||
{} as never,
|
||||
capabilityService as never,
|
||||
{ clear: vi.fn(), selectFiles } as never,
|
||||
{ clear: vi.fn(), selectFiles, importFiles: selectFiles } as never,
|
||||
{} as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
@@ -616,6 +616,11 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
electronMocks.handlers.get(ipcChannels.contextSelectFiles)?.(event)
|
||||
).resolves.toEqual([])
|
||||
expect(selectFiles).toHaveBeenCalledWith(window, expect.any(Function))
|
||||
const importFiles = electronMocks.handlers.get(ipcChannels.contextImportFiles)!
|
||||
await expect(importFiles(event, { paths: ['C:\\scan.pdf'] })).resolves.toEqual([])
|
||||
expect(selectFiles).toHaveBeenCalledWith(['C:\\scan.pdf'], expect.any(Function))
|
||||
expect(() => importFiles(event, { paths: [''] })).toThrow()
|
||||
expect(() => importFiles({ ...event, senderFrame: {} }, { paths: ['C:\\scan.pdf'] })).toThrow()
|
||||
expect(webContents.send).toHaveBeenCalledWith(
|
||||
ipcChannels.contextFileSelectionProgress,
|
||||
{
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
browserStopRequestSchema,
|
||||
browserTypeRequestSchema,
|
||||
clipboardTextSchema,
|
||||
contextImportFilesSchema,
|
||||
conversationQueueUserInputSchema,
|
||||
defaultRuntimeSettings,
|
||||
knowledgeCreateSchema,
|
||||
@@ -7570,6 +7571,16 @@ export function registerIpcHandlers(
|
||||
})
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.contextImportFiles, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { paths } = contextImportFilesSchema.parse(input)
|
||||
return contextManager.importFiles(paths, (progress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send(ipcChannels.contextFileSelectionProgress, progress)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.contextAddPastedImage,
|
||||
(event, input: unknown) => {
|
||||
|
||||
Reference in New Issue
Block a user