feat(TopicsTab): 简单方式优化话题列表切换卡顿问题 (#5436)

* feat(TopicsTab): refactor topic menu item handling and improve state management

* refactor(TopicsTab): enhance state management with useDeferredValue for target topic
This commit is contained in:
Teo 2025-04-28 17:55:42 +08:00 committed by GitHub
parent 8ad1498df6
commit b381a7096b

View File

@ -39,7 +39,7 @@ 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, startTransition, useCallback, useMemo, useRef, useState } from 'react' import { FC, startTransition, useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
@ -158,236 +158,240 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions) const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
const getTopicMenuItems = useCallback( const [_targetTopic, setTargetTopic] = useState<Topic | null>(null)
(topic: Topic) => { const targetTopic = useDeferredValue(_targetTopic)
const menus: MenuProps['items'] = [ const getTopicMenuItems = useMemo(() => {
{ const topic = targetTopic
label: t('chat.topics.auto_rename'), if (!topic) return []
key: 'auto-rename',
icon: <i className="iconfont icon-business-smart-assistant" style={{ fontSize: '14px' }} />,
async onClick() {
const messages = await TopicManager.getTopicMessages(topic.id)
if (messages.length >= 2) {
const summaryText = await fetchMessagesSummary({ messages, assistant })
if (summaryText) {
updateTopic({ ...topic, name: summaryText, isNameManuallyEdited: false })
}
}
}
},
{
label: t('chat.topics.edit.title'),
key: 'rename',
icon: <EditOutlined />,
async onClick() {
const name = await PromptPopup.show({
title: t('chat.topics.edit.title'),
message: '',
defaultValue: topic?.name || ''
})
if (name && topic?.name !== name) {
updateTopic({ ...topic, name, isNameManuallyEdited: true })
}
}
},
{
label: t('chat.topics.prompt'),
key: 'topic-prompt',
icon: <i className="iconfont icon-ai-model1" style={{ fontSize: '14px' }} />,
extra: (
<Tooltip title={t('chat.topics.prompt.tips')}>
<QuestionIcon />
</Tooltip>
),
async onClick() {
const prompt = await PromptPopup.show({
title: t('chat.topics.prompt.edit.title'),
message: '',
defaultValue: topic?.prompt || '',
inputProps: {
rows: 8,
allowClear: true
}
})
prompt !== null && const menus: MenuProps['items'] = [
(() => { {
const updatedTopic = { ...topic, prompt: prompt.trim() } label: t('chat.topics.auto_rename'),
updateTopic(updatedTopic) key: 'auto-rename',
topic.id === activeTopic.id && setActiveTopic(updatedTopic) icon: <i className="iconfont icon-business-smart-assistant" style={{ fontSize: '14px' }} />,
})() async onClick() {
} const messages = await TopicManager.getTopicMessages(topic.id)
}, if (messages.length >= 2) {
{ const summaryText = await fetchMessagesSummary({ messages, assistant })
label: topic.pinned ? t('chat.topics.unpinned') : t('chat.topics.pinned'), if (summaryText) {
key: 'pin', updateTopic({ ...topic, name: summaryText, isNameManuallyEdited: false })
icon: <PushpinOutlined />,
onClick() {
onPinTopic(topic)
}
},
{
label: t('chat.topics.clear.title'),
key: 'clear-messages',
icon: <ClearOutlined />,
async onClick() {
window.modal.confirm({
title: t('chat.input.clear.content'),
centered: true,
onOk: () => onClearMessages(topic)
})
}
},
{
label: t('chat.topics.copy.title'),
key: 'copy',
icon: <CopyIcon />,
children: [
{
label: t('chat.topics.copy.image'),
key: 'img',
onClick: () => EventEmitter.emit(EVENT_NAMES.COPY_TOPIC_IMAGE, topic)
},
{
label: t('chat.topics.copy.md'),
key: 'md',
onClick: () => copyTopicAsMarkdown(topic)
} }
] }
},
{
label: t('chat.topics.export.title'),
key: 'export',
icon: <UploadOutlined />,
children: [
exportMenuOptions.image !== false && {
label: t('chat.topics.export.image'),
key: 'image',
onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic)
},
exportMenuOptions.markdown !== false && {
label: t('chat.topics.export.md'),
key: 'markdown',
onClick: () => exportTopicAsMarkdown(topic)
},
exportMenuOptions.markdown_reason !== false && {
label: t('chat.topics.export.md.reason'),
key: 'markdown_reason',
onClick: () => exportTopicAsMarkdown(topic, true)
},
exportMenuOptions.docx !== false && {
label: t('chat.topics.export.word'),
key: 'word',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
window.api.export.toWord(markdown, removeSpecialCharactersForFileName(topic.name))
}
},
exportMenuOptions.notion !== false && {
label: t('chat.topics.export.notion'),
key: 'notion',
onClick: async () => {
exportTopicToNotion(topic)
}
},
exportMenuOptions.yuque !== false && {
label: t('chat.topics.export.yuque'),
key: 'yuque',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToYuque(topic.name, markdown)
}
},
exportMenuOptions.obsidian !== false && {
label: t('chat.topics.export.obsidian'),
key: 'obsidian',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
await ObsidianExportPopup.show({ title: topic.name, markdown, processingMethod: '3' })
}
},
exportMenuOptions.joplin !== false && {
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToJoplin(topic.name, markdown)
}
},
exportMenuOptions.siyuan !== false && {
label: t('chat.topics.export.siyuan'),
key: 'siyuan',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToSiyuan(topic.name, markdown)
}
}
].filter(Boolean) as ItemType<MenuItemType>[]
} }
] },
{
label: t('chat.topics.edit.title'),
key: 'rename',
icon: <EditOutlined />,
async onClick() {
const name = await PromptPopup.show({
title: t('chat.topics.edit.title'),
message: '',
defaultValue: topic?.name || ''
})
if (name && topic?.name !== name) {
updateTopic({ ...topic, name, isNameManuallyEdited: true })
}
}
},
{
label: t('chat.topics.prompt'),
key: 'topic-prompt',
icon: <i className="iconfont icon-ai-model1" style={{ fontSize: '14px' }} />,
extra: (
<Tooltip title={t('chat.topics.prompt.tips')}>
<QuestionIcon />
</Tooltip>
),
async onClick() {
const prompt = await PromptPopup.show({
title: t('chat.topics.prompt.edit.title'),
message: '',
defaultValue: topic?.prompt || '',
inputProps: {
rows: 8,
allowClear: true
}
})
if (assistants.length > 1 && assistant.topics.length > 1) { prompt !== null &&
menus.push({ (() => {
label: t('chat.topics.move_to'), const updatedTopic = { ...topic, prompt: prompt.trim() }
key: 'move', updateTopic(updatedTopic)
icon: <FolderOutlined />, topic.id === activeTopic.id && setActiveTopic(updatedTopic)
children: assistants })()
.filter((a) => a.id !== assistant.id) }
.map((a) => ({ },
label: a.name, {
key: a.id, label: topic.pinned ? t('chat.topics.unpinned') : t('chat.topics.pinned'),
onClick: () => onMoveTopic(topic, a) key: 'pin',
})) icon: <PushpinOutlined />,
}) onClick() {
onPinTopic(topic)
}
},
{
label: t('chat.topics.clear.title'),
key: 'clear-messages',
icon: <ClearOutlined />,
async onClick() {
window.modal.confirm({
title: t('chat.input.clear.content'),
centered: true,
onOk: () => onClearMessages(topic)
})
}
},
{
label: t('chat.topics.copy.title'),
key: 'copy',
icon: <CopyIcon />,
children: [
{
label: t('chat.topics.copy.image'),
key: 'img',
onClick: () => EventEmitter.emit(EVENT_NAMES.COPY_TOPIC_IMAGE, topic)
},
{
label: t('chat.topics.copy.md'),
key: 'md',
onClick: () => copyTopicAsMarkdown(topic)
}
]
},
{
label: t('chat.topics.export.title'),
key: 'export',
icon: <UploadOutlined />,
children: [
exportMenuOptions.image !== false && {
label: t('chat.topics.export.image'),
key: 'image',
onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic)
},
exportMenuOptions.markdown !== false && {
label: t('chat.topics.export.md'),
key: 'markdown',
onClick: () => exportTopicAsMarkdown(topic)
},
exportMenuOptions.markdown_reason !== false && {
label: t('chat.topics.export.md.reason'),
key: 'markdown_reason',
onClick: () => exportTopicAsMarkdown(topic, true)
},
exportMenuOptions.docx !== false && {
label: t('chat.topics.export.word'),
key: 'word',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
window.api.export.toWord(markdown, removeSpecialCharactersForFileName(topic.name))
}
},
exportMenuOptions.notion !== false && {
label: t('chat.topics.export.notion'),
key: 'notion',
onClick: async () => {
exportTopicToNotion(topic)
}
},
exportMenuOptions.yuque !== false && {
label: t('chat.topics.export.yuque'),
key: 'yuque',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToYuque(topic.name, markdown)
}
},
exportMenuOptions.obsidian !== false && {
label: t('chat.topics.export.obsidian'),
key: 'obsidian',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
await ObsidianExportPopup.show({ title: topic.name, markdown, processingMethod: '3' })
}
},
exportMenuOptions.joplin !== false && {
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToJoplin(topic.name, markdown)
}
},
exportMenuOptions.siyuan !== false && {
label: t('chat.topics.export.siyuan'),
key: 'siyuan',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToSiyuan(topic.name, markdown)
}
}
].filter(Boolean) as ItemType<MenuItemType>[]
} }
if (assistant.topics.length > 1 && !topic.pinned) {
menus.push({ type: 'divider' })
menus.push({
label: t('common.delete'),
danger: true,
key: 'delete',
icon: <DeleteOutlined />,
onClick: () => onDeleteTopic(topic)
})
}
return menus
},
[
activeTopic.id,
assistant,
assistants,
exportMenuOptions.docx,
exportMenuOptions.image,
exportMenuOptions.joplin,
exportMenuOptions.markdown,
exportMenuOptions.markdown_reason,
exportMenuOptions.notion,
exportMenuOptions.obsidian,
exportMenuOptions.siyuan,
exportMenuOptions.yuque,
onClearMessages,
onDeleteTopic,
onMoveTopic,
onPinTopic,
setActiveTopic,
t,
updateTopic
] ]
)
if (assistants.length > 1 && assistant.topics.length > 1) {
menus.push({
label: t('chat.topics.move_to'),
key: 'move',
icon: <FolderOutlined />,
children: assistants
.filter((a) => a.id !== assistant.id)
.map((a) => ({
label: a.name,
key: a.id,
onClick: () => onMoveTopic(topic, a)
}))
})
}
if (assistant.topics.length > 1 && !topic.pinned) {
menus.push({ type: 'divider' })
menus.push({
label: t('common.delete'),
danger: true,
key: 'delete',
icon: <DeleteOutlined />,
onClick: () => onDeleteTopic(topic)
})
}
return menus
}, [
activeTopic.id,
assistant,
assistants,
exportMenuOptions.docx,
exportMenuOptions.image,
exportMenuOptions.joplin,
exportMenuOptions.markdown,
exportMenuOptions.markdown_reason,
exportMenuOptions.notion,
exportMenuOptions.obsidian,
exportMenuOptions.siyuan,
exportMenuOptions.yuque,
onClearMessages,
onDeleteTopic,
onMoveTopic,
onPinTopic,
setActiveTopic,
t,
updateTopic,
targetTopic
])
return ( return (
<Container right={topicPosition === 'right'} className="topics-tab"> <Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
<DragableList list={assistant.topics} onUpdate={updateTopics}> <Container right={topicPosition === 'right'} className="topics-tab">
{(topic) => { <DragableList list={assistant.topics} onUpdate={updateTopics}>
const isActive = topic.id === activeTopic?.id {(topic) => {
const topicName = topic.name.replace('`', '') const isActive = topic.id === activeTopic?.id
const topicPrompt = topic.prompt const topicName = topic.name.replace('`', '')
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt const topicPrompt = topic.prompt
return ( const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
<Dropdown menu={{ items: getTopicMenuItems(topic) }} trigger={['contextMenu']} key={topic.id}> return (
<TopicListItem <TopicListItem
onMouseEnter={() => setTargetTopic(topic)}
className={isActive ? 'active' : ''} className={isActive ? 'active' : ''}
onClick={() => onSwitchTopic(topic)} onClick={() => onSwitchTopic(topic)}
style={{ borderRadius }}> style={{ borderRadius }}>
@ -435,12 +439,12 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
</Tooltip> </Tooltip>
)} )}
</TopicListItem> </TopicListItem>
</Dropdown> )
) }}
}} </DragableList>
</DragableList> <div style={{ minHeight: '10px' }}></div>
<div style={{ minHeight: '10px' }}></div> </Container>
</Container> </Dropdown>
) )
} }