From 56dbe6b0501ed31892de63f2f2a816d426a11472 Mon Sep 17 00:00:00 2001 From: suyao Date: Tue, 23 Sep 2025 05:05:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=A0=8F=E9=85=8D=E7=BD=AE=E5=92=8C=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=B8=B2=E6=9F=93=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/config/registry/messageMenubar.ts | 60 ++ .../pages/home/Messages/MessageMenubar.tsx | 724 +++++++++++------- 2 files changed, 526 insertions(+), 258 deletions(-) create mode 100644 src/renderer/src/config/registry/messageMenubar.ts diff --git a/src/renderer/src/config/registry/messageMenubar.ts b/src/renderer/src/config/registry/messageMenubar.ts new file mode 100644 index 0000000000..03fe0d9734 --- /dev/null +++ b/src/renderer/src/config/registry/messageMenubar.ts @@ -0,0 +1,60 @@ +import { TopicType } from '@renderer/types' + +export type MessageMenubarScope = TopicType + +export type MessageMenubarButtonId = + | 'user-regenerate' + | 'user-edit' + | 'copy' + | 'assistant-regenerate' + | 'assistant-mention-model' + | 'translate' + | 'useful' + | 'notes' + | 'delete' + | 'trace' + | 'more-menu' + +export type MessageMenubarScopeConfig = { + buttonIds: MessageMenubarButtonId[] + dropdownRootAllowKeys?: string[] +} + +export const DEFAULT_MESSAGE_MENUBAR_SCOPE: MessageMenubarScope = TopicType.Chat + +export const DEFAULT_MESSAGE_MENUBAR_BUTTON_IDS: MessageMenubarButtonId[] = [ + 'user-regenerate', + 'user-edit', + 'copy', + 'assistant-regenerate', + 'assistant-mention-model', + 'translate', + 'useful', + 'notes', + 'delete', + 'trace', + 'more-menu' +] + +export const SESSION_MESSAGE_MENUBAR_BUTTON_IDS: MessageMenubarButtonId[] = ['copy', 'translate', 'notes', 'more-menu'] + +const messageMenubarRegistry = new Map([ + [DEFAULT_MESSAGE_MENUBAR_SCOPE, { buttonIds: [...DEFAULT_MESSAGE_MENUBAR_BUTTON_IDS] }], + [TopicType.Chat, { buttonIds: [...DEFAULT_MESSAGE_MENUBAR_BUTTON_IDS] }], + [TopicType.Session, { buttonIds: [...SESSION_MESSAGE_MENUBAR_BUTTON_IDS], dropdownRootAllowKeys: ['save', 'export'] }] +]) + +export const registerMessageMenubarConfig = (scope: MessageMenubarScope, config: MessageMenubarScopeConfig) => { + const clonedConfig: MessageMenubarScopeConfig = { + buttonIds: [...config.buttonIds], + dropdownRootAllowKeys: config.dropdownRootAllowKeys ? [...config.dropdownRootAllowKeys] : undefined + } + messageMenubarRegistry.set(scope, clonedConfig) +} + +export const getMessageMenubarConfig = (scope: MessageMenubarScope): MessageMenubarScopeConfig => { + if (messageMenubarRegistry.has(scope)) { + return messageMenubarRegistry.get(scope) as MessageMenubarScopeConfig + } + return messageMenubarRegistry.get(DEFAULT_MESSAGE_MENUBAR_SCOPE) as MessageMenubarScopeConfig +} diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index e757bae345..3ef96d0524 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -5,6 +5,12 @@ import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import { isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models' +import { + DEFAULT_MESSAGE_MENUBAR_SCOPE, + getMessageMenubarConfig, + MessageMenubarButtonId, + MessageMenubarScope +} from '@renderer/config/registry/messageMenubar' import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' @@ -40,8 +46,10 @@ import { findTranslationBlocksById, getMainTextContent } from '@renderer/utils/messageUtils/find' +import type { MenuProps } from 'antd' import { Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' +import type { TFunction } from 'i18next' import { AtSign, Check, @@ -55,7 +63,8 @@ import { ThumbsUp, Upload } from 'lucide-react' -import { FC, memo, useCallback, useMemo, useState } from 'react' +import type { Dispatch, ReactNode, SetStateAction } from 'react' +import { FC, Fragment, memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import styled from 'styled-components' @@ -78,6 +87,43 @@ interface Props { const logger = loggerService.withContext('MessageMenubar') +type MessageOperationsHandlers = ReturnType + +type MessageMenubarButtonContext = { + assistant: Assistant + blockEntities: ReturnType + confirmDeleteMessage: boolean + confirmRegenerateMessage: boolean + copied: boolean + deleteMessage: MessageOperationsHandlers['deleteMessage'] + dropdownItems: MenuProps['items'] + enableDeveloperMode: boolean + handleResendUserMessage: (messageUpdate?: Message) => Promise + handleTraceUserMessage: () => void | Promise + handleTranslate: (language: TranslateLanguage) => Promise + hasTranslationBlocks: boolean + isAssistantMessage: boolean + isBubbleStyle: boolean + isGrouped?: boolean + isLastMessage: boolean + isUserMessage: boolean + message: Message + notesPath: string + onCopy: (e: React.MouseEvent) => void + onEdit: () => void | Promise + onMentionModel: (e: React.MouseEvent) => void | Promise + onRegenerate: (e?: React.MouseEvent) => void | Promise + onUseful: (e: React.MouseEvent) => void + removeMessageBlock: MessageOperationsHandlers['removeMessageBlock'] + setShowDeleteTooltip: Dispatch> + showDeleteTooltip: boolean + softHoverBg: boolean + t: TFunction + translateLanguages: TranslateLanguage[] +} + +type MessageMenubarButtonRenderer = (ctx: MessageMenubarButtonContext) => ReactNode | null + const MessageMenubar: FC = (props) => { const { message, @@ -217,12 +263,15 @@ const MessageMenubar: FC = (props) => { } }, [message]) + const menubarScope: MessageMenubarScope = topic?.type ?? DEFAULT_MESSAGE_MENUBAR_SCOPE + const { buttonIds, dropdownRootAllowKeys } = getMessageMenubarConfig(menubarScope) + const isEditable = useMemo(() => { return findMainTextBlocks(message).length > 0 // 使用 MCP Server 后会有大于一段 MatinTextBlock }, [message]) - const dropdownItems = useMemo( - () => [ + const dropdownItems = useMemo(() => { + const items: MenuProps['items'] = [ ...(isEditable ? [ { @@ -342,7 +391,7 @@ const MessageMenubar: FC = (props) => { label: t('chat.topics.export.obsidian'), key: 'obsidian', onClick: async () => { - const title = topic.name?.replace(/\//g, '_') || 'Untitled' + const title = topic.name?.replace(/\\/g, '_') || 'Untitled' await ObsidianExportPopup.show({ title, message, processingMethod: '1' }) } }, @@ -365,29 +414,47 @@ const MessageMenubar: FC = (props) => { } ].filter(Boolean) } - ], - [ - isEditable, - t, - onEdit, - onNewBranch, - exportMenuOptions.plain_text, - exportMenuOptions.image, - exportMenuOptions.markdown, - exportMenuOptions.markdown_reason, - exportMenuOptions.docx, - exportMenuOptions.notion, - exportMenuOptions.yuque, - exportMenuOptions.obsidian, - exportMenuOptions.joplin, - exportMenuOptions.siyuan, - toggleMultiSelectMode, - message, - mainTextContent, - messageContainerRef, - topic.name - ] - ) + ].filter(Boolean) + + if (!dropdownRootAllowKeys || dropdownRootAllowKeys.length === 0) { + return items + } + + const allowSet = new Set(dropdownRootAllowKeys) + return items.filter((item) => { + if (!item || typeof item !== 'object') { + return false + } + if ('type' in item && item.type === 'divider') { + return false + } + if ('key' in item && item.key) { + return allowSet.has(String(item.key)) + } + return false + }) + }, [ + dropdownRootAllowKeys, + exportMenuOptions.docx, + exportMenuOptions.image, + exportMenuOptions.joplin, + exportMenuOptions.markdown, + exportMenuOptions.markdown_reason, + exportMenuOptions.notion, + exportMenuOptions.obsidian, + exportMenuOptions.plain_text, + exportMenuOptions.siyuan, + exportMenuOptions.yuque, + isEditable, + mainTextContent, + message, + messageContainerRef, + onEdit, + onNewBranch, + t, + toggleMultiSelectMode, + topic.name + ]) const onRegenerate = async (e: React.MouseEvent | undefined) => { e?.stopPropagation?.() @@ -461,237 +528,56 @@ const MessageMenubar: FC = (props) => { const showMessageTokens = !isBubbleStyle const isUserBubbleStyleMessage = isBubbleStyle && isUserMessage + const buttonContext: MessageMenubarButtonContext = { + assistant, + blockEntities, + confirmDeleteMessage, + confirmRegenerateMessage, + copied, + deleteMessage, + dropdownItems, + enableDeveloperMode, + handleResendUserMessage, + handleTraceUserMessage, + handleTranslate, + hasTranslationBlocks, + isAssistantMessage, + isBubbleStyle, + isGrouped, + isLastMessage, + isUserMessage, + message, + notesPath, + onCopy, + onEdit, + onMentionModel, + onRegenerate, + onUseful, + removeMessageBlock, + setShowDeleteTooltip, + showDeleteTooltip, + softHoverBg, + t, + translateLanguages + } + return ( <> {showMessageTokens && } - {message.role === 'user' && - (confirmRegenerateMessage ? ( - handleResendUserMessage()} - onOpenChange={(open) => open && setShowDeleteTooltip(false)}> - - e.stopPropagation()} - $softHoverBg={isBubbleStyle}> - - - - - ) : ( - - handleResendUserMessage()} - $softHoverBg={isBubbleStyle}> - - - - ))} - {message.role === 'user' && ( - - - - - - )} - - - {!copied && } - {copied && } - - - {isAssistantMessage && - (confirmRegenerateMessage ? ( - open && setShowDeleteTooltip(false)}> - - e.stopPropagation()} - $softHoverBg={softHoverBg}> - - - - - ) : ( - - - - - - ))} - {isAssistantMessage && ( - - - - - - )} - {!isUserMessage && ( - ({ - label: item.emoji + ' ' + item.label(), - key: item.langCode, - onClick: () => handleTranslate(item) - })), - ...(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.toast.success(t('translate.copied')) - } else { - window.toast.warning(t('translate.empty')) - } - } - } - }, - { - 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.toast.success(t('translate.closed')) - } - } - } - ] - : []) - ], - onClick: (e) => e.domEvent.stopPropagation() - }} - trigger={['click']} - placement="top" - arrow> - - e.stopPropagation()} - $softHoverBg={softHoverBg}> - - - - - )} - {isAssistantMessage && isGrouped && ( - - - {message.useful ? ( - - ) : ( - - )} - - - )} - {isAssistantMessage && ( - - { - e.stopPropagation() - const title = await getMessageTitle(message) - const markdown = messageToMarkdown(message) - exportMessageToNotes(title, markdown, notesPath) - }} - $softHoverBg={softHoverBg}> - - - - )} - {confirmDeleteMessage ? ( - deleteMessage(message.id, message.traceId, message.model?.name)} - onOpenChange={(open) => open && setShowDeleteTooltip(false)}> - e.stopPropagation()} - $softHoverBg={softHoverBg}> - - - - - - ) : ( - { - e.stopPropagation() - deleteMessage(message.id, message.traceId, message.model?.name) - }} - $softHoverBg={softHoverBg}> - - - - - )} - {enableDeveloperMode && message.traceId && ( - - handleTraceUserMessage()}> - - - - )} - {!isUserMessage && ( - e.domEvent.stopPropagation() }} - trigger={['click']} - placement="topRight"> - e.stopPropagation()} - $softHoverBg={softHoverBg}> - - - - )} + {buttonIds.map((buttonId) => { + const renderFn = buttonRenderers[buttonId] + if (!renderFn) { + logger.warn(`No renderer registered for MessageMenubar button id: ${buttonId}`) + return null + } + const element = renderFn(buttonContext) + if (!element) { + return null + } + return {element} + })} ) @@ -739,10 +625,332 @@ const ActionButton = styled.div<{ $softHoverBg?: boolean }>` } ` -// const ReSendButton = styled(Button)` -// position: absolute; -// top: 10px; -// left: 0; -// ` +const buttonRenderers: Record = { + 'user-regenerate': ({ + message, + confirmRegenerateMessage, + handleResendUserMessage, + setShowDeleteTooltip, + t, + isBubbleStyle + }) => { + if (message.role !== 'user') { + return null + } + + if (confirmRegenerateMessage) { + return ( + handleResendUserMessage()} + onOpenChange={(open) => open && setShowDeleteTooltip(false)}> + + e.stopPropagation()} + $softHoverBg={isBubbleStyle}> + + + + + ) + } + + return ( + + handleResendUserMessage()} + $softHoverBg={isBubbleStyle}> + + + + ) + }, + 'user-edit': ({ message, onEdit, softHoverBg, t }) => { + if (message.role !== 'user') { + return null + } + + return ( + + + + + + ) + }, + copy: ({ onCopy, softHoverBg, copied, t }) => ( + + + {!copied && } + {copied && } + + + ), + 'assistant-regenerate': ({ + isAssistantMessage, + confirmRegenerateMessage, + onRegenerate, + setShowDeleteTooltip, + softHoverBg, + t + }) => { + if (!isAssistantMessage) { + return null + } + + if (confirmRegenerateMessage) { + return ( + onRegenerate()} + onOpenChange={(open) => open && setShowDeleteTooltip(false)}> + + e.stopPropagation()} + $softHoverBg={softHoverBg}> + + + + + ) + } + + return ( + + + + + + ) + }, + 'assistant-mention-model': ({ isAssistantMessage, onMentionModel, softHoverBg, t }) => { + if (!isAssistantMessage) { + return null + } + + return ( + + + + + + ) + }, + translate: ({ + isUserMessage, + translateLanguages, + handleTranslate, + hasTranslationBlocks, + message, + blockEntities, + removeMessageBlock, + softHoverBg, + t + }) => { + if (isUserMessage) { + return null + } + + const items: MenuProps['items'] = [ + ...translateLanguages.map((item) => ({ + label: item.emoji + ' ' + item.label(), + key: item.langCode, + onClick: () => handleTranslate(item) + })), + ...(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.toast.success(t('translate.copied')) + } else { + window.toast.warning(t('translate.empty')) + } + } + } + }, + { + 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.toast.success(t('translate.closed')) + } + } + } + ] + : []) + ] + + return ( + e.domEvent.stopPropagation() + }} + trigger={['click']} + placement="top" + arrow> + + e.stopPropagation()} + $softHoverBg={softHoverBg}> + + + + + ) + }, + useful: ({ isAssistantMessage, isGrouped, onUseful, softHoverBg, message, t }) => { + if (!isAssistantMessage || !isGrouped) { + return null + } + + return ( + + + {message.useful ? ( + + ) : ( + + )} + + + ) + }, + notes: ({ isAssistantMessage, softHoverBg, message, notesPath, t }) => { + if (!isAssistantMessage) { + return null + } + + return ( + + { + e.stopPropagation() + const title = await getMessageTitle(message) + const markdown = messageToMarkdown(message) + exportMessageToNotes(title, markdown, notesPath) + }} + $softHoverBg={softHoverBg}> + + + + ) + }, + delete: ({ + confirmDeleteMessage, + deleteMessage, + message, + setShowDeleteTooltip, + showDeleteTooltip, + softHoverBg, + t + }) => { + const deleteTooltip = ( + + + + ) + + if (confirmDeleteMessage) { + return ( + deleteMessage(message.id, message.traceId, message.model?.name)} + onOpenChange={(open) => open && setShowDeleteTooltip(false)}> + e.stopPropagation()} + $softHoverBg={softHoverBg}> + {deleteTooltip} + + + ) + } + + return ( + { + e.stopPropagation() + deleteMessage(message.id, message.traceId, message.model?.name) + }} + $softHoverBg={softHoverBg}> + {deleteTooltip} + + ) + }, + trace: ({ enableDeveloperMode, message, handleTraceUserMessage, t }) => { + if (!enableDeveloperMode || !message.traceId) { + return null + } + + return ( + + handleTraceUserMessage()}> + + + + ) + }, + 'more-menu': ({ isUserMessage, dropdownItems, softHoverBg }) => { + if (isUserMessage) { + return null + } + + return ( + e.domEvent.stopPropagation() }} + trigger={['click']} + placement="topRight"> + e.stopPropagation()} $softHoverBg={softHoverBg}> + + + + ) + } +} export default memo(MessageMenubar)