pert: Optimize/message structure (#3136)

* refactor: Simplify message operations with new useMessageOperations hook

- Introduce useMessageOperations hook to centralize message-related actions
- Remove prop drilling for message deletion and management
- Refactor MessageMenubar, MessageGroup, and Messages components to use new hook
- Remove commented-out code and simplify message state management
- Improve type safety and reduce component complexity

* feat: Enhance topic management with sequence-based sorting and lazy loading

- Add sequence field to topics for better sorting
- Implement lazy loading mechanism for topic messages
- Modify Redux store to support per-topic loading states
- Update database schema to use sequence as an auto-incrementing primary key
- Optimize message initialization and retrieval process

* refactor: Simplify message operations with new useMessageOperations hook

- Introduce useMessageOperations hook to centralize message-related actions
- Remove prop drilling for message deletion and management
- Refactor MessageMenubar, MessageGroup, and Messages components to use new hook
- Remove commented-out code and simplify message state management
- Improve type safety and reduce component complexity

* refactor(database): Enhance topic management with timestamps and upgrade logic

- Modify database schema to include createdAt and updatedAt for topics
- Add database hooks for automatic timestamp handling
- Refactor topic upgrade process to support new timestamp fields
- Remove redundant upgradesV6.ts file
- Update topic retrieval to use updatedAt for sorting
- Improve database consistency and tracking of topic modifications

* fix: Improve message loading state management and UI synchronization

- Update Inputbar to use useMessageOperations hook for loading state
- Correct topic loading state management in Redux store
- Fix loading state synchronization in sendMessage action
- Remove unnecessary commented-out code
- Enhance error handling and loading state tracking

* refactor: Streamline message state management and remove unused code

- Remove commented-out code in multiple components
- Delete initializeMessagesState thunk from messages store
- Simplify message sending and streaming logic
- Remove unnecessary console logs
- Optimize MessageStream component with memo
- Using loading to control message generation within a single session
- Lift the restriction on not being able to switch topics in message generation
This commit is contained in:
MyPrototypeWhat 2025-03-11 11:43:22 +08:00 committed by GitHub
parent 56dd2d17e7
commit 2fd3ebb378
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 519 additions and 369 deletions

View File

@ -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<FileType, 'id'>
@ -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

View File

@ -36,3 +36,18 @@ export async function upgradeToV5(tx: Transaction): Promise<void> {
}
}
}
export async function upgradeToV6(tx: Transaction): Promise<void> {
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
})
}
}
}

View File

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

View File

@ -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<Message>) => {
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
}
}

View File

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

View File

@ -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<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const Inputbar: FC<Props> = ({ 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<Props> = ({ 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<TextAreaRef>(null)
const [files, setFiles] = useState<FileType[]>(_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<NodeJS.Timeout>()
@ -131,9 +133,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
_files = files
const sendMessage = useCallback(async () => {
await modelGenerating()
if (inputEmpty) {
if (inputEmpty || loading) {
return
}
@ -141,7 +141,7 @@ const Inputbar: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic }) => {
</ToolbarMenu>
<ToolbarMenu>
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
{generating && (
{loading && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>
<PauseCircleOutlined style={{ color: 'var(--color-error)', fontSize: 20 }} />
</ToolbarButton>
</Tooltip>
)}
{!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || inputEmpty} />}
{!loading && <SendMessageButton sendMessage={sendMessage} disabled={loading || inputEmpty} />}
</ToolbarMenu>
</Toolbar>
</InputBarContainer>

View File

@ -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<SetStateAction<Message[]>>
onDeleteMessage?: (message: Message) => Promise<void>
}
const MessageItem: FC<Props> = ({
@ -44,11 +39,8 @@ const MessageItem: FC<Props> = ({
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<Props> = ({
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<Props> = ({
isGrouped={isGrouped}
messageContainerRef={messageContainerRef}
setModel={setModel}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageFooter>
)}

View File

@ -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<SetStateAction<Message[]>>
onDeleteMessage: (message: Message) => Promise<void>
onDeleteGroupMessages: (askId: string) => Promise<void>
}
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<MultiModelMessageStyle>(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}
/>
)}
</GroupContainer>

View File

@ -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<Props> = ({
@ -31,9 +32,26 @@ const MessageGroupMenuBar: FC<Props> = ({
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 (
<GroupMenuBar $layout={multiModelMessageStyle} className="group-menu-bar">
<HStack style={{ alignItems: 'center', flex: 1, overflow: 'hidden' }}>
@ -71,7 +89,7 @@ const MessageGroupMenuBar: FC<Props> = ({
type="text"
size="small"
icon={<DeleteOutlined style={{ color: 'var(--color-error)' }} />}
onClick={onDelete}
onClick={handleDeleteGroup}
/>
</GroupMenuBar>
)

View File

@ -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<HTMLDivElement>
setModel: (model: Model) => void
onDeleteMessage?: (message: Message) => Promise<void>
onGetMessages?: () => Message[]
}
const MessageMenubar: FC<Props> = (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> = (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> = (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> = (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> = (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> = (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> = (props) => {
</ActionButton>
</Tooltip>
)}
<Popconfirm
disabled={isGrouped}
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={() => onDeleteMessage?.(message)}>
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
<ActionButton
className="message-action-button"
onClick={
isGrouped
? (e) => {
e.stopPropagation()
onDeleteMessage?.(message)
}
: (e) => e.stopPropagation()
}>
<DeleteOutlined />
{!isGrouped && (
<Popconfirm
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={() => deleteMessage(message)}>
<ActionButton className="message-action-button" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
<DeleteOutlined />
</Tooltip>
</ActionButton>
</Tooltip>
</Popconfirm>
</Popconfirm>
)}
{!isUserMessage && (
<Dropdown
menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }}

View File

@ -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<React.SetStateAction<Message[]>>
onDeleteMessage?: (message: Message) => Promise<void>
onGetMessages?: () => Message[]
}
const MessageStreamContainer = styled.div`
@ -32,9 +31,7 @@ const MessageStream: React.FC<MessageStreamProps> = ({
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<MessageStreamProps> = ({
// 在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 (
<MessageStreamContainer>
<MessageItem
@ -69,11 +65,9 @@ const MessageStream: React.FC<MessageStreamProps> = ({
style={style}
isStreaming={isStreaming}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageStreamContainer>
)
}
export default MessageStream
export default memo(MessageStream)

View File

@ -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<MessagesProps> = ({ assistant, topic, setActiveTopic })
const { t } = useTranslation()
const { showTopics, topicPosition, showAssistants, enableTopicNaming } = useSettings()
const { updateTopic } = useAssistant(assistant.id)
const messages = useAppSelector((state) => selectTopicMessages(state, topic.id))
const loading = useAppSelector(selectLoading)
const displayCount = useAppSelector(selectDisplayCount)
const dispatch = useAppDispatch()
const containerRef = useRef<HTMLDivElement>(null)
const [displayMessages, setDisplayMessages] = useState<Message[]>([])
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const messagesRef = useRef<Message[]>([])
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<MessagesProps> = ({ 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<MessagesProps> = ({ 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<MessagesProps> = ({ 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<MessagesProps> = ({ 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<MessagesProps> = ({ 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<MessagesProps> = ({ 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<MessagesProps> = ({ 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<MessagesProps> = ({ assistant, topic, setActiveTopic })
topic={topic}
hidePresetMessages={assistant.settings?.hideMessages}
onSetMessages={setDisplayMessages}
onDeleteMessage={handleDeleteMessage}
onDeleteGroupMessages={handleDeleteGroupMessages}
onGetMessages={() => messages}
/>
))}
</ScrollContainer>

View File

@ -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<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
const onSwitchTopic = useCallback(
async (topic: Topic) => {
await modelGenerating()
// await modelGenerating()
setActiveTopic(topic)
},
[setActiveTopic]

View File

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

View File

@ -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<string, Message[]>
streamMessagesByTopic: Record<string, Record<string, Message | null>>
currentTopic: string
loading: boolean
currentTopic: Topic | null
loadingByTopic: Record<string, boolean> // 每个会话独立的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<string, Message[]> = {}
// 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<string, Message[]> = {}
// // 只加载最近会话的消息
// 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<boolean>) => {
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<string | null>) => {
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<string>) => {
setCurrentTopic: (state, action: PayloadAction<Topic | null>) => {
state.currentTopic = action.payload
},
clearTopicMessages: (state, action: PayloadAction<string>) => {
@ -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 => {