mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
feat: add message multiple select (#6085)
* feat: add message multiple select * fix: build error * feat: add drag-and-drop multi-selection * fix: code review * Revert "fix: code review" This reverts commit7e29d5147c. * fix: hide input bar display * fix: extract the ChatContext * fix: eventemitter * feat: enhance multi-select functionality with message registration * fix: history page message search * fix: build error * fix: remove Event Emitter * fix: build error * feat: add hideMenuBar prop to MessageItem and integrate MessageEditingProvider * fix: improve message selection logic and handle drag events * fix: update translation keys for multiple select functionality * fix: refactor message deletion logic and enhance message selection handling * fix: replace useSelector with useStore for message selection in ChatContext * fix: refactor MessageGroup to utilize context for multi-select handling and message registration * Revert "fix: refactor MessageGroup to utilize context for multi-select handling and message registration" This reverts commitf4d1454525. * fix: simplify MessageGroup props and utilize context for message selection handling * fix: streamline multi-select handling by consolidating context usage and simplifying component props
This commit is contained in:
parent
57f10bf56f
commit
d07c6ecc6b
1
.gitignore
vendored
1
.gitignore
vendored
@ -51,3 +51,4 @@ local
|
||||
coverage
|
||||
.vitest-cache
|
||||
vitest.config.*.timestamp-*
|
||||
YOUR_MEMORY_FILE_PATH
|
||||
|
||||
95
src/renderer/src/components/Popups/MultiSelectionPopup.tsx
Normal file
95
src/renderer/src/components/Popups/MultiSelectionPopup.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { CloseOutlined, CopyOutlined, DeleteOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { useChatContext } from '@renderer/pages/home/Messages/ChatContext'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const MultiSelectActionPopup: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { toggleMultiSelectMode, selectedMessageIds, isMultiSelectMode, handleMultiSelectAction } = useChatContext()
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
handleMultiSelectAction(action, selectedMessageIds)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
toggleMultiSelectMode(false)
|
||||
}
|
||||
|
||||
if (!isMultiSelectMode) return null
|
||||
|
||||
// TODO: 视情况调整
|
||||
// const isActionDisabled = selectedMessages.some((msg) => msg.role === 'user')
|
||||
const isActionDisabled = false
|
||||
|
||||
return (
|
||||
<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('chat.navigation.close')}>
|
||||
<ActionButton icon={<CloseOutlined />} onClick={handleClose} />
|
||||
</Tooltip>
|
||||
</ActionBar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 36px 20px;
|
||||
background-color: var(--color-background);
|
||||
border-top: 1px solid var(--color-border);
|
||||
z-index: 10;
|
||||
`
|
||||
|
||||
const ActionBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
|
||||
const SelectionCount = styled.div`
|
||||
margin-right: 15px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
export default MultiSelectActionPopup
|
||||
@ -191,6 +191,8 @@
|
||||
"message.quote": "Quote",
|
||||
"message.regenerate.model": "Switch Model",
|
||||
"message.useful": "Helpful",
|
||||
"multiple.select": "Multiple Select",
|
||||
"multiple.select.empty": "No Messages Selected",
|
||||
"navigation": {
|
||||
"first": "Already at the first message",
|
||||
"history": "Chat History",
|
||||
@ -393,6 +395,7 @@
|
||||
"save": "Save",
|
||||
"search": "Search",
|
||||
"select": "Select",
|
||||
"selectedMessages": "Selected {{count}} messages",
|
||||
"topics": "Topics",
|
||||
"warning": "Warning",
|
||||
"you": "You",
|
||||
@ -591,6 +594,10 @@
|
||||
"copied": "Copied!",
|
||||
"copy.failed": "Copy failed",
|
||||
"copy.success": "Copied!",
|
||||
"delete.confirm.title": "Delete Confirmation",
|
||||
"delete.confirm.content": "Are you sure you want to delete the selected {{count}} message(s)?",
|
||||
"delete.failed": "Delete Failed",
|
||||
"delete.success": "Delete Successful",
|
||||
"empty_url": "Failed to download image, possibly due to prompt containing sensitive content or prohibited words",
|
||||
"error.chunk_overlap_too_large": "Chunk overlap cannot be greater than chunk size",
|
||||
"error.dimension_too_large": "Content size is too large",
|
||||
|
||||
@ -191,6 +191,8 @@
|
||||
"message.quote": "引用",
|
||||
"message.regenerate.model": "モデルを切り替え",
|
||||
"message.useful": "役立つ",
|
||||
"multiple.select": "選択",
|
||||
"multiple.select.empty": "メッセージが選択されていません",
|
||||
"navigation": {
|
||||
"first": "最初のメッセージです",
|
||||
"history": "チャット履歴",
|
||||
@ -393,6 +395,7 @@
|
||||
"save": "保存",
|
||||
"search": "検索",
|
||||
"select": "選択",
|
||||
"selectedMessages": "{{count}}件のメッセージを選択しました",
|
||||
"topics": "トピック",
|
||||
"warning": "警告",
|
||||
"you": "あなた",
|
||||
@ -591,6 +594,11 @@
|
||||
"copied": "コピーしました!",
|
||||
"copy.failed": "コピーに失敗しました",
|
||||
"copy.success": "コピーしました!",
|
||||
"delete.confirm.title": "削除確認",
|
||||
"delete.confirm.content": "選択した{{count}}件のメッセージを削除しますか?",
|
||||
"delete.failed": "削除に失敗しました",
|
||||
"delete.success": "削除が成功しました",
|
||||
"error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません",
|
||||
"empty_url": "画像をダウンロードできません。プロンプトに不適切なコンテンツや禁止用語が含まれている可能性があります",
|
||||
"error.chunk_overlap_too_large": "チャンクのオーバーラップがチャンクサイズより大きくなることはできません",
|
||||
"error.dimension_too_large": "内容のサイズが大きすぎます",
|
||||
|
||||
@ -191,6 +191,8 @@
|
||||
"message.quote": "Цитата",
|
||||
"message.regenerate.model": "Переключить модель",
|
||||
"message.useful": "Полезно",
|
||||
"multiple.select": "Множественный выбор",
|
||||
"multiple.select.empty": "Ничего не выбрано",
|
||||
"navigation": {
|
||||
"first": "Уже первое сообщение",
|
||||
"history": "История чата",
|
||||
@ -393,6 +395,7 @@
|
||||
"save": "Сохранить",
|
||||
"search": "Поиск",
|
||||
"select": "Выбрать",
|
||||
"selectedMessages": "Выбрано {{count}} сообщений",
|
||||
"topics": "Топики",
|
||||
"warning": "Предупреждение",
|
||||
"you": "Вы",
|
||||
@ -591,8 +594,12 @@
|
||||
"copied": "Скопировано!",
|
||||
"copy.failed": "Не удалось скопировать",
|
||||
"copy.success": "Скопировано!",
|
||||
"empty_url": "Не удалось загрузить изображение, возможно, запрос содержит конфиденциальный контент или запрещенные слова",
|
||||
"delete.confirm.title": "Подтверждение удаления",
|
||||
"delete.confirm.content": "Вы уверены, что хотите удалить выбранные {{count}} сообщения?",
|
||||
"delete.failed": "Ошибка удаления",
|
||||
"delete.success": "Удаление успешно",
|
||||
"error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента",
|
||||
"empty_url": "Не удалось загрузить изображение, возможно, запрос содержит конфиденциальный контент или запрещенные слова",
|
||||
"error.dimension_too_large": "Размер содержимого слишком велик",
|
||||
"error.enter.api.host": "Пожалуйста, введите ваш API хост",
|
||||
"error.enter.api.key": "Пожалуйста, введите ваш API ключ",
|
||||
@ -803,6 +810,7 @@
|
||||
"model": "Версия",
|
||||
"aspect_ratio": "Пропорции изображения",
|
||||
"style_type": "Стиль",
|
||||
"rendering_speed": "Скорость рендеринга",
|
||||
"learn_more": "Узнать больше",
|
||||
"prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
|
||||
"proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение",
|
||||
|
||||
@ -205,6 +205,8 @@
|
||||
"message.quote": "引用",
|
||||
"message.regenerate.model": "切换模型",
|
||||
"message.useful": "有用",
|
||||
"multiple.select": "多选",
|
||||
"multiple.select.empty": "未选中任何消息",
|
||||
"navigation": {
|
||||
"first": "已经是第一条消息",
|
||||
"history": "聊天历史",
|
||||
@ -393,6 +395,7 @@
|
||||
"save": "保存",
|
||||
"search": "搜索",
|
||||
"select": "选择",
|
||||
"selectedMessages": "选中{{count}}条消息",
|
||||
"topics": "话题",
|
||||
"warning": "警告",
|
||||
"you": "用户",
|
||||
@ -591,6 +594,10 @@
|
||||
"copied": "已复制",
|
||||
"copy.failed": "复制失败",
|
||||
"copy.success": "复制成功",
|
||||
"delete.confirm.title": "删除确认",
|
||||
"delete.confirm.content": "确认删除选中的{{count}}条消息吗?",
|
||||
"delete.failed": "删除失败",
|
||||
"delete.success": "删除成功",
|
||||
"empty_url": "无法下载图片,可能是提示词包含敏感内容或违禁词汇",
|
||||
"error.chunk_overlap_too_large": "分段重叠不能大于分段大小",
|
||||
"error.dimension_too_large": "内容尺寸过大",
|
||||
|
||||
@ -191,6 +191,8 @@
|
||||
"message.quote": "引用",
|
||||
"message.regenerate.model": "切換模型",
|
||||
"message.useful": "有用",
|
||||
"multiple.select": "多選",
|
||||
"multiple.select.empty": "未選中任何訊息",
|
||||
"navigation": {
|
||||
"first": "已經是第一條訊息",
|
||||
"history": "聊天歷史",
|
||||
@ -393,6 +395,7 @@
|
||||
"save": "儲存",
|
||||
"search": "搜尋",
|
||||
"select": "選擇",
|
||||
"selectedMessages": "选中{{count}}条消息",
|
||||
"topics": "話題",
|
||||
"warning": "警告",
|
||||
"you": "您",
|
||||
@ -590,6 +593,11 @@
|
||||
"citations": "引用內容",
|
||||
"copied": "已複製!",
|
||||
"copy.failed": "複製失敗",
|
||||
"copy.success": "已複製!",
|
||||
"delete.confirm.title": "刪除確認",
|
||||
"delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?",
|
||||
"delete.failed": "刪除失敗",
|
||||
"delete.success": "刪除成功",
|
||||
"copy.success": "複製成功",
|
||||
"empty_url": "無法下載圖片,可能是提示詞包含敏感內容或違禁詞彙",
|
||||
"error.chunk_overlap_too_large": "分段重疊不能大於分段大小",
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { ArrowRightOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { ChatProvider } from '@renderer/pages/home/Messages/ChatContext'
|
||||
import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
|
||||
import { locateToMessage } from '@renderer/services/MessagesService'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
@ -41,23 +43,27 @@ const SearchMessage: FC<Props> = ({ message, ...props }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagesContainer {...props} className={messageStyle}>
|
||||
<ContainerWrapper style={{ paddingTop: 20, paddingBottom: 20, position: 'relative' }}>
|
||||
<MessageItem message={message} topic={topic} />
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 10 }}
|
||||
onClick={() => locateToMessage(navigate, message)}
|
||||
icon={<ArrowRightOutlined />}
|
||||
/>
|
||||
<HStack mt="10px" justifyContent="center">
|
||||
<Button onClick={() => locateToMessage(navigate, message)} icon={<ArrowRightOutlined />}>
|
||||
{t('history.locate.message')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ContainerWrapper>
|
||||
</MessagesContainer>
|
||||
<ChatProvider activeTopic={topic}>
|
||||
<MessageEditingProvider>
|
||||
<MessagesContainer {...props} className={messageStyle}>
|
||||
<ContainerWrapper style={{ paddingTop: 20, paddingBottom: 20, position: 'relative' }}>
|
||||
<MessageItem message={message} topic={topic} hideMenuBar={true} />
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 10 }}
|
||||
onClick={() => locateToMessage(navigate, message)}
|
||||
icon={<ArrowRightOutlined />}
|
||||
/>
|
||||
<HStack mt="10px" justifyContent="center">
|
||||
<Button onClick={() => locateToMessage(navigate, message)} icon={<ArrowRightOutlined />}>
|
||||
{t('history.locate.message')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ContainerWrapper>
|
||||
</MessagesContainer>
|
||||
</MessageEditingProvider>
|
||||
</ChatProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { ChatProvider } from '@renderer/pages/home/Messages/ChatContext'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
|
||||
@ -46,31 +48,35 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll} className={messageStyle}>
|
||||
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}>
|
||||
{topic?.messages.map((message) => (
|
||||
<div key={message.id} style={{ position: 'relative' }}>
|
||||
<MessageItem message={message} topic={topic} />
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }}
|
||||
onClick={() => locateToMessage(navigate, message)}
|
||||
icon={<ArrowRightOutlined />}
|
||||
/>
|
||||
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
|
||||
</div>
|
||||
))}
|
||||
{isEmpty && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
{!isEmpty && (
|
||||
<HStack justifyContent="center">
|
||||
<Button onClick={() => onContinueChat(topic)} icon={<MessageOutlined />}>
|
||||
{t('history.continue_chat')}
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</ContainerWrapper>
|
||||
</MessagesContainer>
|
||||
<ChatProvider activeTopic={topic}>
|
||||
<MessageEditingProvider>
|
||||
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll} className={messageStyle}>
|
||||
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}>
|
||||
{topic?.messages.map((message) => (
|
||||
<div key={message.id} style={{ position: 'relative' }}>
|
||||
<MessageItem message={message} topic={topic} hideMenuBar={true} />
|
||||
<Button
|
||||
type="text"
|
||||
size="middle"
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }}
|
||||
onClick={() => locateToMessage(navigate, message)}
|
||||
icon={<ArrowRightOutlined />}
|
||||
/>
|
||||
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
|
||||
</div>
|
||||
))}
|
||||
{isEmpty && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
{!isEmpty && (
|
||||
<HStack justifyContent="center">
|
||||
<Button onClick={() => onContinueChat(topic)} icon={<MessageOutlined />}>
|
||||
{t('history.continue_chat')}
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</ContainerWrapper>
|
||||
</MessagesContainer>
|
||||
</MessageEditingProvider>
|
||||
</ChatProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch'
|
||||
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
|
||||
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@ -12,6 +13,7 @@ import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Inputbar from './Inputbar/Inputbar'
|
||||
import { ChatProvider, useChatContext } from './Messages/ChatContext'
|
||||
import Messages from './Messages/Messages'
|
||||
import Tabs from './Tabs'
|
||||
|
||||
@ -22,10 +24,12 @@ interface Props {
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
}
|
||||
|
||||
const Chat: FC<Props> = (props) => {
|
||||
const ChatContent: FC<Props> = (props) => {
|
||||
const { assistant } = useAssistant(props.assistant.id)
|
||||
const { topicPosition, messageStyle, showAssistants } = useSettings()
|
||||
const { showTopics } = useShowTopics()
|
||||
const { isMultiSelectMode } = useChatContext()
|
||||
|
||||
const mainRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
||||
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
|
||||
@ -123,6 +127,7 @@ const Chat: FC<Props> = (props) => {
|
||||
</MessagesContainer>
|
||||
<QuickPanelProvider>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
{isMultiSelectMode && <MultiSelectActionPopup />}
|
||||
</QuickPanelProvider>
|
||||
</Main>
|
||||
{topicPosition === 'right' && showTopics && (
|
||||
@ -138,6 +143,14 @@ const Chat: FC<Props> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const Chat: FC<Props> = (props) => {
|
||||
return (
|
||||
<ChatProvider activeTopic={props.activeTopic}>
|
||||
<ChatContent {...props} />
|
||||
</ChatProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const MessagesContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -154,7 +167,6 @@ const Container = styled.div`
|
||||
|
||||
const Main = styled(Flex)`
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
// 设置为containing block,方便子元素fixed定位
|
||||
transform: translateZ(0);
|
||||
position: relative;
|
||||
`
|
||||
|
||||
217
src/renderer/src/pages/home/Messages/ChatContext.tsx
Normal file
217
src/renderer/src/pages/home/Messages/ChatContext.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
||||
import { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { Modal } from 'antd'
|
||||
import { createContext, FC, ReactNode, use, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore } from 'react-redux'
|
||||
|
||||
interface ChatContextProps {
|
||||
isMultiSelectMode: boolean
|
||||
selectedMessageIds: string[]
|
||||
toggleMultiSelectMode: (value: boolean) => void
|
||||
handleMultiSelectAction: (actionType: string, messageIds: string[]) => void
|
||||
handleSelectMessage: (messageId: string, selected: boolean) => void
|
||||
activeTopic: Topic
|
||||
locateMessage: (messageId: string) => void
|
||||
messageRefs: Map<string, HTMLElement>
|
||||
registerMessageElement: (id: string, element: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
interface ChatProviderProps {
|
||||
children: ReactNode
|
||||
activeTopic: Topic
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextProps | undefined>(undefined)
|
||||
|
||||
export const useChatContext = () => {
|
||||
const context = use(ChatContext)
|
||||
if (!context) {
|
||||
throw new Error('useChatContext 必须在 ChatProvider 内使用')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const ChatProvider: FC<ChatProviderProps> = ({ children, activeTopic }) => {
|
||||
const { t } = useTranslation()
|
||||
const { deleteMessage } = useMessageOperations(activeTopic)
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||
const [selectedMessageIds, setSelectedMessageIds] = useState<string[]>([])
|
||||
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false)
|
||||
const [messagesToDelete, setMessagesToDelete] = useState<string[]>([])
|
||||
const [messageRefs, setMessageRefs] = useState<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
const store = useStore<RootState>()
|
||||
|
||||
const toggleMultiSelectMode = (value: boolean) => {
|
||||
setIsMultiSelectMode(value)
|
||||
if (!value) {
|
||||
setSelectedMessageIds([])
|
||||
}
|
||||
}
|
||||
|
||||
const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => {
|
||||
setMessageRefs((prev) => {
|
||||
const newRefs = new Map(prev)
|
||||
if (element) {
|
||||
newRefs.set(id, element)
|
||||
} else {
|
||||
newRefs.delete(id)
|
||||
}
|
||||
return newRefs
|
||||
})
|
||||
}, [])
|
||||
|
||||
const locateMessage = (messageId: string) => {
|
||||
const messageElement = messageRefs.get(messageId)
|
||||
if (messageElement) {
|
||||
// 检查消息是否可见
|
||||
const display = window.getComputedStyle(messageElement).display
|
||||
|
||||
if (display === 'none') {
|
||||
// 如果消息隐藏,需要处理显示逻辑
|
||||
// 查找消息并设置为选中状态
|
||||
const state = store.getState()
|
||||
const messages = selectMessagesForTopic(state, activeTopic.id)
|
||||
const message = messages.find((m) => m.id === messageId)
|
||||
if (message) {
|
||||
// 这里需要实现设置消息为选中状态的逻辑
|
||||
// 可能需要调用其他函数或修改状态
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到消息位置
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectMessage = (messageId: string, selected: boolean) => {
|
||||
setSelectedMessageIds((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (selected) {
|
||||
newSet.add(messageId)
|
||||
} else {
|
||||
newSet.delete(messageId)
|
||||
}
|
||||
return Array.from(newSet)
|
||||
})
|
||||
}
|
||||
|
||||
const handleMultiSelectAction = (actionType: string, messageIds: string[]) => {
|
||||
if (messageIds.length === 0) {
|
||||
window.message.warning(t('chat.multiple.select.empty'))
|
||||
return
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
const messages = selectMessagesForTopic(state, activeTopic.id)
|
||||
const messageBlocks = messageBlocksSelectors.selectEntities(state)
|
||||
|
||||
switch (actionType) {
|
||||
case 'delete':
|
||||
setMessagesToDelete(messageIds)
|
||||
setConfirmDeleteVisible(true)
|
||||
break
|
||||
case 'save': {
|
||||
const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id))
|
||||
if (assistantMessages.length > 0) {
|
||||
const contentToSave = assistantMessages
|
||||
.map((msg) => {
|
||||
return msg.blocks
|
||||
.map((blockId) => {
|
||||
const block = messageBlocks[blockId]
|
||||
return block && 'content' in block ? block.content : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim()
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
const fileName = `chat_export_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.md`
|
||||
window.api.file.save(fileName, contentToSave)
|
||||
window.message.success({ content: t('message.save.success.title'), key: 'save-messages' })
|
||||
toggleMultiSelectMode(false)
|
||||
} else {
|
||||
window.message.warning(t('message.save.no.assistant'))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'copy': {
|
||||
const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id))
|
||||
if (assistantMessages.length > 0) {
|
||||
const contentToCopy = assistantMessages
|
||||
.map((msg) => {
|
||||
return msg.blocks
|
||||
.map((blockId) => {
|
||||
const block = messageBlocks[blockId]
|
||||
return block && 'content' in block ? block.content : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim()
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
navigator.clipboard.writeText(contentToCopy)
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-messages' })
|
||||
toggleMultiSelectMode(false)
|
||||
} else {
|
||||
window.message.warning(t('message.copy.no.assistant'))
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await Promise.all(messagesToDelete.map((messageId) => deleteMessage(messageId)))
|
||||
window.message.success(t('message.delete.success'))
|
||||
setMessagesToDelete([])
|
||||
toggleMultiSelectMode(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete messages:', error)
|
||||
window.message.error(t('message.delete.failed'))
|
||||
} finally {
|
||||
setConfirmDeleteVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelDelete = () => {
|
||||
setConfirmDeleteVisible(false)
|
||||
setMessagesToDelete([])
|
||||
}
|
||||
|
||||
const value = {
|
||||
isMultiSelectMode,
|
||||
selectedMessageIds,
|
||||
toggleMultiSelectMode,
|
||||
handleMultiSelectAction,
|
||||
handleSelectMessage,
|
||||
activeTopic,
|
||||
locateMessage,
|
||||
messageRefs,
|
||||
registerMessageElement
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatContext value={value}>
|
||||
{children}
|
||||
<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>
|
||||
</ChatContext>
|
||||
)
|
||||
}
|
||||
@ -29,6 +29,7 @@ interface Props {
|
||||
index?: number
|
||||
total?: number
|
||||
hidePresetMessages?: boolean
|
||||
hideMenuBar?: boolean
|
||||
style?: React.CSSProperties
|
||||
isGrouped?: boolean
|
||||
isStreaming?: boolean
|
||||
@ -41,6 +42,7 @@ const MessageItem: FC<Props> = ({
|
||||
// assistant,
|
||||
index,
|
||||
hidePresetMessages,
|
||||
hideMenuBar = false,
|
||||
isGrouped,
|
||||
isStreaming = false,
|
||||
style
|
||||
@ -80,8 +82,6 @@ const MessageItem: FC<Props> = ({
|
||||
const handleEditResend = useCallback(
|
||||
async (blocks: MessageBlock[]) => {
|
||||
try {
|
||||
// 编辑后重新发送消息
|
||||
console.log('after resend blocks', blocks)
|
||||
await resendUserMessageWithEdit(message, blocks, assistant)
|
||||
stopEditing()
|
||||
} catch (error) {
|
||||
@ -97,7 +97,7 @@ const MessageItem: FC<Props> = ({
|
||||
|
||||
const isLastMessage = index === 0
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
const showMenubar = !isStreaming && !message.status.includes('ing') && !isEditing
|
||||
const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing
|
||||
|
||||
const messageBorder = showMessageDivider ? undefined : 'none'
|
||||
const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage)
|
||||
@ -126,7 +126,9 @@ const MessageItem: FC<Props> = ({
|
||||
|
||||
if (message.type === 'clear') {
|
||||
return (
|
||||
<NewContextMessage onClick={() => EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}>
|
||||
<NewContextMessage
|
||||
className="clear-context-divider"
|
||||
onClick={() => EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}>
|
||||
<Divider dashed style={{ padding: '0 20px' }} plain>
|
||||
{t('chat.message.new.context')}
|
||||
</Divider>
|
||||
|
||||
@ -274,7 +274,7 @@ const FileBlocksContainer = styled.div`
|
||||
gap: 8px;
|
||||
padding: 0 15px;
|
||||
margin: 8px 0;
|
||||
background: transplant;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
`
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { MultiModelMessageStyle } from '@renderer/store/settings'
|
||||
import type { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
@ -11,18 +10,23 @@ import { Popover } from 'antd'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
import { useChatContext } from './ChatContext'
|
||||
import MessageItem from './Message'
|
||||
import MessageGroupMenuBar from './MessageGroupMenuBar'
|
||||
import SelectableMessage from './MessageSelect'
|
||||
|
||||
interface Props {
|
||||
messages: (Message & { index: number })[]
|
||||
topic: Topic
|
||||
hidePresetMessages?: boolean
|
||||
registerMessageElement?: (id: string, element: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElement }: Props) => {
|
||||
const { editMessage } = useMessageOperations(topic)
|
||||
const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings()
|
||||
const { isMultiSelectMode, selectedMessageIds, handleSelectMessage } = useChatContext()
|
||||
const selectedMessages = useMemo(() => new Set(selectedMessageIds), [selectedMessageIds])
|
||||
|
||||
const [multiModelMessageStyle, setMultiModelMessageStyle] = useState<MultiModelMessageStyle>(
|
||||
messages[0].multiModelMessageStyle || multiModelMessageStyleSetting
|
||||
@ -112,41 +116,20 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages, selectedIndex, isGrouped, messageLength])
|
||||
|
||||
// 添加对LOCATE_MESSAGE事件的监听
|
||||
useEffect(() => {
|
||||
// 为每个消息注册一个定位事件监听器
|
||||
const eventHandlers: { [key: string]: () => void } = {}
|
||||
|
||||
messages.forEach((message) => {
|
||||
const eventName = EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id
|
||||
const handler = () => {
|
||||
// 检查消息是否处于可见状态
|
||||
const element = document.getElementById(`message-${message.id}`)
|
||||
if (element) {
|
||||
const display = window.getComputedStyle(element).display
|
||||
|
||||
if (display === 'none') {
|
||||
// 如果消息隐藏,先切换标签
|
||||
setSelectedMessage(message)
|
||||
} else {
|
||||
// 直接滚动
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
const element = document.getElementById(`message-${message.id}`)
|
||||
if (element) {
|
||||
registerMessageElement?.(message.id, element)
|
||||
}
|
||||
|
||||
eventHandlers[eventName] = handler
|
||||
EventEmitter.on(eventName, handler)
|
||||
})
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
// 移除所有事件监听器
|
||||
Object.entries(eventHandlers).forEach(([eventName, handler]) => {
|
||||
EventEmitter.off(eventName, handler)
|
||||
messages.forEach((message) => {
|
||||
registerMessageElement?.(message.id, null)
|
||||
})
|
||||
}
|
||||
}, [messages, setSelectedMessage])
|
||||
}, [messages, registerMessageElement])
|
||||
|
||||
const renderMessage = useCallback(
|
||||
(message: Message & { index: number }) => {
|
||||
@ -162,7 +145,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const messageWrapper = (
|
||||
const messageContent = (
|
||||
<MessageWrapper
|
||||
id={`message-${message.id}`}
|
||||
$layout={multiModelMessageStyle}
|
||||
@ -178,6 +161,15 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
</MessageWrapper>
|
||||
)
|
||||
|
||||
const wrappedMessage = (
|
||||
<SelectableMessage
|
||||
key={`selectable-${message.id}`}
|
||||
messageId={message.id}
|
||||
isClearMessage={message.type === 'clear'}>
|
||||
{messageContent}
|
||||
</SelectableMessage>
|
||||
)
|
||||
|
||||
if (isGridGroupMessage) {
|
||||
return (
|
||||
<Popover
|
||||
@ -194,22 +186,26 @@ 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,
|
||||
isGrouped,
|
||||
isHorizontal,
|
||||
multiModelMessageStyle,
|
||||
topic,
|
||||
hidePresetMessages,
|
||||
gridPopoverTrigger,
|
||||
selectedMessageId
|
||||
multiModelMessageStyle,
|
||||
isHorizontal,
|
||||
selectedMessageId,
|
||||
isMultiSelectMode,
|
||||
selectedMessages,
|
||||
registerMessageElement,
|
||||
handleSelectMessage,
|
||||
gridPopoverTrigger
|
||||
]
|
||||
)
|
||||
|
||||
@ -307,18 +303,6 @@ interface MessageWrapperProps {
|
||||
|
||||
const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
|
||||
width: 100%;
|
||||
&.horizontal {
|
||||
display: inline-block;
|
||||
}
|
||||
&.grid {
|
||||
display: inline-block;
|
||||
}
|
||||
&.fold {
|
||||
display: none;
|
||||
&.selected {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
${({ $layout, $isGrouped }) => {
|
||||
if ($layout === 'horizontal' && $isGrouped) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { CheckOutlined, EditOutlined, MenuOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import { TranslateLanguageOptions } from '@renderer/config/translate'
|
||||
@ -33,6 +33,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useChatContext } from './ChatContext'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
assistant: Assistant
|
||||
@ -50,6 +52,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
const { message, index, isGrouped, isLastMessage, isAssistantMessage, assistant, topic, model, messageContainerRef } =
|
||||
props
|
||||
const { t } = useTranslation()
|
||||
const { toggleMultiSelectMode } = useChatContext()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false)
|
||||
@ -171,6 +174,14 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
icon: <Split size={16} />,
|
||||
onClick: onNewBranch
|
||||
},
|
||||
{
|
||||
label: t('chat.multiple.select'),
|
||||
key: 'multi-select',
|
||||
icon: <MenuOutlined size={16} />,
|
||||
onClick: () => {
|
||||
toggleMultiSelectMode(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.title'),
|
||||
key: 'export',
|
||||
@ -265,7 +276,18 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
].filter(Boolean)
|
||||
}
|
||||
],
|
||||
[message, messageContainerRef, isEditable, onEdit, mainTextContent, onNewBranch, t, topic.name, exportMenuOptions]
|
||||
[
|
||||
t,
|
||||
isEditable,
|
||||
onEdit,
|
||||
onNewBranch,
|
||||
exportMenuOptions,
|
||||
message,
|
||||
mainTextContent,
|
||||
toggleMultiSelectMode,
|
||||
messageContainerRef,
|
||||
topic.name
|
||||
]
|
||||
)
|
||||
|
||||
const onRegenerate = async (e: React.MouseEvent | undefined) => {
|
||||
|
||||
63
src/renderer/src/pages/home/Messages/MessageSelect.tsx
Normal file
63
src/renderer/src/pages/home/Messages/MessageSelect.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Checkbox } from 'antd'
|
||||
import { FC, ReactNode, useEffect, useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useChatContext } from './ChatContext'
|
||||
|
||||
interface SelectableMessageProps {
|
||||
children: ReactNode
|
||||
messageId: string
|
||||
isClearMessage?: boolean
|
||||
}
|
||||
|
||||
const SelectableMessage: FC<SelectableMessageProps> = ({ children, messageId, isClearMessage = false }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
registerMessageElement: contextRegister,
|
||||
isMultiSelectMode,
|
||||
selectedMessageIds,
|
||||
handleSelectMessage
|
||||
} = useChatContext()
|
||||
|
||||
const isSelected = selectedMessageIds?.includes(messageId)
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
contextRegister(messageId, containerRef.current)
|
||||
return () => {
|
||||
contextRegister(messageId, null)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, [messageId, contextRegister])
|
||||
|
||||
return (
|
||||
<Container ref={containerRef}>
|
||||
{isMultiSelectMode && !isClearMessage && (
|
||||
<CheckboxWrapper>
|
||||
<Checkbox checked={isSelected} onChange={(e) => handleSelectMessage(messageId, 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
|
||||
@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useChatContext } from './ChatContext'
|
||||
import ChatNavigation from './ChatNavigation'
|
||||
import MessageAnchorLine from './MessageAnchorLine'
|
||||
import MessageGroup from './MessageGroup'
|
||||
@ -52,12 +53,18 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
)
|
||||
const { t } = useTranslation()
|
||||
const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings()
|
||||
const { isMultiSelectMode, handleSelectMessage } = useChatContext()
|
||||
const { updateTopic, addTopic } = useAssistant(assistant.id)
|
||||
const dispatch = useAppDispatch()
|
||||
const [displayMessages, setDisplayMessages] = useState<Message[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [isProcessingContext, setIsProcessingContext] = useState(false)
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const [dragCurrent, setDragCurrent] = useState({ x: 0, y: 0 })
|
||||
const messageElements = useRef<Map<string, HTMLElement>>(new Map())
|
||||
const messages = useTopicMessages(topic.id)
|
||||
const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic)
|
||||
const messagesRef = useRef<Message[]>(messages)
|
||||
@ -66,6 +73,113 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
messagesRef.current = messages
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMultiSelectMode) return
|
||||
|
||||
const updateDragPos = (e: MouseEvent) => {
|
||||
const container = scrollContainerRef.current!
|
||||
if (!container) return { x: 0, y: 0 }
|
||||
const rect = container.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left + container.scrollLeft
|
||||
const y = e.clientY - rect.top + container.scrollTop
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.ant-checkbox-wrapper')) return
|
||||
if ((e.target as HTMLElement).closest('.MessageFooter')) return
|
||||
setIsDragging(true)
|
||||
const pos = updateDragPos(e)
|
||||
setDragStart(pos)
|
||||
setDragCurrent(pos)
|
||||
document.body.classList.add('no-select')
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
setDragCurrent(updateDragPos(e))
|
||||
const container = scrollContainerRef.current!
|
||||
if (container) {
|
||||
const { top, bottom } = container.getBoundingClientRect()
|
||||
const scrollSpeed = 15
|
||||
if (e.clientY < top + 50) {
|
||||
container.scrollBy(0, -scrollSpeed)
|
||||
} else if (e.clientY > bottom - 50) {
|
||||
container.scrollBy(0, scrollSpeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging) return
|
||||
|
||||
const left = Math.min(dragStart.x, dragCurrent.x)
|
||||
const right = Math.max(dragStart.x, dragCurrent.x)
|
||||
const top = Math.min(dragStart.y, dragCurrent.y)
|
||||
const bottom = Math.max(dragStart.y, dragCurrent.y)
|
||||
|
||||
const MIN_SELECTION_SIZE = 5
|
||||
const isValidSelection =
|
||||
Math.abs(right - left) > MIN_SELECTION_SIZE && Math.abs(bottom - top) > MIN_SELECTION_SIZE
|
||||
|
||||
if (isValidSelection) {
|
||||
// 处理元素选择
|
||||
messageElements.current.forEach((element, messageId) => {
|
||||
try {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const container = scrollContainerRef.current!
|
||||
|
||||
const elementTop = rect.top - container.getBoundingClientRect().top + container.scrollTop
|
||||
const elementLeft = rect.left - container.getBoundingClientRect().left + container.scrollLeft
|
||||
const elementBottom = elementTop + rect.height
|
||||
const elementRight = elementLeft + rect.width
|
||||
|
||||
const isIntersecting = !(
|
||||
elementRight < left ||
|
||||
elementLeft > right ||
|
||||
elementBottom < top ||
|
||||
elementTop > bottom
|
||||
)
|
||||
|
||||
if (isIntersecting) {
|
||||
handleSelectMessage(messageId, true)
|
||||
element.classList.add('selection-highlight')
|
||||
setTimeout(() => element.classList.remove('selection-highlight'), 300)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error calculating element intersection:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
setIsDragging(false)
|
||||
document.body.classList.remove('no-select')
|
||||
}
|
||||
|
||||
const container = scrollContainerRef.current!
|
||||
if (container) {
|
||||
container.addEventListener('mousedown', handleMouseDown)
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (container) {
|
||||
container.removeEventListener('mousedown', handleMouseDown)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.classList.remove('no-select')
|
||||
}
|
||||
}
|
||||
}, [isMultiSelectMode, isDragging, dragStart, dragCurrent, handleSelectMessage, scrollContainerRef])
|
||||
|
||||
const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
messageElements.current.set(id, element)
|
||||
} else {
|
||||
messageElements.current.delete(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount)
|
||||
setDisplayMessages(newDisplayMessages)
|
||||
@ -256,16 +370,19 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => onComponentUpdate?.())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [onComponentUpdate])
|
||||
|
||||
const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages])
|
||||
return (
|
||||
<Container
|
||||
id="messages"
|
||||
style={{ maxWidth, paddingTop: showPrompt ? 10 : 0 }}
|
||||
key={assistant.id}
|
||||
ref={scrollContainerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
maxWidth,
|
||||
paddingTop: showPrompt ? 10 : 0
|
||||
}}
|
||||
key={assistant.id}
|
||||
onScroll={handleScrollPosition}
|
||||
$right={topicPosition === 'left'}>
|
||||
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
|
||||
@ -284,6 +401,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
messages={groupMessages}
|
||||
topic={topic}
|
||||
hidePresetMessages={assistant.settings?.hideMessages}
|
||||
registerMessageElement={registerMessageElement}
|
||||
/>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
@ -297,6 +415,16 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
</NarrowLayout>
|
||||
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />}
|
||||
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
|
||||
{isDragging && isMultiSelectMode && (
|
||||
<SelectionBox
|
||||
style={{
|
||||
left: Math.min(dragStart.x, dragCurrent.x),
|
||||
top: Math.min(dragStart.y, dragCurrent.y),
|
||||
width: Math.abs(dragCurrent.x - dragStart.x),
|
||||
height: Math.abs(dragCurrent.y - dragStart.y)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -364,4 +492,12 @@ const Container = styled(Scrollbar)<ContainerProps>`
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const SelectionBox = styled.div`
|
||||
position: absolute;
|
||||
border: 1px dashed var(--color-primary);
|
||||
background-color: rgba(0, 114, 245, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
`
|
||||
|
||||
export default Messages
|
||||
|
||||
Loading…
Reference in New Issue
Block a user