refactor: simplify translate action by removing dual language selector (#12343)

* feat: enhance ActionTranslate component with language detection and settings dropdown

- Added state management for detected and actual target languages.
- Implemented a settings dropdown for selecting preferred and alternate languages.
- Updated UI to display detected language and integrated new settings for language selection.
- Refactored language change handling to improve user experience and maintain state consistency.

* refactor(ActionTranslate): reorganize layout and improve component structure

- Introduced LeftGroup for better alignment of detected language, target language selector, and settings dropdown.
- Removed unnecessary Spacer component to streamline the layout.
- Enhanced styling for improved visual consistency and user experience.

* fix(ActionTranslate): simplify loading state display for detected language

- Removed loading spinner and adjusted the display for the detected language during the detection process to enhance clarity and reduce visual clutter.
This commit is contained in:
亢奋猫 2026-01-25 13:38:13 +08:00 committed by GitHub
parent f9f550cecd
commit 78c6f97248
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 247 additions and 42 deletions

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "Auto Detect"
},
"detected_source": "Detected",
"detecting": "Detecting...",
"empty": "Translation content is empty",
"error": {
"chat_qwen_mt": "Qwen MT model cannot be used in chat. Please go to the translation page.",
@ -5260,6 +5262,7 @@
"not_pair": "Source language is different from the set language",
"same": "Source and target languages are the same"
},
"language_settings": "Language Settings",
"menu": {
"description": "Translate the content of the current input box"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "Translation"
},
"preferred_target": "Preferred Target",
"processing": "Translation in progress...",
"settings": {
"autoCopy": "Copy after translation ",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "自动检测"
},
"detected_source": "检测到",
"detecting": "检测中...",
"empty": "翻译内容为空",
"error": {
"chat_qwen_mt": "Qwen MT 模型不可在对话中使用,请转至翻译页面",
@ -5260,6 +5262,7 @@
"not_pair": "源语言与设置的语言不同",
"same": "源语言和目标语言相同"
},
"language_settings": "语言设置",
"menu": {
"description": "对当前输入框内容进行翻译"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "翻译"
},
"preferred_target": "首选目标",
"processing": "翻译中...",
"settings": {
"autoCopy": "翻译完成后自动复制",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "自動偵測"
},
"detected_source": "偵測到",
"detecting": "偵測中...",
"empty": "翻譯內容為空",
"error": {
"chat_qwen_mt": "Qwen MT 模型無法在對話中使用,請前往翻譯頁面",
@ -5260,6 +5262,7 @@
"not_pair": "來源語言與設定的語言不同",
"same": "來源語言和目標語言相同"
},
"language_settings": "語言設定",
"menu": {
"description": "對目前輸入框內容進行翻譯"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "翻譯"
},
"preferred_target": "首選目標",
"processing": "翻譯中...",
"settings": {
"autoCopy": "翻譯完成後自動複製",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "Automatische Erkennung"
},
"detected_source": "Erfasst",
"detecting": "Erkenne...",
"empty": "Übersetzungsinhalt leer",
"error": {
"chat_qwen_mt": "Qwen MT-Modell kann nicht in der Konversation verwendet werden, bitte gehen Sie zur Übersetzungsseite",
@ -5260,6 +5262,7 @@
"not_pair": "Quellsprache unterscheidet sich von eingestellter Sprache",
"same": "Quell- und Zielsprache sind identisch"
},
"language_settings": "Spracheinstellungen",
"menu": {
"description": "Inhalt des aktuellen Eingabefelds übersetzen"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "Übersetzen"
},
"preferred_target": "Bevorzugtes Ziel",
"processing": "Wird übersetzt...",
"settings": {
"autoCopy": "Nach Übersetzung automatisch kopieren",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "Αυτόματη ανίχνευση"
},
"detected_source": "Εντοπίστηκε",
"detecting": "Ανίχνευση...",
"empty": "Το μεταφρασμένο κείμενο είναι κενό",
"error": {
"chat_qwen_mt": "Τα μοντέλα Qwen MT δεν είναι διαθέσιμα για χρήση σε διαλόγους, παρακαλώ μεταβείτε στη σελίδα μετάφρασης",
@ -5260,6 +5262,7 @@
"not_pair": "Η γλώσσα πηγής διαφέρει από την οριζόμενη γλώσσα",
"same": "Η γλώσσα πηγής και η γλώσσα προορισμού είναι ίδιες"
},
"language_settings": "Ρυθμίσεις Γλώσσας",
"menu": {
"description": "Μεταφράστε το περιεχόμενο του τρέχοντος πεδίου εισαγωγής"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "Μετάφραση"
},
"preferred_target": "Προτιμώμενος Στόχος",
"processing": "Μεταφράζεται...",
"settings": {
"autoCopy": "Μετά τη μετάφραση, αντιγράφεται αυτόματα",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "Detección automática"
},
"detected_source": "Detectado",
"detecting": "Detectando...",
"empty": "El contenido de traducción está vacío",
"error": {
"chat_qwen_mt": "El modelo Qwen MT no está disponible para uso en conversaciones, por favor vaya a la página de traducción.",
@ -5260,6 +5262,7 @@
"not_pair": "El idioma de origen es diferente al idioma configurado",
"same": "El idioma de origen y el idioma de destino son iguales"
},
"language_settings": "Configuración de idioma",
"menu": {
"description": "Traducir el contenido del campo de entrada actual"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "Traducción"
},
"preferred_target": "Objetivo Preferido",
"processing": "Traduciendo...",
"settings": {
"autoCopy": "Copiar automáticamente después de completar la traducción",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "Détection automatique"
},
"detected_source": "Détecté",
"detecting": "Détection...",
"empty": "Le contenu à traduire est vide",
"error": {
"chat_qwen_mt": "Les modèles Qwen MT ne peuvent pas être utilisés dans les conversations, veuillez vous rendre sur la page de traduction.",
@ -5260,6 +5262,7 @@
"not_pair": "La langue source est différente de la langue définie",
"same": "La langue source et la langue cible sont identiques"
},
"language_settings": "Paramètres de langue",
"menu": {
"description": "Traduire le contenu de la zone de saisie actuelle"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "traduction"
},
"preferred_target": "Cible préférée",
"processing": "en cours de traduction...",
"settings": {
"autoCopy": "Copié automatiquement après la traduction",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "自動検出"
},
"detected_source": "検出されました",
"detecting": "検出中...",
"empty": "翻訳内容が空です",
"error": {
"chat_qwen_mt": "Qwen MT モデルは対話で使用できません。翻訳ページに移動してください",
@ -5260,6 +5262,7 @@
"not_pair": "ソース言語が設定された言語と異なります",
"same": "ソース言語と目標言語が同じです"
},
"language_settings": "言語設定",
"menu": {
"description": "對當前輸入框內容進行翻譯"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "翻訳"
},
"preferred_target": "優先ターゲット",
"processing": "翻訳中...",
"settings": {
"autoCopy": "翻訳完了後、自動的にコピー",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "Detecção automática"
},
"detected_source": "Detectado",
"detecting": "Detectando...",
"empty": "O conteúdo de tradução está vazio",
"error": {
"chat_qwen_mt": "Modelos Qwen MT não estão disponíveis para uso em conversas. Por favor, vá para a página de tradução.",
@ -5260,6 +5262,7 @@
"not_pair": "O idioma de origem é diferente do idioma definido",
"same": "O idioma de origem e o idioma de destino são iguais"
},
"language_settings": "Configurações de Idioma",
"menu": {
"description": "Traduzir o conteúdo da caixa de entrada atual"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "Tradução"
},
"preferred_target": "Alvo Preferencial",
"processing": "Traduzindo...",
"settings": {
"autoCopy": "Cópia automática após a tradução",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "Detectare automată"
},
"detected_source": "Detectat",
"detecting": "Se detectează...",
"empty": "Conținutul traducerii este gol",
"error": {
"chat_qwen_mt": "Modelul Qwen MT nu poate fi folosit în chat. Te rugăm să mergi la pagina de traducere.",
@ -5260,6 +5262,7 @@
"not_pair": "Limba sursă este diferită de limba setată",
"same": "Limbile sursă și țintă sunt aceleași"
},
"language_settings": "Setări limbă",
"menu": {
"description": "Tradu conținutul casetei de intrare curente"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "Traducere"
},
"preferred_target": "Țintă Preferată",
"processing": "Traducere în curs...",
"settings": {
"autoCopy": "Copiază după traducere ",

View File

@ -5208,6 +5208,8 @@
"detected": {
"language": "Автоматическое обнаружение"
},
"detected_source": "Обнаружено",
"detecting": "Обнаружение...",
"empty": "Содержимое перевода пусто",
"error": {
"chat_qwen_mt": "Модель Qwen MT недоступна для использования в диалоге, перейдите на страницу перевода",
@ -5260,6 +5262,7 @@
"not_pair": "Исходный язык отличается от настроенного",
"same": "Исходный и целевой языки совпадают"
},
"language_settings": "Языковые настройки",
"menu": {
"description": "Перевести содержимое текущего ввода"
},
@ -5269,6 +5272,7 @@
"output": {
"placeholder": "Перевод"
},
"preferred_target": "Предпочтительная цель",
"processing": "Перевод в процессе...",
"settings": {
"autoCopy": "Автоматически копировать после завершения перевода",

View File

@ -15,12 +15,12 @@ import { AssistantMessageStatus } from '@renderer/types/newMessage'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { abortCompletion } from '@renderer/utils/abortController'
import { detectLanguage } from '@renderer/utils/translate'
import { Tooltip } from 'antd'
import { ArrowRightFromLine, ArrowRightToLine, ChevronDown, CircleHelp, Globe } from 'lucide-react'
import { Dropdown, Tooltip } from 'antd'
import { ArrowRight, ChevronDown, CircleHelp, Settings2 } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import styled, { createGlobalStyle } from 'styled-components'
import { processMessages } from './ActionUtils'
import WindowFooter from './WindowFooter'
@ -47,12 +47,15 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
})
const [alterLanguage, setAlterLanguage] = useState<TranslateLanguage>(LanguagesEnum.enUS)
const [detectedLanguage, setDetectedLanguage] = useState<TranslateLanguage | null>(null)
const [actualTargetLanguage, setActualTargetLanguage] = useState<TranslateLanguage>(targetLanguage)
const [error, setError] = useState('')
const [showOriginal, setShowOriginal] = useState(false)
const [status, setStatus] = useState<'preparing' | 'streaming' | 'finished'>('preparing')
const [contentToCopy, setContentToCopy] = useState('')
const [initialized, setInitialized] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
// Use useRef for values that shouldn't trigger re-renders
const assistantRef = useRef<Assistant | null>(null)
@ -156,6 +159,10 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
return
}
// Set detected language for UI display
const detectedLang = getLanguageByLangcode(sourceLanguageCode)
setDetectedLanguage(detectedLang)
let translateLang: TranslateLanguage
if (sourceLanguageCode === UNKNOWN.langCode) {
@ -170,11 +177,14 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
}
}
// Set actual target language for UI display
setActualTargetLanguage(translateLang)
const assistant = getDefaultTranslateAssistant(translateLang, action.selectedText)
assistantRef.current = assistant
logger.debug('process once')
processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError)
}, [action, targetLanguage, alterLanguage, scrollToBottom, initialized])
}, [action, targetLanguage, alterLanguage, scrollToBottom, initialized, getLanguageByLangcode])
useEffect(() => {
fetchResult()
@ -213,16 +223,88 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const isPreparing = status === 'preparing'
const isStreaming = status === 'streaming'
const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => {
if (!initialized) {
return
}
setTargetLanguage(targetLanguage)
targetLangRef.current = targetLanguage
setAlterLanguage(alterLanguage)
const handleChangeLanguage = useCallback(
(newTargetLanguage: TranslateLanguage, newAlterLanguage: TranslateLanguage) => {
if (!initialized) {
return
}
setTargetLanguage(newTargetLanguage)
targetLangRef.current = newTargetLanguage
setAlterLanguage(newAlterLanguage)
db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage.langCode, alterLanguage.langCode] })
}
db.settings.put({
id: 'translate:bidirectional:pair',
value: [newTargetLanguage.langCode, newAlterLanguage.langCode]
})
},
[initialized]
)
// Handle direct target language change from the main dropdown
const handleDirectTargetChange = useCallback(
(langCode: TranslateLanguageCode) => {
if (!initialized) return
const newLang = getLanguageByLangcode(langCode)
setActualTargetLanguage(newLang)
// Update settings: if new target equals current target, keep as is
// Otherwise, swap if needed or just update target
if (newLang.langCode !== targetLanguage.langCode && newLang.langCode !== alterLanguage.langCode) {
// New language is different from both, update target
setTargetLanguage(newLang)
targetLangRef.current = newLang
db.settings.put({ id: 'translate:bidirectional:pair', value: [newLang.langCode, alterLanguage.langCode] })
}
},
[initialized, getLanguageByLangcode, targetLanguage.langCode, alterLanguage.langCode]
)
// Settings dropdown menu items
const settingsMenuItems = useMemo(
() => [
{
key: 'preferred',
label: (
<SettingsMenuItem>
<SettingsLabel>{t('translate.preferred_target')}</SettingsLabel>
<LanguageSelect
value={targetLanguage.langCode}
style={{ width: '100%' }}
listHeight={160}
size="small"
onClick={(e) => e.stopPropagation()}
onChange={(value) => {
handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)
setSettingsOpen(false)
}}
disabled={isStreaming}
/>
</SettingsMenuItem>
)
},
{
key: 'alter',
label: (
<SettingsMenuItem>
<SettingsLabel>{t('translate.alter_language')}</SettingsLabel>
<LanguageSelect
value={alterLanguage.langCode}
style={{ width: '100%' }}
listHeight={160}
size="small"
onClick={(e) => e.stopPropagation()}
onChange={(value) => {
handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))
setSettingsOpen(false)
}}
disabled={isStreaming}
/>
</SettingsMenuItem>
)
}
],
[t, targetLanguage, alterLanguage, isStreaming, getLanguageByLangcode, handleChangeLanguage]
)
const handlePause = () => {
// FIXME: It doesn't work because abort signal is not set.
@ -242,39 +324,58 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
return (
<>
<SettingsDropdownStyles />
<Container>
<MenuContainer>
<Tooltip placement="bottom" title={t('translate.any.language')} arrow>
<Globe size={16} style={{ flexShrink: 0 }} />
</Tooltip>
<ArrowRightToLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
<Tooltip placement="bottom" title={t('translate.target_language')} arrow>
<LeftGroup>
{/* Detected language display (read-only) */}
<DetectedLanguageTag>
{isPreparing ? (
<span>{t('translate.detecting')}</span>
) : (
<>
<span style={{ marginRight: 4 }}>{detectedLanguage?.emoji || '🌐'}</span>
<span>{detectedLanguage?.label() || t('translate.detected_source')}</span>
</>
)}
</DetectedLanguageTag>
<ArrowRight size={16} color="var(--color-text-3)" style={{ flexShrink: 0 }} />
{/* Target language selector */}
<LanguageSelect
value={targetLanguage.langCode}
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
value={actualTargetLanguage.langCode}
style={{ minWidth: 100, maxWidth: 160 }}
listHeight={160}
title={t('translate.target_language')}
size="small"
optionFilterProp="label"
onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)}
onChange={handleDirectTargetChange}
disabled={isStreaming}
/>
</Tooltip>
<ArrowRightFromLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
<Tooltip placement="bottom" title={t('translate.alter_language')} arrow>
<LanguageSelect
value={alterLanguage.langCode}
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
listHeight={160}
title={t('translate.alter_language')}
optionFilterProp="label"
onChange={(value) => handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))}
disabled={isStreaming}
/>
</Tooltip>
<Tooltip placement="bottom" title={t('selection.action.translate.smart_translate_tips')} arrow>
<QuestionIcon size={14} style={{ marginLeft: 4 }} />
</Tooltip>
<Spacer />
{/* Settings dropdown */}
<Dropdown
menu={{
items: settingsMenuItems,
selectable: false,
className: 'settings-dropdown-menu'
}}
trigger={['click']}
placement="bottomRight"
open={settingsOpen}
onOpenChange={setSettingsOpen}>
<Tooltip title={t('translate.language_settings')} placement="bottom">
<SettingsButton>
<Settings2 size={14} />
</SettingsButton>
</Tooltip>
</Dropdown>
<Tooltip title={t('selection.action.translate.smart_translate_tips')} placement="bottom">
<HelpIcon size={14} />
</Tooltip>
</LeftGroup>
<OriginalHeader onClick={() => setShowOriginal(!showOriginal)}>
<span>
{showOriginal ? t('selection.action.window.original_hide') : t('selection.action.window.original_show')}
@ -390,12 +491,72 @@ const ErrorMsg = styled.div`
word-break: break-all;
`
const Spacer = styled.div`
flex-grow: 0.5;
const LeftGroup = styled.div`
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 1;
min-width: 0;
`
const QuestionIcon = styled(CircleHelp)`
const DetectedLanguageTag = styled.div`
display: flex;
align-items: center;
padding: 4px 8px;
background-color: var(--color-background-soft);
border-radius: 4px;
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
flex-shrink: 0;
`
const SettingsButton = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
color: var(--color-text-3);
flex-shrink: 0;
&:hover {
background-color: var(--color-background-soft);
color: var(--color-text);
}
`
const SettingsMenuItem = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 0;
min-width: 180px;
cursor: default;
`
const SettingsLabel = styled.span`
font-size: 12px;
color: var(--color-text-secondary);
`
const HelpIcon = styled(CircleHelp)`
cursor: pointer;
color: var(--color-text-3);
flex-shrink: 0;
`
const SettingsDropdownStyles = createGlobalStyle`
.settings-dropdown-menu {
.ant-dropdown-menu-item {
cursor: default !important;
&:hover {
background-color: transparent !important;
}
}
}
`
export default ActionTranslate