diff --git a/FEATURES.md b/FEATURES.md index 4bc1aa31..7baa2508 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -47,7 +47,10 @@ otherwise. available. Conversation search has an inline clear action that restores the scoped list and returns focus to the input. - [x] **File, screenshot, window, and clipboard context**: Added to model - context only after explicit user selection. + context only after explicit user selection. Paste one or more supported local + files into the chat composer with `Ctrl+V` to add attachments, using the same + parsing progress and limits as the attachment button. Text and screenshot + paste remain available. See [attachment rules](./docs/features/document-processing/prd.md#321-聊天附件入口). - [x] **Per-file workspace diffs**: Changed files expose separate staged and unstaged diffs, including deleted, renamed, and untracked files. Refresh reloads the selected diff, browsed directory, and expanded directories; more than 50 changes diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index db33116d..29c08f5d 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -19,7 +19,7 @@ - [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。 - [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话。锚定的“项目 → 会话”菜单支持键盘导航和精确打开对话,不再使用整块活动弹窗;空闲时摘要入口仍显示,项目选择器继续显示活动数量。 - [x] **紧凑会话控件**:输入选项集中在紧凑设置面板,Runtime、工作模式、发送和队列操作保持可用;会话搜索提供内嵌清除按钮,清除后恢复当前范围列表并把焦点返回输入框。 -- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。 +- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。聊天输入框支持 `Ctrl+V` 粘贴一个或多个本地支持文件并自动添加为附件,沿用附件按钮的解析进度与限制;普通文本和截图仍可直接粘贴。详见[聊天附件规则](./docs/features/document-processing/prd.md#321-聊天附件入口)。 - [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异、当前浏览目录和已展开目录,超过 50 项变更可继续加载。 - [x] **工作区文件与 Git 管理**:支持文件和目录的新建、重命名、移动、删除、属性查看,以及本地/远程分支搜索与切换、新建分支、显式 Fetch、提交历史和单文件提交差异。工具按文件/Git 视图分组,分支搜索使用统一紧凑表单,子目录保留文字导航而根目录不再显示孤立图标。提交历史贴底,首次展开占 Git 内容区一半,支持调整比例、独立滚动和统一刷新;切换项目自动检测 Git,不沿用其他项目状态。移动限于工作区内,删除须确认,分支冲突不自动 stash 或丢弃改动;托管 SSH 操作需要 Agent `0.11.23`。 - [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index d105f254..734ac977 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -638,8 +638,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 支持上下文状态的 Runtime 在输入区下方复用同一紧凑用量条;文案必须区分“本次模型调用”和“压缩后对话估算”。手动压缩仅在当前 Runtime 明确支持且没有活动回复时显示,作为元信息区左下角的浮动次操作,不参与输入区高度计算;元信息区始终预留稳定高度,切换 Runtime 不得让输入框上下位移。元信息区与窗口底部只保留紧凑安全留白,不形成额外空白区。进行中禁用重复操作,结果通过应用通知反馈。 - 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。 - 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。 -- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。 -- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。 +- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,系统截图和从文件管理器复制的一个或多个支持文件加入本次消息附件。文件粘贴与上传按钮共用读取、解析进度和数量限制;读取期间阻止重复导入及发送,错误就地关联输入框和附件按钮。具体类型、大小及批次失败规则见[聊天附件要求](./docs/features/document-processing/prd.md#321-聊天附件入口)。默认工具栏不提供独立“读取剪贴板”“截取当前屏幕”和“选择应用窗口”入口。 +- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴文件、图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。 - 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。 - 空对话展示可执行的起始建议,发送失败保留输入并提供重试。 - 消息流的滚动范围必须使用已渲染消息的真实高度。长会话可以按批次只挂载最近消息并由用户 @@ -768,7 +768,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - DOC、XLS、PPT 等旧格式通过受控转换 Provider 生成新式 Office 文档和 PDF。转换子进程必须禁用宏和网络,限制输入、输出、内存、超时与临时目录,并在关闭或取消时清理。 - OCR 来源使用“本地模型 / 远程服务”互斥选择。选择本地后显示模型下载、模型下拉选择和本地运行参数;选择远程后显示 MinerU、PaddleOCR-VL 等服务连接配置。未实现的远程服务入口保持可读但禁用,不再增加与来源选择重复的“隐私与云端处理”授权区。 - 用户配置并保存远程 OCR 服务即表示选择该处理路径,不再逐场景重复询问。界面仍须明确显示当前服务名称、处理范围和远程属性,API 密钥只保存在主进程加密设置中,未选中远程服务时不得上传文档。 -- 解析结果使用统一文档结构,至少保留文档标题、来源格式、页码或工作表定位、正文块、置信度、处理方式和警告。聊天附件对结果做有界截断,知识库使用完整结果分块和索引。 +- 解析结果使用统一文档结构,至少保留文档标题、来源格式、页码或工作表定位、正文块、置信度、处理方式和警告。聊天附件保留完整解析文本,超过暂存容量时报错;知识库使用完整结果分块和索引,具体要求见[文档处理 PRD](./docs/features/document-processing/prd.md#32-场景工作流)。 - 测试结果显示文件类型、页数、实际工作流、提取字数、OCR 页数、耗时和警告。测试文件不得自动进入聊天上下文或知识库。 ## 14. 文案规则 diff --git a/docs/features/document-processing/README.md b/docs/features/document-processing/README.md index 1cdfa29a..a644d978 100644 --- a/docs/features/document-processing/README.md +++ b/docs/features/document-processing/README.md @@ -7,8 +7,10 @@ | 文档 | 权威职责 | | --- | --- | | [文档解析与本地 OCR PRD](./prd.md) | 产品范围、解析策略、OCR 模型管理和验收 | +| [聊天附件技术说明](./chat-attachments-technical-design.md) | 文件选择与粘贴的进程链路、IPC、失败处理和验证方法 | -当前尚无独立 User Stories、功能逻辑、UI、技术设计和进度文档。 +当前尚无独立 User Stories、功能逻辑、UI 和进度文档;聊天输入区交互遵循根目录 +[UI 设计系统](../../../UI-DESIGN.md)。 Office 文件的浏览器内人工编辑、AI 选区修改、保存和撤销不属于文档解析职责,由 [Office 协同编辑](../office-document-editing/README.md)负责。文档处理仍负责内容提取、OCR、 diff --git a/docs/features/document-processing/chat-attachments-technical-design.md b/docs/features/document-processing/chat-attachments-technical-design.md new file mode 100644 index 00000000..a2df6553 --- /dev/null +++ b/docs/features/document-processing/chat-attachments-technical-design.md @@ -0,0 +1,69 @@ +# 聊天附件技术说明 + +产品规则见 [PRD 的聊天附件入口](./prd.md#321-聊天附件入口),输入区布局与反馈遵循 +[UI 设计系统](../../../UI-DESIGN.md)。本文说明本地文件选择、文件粘贴和截图粘贴的实现。 + +## 文件导入链路 + +`App.tsx` 在 textarea 的 `paste` 事件中同步读取 `clipboardData.files`。没有文件时 +保留浏览器默认文本粘贴。有文件时阻止默认插入,并通过 preload 的 +`context.getFilePath(file)` 调用 Electron `webUtils.getPathForFile`。 + +有本地路径的文件调用 `context.importFiles(paths)`,经 `context:import-files` IPC 到 +Main。IPC 先验证可信 sender,再使用共享 `contextImportFilesSchema` 校验输入。 +没有本地路径的截图沿用 `context.addPastedImage`;无路径的非图片文件和混有无路径项目 +的本地文件批次显示可处理的错误,不静默丢弃项目。 + +`ContextManager.selectFiles` 只负责文件对话框,再调用与粘贴共用的 `importFiles`。 +这个方法完成路径规范化、格式及大小检查、文本读取、图片转换、文档解析与上下文暂存。 +任一文件失败时删除本批次已经暂存的项目,保留之前的附件。文件系统错误不向 Renderer +暴露绝对路径。 + +两种文件入口均发送 `context:file-selection-progress`。Renderer 共用正在导入状态、 +附件上限处理和附件清理逻辑,解析期间禁用附件按钮并阻止发送。成功结果加入发起操作的 +会话草稿;发送时通过已有 `contextIds` 和 `ContextManager.enrichRequest` 生成请求上下文。 +本次变更不修改 Agent、远端协议或 Runtime 的附件消费实现。 + +## Electron 验证 + +2026-09-15 在 Windows 的 Electron 43.2.0 上,从当前源码构建隔离输出并启动完整应用, +使用独立用户数据目录,保持 `sandbox: true`、`contextIsolation: true`。 + +已实际验证: + +- Windows `Shell.Application` 的文件 `copy` verb 生成系统剪贴板后,原生粘贴事件提供 + `File` 和有效路径,TXT 出现在聊天附件区。 +- `CF_HDROP` 同时携带 TXT、DOCX 时,两个文件经生产 preload、IPC 和解析器成为附件; + DOCX 发出 `reading`、`parsing` 进度,包含正确文件名及 `2 / 2` 批次序号。 +- 普通文本原生粘贴进入草稿,后续文件粘贴保持文字不变。 +- ZIP 经生产导入路径报出不支持类型,已添加附件仍在。 +- 系统图片剪贴板产生的 `image.png` 没有本地路径,仍通过截图入口生成图片附件。 + +以上使用原生 `webContents.paste()`,未伪造 DOM 粘贴事件或替换附件实现。Windows Shell +复制测试没有操纵资源管理器窗口中的键盘。产品未增加 Win32 剪贴板读取或兼容代码。 +探测程序的原生调用仅用于构造文件剪贴板和保存、恢复测试前的剪贴板对象;最终探测的 +`OleSetClipboard`、`OleFlushClipboard` 均返回成功。早期一次探测的恢复调用返回错误, +无法追溯确认该次剪贴板是否完整恢复。 + +macOS、Linux 文件管理器的实际剪贴板行为尚未在本次环境验证。没有发起模型调用;附件 +进入发送请求的 `contextIds` 由 Renderer 回归测试验证,完整文档内容进入请求由生产 +`ContextManager` 的真实文件解析测试验证。 + +## 自动检查 + +聚焦用例位于 `src/main/context-manager.test.ts`、`src/main/ipc.test.ts`、 +`src/preload/context-files.test.ts`、`src/shared/context-contracts.test.ts` 和 +`src/renderer/src/App.test.tsx`,覆盖多文件、进度、重复粘贴、发送上下文、错误与草稿保留、 +大小及数量限制、批次回滚、可信 sender、输入契约和截图/文本回归。 + +```text +npx vitest run src/main/context-manager.test.ts src/main/ipc.test.ts src/preload/context-files.test.ts src/shared/context-contracts.test.ts src/renderer/src/App.test.tsx +npm test +npm run typecheck +npm run lint +``` + +本次最终拆分检查通过 6 项附件相关 Renderer 用例和 160 项 Main、IPC、preload、契约及 +沙箱用例,类型检查与 lint 通过。完整 `npm test` 运行结果为 4214 项通过、67 项跳过、 +6 项窗口恢复用例失败;这些用例属于并行修改的 `window.test.ts`,随后该文件单独复跑 +10 项全部通过。本次没有再次完整重跑全仓测试。 diff --git a/docs/features/document-processing/prd.md b/docs/features/document-processing/prd.md index 354712a6..a8153723 100644 --- a/docs/features/document-processing/prd.md +++ b/docs/features/document-processing/prd.md @@ -52,10 +52,33 @@ PDF 不是所有文档唯一的中间格式。解析应同时保留: | 高保真审阅 | 视觉增强 | 原生解析、页面渲染、OCR 或视觉理解合并 | PDF、DOCX、PPTX 和 XLSX 的解析结果保留章节定位及全文,不在附件入库时截断为 -256 KiB,也不以富化后 Prompt 的 1 MiB 上限阻止发送。本地选择与消息通道附件使用 +256 KiB,也不以富化后 Prompt 的 1 MiB 上限阻止发送。本地选择、文件粘贴与消息通道附件使用 同一文本保存路径;原文件大小、解析器和 12 MiB 附件暂存容量仍按现有规则检查,超限时 明确报错,不能用截断后的内容冒充全文。模型上下文窗口仍由所选 Runtime 处理。 +#### 3.2.1 聊天附件入口 + +用户可点击附件按钮选择文件,也可在文件管理器中复制一个或多个文件后,在聊天输入框 +按 `Ctrl+V` 添加。macOS 使用系统粘贴快捷键 `Command+V`。粘贴文件不替换草稿文字, +不自动发送;普通文本仍直接插入光标位置,系统截图仍作为图片附件添加。 + +本地文件选择与粘贴使用相同的格式及容量规则: + +| 内容 | 支持范围 | 原文件大小上限 | +| --- | --- | --- | +| 文本、代码和配置 | C/C++、CSS、CSV、Go、HTML、Java、JS/JSX、JSON、日志、Markdown、Python、Rust、SQL、TS/TSX、TXT、XML、YAML | 256 KiB | +| 图片 | JPEG、PNG、WebP | 12 MiB | +| 文档 | PDF、DOCX、PPTX、XLSX | 20 MiB | + +每条消息最多 8 个附件,Main 最多暂存 16 个上下文项目,合计不超过 12 MiB。 +图片转换和文档解析后的内容计入暂存容量。一次粘贴超过 8 个文件时拒绝本次粘贴并提示; +批次本身未超限、但加上已有附件超过 8 个时,只保留剩余位置可容纳的附件并提示。 + +文件读取和解析期间显示文件名、处理阶段及批次序号,阻止重复文件导入和发送。 +不支持的扩展名、目录、超限文件、读取或解析失败会使本批次回滚;已有附件与草稿保留, +错误显示在输入区。剪贴板中的虚拟文档若没有本地路径,提示先保存到本地后添加; +无本地路径的截图继续使用图片粘贴入口。 + ### 3.3 本地优先 - 文本层和本地 OCR 均在设备上处理; diff --git a/src/main/context-manager.test.ts b/src/main/context-manager.test.ts index 71075bd3..8e84a2ed 100644 --- a/src/main/context-manager.test.ts +++ b/src/main/context-manager.test.ts @@ -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('PASTED_DOCX') + })) + 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({ diff --git a/src/main/context-manager.ts b/src/main/context-manager.ts index 13df043c..3a168d47 100644 --- a/src/main/context-manager.ts +++ b/src/main/context-manager.ts @@ -300,8 +300,15 @@ export class ContextManager { return [] } + return this.importFiles(result.filePaths, onProgress) + } + + async importFiles( + paths: string[], + onProgress?: (progress: ContextFileSelectionProgress) => void + ): Promise { const attachments: ContextAttachment[] = [] - const selectedPaths = result.filePaths.slice( + const selectedPaths = paths.slice( 0, maximumAttachmentsPerMessage ) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index f28b3ca6..9ad2c982 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -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, { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6978bb10..66fae96b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) => { diff --git a/src/preload/context-files.test.ts b/src/preload/context-files.test.ts new file mode 100644 index 00000000..dd6e8468 --- /dev/null +++ b/src/preload/context-files.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DesktopApi } from '../shared/contracts' +import { ipcChannels } from '../shared/ipc-channels' + +const electron = vi.hoisted(() => ({ + contextBridge: { exposeInMainWorld: vi.fn() }, + ipcRenderer: { invoke: vi.fn(async () => []) }, + webUtils: { getPathForFile: vi.fn(() => 'C:\\notes.txt') } +})) +vi.mock('electron', () => electron) + +describe('context file preload bridge', () => { + it('resolves native File paths and sends only paths to Main', async () => { + await import('./index') + const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi + const file = {} as File + const path = api.context.getFilePath(file) + expect(electron.webUtils.getPathForFile).toHaveBeenCalledExactlyOnceWith(file) + await expect(api.context.importFiles([path])).resolves.toEqual([]) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith( + ipcChannels.contextImportFiles, { paths: ['C:\\notes.txt'] } + ) + }) +}) diff --git a/src/preload/index.ts b/src/preload/index.ts index d910b867..caeb604b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1486,6 +1486,12 @@ const desktopApi: DesktopApi = { ) as Promise }, context: { + getFilePath: (file) => webUtils.getPathForFile(file), + importFiles: (paths) => + ipcRenderer.invoke( + ipcChannels.contextImportFiles, + { paths } + ) as Promise, selectFiles: () => ipcRenderer.invoke( ipcChannels.contextSelectFiles diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 57984349..1ea70d49 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -753,6 +753,8 @@ const api: DesktopApi = { })), }, context: { + getFilePath: vi.fn(() => ""), + importFiles: vi.fn(async () => []), selectFiles: vi.fn(async () => []), onFileSelectionProgress: vi.fn((listener) => { fileSelectionProgressListener = listener; @@ -1586,11 +1588,11 @@ describe("App", () => { ).toBeInTheDocument(); expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute( "placeholder", - "Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text", + "Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text", ); expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute( "title", - "Enter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text", + "Enter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text", ); } finally { cleanup(); @@ -5776,7 +5778,7 @@ describe("App", () => { name: "附件读取与解析进度", }), ).toBeInTheDocument(); - expect(screen.getByText("正在选择附件…")).toBeInTheDocument(); + expect(screen.getByText("正在添加附件…")).toBeInTheDocument(); act(() => { fileSelectionProgressListener?.({ @@ -5875,6 +5877,63 @@ describe("App", () => { ); }); + it("imports copied files with progress, prevents duplicate pastes, and sends their context", async () => { + const files = [new File(["notes"], "notes.txt"), new File(["doc"], "report.docx")]; + vi.mocked(api.context.getFilePath) + .mockReturnValueOnce("C:\\notes.txt") + .mockReturnValueOnce("C:\\report.docx"); + const attachments: ContextAttachment[] = files.map((file, index) => ({ + id: `00000000-0000-4000-8000-00000000032${index}`, + name: file.name, kind: "text", size: 4, preview: "parsed content", + })); + let finish: ((attachments: ContextAttachment[]) => void) | undefined; + vi.mocked(api.context.importFiles).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + render(); + const input = await screen.findByLabelText("向 GoodBuddy 提问"); + fireEvent.change(input, { target: { value: "Read these files" } }); + expect(fireEvent.paste(input, { clipboardData: { files } })).toBe(false); + expect(api.context.importFiles).toHaveBeenCalledExactlyOnceWith(["C:\\notes.txt", "C:\\report.docx"]); + expect(screen.getByLabelText("添加附件")).toBeDisabled(); + act(() => fileSelectionProgressListener?.({ + phase: "parsing", fileName: "report.docx", fileNumber: 2, fileCount: 2, + })); + expect(screen.getByText("正在解析 report.docx")).toBeInTheDocument(); + fireEvent.paste(input, { clipboardData: { files } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(api.context.importFiles).toHaveBeenCalledOnce(); + expect(run).not.toHaveBeenCalled(); + act(() => finish?.(attachments)); + expect(await screen.findByText("report.docx")).toBeInTheDocument(); + expect(input).toHaveValue("Read these files"); + await waitFor(() => expect(screen.getByLabelText("添加附件")).toBeEnabled()); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => expect(run).toHaveBeenCalledWith(expect.objectContaining({ + prompt: "Read these files", contextIds: attachments.map(item => item.id), + }))); + expect(api.context.addPastedImage).not.toHaveBeenCalled(); + expect(api.context.selectFiles).not.toHaveBeenCalled(); + }); + + it("shows file paste failures without clearing the draft and rejects oversized batches", async () => { + render(); + const input = await screen.findByLabelText("向 GoodBuddy 提问"); + fireEvent.change(input, { target: { value: "Keep this draft" } }); + const file = new File(["archive"], "archive.zip"); + vi.mocked(api.context.getFilePath).mockReturnValueOnce("C:\\archive.zip"); + vi.mocked(api.context.importFiles).mockRejectedValueOnce(new Error("不支持的文件类型:.zip")); + fireEvent.paste(input, { clipboardData: { files: [file] } }); + expect(await screen.findByRole("alert", { name: "不支持的文件类型:.zip" })).toBeInTheDocument(); + expect(input).toHaveValue("Keep this draft"); + await waitFor(() => expect(screen.getByLabelText("添加附件")).toBeEnabled()); + fireEvent.paste(input, { clipboardData: { files: Array(9).fill(file) } }); + expect(api.context.importFiles).toHaveBeenCalledOnce(); + expect(input).toHaveAttribute("aria-invalid", "true"); + fireEvent.paste(input, { clipboardData: { files: [file] } }); + expect(await screen.findByRole("alert", { + name: "无法读取此剪贴板文件,请保存到本地后通过附件按钮添加", + })).toBeInTheDocument(); + }); + it("accepts pasted images without intercepting pasted text", async () => { vi.mocked(api.context.addPastedImage).mockResolvedValueOnce({ id: "00000000-0000-4000-8000-000000000303", @@ -5895,6 +5954,7 @@ describe("App", () => { expect( fireEvent.paste(input, { clipboardData: { + files: [], items: [ { getAsFile: () => null, @@ -5909,6 +5969,7 @@ describe("App", () => { fireEvent.paste(input, { clipboardData: { + files: [pastedImage], items: [ { getAsFile: () => pastedImage, @@ -6685,7 +6746,7 @@ describe("App", () => { ).toHaveTextContent(/^Ask$/u); expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute( "placeholder", - "给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本", + "给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本", ); expect( within(conversationSettings).getByRole("button", { @@ -10146,7 +10207,7 @@ describe("App", () => { expect((await screen.findAllByText("生图")).length).toBeGreaterThan(0); expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute( "placeholder", - "描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本", + "描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本", ); await waitFor(() => expect(api.artifacts.list).toHaveBeenCalled()); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5fe9d66c..a61c3aca 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7571,7 +7571,7 @@ function App(): React.JSX.Element { } }; - const selectContextFiles = async (): Promise => { + const selectContextFiles = async (paths?: string[]): Promise => { if (selectingContextFilesRef.current) { return; } @@ -7579,7 +7579,9 @@ function App(): React.JSX.Element { setSelectingContextFiles(true); setFileSelectionProgress(undefined); try { - await addContext(() => window.goodbuddy.context.selectFiles()); + await addContext(() => paths + ? window.goodbuddy.context.importFiles(paths) + : window.goodbuddy.context.selectFiles()); } finally { selectingContextFilesRef.current = false; setSelectingContextFiles(false); @@ -9452,17 +9454,40 @@ function App(): React.JSX.Element { value={input} onChange={(event) => setInput(event.target.value)} onPaste={(event) => { - const imageItem = Array.from( - event.clipboardData.items, - ).find( - (item) => - item.kind === "file" && - item.type.startsWith("image/"), - ); - if (!imageItem) { + const files = Array.from(event.clipboardData.files); + if (files.length > 0) { + event.preventDefault(); + if (selectingContextFilesRef.current) { + setContextError(t("composer.attachmentProgress.waitBeforeSending")); + return; + } + if (files.length > 8) { + setContextError(t("composer.errors.attachmentLimit")); + return; + } + try { + const paths = files.map((file) => window.goodbuddy.context.getFilePath(file)); + if (paths.some(Boolean)) { + if (paths.some((path) => !path)) { + setContextError(t("composer.errors.pasteFilePath")); + return; + } + void selectContextFiles(paths); + return; + } + if (files.some((file) => !file.type.startsWith("image/"))) { + setContextError(t("composer.errors.pasteFilePath")); + return; + } + } catch (reason) { + setContextError(reason instanceof Error ? reason.message : t("composer.errors.addContext")); + return; + } + } + const image = files.find((file) => file.type.startsWith("image/")); + if (!image) { return; } - const image = imageItem.getAsFile(); const mimeType = image?.type === "image/jpeg" || image?.type === "image/png" || diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index bd4942e0..8b380a05 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -435,11 +435,11 @@ export const app = { placeholder: 'Message GoodBuddy…', imagePlaceholder: 'Describe the image you want to generate…', keyboardHint: - 'Enter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text', + 'Enter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text', addContent: 'Add content', addAttachment: 'Add attachment', attachmentProgress: { - selecting: 'Selecting attachments…', + selecting: 'Adding attachments…', reading: 'Reading {{name}}', parsing: 'Parsing {{name}}', waiting: 'Files will be read and parsed after selection', @@ -618,6 +618,7 @@ export const app = { }, errors: { pasteImageType: 'Only JPEG, PNG, or WebP images can be pasted', + pasteFilePath: 'Cannot read this clipboard file. Save it locally, then use Add attachment.', pasteImageSize: 'Pasted images cannot exceed 12 MB', attachmentLimit: 'A message can include up to 8 attachments', addContext: 'Failed to add context' diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 020d09c9..fa44c07d 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -416,11 +416,11 @@ export const app = { placeholder: '给 GoodBuddy 发消息…', imagePlaceholder: '描述你想生成的图片…', keyboardHint: - 'Enter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本', + 'Enter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本', addContent: '添加内容', addAttachment: '添加附件', attachmentProgress: { - selecting: '正在选择附件…', + selecting: '正在添加附件…', reading: '正在读取 {{name}}', parsing: '正在解析 {{name}}', waiting: '选择文件后将自动读取并解析', @@ -578,6 +578,7 @@ export const app = { }, errors: { pasteImageType: '仅支持粘贴 JPEG、PNG 或 WebP 图片', + pasteFilePath: '无法读取此剪贴板文件,请保存到本地后通过附件按钮添加', pasteImageSize: '粘贴图片不能超过 12MB', attachmentLimit: '单次消息最多添加 8 个附件', addContext: '添加上下文失败' diff --git a/src/shared/context-contracts.test.ts b/src/shared/context-contracts.test.ts index 94dc42cb..73f46a1b 100644 --- a/src/shared/context-contracts.test.ts +++ b/src/shared/context-contracts.test.ts @@ -1,10 +1,18 @@ import { describe, expect, it } from 'vitest' import { + contextImportFilesSchema, maximumPastedImageBytes, pastedImageInputSchema } from './contracts' describe('context contracts', () => { + it('validates file batches without accepting empty paths or more than eight files', () => { + expect(contextImportFilesSchema.parse({ paths: ['C:\\notes.txt', '/tmp/report.pdf'] }).paths).toHaveLength(2) + for (const paths of [[], [''], Array(9).fill('/tmp/notes.txt'), [42]]) { + expect(contextImportFilesSchema.safeParse({ paths }).success).toBe(false) + } + expect(contextImportFilesSchema.safeParse({ paths: ['/tmp/a.txt'], extra: true }).success).toBe(false) + }) it('accepts bounded pasted image bytes in supported formats', () => { expect( pastedImageInputSchema.safeParse({ diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 2e981a57..5880ea16 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1139,6 +1139,10 @@ export const maximumPastedImageBytes = 12 * 1024 * 1024 export const clipboardTextSchema = z.string() +export const contextImportFilesSchema = z.object({ + paths: z.array(z.string().min(1).max(32768)).min(1).max(8) +}).strict() + export const pastedImageInputSchema = z .object({ data: z @@ -2226,6 +2230,8 @@ export type DesktopApi = { } context: { selectFiles: () => Promise + getFilePath: (file: File) => string + importFiles: (paths: string[]) => Promise onFileSelectionProgress: ( listener: (progress: ContextFileSelectionProgress) => void ) => () => void diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 1100c31f..1c347280 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -255,6 +255,7 @@ export const ipcChannels = { runtimeExtensionsSnapshot: 'runtime-extensions:snapshot', runtimeExtensionsApply: 'runtime-extensions:apply', contextSelectFiles: 'context:select-files', + contextImportFiles: 'context:import-files', contextFileSelectionProgress: 'context:file-selection-progress', contextAddPastedImage: 'context:add-pasted-image', contextCaptureScreen: 'context:capture-screen',