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:
Phantom 2025-07-23 14:50:44 +08:00 committed by GitHub
parent 75b8a5a6a7
commit 622d15d5d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 65 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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