Compare commits
13 Commits
shortcut-c
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00fd20d828 | ||
|
|
d94f73b5ca | ||
|
|
7f34d084cc | ||
|
|
821f233728 | ||
|
|
0cb60fb2d6 | ||
|
|
19d1ce4b2a | ||
|
|
c2ee3fff33 | ||
|
|
9b7094ea4a | ||
|
|
6cda7f891d | ||
|
|
dbfece3590 | ||
|
|
9a67ac9018 | ||
|
|
2bd5f39740 | ||
|
|
9ebe4801f4 |
24
packages/shared/data/cache/cacheSchemas.ts
vendored
24
packages/shared/data/cache/cacheSchemas.ts
vendored
@@ -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',
|
||||
|
||||
16
packages/shared/data/cache/cacheValueTypes.ts
vendored
16
packages/shared/data/cache/cacheValueTypes.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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': [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: '🇺🇦'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user