mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 13:31:32 +08:00
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:
parent
3501d377f6
commit
a4cdb5d45f
@ -885,6 +885,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Continue Chatting",
|
||||
"error": {
|
||||
"topic_not_found": "Topic not found"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Locate the message"
|
||||
},
|
||||
|
||||
@ -885,6 +885,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "チャットを続ける",
|
||||
"error": {
|
||||
"topic_not_found": "トピックが見つかりません"
|
||||
},
|
||||
"locate": {
|
||||
"message": "メッセージを探す"
|
||||
},
|
||||
|
||||
@ -885,6 +885,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Продолжить чат",
|
||||
"error": {
|
||||
"topic_not_found": "Топик не найден"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Найти сообщение"
|
||||
},
|
||||
|
||||
@ -885,6 +885,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "继续聊天",
|
||||
"error": {
|
||||
"topic_not_found": "话题不存在"
|
||||
},
|
||||
"locate": {
|
||||
"message": "定位到消息"
|
||||
},
|
||||
|
||||
@ -885,6 +885,9 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "繼續聊天",
|
||||
"error": {
|
||||
"topic_not_found": "話題不存在"
|
||||
},
|
||||
"locate": {
|
||||
"message": "定位到訊息"
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user