mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 20:12:38 +08:00
feat: add message multiple select
This commit is contained in:
parent
51071d65fb
commit
a5009810cd
146
src/renderer/src/components/Popups/MultiSelectionPopup.tsx
Normal file
146
src/renderer/src/components/Popups/MultiSelectionPopup.tsx
Normal 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
|
||||
@ -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",
|
||||
|
||||
@ -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ホストを入力してください",
|
||||
|
||||
@ -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 хост",
|
||||
|
||||
@ -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 地址",
|
||||
|
||||
@ -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 主機地址",
|
||||
|
||||
@ -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);
|
||||
`
|
||||
|
||||
|
||||
@ -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 // 添加依赖项
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
43
src/renderer/src/pages/home/Messages/MessageSelect.tsx
Normal file
43
src/renderer/src/pages/home/Messages/MessageSelect.tsx
Normal 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
|
||||
@ -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 && (
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user