mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 23:10:20 +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.quote": "Quote",
|
||||||
"message.regenerate.model": "Switch Model",
|
"message.regenerate.model": "Switch Model",
|
||||||
"message.useful": "Helpful",
|
"message.useful": "Helpful",
|
||||||
|
"message.select": "Multiple Select",
|
||||||
|
"multiple.select.empty": "No Messages Selected",
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"first": "Already at the first message",
|
"first": "Already at the first message",
|
||||||
"history": "Chat History",
|
"history": "Chat History",
|
||||||
@ -388,6 +390,7 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
|
"selectedMessages": "Selected {{count}} messages",
|
||||||
"topics": "Topics",
|
"topics": "Topics",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
@ -585,6 +588,10 @@
|
|||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"copy.failed": "Copy failed",
|
"copy.failed": "Copy failed",
|
||||||
"copy.success": "Copied!",
|
"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.chunk_overlap_too_large": "Chunk overlap cannot be greater than chunk size",
|
||||||
"error.dimension_too_large": "Content size is too large",
|
"error.dimension_too_large": "Content size is too large",
|
||||||
"error.enter.api.host": "Please enter your API host first",
|
"error.enter.api.host": "Please enter your API host first",
|
||||||
|
|||||||
@ -186,6 +186,8 @@
|
|||||||
"message.quote": "引用",
|
"message.quote": "引用",
|
||||||
"message.regenerate.model": "モデルを切り替え",
|
"message.regenerate.model": "モデルを切り替え",
|
||||||
"message.useful": "役立つ",
|
"message.useful": "役立つ",
|
||||||
|
"message.select": "選択",
|
||||||
|
"multiple.select.empty": "メッセージが選択されていません",
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"first": "最初のメッセージです",
|
"first": "最初のメッセージです",
|
||||||
"history": "チャット履歴",
|
"history": "チャット履歴",
|
||||||
@ -388,6 +390,7 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"search": "検索",
|
"search": "検索",
|
||||||
"select": "選択",
|
"select": "選択",
|
||||||
|
"selectedMessages": "{{count}}件のメッセージを選択しました",
|
||||||
"topics": "トピック",
|
"topics": "トピック",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "あなた",
|
"you": "あなた",
|
||||||
@ -585,6 +588,10 @@
|
|||||||
"copied": "コピーしました!",
|
"copied": "コピーしました!",
|
||||||
"copy.failed": "コピーに失敗しました",
|
"copy.failed": "コピーに失敗しました",
|
||||||
"copy.success": "コピーしました!",
|
"copy.success": "コピーしました!",
|
||||||
|
"delete.confirm.title": "削除確認",
|
||||||
|
"delete.confirm.content": "選択した{{count}}件のメッセージを削除しますか?",
|
||||||
|
"delete.failed": "削除に失敗しました",
|
||||||
|
"delete.success": "削除が成功しました",
|
||||||
"error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません",
|
"error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません",
|
||||||
"error.dimension_too_large": "内容のサイズが大きすぎます",
|
"error.dimension_too_large": "内容のサイズが大きすぎます",
|
||||||
"error.enter.api.host": "APIホストを入力してください",
|
"error.enter.api.host": "APIホストを入力してください",
|
||||||
|
|||||||
@ -186,6 +186,8 @@
|
|||||||
"message.quote": "Цитата",
|
"message.quote": "Цитата",
|
||||||
"message.regenerate.model": "Переключить модель",
|
"message.regenerate.model": "Переключить модель",
|
||||||
"message.useful": "Полезно",
|
"message.useful": "Полезно",
|
||||||
|
"multiple.select": "Множественный выбор",
|
||||||
|
"multiple.select.empty": "Ничего не выбрано",
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"first": "Уже первое сообщение",
|
"first": "Уже первое сообщение",
|
||||||
"history": "История чата",
|
"history": "История чата",
|
||||||
@ -388,6 +390,7 @@
|
|||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
|
"selectedMessages": "Выбрано {{count}} сообщений",
|
||||||
"topics": "Топики",
|
"topics": "Топики",
|
||||||
"warning": "Предупреждение",
|
"warning": "Предупреждение",
|
||||||
"you": "Вы",
|
"you": "Вы",
|
||||||
@ -585,6 +588,10 @@
|
|||||||
"copied": "Скопировано!",
|
"copied": "Скопировано!",
|
||||||
"copy.failed": "Не удалось скопировать",
|
"copy.failed": "Не удалось скопировать",
|
||||||
"copy.success": "Скопировано!",
|
"copy.success": "Скопировано!",
|
||||||
|
"delete.confirm.title": "Подтверждение удаления",
|
||||||
|
"delete.confirm.content": "Вы уверены, что хотите удалить выбранные {{count}} сообщения?",
|
||||||
|
"delete.failed": "Ошибка удаления",
|
||||||
|
"delete.success": "Удаление успешно",
|
||||||
"error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента.",
|
"error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента.",
|
||||||
"error.dimension_too_large": "Размер содержимого слишком велик",
|
"error.dimension_too_large": "Размер содержимого слишком велик",
|
||||||
"error.enter.api.host": "Пожалуйста, введите ваш API хост",
|
"error.enter.api.host": "Пожалуйста, введите ваш API хост",
|
||||||
|
|||||||
@ -200,6 +200,8 @@
|
|||||||
"message.quote": "引用",
|
"message.quote": "引用",
|
||||||
"message.regenerate.model": "切换模型",
|
"message.regenerate.model": "切换模型",
|
||||||
"message.useful": "有用",
|
"message.useful": "有用",
|
||||||
|
"multiple.select": "多选",
|
||||||
|
"multiple.select.empty": "未选中任何消息",
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"first": "已经是第一条消息",
|
"first": "已经是第一条消息",
|
||||||
"history": "聊天历史",
|
"history": "聊天历史",
|
||||||
@ -388,6 +390,7 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"select": "选择",
|
"select": "选择",
|
||||||
|
"selectedMessages": "选中{{count}}条消息",
|
||||||
"topics": "话题",
|
"topics": "话题",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "用户",
|
"you": "用户",
|
||||||
@ -585,6 +588,10 @@
|
|||||||
"copied": "已复制",
|
"copied": "已复制",
|
||||||
"copy.failed": "复制失败",
|
"copy.failed": "复制失败",
|
||||||
"copy.success": "复制成功",
|
"copy.success": "复制成功",
|
||||||
|
"delete.confirm.title": "删除确认",
|
||||||
|
"delete.confirm.content": "确认删除选中的{{count}}条消息吗?",
|
||||||
|
"delete.failed": "删除失败",
|
||||||
|
"delete.success": "删除成功",
|
||||||
"error.chunk_overlap_too_large": "分段重叠不能大于分段大小",
|
"error.chunk_overlap_too_large": "分段重叠不能大于分段大小",
|
||||||
"error.dimension_too_large": "内容尺寸过大",
|
"error.dimension_too_large": "内容尺寸过大",
|
||||||
"error.enter.api.host": "请输入您的 API 地址",
|
"error.enter.api.host": "请输入您的 API 地址",
|
||||||
|
|||||||
@ -186,6 +186,8 @@
|
|||||||
"message.quote": "引用",
|
"message.quote": "引用",
|
||||||
"message.regenerate.model": "切換模型",
|
"message.regenerate.model": "切換模型",
|
||||||
"message.useful": "有用",
|
"message.useful": "有用",
|
||||||
|
"multiple.select": "多選",
|
||||||
|
"multiple.select.empty": "未選中任何訊息",
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"first": "已經是第一條訊息",
|
"first": "已經是第一條訊息",
|
||||||
"history": "聊天歷史",
|
"history": "聊天歷史",
|
||||||
@ -388,6 +390,7 @@
|
|||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
"search": "搜尋",
|
"search": "搜尋",
|
||||||
"select": "選擇",
|
"select": "選擇",
|
||||||
|
"selectedMessages": "选中{{count}}条消息",
|
||||||
"topics": "話題",
|
"topics": "話題",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "您",
|
"you": "您",
|
||||||
@ -585,6 +588,10 @@
|
|||||||
"copied": "已複製!",
|
"copied": "已複製!",
|
||||||
"copy.failed": "複製失敗",
|
"copy.failed": "複製失敗",
|
||||||
"copy.success": "已複製!",
|
"copy.success": "已複製!",
|
||||||
|
"delete.confirm.title": "刪除確認",
|
||||||
|
"delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?",
|
||||||
|
"delete.failed": "刪除失敗",
|
||||||
|
"delete.success": "刪除成功",
|
||||||
"error.chunk_overlap_too_large": "分段重疊不能大於分段大小",
|
"error.chunk_overlap_too_large": "分段重疊不能大於分段大小",
|
||||||
"error.dimension_too_large": "內容尺寸過大",
|
"error.dimension_too_large": "內容尺寸過大",
|
||||||
"error.enter.api.host": "請先輸入您的 API 主機地址",
|
"error.enter.api.host": "請先輸入您的 API 主機地址",
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
|
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
|
||||||
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
|
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
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 { Assistant, Topic } from '@renderer/types'
|
||||||
import { Flex } from 'antd'
|
import { Flex, Modal } from 'antd'
|
||||||
import { FC } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import Inputbar from './Inputbar/Inputbar'
|
import Inputbar from './Inputbar/Inputbar'
|
||||||
@ -22,6 +29,116 @@ const Chat: FC<Props> = (props) => {
|
|||||||
const { assistant } = useAssistant(props.assistant.id)
|
const { assistant } = useAssistant(props.assistant.id)
|
||||||
const { topicPosition, messageStyle } = useSettings()
|
const { topicPosition, messageStyle } = useSettings()
|
||||||
const { showTopics } = useShowTopics()
|
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 (
|
return (
|
||||||
<Container id="chat" className={messageStyle}>
|
<Container id="chat" className={messageStyle}>
|
||||||
@ -33,7 +150,16 @@ const Chat: FC<Props> = (props) => {
|
|||||||
setActiveTopic={props.setActiveTopic}
|
setActiveTopic={props.setActiveTopic}
|
||||||
/>
|
/>
|
||||||
<QuickPanelProvider>
|
<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>
|
</QuickPanelProvider>
|
||||||
</Main>
|
</Main>
|
||||||
{topicPosition === 'right' && showTopics && (
|
{topicPosition === 'right' && showTopics && (
|
||||||
@ -45,6 +171,17 @@ const Chat: FC<Props> = (props) => {
|
|||||||
position="right"
|
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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -59,7 +196,6 @@ const Container = styled.div`
|
|||||||
|
|
||||||
const Main = styled(Flex)`
|
const Main = styled(Flex)`
|
||||||
height: calc(100vh - var(--navbar-height));
|
height: calc(100vh - var(--navbar-height));
|
||||||
// 设置为containing block,方便子元素fixed定位
|
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@ -12,14 +12,25 @@ import styled, { css } from 'styled-components'
|
|||||||
|
|
||||||
import MessageItem from './Message'
|
import MessageItem from './Message'
|
||||||
import MessageGroupMenuBar from './MessageGroupMenuBar'
|
import MessageGroupMenuBar from './MessageGroupMenuBar'
|
||||||
|
import SelectableMessage from './MessageSelect'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: (Message & { index: number })[]
|
messages: (Message & { index: number })[]
|
||||||
topic: Topic
|
topic: Topic
|
||||||
hidePresetMessages?: boolean
|
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 { editMessage } = useMessageOperations(topic)
|
||||||
const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings()
|
const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings()
|
||||||
|
|
||||||
@ -160,7 +171,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageWrapper = (
|
const messageContent = (
|
||||||
<MessageWrapper
|
<MessageWrapper
|
||||||
id={`message-${message.id}`}
|
id={`message-${message.id}`}
|
||||||
$layout={multiModelMessageStyle}
|
$layout={multiModelMessageStyle}
|
||||||
@ -176,6 +187,17 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
|||||||
</MessageWrapper>
|
</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) {
|
if (isGridGroupMessage) {
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@ -192,12 +214,12 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
|||||||
trigger={gridPopoverTrigger}
|
trigger={gridPopoverTrigger}
|
||||||
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
|
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
|
||||||
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}>
|
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}>
|
||||||
{messageWrapper}
|
{wrappedMessage}
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return messageWrapper
|
return wrappedMessage
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
isGrid,
|
isGrid,
|
||||||
@ -208,7 +230,10 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
|||||||
topic,
|
topic,
|
||||||
hidePresetMessages,
|
hidePresetMessages,
|
||||||
gridPopoverTrigger,
|
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 ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||||
@ -259,6 +259,14 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
icon: <Split size={16} />,
|
icon: <Split size={16} />,
|
||||||
onClick: onNewBranch
|
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'),
|
label: t('chat.topics.export.title'),
|
||||||
key: 'export',
|
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 [hasMore, setHasMore] = useState(false)
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||||
const [isProcessingContext, setIsProcessingContext] = 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 messages = useTopicMessages(topic.id)
|
||||||
const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic)
|
const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic)
|
||||||
const messagesRef = useRef<Message[]>(messages)
|
const messagesRef = useRef<Message[]>(messages)
|
||||||
@ -61,6 +63,47 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
|||||||
messagesRef.current = messages
|
messagesRef.current = messages
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount)
|
const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount)
|
||||||
setDisplayMessages(newDisplayMessages)
|
setDisplayMessages(newDisplayMessages)
|
||||||
@ -273,6 +316,9 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
|||||||
messages={groupMessages}
|
messages={groupMessages}
|
||||||
topic={topic}
|
topic={topic}
|
||||||
hidePresetMessages={assistant.settings?.hideMessages}
|
hidePresetMessages={assistant.settings?.hideMessages}
|
||||||
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
|
selectedMessages={selectedMessages}
|
||||||
|
onSelectMessage={handleSelectMessage}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isLoadingMore && (
|
{isLoadingMore && (
|
||||||
|
|||||||
@ -27,5 +27,7 @@ export const EVENT_NAMES = {
|
|||||||
RESEND_MESSAGE: 'RESEND_MESSAGE',
|
RESEND_MESSAGE: 'RESEND_MESSAGE',
|
||||||
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',
|
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',
|
||||||
QUOTE_TEXT: 'QUOTE_TEXT',
|
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