diff --git a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx new file mode 100644 index 0000000000..487677deb7 --- /dev/null +++ b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx @@ -0,0 +1,146 @@ +import { CloseOutlined, CopyOutlined, DeleteOutlined, SaveOutlined } from '@ant-design/icons' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import type { Message } from '@renderer/types/newMessage' +import { Button, Tooltip } from 'antd' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface MultiSelectActionPopupProps { + visible: boolean + onClose: () => void + onAction?: (action: string, messageIds: string[]) => void + topic: any +} + +interface MessageTypeInfo { + hasUserMessages: boolean + hasAssistantMessages: boolean + messageIds: string[] +} + +const MultiSelectActionPopup: FC = ({ visible, onClose, onAction }) => { + const { t } = useTranslation() + const [selectedMessages, setSelectedMessages] = useState([]) + const [selectedMessageIds, setSelectedMessageIds] = useState([]) + const [, setMessageTypeInfo] = useState({ + hasUserMessages: false, + hasAssistantMessages: false, + messageIds: [] + }) + + useEffect(() => { + const handleSelectedMessagesChanged = (messageIds: string[]) => { + setSelectedMessageIds(messageIds) + EventEmitter.emit('REQUEST_SELECTED_MESSAGE_DETAILS', messageIds) + } + + const handleSelectedMessageDetails = (messages: Message[]) => { + setSelectedMessages(messages) + + const hasUserMessages = messages.some((msg) => msg.role === 'user') + const hasAssistantMessages = messages.some((msg) => msg.role === 'assistant') + + setMessageTypeInfo({ + hasUserMessages, + hasAssistantMessages, + messageIds: selectedMessageIds + }) + } + + EventEmitter.on(EVENT_NAMES.SELECTED_MESSAGES_CHANGED, handleSelectedMessagesChanged) + EventEmitter.on('SELECTED_MESSAGE_DETAILS', handleSelectedMessageDetails) + + return () => { + EventEmitter.off(EVENT_NAMES.SELECTED_MESSAGES_CHANGED, handleSelectedMessagesChanged) + EventEmitter.off('SELECTED_MESSAGE_DETAILS', handleSelectedMessageDetails) + } + }, [selectedMessageIds]) + + const handleAction = (action: string) => { + if (onAction) { + onAction(action, selectedMessageIds) + } + } + + const handleClose = () => { + EventEmitter.emit(EVENT_NAMES.MESSAGE_MULTI_SELECT, false) + onClose() + } + + if (!visible) 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: sticky; + 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 94ee6c07a9..96dcf37c49 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -186,6 +186,8 @@ "message.quote": "Quote", "message.regenerate.model": "Switch Model", "message.useful": "Helpful", + "message.select": "Multiple Select", + "multiple.select.empty": "No Messages Selected", "navigation": { "first": "Already at the first message", "history": "Chat History", @@ -388,6 +390,7 @@ "save": "Save", "search": "Search", "select": "Select", + "selectedMessages": "Selected {{count}} messages", "topics": "Topics", "warning": "Warning", "you": "You", @@ -585,6 +588,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", "error.chunk_overlap_too_large": "Chunk overlap cannot be greater than chunk size", "error.dimension_too_large": "Content size is too large", "error.enter.api.host": "Please enter your API host first", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index fef52b7d58..1e992a2f25 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -186,6 +186,8 @@ "message.quote": "引用", "message.regenerate.model": "モデルを切り替え", "message.useful": "役立つ", + "message.select": "選択", + "multiple.select.empty": "メッセージが選択されていません", "navigation": { "first": "最初のメッセージです", "history": "チャット履歴", @@ -388,6 +390,7 @@ "save": "保存", "search": "検索", "select": "選択", + "selectedMessages": "{{count}}件のメッセージを選択しました", "topics": "トピック", "warning": "警告", "you": "あなた", @@ -585,6 +588,10 @@ "copied": "コピーしました!", "copy.failed": "コピーに失敗しました", "copy.success": "コピーしました!", + "delete.confirm.title": "削除確認", + "delete.confirm.content": "選択した{{count}}件のメッセージを削除しますか?", + "delete.failed": "削除に失敗しました", + "delete.success": "削除が成功しました", "error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません", "error.dimension_too_large": "内容のサイズが大きすぎます", "error.enter.api.host": "APIホストを入力してください", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index bda4157737..32e1b3e48d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -186,6 +186,8 @@ "message.quote": "Цитата", "message.regenerate.model": "Переключить модель", "message.useful": "Полезно", + "multiple.select": "Множественный выбор", + "multiple.select.empty": "Ничего не выбрано", "navigation": { "first": "Уже первое сообщение", "history": "История чата", @@ -388,6 +390,7 @@ "save": "Сохранить", "search": "Поиск", "select": "Выбрать", + "selectedMessages": "Выбрано {{count}} сообщений", "topics": "Топики", "warning": "Предупреждение", "you": "Вы", @@ -585,6 +588,10 @@ "copied": "Скопировано!", "copy.failed": "Не удалось скопировать", "copy.success": "Скопировано!", + "delete.confirm.title": "Подтверждение удаления", + "delete.confirm.content": "Вы уверены, что хотите удалить выбранные {{count}} сообщения?", + "delete.failed": "Ошибка удаления", + "delete.success": "Удаление успешно", "error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента.", "error.dimension_too_large": "Размер содержимого слишком велик", "error.enter.api.host": "Пожалуйста, введите ваш API хост", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6808ff9b8a..6195173445 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -200,6 +200,8 @@ "message.quote": "引用", "message.regenerate.model": "切换模型", "message.useful": "有用", + "multiple.select": "多选", + "multiple.select.empty": "未选中任何消息", "navigation": { "first": "已经是第一条消息", "history": "聊天历史", @@ -388,6 +390,7 @@ "save": "保存", "search": "搜索", "select": "选择", + "selectedMessages": "选中{{count}}条消息", "topics": "话题", "warning": "警告", "you": "用户", @@ -585,6 +588,10 @@ "copied": "已复制", "copy.failed": "复制失败", "copy.success": "复制成功", + "delete.confirm.title": "删除确认", + "delete.confirm.content": "确认删除选中的{{count}}条消息吗?", + "delete.failed": "删除失败", + "delete.success": "删除成功", "error.chunk_overlap_too_large": "分段重叠不能大于分段大小", "error.dimension_too_large": "内容尺寸过大", "error.enter.api.host": "请输入您的 API 地址", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 02a18f08f5..eba9038d70 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -186,6 +186,8 @@ "message.quote": "引用", "message.regenerate.model": "切換模型", "message.useful": "有用", + "multiple.select": "多選", + "multiple.select.empty": "未選中任何訊息", "navigation": { "first": "已經是第一條訊息", "history": "聊天歷史", @@ -388,6 +390,7 @@ "save": "儲存", "search": "搜尋", "select": "選擇", + "selectedMessages": "选中{{count}}条消息", "topics": "話題", "warning": "警告", "you": "您", @@ -585,6 +588,10 @@ "copied": "已複製!", "copy.failed": "複製失敗", "copy.success": "已複製!", + "delete.confirm.title": "刪除確認", + "delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?", + "delete.failed": "刪除失敗", + "delete.success": "刪除成功", "error.chunk_overlap_too_large": "分段重疊不能大於分段大小", "error.dimension_too_large": "內容尺寸過大", "error.enter.api.host": "請先輸入您的 API 主機地址", diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 07d89dedcb..9b3f0ff694 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -1,10 +1,17 @@ +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' import { useShowTopics } from '@renderer/hooks/useStore' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { RootState } from '@renderer/store' +import { messageBlocksSelectors } from '@renderer/store/messageBlock' +import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage' import { Assistant, Topic } from '@renderer/types' -import { Flex } from 'antd' -import { FC } from 'react' +import { Flex, Modal } from 'antd' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' import styled from 'styled-components' import Inputbar from './Inputbar/Inputbar' @@ -22,6 +29,116 @@ const Chat: FC = (props) => { const { assistant } = useAssistant(props.assistant.id) const { topicPosition, messageStyle } = useSettings() const { showTopics } = useShowTopics() + const { t } = useTranslation() + const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) + const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false) + const [messagesToDelete, setMessagesToDelete] = useState([]) + + const dispatch = useDispatch() + // 从 Redux 中获取当前主题的消息 + const messages = useSelector((state: RootState) => selectMessagesForTopic(state, props.activeTopic.id)) + // 获取所有消息块 + const messageBlocks = useSelector(messageBlocksSelectors.selectEntities) + + useEffect(() => { + const handleToggleMultiSelect = (value: boolean) => { + setIsMultiSelectMode(value) + } + + EventEmitter.on(EVENT_NAMES.MESSAGE_MULTI_SELECT, handleToggleMultiSelect) + + return () => { + EventEmitter.off(EVENT_NAMES.MESSAGE_MULTI_SELECT, handleToggleMultiSelect) + } + }, []) + + const handleMultiSelectAction = (actionType: string, messageIds: string[]) => { + if (messageIds.length === 0) { + window.message.warning(t('chat.multiple.select.empty')) + return + } + 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' }) + EventEmitter.emit(EVENT_NAMES.MESSAGE_MULTI_SELECT, 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' }) + EventEmitter.emit(EVENT_NAMES.MESSAGE_MULTI_SELECT, false) + } else { + window.message.warning(t('message.copy.no.assistant')) + } + break + } + default: + break + } + } + + const confirmDelete = async () => { + try { + dispatch( + newMessagesActions.removeMessages({ + topicId: props.activeTopic.id, + messageIds: messagesToDelete + }) + ) + window.message.success(t('message.delete.success')) + setMessagesToDelete([]) + setIsMultiSelectMode(false) + EventEmitter.emit(EVENT_NAMES.MESSAGE_MULTI_SELECT, false) + } catch (error) { + console.error('Failed to delete messages:', error) + window.message.error(t('message.delete.failed')) + } finally { + setConfirmDeleteVisible(false) + setIsMultiSelectMode(false) + } + } + + const cancelDelete = () => { + setConfirmDeleteVisible(false) + setMessagesToDelete([]) + } return ( @@ -33,7 +150,16 @@ const Chat: FC = (props) => { setActiveTopic={props.setActiveTopic} /> - + {isMultiSelectMode ? ( + setIsMultiSelectMode(false)} + onAction={handleMultiSelectAction} + topic={props.activeTopic} + /> + ) : ( + + )} {topicPosition === 'right' && showTopics && ( @@ -45,6 +171,17 @@ const Chat: FC = (props) => { position="right" /> )} + +

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

+
) } @@ -59,7 +196,6 @@ const Container = styled.div` const Main = styled(Flex)` height: calc(100vh - var(--navbar-height)); - // 设置为containing block,方便子元素fixed定位 transform: translateZ(0); ` diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 405ced9b6c..56bb8eb548 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -12,14 +12,25 @@ import styled, { css } from 'styled-components' import MessageItem from './Message' import MessageGroupMenuBar from './MessageGroupMenuBar' +import SelectableMessage from './MessageSelect' interface Props { messages: (Message & { index: number })[] topic: Topic hidePresetMessages?: boolean + isMultiSelectMode?: boolean // 添加是否处于多选模式 + selectedMessages?: Set // 已选择的消息ID集合 + onSelectMessage?: (messageId: string, selected: boolean) => void // 消息选择回调 } -const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { +const MessageGroup = ({ + messages, + topic, + hidePresetMessages, + isMultiSelectMode = false, + selectedMessages = new Set(), + onSelectMessage +}: Props) => { const { editMessage } = useMessageOperations(topic) const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() @@ -160,7 +171,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { } } - const messageWrapper = ( + const messageContent = ( { ) + const wrappedMessage = ( + onSelectMessage?.(message.id, selected)}> + {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, @@ -208,7 +230,10 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { topic, hidePresetMessages, gridPopoverTrigger, - getSelectedMessageId + getSelectedMessageId, + isMultiSelectMode, // 添加依赖项 + selectedMessages, // 添加依赖项 + onSelectMessage // 添加依赖项 ] ) diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 7a3ca793ff..ac2a60843e 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 TextEditPopup from '@renderer/components/Popups/TextEditPopup' @@ -259,6 +259,14 @@ const MessageMenubar: FC = (props) => { icon: , onClick: onNewBranch }, + { + label: t('chat.multiple.select'), + key: 'multi-select', + icon: , + onClick: () => { + EventEmitter.emit(EVENT_NAMES.MESSAGE_MULTI_SELECT, true) + } + }, { label: t('chat.topics.export.title'), key: 'export', 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..f982cf67ff --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageSelect.tsx @@ -0,0 +1,43 @@ +import { Checkbox } from 'antd' +import { FC, ReactNode } from 'react' +import styled from 'styled-components' + +interface SelectableMessageProps { + children: ReactNode + isMultiSelectMode: boolean + isSelected: boolean + onSelect: (selected: boolean) => void + messageId: string +} + +const SelectableMessage: FC = ({ children, isMultiSelectMode, isSelected, onSelect }) => { + return ( + + {isMultiSelectMode && ( + + onSelect(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 3d95dc609b..c1b007bf18 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -53,6 +53,8 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) const [hasMore, setHasMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) const [isProcessingContext, setIsProcessingContext] = useState(false) + const [selectedMessages, setSelectedMessages] = useState>(new Set()) + const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) const messages = useTopicMessages(topic.id) const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic) const messagesRef = useRef(messages) @@ -61,6 +63,47 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) messagesRef.current = messages }, [messages]) + useEffect(() => { + const handleToggleMultiSelect = (value: boolean) => { + setIsMultiSelectMode(value) + if (!value) { + setSelectedMessages(new Set()) + } + } + + EventEmitter.on(EVENT_NAMES.MESSAGE_MULTI_SELECT, handleToggleMultiSelect) + + return () => { + EventEmitter.off(EVENT_NAMES.MESSAGE_MULTI_SELECT, handleToggleMultiSelect) + } + }, []) + + useEffect(() => { + const handleRequestSelectedMessageDetails = (messageIds: string[]) => { + const selectedMessages = messages.filter((msg) => messageIds.includes(msg.id)) + EventEmitter.emit('SELECTED_MESSAGE_DETAILS', selectedMessages) + } + + EventEmitter.on('REQUEST_SELECTED_MESSAGE_DETAILS', handleRequestSelectedMessageDetails) + + return () => { + EventEmitter.off('REQUEST_SELECTED_MESSAGE_DETAILS', handleRequestSelectedMessageDetails) + } + }, [messages]) + + const handleSelectMessage = (messageId: string, selected: boolean) => { + setSelectedMessages((prev) => { + const newSet = new Set(prev) + if (selected) { + newSet.add(messageId) + } else { + newSet.delete(messageId) + } + EventEmitter.emit(EVENT_NAMES.SELECTED_MESSAGES_CHANGED, Array.from(newSet)) + return newSet + }) + } + useEffect(() => { const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount) setDisplayMessages(newDisplayMessages) @@ -273,6 +316,9 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) messages={groupMessages} topic={topic} hidePresetMessages={assistant.settings?.hideMessages} + isMultiSelectMode={isMultiSelectMode} + selectedMessages={selectedMessages} + onSelectMessage={handleSelectMessage} /> ))} {isLoadingMore && ( diff --git a/src/renderer/src/services/EventService.ts b/src/renderer/src/services/EventService.ts index 33b99b03bc..69ddefce3e 100644 --- a/src/renderer/src/services/EventService.ts +++ b/src/renderer/src/services/EventService.ts @@ -27,5 +27,7 @@ export const EVENT_NAMES = { RESEND_MESSAGE: 'RESEND_MESSAGE', SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR', QUOTE_TEXT: 'QUOTE_TEXT', - EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK' + EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK', + SELECTED_MESSAGES_CHANGED: 'SELECTED_MESSAGES_CHANGED', + MESSAGE_MULTI_SELECT: 'MESSAGE_MULTI_SELECT' }