feat: add message multiple select

This commit is contained in:
Pleasurecruise 2025-05-17 03:21:28 +08:00
parent 51071d65fb
commit a5009810cd
No known key found for this signature in database
GPG Key ID: E6385136096279B6
12 changed files with 452 additions and 11 deletions

View File

@ -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<MultiSelectActionPopupProps> = ({ visible, onClose, onAction }) => {
const { t } = useTranslation()
const [selectedMessages, setSelectedMessages] = useState<Message[]>([])
const [selectedMessageIds, setSelectedMessageIds] = useState<string[]>([])
const [, setMessageTypeInfo] = useState<MessageTypeInfo>({
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 (
<Container>
<ActionBar>
<SelectionCount>{t('common.selectedMessages', { count: selectedMessageIds.length })}</SelectionCount>
<ActionButtons>
<Tooltip title={t('common.save')}>
<ActionButton icon={<SaveOutlined />} disabled={isActionDisabled} onClick={() => handleAction('save')} />
</Tooltip>
<Tooltip title={t('common.copy')}>
<ActionButton icon={<CopyOutlined />} disabled={isActionDisabled} onClick={() => handleAction('copy')} />
</Tooltip>
<Tooltip title={t('common.delete')}>
<ActionButton danger icon={<DeleteOutlined />} onClick={() => handleAction('delete')} />
</Tooltip>
</ActionButtons>
<Tooltip title={t('popup.close')}>
<ActionButton icon={<CloseOutlined />} onClick={handleClose} />
</Tooltip>
</ActionBar>
</Container>
)
}
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

View File

@ -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",

View File

@ -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ホストを入力してください",

View File

@ -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 хост",

View File

@ -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 地址",

View File

@ -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 主機地址",

View File

@ -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> = (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<string[]>([])
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 (
<Container id="chat" className={messageStyle}>
@ -33,7 +150,16 @@ const Chat: FC<Props> = (props) => {
setActiveTopic={props.setActiveTopic}
/>
<QuickPanelProvider>
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
{isMultiSelectMode ? (
<MultiSelectActionPopup
visible={isMultiSelectMode}
onClose={() => setIsMultiSelectMode(false)}
onAction={handleMultiSelectAction}
topic={props.activeTopic}
/>
) : (
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
)}
</QuickPanelProvider>
</Main>
{topicPosition === 'right' && showTopics && (
@ -45,6 +171,17 @@ const Chat: FC<Props> = (props) => {
position="right"
/>
)}
<Modal
title={t('message.delete.confirm.title')}
open={confirmDeleteVisible}
onOk={confirmDelete}
onCancel={cancelDelete}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
centered={true}>
<p>{t('message.delete.confirm.content', { count: messagesToDelete.length })}</p>
</Modal>
</Container>
)
}
@ -59,7 +196,6 @@ const Container = styled.div`
const Main = styled(Flex)`
height: calc(100vh - var(--navbar-height));
// 设置为containing block方便子元素fixed定位
transform: translateZ(0);
`

View File

@ -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<string> // 已选择的消息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 = (
<MessageWrapper
id={`message-${message.id}`}
$layout={multiModelMessageStyle}
@ -176,6 +187,17 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
</MessageWrapper>
)
const wrappedMessage = (
<SelectableMessage
key={`selectable-${message.id}`}
messageId={message.id}
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedMessages.has(message.id)}
onSelect={(selected) => onSelectMessage?.(message.id, selected)}>
{messageContent}
</SelectableMessage>
)
if (isGridGroupMessage) {
return (
<Popover
@ -192,12 +214,12 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
trigger={gridPopoverTrigger}
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}>
{messageWrapper}
{wrappedMessage}
</Popover>
)
}
return messageWrapper
return wrappedMessage
},
[
isGrid,
@ -208,7 +230,10 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
topic,
hidePresetMessages,
gridPopoverTrigger,
getSelectedMessageId
getSelectedMessageId,
isMultiSelectMode, // 添加依赖项
selectedMessages, // 添加依赖项
onSelectMessage // 添加依赖项
]
)

View File

@ -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> = (props) => {
icon: <Split size={16} />,
onClick: onNewBranch
},
{
label: t('chat.multiple.select'),
key: 'multi-select',
icon: <MenuOutlined />,
onClick: () => {
EventEmitter.emit(EVENT_NAMES.MESSAGE_MULTI_SELECT, true)
}
},
{
label: t('chat.topics.export.title'),
key: 'export',

View File

@ -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<SelectableMessageProps> = ({ children, isMultiSelectMode, isSelected, onSelect }) => {
return (
<Container>
{isMultiSelectMode && (
<CheckboxWrapper>
<Checkbox checked={isSelected} onChange={(e) => onSelect(e.target.checked)} />
</CheckboxWrapper>
)}
<MessageContent isMultiSelectMode={isMultiSelectMode}>{children}</MessageContent>
</Container>
)
}
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

View File

@ -53,6 +53,8 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [isProcessingContext, setIsProcessingContext] = useState(false)
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
const messages = useTopicMessages(topic.id)
const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic)
const messagesRef = useRef<Message[]>(messages)
@ -61,6 +63,47 @@ const Messages: React.FC<MessagesProps> = ({ 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<MessagesProps> = ({ assistant, topic, setActiveTopic })
messages={groupMessages}
topic={topic}
hidePresetMessages={assistant.settings?.hideMessages}
isMultiSelectMode={isMultiSelectMode}
selectedMessages={selectedMessages}
onSelectMessage={handleSelectMessage}
/>
))}
{isLoadingMore && (

View File

@ -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'
}