From 7bb3826cddeef868f5de2c6e02fb4564418d7d2b Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Tue, 26 Aug 2025 00:21:26 +0800 Subject: [PATCH] feat: ocr image to translate (#9423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: 添加 tesseract.js 及其类型定义依赖 * feat(ocr): 添加OCR类型定义文件以支持OCR功能扩展 * feat(ocr): 添加 Tesseract OCR 提供程序配置 * feat(ocr): 添加Tesseract.js的logo * refactor(settings): 重构文档预处理设置模块结构 将PreprocessSettings重命名为DocProcessSettings并调整文件结构 更新相关路由和组件引用以保持功能一致性 * refactor(config): 重命名OCR_PROVIDER_CONFIG为BUILTIN_OCR_PROVIDERS以更准确描述用途 * refactor(ocr): 更改文件名 * refactor(ocr): 将获取OCR提供商logo的功能移动到utils目录 将getOcrProviderLogo函数从config/ocr.ts移动到utils/ocr.ts,保持功能集中 * refactor(ocr): 重构OCR配置结构以支持默认提供者 将内置OCR提供者数组重构为单独定义的常量,并添加默认OCR提供者映射。这提高了代码的可维护性并支持未来扩展。 * feat(store): 添加OCR状态管理切片 实现OCR提供商的增删改查功能,使用Redux Toolkit管理OCR相关状态 * feat(types): 添加图片文件类型守卫函数 添加 ImageFileMetadata 类型和 isImageFile 类型守卫函数,用于检查文件是否为图片类型 * feat(ocr): 添加对OCR支持文件类型的类型定义和校验函数 添加SupportedOcrFileType类型和isSupportedOcrFileType校验函数 添加SupportedOcrFile类型和isSupportedOcrFile校验函数 * feat(ocr): 添加OCR功能支持 实现基于Tesseract的OCR功能,包括文件类型检查、服务接口和IPC通信 新增OCR相关类型定义和服务实现 * refactor(OcrService): 更新日志上下文为'main:OcrService' * feat(ocr): 添加OCR服务基础功能 实现OCR服务的基础功能,通过调用window.api.ocr接口处理支持的文件类型 * feat(store): 添加ocr模块到redux store * feat(ocr): 添加OCR功能支持及文件类型校验 添加OCR功能钩子useOcr,支持图片文件识别 添加不支持文件类型的错误提示国际化文案 * refactor(ocr): 重命名updatePreprocessProvider为updateOcrProvider以保持命名一致性 * feat(ocr): 添加设置图片OCR提供商的功能 * refactor(ocr): 统一OCR类型导入路径 将所有OCR相关类型从'@renderer/types/ocr'改为从'@renderer/types'或'@types'导入 优化DEFAULT_OCR_PROVIDER类型定义 * feat(store): 更新持久化存储版本并添加OCR配置迁移 添加137版本迁移逻辑,初始化OCR提供者和默认图像提供者配置 * feat(ocr): 添加OCR服务设置界面及提供商选择功能 实现OCR服务设置界面,包含图片OCR提供商的选择功能 修复ocr.ts中imageProvider的类型定义 添加相关国际化文本 * fix(ocr): 添加图像大小检查并优化错误处理 检查图像文件大小是否超过50MB限制 使用buffer读取文件替代直接路径识别 简化错误处理逻辑,直接抛出原始错误 * feat(OCR服务): 支持base64字符串作为OCR输入 扩展tesseractOcr函数以接受base64字符串或图像文件作为输入 * feat(hooks): 添加useFiles钩子用于文件选择功能 * refactor(useFiles): 移除multipleSelections参数并重构文件选择逻辑 将multipleSelections从组件props移动到onSelectFile方法参数中,简化组件接口 重构文件选择逻辑,移除不必要的useMemo,提升代码可维护性 * refactor(useFiles): 使用useMemo优化扩展名处理逻辑 将扩展名处理逻辑移至useMemo中,避免不必要的重复计算。当props.extensions未提供时默认返回['*'] * feat(文件选择): 增强文件选择功能并添加清除文件方法 - 为文件选择API添加返回类型声明 - 完善文件选择回调函数的文档注释 - 修改文件选择逻辑以返回选中的文件数组 - 添加清除文件列表的方法 * refactor(useFiles): 将参数从布尔值改为对象以增强可扩展性 * feat(hooks): 在useFiles钩子中暴露selecting状态 * feat(translate): 添加文件OCR功能支持 在翻译页面新增浮动按钮,支持通过OCR识别文件内容并自动填充到输入框。添加相关hooks和文件类型检查逻辑,提升用户输入便捷性。 * build: 将 tesseract.js 从 devDependencies 移至 dependencies 确保生产环境能正确使用 tesseract.js 功能 * refactor(ocr): 将Tesseract服务文件移动到tesseract子目录并更新配置 * refactor(TesseractService): 添加日志记录并更新worker配置 添加loggerService用于记录worker日志,并更新createWorker配置以使用自定义logger * feat(翻译页面): 添加OCR处理中的加载状态提示 在翻译页面中添加OCR处理时的加载状态提示,提升用户体验 * fix(translate): 为OCR处理消息添加无限持续时间 防止OCR处理过程中消息自动消失,确保用户明确知道处理状态 * fix: 添加OCR未知错误的翻译并更新错误提示 在OCR处理失败时,使用翻译后的错误消息替代原始错误提示 * style(translate): 调整浮动按钮位置从右上到左下 * fix(translate): 处理未选择文件时提前返回以避免空指针异常 * feat(i18n): 添加OCR功能的多语言支持 * feat(fs): 添加自动识别编码读取文本文件功能 实现通过自动检测文件编码来读取文本文件的功能 在IPC通道、预加载API和文件服务中添加相关方法 * feat(翻译): 添加文件读取功能并改进错误处理 添加对文本文件的支持并优化文件处理流程 改进错误提示信息,包括文件过大和读取失败的场景 * fix(i18n): 更新文件大小限制错误信息并添加多语言支持 修改文件大小限制的错误信息格式,移除括号内的限制范围 为多种语言添加文件操作相关的翻译条目 在错误提示中动态显示文件大小限制范围 * refactor(AttachmentButton): 移除类型注释,使用自动类型推断 * fix(hooks): 返回变量supportedFiles * fix(ocr): 改进OCR处理中的消息管理和错误处理 在useOcr钩子中统一管理OCR处理的消息提示,并完善错误处理逻辑 移除TranslatePage中重复的消息管理代码,简化OCR处理流程 * fix(translate): 在选择文件后清除文件状态以避免残留 在文件选择完成后调用clearFiles以清除文件状态 * refactor(preload): 移动OCR类型定义到共享类型文件 将OCR相关的类型定义(OcrProvider, OcrResult, SupportedOcrFile)从渲染进程类型文件移动到共享类型文件@types,以提高代码复用性和维护性 * refactor(ocr): 修改tesseractOcr返回完整识别结果而非仅文本 返回完整识别结果以便后续处理使用更多OCR信息,同时简化imageOcr中的条件判断逻辑 * fix(ocr): 修复文件类型与OCR提供者能力不匹配时的错误抛出位置 将错误抛出语句移至else分支 * refactor(ocr): 简化 DEFAULT_OCR_PROVIDER 的类型定义 * build: 将 tesseract.js 从 devDependencies 移至 dependencies 确保生产环境能正确使用 tesseract.js 功能 * refactor(ocr): 将Tesseract服务文件移动到tesseract子目录并更新配置 * refactor(TesseractService): 添加日志记录并更新worker配置 添加loggerService用于记录worker日志,并更新createWorker配置以使用自定义logger * feat(i18n): 添加OCR功能的多语言支持 * refactor(preload): 移动OCR类型定义到共享类型文件 将OCR相关的类型定义(OcrProvider, OcrResult, SupportedOcrFile)从渲染进程类型文件移动到共享类型文件@types,以提高代码复用性和维护性 * refactor(ocr): 修改tesseractOcr返回完整识别结果而非仅文本 返回完整识别结果以便后续处理使用更多OCR信息,同时简化imageOcr中的条件判断逻辑 * fix(ocr): 修复文件类型与OCR提供者能力不匹配时的错误抛出位置 将错误抛出语句移至else分支 * refactor(ocr): 简化 DEFAULT_OCR_PROVIDER 的类型定义 * fix(ocr): 改进OCR处理中的消息管理和错误处理 在useOcr钩子中统一管理OCR处理的消息提示,并完善错误处理逻辑 移除TranslatePage中重复的消息管理代码,简化OCR处理流程 * feat(i18n): 添加OCR相关的错误和状态翻译文本 * fix(useOcr): 修复未支持文件类型错误抛出位置 将不支持的OCR文件类型错误抛出逻辑移至条件判断内 * refactor(ocr): ocrImage实现使用OcrService并更新日志上下文 将ocrImage函数从useOcr钩子移动到OcrService中,提高代码复用性 更新日志服务上下文从'main'改为'renderer'以更准确反映模块位置 * style(TabContainer): 移除多余的空行并保持代码整洁 * refactor(ocr): 简化OCR文件类型检查逻辑 使用现有的isImageFile函数替代冗余的类型检查逻辑,提高代码复用性 * fix: 将迁移错误日志从136更新为137 * feat(ocr): enhance Tesseract service with language support and worker management - Added support for multiple Tesseract languages: Chinese (Simplified and Traditional) and English. - Refactored Tesseract worker management into a class for better encapsulation and reuse. - Introduced methods to dynamically determine language path based on IP country and manage worker lifecycle. * update cn url * support cn data * change to asyn * use register design mode * add type * use bind function * refactor(ipc): 简化OCR处理程序参数 * refactor(ocr): 修改ocrProviderCapabilityRecord类型定义 允许只定义部分能力 * refactor(ocr): 将Tesseract相关配置移至服务内部 将语言列表和下载URL常量从共享配置移至Tesseract服务内部 使用常量定义图片大小阈值以提高可读性 * refactor(ocr): 统一使用 SupportedOcrFile 类型替换 FileMetadata 更新 OCR 服务及其 Tesseract 实现,使用 SupportedOcrFile 类型替代原有的 FileMetadata 类型,以提高类型安全性和一致性。同时在 OcrService 中添加重复注册的警告日志。 * refactor(ocr): 重构OCR类型定义以支持模型和API配置 将OCR提供者配置拆分为独立类型,增加模型能力记录和API配置类型检查 添加OCR处理程序类型定义,为未来扩展提供更好的类型支持 * refactor(OcrService): 移除重复的OcrHandler类型定义 已在@types中定义OcrHandler类型,移除重复定义以提高代码一致性 * refactor(ocr): 将OcrService移动到ocr目录下并更新引用路径 * feat(ocr): 添加OCR API客户端工厂及示例实现 实现OCR API客户端工厂模式,支持根据不同提供商创建对应的客户端 新增OcrBaseApiClient作为基础类,提供通用功能 添加OcrExampleApiClient作为示例实现 修改OcrService以使用新的客户端工厂 * refactor(ocr): 添加日志记录以跟踪OCR文件处理 在OCR服务中添加日志记录功能,便于跟踪文件处理过程 * fix(deps): 更新 tesseract.js 依赖并添加补丁文件 修复 tesseract.js 类型定义问题并添加语言常量支持 * refactor(ocr): 移除注释掉的tesseract语言映射代码 使用Tesseract.js的LanguageCode类型替代硬编码的语言列表,提高类型安全性 * feat(ocr): 添加 Tesseract OCR 配置类型 * refactor(OCR设置): 重命名OcrImageProviderSettings为OcrImageSettings并优化代码结构 * refactor(ocr): 将 Tesseract 相关类型移动到文件底部以改善代码组织 * feat(ocr): 添加 Tesseract OCR 提供者类型检查函数 * feat(ocr): 添加更新OCR提供者配置的功能 * feat: 添加OCR提供者钩子函数 实现useOcrProvider钩子用于获取和更新OCR提供者配置 * refactor(ocr): 修改removeOcrProvider参数为字符串id 简化removeOcrProvider方法的参数类型,直接使用字符串id进行过滤,提高代码简洁性 * refactor(ocr): 将内置OCR提供者从数组改为映射结构 重构OCR配置模块,使用映射结构存储内置OCR提供者以便于扩展和维护 * refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组 使用Object.freeze确保数组不可变,提高代码安全性 * feat(ocr): 添加OCR提供者管理功能并改进错误处理 添加useOcrProviders钩子用于管理OCR提供者的添加和删除 当内置OCR提供者不存在时自动恢复默认配置 改进错误提示信息并增加国际化支持 * Revert "refactor(ocr): 将BUILTIN_OCR_PROVIDERS改为只读数组" This reverts commit f23e37941abba4fcc703b31e955b67bff565c432. * feat(ocr): 为Tesseract OCR添加多语言支持配置 添加对简体中文、繁体中文和英文的语言支持配置,扩展OCR功能以满足多语言识别需求 * refactor(types): 将Tesseract.LanguageCode重命名为TesseractLangCode以提高可读性 * feat(OCR设置): 添加OCR提供商设置组件及状态管理 新增OCR提供商设置组件,支持显示当前选择的OCR提供商信息 在OCR图片设置中添加状态管理,同步提供商选择到父组件 添加Tesseract OCR设置组件,支持多语言选择(暂不可用) * fix(DocProcessSettings): 修复OCR语言选择默认值问题 * feat(i18n): 添加OCR提供商相关错误和警告的翻译 * fix(ocr): 将 Tesseract 语言配置类型改为部分 * fix(ocr): 修复ocrImage函数未使用await导致的问题 * fix(ocr): 修复迁移配置中ocr状态的初始化方式 将分散的属性赋值改为对象整体赋值,避免潜在的属性丢失问题 * chore: 移除不再使用的@types/tesseract.js依赖 * refactor(OCR设置): 添加错误边界处理并移除无用注释 在OCR设置组件中添加ErrorBoundary以处理潜在错误 移除OcrTesseractSettings中的TODO注释 * build: 添加 sharp 依赖以支持图片处理功能 * refactor(ocr): 添加OCR图像预处理功能并优化TesseractService Co-authored-by: Qwen-Coder * refactor(ocr): 移除独立的灰度处理模块并改进预处理流程 将灰度处理功能直接集成到OCR预处理中,不再需要单独的image模块 添加normalise和threshold处理以提升OCR识别效果 * feat(i18n): 添加文件上传tool tip的翻译文本 * feat(hooks): 添加useDrag钩子实现拖拽功能 * feat(translate): 添加拖拽上传文件功能 实现文件拖拽上传功能,包括拖拽区域高亮显示和提示文本 添加多文件上传错误提示和未知错误处理 * feat(i18n): 添加文件拖拽和多文件上传错误提示的翻译 * refactor(PasteService): 优化粘贴服务逻辑并移除不必要的翻译依赖 将`t`参数改为布尔类型的`showMessage`参数,简化消息显示逻辑 添加默认的粘贴文本长度阈值 使文件扩展名检查变为可选参数 更新相关调用处的参数传递 * Revert "refactor(PasteService): 优化粘贴服务逻辑并移除不必要的翻译依赖" This reverts commit 07c7ecd0cfb586759ac4d7a09641b9bf3e8f88bb. * fix(preload): 为文件获取方法添加返回类型声明 添加Promise返回类型以明确get方法的返回值类型,提高代码可读性和类型安全性 * refactor(TopView): 移除未使用的loggerService导入和调用 * feat(TranslatePage): 添加对粘贴上传文件的支持 新增粘贴上传文件功能,处理剪贴板中的文件数据并支持图片临时文件创建 添加文件类型检查和不支持类型的错误提示 重构文件选择逻辑到通用函数 getSingleFile * feat(i18n): 添加不支持文件类型的多语言翻译 * feat(translate): 添加翻译输入状态并优化内容更新逻辑 添加translateInput状态以存储翻译输入内容 优化setTranslatedContent reducer直接修改状态而非返回新对象 * refactor(translate): 将文本输入状态迁移至redux存储 移除本地状态_text和使用useState管理的text,改为从redux store中获取和管理输入文本 * fix(translate): 修复依赖数组中缺少setText导致的状态更新问题 * fix(store): 初始化翻译输入为空字符串 修复迁移配置时未初始化翻译输入的问题,避免潜在的undefined错误 * fix(hooks): 使 useDrag 的 onDrop 参数变为可选 处理 onDrop 未定义时的调用情况,避免运行时错误 * fix(拖拽): 修复拖拽状态未正确更新的问题 修复 handleDragOver 中未设置 isDragging 状态的问题 为输入区域添加独立的拖拽状态处理 防止容器元素意外触发文件拖放 * refactor(translate): 在文件拖放错误处理中移动错误提示位置 将文件拖放错误提示从空文件检查移动到文件读取错误捕获中 * improve image preprocess --------- Co-authored-by: beyondkmp Co-authored-by: Qwen-Coder --- packages/shared/IpcChannel.ts | 1 + src/main/ipc.ts | 1 + src/main/services/FileSystemService.ts | 12 + src/main/utils/file.ts | 1 + src/preload/index.ts | 8 +- src/renderer/src/components/TopView/index.tsx | 6 +- src/renderer/src/hooks/useDrag.ts | 43 +++ src/renderer/src/hooks/useFiles.ts | 97 +++++++ src/renderer/src/i18n/locales/en-us.json | 13 + src/renderer/src/i18n/locales/ja-jp.json | 13 + src/renderer/src/i18n/locales/ru-ru.json | 13 + src/renderer/src/i18n/locales/zh-cn.json | 13 + src/renderer/src/i18n/locales/zh-tw.json | 13 + src/renderer/src/i18n/translate/el-gr.json | 13 + src/renderer/src/i18n/translate/es-es.json | 13 + src/renderer/src/i18n/translate/fr-fr.json | 13 + src/renderer/src/i18n/translate/pt-pt.json | 13 + .../pages/home/Inputbar/AttachmentButton.tsx | 4 +- .../src/pages/translate/TranslatePage.tsx | 271 +++++++++++++++++- src/renderer/src/services/PasteService.ts | 2 +- src/renderer/src/store/migrate.ts | 1 + src/renderer/src/store/translate.ts | 12 +- src/renderer/src/utils/file.ts | 9 + 23 files changed, 558 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/hooks/useDrag.ts create mode 100644 src/renderer/src/hooks/useFiles.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index f35db50bc6..f2b856ef1d 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -156,6 +156,7 @@ export enum IpcChannel { File_Base64File = 'file:base64File', File_GetPdfInfo = 'file:getPdfInfo', Fs_Read = 'fs:read', + Fs_ReadText = 'fs:readText', File_OpenWithRelativePath = 'file:openWithRelativePath', File_IsTextFile = 'file:isTextFile', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3d72b67390..20ccf06d76 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -470,6 +470,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // fs ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile.bind(FileService)) + ipcMain.handle(IpcChannel.Fs_ReadText, FileService.readTextFileWithAutoEncoding.bind(FileService)) // export ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService)) diff --git a/src/main/services/FileSystemService.ts b/src/main/services/FileSystemService.ts index 47e897e15b..2cd0d5aeb6 100644 --- a/src/main/services/FileSystemService.ts +++ b/src/main/services/FileSystemService.ts @@ -1,3 +1,4 @@ +import { readTextFileWithAutoEncoding } from '@main/utils/file' import { TraceMethod } from '@mcp-trace/trace-core' import fs from 'fs/promises' @@ -8,4 +9,15 @@ export default class FileService { if (encoding) return fs.readFile(path, { encoding }) return fs.readFile(path) } + + /** + * 自动识别编码,读取文本文件 + * @param _ event + * @param pathOrUrl + * @throws 路径不存在时抛出错误 + */ + @TraceMethod({ spanName: 'readTextFileWithAutoEncoding', tag: 'FileService' }) + public static async readTextFileWithAutoEncoding(_: Electron.IpcMainInvokeEvent, path: string): Promise { + return readTextFileWithAutoEncoding(path) + } } diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index dc6af193f8..150a28eaca 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -168,6 +168,7 @@ export function getMcpDir() { * 读取文件内容并自动检测编码格式进行解码 * @param filePath - 文件路径 * @returns 解码后的文件内容 + * @throws 如果路径不存在抛出错误 */ export async function readTextFileWithAutoEncoding(filePath: string): Promise { const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8' diff --git a/src/preload/index.ts b/src/preload/index.ts index af4803fd50..49c40d1166 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -136,14 +136,15 @@ const api = { checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) }, file: { - select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), + select: (options?: OpenDialogOptions): Promise => + ipcRenderer.invoke(IpcChannel.File_Select, options), upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file), delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId), deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath), read: (fileId: string, detectEncoding?: boolean) => ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding), clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext), - get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath), + get: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_Get, filePath), /** * 创建一个空的临时文件 * @param fileName 文件名 @@ -177,7 +178,8 @@ const api = { isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath) }, fs: { - read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding) + read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding), + readText: (pathOrUrl: string): Promise => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl) }, export: { toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName) diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx index b138ab5f08..8c2cb4a3bb 100644 --- a/src/renderer/src/components/TopView/index.tsx +++ b/src/renderer/src/components/TopView/index.tsx @@ -1,4 +1,4 @@ -import { loggerService } from '@logger' +// import { loggerService } from '@logger' import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer' import { useAppInit } from '@renderer/hooks/useAppInit' import { useShortcuts } from '@renderer/hooks/useShortcuts' @@ -26,7 +26,7 @@ type ElementItem = { element: React.FC | React.ReactNode } -const logger = loggerService.withContext('TopView') +// const logger = loggerService.withContext('TopView') const TopViewContainer: React.FC = ({ children }) => { const [elements, setElements] = useState([]) @@ -80,7 +80,7 @@ const TopViewContainer: React.FC = ({ children }) => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - logger.debug('keydown', e) + // logger.debug('keydown', e) if (!enableQuitFullScreen) return if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) { diff --git a/src/renderer/src/hooks/useDrag.ts b/src/renderer/src/hooks/useDrag.ts new file mode 100644 index 0000000000..803d86048e --- /dev/null +++ b/src/renderer/src/hooks/useDrag.ts @@ -0,0 +1,43 @@ +// import { loggerService } from '@logger' +import { useCallback, useState } from 'react' + +// const logger = loggerService.withContext('useDrag') + +export const useDrag = (onDrop?: (e: React.DragEvent) => Promise | void) => { + const [isDragging, setIsDragging] = useState(false) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + }, []) + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + // 确保是离开当前元素,而不是进入子元素 + // logger.debug('drag leave', { currentTarget: e.currentTarget, relatedTarget: e.relatedTarget }) + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return + } + setIsDragging(false) + }, []) + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + await onDrop?.(e) + }, + [onDrop] + ) + + return { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } +} diff --git a/src/renderer/src/hooks/useFiles.ts b/src/renderer/src/hooks/useFiles.ts new file mode 100644 index 0000000000..59a9c99cfa --- /dev/null +++ b/src/renderer/src/hooks/useFiles.ts @@ -0,0 +1,97 @@ +import { FileMetadata } from '@renderer/types' +import { filterSupportedFiles } from '@renderer/utils' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type Props = { + /** 支持选择的扩展名 */ + extensions?: string[] +} + +export const useFiles = (props?: Props) => { + const { t } = useTranslation() + + const [files, setFiles] = useState([]) + const [selecting, setSelecting] = useState(false) + + const extensions = useMemo(() => { + if (props?.extensions) { + return props.extensions + } else { + return ['*'] + } + }, [props?.extensions]) + + /** + * 选择文件的回调函数 + * @param multipleSelections - 是否允许多选文件,默认为 true + * @returns 返回选中的文件元数据数组 + * @description + * 1. 打开系统文件选择对话框 + * 2. 根据扩展名过滤文件 + * 3. 更新内部文件状态 + * 4. 当选择了不支持的文件类型时,会显示提示信息 + */ + const onSelectFile = useCallback( + async ({ multipleSelections = true }: { multipleSelections?: boolean }): Promise => { + if (selecting) { + return [] + } + + const selectProps: Electron.OpenDialogOptions['properties'] = multipleSelections + ? ['openFile', 'multiSelections'] + : ['openFile'] + + // when the number of extensions is greater than 20, use *.* to avoid selecting window lag + const useAllFiles = extensions.length > 20 + + setSelecting(true) + const _files = await window.api.file.select({ + properties: selectProps, + filters: [ + { + name: 'Files', + extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', '')) + } + ] + }) + setSelecting(false) + + if (_files) { + if (!useAllFiles) { + setFiles([...files, ..._files]) + return _files + } + const supportedFiles = await filterSupportedFiles(_files, extensions) + if (supportedFiles.length > 0) { + setFiles([...files, ...supportedFiles]) + } + + if (supportedFiles.length !== _files.length) { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported_count', { + count: _files.length - supportedFiles.length + }) + }) + } + return supportedFiles + } else { + return [] + } + }, + [extensions, files, selecting, t] + ) + + const clearFiles = useCallback(() => { + setFiles([]) + }, []) + + return { + files, + selecting, + setFiles, + onSelectFile, + clearFiles + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 9dbf612fa5..24f8b55e33 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -750,6 +750,9 @@ "enabled": "Enabled", "error": "error", "expand": "Expand", + "file": { + "not_supported": "Unsupported file type {{type}}" + }, "footnote": "Reference content", "footnotes": "References", "fullscreen": "Entered fullscreen mode. Press F11 to exit", @@ -790,6 +793,7 @@ "success": "Success", "swap": "Swap", "topics": "Topics", + "upload_files": "Upload file", "warning": "Warning", "you": "You" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Swap the source and target languages" }, + "files": { + "drag_text": "Drop here", + "error": { + "multiple": "Multiple file uploads are not allowed", + "too_large": "File too large", + "unknown": "Failed to read file content" + }, + "reading": "Reading file content..." + }, "history": { "clear": "Clear History", "clear_description": "Clear history will delete all translation history, continue?", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index f3a819565b..79a56ebdaf 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -750,6 +750,9 @@ "enabled": "有効", "error": "エラー", "expand": "展開", + "file": { + "not_supported": "サポートされていないファイルタイプ {{type}}" + }, "footnote": "引用内容", "footnotes": "脚注", "fullscreen": "全画面モードに入りました。F11キーで終了します", @@ -790,6 +793,7 @@ "success": "成功", "swap": "交換", "topics": "トピック", + "upload_files": "ファイルをアップロードする", "warning": "警告", "you": "あなた" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "入力言語と出力言語を入れ替える" }, + "files": { + "drag_text": "ここにドラッグ&ドロップしてください", + "error": { + "multiple": "複数のファイルのアップロードは許可されていません", + "too_large": "ファイルが大きすぎます", + "unknown": "ファイルの内容を読み取るのに失敗しました" + }, + "reading": "ファイルの内容を読み込んでいます..." + }, "history": { "clear": "履歴をクリア", "clear_description": "履歴をクリアすると、すべての翻訳履歴が削除されます。続行しますか?", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index e5a7323bcc..5b0c28fa2d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -750,6 +750,9 @@ "enabled": "Включено", "error": "ошибка", "expand": "Развернуть", + "file": { + "not_supported": "Неподдерживаемый тип файла {{type}}" + }, "footnote": "Цитируемый контент", "footnotes": "Сноски", "fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода", @@ -790,6 +793,7 @@ "success": "Успешно", "swap": "Поменять местами", "topics": "Топики", + "upload_files": "Загрузить файл", "warning": "Предупреждение", "you": "Вы" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Поменяйте исходный и целевой языки местами" }, + "files": { + "drag_text": "Перетащите сюда", + "error": { + "multiple": "Не разрешается загружать несколько файлов", + "too_large": "Файл слишком большой", + "unknown": "Ошибка при чтении содержимого файла" + }, + "reading": "Чтение содержимого файла..." + }, "history": { "clear": "Очистить историю", "clear_description": "Очистка истории удалит все записи переводов. Продолжить?", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 4ba42ba646..43cf5d3c7a 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -750,6 +750,9 @@ "enabled": "已启用", "error": "错误", "expand": "展开", + "file": { + "not_supported": "不支持的文件类型 {{type}}" + }, "footnote": "引用内容", "footnotes": "引用内容", "fullscreen": "已进入全屏模式,按 F11 退出", @@ -790,6 +793,7 @@ "success": "成功", "swap": "交换", "topics": "话题", + "upload_files": "上传文件", "warning": "警告", "you": "用户" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "交换源语言与目标语言" }, + "files": { + "drag_text": "拖放到此处", + "error": { + "multiple": "不允许上传多个文件", + "too_large": "文件过大", + "unknown": "读取文件内容失败" + }, + "reading": "读取文件内容中..." + }, "history": { "clear": "清空历史", "clear_description": "清空历史将删除所有翻译历史记录,是否继续?", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 6d25a814b0..d500e51b79 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -750,6 +750,9 @@ "enabled": "已啟用", "error": "錯誤", "expand": "展開", + "file": { + "not_supported": "不支持的文件類型 {{type}}" + }, "footnote": "引用內容", "footnotes": "引用", "fullscreen": "已進入全螢幕模式,按 F11 結束", @@ -790,6 +793,7 @@ "success": "成功", "swap": "交換", "topics": "話題", + "upload_files": "上傳檔案", "warning": "警告", "you": "您" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "交換源語言與目標語言" }, + "files": { + "drag_text": "拖放到此处", + "error": { + "multiple": "不允许上传多个文件", + "too_large": "文件過大", + "unknown": "读取文件内容失败" + }, + "reading": "讀取檔案內容中..." + }, "history": { "clear": "清空歷史", "clear_description": "清空歷史將刪除所有翻譯歷史記錄,是否繼續?", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 43bdc945ac..2b3ee33c66 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -750,6 +750,9 @@ "enabled": "Ενεργοποιημένο", "error": "σφάλμα", "expand": "Επεκτάση", + "file": { + "not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}" + }, "footnote": "Παραπομπή", "footnotes": "Παραπομπές", "fullscreen": "Εισήχθη σε πλήρη οθόνη, πατήστε F11 για να έξω", @@ -790,6 +793,7 @@ "success": "Επιτυχία", "swap": "Εναλλαγή", "topics": "Θέματα", + "upload_files": "Ανέβασμα αρχείου", "warning": "Προσοχή", "you": "Εσείς" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Ανταλλαγή γλώσσας πηγής και γλώσσας προορισμού" }, + "files": { + "drag_text": "Σύρετε και αφήστε εδώ", + "error": { + "multiple": "Δεν επιτρέπεται η μεταφόρτωση πολλαπλών αρχείων", + "too_large": "Το αρχείο είναι πολύ μεγάλο", + "unknown": "Αποτυχία ανάγνωσης του περιεχομένου του αρχείου" + }, + "reading": "Διαβάζοντας το περιεχόμενο του αρχείου..." + }, "history": { "clear": "Καθαρισμός ιστορικού", "clear_description": "Η διαγραφή του ιστορικού θα διαγράψει όλα τα απομνημονεύματα μετάφρασης. Θέλετε να συνεχίσετε;", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index e0d86e7a37..9cf86e2eae 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -750,6 +750,9 @@ "enabled": "Activado", "error": "error", "expand": "Expandir", + "file": { + "not_supported": "Tipo de archivo no compatible {{type}}" + }, "footnote": "Nota al pie", "footnotes": "Notas al pie", "fullscreen": "En modo pantalla completa, presione F11 para salir", @@ -790,6 +793,7 @@ "success": "Éxito", "swap": "Intercambiar", "topics": "Temas", + "upload_files": "Subir archivo", "warning": "Advertencia", "you": "Usuario" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Intercambiar el idioma de origen y el idioma de destino" }, + "files": { + "drag_text": "Arrastrar y soltar aquí", + "error": { + "multiple": "No se permite cargar varios archivos", + "too_large": "El archivo es demasiado grande", + "unknown": "Error al leer el contenido del archivo" + }, + "reading": "Leyendo el contenido del archivo..." + }, "history": { "clear": "Borrar historial", "clear_description": "Borrar el historial eliminará todos los registros de traducciones, ¿desea continuar?", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 646e2b28a4..3b4188ba2d 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -750,6 +750,9 @@ "enabled": "Activé", "error": "erreur", "expand": "Développer", + "file": { + "not_supported": "Type de fichier non pris en charge {{type}}" + }, "footnote": "Note de bas de page", "footnotes": "Notes de bas de page", "fullscreen": "Mode plein écran, appuyez sur F11 pour quitter", @@ -790,6 +793,7 @@ "success": "Succès", "swap": "Échanger", "topics": "Sujets", + "upload_files": "Uploader des fichiers", "warning": "Avertissement", "you": "Vous" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Échanger la langue source et la langue cible" }, + "files": { + "drag_text": "Glisser-déposer ici", + "error": { + "multiple": "Impossible de téléverser plusieurs fichiers", + "too_large": "Fichier trop volumineux", + "unknown": "Échec de la lecture du contenu du fichier" + }, + "reading": "Lecture du contenu du fichier en cours..." + }, "history": { "clear": "Effacer l'historique", "clear_description": "L'effacement de l'historique supprimera toutes les entrées d'historique de traduction, voulez-vous continuer ?", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index e1828408f0..0a4d8f7a14 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -750,6 +750,9 @@ "enabled": "Ativado", "error": "错误", "expand": "Expandir", + "file": { + "not_supported": "Tipo de arquivo não suportado {{type}}" + }, "footnote": "Nota de rodapé", "footnotes": "Notas de rodapé", "fullscreen": "Entrou no modo de tela cheia, pressione F11 para sair", @@ -790,6 +793,7 @@ "success": "Sucesso", "swap": "Trocar", "topics": "Tópicos", + "upload_files": "Carregar arquivo", "warning": "Aviso", "you": "Você" }, @@ -3772,6 +3776,15 @@ "exchange": { "label": "Trocar idioma de origem e idioma de destino" }, + "files": { + "drag_text": "Arraste e solte aqui", + "error": { + "multiple": "Não é permitido fazer upload de vários arquivos", + "too_large": "Arquivo muito grande", + "unknown": "Falha ao ler o conteúdo do arquivo" + }, + "reading": "Lendo o conteúdo do arquivo..." + }, "history": { "clear": "Limpar Histórico", "clear_description": "Limpar histórico irá deletar todos os registros de tradução. Deseja continuar?", diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index 8135eecf7c..a67382a888 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -1,4 +1,4 @@ -import { FileMetadata, FileType } from '@renderer/types' +import { FileType } from '@renderer/types' import { filterSupportedFiles } from '@renderer/utils/file' import { Tooltip } from 'antd' import { Paperclip } from 'lucide-react' @@ -39,7 +39,7 @@ const AttachmentButton: FC = ({ const useAllFiles = extensions.length > 20 setSelecting(true) - const _files: FileMetadata[] = await window.api.file.select({ + const _files = await window.api.file.select({ properties: ['openFile', 'multiSelections'], filters: [ { diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index f426616a5d..fcb759daa4 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -1,4 +1,4 @@ -import { SendOutlined, SwapOutlined } from '@ant-design/icons' +import { PlusOutlined, SendOutlined, SwapOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { CopyIcon } from '@renderer/components/Icons' @@ -9,26 +9,38 @@ import { LanguagesEnum, UNKNOWN } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' +import { useDrag } from '@renderer/hooks/useDrag' +import { useFiles } from '@renderer/hooks/useFiles' +import { useOcr } from '@renderer/hooks/useOcr' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import useTranslate from '@renderer/hooks/useTranslate' import { estimateTextTokens } from '@renderer/services/TokenService' import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setTranslating as setTranslatingAction } from '@renderer/store/runtime' -import { setTranslatedContent as setTranslatedContentAction } from '@renderer/store/translate' -import type { AutoDetectionMethod, Model, TranslateHistory, TranslateLanguage } from '@renderer/types' -import { runAsyncFunction } from '@renderer/utils' +import { setTranslatedContent as setTranslatedContentAction, setTranslateInput } from '@renderer/store/translate' +import { + type AutoDetectionMethod, + FileMetadata, + isSupportedOcrFile, + type Model, + type TranslateHistory, + type TranslateLanguage +} from '@renderer/types' +import { getFileExtension, runAsyncFunction } from '@renderer/utils' import { formatErrorMessage } from '@renderer/utils/error' +import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input' import { createInputScrollHandler, createOutputScrollHandler, detectLanguage, determineTargetLanguage } from '@renderer/utils/translate' -import { Button, Flex, Popover, Tooltip, Typography } from 'antd' +import { imageExts, MB, textExts } from '@shared/config/constant' +import { Button, Flex, FloatButton, Popover, Tooltip, Typography } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import { isEmpty, throttle } from 'lodash' -import { Check, FolderClock, Settings2 } from 'lucide-react' +import { Check, FolderClock, Settings2, UploadIcon } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -39,7 +51,6 @@ import TranslateSettings from './TranslateSettings' const logger = loggerService.withContext('TranslatePage') // cache variables -let _text = '' let _sourceLanguage: TranslateLanguage | 'auto' = 'auto' let _targetLanguage = LanguagesEnum.enUS @@ -49,9 +60,11 @@ const TranslatePage: FC = () => { const { translateModel, setTranslateModel } = useDefaultModel() const { prompt, getLanguageByLangcode } = useTranslate() const { shikiMarkdownIt } = useCodeStyle() + const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] }) + const { ocr } = useOcr() // states - const [text, setText] = useState(_text) + // const [text, setText] = useState(_text) const [renderedMarkdown, setRenderedMarkdown] = useState('') const [copied, setCopied] = useTemporaryValue(false, 2000) const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false) @@ -67,8 +80,10 @@ const TranslatePage: FC = () => { const [sourceLanguage, setSourceLanguage] = useState(_sourceLanguage) const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const [autoDetectionMethod, setAutoDetectionMethod] = useState('franc') + const [isProcessing, setIsProcessing] = useState(false) // redux states + const text = useAppSelector((state) => state.translate.translateInput) const translatedContent = useAppSelector((state) => state.translate.translatedContent) const translating = useAppSelector((state) => state.runtime.translating) @@ -80,7 +95,6 @@ const TranslatePage: FC = () => { const dispatch = useAppDispatch() - _text = text _sourceLanguage = sourceLanguage _targetLanguage = targetLanguage @@ -91,6 +105,13 @@ const TranslatePage: FC = () => { } // 控制翻译状态 + const setText = useCallback( + (input: string) => { + dispatch(setTranslateInput(input)) + }, + [dispatch] + ) + const setTranslatedContent = useCallback( (content: string) => { dispatch(setTranslatedContentAction(content)) @@ -414,15 +435,195 @@ const TranslatePage: FC = () => { (sourceLanguage !== 'auto' && sourceLanguage.langCode === UNKNOWN.langCode) || targetLanguage.langCode === UNKNOWN.langCode || (isBidirectional && - (bidirectionalPair[0].langCode === UNKNOWN.langCode || bidirectionalPair[1].langCode === UNKNOWN.langCode)) + (bidirectionalPair[0].langCode === UNKNOWN.langCode || bidirectionalPair[1].langCode === UNKNOWN.langCode)) || + isProcessing ) - }, [bidirectionalPair, isBidirectional, sourceLanguage, targetLanguage.langCode, text]) + }, [bidirectionalPair, isBidirectional, isProcessing, sourceLanguage, targetLanguage.langCode, text]) // 控制token估计 const tokenCount = useMemo(() => estimateTextTokens(text + prompt), [prompt, text]) + // 统一的文件处理 + const processFile = useCallback( + async (file: FileMetadata) => { + // extensible + const shouldOCR = isSupportedOcrFile(file) + + if (shouldOCR) { + try { + const ocrResult = await ocr(file) + setText(ocrResult.text) + } finally { + // do nothing when failed. + } + } else { + // the threshold may be too large + if (file.size > 5 * MB) { + window.message.error(t('translate.files.error.too_large') + ' (0 ~ 5 MB)') + } else { + window.message.loading({ content: t('translate.files.reading'), key: 'translate_files_reading', duration: 0 }) + try { + const result = await window.api.fs.readText(file.path) + setText(result) + } catch (e) { + logger.error('Failed to read text file.', e as Error) + window.message.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) + } finally { + window.message.destroy('translate_files_reading') + } + } + } + }, + [ocr, setText, t] + ) + + // 点击上传文件按钮 + const handleSelectFile = useCallback(async () => { + if (selecting) return + setIsProcessing(true) + try { + const [file] = await onSelectFile({ multipleSelections: false }) + if (!file) { + return + } + + return await processFile(file) + } catch (e) { + logger.error('Unknown error when selecting file.', e as Error) + window.message.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) + } finally { + clearFiles() + setIsProcessing(false) + } + }, [clearFiles, onSelectFile, processFile, selecting, t]) + + const getSingleFile = useCallback( + (files: FileMetadata[] | FileList): FileMetadata | File | null => { + if (files.length === 0) return null + if (files.length > 1) { + // 多文件上传时显示提示信息 + window.message.error({ + key: 'multiple_files', + content: t('translate.files.error.multiple') + }) + return null + } + return files[0] + }, + [t] + ) + + // 拖动上传文件 + const onDrop = useCallback( + async (e: React.DragEvent) => { + setIsProcessing(true) + // const supportedFiles = await filterSupportedFiles(_files, extensions) + const data = await getTextFromDropEvent(e).catch((err) => { + logger.error('getTextFromDropEvent', err) + window.message.error({ + key: 'file_error', + content: t('translate.files.error.unknown') + }) + return null + }) + if (data === null) { + return + } + setText(text + data) + + const droppedFiles = await getFilesFromDropEvent(e).catch((err) => { + logger.error('handleDrop:', err) + window.message.error({ + key: 'file_error', + content: t('translate.files.error.unknown') + }) + return null + }) + + if (droppedFiles) { + const file = getSingleFile(droppedFiles) as FileMetadata + if (!file) return + processFile(file) + } + setIsProcessing(false) + }, + [getSingleFile, processFile, setText, t, text] + ) + + const { + isDragging, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop: preventDrop + } = useDrag() + const { + isDragging: isDraggingOnInput, + handleDragEnter: handleDragEnterInput, + handleDragLeave: handleDragLeaveInput, + handleDragOver: handleDragOverInput, + handleDrop + } = useDrag(onDrop) + + // 粘贴上传文件 + const onPaste = useCallback( + async (event: React.ClipboardEvent) => { + setIsProcessing(true) + logger.debug('event', event) + if (event.clipboardData?.files && event.clipboardData.files.length > 0) { + event.preventDefault() + const files = event.clipboardData.files + const file = getSingleFile(files) as File + if (!file) return + try { + // 使用新的API获取文件路径 + const filePath = window.api.file.getPathForFile(file) + let selectedFile: FileMetadata | null + + // 如果没有路径,可能是剪贴板中的图像数据 + if (!filePath) { + if (file.type.startsWith('image/')) { + const tempFilePath = await window.api.file.createTempFile(file.name) + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + await window.api.file.write(tempFilePath, uint8Array) + selectedFile = await window.api.file.get(tempFilePath) + } else { + window.message.info({ + key: 'file_not_supported', + content: t('common.file.not_supported', { type: getFileExtension(filePath) }) + }) + return + } + } else { + // 有路径的情况 + selectedFile = await window.api.file.get(filePath) + } + + if (!selectedFile) { + window.message.error({ + key: 'file_error', + content: t('translate.files.error.unknown') + }) + return + } + processFile(selectedFile) + } catch (error) { + logger.error('onPaste:', error as Error) + window.message.error(t('chat.input.file_error')) + } + } + setIsProcessing(false) + }, + [getSingleFile, processFile, t] + ) return ( - + {t('translate.title')} @@ -484,7 +685,27 @@ const TranslatePage: FC = () => { - + + {(isDragging || isDraggingOnInput) && ( + + + {t('translate.files.drag_text')} + + )} + } + tooltip={t('common.upload_files')} + shape="circle" + type="primary" + onClick={handleSelectFile} + />