feat: add chat message translate copy button (#4620)

* feat: add chat message translate copy button

* Update MessageMenubar.tsx

* fix: copy button display
This commit is contained in:
自由的世界人 2025-04-19 10:36:57 +08:00 committed by GitHub
parent d907344ca7
commit 8b462935b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 90 additions and 59 deletions

View File

@ -1,5 +1,6 @@
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { estimateMessageUsage } from '@renderer/services/TokenService' import { estimateMessageUsage } 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 { import {
clearStreamMessage, clearStreamMessage,
@ -18,8 +19,10 @@ import {
import type { Assistant, Message, Topic } from '@renderer/types' import type { Assistant, Message, Topic } from '@renderer/types'
import { abortCompletion } from '@renderer/utils/abortController' import { abortCompletion } from '@renderer/utils/abortController'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { TopicManager } from './useTopic' import { TopicManager } from './useTopic'
/** /**
* Hook * Hook
* *
@ -28,6 +31,7 @@ import { TopicManager } from './useTopic'
*/ */
export function useMessageOperations(topic: Topic) { export function useMessageOperations(topic: Topic) {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { t } = useTranslation()
/** /**
* *
@ -60,8 +64,7 @@ export function useMessageOperations(topic: Topic) {
const message = messages?.find((m) => m.id === messageId) const message = messages?.find((m) => m.id === messageId)
if (message) { if (message) {
const updatedMessage = { ...message, ...updates } const updatedMessage = { ...message, ...updates }
const usage = await estimateMessageUsage(updatedMessage) updates.usage = await estimateMessageUsage(updatedMessage)
updates.usage = usage
} }
} }
await dispatch(updateMessageThunk(topic.id, messageId, updates)) await dispatch(updateMessageThunk(topic.id, messageId, updates))
@ -128,7 +131,7 @@ export function useMessageOperations(topic: Topic) {
const clearTopicMessagesAction = useCallback( const clearTopicMessagesAction = useCallback(
async (_topicId?: string) => { async (_topicId?: string) => {
const topicId = _topicId || topic.id const topicId = _topicId || topic.id
await dispatch(clearTopicMessages(topicId)) dispatch(clearTopicMessages(topicId))
await TopicManager.clearTopicMessages(topicId) await TopicManager.clearTopicMessages(topicId)
}, },
[dispatch, topic.id] [dispatch, topic.id]
@ -148,7 +151,7 @@ export function useMessageOperations(topic: Topic) {
* clear message * clear message
*/ */
const createNewContext = useCallback(async () => { const createNewContext = useCallback(async () => {
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT) await EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
}, []) }, [])
const displayCount = useAppSelector(selectDisplayCount) const displayCount = useAppSelector(selectDisplayCount)
@ -190,6 +193,28 @@ export function useMessageOperations(topic: Topic) {
dispatch(setTopicLoading({ topicId: topic.id, loading: false })) dispatch(setTopicLoading({ topicId: topic.id, loading: false }))
}, [topic.id, dispatch]) }, [topic.id, dispatch])
const translateMessage = useCallback(
async (messageId: string, language: string) => {
const messages = store.getState().messages.messagesByTopic[topic.id]
const message = messages?.find((m) => m.id === messageId)
if (!message) return
translateText(message.content, language, (text) => {
setStreamMessageAction({ ...message, translatedContent: text })
})
.then(() => {
commitStreamMessageAction(messageId)
})
.catch((error) => {
console.error('Translation failed:', error)
window.message.error({ content: t('translate.error.failed'), key: 'translate-message' })
editMessage(messageId, { translatedContent: undefined })
clearStreamMessageAction(messageId)
})
},
[topic.id, editMessage, t, clearStreamMessageAction, setStreamMessageAction, commitStreamMessageAction]
)
/** /**
* / * /
* *
@ -216,16 +241,15 @@ export function useMessageOperations(topic: Topic) {
clearTopicMessages: clearTopicMessagesAction, clearTopicMessages: clearTopicMessagesAction,
// pauseMessage, // pauseMessage,
pauseMessages, pauseMessages,
resumeMessage resumeMessage,
translateMessage
} }
} }
export const useTopicMessages = (topic: Topic) => { export const useTopicMessages = (topic: Topic) => {
const messages = useAppSelector((state) => selectTopicMessages(state, topic.id)) return useAppSelector((state) => selectTopicMessages(state, topic.id))
return messages
} }
export const useTopicLoading = (topic: Topic) => { export const useTopicLoading = (topic: Topic) => {
const loading = useAppSelector((state) => selectTopicLoading(state, topic.id)) return useAppSelector((state) => selectTopicLoading(state, topic.id))
return loading
} }

View File

@ -561,6 +561,7 @@
"error": "Error occurred" "error": "Error occurred"
}, },
"topic.added": "New topic added", "topic.added": "New topic added",
"translation.copied": "Translation copied",
"upgrade.success.button": "Restart", "upgrade.success.button": "Restart",
"upgrade.success.content": "Please restart the application to complete the upgrade", "upgrade.success.content": "Please restart the application to complete the upgrade",
"upgrade.success.title": "Upgrade successfully", "upgrade.success.title": "Upgrade successfully",
@ -1407,6 +1408,7 @@
"content": "Translation will replace the original text, continue?", "content": "Translation will replace the original text, continue?",
"title": "Translation Confirmation" "title": "Translation Confirmation"
}, },
"copy": "Copy",
"error.failed": "Translation failed", "error.failed": "Translation failed",
"error.not_configured": "Translation model is not configured", "error.not_configured": "Translation model is not configured",
"history": { "history": {

View File

@ -560,6 +560,7 @@
"error": "エラーが発生しました" "error": "エラーが発生しました"
}, },
"topic.added": "新しいトピックが追加されました", "topic.added": "新しいトピックが追加されました",
"translation.copied": "翻訳結果をコピーしました",
"upgrade.success.button": "再起動", "upgrade.success.button": "再起動",
"upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください", "upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください",
"upgrade.success.title": "アップグレードに成功しました", "upgrade.success.title": "アップグレードに成功しました",
@ -1407,6 +1408,7 @@
"content": "翻訳すると元のテキストが上書きされます。続行しますか?", "content": "翻訳すると元のテキストが上書きされます。続行しますか?",
"title": "翻訳確認" "title": "翻訳確認"
}, },
"copy": "翻訳をコピー",
"error.failed": "翻訳に失敗しました", "error.failed": "翻訳に失敗しました",
"error.not_configured": "翻訳モデルが設定されていません", "error.not_configured": "翻訳モデルが設定されていません",
"history": { "history": {

View File

@ -561,6 +561,7 @@
"error": "Произошла ошибка" "error": "Произошла ошибка"
}, },
"topic.added": "Новый топик добавлен", "topic.added": "Новый топик добавлен",
"translation.copied": "Перевод скопирован",
"upgrade.success.button": "Перезапустить", "upgrade.success.button": "Перезапустить",
"upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления", "upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления",
"upgrade.success.title": "Обновление успешно", "upgrade.success.title": "Обновление успешно",
@ -1407,6 +1408,7 @@
"content": "Перевод заменит исходный текст, продолжить?", "content": "Перевод заменит исходный текст, продолжить?",
"title": "Перевод подтверждение" "title": "Перевод подтверждение"
}, },
"copy": "Копировать перевод",
"error.failed": "Перевод не удалось", "error.failed": "Перевод не удалось",
"error.not_configured": "Модель перевода не настроена", "error.not_configured": "Модель перевода не настроена",
"history": { "history": {

View File

@ -561,6 +561,7 @@
"error": "发生错误" "error": "发生错误"
}, },
"topic.added": "话题添加成功", "topic.added": "话题添加成功",
"translation.copied": "翻译已复制",
"upgrade.success.button": "重启", "upgrade.success.button": "重启",
"upgrade.success.content": "重启用以完成升级", "upgrade.success.content": "重启用以完成升级",
"upgrade.success.title": "升级成功", "upgrade.success.title": "升级成功",
@ -1407,6 +1408,7 @@
"content": "翻译后将覆盖原文,是否继续?", "content": "翻译后将覆盖原文,是否继续?",
"title": "翻译确认" "title": "翻译确认"
}, },
"copy": "复制翻译",
"error.failed": "翻译失败", "error.failed": "翻译失败",
"error.not_configured": "翻译模型未配置", "error.not_configured": "翻译模型未配置",
"history": { "history": {

View File

@ -560,6 +560,7 @@
"invoking": "調用中", "invoking": "調用中",
"error": "發生錯誤" "error": "發生錯誤"
}, },
"translation.copied": "翻譯已複製",
"topic.added": "新話題已新增", "topic.added": "新話題已新增",
"upgrade.success.button": "重新啟動", "upgrade.success.button": "重新啟動",
"upgrade.success.content": "請重新啟動程式以完成升級", "upgrade.success.content": "請重新啟動程式以完成升級",
@ -1407,6 +1408,7 @@
"content": "翻譯後將覆蓋原文,是否繼續?", "content": "翻譯後將覆蓋原文,是否繼續?",
"title": "翻譯確認" "title": "翻譯確認"
}, },
"copy": "複製翻譯",
"error.failed": "翻譯失敗", "error.failed": "翻譯失敗",
"error.not_configured": "翻譯模型未設定", "error.not_configured": "翻譯模型未設定",
"history": { "history": {

View File

@ -181,7 +181,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
return return
} }
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE) await EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE)
try { try {
// Dispatch the sendMessage action with all options // Dispatch the sendMessage action with all options
@ -209,7 +209,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
userMessage.usage = await estimateMessageUsage(userMessage) userMessage.usage = await estimateMessageUsage(userMessage)
currentMessageId.current = userMessage.id currentMessageId.current = userMessage.id
dispatch( await dispatch(
_sendMessage(userMessage, assistant, topic, { _sendMessage(userMessage, assistant, topic, {
mentions: mentionModels mentions: mentionModels
}) })
@ -525,7 +525,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
await onPause() await onPause()
await delay(1) await delay(1)
} }
EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES) await EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES)
} }
const onNewContext = () => { const onNewContext = () => {

View File

@ -7,7 +7,6 @@ import { TranslateLanguageOptions } from '@renderer/config/translate'
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService' import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService'
import { translateText } from '@renderer/services/TranslateService'
import { RootState } from '@renderer/store' import { RootState } from '@renderer/store'
import type { Message, Model } from '@renderer/types' import type { Message, Model } from '@renderer/types'
import type { Assistant, Topic } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types'
@ -37,7 +36,7 @@ import {
ThumbsUp, ThumbsUp,
Trash Trash
} from 'lucide-react' } from 'lucide-react'
import { FC, memo, useCallback, useMemo, useState } from 'react' import React, { FC, memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
@ -60,12 +59,11 @@ const MessageMenubar: FC<Props> = (props) => {
props props
const { t } = useTranslation() const { t } = useTranslation()
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [isTranslating, setIsTranslating] = useState(false) const [translationCopied, setTranslationCopied] = useState(false)
const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false) const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false)
const [showDeleteTooltip, setShowDeleteTooltip] = useState(false) const [showDeleteTooltip, setShowDeleteTooltip] = useState(false)
const assistantModel = assistant?.model const assistantModel = assistant?.model
const { editMessage, setStreamMessage, deleteMessage, resendMessage, commitStreamMessage, clearStreamMessage } = const { editMessage, deleteMessage, resendMessage, translateMessage } = useMessageOperations(topic)
useMessageOperations(topic)
const loading = useTopicLoading(topic) const loading = useTopicLoading(topic)
const isUserMessage = message.role === 'user' const isUserMessage = message.role === 'user'
@ -92,9 +90,23 @@ const MessageMenubar: FC<Props> = (props) => {
[message, t] [message, t]
) )
const onCopyTranslation = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (message.translatedContent) {
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.translatedContent.trimStart()))
window.message.success({ content: t('message.translation.copied'), key: 'copy-translation' })
setTranslationCopied(true)
setTimeout(() => setTranslationCopied(false), 2000)
}
},
[message.translatedContent, t]
)
const onNewBranch = useCallback(async () => { const onNewBranch = useCallback(async () => {
if (loading) return if (loading) return
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index) await EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
window.message.success({ content: t('chat.message.new.branch.created'), key: 'new-branch' }) window.message.success({ content: t('chat.message.new.branch.created'), key: 'new-branch' })
}, [index, t, loading]) }, [index, t, loading])
@ -144,7 +156,7 @@ const MessageMenubar: FC<Props> = (props) => {
if (editedText && editedText !== textToEdit) { if (editedText && editedText !== textToEdit) {
// 解析编辑后的文本,提取图片 URL // 解析编辑后的文本,提取图片 URL
const imageRegex = /!\[image-\d+\]\((.*?)\)/g const imageRegex = /!\[image-\d+]\((.*?)\)/g
const imageUrls: string[] = [] const imageUrls: string[] = []
let match let match
let content = editedText let content = editedText
@ -170,7 +182,7 @@ const MessageMenubar: FC<Props> = (props) => {
}) })
resendMessage && resendMessage &&
handleResendUserMessage({ (await handleResendUserMessage({
...message, ...message,
content: content.trim(), content: content.trim(),
metadata: { metadata: {
@ -183,38 +195,10 @@ const MessageMenubar: FC<Props> = (props) => {
} }
: undefined : undefined
} }
}) }))
} }
}, [message, editMessage, handleResendUserMessage, t]) }, [message, editMessage, handleResendUserMessage, t])
const handleTranslate = useCallback(
async (language: string) => {
if (isTranslating) return
editMessage(message.id, { translatedContent: t('translate.processing') })
setIsTranslating(true)
try {
await translateText(message.content, language, (text) => {
// 使用 setStreamMessage 来更新翻译内容
setStreamMessage({ ...message, translatedContent: text })
})
// 翻译完成后,提交流消息
commitStreamMessage(message.id)
} catch (error) {
console.error('Translation failed:', error)
window.message.error({ content: t('translate.error.failed'), key: 'translate-message' })
editMessage(message.id, { translatedContent: undefined })
clearStreamMessage(message.id)
} finally {
setIsTranslating(false)
}
},
[isTranslating, message, editMessage, setStreamMessage, commitStreamMessage, clearStreamMessage, t]
)
const dropdownItems = useMemo( const dropdownItems = useMemo(
() => [ () => [
{ {
@ -281,7 +265,7 @@ const MessageMenubar: FC<Props> = (props) => {
onClick: async () => { onClick: async () => {
const markdown = messageToMarkdown(message) const markdown = messageToMarkdown(message)
const title = await getMessageTitle(message) const title = await getMessageTitle(message)
window.api.export.toWord(markdown, title) await window.api.export.toWord(markdown, title)
} }
}, },
exportMenuOptions.notion && { exportMenuOptions.notion && {
@ -290,7 +274,7 @@ const MessageMenubar: FC<Props> = (props) => {
onClick: async () => { onClick: async () => {
const title = await getMessageTitle(message) const title = await getMessageTitle(message)
const markdown = messageToMarkdown(message) const markdown = messageToMarkdown(message)
exportMarkdownToNotion(title, markdown) await exportMarkdownToNotion(title, markdown)
} }
}, },
exportMenuOptions.yuque && { exportMenuOptions.yuque && {
@ -299,7 +283,7 @@ const MessageMenubar: FC<Props> = (props) => {
onClick: async () => { onClick: async () => {
const title = await getMessageTitle(message) const title = await getMessageTitle(message)
const markdown = messageToMarkdown(message) const markdown = messageToMarkdown(message)
exportMarkdownToYuque(title, markdown) await exportMarkdownToYuque(title, markdown)
} }
}, },
exportMenuOptions.obsidian && { exportMenuOptions.obsidian && {
@ -317,7 +301,7 @@ const MessageMenubar: FC<Props> = (props) => {
onClick: async () => { onClick: async () => {
const title = await getMessageTitle(message) const title = await getMessageTitle(message)
const markdown = messageToMarkdown(message) const markdown = messageToMarkdown(message)
exportMarkdownToJoplin(title, markdown) await exportMarkdownToJoplin(title, markdown)
} }
}, },
exportMenuOptions.siyuan && { exportMenuOptions.siyuan && {
@ -326,7 +310,7 @@ const MessageMenubar: FC<Props> = (props) => {
onClick: async () => { onClick: async () => {
const title = await getMessageTitle(message) const title = await getMessageTitle(message)
const markdown = messageToMarkdown(message) const markdown = messageToMarkdown(message)
exportMarkdownToSiyuan(title, markdown) await exportMarkdownToSiyuan(title, markdown)
} }
} }
].filter(Boolean) ].filter(Boolean)
@ -340,8 +324,8 @@ const MessageMenubar: FC<Props> = (props) => {
if (loading) return if (loading) return
const selectedModel = isGrouped ? model : assistantModel const selectedModel = isGrouped ? model : assistantModel
const _message = resetAssistantMessage(message, selectedModel) const _message = resetAssistantMessage(message, selectedModel)
editMessage(message.id, { ..._message }) await editMessage(message.id, { ..._message })
resendMessage(_message, assistant) await resendMessage(_message, assistant)
} }
const onMentionModel = async (e: React.MouseEvent) => { const onMentionModel = async (e: React.MouseEvent) => {
@ -349,7 +333,7 @@ const MessageMenubar: FC<Props> = (props) => {
if (loading) return if (loading) return
const selectedModel = await SelectModelPopup.show({ model }) const selectedModel = await SelectModelPopup.show({ model })
if (!selectedModel) return if (!selectedModel) return
resendMessage(message, { ...assistant, model: selectedModel }, true) await resendMessage(message, { ...assistant, model: selectedModel }, true)
} }
const onUseful = useCallback( const onUseful = useCallback(
@ -414,13 +398,26 @@ const MessageMenubar: FC<Props> = (props) => {
...TranslateLanguageOptions.map((item) => ({ ...TranslateLanguageOptions.map((item) => ({
label: item.emoji + ' ' + item.label, label: item.emoji + ' ' + item.label,
key: item.value, key: item.value,
onClick: () => handleTranslate(item.value) onClick: () => translateMessage(message.id, item.value)
})), })),
{ {
label: '✖ ' + t('translate.close'), label: '✖ ' + t('translate.close'),
key: 'translate-close', key: 'translate-close',
onClick: () => editMessage(message.id, { translatedContent: undefined }) onClick: () => editMessage(message.id, { translatedContent: undefined })
},
...(message.translatedContent
? [
{
label: '📋 ' + t('translate.copy'),
key: 'translate-copy',
icon: translationCopied ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : null,
onClick: (e) => {
e.domEvent.stopPropagation()
onCopyTranslation(e.domEvent as unknown as React.MouseEvent)
} }
}
]
: [])
], ],
onClick: (e) => e.domEvent.stopPropagation() onClick: (e) => e.domEvent.stopPropagation()
}} }}