diff --git a/src/renderer/src/hooks/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts index 86cfd89b88..b6e853f5f3 100644 --- a/src/renderer/src/hooks/useMessageOperations.ts +++ b/src/renderer/src/hooks/useMessageOperations.ts @@ -333,6 +333,36 @@ export function useMessageOperations(topic: Topic) { [dispatch, topic.id] ) + /** + * Removes a specific block from a message. + */ + const removeMessageBlock = useCallback( + async (messageId: string, blockIdToRemove: string) => { + if (!topic?.id) { + console.error('[removeMessageBlock] Topic prop is not valid.') + return + } + + const state = store.getState() + const message = state.messages.entities[messageId] + if (!message || !message.blocks) { + console.error('[removeMessageBlock] Message not found or has no blocks:', messageId) + return + } + + const updatedBlocks = message.blocks.filter((blockId) => blockId !== blockIdToRemove) + + const messageUpdates: Partial & Pick = { + id: messageId, + updatedAt: new Date().toISOString(), + blocks: updatedBlocks + } + + await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [])) + }, + [dispatch, topic?.id] + ) + return { displayCount, deleteMessage, @@ -348,7 +378,8 @@ export function useMessageOperations(topic: Topic) { resumeMessage, getTranslationUpdater, createTopicBranch, - editMessageBlocks + editMessageBlocks, + removeMessageBlock } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index cf87d3dfed..d5500fac41 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1624,6 +1624,10 @@ "any.language": "Any language", "button.translate": "Translate", "close": "Close", + "closed": "Translation closed", + "copied": "Translation content copied", + "empty": "Translation content is empty", + "not.found": "Translation content not found", "confirm": { "content": "Translation will replace the original text, continue?", "title": "Translation Confirmation" diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index e351131275..a87d01d6ce 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1624,6 +1624,10 @@ "any.language": "任意の言語", "button.translate": "翻訳", "close": "閉じる", + "closed": "翻訳は閉じられました", + "copied": "翻訳内容がコピーされました", + "empty": "翻訳内容が空です", + "not.found": "翻訳内容が見つかりません", "confirm": { "content": "翻訳すると元のテキストが上書きされます。続行しますか?", "title": "翻訳確認" diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 516f829534..aa1d22cf46 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1624,6 +1624,10 @@ "any.language": "Любой язык", "button.translate": "Перевести", "close": "Закрыть", + "closed": "Перевод закрыт", + "copied": "Содержимое перевода скопировано", + "empty": "Содержимое перевода пусто", + "not.found": "Содержимое перевода не найдено", "confirm": { "content": "Перевод заменит исходный текст, продолжить?", "title": "Перевод подтверждение" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 64647621b4..6f68ce6f68 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1624,6 +1624,10 @@ "any.language": "任意语言", "button.translate": "翻译", "close": "关闭", + "closed": "翻译已关闭", + "copied": "翻译内容已复制", + "empty": "翻译内容为空", + "not.found": "未找到翻译内容", "confirm": { "content": "翻译后将覆盖原文,是否继续?", "title": "翻译确认" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 61d4bd2ff3..3d013c5f14 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1624,6 +1624,10 @@ "any.language": "任意語言", "button.translate": "翻譯", "close": "關閉", + "closed": "翻譯已關閉", + "copied": "翻譯內容已複製", + "empty": "翻譯內容為空", + "not.found": "未找到翻譯內容", "confirm": { "content": "翻譯後將覆蓋原文,是否繼續?", "title": "翻譯確認" diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index fa685006a4..7a3ca793ff 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -8,6 +8,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle } from '@renderer/services/MessagesService' import { translateText } from '@renderer/services/TranslateService' import { RootState } from '@renderer/store' +import { messageBlocksSelectors } from '@renderer/store/messageBlock' import type { Model } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' @@ -22,7 +23,12 @@ import { } from '@renderer/utils/export' // import { withMessageThought } from '@renderer/utils/formats' import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown' -import { findImageBlocks, findMainTextBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' +import { + findImageBlocks, + findMainTextBlocks, + findTranslationBlocks, + getMainTextContent +} from '@renderer/utils/messageUtils/find' import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' import { AtSign, Copy, Languages, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react' @@ -62,7 +68,8 @@ const MessageMenubar: FC = (props) => { resendUserMessageWithEdit, getTranslationUpdater, appendAssistantResponse, - editMessageBlocks + editMessageBlocks, + removeMessageBlock } = useMessageOperations(topic) const loading = useTopicLoading(topic) @@ -377,6 +384,12 @@ const MessageMenubar: FC = (props) => { [message, editMessage] ) + const blockEntities = useSelector(messageBlocksSelectors.selectEntities) + const hasTranslationBlocks = useMemo(() => { + const translationBlocks = findTranslationBlocks(message) + return translationBlocks.length > 0 + }, [message]) + return ( {message.role === 'user' && ( @@ -432,13 +445,52 @@ const MessageMenubar: FC = (props) => { label: item.emoji + ' ' + item.label, key: item.value, onClick: () => handleTranslate(item.value) - })) - // { - // TODO 删除翻译块可以放在翻译块内 - // label: '✖ ' + t('translate.close'), - // key: 'translate-close', - // onClick: () => editMessage(message.id, { translatedContent: undefined }) - // } + })), + ...(hasTranslationBlocks + ? [ + { type: 'divider' as const }, + { + label: '📋 ' + t('common.copy'), + key: 'translate-copy', + onClick: () => { + const translationBlocks = message.blocks + .map((blockId) => blockEntities[blockId]) + .filter((block) => block?.type === 'translation') + + if (translationBlocks.length > 0) { + const translationContent = translationBlocks + .map((block) => block?.content || '') + .join('\n\n') + .trim() + + if (translationContent) { + navigator.clipboard.writeText(translationContent) + window.message.success({ content: t('translate.copied'), key: 'translate-copy' }) + } else { + window.message.warning({ content: t('translate.empty'), key: 'translate-copy' }) + } + } + } + }, + { + label: '✖ ' + t('translate.close'), + key: 'translate-close', + onClick: () => { + const translationBlocks = message.blocks + .map((blockId) => blockEntities[blockId]) + .filter((block) => block?.type === 'translation') + .map((block) => block?.id) + + if (translationBlocks.length > 0) { + translationBlocks.forEach((blockId) => { + if (blockId) removeMessageBlock(message.id, blockId) + }) + window.message.success({ content: t('translate.closed'), key: 'translate-close' }) + } + } + } + ] + : []) ], onClick: (e) => e.domEvent.stopPropagation() }} diff --git a/src/renderer/src/utils/messageUtils/find.ts b/src/renderer/src/utils/messageUtils/find.ts index fcd0a539e4..64e8beba05 100644 --- a/src/renderer/src/utils/messageUtils/find.ts +++ b/src/renderer/src/utils/messageUtils/find.ts @@ -6,7 +6,8 @@ import type { ImageMessageBlock, MainTextMessageBlock, Message, - ThinkingMessageBlock + ThinkingMessageBlock, + TranslationMessageBlock } from '@renderer/types/newMessage' import { MessageBlockType } from '@renderer/types/newMessage' @@ -30,6 +31,11 @@ export const findMainTextBlocks = (message: Message): MainTextMessageBlock[] => return textBlocks } +/** + * Finds all ThinkingMessageBlocks associated with a given message. + * @param message - The message object. + * @returns An array of ThinkingMessageBlocks (empty if none found). + */ export const findThinkingBlocks = (message: Message): ThinkingMessageBlock[] => { if (!message || !message.blocks || message.blocks.length === 0) { return [] @@ -95,6 +101,11 @@ export const getMainTextContent = (message: Message): string => { return textBlocks.map((block) => block.content).join('\n\n') } +/** + * Gets the concatenated content string from all ThinkingMessageBlocks of a message, in order. + * @param message + * @returns The concatenated content string or an empty string if no thinking blocks are found. + */ export const getThinkingContent = (message: Message): string => { const thinkingBlocks = findThinkingBlocks(message) return thinkingBlocks.map((block) => block.content).join('\n\n') @@ -131,6 +142,26 @@ export const findCitationBlocks = (message: Message): CitationMessageBlock[] => return citationBlocks } +/** + * Finds all TranslationMessageBlocks associated with a given message. + * @param message - The message object. + * @returns An array of TranslationMessageBlocks (empty if none found). + */ +export const findTranslationBlocks = (message: Message): TranslationMessageBlock[] => { + if (!message || !message.blocks || message.blocks.length === 0) { + return [] + } + const state = store.getState() + const translationBlocks: TranslationMessageBlock[] = [] + for (const blockId of message.blocks) { + const block = messageBlocksSelectors.selectById(state, blockId) + if (block && block.type === 'translation') { + translationBlocks.push(block as TranslationMessageBlock) + } + } + return translationBlocks +} + /** * Finds the WebSearchMessageBlock associated with a given message. * Assumes only one web search block per message.