From 11502edad26ec545fe0a49c935e08ef710eb35e3 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Tue, 9 Sep 2025 00:49:20 +0800 Subject: [PATCH] feat(translate): Auto copy when translation is done (#10032) * feat(translate): add settings with autoCopy option to translate state Add settings object to translate state to store user preferences like autoCopy functionality. Implement updateSettings reducer to handle settings updates. * docs(translate): add todo comment for settings field * refactor(translate): simplify settings update and expose in hook Remove dependency on objectEntriesStrict and use Object.entries directly Expose translate settings and update function in useTranslate hook * fix(useTranslate): use dispatch to update * feat(translate): add auto-copy setting for translated content Add auto-copy functionality that automatically copies translated text to clipboard when enabled. Includes settings toggle in TranslateSettings component and integration with translate logic. * chore: add tailwindcss file association to vscode settings * feat(translate): add auto copy setting and improve switch styling Add new translation strings for auto copy feature and apply consistent primary color to all switches in translation settings * fix(theme): update hero UI primary color variable Add --primary CSS variable to match --color-primary for hero UI components * refactor(hooks): rename _updateSettings to handleUpdateSettings for clarity Improve variable naming consistency and better reflect the function's purpose * refactor(translate): simplify settings update using Object.assign * fix(translate): handle clipboard write errors in copy functionality Add error handling for clipboard operations to prevent silent failures and show user feedback when copy fails * feat(i18n): add translation placeholders for new UI strings Add new translation keys for front matter operations and auto-copy setting Include additional Anthropic OAuth related messages for better user feedback * fix(i18n): Auto update translations for PR #10032 * fix(i18n): correct translation errors in multiple language files Fix incorrect translations and fill missing values in Japanese, Russian, Portuguese, French and Spanish localization files. Changes include correcting property name in Japanese, adding missing empty value in Russian, fixing editValue in Portuguese, correcting date and empty values in French, and fixing multiple terms in Spanish. * fix: update error message in migration from 151 to 152 * fix(translate): await copy operation and show success message Ensure the copy operation completes before proceeding and notify user of successful copy * feat(translate): add delay timer for auto-copy functionality Use setTimeoutTimer to introduce a 100ms delay before auto-copy to ensure UI stability * fix(translate): increase modal width from 420 to 520 for better content display * fix(ThemeProvider): ensure proper theme class is applied to body Add logic to toggle 'light' and 'dark' classes on body element when theme changes * fix(translate): only copy when success * fix(translate): remove redundant error message display on translation failure * fix(translate): handle abort and empty translation cases properly Improve error handling for translation abort scenarios and empty responses. Show appropriate user messages when translation is aborted and properly handle NoOutputGeneratedError cases. * fix(translate): handle translation errors by showing user-friendly message Display a localized error message to users when translation fails instead of just logging it --------- Co-authored-by: GitHub Action --- .vscode/settings.json | 3 + src/renderer/src/context/ThemeProvider.tsx | 7 ++ src/renderer/src/hooks/useTranslate.ts | 16 ++- src/renderer/src/hooks/useUserTheme.ts | 2 + src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 20 +++ src/renderer/src/i18n/translate/es-es.json | 20 +++ src/renderer/src/i18n/translate/fr-fr.json | 20 +++ src/renderer/src/i18n/translate/ja-jp.json | 16 +++ src/renderer/src/i18n/translate/pt-pt.json | 20 +++ src/renderer/src/i18n/translate/ru-ru.json | 16 +++ .../src/pages/translate/TranslatePage.tsx | 43 +++++-- .../src/pages/translate/TranslateSettings.tsx | 42 +++++-- src/renderer/src/services/TranslateService.ts | 117 +++++------------- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 11 ++ src/renderer/src/store/translate.ts | 17 ++- 19 files changed, 262 insertions(+), 113 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 997c26aedf..3dd634507f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,6 +28,9 @@ "source.organizeImports": "never" }, "editor.formatOnSave": true, + "files.associations": { + "*.css": "tailwindcss" + }, "files.eol": "\n", "i18n-ally.displayLanguage": "zh-cn", "i18n-ally.enabledFrameworks": ["react-i18next", "i18next"], diff --git a/src/renderer/src/context/ThemeProvider.tsx b/src/renderer/src/context/ThemeProvider.tsx index daa2fd9011..5fe61108eb 100644 --- a/src/renderer/src/context/ThemeProvider.tsx +++ b/src/renderer/src/context/ThemeProvider.tsx @@ -51,6 +51,13 @@ export const ThemeProvider: React.FC = ({ children }) => { // Set initial theme and OS attributes on body document.body.setAttribute('os', isMac ? 'mac' : isWin ? 'windows' : 'linux') document.body.setAttribute('theme-mode', actualTheme) + if (actualTheme === ThemeMode.dark) { + document.body.classList.remove('light') + document.body.classList.add('dark') + } else { + document.body.classList.remove('dark') + document.body.classList.add('light') + } document.body.setAttribute('navbar-position', navbarPosition) // if theme is old auto, then set theme to system diff --git a/src/renderer/src/hooks/useTranslate.ts b/src/renderer/src/hooks/useTranslate.ts index 4a4dacd53c..5f5f1cbded 100644 --- a/src/renderer/src/hooks/useTranslate.ts +++ b/src/renderer/src/hooks/useTranslate.ts @@ -1,10 +1,12 @@ import { loggerService } from '@logger' import { builtinLanguages, UNKNOWN } from '@renderer/config/translate' import { useAppSelector } from '@renderer/store' +import { TranslateState, updateSettings } from '@renderer/store/translate' import { TranslateLanguage } from '@renderer/types' import { runAsyncFunction } from '@renderer/utils' import { getTranslateOptions } from '@renderer/utils/translate' import { useCallback, useEffect, useState } from 'react' +import { useDispatch } from 'react-redux' const logger = loggerService.withContext('useTranslate') @@ -17,9 +19,12 @@ const logger = loggerService.withContext('useTranslate') */ export default function useTranslate() { const prompt = useAppSelector((state) => state.settings.translateModelPrompt) + const settings = useAppSelector((state) => state.translate.settings) const [translateLanguages, setTranslateLanguages] = useState(builtinLanguages) const [isLoaded, setIsLoaded] = useState(false) + const dispatch = useDispatch() + useEffect(() => { runAsyncFunction(async () => { const options = await getTranslateOptions() @@ -46,9 +51,18 @@ export default function useTranslate() { [isLoaded, translateLanguages] ) + const handleUpdateSettings = useCallback( + (update: Partial) => { + dispatch(updateSettings(update)) + }, + [dispatch] + ) + return { prompt, + settings, translateLanguages, - getLanguageByLangcode + getLanguageByLangcode, + updateSettings: handleUpdateSettings } } diff --git a/src/renderer/src/hooks/useUserTheme.ts b/src/renderer/src/hooks/useUserTheme.ts index 815903da73..0b1bc5fb24 100644 --- a/src/renderer/src/hooks/useUserTheme.ts +++ b/src/renderer/src/hooks/useUserTheme.ts @@ -11,6 +11,8 @@ export default function useUserTheme() { const colorPrimary = Color(theme.colorPrimary) document.body.style.setProperty('--color-primary', colorPrimary.toString()) + // overwrite hero UI primary color. + document.body.style.setProperty('--primary', colorPrimary.toString()) document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString()) document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString()) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 646a5c48cc..17213ec798 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4301,6 +4301,7 @@ }, "processing": "Translation in progress...", "settings": { + "autoCopy": "Copy after translation ", "bidirectional": "Bidirectional Translation Settings", "bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported", "model": "Model Settings", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 5122edc291..221e36f6af 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4301,6 +4301,7 @@ }, "processing": "翻译中...", "settings": { + "autoCopy": "翻译完成后自动复制", "bidirectional": "双向翻译设置", "bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译", "model": "模型设置", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 364a5b7e3e..3f080ee249 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4301,6 +4301,7 @@ }, "processing": "翻譯中...", "settings": { + "autoCopy": "翻譯完成後自動複製", "bidirectional": "雙向翻譯設定", "bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯", "model": "模型設定", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 2ca2574bf0..b85f5c9b50 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -2221,6 +2221,21 @@ } }, "dragHandle": "στοιχεία σύρεσης", + "frontMatter": { + "addProperty": "Προσθήκη χαρακτηριστικού", + "addTag": "Προσθήκη ετικέτας", + "changeToBoolean": "Πλαίσιο ελέγχου", + "changeToDate": "Ημερομηνία", + "changeToNumber": "ψηφίο", + "changeToTags": "ετικέτα", + "changeToText": "κείμενο", + "changeType": "Αλλαγή τύπου", + "deleteProperty": "διαγραφή χαρακτηριστικού", + "editValue": "Επεξεργασία τιμής", + "empty": "\n空\n", + "moreActions": "Περισσότερες ενέργειες", + "propertyName": "όνομα χαρακτηριστικού" + }, "image": { "placeholder": "προσθήκη εικόνας" }, @@ -3681,15 +3696,19 @@ "apikey": "Κλειδί API", "auth_failed": "Αποτυχία πιστοποίησης ταυτότητας Anthropic", "auth_method": "Τρόπος πιστοποίησης", + "auth_success": "Η πιστοποίηση OAuth της Anthropic ήταν επιτυχής", "authenticated": "Επαληθευμένο", "authenticating": "Επαλήθευση σε εξέλιξη", "cancel": "Ακύρωση", + "code_error": "Μη έγκυρος κωδικός εξουσιοδότησης, δοκιμάστε ξανά", "code_placeholder": "Παρακαλώ εισαγάγετε τον κωδικό εξουσιοδότησης που εμφανίζεται στον περιηγητή σας", "code_required": "Ο κωδικός εξουσιοδότησης δεν μπορεί να είναι κενός", "description": "Πιστοποίηση OAuth", "description_detail": "Για να χρησιμοποιήσετε αυτόν τον τρόπο επαλήθευσης, πρέπει να είστε συνδρομητής Claude Pro ή έκδοσης μεγαλύτερης από αυτήν", "enter_auth_code": "κωδικός εξουσιοδότησης", "logout": "Αποσύνδεση λογαριασμού", + "logout_failed": "Η αποσύνδεση απέτυχε, δοκιμάστε ξανά.", + "logout_success": "Επιτυχής αποσύνδεση από το Anthropic", "oauth": "ιστοσελίδα OAuth", "start_auth": "Έναρξη εξουσιοδότησης", "submit_code": "Ολοκληρώστε την σύνδεση" @@ -4278,6 +4297,7 @@ }, "processing": "Μεταφράζεται...", "settings": { + "autoCopy": "Μετά τη μετάφραση, αντιγράφεται αυτόματα", "bidirectional": "Ρύθμιση διπλής κατεύθυνσης μετάφρασης", "bidirectional_tip": "Όταν ενεργοποιηθεί, υποστηρίζεται μόνο διπλής κατεύθυνσης μετάφραση μεταξύ της πηγαίας και της στόχου γλώσσας", "model": "Ρύθμιση μοντέλου", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 94a6a0d0cd..95d79571a1 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -2221,6 +2221,21 @@ } }, "dragHandle": "bloque de arrastre", + "frontMatter": { + "addProperty": "Agregar atributo", + "addTag": "Añadir etiqueta", + "changeToBoolean": "Casilla de verificación", + "changeToDate": "Fecha", + "changeToNumber": "número", + "changeToTags": "etiqueta", + "changeToText": "texto", + "changeType": "cambiar tipo", + "deleteProperty": "eliminar atributo", + "editValue": "editar valor", + "empty": "vacío", + "moreActions": "Más operaciones", + "propertyName": "Nombre del atributo" + }, "image": { "placeholder": "añadir imágenes" }, @@ -3681,15 +3696,19 @@ "apikey": "Clave de API", "auth_failed": "Error de autenticación de Anthropic", "auth_method": "Método de autenticación", + "auth_success": "Autenticación OAuth de Anthropic exitosa", "authenticated": "Verificado", "authenticating": "Autenticando", "cancel": "Cancelar", + "code_error": "Código de autorización inválido, inténtalo de nuevo", "code_placeholder": "Introduzca el código de autorización que se muestra en el navegador", "code_required": "El código de autorización no puede estar vacío", "description": "Autenticación OAuth", "description_detail": "Necesitas suscribirte a Claude Pro o a una versión superior para utilizar este método de autenticación", "enter_auth_code": "Código de autorización", "logout": "Cerrar sesión", + "logout_failed": "Error al cerrar sesión, inténtalo de nuevo", + "logout_success": "Cierre de sesión exitoso en Anthropic", "oauth": "Web OAuth", "start_auth": "Comenzar autorización", "submit_code": "Iniciar sesión completado" @@ -4278,6 +4297,7 @@ }, "processing": "Traduciendo...", "settings": { + "autoCopy": "Copiar automáticamente después de completar la traducción", "bidirectional": "Configuración de traducción bidireccional", "bidirectional_tip": "Una vez activada, solo se admitirá la traducción bidireccional entre el idioma de origen y el idioma de destino", "model": "Configuración del modelo", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 0c929c697d..d3ae5f5dfb 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -2221,6 +2221,21 @@ } }, "dragHandle": "bloc de glisser-déposer", + "frontMatter": { + "addProperty": "Ajouter un attribut", + "addTag": "Ajouter une étiquette", + "changeToBoolean": "Case à cocher", + "changeToDate": "fecha", + "changeToNumber": "numérique", + "changeToTags": "étiquette", + "changeToText": "texte", + "changeType": "Modifier le type", + "deleteProperty": "Supprimer l'attribut", + "editValue": "valeur d'édition", + "empty": "vacío", + "moreActions": "Plus d'actions", + "propertyName": "Nom de l'attribut" + }, "image": { "placeholder": "ajouter une image" }, @@ -3681,15 +3696,19 @@ "apikey": "Clé API", "auth_failed": "Échec de l'authentification Anthropic", "auth_method": "Mode d'authentification", + "auth_success": "Authentification OAuth Anthropic réussie", "authenticated": "Certifié", "authenticating": "Authentification en cours", "cancel": "Annuler", + "code_error": "Code d'autorisation invalide, veuillez réessayer", "code_placeholder": "Veuillez saisir le code d'autorisation affiché dans le navigateur", "code_required": "Le code d'autorisation ne peut pas être vide", "description": "Authentification OAuth", "description_detail": "Vous devez souscrire à Claude Pro ou à une version supérieure pour pouvoir utiliser cette méthode d'authentification.", "enter_auth_code": "code d'autorisation", "logout": "Déconnexion", + "logout_failed": "Échec de la déconnexion, veuillez réessayer", + "logout_success": "Déconnexion réussie d'Anthropic", "oauth": "Authentification OAuth web", "start_auth": "Commencer l'autorisation", "submit_code": "Terminer la connexion" @@ -4278,6 +4297,7 @@ }, "processing": "en cours de traduction...", "settings": { + "autoCopy": "Copié automatiquement après la traduction", "bidirectional": "Paramètres de traduction bidirectionnelle", "bidirectional_tip": "Une fois activé, seul la traduction bidirectionnelle entre la langue source et la langue cible est prise en charge", "model": "Paramètres du modèle", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 10cd57db6a..30ea6e3411 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -2221,6 +2221,21 @@ } }, "dragHandle": "ブロックをドラッグします", + "frontMatter": { + "addProperty": "属性を追加", + "addTag": "タグを追加", + "changeToBoolean": "チェックボックス", + "changeToDate": "日付", + "changeToNumber": "数字", + "changeToTags": "タグ", + "changeToText": "テキスト", + "changeType": "種類を変更", + "deleteProperty": "削除属性", + "editValue": "編集値", + "empty": "空", + "moreActions": "その他の操作", + "propertyName": "プロパティ名" + }, "image": { "placeholder": "写真を追加します" }, @@ -4282,6 +4297,7 @@ }, "processing": "翻訳中...", "settings": { + "autoCopy": "翻訳完了後、自動的にコピー", "bidirectional": "双方向翻訳設定", "bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます", "model": "モデル設定", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 4127b25391..2d315e8fc8 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -2221,6 +2221,21 @@ } }, "dragHandle": "bloco de arrastar", + "frontMatter": { + "addProperty": "Adicionar atributo", + "addTag": "Adicionar etiqueta", + "changeToBoolean": "Caixa de seleção", + "changeToDate": "Data", + "changeToNumber": "número", + "changeToTags": "etiqueta", + "changeToText": "texto", + "changeType": "Alterar tipo", + "deleteProperty": "Excluir atributo", + "editValue": "Editar valor", + "empty": "vazio", + "moreActions": "Mais ações", + "propertyName": "nome do atributo" + }, "image": { "placeholder": "adicionar imagem" }, @@ -3681,15 +3696,19 @@ "apikey": "Chave da API", "auth_failed": "Falha na autenticação da Anthropic", "auth_method": "Método de autenticação", + "auth_success": "Autenticação OAuth da Anthropic bem-sucedida", "authenticated": "[retranslating]: Verificado", "authenticating": "A autenticar", "cancel": "Cancelar", + "code_error": "Código de autorização inválido, tente novamente", "code_placeholder": "Introduza o código de autorização exibido no browser", "code_required": "O código de autorização não pode estar vazio", "description": "Autenticação OAuth", "description_detail": "Precisa de uma subscrição Claude Pro ou superior para utilizar este método de autenticação", "enter_auth_code": "Código de autorização", "logout": "Sair da sessão", + "logout_failed": "Falha ao sair da conta, tente novamente", + "logout_success": "Logout bem-sucedido do login Anthropic", "oauth": "OAuth da Página Web", "start_auth": "Iniciar autorização", "submit_code": "Concluir login" @@ -4278,6 +4297,7 @@ }, "processing": "Traduzindo...", "settings": { + "autoCopy": "Cópia automática após a tradução", "bidirectional": "Configuração de Tradução Bidirecional", "bidirectional_tip": "Quando ativado, suporta apenas tradução bidirecional entre o idioma de origem e o idioma de destino", "model": "Configuração de Modelo", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index bae0264e88..045d8d618f 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -2221,6 +2221,21 @@ } }, "dragHandle": "Перетащить блок", + "frontMatter": { + "addProperty": "Добавить атрибут", + "addTag": "Добавить метки", + "changeToBoolean": "Флажок", + "changeToDate": "Дата", + "changeToNumber": "цифры", + "changeToTags": "ярлык", + "changeToText": "текст", + "changeType": "Изменить тип", + "deleteProperty": "Удалить атрибут", + "editValue": "Редактировать значение", + "empty": "Пусто", + "moreActions": "Дополнительные действия", + "propertyName": "Имя атрибута" + }, "image": { "placeholder": "Добавить картинку" }, @@ -4282,6 +4297,7 @@ }, "processing": "Перевод в процессе...", "settings": { + "autoCopy": "Автоматически копировать после завершения перевода", "bidirectional": "Настройки двунаправленного перевода", "bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот.", "model": "Настройки модели", diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index b8118e800f..552da3ca03 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -13,6 +13,7 @@ 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 { useTimer } from '@renderer/hooks/useTimer' import useTranslate from '@renderer/hooks/useTranslate' import { estimateTextTokens } from '@renderer/services/TokenService' import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService' @@ -60,10 +61,12 @@ const TranslatePage: FC = () => { // hooks const { t } = useTranslation() const { translateModel, setTranslateModel } = useDefaultModel() - const { prompt, getLanguageByLangcode } = useTranslate() + const { prompt, getLanguageByLangcode, settings } = useTranslate() + const { autoCopy } = settings const { shikiMarkdownIt } = useCodeStyle() const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] }) const { ocr } = useOcr() + const { setTimeoutTimer } = useTimer() // states // const [text, setText] = useState(_text) @@ -129,6 +132,18 @@ const TranslatePage: FC = () => { [dispatch] ) + // 控制复制行为 + const onCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(translatedContent) + setCopied(true) + } catch (error) { + logger.error('Failed to copy text to clipboard:', error as Error) + // TODO: use toast + window.message.error(t('common.copy_failed')) + } + }, [setCopied, t, translatedContent]) + /** * 翻译文本并保存历史记录,包含完整的异常处理,不会抛出异常 * @param text - 需要翻译的文本 @@ -153,7 +168,9 @@ const TranslatePage: FC = () => { try { translated = await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100), abortKey) } catch (e) { - if (!isAbortError(e)) { + if (isAbortError(e)) { + window.message.info(t('translate.info.aborted')) + } else { logger.error('Failed to translate text', e as Error) window.message.error(t('translate.error.failed') + ': ' + formatErrorMessage(e)) } @@ -162,6 +179,15 @@ const TranslatePage: FC = () => { } window.message.success(t('translate.complete')) + if (autoCopy) { + setTimeoutTimer( + 'auto-copy', + async () => { + await onCopy() + }, + 100 + ) + } try { await saveTranslateHistory(text, translated, actualSourceLanguage.langCode, actualTargetLanguage.langCode) @@ -174,7 +200,7 @@ const TranslatePage: FC = () => { window.message.error(t('translate.error.unknown') + ': ' + formatErrorMessage(e)) } }, - [dispatch, setTranslatedContent, setTranslating, t, translating] + [autoCopy, dispatch, onCopy, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating] ) // 控制翻译按钮是否可用 @@ -237,10 +263,7 @@ const TranslatePage: FC = () => { await translate(text, actualSourceLanguage, actualTargetLanguage) } catch (error) { logger.error('Translation error:', error as Error) - window.message.error({ - content: String(error), - key: 'translate-message' - }) + window.message.error(t('translate.error.failed') + ': ' + formatErrorMessage(error)) return } finally { setTranslating(false) @@ -274,12 +297,6 @@ const TranslatePage: FC = () => { db.settings.put({ id: 'translate:bidirectional:enabled', value }) } - // 控制复制按钮 - const onCopy = () => { - navigator.clipboard.writeText(translatedContent) - setCopied(true) - } - // 控制历史记录点击 const onHistoryItemClick = ( history: TranslateHistory & { _sourceLanguage: TranslateLanguage; _targetLanguage: TranslateLanguage } diff --git a/src/renderer/src/pages/translate/TranslateSettings.tsx b/src/renderer/src/pages/translate/TranslateSettings.tsx index f26fad8114..3495cf7b57 100644 --- a/src/renderer/src/pages/translate/TranslateSettings.tsx +++ b/src/renderer/src/pages/translate/TranslateSettings.tsx @@ -1,15 +1,17 @@ +import { Switch } from '@heroui/react' import LanguageSelect from '@renderer/components/LanguageSelect' import { HStack } from '@renderer/components/Layout' import db from '@renderer/databases' import useTranslate from '@renderer/hooks/useTranslate' import { AutoDetectionMethod, Model, TranslateLanguage } from '@renderer/types' -import { Button, Flex, Modal, Radio, Space, Switch, Tooltip } from 'antd' +import { Button, Flex, Modal, Radio, Space, Tooltip } from 'antd' import { HelpCircle } from 'lucide-react' import { FC, memo, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import TranslateSettingsPopup from '../settings/TranslateSettingsPopup/TranslateSettingsPopup' +// TODO: Just don't send so many props. Migrate them to redux. const TranslateSettings: FC<{ visible: boolean onClose: () => void @@ -40,7 +42,8 @@ const TranslateSettings: FC<{ }) => { const { t } = useTranslation() const [localPair, setLocalPair] = useState<[TranslateLanguage, TranslateLanguage]>(bidirectionalPair) - const { getLanguageByLangcode } = useTranslate() + const { getLanguageByLangcode, settings, updateSettings } = useTranslate() + const { autoCopy } = settings useEffect(() => { setLocalPair(bidirectionalPair) @@ -58,15 +61,15 @@ const TranslateSettings: FC<{ onCancel={onClose} centered={true} footer={null} - width={420} + width={520} transitionName="animation-move-down">
{t('translate.settings.preview')}
{ + isSelected={enableMarkdown} + onValueChange={(checked) => { setEnableMarkdown(checked) db.settings.put({ id: 'translate:markdown:enabled', value: checked }) }} @@ -74,14 +77,28 @@ const TranslateSettings: FC<{
+
+ +
{t('translate.settings.autoCopy')}
+ { + updateSettings({ autoCopy: isSelected }) + }} + /> +
+
+
{t('translate.settings.scroll_sync')}
{ - setIsScrollSyncEnabled(checked) - db.settings.put({ id: 'translate:scroll:sync', value: checked }) + isSelected={isScrollSyncEnabled} + color="primary" + onValueChange={(isSelected) => { + setIsScrollSyncEnabled(isSelected) + db.settings.put({ id: 'translate:scroll:sync', value: isSelected }) }} />
@@ -131,9 +148,10 @@ const TranslateSettings: FC<{
{ - setIsBidirectional(checked) + isSelected={isBidirectional} + color="primary" + onValueChange={(isSelected) => { + setIsBidirectional(isSelected) // 双向翻译设置不需要持久化,它只是界面状态 }} /> diff --git a/src/renderer/src/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index c8bdad9406..0eb7fd24f7 100644 --- a/src/renderer/src/services/TranslateService.ts +++ b/src/renderer/src/services/TranslateService.ts @@ -10,7 +10,8 @@ import { import { Chunk, ChunkType } from '@renderer/types/chunk' import { uuid } from '@renderer/utils' import { readyToAbort } from '@renderer/utils/abortController' -import { formatErrorMessage, isAbortError } from '@renderer/utils/error' +import { isAbortError } from '@renderer/utils/error' +import { NoOutputGeneratedError } from 'ai' import { t } from 'i18next' import { fetchChatCompletion } from './ApiService' @@ -18,53 +19,6 @@ import { getDefaultTranslateAssistant } from './AssistantService' const logger = loggerService.withContext('TranslateService') -// async function fetchTranslate({ assistant, onResponse, abortKey }: FetchTranslateProps) { -// const model = assistant.model - -// const provider = getProviderByModel(model) - -// if (!hasApiKey(provider)) { -// throw new Error(t('error.no_api_key')) -// } - -// const isSupportedStreamOutput = () => { -// if (!onResponse) { -// return false -// } -// return true -// } - -// const stream = isSupportedStreamOutput() -// const enableReasoning = -// ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && -// assistant.settings?.reasoning_effort !== undefined) || -// (isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model))) - -// // abort control -// const controller = new AbortController() -// const signal = controller.signal - -// // 使用 transformParameters 模块构建参数 -// const { params, modelId, capabilities } = await buildStreamTextParams(undefined, assistant, provider, { -// requestOptions: { -// signal -// } -// }) - -// const options: ModernAiProviderConfig = { -// assistant, -// streamOutput: stream, -// enableReasoning, -// model: assistant.model, -// provider: provider -// } - -// const AI = new ModernAiProvider(model, provider) - -// const result = (await AI.completions(modelId, params, options)).getText().trim() -// return result -// } - /** * 翻译文本到目标语言 * @param text - 需要翻译的文本内容 @@ -81,58 +35,55 @@ export const translateText = async ( abortKey?: string ) => { let abortError - try { - const assistant = getDefaultTranslateAssistant(targetLanguage, text) + const assistant = getDefaultTranslateAssistant(targetLanguage, text) - const signal = abortKey ? readyToAbort(abortKey) : undefined + const signal = abortKey ? readyToAbort(abortKey) : undefined - let translatedText = '' - let completed = false - const onChunk = (chunk: Chunk) => { - if (chunk.type === ChunkType.TEXT_DELTA) { - translatedText = chunk.text - } else if (chunk.type === ChunkType.TEXT_COMPLETE) { + let translatedText = '' + let completed = false + const onChunk = (chunk: Chunk) => { + if (chunk.type === ChunkType.TEXT_DELTA) { + translatedText = chunk.text + } else if (chunk.type === ChunkType.TEXT_COMPLETE) { + completed = true + } else if (chunk.type === ChunkType.ERROR) { + if (isAbortError(chunk.error)) { + abortError = chunk.error completed = true - } else if (chunk.type === ChunkType.ERROR) { - if (isAbortError(chunk.error)) { - abortError = chunk.error - completed = true - } } - onResponse?.(translatedText, completed) } + onResponse?.(translatedText, completed) + } - const options = { - signal - } satisfies FetchChatCompletionOptions + const options = { + signal + } satisfies FetchChatCompletionOptions + try { await fetchChatCompletion({ prompt: assistant.content, assistant, options, onChunkReceived: onChunk }) - - const trimmedText = translatedText.trim() - - if (!trimmedText) { - return Promise.reject(new Error(t('translate.error.empty'))) - } - - return trimmedText } catch (e) { - if (isAbortError(e)) { - window.message.info(t('translate.info.aborted')) - throw e - } else if (isAbortError(abortError)) { - window.message.info(t('translate.info.aborted')) - throw abortError - } else { - logger.error('Failed to translate', e as Error) - window.message.error(t('translate.error.failed' + ': ' + formatErrorMessage(e))) + // dismiss no output generated error. it will be thrown when aborted. + if (!NoOutputGeneratedError.isInstance(e)) { throw e } } + + if (abortError) { + throw abortError + } + + const trimmedText = translatedText.trim() + + if (!trimmedText) { + return Promise.reject(new Error(t('translate.error.empty'))) + } + + return trimmedText } /** diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 56cfcd19f9..2710ca7a4a 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 150, + version: 152, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 6ff56ec83e..6485a7a2aa 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2428,6 +2428,17 @@ const migrateConfig = { logger.error('migrate 151 error', error as Error) return state } + }, + '152': (state: RootState) => { + try { + state.translate.settings = { + autoCopy: false + } + return state + } catch (error) { + logger.error('migrate 152 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/translate.ts b/src/renderer/src/store/translate.ts index 1eada6ac7c..999f7f6580 100644 --- a/src/renderer/src/store/translate.ts +++ b/src/renderer/src/store/translate.ts @@ -3,12 +3,19 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' export interface TranslateState { translateInput: string translatedContent: string + // TODO: #9749 + settings: { + autoCopy: boolean + } } const initialState: TranslateState = { translateInput: '', - translatedContent: '' -} + translatedContent: '', + settings: { + autoCopy: false + } +} as const const translateSlice = createSlice({ name: 'translate', @@ -19,10 +26,14 @@ const translateSlice = createSlice({ }, setTranslatedContent: (state, action: PayloadAction) => { state.translatedContent = action.payload + }, + updateSettings: (state, action: PayloadAction>) => { + const update = action.payload + Object.assign(state.settings, update) } } }) -export const { setTranslateInput, setTranslatedContent } = translateSlice.actions +export const { setTranslateInput, setTranslatedContent, updateSettings } = translateSlice.actions export default translateSlice.reducer