mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
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:
parent
242ea279ee
commit
41bc118426
@ -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;
|
||||
|
||||
@ -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: '🇲🇾'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user