feat: auto translate input text
This commit is contained in:
parent
3717ff25bf
commit
2e9041c891
@ -1,27 +1,41 @@
|
||||
import { TranslationOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
||||
import { getUserMessage } from '@renderer/services/MessagesService'
|
||||
import { Button } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
text?: string
|
||||
onTranslated: (translatedText: string) => void
|
||||
disabled?: boolean
|
||||
style?: React.CSSProperties
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style }) => {
|
||||
const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoading }) => {
|
||||
const { t } = useTranslation()
|
||||
const { translateModel } = useDefaultModel()
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
|
||||
const translateConfirm = () => {
|
||||
return window?.modal?.confirm({
|
||||
title: t('translate.confirm.title'),
|
||||
content: t('translate.confirm.content'),
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!text?.trim()) return
|
||||
|
||||
if (!(await translateConfirm())) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!translateModel) {
|
||||
window.message.error({
|
||||
content: t('translate.error.not_configured'),
|
||||
@ -55,16 +69,53 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style }) =>
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsTranslating(isLoading ?? false)
|
||||
}, [isLoading])
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<TranslationOutlined style={{ fontSize: 14 }} />}
|
||||
onClick={handleTranslate}
|
||||
disabled={disabled || isTranslating}
|
||||
loading={isTranslating}
|
||||
style={style}
|
||||
size="small"
|
||||
/>
|
||||
<Tooltip placement="top" title={t('chat.input.translate')} arrow>
|
||||
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
|
||||
{isTranslating ? <LoadingOutlined spin /> : <TranslationOutlined />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolbarButton = styled(Button)`
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
font-size: 17px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-icon);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
&.anticon,
|
||||
&.iconfont {
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-icon);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
.anticon,
|
||||
.iconfont {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-primary) !important;
|
||||
.anticon,
|
||||
.iconfont {
|
||||
color: var(--color-white-soft);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default TranslateButton
|
||||
|
||||
@ -104,6 +104,7 @@
|
||||
"input.upload": "Upload image or document file",
|
||||
"input.context_count.tip": "Context Count",
|
||||
"input.estimated_tokens.tip": "Estimated tokens",
|
||||
"input.translate": "Translate to English",
|
||||
"settings.temperature": "Temperature",
|
||||
"settings.temperature.tip": "Lower values make the model more creative and unpredictable, while higher values make it more deterministic and precise.",
|
||||
"settings.context_count": "Context",
|
||||
@ -350,6 +351,7 @@
|
||||
"topic.position.right": "Right",
|
||||
"topic.show.time": "Show Topic Time",
|
||||
"display.title": "Display Settings",
|
||||
"input.auto_translate_with_space": "Quickly translate with 3 spaces",
|
||||
"shortcuts": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"action": "Action",
|
||||
@ -425,7 +427,10 @@
|
||||
"error.not_configured": "Translation model is not configured",
|
||||
"input.placeholder": "Enter text to translate",
|
||||
"output.placeholder": "Translation",
|
||||
"confirm": "Original text has been copied to clipboard. Do you want to replace it with the translated text?"
|
||||
"confirm": {
|
||||
"title": "Translation Confirmation",
|
||||
"content": "Translation will replace the original text, continue?"
|
||||
}
|
||||
},
|
||||
"languages": {
|
||||
"english": "English",
|
||||
|
||||
@ -104,6 +104,7 @@
|
||||
"input.upload": "Загрузить изображение или документ",
|
||||
"input.context_count.tip": "Количество контекстов",
|
||||
"input.estimated_tokens.tip": "Затраты токенов",
|
||||
"input.translate": "Перевести на английский",
|
||||
"settings.temperature": "Температура",
|
||||
"settings.temperature.tip": "Меньшие значения делают модель более креативной и непредсказуемой, в то время как большие значения делают её более детерминированной и точной.",
|
||||
"settings.context_count": "Контекст",
|
||||
@ -350,6 +351,7 @@
|
||||
"topic.position.right": "Справа",
|
||||
"topic.show.time": "Показывать время топика",
|
||||
"display.title": "Настройки отображения",
|
||||
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
|
||||
"shortcuts": {
|
||||
"title": "Горячие клавиши",
|
||||
"action": "Действие",
|
||||
@ -425,7 +427,10 @@
|
||||
"error.not_configured": "Модель перевода не настроена",
|
||||
"input.placeholder": "Введите текст для перевода",
|
||||
"output.placeholder": "Перевод",
|
||||
"confirm": "Исходный текст скопирован в буфер обмена. Хотите заменить его переведенным текстом?"
|
||||
"confirm": {
|
||||
"title": "Перевод подтверждение",
|
||||
"content": "Перевод заменит исходный текст, продолжить?"
|
||||
}
|
||||
},
|
||||
"languages": {
|
||||
"english": "Английский",
|
||||
|
||||
@ -104,6 +104,7 @@
|
||||
"input.upload": "上传图片或文档",
|
||||
"input.context_count.tip": "上下文数",
|
||||
"input.estimated_tokens.tip": "预估 token 数",
|
||||
"input.translate": "翻译成英文",
|
||||
"settings.temperature": "模型温度",
|
||||
"settings.temperature.tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7",
|
||||
"settings.context_count": "上下文数",
|
||||
@ -338,6 +339,7 @@
|
||||
"topic.position.right": "右侧",
|
||||
"topic.show.time": "显示话题时间",
|
||||
"display.title": "显示设置",
|
||||
"input.auto_translate_with_space": "快速敲击3次空格翻译",
|
||||
"shortcuts": {
|
||||
"title": "快捷方式",
|
||||
"action": "操作",
|
||||
@ -413,7 +415,10 @@
|
||||
"error.not_configured": "翻译模型未配置",
|
||||
"input.placeholder": "输入文本进行翻译",
|
||||
"output.placeholder": "翻译",
|
||||
"confirm": "原文已复制到剪贴板,是否用翻译后的文本替换?"
|
||||
"confirm": {
|
||||
"title": "翻译确认",
|
||||
"content": "翻译后将覆盖原文,是否继续?"
|
||||
}
|
||||
},
|
||||
"languages": {
|
||||
"english": "英文",
|
||||
|
||||
@ -104,6 +104,7 @@
|
||||
"input.upload": "上傳圖片或文檔",
|
||||
"input.context_count.tip": "上下文數量",
|
||||
"input.estimated_tokens.tip": "預估 Token 數",
|
||||
"input.translate": "翻譯成英文",
|
||||
"settings.temperature": "溫度",
|
||||
"settings.temperature.tip": "較低的值使模型更具創造性和不可預測性,較高的值則使其更具確定性和精確性。",
|
||||
"settings.context_count": "上下文",
|
||||
@ -338,6 +339,7 @@
|
||||
"topic.position.right": "右側",
|
||||
"topic.show.time": "顯示話題時間",
|
||||
"display.title": "顯示設定",
|
||||
"input.auto_translate_with_space": "快速敲擊3次空格翻譯",
|
||||
"shortcuts": {
|
||||
"title": "快速方式",
|
||||
"action": "操作",
|
||||
@ -413,7 +415,10 @@
|
||||
"error.not_configured": "翻譯模型未配置",
|
||||
"input.placeholder": "輸入文字進行翻譯",
|
||||
"output.placeholder": "翻譯",
|
||||
"confirm": "原文已複製到剪貼簿,是否用翻譯後的文字替換?"
|
||||
"confirm": {
|
||||
"title": "翻譯確認",
|
||||
"content": "翻譯後將覆蓋原文,是否繼續?"
|
||||
}
|
||||
},
|
||||
"languages": {
|
||||
"english": "英文",
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
QuestionCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { PicCenterOutlined } from '@ant-design/icons'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { documentExts, imageExts, isMac, textExts } from '@renderer/config/constant'
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
@ -19,6 +20,7 @@ import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setGenerating, setSearching } from '@renderer/store/runtime'
|
||||
import { Assistant, FileType, Message, Topic } from '@renderer/types'
|
||||
@ -48,8 +50,15 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
const [text, setText] = useState(_text)
|
||||
const [inputFocus, setInputFocus] = useState(false)
|
||||
const { addTopic, model, setModel } = useAssistant(assistant.id)
|
||||
const { sendMessageShortcut, fontSize, pasteLongTextAsFile, showInputEstimatedTokens, clickAssistantToShowTopic } =
|
||||
useSettings()
|
||||
const {
|
||||
sendMessageShortcut,
|
||||
fontSize,
|
||||
pasteLongTextAsFile,
|
||||
showInputEstimatedTokens,
|
||||
clickAssistantToShowTopic,
|
||||
language,
|
||||
autoTranslateWithSpace
|
||||
} = useSettings()
|
||||
const [expended, setExpend] = useState(false)
|
||||
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
||||
const [contextCount, setContextCount] = useState(0)
|
||||
@ -62,6 +71,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
const { searching } = useRuntime()
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const dispatch = useAppDispatch()
|
||||
const [spaceClickCount, setSpaceClickCount] = useState(0)
|
||||
const spaceClickTimer = useRef<NodeJS.Timeout>()
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
|
||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
@ -109,9 +121,47 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
setExpend(false)
|
||||
}, [assistant.id, assistant.topics, generating, files, text])
|
||||
|
||||
const translate = async () => {
|
||||
if (isTranslating) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsTranslating(true)
|
||||
setText(await translateText(text, 'english'))
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
} catch (error) {
|
||||
console.error('Translation failed:', error)
|
||||
} finally {
|
||||
setIsTranslating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isEnterPressed = event.keyCode == 13
|
||||
|
||||
if (autoTranslateWithSpace) {
|
||||
if (event.key === ' ') {
|
||||
setSpaceClickCount((prev) => prev + 1)
|
||||
|
||||
if (spaceClickTimer.current) {
|
||||
clearTimeout(spaceClickTimer.current)
|
||||
}
|
||||
|
||||
spaceClickTimer.current = setTimeout(() => {
|
||||
setSpaceClickCount(0)
|
||||
}, 200)
|
||||
|
||||
if (spaceClickCount === 2) {
|
||||
console.log('Triple space detected - trigger translation')
|
||||
setSpaceClickCount(0)
|
||||
setIsTranslating(true)
|
||||
translate()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expended) {
|
||||
if (event.key === 'Escape') {
|
||||
return setExpend(false)
|
||||
@ -261,6 +311,11 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const onTranslated = (translatedText: string) => {
|
||||
setText(translatedText)
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
}
|
||||
|
||||
// Command or Ctrl + N create new topic
|
||||
useEffect(() => {
|
||||
const onKeydown = (e) => {
|
||||
@ -297,6 +352,14 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
textareaRef.current?.focus()
|
||||
}, [assistant])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (spaceClickTimer.current) {
|
||||
clearTimeout(spaceClickTimer.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Container onDragOver={handleDragOver} onDrop={handleDrop}>
|
||||
<AttachmentPreview files={files} setFiles={setFiles} />
|
||||
@ -305,7 +368,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('chat.input.placeholder')}
|
||||
placeholder={isTranslating ? t('chat.input.translating') : t('chat.input.placeholder')}
|
||||
autoFocus
|
||||
contextMenu="true"
|
||||
variant="borderless"
|
||||
@ -370,6 +433,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
/>
|
||||
</ToolbarMenu>
|
||||
<ToolbarMenu>
|
||||
{!language.startsWith('en') && (
|
||||
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
|
||||
)}
|
||||
{generating && (
|
||||
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
|
||||
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>
|
||||
|
||||
@ -8,6 +8,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { SettingDivider, SettingRow, SettingRowTitle, SettingSubtitle } from '@renderer/pages/settings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setAutoTranslateWithSpace,
|
||||
setClickAssistantToShowTopic,
|
||||
setCodeCollapsible,
|
||||
setCodeShowLineNumbers,
|
||||
@ -60,6 +61,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
topicPosition,
|
||||
showTopicTime,
|
||||
clickAssistantToShowTopic,
|
||||
autoTranslateWithSpace,
|
||||
setTopicPosition
|
||||
} = useSettings()
|
||||
|
||||
@ -328,6 +330,15 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.input.auto_translate_with_space')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={autoTranslateWithSpace}
|
||||
onChange={(checked) => dispatch(setAutoTranslateWithSpace(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.input.send_shortcuts')}</SettingRowTitleSmall>
|
||||
<Select
|
||||
@ -342,8 +353,9 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
style={{ width: 135 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingSubtitle>{t('settings.display.title')}</SettingSubtitle>
|
||||
</SettingGroup>
|
||||
<SettingGroup>
|
||||
<SettingSubtitle style={{ marginTop: 0 }}>{t('settings.display.title')}</SettingSubtitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.topic.position')}</SettingRowTitle>
|
||||
|
||||
@ -232,23 +232,6 @@ const PaintingsPage: FC = () => {
|
||||
setCurrentImageIndex(0)
|
||||
}
|
||||
|
||||
const handleTranslation = async (translatedText: string) => {
|
||||
const currentText = textareaRef.current?.resizableTextArea?.textArea?.value
|
||||
|
||||
if (currentText) {
|
||||
await navigator.clipboard.writeText(currentText)
|
||||
|
||||
const confirmed = await window.modal.confirm({
|
||||
content: t('translate.confirm'),
|
||||
centered: true
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
updatePaintingState({ prompt: translatedText })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
@ -385,7 +368,7 @@ const PaintingsPage: FC = () => {
|
||||
<ToolbarMenu>
|
||||
<TranslateButton
|
||||
text={textareaRef.current?.resizableTextArea?.textArea?.value}
|
||||
onTranslated={handleTranslation}
|
||||
onTranslated={(translatedText) => updatePaintingState({ prompt: translatedText })}
|
||||
disabled={isLoading}
|
||||
style={{ marginRight: 6, borderRadius: '50%' }}
|
||||
/>
|
||||
|
||||
15
src/renderer/src/services/TranslateService.ts
Normal file
15
src/renderer/src/services/TranslateService.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { fetchTranslate } from './ApiService'
|
||||
import { getDefaultTopic } from './AssistantService'
|
||||
import { getDefaultTranslateAssistant } from './AssistantService'
|
||||
import { getUserMessage } from './MessagesService'
|
||||
|
||||
export const translateText = async (text: string, targetLanguage: string) => {
|
||||
const assistant = getDefaultTranslateAssistant(targetLanguage, text)
|
||||
const message = getUserMessage({
|
||||
assistant,
|
||||
topic: getDefaultTopic('default'),
|
||||
type: 'text'
|
||||
})
|
||||
const translatedText = await fetchTranslate({ message, assistant })
|
||||
return translatedText
|
||||
}
|
||||
@ -36,6 +36,7 @@ export interface SettingsState {
|
||||
webdavPass: string
|
||||
webdavPath: string
|
||||
translateModelPrompt: string
|
||||
autoTranslateWithSpace: boolean
|
||||
}
|
||||
|
||||
const initialState: SettingsState = {
|
||||
@ -68,7 +69,8 @@ const initialState: SettingsState = {
|
||||
webdavUser: '',
|
||||
webdavPass: '',
|
||||
webdavPath: '/cherry-studio',
|
||||
translateModelPrompt: TRANSLATE_PROMPT
|
||||
translateModelPrompt: TRANSLATE_PROMPT,
|
||||
autoTranslateWithSpace: false
|
||||
}
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
@ -171,6 +173,9 @@ const settingsSlice = createSlice({
|
||||
},
|
||||
setTranslateModelPrompt: (state, action: PayloadAction<string>) => {
|
||||
state.translateModelPrompt = action.payload
|
||||
},
|
||||
setAutoTranslateWithSpace: (state, action: PayloadAction<boolean>) => {
|
||||
state.autoTranslateWithSpace = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -207,7 +212,8 @@ export const {
|
||||
setMathEngine,
|
||||
setMessageStyle,
|
||||
setCodeStyle,
|
||||
setTranslateModelPrompt
|
||||
setTranslateModelPrompt,
|
||||
setAutoTranslateWithSpace
|
||||
} = settingsSlice.actions
|
||||
|
||||
export default settingsSlice.reducer
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user