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} + />