feat: auto translate input text

This commit is contained in:
kangfenmao 2024-11-30 22:33:10 +08:00
parent 3717ff25bf
commit 2e9041c891
10 changed files with 194 additions and 41 deletions

View File

@ -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

View File

@ -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",

View File

@ -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": "Английский",

View File

@ -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": "英文",

View File

@ -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": "英文",

View File

@ -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 }}>

View File

@ -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>

View File

@ -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%' }}
/>

View 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
}

View File

@ -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