From d07c6ecc6bcf05052b48a294d02ea56fb631ac35 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: Wed, 21 May 2025 11:22:36 +0800 Subject: [PATCH] feat: add message multiple select (#6085) * feat: add message multiple select * fix: build error * feat: add drag-and-drop multi-selection * fix: code review * Revert "fix: code review" This reverts commit 7e29d5147c13cd04ce825ce65d91e40b8fe00159. * fix: hide input bar display * fix: extract the ChatContext * fix: eventemitter * feat: enhance multi-select functionality with message registration * fix: history page message search * fix: build error * fix: remove Event Emitter * fix: build error * feat: add hideMenuBar prop to MessageItem and integrate MessageEditingProvider * fix: improve message selection logic and handle drag events * fix: update translation keys for multiple select functionality * fix: refactor message deletion logic and enhance message selection handling * fix: replace useSelector with useStore for message selection in ChatContext * fix: refactor MessageGroup to utilize context for multi-select handling and message registration * Revert "fix: refactor MessageGroup to utilize context for multi-select handling and message registration" This reverts commit f4d1454525861a1a60a9c9f52a7551c0df9813dd. * fix: simplify MessageGroup props and utilize context for message selection handling * fix: streamline multi-select handling by consolidating context usage and simplifying component props --- .gitignore | 1 + .../components/Popups/MultiSelectionPopup.tsx | 95 ++++++++ src/renderer/src/i18n/locales/en-us.json | 7 + src/renderer/src/i18n/locales/ja-jp.json | 8 + src/renderer/src/i18n/locales/ru-ru.json | 10 +- src/renderer/src/i18n/locales/zh-cn.json | 7 + src/renderer/src/i18n/locales/zh-tw.json | 8 + .../history/components/SearchMessage.tsx | 40 ++-- .../history/components/TopicMessages.tsx | 56 +++-- src/renderer/src/pages/home/Chat.tsx | 16 +- .../src/pages/home/Messages/ChatContext.tsx | 217 ++++++++++++++++++ .../src/pages/home/Messages/Message.tsx | 10 +- .../src/pages/home/Messages/MessageEditor.tsx | 2 +- .../src/pages/home/Messages/MessageGroup.tsx | 80 +++---- .../pages/home/Messages/MessageMenubar.tsx | 26 ++- .../src/pages/home/Messages/MessageSelect.tsx | 63 +++++ .../src/pages/home/Messages/Messages.tsx | 144 +++++++++++- 17 files changed, 686 insertions(+), 104 deletions(-) create mode 100644 src/renderer/src/components/Popups/MultiSelectionPopup.tsx create mode 100644 src/renderer/src/pages/home/Messages/ChatContext.tsx create mode 100644 src/renderer/src/pages/home/Messages/MessageSelect.tsx diff --git a/.gitignore b/.gitignore index 459dc6201c..68ea0f203f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ local coverage .vitest-cache vitest.config.*.timestamp-* +YOUR_MEMORY_FILE_PATH diff --git a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx new file mode 100644 index 0000000000..b83fe7b9d6 --- /dev/null +++ b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx @@ -0,0 +1,95 @@ +import { CloseOutlined, CopyOutlined, DeleteOutlined, SaveOutlined } from '@ant-design/icons' +import { useChatContext } from '@renderer/pages/home/Messages/ChatContext' +import { Button, Tooltip } from 'antd' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const MultiSelectActionPopup: FC = () => { + const { t } = useTranslation() + const { toggleMultiSelectMode, selectedMessageIds, isMultiSelectMode, handleMultiSelectAction } = useChatContext() + + const handleAction = (action: string) => { + handleMultiSelectAction(action, selectedMessageIds) + } + + const handleClose = () => { + toggleMultiSelectMode(false) + } + + if (!isMultiSelectMode) return null + + // TODO: 视情况调整 + // const isActionDisabled = selectedMessages.some((msg) => msg.role === 'user') + const isActionDisabled = false + + return ( + + + {t('common.selectedMessages', { count: selectedMessageIds.length })} + + + } disabled={isActionDisabled} onClick={() => handleAction('save')} /> + + + } disabled={isActionDisabled} onClick={() => handleAction('copy')} /> + + + } onClick={() => handleAction('delete')} /> + + + + } onClick={handleClose} /> + + + + ) +} + +const Container = styled.div` + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 36px 20px; + background-color: var(--color-background); + border-top: 1px solid var(--color-border); + z-index: 10; +` + +const ActionBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +` + +const ActionButtons = styled.div` + display: flex; + gap: 16px; +` + +const ActionButton = styled(Button)` + display: flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: 4px; + .anticon { + font-size: 16px; + } + &:hover { + background-color: var(--color-background-mute); + } + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +` + +const SelectionCount = styled.div` + margin-right: 15px; + color: var(--color-text-2); + font-size: 14px; +` + +export default MultiSelectActionPopup diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4a9831a9f7..325e5b74ea 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -191,6 +191,8 @@ "message.quote": "Quote", "message.regenerate.model": "Switch Model", "message.useful": "Helpful", + "multiple.select": "Multiple Select", + "multiple.select.empty": "No Messages Selected", "navigation": { "first": "Already at the first message", "history": "Chat History", @@ -393,6 +395,7 @@ "save": "Save", "search": "Search", "select": "Select", + "selectedMessages": "Selected {{count}} messages", "topics": "Topics", "warning": "Warning", "you": "You", @@ -591,6 +594,10 @@ "copied": "Copied!", "copy.failed": "Copy failed", "copy.success": "Copied!", + "delete.confirm.title": "Delete Confirmation", + "delete.confirm.content": "Are you sure you want to delete the selected {{count}} message(s)?", + "delete.failed": "Delete Failed", + "delete.success": "Delete Successful", "empty_url": "Failed to download image, possibly due to prompt containing sensitive content or prohibited words", "error.chunk_overlap_too_large": "Chunk overlap cannot be greater than chunk size", "error.dimension_too_large": "Content size is too large", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index ebef93d2fa..48b99908e5 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -191,6 +191,8 @@ "message.quote": "引用", "message.regenerate.model": "モデルを切り替え", "message.useful": "役立つ", + "multiple.select": "選択", + "multiple.select.empty": "メッセージが選択されていません", "navigation": { "first": "最初のメッセージです", "history": "チャット履歴", @@ -393,6 +395,7 @@ "save": "保存", "search": "検索", "select": "選択", + "selectedMessages": "{{count}}件のメッセージを選択しました", "topics": "トピック", "warning": "警告", "you": "あなた", @@ -591,6 +594,11 @@ "copied": "コピーしました!", "copy.failed": "コピーに失敗しました", "copy.success": "コピーしました!", + "delete.confirm.title": "削除確認", + "delete.confirm.content": "選択した{{count}}件のメッセージを削除しますか?", + "delete.failed": "削除に失敗しました", + "delete.success": "削除が成功しました", + "error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません", "empty_url": "画像をダウンロードできません。プロンプトに不適切なコンテンツや禁止用語が含まれている可能性があります", "error.chunk_overlap_too_large": "チャンクのオーバーラップがチャンクサイズより大きくなることはできません", "error.dimension_too_large": "内容のサイズが大きすぎます", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 684c14d163..bf8c03d812 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -191,6 +191,8 @@ "message.quote": "Цитата", "message.regenerate.model": "Переключить модель", "message.useful": "Полезно", + "multiple.select": "Множественный выбор", + "multiple.select.empty": "Ничего не выбрано", "navigation": { "first": "Уже первое сообщение", "history": "История чата", @@ -393,6 +395,7 @@ "save": "Сохранить", "search": "Поиск", "select": "Выбрать", + "selectedMessages": "Выбрано {{count}} сообщений", "topics": "Топики", "warning": "Предупреждение", "you": "Вы", @@ -591,8 +594,12 @@ "copied": "Скопировано!", "copy.failed": "Не удалось скопировать", "copy.success": "Скопировано!", - "empty_url": "Не удалось загрузить изображение, возможно, запрос содержит конфиденциальный контент или запрещенные слова", + "delete.confirm.title": "Подтверждение удаления", + "delete.confirm.content": "Вы уверены, что хотите удалить выбранные {{count}} сообщения?", + "delete.failed": "Ошибка удаления", + "delete.success": "Удаление успешно", "error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента", + "empty_url": "Не удалось загрузить изображение, возможно, запрос содержит конфиденциальный контент или запрещенные слова", "error.dimension_too_large": "Размер содержимого слишком велик", "error.enter.api.host": "Пожалуйста, введите ваш API хост", "error.enter.api.key": "Пожалуйста, введите ваш API ключ", @@ -803,6 +810,7 @@ "model": "Версия", "aspect_ratio": "Пропорции изображения", "style_type": "Стиль", + "rendering_speed": "Скорость рендеринга", "learn_more": "Узнать больше", "prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки", "proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 9e534728ab..b757c85c9a 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -205,6 +205,8 @@ "message.quote": "引用", "message.regenerate.model": "切换模型", "message.useful": "有用", + "multiple.select": "多选", + "multiple.select.empty": "未选中任何消息", "navigation": { "first": "已经是第一条消息", "history": "聊天历史", @@ -393,6 +395,7 @@ "save": "保存", "search": "搜索", "select": "选择", + "selectedMessages": "选中{{count}}条消息", "topics": "话题", "warning": "警告", "you": "用户", @@ -591,6 +594,10 @@ "copied": "已复制", "copy.failed": "复制失败", "copy.success": "复制成功", + "delete.confirm.title": "删除确认", + "delete.confirm.content": "确认删除选中的{{count}}条消息吗?", + "delete.failed": "删除失败", + "delete.success": "删除成功", "empty_url": "无法下载图片,可能是提示词包含敏感内容或违禁词汇", "error.chunk_overlap_too_large": "分段重叠不能大于分段大小", "error.dimension_too_large": "内容尺寸过大", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index da01944428..815c61748d 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -191,6 +191,8 @@ "message.quote": "引用", "message.regenerate.model": "切換模型", "message.useful": "有用", + "multiple.select": "多選", + "multiple.select.empty": "未選中任何訊息", "navigation": { "first": "已經是第一條訊息", "history": "聊天歷史", @@ -393,6 +395,7 @@ "save": "儲存", "search": "搜尋", "select": "選擇", + "selectedMessages": "选中{{count}}条消息", "topics": "話題", "warning": "警告", "you": "您", @@ -590,6 +593,11 @@ "citations": "引用內容", "copied": "已複製!", "copy.failed": "複製失敗", + "copy.success": "已複製!", + "delete.confirm.title": "刪除確認", + "delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?", + "delete.failed": "刪除失敗", + "delete.success": "刪除成功", "copy.success": "複製成功", "empty_url": "無法下載圖片,可能是提示詞包含敏感內容或違禁詞彙", "error.chunk_overlap_too_large": "分段重疊不能大於分段大小", diff --git a/src/renderer/src/pages/history/components/SearchMessage.tsx b/src/renderer/src/pages/history/components/SearchMessage.tsx index b928486c28..3e05d53fb6 100644 --- a/src/renderer/src/pages/history/components/SearchMessage.tsx +++ b/src/renderer/src/pages/history/components/SearchMessage.tsx @@ -1,7 +1,9 @@ import { ArrowRightOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' +import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { useSettings } from '@renderer/hooks/useSettings' import { getTopicById } from '@renderer/hooks/useTopic' +import { ChatProvider } from '@renderer/pages/home/Messages/ChatContext' import { default as MessageItem } from '@renderer/pages/home/Messages/Message' import { locateToMessage } from '@renderer/services/MessagesService' import NavigationService from '@renderer/services/NavigationService' @@ -41,23 +43,27 @@ const SearchMessage: FC = ({ message, ...props }) => { } return ( - - - - - - - + + + + + + + + + + + ) } diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 86805dd1af..5e905a3141 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -1,8 +1,10 @@ import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import SearchPopup from '@renderer/components/Popups/SearchPopup' +import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' +import { ChatProvider } from '@renderer/pages/home/Messages/ChatContext' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' @@ -46,31 +48,35 @@ const TopicMessages: FC = ({ topic, ...props }) => { } return ( - - - {topic?.messages.map((message) => ( -
- -
- ))} - {isEmpty && } - {!isEmpty && ( - - - - )} -
-
+ + + + + {topic?.messages.map((message) => ( +
+ +
+ ))} + {isEmpty && } + {!isEmpty && ( + + + + )} +
+
+
+
) } diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 8516692309..8cdd481089 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -1,4 +1,5 @@ import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch' +import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup' import { QuickPanelProvider } from '@renderer/components/QuickPanel' import { useAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' @@ -12,6 +13,7 @@ import { useHotkeys } from 'react-hotkeys-hook' import styled from 'styled-components' import Inputbar from './Inputbar/Inputbar' +import { ChatProvider, useChatContext } from './Messages/ChatContext' import Messages from './Messages/Messages' import Tabs from './Tabs' @@ -22,10 +24,12 @@ interface Props { setActiveAssistant: (assistant: Assistant) => void } -const Chat: FC = (props) => { +const ChatContent: FC = (props) => { const { assistant } = useAssistant(props.assistant.id) const { topicPosition, messageStyle, showAssistants } = useSettings() const { showTopics } = useShowTopics() + const { isMultiSelectMode } = useChatContext() + const mainRef = React.useRef(null) const contentSearchRef = React.useRef(null) const [filterIncludeUser, setFilterIncludeUser] = useState(false) @@ -123,6 +127,7 @@ const Chat: FC = (props) => { + {isMultiSelectMode && } {topicPosition === 'right' && showTopics && ( @@ -138,6 +143,14 @@ const Chat: FC = (props) => { ) } +const Chat: FC = (props) => { + return ( + + + + ) +} + const MessagesContainer = styled.div` display: flex; flex-direction: column; @@ -154,7 +167,6 @@ const Container = styled.div` const Main = styled(Flex)` height: calc(100vh - var(--navbar-height)); - // 设置为containing block,方便子元素fixed定位 transform: translateZ(0); position: relative; ` diff --git a/src/renderer/src/pages/home/Messages/ChatContext.tsx b/src/renderer/src/pages/home/Messages/ChatContext.tsx new file mode 100644 index 0000000000..221fd22adb --- /dev/null +++ b/src/renderer/src/pages/home/Messages/ChatContext.tsx @@ -0,0 +1,217 @@ +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' +import { RootState } from '@renderer/store' +import { messageBlocksSelectors } from '@renderer/store/messageBlock' +import { selectMessagesForTopic } from '@renderer/store/newMessage' +import { Topic } from '@renderer/types' +import { Modal } from 'antd' +import { createContext, FC, ReactNode, use, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore } from 'react-redux' + +interface ChatContextProps { + isMultiSelectMode: boolean + selectedMessageIds: string[] + toggleMultiSelectMode: (value: boolean) => void + handleMultiSelectAction: (actionType: string, messageIds: string[]) => void + handleSelectMessage: (messageId: string, selected: boolean) => void + activeTopic: Topic + locateMessage: (messageId: string) => void + messageRefs: Map + registerMessageElement: (id: string, element: HTMLElement | null) => void +} + +interface ChatProviderProps { + children: ReactNode + activeTopic: Topic +} + +const ChatContext = createContext(undefined) + +export const useChatContext = () => { + const context = use(ChatContext) + if (!context) { + throw new Error('useChatContext 必须在 ChatProvider 内使用') + } + return context +} + +export const ChatProvider: FC = ({ children, activeTopic }) => { + const { t } = useTranslation() + const { deleteMessage } = useMessageOperations(activeTopic) + const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) + const [selectedMessageIds, setSelectedMessageIds] = useState([]) + const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false) + const [messagesToDelete, setMessagesToDelete] = useState([]) + const [messageRefs, setMessageRefs] = useState>(new Map()) + + const store = useStore() + + const toggleMultiSelectMode = (value: boolean) => { + setIsMultiSelectMode(value) + if (!value) { + setSelectedMessageIds([]) + } + } + + const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => { + setMessageRefs((prev) => { + const newRefs = new Map(prev) + if (element) { + newRefs.set(id, element) + } else { + newRefs.delete(id) + } + return newRefs + }) + }, []) + + const locateMessage = (messageId: string) => { + const messageElement = messageRefs.get(messageId) + if (messageElement) { + // 检查消息是否可见 + const display = window.getComputedStyle(messageElement).display + + if (display === 'none') { + // 如果消息隐藏,需要处理显示逻辑 + // 查找消息并设置为选中状态 + const state = store.getState() + const messages = selectMessagesForTopic(state, activeTopic.id) + const message = messages.find((m) => m.id === messageId) + if (message) { + // 这里需要实现设置消息为选中状态的逻辑 + // 可能需要调用其他函数或修改状态 + } + } + + // 滚动到消息位置 + messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + + const handleSelectMessage = (messageId: string, selected: boolean) => { + setSelectedMessageIds((prev) => { + const newSet = new Set(prev) + if (selected) { + newSet.add(messageId) + } else { + newSet.delete(messageId) + } + return Array.from(newSet) + }) + } + + const handleMultiSelectAction = (actionType: string, messageIds: string[]) => { + if (messageIds.length === 0) { + window.message.warning(t('chat.multiple.select.empty')) + return + } + + const state = store.getState() + const messages = selectMessagesForTopic(state, activeTopic.id) + const messageBlocks = messageBlocksSelectors.selectEntities(state) + + switch (actionType) { + case 'delete': + setMessagesToDelete(messageIds) + setConfirmDeleteVisible(true) + break + case 'save': { + const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id)) + if (assistantMessages.length > 0) { + const contentToSave = assistantMessages + .map((msg) => { + return msg.blocks + .map((blockId) => { + const block = messageBlocks[blockId] + return block && 'content' in block ? block.content : '' + }) + .filter(Boolean) + .join('\n') + .trim() + }) + .join('\n\n---\n\n') + const fileName = `chat_export_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.md` + window.api.file.save(fileName, contentToSave) + window.message.success({ content: t('message.save.success.title'), key: 'save-messages' }) + toggleMultiSelectMode(false) + } else { + window.message.warning(t('message.save.no.assistant')) + } + break + } + case 'copy': { + const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id)) + if (assistantMessages.length > 0) { + const contentToCopy = assistantMessages + .map((msg) => { + return msg.blocks + .map((blockId) => { + const block = messageBlocks[blockId] + return block && 'content' in block ? block.content : '' + }) + .filter(Boolean) + .join('\n') + .trim() + }) + .join('\n\n---\n\n') + navigator.clipboard.writeText(contentToCopy) + window.message.success({ content: t('message.copied'), key: 'copy-messages' }) + toggleMultiSelectMode(false) + } else { + window.message.warning(t('message.copy.no.assistant')) + } + break + } + default: + break + } + } + + const confirmDelete = async () => { + try { + await Promise.all(messagesToDelete.map((messageId) => deleteMessage(messageId))) + window.message.success(t('message.delete.success')) + setMessagesToDelete([]) + toggleMultiSelectMode(false) + } catch (error) { + console.error('Failed to delete messages:', error) + window.message.error(t('message.delete.failed')) + } finally { + setConfirmDeleteVisible(false) + } + } + + const cancelDelete = () => { + setConfirmDeleteVisible(false) + setMessagesToDelete([]) + } + + const value = { + isMultiSelectMode, + selectedMessageIds, + toggleMultiSelectMode, + handleMultiSelectAction, + handleSelectMessage, + activeTopic, + locateMessage, + messageRefs, + registerMessageElement + } + + return ( + + {children} + +

{t('message.delete.confirm.content', { count: messagesToDelete.length })}

+
+
+ ) +} diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index b1995a4c05..014aa26282 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -29,6 +29,7 @@ interface Props { index?: number total?: number hidePresetMessages?: boolean + hideMenuBar?: boolean style?: React.CSSProperties isGrouped?: boolean isStreaming?: boolean @@ -41,6 +42,7 @@ const MessageItem: FC = ({ // assistant, index, hidePresetMessages, + hideMenuBar = false, isGrouped, isStreaming = false, style @@ -80,8 +82,6 @@ const MessageItem: FC = ({ const handleEditResend = useCallback( async (blocks: MessageBlock[]) => { try { - // 编辑后重新发送消息 - console.log('after resend blocks', blocks) await resendUserMessageWithEdit(message, blocks, assistant) stopEditing() } catch (error) { @@ -97,7 +97,7 @@ const MessageItem: FC = ({ const isLastMessage = index === 0 const isAssistantMessage = message.role === 'assistant' - const showMenubar = !isStreaming && !message.status.includes('ing') && !isEditing + const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing const messageBorder = showMessageDivider ? undefined : 'none' const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) @@ -126,7 +126,9 @@ const MessageItem: FC = ({ if (message.type === 'clear') { return ( - EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}> + EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}> {t('chat.message.new.context')} diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index 6b82ec625d..84dcfd3fad 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -274,7 +274,7 @@ const FileBlocksContainer = styled.div` gap: 8px; padding: 0 15px; margin: 8px 0; - background: transplant; + background: transparent; border-radius: 4px; ` diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 3d8c1de1fd..8f36fba285 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -2,7 +2,6 @@ import Scrollbar from '@renderer/components/Scrollbar' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' -import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { MultiModelMessageStyle } from '@renderer/store/settings' import type { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' @@ -11,18 +10,23 @@ import { Popover } from 'antd' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import styled, { css } from 'styled-components' +import { useChatContext } from './ChatContext' import MessageItem from './Message' import MessageGroupMenuBar from './MessageGroupMenuBar' +import SelectableMessage from './MessageSelect' interface Props { messages: (Message & { index: number })[] topic: Topic hidePresetMessages?: boolean + registerMessageElement?: (id: string, element: HTMLElement | null) => void } -const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { +const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElement }: Props) => { const { editMessage } = useMessageOperations(topic) const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() + const { isMultiSelectMode, selectedMessageIds, handleSelectMessage } = useChatContext() + const selectedMessages = useMemo(() => new Set(selectedMessageIds), [selectedMessageIds]) const [multiModelMessageStyle, setMultiModelMessageStyle] = useState( messages[0].multiModelMessageStyle || multiModelMessageStyleSetting @@ -112,41 +116,20 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages, selectedIndex, isGrouped, messageLength]) - // 添加对LOCATE_MESSAGE事件的监听 useEffect(() => { - // 为每个消息注册一个定位事件监听器 - const eventHandlers: { [key: string]: () => void } = {} - messages.forEach((message) => { - const eventName = EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id - const handler = () => { - // 检查消息是否处于可见状态 - const element = document.getElementById(`message-${message.id}`) - if (element) { - const display = window.getComputedStyle(element).display - - if (display === 'none') { - // 如果消息隐藏,先切换标签 - setSelectedMessage(message) - } else { - // 直接滚动 - element.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - } + const element = document.getElementById(`message-${message.id}`) + if (element) { + registerMessageElement?.(message.id, element) } - - eventHandlers[eventName] = handler - EventEmitter.on(eventName, handler) }) - // 清理函数 return () => { - // 移除所有事件监听器 - Object.entries(eventHandlers).forEach(([eventName, handler]) => { - EventEmitter.off(eventName, handler) + messages.forEach((message) => { + registerMessageElement?.(message.id, null) }) } - }, [messages, setSelectedMessage]) + }, [messages, registerMessageElement]) const renderMessage = useCallback( (message: Message & { index: number }) => { @@ -162,7 +145,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { } } - const messageWrapper = ( + const messageContent = ( { ) + const wrappedMessage = ( + + {messageContent} + + ) + if (isGridGroupMessage) { return ( { trigger={gridPopoverTrigger} styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }} getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}> - {messageWrapper} + {wrappedMessage} ) } - return messageWrapper + return wrappedMessage }, [ isGrid, isGrouped, - isHorizontal, - multiModelMessageStyle, topic, hidePresetMessages, - gridPopoverTrigger, - selectedMessageId + multiModelMessageStyle, + isHorizontal, + selectedMessageId, + isMultiSelectMode, + selectedMessages, + registerMessageElement, + handleSelectMessage, + gridPopoverTrigger ] ) @@ -307,18 +303,6 @@ interface MessageWrapperProps { const MessageWrapper = styled(Scrollbar)` width: 100%; - &.horizontal { - display: inline-block; - } - &.grid { - display: inline-block; - } - &.fold { - display: none; - &.selected { - display: inline-block; - } - } ${({ $layout, $isGrouped }) => { if ($layout === 'horizontal' && $isGrouped) { diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index eac6e4c9d2..2678e6ef21 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -1,4 +1,4 @@ -import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' +import { CheckOutlined, EditOutlined, MenuOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import { TranslateLanguageOptions } from '@renderer/config/translate' @@ -33,6 +33,8 @@ import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import styled from 'styled-components' +import { useChatContext } from './ChatContext' + interface Props { message: Message assistant: Assistant @@ -50,6 +52,7 @@ const MessageMenubar: FC = (props) => { const { message, index, isGrouped, isLastMessage, isAssistantMessage, assistant, topic, model, messageContainerRef } = props const { t } = useTranslation() + const { toggleMultiSelectMode } = useChatContext() const [copied, setCopied] = useState(false) const [isTranslating, setIsTranslating] = useState(false) const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false) @@ -171,6 +174,14 @@ const MessageMenubar: FC = (props) => { icon: , onClick: onNewBranch }, + { + label: t('chat.multiple.select'), + key: 'multi-select', + icon: , + onClick: () => { + toggleMultiSelectMode(true) + } + }, { label: t('chat.topics.export.title'), key: 'export', @@ -265,7 +276,18 @@ const MessageMenubar: FC = (props) => { ].filter(Boolean) } ], - [message, messageContainerRef, isEditable, onEdit, mainTextContent, onNewBranch, t, topic.name, exportMenuOptions] + [ + t, + isEditable, + onEdit, + onNewBranch, + exportMenuOptions, + message, + mainTextContent, + toggleMultiSelectMode, + messageContainerRef, + topic.name + ] ) const onRegenerate = async (e: React.MouseEvent | undefined) => { diff --git a/src/renderer/src/pages/home/Messages/MessageSelect.tsx b/src/renderer/src/pages/home/Messages/MessageSelect.tsx new file mode 100644 index 0000000000..ab2fac4f49 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageSelect.tsx @@ -0,0 +1,63 @@ +import { Checkbox } from 'antd' +import { FC, ReactNode, useEffect, useRef } from 'react' +import styled from 'styled-components' + +import { useChatContext } from './ChatContext' + +interface SelectableMessageProps { + children: ReactNode + messageId: string + isClearMessage?: boolean +} + +const SelectableMessage: FC = ({ children, messageId, isClearMessage = false }) => { + const containerRef = useRef(null) + const { + registerMessageElement: contextRegister, + isMultiSelectMode, + selectedMessageIds, + handleSelectMessage + } = useChatContext() + + const isSelected = selectedMessageIds?.includes(messageId) + + useEffect(() => { + if (containerRef.current) { + contextRegister(messageId, containerRef.current) + return () => { + contextRegister(messageId, null) + } + } + return undefined + }, [messageId, contextRegister]) + + return ( + + {isMultiSelectMode && !isClearMessage && ( + + handleSelectMessage(messageId, e.target.checked)} /> + + )} + {children} + + ) +} + +const Container = styled.div` + display: flex; + width: 100%; + position: relative; +` + +const CheckboxWrapper = styled.div` + padding: 10px 0 10px 20px; + display: flex; + align-items: flex-start; +` + +const MessageContent = styled.div<{ isMultiSelectMode: boolean }>` + flex: 1; + ${(props) => props.isMultiSelectMode && 'margin-left: 8px;'} +` + +export default SelectableMessage diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 84b89f0e43..7fa4cc4462 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next' import InfiniteScroll from 'react-infinite-scroll-component' import styled from 'styled-components' +import { useChatContext } from './ChatContext' import ChatNavigation from './ChatNavigation' import MessageAnchorLine from './MessageAnchorLine' import MessageGroup from './MessageGroup' @@ -52,12 +53,18 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o ) const { t } = useTranslation() const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() + const { isMultiSelectMode, handleSelectMessage } = useChatContext() const { updateTopic, addTopic } = useAssistant(assistant.id) const dispatch = useAppDispatch() const [displayMessages, setDisplayMessages] = useState([]) const [hasMore, setHasMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) const [isProcessingContext, setIsProcessingContext] = useState(false) + + const [isDragging, setIsDragging] = useState(false) + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) + const [dragCurrent, setDragCurrent] = useState({ x: 0, y: 0 }) + const messageElements = useRef>(new Map()) const messages = useTopicMessages(topic.id) const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic) const messagesRef = useRef(messages) @@ -66,6 +73,113 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o messagesRef.current = messages }, [messages]) + useEffect(() => { + if (!isMultiSelectMode) return + + const updateDragPos = (e: MouseEvent) => { + const container = scrollContainerRef.current! + if (!container) return { x: 0, y: 0 } + const rect = container.getBoundingClientRect() + const x = e.clientX - rect.left + container.scrollLeft + const y = e.clientY - rect.top + container.scrollTop + return { x, y } + } + + const handleMouseDown = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest('.ant-checkbox-wrapper')) return + if ((e.target as HTMLElement).closest('.MessageFooter')) return + setIsDragging(true) + const pos = updateDragPos(e) + setDragStart(pos) + setDragCurrent(pos) + document.body.classList.add('no-select') + } + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging) return + setDragCurrent(updateDragPos(e)) + const container = scrollContainerRef.current! + if (container) { + const { top, bottom } = container.getBoundingClientRect() + const scrollSpeed = 15 + if (e.clientY < top + 50) { + container.scrollBy(0, -scrollSpeed) + } else if (e.clientY > bottom - 50) { + container.scrollBy(0, scrollSpeed) + } + } + } + + const handleMouseUp = () => { + if (!isDragging) return + + const left = Math.min(dragStart.x, dragCurrent.x) + const right = Math.max(dragStart.x, dragCurrent.x) + const top = Math.min(dragStart.y, dragCurrent.y) + const bottom = Math.max(dragStart.y, dragCurrent.y) + + const MIN_SELECTION_SIZE = 5 + const isValidSelection = + Math.abs(right - left) > MIN_SELECTION_SIZE && Math.abs(bottom - top) > MIN_SELECTION_SIZE + + if (isValidSelection) { + // 处理元素选择 + messageElements.current.forEach((element, messageId) => { + try { + const rect = element.getBoundingClientRect() + const container = scrollContainerRef.current! + + const elementTop = rect.top - container.getBoundingClientRect().top + container.scrollTop + const elementLeft = rect.left - container.getBoundingClientRect().left + container.scrollLeft + const elementBottom = elementTop + rect.height + const elementRight = elementLeft + rect.width + + const isIntersecting = !( + elementRight < left || + elementLeft > right || + elementBottom < top || + elementTop > bottom + ) + + if (isIntersecting) { + handleSelectMessage(messageId, true) + element.classList.add('selection-highlight') + setTimeout(() => element.classList.remove('selection-highlight'), 300) + } + } catch (error) { + console.error('Error calculating element intersection:', error) + } + }) + } + setIsDragging(false) + document.body.classList.remove('no-select') + } + + const container = scrollContainerRef.current! + if (container) { + container.addEventListener('mousedown', handleMouseDown) + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + } + + return () => { + if (container) { + container.removeEventListener('mousedown', handleMouseDown) + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + document.body.classList.remove('no-select') + } + } + }, [isMultiSelectMode, isDragging, dragStart, dragCurrent, handleSelectMessage, scrollContainerRef]) + + const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => { + if (element) { + messageElements.current.set(id, element) + } else { + messageElements.current.delete(id) + } + }, []) + useEffect(() => { const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount) setDisplayMessages(newDisplayMessages) @@ -256,16 +370,19 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o useEffect(() => { requestAnimationFrame(() => onComponentUpdate?.()) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [onComponentUpdate]) const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages]) return ( @@ -284,6 +401,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o messages={groupMessages} topic={topic} hidePresetMessages={assistant.settings?.hideMessages} + registerMessageElement={registerMessageElement} /> ))} {isLoadingMore && ( @@ -297,6 +415,16 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o {messageNavigation === 'anchor' && } {messageNavigation === 'buttons' && } + {isDragging && isMultiSelectMode && ( + + )} ) } @@ -364,4 +492,12 @@ const Container = styled(Scrollbar)` z-index: 1; ` +const SelectionBox = styled.div` + position: absolute; + border: 1px dashed var(--color-primary); + background-color: rgba(0, 114, 245, 0.1); + pointer-events: none; + z-index: 100; +` + export default Messages