From 8b462935b49a38e6722ccfc3769f0416fb6a071a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Sat, 19 Apr 2025 10:36:57 +0800 Subject: [PATCH] feat: add chat message translate copy button (#4620) * feat: add chat message translate copy button * Update MessageMenubar.tsx * fix: copy button display --- .../src/hooks/useMessageOperations.ts | 42 +++++++-- src/renderer/src/i18n/locales/en-us.json | 2 + src/renderer/src/i18n/locales/ja-jp.json | 2 + src/renderer/src/i18n/locales/ru-ru.json | 2 + src/renderer/src/i18n/locales/zh-cn.json | 2 + src/renderer/src/i18n/locales/zh-tw.json | 2 + .../src/pages/home/Inputbar/Inputbar.tsx | 6 +- .../pages/home/Messages/MessageMenubar.tsx | 91 +++++++++---------- 8 files changed, 90 insertions(+), 59 deletions(-) diff --git a/src/renderer/src/hooks/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts index 05d072c7..668ebdc9 100644 --- a/src/renderer/src/hooks/useMessageOperations.ts +++ b/src/renderer/src/hooks/useMessageOperations.ts @@ -1,5 +1,6 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { estimateMessageUsage } from '@renderer/services/TokenService' +import { translateText } from '@renderer/services/TranslateService' import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { clearStreamMessage, @@ -18,8 +19,10 @@ import { import type { Assistant, Message, Topic } from '@renderer/types' import { abortCompletion } from '@renderer/utils/abortController' import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' import { TopicManager } from './useTopic' + /** * 自定义Hook,提供消息操作相关的功能 * @@ -28,6 +31,7 @@ import { TopicManager } from './useTopic' */ export function useMessageOperations(topic: Topic) { const dispatch = useAppDispatch() + const { t } = useTranslation() /** * 删除单个消息 @@ -60,8 +64,7 @@ export function useMessageOperations(topic: Topic) { const message = messages?.find((m) => m.id === messageId) if (message) { const updatedMessage = { ...message, ...updates } - const usage = await estimateMessageUsage(updatedMessage) - updates.usage = usage + updates.usage = await estimateMessageUsage(updatedMessage) } } await dispatch(updateMessageThunk(topic.id, messageId, updates)) @@ -128,7 +131,7 @@ export function useMessageOperations(topic: Topic) { const clearTopicMessagesAction = useCallback( async (_topicId?: string) => { const topicId = _topicId || topic.id - await dispatch(clearTopicMessages(topicId)) + dispatch(clearTopicMessages(topicId)) await TopicManager.clearTopicMessages(topicId) }, [dispatch, topic.id] @@ -148,7 +151,7 @@ export function useMessageOperations(topic: Topic) { * 创建新的上下文(clear message) */ const createNewContext = useCallback(async () => { - EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT) + await EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT) }, []) const displayCount = useAppSelector(selectDisplayCount) @@ -190,6 +193,28 @@ export function useMessageOperations(topic: Topic) { dispatch(setTopicLoading({ topicId: topic.id, loading: false })) }, [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, // pauseMessage, pauseMessages, - resumeMessage + resumeMessage, + translateMessage } } export const useTopicMessages = (topic: Topic) => { - const messages = useAppSelector((state) => selectTopicMessages(state, topic.id)) - return messages + return useAppSelector((state) => selectTopicMessages(state, topic.id)) } export const useTopicLoading = (topic: Topic) => { - const loading = useAppSelector((state) => selectTopicLoading(state, topic.id)) - return loading + return useAppSelector((state) => selectTopicLoading(state, topic.id)) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 60b72808..dffe50b0 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -561,6 +561,7 @@ "error": "Error occurred" }, "topic.added": "New topic added", + "translation.copied": "Translation copied", "upgrade.success.button": "Restart", "upgrade.success.content": "Please restart the application to complete the upgrade", "upgrade.success.title": "Upgrade successfully", @@ -1407,6 +1408,7 @@ "content": "Translation will replace the original text, continue?", "title": "Translation Confirmation" }, + "copy": "Copy", "error.failed": "Translation failed", "error.not_configured": "Translation model is not configured", "history": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 528f6a21..f9bf319f 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -560,6 +560,7 @@ "error": "エラーが発生しました" }, "topic.added": "新しいトピックが追加されました", + "translation.copied": "翻訳結果をコピーしました", "upgrade.success.button": "再起動", "upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください", "upgrade.success.title": "アップグレードに成功しました", @@ -1407,6 +1408,7 @@ "content": "翻訳すると元のテキストが上書きされます。続行しますか?", "title": "翻訳確認" }, + "copy": "翻訳をコピー", "error.failed": "翻訳に失敗しました", "error.not_configured": "翻訳モデルが設定されていません", "history": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 0349c617..7166e84e 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -561,6 +561,7 @@ "error": "Произошла ошибка" }, "topic.added": "Новый топик добавлен", + "translation.copied": "Перевод скопирован", "upgrade.success.button": "Перезапустить", "upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления", "upgrade.success.title": "Обновление успешно", @@ -1407,6 +1408,7 @@ "content": "Перевод заменит исходный текст, продолжить?", "title": "Перевод подтверждение" }, + "copy": "Копировать перевод", "error.failed": "Перевод не удалось", "error.not_configured": "Модель перевода не настроена", "history": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 86156aeb..9c5d130a 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -561,6 +561,7 @@ "error": "发生错误" }, "topic.added": "话题添加成功", + "translation.copied": "翻译已复制", "upgrade.success.button": "重启", "upgrade.success.content": "重启用以完成升级", "upgrade.success.title": "升级成功", @@ -1407,6 +1408,7 @@ "content": "翻译后将覆盖原文,是否继续?", "title": "翻译确认" }, + "copy": "复制翻译", "error.failed": "翻译失败", "error.not_configured": "翻译模型未配置", "history": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 72d0ba7c..746552f6 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -560,6 +560,7 @@ "invoking": "調用中", "error": "發生錯誤" }, + "translation.copied": "翻譯已複製", "topic.added": "新話題已新增", "upgrade.success.button": "重新啟動", "upgrade.success.content": "請重新啟動程式以完成升級", @@ -1407,6 +1408,7 @@ "content": "翻譯後將覆蓋原文,是否繼續?", "title": "翻譯確認" }, + "copy": "複製翻譯", "error.failed": "翻譯失敗", "error.not_configured": "翻譯模型未設定", "history": { diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index c08fa49a..9ba1653f 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -181,7 +181,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = return } - EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE) + await EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE) try { // Dispatch the sendMessage action with all options @@ -209,7 +209,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = userMessage.usage = await estimateMessageUsage(userMessage) currentMessageId.current = userMessage.id - dispatch( + await dispatch( _sendMessage(userMessage, assistant, topic, { mentions: mentionModels }) @@ -525,7 +525,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = await onPause() await delay(1) } - EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES) + await EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES) } const onNewContext = () => { diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index b936dd0c..09e3932f 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -7,7 +7,6 @@ import { TranslateLanguageOptions } from '@renderer/config/translate' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService' -import { translateText } from '@renderer/services/TranslateService' import { RootState } from '@renderer/store' import type { Message, Model } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types' @@ -37,7 +36,7 @@ import { ThumbsUp, Trash } 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 { useSelector } from 'react-redux' import styled from 'styled-components' @@ -60,12 +59,11 @@ const MessageMenubar: FC = (props) => { props const { t } = useTranslation() const [copied, setCopied] = useState(false) - const [isTranslating, setIsTranslating] = useState(false) + const [translationCopied, setTranslationCopied] = useState(false) const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false) const [showDeleteTooltip, setShowDeleteTooltip] = useState(false) const assistantModel = assistant?.model - const { editMessage, setStreamMessage, deleteMessage, resendMessage, commitStreamMessage, clearStreamMessage } = - useMessageOperations(topic) + const { editMessage, deleteMessage, resendMessage, translateMessage } = useMessageOperations(topic) const loading = useTopicLoading(topic) const isUserMessage = message.role === 'user' @@ -92,9 +90,23 @@ const MessageMenubar: FC = (props) => { [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 () => { 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' }) }, [index, t, loading]) @@ -144,7 +156,7 @@ const MessageMenubar: FC = (props) => { if (editedText && editedText !== textToEdit) { // 解析编辑后的文本,提取图片 URL - const imageRegex = /!\[image-\d+\]\((.*?)\)/g + const imageRegex = /!\[image-\d+]\((.*?)\)/g const imageUrls: string[] = [] let match let content = editedText @@ -170,7 +182,7 @@ const MessageMenubar: FC = (props) => { }) resendMessage && - handleResendUserMessage({ + (await handleResendUserMessage({ ...message, content: content.trim(), metadata: { @@ -183,38 +195,10 @@ const MessageMenubar: FC = (props) => { } : undefined } - }) + })) } }, [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( () => [ { @@ -281,7 +265,7 @@ const MessageMenubar: FC = (props) => { onClick: async () => { const markdown = messageToMarkdown(message) const title = await getMessageTitle(message) - window.api.export.toWord(markdown, title) + await window.api.export.toWord(markdown, title) } }, exportMenuOptions.notion && { @@ -290,7 +274,7 @@ const MessageMenubar: FC = (props) => { onClick: async () => { const title = await getMessageTitle(message) const markdown = messageToMarkdown(message) - exportMarkdownToNotion(title, markdown) + await exportMarkdownToNotion(title, markdown) } }, exportMenuOptions.yuque && { @@ -299,7 +283,7 @@ const MessageMenubar: FC = (props) => { onClick: async () => { const title = await getMessageTitle(message) const markdown = messageToMarkdown(message) - exportMarkdownToYuque(title, markdown) + await exportMarkdownToYuque(title, markdown) } }, exportMenuOptions.obsidian && { @@ -317,7 +301,7 @@ const MessageMenubar: FC = (props) => { onClick: async () => { const title = await getMessageTitle(message) const markdown = messageToMarkdown(message) - exportMarkdownToJoplin(title, markdown) + await exportMarkdownToJoplin(title, markdown) } }, exportMenuOptions.siyuan && { @@ -326,7 +310,7 @@ const MessageMenubar: FC = (props) => { onClick: async () => { const title = await getMessageTitle(message) const markdown = messageToMarkdown(message) - exportMarkdownToSiyuan(title, markdown) + await exportMarkdownToSiyuan(title, markdown) } } ].filter(Boolean) @@ -340,8 +324,8 @@ const MessageMenubar: FC = (props) => { if (loading) return const selectedModel = isGrouped ? model : assistantModel const _message = resetAssistantMessage(message, selectedModel) - editMessage(message.id, { ..._message }) - resendMessage(_message, assistant) + await editMessage(message.id, { ..._message }) + await resendMessage(_message, assistant) } const onMentionModel = async (e: React.MouseEvent) => { @@ -349,7 +333,7 @@ const MessageMenubar: FC = (props) => { if (loading) return const selectedModel = await SelectModelPopup.show({ model }) if (!selectedModel) return - resendMessage(message, { ...assistant, model: selectedModel }, true) + await resendMessage(message, { ...assistant, model: selectedModel }, true) } const onUseful = useCallback( @@ -414,13 +398,26 @@ const MessageMenubar: FC = (props) => { ...TranslateLanguageOptions.map((item) => ({ label: item.emoji + ' ' + item.label, key: item.value, - onClick: () => handleTranslate(item.value) + onClick: () => translateMessage(message.id, item.value) })), { label: '✖ ' + t('translate.close'), key: 'translate-close', onClick: () => editMessage(message.id, { translatedContent: undefined }) - } + }, + ...(message.translatedContent + ? [ + { + label: '📋 ' + t('translate.copy'), + key: 'translate-copy', + icon: translationCopied ? : null, + onClick: (e) => { + e.domEvent.stopPropagation() + onCopyTranslation(e.domEvent as unknown as React.MouseEvent) + } + } + ] + : []) ], onClick: (e) => e.domEvent.stopPropagation() }}