refactor: 重构message模块 (#2561)

* feat: Implement Redux-based message management with enhanced state handling

- Add new Redux slice for managing messages with advanced state control
- Introduce topic-specific message queues using p-queue for request management
- Refactor message sending, loading, and updating logic
- Improve error handling and state synchronization with database
- Add selectors for efficient message retrieval and state access

* feat: Implement streaming message handling in Redux store

- Add stream message support in messages slice
- Create MessageStream component for rendering streaming messages
- Update Inputbar and Suggestions components to use new Redux message sending logic
- Refactor message sending flow to use stream message management
- Improve error handling and message state management

* feat:添加StreamMessage,优化数据流展示,减少大面积rerender

* refactor: Simplify messages state management and initialization

- Refactor messages slice to use flat message array instead of separate user/assistant messages
- Add initializeMessagesState thunk to load messages from database on app startup
- Update message-related reducers to work with flat message array
- Modify MessageStream and related components to use new state structure
- Improve type safety and reduce complexity in messages state management

*  feat: add Model Context Protocol (MCP) support (#2809)

*  feat: add Model Context Protocol (MCP) server configuration (main)

- Added `@modelcontextprotocol/sdk` dependency for MCP integration.
- Introduced MCP server configuration UI in settings with add, edit, delete, and activation functionalities.
- Created `useMCPServers` hook to manage MCP server state and actions.
- Added i18n support for MCP settings with translation keys.
- Integrated MCP settings into the application's settings navigation and routing.
- Implemented Redux state management for MCP servers.
- Updated `yarn.lock` with new dependencies and their resolutions.

* 🌟 feat: implement mcp service and integrate with ipc handlers

- Added `MCPService` class to manage Model Context Protocol servers.
- Implemented various handlers in `ipc.ts` for managing MCP servers including listing, adding, updating, deleting, and activating/deactivating servers.
- Integrated MCP related types into existing type declarations for consistency across the application.
- Updated `preload` to expose new MCP related APIs to the renderer process.
- Enhanced `MCPSettings` component to interact directly with the new MCP service for adding, updating, deleting servers and setting their active states.
- Introduced selectors in the MCP Redux slice for fetching active and all servers from the store.
- Moved MCP types to a centralized location in `@renderer/types` for reuse across different parts of the application.

* feat: enhance MCPService initialization to prevent recursive calls and improve error handling

* feat: enhance MCP integration by adding MCPTool type and updating related methods

* feat: implement streaming support for tool calls in OpenAIProvider and enhance message processing

* refactor: Improve message handling and type safety in message components

- Update Message, MessageGroup, MessageStream, and MessageMenubar to require Topic prop
- Modify message sending and resending logic in MessageMenubar
- Remove commented-out code and simplify message state management
- Enhance type safety by explicitly defining prop types

* fix: finish_reason undefined

* refactor: Streamline message resending and state management

- Introduce `resendMessage` thunk for more robust message resending logic
- Update `sendMessage` to support resending existing messages
- Remove deprecated `onEditMessage` callback from MessageMenubar
- Simplify message state updates using Redux actions
- Improve type safety and reduce complexity in message handling

* refactor: Optimize message resending and event handling

- Remove deprecated `APPEND_MESSAGE` event and related callbacks
- Update `resendMessage` thunk to support mentioning new models
- Modify message state synchronization in database
- Simplify dependency arrays and remove unused event listeners
- Add migration step for initializing messages state

* refactor: Enhance message translation and suggestions handling

- Update MessageMenubar to use stream message actions for translation
- Modify Suggestions component to optimize suggestion fetching
- Remove deprecated event listeners and simplify component logic
- Memoize MessageMenubar and Suggestions components for performance
- Trigger AI auto-rename on message completion in messages slice

* refactor: Optimize message streaming with throttled updates

- Introduce throttled message update mechanism using lodash
- Improve performance by limiting Redux state updates during streaming
- Create a separate handler for response message updates
- Enhance message synchronization with database
- Prevent unnecessary re-renders and reduce computational overhead

* fix: Remove unnecessary await in message dispatch

Removes the `await` keyword from the message dispatch in Inputbar, which was causing an unnecessary async operation. Also adds a missing closing brace in the migration configuration file.

* fix: Update Redux persist configuration for messages slice

Modify store configuration to exclude 'messages' slice from persistence and remove unnecessary migration step for message state initialization

* feat: Enhance message streaming and multi-model support

- Refactor Redux messages slice to support multiple stream messages per topic
- Update MessageStream and messages slice to handle message streaming with message-specific IDs
- Implement support for multi-model message generation
- Modify queue concurrency to improve parallel message processing
- Update message selection and streaming logic to be more flexible and robust

* feat: Implement file upload handling in message sending

- Add FileManager service integration for file uploads in Inputbar
- Modify sendMessage action to use uploaded file references
- Update messages slice to conditionally dispatch messages during resend

*  feat(MCP): add support for enabling/disabling MCPServers per message (#2989)

*  feat: add MCP servers in chat input

- Introduce MCPToolsButton component for managing MCP servers
- Add new icon for MCP server tools in iconfont.css
- Update Inputbar to include MCP tools functionality
- Add toggle functionality for enabling/disabling MCP servers
- Implement styled dropdown menu for server selection
- Add necessary type imports and useState for MCP server management

*  feat: add support for enabling/disabling MCPServers per message (main)

- Added `enabledMCPs` property to the `Message` type to track enabled MCPServers.
- Modified `MCPToolsButton` to enable all active MCPServers by default using a new `enableAll` state.
- Introduced `filterMCPTools` utility to filter tools based on enabled MCPServers.
- Updated `AnthropicProvider`, `GeminiProvider`, and `OpenAIProvider` to filter tools using `filterMCPTools`.
- Enhanced `Inputbar` to include `enabledMCPs` in the message payload when set.

*  feat(MCP): add enabledMCPs parameter to sendMessage action

- Update sendMessage action type to include optional enabledMCPs parameter
- Import MCPServer type for type safety
- Modify action signature to support passing enabled MCP servers per message

---------

Co-authored-by: lizhixuan <zhixuan.li@banosuperapp.com>
Co-authored-by: lizhixuan <zhixuanli219643@sohu-inc.com>
Co-authored-by: LiuVaayne <10231735+vaayne@users.noreply.github.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
MyPrototypeWhat 2025-03-08 01:41:05 +08:00 committed by GitHub
parent 4a06c86412
commit 9afc6989af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1144 additions and 614 deletions

View File

@ -21,7 +21,8 @@ export default defineConfig({
'@llm-tools/embedjs-loader-pdf',
'@llm-tools/embedjs-loader-sitemap',
'@llm-tools/embedjs-libsql',
'@llm-tools/embedjs-loader-image'
'@llm-tools/embedjs-loader-image',
'p-queue'
]
}),
...visualizerPlugin('main')

View File

@ -81,6 +81,7 @@
"fs-extra": "^11.2.0",
"markdown-it": "^14.1.0",
"officeparser": "^4.1.1",
"p-queue": "^8.1.0",
"tokenx": "^0.4.1",
"webdav": "4.11.4"
},

View File

@ -12,3 +12,7 @@ export const isWindows = platform === 'win32' || platform === 'win64'
export const isLinux = platform === 'linux'
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'
// Messages loading configuration
export const INITIAL_MESSAGES_COUNT = 20
export const LOAD_MORE_COUNT = 20

View File

@ -3,6 +3,7 @@ import { isLocalAi } from '@renderer/config/env'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import { useAppDispatch } from '@renderer/store'
import { initializeMessagesState } from '@renderer/store/messages'
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
import { delay, runAsyncFunction } from '@renderer/utils'
import { useLiveQuery } from 'dexie-react-hooks'
@ -25,6 +26,11 @@ export function useAppInit() {
useFullScreenNotice()
// Initialize messages state
useEffect(() => {
dispatch(initializeMessagesState())
}, [dispatch])
useEffect(() => {
avatar?.value && dispatch(setAvatar(avatar.value))
}, [avatar, dispatch])

View File

@ -41,28 +41,34 @@ export async function getTopicById(topicId: string) {
return { ...topic, messages } as Topic
}
export class TopicManager {
static async getTopic(id: string) {
// Convert class to object with functions since class only has static methods
// 只有静态方法,没必要用class
export const TopicManager = {
async getTopic(id: string) {
return await db.topics.get(id)
}
},
static async getTopicMessages(id: string) {
const topic = await this.getTopic(id)
async getAllTopics() {
return await db.topics.toArray()
},
async getTopicMessages(id: string) {
const topic = await TopicManager.getTopic(id)
return topic ? topic.messages : []
}
},
static async removeTopic(id: string) {
const messages = await this.getTopicMessages(id)
async removeTopic(id: string) {
const messages = await TopicManager.getTopicMessages(id)
for (const message of messages) {
await deleteMessageFiles(message)
}
db.topics.delete(id)
}
},
static async clearTopicMessages(id: string) {
const topic = await this.getTopic(id)
async clearTopicMessages(id: string) {
const topic = await TopicManager.getTopic(id)
if (topic) {
for (const message of topic?.messages ?? []) {

View File

@ -31,7 +31,7 @@ const Chat: FC<Props> = (props) => {
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
/>
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} />
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</Main>
{topicPosition === 'right' && showTopics && (
<Tabs

View File

@ -25,15 +25,15 @@ import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/Toke
import { translateText } from '@renderer/services/TranslateService'
import WebSearchService from '@renderer/services/WebSearchService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { sendMessage as _sendMessage } from '@renderer/store/messages'
import { setGenerating, setSearching } from '@renderer/store/runtime'
import { Assistant, FileType, KnowledgeBase, MCPServer, Message, Model, Topic } from '@renderer/types'
import { classNames, delay, getFileExtension, uuid } from '@renderer/utils'
import { Assistant, FileType, KnowledgeBase, MCPServer, MCPServer, Message, Model, Topic } from '@renderer/types'
import { classNames, delay, getFileExtension } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController'
import { getFilesFromDropEvent } from '@renderer/utils/input'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { Button, Popconfirm, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
import Logger from 'electron-log/renderer'
import { debounce, isEmpty } from 'lodash'
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -50,10 +50,10 @@ import MentionModelsButton from './MentionModelsButton'
import MentionModelsInput from './MentionModelsInput'
import SendMessageButton from './SendMessageButton'
import TokenCount from './TokenCount'
interface Props {
assistant: Assistant
setActiveTopic: (topic: Topic) => void
topic: Topic
}
let _text = ''
@ -137,43 +137,28 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
return
}
const message: Message = {
id: uuid(),
role: 'user',
content: text,
assistantId: assistant.id,
topicId: assistant.topics[0].id || uuid(),
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
type: 'text',
status: 'success'
}
if (selectedKnowledgeBases) {
message.knowledgeBaseIds = selectedKnowledgeBases.map((base) => base.id)
}
if (files.length > 0) {
message.files = await FileManager.uploadFiles(files)
}
if (mentionModels.length > 0) {
message.mentions = mentionModels
}
if (enabledMCPs.length > 0) {
message.enabledMCPs = enabledMCPs
}
currentMessageId.current = message.id
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
try {
// Dispatch the sendMessage action with all options
const uploadedFiles = await FileManager.uploadFiles(files)
dispatch(
_sendMessage(text, assistant, assistant.topics[0], {
files: uploadedFiles,
knowledgeBaseIds: selectedKnowledgeBases?.map((base) => base.id),
mentionModels,
enabledMCPs
})
)
// Clear input
setText('')
setFiles([])
setTimeout(() => setText(''), 500)
setTimeout(() => resizeTextArea(), 0)
setExpend(false)
}, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBases, files, mentionModels])
} catch (error) {
console.error('Failed to send message:', error)
}
}, [inputEmpty, text, assistant, files, selectedKnowledgeBases, mentionModels, dispatch])
const translate = async () => {
if (isTranslating) {

View File

@ -1,15 +1,14 @@
import { FONT_FAMILY } from '@renderer/config/constant'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useModel } from '@renderer/hooks/useModel'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { useTopic } from '@renderer/hooks/useTopic'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getContextCount, getMessageModelId } from '@renderer/services/MessagesService'
import { getMessageModelId } from '@renderer/services/MessagesService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/TokenService'
import { Message, Topic } from '@renderer/types'
import { estimateMessageUsage } from '@renderer/services/TokenService'
import { useAppDispatch } from '@renderer/store'
import { updateMessages } from '@renderer/store/messages'
import { Assistant, Message, Topic } from '@renderer/types'
import { classNames, runAsyncFunction } from '@renderer/utils'
import { Divider, Dropdown } from 'antd'
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -24,44 +23,46 @@ import MessageTokens from './MessageTokens'
interface Props {
message: Message
topic?: Topic
topic: Topic
assistant?: Assistant
index?: number
total?: number
hidePresetMessages?: boolean
style?: React.CSSProperties
isGrouped?: boolean
isStreaming?: boolean
onGetMessages?: () => Message[]
onSetMessages?: Dispatch<SetStateAction<Message[]>>
onDeleteMessage?: (message: Message) => Promise<void>
}
const MessageItem: FC<Props> = ({
message: _message,
topic: _topic,
message,
topic,
// assistant,
index,
hidePresetMessages,
isGrouped,
isStreaming = false,
style,
onDeleteMessage,
onSetMessages,
onGetMessages
}) => {
const [message, setMessage] = useState(_message)
const dispatch = useAppDispatch()
const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
const { isBubbleStyle } = useMessageStyle()
const { showMessageDivider, messageFont, fontSize } = useSettings()
const messageContainerRef = useRef<HTMLDivElement>(null)
const topic = useTopic(assistant, _topic?.id)
// const topic = useTopic(assistant, _topic?.id)
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
const [selectedText, setSelectedText] = useState<string>('')
const isLastMessage = index === 0
const isAssistantMessage = message.role === 'assistant'
const showMenubar = !message.status.includes('ing')
const showMenubar = !isStreaming && !message.status.includes('ing')
const fontFamily = useMemo(() => {
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
@ -95,28 +96,7 @@ const MessageItem: FC<Props> = ({
}
}, [])
const onEditMessage = useCallback(
async (msg: Message) => {
if (msg.role === 'user') {
const usage = await estimateMessageUsage(msg)
msg.usage = usage
}
setMessage(msg)
const messages = onGetMessages?.()?.map((m) => (m.id === message.id ? msg : m))
messages && onSetMessages?.(messages)
topic && db.topics.update(topic.id, { messages })
if (messages) {
const tokensCount = await estimateHistoryTokens(assistant, messages)
const contextCount = getContextCount(assistant, messages)
EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, { tokensCount, contextCount })
}
},
[message.id, onGetMessages, onSetMessages, topic, assistant]
)
const messageHighlightHandler = (highlight: boolean = true) => {
const messageHighlightHandler = useCallback((highlight: boolean = true) => {
if (messageContainerRef.current) {
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' })
if (highlight) {
@ -127,62 +107,25 @@ const MessageItem: FC<Props> = ({
}, 500)
}
}
}
}, [])
useEffect(() => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, messageHighlightHandler),
EventEmitter.on(EVENT_NAMES.RESEND_MESSAGE + ':' + message.id, onEditMessage)
]
const unsubscribes = [EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, messageHighlightHandler)]
return () => unsubscribes.forEach((unsub) => unsub())
}, [message, onEditMessage])
}, [message.id, messageHighlightHandler])
useEffect(() => {
if (message.role === 'user' && !message.usage) {
if (message.role === 'user' && !message.usage && topic) {
runAsyncFunction(async () => {
const usage = await estimateMessageUsage(message)
setMessage({ ...message, usage })
const topic = await db.topics.get({ id: message.topicId })
const messages = topic?.messages.map((m) => (m.id === message.id ? { ...m, usage } : m))
db.topics.update(message.topicId, { messages })
})
}
}, [message])
useEffect(() => {
if (topic && onGetMessages && onSetMessages) {
if (message.status === 'sending') {
const messages = onGetMessages()
const assistantWithModel = message.model ? { ...assistant, model: message.model } : assistant
if (topic.prompt) {
assistantWithModel.prompt = assistantWithModel.prompt
? `${assistantWithModel.prompt}\n${topic.prompt}`
: topic.prompt
}
fetchChatCompletion({
message,
messages: messages
.filter((m) => !m.status.includes('ing'))
.slice(
0,
messages.findIndex((m) => m.id === message.id)
),
assistant: assistantWithModel,
onResponse: (msg) => {
setMessage(msg)
if (msg.status !== 'pending') {
const _messages = onGetMessages().map((m) => (m.id === msg.id ? msg : m))
onSetMessages(_messages)
db.topics.update(topic.id, { messages: _messages })
}
if (topic) {
await dispatch(
updateMessages(topic, onGetMessages?.()?.map((m) => (m.id === message.id ? { ...m, usage } : m)) || [])
)
}
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message.status])
}, [message, topic, dispatch, onGetMessages])
if (hidePresetMessages && message.isPreset) {
return null
@ -235,15 +178,15 @@ const MessageItem: FC<Props> = ({
<MessageTokens message={message} isLastMessage={isLastMessage} />
<MessageMenubar
message={message}
assistantModel={assistant?.model}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef}
setModel={setModel}
onEditMessage={onEditMessage}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>

View File

@ -1,27 +1,28 @@
import Scrollbar from '@renderer/components/Scrollbar'
import { useSettings } from '@renderer/hooks/useSettings'
import { MultiModelMessageStyle } from '@renderer/store/settings'
import { Message, Topic } from '@renderer/types'
import type { Message, Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Popover } from 'antd'
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
import MessageItem from './Message'
import MessageGroupMenuBar from './MessageGroupMenuBar'
import MessageStream from './MessageStream'
interface Props {
messages: (Message & { index: number })[]
topic?: Topic
topic: Topic
hidePresetMessages?: boolean
onGetMessages?: () => Message[]
onSetMessages?: Dispatch<SetStateAction<Message[]>>
onDeleteMessage?: (message: Message) => Promise<void>
onDeleteGroupMessages?: (askId: string) => Promise<void>
onGetMessages: () => Message[]
onSetMessages: Dispatch<SetStateAction<Message[]>>
onDeleteMessage: (message: Message) => Promise<void>
onDeleteGroupMessages: (askId: string) => Promise<void>
}
const MessageGroup: FC<Props> = ({
const MessageGroup = ({
messages,
topic,
hidePresetMessages,
@ -29,7 +30,7 @@ const MessageGroup: FC<Props> = ({
onSetMessages,
onGetMessages,
onDeleteGroupMessages
}) => {
}: Props) => {
const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings()
const { t } = useTranslation()
@ -43,7 +44,10 @@ const MessageGroup: FC<Props> = ({
const isHorizontal = multiModelMessageStyle === 'horizontal'
const isGrid = multiModelMessageStyle === 'grid'
const onDelete = useCallback(async () => {
const handleDeleteGroup = useCallback(async () => {
const askId = messages[0]?.askId
if (!askId) return
window.modal.confirm({
title: t('message.group.delete.title'),
content: t('message.group.delete.content'),
@ -52,10 +56,7 @@ const MessageGroup: FC<Props> = ({
danger: true
},
okText: t('common.delete'),
onOk: () => {
const askId = messages[0].askId
askId && onDeleteGroupMessages?.(askId)
}
onOk: () => onDeleteGroupMessages(askId)
})
}, [messages, onDeleteGroupMessages, t])
@ -63,6 +64,72 @@ const MessageGroup: FC<Props> = ({
setSelectedIndex(messageLength - 1)
}, [messageLength])
const renderMessage = useCallback(
(message: Message & { index: number }, index: number) => {
const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped
const messageProps = {
isGrouped,
message,
topic,
index: message.index,
hidePresetMessages,
style: {
paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15
},
onSetMessages,
onDeleteMessage,
onGetMessages
}
const messageWrapper = (
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
key={message.id}
className={message.role === 'assistant' && isHorizontal && isGrouped ? 'group-message-wrapper' : ''}>
<MessageStream {...messageProps} />
</MessageWrapper>
)
if (isGridGroupMessage) {
return (
<Popover
key={message.id}
content={
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
$isInPopover={true}>
<MessageStream {...messageProps} />
</MessageWrapper>
}
trigger={gridPopoverTrigger}
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}>
{messageWrapper}
</Popover>
)
}
return messageWrapper
},
[
isGrid,
isGrouped,
isHorizontal,
multiModelMessageStyle,
selectedIndex,
topic,
hidePresetMessages,
onSetMessages,
onDeleteMessage,
onGetMessages,
gridPopoverTrigger
]
)
return (
<GroupContainer
$isGrouped={isGrouped}
@ -73,86 +140,7 @@ const MessageGroup: FC<Props> = ({
$layout={multiModelMessageStyle}
$gridColumns={gridColumns}
className={classNames([isGrouped && 'group-grid-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
{messages.map((message, index) => {
const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped
if (isGridGroupMessage) {
return (
<Popover
content={
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
$isInPopover={true}
key={message.id}>
<MessageItem
isGrouped={isGrouped}
message={message}
topic={topic}
index={message.index}
hidePresetMessages={hidePresetMessages}
style={{
paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15
}}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageWrapper>
}
trigger={gridPopoverTrigger}
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}
key={message.id}>
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
key={message.id}>
<MessageItem
isGrouped={isGrouped}
message={message}
topic={topic}
index={message.index}
hidePresetMessages={hidePresetMessages}
style={
gridPopoverTrigger === 'hover' && isGrouped
? {
paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15,
overflow: isGrouped ? 'hidden' : 'auto',
maxHeight: isGrouped ? '280px' : 'unset'
}
: undefined
}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageWrapper>
</Popover>
)
}
return (
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
key={message.id}
className={message.role === 'assistant' && isHorizontal && isGrouped ? 'group-message-wrapper' : ''}>
<MessageItem
isGrouped={isGrouped}
message={message}
topic={topic}
index={message.index}
hidePresetMessages={hidePresetMessages}
style={{ paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15 }}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageWrapper>
)
})}
{messages.map((message, index) => renderMessage(message, index))}
</GridContainer>
{isGrouped && (
<MessageGroupMenuBar
@ -161,7 +149,7 @@ const MessageGroup: FC<Props> = ({
messages={messages}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
onDelete={onDelete}
onDelete={handleDeleteGroup}
/>
)}
</GroupContainer>

View File

@ -19,13 +19,18 @@ import { modelGenerating } from '@renderer/hooks/useRuntime'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService'
import { translateText } from '@renderer/services/TranslateService'
import { Message, Model } from '@renderer/types'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
captureScrollableDivAsBlob,
captureScrollableDivAsDataURL,
removeTrailingDoubleSpaces,
uuid
} from '@renderer/utils'
clearStreamMessage,
commitStreamMessage,
resendMessage,
setStreamMessage,
updateMessage
} from '@renderer/store/messages'
import { selectTopicMessages } from '@renderer/store/messages'
import { Message, Model } from '@renderer/types'
import { Assistant, Topic } from '@renderer/types'
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeTrailingDoubleSpaces } from '@renderer/utils'
import {
exportMarkdownToNotion,
exportMarkdownToYuque,
@ -35,21 +40,21 @@ import {
import { Button, Dropdown, Popconfirm, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash'
import { FC, useCallback, useMemo, useState } from 'react'
import { FC, memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
message: Message
assistantModel?: Model
model?: Model
assistant: Assistant
topic: Topic
model: Model
index?: number
isGrouped?: boolean
isLastMessage: boolean
isAssistantMessage: boolean
messageContainerRef: React.RefObject<HTMLDivElement>
setModel: (model: Model) => void
onEditMessage?: (message: Message) => void
onDeleteMessage?: (message: Message) => Promise<void>
onGetMessages?: () => Message[]
}
@ -59,18 +64,21 @@ const MessageMenubar: FC<Props> = (props) => {
message,
index,
isGrouped,
model,
isLastMessage,
isAssistantMessage,
assistantModel,
assistant,
topic,
model,
messageContainerRef,
onEditMessage,
onDeleteMessage,
onGetMessages
} = props
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const [isTranslating, setIsTranslating] = useState(false)
const assistantModel = assistant?.model
const dispatch = useAppDispatch()
const messages = useAppSelector((state) => selectTopicMessages(state, topic.id))
const isUserMessage = message.role === 'user'
@ -88,50 +96,33 @@ const MessageMenubar: FC<Props> = (props) => {
const onNewBranch = useCallback(async () => {
await modelGenerating()
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
window.message.success({
content: t('chat.message.new.branch.created'),
key: 'new-branch'
})
window.message.success({ content: t('chat.message.new.branch.created'), key: 'new-branch' })
}, [index, t])
const onResend = useCallback(async () => {
const handleResendUserMessage = useCallback(
async (messageUpdate?: Message) => {
// messageUpdate 为了处理用户消息更改后的message
await modelGenerating()
const _messages = onGetMessages?.() || []
const groupdMessages = _messages.filter((m) => m.askId === message.id)
const groupdMessages = messages.filter((m) => m.askId === message.id)
// Resend all groupd messages
// Resend all grouped messages
if (!isEmpty(groupdMessages)) {
for (const assistantMessage of groupdMessages) {
const _model = assistantMessage.model || assistantModel
EventEmitter.emit(
EVENT_NAMES.RESEND_MESSAGE + ':' + assistantMessage.id,
resetAssistantMessage(assistantMessage, _model)
)
await dispatch(resendMessage({ ...assistantMessage, model: _model }, assistant, topic))
}
return
}
// If there is no groupd message, resend next message
const index = _messages.findIndex((m) => m.id === message.id)
const nextIndex = index + 1
const nextMessage = _messages[nextIndex]
await dispatch(resendMessage(messageUpdate ?? message, assistant, topic))
},
[message, assistantModel, model, onDeleteMessage, onGetMessages, dispatch, assistant, topic]
)
if (nextMessage && nextMessage.role === 'assistant') {
EventEmitter.emit(EVENT_NAMES.RESEND_MESSAGE + ':' + nextMessage.id, {
...nextMessage,
content: '',
status: 'sending',
model: assistantModel || model,
translatedContent: undefined
})
}
// If next message is not exist or next message role is user, delete current message and resend
if (!nextMessage || nextMessage.role === 'user') {
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, { ...message, id: uuid() })
onDeleteMessage?.(message)
}
}, [assistantModel, message, model, onDeleteMessage, onGetMessages])
// const onResendUserMessage = useCallback(async () => {
// // await dispatch(resendMessage(message, assistant, topic))
// onResend()
// }, [message, dispatch, assistant, topic])
const onEdit = useCallback(async () => {
let resendMessage = false
@ -152,43 +143,54 @@ const MessageMenubar: FC<Props> = (props) => {
) : null
}
})
if (editedText && editedText !== message.content) {
// 同步修改store中用户消息
dispatch(updateMessage({ topicId: topic.id, messageId: message.id, updates: { content: editedText } }))
if (editedText) {
await onEditMessage?.({ ...message, content: editedText })
// const updatedMessages = onGetMessages?.() || []
// dispatch(updateMessages(topic, updatedMessages))
}
resendMessage && onResend()
}, [message, onEditMessage, onResend, t])
const onResendUserMessage = useCallback(async () => {
await onEditMessage?.({ ...message, content: message.content })
onResend && onResend()
}, [message, onEditMessage, onResend])
if (resendMessage) handleResendUserMessage({ ...message, content: editedText })
}, [message, dispatch, topic, onGetMessages, handleResendUserMessage, t])
const handleTranslate = useCallback(
async (language: string) => {
if (isTranslating) return
onEditMessage?.({ ...message, translatedContent: t('translate.processing') })
dispatch(
updateMessage({
topicId: topic.id,
messageId: message.id,
updates: { translatedContent: t('translate.processing') }
})
)
setIsTranslating(true)
try {
await translateText(message.content, language, (text) =>
onEditMessage?.({ ...message, translatedContent: text })
await translateText(message.content, language, (text) => {
// 使用 setStreamMessage 来更新翻译内容
dispatch(
setStreamMessage({
topicId: topic.id,
message: { ...message, translatedContent: text }
})
)
})
// 翻译完成后,提交流消息
dispatch(commitStreamMessage({ topicId: topic.id }))
} catch (error) {
console.error('Translation failed:', error)
window.message.error({
content: t('translate.error.failed'),
key: 'translate-message'
})
onEditMessage?.({ ...message, translatedContent: undefined })
window.message.error({ content: t('translate.error.failed'), key: 'translate-message' })
dispatch(updateMessage({ topicId: topic.id, messageId: message.id, updates: { translatedContent: undefined } }))
dispatch(clearStreamMessage({ topicId: topic.id }))
} finally {
setIsTranslating(false)
}
},
[isTranslating, message, onEditMessage, t]
[isTranslating, message, dispatch, topic, t]
)
const dropdownItems = useMemo(
@ -202,18 +204,8 @@ const MessageMenubar: FC<Props> = (props) => {
window.api.file.save(fileName, message.content)
}
},
{
label: t('common.edit'),
key: 'edit',
icon: <EditOutlined />,
onClick: onEdit
},
{
label: t('chat.message.new.branch'),
key: 'new-branch',
icon: <ForkOutlined />,
onClick: onNewBranch
},
{ label: t('common.edit'), key: 'edit', icon: <EditOutlined />, onClick: onEdit },
{ label: t('chat.message.new.branch'), key: 'new-branch', icon: <ForkOutlined />, onClick: onNewBranch },
{
label: t('chat.topics.export.title'),
key: 'export',
@ -241,11 +233,7 @@ const MessageMenubar: FC<Props> = (props) => {
}
}
},
{
label: t('chat.topics.export.md'),
key: 'markdown',
onClick: () => exportMessageAsMarkdown(message)
},
{ label: t('chat.topics.export.md'), key: 'markdown', onClick: () => exportMessageAsMarkdown(message) },
{
label: t('chat.topics.export.word'),
@ -284,7 +272,8 @@ const MessageMenubar: FC<Props> = (props) => {
await modelGenerating()
const selectedModel = isGrouped ? model : assistantModel
const _message = resetAssistantMessage(message, selectedModel)
onEditMessage?.(_message)
dispatch(updateMessage({ topicId: topic.id, messageId: message.id, updates: _message }))
dispatch(resendMessage(_message, assistant, topic))
}
const onMentionModel = async (e: React.MouseEvent) => {
@ -293,28 +282,24 @@ const MessageMenubar: FC<Props> = (props) => {
const selectedModel = await SelectModelPopup.show({ model })
if (!selectedModel) return
const _message: Message = resetAssistantMessage(message, selectedModel)
if (message.askId && message.model) {
return EventEmitter.emit(EVENT_NAMES.APPEND_MESSAGE, { ..._message, id: uuid() })
}
onEditMessage?.(_message)
// const mentionModelMessage: Message = resetAssistantMessage(message, selectedModel)
// dispatch(updateMessage({ topicId: topic.id, messageId: message.id, updates: _message }))
await dispatch(resendMessage(message, { ...assistant, model: selectedModel }, topic, true))
}
const onUseful = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
onEditMessage?.({ ...message, useful: !message.useful })
dispatch(updateMessage({ topicId: topic.id, messageId: message.id, updates: { useful: !message.useful } }))
},
[message, onEditMessage]
[message, dispatch, topic]
)
return (
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
{message.role === 'user' && (
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onResendUserMessage}>
<ActionButton className="message-action-button" onClick={() => handleResendUserMessage()}>
<SyncOutlined />
</ActionButton>
</Tooltip>
@ -365,7 +350,14 @@ const MessageMenubar: FC<Props> = (props) => {
{
label: '✖ ' + t('translate.close'),
key: 'translate-close',
onClick: () => onEditMessage?.({ ...message, translatedContent: undefined })
onClick: () =>
dispatch(
updateMessage({
topicId: topic.id,
messageId: message.id,
updates: { translatedContent: undefined }
})
)
}
],
onClick: (e) => e.domEvent.stopPropagation()
@ -467,4 +459,4 @@ const ReSendButton = styled(Button)`
left: 0;
`
export default MessageMenubar
export default memo(MessageMenubar)

View File

@ -0,0 +1,79 @@
import { useAppSelector } from '@renderer/store'
import { selectStreamMessage } from '@renderer/store/messages'
import { Assistant, Message, Topic } from '@renderer/types'
import styled from 'styled-components'
import MessageItem from './Message'
interface MessageStreamProps {
message: Message
topic: Topic
assistant?: Assistant
index?: number
hidePresetMessages?: boolean
isGrouped?: boolean
style?: React.CSSProperties
onSetMessages?: React.Dispatch<React.SetStateAction<Message[]>>
onDeleteMessage?: (message: Message) => Promise<void>
onGetMessages?: () => Message[]
}
const MessageStreamContainer = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
`
const MessageStream: React.FC<MessageStreamProps> = ({
message: _message,
topic,
assistant,
index,
hidePresetMessages,
isGrouped,
style,
onDeleteMessage,
onSetMessages,
onGetMessages
}) => {
// 获取流式消息
const streamMessage = useAppSelector((state) => selectStreamMessage(state, _message.topicId, _message.id))
// 获取常规消息
const regularMessage = useAppSelector((state) => {
// 如果是用户消息直接使用传入的_message
if (_message.role === 'user') {
return _message
}
// 对于助手消息从store中查找最新状态
const topicMessages = state.messages.messagesByTopic[_message.topicId]
if (!topicMessages) return _message
return topicMessages.find((m) => m.id === _message.id) || _message
})
// 在hooks调用后进行条件判断
const isStreaming = !!(streamMessage && streamMessage.id === _message.id)
const message = isStreaming ? streamMessage : regularMessage
// console.log('streamMessage', streamMessage)
// console.log('regularMessage', regularMessage)
return (
<MessageStreamContainer>
<MessageItem
message={message}
topic={topic}
assistant={assistant}
index={index}
hidePresetMessages={hidePresetMessages}
isGrouped={isGrouped}
style={style}
isStreaming={isStreaming}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageStreamContainer>
)
}
export default MessageStream

View File

@ -1,25 +1,27 @@
import Scrollbar from '@renderer/components/Scrollbar'
import db from '@renderer/databases'
import { LOAD_MORE_COUNT } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { getTopic, TopicManager } from '@renderer/hooks/useTopic'
import { getTopic } from '@renderer/hooks/useTopic'
import { fetchMessagesSummary } from '@renderer/services/ApiService'
import { getDefaultTopic } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import {
deleteMessageFiles,
getAssistantMessage,
getContextCount,
getGroupedMessages,
getUserMessage
} from '@renderer/services/MessagesService'
import { getContextCount, getGroupedMessages, getUserMessage } from '@renderer/services/MessagesService'
import { estimateHistoryTokens } from '@renderer/services/TokenService'
import { Assistant, Message, Topic } from '@renderer/types'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
clearTopicMessages,
selectDisplayCount,
selectLoading,
selectTopicMessages,
updateMessages
} from '@renderer/store/messages'
import type { Assistant, Message, Topic } from '@renderer/types'
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, runAsyncFunction } from '@renderer/utils'
import { t } from 'i18next'
import { flatten, last, take } from 'lodash'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { last } from 'lodash'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
@ -29,29 +31,48 @@ import MessageGroup from './MessageGroup'
import NarrowLayout from './NarrowLayout'
import Prompt from './Prompt'
interface Props {
interface MessagesProps {
assistant: Assistant
topic: Topic
setActiveTopic: (topic: Topic) => void
}
const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const [messages, setMessages] = useState<Message[]>([])
const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic }) => {
const { t } = useTranslation()
const { showTopics, topicPosition, showAssistants, enableTopicNaming } = useSettings()
const { updateTopic } = useAssistant(assistant.id)
const messages = useAppSelector((state) => selectTopicMessages(state, topic.id))
const loading = useAppSelector(selectLoading)
const displayCount = useAppSelector(selectDisplayCount)
const dispatch = useAppDispatch()
const containerRef = useRef<HTMLDivElement>(null)
const [displayMessages, setDisplayMessages] = useState<Message[]>([])
const [hasMore, setHasMore] = useState(true)
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const messagesRef = useRef(messages)
const { updateTopic, addTopic } = useAssistant(assistant.id)
const { showTopics, topicPosition, showAssistants, enableTopicNaming } = useSettings()
useEffect(() => {
const reversedMessages = [...messages].reverse()
const newDisplayMessages = reversedMessages.slice(0, displayCount)
const groupedMessages = getGroupedMessages(displayMessages)
setDisplayMessages(newDisplayMessages)
setHasMore(messages.length > displayCount)
}, [messages, displayCount])
const INITIAL_MESSAGES_COUNT = 20
const LOAD_MORE_COUNT = 20
const handleDeleteMessage = useCallback(
async (message: Message) => {
const newMessages = messages.filter((m) => m.id !== message.id)
await dispatch(updateMessages(topic, newMessages))
},
[dispatch, topic, messages]
)
messagesRef.current = messages
const handleDeleteGroupMessages = useCallback(
async (askId: string) => {
const newMessages = messages.filter((m) => m.askId !== askId)
await dispatch(updateMessages(topic, newMessages))
},
[dispatch, topic, messages]
)
const maxWidth = useMemo(() => {
const showRightTopics = showTopics && topicPosition === 'right'
@ -64,58 +85,27 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
setTimeout(() => containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'auto' }), 50)
}, [])
const onSendMessage = useCallback(
async (message: Message) => {
const assistantMessages: Message[] = []
// const onAppendMessageMemo = useCallback(
// async (message: Message) => {
// const newMessages = [...messages, message]
// await dispatch(updateMessages(topic, newMessages))
// },
// [topic, dispatch, messages]
// )
if (message.mentions?.length) {
message.mentions.forEach((m) => {
const assistantMessage = getAssistantMessage({ assistant: { ...assistant, model: m }, topic })
assistantMessage.model = m
assistantMessage.askId = message.id
assistantMessages.push(assistantMessage)
})
} else {
const assistantMessage = getAssistantMessage({ assistant, topic })
assistantMessage.askId = message.id
assistantMessages.push(assistantMessage)
}
setMessages((prev) => {
const messages = prev.concat([message, ...assistantMessages])
db.topics.put({ id: topic.id, messages })
return messages
})
scrollToBottom()
},
[assistant, scrollToBottom, topic]
)
const onAppendMessage = useCallback(
(message: Message) => {
setMessages((prev) => {
const messages = prev.concat([message])
db.topics.put({ id: topic.id, messages })
return messages
})
},
[topic.id]
)
const autoRenameTopic = useCallback(async () => {
const autoRenameTopicMemo = useCallback(async () => {
const _topic = getTopic(assistant, topic.id)
// If the topic auto naming is not enabled, use the first message content as the topic name
if (!enableTopicNaming) {
const topicName = messages[0].content.substring(0, 50)
const topicName = messages[0]?.content.substring(0, 50)
if (topicName) {
const data = { ..._topic, name: topicName } as Topic
setActiveTopic(data)
updateTopic(data)
}
return
}
// Auto rename the topic
if (_topic && _topic.name === t('chat.default.topic.name') && messages.length >= 2) {
const summaryText = await fetchMessagesSummary({ messages, assistant })
if (summaryText) {
@ -124,60 +114,33 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
updateTopic(data)
}
}
}, [assistant, enableTopicNaming, messages, setActiveTopic, topic.id, updateTopic])
const onDeleteMessage = useCallback(
async (message: Message) => {
const _messages = messages.filter((m) => m.id !== message.id)
setMessages(_messages)
setDisplayMessages(_messages)
await db.topics.update(topic.id, { messages: _messages })
await deleteMessageFiles(message)
},
[messages, topic.id]
)
const onDeleteGroupMessages = useCallback(
async (askId: string) => {
const _messages = messages.filter((m) => m.askId !== askId && m.id !== askId)
setMessages(_messages)
setDisplayMessages(_messages)
await db.topics.update(topic.id, { messages: _messages })
for (const message of _messages) {
await deleteMessageFiles(message)
}
},
[messages, topic.id]
)
const onGetMessages = useCallback(() => {
return messagesRef.current
}, [])
}, [assistant, enableTopicNaming, messages, setActiveTopic, topic.id, updateTopic, t])
useEffect(() => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, onSendMessage),
EventEmitter.on(EVENT_NAMES.APPEND_MESSAGE, onAppendMessage),
EventEmitter.on(EVENT_NAMES.RECEIVE_MESSAGE, async () => {
setTimeout(() => EventEmitter.emit(EVENT_NAMES.AI_AUTO_RENAME), 100)
// EventEmitter.on(EVENT_NAMES.APPEND_MESSAGE, onAppendMessageMemo),
// EventEmitter.on(EVENT_NAMES.RECEIVE_MESSAGE, () => {
// setTimeout(() => EventEmitter.emit(EVENT_NAMES.AI_AUTO_RENAME), 100)
// }),
EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, () => {
scrollToBottom()
}),
EventEmitter.on(EVENT_NAMES.AI_AUTO_RENAME, autoRenameTopic),
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, (data: Topic) => {
EventEmitter.on(EVENT_NAMES.AI_AUTO_RENAME, autoRenameTopicMemo),
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, async (data: Topic) => {
const defaultTopic = getDefaultTopic(assistant.id)
// Clear messages of other topics
if (data && data.id !== topic.id) {
TopicManager.clearTopicMessages(data.id)
updateTopic({ ...data, name: defaultTopic.name, messages: [] })
await dispatch(clearTopicMessages(data.id))
updateTopic({ ...data, name: defaultTopic.name } as Topic)
return
}
// Clear messages of current topic
setMessages([])
await dispatch(clearTopicMessages(topic.id))
setDisplayMessages([])
const _topic = getTopic(assistant, topic.id)
_topic && updateTopic({ ..._topic, name: defaultTopic.name, messages: [] })
TopicManager.clearTopicMessages(topic.id)
if (_topic) {
updateTopic({ ..._topic, name: defaultTopic.name } as Topic)
}
}),
EventEmitter.on(EVENT_NAMES.COPY_TOPIC_IMAGE, async () => {
await captureScrollableDivAsBlob(containerRef, async (blob) => {
@ -192,68 +155,29 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
window.api.file.saveImage(topic.name, imageData)
}
}),
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => {
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, async () => {
const lastMessage = last(messages)
if (lastMessage && lastMessage.type === 'clear') {
onDeleteMessage(lastMessage)
if (lastMessage?.type === 'clear') {
handleDeleteMessage(lastMessage)
scrollToBottom()
return
}
if (messages.length === 0) {
return
}
setMessages((prev) => {
const messages = prev.concat([getUserMessage({ assistant, topic, type: 'clear' })])
db.topics.put({ id: topic.id, messages })
return messages
})
if (messages.length === 0) return
const clearMessage = getUserMessage({ assistant, topic, type: 'clear' })
const newMessages = [...messages, clearMessage]
await dispatch(updateMessages(topic, newMessages))
scrollToBottom()
}),
EventEmitter.on(EVENT_NAMES.NEW_BRANCH, async (index: number) => {
const newTopic = getDefaultTopic(assistant.id)
newTopic.name = topic.name
const branchMessages = take(messages, messages.length - index)
// 将分支的消息放入数据库
await db.topics.add({ id: newTopic.id, messages: branchMessages })
addTopic(newTopic)
setActiveTopic(newTopic)
autoRenameTopic()
// 由于复制了消息,消息中附带的文件的总数变了,需要更新
const filesArr = branchMessages.map((m) => m.files)
const files = flatten(filesArr).filter(Boolean)
files.map(async (f) => {
const file = await db.files.get({ id: f?.id })
file && db.files.update(file.id, { count: file.count + 1 })
})
})
]
return () => unsubscribes.forEach((unsub) => unsub())
}, [
addTopic,
assistant,
autoRenameTopic,
messages,
onAppendMessage,
onDeleteMessage,
onSendMessage,
scrollToBottom,
setActiveTopic,
topic,
updateTopic
])
useEffect(() => {
runAsyncFunction(async () => {
const messages = (await TopicManager.getTopicMessages(topic.id)) || []
setMessages(messages)
})
}, [topic.id])
return () => {
for (const unsub of unsubscribes) {
unsub()
}
}
}, [assistant, autoRenameTopicMemo, dispatch, messages, handleDeleteMessage, scrollToBottom, topic, updateTopic])
useEffect(() => {
runAsyncFunction(async () => {
@ -264,21 +188,10 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
})
}, [assistant, messages])
// 初始化显示最新的消息
useEffect(() => {
if (messages.length > 0) {
const reversedMessages = [...messages].reverse()
setDisplayMessages(reversedMessages.slice(0, INITIAL_MESSAGES_COUNT))
setHasMore(messages.length > INITIAL_MESSAGES_COUNT)
}
}, [messages])
// 加载更多历史消息
const loadMoreMessages = useCallback(() => {
if (!hasMore || isLoadingMore) return
setIsLoadingMore(true)
setTimeout(() => {
const currentLength = displayMessages.length
const reversedMessages = [...messages].reverse()
@ -288,7 +201,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
setHasMore(currentLength + LOAD_MORE_COUNT < messages.length)
setIsLoadingMore(false)
}, 300)
}, [displayMessages, hasMore, isLoadingMore, messages])
}, [displayMessages.length, hasMore, isLoadingMore, messages])
useShortcut('copy_last_message', () => {
const lastMessage = last(messages)
@ -315,19 +228,19 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
inverse={true}
scrollableTarget="messages">
<ScrollContainer>
<LoaderContainer $loading={isLoadingMore}>
<LoaderContainer $loading={loading || isLoadingMore}>
<BeatLoader size={8} color="var(--color-text-2)" />
</LoaderContainer>
{Object.entries(groupedMessages).map(([key, messages]) => (
{Object.entries(getGroupedMessages(displayMessages)).map(([key, groupMessages]) => (
<MessageGroup
key={key}
messages={messages}
messages={groupMessages}
topic={topic}
hidePresetMessages={assistant.settings?.hideMessages}
onSetMessages={setMessages}
onDeleteMessage={onDeleteMessage}
onDeleteGroupMessages={onDeleteGroupMessages}
onGetMessages={onGetMessages}
onSetMessages={setDisplayMessages}
onDeleteMessage={handleDeleteMessage}
onDeleteGroupMessages={handleDeleteGroupMessages}
onGetMessages={() => messages}
/>
))}
</ScrollContainer>

View File

@ -1,13 +1,11 @@
import { fetchSuggestions } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch } from '@renderer/store'
import { sendMessage } from '@renderer/store/messages'
import { Assistant, Message, Suggestion } from '@renderer/types'
import { uuid } from '@renderer/utils'
import dayjs from 'dayjs'
import { last } from 'lodash'
import { FC, useEffect, useState } from 'react'
import { FC, memo, useEffect, useState } from 'react'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
interface Props {
assistant: Assistant
messages: Message[]
@ -16,40 +14,46 @@ interface Props {
const suggestionsMap = new Map<string, Suggestion[]>()
const Suggestions: FC<Props> = ({ assistant, messages }) => {
const dispatch = useAppDispatch()
const [suggestions, setSuggestions] = useState<Suggestion[]>(
suggestionsMap.get(messages[messages.length - 1]?.id) || []
)
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const onClick = (s: Suggestion) => {
const message: Message = {
id: uuid(),
role: 'user',
content: s.content,
assistantId: assistant.id,
topicId: assistant.topics[0].id || uuid(),
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
type: 'text',
status: 'success'
const handleSuggestionClick = async (content: string) => {
await dispatch(sendMessage(content, assistant, assistant.topics[0]))
}
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
const suggestionsHandle = async () => {
if (loadingSuggestions) return
try {
setLoadingSuggestions(true)
const _suggestions = await fetchSuggestions({
assistant,
messages
})
if (_suggestions.length) {
setSuggestions(_suggestions)
suggestionsMap.set(messages[messages.length - 1].id, _suggestions)
}
} finally {
setLoadingSuggestions(false)
}
}
useEffect(() => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.RECEIVE_MESSAGE, async (msg: Message) => {
setLoadingSuggestions(true)
const _suggestions = await fetchSuggestions({ assistant, messages: [...messages, msg] })
if (_suggestions.length) {
setSuggestions(_suggestions)
suggestionsMap.set(msg.id, _suggestions)
}
setLoadingSuggestions(false)
})
]
return () => unsubscribes.forEach((unsub) => unsub())
}, [assistant, messages])
suggestionsHandle()
// const unsubscribes = [
// EventEmitter.on(EVENT_NAMES.RECEIVE_MESSAGE, async (msg: Message) => {
// ]
// return () => {
// for (const unsub of unsubscribes) {
// unsub()
// }
// }
}, []) // Remove messages dependency
useEffect(() => {
setSuggestions(suggestionsMap.get(messages[messages.length - 1]?.id) || [])
@ -58,7 +62,6 @@ const Suggestions: FC<Props> = ({ assistant, messages }) => {
if (last(messages)?.status !== 'success') {
return null
}
if (loadingSuggestions) {
return (
<Container>
@ -75,7 +78,7 @@ const Suggestions: FC<Props> = ({ assistant, messages }) => {
<Container>
<SuggestionsContainer>
{suggestions.map((s, i) => (
<SuggestionItem key={i} onClick={() => onClick(s)}>
<SuggestionItem key={i} onClick={() => handleSuggestionClick(s.content)}>
{s.content}
</SuggestionItem>
))}
@ -117,4 +120,4 @@ const SuggestionItem = styled.div`
}
`
export default Suggestions
export default memo(Suggestions)

View File

@ -129,6 +129,7 @@ export async function fetchChatCompletion({
}
}
} catch (error: any) {
console.log('error', error)
message.status = 'error'
message.error = formatMessageError(error)
}
@ -216,7 +217,6 @@ export async function fetchSuggestions({
assistant: Assistant
}): Promise<Suggestion[]> {
const model = assistant.model
if (!model) {
return []
}

View File

@ -4,7 +4,7 @@ export const EventEmitter = new Emittery()
export const EVENT_NAMES = {
SEND_MESSAGE: 'SEND_MESSAGE',
APPEND_MESSAGE: 'APPEND_MESSAGE',
// APPEND_MESSAGE: 'APPEND_MESSAGE',
RECEIVE_MESSAGE: 'RECEIVE_MESSAGE',
AI_AUTO_RENAME: 'AI_AUTO_RENAME',
CLEAR_MESSAGES: 'CLEAR_MESSAGES',

View File

@ -8,6 +8,7 @@ import assistants from './assistants'
import knowledge from './knowledge'
import llm from './llm'
import mcp from './mcp'
import messagesReducer from './messages'
import migrate from './migrate'
import minapps from './minapps'
import paintings from './paintings'
@ -27,6 +28,7 @@ const rootReducer = combineReducers({
knowledge,
minapps,
websearch,
messages: messagesReducer,
mcp
})
@ -35,7 +37,7 @@ const persistedReducer = persistReducer(
key: 'cherry-studio',
storage,
version: 77,
blacklist: ['runtime'],
blacklist: ['runtime', 'messages'],
migrate
},
rootReducer

View File

@ -0,0 +1,509 @@
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { createSelector } from '@reduxjs/toolkit'
import db from '@renderer/databases'
import { TopicManager } from '@renderer/hooks/useTopic'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getAssistantMessage, getUserMessage, resetAssistantMessage } from '@renderer/services/MessagesService'
import type { AppDispatch, RootState } from '@renderer/store'
import type { Assistant, FileType, MCPServer, Message, Model, Topic } from '@renderer/types'
import { clearTopicQueue, getTopicQueue, waitForTopicQueue } from '@renderer/utils/queue'
import { throttle } from 'lodash'
const convertToDBFormat = (messages: Message[]): Message[] => {
return [...messages].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
}
export interface MessagesState {
messagesByTopic: Record<string, Message[]>
streamMessagesByTopic: Record<string, Record<string, Message | null>>
currentTopic: string
loading: boolean
displayCount: number
error: string | null
}
const initialState: MessagesState = {
messagesByTopic: {},
streamMessagesByTopic: {},
currentTopic: '',
loading: false,
displayCount: 20,
error: null
}
export const initializeMessagesState = createAsyncThunk('messages/initialize', async () => {
// Get all topics from database
const topics = await TopicManager.getAllTopics()
const messagesByTopic: Record<string, Message[]> = {}
// Group topics by assistantId and update messagesByTopic
for (const topic of topics) {
if (topic.messages && topic.messages.length > 0) {
messagesByTopic[topic.id] = topic.messages.map((msg) => ({ ...msg }))
}
}
return messagesByTopic
})
const messagesSlice = createSlice({
name: 'messages',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload
},
setDisplayCount: (state, action: PayloadAction<number>) => {
state.displayCount = action.payload
},
addMessage: (state, action: PayloadAction<{ topicId: string; messages: Message | Message[] }>) => {
const { topicId, messages } = action.payload
if (!state.messagesByTopic[topicId]) {
state.messagesByTopic[topicId] = []
}
if (Array.isArray(messages)) {
// 为了兼容多模型新发消息,一次性添加多个助手消息
// 不是什么好主意,不符合语义
state.messagesByTopic[topicId].push(...messages)
} else {
state.messagesByTopic[topicId].push(messages)
}
},
updateMessage: (
state,
action: PayloadAction<{ topicId: string; messageId: string; updates: Partial<Message> }>
) => {
const { topicId, messageId, updates } = action.payload
const topicMessages = state.messagesByTopic[topicId]
if (topicMessages) {
const messageIndex = topicMessages.findIndex((msg) => msg.id === messageId)
if (messageIndex !== -1) {
topicMessages[messageIndex] = { ...topicMessages[messageIndex], ...updates }
}
}
},
setCurrentTopic: (state, action: PayloadAction<string>) => {
state.currentTopic = action.payload
},
clearTopicMessages: (state, action: PayloadAction<string>) => {
const topicId = action.payload
state.messagesByTopic[topicId] = []
state.error = null
},
loadTopicMessages: (state, action: PayloadAction<{ topicId: string; messages: Message[] }>) => {
const { topicId, messages } = action.payload
state.messagesByTopic[topicId] = messages.map((msg) => ({ ...msg }))
},
setStreamMessage: (state, action: PayloadAction<{ topicId: string; message: Message | null }>) => {
const { topicId, message } = action.payload
if (!state.streamMessagesByTopic[topicId]) {
state.streamMessagesByTopic[topicId] = {}
}
if (message) {
state.streamMessagesByTopic[topicId][message.id] = message
}
},
commitStreamMessage: (state, action: PayloadAction<{ topicId: string; messageId: string }>) => {
const { topicId, messageId } = action.payload
const streamMessage = state.streamMessagesByTopic[topicId]?.[messageId]
// 如果没有流消息,则不执行任何操作
if (!streamMessage || streamMessage.role !== 'assistant') {
return
}
// 查找是否已经存在具有相同Id的助手消息
const existingMessageIndex =
state.messagesByTopic[topicId]?.findIndex((m) => m.role === 'assistant' && m.id === streamMessage.id) ?? -1
if (existingMessageIndex !== -1) {
// 替换已有的消息
state.messagesByTopic[topicId][existingMessageIndex] = streamMessage
} else if (state.messagesByTopic[topicId]) {
// 如果不存在但存在topicMessages则添加新消息
state.messagesByTopic[topicId].push(streamMessage)
}
// 只删除这个特定消息的流状态
delete state.streamMessagesByTopic[topicId][messageId]
},
clearStreamMessage: (state, action: PayloadAction<{ topicId: string; messageId: string }>) => {
const { topicId, messageId } = action.payload
if (state.streamMessagesByTopic[topicId]) {
delete state.streamMessagesByTopic[topicId][messageId]
}
}
},
extraReducers: (builder) => {
builder
.addCase(initializeMessagesState.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(initializeMessagesState.fulfilled, (state, action) => {
console.log('initializeMessagesState.fulfilled', action.payload)
state.loading = false
state.messagesByTopic = action.payload
})
.addCase(initializeMessagesState.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Failed to load messages'
})
}
})
export const {
setLoading,
setError,
setDisplayCount,
addMessage,
updateMessage,
setCurrentTopic,
clearTopicMessages,
loadTopicMessages,
setStreamMessage,
commitStreamMessage,
clearStreamMessage
} = messagesSlice.actions
const handleResponseMessageUpdate = (message, topicId, dispatch, getState) => {
dispatch(setStreamMessage({ topicId, message }))
// When message is complete, commit to messages and sync with DB
if (message.status !== 'pending') {
EventEmitter.emit(EVENT_NAMES.AI_AUTO_RENAME)
dispatch(commitStreamMessage({ topicId, messageId: message.id }))
const state = getState()
const topicMessages = state.messages.messagesByTopic[topicId]
if (topicMessages) {
syncMessagesWithDB(topicId, topicMessages)
}
}
}
// Helper function to sync messages with database
const syncMessagesWithDB = async (topicId: string, messages: Message[]) => {
const dbMessages = convertToDBFormat(messages)
await db.topics.put({
id: topicId,
messages: dbMessages
})
}
// Modified sendMessage thunk
export const sendMessage =
(
content: string,
assistant: Assistant,
topic: Topic,
options?: {
files?: FileType[]
knowledgeBaseIds?: string[]
mentionModels?: Model[]
resendUserMessage?: Message
resendAssistantMessage?: Message
enabledMCPs?: MCPServer[]
}
) =>
async (dispatch: AppDispatch, getState: () => RootState) => {
try {
dispatch(setLoading(true))
// Initialize topic messages if not exists
const initialState = getState()
if (!initialState.messages.messagesByTopic[topic.id]) {
dispatch(clearTopicMessages(topic.id))
}
// 判断是否重发消息
const isResend = !!options?.resendUserMessage
// 使用用户消息
let userMessage: Message
if (isResend) {
userMessage = options.resendUserMessage
} else {
// 创建新的用户消息
userMessage = getUserMessage({ assistant, topic, type: 'text', content })
if (options?.files) {
userMessage.files = options.files
}
if (options?.knowledgeBaseIds) {
userMessage.knowledgeBaseIds = options.knowledgeBaseIds
}
if (options?.mentionModels) {
userMessage.mentions = options.mentionModels
}
}
// 如果不是重发,才添加新的用户消息
if (!isResend) {
dispatch(addMessage({ topicId: topic.id, messages: userMessage }))
}
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE)
// 处理助手消息
// let assistantMessage: Message
let assistantMessages: Message[] = []
// 使用助手消息
if (isResend && options.resendAssistantMessage) {
// 直接使用传入的助手消息,进行重置
const messageToReset = options.resendAssistantMessage
const { model, id } = messageToReset
const resetMessage = resetAssistantMessage(messageToReset, model)
// 更新状态
dispatch(updateMessage({ topicId: topic.id, messageId: id, updates: resetMessage }))
// 使用重置后的消息
assistantMessages.push(resetMessage)
} else {
// 不是重发情况
// 为每个被 mention 的模型创建一个助手消息
if (options?.mentionModels?.length) {
assistantMessages = options.mentionModels.map((m) => {
const assistantMessage = getAssistantMessage({ assistant: { ...assistant, model: m }, topic })
assistantMessage.model = m
assistantMessage.askId = userMessage.id
assistantMessage.status = 'sending'
return assistantMessage
})
} else {
// 创建新的助手消息
const assistantMessage = getAssistantMessage({ assistant, topic })
assistantMessage.askId = userMessage.id
assistantMessage.status = 'sending'
assistantMessages.push(assistantMessage)
}
}
// Use topic queue to handle request
const queue = getTopicQueue(topic.id)
// let assistantMessage: Message | undefined
!isResend && dispatch(addMessage({ topicId: topic.id, messages: assistantMessages }))
for (const assistantMessage of assistantMessages) {
// console.log('assistantMessage', assistantMessage)
// Set as stream message instead of adding to messages
dispatch(setStreamMessage({ topicId: topic.id, message: assistantMessage }))
// Sync user message with database
const state = getState()
const currentTopicMessages = state.messages.messagesByTopic[topic.id]
if (currentTopicMessages) {
await syncMessagesWithDB(topic.id, currentTopicMessages)
}
queue.add(async () => {
try {
const state = getState()
const topicMessages = state.messages.messagesByTopic[topic.id]
if (!topicMessages) {
dispatch(clearTopicMessages(topic.id))
return
}
const messages = convertToDBFormat(topicMessages)
// Prepare assistant config
const assistantWithModel = assistantMessage.model
? { ...assistant, model: assistantMessage.model }
: assistant
if (topic.prompt) {
assistantWithModel.prompt = assistantWithModel.prompt
? `${assistantWithModel.prompt}\n${topic.prompt}`
: topic.prompt
}
// 节流
const throttledDispatch = throttle(handleResponseMessageUpdate, 100, { trailing: true }) // 100ms的节流时间应足够平衡用户体验和性能
await fetchChatCompletion({
message: { ...assistantMessage },
messages: messages
.filter((m) => !m.status?.includes('ing'))
.slice(
0,
messages.findIndex((m) => m.id === assistantMessage.id)
),
assistant: assistantWithModel,
onResponse: async (msg) => {
// 允许在回调外维护一个最新的消息状态每次都更新这个对象但只通过节流函数分发到Redux
const updatedMsg = { ...msg, status: msg.status || 'pending', content: msg.content || '' }
// 创建节流函数限制Redux更新频率
// 使用节流函数更新Redux
throttledDispatch({ ...assistantMessage, ...updatedMsg }, topic.id, dispatch, getState)
}
})
} catch (error) {
console.error('Error in chat completion:', error)
dispatch(
updateMessage({
topicId: topic.id,
messageId: assistantMessage.id,
updates: { status: 'error', error: { message: error.message } }
})
)
dispatch(clearStreamMessage({ topicId: topic.id, messageId: assistantMessage.id }))
dispatch(setError(error.message))
}
})
}
} catch (error) {
console.error('Error in sendMessage:', error)
dispatch(setError(error.message))
} finally {
dispatch(setLoading(false))
}
}
// resendMessage thunk专门用于重发消息和在助手消息下@新模型
// 本质都是重发助手消息,兼容了两种消息类型,以及@新模型(属于追加助手消息之后重发)
export const resendMessage =
(message: Message, assistant: Assistant, topic: Topic, isMentionModel = false) =>
async (dispatch: AppDispatch, getState: () => RootState) => {
try {
// 获取状态
const state = getState()
const topicMessages = state.messages.messagesByTopic[topic.id] || []
// 如果是用户消息,直接重发
if (message.role === 'user') {
// 查找此用户消息对应的助手消息
const assistantMessage = topicMessages.find((m) => m.role === 'assistant' && m.askId === message.id)
dispatch(
sendMessage(message.content, assistant, topic, {
resendUserMessage: message,
resendAssistantMessage: assistantMessage
})
)
}
// 如果是助手消息,找到对应的用户消息
const userMessage = topicMessages.find((m) => m.id === message.askId && m.role === 'user')
if (!userMessage) {
console.error('Cannot find original user message to resend')
dispatch(setError('Cannot find original user message to resend'))
return
}
if (isMentionModel) {
// @
return dispatch(
sendMessage(userMessage.content, assistant, topic, {
resendUserMessage: userMessage
})
)
}
dispatch(
sendMessage(userMessage.content, assistant, topic, {
resendUserMessage: userMessage,
resendAssistantMessage: message
})
)
} catch (error) {
console.error('Error in resendMessage:', error)
dispatch(setError(error.message))
} finally {
dispatch(setLoading(false))
}
}
// Modified loadTopicMessages thunk
export const loadTopicMessagesThunk = (topicId: string) => async (dispatch: AppDispatch) => {
try {
dispatch(setLoading(true))
const topic = await db.topics.get(topicId)
const messages = topic?.messages || []
// Initialize topic messages
dispatch(clearTopicMessages(topicId))
dispatch(loadTopicMessages({ topicId, messages }))
dispatch(setCurrentTopic(topicId))
} catch (error) {
dispatch(setError(error instanceof Error ? error.message : 'Failed to load messages'))
} finally {
dispatch(setLoading(false))
}
}
// Modified clearMessages thunk
export const clearTopicMessagesThunk = (topic: Topic) => async (dispatch: AppDispatch) => {
try {
dispatch(setLoading(true))
// Wait for any pending requests to complete
await waitForTopicQueue(topic.id)
// Clear the topic's request queue
clearTopicQueue(topic.id)
// Clear messages from state and database
dispatch(clearTopicMessages(topic.id))
await db.topics.update(topic.id, { messages: [] })
// Update current topic
dispatch(setCurrentTopic(topic.id))
} catch (error) {
dispatch(setError(error instanceof Error ? error.message : 'Failed to clear messages'))
} finally {
dispatch(setLoading(false))
}
}
// Modified updateMessages thunk
export const updateMessages = (topic: Topic, messages: Message[]) => async (dispatch: AppDispatch) => {
try {
dispatch(setLoading(true))
await db.topics.update(topic.id, { messages })
dispatch(loadTopicMessages({ topicId: topic.id, messages }))
} catch (error) {
dispatch(setError(error instanceof Error ? error.message : 'Failed to update messages'))
} finally {
dispatch(setLoading(false))
}
}
// Selectors
export const selectTopicMessages = createSelector(
[(state: RootState) => state.messages, (_, topicId: string) => topicId],
(messagesState, topicId) => {
const topicMessages = messagesState.messagesByTopic[topicId]
if (!topicMessages) return []
return [...topicMessages].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
}
)
export const selectCurrentTopicId = (state: RootState): string => {
const messagesState = state.messages as MessagesState
return messagesState?.currentTopic || ''
}
export const selectLoading = (state: RootState): boolean => {
const messagesState = state.messages as MessagesState
return messagesState?.loading || false
}
export const selectDisplayCount = (state: RootState): number => {
const messagesState = state.messages as MessagesState
return messagesState?.displayCount || 20
}
export const selectError = (state: RootState): string | null => {
const messagesState = state.messages as MessagesState
return messagesState?.error || null
}
export const selectStreamMessage = (state: RootState, topicId: string, messageId: string): Message | null =>
state.messages.streamMessagesByTopic[topicId]?.[messageId] || null
export default messagesSlice.reducer

View File

@ -0,0 +1,72 @@
import PQueue from 'p-queue'
// Queue configuration - managed by topic
const requestQueues: { [topicId: string]: PQueue } = {}
/**
* Get or create a queue for a specific topic
* @param topicId The ID of the topic
* @returns A PQueue instance for the topic
*/
export const getTopicQueue = (topicId: string): PQueue => {
if (!requestQueues[topicId]) {
requestQueues[topicId] = new PQueue({
concurrency: 4,
timeout: 1000 * 60 * 5, // 5 minutes
throwOnTimeout: false
})
}
return requestQueues[topicId]
}
/**
* Clear the queue for a specific topic
* @param topicId The ID of the topic
*/
export const clearTopicQueue = (topicId: string): void => {
if (requestQueues[topicId]) {
requestQueues[topicId].clear()
delete requestQueues[topicId]
}
}
/**
* Clear all topic queues
*/
export const clearAllQueues = (): void => {
Object.keys(requestQueues).forEach((topicId) => {
requestQueues[topicId].clear()
delete requestQueues[topicId]
})
}
/**
* Check if a topic has pending requests
* @param topicId The ID of the topic
* @returns True if the topic has pending requests
*/
export const hasTopicPendingRequests = (topicId: string): boolean => {
return requestQueues[topicId]?.size > 0 || requestQueues[topicId]?.pending > 0
}
/**
* Get the number of pending requests for a topic
* @param topicId The ID of the topic
* @returns The number of pending requests
*/
export const getTopicPendingRequestCount = (topicId: string): number => {
if (!requestQueues[topicId]) {
return 0
}
return requestQueues[topicId].size + requestQueues[topicId].pending
}
/**
* Wait for all pending requests in a topic queue to complete
* @param topicId The ID of the topic
*/
export const waitForTopicQueue = async (topicId: string): Promise<void> => {
if (requestQueues[topicId]) {
await requestQueues[topicId].onIdle()
}
}

148
yarn.lock
View File

@ -2933,11 +2933,11 @@ __metadata:
linkType: hard
"@types/ws@npm:^8.5.4":
version: 8.5.14
resolution: "@types/ws@npm:8.5.14"
version: 8.18.0
resolution: "@types/ws@npm:8.18.0"
dependencies:
"@types/node": "npm:*"
checksum: 10c0/be88a0b6252f939cb83340bd1b4d450287f752c19271195cd97564fd94047259a9bb8c31c585a61b69d8a1b069a99df9dd804db0132d3359c54d3890c501416a
checksum: 10c0/a56d2e0d1da7411a1f3548ce02b51a50cbe9e23f025677d03df48f87e4a3c72e1342fbf1d12e487d7eafa8dc670c605152b61bbf9165891ec0e9694b0d3ea8d4
languageName: node
linkType: hard
@ -3184,6 +3184,7 @@ __metadata:
mime: "npm:^4.0.4"
officeparser: "npm:^4.1.1"
openai: "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch"
p-queue: "npm:^8.1.0"
prettier: "npm:^3.2.4"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
@ -3262,11 +3263,11 @@ __metadata:
linkType: hard
"acorn@npm:^8.9.0":
version: 8.14.0
resolution: "acorn@npm:8.14.0"
version: 8.14.1
resolution: "acorn@npm:8.14.1"
bin:
acorn: bin/acorn
checksum: 10c0/6d4ee461a7734b2f48836ee0fbb752903606e576cc100eb49340295129ca0b452f3ba91ddd4424a1d4406a98adfb2ebb6bd0ff4c49d7a0930c10e462719bbfd7
checksum: 10c0/dbd36c1ed1d2fa3550140000371fcf721578095b18777b85a79df231ca093b08edc6858d75d6e48c73e431c174dcf9214edbd7e6fa5911b93bd8abfa54e47123
languageName: node
linkType: hard
@ -3420,8 +3421,8 @@ __metadata:
linkType: hard
"antd@npm:^5.22.5":
version: 5.24.2
resolution: "antd@npm:5.24.2"
version: 5.24.3
resolution: "antd@npm:5.24.3"
dependencies:
"@ant-design/colors": "npm:^7.2.0"
"@ant-design/cssinjs": "npm:^1.23.0"
@ -3438,7 +3439,7 @@ __metadata:
classnames: "npm:^2.5.1"
copy-to-clipboard: "npm:^3.3.3"
dayjs: "npm:^1.11.11"
rc-cascader: "npm:~3.33.0"
rc-cascader: "npm:~3.33.1"
rc-checkbox: "npm:~3.5.0"
rc-collapse: "npm:~3.9.0"
rc-dialog: "npm:~9.6.0"
@ -3446,14 +3447,14 @@ __metadata:
rc-dropdown: "npm:~4.2.1"
rc-field-form: "npm:~2.7.0"
rc-image: "npm:~7.11.0"
rc-input: "npm:~1.7.2"
rc-input: "npm:~1.7.3"
rc-input-number: "npm:~9.4.0"
rc-mentions: "npm:~2.19.1"
rc-menu: "npm:~9.16.1"
rc-motion: "npm:^2.9.5"
rc-notification: "npm:~5.6.3"
rc-pagination: "npm:~5.1.0"
rc-picker: "npm:~4.11.2"
rc-picker: "npm:~4.11.3"
rc-progress: "npm:~4.0.0"
rc-rate: "npm:~2.13.1"
rc-resize-observer: "npm:^1.4.3"
@ -3466,7 +3467,7 @@ __metadata:
rc-tabs: "npm:~15.5.1"
rc-textarea: "npm:~1.9.0"
rc-tooltip: "npm:~6.4.0"
rc-tree: "npm:~5.13.0"
rc-tree: "npm:~5.13.1"
rc-tree-select: "npm:~5.27.0"
rc-upload: "npm:~4.8.1"
rc-util: "npm:^5.44.4"
@ -3475,7 +3476,7 @@ __metadata:
peerDependencies:
react: ">=16.9.0"
react-dom: ">=16.9.0"
checksum: 10c0/a6772136b828ef73925af633dee66b7580bad736eac20c713d1991e457aae3879d1a826ca06cc40f824238da0ad1a9745dbde6acbd801aec589deaeef04846fe
checksum: 10c0/bbd1dcc4cce3bebecebde135014352156d57c8979b1ad8cb8cf873e8919454292a812f6d2e8995673b82f3cb46a2cdd14ea7b2190193bd5127380c1091ce472a
languageName: node
linkType: hard
@ -4926,15 +4927,6 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:2.6.9, debug@npm:^2.6.9":
version: 2.6.9
resolution: "debug@npm:2.6.9"
dependencies:
ms: "npm:2.0.0"
checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589
languageName: node
linkType: hard
"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0":
version: 4.4.0
resolution: "debug@npm:4.4.0"
@ -4959,6 +4951,15 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:^2.6.9":
version: 2.6.9
resolution: "debug@npm:2.6.9"
dependencies:
ms: "npm:2.0.0"
checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589
languageName: node
linkType: hard
"debug@npm:^3.1.0, debug@npm:^3.2.6":
version: 3.2.7
resolution: "debug@npm:3.2.7"
@ -5533,9 +5534,9 @@ __metadata:
linkType: hard
"electron-log@npm:^5.1.5":
version: 5.3.1
resolution: "electron-log@npm:5.3.1"
checksum: 10c0/051157400b36f4ad51c52ae30a6c37d0c1525dc582312dc4043723d0e52aa11a95c28ee1421a0067f11ad641624e1163850e24d1c8cec47ba31aaf905953100f
version: 5.3.2
resolution: "electron-log@npm:5.3.2"
checksum: 10c0/8cfcd6eb6ab2dff010941f9a39793a411646dc0462991632d2427fc9a45f22b00ec758996629f8c203612d99cb01713c08c72d2b0454603d13386f433f5b755b
languageName: node
linkType: hard
@ -5565,9 +5566,9 @@ __metadata:
linkType: hard
"electron-to-chromium@npm:^1.5.73":
version: 1.5.112
resolution: "electron-to-chromium@npm:1.5.112"
checksum: 10c0/fc597268d6d3d7458b55141c436802a6c51078855f021823cdb380b80ad1a69e1c2899fdfc9cffa501d47feb3791ea6a75893fe802a608c7845e979a48f5ac25
version: 1.5.113
resolution: "electron-to-chromium@npm:1.5.113"
checksum: 10c0/837fe2fd26adbc4f3ad8e758d14067a14f636f9c2923b5ded8adb93426bbe3fdc83b48ddf9f2cf03be31b5becb0c31144db19c823b696fd52a7bc4583f4bde00
languageName: node
linkType: hard
@ -5681,13 +5682,6 @@ __metadata:
languageName: node
linkType: hard
"encodeurl@npm:~1.0.2":
version: 1.0.2
resolution: "encodeurl@npm:1.0.2"
checksum: 10c0/f6c2387379a9e7c1156c1c3d4f9cb7bb11cf16dd4c1682e1f6746512564b053df5781029b6061296832b59fb22f459dbe250386d217c2f6e203601abb2ee0bec
languageName: node
linkType: hard
"encoding@npm:^0.1.12, encoding@npm:^0.1.13":
version: 0.1.13
resolution: "encoding@npm:0.1.13"
@ -6260,6 +6254,13 @@ __metadata:
languageName: node
linkType: hard
"eventemitter3@npm:^5.0.1":
version: 5.0.1
resolution: "eventemitter3@npm:5.0.1"
checksum: 10c0/4ba5c00c506e6c786b4d6262cfbce90ddc14c10d4667e5c83ae993c9de88aa856033994dd2b35b83e8dc1170e224e66a319fa80adc4c32adcd2379bbc75da814
languageName: node
linkType: hard
"events@npm:^3.3.0":
version: 3.3.0
resolution: "events@npm:3.3.0"
@ -6602,17 +6603,16 @@ __metadata:
linkType: hard
"finalhandler@npm:^2.0.0":
version: 2.0.0
resolution: "finalhandler@npm:2.0.0"
version: 2.1.0
resolution: "finalhandler@npm:2.1.0"
dependencies:
debug: "npm:2.6.9"
encodeurl: "npm:~1.0.2"
escape-html: "npm:~1.0.3"
on-finished: "npm:2.4.1"
parseurl: "npm:~1.3.3"
statuses: "npm:2.0.1"
unpipe: "npm:~1.0.0"
checksum: 10c0/ca6f69d69797eebc900d7627bde4bb7d38417112911eb11ce4e40011195b6ad1a09413ad082da9bb64da789a4ecfffdd0e6a5ea1ccb4147062224c3050f134ea
debug: "npm:^4.4.0"
encodeurl: "npm:^2.0.0"
escape-html: "npm:^1.0.3"
on-finished: "npm:^2.4.1"
parseurl: "npm:^1.3.3"
statuses: "npm:^2.0.1"
checksum: 10c0/da0bbca6d03873472ee890564eb2183f4ed377f25f3628a0fc9d16dac40bed7b150a0d82ebb77356e4c6d97d2796ad2dba22948b951dddee2c8768b0d1b9fb1f
languageName: node
linkType: hard
@ -7478,8 +7478,8 @@ __metadata:
linkType: hard
"hast-util-to-jsx-runtime@npm:^2.0.0":
version: 2.3.5
resolution: "hast-util-to-jsx-runtime@npm:2.3.5"
version: 2.3.6
resolution: "hast-util-to-jsx-runtime@npm:2.3.6"
dependencies:
"@types/estree": "npm:^1.0.0"
"@types/hast": "npm:^3.0.0"
@ -7493,10 +7493,10 @@ __metadata:
mdast-util-mdxjs-esm: "npm:^2.0.0"
property-information: "npm:^7.0.0"
space-separated-tokens: "npm:^2.0.0"
style-to-object: "npm:^1.0.0"
style-to-js: "npm:^1.0.0"
unist-util-position: "npm:^5.0.0"
vfile-message: "npm:^4.0.0"
checksum: 10c0/9db65b2b417cdaad1f1cc619b613abd8d1fa7196f5979ce54bd1dc8a937613f11fecb8b7a43425342cf36fd085b0fed89daadcce43bed8762786a4cdc21a1df8
checksum: 10c0/27297e02848fe37ef219be04a26ce708d17278a175a807689e94a821dcffc88aa506d62c3a85beed1f9a8544f7211bdcbcde0528b7b456a57c2e342c3fd11056
languageName: node
linkType: hard
@ -10979,6 +10979,16 @@ __metadata:
languageName: node
linkType: hard
"p-queue@npm:^8.1.0":
version: 8.1.0
resolution: "p-queue@npm:8.1.0"
dependencies:
eventemitter3: "npm:^5.0.1"
p-timeout: "npm:^6.1.2"
checksum: 10c0/6bdea170840546769c29682fed212745c951933476761ed3a981967fab624c7c0120dff79bd99a1ac8b650b420719a245813e944af4b8ee77d4dd78adbf5fe75
languageName: node
linkType: hard
"p-retry@npm:4":
version: 4.6.2
resolution: "p-retry@npm:4.6.2"
@ -11005,6 +11015,13 @@ __metadata:
languageName: node
linkType: hard
"p-timeout@npm:^6.1.2":
version: 6.1.4
resolution: "p-timeout@npm:6.1.4"
checksum: 10c0/019edad1c649ab07552aa456e40ce7575c4b8ae863191477f02ac8d283ac8c66cedef0ca93422735130477a051dfe952ba717641673fd3599befdd13f63bcc33
languageName: node
linkType: hard
"p-try@npm:^2.0.0":
version: 2.2.0
resolution: "p-try@npm:2.2.0"
@ -11691,7 +11708,7 @@ __metadata:
languageName: node
linkType: hard
"rc-cascader@npm:~3.33.0":
"rc-cascader@npm:~3.33.1":
version: 3.33.1
resolution: "rc-cascader@npm:3.33.1"
dependencies:
@ -11830,7 +11847,7 @@ __metadata:
languageName: node
linkType: hard
"rc-input@npm:~1.7.1, rc-input@npm:~1.7.2":
"rc-input@npm:~1.7.1, rc-input@npm:~1.7.3":
version: 1.7.3
resolution: "rc-input@npm:1.7.3"
dependencies:
@ -11937,7 +11954,7 @@ __metadata:
languageName: node
linkType: hard
"rc-picker@npm:~4.11.2":
"rc-picker@npm:~4.11.3":
version: 4.11.3
resolution: "rc-picker@npm:4.11.3"
dependencies:
@ -12086,8 +12103,8 @@ __metadata:
linkType: hard
"rc-table@npm:~7.50.3":
version: 7.50.3
resolution: "rc-table@npm:7.50.3"
version: 7.50.4
resolution: "rc-table@npm:7.50.4"
dependencies:
"@babel/runtime": "npm:^7.10.1"
"@rc-component/context": "npm:^1.4.0"
@ -12098,7 +12115,7 @@ __metadata:
peerDependencies:
react: ">=16.9.0"
react-dom: ">=16.9.0"
checksum: 10c0/61fee18289063d33e135f87e7d325c4ab319db6a452bc7151b13c9bfdcedc9a280a543fe06dea05cd41814c47dfc0c5b3ade876f7e6c286714a8276a53f7125b
checksum: 10c0/ab5eb3db00bc31470d7dd5946c1a919247742703a3278ea2d9a33e719a08e170ca80977e615084c7cfcb54508a9c3da2449409cce16fc1d7ec1ea67596c30d79
languageName: node
linkType: hard
@ -12167,7 +12184,7 @@ __metadata:
languageName: node
linkType: hard
"rc-tree@npm:~5.13.0":
"rc-tree@npm:~5.13.0, rc-tree@npm:~5.13.1":
version: 5.13.1
resolution: "rc-tree@npm:5.13.1"
dependencies:
@ -12211,8 +12228,8 @@ __metadata:
linkType: hard
"rc-virtual-list@npm:^3.14.2, rc-virtual-list@npm:^3.5.1, rc-virtual-list@npm:^3.5.2":
version: 3.18.3
resolution: "rc-virtual-list@npm:3.18.3"
version: 3.18.4
resolution: "rc-virtual-list@npm:3.18.4"
dependencies:
"@babel/runtime": "npm:^7.20.0"
classnames: "npm:^2.2.6"
@ -12221,7 +12238,7 @@ __metadata:
peerDependencies:
react: ">=16.9.0"
react-dom: ">=16.9.0"
checksum: 10c0/773b18d9594d78b4f08182caec3043fef6c1b1871db91e05294b62a65e309e2d0ee50ca699a1b237af1ac0b1b1c9cc25d62f47bae30d4a0c0dcab0ddc4d5bb8d
checksum: 10c0/2566e98418b8072af4591488e028a1e8433d2a190716f47134ee63a506afec9cf472f8a8d3dda55bf2e82ccf1215d324abd280892db4fbf5999fc30184068f62
languageName: node
linkType: hard
@ -13961,7 +13978,16 @@ __metadata:
languageName: node
linkType: hard
"style-to-object@npm:^1.0.0":
"style-to-js@npm:^1.0.0":
version: 1.1.16
resolution: "style-to-js@npm:1.1.16"
dependencies:
style-to-object: "npm:1.0.8"
checksum: 10c0/578a4dff804539ec7e64d3cc8d327540befb9ad30e3cd0b6b0392f93f793f3a028f90084a9aaff088bffb87818fa2c6c153f0df576f61f9ab0b0938b582bcac7
languageName: node
linkType: hard
"style-to-object@npm:1.0.8":
version: 1.0.8
resolution: "style-to-object@npm:1.0.8"
dependencies:
@ -14737,7 +14763,7 @@ __metadata:
languageName: node
linkType: hard
"unpipe@npm:1.0.0, unpipe@npm:~1.0.0":
"unpipe@npm:1.0.0":
version: 1.0.0
resolution: "unpipe@npm:1.0.0"
checksum: 10c0/193400255bd48968e5c5383730344fbb4fa114cdedfab26e329e50dd2d81b134244bb8a72c6ac1b10ab0281a58b363d06405632c9d49ca9dfd5e90cbd7d0f32c