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 commit 7e29d5147c.

* 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 commit f4d1454525.

* 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:
自由的世界人 2025-05-21 11:22:36 +08:00 committed by GitHub
parent 57f10bf56f
commit d07c6ecc6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 686 additions and 104 deletions

1
.gitignore vendored
View File

@ -51,3 +51,4 @@ local
coverage
.vitest-cache
vitest.config.*.timestamp-*
YOUR_MEMORY_FILE_PATH

View 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

View File

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

View File

@ -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": "内容のサイズが大きすぎます",

View File

@ -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": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение",

View File

@ -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": "内容尺寸过大",

View File

@ -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": "分段重疊不能大於分段大小",

View File

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

View File

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

View File

@ -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;
`

View 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>
)
}

View File

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

View File

@ -274,7 +274,7 @@ const FileBlocksContainer = styled.div`
gap: 8px;
padding: 0 15px;
margin: 8px 0;
background: transplant;
background: transparent;
border-radius: 4px;
`

View File

@ -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) {

View File

@ -1,4 +1,4 @@
import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
import { CheckOutlined, EditOutlined, MenuOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import { 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) => {

View 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

View File

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