mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 21:42:27 +08:00
feat: ocr image to translate (#9423)
* 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 commitf23e37941a. * 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 <qwen-coder@alibabacloud.com> * 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 commit07c7ecd0cf. * fix(preload): 为文件获取方法添加返回类型声明 添加Promise<FileMetadata | null>返回类型以明确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 <beyondkmp@gmail.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
0af5a85f67
commit
7bb3826cdd
@ -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',
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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<string> {
|
||||
return readTextFileWithAutoEncoding(path)
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,6 +168,7 @@ export function getMcpDir() {
|
||||
* 读取文件内容并自动检测编码格式进行解码
|
||||
* @param filePath - 文件路径
|
||||
* @returns 解码后的文件内容
|
||||
* @throws 如果路径不存在抛出错误
|
||||
*/
|
||||
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
|
||||
const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8'
|
||||
|
||||
@ -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<FileMetadata[] | null> =>
|
||||
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<FileMetadata | null> => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
/**
|
||||
* 创建一个空的临时文件
|
||||
* @param fileName 文件名
|
||||
@ -177,7 +178,8 @@ const api = {
|
||||
isTextFile: (filePath: string): Promise<boolean> => 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<string> => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl)
|
||||
},
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
|
||||
|
||||
@ -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<Props> = ({ children }) => {
|
||||
const [elements, setElements] = useState<ElementItem[]>([])
|
||||
@ -80,7 +80,7 @@ const TopViewContainer: React.FC<Props> = ({ 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) {
|
||||
|
||||
43
src/renderer/src/hooks/useDrag.ts
Normal file
43
src/renderer/src/hooks/useDrag.ts
Normal file
@ -0,0 +1,43 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
// const logger = loggerService.withContext('useDrag')
|
||||
|
||||
export const useDrag = <T extends HTMLElement>(onDrop?: (e: React.DragEvent<T>) => Promise<void> | void) => {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
|
||||
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<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
await onDrop?.(e)
|
||||
},
|
||||
[onDrop]
|
||||
)
|
||||
|
||||
return { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop }
|
||||
}
|
||||
97
src/renderer/src/hooks/useFiles.ts
Normal file
97
src/renderer/src/hooks/useFiles.ts
Normal file
@ -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<FileMetadata[]>([])
|
||||
const [selecting, setSelecting] = useState<boolean>(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<FileMetadata[]> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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?",
|
||||
|
||||
@ -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": "履歴をクリアすると、すべての翻訳履歴が削除されます。続行しますか?",
|
||||
|
||||
@ -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": "Очистка истории удалит все записи переводов. Продолжить?",
|
||||
|
||||
@ -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": "清空历史将删除所有翻译历史记录,是否继续?",
|
||||
|
||||
@ -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": "清空歷史將刪除所有翻譯歷史記錄,是否繼續?",
|
||||
|
||||
@ -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": "Η διαγραφή του ιστορικού θα διαγράψει όλα τα απομνημονεύματα μετάφρασης. Θέλετε να συνεχίσετε;",
|
||||
|
||||
@ -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?",
|
||||
|
||||
@ -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 ?",
|
||||
|
||||
@ -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?",
|
||||
|
||||
@ -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<Props> = ({
|
||||
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: [
|
||||
{
|
||||
|
||||
@ -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<string>('')
|
||||
const [copied, setCopied] = useTemporaryValue(false, 2000)
|
||||
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false)
|
||||
@ -67,8 +80,10 @@ const TranslatePage: FC = () => {
|
||||
const [sourceLanguage, setSourceLanguage] = useState<TranslateLanguage | 'auto'>(_sourceLanguage)
|
||||
const [targetLanguage, setTargetLanguage] = useState<TranslateLanguage>(_targetLanguage)
|
||||
const [autoDetectionMethod, setAutoDetectionMethod] = useState<AutoDetectionMethod>('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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>()
|
||||
const {
|
||||
isDragging: isDraggingOnInput,
|
||||
handleDragEnter: handleDragEnterInput,
|
||||
handleDragLeave: handleDragLeaveInput,
|
||||
handleDragOver: handleDragOverInput,
|
||||
handleDrop
|
||||
} = useDrag<HTMLDivElement>(onDrop)
|
||||
|
||||
// 粘贴上传文件
|
||||
const onPaste = useCallback(
|
||||
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<Container id="translate-page">
|
||||
<Container
|
||||
id="translate-page"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={preventDrop}>
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none', gap: 10 }}>{t('translate.title')}</NavbarCenter>
|
||||
</Navbar>
|
||||
@ -484,7 +685,27 @@ const TranslatePage: FC = () => {
|
||||
</InnerOperationBar>
|
||||
</OperationBar>
|
||||
<AreaContainer>
|
||||
<InputContainer>
|
||||
<InputContainer
|
||||
style={isDraggingOnInput ? { border: '2px dashed var(--color-primary)' } : undefined}
|
||||
onDragEnter={handleDragEnterInput}
|
||||
onDragLeave={handleDragLeaveInput}
|
||||
onDragOver={handleDragOverInput}
|
||||
onDrop={handleDrop}>
|
||||
{(isDragging || isDraggingOnInput) && (
|
||||
<InputContainerDraggingHintContainer>
|
||||
<UploadIcon color="var(--color-text-3)" />
|
||||
{t('translate.files.drag_text')}
|
||||
</InputContainerDraggingHintContainer>
|
||||
)}
|
||||
<FloatButton
|
||||
style={{ position: 'absolute', left: 8, bottom: 8 }}
|
||||
className="float-button"
|
||||
icon={<PlusOutlined />}
|
||||
tooltip={t('common.upload_files')}
|
||||
shape="circle"
|
||||
type="primary"
|
||||
onClick={handleSelectFile}
|
||||
/>
|
||||
<Textarea
|
||||
ref={textAreaRef}
|
||||
variant="borderless"
|
||||
@ -493,6 +714,7 @@ const TranslatePage: FC = () => {
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onScroll={handleInputScroll}
|
||||
onPaste={onPaste}
|
||||
disabled={translating}
|
||||
spellCheck={false}
|
||||
allowClear
|
||||
@ -583,6 +805,29 @@ const InputContainer = styled.div`
|
||||
border-radius: 10px;
|
||||
height: calc(100vh - var(--navbar-height) - 70px);
|
||||
overflow: hidden;
|
||||
.float-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.float-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const InputContainerDraggingHintContainer = styled.div`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const Textarea = styled(TextArea)`
|
||||
|
||||
@ -5,7 +5,7 @@ import { getFileExtension, isSupportedFile } from '@renderer/utils'
|
||||
const logger = loggerService.withContext('PasteService')
|
||||
|
||||
// Track last focused component
|
||||
type ComponentType = 'inputbar' | 'messageEditor' | null
|
||||
type ComponentType = 'inputbar' | 'messageEditor' | 'TranslatePage' | null
|
||||
let lastFocusedComponent: ComponentType = 'inputbar' // Default to inputbar
|
||||
|
||||
// 处理函数类型
|
||||
|
||||
@ -2182,6 +2182,7 @@ const migrateConfig = {
|
||||
providers: BUILTIN_OCR_PROVIDERS,
|
||||
imageProvider: DEFAULT_OCR_PROVIDER.image
|
||||
}
|
||||
state.translate.translateInput = ''
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 137 error', error as Error)
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
export interface TranslateState {
|
||||
translateInput: string
|
||||
translatedContent: string
|
||||
}
|
||||
|
||||
const initialState: TranslateState = {
|
||||
translateInput: '',
|
||||
translatedContent: ''
|
||||
}
|
||||
|
||||
@ -12,15 +14,15 @@ const translateSlice = createSlice({
|
||||
name: 'translate',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTranslateInput: (state, action: PayloadAction<string>) => {
|
||||
state.translateInput = action.payload
|
||||
},
|
||||
setTranslatedContent: (state, action: PayloadAction<string>) => {
|
||||
return {
|
||||
...state,
|
||||
translatedContent: action.payload
|
||||
}
|
||||
state.translatedContent = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { setTranslatedContent } = translateSlice.actions
|
||||
export const { setTranslateInput, setTranslatedContent } = translateSlice.actions
|
||||
|
||||
export default translateSlice.reducer
|
||||
|
||||
@ -57,6 +57,15 @@ export function removeSpecialCharactersForFileName(str: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否为支持的类型。
|
||||
* 支持的文件类型包括:
|
||||
* 1. 文件扩展名在supportExts集合中的文件
|
||||
* 2. 文本文件
|
||||
* @param {string} filePath 文件路径
|
||||
* @param {Set<string>} supportExts 支持的文件扩展名集合
|
||||
* @returns {Promise<boolean>} 如果文件类型受支持返回true,否则返回false
|
||||
*/
|
||||
export async function isSupportedFile(filePath: string, supportExts: Set<string>): Promise<boolean> {
|
||||
try {
|
||||
if (supportExts.has(getFileExtension(filePath))) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user