mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 05:09:09 +08:00
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:
parent
8ad1498df6
commit
b381a7096b
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user