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 <action@github.com>
This commit is contained in:
Phantom 2025-09-09 00:49:20 +08:00 committed by GitHub
parent f6ffd574bf
commit 11502edad2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 262 additions and 113 deletions

View File

@ -28,6 +28,9 @@
"source.organizeImports": "never" "source.organizeImports": "never"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"files.associations": {
"*.css": "tailwindcss"
},
"files.eol": "\n", "files.eol": "\n",
"i18n-ally.displayLanguage": "zh-cn", "i18n-ally.displayLanguage": "zh-cn",
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"], "i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],

View File

@ -51,6 +51,13 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// Set initial theme and OS attributes on body // Set initial theme and OS attributes on body
document.body.setAttribute('os', isMac ? 'mac' : isWin ? 'windows' : 'linux') document.body.setAttribute('os', isMac ? 'mac' : isWin ? 'windows' : 'linux')
document.body.setAttribute('theme-mode', actualTheme) 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) document.body.setAttribute('navbar-position', navbarPosition)
// if theme is old auto, then set theme to system // if theme is old auto, then set theme to system

View File

@ -1,10 +1,12 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { builtinLanguages, UNKNOWN } from '@renderer/config/translate' import { builtinLanguages, UNKNOWN } from '@renderer/config/translate'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { TranslateState, updateSettings } from '@renderer/store/translate'
import { TranslateLanguage } from '@renderer/types' import { TranslateLanguage } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils' import { runAsyncFunction } from '@renderer/utils'
import { getTranslateOptions } from '@renderer/utils/translate' import { getTranslateOptions } from '@renderer/utils/translate'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
const logger = loggerService.withContext('useTranslate') const logger = loggerService.withContext('useTranslate')
@ -17,9 +19,12 @@ const logger = loggerService.withContext('useTranslate')
*/ */
export default function useTranslate() { export default function useTranslate() {
const prompt = useAppSelector((state) => state.settings.translateModelPrompt) const prompt = useAppSelector((state) => state.settings.translateModelPrompt)
const settings = useAppSelector((state) => state.translate.settings)
const [translateLanguages, setTranslateLanguages] = useState<TranslateLanguage[]>(builtinLanguages) const [translateLanguages, setTranslateLanguages] = useState<TranslateLanguage[]>(builtinLanguages)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const dispatch = useDispatch()
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => { runAsyncFunction(async () => {
const options = await getTranslateOptions() const options = await getTranslateOptions()
@ -46,9 +51,18 @@ export default function useTranslate() {
[isLoaded, translateLanguages] [isLoaded, translateLanguages]
) )
const handleUpdateSettings = useCallback(
(update: Partial<TranslateState['settings']>) => {
dispatch(updateSettings(update))
},
[dispatch]
)
return { return {
prompt, prompt,
settings,
translateLanguages, translateLanguages,
getLanguageByLangcode getLanguageByLangcode,
updateSettings: handleUpdateSettings
} }
} }

View File

@ -11,6 +11,8 @@ export default function useUserTheme() {
const colorPrimary = Color(theme.colorPrimary) const colorPrimary = Color(theme.colorPrimary)
document.body.style.setProperty('--color-primary', colorPrimary.toString()) 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-soft', colorPrimary.alpha(0.6).toString())
document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString()) document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString())
} }

View File

@ -4301,6 +4301,7 @@
}, },
"processing": "Translation in progress...", "processing": "Translation in progress...",
"settings": { "settings": {
"autoCopy": "Copy after translation ",
"bidirectional": "Bidirectional Translation Settings", "bidirectional": "Bidirectional Translation Settings",
"bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported", "bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported",
"model": "Model Settings", "model": "Model Settings",

View File

@ -4301,6 +4301,7 @@
}, },
"processing": "翻译中...", "processing": "翻译中...",
"settings": { "settings": {
"autoCopy": "翻译完成后自动复制",
"bidirectional": "双向翻译设置", "bidirectional": "双向翻译设置",
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译", "bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
"model": "模型设置", "model": "模型设置",

View File

@ -4301,6 +4301,7 @@
}, },
"processing": "翻譯中...", "processing": "翻譯中...",
"settings": { "settings": {
"autoCopy": "翻譯完成後自動複製",
"bidirectional": "雙向翻譯設定", "bidirectional": "雙向翻譯設定",
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯", "bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
"model": "模型設定", "model": "模型設定",

View File

@ -2221,6 +2221,21 @@
} }
}, },
"dragHandle": "στοιχεία σύρεσης", "dragHandle": "στοιχεία σύρεσης",
"frontMatter": {
"addProperty": "Προσθήκη χαρακτηριστικού",
"addTag": "Προσθήκη ετικέτας",
"changeToBoolean": "Πλαίσιο ελέγχου",
"changeToDate": "Ημερομηνία",
"changeToNumber": "ψηφίο",
"changeToTags": "ετικέτα",
"changeToText": "κείμενο",
"changeType": "Αλλαγή τύπου",
"deleteProperty": "διαγραφή χαρακτηριστικού",
"editValue": "Επεξεργασία τιμής",
"empty": "<translate_input>\n空\n</translate_input>",
"moreActions": "Περισσότερες ενέργειες",
"propertyName": "όνομα χαρακτηριστικού"
},
"image": { "image": {
"placeholder": "προσθήκη εικόνας" "placeholder": "προσθήκη εικόνας"
}, },
@ -3681,15 +3696,19 @@
"apikey": "Κλειδί API", "apikey": "Κλειδί API",
"auth_failed": "Αποτυχία πιστοποίησης ταυτότητας Anthropic", "auth_failed": "Αποτυχία πιστοποίησης ταυτότητας Anthropic",
"auth_method": "Τρόπος πιστοποίησης", "auth_method": "Τρόπος πιστοποίησης",
"auth_success": "Η πιστοποίηση OAuth της Anthropic ήταν επιτυχής",
"authenticated": "Επαληθευμένο", "authenticated": "Επαληθευμένο",
"authenticating": "Επαλήθευση σε εξέλιξη", "authenticating": "Επαλήθευση σε εξέλιξη",
"cancel": "Ακύρωση", "cancel": "Ακύρωση",
"code_error": "Μη έγκυρος κωδικός εξουσιοδότησης, δοκιμάστε ξανά",
"code_placeholder": "Παρακαλώ εισαγάγετε τον κωδικό εξουσιοδότησης που εμφανίζεται στον περιηγητή σας", "code_placeholder": "Παρακαλώ εισαγάγετε τον κωδικό εξουσιοδότησης που εμφανίζεται στον περιηγητή σας",
"code_required": "Ο κωδικός εξουσιοδότησης δεν μπορεί να είναι κενός", "code_required": "Ο κωδικός εξουσιοδότησης δεν μπορεί να είναι κενός",
"description": "Πιστοποίηση OAuth", "description": "Πιστοποίηση OAuth",
"description_detail": "Για να χρησιμοποιήσετε αυτόν τον τρόπο επαλήθευσης, πρέπει να είστε συνδρομητής Claude Pro ή έκδοσης μεγαλύτερης από αυτήν", "description_detail": "Για να χρησιμοποιήσετε αυτόν τον τρόπο επαλήθευσης, πρέπει να είστε συνδρομητής Claude Pro ή έκδοσης μεγαλύτερης από αυτήν",
"enter_auth_code": "κωδικός εξουσιοδότησης", "enter_auth_code": "κωδικός εξουσιοδότησης",
"logout": "Αποσύνδεση λογαριασμού", "logout": "Αποσύνδεση λογαριασμού",
"logout_failed": "Η αποσύνδεση απέτυχε, δοκιμάστε ξανά.",
"logout_success": "Επιτυχής αποσύνδεση από το Anthropic",
"oauth": "ιστοσελίδα OAuth", "oauth": "ιστοσελίδα OAuth",
"start_auth": "Έναρξη εξουσιοδότησης", "start_auth": "Έναρξη εξουσιοδότησης",
"submit_code": "Ολοκληρώστε την σύνδεση" "submit_code": "Ολοκληρώστε την σύνδεση"
@ -4278,6 +4297,7 @@
}, },
"processing": "Μεταφράζεται...", "processing": "Μεταφράζεται...",
"settings": { "settings": {
"autoCopy": "Μετά τη μετάφραση, αντιγράφεται αυτόματα",
"bidirectional": "Ρύθμιση διπλής κατεύθυνσης μετάφρασης", "bidirectional": "Ρύθμιση διπλής κατεύθυνσης μετάφρασης",
"bidirectional_tip": "Όταν ενεργοποιηθεί, υποστηρίζεται μόνο διπλής κατεύθυνσης μετάφραση μεταξύ της πηγαίας και της στόχου γλώσσας", "bidirectional_tip": "Όταν ενεργοποιηθεί, υποστηρίζεται μόνο διπλής κατεύθυνσης μετάφραση μεταξύ της πηγαίας και της στόχου γλώσσας",
"model": "Ρύθμιση μοντέλου", "model": "Ρύθμιση μοντέλου",

View File

@ -2221,6 +2221,21 @@
} }
}, },
"dragHandle": "bloque de arrastre", "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": { "image": {
"placeholder": "añadir imágenes" "placeholder": "añadir imágenes"
}, },
@ -3681,15 +3696,19 @@
"apikey": "Clave de API", "apikey": "Clave de API",
"auth_failed": "Error de autenticación de Anthropic", "auth_failed": "Error de autenticación de Anthropic",
"auth_method": "Método de autenticación", "auth_method": "Método de autenticación",
"auth_success": "Autenticación OAuth de Anthropic exitosa",
"authenticated": "Verificado", "authenticated": "Verificado",
"authenticating": "Autenticando", "authenticating": "Autenticando",
"cancel": "Cancelar", "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_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", "code_required": "El código de autorización no puede estar vacío",
"description": "Autenticación OAuth", "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", "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", "enter_auth_code": "Código de autorización",
"logout": "Cerrar sesió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", "oauth": "Web OAuth",
"start_auth": "Comenzar autorización", "start_auth": "Comenzar autorización",
"submit_code": "Iniciar sesión completado" "submit_code": "Iniciar sesión completado"
@ -4278,6 +4297,7 @@
}, },
"processing": "Traduciendo...", "processing": "Traduciendo...",
"settings": { "settings": {
"autoCopy": "Copiar automáticamente después de completar la traducción",
"bidirectional": "Configuración de traducción bidireccional", "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", "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", "model": "Configuración del modelo",

View File

@ -2221,6 +2221,21 @@
} }
}, },
"dragHandle": "bloc de glisser-déposer", "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": { "image": {
"placeholder": "ajouter une image" "placeholder": "ajouter une image"
}, },
@ -3681,15 +3696,19 @@
"apikey": "Clé API", "apikey": "Clé API",
"auth_failed": "Échec de l'authentification Anthropic", "auth_failed": "Échec de l'authentification Anthropic",
"auth_method": "Mode d'authentification", "auth_method": "Mode d'authentification",
"auth_success": "Authentification OAuth Anthropic réussie",
"authenticated": "Certifié", "authenticated": "Certifié",
"authenticating": "Authentification en cours", "authenticating": "Authentification en cours",
"cancel": "Annuler", "cancel": "Annuler",
"code_error": "Code d'autorisation invalide, veuillez réessayer",
"code_placeholder": "Veuillez saisir le code d'autorisation affiché dans le navigateur", "code_placeholder": "Veuillez saisir le code d'autorisation affiché dans le navigateur",
"code_required": "Le code d'autorisation ne peut pas être vide", "code_required": "Le code d'autorisation ne peut pas être vide",
"description": "Authentification OAuth", "description": "Authentification OAuth",
"description_detail": "Vous devez souscrire à Claude Pro ou à une version supérieure pour pouvoir utiliser cette méthode d'authentification.", "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", "enter_auth_code": "code d'autorisation",
"logout": "Déconnexion", "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", "oauth": "Authentification OAuth web",
"start_auth": "Commencer l'autorisation", "start_auth": "Commencer l'autorisation",
"submit_code": "Terminer la connexion" "submit_code": "Terminer la connexion"
@ -4278,6 +4297,7 @@
}, },
"processing": "en cours de traduction...", "processing": "en cours de traduction...",
"settings": { "settings": {
"autoCopy": "Copié automatiquement après la traduction",
"bidirectional": "Paramètres de traduction bidirectionnelle", "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", "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", "model": "Paramètres du modèle",

View File

@ -2221,6 +2221,21 @@
} }
}, },
"dragHandle": "ブロックをドラッグします", "dragHandle": "ブロックをドラッグします",
"frontMatter": {
"addProperty": "属性を追加",
"addTag": "タグを追加",
"changeToBoolean": "チェックボックス",
"changeToDate": "日付",
"changeToNumber": "数字",
"changeToTags": "タグ",
"changeToText": "テキスト",
"changeType": "種類を変更",
"deleteProperty": "削除属性",
"editValue": "編集値",
"empty": "空",
"moreActions": "その他の操作",
"propertyName": "プロパティ名"
},
"image": { "image": {
"placeholder": "写真を追加します" "placeholder": "写真を追加します"
}, },
@ -4282,6 +4297,7 @@
}, },
"processing": "翻訳中...", "processing": "翻訳中...",
"settings": { "settings": {
"autoCopy": "翻訳完了後、自動的にコピー",
"bidirectional": "双方向翻訳設定", "bidirectional": "双方向翻訳設定",
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます", "bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
"model": "モデル設定", "model": "モデル設定",

View File

@ -2221,6 +2221,21 @@
} }
}, },
"dragHandle": "bloco de arrastar", "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": { "image": {
"placeholder": "adicionar imagem" "placeholder": "adicionar imagem"
}, },
@ -3681,15 +3696,19 @@
"apikey": "Chave da API", "apikey": "Chave da API",
"auth_failed": "Falha na autenticação da Anthropic", "auth_failed": "Falha na autenticação da Anthropic",
"auth_method": "Método de autenticação", "auth_method": "Método de autenticação",
"auth_success": "Autenticação OAuth da Anthropic bem-sucedida",
"authenticated": "[retranslating]: Verificado", "authenticated": "[retranslating]: Verificado",
"authenticating": "A autenticar", "authenticating": "A autenticar",
"cancel": "Cancelar", "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_placeholder": "Introduza o código de autorização exibido no browser",
"code_required": "O código de autorização não pode estar vazio", "code_required": "O código de autorização não pode estar vazio",
"description": "Autenticação OAuth", "description": "Autenticação OAuth",
"description_detail": "Precisa de uma subscrição Claude Pro ou superior para utilizar este método de autenticação", "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", "enter_auth_code": "Código de autorização",
"logout": "Sair da sessã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", "oauth": "OAuth da Página Web",
"start_auth": "Iniciar autorização", "start_auth": "Iniciar autorização",
"submit_code": "Concluir login" "submit_code": "Concluir login"
@ -4278,6 +4297,7 @@
}, },
"processing": "Traduzindo...", "processing": "Traduzindo...",
"settings": { "settings": {
"autoCopy": "Cópia automática após a tradução",
"bidirectional": "Configuração de Tradução Bidirecional", "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", "bidirectional_tip": "Quando ativado, suporta apenas tradução bidirecional entre o idioma de origem e o idioma de destino",
"model": "Configuração de Modelo", "model": "Configuração de Modelo",

View File

@ -2221,6 +2221,21 @@
} }
}, },
"dragHandle": "Перетащить блок", "dragHandle": "Перетащить блок",
"frontMatter": {
"addProperty": "Добавить атрибут",
"addTag": "Добавить метки",
"changeToBoolean": "Флажок",
"changeToDate": "Дата",
"changeToNumber": "цифры",
"changeToTags": "ярлык",
"changeToText": "текст",
"changeType": "Изменить тип",
"deleteProperty": "Удалить атрибут",
"editValue": "Редактировать значение",
"empty": "Пусто",
"moreActions": "Дополнительные действия",
"propertyName": "Имя атрибута"
},
"image": { "image": {
"placeholder": "Добавить картинку" "placeholder": "Добавить картинку"
}, },
@ -4282,6 +4297,7 @@
}, },
"processing": "Перевод в процессе...", "processing": "Перевод в процессе...",
"settings": { "settings": {
"autoCopy": "Автоматически копировать после завершения перевода",
"bidirectional": "Настройки двунаправленного перевода", "bidirectional": "Настройки двунаправленного перевода",
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот.", "bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот.",
"model": "Настройки модели", "model": "Настройки модели",

View File

@ -13,6 +13,7 @@ import { useDrag } from '@renderer/hooks/useDrag'
import { useFiles } from '@renderer/hooks/useFiles' import { useFiles } from '@renderer/hooks/useFiles'
import { useOcr } from '@renderer/hooks/useOcr' import { useOcr } from '@renderer/hooks/useOcr'
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
import { useTimer } from '@renderer/hooks/useTimer'
import useTranslate from '@renderer/hooks/useTranslate' import useTranslate from '@renderer/hooks/useTranslate'
import { estimateTextTokens } from '@renderer/services/TokenService' import { estimateTextTokens } from '@renderer/services/TokenService'
import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService' import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService'
@ -60,10 +61,12 @@ const TranslatePage: FC = () => {
// hooks // hooks
const { t } = useTranslation() const { t } = useTranslation()
const { translateModel, setTranslateModel } = useDefaultModel() const { translateModel, setTranslateModel } = useDefaultModel()
const { prompt, getLanguageByLangcode } = useTranslate() const { prompt, getLanguageByLangcode, settings } = useTranslate()
const { autoCopy } = settings
const { shikiMarkdownIt } = useCodeStyle() const { shikiMarkdownIt } = useCodeStyle()
const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] }) const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] })
const { ocr } = useOcr() const { ocr } = useOcr()
const { setTimeoutTimer } = useTimer()
// states // states
// const [text, setText] = useState(_text) // const [text, setText] = useState(_text)
@ -129,6 +132,18 @@ const TranslatePage: FC = () => {
[dispatch] [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 - * @param text -
@ -153,7 +168,9 @@ const TranslatePage: FC = () => {
try { try {
translated = await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100), abortKey) translated = await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100), abortKey)
} catch (e) { } 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) logger.error('Failed to translate text', e as Error)
window.message.error(t('translate.error.failed') + ': ' + formatErrorMessage(e)) window.message.error(t('translate.error.failed') + ': ' + formatErrorMessage(e))
} }
@ -162,6 +179,15 @@ const TranslatePage: FC = () => {
} }
window.message.success(t('translate.complete')) window.message.success(t('translate.complete'))
if (autoCopy) {
setTimeoutTimer(
'auto-copy',
async () => {
await onCopy()
},
100
)
}
try { try {
await saveTranslateHistory(text, translated, actualSourceLanguage.langCode, actualTargetLanguage.langCode) await saveTranslateHistory(text, translated, actualSourceLanguage.langCode, actualTargetLanguage.langCode)
@ -174,7 +200,7 @@ const TranslatePage: FC = () => {
window.message.error(t('translate.error.unknown') + ': ' + formatErrorMessage(e)) 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) await translate(text, actualSourceLanguage, actualTargetLanguage)
} catch (error) { } catch (error) {
logger.error('Translation error:', error as Error) logger.error('Translation error:', error as Error)
window.message.error({ window.message.error(t('translate.error.failed') + ': ' + formatErrorMessage(error))
content: String(error),
key: 'translate-message'
})
return return
} finally { } finally {
setTranslating(false) setTranslating(false)
@ -274,12 +297,6 @@ const TranslatePage: FC = () => {
db.settings.put({ id: 'translate:bidirectional:enabled', value }) db.settings.put({ id: 'translate:bidirectional:enabled', value })
} }
// 控制复制按钮
const onCopy = () => {
navigator.clipboard.writeText(translatedContent)
setCopied(true)
}
// 控制历史记录点击 // 控制历史记录点击
const onHistoryItemClick = ( const onHistoryItemClick = (
history: TranslateHistory & { _sourceLanguage: TranslateLanguage; _targetLanguage: TranslateLanguage } history: TranslateHistory & { _sourceLanguage: TranslateLanguage; _targetLanguage: TranslateLanguage }

View File

@ -1,15 +1,17 @@
import { Switch } from '@heroui/react'
import LanguageSelect from '@renderer/components/LanguageSelect' import LanguageSelect from '@renderer/components/LanguageSelect'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import db from '@renderer/databases' import db from '@renderer/databases'
import useTranslate from '@renderer/hooks/useTranslate' import useTranslate from '@renderer/hooks/useTranslate'
import { AutoDetectionMethod, Model, TranslateLanguage } from '@renderer/types' 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 { HelpCircle } from 'lucide-react'
import { FC, memo, useEffect, useState } from 'react' import { FC, memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import TranslateSettingsPopup from '../settings/TranslateSettingsPopup/TranslateSettingsPopup' import TranslateSettingsPopup from '../settings/TranslateSettingsPopup/TranslateSettingsPopup'
// TODO: Just don't send so many props. Migrate them to redux.
const TranslateSettings: FC<{ const TranslateSettings: FC<{
visible: boolean visible: boolean
onClose: () => void onClose: () => void
@ -40,7 +42,8 @@ const TranslateSettings: FC<{
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [localPair, setLocalPair] = useState<[TranslateLanguage, TranslateLanguage]>(bidirectionalPair) const [localPair, setLocalPair] = useState<[TranslateLanguage, TranslateLanguage]>(bidirectionalPair)
const { getLanguageByLangcode } = useTranslate() const { getLanguageByLangcode, settings, updateSettings } = useTranslate()
const { autoCopy } = settings
useEffect(() => { useEffect(() => {
setLocalPair(bidirectionalPair) setLocalPair(bidirectionalPair)
@ -58,15 +61,15 @@ const TranslateSettings: FC<{
onCancel={onClose} onCancel={onClose}
centered={true} centered={true}
footer={null} footer={null}
width={420} width={520}
transitionName="animation-move-down"> transitionName="animation-move-down">
<Flex vertical gap={16} style={{ marginTop: 16, paddingBottom: 20 }}> <Flex vertical gap={16} style={{ marginTop: 16, paddingBottom: 20 }}>
<div> <div>
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<div style={{ fontWeight: 500 }}>{t('translate.settings.preview')}</div> <div style={{ fontWeight: 500 }}>{t('translate.settings.preview')}</div>
<Switch <Switch
checked={enableMarkdown} isSelected={enableMarkdown}
onChange={(checked) => { onValueChange={(checked) => {
setEnableMarkdown(checked) setEnableMarkdown(checked)
db.settings.put({ id: 'translate:markdown:enabled', value: checked }) db.settings.put({ id: 'translate:markdown:enabled', value: checked })
}} }}
@ -74,14 +77,28 @@ const TranslateSettings: FC<{
</Flex> </Flex>
</div> </div>
<div>
<HStack alignItems="center" justifyContent="space-between">
<div style={{ fontWeight: 500 }}>{t('translate.settings.autoCopy')}</div>
<Switch
isSelected={autoCopy}
color="primary"
onValueChange={(isSelected) => {
updateSettings({ autoCopy: isSelected })
}}
/>
</HStack>
</div>
<div> <div>
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<div style={{ fontWeight: 500 }}>{t('translate.settings.scroll_sync')}</div> <div style={{ fontWeight: 500 }}>{t('translate.settings.scroll_sync')}</div>
<Switch <Switch
checked={isScrollSyncEnabled} isSelected={isScrollSyncEnabled}
onChange={(checked) => { color="primary"
setIsScrollSyncEnabled(checked) onValueChange={(isSelected) => {
db.settings.put({ id: 'translate:scroll:sync', value: checked }) setIsScrollSyncEnabled(isSelected)
db.settings.put({ id: 'translate:scroll:sync', value: isSelected })
}} }}
/> />
</Flex> </Flex>
@ -131,9 +148,10 @@ const TranslateSettings: FC<{
</HStack> </HStack>
</div> </div>
<Switch <Switch
checked={isBidirectional} isSelected={isBidirectional}
onChange={(checked) => { color="primary"
setIsBidirectional(checked) onValueChange={(isSelected) => {
setIsBidirectional(isSelected)
// 双向翻译设置不需要持久化,它只是界面状态 // 双向翻译设置不需要持久化,它只是界面状态
}} }}
/> />

View File

@ -10,7 +10,8 @@ import {
import { Chunk, ChunkType } from '@renderer/types/chunk' import { Chunk, ChunkType } from '@renderer/types/chunk'
import { uuid } from '@renderer/utils' import { uuid } from '@renderer/utils'
import { readyToAbort } from '@renderer/utils/abortController' 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 { t } from 'i18next'
import { fetchChatCompletion } from './ApiService' import { fetchChatCompletion } from './ApiService'
@ -18,53 +19,6 @@ import { getDefaultTranslateAssistant } from './AssistantService'
const logger = loggerService.withContext('TranslateService') 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 - * @param text -
@ -81,58 +35,55 @@ export const translateText = async (
abortKey?: string abortKey?: string
) => { ) => {
let abortError 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 translatedText = ''
let completed = false let completed = false
const onChunk = (chunk: Chunk) => { const onChunk = (chunk: Chunk) => {
if (chunk.type === ChunkType.TEXT_DELTA) { if (chunk.type === ChunkType.TEXT_DELTA) {
translatedText = chunk.text translatedText = chunk.text
} else if (chunk.type === ChunkType.TEXT_COMPLETE) { } else if (chunk.type === ChunkType.TEXT_COMPLETE) {
completed = true
} else if (chunk.type === ChunkType.ERROR) {
if (isAbortError(chunk.error)) {
abortError = chunk.error
completed = true 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 = { const options = {
signal signal
} satisfies FetchChatCompletionOptions } satisfies FetchChatCompletionOptions
try {
await fetchChatCompletion({ await fetchChatCompletion({
prompt: assistant.content, prompt: assistant.content,
assistant, assistant,
options, options,
onChunkReceived: onChunk onChunkReceived: onChunk
}) })
const trimmedText = translatedText.trim()
if (!trimmedText) {
return Promise.reject(new Error(t('translate.error.empty')))
}
return trimmedText
} catch (e) { } catch (e) {
if (isAbortError(e)) { // dismiss no output generated error. it will be thrown when aborted.
window.message.info(t('translate.info.aborted')) if (!NoOutputGeneratedError.isInstance(e)) {
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)))
throw e throw e
} }
} }
if (abortError) {
throw abortError
}
const trimmedText = translatedText.trim()
if (!trimmedText) {
return Promise.reject(new Error(t('translate.error.empty')))
}
return trimmedText
} }
/** /**

View File

@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{ {
key: 'cherry-studio', key: 'cherry-studio',
storage, storage,
version: 150, version: 152,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate migrate
}, },

View File

@ -2428,6 +2428,17 @@ const migrateConfig = {
logger.error('migrate 151 error', error as Error) logger.error('migrate 151 error', error as Error)
return state 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
}
} }
} }

View File

@ -3,12 +3,19 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface TranslateState { export interface TranslateState {
translateInput: string translateInput: string
translatedContent: string translatedContent: string
// TODO: #9749
settings: {
autoCopy: boolean
}
} }
const initialState: TranslateState = { const initialState: TranslateState = {
translateInput: '', translateInput: '',
translatedContent: '' translatedContent: '',
} settings: {
autoCopy: false
}
} as const
const translateSlice = createSlice({ const translateSlice = createSlice({
name: 'translate', name: 'translate',
@ -19,10 +26,14 @@ const translateSlice = createSlice({
}, },
setTranslatedContent: (state, action: PayloadAction<string>) => { setTranslatedContent: (state, action: PayloadAction<string>) => {
state.translatedContent = action.payload state.translatedContent = action.payload
},
updateSettings: (state, action: PayloadAction<Partial<TranslateState['settings']>>) => {
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 export default translateSlice.reducer