refactor(ChatProvider, useChat): implement context for chat state management and enhance event handling

- Introduced ChatProvider to manage active assistant and topic state using React context.
- Updated useChat hook to utilize context for accessing chat state and actions.
- Refactored message navigation to emit events for setting active assistant and topic.
- Improved topic selection logic to ensure active topic is valid when topics change.
- Integrated ChatProvider into HomePage and HistoryPage for consistent chat state management.
This commit is contained in:
kangfenmao 2025-06-13 18:56:55 +08:00
parent 58f3edb352
commit cdfa2ac13a
14 changed files with 215 additions and 161 deletions

View File

@ -1,5 +1,5 @@
import { IpcChannel } from '@shared/IpcChannel'
import { AnimatePresence, motion } from 'framer-motion'
import { AnimatePresence, easeInOut, motion } from 'framer-motion'
import { useEffect } from 'react'
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
@ -94,9 +94,9 @@ const PageContainer = styled(motion.div)`
`
const pageTransition = {
type: 'tween',
type: 'tween' as const,
duration: 0.25,
ease: [0.4, 0.0, 0.2, 1]
ease: easeInOut
}
const pageVariants = {

View File

@ -1,22 +1,43 @@
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setActiveAssistant, setActiveTopic } from '@renderer/store/runtime'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Assistant } from '@renderer/types'
import { Topic } from '@renderer/types'
import { useEffect } from 'react'
import { use, useEffect, useMemo, useState } from 'react'
import { createContext } from 'react'
import { useAssistants, useTopicsForAssistant } from './useAssistant'
import { useTopicsForAssistant } from './useAssistant'
import { useSettings } from './useSettings'
export const useChat = () => {
const { assistants } = useAssistants()
const activeAssistant = useAppSelector((state) => state.runtime.chat.activeAssistant) || assistants[0]
interface ChatContextType {
activeAssistant: Assistant
activeTopic: Topic
setActiveAssistant: (assistant: Assistant) => void
setActiveTopic: (topic: Topic) => void
}
const ChatContext = createContext<ChatContextType | null>(null)
export const ChatProvider = ({ children }) => {
const assistants = useAppSelector((state) => state.assistants.assistants)
const [activeAssistant, setActiveAssistant] = useState<Assistant>(assistants[0])
const topics = useTopicsForAssistant(activeAssistant.id)
const activeTopic = useAppSelector((state) => state.runtime.chat.activeTopic) || topics[0]
const [activeTopic, setActiveTopic] = useState<Topic>(topics[0])
const { clickAssistantToShowTopic } = useSettings()
const dispatch = useAppDispatch()
console.log('activeAssistant', activeAssistant)
console.log('activeTopic', activeTopic)
// 当 topics 变化时,如果当前 activeTopic 不在 topics 中,设置第一个 topic
useEffect(() => {
if (!topics.find((topic) => topic.id === activeTopic?.id)) {
const firstTopic = topics[0]
firstTopic && setActiveTopic(firstTopic)
}
}, [topics, activeTopic?.id])
// 当 activeTopic 变化时加载消息
useEffect(() => {
if (activeTopic) {
dispatch(loadTopicMessagesThunk(activeTopic.id))
@ -24,28 +45,38 @@ export const useChat = () => {
}
}, [activeTopic, dispatch])
useEffect(() => {
if (topics.find((topic) => topic.id === activeTopic?.id)) {
return
}
const firstTopic = topics[0]
firstTopic && dispatch(setActiveTopic(firstTopic))
}, [activeAssistant, activeTopic?.id, dispatch, topics])
// 处理点击助手显示话题侧边栏
useEffect(() => {
if (clickAssistantToShowTopic) {
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
}
}, [clickAssistantToShowTopic, activeAssistant])
return {
activeAssistant,
activeTopic,
setActiveAssistant: (assistant: Assistant) => {
dispatch(setActiveAssistant(assistant))
},
setActiveTopic: (topic: Topic) => {
dispatch(setActiveTopic(topic))
}
}
useEffect(() => {
const subscriptions = [
EventEmitter.on(EVENT_NAMES.SET_ASSISTANT, setActiveAssistant),
EventEmitter.on(EVENT_NAMES.SET_TOPIC, setActiveTopic)
]
return () => subscriptions.forEach((subscription) => subscription())
}, [])
const value = useMemo(
() => ({
activeAssistant,
activeTopic,
setActiveAssistant,
setActiveTopic
}),
[activeAssistant, activeTopic]
)
return <ChatContext value={value}>{children}</ChatContext>
}
export const useChat = () => {
const context = use(ChatContext)
if (!context) {
throw new Error('useChat must be used within ChatProvider')
}
return context
}

View File

@ -3,7 +3,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { selectMessagesForTopic } from '@renderer/store/newMessage'
import { setActiveTopic, setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
import { setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
import { Topic } from '@renderer/types'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -27,10 +27,6 @@ export const useChatContext = (activeTopic: Topic) => {
return () => unsubscribe()
}, [dispatch])
useEffect(() => {
dispatch(setActiveTopic(activeTopic))
}, [dispatch, activeTopic])
const handleToggleMultiSelectMode = useCallback(
(value: boolean) => {
dispatch(toggleMultiSelectMode(value))

View File

@ -29,7 +29,9 @@ const App: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const handleClick = () => {
if (!isHome) {
navigate('/')
setTimeout(() => {
navigate('/')
}, 300)
}
openMinappKeepAlive(app)

View File

@ -1,4 +1,5 @@
import { HStack } from '@renderer/components/Layout'
import { ChatProvider } from '@renderer/hooks/useChat'
import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Topic } from '@renderer/types'
@ -72,51 +73,53 @@ const TopicsPage: FC = () => {
}, [])
return (
<Container>
<HStack style={{ padding: '0 12px', marginTop: 8 }}>
<Input
prefix={
stack.length > 1 ? (
<SearchIcon className="back-icon" onClick={goBack}>
<ChevronLeft size={16} />
</SearchIcon>
) : (
<SearchIcon>
<Search size={15} />
</SearchIcon>
)
}
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
ref={inputRef}
placeholder={t('history.search.placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value.trimStart())}
allowClear
autoFocus
spellCheck={false}
style={{ paddingLeft: 0 }}
variant="borderless"
size="middle"
onPressEnter={onSearch}
/>
</HStack>
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
<ChatProvider>
<Container>
<HStack style={{ padding: '0 12px', marginTop: 8 }}>
<Input
prefix={
stack.length > 1 ? (
<SearchIcon className="back-icon" onClick={goBack}>
<ChevronLeft size={16} />
</SearchIcon>
) : (
<SearchIcon>
<Search size={15} />
</SearchIcon>
)
}
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
ref={inputRef}
placeholder={t('history.search.placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value.trimStart())}
allowClear
autoFocus
spellCheck={false}
style={{ paddingLeft: 0 }}
variant="borderless"
size="middle"
onPressEnter={onSearch}
/>
</HStack>
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
<TopicsHistory
keywords={search}
onClick={onTopicClick as any}
onSearch={onSearch}
style={{ display: isShow('topics') }}
/>
<TopicMessages topic={topic} style={{ display: isShow('topic') }} />
<SearchResults
keywords={isShow('search') ? searchKeywords : ''}
onMessageClick={onMessageClick}
onTopicClick={onTopicClick}
style={{ display: isShow('search') }}
/>
<SearchMessage message={message} style={{ display: isShow('message') }} />
</Container>
<TopicsHistory
keywords={search}
onClick={onTopicClick as any}
onSearch={onSearch}
style={{ display: isShow('topics') }}
/>
<TopicMessages topic={topic} style={{ display: isShow('topic') }} />
<SearchResults
keywords={isShow('search') ? searchKeywords : ''}
onMessageClick={onMessageClick}
onTopicClick={onTopicClick}
style={{ display: isShow('search') }}
/>
<SearchMessage message={message} style={{ display: isShow('message') }} />
</Container>
</ChatProvider>
)
}

View File

@ -1,7 +1,6 @@
import { ArrowRightOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import { useChat } from '@renderer/hooks/useChat'
import { useSettings } from '@renderer/hooks/useSettings'
import { getTopicById } from '@renderer/hooks/useTopic'
import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
@ -22,7 +21,6 @@ const SearchMessage: FC<Props> = ({ message, ...props }) => {
const { messageStyle } = useSettings()
const { t } = useTranslation()
const [topic, setTopic] = useState<Topic | null>(null)
const { setActiveAssistant, setActiveTopic } = useChat()
useEffect(() => {
runAsyncFunction(async () => {
@ -50,13 +48,11 @@ const SearchMessage: FC<Props> = ({ message, ...props }) => {
type="text"
size="middle"
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 10 }}
onClick={() => locateToMessage({ message, setActiveAssistant, setActiveTopic })}
onClick={() => locateToMessage(message)}
icon={<ArrowRightOutlined />}
/>
<HStack mt="10px" justifyContent="center">
<Button
onClick={() => locateToMessage({ message, setActiveAssistant, setActiveTopic })}
icon={<ArrowRightOutlined />}>
<Button onClick={() => locateToMessage(message)} icon={<ArrowRightOutlined />}>
{t('history.locate.message')}
</Button>
</HStack>

View File

@ -59,7 +59,7 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
type="text"
size="middle"
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }}
onClick={() => locateToMessage({ message, setActiveAssistant, setActiveTopic })}
onClick={() => locateToMessage(message)}
icon={<ArrowRightOutlined />}
/>
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />

View File

@ -1,4 +1,5 @@
import { HStack } from '@renderer/components/Layout'
import { ChatProvider } from '@renderer/hooks/useChat'
import { useSettings } from '@renderer/hooks/useSettings'
import { FC, useEffect } from 'react'
import styled from 'styled-components'
@ -19,15 +20,17 @@ const HomePage: FC<{ style?: React.CSSProperties }> = ({ style }) => {
}, [showAssistants, showTopics, topicPosition])
return (
<HStack style={{ display: 'flex', flex: 1 }} id="home-page">
<MainSidebar />
<Container style={style}>
<ChatNavbar />
<ContentContainer id="content-container">
<Chat />
</ContentContainer>
</Container>
</HStack>
<ChatProvider>
<HStack style={{ display: 'flex', flex: 1 }} id="home-page">
<MainSidebar />
<Container style={style}>
<ChatNavbar />
<ContentContainer id="content-container">
<Chat />
</ContentContainer>
</Container>
</HStack>
</ChatProvider>
)
}

View File

@ -52,7 +52,6 @@ import {
SubMenu
} from './MainSidebarStyles'
import OpenedMinappTabs from './OpenedMinapps'
import PinnedApps from './PinnedApps'
type Tab = 'assistants' | 'topic'
@ -174,7 +173,7 @@ const MainSidebar: FC = () => {
overflow: showAssistants ? 'initial' : 'hidden'
}}>
<MainNavbar />
<MainMenu>
<MainMenu style={{ marginBottom: 4 }}>
<MainMenuItem active={isAppMenuExpanded} onClick={() => setIsAppMenuExpanded(!isAppMenuExpanded)}>
<MainMenuItemLeft>
<MainMenuItemIcon>
@ -200,7 +199,6 @@ const MainSidebar: FC = () => {
</MainMenuItemLeft>
</MainMenuItem>
))}
<PinnedApps />
</SubMenu>
)}
<OpenedMinappTabs />
@ -299,9 +297,7 @@ const MainContainer = styled.div`
`
const AssistantContainer = styled.div`
margin: 0 10px;
margin-top: 4px;
margin-bottom: 4px;
margin: 4px 10px;
display: flex;
`

View File

@ -85,7 +85,6 @@ export const TabsContainer = styled.div`
export const TabsWrapper = styled(Scrollbar as any)`
width: 100%;
padding: 5px 0;
max-height: 50vh;
`

View File

@ -1,14 +1,16 @@
import DragableList from '@renderer/components/DragableList'
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { Center } from '@renderer/components/Layout'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import type { MenuProps } from 'antd'
import { Empty } from 'antd'
import { Dropdown } from 'antd'
import { isEmpty } from 'lodash'
import { FC, useEffect } from 'react'
import { FC, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -18,7 +20,6 @@ import {
MainMenuItemLeft,
MainMenuItemRight,
MainMenuItemText,
Menus,
TabsContainer,
TabsWrapper
} from './MainSidebarStyles'
@ -27,8 +28,24 @@ const OpenedMinapps: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
const { showOpenedMinappsInSidebar } = useSettings()
const { pinned, updatePinnedMinapps } = useMinapps()
const { t } = useTranslation()
// 合并并排序应用列表
const sortedApps = useMemo(() => {
// 分离已打开但未固定的应用
const openedNotPinned = openedKeepAliveMinapps.filter((app) => !pinned.find((p) => p.id === app.id))
// 获取固定应用列表(保持原有顺序)
const pinnedApps = pinned.map((app) => {
const openedApp = openedKeepAliveMinapps.find((o) => o.id === app.id)
return openedApp || app
})
// 把已启动但未固定的放到列表下面
return [...pinnedApps, ...openedNotPinned]
}, [openedKeepAliveMinapps, pinned])
const handleOnClick = (app) => {
if (minappShow && currentMinappId === app.id) {
hideMinappPopup()
@ -59,51 +76,80 @@ const OpenedMinapps: FC = () => {
container.style.setProperty('--indicator-right', `${indicatorRight}px`)
}, [currentMinappId, openedKeepAliveMinapps, minappShow])
const isShowOpened = showOpenedMinappsInSidebar && openedKeepAliveMinapps.length > 0
const isShowApps = showOpenedMinappsInSidebar && sortedApps.length > 0
if (!isShowOpened) return <TabsContainer className="TabsContainer" />
if (!isShowApps) return <TabsContainer className="TabsContainer" />
return (
<TabsContainer className="TabsContainer">
<Divider />
<TabsWrapper>
<Menus>
{openedKeepAliveMinapps.map((app) => {
<DragableList
list={sortedApps}
onUpdate={(newList) => {
// 只更新固定应用的顺序
const newPinned = newList.filter((app) => pinned.find((p) => p.id === app.id))
updatePinnedMinapps(newPinned)
}}
listStyle={{ margin: '4px 0' }}>
{(app) => {
const isPinned = pinned.find((p) => p.id === app.id)
const isOpened = openedKeepAliveMinapps.find((o) => o.id === app.id)
const menuItems: MenuProps['items'] = [
{
key: 'closeApp',
label: t('minapp.sidebar.close.title'),
onClick: () => closeMinapp(app.id)
},
{
key: 'closeAllApp',
label: t('minapp.sidebar.closeall.title'),
onClick: () => closeAllMinapps()
key: 'togglePin',
label: isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.pin.title'),
onClick: () => {
if (isPinned) {
const newPinned = pinned.filter((item) => item.id !== app.id)
updatePinnedMinapps(newPinned)
} else {
updatePinnedMinapps([...pinned, app])
}
}
}
]
if (isOpened) {
menuItems.push(
{
key: 'closeApp',
label: t('minapp.sidebar.close.title'),
onClick: () => closeMinapp(app.id)
},
{
key: 'closeAllApp',
label: t('minapp.sidebar.closeall.title'),
onClick: () => closeAllMinapps()
}
)
}
return (
<MainMenuItem key={app.id} onClick={() => handleOnClick(app)}>
<MainMenuItemLeft>
<MainMenuItemIcon>
<MinAppIcon size={22} app={app} style={{ borderRadius: 6 }} sidebar />
</MainMenuItemIcon>
<MainMenuItemText>{app.name}</MainMenuItemText>
</MainMenuItemLeft>
<MainMenuItemRight style={{ marginRight: 4 }}>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
<IndicatorLight color="var(--color-primary)" shadow={false} animation={false} size={5} />
</Dropdown>
</MainMenuItemRight>
</MainMenuItem>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
<MainMenuItem key={app.id} onClick={() => handleOnClick(app)}>
<MainMenuItemLeft>
<MainMenuItemIcon>
<MinAppIcon size={22} app={app} style={{ borderRadius: 6 }} sidebar />
</MainMenuItemIcon>
<MainMenuItemText>{app.name}</MainMenuItemText>
</MainMenuItemLeft>
{isOpened && (
<MainMenuItemRight style={{ marginRight: 4 }}>
<IndicatorLight color="var(--color-primary)" shadow={false} animation={false} size={5} />
</MainMenuItemRight>
)}
</MainMenuItem>
</Dropdown>
)
})}
{isEmpty(openedKeepAliveMinapps) && (
<Center>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
</Menus>
}}
</DragableList>
{isEmpty(sortedApps) && (
<Center>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
</TabsWrapper>
<Divider />
</TabsContainer>

View File

@ -29,5 +29,7 @@ export const EVENT_NAMES = {
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',
EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK',
CHANGE_TOPIC: 'CHANGE_TOPIC',
OPEN_MINAPP: 'OPEN_MINAPP'
OPEN_MINAPP: 'OPEN_MINAPP',
SET_ASSISTANT: 'SET_ASSISTANT',
SET_TOPIC: 'SET_TOPIC'
}

View File

@ -81,15 +81,7 @@ export function isGenerating() {
})
}
export async function locateToMessage({
message,
setActiveAssistant,
setActiveTopic
}: {
message: Message
setActiveAssistant: (assistant: Assistant) => void
setActiveTopic: (topic: Topic) => void
}) {
export async function locateToMessage(message: Message) {
await isGenerating()
SearchPopup.hide()
@ -100,11 +92,11 @@ export async function locateToMessage({
return
}
setActiveAssistant(assistant)
setActiveTopic(topic)
EventEmitter.emit(EVENT_NAMES.SET_ASSISTANT, assistant)
EventEmitter.emit(EVENT_NAMES.SET_TOPIC, topic)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id), 500)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id), 200)
}
/**

View File

@ -1,13 +1,11 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { AppLogo, UserAvatar } from '@renderer/config/env'
import type { Assistant, MinAppType, Topic } from '@renderer/types'
import type { MinAppType } from '@renderer/types'
import type { UpdateInfo } from 'builder-util-runtime'
export interface ChatState {
isMultiSelectMode: boolean
selectedMessageIds: string[]
activeTopic: Topic | null
activeAssistant: Assistant | null
/** topic ids that are currently being renamed */
renamingTopics: string[]
/** topic ids that are newly renamed */
@ -70,8 +68,6 @@ const initialState: RuntimeState = {
chat: {
isMultiSelectMode: false,
selectedMessageIds: [],
activeTopic: null,
activeAssistant: null,
renamingTopics: [],
newlyRenamedTopics: []
}
@ -124,12 +120,6 @@ const runtimeSlice = createSlice({
setSelectedMessageIds: (state, action: PayloadAction<string[]>) => {
state.chat.selectedMessageIds = action.payload
},
setActiveTopic: (state, action: PayloadAction<Topic>) => {
state.chat.activeTopic = action.payload
},
setActiveAssistant: (state, action: PayloadAction<Assistant>) => {
state.chat.activeAssistant = action.payload
},
setRenamingTopics: (state, action: PayloadAction<string[]>) => {
state.chat.renamingTopics = action.payload
},
@ -154,8 +144,6 @@ export const {
// Chat related actions
toggleMultiSelectMode,
setSelectedMessageIds,
setActiveTopic,
setActiveAssistant,
setRenamingTopics,
setNewlyRenamedTopics
} = runtimeSlice.actions