diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index 3fc1ae95..d85fa7fe 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -1,8 +1,7 @@ import { FileType, KnowledgeItem, Topic, TranslateHistory } from '@renderer/types' import { Dexie, type EntityTable } from 'dexie' -import { upgradeToV5 } from './upgrades' - +import { upgradeToV5, upgradeToV6 } from './upgrades' // Database declaration (move this to its own module also) export const db = new Dexie('CherryStudio') as Dexie & { files: EntityTable @@ -47,4 +46,27 @@ db.version(5) }) .upgrade((tx) => upgradeToV5(tx)) +db.version(6) + .stores({ + files: 'id, name, origin_name, path, size, ext, type, created_at, count', + topics: '&id, messages, createdAt, updatedAt', + settings: '&id, value', + knowledge_notes: '&id, baseId, type, content, created_at, updated_at', + translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt' + }) + .upgrade((tx) => upgradeToV6(tx)) + +// Add hooks for automatic timestamp handling +db.topics.hook('creating', (_, obj: any) => { + const now = new Date().toISOString() + obj.createdAt = now + obj.updatedAt = now +}) + +db.topics.hook('updating', (modifications: any) => { + if (typeof modifications === 'object') { + modifications.updatedAt = new Date().toISOString() + } +}) + export default db diff --git a/src/renderer/src/databases/upgrades.ts b/src/renderer/src/databases/upgrades.ts index 9149c25e..ec63dbbd 100644 --- a/src/renderer/src/databases/upgrades.ts +++ b/src/renderer/src/databases/upgrades.ts @@ -36,3 +36,18 @@ export async function upgradeToV5(tx: Transaction): Promise { } } } + +export async function upgradeToV6(tx: Transaction): Promise { + const topics = await tx.table('topics').toArray() + + // 为每个 topic 添加时间戳,兼容老数据,默认按照最新的时间戳来 + const now = new Date().toISOString() + for (const topic of topics) { + if (!topic.createdAt && !topic.updatedAt) { + await tx.table('topics').update(topic.id, { + createdAt: now, + updatedAt: now + }) + } + } +} diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 16c9e943..c1460e6f 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -3,7 +3,6 @@ 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' @@ -26,11 +25,6 @@ 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/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts new file mode 100644 index 00000000..9ee0eb4f --- /dev/null +++ b/src/renderer/src/hooks/useMessageOperations.ts @@ -0,0 +1,168 @@ +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { + clearStreamMessage, + clearTopicMessages, + commitStreamMessage, + resendMessage, + selectDisplayCount, + selectTopicLoading, + selectTopicMessages, + setStreamMessage, + updateMessage, + updateMessages +} from '@renderer/store/messages' +import type { Assistant, Message, Topic } from '@renderer/types' +import { useCallback } from 'react' +/** + * 自定义Hook,提供消息操作相关的功能 + * + * @param topic 当前主题 + * @returns 一组消息操作方法 + */ +export function useMessageOperations(topic: Topic) { + const dispatch = useAppDispatch() + const messages = useAppSelector((state) => selectTopicMessages(state, topic.id)) + + /** + * 删除单个消息 + */ + const deleteMessage = useCallback( + async (message: Message) => { + const newMessages = messages.filter((m) => m.id !== message.id) + await dispatch(updateMessages(topic, newMessages)) + }, + [dispatch, topic, messages] + ) + + /** + * 删除一组消息(基于askId) + */ + const deleteGroupMessages = useCallback( + async (askId: string) => { + const newMessages = messages.filter((m) => m.askId !== askId) + await dispatch(updateMessages(topic, newMessages)) + }, + [dispatch, topic, messages] + ) + + /** + * 编辑消息内容 + */ + const editMessage = useCallback( + async (messageId: string, updates: Partial) => { + await dispatch( + updateMessage({ + topicId: topic.id, + messageId, + updates + }) + ) + }, + [dispatch, topic.id] + ) + + /** + * 重新发送消息 + */ + const resendMessageAction = useCallback( + async (message: Message, assistant: Assistant, isMentionModel = false) => { + return dispatch(resendMessage(message, assistant, topic, isMentionModel)) + }, + [dispatch, topic] + ) + + /** + * 重新发送用户消息(编辑后) + */ + const resendUserMessageWithEdit = useCallback( + async (message: Message, editedContent: string, assistant: Assistant) => { + // 先更新消息内容 + await editMessage(message.id, { content: editedContent }) + // 然后重新发送 + return dispatch(resendMessage({ ...message, content: editedContent }, assistant, topic)) + }, + [dispatch, editMessage, topic] + ) + + /** + * 设置流式消息 + */ + const setStreamMessageAction = useCallback( + (message: Message | null) => { + dispatch(setStreamMessage({ topicId: topic.id, message })) + }, + [dispatch, topic.id] + ) + + /** + * 提交流式消息 + */ + const commitStreamMessageAction = useCallback( + (messageId: string) => { + dispatch(commitStreamMessage({ topicId: topic.id, messageId })) + }, + [dispatch, topic.id] + ) + + /** + * 清除流式消息 + */ + const clearStreamMessageAction = useCallback( + (messageId: string) => { + dispatch(clearStreamMessage({ topicId: topic.id, messageId })) + }, + [dispatch, topic.id] + ) + + /** + * 清除会话消息 + */ + const clearTopicMessagesAction = useCallback( + async (_topicId?: string) => { + await dispatch(clearTopicMessages(_topicId || topic.id)) + }, + [dispatch, topic.id] + ) + + /** + * 更新消息数据 + */ + const updateMessagesAction = useCallback( + async (messages: Message[]) => { + await dispatch(updateMessages(topic, messages)) + }, + [dispatch, topic] + ) + + /** + * 创建新的上下文(clear message) + */ + const createNewContext = useCallback(async () => { + EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT) + }, []) + + const loading = useAppSelector((state) => selectTopicLoading(state, topic.id)) + const displayCount = useAppSelector(selectDisplayCount) + // /** + // * 获取当前消息列表 + // */ + // const getMessages = useCallback(() => messages, [messages]) + + return { + messages, + loading, + displayCount, + updateMessages: updateMessagesAction, + deleteMessage, + deleteGroupMessages, + editMessage, + resendMessage: resendMessageAction, + resendUserMessageWithEdit, + setStreamMessage: setStreamMessageAction, + commitStreamMessage: commitStreamMessageAction, + clearStreamMessage: clearStreamMessageAction, + createNewContext, + clearTopicMessages: clearTopicMessagesAction + } +} diff --git a/src/renderer/src/hooks/useTopic.ts b/src/renderer/src/hooks/useTopic.ts index 2e8ea221..3db8d62c 100644 --- a/src/renderer/src/hooks/useTopic.ts +++ b/src/renderer/src/hooks/useTopic.ts @@ -1,6 +1,7 @@ import db from '@renderer/databases' import { deleteMessageFiles } from '@renderer/services/MessagesService' import store from '@renderer/store' +import { prepareTopicMessages } from '@renderer/store/messages' import { Assistant, Topic } from '@renderer/types' import { find } from 'lodash' import { useEffect, useState } from 'react' @@ -15,6 +16,12 @@ export function useActiveTopic(_assistant: Assistant, topic?: Topic) { _activeTopic = activeTopic + useEffect(() => { + if (activeTopic) { + store.dispatch(prepareTopicMessages(activeTopic)) + } + }, [activeTopic]) + useEffect(() => { // activeTopic not in assistant.topics if (assistant && !find(assistant.topics, { id: activeTopic?.id })) { @@ -42,8 +49,16 @@ export async function getTopicById(topicId: string) { } // Convert class to object with functions since class only has static methods -// 只有静态方法,没必要用class +// 只有静态方法,没必要用class,可以export {} export const TopicManager = { + async getTopicLimit(limit: number) { + return await db.topics + .orderBy('updatedAt') // 按 updatedAt 排序(默认升序) + .reverse() // 逆序(变成降序) + .limit(limit) // 取前 10 条 + .toArray() + }, + async getTopic(id: string) { return await db.topics.get(id) }, diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 589ac1e6..4a11a7cf 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -14,6 +14,7 @@ import TranslateButton from '@renderer/components/TranslateButton' import { isVisionModel, isWebSearchModel } from '@renderer/config/models' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts' @@ -24,7 +25,7 @@ import FileManager from '@renderer/services/FileManager' import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService' import { translateText } from '@renderer/services/TranslateService' import WebSearchService from '@renderer/services/WebSearchService' -import store, { useAppDispatch, useAppSelector } from '@renderer/store' +import store, { useAppDispatch } 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' @@ -59,7 +60,7 @@ interface Props { let _text = '' let _files: FileType[] = [] -const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { +const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) => { const [text, setText] = useState(_text) const [inputFocus, setInputFocus] = useState(false) const { assistant, addTopic, model, setModel, updateAssistant } = useAssistant(_assistant.id) @@ -76,13 +77,14 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { const [expended, setExpend] = useState(false) const [estimateTokenCount, setEstimateTokenCount] = useState(0) const [contextCount, setContextCount] = useState({ current: 0, max: 0 }) - const generating = useAppSelector((state) => state.runtime.generating) + // const generating = useAppSelector((state) => state.runtime.generating) const textareaRef = useRef(null) const [files, setFiles] = useState(_files) const { t } = useTranslation() const containerRef = useRef(null) const { searching } = useRuntime() const { isBubbleStyle } = useMessageStyle() + const { loading } = useMessageOperations(topic) const dispatch = useAppDispatch() const [spaceClickCount, setSpaceClickCount] = useState(0) const spaceClickTimer = useRef() @@ -131,9 +133,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { _files = files const sendMessage = useCallback(async () => { - await modelGenerating() - - if (inputEmpty) { + if (inputEmpty || loading) { return } @@ -141,7 +141,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { // Dispatch the sendMessage action with all options const uploadedFiles = await FileManager.uploadFiles(files) dispatch( - _sendMessage(text, assistant, assistant.topics[0], { + _sendMessage(text, assistant, topic, { files: uploadedFiles, knowledgeBaseIds: selectedKnowledgeBases?.map((base) => base.id), mentionModels, @@ -283,7 +283,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { }, [addTopic, assistant, clickAssistantToShowTopic, setActiveTopic, setModel]) const clearTopic = async () => { - if (generating) { + if (loading) { onPause() await delay(1) } @@ -299,7 +299,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { } const onNewContext = () => { - if (generating) return onPause() + if (loading) return onPause() EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT) } @@ -478,7 +478,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { }, [isDragging, handleDrag, handleDragEnd]) useShortcut('new_topic', () => { - if (!generating) { + if (!loading) { addNewTopic() EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) textareaRef.current?.focus() @@ -742,14 +742,14 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { - {generating && ( + {loading && ( )} - {!generating && } + {!loading && } diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 4295de17..e89f8d5a 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -5,11 +5,8 @@ import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelUniqId } from '@renderer/services/ModelService' -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 { classNames } from '@renderer/utils' import { Divider, Dropdown } from 'antd' import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -31,9 +28,7 @@ interface Props { style?: React.CSSProperties isGrouped?: boolean isStreaming?: boolean - onGetMessages?: () => Message[] onSetMessages?: Dispatch> - onDeleteMessage?: (message: Message) => Promise } const MessageItem: FC = ({ @@ -44,11 +39,8 @@ const MessageItem: FC = ({ hidePresetMessages, isGrouped, isStreaming = false, - style, - onDeleteMessage, - onGetMessages + style }) => { - const dispatch = useAppDispatch() const { t } = useTranslation() const { assistant, setModel } = useAssistant(message.assistantId) const model = useModel(getMessageModelId(message), message.model?.provider) || message.model @@ -114,19 +106,6 @@ const MessageItem: FC = ({ return () => unsubscribes.forEach((unsub) => unsub()) }, [message.id, messageHighlightHandler]) - useEffect(() => { - if (message.role === 'user' && !message.usage && topic) { - runAsyncFunction(async () => { - const usage = await estimateMessageUsage(message) - if (topic) { - await dispatch( - updateMessages(topic, onGetMessages?.()?.map((m) => (m.id === message.id ? { ...m, usage } : m)) || []) - ) - } - }) - } - }, [message, topic, dispatch, onGetMessages]) - if (hidePresetMessages && message.isPreset) { return null } @@ -187,8 +166,6 @@ const MessageItem: FC = ({ isGrouped={isGrouped} messageContainerRef={messageContainerRef} setModel={setModel} - onDeleteMessage={onDeleteMessage} - onGetMessages={onGetMessages} /> )} diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 6dfb02a9..104851bc 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -6,7 +6,6 @@ import { classNames } from '@renderer/utils' import { Popover } from 'antd' 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 MessageGroupMenuBar from './MessageGroupMenuBar' @@ -16,23 +15,12 @@ interface Props { messages: (Message & { index: number })[] topic: Topic hidePresetMessages?: boolean - onGetMessages: () => Message[] onSetMessages: Dispatch> - onDeleteMessage: (message: Message) => Promise - onDeleteGroupMessages: (askId: string) => Promise } -const MessageGroup = ({ - messages, - topic, - hidePresetMessages, - onDeleteMessage, - onSetMessages, - onGetMessages, - onDeleteGroupMessages -}: Props) => { +const MessageGroup = ({ messages, topic, hidePresetMessages, onSetMessages }: Props) => { const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() - const { t } = useTranslation() + // const { t } = useTranslation() const [multiModelMessageStyle, setMultiModelMessageStyle] = useState(multiModelMessageStyleSetting) @@ -44,21 +32,9 @@ const MessageGroup = ({ const isHorizontal = multiModelMessageStyle === 'horizontal' const isGrid = multiModelMessageStyle === 'grid' - const handleDeleteGroup = useCallback(async () => { - const askId = messages[0]?.askId - if (!askId) return + // const handleDeleteGroup = useCallback(async () => { - window.modal.confirm({ - title: t('message.group.delete.title'), - content: t('message.group.delete.content'), - centered: true, - okButtonProps: { - danger: true - }, - okText: t('common.delete'), - onOk: () => onDeleteGroupMessages(askId) - }) - }, [messages, onDeleteGroupMessages, t]) + // }, [messages, t, deleteGroupMessages]) useEffect(() => { setSelectedIndex(messageLength - 1) @@ -76,9 +52,9 @@ const MessageGroup = ({ style: { paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15 }, - onSetMessages, - onDeleteMessage, - onGetMessages + onSetMessages + // onDeleteMessage, + // onGetMessages } const messageWrapper = ( @@ -124,8 +100,7 @@ const MessageGroup = ({ topic, hidePresetMessages, onSetMessages, - onDeleteMessage, - onGetMessages, + // onDeleteMessage, gridPopoverTrigger ] ) @@ -149,7 +124,7 @@ const MessageGroup = ({ messages={messages} selectedIndex={selectedIndex} setSelectedIndex={setSelectedIndex} - onDelete={handleDeleteGroup} + topic={topic} /> )} diff --git a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx index da272d14..c19e7cc1 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx @@ -6,8 +6,9 @@ import { NumberOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { MultiModelMessageStyle } from '@renderer/store/settings' -import { Message } from '@renderer/types' +import { Message, Topic } from '@renderer/types' import { Button, Tooltip } from 'antd' import { FC, memo } from 'react' import { useTranslation } from 'react-i18next' @@ -22,7 +23,7 @@ interface Props { messages: Message[] selectedIndex: number setSelectedIndex: (index: number) => void - onDelete: () => void + topic: Topic } const MessageGroupMenuBar: FC = ({ @@ -31,9 +32,26 @@ const MessageGroupMenuBar: FC = ({ messages, selectedIndex, setSelectedIndex, - onDelete + topic }) => { const { t } = useTranslation() + const { deleteGroupMessages } = useMessageOperations(topic) + + const handleDeleteGroup = async () => { + const askId = messages[0]?.askId + if (!askId) return + + window.modal.confirm({ + title: t('message.group.delete.title'), + content: t('message.group.delete.content'), + centered: true, + okButtonProps: { + danger: true + }, + okText: t('common.delete'), + onOk: () => deleteGroupMessages(askId) + }) + } return ( @@ -71,7 +89,7 @@ const MessageGroupMenuBar: FC = ({ type="text" size="small" icon={} - onClick={onDelete} + onClick={handleDeleteGroup} /> ) diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 0d9d2436..f102df75 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -15,19 +15,11 @@ import { UploadOutlined } from '@ant-design/icons' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import TextEditPopup from '@renderer/components/Popups/TextEditPopup' import { TranslateLanguageOptions } from '@renderer/config/translate' +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' 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 { useAppDispatch, useAppSelector } from '@renderer/store' -import { - 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' @@ -43,7 +35,6 @@ import { isEmpty } from 'lodash' import { FC, memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' - interface Props { message: Message assistant: Assistant @@ -55,30 +46,24 @@ interface Props { isAssistantMessage: boolean messageContainerRef: React.RefObject setModel: (model: Model) => void - onDeleteMessage?: (message: Message) => Promise - onGetMessages?: () => Message[] } const MessageMenubar: FC = (props) => { - const { - message, - index, - isGrouped, - isLastMessage, - isAssistantMessage, - assistant, - topic, - model, - messageContainerRef, - onDeleteMessage, - onGetMessages - } = props + const { message, index, isGrouped, isLastMessage, isAssistantMessage, assistant, topic, model, messageContainerRef } = + 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 { + messages, + editMessage, + setStreamMessage, + deleteMessage, + resendMessage, + commitStreamMessage, + clearStreamMessage + } = useMessageOperations(topic) const isUserMessage = message.role === 'user' @@ -109,21 +94,16 @@ const MessageMenubar: FC = (props) => { if (!isEmpty(groupdMessages)) { for (const assistantMessage of groupdMessages) { const _model = assistantMessage.model || assistantModel - await dispatch(resendMessage({ ...assistantMessage, model: _model }, assistant, topic)) + await resendMessage({ ...assistantMessage, model: _model }, assistant) } return } - await dispatch(resendMessage(messageUpdate ?? message, assistant, topic)) + await resendMessage(messageUpdate ?? message, assistant) }, - [message, assistantModel, model, onDeleteMessage, onGetMessages, dispatch, assistant, topic] + [message, assistantModel, resendMessage, assistant] ) - // const onResendUserMessage = useCallback(async () => { - // // await dispatch(resendMessage(message, assistant, topic)) - // onResend() - // }, [message, dispatch, assistant, topic]) - const onEdit = useCallback(async () => { let resendMessage = false @@ -145,52 +125,41 @@ const MessageMenubar: FC = (props) => { }) if (editedText && editedText !== message.content) { // 同步修改store中用户消息 - dispatch(updateMessage({ topicId: topic.id, messageId: message.id, updates: { content: editedText } })) + editMessage(message.id, { content: editedText }) // const updatedMessages = onGetMessages?.() || [] // dispatch(updateMessages(topic, updatedMessages)) } if (resendMessage) handleResendUserMessage({ ...message, content: editedText }) - }, [message, dispatch, topic, onGetMessages, handleResendUserMessage, t]) + }, [message, editMessage, topic, handleResendUserMessage, t]) const handleTranslate = useCallback( async (language: string) => { if (isTranslating) return - dispatch( - updateMessage({ - topicId: topic.id, - messageId: message.id, - updates: { translatedContent: t('translate.processing') } - }) - ) + editMessage(message.id, { translatedContent: t('translate.processing') }) setIsTranslating(true) try { await translateText(message.content, language, (text) => { // 使用 setStreamMessage 来更新翻译内容 - dispatch( - setStreamMessage({ - topicId: topic.id, - message: { ...message, translatedContent: text } - }) - ) + setStreamMessage({ ...message, translatedContent: text }) }) // 翻译完成后,提交流消息 - dispatch(commitStreamMessage({ topicId: topic.id, messageId: message.id })) + commitStreamMessage(message.id) } catch (error) { console.error('Translation failed:', error) 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, messageId: message.id })) + editMessage(message.id, { translatedContent: undefined }) + clearStreamMessage(message.id) } finally { setIsTranslating(false) } }, - [isTranslating, message, dispatch, topic, t] + [isTranslating, message, editMessage, setStreamMessage, commitStreamMessage, clearStreamMessage, t] ) const dropdownItems = useMemo( @@ -272,8 +241,8 @@ const MessageMenubar: FC = (props) => { await modelGenerating() const selectedModel = isGrouped ? model : assistantModel const _message = resetAssistantMessage(message, selectedModel) - dispatch(updateMessage({ topicId: topic.id, messageId: message.id, updates: _message })) - dispatch(resendMessage(_message, assistant, topic)) + editMessage(message.id, { ..._message }) + resendMessage(_message, assistant) } const onMentionModel = async (e: React.MouseEvent) => { @@ -284,15 +253,15 @@ const MessageMenubar: FC = (props) => { // 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)) + resendMessage(message, { ...assistant, model: selectedModel }, true) } const onUseful = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - dispatch(updateMessage({ topicId: topic.id, messageId: message.id, updates: { useful: !message.useful } })) + editMessage(message.id, { useful: !message.useful }) }, - [message, dispatch, topic] + [message, editMessage] ) return ( @@ -350,14 +319,7 @@ const MessageMenubar: FC = (props) => { { label: '✖ ' + t('translate.close'), key: 'translate-close', - onClick: () => - dispatch( - updateMessage({ - topicId: topic.id, - messageId: message.id, - updates: { translatedContent: undefined } - }) - ) + onClick: () => editMessage(message.id, { translatedContent: undefined }) } ], onClick: (e) => e.domEvent.stopPropagation() @@ -379,27 +341,19 @@ const MessageMenubar: FC = (props) => { )} - } - onConfirm={() => onDeleteMessage?.(message)}> - - { - e.stopPropagation() - onDeleteMessage?.(message) - } - : (e) => e.stopPropagation() - }> - + {!isGrouped && ( + } + onConfirm={() => deleteMessage(message)}> + e.stopPropagation()}> + + + - - + + )} {!isUserMessage && ( e.domEvent.stopPropagation() }} diff --git a/src/renderer/src/pages/home/Messages/MessageStream.tsx b/src/renderer/src/pages/home/Messages/MessageStream.tsx index b24778d0..e05f3984 100644 --- a/src/renderer/src/pages/home/Messages/MessageStream.tsx +++ b/src/renderer/src/pages/home/Messages/MessageStream.tsx @@ -1,6 +1,7 @@ import { useAppSelector } from '@renderer/store' import { selectStreamMessage } from '@renderer/store/messages' import { Assistant, Message, Topic } from '@renderer/types' +import { memo } from 'react' import styled from 'styled-components' import MessageItem from './Message' @@ -14,8 +15,6 @@ interface MessageStreamProps { isGrouped?: boolean style?: React.CSSProperties onSetMessages?: React.Dispatch> - onDeleteMessage?: (message: Message) => Promise - onGetMessages?: () => Message[] } const MessageStreamContainer = styled.div` @@ -32,9 +31,7 @@ const MessageStream: React.FC = ({ hidePresetMessages, isGrouped, style, - onDeleteMessage, - onSetMessages, - onGetMessages + onSetMessages }) => { // 获取流式消息 const streamMessage = useAppSelector((state) => selectStreamMessage(state, _message.topicId, _message.id)) @@ -55,8 +52,7 @@ const MessageStream: React.FC = ({ // 在hooks调用后进行条件判断 const isStreaming = !!(streamMessage && streamMessage.id === _message.id) const message = isStreaming ? streamMessage : regularMessage - // console.log('streamMessage', streamMessage) - // console.log('regularMessage', regularMessage) + console.log('isStreaming', isStreaming) return ( = ({ style={style} isStreaming={isStreaming} onSetMessages={onSetMessages} - onDeleteMessage={onDeleteMessage} - onGetMessages={onGetMessages} /> ) } -export default MessageStream +export default memo(MessageStream) diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index a804c601..efd63a8c 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -1,6 +1,7 @@ import Scrollbar from '@renderer/components/Scrollbar' import { LOAD_MORE_COUNT } from '@renderer/config/constant' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { getTopic } from '@renderer/hooks/useTopic' @@ -9,14 +10,7 @@ import { getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getContextCount, getGroupedMessages, getUserMessage } from '@renderer/services/MessagesService' import { estimateHistoryTokens } from '@renderer/services/TokenService' -import { useAppDispatch, useAppSelector } from '@renderer/store' -import { - clearTopicMessages, - selectDisplayCount, - selectLoading, - selectTopicMessages, - updateMessages -} from '@renderer/store/messages' +import { useAppDispatch } from '@renderer/store' import type { Assistant, Message, Topic } from '@renderer/types' import { captureScrollableDivAsBlob, @@ -46,20 +40,12 @@ 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(false) const [isLoadingMore, setIsLoadingMore] = useState(false) - - const messagesRef = useRef([]) - - useEffect(() => { - messagesRef.current = messages - }, [messages]) + const { messages, loading, displayCount, updateMessages, clearTopicMessages } = useMessageOperations(topic) useEffect(() => { const reversedMessages = [...messages].reverse() @@ -69,22 +55,6 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) setHasMore(messages.length > displayCount) }, [messages, displayCount]) - const handleDeleteMessage = useCallback( - async (message: Message) => { - const newMessages = messages.filter((m) => m.id !== message.id) - await dispatch(updateMessages(topic, newMessages)) - }, - [dispatch, topic, 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' const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' @@ -97,17 +67,16 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) }, []) const autoRenameTopic = useCallback(async () => { - let messages = [...messagesRef.current] const _topic = getTopic(assistant, topic.id) if (isEmpty(messages)) { return } - messages = messages.filter((m) => m.status === 'success') + const filteredMessages = messages.filter((m) => m.status === 'success') if (!enableTopicNaming) { - const topicName = messages[0]?.content.substring(0, 50) + const topicName = filteredMessages[0]?.content.substring(0, 50) if (topicName) { const data = { ..._topic, name: topicName } as Topic setActiveTopic(data) @@ -116,8 +85,8 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) return } - if (_topic && _topic.name === t('chat.default.topic.name') && messages.length >= 2) { - const summaryText = await fetchMessagesSummary({ messages, assistant }) + if (_topic && _topic.name === t('chat.default.topic.name') && filteredMessages.length >= 2) { + const summaryText = await fetchMessagesSummary({ messages: filteredMessages, assistant }) if (summaryText) { const data = { ..._topic, name: summaryText } setActiveTopic(data) @@ -125,11 +94,9 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [assistant, topic.id, enableTopicNaming, t, setActiveTopic]) + }, [assistant, topic.id, enableTopicNaming, t, setActiveTopic, updateTopic, messages]) useEffect(() => { - const messages = messagesRef.current - const unsubscribes = [ EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, () => { scrollToBottom() @@ -138,12 +105,12 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) const defaultTopic = getDefaultTopic(assistant.id) if (data && data.id !== topic.id) { - await dispatch(clearTopicMessages(data.id)) + await clearTopicMessages(data.id) updateTopic({ ...data, name: defaultTopic.name } as Topic) return } - await dispatch(clearTopicMessages(topic.id)) + await clearTopicMessages() setDisplayMessages([]) const _topic = getTopic(assistant, topic.id) if (_topic) { @@ -166,7 +133,8 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, async () => { const lastMessage = last(messages) if (lastMessage?.type === 'clear') { - handleDeleteMessage(lastMessage) + // TODO + // handleDeleteMessage(lastMessage) scrollToBottom() return } @@ -175,17 +143,25 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) const clearMessage = getUserMessage({ assistant, topic, type: 'clear' }) const newMessages = [...messages, clearMessage] - await dispatch(updateMessages(topic, newMessages)) + await updateMessages(newMessages) scrollToBottom() }) ] - return () => unsubscribes.forEach((unsub) => unsub()) - }, [assistant, dispatch, handleDeleteMessage, scrollToBottom, topic, updateTopic]) + return () => { + for (const unsub of unsubscribes) { + unsub() + } + } + }, [assistant, dispatch, scrollToBottom, topic, updateTopic]) useEffect(() => { const unsubscribes = [EventEmitter.on(EVENT_NAMES.AI_AUTO_RENAME, autoRenameTopic)] - return () => unsubscribes.forEach((unsub) => unsub()) + return () => { + for (const unsub of unsubscribes) { + unsub() + } + } }, [autoRenameTopic]) useEffect(() => { @@ -246,9 +222,6 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) topic={topic} hidePresetMessages={assistant.settings?.hideMessages} onSetMessages={setDisplayMessages} - onDeleteMessage={handleDeleteMessage} - onDeleteGroupMessages={handleDeleteGroupMessages} - onGetMessages={() => messages} /> ))} diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index e30c06b0..bb7806c4 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -22,6 +22,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import store from '@renderer/store' import { setGenerating } from '@renderer/store/runtime' import { Assistant, Topic } from '@renderer/types' +import { removeSpecialCharactersForFileName } from '@renderer/utils' import { copyTopicAsMarkdown } from '@renderer/utils/copy' import { exportMarkdownToNotion, @@ -35,7 +36,6 @@ import { findIndex } from 'lodash' import { FC, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { removeSpecialCharactersForFileName } from '@renderer/utils' interface Props { assistant: Assistant @@ -117,7 +117,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic const onSwitchTopic = useCallback( async (topic: Topic) => { - await modelGenerating() + // await modelGenerating() setActiveTopic(topic) }, [setActiveTopic] diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 94e2b5f4..2abd5a39 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -136,8 +136,11 @@ export async function addAssistantMessagesToTopic({ assistant, topic }: { assist message.usage = await estimateMessageUsage(message) messages.push(message) } - - db.topics.put({ id: topic.id, messages }, topic.id) + if (await db.topics.get(topic.id)) { + await db.topics.update(topic.id, { messages }) + } else { + await db.topics.add({ id: topic.id, messages }) + } return messages } diff --git a/src/renderer/src/store/messages.ts b/src/renderer/src/store/messages.ts index 08223921..f4a48e00 100644 --- a/src/renderer/src/store/messages.ts +++ b/src/renderer/src/store/messages.ts @@ -1,24 +1,20 @@ -import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit' -import { createSelector } from '@reduxjs/toolkit' +import { createAsyncThunk, createSelector, createSlice, type PayloadAction } 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 { estimateMessageUsage } from '@renderer/services/TokenService' 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 + currentTopic: Topic | null + loadingByTopic: Record // 每个会话独立的loading状态 displayCount: number error: string | null } @@ -26,33 +22,69 @@ export interface MessagesState { const initialState: MessagesState = { messagesByTopic: {}, streamMessagesByTopic: {}, - currentTopic: '', - loading: false, + currentTopic: null, + loadingByTopic: {}, displayCount: 20, error: null } -export const initializeMessagesState = createAsyncThunk('messages/initialize', async () => { - // Get all topics from database - const topics = await TopicManager.getAllTopics() - const messagesByTopic: Record = {} +// const MAX_RECENT_TOPICS = 10 - // 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 })) +// // 只初始化最近的会话消息 +// export const initializeMessagesState = createAsyncThunk('messages/initialize', async () => { +// try { +// // 获取所有会话的基本信息 +// const recentTopics = await TopicManager.getTopicLimit(MAX_RECENT_TOPICS) +// console.log('recentTopics', recentTopics) +// const messagesByTopic: Record = {} + +// // 只加载最近会话的消息 +// for (const topic of recentTopics) { +// if (topic.messages && topic.messages.length > 0) { +// const messages = topic.messages.map((msg) => ({ ...msg })) +// messagesByTopic[topic.id] = messages +// } +// } + +// return messagesByTopic +// } catch (error) { +// console.error('Failed to initialize recent messages:', error) +// return {} +// } +// }) + +// 新增准备会话消息的函数,实现懒加载机制 +export const prepareTopicMessages = createAsyncThunk( + 'messages/prepareTopic', + async (topic: Topic, { dispatch, getState }) => { + try { + const state = getState() as RootState + const hasMessageInStore = !!state.messages.messagesByTopic[topic.id] + + // 如果消息不在 Redux store 中,从数据库加载 + if (!hasMessageInStore) { + // 从数据库加载 + await loadTopicMessagesThunk(topic)(dispatch as AppDispatch) + } + + // 设置为当前会话 + dispatch(setCurrentTopic(topic)) + + return true + } catch (error) { + console.error('Failed to prepare topic messages:', error) + return false } } - - return messagesByTopic -}) +) const messagesSlice = createSlice({ name: 'messages', initialState, reducers: { - setLoading: (state, action: PayloadAction) => { - state.loading = action.payload + setTopicLoading: (state, action: PayloadAction<{ topicId: string; loading: boolean }>) => { + const { topicId, loading } = action.payload + state.loadingByTopic[topicId] = loading }, setError: (state, action: PayloadAction) => { state.error = action.payload @@ -70,6 +102,7 @@ const messagesSlice = createSlice({ // 不是什么好主意,不符合语义 state.messagesByTopic[topicId].push(...messages) } else { + // 添加单条消息 state.messagesByTopic[topicId].push(messages) } }, @@ -80,13 +113,13 @@ const messagesSlice = createSlice({ 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 } + const message = topicMessages.find((msg) => msg.id === messageId) + if (message) { + Object.assign(message, updates) } } }, - setCurrentTopic: (state, action: PayloadAction) => { + setCurrentTopic: (state, action: PayloadAction) => { state.currentTopic = action.payload }, clearTopicMessages: (state, action: PayloadAction) => { @@ -96,7 +129,7 @@ const messagesSlice = createSlice({ }, loadTopicMessages: (state, action: PayloadAction<{ topicId: string; messages: Message[] }>) => { const { topicId, messages } = action.payload - state.messagesByTopic[topicId] = messages.map((msg) => ({ ...msg })) + state.messagesByTopic[topicId] = messages }, setStreamMessage: (state, action: PayloadAction<{ topicId: string; message: Message | null }>) => { const { topicId, message } = action.payload @@ -111,24 +144,29 @@ const messagesSlice = createSlice({ 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) + // 确保消息数组存在 + if (!state.messagesByTopic[topicId]) { + state.messagesByTopic[topicId] = [] } - // 只删除这个特定消息的流状态 + // 尝试找到现有消息 + const existingMessage = state.messagesByTopic[topicId].find( + (m) => m.role === 'assistant' && m.id === streamMessage.id + ) + + if (existingMessage) { + // 更新 + Object.assign(existingMessage, streamMessage) + } else { + // 添加新消息 + state.messagesByTopic[topicId].push(streamMessage) + } + // 删除流状态 delete state.streamMessagesByTopic[topicId][messageId] }, clearStreamMessage: (state, action: PayloadAction<{ topicId: string; messageId: string }>) => { @@ -137,27 +175,24 @@ const messagesSlice = createSlice({ 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' - }) } + // extraReducers: (builder) => { + // builder + // .addCase(initializeMessagesState.pending, (state) => { + // state.error = null + // }) + // .addCase(initializeMessagesState.fulfilled, (state, action) => { + // console.log('initializeMessagesState.fulfilled', action.payload) + // state.messagesByTopic = action.payload + // }) + // .addCase(initializeMessagesState.rejected, (state, action) => { + // state.error = action.error.message || 'Failed to load messages' + // }) + // } }) export const { - setLoading, + setTopicLoading, setError, setDisplayCount, addMessage, @@ -178,24 +213,28 @@ const handleResponseMessageUpdate = (message, topicId, dispatch, getState) => { if (message.status === 'success') { 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) + if (message.status !== 'sending') { + dispatch(commitStreamMessage({ topicId, messageId: message.id })) + const state = getState() + const topicMessages = state.messages.messagesByTopic[topicId] + if (topicMessages) { + syncMessagesWithDB(topicId, topicMessages) + } + dispatch(setTopicLoading({ topicId, loading: false })) } } } // 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 - }) + const topic = await db.topics.get(topicId) + if (topic) { + await db.topics.update(topicId, { + messages + }) + } else { + await db.topics.add({ id: topicId, messages }) + } } // Modified sendMessage thunk @@ -215,7 +254,7 @@ export const sendMessage = ) => async (dispatch: AppDispatch, getState: () => RootState) => { try { - dispatch(setLoading(true)) + dispatch(setTopicLoading({ topicId: topic.id, loading: true })) // Initialize topic messages if not exists const initialState = getState() @@ -229,11 +268,10 @@ export const sendMessage = // 使用用户消息 let userMessage: Message if (isResend) { - userMessage = options.resendUserMessage! + userMessage = options.resendUserMessage } else { // 创建新的用户消息 userMessage = getUserMessage({ assistant, topic, type: 'text', content }) - if (options?.files) { userMessage.files = options.files } @@ -249,11 +287,7 @@ export const sendMessage = if (options?.enabledMCPs) { userMessage.enabledMCPs = options.enabledMCPs } - } - - // 如果不是重发,才添加新的用户消息 - if (!isResend) { - dispatch(addMessage({ topicId: topic.id, messages: userMessage })) + userMessage.usage = await estimateMessageUsage(userMessage) } EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE) @@ -261,7 +295,6 @@ export const sendMessage = // 处理助手消息 // let assistantMessage: Message let assistantMessages: Message[] = [] - // 使用助手消息 if (isResend && options.resendAssistantMessage) { // 直接使用传入的助手消息,进行重置 @@ -270,7 +303,6 @@ export const sendMessage = const resetMessage = resetAssistantMessage(messageToReset, model) // 更新状态 dispatch(updateMessage({ topicId: topic.id, messageId: id, updates: resetMessage })) - // 使用重置后的消息 assistantMessages.push(resetMessage) } else { @@ -293,17 +325,18 @@ export const sendMessage = } } - // Use topic queue to handle request + // 如果不是重发 + !options?.resendAssistantMessage && + dispatch( + addMessage({ + topicId: topic.id, + messages: !isResend ? [userMessage, ...assistantMessages] : assistantMessages + }) + ) + const queue = getTopicQueue(topic.id) - - // let assistantMessage: Message | undefined - if (!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 })) @@ -314,18 +347,16 @@ export const sendMessage = if (currentTopicMessages) { await syncMessagesWithDB(topic.id, currentTopicMessages) } - + // 保证请求有序,防止请求静态,限制并发数量 queue.add(async () => { try { const state = getState() - const topicMessages = state.messages.messagesByTopic[topic.id] - if (!topicMessages) { + const messages = state.messages.messagesByTopic[topic.id] + if (!messages) { dispatch(clearTopicMessages(topic.id)) return } - const messages = convertToDBFormat(topicMessages) - // Prepare assistant config const assistantWithModel = assistantMessage.model ? { ...assistant, model: assistantMessage.model } @@ -374,8 +405,7 @@ export const sendMessage = } catch (error: any) { console.error('Error in sendMessage:', error) dispatch(setError(error.message)) - } finally { - dispatch(setLoading(false)) + dispatch(setTopicLoading({ topicId: topic.id, loading: false })) } } @@ -394,7 +424,7 @@ export const resendMessage = // 查找此用户消息对应的助手消息 const assistantMessage = topicMessages.find((m) => m.role === 'assistant' && m.askId === message.id) - dispatch( + return dispatch( sendMessage(message.content, assistant, topic, { resendUserMessage: message, resendAssistantMessage: assistantMessage @@ -404,11 +434,10 @@ export const resendMessage = // 如果是助手消息,找到对应的用户消息 const userMessage = topicMessages.find((m) => m.id === message.askId && m.role === 'user') - + console.log('topicMessages,topicMessages', topicMessages) if (!userMessage) { console.error('Cannot find original user message to resend') - dispatch(setError('Cannot find original user message to resend')) - return + return dispatch(setError('Cannot find original user message to resend')) } if (isMentionModel) { @@ -430,32 +459,44 @@ export const resendMessage = console.error('Error in resendMessage:', error) dispatch(setError(error.message)) } finally { - dispatch(setLoading(false)) + dispatch(setTopicLoading({ topicId: topic.id, loading: false })) } } // Modified loadTopicMessages thunk -export const loadTopicMessagesThunk = (topicId: string) => async (dispatch: AppDispatch) => { +export const loadTopicMessagesThunk = (topic: Topic) => 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)) + // 设置会话的loading状态 + dispatch(setTopicLoading({ topicId: topic.id, loading: true })) + dispatch(setCurrentTopic(topic)) + try { + // 使用 getTopic 获取会话对象 + const topicWithDB = await TopicManager.getTopic(topic.id) + if (topicWithDB) { + // 如果数据库中有会话,加载消息,保存会话 + dispatch(loadTopicMessages({ topicId: topic.id, messages: topicWithDB.messages })) + } + // else { + // // 如果找不到,可以将当前会话设为 null + // dispatch(setCurrentTopic(null)) + // } + } catch (error) { + console.error('Failed to get complete topic:', error) + dispatch(setCurrentTopic(null)) + } } catch (error) { dispatch(setError(error instanceof Error ? error.message : 'Failed to load messages')) } finally { - dispatch(setLoading(false)) + // 清除会话的loading状态 + dispatch(setTopicLoading({ topicId: topic.id, loading: false })) } } // Modified clearMessages thunk export const clearTopicMessagesThunk = (topic: Topic) => async (dispatch: AppDispatch) => { try { - dispatch(setLoading(true)) + // 设置会话的loading状态 + dispatch(setTopicLoading({ topicId: topic.id, loading: true })) // Wait for any pending requests to complete await waitForTopicQueue(topic.id) @@ -468,49 +509,50 @@ export const clearTopicMessagesThunk = (topic: Topic) => async (dispatch: AppDis await db.topics.update(topic.id, { messages: [] }) // Update current topic - dispatch(setCurrentTopic(topic.id)) + dispatch(setCurrentTopic(topic)) } catch (error) { dispatch(setError(error instanceof Error ? error.message : 'Failed to clear messages')) } finally { - dispatch(setLoading(false)) + // 清除会话的loading状态 + dispatch(setTopicLoading({ topicId: topic.id, loading: false })) } } -// Modified updateMessages thunk +// 修改的 updateMessages thunk,同时更新缓存 export const updateMessages = (topic: Topic, messages: Message[]) => async (dispatch: AppDispatch) => { try { - dispatch(setLoading(true)) + // 设置会话的loading状态 + dispatch(setTopicLoading({ topicId: topic.id, loading: true })) + + // 更新数据库 await db.topics.update(topic.id, { messages }) + + // 更新 Redux store dispatch(loadTopicMessages({ topicId: topic.id, messages })) } catch (error) { dispatch(setError(error instanceof Error ? error.message : 'Failed to update messages')) } finally { - dispatch(setLoading(false)) + // 清除会话的loading状态 + dispatch(setTopicLoading({ topicId: topic.id, loading: 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 selectCurrentTopicId = (state: RootState): string | null => { + const messagesState = state.messages + return messagesState.currentTopic?.id ?? null } -export const selectLoading = (state: RootState): boolean => { +export const selectTopicMessages = createSelector( + [(state: RootState) => state.messages.messagesByTopic, (_, topicId: string) => topicId], + (messagesByTopic, topicId) => (topicId ? (messagesByTopic[topicId] ?? []) : []) +) + +// 获取特定话题的loading状态 +export const selectTopicLoading = (state: RootState, topicId?: string): boolean => { const messagesState = state.messages as MessagesState - return messagesState?.loading || false + const currentTopicId = topicId || messagesState.currentTopic?.id || '' + return currentTopicId ? (messagesState.loadingByTopic[currentTopicId] ?? false) : false } export const selectDisplayCount = (state: RootState): number => {