mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 05:39:05 +08:00
fix(TopicsTab): persist pending state via Redux and update after task completion (#8376)
* fix(TopicsTab): pending状态将自动关闭并不再由组件管理 * feat(消息状态): 添加话题完成状态指示器及相关逻辑 - 在消息状态中新增fulfilledByTopic字段记录话题完成状态 - 添加setTopicFulfilled action用于更新话题完成状态 - 在话题切换时重置完成状态为false - 在加载完成后设置完成状态为true - 添加完成状态指示器组件并显示在话题列表中 * fix(TopicsTab): 修复不切换话题时未重置当前话题fulfilled状态的问题 * refactor(messageThunk): 重命名 handleChangeLoadingOfTopic 为 finishTopicLoading 提高函数命名清晰度,更准确描述其功能
This commit is contained in:
parent
75b8a5a6a7
commit
622d15d5d7
@ -18,6 +18,8 @@ import { getStoreSetting } from './useSettings'
|
|||||||
let _activeTopic: Topic
|
let _activeTopic: Topic
|
||||||
let _setActiveTopic: (topic: Topic) => void
|
let _setActiveTopic: (topic: Topic) => void
|
||||||
|
|
||||||
|
// const logger = loggerService.withContext('useTopic')
|
||||||
|
|
||||||
export function useActiveTopic(assistantId: string, topic?: Topic) {
|
export function useActiveTopic(assistantId: string, topic?: Topic) {
|
||||||
const { assistant } = useAssistant(assistantId)
|
const { assistant } = useAssistant(assistantId)
|
||||||
const [activeTopic, setActiveTopic] = useState(topic || _activeTopic || assistant?.topics[0])
|
const [activeTopic, setActiveTopic] = useState(topic || _activeTopic || assistant?.topics[0])
|
||||||
|
|||||||
@ -3,8 +3,10 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
|||||||
import { useActiveTopic } from '@renderer/hooks/useTopic'
|
import { useActiveTopic } from '@renderer/hooks/useTopic'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
import NavigationService from '@renderer/services/NavigationService'
|
import NavigationService from '@renderer/services/NavigationService'
|
||||||
|
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||||
import { Assistant, Topic } from '@renderer/types'
|
import { Assistant, Topic } from '@renderer/types'
|
||||||
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useDispatch } from 'react-redux'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@ -25,6 +27,7 @@ const HomePage: FC = () => {
|
|||||||
const [activeAssistant, _setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0])
|
const [activeAssistant, _setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0])
|
||||||
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id, state?.topic)
|
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id, state?.topic)
|
||||||
const { showAssistants, showTopics, topicPosition } = useSettings()
|
const { showAssistants, showTopics, topicPosition } = useSettings()
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
_activeAssistant = activeAssistant
|
_activeAssistant = activeAssistant
|
||||||
|
|
||||||
@ -43,9 +46,12 @@ const HomePage: FC = () => {
|
|||||||
|
|
||||||
const setActiveTopic = useCallback(
|
const setActiveTopic = useCallback(
|
||||||
(newTopic: Topic) => {
|
(newTopic: Topic) => {
|
||||||
startTransition(() => _setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic)))
|
startTransition(() => {
|
||||||
|
_setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic))
|
||||||
|
dispatch(newMessagesActions.setTopicFulfilled({ topicId: newTopic.id, fulfilled: false }))
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[_setActiveTopic]
|
[_setActiveTopic, dispatch]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { fetchMessagesSummary } from '@renderer/services/ApiService'
|
|||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
import store from '@renderer/store'
|
import store from '@renderer/store'
|
||||||
import { RootState } from '@renderer/store'
|
import { RootState } from '@renderer/store'
|
||||||
|
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||||
import { setGenerating } from '@renderer/store/runtime'
|
import { setGenerating } from '@renderer/store/runtime'
|
||||||
import { Assistant, Topic } from '@renderer/types'
|
import { Assistant, Topic } from '@renderer/types'
|
||||||
import { classNames, removeSpecialCharactersForFileName } from '@renderer/utils'
|
import { classNames, removeSpecialCharactersForFileName } from '@renderer/utils'
|
||||||
@ -35,16 +36,17 @@ import {
|
|||||||
exportTopicToNotion,
|
exportTopicToNotion,
|
||||||
topicToMarkdown
|
topicToMarkdown
|
||||||
} from '@renderer/utils/export'
|
} from '@renderer/utils/export'
|
||||||
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
|
||||||
import { Dropdown, MenuProps, Tooltip } from 'antd'
|
import { Dropdown, MenuProps, Tooltip } from 'antd'
|
||||||
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { findIndex } from 'lodash'
|
import { findIndex } from 'lodash'
|
||||||
import { FC, useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'
|
import { FC, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
// const logger = loggerService.withContext('TopicsTab')
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assistant: Assistant
|
assistant: Assistant
|
||||||
activeTopic: Topic
|
activeTopic: Topic
|
||||||
@ -59,6 +61,8 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
|||||||
const { showTopicTime, pinTopicsToTop, setTopicPosition, topicPosition } = useSettings()
|
const { showTopicTime, pinTopicsToTop, setTopicPosition, topicPosition } = useSettings()
|
||||||
|
|
||||||
const renamingTopics = useSelector((state: RootState) => state.runtime.chat.renamingTopics)
|
const renamingTopics = useSelector((state: RootState) => state.runtime.chat.renamingTopics)
|
||||||
|
const topicLoadingQuery = useSelector((state: RootState) => state.messages.loadingByTopic)
|
||||||
|
const topicFulfilledQuery = useSelector((state: RootState) => state.messages.fulfilledByTopic)
|
||||||
const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics)
|
const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics)
|
||||||
|
|
||||||
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
|
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
|
||||||
@ -66,27 +70,13 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
|||||||
const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
|
const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
|
||||||
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
|
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
|
||||||
|
|
||||||
const pendingTopics = useMemo(() => {
|
const isPending = useCallback((topicId: string) => topicLoadingQuery[topicId], [topicLoadingQuery])
|
||||||
return new Set<string>()
|
const isFulfilled = useCallback((topicId: string) => topicFulfilledQuery[topicId], [topicFulfilledQuery])
|
||||||
}, [])
|
const dispatch = useDispatch()
|
||||||
const isPending = useCallback(
|
|
||||||
(topicId: string) => {
|
useEffect(() => {
|
||||||
const hasPending = hasTopicPendingRequests(topicId)
|
dispatch(newMessagesActions.setTopicFulfilled({ topicId: activeTopic.id, fulfilled: false }))
|
||||||
if (topicId === activeTopic.id && !hasPending) {
|
}, [activeTopic.id, dispatch, topicFulfilledQuery])
|
||||||
pendingTopics.delete(topicId)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (pendingTopics.has(topicId)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (hasPending) {
|
|
||||||
pendingTopics.add(topicId)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
[activeTopic.id, pendingTopics]
|
|
||||||
)
|
|
||||||
|
|
||||||
const isRenaming = useCallback(
|
const isRenaming = useCallback(
|
||||||
(topicId: string) => {
|
(topicId: string) => {
|
||||||
@ -480,6 +470,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
|||||||
onClick={() => onSwitchTopic(topic)}
|
onClick={() => onSwitchTopic(topic)}
|
||||||
style={{ borderRadius }}>
|
style={{ borderRadius }}>
|
||||||
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
||||||
|
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
|
||||||
<TopicNameContainer>
|
<TopicNameContainer>
|
||||||
<TopicName className={getTopicNameClassName()} title={topicName}>
|
<TopicName className={getTopicNameClassName()} title={topicName}>
|
||||||
{topicName}
|
{topicName}
|
||||||
@ -644,7 +635,20 @@ const PendingIndicator = styled.div.attrs({
|
|||||||
left: 3px;
|
left: 3px;
|
||||||
top: 15px;
|
top: 15px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-status-warning);
|
||||||
|
`
|
||||||
|
|
||||||
|
const FulfilledIndicator = styled.div.attrs({
|
||||||
|
className: 'animation-pulse'
|
||||||
|
})`
|
||||||
|
--pulse-size: 5px;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
position: absolute;
|
||||||
|
left: 3px;
|
||||||
|
top: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-status-success);
|
||||||
`
|
`
|
||||||
|
|
||||||
const AddTopicButton = styled.div`
|
const AddTopicButton = styled.div`
|
||||||
|
|||||||
@ -212,8 +212,8 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
await saveUpdatesToDB(assistantMsgId, topicId, messageUpdates, [])
|
await saveUpdatesToDB(assistantMsgId, topicId, messageUpdates, [])
|
||||||
|
|
||||||
EventEmitter.emit(EVENT_NAMES.MESSAGE_COMPLETE, { id: assistantMsgId, topicId, status })
|
EventEmitter.emit(EVENT_NAMES.MESSAGE_COMPLETE, { id: assistantMsgId, topicId, status })
|
||||||
|
logger.debug('onComplete finished')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export interface MessagesState extends EntityState<Message, string> {
|
|||||||
messageIdsByTopic: Record<string, string[]> // Map: topicId -> ordered message IDs
|
messageIdsByTopic: Record<string, string[]> // Map: topicId -> ordered message IDs
|
||||||
currentTopicId: string | null
|
currentTopicId: string | null
|
||||||
loadingByTopic: Record<string, boolean>
|
loadingByTopic: Record<string, boolean>
|
||||||
|
fulfilledByTopic: Record<string, boolean>
|
||||||
displayCount: number
|
displayCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ const initialState: MessagesState = messagesAdapter.getInitialState({
|
|||||||
messageIdsByTopic: {},
|
messageIdsByTopic: {},
|
||||||
currentTopicId: null,
|
currentTopicId: null,
|
||||||
loadingByTopic: {},
|
loadingByTopic: {},
|
||||||
|
fulfilledByTopic: {},
|
||||||
displayCount: 20
|
displayCount: 20
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -37,6 +39,12 @@ interface SetTopicLoadingPayload {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payload for setting topic loading state
|
||||||
|
interface SetTopicFulfilledPayload {
|
||||||
|
topicId: string
|
||||||
|
fulfilled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// Payload for upserting a block reference
|
// Payload for upserting a block reference
|
||||||
interface UpsertBlockReferencePayload {
|
interface UpsertBlockReferencePayload {
|
||||||
messageId: string
|
messageId: string
|
||||||
@ -85,6 +93,10 @@ export const messagesSlice = createSlice({
|
|||||||
const { topicId, loading } = action.payload
|
const { topicId, loading } = action.payload
|
||||||
state.loadingByTopic[topicId] = loading
|
state.loadingByTopic[topicId] = loading
|
||||||
},
|
},
|
||||||
|
setTopicFulfilled(state, action: PayloadAction<SetTopicFulfilledPayload>) {
|
||||||
|
const { topicId, fulfilled } = action.payload
|
||||||
|
state.fulfilledByTopic[topicId] = fulfilled
|
||||||
|
},
|
||||||
setDisplayCount(state, action: PayloadAction<number>) {
|
setDisplayCount(state, action: PayloadAction<number>) {
|
||||||
state.displayCount = action.payload
|
state.displayCount = action.payload
|
||||||
},
|
},
|
||||||
@ -104,6 +116,9 @@ export const messagesSlice = createSlice({
|
|||||||
if (!(topicId in state.loadingByTopic)) {
|
if (!(topicId in state.loadingByTopic)) {
|
||||||
state.loadingByTopic[topicId] = false
|
state.loadingByTopic[topicId] = false
|
||||||
}
|
}
|
||||||
|
if (!(topicId in state.fulfilledByTopic)) {
|
||||||
|
state.fulfilledByTopic[topicId] = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
insertMessageAtIndex(state, action: PayloadAction<InsertMessageAtIndexPayload>) {
|
insertMessageAtIndex(state, action: PayloadAction<InsertMessageAtIndexPayload>) {
|
||||||
const { topicId, message, index } = action.payload
|
const { topicId, message, index } = action.payload
|
||||||
@ -118,6 +133,9 @@ export const messagesSlice = createSlice({
|
|||||||
if (!(topicId in state.loadingByTopic)) {
|
if (!(topicId in state.loadingByTopic)) {
|
||||||
state.loadingByTopic[topicId] = false
|
state.loadingByTopic[topicId] = false
|
||||||
}
|
}
|
||||||
|
if (!(topicId in state.fulfilledByTopic)) {
|
||||||
|
state.fulfilledByTopic[topicId] = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
updateMessage(
|
updateMessage(
|
||||||
state,
|
state,
|
||||||
@ -162,6 +180,7 @@ export const messagesSlice = createSlice({
|
|||||||
}
|
}
|
||||||
delete state.messageIdsByTopic[topicId]
|
delete state.messageIdsByTopic[topicId]
|
||||||
state.loadingByTopic[topicId] = false
|
state.loadingByTopic[topicId] = false
|
||||||
|
state.fulfilledByTopic[topicId] = false
|
||||||
},
|
},
|
||||||
removeMessage(state, action: PayloadAction<RemoveMessagePayload>) {
|
removeMessage(state, action: PayloadAction<RemoveMessagePayload>) {
|
||||||
const { topicId, messageId } = action.payload
|
const { topicId, messageId } = action.payload
|
||||||
|
|||||||
@ -29,9 +29,10 @@ import { newMessagesActions, selectMessagesForTopic } from '../newMessage'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('MessageThunk')
|
const logger = loggerService.withContext('MessageThunk')
|
||||||
|
|
||||||
const handleChangeLoadingOfTopic = async (topicId: string) => {
|
const finishTopicLoading = async (topicId: string) => {
|
||||||
await waitForTopicQueue(topicId)
|
await waitForTopicQueue(topicId)
|
||||||
store.dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
store.dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
||||||
|
store.dispatch(newMessagesActions.setTopicFulfilled({ topicId, fulfilled: true }))
|
||||||
}
|
}
|
||||||
// TODO: 后续可以将db操作移到Listener Middleware中
|
// TODO: 后续可以将db操作移到Listener Middleware中
|
||||||
export const saveMessageAndBlocksToDB = async (message: Message, blocks: MessageBlock[], messageIndex: number = -1) => {
|
export const saveMessageAndBlocksToDB = async (message: Message, blocks: MessageBlock[], messageIndex: number = -1) => {
|
||||||
@ -956,7 +957,7 @@ export const sendMessage =
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in sendMessage thunk:', error as Error)
|
logger.error('Error in sendMessage thunk:', error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
handleChangeLoadingOfTopic(topicId)
|
finishTopicLoading(topicId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1213,7 +1214,7 @@ export const resendMessageThunk =
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[resendMessageThunk] Error resending user message ${userMessageToResend.id}:`, error as Error)
|
logger.error(`[resendMessageThunk] Error resending user message ${userMessageToResend.id}:`, error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
handleChangeLoadingOfTopic(topicId)
|
finishTopicLoading(topicId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1347,7 +1348,7 @@ export const regenerateAssistantResponseThunk =
|
|||||||
)
|
)
|
||||||
// dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
// dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
||||||
} finally {
|
} finally {
|
||||||
handleChangeLoadingOfTopic(topicId)
|
finishTopicLoading(topicId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1524,7 +1525,7 @@ export const appendAssistantResponseThunk =
|
|||||||
// Optionally dispatch an error action or notification
|
// Optionally dispatch an error action or notification
|
||||||
// Resetting loading state should be handled by the underlying fetchAndProcessAssistantResponseImpl
|
// Resetting loading state should be handled by the underlying fetchAndProcessAssistantResponseImpl
|
||||||
} finally {
|
} finally {
|
||||||
handleChangeLoadingOfTopic(topicId)
|
finishTopicLoading(topicId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user