mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 21:01:32 +08:00
feat: improve translation setting logic (#6463)
* feat: add auto-detect language option and improve translation logic * feat: remove auto-detect language option and add bidirectional translation settings * fix: remove unused model removal function from TranslatePage component * feat: add language detection and bidirectional translation utilities * feat: update translation settings to include bidirectional translation tips and remove deprecated options * fix: improve interaction * fix: change cld3-asm to franc * fix: ui/ux * fix: change eslint * fix: update * Revert "fix: update" This reverts commit1126a5cce9. * Reapply "fix: update" This reverts commit82b7890f92. * fix: setloading missing
This commit is contained in:
parent
a5a04e1df7
commit
0df331cf8a
@ -83,6 +83,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"franc": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
|
||||
@ -1798,6 +1798,7 @@
|
||||
"close": "Close",
|
||||
"closed": "Translation closed",
|
||||
"copied": "Translation content copied",
|
||||
"detected.language": "Detected Language",
|
||||
"empty": "Translation content is empty",
|
||||
"not.found": "Translation content not found",
|
||||
"confirm": {
|
||||
@ -1816,8 +1817,16 @@
|
||||
"input.placeholder": "Enter text to translate",
|
||||
"output.placeholder": "Translation",
|
||||
"processing": "Translation in progress...",
|
||||
"scroll_sync.disable": "Disable synced scroll",
|
||||
"scroll_sync.enable": "Enable synced scroll",
|
||||
"language.same": "Source and target languages are the same",
|
||||
"language.not_pair": "Source language is different from the set language",
|
||||
"settings": {
|
||||
"title": "Translation Settings",
|
||||
"model": "Model Settings",
|
||||
"model_desc": "Model used for translation service",
|
||||
"bidirectional": "Bidirectional Translation Settings",
|
||||
"bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported",
|
||||
"scroll_sync": "Scroll Sync Settings"
|
||||
},
|
||||
"title": "Translation",
|
||||
"tooltip.newline": "Newline",
|
||||
"menu": {
|
||||
|
||||
@ -1815,13 +1815,22 @@
|
||||
"input.placeholder": "翻訳するテキストを入力",
|
||||
"output.placeholder": "翻訳",
|
||||
"processing": "翻訳中...",
|
||||
"scroll_sync.disable": "關閉滾動同步",
|
||||
"scroll_sync.enable": "開啟滾動同步",
|
||||
"language.same": "ソース言語と目標言語が同じです",
|
||||
"language.not_pair": "ソース言語が設定された言語と異なります",
|
||||
"settings": {
|
||||
"title": "翻訳設定",
|
||||
"model": "モデル設定",
|
||||
"model_desc": "翻訳サービスで使用されるモデル",
|
||||
"bidirectional": "双方向翻訳設定",
|
||||
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
|
||||
"scroll_sync": "スクロール同期設定"
|
||||
},
|
||||
"title": "翻訳",
|
||||
"tooltip.newline": "改行",
|
||||
"menu": {
|
||||
"description": "對當前輸入框內容進行翻譯"
|
||||
}
|
||||
},
|
||||
"detected.language": "検出された言語"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "終了",
|
||||
|
||||
@ -1815,13 +1815,22 @@
|
||||
"input.placeholder": "Введите текст для перевода",
|
||||
"output.placeholder": "Перевод",
|
||||
"processing": "Перевод в процессе...",
|
||||
"scroll_sync.disable": "Отключить синхронизацию прокрутки",
|
||||
"scroll_sync.enable": "Включить синхронизацию прокрутки",
|
||||
"language.same": "Исходный и целевой языки совпадают",
|
||||
"language.not_pair": "Исходный язык отличается от настроенного",
|
||||
"settings": {
|
||||
"title": "Настройки перевода",
|
||||
"model": "Настройки модели",
|
||||
"model_desc": "Модель, используемая для службы перевода",
|
||||
"bidirectional": "Настройки двунаправленного перевода",
|
||||
"scroll_sync": "Настройки синхронизации прокрутки",
|
||||
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот."
|
||||
},
|
||||
"title": "Перевод",
|
||||
"tooltip.newline": "Перевести",
|
||||
"menu": {
|
||||
"description": "Перевести содержимое текущего ввода"
|
||||
}
|
||||
},
|
||||
"detected.language": "Обнаруженный язык"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "Выйти",
|
||||
|
||||
@ -1818,10 +1818,19 @@
|
||||
"input.placeholder": "输入文本进行翻译",
|
||||
"output.placeholder": "翻译",
|
||||
"processing": "翻译中...",
|
||||
"scroll_sync.disable": "关闭滚动同步",
|
||||
"scroll_sync.enable": "开启滚动同步",
|
||||
"language.same": "源语言和目标语言相同",
|
||||
"language.not_pair": "源语言与设置的语言不同",
|
||||
"settings": {
|
||||
"title": "翻译设置",
|
||||
"model": "模型设置",
|
||||
"model_desc": "翻译服务使用的模型",
|
||||
"bidirectional": "双向翻译设置",
|
||||
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
|
||||
"scroll_sync": "滚动同步设置"
|
||||
},
|
||||
"title": "翻译",
|
||||
"tooltip.newline": "换行"
|
||||
"tooltip.newline": "换行",
|
||||
"detected.language": "检测到的语言"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "退出",
|
||||
|
||||
@ -615,7 +615,7 @@
|
||||
"citations": "引用內容",
|
||||
"copied": "已複製!",
|
||||
"copy.failed": "複製失敗",
|
||||
"copy.success": "已複製!",
|
||||
"copy.success": "複製成功",
|
||||
"delete.confirm.title": "刪除確認",
|
||||
"delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?",
|
||||
"delete.failed": "刪除失敗",
|
||||
@ -1815,13 +1815,22 @@
|
||||
"input.placeholder": "輸入文字進行翻譯",
|
||||
"output.placeholder": "翻譯",
|
||||
"processing": "翻譯中...",
|
||||
"scroll_sync.disable": "關閉滾動同步",
|
||||
"scroll_sync.enable": "開啟滾動同步",
|
||||
"language.same": "源語言和目標語言相同",
|
||||
"language.not_pair": "源語言與設定的語言不同",
|
||||
"settings": {
|
||||
"title": "翻譯設定",
|
||||
"model": "模型設定",
|
||||
"model_desc": "翻譯服務使用的模型",
|
||||
"bidirectional": "雙向翻譯設定",
|
||||
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
|
||||
"scroll_sync": "滾動同步設定"
|
||||
},
|
||||
"title": "翻譯",
|
||||
"tooltip.newline": "換行",
|
||||
"menu": {
|
||||
"description": "對當前輸入框內容進行翻譯"
|
||||
}
|
||||
},
|
||||
"detected.language": "檢測到的語言"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "結束",
|
||||
|
||||
@ -1,50 +1,256 @@
|
||||
import { CheckOutlined, DeleteOutlined, HistoryOutlined, SendOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { isEmbeddingModel } from '@renderer/config/models'
|
||||
import { translateLanguageOptions } from '@renderer/config/translate'
|
||||
import db from '@renderer/databases'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
||||
import type { Assistant, TranslateHistory } from '@renderer/types'
|
||||
import { getModelUniqId, hasModel } from '@renderer/services/ModelService'
|
||||
import type { Model, TranslateHistory } from '@renderer/types'
|
||||
import { runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { Button, Dropdown, Empty, Flex, Popconfirm, Select, Space, Tooltip } from 'antd'
|
||||
import {
|
||||
createInputScrollHandler,
|
||||
createOutputScrollHandler,
|
||||
detectLanguage,
|
||||
determineTargetLanguage
|
||||
} from '@renderer/utils/translate'
|
||||
import { Button, Dropdown, Empty, Flex, Modal, Popconfirm, Select, Space, Switch, Tooltip } from 'antd'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import dayjs from 'dayjs'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { Mouse, Settings2, TriangleAlert } from 'lucide-react'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { find, isEmpty, sortBy } from 'lodash'
|
||||
import { HelpCircle, Settings2, TriangleAlert } from 'lucide-react'
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
let _text = ''
|
||||
let _result = ''
|
||||
let _targetLanguage = 'english'
|
||||
|
||||
const TranslateSettings: FC<{
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
isScrollSyncEnabled: boolean
|
||||
setIsScrollSyncEnabled: (value: boolean) => void
|
||||
isBidirectional: boolean
|
||||
setIsBidirectional: (value: boolean) => void
|
||||
bidirectionalPair: [string, string]
|
||||
setBidirectionalPair: (value: [string, string]) => void
|
||||
translateModel: Model | undefined
|
||||
onModelChange: (model: Model) => void
|
||||
allModels: Model[]
|
||||
selectOptions: any[]
|
||||
}> = ({
|
||||
visible,
|
||||
onClose,
|
||||
isScrollSyncEnabled,
|
||||
setIsScrollSyncEnabled,
|
||||
isBidirectional,
|
||||
setIsBidirectional,
|
||||
bidirectionalPair,
|
||||
setBidirectionalPair,
|
||||
translateModel,
|
||||
onModelChange,
|
||||
allModels,
|
||||
selectOptions
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [localPair, setLocalPair] = useState<[string, string]>(bidirectionalPair)
|
||||
|
||||
const defaultTranslateModel = useMemo(
|
||||
() => (hasModel(translateModel) ? getModelUniqId(translateModel) : undefined),
|
||||
[translateModel]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPair(bidirectionalPair)
|
||||
}, [bidirectionalPair, visible])
|
||||
|
||||
const handleSave = () => {
|
||||
if (localPair[0] === localPair[1]) {
|
||||
window.message.warning({
|
||||
content: t('translate.language.same'),
|
||||
key: 'translate-message'
|
||||
})
|
||||
return
|
||||
}
|
||||
setBidirectionalPair(localPair)
|
||||
db.settings.put({ id: 'translate:bidirectional:pair', value: localPair })
|
||||
db.settings.put({ id: 'translate:scroll:sync', value: isScrollSyncEnabled })
|
||||
window.message.success({
|
||||
content: t('message.save.success.title'),
|
||||
key: 'translate-settings-save'
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<div style={{ fontSize: 16 }}>{t('translate.settings.title')}</div>}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
centered={true}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button key="save" type="primary" onClick={handleSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
]}
|
||||
width={420}>
|
||||
<Flex vertical gap={16} style={{ marginTop: 16 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('translate.settings.model')}</div>
|
||||
<HStack alignItems="center" gap={5}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('translate.settings.model_placeholder')}
|
||||
value={defaultTranslateModel}
|
||||
onChange={(value) => {
|
||||
const selectedModel = find(allModels, JSON.parse(value)) as Model
|
||||
if (selectedModel) {
|
||||
onModelChange(selectedModel)
|
||||
}
|
||||
}}
|
||||
options={selectOptions}
|
||||
showSearch
|
||||
/>
|
||||
</HStack>
|
||||
{!translateModel && (
|
||||
<div style={{ marginTop: 8, color: 'var(--color-warning)' }}>
|
||||
<HStack alignItems="center" gap={5}>
|
||||
<TriangleAlert size={14} />
|
||||
<span style={{ fontSize: 12 }}>{t('translate.settings.no_model_warning')}</span>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-3)' }}>
|
||||
{t('translate.settings.model_desc')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex align="center" justify="space-between">
|
||||
<div style={{ fontWeight: 500 }}>{t('translate.settings.scroll_sync')}</div>
|
||||
<Switch checked={isScrollSyncEnabled} onChange={setIsScrollSyncEnabled} />
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex align="center" justify="space-between" style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
<HStack alignItems="center" gap={5}>
|
||||
{t('translate.settings.bidirectional')}
|
||||
<Tooltip title={t('translate.settings.bidirectional_tip')}>
|
||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<HelpCircle size={14} style={{ color: 'var(--color-text-3)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</div>
|
||||
<Switch checked={isBidirectional} onChange={setIsBidirectional} />
|
||||
</Flex>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{isBidirectional && (
|
||||
<Flex align="center" justify="space-between" gap={10}>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
value={localPair[0]}
|
||||
onChange={(value) => setLocalPair([value, localPair[1]])}
|
||||
options={translateLanguageOptions().map((lang) => ({
|
||||
value: lang.value,
|
||||
label: (
|
||||
<Space.Compact direction="horizontal" block>
|
||||
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||
{lang.emoji}
|
||||
</span>
|
||||
<Space.Compact block>{lang.label}</Space.Compact>
|
||||
</Space.Compact>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
<span>⇆</span>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
value={localPair[1]}
|
||||
onChange={(value) => setLocalPair([localPair[0], value])}
|
||||
options={translateLanguageOptions().map((lang) => ({
|
||||
value: lang.value,
|
||||
label: (
|
||||
<Space.Compact direction="horizontal" block>
|
||||
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||
{lang.emoji}
|
||||
</span>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>{lang.label}</div>
|
||||
</Space.Compact>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Flex>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TranslatePage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
|
||||
const [text, setText] = useState(_text)
|
||||
const [result, setResult] = useState(_result)
|
||||
const { translateModel } = useDefaultModel()
|
||||
const { translateModel, setTranslateModel } = useDefaultModel()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false)
|
||||
const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false)
|
||||
const [isBidirectional, setIsBidirectional] = useState(false)
|
||||
const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese'])
|
||||
const [settingsVisible, setSettingsVisible] = useState(false)
|
||||
const [detectedLanguage, setDetectedLanguage] = useState<string | null>(null)
|
||||
const contentContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textAreaRef = useRef<TextAreaRef>(null)
|
||||
const outputTextRef = useRef<HTMLDivElement>(null)
|
||||
const isProgrammaticScroll = useRef(false)
|
||||
|
||||
const { providers } = useProviders()
|
||||
const allModels = useMemo(() => providers.map((p) => p.models).flat(), [providers])
|
||||
|
||||
const translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), [])
|
||||
|
||||
_text = text
|
||||
_result = result
|
||||
_targetLanguage = targetLanguage
|
||||
|
||||
const selectOptions = useMemo(
|
||||
() =>
|
||||
providers
|
||||
.filter((p) => p.models.length > 0)
|
||||
.map((p) => ({
|
||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||
title: p.name,
|
||||
options: sortBy(p.models, 'name')
|
||||
.filter((m) => !isEmbeddingModel(m))
|
||||
.map((m) => ({
|
||||
label: `${m.name} | ${p.isSystem ? t(`provider.${p.id}`) : p.name}`,
|
||||
value: getModelUniqId(m)
|
||||
}))
|
||||
})),
|
||||
[providers, t]
|
||||
)
|
||||
|
||||
const handleModelChange = (model: Model) => {
|
||||
setTranslateModel(model)
|
||||
db.settings.put({ id: 'translate:model', value: model.id })
|
||||
}
|
||||
|
||||
const saveTranslateHistory = async (
|
||||
sourceText: string,
|
||||
targetText: string,
|
||||
@ -71,10 +277,7 @@ const TranslatePage: FC = () => {
|
||||
}
|
||||
|
||||
const onTranslate = async () => {
|
||||
if (!text.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!text.trim()) return
|
||||
if (!translateModel) {
|
||||
window.message.error({
|
||||
content: t('translate.error.not_configured'),
|
||||
@ -83,11 +286,35 @@ const TranslatePage: FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text)
|
||||
|
||||
setLoading(true)
|
||||
let translatedText = ''
|
||||
try {
|
||||
const sourceLanguage = await detectLanguage(text)
|
||||
console.log('检测到的语言:', sourceLanguage)
|
||||
setDetectedLanguage(sourceLanguage)
|
||||
const result = determineTargetLanguage(sourceLanguage, targetLanguage, isBidirectional, bidirectionalPair)
|
||||
if (!result.success) {
|
||||
let errorMessage = ''
|
||||
if (result.errorType === 'same_language') {
|
||||
errorMessage = t('translate.language.same')
|
||||
} else if (result.errorType === 'not_in_pair') {
|
||||
errorMessage = t('translate.language.not_pair')
|
||||
}
|
||||
|
||||
window.message.warning({
|
||||
content: errorMessage,
|
||||
key: 'translate-message'
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const actualTargetLanguage = result.language as string
|
||||
if (isBidirectional) {
|
||||
setTargetLanguage(actualTargetLanguage)
|
||||
}
|
||||
|
||||
const assistant = getDefaultTranslateAssistant(actualTargetLanguage, text)
|
||||
let translatedText = ''
|
||||
await fetchTranslate({
|
||||
content: text,
|
||||
assistant,
|
||||
@ -96,6 +323,9 @@ const TranslatePage: FC = () => {
|
||||
setResult(translatedText)
|
||||
}
|
||||
})
|
||||
|
||||
await saveTranslateHistory(text, translatedText, sourceLanguage, actualTargetLanguage)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error('Translation error:', error)
|
||||
window.message.error({
|
||||
@ -105,9 +335,11 @@ const TranslatePage: FC = () => {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await saveTranslateHistory(text, translatedText, 'any', targetLanguage)
|
||||
setLoading(false)
|
||||
const toggleBidirectional = (value: boolean) => {
|
||||
setIsBidirectional(value)
|
||||
db.settings.put({ id: 'translate:bidirectional:enabled', value })
|
||||
}
|
||||
|
||||
const onCopy = () => {
|
||||
@ -130,6 +362,24 @@ const TranslatePage: FC = () => {
|
||||
runAsyncFunction(async () => {
|
||||
const targetLang = await db.settings.get({ id: 'translate:target:language' })
|
||||
targetLang && setTargetLanguage(targetLang.value)
|
||||
|
||||
const bidirectionalPairSetting = await db.settings.get({ id: 'translate:bidirectional:pair' })
|
||||
if (bidirectionalPairSetting) {
|
||||
const langPair = bidirectionalPairSetting.value
|
||||
if (Array.isArray(langPair) && langPair.length === 2 && langPair[0] !== langPair[1]) {
|
||||
setBidirectionalPair(langPair as [string, string])
|
||||
} else {
|
||||
const defaultPair: [string, string] = ['english', 'chinese']
|
||||
setBidirectionalPair(defaultPair)
|
||||
db.settings.put({ id: 'translate:bidirectional:pair', value: defaultPair })
|
||||
}
|
||||
}
|
||||
|
||||
const bidirectionalSetting = await db.settings.get({ id: 'translate:bidirectional:enabled' })
|
||||
setIsBidirectional(bidirectionalSetting ? bidirectionalSetting.value : false)
|
||||
|
||||
const scrollSyncSetting = await db.settings.get({ id: 'translate:scroll:sync' })
|
||||
setIsScrollSyncEnabled(scrollSyncSetting ? scrollSyncSetting.value : false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@ -141,71 +391,44 @@ const TranslatePage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const SettingButton = () => {
|
||||
if (isLocalAi) {
|
||||
return null
|
||||
}
|
||||
const handleInputScroll = createInputScrollHandler(outputTextRef, isProgrammaticScroll, isScrollSyncEnabled)
|
||||
const handleOutputScroll = createOutputScrollHandler(textAreaRef, isProgrammaticScroll, isScrollSyncEnabled)
|
||||
|
||||
if (translateModel) {
|
||||
// 获取当前语言状态显示
|
||||
const getLanguageDisplay = () => {
|
||||
if (isBidirectional) {
|
||||
return (
|
||||
<Link to="/settings/model" style={{ color: 'var(--color-text-2)', display: 'flex' }}>
|
||||
<Settings2 size={18} />
|
||||
</Link>
|
||||
<Flex align="center" style={{ width: 160 }}>
|
||||
<BidirectionalLanguageDisplay>
|
||||
{`${t(`languages.${bidirectionalPair[0]}`)} ⇆ ${t(`languages.${bidirectionalPair[1]}`)}`}
|
||||
</BidirectionalLanguageDisplay>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to="/settings/model" style={{ marginLeft: -10, display: 'flex' }}>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ color: 'var(--color-error)', textDecoration: 'underline' }}
|
||||
icon={<TriangleAlert size={16} />}>
|
||||
{t('translate.error.not_configured')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={targetLanguage}
|
||||
onChange={(value) => {
|
||||
setTargetLanguage(value)
|
||||
db.settings.put({ id: 'translate:target:language', value })
|
||||
}}
|
||||
options={translateLanguageOptions().map((lang) => ({
|
||||
value: lang.value,
|
||||
label: (
|
||||
<Space.Compact direction="horizontal" block>
|
||||
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||
{lang.emoji}
|
||||
</span>
|
||||
<Space.Compact block>{lang.label}</Space.Compact>
|
||||
</Space.Compact>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle input area scroll event
|
||||
const handleInputScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (!isScrollSyncEnabled || !outputTextRef.current || isProgrammaticScroll.current) return
|
||||
|
||||
isProgrammaticScroll.current = true
|
||||
|
||||
const inputEl = e.currentTarget
|
||||
const outputEl = outputTextRef.current
|
||||
|
||||
// Calculate scroll position by ratio
|
||||
const inputScrollRatio = inputEl.scrollTop / (inputEl.scrollHeight - inputEl.clientHeight || 1)
|
||||
outputEl.scrollTop = inputScrollRatio * (outputEl.scrollHeight - outputEl.clientHeight || 1)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isProgrammaticScroll.current = false
|
||||
})
|
||||
}
|
||||
|
||||
// Handle output area scroll event
|
||||
const handleOutputScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const inputEl = textAreaRef.current?.resizableTextArea?.textArea
|
||||
if (!isScrollSyncEnabled || !inputEl || isProgrammaticScroll.current) return
|
||||
|
||||
isProgrammaticScroll.current = true
|
||||
|
||||
const outputEl = e.currentTarget
|
||||
|
||||
// Calculate scroll position by ratio
|
||||
const outputScrollRatio = outputEl.scrollTop / (outputEl.scrollHeight - outputEl.clientHeight || 1)
|
||||
inputEl.scrollTop = outputScrollRatio * (inputEl.scrollHeight - inputEl.clientHeight || 1)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isProgrammaticScroll.current = false
|
||||
})
|
||||
}
|
||||
|
||||
const toggleScrollSync = () => {
|
||||
setIsScrollSyncEnabled(!isScrollSyncEnabled)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container id="translate-page">
|
||||
<Navbar>
|
||||
@ -275,23 +498,23 @@ const TranslatePage: FC = () => {
|
||||
<Flex align="center" gap={20}>
|
||||
<Select
|
||||
showSearch
|
||||
value="any"
|
||||
value="auto"
|
||||
style={{ width: 180 }}
|
||||
optionFilterProp="label"
|
||||
disabled
|
||||
options={[{ label: t('translate.any.language'), value: 'any' }]}
|
||||
options={[
|
||||
{
|
||||
label: detectedLanguage ? t(`languages.${detectedLanguage}`) : t('translate.detected.language'),
|
||||
value: 'auto'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Settings2 size={18} />}
|
||||
onClick={() => setSettingsVisible(true)}
|
||||
style={{ color: 'var(--color-text-2)', display: 'flex' }}
|
||||
/>
|
||||
<SettingButton />
|
||||
<Tooltip
|
||||
mouseEnterDelay={0.5}
|
||||
title={isScrollSyncEnabled ? t('translate.scroll_sync.disable') : t('translate.scroll_sync.enable')}>
|
||||
<Mouse
|
||||
size={16}
|
||||
onClick={toggleScrollSync}
|
||||
style={{ cursor: 'pointer' }}
|
||||
color={isScrollSyncEnabled ? 'var(--color-primary)' : 'var(--color-icon)'}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Tooltip
|
||||
@ -331,25 +554,9 @@ const TranslatePage: FC = () => {
|
||||
|
||||
<OutputContainer>
|
||||
<OperationBar>
|
||||
<Select
|
||||
showSearch
|
||||
value={targetLanguage}
|
||||
style={{ width: 180 }}
|
||||
optionFilterProp="label"
|
||||
options={translateLanguageOptions()}
|
||||
onChange={(value) => {
|
||||
setTargetLanguage(value)
|
||||
db.settings.put({ id: 'translate:target:language', value })
|
||||
}}
|
||||
optionRender={(option) => (
|
||||
<Space>
|
||||
<span role="img" aria-label={option.data.label}>
|
||||
{option.data.emoji}
|
||||
</span>
|
||||
{option.label}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
<HStack alignItems="center" gap={5}>
|
||||
{getLanguageDisplay()}
|
||||
</HStack>
|
||||
<CopyButton
|
||||
onClick={onCopy}
|
||||
disabled={!result}
|
||||
@ -362,6 +569,21 @@ const TranslatePage: FC = () => {
|
||||
</OutputText>
|
||||
</OutputContainer>
|
||||
</ContentContainer>
|
||||
|
||||
<TranslateSettings
|
||||
visible={settingsVisible}
|
||||
onClose={() => setSettingsVisible(false)}
|
||||
isScrollSyncEnabled={isScrollSyncEnabled}
|
||||
setIsScrollSyncEnabled={setIsScrollSyncEnabled}
|
||||
isBidirectional={isBidirectional}
|
||||
setIsBidirectional={toggleBidirectional}
|
||||
bidirectionalPair={bidirectionalPair}
|
||||
setBidirectionalPair={setBidirectionalPair}
|
||||
translateModel={translateModel}
|
||||
onModelChange={handleModelChange}
|
||||
allModels={allModels}
|
||||
selectOptions={selectOptions}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -437,6 +659,16 @@ const TranslateButton = styled(Button)``
|
||||
|
||||
const CopyButton = styled(Button)``
|
||||
|
||||
const BidirectionalLanguageDisplay = styled.div`
|
||||
padding: 4px 11px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
const HistoryContainner = styled.div<{ $historyDrawerVisible: boolean }>`
|
||||
width: ${({ $historyDrawerVisible }) => ($historyDrawerVisible ? '300px' : '0')};
|
||||
height: calc(100vh - var(--navbar-height) - 40px);
|
||||
|
||||
216
src/renderer/src/utils/translate.ts
Normal file
216
src/renderer/src/utils/translate.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { franc } from 'franc'
|
||||
import React, { MutableRefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 使用Unicode字符范围检测语言
|
||||
* 适用于较短文本的语言检测
|
||||
* @param {string} text 需要检测语言的文本
|
||||
* @returns {string} 检测到的语言代码
|
||||
*/
|
||||
export const detectLanguageByUnicode = (text: string): string => {
|
||||
const counts = {
|
||||
zh: 0,
|
||||
ja: 0,
|
||||
ko: 0,
|
||||
ru: 0,
|
||||
ar: 0,
|
||||
latin: 0
|
||||
}
|
||||
|
||||
let totalChars = 0
|
||||
|
||||
for (const char of text) {
|
||||
const code = char.codePointAt(0) || 0
|
||||
totalChars++
|
||||
|
||||
if (code >= 0x4e00 && code <= 0x9fff) {
|
||||
counts.zh++
|
||||
} else if ((code >= 0x3040 && code <= 0x309f) || (code >= 0x30a0 && code <= 0x30ff)) {
|
||||
counts.ja++
|
||||
} else if ((code >= 0xac00 && code <= 0xd7a3) || (code >= 0x1100 && code <= 0x11ff)) {
|
||||
counts.ko++
|
||||
} else if (code >= 0x0400 && code <= 0x04ff) {
|
||||
counts.ru++
|
||||
} else if (code >= 0x0600 && code <= 0x06ff) {
|
||||
counts.ar++
|
||||
} else if ((code >= 0x0020 && code <= 0x007f) || (code >= 0x0080 && code <= 0x00ff)) {
|
||||
counts.latin++
|
||||
} else {
|
||||
totalChars--
|
||||
}
|
||||
}
|
||||
|
||||
if (totalChars === 0) return 'en'
|
||||
let maxLang = 'en'
|
||||
let maxCount = 0
|
||||
|
||||
for (const [lang, count] of Object.entries(counts)) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count
|
||||
maxLang = lang === 'latin' ? 'en' : lang
|
||||
}
|
||||
}
|
||||
|
||||
if (maxCount / totalChars < 0.3) {
|
||||
return 'en'
|
||||
}
|
||||
return maxLang
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测输入文本的语言
|
||||
* @param {string} inputText 需要检测语言的文本
|
||||
* @returns {Promise<string>} 检测到的语言代码
|
||||
*/
|
||||
export const detectLanguage = async (inputText: string): Promise<string> => {
|
||||
const text = inputText.trim()
|
||||
if (!text) return 'any'
|
||||
let code: string
|
||||
|
||||
// 如果文本长度小于20个字符,使用Unicode范围检测
|
||||
if (text.length < 20) {
|
||||
code = detectLanguageByUnicode(text)
|
||||
} else {
|
||||
// franc 返回 ISO 639-3 代码
|
||||
const iso3 = franc(text)
|
||||
const isoMap: Record<string, string> = {
|
||||
cmn: 'zh',
|
||||
jpn: 'ja',
|
||||
kor: 'ko',
|
||||
rus: 'ru',
|
||||
ara: 'ar',
|
||||
spa: 'es',
|
||||
fra: 'fr',
|
||||
deu: 'de',
|
||||
ita: 'it',
|
||||
por: 'pt',
|
||||
eng: 'en'
|
||||
}
|
||||
code = isoMap[iso3] || 'en'
|
||||
}
|
||||
|
||||
// 映射到应用使用的语言键
|
||||
const languageMap: Record<string, string> = {
|
||||
zh: 'chinese',
|
||||
ja: 'japanese',
|
||||
ko: 'korean',
|
||||
ru: 'russian',
|
||||
es: 'spanish',
|
||||
fr: 'french',
|
||||
de: 'german',
|
||||
it: 'italian',
|
||||
pt: 'portuguese',
|
||||
ar: 'arabic',
|
||||
en: 'english'
|
||||
}
|
||||
|
||||
return languageMap[code] || 'english'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取双向翻译的目标语言
|
||||
* @param sourceLanguage 检测到的源语言
|
||||
* @param languagePair 配置的语言对
|
||||
* @returns 目标语言
|
||||
*/
|
||||
export const getTargetLanguageForBidirectional = (sourceLanguage: string, languagePair: [string, string]): string => {
|
||||
if (sourceLanguage === languagePair[0]) {
|
||||
return languagePair[1]
|
||||
} else if (sourceLanguage === languagePair[1]) {
|
||||
return languagePair[0]
|
||||
}
|
||||
return languagePair[0] !== sourceLanguage ? languagePair[0] : languagePair[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查源语言是否在配置的语言对中
|
||||
* @param sourceLanguage 检测到的源语言
|
||||
* @param languagePair 配置的语言对
|
||||
* @returns 是否在语言对中
|
||||
*/
|
||||
export const isLanguageInPair = (sourceLanguage: string, languagePair: [string, string]): boolean => {
|
||||
return [languagePair[0], languagePair[1]].includes(sourceLanguage)
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定翻译的目标语言
|
||||
* @param sourceLanguage 检测到的源语言
|
||||
* @param targetLanguage 用户设置的目标语言
|
||||
* @param isBidirectional 是否开启双向翻译
|
||||
* @param bidirectionalPair 双向翻译的语言对
|
||||
* @returns 处理结果对象
|
||||
*/
|
||||
export const determineTargetLanguage = (
|
||||
sourceLanguage: string,
|
||||
targetLanguage: string,
|
||||
isBidirectional: boolean,
|
||||
bidirectionalPair: [string, string]
|
||||
): { success: boolean; language?: string; errorType?: 'same_language' | 'not_in_pair' } => {
|
||||
if (isBidirectional) {
|
||||
if (!isLanguageInPair(sourceLanguage, bidirectionalPair)) {
|
||||
return { success: false, errorType: 'not_in_pair' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
language: getTargetLanguageForBidirectional(sourceLanguage, bidirectionalPair)
|
||||
}
|
||||
} else {
|
||||
if (sourceLanguage === targetLanguage) {
|
||||
return { success: false, errorType: 'same_language' }
|
||||
}
|
||||
return { success: true, language: targetLanguage }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理滚动同步
|
||||
* @param sourceElement 源元素
|
||||
* @param targetElement 目标元素
|
||||
* @param isProgrammaticScrollRef 是否程序控制滚动的引用
|
||||
*/
|
||||
export const handleScrollSync = (
|
||||
sourceElement: HTMLElement,
|
||||
targetElement: HTMLElement,
|
||||
isProgrammaticScrollRef: MutableRefObject<boolean>
|
||||
): void => {
|
||||
if (isProgrammaticScrollRef.current) return
|
||||
|
||||
isProgrammaticScrollRef.current = true
|
||||
|
||||
// 计算滚动位置比例
|
||||
const scrollRatio = sourceElement.scrollTop / (sourceElement.scrollHeight - sourceElement.clientHeight || 1)
|
||||
targetElement.scrollTop = scrollRatio * (targetElement.scrollHeight - targetElement.clientHeight || 1)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isProgrammaticScrollRef.current = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建输入区域滚动处理函数
|
||||
*/
|
||||
export const createInputScrollHandler = (
|
||||
targetRef: MutableRefObject<HTMLDivElement | null>,
|
||||
isProgrammaticScrollRef: MutableRefObject<boolean>,
|
||||
isScrollSyncEnabled: boolean
|
||||
) => {
|
||||
return (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (!isScrollSyncEnabled || !targetRef.current || isProgrammaticScrollRef.current) return
|
||||
handleScrollSync(e.currentTarget, targetRef.current, isProgrammaticScrollRef)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建输出区域滚动处理函数
|
||||
*/
|
||||
export const createOutputScrollHandler = (
|
||||
textAreaRef: MutableRefObject<any>,
|
||||
isProgrammaticScrollRef: MutableRefObject<boolean>,
|
||||
isScrollSyncEnabled: boolean
|
||||
) => {
|
||||
return (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const inputEl = textAreaRef.current?.resizableTextArea?.textArea
|
||||
if (!isScrollSyncEnabled || !inputEl || isProgrammaticScrollRef.current) return
|
||||
handleScrollSync(e.currentTarget, inputEl, isProgrammaticScrollRef)
|
||||
}
|
||||
}
|
||||
34
yarn.lock
34
yarn.lock
@ -5621,6 +5621,7 @@ __metadata:
|
||||
eslint-plugin-unused-imports: "npm:^4.1.4"
|
||||
fast-diff: "npm:^1.3.0"
|
||||
fast-xml-parser: "npm:^5.2.0"
|
||||
franc: "npm:^6.2.0"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
html-to-image: "npm:^1.11.13"
|
||||
husky: "npm:^9.1.7"
|
||||
@ -6953,6 +6954,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"collapse-white-space@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "collapse-white-space@npm:2.1.0"
|
||||
checksum: 10c0/b2e2800f4ab261e62eb27a1fbe853378296e3a726d6695117ed033e82d61fb6abeae4ffc1465d5454499e237005de9cfc52c9562dc7ca4ac759b9a222ef14453
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"color-convert@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "color-convert@npm:2.0.1"
|
||||
@ -9812,6 +9820,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"franc@npm:^6.2.0":
|
||||
version: 6.2.0
|
||||
resolution: "franc@npm:6.2.0"
|
||||
dependencies:
|
||||
trigram-utils: "npm:^2.0.0"
|
||||
checksum: 10c0/136a08d6e4632f17eae6f0ae93b224b0bf2233dc1d5dbd0b23e479960f6c71c0847bef834d3b6b7c9cefb4f905d5e08fc82b0738bb3ed4a6c83faffcf9fa2a11
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fresh@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "fresh@npm:2.0.0"
|
||||
@ -13543,6 +13560,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"n-gram@npm:^2.0.0":
|
||||
version: 2.0.2
|
||||
resolution: "n-gram@npm:2.0.2"
|
||||
checksum: 10c0/72e2cdc8c37c9253b556a0deb9cd26d5ac59a5d7a38b2d2928927e3959bc7d3cb591d766e30309a4c685dbc51330025cb30c5c6518ee516caf3318aed2635f1b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nanoid@npm:^3.3.7, nanoid@npm:^3.3.8":
|
||||
version: 3.3.11
|
||||
resolution: "nanoid@npm:3.3.11"
|
||||
@ -17509,6 +17533,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"trigram-utils@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "trigram-utils@npm:2.0.1"
|
||||
dependencies:
|
||||
collapse-white-space: "npm:^2.0.0"
|
||||
n-gram: "npm:^2.0.0"
|
||||
checksum: 10c0/d024dc91a9c0310e75fa68422185e3a32814831971b9e86a2925e74bd1932a30501aa2ac214768f0a545f3db63610ee14b4748ac31532e1bc46c791941d71c6d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"trim-lines@npm:^3.0.0":
|
||||
version: 3.0.1
|
||||
resolution: "trim-lines@npm:3.0.1"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user