From 9afc6989af9be505a24a645a780fcce828296f12 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat <43230886+MyPrototypeWhat@users.noreply.github.com> Date: Sat, 8 Mar 2025 01:41:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84message=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20(#2561)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: lizhixuan Co-authored-by: LiuVaayne <10231735+vaayne@users.noreply.github.com> Co-authored-by: kangfenmao --- electron.vite.config.ts | 3 +- package.json | 1 + src/renderer/src/config/constant.ts | 4 + src/renderer/src/hooks/useAppInit.ts | 6 + src/renderer/src/hooks/useTopic.ts | 28 +- src/renderer/src/pages/home/Chat.tsx | 2 +- .../src/pages/home/Inputbar/Inputbar.tsx | 65 +-- .../src/pages/home/Messages/Message.tsx | 113 +--- .../src/pages/home/Messages/MessageGroup.tsx | 180 +++---- .../pages/home/Messages/MessageMenubar.tsx | 192 ++++--- .../src/pages/home/Messages/MessageStream.tsx | 79 +++ .../src/pages/home/Messages/Messages.tsx | 279 ++++------ .../src/pages/home/components/Suggestions.tsx | 69 +-- src/renderer/src/services/ApiService.ts | 2 +- src/renderer/src/services/EventService.ts | 2 +- src/renderer/src/store/index.ts | 4 +- src/renderer/src/store/messages.ts | 509 ++++++++++++++++++ src/renderer/src/utils/queue.ts | 72 +++ yarn.lock | 148 ++--- 19 files changed, 1144 insertions(+), 614 deletions(-) create mode 100644 src/renderer/src/pages/home/Messages/MessageStream.tsx create mode 100644 src/renderer/src/store/messages.ts create mode 100644 src/renderer/src/utils/queue.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index c5f0e76b..65db2223 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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') diff --git a/package.json b/package.json index 44d4d04f..7603d072 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/renderer/src/config/constant.ts b/src/renderer/src/config/constant.ts index f3229bdc..5d2376d1 100644 --- a/src/renderer/src/config/constant.ts +++ b/src/renderer/src/config/constant.ts @@ -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 diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index c1460e6f..16c9e943 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -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]) diff --git a/src/renderer/src/hooks/useTopic.ts b/src/renderer/src/hooks/useTopic.ts index ae1042f1..2e8ea221 100644 --- a/src/renderer/src/hooks/useTopic.ts +++ b/src/renderer/src/hooks/useTopic.ts @@ -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 ?? []) { diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 738eff54..7846a1a7 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -31,7 +31,7 @@ const Chat: FC = (props) => { topic={props.activeTopic} setActiveTopic={props.setActiveTopic} /> - + {topicPosition === 'right' && showTopics && ( void + topic: Topic } let _text = '' @@ -137,43 +137,28 @@ const Inputbar: FC = ({ 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' + 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) + } catch (error) { + console.error('Failed to send message:', error) } - - 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) - - setText('') - setFiles([]) - setTimeout(() => setText(''), 500) - setTimeout(() => resizeTextArea(), 0) - - setExpend(false) - }, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBases, files, mentionModels]) + }, [inputEmpty, text, assistant, files, selectedKnowledgeBases, mentionModels, dispatch]) const translate = async () => { if (isTranslating) { diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 96ed50bf..4295de17 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -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> onDeleteMessage?: (message: Message) => Promise } const MessageItem: FC = ({ - 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(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('') const [selectedText, setSelectedText] = useState('') 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 = ({ } }, []) - 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 = ({ }, 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 }) + if (topic) { + await dispatch( + updateMessages(topic, onGetMessages?.()?.map((m) => (m.id === message.id ? { ...m, usage } : m)) || []) + ) + } }) } - }, [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 }) - } - } - }) - } - } - // 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 = ({ diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 090cffbe..6dfb02a9 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -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> - onDeleteMessage?: (message: Message) => Promise - onDeleteGroupMessages?: (askId: string) => Promise + onGetMessages: () => Message[] + onSetMessages: Dispatch> + onDeleteMessage: (message: Message) => Promise + onDeleteGroupMessages: (askId: string) => Promise } -const MessageGroup: FC = ({ +const MessageGroup = ({ messages, topic, hidePresetMessages, @@ -29,7 +30,7 @@ const MessageGroup: FC = ({ onSetMessages, onGetMessages, onDeleteGroupMessages -}) => { +}: Props) => { const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() const { t } = useTranslation() @@ -43,7 +44,10 @@ const MessageGroup: FC = ({ 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 = ({ 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 = ({ 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 = ( + + + + ) + + if (isGridGroupMessage) { + return ( + + + + } + trigger={gridPopoverTrigger} + styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }} + getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}> + {messageWrapper} + + ) + } + + return messageWrapper + }, + [ + isGrid, + isGrouped, + isHorizontal, + multiModelMessageStyle, + selectedIndex, + topic, + hidePresetMessages, + onSetMessages, + onDeleteMessage, + onGetMessages, + gridPopoverTrigger + ] + ) + return ( = ({ $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 ( - - - - } - trigger={gridPopoverTrigger} - styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }} - getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement} - key={message.id}> - - - - - ) - } - return ( - - - - ) - })} + {messages.map((message, index) => renderMessage(message, index))} {isGrouped && ( = ({ messages={messages} selectedIndex={selectedIndex} setSelectedIndex={setSelectedIndex} - onDelete={onDelete} + onDelete={handleDeleteGroup} /> )} diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 355b86f9..b8dce585 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -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 setModel: (model: Model) => void - onEditMessage?: (message: Message) => void onDeleteMessage?: (message: Message) => Promise onGetMessages?: () => Message[] } @@ -59,18 +64,21 @@ const MessageMenubar: FC = (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) => { 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 () => { - await modelGenerating() - const _messages = onGetMessages?.() || [] - const groupdMessages = _messages.filter((m) => m.askId === message.id) + const handleResendUserMessage = useCallback( + async (messageUpdate?: Message) => { + // messageUpdate 为了处理用户消息更改后的message + await modelGenerating() + const groupdMessages = messages.filter((m) => m.askId === message.id) - // Resend all groupd 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) - ) + // Resend all grouped messages + if (!isEmpty(groupdMessages)) { + for (const assistantMessage of groupdMessages) { + const _model = assistantMessage.model || assistantModel + await dispatch(resendMessage({ ...assistantMessage, model: _model }, assistant, topic)) + } + return } - 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) => { ) : 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) => { window.api.file.save(fileName, message.content) } }, - { - label: t('common.edit'), - key: 'edit', - icon: , - onClick: onEdit - }, - { - label: t('chat.message.new.branch'), - key: 'new-branch', - icon: , - onClick: onNewBranch - }, + { label: t('common.edit'), key: 'edit', icon: , onClick: onEdit }, + { label: t('chat.message.new.branch'), key: 'new-branch', icon: , onClick: onNewBranch }, { label: t('chat.topics.export.title'), key: 'export', @@ -241,11 +233,7 @@ const MessageMenubar: FC = (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) => { 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) => { 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 ( {message.role === 'user' && ( - + handleResendUserMessage()}> @@ -365,7 +350,14 @@ const MessageMenubar: FC = (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) diff --git a/src/renderer/src/pages/home/Messages/MessageStream.tsx b/src/renderer/src/pages/home/Messages/MessageStream.tsx new file mode 100644 index 00000000..b24778d0 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageStream.tsx @@ -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> + onDeleteMessage?: (message: Message) => Promise + onGetMessages?: () => Message[] +} + +const MessageStreamContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +` + +const MessageStream: React.FC = ({ + 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 ( + + + + ) +} + +export default MessageStream diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 32778e7e..433d5237 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -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 = ({ assistant, topic, setActiveTopic }) => { - const [messages, setMessages] = useState([]) +const Messages: React.FC = ({ 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(null) const [displayMessages, setDisplayMessages] = useState([]) - const [hasMore, setHasMore] = useState(true) + const [hasMore, setHasMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) - const containerRef = useRef(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 = ({ 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 data = { ..._topic, name: topicName } as Topic - setActiveTopic(data) - updateTopic(data) + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ assistant, topic, setActiveTopic }) => { inverse={true} scrollableTarget="messages"> - + - {Object.entries(groupedMessages).map(([key, messages]) => ( + {Object.entries(getGroupedMessages(displayMessages)).map(([key, groupMessages]) => ( messages} /> ))} diff --git a/src/renderer/src/pages/home/components/Suggestions.tsx b/src/renderer/src/pages/home/components/Suggestions.tsx index 1a402d6f..00317082 100644 --- a/src/renderer/src/pages/home/components/Suggestions.tsx +++ b/src/renderer/src/pages/home/components/Suggestions.tsx @@ -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() const Suggestions: FC = ({ assistant, messages }) => { + const dispatch = useAppDispatch() + const [suggestions, setSuggestions] = useState( 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 = ({ assistant, messages }) => { if (last(messages)?.status !== 'success') { return null } - if (loadingSuggestions) { return ( @@ -75,7 +78,7 @@ const Suggestions: FC = ({ assistant, messages }) => { {suggestions.map((s, i) => ( - onClick(s)}> + handleSuggestionClick(s.content)}> {s.content} → ))} @@ -117,4 +120,4 @@ const SuggestionItem = styled.div` } ` -export default Suggestions +export default memo(Suggestions) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 1c024a9e..b8d61b2b 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -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 { const model = assistant.model - if (!model) { return [] } diff --git a/src/renderer/src/services/EventService.ts b/src/renderer/src/services/EventService.ts index 4de89880..39fc59c5 100644 --- a/src/renderer/src/services/EventService.ts +++ b/src/renderer/src/services/EventService.ts @@ -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', diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 98bbf6a4..d4c1725b 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -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 diff --git a/src/renderer/src/store/messages.ts b/src/renderer/src/store/messages.ts new file mode 100644 index 00000000..6baf9ec6 --- /dev/null +++ b/src/renderer/src/store/messages.ts @@ -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 + streamMessagesByTopic: Record> + 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 = {} + + // 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) => { + state.loading = action.payload + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload + }, + setDisplayCount: (state, action: PayloadAction) => { + 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 }> + ) => { + 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) => { + state.currentTopic = action.payload + }, + clearTopicMessages: (state, action: PayloadAction) => { + 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 diff --git a/src/renderer/src/utils/queue.ts b/src/renderer/src/utils/queue.ts new file mode 100644 index 00000000..49144294 --- /dev/null +++ b/src/renderer/src/utils/queue.ts @@ -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 => { + if (requestQueues[topicId]) { + await requestQueues[topicId].onIdle() + } +} diff --git a/yarn.lock b/yarn.lock index 6bf2cd90..b1c24a84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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