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
+24
View File
@@ -1,3 +1,5 @@
import type { TranslateLanguageCode } from '@types'
import type * as CacheValueTypes from './cacheValueTypes' import type * as CacheValueTypes from './cacheValueTypes'
/** /**
@@ -27,6 +29,15 @@ export type UseCacheSchema = {
'topic.renaming': string[] 'topic.renaming': string[]
'topic.newly_renamed': 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) // Test keys (for dataRefactorTest window)
// TODO: remove after testing // TODO: remove after testing
'test-hook-memory-1': string 'test-hook-memory-1': string
@@ -72,6 +83,19 @@ export const DefaultUseCache: UseCacheSchema = {
'topic.renaming': [], 'topic.renaming': [],
'topic.newly_renamed': [], '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) // Test keys (for dataRefactorTest window)
// TODO: remove after testing // TODO: remove after testing
'test-hook-memory-1': 'default-memory-value', 'test-hook-memory-1': 'default-memory-value',
+15 -1
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' import type { UpdateInfo } from 'builder-util-runtime'
export type CacheAppUpdateState = { 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 // The actual type checking will be done at runtime by the cache system
export type CacheMinAppType = MinAppType export type CacheMinAppType = MinAppType
export type CacheTopic = Topic export type CacheTopic = Topic
export type CacheTranslating =
| {
isTranslating: true
abortKey: string
}
| {
isTranslating: false
abortKey: null
}
export type CacheTranslateBidirectional = {
enabled: boolean
origin: TranslateLanguageCode
target: TranslateLanguageCode
}
@@ -395,6 +395,14 @@ export interface PreferenceSchemas {
'topic.tab.show': boolean 'topic.tab.show': boolean
// redux/settings/showTopicTime // redux/settings/showTopicTime
'topic.tab.show_time': boolean '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 // redux/settings/customCss
'ui.custom_css': string 'ui.custom_css': string
// redux/settings/navbarPosition // redux/settings/navbarPosition
@@ -654,6 +662,14 @@ export const DefaultPreferences: PreferenceSchemas = {
'topic.tab.pin_to_top': false, 'topic.tab.pin_to_top': false,
'topic.tab.show': true, 'topic.tab.show': true,
'topic.tab.show_time': false, '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.custom_css': '',
'ui.navbar.position': 'top', 'ui.navbar.position': 'top',
'ui.sidebar.icons.invisible': [], 'ui.sidebar.icons.invisible': [],
@@ -1,3 +1,6 @@
import type { TranslateLanguageCode } from '@types'
import * as z from 'zod'
import type { PreferenceSchemas } from './preferenceSchemas' import type { PreferenceSchemas } from './preferenceSchemas'
export type PreferenceDefaultScopeType = PreferenceSchemas['default'] export type PreferenceDefaultScopeType = PreferenceSchemas['default']
@@ -85,3 +88,13 @@ export type ChatMessageNavigationMode = 'none' | 'buttons' | 'anchor'
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
export type MultiModelGridPopoverTrigger = 'hover' | 'click' 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
}
@@ -736,7 +736,8 @@ export const REDUX_STORE_MAPPINGS = {
originalKey: 'shortcuts.exit_fullscreen', originalKey: 'shortcuts.exit_fullscreen',
targetKey: 'shortcut.app.exit_fullscreen' targetKey: 'shortcut.app.exit_fullscreen'
} }
] ],
translate: [{ originalKey: 'settings.autoCopy', targetKey: 'translate.settings.auto_copy' }]
} as const } as const
// === AUTO-GENERATED CONTENT END === // === AUTO-GENERATED CONTENT END ===
+14 -11
View File
@@ -18,19 +18,22 @@ type Props = {
} & Omit<SelectProps, 'labelRender' | 'options'> } & Omit<SelectProps, 'labelRender' | 'options'>
const LanguageSelect = (props: Props) => { const LanguageSelect = (props: Props) => {
const { translateLanguages } = useTranslate() const { translateLanguages, getLanguageLabel } = useTranslate()
const { extraOptionsAfter, extraOptionsBefore, languageRenderer, ...restProps } = props const { extraOptionsAfter, extraOptionsBefore, languageRenderer, ...restProps } = props
const defaultLanguageRenderer = useCallback((lang: TranslateLanguage) => { const defaultLanguageRenderer = useCallback(
return ( (lang: TranslateLanguage) => {
<Space.Compact direction="horizontal" block> return (
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}> <Space.Compact direction="horizontal" block>
{lang.emoji} <span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
</span> {lang.emoji}
{lang.label()} </span>
</Space.Compact> {getLanguageLabel(lang.langCode)}
) </Space.Compact>
}, []) )
},
[getLanguageLabel]
)
const labelRender = (props) => { const labelRender = (props) => {
const { label } = props const { label } = props
@@ -24,7 +24,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
const [isTranslating, setIsTranslating] = useState(false) const [isTranslating, setIsTranslating] = useState(false)
const [targetLanguage] = usePreference('feature.translate.target_language') const [targetLanguage] = usePreference('feature.translate.target_language')
const [showTranslateConfirm] = usePreference('chat.input.translate.show_confirm') const [showTranslateConfirm] = usePreference('chat.input.translate.show_confirm')
const { getLanguageByLangcode } = useTranslate() const { getLanguageLabel, getLanguageByLangcode } = useTranslate()
const translateConfirm = () => { const translateConfirm = () => {
if (!showTranslateConfirm) { if (!showTranslateConfirm) {
@@ -64,9 +64,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
}, [isLoading]) }, [isLoading])
return ( return (
<Tooltip <Tooltip content={t('chat.input.translate', { target_language: getLanguageLabel(targetLanguage) })} closeDelay={0}>
content={t('chat.input.translate', { target_language: getLanguageByLangcode(targetLanguage).label() })}
closeDelay={0}>
<Button <Button
onPress={handleTranslate} onPress={handleTranslate}
isDisabled={disabled || isTranslating} isDisabled={disabled || isTranslating}
-22
View File
@@ -1,150 +1,128 @@
import i18n from '@renderer/i18n'
import type { TranslateLanguage } from '@renderer/types' import type { TranslateLanguage } from '@renderer/types'
export const UNKNOWN: TranslateLanguage = { export const UNKNOWN: TranslateLanguage = {
value: 'Unknown', value: 'Unknown',
langCode: 'unknown', langCode: 'unknown',
label: () => i18n.t('languages.unknown'),
emoji: '🏳️' emoji: '🏳️'
} }
export const ENGLISH: TranslateLanguage = { export const ENGLISH: TranslateLanguage = {
value: 'English', value: 'English',
langCode: 'en-us', langCode: 'en-us',
label: () => i18n.t('languages.english'),
emoji: '🇬🇧' emoji: '🇬🇧'
} }
export const CHINESE_SIMPLIFIED: TranslateLanguage = { export const CHINESE_SIMPLIFIED: TranslateLanguage = {
value: 'Chinese (Simplified)', value: 'Chinese (Simplified)',
langCode: 'zh-cn', langCode: 'zh-cn',
label: () => i18n.t('languages.chinese'),
emoji: '🇨🇳' emoji: '🇨🇳'
} }
export const CHINESE_TRADITIONAL: TranslateLanguage = { export const CHINESE_TRADITIONAL: TranslateLanguage = {
value: 'Chinese (Traditional)', value: 'Chinese (Traditional)',
langCode: 'zh-tw', langCode: 'zh-tw',
label: () => i18n.t('languages.chinese-traditional'),
emoji: '🇭🇰' emoji: '🇭🇰'
} }
export const JAPANESE: TranslateLanguage = { export const JAPANESE: TranslateLanguage = {
value: 'Japanese', value: 'Japanese',
langCode: 'ja-jp', langCode: 'ja-jp',
label: () => i18n.t('languages.japanese'),
emoji: '🇯🇵' emoji: '🇯🇵'
} }
export const KOREAN: TranslateLanguage = { export const KOREAN: TranslateLanguage = {
value: 'Korean', value: 'Korean',
langCode: 'ko-kr', langCode: 'ko-kr',
label: () => i18n.t('languages.korean'),
emoji: '🇰🇷' emoji: '🇰🇷'
} }
export const FRENCH: TranslateLanguage = { export const FRENCH: TranslateLanguage = {
value: 'French', value: 'French',
langCode: 'fr-fr', langCode: 'fr-fr',
label: () => i18n.t('languages.french'),
emoji: '🇫🇷' emoji: '🇫🇷'
} }
export const GERMAN: TranslateLanguage = { export const GERMAN: TranslateLanguage = {
value: 'German', value: 'German',
langCode: 'de-de', langCode: 'de-de',
label: () => i18n.t('languages.german'),
emoji: '🇩🇪' emoji: '🇩🇪'
} }
export const ITALIAN: TranslateLanguage = { export const ITALIAN: TranslateLanguage = {
value: 'Italian', value: 'Italian',
langCode: 'it-it', langCode: 'it-it',
label: () => i18n.t('languages.italian'),
emoji: '🇮🇹' emoji: '🇮🇹'
} }
export const SPANISH: TranslateLanguage = { export const SPANISH: TranslateLanguage = {
value: 'Spanish', value: 'Spanish',
langCode: 'es-es', langCode: 'es-es',
label: () => i18n.t('languages.spanish'),
emoji: '🇪🇸' emoji: '🇪🇸'
} }
export const PORTUGUESE: TranslateLanguage = { export const PORTUGUESE: TranslateLanguage = {
value: 'Portuguese', value: 'Portuguese',
langCode: 'pt-pt', langCode: 'pt-pt',
label: () => i18n.t('languages.portuguese'),
emoji: '🇵🇹' emoji: '🇵🇹'
} }
export const RUSSIAN: TranslateLanguage = { export const RUSSIAN: TranslateLanguage = {
value: 'Russian', value: 'Russian',
langCode: 'ru-ru', langCode: 'ru-ru',
label: () => i18n.t('languages.russian'),
emoji: '🇷🇺' emoji: '🇷🇺'
} }
export const POLISH: TranslateLanguage = { export const POLISH: TranslateLanguage = {
value: 'Polish', value: 'Polish',
langCode: 'pl-pl', langCode: 'pl-pl',
label: () => i18n.t('languages.polish'),
emoji: '🇵🇱' emoji: '🇵🇱'
} }
export const ARABIC: TranslateLanguage = { export const ARABIC: TranslateLanguage = {
value: 'Arabic', value: 'Arabic',
langCode: 'ar-ar', langCode: 'ar-ar',
label: () => i18n.t('languages.arabic'),
emoji: '🇸🇦' emoji: '🇸🇦'
} }
export const TURKISH: TranslateLanguage = { export const TURKISH: TranslateLanguage = {
value: 'Turkish', value: 'Turkish',
langCode: 'tr-tr', langCode: 'tr-tr',
label: () => i18n.t('languages.turkish'),
emoji: '🇹🇷' emoji: '🇹🇷'
} }
export const THAI: TranslateLanguage = { export const THAI: TranslateLanguage = {
value: 'Thai', value: 'Thai',
langCode: 'th-th', langCode: 'th-th',
label: () => i18n.t('languages.thai'),
emoji: '🇹🇭' emoji: '🇹🇭'
} }
export const VIETNAMESE: TranslateLanguage = { export const VIETNAMESE: TranslateLanguage = {
value: 'Vietnamese', value: 'Vietnamese',
langCode: 'vi-vn', langCode: 'vi-vn',
label: () => i18n.t('languages.vietnamese'),
emoji: '🇻🇳' emoji: '🇻🇳'
} }
export const INDONESIAN: TranslateLanguage = { export const INDONESIAN: TranslateLanguage = {
value: 'Indonesian', value: 'Indonesian',
langCode: 'id-id', langCode: 'id-id',
label: () => i18n.t('languages.indonesian'),
emoji: '🇮🇩' emoji: '🇮🇩'
} }
export const URDU: TranslateLanguage = { export const URDU: TranslateLanguage = {
value: 'Urdu', value: 'Urdu',
langCode: 'ur-pk', langCode: 'ur-pk',
label: () => i18n.t('languages.urdu'),
emoji: '🇵🇰' emoji: '🇵🇰'
} }
export const MALAY: TranslateLanguage = { export const MALAY: TranslateLanguage = {
value: 'Malay', value: 'Malay',
langCode: 'ms-my', langCode: 'ms-my',
label: () => i18n.t('languages.malay'),
emoji: '🇲🇾' emoji: '🇲🇾'
} }
export const UKRAINIAN: TranslateLanguage = { export const UKRAINIAN: TranslateLanguage = {
value: 'Ukrainian', value: 'Ukrainian',
langCode: 'uk-ua', langCode: 'uk-ua',
label: () => i18n.t('languages.ukrainian'),
emoji: '🇺🇦' emoji: '🇺🇦'
} }
+51 -21
View File
@@ -1,14 +1,12 @@
import { usePreference } from '@data/hooks/usePreference' import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { builtinLanguages, UNKNOWN } from '@renderer/config/translate' import { builtinLanguages, UNKNOWN } from '@renderer/config/translate'
import { useAppSelector } from '@renderer/store' import type { TranslateLanguageCode } from '@renderer/types'
import type { TranslateState } from '@renderer/store/translate' import { type TranslateLanguage } from '@renderer/types'
import { updateSettings } from '@renderer/store/translate'
import type { TranslateLanguage } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils' import { runAsyncFunction } from '@renderer/utils'
import { getTranslateOptions } from '@renderer/utils/translate' import { getTranslateOptions } from '@renderer/utils/translate'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useDispatch } from 'react-redux' import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useTranslate') const logger = loggerService.withContext('useTranslate')
@@ -20,13 +18,11 @@ const logger = loggerService.withContext('useTranslate')
* - getLanguageByLangcode: 通过语言代码获取语言对象 * - getLanguageByLangcode: 通过语言代码获取语言对象
*/ */
export default function useTranslate() { export default function useTranslate() {
const { t } = useTranslation()
const [prompt] = usePreference('feature.translate.model_prompt') const [prompt] = usePreference('feature.translate.model_prompt')
const settings = useAppSelector((state) => state.translate.settings)
const [translateLanguages, setTranslateLanguages] = useState<TranslateLanguage[]>(builtinLanguages) const [translateLanguages, setTranslateLanguages] = useState<TranslateLanguage[]>(builtinLanguages)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const dispatch = useDispatch()
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => { runAsyncFunction(async () => {
const options = await getTranslateOptions() const options = await getTranslateOptions()
@@ -37,34 +33,68 @@ export default function useTranslate() {
const getLanguageByLangcode = useCallback( const getLanguageByLangcode = useCallback(
(langCode: string) => { (langCode: string) => {
if (!isLoaded) {
logger.verbose('Translate languages are not loaded yet. Return UNKNOWN.')
return UNKNOWN
}
const result = translateLanguages.find((item) => item.langCode === langCode) const result = translateLanguages.find((item) => item.langCode === langCode)
if (result) { if (result) {
return result return result
} else { } 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 return UNKNOWN
} }
}, },
[isLoaded, translateLanguages] [isLoaded, translateLanguages]
) )
const handleUpdateSettings = useCallback( const labelMap: Record<string, string> = useMemo(
(update: Partial<TranslateState['settings']>) => { () => ({
dispatch(updateSettings(update)) '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 { return {
prompt, prompt,
settings, isLoaded,
translateLanguages, translateLanguages,
getLanguageByLangcode, getLanguageByLangcode,
updateSettings: handleUpdateSettings getLanguageLabel
} }
} }
@@ -23,7 +23,7 @@ import store, { useAppDispatch } from '@renderer/store'
import { messageBlocksSelectors, removeOneBlock } from '@renderer/store/messageBlock' import { messageBlocksSelectors, removeOneBlock } from '@renderer/store/messageBlock'
import { selectMessagesForTopic } from '@renderer/store/newMessage' import { selectMessagesForTopic } from '@renderer/store/newMessage'
import { TraceIcon } from '@renderer/trace/pages/Component' 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 { type Message, MessageBlockType } from '@renderer/types/newMessage'
import { captureScrollableAsBlob, captureScrollableAsDataURL, classNames } from '@renderer/utils' import { captureScrollableAsBlob, captureScrollableAsDataURL, classNames } from '@renderer/utils'
import { copyMessageAsPlainText } from '@renderer/utils/copy' import { copyMessageAsPlainText } from '@renderer/utils/copy'
@@ -118,6 +118,7 @@ type MessageMenubarButtonContext = {
softHoverBg: boolean softHoverBg: boolean
t: TFunction t: TFunction
translateLanguages: TranslateLanguage[] translateLanguages: TranslateLanguage[]
getLanguageLabel: (lang: TranslateLanguageCode) => string
} }
type MessageMenubarButtonRenderer = (ctx: MessageMenubarButtonContext) => ReactNode | null type MessageMenubarButtonRenderer = (ctx: MessageMenubarButtonContext) => ReactNode | null
@@ -142,7 +143,7 @@ const MessageMenubar: FC<Props> = (props) => {
const [isTranslating, setIsTranslating] = useState(false) const [isTranslating, setIsTranslating] = useState(false)
// remove confirm for regenerate; tooltip stays simple // remove confirm for regenerate; tooltip stays simple
const [showDeleteTooltip, setShowDeleteTooltip] = useState(false) const [showDeleteTooltip, setShowDeleteTooltip] = useState(false)
const { translateLanguages } = useTranslate() const { translateLanguages, getLanguageLabel } = useTranslate()
// const assistantModel = assistant?.model // const assistantModel = assistant?.model
const { const {
deleteMessage, deleteMessage,
@@ -571,7 +572,8 @@ const MessageMenubar: FC<Props> = (props) => {
showDeleteTooltip, showDeleteTooltip,
softHoverBg, softHoverBg,
t, t,
translateLanguages translateLanguages,
getLanguageLabel
} }
return ( return (
@@ -757,6 +759,7 @@ const buttonRenderers: Record<MessageMenubarButtonId, MessageMenubarButtonRender
translate: ({ translate: ({
isUserMessage, isUserMessage,
translateLanguages, translateLanguages,
getLanguageLabel,
handleTranslate, handleTranslate,
hasTranslationBlocks, hasTranslationBlocks,
message, message,
@@ -771,7 +774,7 @@ const buttonRenderers: Record<MessageMenubarButtonId, MessageMenubarButtonRender
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
...translateLanguages.map((item) => ({ ...translateLanguages.map((item) => ({
label: item.emoji + ' ' + item.label(), label: item.emoji + ' ' + getLanguageLabel(item.langCode),
key: item.langCode, key: item.langCode,
onClick: () => handleTranslate(item) onClick: () => handleTranslate(item)
})), })),
@@ -96,7 +96,7 @@ const SettingsTab: FC<Props> = (props) => {
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0) const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
const [fontSizeValue, setFontSizeValue] = useState(fontSize) const [fontSizeValue, setFontSizeValue] = useState(fontSize)
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput) const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput)
const { translateLanguages } = useTranslate() const { translateLanguages, getLanguageLabel } = useTranslate()
const { t } = useTranslation() const { t } = useTranslation()
@@ -142,8 +142,12 @@ const SettingsTab: FC<Props> = (props) => {
) )
const targetLanguageItems = useMemo<SelectorItem<string>[]>( 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>[]>( const sendMessageShortcutItems = useMemo<SelectorItem<SendMessageShortcut>[]>(
@@ -727,7 +731,7 @@ const SettingsTab: FC<Props> = (props) => {
selectionMode="single" selectionMode="single"
selectedKeys={targetLanguage} selectedKeys={targetLanguage}
onSelectionChange={(value) => setTargetLanguage(value)} onSelectionChange={(value) => setTargetLanguage(value)}
placeholder={UNKNOWN.emoji + ' ' + UNKNOWN.label()} placeholder={UNKNOWN.emoji + ' ' + getLanguageLabel(UNKNOWN.langCode)}
items={targetLanguageItems} items={targetLanguageItems}
/> />
</SettingRow> </SettingRow>
@@ -18,7 +18,7 @@ import { SettingRow, SettingRowTitle } from '..'
export const OcrSystemSettings = () => { export const OcrSystemSettings = () => {
const { t } = useTranslation() const { t } = useTranslation()
// 和翻译自定义语言耦合了,应该还ok // 和翻译自定义语言耦合了,应该还ok
const { translateLanguages } = useTranslate() const { translateLanguages, getLanguageLabel } = useTranslate()
const { provider, updateConfig } = useOcrProvider(BuiltinOcrProviderIds.system) const { provider, updateConfig } = useOcrProvider(BuiltinOcrProviderIds.system)
if (!isOcrSystemProvider(provider)) { if (!isOcrSystemProvider(provider)) {
@@ -36,9 +36,9 @@ export const OcrSystemSettings = () => {
() => () =>
translateLanguages.map((lang) => ({ translateLanguages.map((lang) => ({
value: lang.langCode, value: lang.langCode,
label: lang.emoji + ' ' + lang.label() label: lang.emoji + ' ' + getLanguageLabel(lang.langCode)
})), })),
[translateLanguages] [getLanguageLabel, translateLanguages]
) )
const onChange = useCallback((value: TranslateLanguageCode[]) => { const onChange = useCallback((value: TranslateLanguageCode[]) => {
@@ -24,17 +24,17 @@ export const OcrTesseractSettings = () => {
} }
const [langs, setLangs] = useState<Partial<Record<TesseractLangCode, boolean>>>(provider.config?.langs ?? {}) const [langs, setLangs] = useState<Partial<Record<TesseractLangCode, boolean>>>(provider.config?.langs ?? {})
const { translateLanguages } = useTranslate() const { translateLanguages, getLanguageLabel } = useTranslate()
const options = useMemo( const options = useMemo(
() => () =>
translateLanguages translateLanguages
.map((lang) => ({ .map((lang) => ({
value: TESSERACT_LANG_MAP[lang.langCode], value: TESSERACT_LANG_MAP[lang.langCode],
label: lang.emoji + ' ' + lang.label() label: lang.emoji + ' ' + getLanguageLabel(lang.langCode)
})) }))
.filter((option) => option.value), .filter((option) => option.value),
[translateLanguages] [getLanguageLabel, translateLanguages]
) )
// TODO: type safe objectKeys // TODO: type safe objectKeys
@@ -6,7 +6,7 @@ import { DynamicVirtualList } from '@renderer/components/VirtualList'
import db from '@renderer/databases' import db from '@renderer/databases'
import useTranslate from '@renderer/hooks/useTranslate' import useTranslate from '@renderer/hooks/useTranslate'
import { clearHistory, deleteHistory, updateTranslateHistory } from '@renderer/services/TranslateService' 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 { Drawer, Empty, Input, Popconfirm } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
@@ -17,14 +17,9 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'rea
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
type DisplayedTranslateHistoryItem = TranslateHistory & {
_sourceLanguage: TranslateLanguage
_targetLanguage: TranslateLanguage
}
type TranslateHistoryProps = { type TranslateHistoryProps = {
isOpen: boolean isOpen: boolean
onHistoryItemClick: (history: DisplayedTranslateHistoryItem) => void onHistoryItemClick: (history: TranslateHistory) => void
onClose: () => void onClose: () => void
} }
@@ -35,39 +30,34 @@ const ITEM_HEIGHT = 160
const TranslateHistoryList: FC<TranslateHistoryProps> = ({ isOpen, onHistoryItemClick, onClose }) => { const TranslateHistoryList: FC<TranslateHistoryProps> = ({ isOpen, onHistoryItemClick, onClose }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { getLanguageByLangcode } = useTranslate() const { getLanguageLabel } = useTranslate()
const _translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), []) const _translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), [])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [displayedHistory, setDisplayedHistory] = useState<DisplayedTranslateHistoryItem[]>([]) const [displayedHistory, setDisplayedHistory] = useState<TranslateHistory[]>([])
const [showStared, setShowStared] = useState<boolean>(false) const [showStared, setShowStared] = useState<boolean>(false)
const translateHistory: DisplayedTranslateHistoryItem[] = useMemo(() => { const translateHistory: TranslateHistory[] = useMemo(() => {
if (!_translateHistory) return [] if (!_translateHistory) return []
return _translateHistory.map((item) => ({ return _translateHistory.map((item) => ({
...item, ...item,
_sourceLanguage: getLanguageByLangcode(item.sourceLanguage),
_targetLanguage: getLanguageByLangcode(item.targetLanguage),
createdAt: dayjs(item.createdAt).format('MM/DD HH:mm') createdAt: dayjs(item.createdAt).format('MM/DD HH:mm')
})) }))
}, [_translateHistory, getLanguageByLangcode]) }, [_translateHistory])
const searchFilter = useCallback( const searchFilter = useCallback(
(item: DisplayedTranslateHistoryItem) => { (item: TranslateHistory) => {
if (isEmpty(search)) return true 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) return content.includes(search)
}, },
[search] [getLanguageLabel, search]
) )
const starFilter = useMemo( const starFilter = useMemo(() => (showStared ? (item: TranslateHistory) => !!item.star : () => true), [showStared])
() => (showStared ? (item: DisplayedTranslateHistoryItem) => !!item.star : () => true),
[showStared]
)
const finalFilter = useCallback( const finalFilter = useCallback(
(item: DisplayedTranslateHistoryItem) => searchFilter(item) && starFilter(item), (item: TranslateHistory) => searchFilter(item) && starFilter(item),
[searchFilter, starFilter] [searchFilter, starFilter]
) )
@@ -179,8 +169,8 @@ const TranslateHistoryList: FC<TranslateHistoryProps> = ({ isOpen, onHistoryItem
<ColFlex className="h-full w-full flex-1 justify-between gap-1"> <ColFlex className="h-full w-full flex-1 justify-between gap-1">
<Flex className="h-[30px] items-center justify-between"> <Flex className="h-[30px] items-center justify-between">
<Flex className="items-center gap-1.5"> <Flex className="items-center gap-1.5">
<HistoryListItemLanguage>{item._sourceLanguage.label()} </HistoryListItemLanguage> <HistoryListItemLanguage>{getLanguageLabel(item.sourceLanguage)} </HistoryListItemLanguage>
<HistoryListItemLanguage>{item._targetLanguage.label()}</HistoryListItemLanguage> <HistoryListItemLanguage>{getLanguageLabel(item.targetLanguage)}</HistoryListItemLanguage>
</Flex> </Flex>
{/* tool bar */} {/* tool bar */}
<Flex className="mt-2 items-center justify-end"> <Flex className="mt-2 items-center justify-end">
+137 -252
View File
@@ -1,5 +1,7 @@
import { PlusOutlined, SendOutlined, SwapOutlined } from '@ant-design/icons' import { PlusOutlined, SendOutlined, SwapOutlined } from '@ant-design/icons'
import { Button, Flex, Tooltip } from '@cherrystudio/ui' import { Button, Flex, Tooltip } from '@cherrystudio/ui'
import { useCache } from '@data/hooks/useCache'
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { CopyIcon } from '@renderer/components/Icons' 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 { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
import { LanguagesEnum, UNKNOWN } from '@renderer/config/translate' import { LanguagesEnum, UNKNOWN } from '@renderer/config/translate'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant' import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { useDrag } from '@renderer/hooks/useDrag' import { useDrag } from '@renderer/hooks/useDrag'
import { useFiles } from '@renderer/hooks/useFiles' import { useFiles } from '@renderer/hooks/useFiles'
@@ -18,18 +19,9 @@ import { useTimer } from '@renderer/hooks/useTimer'
import useTranslate from '@renderer/hooks/useTranslate' import useTranslate from '@renderer/hooks/useTranslate'
import { estimateTextTokens } from '@renderer/services/TokenService' import { estimateTextTokens } from '@renderer/services/TokenService'
import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService' import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService'
import { useAppDispatch, useAppSelector } from '@renderer/store' import type { FileMetadata, SupportedOcrFile, TranslateLanguageCode } from '@renderer/types'
// import { setTranslateAbortKey, setTranslating as setTranslatingAction } from '@renderer/store/runtime' import { isSupportedOcrFile, type Model, type TranslateHistory, type TranslateLanguage } from '@renderer/types'
import { setTranslatedContent as setTranslatedContentAction, setTranslateInput } from '@renderer/store/translate' import { getFileExtension, isTextFile } from '@renderer/utils'
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 { abortCompletion } from '@renderer/utils/abortController' import { abortCompletion } from '@renderer/utils/abortController'
import { isAbortError } from '@renderer/utils/error' import { isAbortError } from '@renderer/utils/error'
import { formatErrorMessage } from '@renderer/utils/error' import { formatErrorMessage } from '@renderer/utils/error'
@@ -56,168 +48,112 @@ import TranslateSettings from './TranslateSettings'
const logger = loggerService.withContext('TranslatePage') const logger = loggerService.withContext('TranslatePage')
// cache variables
let _sourceLanguage: TranslateLanguage | 'auto' = 'auto'
let _targetLanguage = LanguagesEnum.enUS
const TranslatePage: FC = () => { const TranslatePage: FC = () => {
// hooks // hooks
const { t } = useTranslation() const { t } = useTranslation()
const { translateModel, setTranslateModel } = useDefaultModel() const { translateModel, setTranslateModel } = useDefaultModel()
const { prompt, getLanguageByLangcode, settings } = useTranslate() const { prompt, getLanguageByLangcode, getLanguageLabel } = useTranslate()
const { autoCopy } = settings
const { shikiMarkdownIt } = useCodeStyle() const { shikiMarkdownIt } = useCodeStyle()
const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] }) const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] })
const { ocr } = useOcr() const { ocr } = useOcr()
const { setTimeoutTimer } = useTimer() 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 // states
// const [text, setText] = useState(_text)
const [renderedMarkdown, setRenderedMarkdown] = useState<string>('') const [renderedMarkdown, setRenderedMarkdown] = useState<string>('')
const [copied, setCopied] = useTemporaryValue(false, 2000) const [copied, setCopied] = useTemporaryValue(false, 2000)
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false) 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 [settingsVisible, setSettingsVisible] = useState(false)
const [detectedLanguage, setDetectedLanguage] = useState<TranslateLanguage | null>(null) const [detectedLanguage, setDetectedLanguage] = useState<TranslateLanguageCode | null>(null)
const [sourceLanguage, setSourceLanguage] = useState<TranslateLanguage | 'auto'>(_sourceLanguage) const [sourceLanguage, setSourceLanguage] = useCache('translate.lang.source')
const [targetLanguage, setTargetLanguage] = useState<TranslateLanguage>(_targetLanguage) const [targetLanguage, setTargetLanguage] = useCache('translate.lang.target')
const [autoDetectionMethod, setAutoDetectionMethod] = useState<AutoDetectionMethod>('franc')
const [isProcessing, setIsProcessing] = useState(false) 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 // ref
const contentContainerRef = useRef<HTMLDivElement>(null) const contentContainerRef = useRef<HTMLDivElement>(null)
const textAreaRef = useRef<TextAreaRef>(null) const textAreaRef = useRef<TextAreaRef>(null)
const outputTextRef = useRef<HTMLDivElement>(null) const outputTextRef = useRef<HTMLDivElement>(null)
const isProgrammaticScroll = useRef(false) const isProgrammaticScroll = useRef(false)
const dispatch = useAppDispatch()
_sourceLanguage = sourceLanguage
_targetLanguage = targetLanguage
// 控制翻译模型切换 // 控制翻译模型切换
const handleModelChange = (model: Model) => { const handleModelChange = (model: Model) => {
setTranslateModel(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 () => { const onCopy = useCallback(async () => {
try { try {
await navigator.clipboard.writeText(translatedContent) await navigator.clipboard.writeText(output)
setCopied(true) setCopied(true)
} catch (error) { } catch (error) {
logger.error('Failed to copy text to clipboard:', error as Error) logger.error('Failed to copy text to clipboard:', error as Error)
window.toast.error(t('common.copy_failed')) window.toast.error(t('common.copy_failed'))
} }
}, [setCopied, t, translatedContent]) }, [setCopied, t, output])
/** /**
* 翻译文本并保存历史记录,包含完整的异常处理,不会抛出异常 * Translate text and save history with full exception handling; never throws.
* @param text - 需要翻译的文本 * This function is responsible for managing the translating state.
* @param actualSourceLanguage - 源语言 * No other part of the code should directly write to the translating state.
* @param actualTargetLanguage - 目标语言 * @param text - Text to be translated
* @param actualSourceLanguage - Source language
* @param actualTargetLanguage - Target language
*/ */
const translate = useCallback( const translate = useCallback(
async ( async (text: string, targetLanguage: TranslateLanguage): Promise<string | null> => {
text: string,
actualSourceLanguage: TranslateLanguage,
actualTargetLanguage: TranslateLanguage
): Promise<void> => {
try { try {
if (translating) { const abortKey = crypto.randomUUID()
return 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)
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))
}
} catch (e) { } catch (e) {
logger.error('Failed to translate', e as Error) if (isAbortError(e)) {
window.toast.error(t('translate.error.unknown') + ': ' + formatErrorMessage(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(() => { const couldTranslate = useMemo(() => {
return !( return !(
!text.trim() || !text.trim() ||
(sourceLanguage !== 'auto' && sourceLanguage.langCode === UNKNOWN.langCode) || (sourceLanguage !== 'auto' && sourceLanguage === UNKNOWN.langCode) ||
targetLanguage.langCode === UNKNOWN.langCode || targetLanguage === UNKNOWN.langCode ||
(isBidirectional && (isBidirectional && (bidirectional.origin === UNKNOWN.langCode || bidirectional.target === UNKNOWN.langCode)) ||
(bidirectionalPair[0].langCode === UNKNOWN.langCode || bidirectionalPair[1].langCode === UNKNOWN.langCode)) || isProcessing ||
isProcessing isDetecting
) )
}, [bidirectionalPair, isBidirectional, isProcessing, sourceLanguage, targetLanguage.langCode, text]) }, [
bidirectional.origin,
bidirectional.target,
isBidirectional,
isDetecting,
isProcessing,
sourceLanguage,
targetLanguage,
text
])
// 控制翻译按钮,翻译前进行校验 // 控制翻译按钮,翻译前进行校验
const onTranslate = useCallback(async () => { const onTranslate = useCallback(async () => {
@@ -228,19 +164,26 @@ const TranslatePage: FC = () => {
return return
} }
setTranslating(true) let actualSourceLanguage: TranslateLanguageCode
try { try {
setIsDetecting(true)
// 确定源语言:如果用户选择了特定语言,使用用户选择的;如果选择'auto',则自动检测 // 确定源语言:如果用户选择了特定语言,使用用户选择的;如果选择'auto',则自动检测
let actualSourceLanguage: TranslateLanguage
if (sourceLanguage === 'auto') { if (sourceLanguage === 'auto') {
actualSourceLanguage = getLanguageByLangcode(await detectLanguage(text)) actualSourceLanguage = await detectLanguage(text)
setDetectedLanguage(actualSourceLanguage) setDetectedLanguage(actualSourceLanguage)
} else { } else {
actualSourceLanguage = sourceLanguage 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) { if (!result.success) {
let errorMessage = '' let errorMessage = ''
if (result.errorType === 'same_language') { if (result.errorType === 'same_language') {
@@ -253,25 +196,48 @@ const TranslatePage: FC = () => {
return return
} }
const actualTargetLanguage = result.language as TranslateLanguage const actualTargetLanguage = result.language
if (isBidirectional) { if (isBidirectional) {
setTargetLanguage(actualTargetLanguage) 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) { } 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)) window.toast.error(t('translate.error.failed') + ': ' + formatErrorMessage(error))
return
} finally {
setTranslating(false)
} }
}, [ }, [
bidirectionalPair, autoCopy,
bidirectional,
couldTranslate, couldTranslate,
getLanguageByLangcode, getLanguageByLangcode,
isBidirectional, isBidirectional,
setTranslating, onCopy,
setIsDetecting,
setTargetLanguage,
setTimeoutTimer,
sourceLanguage, sourceLanguage,
t, t,
targetLanguage, targetLanguage,
@@ -289,24 +255,16 @@ const TranslatePage: FC = () => {
abortCompletion(abortKey) abortCompletion(abortKey)
} }
// 控制双向翻译切换
const toggleBidirectional = (value: boolean) => {
setIsBidirectional(value)
db.settings.put({ id: 'translate:bidirectional:enabled', value })
}
// 控制历史记录点击 // 控制历史记录点击
const onHistoryItemClick = ( const onHistoryItemClick = (history: TranslateHistory) => {
history: TranslateHistory & { _sourceLanguage: TranslateLanguage; _targetLanguage: TranslateLanguage }
) => {
setText(history.sourceText) setText(history.sourceText)
setTranslatedContent(history.targetText) setOutput(history.targetText)
if (history._sourceLanguage === UNKNOWN) { if (history.sourceLanguage === UNKNOWN.langCode) {
setSourceLanguage('auto') setSourceLanguage('auto')
} else { } else {
setSourceLanguage(history._sourceLanguage) setSourceLanguage(history.sourceLanguage)
} }
setTargetLanguage(history._targetLanguage) setTargetLanguage(history.targetLanguage)
setHistoryDrawerVisible(false) setHistoryDrawerVisible(false)
} }
@@ -314,7 +272,7 @@ const TranslatePage: FC = () => {
/** 与自动检测相关的交换条件检查 */ /** 与自动检测相关的交换条件检查 */
const couldExchangeAuto = useMemo( const couldExchangeAuto = useMemo(
() => () =>
(sourceLanguage === 'auto' && detectedLanguage && detectedLanguage.langCode !== UNKNOWN.langCode) || (sourceLanguage === 'auto' && detectedLanguage && detectedLanguage !== UNKNOWN.langCode) ||
sourceLanguage !== 'auto', sourceLanguage !== 'auto',
[detectedLanguage, sourceLanguage] [detectedLanguage, sourceLanguage]
) )
@@ -330,24 +288,24 @@ const TranslatePage: FC = () => {
window.toast.error(t('translate.error.invalid_source')) window.toast.error(t('translate.error.invalid_source'))
return return
} }
if (source.langCode === UNKNOWN.langCode) { if (source === UNKNOWN.langCode) {
window.toast.error(t('translate.error.detect.unknown')) window.toast.error(t('translate.error.detect.unknown'))
return return
} }
setSourceLanguage(targetLanguage) setSourceLanguage(targetLanguage)
setTargetLanguage(source) setTargetLanguage(source)
}, [couldExchangeAuto, detectedLanguage, sourceLanguage, t, targetLanguage]) }, [couldExchangeAuto, detectedLanguage, setSourceLanguage, setTargetLanguage, sourceLanguage, t, targetLanguage])
useEffect(() => { useEffect(() => {
isEmpty(text) && setTranslatedContent('') isEmpty(text) && setOutput('')
}, [setTranslatedContent, text]) }, [setOutput, text])
// Render markdown content when result or enableMarkdown changes // Render markdown content when result or enableMarkdown changes
// 控制Markdown渲染 // 控制Markdown渲染
useEffect(() => { useEffect(() => {
if (enableMarkdown && translatedContent) { if (enableMarkdown && output) {
let isMounted = true let isMounted = true
shikiMarkdownIt(translatedContent).then((rendered) => { shikiMarkdownIt(output).then((rendered) => {
if (isMounted) { if (isMounted) {
setRenderedMarkdown(rendered) setRenderedMarkdown(rendered)
} }
@@ -359,71 +317,7 @@ const TranslatePage: FC = () => {
setRenderedMarkdown('') setRenderedMarkdown('')
return undefined return undefined
} }
}, [enableMarkdown, shikiMarkdownIt, translatedContent]) }, [enableMarkdown, shikiMarkdownIt, output])
// 控制设置加载
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))
}
}
// 控制Enter触发翻译 // 控制Enter触发翻译
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -440,28 +334,31 @@ const TranslatePage: FC = () => {
// 获取目标语言显示 // 获取目标语言显示
const getLanguageDisplay = () => { const getLanguageDisplay = () => {
try { if (isBidirectional) {
if (isBidirectional) { try {
return ( return (
<Flex className="min-w-40 items-center"> <Flex className="min-w-40 items-center">
<BidirectionalLanguageDisplay> <BidirectionalLanguageDisplay>
{`${bidirectionalPair[0].label()}${bidirectionalPair[1].label()}`} {`${getLanguageLabel(bidirectional.origin)}${getLanguageLabel(bidirectional.target)}`}
</BidirectionalLanguageDisplay> </BidirectionalLanguageDisplay>
</Flex> </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 ( return (
<LanguageSelect <LanguageSelect
style={{ width: 200 }} style={{ width: 200 }}
value={targetLanguage.langCode} value={targetLanguage}
onChange={(value) => { onChange={(value) => {
setTargetLanguage(getLanguageByLangcode(value)) setTargetLanguage(value)
db.settings.put({ id: 'translate:target:language', value })
}} }}
/> />
) )
@@ -700,18 +597,16 @@ const TranslatePage: FC = () => {
<LanguageSelect <LanguageSelect
showSearch showSearch
style={{ width: 200 }} style={{ width: 200 }}
value={sourceLanguage !== 'auto' ? sourceLanguage.langCode : 'auto'} value={sourceLanguage}
optionFilterProp="label" optionFilterProp="label"
onChange={(value) => { onChange={(value) => {
if (value !== 'auto') setSourceLanguage(getLanguageByLangcode(value)) setSourceLanguage(value)
else setSourceLanguage('auto')
db.settings.put({ id: 'translate:source:language', value })
}} }}
extraOptionsBefore={[ extraOptionsBefore={[
{ {
value: 'auto', value: 'auto',
label: detectedLanguage label: detectedLanguage
? `${t('translate.detected.language')} (${detectedLanguage.label()})` ? `${t('translate.detected.language')} (${getLanguageLabel(detectedLanguage)})`
: t('translate.detected.language') : t('translate.detected.language')
} }
]} ]}
@@ -728,7 +623,7 @@ const TranslatePage: FC = () => {
</Tooltip> </Tooltip>
{getLanguageDisplay()} {getLanguageDisplay()}
<TranslateButton <TranslateButton
translating={translating} translating={isTranslating}
onTranslate={onTranslate} onTranslate={onTranslate}
couldTranslate={couldTranslate} couldTranslate={couldTranslate}
onAbort={onAbort} onAbort={onAbort}
@@ -780,7 +675,7 @@ const TranslatePage: FC = () => {
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onScroll={handleInputScroll} onScroll={handleInputScroll}
onPaste={onPaste} onPaste={onPaste}
disabled={translating} disabled={isTranslating}
spellCheck={false} spellCheck={false}
allowClear allowClear
/> />
@@ -799,19 +694,19 @@ const TranslatePage: FC = () => {
size="sm" size="sm"
className="copy-button" className="copy-button"
onPress={onCopy} onPress={onCopy}
isDisabled={!translatedContent} isDisabled={!output}
startContent={copied ? <Check size={16} color="var(--color-primary)" /> : <CopyIcon size={16} />} startContent={copied ? <Check size={16} color="var(--color-primary)" /> : <CopyIcon size={16} />}
isIconOnly isIconOnly
/> />
<OutputText ref={outputTextRef} onScroll={handleOutputScroll} className={'selectable'}> <OutputText ref={outputTextRef} onScroll={handleOutputScroll} className={'selectable'}>
{!translatedContent ? ( {!output ? (
<div style={{ color: 'var(--color-text-3)', userSelect: 'none' }}> <div style={{ color: 'var(--color-text-3)', userSelect: 'none' }}>
{t('translate.output.placeholder')} {t('translate.output.placeholder')}
</div> </div>
) : enableMarkdown ? ( ) : enableMarkdown ? (
<div className="markdown" dangerouslySetInnerHTML={{ __html: renderedMarkdown }} /> <div className="markdown" dangerouslySetInnerHTML={{ __html: renderedMarkdown }} />
) : ( ) : (
<div className="plain">{translatedContent}</div> <div className="plain">{output}</div>
)} )}
</OutputText> </OutputText>
</OutputContainer> </OutputContainer>
@@ -821,17 +716,7 @@ const TranslatePage: FC = () => {
<TranslateSettings <TranslateSettings
visible={settingsVisible} visible={settingsVisible}
onClose={() => setSettingsVisible(false)} onClose={() => setSettingsVisible(false)}
isScrollSyncEnabled={isScrollSyncEnabled}
setIsScrollSyncEnabled={setIsScrollSyncEnabled}
isBidirectional={isBidirectional}
setIsBidirectional={toggleBidirectional}
enableMarkdown={enableMarkdown}
setEnableMarkdown={setEnableMarkdown}
bidirectionalPair={bidirectionalPair}
setBidirectionalPair={setBidirectionalPair}
translateModel={translateModel} translateModel={translateModel}
autoDetectionMethod={autoDetectionMethod}
setAutoDetectionMethod={updateAutoDetectionMethod}
/> />
</Container> </Container>
) )
@@ -1,53 +1,27 @@
import { Button, ColFlex, Flex, HelpTooltip, RowFlex, Switch, Tooltip } from '@cherrystudio/ui' 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 LanguageSelect from '@renderer/components/LanguageSelect'
import db from '@renderer/databases' import type { Model } from '@renderer/types'
import useTranslate from '@renderer/hooks/useTranslate'
import type { AutoDetectionMethod, Model, TranslateLanguage } from '@renderer/types'
import { Modal, Radio, Space } from 'antd' import { Modal, Radio, Space } from 'antd'
import type { FC } from 'react' import type { FC } from 'react'
import { memo, useEffect, useState } from 'react' import { memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import TranslateSettingsPopup from '../settings/TranslateSettingsPopup/TranslateSettingsPopup' import TranslateSettingsPopup from '../settings/TranslateSettingsPopup/TranslateSettingsPopup'
// TODO: Just don't send so many props. Migrate them to redux.
const TranslateSettings: FC<{ const TranslateSettings: FC<{
visible: boolean visible: boolean
onClose: () => void onClose: () => void
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 translateModel: Model | undefined
autoDetectionMethod: AutoDetectionMethod }> = ({ visible, onClose }) => {
setAutoDetectionMethod: (method: AutoDetectionMethod) => void
}> = ({
visible,
onClose,
isScrollSyncEnabled,
setIsScrollSyncEnabled,
isBidirectional,
setIsBidirectional,
enableMarkdown,
setEnableMarkdown,
bidirectionalPair,
setBidirectionalPair,
autoDetectionMethod,
setAutoDetectionMethod
}) => {
const { t } = useTranslation() const { t } = useTranslation()
const [localPair, setLocalPair] = useState<[TranslateLanguage, TranslateLanguage]>(bidirectionalPair) const [autoCopy, setAutoCopy] = usePreference('translate.settings.auto_copy')
const { getLanguageByLangcode, settings, updateSettings } = useTranslate() const [autoDetectionMethod, setAutoDetectionMethod] = usePreference('translate.settings.auto_detection_method')
const { autoCopy } = settings const [enableMarkdown, setEnableMarkdown] = usePreference('translate.settings.enable_markdown')
const [isScrollSyncEnabled, setIsScrollSyncEnabled] = usePreference('translate.settings.scroll_sync')
useEffect(() => { const [bidirectional, setBidirectional] = useCache('translate.bidirectional')
setLocalPair(bidirectionalPair) const { enabled: isBidirectional } = bidirectional
}, [bidirectionalPair, visible])
const onMoreSetting = () => { const onMoreSetting = () => {
onClose() onClose()
TranslateSettingsPopup.show() TranslateSettingsPopup.show()
@@ -70,7 +44,6 @@ const TranslateSettings: FC<{
isSelected={enableMarkdown} isSelected={enableMarkdown}
onValueChange={(checked) => { onValueChange={(checked) => {
setEnableMarkdown(checked) setEnableMarkdown(checked)
db.settings.put({ id: 'translate:markdown:enabled', value: checked })
}} }}
/> />
</Flex> </Flex>
@@ -83,7 +56,7 @@ const TranslateSettings: FC<{
isSelected={autoCopy} isSelected={autoCopy}
color="primary" color="primary"
onValueChange={(isSelected) => { onValueChange={(isSelected) => {
updateSettings({ autoCopy: isSelected }) setAutoCopy(isSelected)
}} }}
/> />
</RowFlex> </RowFlex>
@@ -97,7 +70,6 @@ const TranslateSettings: FC<{
color="primary" color="primary"
onValueChange={(isSelected) => { onValueChange={(isSelected) => {
setIsScrollSyncEnabled(isSelected) setIsScrollSyncEnabled(isSelected)
db.settings.put({ id: 'translate:scroll:sync', value: isSelected })
}} }}
/> />
</Flex> </Flex>
@@ -148,7 +120,7 @@ const TranslateSettings: FC<{
isSelected={isBidirectional} isSelected={isBidirectional}
color="primary" color="primary"
onValueChange={(isSelected) => { onValueChange={(isSelected) => {
setIsBidirectional(isSelected) setBidirectional({ ...bidirectional, enabled: isSelected })
// 双向翻译设置不需要持久化,它只是界面状态 // 双向翻译设置不需要持久化,它只是界面状态
}} }}
/> />
@@ -158,36 +130,32 @@ const TranslateSettings: FC<{
<Flex className="items-center justify-between gap-2.5"> <Flex className="items-center justify-between gap-2.5">
<LanguageSelect <LanguageSelect
style={{ flex: 1 }} style={{ flex: 1 }}
value={localPair[0].langCode} value={bidirectional.origin}
onChange={(value) => { onChange={(value) => {
const newPair: [TranslateLanguage, TranslateLanguage] = [getLanguageByLangcode(value), localPair[1]] if (value === bidirectional.target) {
if (newPair[0] === newPair[1]) {
window.toast.warning(t('translate.language.same')) window.toast.warning(t('translate.language.same'))
return return
} }
setLocalPair(newPair) setBidirectional({
setBidirectionalPair(newPair) ...bidirectional,
db.settings.put({ origin: value,
id: 'translate:bidirectional:pair', target: bidirectional.target
value: [newPair[0].langCode, newPair[1].langCode]
}) })
}} }}
/> />
<span></span> <span></span>
<LanguageSelect <LanguageSelect
style={{ flex: 1 }} style={{ flex: 1 }}
value={localPair[1].langCode} value={bidirectional.target}
onChange={(value) => { onChange={(value) => {
const newPair: [TranslateLanguage, TranslateLanguage] = [localPair[0], getLanguageByLangcode(value)] if (bidirectional.origin === value) {
if (newPair[0] === newPair[1]) {
window.toast.warning(t('translate.language.same')) window.toast.warning(t('translate.language.same'))
return return
} }
setLocalPair(newPair) setBidirectional({
setBidirectionalPair(newPair) ...bidirectional,
db.settings.put({ origin: bidirectional.origin,
id: 'translate:bidirectional:pair', target: value
value: [newPair[0].langCode, newPair[1].langCode]
}) })
}} }}
/> />
+11 -14
View File
@@ -1,10 +1,9 @@
import type { PayloadAction } from '@reduxjs/toolkit' /** @deprecated All migrated. */
import { createSlice } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit'
export interface TranslateState { export interface TranslateState {
translateInput: string translateInput: string
translatedContent: string translatedContent: string
// TODO: #9749
settings: { settings: {
autoCopy: boolean autoCopy: boolean
} }
@@ -22,19 +21,17 @@ const translateSlice = createSlice({
name: 'translate', name: 'translate',
initialState, initialState,
reducers: { reducers: {
setTranslateInput: (state, action: PayloadAction<string>) => { // setTranslateInput: (state, action: PayloadAction<string>) => {
state.translateInput = action.payload // state.translateInput = action.payload
}, // },
setTranslatedContent: (state, action: PayloadAction<string>) => { // setTranslatedContent: (state, action: PayloadAction<string>) => {
state.translatedContent = action.payload // state.translatedContent = action.payload
}, // },
updateSettings: (state, action: PayloadAction<Partial<TranslateState['settings']>>) => { // updateSettings: (state, action: PayloadAction<Partial<TranslateState['settings']>>) => {
const update = action.payload // const update = action.payload
Object.assign(state.settings, update) // Object.assign(state.settings, update)
} // }
} }
}) })
export const { setTranslateInput, setTranslatedContent, updateSettings } = translateSlice.actions
export default translateSlice.reducer export default translateSlice.reducer
-13
View File
@@ -485,7 +485,6 @@ export type TranslateLanguageCode = string
export type TranslateLanguage = { export type TranslateLanguage = {
value: string value: string
langCode: TranslateLanguageCode langCode: TranslateLanguageCode
label: () => string
emoji: string emoji: string
} }
@@ -507,18 +506,6 @@ export type CustomTranslateLanguage = {
emoji: string 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 // by fullex @ data refactor
// export type SidebarIcon = // export type SidebarIcon =
// | 'assistants' // | 'assistants'
+42 -34
View File
@@ -11,6 +11,7 @@ import type { Assistant, TranslateLanguage, TranslateLanguageCode } from '@rende
import type { Chunk } from '@renderer/types/chunk' import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk'
import { LANG_DETECT_PROMPT } from '@shared/config/prompts' import { LANG_DETECT_PROMPT } from '@shared/config/prompts'
import type { CacheTranslateBidirectional } from '@shared/data/cache/cacheValueTypes'
import { franc } from 'franc-min' import { franc } from 'franc-min'
import type { RefObject } from 'react' import type { RefObject } from 'react'
import React from 'react' import React from 'react'
@@ -128,60 +129,68 @@ const detectLanguageByFranc = (inputText: string): TranslateLanguageCode => {
} }
/** /**
* 获取双向翻译的目标语言 * Determine the target language for bidirectional translation.
* @param sourceLanguage 检测到的源语言 * When the source language matches one side of the pair, the opposite side is returned.
* @param languagePair 配置的语言对 * @param sourceLanguage The detected source language code
* @returns 目标语言 * @param languagePair The configured bidirectional language pair
* @returns The target language code to translate into
*/ */
export const getTargetLanguageForBidirectional = ( export const getTargetLanguageForBidirectional = (
sourceLanguage: TranslateLanguage, sourceLanguage: TranslateLanguageCode,
languagePair: [TranslateLanguage, TranslateLanguage] languagePair: CacheTranslateBidirectional
): TranslateLanguage => { ): TranslateLanguageCode => {
if (sourceLanguage.langCode === languagePair[0].langCode) { const { origin, target } = languagePair
return languagePair[1] if (sourceLanguage === origin) {
} else if (sourceLanguage.langCode === languagePair[1].langCode) { return target
return languagePair[0] } else if (sourceLanguage === target) {
return origin
} }
return languagePair[0] !== sourceLanguage ? languagePair[0] : languagePair[1] return origin !== sourceLanguage ? origin : target
} }
/** /**
* 检查源语言是否在配置的语言对中 * Check if the source language is within the configured language pair
* @param sourceLanguage 检测到的源语言 * @param sourceLanguage The detected source language code
* @param languagePair 配置的语言对 * @param languagePair The configured bidirectional language pair
* @returns 是否在语言对中 * @returns true if the source language is in the pair, false otherwise
*/ */
export const isLanguageInPair = ( export const isLanguageInPair = (
sourceLanguage: TranslateLanguage, sourceLanguage: TranslateLanguageCode,
languagePair: [TranslateLanguage, TranslateLanguage] languagePair: CacheTranslateBidirectional
): boolean => { ): 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'
}
/** /**
* 确定翻译的目标语言 * Determine the target language for translation
* @param sourceLanguage 检测到的源语言 * @param sourceLanguage The detected source language code
* @param targetLanguage 用户设置的目标语言 * @param targetLanguage The user-set target language code
* @param isBidirectional 是否开启双向翻译 * @param bidirectional The bidirectional translation configuration
* @param bidirectionalPair 双向翻译的语言对 * @returns An object indicating success or failure, including the target language code if successful, or an error type if failed
* @returns 处理结果对象
*/ */
export const determineTargetLanguage = ( export const determineTargetLanguage = (
sourceLanguage: TranslateLanguage, sourceLanguage: TranslateLanguageCode,
targetLanguage: TranslateLanguage, targetLanguage: TranslateLanguageCode,
isBidirectional: boolean, bidirectional: CacheTranslateBidirectional
bidirectionalPair: [TranslateLanguage, TranslateLanguage] ): DetermineTargetLanguageReturn => {
): { success: boolean; language?: TranslateLanguage; errorType?: 'same_language' | 'not_in_pair' } => { const isBidirectional = bidirectional.enabled
if (isBidirectional) { if (isBidirectional) {
if (!isLanguageInPair(sourceLanguage, bidirectionalPair)) { if (!isLanguageInPair(sourceLanguage, bidirectional)) {
return { success: false, errorType: 'not_in_pair' } return { success: false, errorType: 'not_in_pair' }
} }
return { return {
success: true, success: true,
language: getTargetLanguageForBidirectional(sourceLanguage, bidirectionalPair) language: getTargetLanguageForBidirectional(sourceLanguage, bidirectional)
} }
} else { } else {
if (sourceLanguage.langCode === targetLanguage.langCode) { if (sourceLanguage === targetLanguage) {
return { success: false, errorType: 'same_language' } return { success: false, errorType: 'same_language' }
} }
return { success: true, language: targetLanguage } return { success: true, language: targetLanguage }
@@ -251,7 +260,6 @@ export const getTranslateOptions = async () => {
// 转换为Language类型 // 转换为Language类型
const transformedCustomLangs: TranslateLanguage[] = customLanguages.map((item) => ({ const transformedCustomLangs: TranslateLanguage[] = customLanguages.map((item) => ({
value: item.value, value: item.value,
label: () => item.value,
emoji: item.emoji, emoji: item.emoji,
langCode: item.langCode langCode: item.langCode
})) }))
@@ -4,17 +4,15 @@ import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import CopyButton from '@renderer/components/CopyButton' import CopyButton from '@renderer/components/CopyButton'
import LanguageSelect from '@renderer/components/LanguageSelect' import LanguageSelect from '@renderer/components/LanguageSelect'
import { LanguagesEnum, UNKNOWN } from '@renderer/config/translate' import { UNKNOWN } from '@renderer/config/translate'
import db from '@renderer/databases'
import { useTopicMessages } from '@renderer/hooks/useMessageOperations' import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import useTranslate from '@renderer/hooks/useTranslate' import useTranslate from '@renderer/hooks/useTranslate'
import MessageContent from '@renderer/pages/home/Messages/MessageContent' import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService' 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 { runAsyncFunction } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController' import { abortCompletion } from '@renderer/utils/abortController'
import { detectLanguage } from '@renderer/utils/translate' import { detectLanguage } from '@renderer/utils/translate'
import { defaultLanguage } from '@shared/config/constant'
import type { SelectionActionItem } from '@shared/data/preference/preferenceTypes' import type { SelectionActionItem } from '@shared/data/preference/preferenceTypes'
import { ArrowRightFromLine, ArrowRightToLine, ChevronDown, CircleHelp, Globe } from 'lucide-react' import { ArrowRightFromLine, ArrowRightToLine, ChevronDown, CircleHelp, Globe } from 'lucide-react'
import type { FC } from 'react' import type { FC } from 'react'
@@ -34,11 +32,9 @@ const logger = loggerService.withContext('ActionTranslate')
const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [language] = usePreference('app.language')
const [translateModelPrompt] = usePreference('feature.translate.model_prompt') const [translateModelPrompt] = usePreference('feature.translate.model_prompt')
const [targetLangs, setTargetLangs] = usePreference('translate.settings.target_langs')
const [targetLanguage, setTargetLanguage] = useState<TranslateLanguage>(LanguagesEnum.enUS) const { target: targetLanguage, alter: alterLanguage } = targetLangs
const [alterLanguage, setAlterLanguage] = useState<TranslateLanguage>(LanguagesEnum.zhCN)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showOriginal, setShowOriginal] = useState(false) const [showOriginal, setShowOriginal] = useState(false)
@@ -53,36 +49,6 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const topicRef = useRef<Topic | null>(null) const topicRef = useRef<Topic | null>(null)
const askId = useRef('') 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 // Initialize values only once when action changes
useEffect(() => { useEffect(() => {
if (initialized.current || !action.selectedText) return if (initialized.current || !action.selectedText) return
@@ -90,14 +56,22 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
runAsyncFunction(async () => { runAsyncFunction(async () => {
// Initialize assistant // 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 assistantRef.current = currentAssistant
// Initialize topic // Initialize topic
topicRef.current = getDefaultTopic(currentAssistant.id) topicRef.current = getDefaultTopic(currentAssistant.id)
}) })
}, [action, targetLanguage, translateModelPrompt]) }, [action, getLanguageByLangcode, targetLanguage, translateModelPrompt])
const fetchResult = useCallback(async () => { const fetchResult = useCallback(async () => {
if (!assistantRef.current || !topicRef.current || !action.selectedText) return if (!assistantRef.current || !topicRef.current || !action.selectedText) return
@@ -130,24 +104,31 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
return return
} }
let translateLang: TranslateLanguage let translateLang: TranslateLanguageCode
if (sourceLanguageCode === UNKNOWN.langCode) { if (sourceLanguageCode === UNKNOWN.langCode) {
logger.debug('Unknown source language. Just use target language.') logger.debug('Unknown source language. Just use target language.')
translateLang = targetLanguage translateLang = targetLanguage
} else { } else {
logger.debug('Detected Language: ', { sourceLanguage: sourceLanguageCode }) logger.debug('Detected Language: ', { sourceLanguage: sourceLanguageCode })
if (sourceLanguageCode === targetLanguage.langCode) { if (sourceLanguageCode === targetLanguage) {
translateLang = alterLanguage translateLang = alterLanguage
} else { } else {
translateLang = targetLanguage translateLang = targetLanguage
} }
} }
const assistant = await getDefaultTranslateAssistant(translateLang, action.selectedText) let assistant: TranslateAssistant
assistantRef.current = assistant 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) processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError)
}, [action, targetLanguage, alterLanguage, scrollToBottom]) }, [action.selectedText, getLanguageByLangcode, scrollToBottom, targetLanguage, alterLanguage])
useEffect(() => { useEffect(() => {
fetchResult() fetchResult()
@@ -161,11 +142,11 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null
}, [allMessages]) }, [allMessages])
const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => { const handleChangeLanguage = (targetLanguage: TranslateLanguageCode, alterLanguage: TranslateLanguageCode) => {
setTargetLanguage(targetLanguage) setTargetLangs({
setAlterLanguage(alterLanguage) target: targetLanguage,
alter: alterLanguage
db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage.langCode, alterLanguage.langCode] }) })
} }
const handlePause = () => { const handlePause = () => {
@@ -191,24 +172,24 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
<ArrowRightToLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} /> <ArrowRightToLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
<Tooltip placement="bottom" content={t('translate.target_language')}> <Tooltip placement="bottom" content={t('translate.target_language')}>
<LanguageSelect <LanguageSelect
value={targetLanguage.langCode} value={targetLanguage}
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }} style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
listHeight={160} listHeight={160}
title={t('translate.target_language')} title={t('translate.target_language')}
optionFilterProp="label" optionFilterProp="label"
onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)} onChange={(value) => handleChangeLanguage(value, alterLanguage)}
disabled={isLoading} disabled={isLoading}
/> />
</Tooltip> </Tooltip>
<ArrowRightFromLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} /> <ArrowRightFromLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
<Tooltip placement="bottom" content={t('translate.alter_language')}> <Tooltip placement="bottom" content={t('translate.alter_language')}>
<LanguageSelect <LanguageSelect
value={alterLanguage.langCode} value={alterLanguage}
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }} style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
listHeight={160} listHeight={160}
title={t('translate.alter_language')} title={t('translate.alter_language')}
optionFilterProp="label" optionFilterProp="label"
onChange={(value) => handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))} onChange={(value) => handleChangeLanguage(targetLanguage, value)}
disabled={isLoading} disabled={isLoading}
/> />
</Tooltip> </Tooltip>