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 commit f23e37941a.

* 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 commit 07c7ecd0cf.

* 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:
Phantom 2025-08-26 00:21:26 +08:00 committed by GitHub
parent 0af5a85f67
commit 7bb3826cdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 558 additions and 27 deletions

View File

@ -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',

View File

@ -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))

View File

@ -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)
}
}

View File

@ -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'

View File

@ -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)

View File

@ -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) {

View 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 }
}

View 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
}
}

View File

@ -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?",

View File

@ -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": "履歴をクリアすると、すべての翻訳履歴が削除されます。続行しますか?",

View File

@ -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": "Очистка истории удалит все записи переводов. Продолжить?",

View File

@ -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": "清空历史将删除所有翻译历史记录,是否继续?",

View File

@ -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": "清空歷史將刪除所有翻譯歷史記錄,是否繼續?",

View File

@ -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": "Η διαγραφή του ιστορικού θα διαγράψει όλα τα απομνημονεύματα μετάφρασης. Θέλετε να συνεχίσετε;",

View File

@ -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?",

View File

@ -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 ?",

View File

@ -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?",

View File

@ -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: [
{

View File

@ -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)`

View File

@ -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
// 处理函数类型

View File

@ -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)

View File

@ -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

View File

@ -57,6 +57,15 @@ export function removeSpecialCharactersForFileName(str: string): string {
.trim()
}
/**
*
* :
* 1. supportExts集合中的文件
* 2.
* @param {string} filePath
* @param {Set<string>} supportExts
* @returns {Promise<boolean>} truefalse
*/
export async function isSupportedFile(filePath: string, supportExts: Set<string>): Promise<boolean> {
try {
if (supportExts.has(getFileExtension(filePath))) {