feat(SelectionAssistant): Smart Translation ( aka BiDirectionTranslate) (#6715)

* feat(Translation): enhance translation functionality and UI improvements

- Added secondary text color variables in color.scss for better UI contrast.
- Updated translation configuration to include language codes for better language handling.
- Enhanced translation UI with new language selection options and improved loading indicators.
- Implemented smart translation tips in multiple language JSON files for user guidance.
- Refactored translation logic to streamline message processing and error handling.

* feat(Translation): expand language options and update localization files

- Added new languages (Polish, Turkish, Thai, Vietnamese, Indonesian, Urdu, Malay) to translation options in translate.ts.
- Updated localization JSON files (en-us, ja-jp, ru-ru, zh-cn, zh-tw) to include translations for the new languages.
- Enhanced language detection logic in translate.ts to support new language codes.
This commit is contained in:
fullex 2025-06-04 17:11:53 +08:00 committed by GitHub
parent 242ea279ee
commit 41bc118426
12 changed files with 514 additions and 220 deletions

View File

@ -26,6 +26,7 @@
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-text-secondary: rgba(235, 235, 245, 0.7);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #ffffff19;
@ -98,6 +99,7 @@
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-text-secondary: rgba(0, 0, 0, 0.75);
--color-icon: #00000099;
--color-icon-white: #000000;
--color-border: #00000019;

View File

@ -1,65 +1,127 @@
import i18n from '@renderer/i18n'
export const TranslateLanguageOptions = [
export interface TranslateLanguageOption {
value: string
langCode?: string
label: string
emoji: string
}
export const TranslateLanguageOptions: TranslateLanguageOption[] = [
{
value: 'english',
langCode: 'en-us',
label: i18n.t('languages.english'),
emoji: '🇬🇧'
},
{
value: 'chinese',
langCode: 'zh-cn',
label: i18n.t('languages.chinese'),
emoji: '🇨🇳'
},
{
value: 'chinese-traditional',
langCode: 'zh-tw',
label: i18n.t('languages.chinese-traditional'),
emoji: '🇭🇰'
},
{
value: 'japanese',
langCode: 'ja-jp',
label: i18n.t('languages.japanese'),
emoji: '🇯🇵'
},
{
value: 'korean',
langCode: 'ko-kr',
label: i18n.t('languages.korean'),
emoji: '🇰🇷'
},
{
value: 'russian',
label: i18n.t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'spanish',
label: i18n.t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'french',
langCode: 'fr-fr',
label: i18n.t('languages.french'),
emoji: '🇫🇷'
},
{
value: 'german',
langCode: 'de-de',
label: i18n.t('languages.german'),
emoji: '🇩🇪'
},
{
value: 'italian',
langCode: 'it-it',
label: i18n.t('languages.italian'),
emoji: '🇮🇹'
},
{
value: 'spanish',
langCode: 'es-es',
label: i18n.t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'portuguese',
langCode: 'pt-pt',
label: i18n.t('languages.portuguese'),
emoji: '🇵🇹'
},
{
value: 'russian',
langCode: 'ru-ru',
label: i18n.t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'polish',
langCode: 'pl-pl',
label: i18n.t('languages.polish'),
emoji: '🇵🇱'
},
{
value: 'arabic',
langCode: 'ar-ar',
label: i18n.t('languages.arabic'),
emoji: '🇸🇦'
},
{
value: 'german',
label: i18n.t('languages.german'),
emoji: '🇩🇪'
value: 'turkish',
langCode: 'tr-tr',
label: i18n.t('languages.turkish'),
emoji: '🇹🇷'
},
{
value: 'thai',
langCode: 'th-th',
label: i18n.t('languages.thai'),
emoji: '🇹🇭'
},
{
value: 'vietnamese',
langCode: 'vi-vn',
label: i18n.t('languages.vietnamese'),
emoji: '🇻🇳'
},
{
value: 'indonesian',
langCode: 'id-id',
label: i18n.t('languages.indonesian'),
emoji: '🇮🇩'
},
{
value: 'urdu',
langCode: 'ur-pk',
label: i18n.t('languages.urdu'),
emoji: '🇵🇰'
},
{
value: 'malay',
langCode: 'ms-my',
label: i18n.t('languages.malay'),
emoji: '🇲🇾'
}
]

View File

@ -586,7 +586,14 @@
"korean": "Korean",
"portuguese": "Portuguese",
"russian": "Russian",
"spanish": "Spanish"
"spanish": "Spanish",
"polish": "Polish",
"turkish": "Turkish",
"thai": "Thai",
"vietnamese": "Vietnamese",
"indonesian": "Indonesian",
"urdu": "Urdu",
"malay": "Malay"
},
"lmstudio": {
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
@ -1794,6 +1801,8 @@
},
"translate": {
"any.language": "Any language",
"target_language": "Target Language",
"alter_language": "Alternative Language",
"button.translate": "Translate",
"close": "Close",
"closed": "Translation closed",
@ -1873,6 +1882,9 @@
"esc_stop": "Esc: Stop",
"c_copy": "C: Copy",
"r_regenerate": "R: Regenerate"
},
"translate": {
"smart_translate_tips": "Smart Translation: Content will be translated to the target language first; content already in the target language will be translated to the alternative language"
}
},
"settings": {

View File

@ -586,7 +586,14 @@
"korean": "韓国語",
"portuguese": "ポルトガル語",
"russian": "ロシア語",
"spanish": "スペイン語"
"spanish": "スペイン語",
"polish": "ポーランド語",
"turkish": "トルコ語",
"thai": "タイ語",
"vietnamese": "ベトナム語",
"indonesian": "インドネシア語",
"urdu": "ウルドゥー語",
"malay": "マレー語"
},
"lmstudio": {
"keep_alive_time.description": "モデルがメモリに保持される時間デフォルト5分",
@ -1794,6 +1801,8 @@
},
"translate": {
"any.language": "任意の言語",
"target_language": "目標言語",
"alter_language": "備用言語",
"button.translate": "翻訳",
"close": "閉じる",
"closed": "翻訳は閉じられました",
@ -1873,6 +1882,9 @@
"esc_stop": "Escで停止",
"c_copy": "Cでコピー",
"r_regenerate": "Rで再生成"
},
"translate": {
"smart_translate_tips": "スマート翻訳:内容は優先的に目標言語に翻訳されます。すでに目標言語の場合は、備用言語に翻訳されます。"
}
},
"settings": {

View File

@ -586,7 +586,14 @@
"korean": "Корейский",
"portuguese": "Португальский",
"russian": "Русский",
"spanish": "Испанский"
"spanish": "Испанский",
"polish": "Польский",
"turkish": "Туркменский",
"thai": "Тайский",
"vietnamese": "Вьетнамский",
"indonesian": "Индонезийский",
"urdu": "Урду",
"malay": "Малайзийский"
},
"lmstudio": {
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
@ -1794,6 +1801,8 @@
},
"translate": {
"any.language": "Любой язык",
"target_language": "Целевой язык",
"alter_language": "Альтернативный язык",
"button.translate": "Перевести",
"close": "Закрыть",
"closed": "Перевод закрыт",
@ -1873,6 +1882,9 @@
"esc_stop": "Esc - остановить",
"c_copy": "C - копировать",
"r_regenerate": "R - перегенерировать"
},
"translate": {
"smart_translate_tips": "Смарт-перевод: содержимое будет переведено на целевой язык; содержимое уже на целевом языке будет переведено на альтернативный язык"
}
},
"settings": {

View File

@ -586,7 +586,14 @@
"korean": "韩文",
"portuguese": "葡萄牙文",
"russian": "俄文",
"spanish": "西班牙文"
"spanish": "西班牙文",
"polish": "波兰文",
"turkish": "土耳其文",
"thai": "泰文",
"vietnamese": "越南文",
"indonesian": "印尼文",
"urdu": "乌尔都文",
"malay": "马来文"
},
"lmstudio": {
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟",
@ -1794,6 +1801,8 @@
},
"translate": {
"any.language": "任意语言",
"target_language": "目标语言",
"alter_language": "备用语言",
"button.translate": "翻译",
"close": "关闭",
"closed": "翻译已关闭",
@ -1873,6 +1882,9 @@
"esc_stop": "Esc 停止",
"c_copy": "C 复制",
"r_regenerate": "R 重新生成"
},
"translate": {
"smart_translate_tips": "智能翻译:内容将优先翻译为目标语言;内容已是目标语言的,将翻译为备选语言"
}
},
"settings": {

View File

@ -586,7 +586,14 @@
"korean": "韓文",
"portuguese": "葡萄牙文",
"russian": "俄文",
"spanish": "西班牙文"
"spanish": "西班牙文",
"polish": "波蘭文",
"turkish": "土耳其文",
"thai": "泰文",
"vietnamese": "越南文",
"indonesian": "印尼文",
"urdu": "烏爾都文",
"malay": "馬來文"
},
"lmstudio": {
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
@ -1794,6 +1801,8 @@
},
"translate": {
"any.language": "任意語言",
"target_language": "目標語言",
"alter_language": "備用語言",
"button.translate": "翻譯",
"close": "關閉",
"closed": "翻譯已關閉",
@ -1873,6 +1882,9 @@
"esc_stop": "Esc 停止",
"c_copy": "C 複製",
"r_regenerate": "R 重新生成"
},
"translate": {
"smart_translate_tips": "智能翻譯:內容將優先翻譯為目標語言;內容已是目標語言的,將翻譯為備用語言"
}
},
"settings": {

View File

@ -84,7 +84,14 @@ export const detectLanguage = async (inputText: string): Promise<string> => {
deu: 'de',
ita: 'it',
por: 'pt',
eng: 'en'
eng: 'en',
pol: 'pl',
tur: 'tr',
tha: 'th',
vie: 'vi',
ind: 'id',
urd: 'ur',
zsm: 'ms'
}
code = isoMap[iso3] || 'en'
}
@ -101,7 +108,14 @@ export const detectLanguage = async (inputText: string): Promise<string> => {
it: 'italian',
pt: 'portuguese',
ar: 'arabic',
en: 'english'
en: 'english',
pl: 'polish',
tr: 'turkish',
th: 'thai',
vi: 'vietnamese',
id: 'indonesian',
ur: 'urdu',
ms: 'malay'
}
return languageMap[code] || 'english'

View File

@ -7,7 +7,7 @@ import { IpcChannel } from '@shared/IpcChannel'
import { Button, Slider, Tooltip } from 'antd'
import { Droplet, Minus, Pin, X } from 'lucide-react'
import { DynamicIcon } from 'lucide-react/dynamic'
import { FC, useEffect, useRef, useState } from 'react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -140,14 +140,14 @@ const SelectionActionApp: FC = () => {
setOpacity(value)
}
const handleScrollToBottom = () => {
const handleScrollToBottom = useCallback(() => {
if (contentElementRef.current && isAutoScrollEnabled.current) {
contentElementRef.current.scrollTo({
top: contentElementRef.current.scrollHeight,
behavior: 'smooth'
})
}
}
}, [])
const handleUserScroll = () => {
if (!contentElementRef.current) return

View File

@ -3,29 +3,21 @@ import CopyButton from '@renderer/components/CopyButton'
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import { useSettings } from '@renderer/hooks/useSettings'
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import {
getAssistantById,
getDefaultAssistant,
getDefaultModel,
getDefaultTopic
} from '@renderer/services/AssistantService'
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
import store from '@renderer/store'
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
import { newMessagesActions } from '@renderer/store/newMessage'
import { Assistant, Topic } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { abortCompletion } from '@renderer/utils/abortController'
import { isAbortError } from '@renderer/utils/error'
import { createMainTextBlock } from '@renderer/utils/messageUtils/create'
import { ChevronDown } from 'lucide-react'
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { processMessages } from './ActionUtils'
import WindowFooter from './WindowFooter'
interface Props {
@ -101,111 +93,41 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
promptContentRef.current = userContent
}, [action, language])
const allMessages = useTopicMessages(topicRef.current?.id || '')
const fetchResult = useCallback(async () => {
if (!assistantRef.current || !topicRef.current) return
try {
const { message: userMessage, blocks: userBlocks } = getUserMessage({
assistant: assistantRef.current,
topic: topicRef.current,
content: promptContentRef.current
})
askId.current = userMessage.id
store.dispatch(newMessagesActions.addMessage({ topicId: topicRef.current.id, message: userMessage }))
store.dispatch(upsertManyBlocks(userBlocks))
let blockId: string | null = null
let blockContent: string = ''
const assistantMessage = getAssistantMessage({
assistant: assistantRef.current,
topic: topicRef.current
})
store.dispatch(
newMessagesActions.addMessage({
topicId: topicRef.current.id,
message: assistantMessage
})
)
await fetchChatCompletion({
messages: [userMessage],
assistant: assistantRef.current,
onChunkReceived: (chunk: Chunk) => {
switch (chunk.type) {
case ChunkType.THINKING_DELTA:
case ChunkType.THINKING_COMPLETE:
//TODO
break
case ChunkType.TEXT_DELTA:
{
setIsContented(true)
blockContent += chunk.text
if (!blockId) {
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
status: MessageBlockStatus.STREAMING
})
blockId = block.id
store.dispatch(
newMessagesActions.updateMessage({
topicId: topicRef.current!.id,
messageId: assistantMessage.id,
updates: { blockInstruction: { id: block.id } }
})
)
store.dispatch(upsertOneBlock(block))
} else {
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
}
scrollToBottom?.()
}
break
case ChunkType.TEXT_COMPLETE:
{
blockId &&
store.dispatch(
updateOneBlock({
id: blockId,
changes: { status: MessageBlockStatus.SUCCESS }
})
)
store.dispatch(
newMessagesActions.updateMessage({
topicId: topicRef.current!.id,
messageId: assistantMessage.id,
updates: { status: AssistantMessageStatus.SUCCESS }
})
)
setContentToCopy(chunk.text)
}
break
case ChunkType.BLOCK_COMPLETE:
case ChunkType.ERROR:
setIsLoading(false)
break
}
}
})
} catch (err) {
if (isAbortError(err)) return
setIsLoading(false)
setError(err instanceof Error ? err.message : 'An error occurred')
console.error('Error fetching result:', err)
const fetchResult = useCallback(() => {
const setAskId = (id: string) => {
askId.current = id
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onStream = () => {
setIsContented(true)
scrollToBottom?.()
}
const onFinish = (content: string) => {
setContentToCopy(content)
setIsLoading(false)
}
const onError = (error: Error) => {
setIsLoading(false)
setError(error.message)
}
if (!assistantRef.current || !topicRef.current) return
processMessages(
assistantRef.current,
topicRef.current,
promptContentRef.current,
setAskId,
onStream,
onFinish,
onError
)
}, [scrollToBottom])
useEffect(() => {
if (assistantRef.current && topicRef.current) {
fetchResult()
}
fetchResult()
}, [fetchResult])
const allMessages = useTopicMessages(topicRef.current?.id || '')
// Memoize the messages to prevent unnecessary re-renders
const messageContent = useMemo(() => {
const assistantMessages = allMessages.filter((message) => message.role === 'assistant')

View File

@ -1,119 +1,229 @@
import { LoadingOutlined } from '@ant-design/icons'
import CopyButton from '@renderer/components/CopyButton'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import { TranslateLanguageOptions, translateLanguageOptions } from '@renderer/config/translate'
import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { Assistant } from '@renderer/types'
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import { useSettings } from '@renderer/hooks/useSettings'
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import {
getDefaultAssistant,
getDefaultModel,
getDefaultTopic,
getTranslateModel
} from '@renderer/services/AssistantService'
import { Assistant, Topic } from '@renderer/types'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { runAsyncFunction } from '@renderer/utils'
import { Select, Space } from 'antd'
import { isEmpty } from 'lodash'
import { ChevronDown } from 'lucide-react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { abortCompletion } from '@renderer/utils/abortController'
import { detectLanguage } from '@renderer/utils/translate'
import { Select, Space, Tooltip } from 'antd'
import { ArrowRightFromLine, ArrowRightToLine, ChevronDown, CircleHelp, Globe } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { processMessages } from './ActionUtils'
import WindowFooter from './WindowFooter'
interface Props {
action: ActionItem
scrollToBottom: () => void
}
let _targetLanguage = 'chinese'
const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const { t } = useTranslation()
const { translateModelPrompt, language } = useSettings()
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
const { translateModel } = useDefaultModel()
const [targetLanguage, setTargetLanguage] = useState('')
const [alterLanguage, setAlterLanguage] = useState('')
const [isLangSelectDisabled, setIsLangSelectDisabled] = useState(false)
const [showOriginal, setShowOriginal] = useState(false)
const [result, setResult] = useState('')
const [contentToCopy, setContentToCopy] = useState('')
const [error, setError] = useState('')
const [showOriginal, setShowOriginal] = useState(false)
const [isContented, setIsContented] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [contentToCopy, setContentToCopy] = useState('')
const translatingRef = useRef(false)
_targetLanguage = targetLanguage
const translate = useCallback(async () => {
if (!action.selectedText || !action.selectedText.trim() || !translateModel) return
if (translatingRef.current) return
try {
translatingRef.current = true
setError('')
const targetLang = await db.settings.get({ id: 'translate:target:language' })
const assistant: Assistant = getDefaultTranslateAssistant(
targetLang?.value || targetLanguage,
action.selectedText
)
const onResult = (text: string, isComplete: boolean) => {
setResult(text)
scrollToBottom()
if (isComplete) {
setContentToCopy(text)
setIsLangSelectDisabled(false)
}
}
setIsLangSelectDisabled(true)
await fetchTranslate({ content: action.selectedText || '', assistant, onResponse: onResult })
translatingRef.current = false
} catch (error: any) {
setError(error?.message || t('error.unknown'))
console.error(error)
} finally {
translatingRef.current = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [action, targetLanguage, translateModel])
// Use useRef for values that shouldn't trigger re-renders
const initialized = useRef(false)
const assistantRef = useRef<Assistant | null>(null)
const topicRef = useRef<Topic | null>(null)
const askId = useRef('')
useEffect(() => {
runAsyncFunction(async () => {
const targetLang = await db.settings.get({ id: 'translate:target:language' })
targetLang && setTargetLanguage(targetLang.value)
const biDirectionLangPair = await db.settings.get({ id: 'translate:bidirectional:pair' })
let targetLang = ''
let alterLang = ''
if (!biDirectionLangPair || !biDirectionLangPair.value[0]) {
const lang = TranslateLanguageOptions.find((lang) => lang.langCode?.toLowerCase() === language.toLowerCase())
if (lang) {
targetLang = lang.value
} else {
targetLang = 'chinese'
}
} else {
targetLang = biDirectionLangPair.value[0]
}
if (!biDirectionLangPair || !biDirectionLangPair.value[1]) {
alterLang = 'english'
} else {
alterLang = biDirectionLangPair.value[1]
}
setTargetLanguage(targetLang)
setAlterLanguage(alterLang)
})
}, [])
}, [language])
// Initialize values only once when action changes
useEffect(() => {
if (initialized.current || !action.selectedText) return
initialized.current = true
// Initialize assistant
const currentAssistant = getDefaultAssistant()
const translateModel = getTranslateModel() || getDefaultModel()
currentAssistant.model = translateModel
currentAssistant.settings = {
temperature: 0.7
}
assistantRef.current = currentAssistant
// Initialize topic
topicRef.current = getDefaultTopic(currentAssistant.id)
}, [action, targetLanguage, translateModelPrompt])
const fetchResult = useCallback(async () => {
if (!assistantRef.current || !topicRef.current || !action.selectedText) return
const setAskId = (id: string) => {
askId.current = id
}
const onStream = () => {
setIsContented(true)
scrollToBottom?.()
}
const onFinish = (content: string) => {
setContentToCopy(content)
setIsLoading(false)
}
const onError = (error: Error) => {
setIsLoading(false)
setError(error.message)
}
setIsLoading(true)
const sourceLanguage = await detectLanguage(action.selectedText)
let translateLang = ''
if (sourceLanguage === targetLanguage) {
translateLang = alterLanguage
} else {
translateLang = targetLanguage
}
// Initialize prompt content
const userContent = translateModelPrompt
.replaceAll('{{target_language}}', translateLang)
.replaceAll('{{text}}', action.selectedText)
processMessages(assistantRef.current, topicRef.current, userContent, setAskId, onStream, onFinish, onError)
}, [action, targetLanguage, alterLanguage, translateModelPrompt, scrollToBottom])
useEffect(() => {
translate()
}, [translate])
fetchResult()
}, [fetchResult])
const allMessages = useTopicMessages(topicRef.current?.id || '')
const messageContent = useMemo(() => {
const assistantMessages = allMessages.filter((message) => message.role === 'assistant')
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1]
return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null
}, [allMessages])
const handleChangeLanguage = (targetLanguage: string, alterLanguage: string) => {
setTargetLanguage(targetLanguage)
setAlterLanguage(alterLanguage)
db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage, alterLanguage] })
}
const handlePause = () => {
if (askId.current) {
abortCompletion(askId.current)
setIsLoading(false)
}
}
const handleRegenerate = () => {
setContentToCopy('')
setIsLoading(true)
fetchResult()
}
return (
<>
<Container>
<MenuContainer>
<Select
value={targetLanguage}
style={{ maxWidth: 200, minWidth: 130, flex: 1 }}
listHeight={160}
optionFilterProp="label"
options={TranslateLanguageOptions}
onChange={async (value) => {
await db.settings.put({ id: 'translate:target:language', value })
setTargetLanguage(value)
}}
disabled={isLangSelectDisabled}
optionRender={(option) => (
<Space>
<span role="img" aria-label={option.data.label}>
{option.data.emoji}
</span>
{option.label}
</Space>
)}
/>
<Tooltip placement="bottom" title={t('translate.any.language')} arrow>
<Globe size={16} style={{ flexShrink: 0 }} />
</Tooltip>
<ArrowRightToLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
<Tooltip placement="bottom" title={t('translate.target_language')} arrow>
<Select
value={targetLanguage}
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
listHeight={160}
title={t('translate.target_language')}
optionFilterProp="label"
options={translateLanguageOptions().map((lang) => ({
value: lang.value,
label: (
<Space.Compact direction="horizontal" block>
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
{lang.emoji}
</span>
<Space.Compact block>{lang.label}</Space.Compact>
</Space.Compact>
)
}))}
onChange={(value) => handleChangeLanguage(value, alterLanguage)}
disabled={isLoading}
/>
</Tooltip>
<ArrowRightFromLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
<Tooltip placement="bottom" title={t('translate.alter_language')} arrow>
<Select
value={alterLanguage}
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
listHeight={160}
title={t('translate.alter_language')}
optionFilterProp="label"
options={translateLanguageOptions().map((lang) => ({
value: lang.value,
label: (
<Space.Compact direction="horizontal" block>
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
{lang.emoji}
</span>
<Space.Compact block>{lang.label}</Space.Compact>
</Space.Compact>
)
}))}
onChange={(value) => handleChangeLanguage(targetLanguage, value)}
disabled={isLoading}
/>
</Tooltip>
<Tooltip placement="bottom" title={t('selection.action.translate.smart_translate_tips')} arrow>
<QuestionIcon size={14} style={{ marginLeft: 4 }} />
</Tooltip>
<Spacer />
<OriginalHeader onClick={() => setShowOriginal(!showOriginal)}>
<span>
{showOriginal ? t('selection.action.window.original_hide') : t('selection.action.window.original_show')}
@ -133,11 +243,14 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
</OriginalContentCopyWrapper>
</OriginalContent>
)}
<Result>{isEmpty(result) ? <LoadingOutlined style={{ fontSize: 16 }} spin /> : result}</Result>
<Result>
{!isContented && isLoading && <LoadingOutlined style={{ fontSize: 16 }} spin />}
{messageContent}
</Result>
{error && <ErrorMsg>{error}</ErrorMsg>}
</Container>
<FooterPadding />
<WindowFooter content={contentToCopy} />
<WindowFooter loading={isLoading} onPause={handlePause} onRegenerate={handleRegenerate} content={contentToCopy} />
</>
)
}
@ -173,6 +286,7 @@ const OriginalHeader = styled.div`
color: var(--color-text-secondary);
font-size: 12px;
padding: 4px 0;
white-space: nowrap;
&:hover {
color: var(--color-primary);
@ -218,4 +332,12 @@ const ErrorMsg = styled.div`
word-break: break-all;
`
const Spacer = styled.div`
flex-grow: 0.5;
`
const QuestionIcon = styled(CircleHelp)`
cursor: pointer;
color: var(--color-text-3);
`
export default ActionTranslate

View File

@ -0,0 +1,112 @@
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
import store from '@renderer/store'
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
import { newMessagesActions } from '@renderer/store/newMessage'
import { Assistant, Topic } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
import { isAbortError } from '@renderer/utils/error'
import { createMainTextBlock } from '@renderer/utils/messageUtils/create'
export const processMessages = async (
assistant: Assistant,
topic: Topic,
promptContent: string,
setAskId: (id: string) => void,
onStream: () => void,
onFinish: (content: string) => void,
onError: (error: Error) => void
) => {
if (!assistant || !topic) return
try {
const { message: userMessage, blocks: userBlocks } = getUserMessage({
assistant,
topic,
content: promptContent
})
setAskId(userMessage.id)
store.dispatch(newMessagesActions.addMessage({ topicId: topic.id, message: userMessage }))
store.dispatch(upsertManyBlocks(userBlocks))
let blockId: string | null = null
let blockContent: string = ''
const assistantMessage = getAssistantMessage({
assistant,
topic
})
store.dispatch(
newMessagesActions.addMessage({
topicId: topic.id,
message: assistantMessage
})
)
await fetchChatCompletion({
messages: [userMessage],
assistant,
onChunkReceived: (chunk: Chunk) => {
switch (chunk.type) {
case ChunkType.THINKING_DELTA:
case ChunkType.THINKING_COMPLETE:
//TODO
break
case ChunkType.TEXT_DELTA:
{
blockContent += chunk.text
if (!blockId) {
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
status: MessageBlockStatus.STREAMING
})
blockId = block.id
store.dispatch(
newMessagesActions.updateMessage({
topicId: topic.id,
messageId: assistantMessage.id,
updates: { blockInstruction: { id: block.id } }
})
)
store.dispatch(upsertOneBlock(block))
} else {
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
}
onStream()
}
break
case ChunkType.TEXT_COMPLETE:
{
blockId &&
store.dispatch(
updateOneBlock({
id: blockId,
changes: { status: MessageBlockStatus.SUCCESS }
})
)
store.dispatch(
newMessagesActions.updateMessage({
topicId: topic.id,
messageId: assistantMessage.id,
updates: { status: AssistantMessageStatus.SUCCESS }
})
)
blockContent = chunk.text
}
break
case ChunkType.BLOCK_COMPLETE:
case ChunkType.ERROR:
onFinish(blockContent)
break
}
}
})
} catch (err) {
if (isAbortError(err)) return
onError(err instanceof Error ? err : new Error('An error occurred'))
console.error('Error fetching result:', err)
}
}