Compare commits

...

13 Commits

Author SHA1 Message Date
icarus
00fd20d828 refactor: remove unused import comment in TranslatePage 2025-10-15 01:53:50 +08:00
icarus
d94f73b5ca feat(translate): add target languages preference and refactor language handling
- Introduce TargetLangs type to manage target and alter languages
- Replace local state with preference-based language management
- Simplify language selection logic in ActionTranslate component
- Remove deprecated database storage for language pairs
2025-10-15 01:42:16 +08:00
icarus
7f34d084cc feat(migration): add auto-copy translation mapping for preferences 2025-10-15 01:05:42 +08:00
icarus
821f233728 refactor(translate): store language preferences in cache instead of db
Move source and target language state management from database to cache for better performance and consistency
2025-10-15 00:41:52 +08:00
icarus
0cb60fb2d6 refactor(translate): remove redundant model persistence in handleModelChange 2025-10-15 00:30:39 +08:00
icarus
19d1ce4b2a refactor(translate): migrate scroll sync setting to preferences
Remove local state management for scroll sync and use preference system instead
Clean up unused imports and props in translate components
2025-10-15 00:26:44 +08:00
icarus
c2ee3fff33 refactor(translate): add markdown toggle preference and refactor state management
Move markdown toggle state to preferences system and remove direct database operations
2025-10-15 00:22:43 +08:00
icarus
9b7094ea4a feat(translate): add bidirectional translation configuration in cache
Refactor bidirectional translation logic to use cache for state management
Simplify language pair handling by using language codes directly
2025-10-15 00:16:04 +08:00
icarus
6cda7f891d refactor(translate): centralize language label handling with getLanguageLabel
Move language label generation from individual TranslateLanguage objects to a centralized getLanguageLabel function in useTranslate hook. This improves maintainability by removing duplicate label logic and makes it easier to update language labels globally.

- Remove label() method from TranslateLanguage type and all language objects
- Add getLanguageLabel function in useTranslate that handles label generation
- Update all components to use getLanguageLabel instead of label()
- Add labelMap for common language codes to avoid unnecessary lookups
2025-10-15 00:15:48 +08:00
icarus
dbfece3590 refactor(translate): move auto detection method to preferences
Move auto detection method configuration from local state to preference store
Remove unused auto detection types from renderer types
Add zod schema for auto detection method validation
2025-10-14 22:03:38 +08:00
icarus
9a67ac9018 refactor(translate): remove deprecated redux actions and types
The translate slice actions and types have been migrated elsewhere, so they are removed from this file. The file is marked as deprecated.
2025-10-14 21:51:08 +08:00
icarus
2bd5f39740 refactor(translate): migrate auto copy preference setting to usePreference
Move auto copy setting from redux store to preference system for better consistency
2025-10-14 21:47:31 +08:00
icarus
9ebe4801f4 feat(translate): migrate translate state to cache system
- Replace Redux state management with cache system for translate functionality
- Add new CacheTranslating type to track translation state
- Update TranslatePage to use cache hooks for input, output and state
- Simplify translate function and improve error handling
2025-10-14 21:42:48 +08:00
20 changed files with 422 additions and 522 deletions

View File

@@ -1,3 +1,5 @@
import type { TranslateLanguageCode } from '@types'
import type * as CacheValueTypes from './cacheValueTypes'
/**
@@ -27,6 +29,15 @@ export type UseCacheSchema = {
'topic.renaming': string[]
'topic.newly_renamed': string[]
// Translate state
'translate.lang.source': TranslateLanguageCode | 'auto'
'translate.lang.target': TranslateLanguageCode
'translate.input': string
'translate.output': string
'translate.detecting': boolean
'translate.translating': CacheValueTypes.CacheTranslating
'translate.bidirectional': CacheValueTypes.CacheTranslateBidirectional
// Test keys (for dataRefactorTest window)
// TODO: remove after testing
'test-hook-memory-1': string
@@ -72,6 +83,19 @@ export const DefaultUseCache: UseCacheSchema = {
'topic.renaming': [],
'topic.newly_renamed': [],
// Translate state
'translate.lang.source': 'auto',
'translate.lang.target': 'zh-cn',
'translate.input': '',
'translate.output': '',
'translate.detecting': false,
'translate.translating': { isTranslating: false, abortKey: null },
'translate.bidirectional': {
enabled: false,
origin: 'en-us',
target: 'zh-cn'
},
// Test keys (for dataRefactorTest window)
// TODO: remove after testing
'test-hook-memory-1': 'default-memory-value',

View File

@@ -1,4 +1,4 @@
import type { MinAppType, Topic, WebSearchStatus } from '@types'
import type { MinAppType, Topic, TranslateLanguageCode, WebSearchStatus } from '@types'
import type { UpdateInfo } from 'builder-util-runtime'
export type CacheAppUpdateState = {
@@ -16,3 +16,17 @@ export type CacheActiveSearches = Record<string, WebSearchStatus>
// The actual type checking will be done at runtime by the cache system
export type CacheMinAppType = MinAppType
export type CacheTopic = Topic
export type CacheTranslating =
| {
isTranslating: true
abortKey: string
}
| {
isTranslating: false
abortKey: null
}
export type CacheTranslateBidirectional = {
enabled: boolean
origin: TranslateLanguageCode
target: TranslateLanguageCode
}

View File

@@ -395,6 +395,14 @@ export interface PreferenceSchemas {
'topic.tab.show': boolean
// redux/settings/showTopicTime
'topic.tab.show_time': boolean
// redux/translate/settings
'translate.settings.auto_copy': boolean
// indexedDB/translate
'translate.settings.auto_detection_method': PreferenceTypes.AutoDetectionMethod
'translate.settings.enable_markdown': boolean
'translate.settings.scroll_sync': boolean
// new preference
'translate.settings.target_langs': PreferenceTypes.TargetLangs
// redux/settings/customCss
'ui.custom_css': string
// redux/settings/navbarPosition
@@ -654,6 +662,14 @@ export const DefaultPreferences: PreferenceSchemas = {
'topic.tab.pin_to_top': false,
'topic.tab.show': true,
'topic.tab.show_time': false,
'translate.settings.auto_copy': false,
'translate.settings.auto_detection_method': 'franc',
'translate.settings.enable_markdown': false,
'translate.settings.scroll_sync': false,
'translate.settings.target_langs': {
alter: 'zh-cn',
target: 'en-us'
},
'ui.custom_css': '',
'ui.navbar.position': 'top',
'ui.sidebar.icons.invisible': [],

View File

@@ -1,3 +1,6 @@
import type { TranslateLanguageCode } from '@types'
import * as z from 'zod'
import type { PreferenceSchemas } from './preferenceSchemas'
export type PreferenceDefaultScopeType = PreferenceSchemas['default']
@@ -85,3 +88,13 @@ export type ChatMessageNavigationMode = 'none' | 'buttons' | 'anchor'
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
export type MultiModelGridPopoverTrigger = 'hover' | 'click'
const AutoDetectionMethodSchema = z.enum(['franc', 'llm', 'auto'])
export type AutoDetectionMethod = z.infer<typeof AutoDetectionMethodSchema>
export const isAutoDetectionMethod = (method: string): method is AutoDetectionMethod => {
return AutoDetectionMethodSchema.safeParse(method).success
}
export type TargetLangs = {
target: TranslateLanguageCode
alter: TranslateLanguageCode
}

View File

@@ -736,7 +736,8 @@ export const REDUX_STORE_MAPPINGS = {
originalKey: 'shortcuts.exit_fullscreen',
targetKey: 'shortcut.app.exit_fullscreen'
}
]
],
translate: [{ originalKey: 'settings.autoCopy', targetKey: 'translate.settings.auto_copy' }]
} as const
// === AUTO-GENERATED CONTENT END ===

View File

@@ -18,19 +18,22 @@ type Props = {
} & Omit<SelectProps, 'labelRender' | 'options'>
const LanguageSelect = (props: Props) => {
const { translateLanguages } = useTranslate()
const { translateLanguages, getLanguageLabel } = useTranslate()
const { extraOptionsAfter, extraOptionsBefore, languageRenderer, ...restProps } = props
const defaultLanguageRenderer = useCallback((lang: TranslateLanguage) => {
return (
<Space.Compact direction="horizontal" block>
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
{lang.emoji}
</span>
{lang.label()}
</Space.Compact>
)
}, [])
const defaultLanguageRenderer = useCallback(
(lang: TranslateLanguage) => {
return (
<Space.Compact direction="horizontal" block>
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
{lang.emoji}
</span>
{getLanguageLabel(lang.langCode)}
</Space.Compact>
)
},
[getLanguageLabel]
)
const labelRender = (props) => {
const { label } = props

View File

@@ -24,7 +24,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
const [isTranslating, setIsTranslating] = useState(false)
const [targetLanguage] = usePreference('feature.translate.target_language')
const [showTranslateConfirm] = usePreference('chat.input.translate.show_confirm')
const { getLanguageByLangcode } = useTranslate()
const { getLanguageLabel, getLanguageByLangcode } = useTranslate()
const translateConfirm = () => {
if (!showTranslateConfirm) {
@@ -64,9 +64,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
}, [isLoading])
return (
<Tooltip
content={t('chat.input.translate', { target_language: getLanguageByLangcode(targetLanguage).label() })}
closeDelay={0}>
<Tooltip content={t('chat.input.translate', { target_language: getLanguageLabel(targetLanguage) })} closeDelay={0}>
<Button
onPress={handleTranslate}
isDisabled={disabled || isTranslating}

View File

@@ -1,150 +1,128 @@
import i18n from '@renderer/i18n'
import type { TranslateLanguage } from '@renderer/types'
export const UNKNOWN: TranslateLanguage = {
value: 'Unknown',
langCode: 'unknown',
label: () => i18n.t('languages.unknown'),
emoji: '🏳️'
}
export const ENGLISH: TranslateLanguage = {
value: 'English',
langCode: 'en-us',
label: () => i18n.t('languages.english'),
emoji: '🇬🇧'
}
export const CHINESE_SIMPLIFIED: TranslateLanguage = {
value: 'Chinese (Simplified)',
langCode: 'zh-cn',
label: () => i18n.t('languages.chinese'),
emoji: '🇨🇳'
}
export const CHINESE_TRADITIONAL: TranslateLanguage = {
value: 'Chinese (Traditional)',
langCode: 'zh-tw',
label: () => i18n.t('languages.chinese-traditional'),
emoji: '🇭🇰'
}
export const JAPANESE: TranslateLanguage = {
value: 'Japanese',
langCode: 'ja-jp',
label: () => i18n.t('languages.japanese'),
emoji: '🇯🇵'
}
export const KOREAN: TranslateLanguage = {
value: 'Korean',
langCode: 'ko-kr',
label: () => i18n.t('languages.korean'),
emoji: '🇰🇷'
}
export const FRENCH: TranslateLanguage = {
value: 'French',
langCode: 'fr-fr',
label: () => i18n.t('languages.french'),
emoji: '🇫🇷'
}
export const GERMAN: TranslateLanguage = {
value: 'German',
langCode: 'de-de',
label: () => i18n.t('languages.german'),
emoji: '🇩🇪'
}
export const ITALIAN: TranslateLanguage = {
value: 'Italian',
langCode: 'it-it',
label: () => i18n.t('languages.italian'),
emoji: '🇮🇹'
}
export const SPANISH: TranslateLanguage = {
value: 'Spanish',
langCode: 'es-es',
label: () => i18n.t('languages.spanish'),
emoji: '🇪🇸'
}
export const PORTUGUESE: TranslateLanguage = {
value: 'Portuguese',
langCode: 'pt-pt',
label: () => i18n.t('languages.portuguese'),
emoji: '🇵🇹'
}
export const RUSSIAN: TranslateLanguage = {
value: 'Russian',
langCode: 'ru-ru',
label: () => i18n.t('languages.russian'),
emoji: '🇷🇺'
}
export const POLISH: TranslateLanguage = {
value: 'Polish',
langCode: 'pl-pl',
label: () => i18n.t('languages.polish'),
emoji: '🇵🇱'
}
export const ARABIC: TranslateLanguage = {
value: 'Arabic',
langCode: 'ar-ar',
label: () => i18n.t('languages.arabic'),
emoji: '🇸🇦'
}
export const TURKISH: TranslateLanguage = {
value: 'Turkish',
langCode: 'tr-tr',
label: () => i18n.t('languages.turkish'),
emoji: '🇹🇷'
}
export const THAI: TranslateLanguage = {
value: 'Thai',
langCode: 'th-th',
label: () => i18n.t('languages.thai'),
emoji: '🇹🇭'
}
export const VIETNAMESE: TranslateLanguage = {
value: 'Vietnamese',
langCode: 'vi-vn',
label: () => i18n.t('languages.vietnamese'),
emoji: '🇻🇳'
}
export const INDONESIAN: TranslateLanguage = {
value: 'Indonesian',
langCode: 'id-id',
label: () => i18n.t('languages.indonesian'),
emoji: '🇮🇩'
}
export const URDU: TranslateLanguage = {
value: 'Urdu',
langCode: 'ur-pk',
label: () => i18n.t('languages.urdu'),
emoji: '🇵🇰'
}
export const MALAY: TranslateLanguage = {
value: 'Malay',
langCode: 'ms-my',
label: () => i18n.t('languages.malay'),
emoji: '🇲🇾'
}
export const UKRAINIAN: TranslateLanguage = {
value: 'Ukrainian',
langCode: 'uk-ua',
label: () => i18n.t('languages.ukrainian'),
emoji: '🇺🇦'
}

View File

@@ -1,14 +1,12 @@
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import { builtinLanguages, UNKNOWN } from '@renderer/config/translate'
import { useAppSelector } from '@renderer/store'
import type { TranslateState } from '@renderer/store/translate'
import { updateSettings } from '@renderer/store/translate'
import type { TranslateLanguage } from '@renderer/types'
import type { TranslateLanguageCode } from '@renderer/types'
import { type TranslateLanguage } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { getTranslateOptions } from '@renderer/utils/translate'
import { useCallback, useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useTranslate')
@@ -20,13 +18,11 @@ const logger = loggerService.withContext('useTranslate')
* - getLanguageByLangcode: 通过语言代码获取语言对象
*/
export default function useTranslate() {
const { t } = useTranslation()
const [prompt] = usePreference('feature.translate.model_prompt')
const settings = useAppSelector((state) => state.translate.settings)
const [translateLanguages, setTranslateLanguages] = useState<TranslateLanguage[]>(builtinLanguages)
const [isLoaded, setIsLoaded] = useState(false)
const dispatch = useDispatch()
useEffect(() => {
runAsyncFunction(async () => {
const options = await getTranslateOptions()
@@ -37,34 +33,68 @@ export default function useTranslate() {
const getLanguageByLangcode = useCallback(
(langCode: string) => {
if (!isLoaded) {
logger.verbose('Translate languages are not loaded yet. Return UNKNOWN.')
return UNKNOWN
}
const result = translateLanguages.find((item) => item.langCode === langCode)
if (result) {
return result
} else {
logger.warn(`Unknown language ${langCode}`)
if (!isLoaded) {
logger.verbose('Translate languages are not loaded yet. Return UNKNOWN.')
} else {
logger.warn(`Unknown language ${langCode}`)
}
return UNKNOWN
}
},
[isLoaded, translateLanguages]
)
const handleUpdateSettings = useCallback(
(update: Partial<TranslateState['settings']>) => {
dispatch(updateSettings(update))
const labelMap: Record<string, string> = useMemo(
() => ({
'zh-cn': t('languages.chinese'),
'zh-tw': t('languages.chinese-traditional'),
'ja-jp': t('languages.japanese'),
'ko-kr': t('languages.korean'),
'en-us': t('languages.english'),
'fr-fr': t('languages.french'),
'de-de': t('languages.german'),
'it-it': t('languages.italian'),
'es-es': t('languages.spanish'),
'pt-pt': t('languages.portuguese'),
'ru-ru': t('languages.russian'),
'pl-pl': t('languages.polish'),
'ar-ar': t('languages.arabic'),
'tr-tr': t('languages.turkish'),
'th-th': t('languages.thai'),
'vi-vn': t('languages.vietnamese'),
'id-id': t('languages.indonesian'),
'ur-pk': t('languages.urdu'),
'ms-my': t('languages.malay'),
'uk-ua': t('languages.ukrainian'),
unknown: t('common.unknown')
}),
[t]
)
const getLanguageLabel = useCallback(
(code: TranslateLanguageCode) => {
const label = labelMap[code]
if (label) {
return label
} else if (isLoaded) {
const language = getLanguageByLangcode(code)
return language.value
} else {
return t('common.unknown')
}
},
[dispatch]
[getLanguageByLangcode, isLoaded, labelMap, t]
)
return {
prompt,
settings,
isLoaded,
translateLanguages,
getLanguageByLangcode,
updateSettings: handleUpdateSettings
getLanguageLabel
}
}

View File

@@ -23,7 +23,7 @@ import store, { useAppDispatch } from '@renderer/store'
import { messageBlocksSelectors, removeOneBlock } from '@renderer/store/messageBlock'
import { selectMessagesForTopic } from '@renderer/store/newMessage'
import { TraceIcon } from '@renderer/trace/pages/Component'
import type { Assistant, Model, Topic, TranslateLanguage } from '@renderer/types'
import type { Assistant, Model, Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types'
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
import { captureScrollableAsBlob, captureScrollableAsDataURL, classNames } from '@renderer/utils'
import { copyMessageAsPlainText } from '@renderer/utils/copy'
@@ -118,6 +118,7 @@ type MessageMenubarButtonContext = {
softHoverBg: boolean
t: TFunction
translateLanguages: TranslateLanguage[]
getLanguageLabel: (lang: TranslateLanguageCode) => string
}
type MessageMenubarButtonRenderer = (ctx: MessageMenubarButtonContext) => ReactNode | null
@@ -142,7 +143,7 @@ const MessageMenubar: FC<Props> = (props) => {
const [isTranslating, setIsTranslating] = useState(false)
// remove confirm for regenerate; tooltip stays simple
const [showDeleteTooltip, setShowDeleteTooltip] = useState(false)
const { translateLanguages } = useTranslate()
const { translateLanguages, getLanguageLabel } = useTranslate()
// const assistantModel = assistant?.model
const {
deleteMessage,
@@ -571,7 +572,8 @@ const MessageMenubar: FC<Props> = (props) => {
showDeleteTooltip,
softHoverBg,
t,
translateLanguages
translateLanguages,
getLanguageLabel
}
return (
@@ -757,6 +759,7 @@ const buttonRenderers: Record<MessageMenubarButtonId, MessageMenubarButtonRender
translate: ({
isUserMessage,
translateLanguages,
getLanguageLabel,
handleTranslate,
hasTranslationBlocks,
message,
@@ -771,7 +774,7 @@ const buttonRenderers: Record<MessageMenubarButtonId, MessageMenubarButtonRender
const items: MenuProps['items'] = [
...translateLanguages.map((item) => ({
label: item.emoji + ' ' + item.label(),
label: item.emoji + ' ' + getLanguageLabel(item.langCode),
key: item.langCode,
onClick: () => handleTranslate(item)
})),

View File

@@ -96,7 +96,7 @@ const SettingsTab: FC<Props> = (props) => {
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput)
const { translateLanguages } = useTranslate()
const { translateLanguages, getLanguageLabel } = useTranslate()
const { t } = useTranslation()
@@ -142,8 +142,12 @@ const SettingsTab: FC<Props> = (props) => {
)
const targetLanguageItems = useMemo<SelectorItem<string>[]>(
() => translateLanguages.map((item) => ({ value: item.langCode, label: item.emoji + ' ' + item.label() })),
[translateLanguages]
() =>
translateLanguages.map((item) => ({
value: item.langCode,
label: item.emoji + ' ' + getLanguageLabel(item.langCode)
})),
[getLanguageLabel, translateLanguages]
)
const sendMessageShortcutItems = useMemo<SelectorItem<SendMessageShortcut>[]>(
@@ -727,7 +731,7 @@ const SettingsTab: FC<Props> = (props) => {
selectionMode="single"
selectedKeys={targetLanguage}
onSelectionChange={(value) => setTargetLanguage(value)}
placeholder={UNKNOWN.emoji + ' ' + UNKNOWN.label()}
placeholder={UNKNOWN.emoji + ' ' + getLanguageLabel(UNKNOWN.langCode)}
items={targetLanguageItems}
/>
</SettingRow>

View File

@@ -18,7 +18,7 @@ import { SettingRow, SettingRowTitle } from '..'
export const OcrSystemSettings = () => {
const { t } = useTranslation()
// 和翻译自定义语言耦合了应该还ok
const { translateLanguages } = useTranslate()
const { translateLanguages, getLanguageLabel } = useTranslate()
const { provider, updateConfig } = useOcrProvider(BuiltinOcrProviderIds.system)
if (!isOcrSystemProvider(provider)) {
@@ -36,9 +36,9 @@ export const OcrSystemSettings = () => {
() =>
translateLanguages.map((lang) => ({
value: lang.langCode,
label: lang.emoji + ' ' + lang.label()
label: lang.emoji + ' ' + getLanguageLabel(lang.langCode)
})),
[translateLanguages]
[getLanguageLabel, translateLanguages]
)
const onChange = useCallback((value: TranslateLanguageCode[]) => {

View File

@@ -24,17 +24,17 @@ export const OcrTesseractSettings = () => {
}
const [langs, setLangs] = useState<Partial<Record<TesseractLangCode, boolean>>>(provider.config?.langs ?? {})
const { translateLanguages } = useTranslate()
const { translateLanguages, getLanguageLabel } = useTranslate()
const options = useMemo(
() =>
translateLanguages
.map((lang) => ({
value: TESSERACT_LANG_MAP[lang.langCode],
label: lang.emoji + ' ' + lang.label()
label: lang.emoji + ' ' + getLanguageLabel(lang.langCode)
}))
.filter((option) => option.value),
[translateLanguages]
[getLanguageLabel, translateLanguages]
)
// TODO: type safe objectKeys

View File

@@ -6,7 +6,7 @@ import { DynamicVirtualList } from '@renderer/components/VirtualList'
import db from '@renderer/databases'
import useTranslate from '@renderer/hooks/useTranslate'
import { clearHistory, deleteHistory, updateTranslateHistory } from '@renderer/services/TranslateService'
import type { TranslateHistory, TranslateLanguage } from '@renderer/types'
import type { TranslateHistory } from '@renderer/types'
import { Drawer, Empty, Input, Popconfirm } from 'antd'
import dayjs from 'dayjs'
import { useLiveQuery } from 'dexie-react-hooks'
@@ -17,14 +17,9 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'rea
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
type DisplayedTranslateHistoryItem = TranslateHistory & {
_sourceLanguage: TranslateLanguage
_targetLanguage: TranslateLanguage
}
type TranslateHistoryProps = {
isOpen: boolean
onHistoryItemClick: (history: DisplayedTranslateHistoryItem) => void
onHistoryItemClick: (history: TranslateHistory) => void
onClose: () => void
}
@@ -35,39 +30,34 @@ const ITEM_HEIGHT = 160
const TranslateHistoryList: FC<TranslateHistoryProps> = ({ isOpen, onHistoryItemClick, onClose }) => {
const { t } = useTranslation()
const { getLanguageByLangcode } = useTranslate()
const { getLanguageLabel } = useTranslate()
const _translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), [])
const [search, setSearch] = useState('')
const [displayedHistory, setDisplayedHistory] = useState<DisplayedTranslateHistoryItem[]>([])
const [displayedHistory, setDisplayedHistory] = useState<TranslateHistory[]>([])
const [showStared, setShowStared] = useState<boolean>(false)
const translateHistory: DisplayedTranslateHistoryItem[] = useMemo(() => {
const translateHistory: TranslateHistory[] = useMemo(() => {
if (!_translateHistory) return []
return _translateHistory.map((item) => ({
...item,
_sourceLanguage: getLanguageByLangcode(item.sourceLanguage),
_targetLanguage: getLanguageByLangcode(item.targetLanguage),
createdAt: dayjs(item.createdAt).format('MM/DD HH:mm')
}))
}, [_translateHistory, getLanguageByLangcode])
}, [_translateHistory])
const searchFilter = useCallback(
(item: DisplayedTranslateHistoryItem) => {
(item: TranslateHistory) => {
if (isEmpty(search)) return true
const content = `${item._sourceLanguage.label()} ${item._targetLanguage.label()} ${item.sourceText} ${item.targetText} ${item.createdAt}`
const content = `${getLanguageLabel(item.sourceLanguage)} ${getLanguageLabel(item.targetLanguage)} ${item.sourceText} ${item.targetText} ${item.createdAt}`
return content.includes(search)
},
[search]
[getLanguageLabel, search]
)
const starFilter = useMemo(
() => (showStared ? (item: DisplayedTranslateHistoryItem) => !!item.star : () => true),
[showStared]
)
const starFilter = useMemo(() => (showStared ? (item: TranslateHistory) => !!item.star : () => true), [showStared])
const finalFilter = useCallback(
(item: DisplayedTranslateHistoryItem) => searchFilter(item) && starFilter(item),
(item: TranslateHistory) => searchFilter(item) && starFilter(item),
[searchFilter, starFilter]
)
@@ -179,8 +169,8 @@ const TranslateHistoryList: FC<TranslateHistoryProps> = ({ isOpen, onHistoryItem
<ColFlex className="h-full w-full flex-1 justify-between gap-1">
<Flex className="h-[30px] items-center justify-between">
<Flex className="items-center gap-1.5">
<HistoryListItemLanguage>{item._sourceLanguage.label()} </HistoryListItemLanguage>
<HistoryListItemLanguage>{item._targetLanguage.label()}</HistoryListItemLanguage>
<HistoryListItemLanguage>{getLanguageLabel(item.sourceLanguage)} </HistoryListItemLanguage>
<HistoryListItemLanguage>{getLanguageLabel(item.targetLanguage)}</HistoryListItemLanguage>
</Flex>
{/* tool bar */}
<Flex className="mt-2 items-center justify-end">

View File

@@ -1,5 +1,7 @@
import { PlusOutlined, SendOutlined, SwapOutlined } from '@ant-design/icons'
import { Button, Flex, Tooltip } from '@cherrystudio/ui'
import { useCache } from '@data/hooks/useCache'
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { CopyIcon } from '@renderer/components/Icons'
@@ -8,7 +10,6 @@ import ModelSelectButton from '@renderer/components/ModelSelectButton'
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
import { LanguagesEnum, UNKNOWN } from '@renderer/config/translate'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { useDrag } from '@renderer/hooks/useDrag'
import { useFiles } from '@renderer/hooks/useFiles'
@@ -18,18 +19,9 @@ import { useTimer } from '@renderer/hooks/useTimer'
import useTranslate from '@renderer/hooks/useTranslate'
import { estimateTextTokens } from '@renderer/services/TokenService'
import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
// import { setTranslateAbortKey, setTranslating as setTranslatingAction } from '@renderer/store/runtime'
import { setTranslatedContent as setTranslatedContentAction, setTranslateInput } from '@renderer/store/translate'
import type { FileMetadata, SupportedOcrFile } from '@renderer/types'
import {
type AutoDetectionMethod,
isSupportedOcrFile,
type Model,
type TranslateHistory,
type TranslateLanguage
} from '@renderer/types'
import { getFileExtension, isTextFile, runAsyncFunction, uuid } from '@renderer/utils'
import type { FileMetadata, SupportedOcrFile, TranslateLanguageCode } from '@renderer/types'
import { isSupportedOcrFile, type Model, type TranslateHistory, type TranslateLanguage } from '@renderer/types'
import { getFileExtension, isTextFile } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController'
import { isAbortError } from '@renderer/utils/error'
import { formatErrorMessage } from '@renderer/utils/error'
@@ -56,168 +48,112 @@ import TranslateSettings from './TranslateSettings'
const logger = loggerService.withContext('TranslatePage')
// cache variables
let _sourceLanguage: TranslateLanguage | 'auto' = 'auto'
let _targetLanguage = LanguagesEnum.enUS
const TranslatePage: FC = () => {
// hooks
const { t } = useTranslation()
const { translateModel, setTranslateModel } = useDefaultModel()
const { prompt, getLanguageByLangcode, settings } = useTranslate()
const { autoCopy } = settings
const { prompt, getLanguageByLangcode, getLanguageLabel } = useTranslate()
const { shikiMarkdownIt } = useCodeStyle()
const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] })
const { ocr } = useOcr()
const { setTimeoutTimer } = useTimer()
// Preferences
const [autoCopy] = usePreference('translate.settings.auto_copy')
const [enableMarkdown] = usePreference('translate.settings.enable_markdown')
const [isScrollSyncEnabled] = usePreference('translate.settings.scroll_sync')
// Cache
const [text, setText] = useCache('translate.input')
const [output, setOutput] = useCache('translate.output')
const [isDetecting, setIsDetecting] = useCache('translate.detecting')
const [translatingState, setTranslatingState] = useCache('translate.translating')
const { isTranslating, abortKey } = translatingState
const [bidirectional, setBidirectional] = useCache('translate.bidirectional')
const { enabled: isBidirectional } = bidirectional
// states
// const [text, setText] = useState(_text)
const [renderedMarkdown, setRenderedMarkdown] = useState<string>('')
const [copied, setCopied] = useTemporaryValue(false, 2000)
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false)
const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false)
const [isBidirectional, setIsBidirectional] = useState(false)
const [enableMarkdown, setEnableMarkdown] = useState(false)
const [bidirectionalPair, setBidirectionalPair] = useState<[TranslateLanguage, TranslateLanguage]>([
LanguagesEnum.enUS,
LanguagesEnum.zhCN
])
const [settingsVisible, setSettingsVisible] = useState(false)
const [detectedLanguage, setDetectedLanguage] = useState<TranslateLanguage | null>(null)
const [sourceLanguage, setSourceLanguage] = useState<TranslateLanguage | 'auto'>(_sourceLanguage)
const [targetLanguage, setTargetLanguage] = useState<TranslateLanguage>(_targetLanguage)
const [autoDetectionMethod, setAutoDetectionMethod] = useState<AutoDetectionMethod>('franc')
const [detectedLanguage, setDetectedLanguage] = useState<TranslateLanguageCode | null>(null)
const [sourceLanguage, setSourceLanguage] = useCache('translate.lang.source')
const [targetLanguage, setTargetLanguage] = useCache('translate.lang.target')
const [isProcessing, setIsProcessing] = useState(false)
const [translating, setTranslating] = useState(false)
const [abortKey, setTranslateAbortKey] = useState<string>('')
// redux states
const text = useAppSelector((state) => state.translate.translateInput)
const translatedContent = useAppSelector((state) => state.translate.translatedContent)
// const translating = useAppSelector((state) => state.runtime.translating)
// const abortKey = useAppSelector((state) => state.runtime.translateAbortKey)
// ref
const contentContainerRef = useRef<HTMLDivElement>(null)
const textAreaRef = useRef<TextAreaRef>(null)
const outputTextRef = useRef<HTMLDivElement>(null)
const isProgrammaticScroll = useRef(false)
const dispatch = useAppDispatch()
_sourceLanguage = sourceLanguage
_targetLanguage = targetLanguage
// 控制翻译模型切换
const handleModelChange = (model: Model) => {
setTranslateModel(model)
db.settings.put({ id: 'translate:model', value: model.id })
}
// 控制翻译状态
const setText = useCallback(
(input: string) => {
dispatch(setTranslateInput(input))
},
[dispatch]
)
const setTranslatedContent = useCallback(
(content: string) => {
dispatch(setTranslatedContentAction(content))
},
[dispatch]
)
// const setTranslating = useCallback(
// (translating: boolean) => {
// dispatch(setTranslatingAction(translating))
// },
// [dispatch]
// )
// 控制复制行为
const onCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(translatedContent)
await navigator.clipboard.writeText(output)
setCopied(true)
} catch (error) {
logger.error('Failed to copy text to clipboard:', error as Error)
window.toast.error(t('common.copy_failed'))
}
}, [setCopied, t, translatedContent])
}, [setCopied, t, output])
/**
* 翻译文本并保存历史记录,包含完整的异常处理,不会抛出异常
* @param text - 需要翻译的文本
* @param actualSourceLanguage - 源语言
* @param actualTargetLanguage - 目标语言
* Translate text and save history with full exception handling; never throws.
* This function is responsible for managing the translating state.
* No other part of the code should directly write to the translating state.
* @param text - Text to be translated
* @param actualSourceLanguage - Source language
* @param actualTargetLanguage - Target language
*/
const translate = useCallback(
async (
text: string,
actualSourceLanguage: TranslateLanguage,
actualTargetLanguage: TranslateLanguage
): Promise<void> => {
async (text: string, targetLanguage: TranslateLanguage): Promise<string | null> => {
try {
if (translating) {
return
}
let translated: string
const abortKey = uuid()
setTranslateAbortKey(abortKey)
try {
translated = await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100), abortKey)
} catch (e) {
if (isAbortError(e)) {
window.toast.info(t('translate.info.aborted'))
} else {
logger.error('Failed to translate text', e as Error)
window.toast.error(t('translate.error.failed') + ': ' + formatErrorMessage(e))
}
setTranslating(false)
return
}
window.toast.success(t('translate.complete'))
if (autoCopy) {
setTimeoutTimer(
'auto-copy',
async () => {
await onCopy()
},
100
)
}
try {
await saveTranslateHistory(text, translated, actualSourceLanguage.langCode, actualTargetLanguage.langCode)
} catch (e) {
logger.error('Failed to save translate history', e as Error)
window.toast.error(t('translate.history.error.save') + ': ' + formatErrorMessage(e))
}
const abortKey = crypto.randomUUID()
setTranslatingState({ isTranslating: true, abortKey })
// This await is necessary. Finally must be done after the promise is settled.
return await translateText(text, targetLanguage, throttle(setOutput, 100), abortKey)
} catch (e) {
logger.error('Failed to translate', e as Error)
window.toast.error(t('translate.error.unknown') + ': ' + formatErrorMessage(e))
if (isAbortError(e)) {
window.toast.info(t('translate.info.aborted'))
} else {
logger.error('Failed to translate text', e as Error)
window.toast.error(t('translate.error.failed') + ': ' + formatErrorMessage(e))
}
return null
} finally {
setTranslatingState({ isTranslating: false, abortKey: null })
}
},
[autoCopy, onCopy, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating]
[t, setOutput, setTranslatingState]
)
// 控制翻译按钮是否可用
const couldTranslate = useMemo(() => {
return !(
!text.trim() ||
(sourceLanguage !== 'auto' && sourceLanguage.langCode === UNKNOWN.langCode) ||
targetLanguage.langCode === UNKNOWN.langCode ||
(isBidirectional &&
(bidirectionalPair[0].langCode === UNKNOWN.langCode || bidirectionalPair[1].langCode === UNKNOWN.langCode)) ||
isProcessing
(sourceLanguage !== 'auto' && sourceLanguage === UNKNOWN.langCode) ||
targetLanguage === UNKNOWN.langCode ||
(isBidirectional && (bidirectional.origin === UNKNOWN.langCode || bidirectional.target === UNKNOWN.langCode)) ||
isProcessing ||
isDetecting
)
}, [bidirectionalPair, isBidirectional, isProcessing, sourceLanguage, targetLanguage.langCode, text])
}, [
bidirectional.origin,
bidirectional.target,
isBidirectional,
isDetecting,
isProcessing,
sourceLanguage,
targetLanguage,
text
])
// 控制翻译按钮,翻译前进行校验
const onTranslate = useCallback(async () => {
@@ -228,19 +164,26 @@ const TranslatePage: FC = () => {
return
}
setTranslating(true)
let actualSourceLanguage: TranslateLanguageCode
try {
setIsDetecting(true)
// 确定源语言:如果用户选择了特定语言,使用用户选择的;如果选择'auto',则自动检测
let actualSourceLanguage: TranslateLanguage
if (sourceLanguage === 'auto') {
actualSourceLanguage = getLanguageByLangcode(await detectLanguage(text))
actualSourceLanguage = await detectLanguage(text)
setDetectedLanguage(actualSourceLanguage)
} else {
actualSourceLanguage = sourceLanguage
}
} catch (error) {
logger.error('Language detecting error:', error as Error)
window.toast.error(t('translate.error.failed') + ': ' + formatErrorMessage(error))
return
} finally {
setIsDetecting(false)
}
const result = determineTargetLanguage(actualSourceLanguage, targetLanguage, isBidirectional, bidirectionalPair)
try {
const result = determineTargetLanguage(actualSourceLanguage, targetLanguage, bidirectional)
if (!result.success) {
let errorMessage = ''
if (result.errorType === 'same_language') {
@@ -253,25 +196,48 @@ const TranslatePage: FC = () => {
return
}
const actualTargetLanguage = result.language as TranslateLanguage
const actualTargetLanguage = result.language
if (isBidirectional) {
setTargetLanguage(actualTargetLanguage)
}
const translated = await translate(text, getLanguageByLangcode(actualTargetLanguage))
if (translated === null) {
return
}
await translate(text, actualSourceLanguage, actualTargetLanguage)
if (autoCopy) {
setTimeoutTimer(
'auto-copy',
async () => {
await onCopy()
},
100
)
}
try {
await saveTranslateHistory(text, translated, actualSourceLanguage, actualTargetLanguage)
} catch (e) {
logger.error('Failed to save translate history', e as Error)
window.toast.error(t('translate.history.error.save') + ': ' + formatErrorMessage(e))
}
window.toast.success(t('translate.complete'))
} catch (error) {
logger.error('Translation error:', error as Error)
logger.error('Language detecting error:', error as Error)
window.toast.error(t('translate.error.failed') + ': ' + formatErrorMessage(error))
return
} finally {
setTranslating(false)
}
}, [
bidirectionalPair,
autoCopy,
bidirectional,
couldTranslate,
getLanguageByLangcode,
isBidirectional,
setTranslating,
onCopy,
setIsDetecting,
setTargetLanguage,
setTimeoutTimer,
sourceLanguage,
t,
targetLanguage,
@@ -289,24 +255,16 @@ const TranslatePage: FC = () => {
abortCompletion(abortKey)
}
// 控制双向翻译切换
const toggleBidirectional = (value: boolean) => {
setIsBidirectional(value)
db.settings.put({ id: 'translate:bidirectional:enabled', value })
}
// 控制历史记录点击
const onHistoryItemClick = (
history: TranslateHistory & { _sourceLanguage: TranslateLanguage; _targetLanguage: TranslateLanguage }
) => {
const onHistoryItemClick = (history: TranslateHistory) => {
setText(history.sourceText)
setTranslatedContent(history.targetText)
if (history._sourceLanguage === UNKNOWN) {
setOutput(history.targetText)
if (history.sourceLanguage === UNKNOWN.langCode) {
setSourceLanguage('auto')
} else {
setSourceLanguage(history._sourceLanguage)
setSourceLanguage(history.sourceLanguage)
}
setTargetLanguage(history._targetLanguage)
setTargetLanguage(history.targetLanguage)
setHistoryDrawerVisible(false)
}
@@ -314,7 +272,7 @@ const TranslatePage: FC = () => {
/** 与自动检测相关的交换条件检查 */
const couldExchangeAuto = useMemo(
() =>
(sourceLanguage === 'auto' && detectedLanguage && detectedLanguage.langCode !== UNKNOWN.langCode) ||
(sourceLanguage === 'auto' && detectedLanguage && detectedLanguage !== UNKNOWN.langCode) ||
sourceLanguage !== 'auto',
[detectedLanguage, sourceLanguage]
)
@@ -330,24 +288,24 @@ const TranslatePage: FC = () => {
window.toast.error(t('translate.error.invalid_source'))
return
}
if (source.langCode === UNKNOWN.langCode) {
if (source === UNKNOWN.langCode) {
window.toast.error(t('translate.error.detect.unknown'))
return
}
setSourceLanguage(targetLanguage)
setTargetLanguage(source)
}, [couldExchangeAuto, detectedLanguage, sourceLanguage, t, targetLanguage])
}, [couldExchangeAuto, detectedLanguage, setSourceLanguage, setTargetLanguage, sourceLanguage, t, targetLanguage])
useEffect(() => {
isEmpty(text) && setTranslatedContent('')
}, [setTranslatedContent, text])
isEmpty(text) && setOutput('')
}, [setOutput, text])
// Render markdown content when result or enableMarkdown changes
// 控制Markdown渲染
useEffect(() => {
if (enableMarkdown && translatedContent) {
if (enableMarkdown && output) {
let isMounted = true
shikiMarkdownIt(translatedContent).then((rendered) => {
shikiMarkdownIt(output).then((rendered) => {
if (isMounted) {
setRenderedMarkdown(rendered)
}
@@ -359,71 +317,7 @@ const TranslatePage: FC = () => {
setRenderedMarkdown('')
return undefined
}
}, [enableMarkdown, shikiMarkdownIt, translatedContent])
// 控制设置加载
useEffect(() => {
runAsyncFunction(async () => {
const targetLang = await db.settings.get({ id: 'translate:target:language' })
targetLang && setTargetLanguage(getLanguageByLangcode(targetLang.value))
const sourceLang = await db.settings.get({ id: 'translate:source:language' })
sourceLang &&
setSourceLanguage(sourceLang.value === 'auto' ? sourceLang.value : getLanguageByLangcode(sourceLang.value))
const bidirectionalPairSetting = await db.settings.get({ id: 'translate:bidirectional:pair' })
if (bidirectionalPairSetting) {
const langPair = bidirectionalPairSetting.value
let source: undefined | TranslateLanguage
let target: undefined | TranslateLanguage
if (Array.isArray(langPair) && langPair.length === 2 && langPair[0] !== langPair[1]) {
source = getLanguageByLangcode(langPair[0])
target = getLanguageByLangcode(langPair[1])
}
if (source && target) {
setBidirectionalPair([source, target])
} else {
const defaultPair: [TranslateLanguage, TranslateLanguage] = [LanguagesEnum.enUS, LanguagesEnum.zhCN]
setBidirectionalPair(defaultPair)
db.settings.put({
id: 'translate:bidirectional:pair',
value: [defaultPair[0].langCode, defaultPair[1].langCode]
})
}
}
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)
const markdownSetting = await db.settings.get({ id: 'translate:markdown:enabled' })
setEnableMarkdown(markdownSetting ? markdownSetting.value : false)
const autoDetectionMethodSetting = await db.settings.get({ id: 'translate:detect:method' })
if (autoDetectionMethodSetting) {
setAutoDetectionMethod(autoDetectionMethodSetting.value)
} else {
setAutoDetectionMethod('franc')
db.settings.put({ id: 'translate:detect:method', value: 'franc' })
}
})
}, [getLanguageByLangcode])
// 控制设置同步
const updateAutoDetectionMethod = async (method: AutoDetectionMethod) => {
try {
await db.settings.put({ id: 'translate:detect:method', value: method })
setAutoDetectionMethod(method)
} catch (e) {
logger.error('Failed to update auto detection method setting.', e as Error)
window.toast.error(t('translate.error.detect.update_setting') + formatErrorMessage(e))
}
}
}, [enableMarkdown, shikiMarkdownIt, output])
// 控制Enter触发翻译
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -440,28 +334,31 @@ const TranslatePage: FC = () => {
// 获取目标语言显示
const getLanguageDisplay = () => {
try {
if (isBidirectional) {
if (isBidirectional) {
try {
return (
<Flex className="min-w-40 items-center">
<BidirectionalLanguageDisplay>
{`${bidirectionalPair[0].label()}${bidirectionalPair[1].label()}`}
{`${getLanguageLabel(bidirectional.origin)}${getLanguageLabel(bidirectional.target)}`}
</BidirectionalLanguageDisplay>
</Flex>
)
} catch (error) {
logger.error('Error getting language display:', error as Error)
setBidirectional({
enabled: true,
origin: LanguagesEnum.enUS.langCode,
target: LanguagesEnum.zhCN.langCode
})
}
} catch (error) {
logger.error('Error getting language display:', error as Error)
setBidirectionalPair([LanguagesEnum.enUS, LanguagesEnum.zhCN])
}
return (
<LanguageSelect
style={{ width: 200 }}
value={targetLanguage.langCode}
value={targetLanguage}
onChange={(value) => {
setTargetLanguage(getLanguageByLangcode(value))
db.settings.put({ id: 'translate:target:language', value })
setTargetLanguage(value)
}}
/>
)
@@ -700,18 +597,16 @@ const TranslatePage: FC = () => {
<LanguageSelect
showSearch
style={{ width: 200 }}
value={sourceLanguage !== 'auto' ? sourceLanguage.langCode : 'auto'}
value={sourceLanguage}
optionFilterProp="label"
onChange={(value) => {
if (value !== 'auto') setSourceLanguage(getLanguageByLangcode(value))
else setSourceLanguage('auto')
db.settings.put({ id: 'translate:source:language', value })
setSourceLanguage(value)
}}
extraOptionsBefore={[
{
value: 'auto',
label: detectedLanguage
? `${t('translate.detected.language')} (${detectedLanguage.label()})`
? `${t('translate.detected.language')} (${getLanguageLabel(detectedLanguage)})`
: t('translate.detected.language')
}
]}
@@ -728,7 +623,7 @@ const TranslatePage: FC = () => {
</Tooltip>
{getLanguageDisplay()}
<TranslateButton
translating={translating}
translating={isTranslating}
onTranslate={onTranslate}
couldTranslate={couldTranslate}
onAbort={onAbort}
@@ -780,7 +675,7 @@ const TranslatePage: FC = () => {
onKeyDown={onKeyDown}
onScroll={handleInputScroll}
onPaste={onPaste}
disabled={translating}
disabled={isTranslating}
spellCheck={false}
allowClear
/>
@@ -799,19 +694,19 @@ const TranslatePage: FC = () => {
size="sm"
className="copy-button"
onPress={onCopy}
isDisabled={!translatedContent}
isDisabled={!output}
startContent={copied ? <Check size={16} color="var(--color-primary)" /> : <CopyIcon size={16} />}
isIconOnly
/>
<OutputText ref={outputTextRef} onScroll={handleOutputScroll} className={'selectable'}>
{!translatedContent ? (
{!output ? (
<div style={{ color: 'var(--color-text-3)', userSelect: 'none' }}>
{t('translate.output.placeholder')}
</div>
) : enableMarkdown ? (
<div className="markdown" dangerouslySetInnerHTML={{ __html: renderedMarkdown }} />
) : (
<div className="plain">{translatedContent}</div>
<div className="plain">{output}</div>
)}
</OutputText>
</OutputContainer>
@@ -821,17 +716,7 @@ const TranslatePage: FC = () => {
<TranslateSettings
visible={settingsVisible}
onClose={() => setSettingsVisible(false)}
isScrollSyncEnabled={isScrollSyncEnabled}
setIsScrollSyncEnabled={setIsScrollSyncEnabled}
isBidirectional={isBidirectional}
setIsBidirectional={toggleBidirectional}
enableMarkdown={enableMarkdown}
setEnableMarkdown={setEnableMarkdown}
bidirectionalPair={bidirectionalPair}
setBidirectionalPair={setBidirectionalPair}
translateModel={translateModel}
autoDetectionMethod={autoDetectionMethod}
setAutoDetectionMethod={updateAutoDetectionMethod}
/>
</Container>
)

View File

@@ -1,53 +1,27 @@
import { Button, ColFlex, Flex, HelpTooltip, RowFlex, Switch, Tooltip } from '@cherrystudio/ui'
import { useCache } from '@data/hooks/useCache'
import { usePreference } from '@data/hooks/usePreference'
import LanguageSelect from '@renderer/components/LanguageSelect'
import db from '@renderer/databases'
import useTranslate from '@renderer/hooks/useTranslate'
import type { AutoDetectionMethod, Model, TranslateLanguage } from '@renderer/types'
import type { Model } from '@renderer/types'
import { Modal, Radio, Space } from 'antd'
import type { FC } from 'react'
import { memo, useEffect, useState } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import TranslateSettingsPopup from '../settings/TranslateSettingsPopup/TranslateSettingsPopup'
// TODO: Just don't send so many props. Migrate them to redux.
const TranslateSettings: FC<{
visible: boolean
onClose: () => void
isScrollSyncEnabled: boolean
setIsScrollSyncEnabled: (value: boolean) => void
isBidirectional: boolean
setIsBidirectional: (value: boolean) => void
enableMarkdown: boolean
setEnableMarkdown: (value: boolean) => void
bidirectionalPair: [TranslateLanguage, TranslateLanguage]
setBidirectionalPair: (value: [TranslateLanguage, TranslateLanguage]) => void
translateModel: Model | undefined
autoDetectionMethod: AutoDetectionMethod
setAutoDetectionMethod: (method: AutoDetectionMethod) => void
}> = ({
visible,
onClose,
isScrollSyncEnabled,
setIsScrollSyncEnabled,
isBidirectional,
setIsBidirectional,
enableMarkdown,
setEnableMarkdown,
bidirectionalPair,
setBidirectionalPair,
autoDetectionMethod,
setAutoDetectionMethod
}) => {
}> = ({ visible, onClose }) => {
const { t } = useTranslation()
const [localPair, setLocalPair] = useState<[TranslateLanguage, TranslateLanguage]>(bidirectionalPair)
const { getLanguageByLangcode, settings, updateSettings } = useTranslate()
const { autoCopy } = settings
useEffect(() => {
setLocalPair(bidirectionalPair)
}, [bidirectionalPair, visible])
const [autoCopy, setAutoCopy] = usePreference('translate.settings.auto_copy')
const [autoDetectionMethod, setAutoDetectionMethod] = usePreference('translate.settings.auto_detection_method')
const [enableMarkdown, setEnableMarkdown] = usePreference('translate.settings.enable_markdown')
const [isScrollSyncEnabled, setIsScrollSyncEnabled] = usePreference('translate.settings.scroll_sync')
const [bidirectional, setBidirectional] = useCache('translate.bidirectional')
const { enabled: isBidirectional } = bidirectional
const onMoreSetting = () => {
onClose()
TranslateSettingsPopup.show()
@@ -70,7 +44,6 @@ const TranslateSettings: FC<{
isSelected={enableMarkdown}
onValueChange={(checked) => {
setEnableMarkdown(checked)
db.settings.put({ id: 'translate:markdown:enabled', value: checked })
}}
/>
</Flex>
@@ -83,7 +56,7 @@ const TranslateSettings: FC<{
isSelected={autoCopy}
color="primary"
onValueChange={(isSelected) => {
updateSettings({ autoCopy: isSelected })
setAutoCopy(isSelected)
}}
/>
</RowFlex>
@@ -97,7 +70,6 @@ const TranslateSettings: FC<{
color="primary"
onValueChange={(isSelected) => {
setIsScrollSyncEnabled(isSelected)
db.settings.put({ id: 'translate:scroll:sync', value: isSelected })
}}
/>
</Flex>
@@ -148,7 +120,7 @@ const TranslateSettings: FC<{
isSelected={isBidirectional}
color="primary"
onValueChange={(isSelected) => {
setIsBidirectional(isSelected)
setBidirectional({ ...bidirectional, enabled: isSelected })
// 双向翻译设置不需要持久化,它只是界面状态
}}
/>
@@ -158,36 +130,32 @@ const TranslateSettings: FC<{
<Flex className="items-center justify-between gap-2.5">
<LanguageSelect
style={{ flex: 1 }}
value={localPair[0].langCode}
value={bidirectional.origin}
onChange={(value) => {
const newPair: [TranslateLanguage, TranslateLanguage] = [getLanguageByLangcode(value), localPair[1]]
if (newPair[0] === newPair[1]) {
if (value === bidirectional.target) {
window.toast.warning(t('translate.language.same'))
return
}
setLocalPair(newPair)
setBidirectionalPair(newPair)
db.settings.put({
id: 'translate:bidirectional:pair',
value: [newPair[0].langCode, newPair[1].langCode]
setBidirectional({
...bidirectional,
origin: value,
target: bidirectional.target
})
}}
/>
<span></span>
<LanguageSelect
style={{ flex: 1 }}
value={localPair[1].langCode}
value={bidirectional.target}
onChange={(value) => {
const newPair: [TranslateLanguage, TranslateLanguage] = [localPair[0], getLanguageByLangcode(value)]
if (newPair[0] === newPair[1]) {
if (bidirectional.origin === value) {
window.toast.warning(t('translate.language.same'))
return
}
setLocalPair(newPair)
setBidirectionalPair(newPair)
db.settings.put({
id: 'translate:bidirectional:pair',
value: [newPair[0].langCode, newPair[1].langCode]
setBidirectional({
...bidirectional,
origin: bidirectional.origin,
target: value
})
}}
/>

View File

@@ -1,10 +1,9 @@
import type { PayloadAction } from '@reduxjs/toolkit'
/** @deprecated All migrated. */
import { createSlice } from '@reduxjs/toolkit'
export interface TranslateState {
translateInput: string
translatedContent: string
// TODO: #9749
settings: {
autoCopy: boolean
}
@@ -22,19 +21,17 @@ const translateSlice = createSlice({
name: 'translate',
initialState,
reducers: {
setTranslateInput: (state, action: PayloadAction<string>) => {
state.translateInput = action.payload
},
setTranslatedContent: (state, action: PayloadAction<string>) => {
state.translatedContent = action.payload
},
updateSettings: (state, action: PayloadAction<Partial<TranslateState['settings']>>) => {
const update = action.payload
Object.assign(state.settings, update)
}
// setTranslateInput: (state, action: PayloadAction<string>) => {
// state.translateInput = action.payload
// },
// setTranslatedContent: (state, action: PayloadAction<string>) => {
// state.translatedContent = action.payload
// },
// updateSettings: (state, action: PayloadAction<Partial<TranslateState['settings']>>) => {
// const update = action.payload
// Object.assign(state.settings, update)
// }
}
})
export const { setTranslateInput, setTranslatedContent, updateSettings } = translateSlice.actions
export default translateSlice.reducer

View File

@@ -485,7 +485,6 @@ export type TranslateLanguageCode = string
export type TranslateLanguage = {
value: string
langCode: TranslateLanguageCode
label: () => string
emoji: string
}
@@ -507,18 +506,6 @@ export type CustomTranslateLanguage = {
emoji: string
}
export const AutoDetectionMethods = {
franc: 'franc',
llm: 'llm',
auto: 'auto'
} as const
export type AutoDetectionMethod = keyof typeof AutoDetectionMethods
export const isAutoDetectionMethod = (method: string): method is AutoDetectionMethod => {
return Object.hasOwn(AutoDetectionMethods, method)
}
// by fullex @ data refactor
// export type SidebarIcon =
// | 'assistants'

View File

@@ -11,6 +11,7 @@ import type { Assistant, TranslateLanguage, TranslateLanguageCode } from '@rende
import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import { LANG_DETECT_PROMPT } from '@shared/config/prompts'
import type { CacheTranslateBidirectional } from '@shared/data/cache/cacheValueTypes'
import { franc } from 'franc-min'
import type { RefObject } from 'react'
import React from 'react'
@@ -128,60 +129,68 @@ const detectLanguageByFranc = (inputText: string): TranslateLanguageCode => {
}
/**
* 获取双向翻译的目标语言
* @param sourceLanguage 检测到的源语言
* @param languagePair 配置的语言对
* @returns 目标语言
* Determine the target language for bidirectional translation.
* When the source language matches one side of the pair, the opposite side is returned.
* @param sourceLanguage The detected source language code
* @param languagePair The configured bidirectional language pair
* @returns The target language code to translate into
*/
export const getTargetLanguageForBidirectional = (
sourceLanguage: TranslateLanguage,
languagePair: [TranslateLanguage, TranslateLanguage]
): TranslateLanguage => {
if (sourceLanguage.langCode === languagePair[0].langCode) {
return languagePair[1]
} else if (sourceLanguage.langCode === languagePair[1].langCode) {
return languagePair[0]
sourceLanguage: TranslateLanguageCode,
languagePair: CacheTranslateBidirectional
): TranslateLanguageCode => {
const { origin, target } = languagePair
if (sourceLanguage === origin) {
return target
} else if (sourceLanguage === target) {
return origin
}
return languagePair[0] !== sourceLanguage ? languagePair[0] : languagePair[1]
return origin !== sourceLanguage ? origin : target
}
/**
* 检查源语言是否在配置的语言对中
* @param sourceLanguage 检测到的源语言
* @param languagePair 配置的语言对
* @returns 是否在语言对中
* Check if the source language is within the configured language pair
* @param sourceLanguage The detected source language code
* @param languagePair The configured bidirectional language pair
* @returns true if the source language is in the pair, false otherwise
*/
export const isLanguageInPair = (
sourceLanguage: TranslateLanguage,
languagePair: [TranslateLanguage, TranslateLanguage]
sourceLanguage: TranslateLanguageCode,
languagePair: CacheTranslateBidirectional
): boolean => {
return [languagePair[0].langCode, languagePair[1].langCode].includes(sourceLanguage.langCode)
return [languagePair.origin, languagePair.target].includes(sourceLanguage)
}
type DetermineTargetLanguageReturn =
| { success: true; language: TranslateLanguageCode; errorType?: never }
| {
success: false
errorType: 'same_language' | 'not_in_pair'
}
/**
* 确定翻译的目标语言
* @param sourceLanguage 检测到的源语言
* @param targetLanguage 用户设置的目标语言
* @param isBidirectional 是否开启双向翻译
* @param bidirectionalPair 双向翻译的语言对
* @returns 处理结果对象
* Determine the target language for translation
* @param sourceLanguage The detected source language code
* @param targetLanguage The user-set target language code
* @param bidirectional The bidirectional translation configuration
* @returns An object indicating success or failure, including the target language code if successful, or an error type if failed
*/
export const determineTargetLanguage = (
sourceLanguage: TranslateLanguage,
targetLanguage: TranslateLanguage,
isBidirectional: boolean,
bidirectionalPair: [TranslateLanguage, TranslateLanguage]
): { success: boolean; language?: TranslateLanguage; errorType?: 'same_language' | 'not_in_pair' } => {
sourceLanguage: TranslateLanguageCode,
targetLanguage: TranslateLanguageCode,
bidirectional: CacheTranslateBidirectional
): DetermineTargetLanguageReturn => {
const isBidirectional = bidirectional.enabled
if (isBidirectional) {
if (!isLanguageInPair(sourceLanguage, bidirectionalPair)) {
if (!isLanguageInPair(sourceLanguage, bidirectional)) {
return { success: false, errorType: 'not_in_pair' }
}
return {
success: true,
language: getTargetLanguageForBidirectional(sourceLanguage, bidirectionalPair)
language: getTargetLanguageForBidirectional(sourceLanguage, bidirectional)
}
} else {
if (sourceLanguage.langCode === targetLanguage.langCode) {
if (sourceLanguage === targetLanguage) {
return { success: false, errorType: 'same_language' }
}
return { success: true, language: targetLanguage }
@@ -251,7 +260,6 @@ export const getTranslateOptions = async () => {
// 转换为Language类型
const transformedCustomLangs: TranslateLanguage[] = customLanguages.map((item) => ({
value: item.value,
label: () => item.value,
emoji: item.emoji,
langCode: item.langCode
}))

View File

@@ -4,17 +4,15 @@ import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import CopyButton from '@renderer/components/CopyButton'
import LanguageSelect from '@renderer/components/LanguageSelect'
import { LanguagesEnum, UNKNOWN } from '@renderer/config/translate'
import db from '@renderer/databases'
import { UNKNOWN } from '@renderer/config/translate'
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import useTranslate from '@renderer/hooks/useTranslate'
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import type { Assistant, Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types'
import type { Assistant, Topic, TranslateAssistant, TranslateLanguageCode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController'
import { detectLanguage } from '@renderer/utils/translate'
import { defaultLanguage } from '@shared/config/constant'
import type { SelectionActionItem } from '@shared/data/preference/preferenceTypes'
import { ArrowRightFromLine, ArrowRightToLine, ChevronDown, CircleHelp, Globe } from 'lucide-react'
import type { FC } from 'react'
@@ -34,11 +32,9 @@ const logger = loggerService.withContext('ActionTranslate')
const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const { t } = useTranslation()
const [language] = usePreference('app.language')
const [translateModelPrompt] = usePreference('feature.translate.model_prompt')
const [targetLanguage, setTargetLanguage] = useState<TranslateLanguage>(LanguagesEnum.enUS)
const [alterLanguage, setAlterLanguage] = useState<TranslateLanguage>(LanguagesEnum.zhCN)
const [targetLangs, setTargetLangs] = usePreference('translate.settings.target_langs')
const { target: targetLanguage, alter: alterLanguage } = targetLangs
const [error, setError] = useState('')
const [showOriginal, setShowOriginal] = useState(false)
@@ -53,36 +49,6 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const topicRef = useRef<Topic | null>(null)
const askId = useRef('')
useEffect(() => {
runAsyncFunction(async () => {
const biDirectionLangPair = await db.settings.get({ id: 'translate:bidirectional:pair' })
let targetLang: TranslateLanguage
let alterLang: TranslateLanguage
if (!biDirectionLangPair || !biDirectionLangPair.value[0]) {
const lang = getLanguageByLangcode(language || navigator.language || defaultLanguage)
if (lang !== UNKNOWN) {
targetLang = lang
} else {
logger.warn('Fallback to zh-CN')
targetLang = LanguagesEnum.zhCN
}
} else {
targetLang = getLanguageByLangcode(biDirectionLangPair.value[0])
}
if (!biDirectionLangPair || !biDirectionLangPair.value[1]) {
alterLang = LanguagesEnum.enUS
} else {
alterLang = getLanguageByLangcode(biDirectionLangPair.value[1])
}
setTargetLanguage(targetLang)
setAlterLanguage(alterLang)
})
}, [getLanguageByLangcode, language])
// Initialize values only once when action changes
useEffect(() => {
if (initialized.current || !action.selectedText) return
@@ -90,14 +56,22 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
runAsyncFunction(async () => {
// Initialize assistant
const currentAssistant = await getDefaultTranslateAssistant(targetLanguage, action.selectedText!)
let currentAssistant: TranslateAssistant
try {
currentAssistant = await getDefaultTranslateAssistant(
getLanguageByLangcode(targetLanguage),
action.selectedText!
)
} catch (e) {
logger.error('Failed to initialize assistant', { targetLanguage, text: action.selectedText })
return
}
assistantRef.current = currentAssistant
// Initialize topic
topicRef.current = getDefaultTopic(currentAssistant.id)
})
}, [action, targetLanguage, translateModelPrompt])
}, [action, getLanguageByLangcode, targetLanguage, translateModelPrompt])
const fetchResult = useCallback(async () => {
if (!assistantRef.current || !topicRef.current || !action.selectedText) return
@@ -130,24 +104,31 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
return
}
let translateLang: TranslateLanguage
let translateLang: TranslateLanguageCode
if (sourceLanguageCode === UNKNOWN.langCode) {
logger.debug('Unknown source language. Just use target language.')
translateLang = targetLanguage
} else {
logger.debug('Detected Language: ', { sourceLanguage: sourceLanguageCode })
if (sourceLanguageCode === targetLanguage.langCode) {
if (sourceLanguageCode === targetLanguage) {
translateLang = alterLanguage
} else {
translateLang = targetLanguage
}
}
const assistant = await getDefaultTranslateAssistant(translateLang, action.selectedText)
assistantRef.current = assistant
let assistant: TranslateAssistant
try {
assistant = await getDefaultTranslateAssistant(getLanguageByLangcode(translateLang), action.selectedText)
assistantRef.current = assistant
} catch (err) {
onError(err instanceof Error ? err : new Error('An error occurred'))
logger.error('Error when getting assistant:', err as Error)
return
}
processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError)
}, [action, targetLanguage, alterLanguage, scrollToBottom])
}, [action.selectedText, getLanguageByLangcode, scrollToBottom, targetLanguage, alterLanguage])
useEffect(() => {
fetchResult()
@@ -161,11 +142,11 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null
}, [allMessages])
const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => {
setTargetLanguage(targetLanguage)
setAlterLanguage(alterLanguage)
db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage.langCode, alterLanguage.langCode] })
const handleChangeLanguage = (targetLanguage: TranslateLanguageCode, alterLanguage: TranslateLanguageCode) => {
setTargetLangs({
target: targetLanguage,
alter: alterLanguage
})
}
const handlePause = () => {
@@ -191,24 +172,24 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
<ArrowRightToLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
<Tooltip placement="bottom" content={t('translate.target_language')}>
<LanguageSelect
value={targetLanguage.langCode}
value={targetLanguage}
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
listHeight={160}
title={t('translate.target_language')}
optionFilterProp="label"
onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)}
onChange={(value) => handleChangeLanguage(value, alterLanguage)}
disabled={isLoading}
/>
</Tooltip>
<ArrowRightFromLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
<Tooltip placement="bottom" content={t('translate.alter_language')}>
<LanguageSelect
value={alterLanguage.langCode}
value={alterLanguage}
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
listHeight={160}
title={t('translate.alter_language')}
optionFilterProp="label"
onChange={(value) => handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))}
onChange={(value) => handleChangeLanguage(targetLanguage, value)}
disabled={isLoading}
/>
</Tooltip>