perf: history page search performance and loading state (#9344)

* refactor(HistoryPage): add loading state to search results

* refactor: add min height

* perf: speedup message search

* refactor: use cached topics map in onTopicClick

* refactor: smooth scrolling

* refactor: use MutationObserver for better scroll timing

* refactor: remove search.length restrictions

* refactor: use getTopicById in TopicMessages, improve error messages

* fix: i18n
This commit is contained in:
one 2025-08-22 14:39:57 +08:00 committed by GitHub
parent 3501d377f6
commit a4cdb5d45f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 117 additions and 61 deletions

View File

@ -885,6 +885,9 @@
},
"history": {
"continue_chat": "Continue Chatting",
"error": {
"topic_not_found": "Topic not found"
},
"locate": {
"message": "Locate the message"
},

View File

@ -885,6 +885,9 @@
},
"history": {
"continue_chat": "チャットを続ける",
"error": {
"topic_not_found": "トピックが見つかりません"
},
"locate": {
"message": "メッセージを探す"
},

View File

@ -885,6 +885,9 @@
},
"history": {
"continue_chat": "Продолжить чат",
"error": {
"topic_not_found": "Топик не найден"
},
"locate": {
"message": "Найти сообщение"
},

View File

@ -885,6 +885,9 @@
},
"history": {
"continue_chat": "继续聊天",
"error": {
"topic_not_found": "话题不存在"
},
"locate": {
"message": "定位到消息"
},

View File

@ -885,6 +885,9 @@
},
"history": {
"continue_chat": "繼續聊天",
"error": {
"topic_not_found": "話題不存在"
},
"locate": {
"message": "定位到訊息"
},

View File

@ -22,7 +22,7 @@ let _stack: Route[] = ['topics']
let _topic: Topic | undefined
let _message: Message | undefined
const TopicsPage: FC = () => {
const HistoryPage: FC = () => {
const { t } = useTranslation()
const [search, setSearch] = useState(_search)
const [searchKeywords, setSearchKeywords] = useState(_search)
@ -52,7 +52,12 @@ const TopicsPage: FC = () => {
setTopic(undefined)
}
const onTopicClick = (topic: Topic) => {
// topic 不包含 messages用到的时候才会获取
const onTopicClick = (topic: Topic | null | undefined) => {
if (!topic) {
window.message.error(t('history.error.topic_not_found'))
return
}
setStack((prev) => [...prev, 'topic'])
setTopic(topic)
}
@ -86,7 +91,7 @@ const TopicsPage: FC = () => {
</SearchIcon>
)
}
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
suffix={search.length ? <CornerDownLeft size={16} /> : null}
ref={inputRef}
placeholder={t('history.search.placeholder')}
value={search}
@ -146,4 +151,4 @@ const SearchIcon = styled.div`
}
`
export default TopicsPage
export default HistoryPage

View File

@ -1,16 +1,23 @@
import { LoadingIcon } from '@renderer/components/Icons'
import db from '@renderer/databases'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useTimer } from '@renderer/hooks/useTimer'
import { getTopicById } from '@renderer/hooks/useTopic'
import { selectTopicsMap } from '@renderer/store/assistants'
import { Topic } from '@renderer/types'
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
import { List, Typography } from 'antd'
import { List, Spin, Typography } from 'antd'
import { useLiveQuery } from 'dexie-react-hooks'
import { FC, memo, useCallback, useEffect, useState } from 'react'
import { FC, memo, useCallback, useEffect, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
const { Text, Title } = Typography
type SearchResult = {
message: Message
topic: Topic
content: string
}
interface Props extends React.HTMLAttributes<HTMLDivElement> {
keywords: string
onMessageClick: (message: Message) => void
@ -19,7 +26,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...props }) => {
const { handleScroll, containerRef } = useScrollPosition('SearchResults')
const { setTimeoutTimer } = useTimer()
const observerRef = useRef<MutationObserver | null>(null)
const [searchTerms, setSearchTerms] = useState<string[]>(
keywords
@ -29,9 +36,12 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
)
const topics = useLiveQuery(() => db.topics.toArray(), [])
// FIXME: db 中没有 topic.name 等信息,只能从 store 获取
const storeTopicsMap = useSelector(selectTopicsMap)
const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic; content: string }[]>([])
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [searchStats, setSearchStats] = useState({ count: 0, time: 0 })
const [isLoading, setIsLoading] = useState(false)
const removeMarkdown = (text: string) => {
return text
@ -46,33 +56,40 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
const onSearch = useCallback(async () => {
setSearchResults([])
setIsLoading(true)
if (keywords.length === 0) {
setSearchStats({ count: 0, time: 0 })
setSearchTerms([])
setIsLoading(false)
return
}
const startTime = performance.now()
const results: { message: Message; topic: Topic; content: string }[] = []
const newSearchTerms = keywords
.toLowerCase()
.split(' ')
.filter((term) => term.length > 0)
const searchRegexes = newSearchTerms.map((term) => new RegExp(term, 'i'))
const blocksArray = await db.message_blocks.toArray()
const blocks = blocksArray
const blocks = (await db.message_blocks.toArray())
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
.filter((block) => newSearchTerms.some((term) => block.content.toLowerCase().includes(term)))
.filter((block) => searchRegexes.some((regex) => regex.test(block.content)))
const messages = topics?.map((topic) => topic.messages).flat()
const messages = topics?.flatMap((topic) => topic.messages)
for (const block of blocks) {
const message = messages?.find((message) => message.id === block.messageId)
if (message) {
results.push({ message, topic: await getTopicById(message.topicId)!, content: block.content })
}
}
const results = await Promise.all(
blocks.map(async (block) => {
const message = messages?.find((message) => message.id === block.messageId)
if (message) {
const topic = storeTopicsMap.get(message.topicId)
if (topic) {
return { message, topic, content: block.content }
}
}
return null
})
).then((results) => results.filter(Boolean) as SearchResult[])
const endTime = performance.now()
setSearchResults(results)
@ -81,7 +98,8 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
time: (endTime - startTime) / 1000
})
setSearchTerms(newSearchTerms)
}, [keywords, topics])
setIsLoading(false)
}, [keywords, storeTopicsMap, topics])
const highlightText = (text: string) => {
let highlightedText = removeMarkdown(text)
@ -100,9 +118,24 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
onSearch()
}, [onSearch])
useEffect(() => {
if (!containerRef.current) return
observerRef.current = new MutationObserver(() => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
})
observerRef.current.observe(containerRef.current, {
childList: true,
subtree: true
})
return () => observerRef.current?.disconnect()
}, [containerRef])
return (
<Container ref={containerRef} {...props} onScroll={handleScroll}>
<ContainerWrapper>
<Spin spinning={isLoading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
{searchResults.length > 0 && (
<SearchStats>
Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds
@ -113,19 +146,15 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
dataSource={searchResults}
pagination={{
pageSize: 10,
onChange: () => {
setTimeoutTimer('scroll', () => containerRef.current?.scrollTo({ top: 0 }), 0)
}
hideOnSinglePage: true
}}
style={{ opacity: isLoading ? 0 : 1 }}
renderItem={({ message, topic, content }) => (
<List.Item>
<Title
level={5}
style={{ color: 'var(--color-primary)', cursor: 'pointer' }}
onClick={async () => {
const _topic = await getTopicById(topic.id)
onTopicClick(_topic)
}}>
onClick={() => onTopicClick(topic)}>
{topic.name}
</Title>
<div style={{ cursor: 'pointer' }} onClick={() => onMessageClick(message)}>
@ -138,24 +167,17 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
)}
/>
<div style={{ minHeight: 30 }}></div>
</ContainerWrapper>
</Spin>
</Container>
)
}
const Container = styled.div`
width: 100%;
padding: 20px;
height: 100%;
padding: 20px 36px;
overflow-y: auto;
display: flex;
flex-direction: row;
justify-content: center;
`
const ContainerWrapper = styled.div`
width: 100%;
padding: 0 16px;
display: flex;
flex-direction: column;
`
@ -166,6 +188,7 @@ const SearchStats = styled.div`
const SearchResultTime = styled.div`
margin-top: 10px;
text-align: right;
`
export default memo(SearchResults)

View File

@ -5,18 +5,17 @@ import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import { getTopicById } from '@renderer/hooks/useTopic'
import { getAssistantById } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
import NavigationService from '@renderer/services/NavigationService'
import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { classNames, runAsyncFunction } from '@renderer/utils'
import { Button, Divider, Empty } from 'antd'
import { t } from 'i18next'
import { Forward } from 'lucide-react'
import { FC, useEffect } from 'react'
import { FC, useEffect, useState } from 'react'
import styled from 'styled-components'
import { default as MessageItem } from '../../home/Messages/Message'
@ -25,16 +24,22 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
topic?: Topic
}
const TopicMessages: FC<Props> = ({ topic, ...props }) => {
const TopicMessages: FC<Props> = ({ topic: _topic, ...props }) => {
const navigate = NavigationService.navigate!
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
const dispatch = useAppDispatch()
const { messageStyle } = useSettings()
const { setTimeoutTimer } = useTimer()
const [topic, setTopic] = useState<Topic | undefined>(_topic)
useEffect(() => {
topic && dispatch(loadTopicMessagesThunk(topic.id))
}, [dispatch, topic])
if (!_topic) return
runAsyncFunction(async () => {
const topic = await getTopicById(_topic.id)
setTopic(topic)
})
}, [_topic, topic])
const isEmpty = (topic?.messages || []).length === 0

View File

@ -1,14 +1,14 @@
import { SearchOutlined } from '@ant-design/icons'
import { VStack } from '@renderer/components/Layout'
import { useAssistants } from '@renderer/hooks/useAssistant'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { getTopicById } from '@renderer/hooks/useTopic'
import { selectAllTopics } from '@renderer/store/assistants'
import { Topic } from '@renderer/types'
import { Button, Divider, Empty, Segmented } from 'antd'
import dayjs from 'dayjs'
import { groupBy, isEmpty, orderBy } from 'lodash'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
type SortType = 'createdAt' | 'updatedAt'
@ -20,18 +20,18 @@ type Props = {
} & React.HTMLAttributes<HTMLDivElement>
const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props }) => {
const { assistants } = useAssistants()
const { t } = useTranslation()
const { handleScroll, containerRef } = useScrollPosition('TopicsHistory')
const [sortType, setSortType] = useState<SortType>('createdAt')
const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), sortType, 'desc')
// FIXME: db 中没有 topic.name 等信息,只能从 store 获取
const topics = useSelector(selectAllTopics)
const filteredTopics = topics.filter((topic) => {
return topic.name.toLowerCase().includes(keywords.toLowerCase())
})
const groupedTopics = groupBy(filteredTopics, (topic) => {
const groupedTopics = groupBy(orderBy(filteredTopics, sortType, 'desc'), (topic) => {
return dayjs(topic[sortType]).format('MM/DD')
})
@ -66,19 +66,14 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
<Date>{date}</Date>
<Divider style={{ margin: '5px 0' }} />
{items.map((topic) => (
<TopicItem
key={topic.id}
onClick={async () => {
const _topic = await getTopicById(topic.id)
onClick(_topic)
}}>
<TopicItem key={topic.id} onClick={() => onClick(topic)}>
<TopicName>{topic.name.substring(0, 50)}</TopicName>
<TopicDate>{dayjs(topic[sortType]).format('HH:mm')}</TopicDate>
</TopicItem>
))}
</ListItem>
))}
{keywords.length >= 2 && (
{keywords && (
<div style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}>
{t('history.search.messages')}

View File

@ -1,10 +1,12 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'
import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
import { TopicManager } from '@renderer/hooks/useTopic'
import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/AssistantService'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { isEmpty, uniqBy } from 'lodash'
import { RootState } from '.'
export interface AssistantsState {
defaultAssistant: Assistant
assistants: Assistant[]
@ -194,4 +196,15 @@ export const {
updateTagCollapse
} = assistantsSlice.actions
export const selectAllTopics = createSelector([(state: RootState) => state.assistants.assistants], (assistants) =>
assistants.flatMap((assistant: Assistant) => assistant.topics)
)
export const selectTopicsMap = createSelector([selectAllTopics], (topics) => {
return topics.reduce((map, topic) => {
map.set(topic.id, topic)
return map
}, new Map())
})
export default assistantsSlice.reducer