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 { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/ApiService' import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService' import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { getUserMessage } from '@renderer/services/MessagesService' import { getUserMessage } from '@renderer/services/MessagesService'
import { Button } from 'antd' import { Button, Tooltip } from 'antd'
import { FC, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props { interface Props {
text?: string text?: string
onTranslated: (translatedText: string) => void onTranslated: (translatedText: string) => void
disabled?: boolean disabled?: boolean
style?: React.CSSProperties 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 { t } = useTranslation()
const { translateModel } = useDefaultModel() const { translateModel } = useDefaultModel()
const [isTranslating, setIsTranslating] = useState(false) 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 () => { const handleTranslate = async () => {
if (!text?.trim()) return if (!text?.trim()) return
if (!(await translateConfirm())) {
return
}
if (!translateModel) { if (!translateModel) {
window.message.error({ window.message.error({
content: t('translate.error.not_configured'), content: t('translate.error.not_configured'),
@ -55,16 +69,53 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style }) =>
} }
} }
useEffect(() => {
setIsTranslating(isLoading ?? false)
}, [isLoading])
return ( return (
<Button <Tooltip placement="top" title={t('chat.input.translate')} arrow>
icon={<TranslationOutlined style={{ fontSize: 14 }} />} <ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
onClick={handleTranslate} {isTranslating ? <LoadingOutlined spin /> : <TranslationOutlined />}
disabled={disabled || isTranslating} </ToolbarButton>
loading={isTranslating} </Tooltip>
style={style}
size="small"
/>
) )
} }
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 export default TranslateButton

View File

@ -104,6 +104,7 @@
"input.upload": "Upload image or document file", "input.upload": "Upload image or document file",
"input.context_count.tip": "Context Count", "input.context_count.tip": "Context Count",
"input.estimated_tokens.tip": "Estimated tokens", "input.estimated_tokens.tip": "Estimated tokens",
"input.translate": "Translate to English",
"settings.temperature": "Temperature", "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.temperature.tip": "Lower values make the model more creative and unpredictable, while higher values make it more deterministic and precise.",
"settings.context_count": "Context", "settings.context_count": "Context",
@ -350,6 +351,7 @@
"topic.position.right": "Right", "topic.position.right": "Right",
"topic.show.time": "Show Topic Time", "topic.show.time": "Show Topic Time",
"display.title": "Display Settings", "display.title": "Display Settings",
"input.auto_translate_with_space": "Quickly translate with 3 spaces",
"shortcuts": { "shortcuts": {
"title": "Keyboard Shortcuts", "title": "Keyboard Shortcuts",
"action": "Action", "action": "Action",
@ -425,7 +427,10 @@
"error.not_configured": "Translation model is not configured", "error.not_configured": "Translation model is not configured",
"input.placeholder": "Enter text to translate", "input.placeholder": "Enter text to translate",
"output.placeholder": "Translation", "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": { "languages": {
"english": "English", "english": "English",

View File

@ -104,6 +104,7 @@
"input.upload": "Загрузить изображение или документ", "input.upload": "Загрузить изображение или документ",
"input.context_count.tip": "Количество контекстов", "input.context_count.tip": "Количество контекстов",
"input.estimated_tokens.tip": "Затраты токенов", "input.estimated_tokens.tip": "Затраты токенов",
"input.translate": "Перевести на английский",
"settings.temperature": "Температура", "settings.temperature": "Температура",
"settings.temperature.tip": "Меньшие значения делают модель более креативной и непредсказуемой, в то время как большие значения делают её более детерминированной и точной.", "settings.temperature.tip": "Меньшие значения делают модель более креативной и непредсказуемой, в то время как большие значения делают её более детерминированной и точной.",
"settings.context_count": "Контекст", "settings.context_count": "Контекст",
@ -350,6 +351,7 @@
"topic.position.right": "Справа", "topic.position.right": "Справа",
"topic.show.time": "Показывать время топика", "topic.show.time": "Показывать время топика",
"display.title": "Настройки отображения", "display.title": "Настройки отображения",
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
"shortcuts": { "shortcuts": {
"title": "Горячие клавиши", "title": "Горячие клавиши",
"action": "Действие", "action": "Действие",
@ -425,7 +427,10 @@
"error.not_configured": "Модель перевода не настроена", "error.not_configured": "Модель перевода не настроена",
"input.placeholder": "Введите текст для перевода", "input.placeholder": "Введите текст для перевода",
"output.placeholder": "Перевод", "output.placeholder": "Перевод",
"confirm": "Исходный текст скопирован в буфер обмена. Хотите заменить его переведенным текстом?" "confirm": {
"title": "Перевод подтверждение",
"content": "Перевод заменит исходный текст, продолжить?"
}
}, },
"languages": { "languages": {
"english": "Английский", "english": "Английский",

View File

@ -104,6 +104,7 @@
"input.upload": "上传图片或文档", "input.upload": "上传图片或文档",
"input.context_count.tip": "上下文数", "input.context_count.tip": "上下文数",
"input.estimated_tokens.tip": "预估 token 数", "input.estimated_tokens.tip": "预估 token 数",
"input.translate": "翻译成英文",
"settings.temperature": "模型温度", "settings.temperature": "模型温度",
"settings.temperature.tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7", "settings.temperature.tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7",
"settings.context_count": "上下文数", "settings.context_count": "上下文数",
@ -338,6 +339,7 @@
"topic.position.right": "右侧", "topic.position.right": "右侧",
"topic.show.time": "显示话题时间", "topic.show.time": "显示话题时间",
"display.title": "显示设置", "display.title": "显示设置",
"input.auto_translate_with_space": "快速敲击3次空格翻译",
"shortcuts": { "shortcuts": {
"title": "快捷方式", "title": "快捷方式",
"action": "操作", "action": "操作",
@ -413,7 +415,10 @@
"error.not_configured": "翻译模型未配置", "error.not_configured": "翻译模型未配置",
"input.placeholder": "输入文本进行翻译", "input.placeholder": "输入文本进行翻译",
"output.placeholder": "翻译", "output.placeholder": "翻译",
"confirm": "原文已复制到剪贴板,是否用翻译后的文本替换?" "confirm": {
"title": "翻译确认",
"content": "翻译后将覆盖原文,是否继续?"
}
}, },
"languages": { "languages": {
"english": "英文", "english": "英文",

View File

@ -104,6 +104,7 @@
"input.upload": "上傳圖片或文檔", "input.upload": "上傳圖片或文檔",
"input.context_count.tip": "上下文數量", "input.context_count.tip": "上下文數量",
"input.estimated_tokens.tip": "預估 Token 數", "input.estimated_tokens.tip": "預估 Token 數",
"input.translate": "翻譯成英文",
"settings.temperature": "溫度", "settings.temperature": "溫度",
"settings.temperature.tip": "較低的值使模型更具創造性和不可預測性,較高的值則使其更具確定性和精確性。", "settings.temperature.tip": "較低的值使模型更具創造性和不可預測性,較高的值則使其更具確定性和精確性。",
"settings.context_count": "上下文", "settings.context_count": "上下文",
@ -338,6 +339,7 @@
"topic.position.right": "右側", "topic.position.right": "右側",
"topic.show.time": "顯示話題時間", "topic.show.time": "顯示話題時間",
"display.title": "顯示設定", "display.title": "顯示設定",
"input.auto_translate_with_space": "快速敲擊3次空格翻譯",
"shortcuts": { "shortcuts": {
"title": "快速方式", "title": "快速方式",
"action": "操作", "action": "操作",
@ -413,7 +415,10 @@
"error.not_configured": "翻譯模型未配置", "error.not_configured": "翻譯模型未配置",
"input.placeholder": "輸入文字進行翻譯", "input.placeholder": "輸入文字進行翻譯",
"output.placeholder": "翻譯", "output.placeholder": "翻譯",
"confirm": "原文已複製到剪貼簿,是否用翻譯後的文字替換?" "confirm": {
"title": "翻譯確認",
"content": "翻譯後將覆蓋原文,是否繼續?"
}
}, },
"languages": { "languages": {
"english": "英文", "english": "英文",

View File

@ -8,6 +8,7 @@ import {
QuestionCircleOutlined QuestionCircleOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { PicCenterOutlined } 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 { documentExts, imageExts, isMac, textExts } from '@renderer/config/constant'
import { isVisionModel } from '@renderer/config/models' import { isVisionModel } from '@renderer/config/models'
import db from '@renderer/databases' import db from '@renderer/databases'
@ -19,6 +20,7 @@ import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService' import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
import { translateText } from '@renderer/services/TranslateService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store' import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setGenerating, setSearching } from '@renderer/store/runtime' import { setGenerating, setSearching } from '@renderer/store/runtime'
import { Assistant, FileType, Message, Topic } from '@renderer/types' import { Assistant, FileType, Message, Topic } from '@renderer/types'
@ -48,8 +50,15 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const [text, setText] = useState(_text) const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false) const [inputFocus, setInputFocus] = useState(false)
const { addTopic, model, setModel } = useAssistant(assistant.id) const { addTopic, model, setModel } = useAssistant(assistant.id)
const { sendMessageShortcut, fontSize, pasteLongTextAsFile, showInputEstimatedTokens, clickAssistantToShowTopic } = const {
useSettings() sendMessageShortcut,
fontSize,
pasteLongTextAsFile,
showInputEstimatedTokens,
clickAssistantToShowTopic,
language,
autoTranslateWithSpace
} = useSettings()
const [expended, setExpend] = useState(false) const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0) const [estimateTokenCount, setEstimateTokenCount] = useState(0)
const [contextCount, setContextCount] = useState(0) const [contextCount, setContextCount] = useState(0)
@ -62,6 +71,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const { searching } = useRuntime() const { searching } = useRuntime()
const { isBubbleStyle } = useMessageStyle() const { isBubbleStyle } = useMessageStyle()
const dispatch = useAppDispatch() 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 isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
@ -109,9 +121,47 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
setExpend(false) setExpend(false)
}, [assistant.id, assistant.topics, generating, files, text]) }, [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 handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13 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 (expended) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
return setExpend(false) 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 // Command or Ctrl + N create new topic
useEffect(() => { useEffect(() => {
const onKeydown = (e) => { const onKeydown = (e) => {
@ -297,6 +352,14 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
textareaRef.current?.focus() textareaRef.current?.focus()
}, [assistant]) }, [assistant])
useEffect(() => {
return () => {
if (spaceClickTimer.current) {
clearTimeout(spaceClickTimer.current)
}
}
}, [])
return ( return (
<Container onDragOver={handleDragOver} onDrop={handleDrop}> <Container onDragOver={handleDragOver} onDrop={handleDrop}>
<AttachmentPreview files={files} setFiles={setFiles} /> <AttachmentPreview files={files} setFiles={setFiles} />
@ -305,7 +368,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder')} placeholder={isTranslating ? t('chat.input.translating') : t('chat.input.placeholder')}
autoFocus autoFocus
contextMenu="true" contextMenu="true"
variant="borderless" variant="borderless"
@ -370,6 +433,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
/> />
</ToolbarMenu> </ToolbarMenu>
<ToolbarMenu> <ToolbarMenu>
{!language.startsWith('en') && (
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
)}
{generating && ( {generating && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow> <Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}> <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 { SettingDivider, SettingRow, SettingRowTitle, SettingSubtitle } from '@renderer/pages/settings'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { import {
setAutoTranslateWithSpace,
setClickAssistantToShowTopic, setClickAssistantToShowTopic,
setCodeCollapsible, setCodeCollapsible,
setCodeShowLineNumbers, setCodeShowLineNumbers,
@ -60,6 +61,7 @@ const SettingsTab: FC<Props> = (props) => {
topicPosition, topicPosition,
showTopicTime, showTopicTime,
clickAssistantToShowTopic, clickAssistantToShowTopic,
autoTranslateWithSpace,
setTopicPosition setTopicPosition
} = useSettings() } = useSettings()
@ -328,6 +330,15 @@ const SettingsTab: FC<Props> = (props) => {
/> />
</SettingRow> </SettingRow>
<SettingDivider /> <SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.input.auto_translate_with_space')}</SettingRowTitleSmall>
<Switch
size="small"
checked={autoTranslateWithSpace}
onChange={(checked) => dispatch(setAutoTranslateWithSpace(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.send_shortcuts')}</SettingRowTitleSmall> <SettingRowTitleSmall>{t('settings.messages.input.send_shortcuts')}</SettingRowTitleSmall>
<Select <Select
@ -342,8 +353,9 @@ const SettingsTab: FC<Props> = (props) => {
style={{ width: 135 }} style={{ width: 135 }}
/> />
</SettingRow> </SettingRow>
<SettingDivider /> </SettingGroup>
<SettingSubtitle>{t('settings.display.title')}</SettingSubtitle> <SettingGroup>
<SettingSubtitle style={{ marginTop: 0 }}>{t('settings.display.title')}</SettingSubtitle>
<SettingDivider /> <SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitle>{t('settings.topic.position')}</SettingRowTitle> <SettingRowTitle>{t('settings.topic.position')}</SettingRowTitle>

View File

@ -232,23 +232,6 @@ const PaintingsPage: FC = () => {
setCurrentImageIndex(0) 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 ( return (
<Container> <Container>
<Navbar> <Navbar>
@ -385,7 +368,7 @@ const PaintingsPage: FC = () => {
<ToolbarMenu> <ToolbarMenu>
<TranslateButton <TranslateButton
text={textareaRef.current?.resizableTextArea?.textArea?.value} text={textareaRef.current?.resizableTextArea?.textArea?.value}
onTranslated={handleTranslation} onTranslated={(translatedText) => updatePaintingState({ prompt: translatedText })}
disabled={isLoading} disabled={isLoading}
style={{ marginRight: 6, borderRadius: '50%' }} 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 webdavPass: string
webdavPath: string webdavPath: string
translateModelPrompt: string translateModelPrompt: string
autoTranslateWithSpace: boolean
} }
const initialState: SettingsState = { const initialState: SettingsState = {
@ -68,7 +69,8 @@ const initialState: SettingsState = {
webdavUser: '', webdavUser: '',
webdavPass: '', webdavPass: '',
webdavPath: '/cherry-studio', webdavPath: '/cherry-studio',
translateModelPrompt: TRANSLATE_PROMPT translateModelPrompt: TRANSLATE_PROMPT,
autoTranslateWithSpace: false
} }
const settingsSlice = createSlice({ const settingsSlice = createSlice({
@ -171,6 +173,9 @@ const settingsSlice = createSlice({
}, },
setTranslateModelPrompt: (state, action: PayloadAction<string>) => { setTranslateModelPrompt: (state, action: PayloadAction<string>) => {
state.translateModelPrompt = action.payload state.translateModelPrompt = action.payload
},
setAutoTranslateWithSpace: (state, action: PayloadAction<boolean>) => {
state.autoTranslateWithSpace = action.payload
} }
} }
}) })
@ -207,7 +212,8 @@ export const {
setMathEngine, setMathEngine,
setMessageStyle, setMessageStyle,
setCodeStyle, setCodeStyle,
setTranslateModelPrompt setTranslateModelPrompt,
setAutoTranslateWithSpace
} = settingsSlice.actions } = settingsSlice.actions
export default settingsSlice.reducer export default settingsSlice.reducer