mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-30 15:59:09 +08:00
580 lines
18 KiB
TypeScript
580 lines
18 KiB
TypeScript
import {
|
|
ClearOutlined,
|
|
CloseOutlined,
|
|
DeleteOutlined,
|
|
EditOutlined,
|
|
FolderOutlined,
|
|
PushpinOutlined,
|
|
QuestionCircleOutlined,
|
|
UploadOutlined
|
|
} from '@ant-design/icons'
|
|
import DragableList from '@renderer/components/DragableList'
|
|
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
|
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
|
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
|
import Scrollbar from '@renderer/components/Scrollbar'
|
|
import { isMac } from '@renderer/config/constant'
|
|
import { useAssistant, useAssistants, useTopicsForAssistant } from '@renderer/hooks/useAssistant'
|
|
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
|
import { useSettings } from '@renderer/hooks/useSettings'
|
|
import { TopicManager } from '@renderer/hooks/useTopic'
|
|
import { fetchMessagesSummary } from '@renderer/services/ApiService'
|
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
|
import store from '@renderer/store'
|
|
import { RootState } from '@renderer/store'
|
|
import { setGenerating } from '@renderer/store/runtime'
|
|
import { Assistant, Topic } from '@renderer/types'
|
|
import { removeSpecialCharactersForFileName } from '@renderer/utils'
|
|
import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy'
|
|
import {
|
|
exportMarkdownToJoplin,
|
|
exportMarkdownToSiyuan,
|
|
exportMarkdownToYuque,
|
|
exportTopicAsMarkdown,
|
|
exportTopicToNotion,
|
|
topicToMarkdown
|
|
} from '@renderer/utils/export'
|
|
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
|
import { Dropdown, MenuProps, Tooltip } from 'antd'
|
|
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
|
import dayjs from 'dayjs'
|
|
import { findIndex } from 'lodash'
|
|
import { FC, startTransition, useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useSelector } from 'react-redux'
|
|
import styled from 'styled-components'
|
|
|
|
interface Props {
|
|
assistant: Assistant
|
|
activeTopic: Topic
|
|
setActiveTopic: (topic: Topic) => void
|
|
}
|
|
|
|
const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic }) => {
|
|
const { assistants } = useAssistants()
|
|
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
|
|
const { t } = useTranslation()
|
|
const { showTopicTime, pinTopicsToTop } = useSettings()
|
|
|
|
const topics = useTopicsForAssistant(_assistant.id)
|
|
|
|
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
|
|
|
|
const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
|
|
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
|
|
|
|
const pendingTopics = useMemo(() => {
|
|
return new Set<string>()
|
|
}, [])
|
|
const isPending = useCallback(
|
|
(topicId: string) => {
|
|
const hasPending = hasTopicPendingRequests(topicId)
|
|
if (topicId === activeTopic.id && !hasPending) {
|
|
pendingTopics.delete(topicId)
|
|
return false
|
|
}
|
|
if (pendingTopics.has(topicId)) {
|
|
return true
|
|
}
|
|
if (hasPending) {
|
|
pendingTopics.add(topicId)
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
[activeTopic.id, pendingTopics]
|
|
)
|
|
|
|
const handleDeleteClick = useCallback((topicId: string, e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
|
|
if (deleteTimerRef.current) {
|
|
clearTimeout(deleteTimerRef.current)
|
|
}
|
|
|
|
setDeletingTopicId(topicId)
|
|
|
|
deleteTimerRef.current = setTimeout(() => setDeletingTopicId(null), 2000)
|
|
}, [])
|
|
|
|
const onClearMessages = useCallback((topic: Topic) => {
|
|
// window.keyv.set(EVENT_NAMES.CHAT_COMPLETION_PAUSED, true)
|
|
store.dispatch(setGenerating(false))
|
|
EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES, topic)
|
|
}, [])
|
|
|
|
const handleConfirmDelete = useCallback(
|
|
async (topic: Topic, e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
if (topics.length === 1) {
|
|
return onClearMessages(topic)
|
|
}
|
|
await modelGenerating()
|
|
const index = findIndex(topics, (t) => t.id === topic.id)
|
|
setActiveTopic(topics[index + 1 === topics.length ? index - 1 : index + 1])
|
|
removeTopic(topic)
|
|
setDeletingTopicId(null)
|
|
},
|
|
[topics, onClearMessages, removeTopic, setActiveTopic]
|
|
)
|
|
|
|
const onPinTopic = useCallback(
|
|
(topic: Topic) => {
|
|
const updatedTopic = { ...topic, pinned: !topic.pinned }
|
|
updateTopic(updatedTopic)
|
|
},
|
|
[updateTopic]
|
|
)
|
|
|
|
const onDeleteTopic = useCallback(
|
|
async (topic: Topic) => {
|
|
await modelGenerating()
|
|
if (topic.id === activeTopic?.id) {
|
|
const index = findIndex(topics, (t) => t.id === topic.id)
|
|
setActiveTopic(topics[index + 1 === topics.length ? index - 1 : index + 1])
|
|
}
|
|
removeTopic(topic)
|
|
},
|
|
[topics, removeTopic, setActiveTopic, activeTopic]
|
|
)
|
|
|
|
const onMoveTopic = useCallback(
|
|
async (topic: Topic, toAssistant: Assistant) => {
|
|
await modelGenerating()
|
|
const index = findIndex(topics, (t) => t.id === topic.id)
|
|
setActiveTopic(topics[index + 1 === topics.length ? 0 : index + 1])
|
|
moveTopic(topic, toAssistant)
|
|
},
|
|
[topics, moveTopic, setActiveTopic]
|
|
)
|
|
|
|
const onSwitchTopic = useCallback(
|
|
async (topic: Topic) => {
|
|
// await modelGenerating()
|
|
startTransition(() => {
|
|
setActiveTopic(topic)
|
|
})
|
|
},
|
|
[setActiveTopic]
|
|
)
|
|
|
|
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
|
|
|
const [_targetTopic, setTargetTopic] = useState<Topic | null>(null)
|
|
const targetTopic = useDeferredValue(_targetTopic)
|
|
const getTopicMenuItems = useMemo(() => {
|
|
const topic = targetTopic
|
|
if (!topic) return []
|
|
|
|
const menus: MenuProps['items'] = [
|
|
{
|
|
label: t('chat.topics.auto_rename'),
|
|
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) {
|
|
const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false }
|
|
updateTopic(updatedTopic)
|
|
topic.id === activeTopic.id && setActiveTopic(updatedTopic)
|
|
} else {
|
|
window.message?.error(t('message.error.fetchTopicName'))
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
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) {
|
|
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
|
|
updateTopic(updatedTopic)
|
|
topic.id === activeTopic.id && setActiveTopic(updatedTopic)
|
|
}
|
|
}
|
|
},
|
|
{
|
|
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 updatedTopic = { ...topic, prompt: prompt.trim() }
|
|
updateTopic(updatedTopic)
|
|
topic.id === activeTopic.id && setActiveTopic(updatedTopic)
|
|
})()
|
|
}
|
|
},
|
|
{
|
|
label: topic.pinned ? t('chat.topics.unpinned') : t('chat.topics.pinned'),
|
|
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.copy.plain_text'),
|
|
key: 'plain_text',
|
|
onClick: () => copyTopicAsPlainText(topic)
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: t('chat.topics.export.title'),
|
|
key: 'export',
|
|
icon: <UploadOutlined />,
|
|
children: [
|
|
exportMenuOptions.image && {
|
|
label: t('chat.topics.export.image'),
|
|
key: 'image',
|
|
onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic)
|
|
},
|
|
exportMenuOptions.markdown && {
|
|
label: t('chat.topics.export.md'),
|
|
key: 'markdown',
|
|
onClick: () => exportTopicAsMarkdown(topic)
|
|
},
|
|
exportMenuOptions.markdown_reason && {
|
|
label: t('chat.topics.export.md.reason'),
|
|
key: 'markdown_reason',
|
|
onClick: () => exportTopicAsMarkdown(topic, true)
|
|
},
|
|
exportMenuOptions.docx && {
|
|
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 && {
|
|
label: t('chat.topics.export.notion'),
|
|
key: 'notion',
|
|
onClick: async () => {
|
|
exportTopicToNotion(topic)
|
|
}
|
|
},
|
|
exportMenuOptions.yuque && {
|
|
label: t('chat.topics.export.yuque'),
|
|
key: 'yuque',
|
|
onClick: async () => {
|
|
const markdown = await topicToMarkdown(topic)
|
|
exportMarkdownToYuque(topic.name, markdown)
|
|
}
|
|
},
|
|
exportMenuOptions.obsidian && {
|
|
label: t('chat.topics.export.obsidian'),
|
|
key: 'obsidian',
|
|
onClick: async () => {
|
|
await ObsidianExportPopup.show({ title: topic.name, topic, processingMethod: '3' })
|
|
}
|
|
},
|
|
exportMenuOptions.joplin && {
|
|
label: t('chat.topics.export.joplin'),
|
|
key: 'joplin',
|
|
onClick: async () => {
|
|
const topicMessages = await TopicManager.getTopicMessages(topic.id)
|
|
exportMarkdownToJoplin(topic.name, topicMessages)
|
|
}
|
|
},
|
|
exportMenuOptions.siyuan && {
|
|
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 (assistants.length > 1 && 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 (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
|
|
}, [
|
|
targetTopic,
|
|
t,
|
|
exportMenuOptions.image,
|
|
exportMenuOptions.markdown,
|
|
exportMenuOptions.markdown_reason,
|
|
exportMenuOptions.docx,
|
|
exportMenuOptions.notion,
|
|
exportMenuOptions.yuque,
|
|
exportMenuOptions.obsidian,
|
|
exportMenuOptions.joplin,
|
|
exportMenuOptions.siyuan,
|
|
assistants,
|
|
topics.length,
|
|
assistant,
|
|
updateTopic,
|
|
activeTopic.id,
|
|
setActiveTopic,
|
|
onPinTopic,
|
|
onClearMessages,
|
|
onMoveTopic,
|
|
onDeleteTopic
|
|
])
|
|
|
|
// Sort topics based on pinned status if pinTopicsToTop is enabled
|
|
const sortedTopics = useMemo(() => {
|
|
if (pinTopicsToTop) {
|
|
return [...topics].sort((a, b) => {
|
|
if (a.pinned && !b.pinned) return -1
|
|
if (!a.pinned && b.pinned) return 1
|
|
return 0
|
|
})
|
|
}
|
|
return topics
|
|
}, [topics, pinTopicsToTop])
|
|
|
|
return (
|
|
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
|
|
<Container className="topics-tab">
|
|
<DragableList list={sortedTopics} onUpdate={updateTopics}>
|
|
{(topic) => {
|
|
const isActive = topic.id === activeTopic?.id
|
|
const topicName = topic.name.replace('`', '')
|
|
const topicPrompt = topic.prompt
|
|
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
|
|
return (
|
|
<TopicListItem
|
|
onContextMenu={() => setTargetTopic(topic)}
|
|
className={isActive ? 'active' : ''}
|
|
onClick={() => onSwitchTopic(topic)}
|
|
style={{ borderRadius }}>
|
|
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
|
<TopicNameContainer>
|
|
<TopicName className="name" title={topicName}>
|
|
{topicName}
|
|
</TopicName>
|
|
{isActive && !topic.pinned && (
|
|
<Tooltip
|
|
placement="bottom"
|
|
mouseEnterDelay={0.7}
|
|
title={
|
|
<div>
|
|
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
|
|
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
|
|
</div>
|
|
</div>
|
|
}>
|
|
<MenuButton
|
|
className="menu"
|
|
onClick={(e) => {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
handleConfirmDelete(topic, e)
|
|
} else if (deletingTopicId === topic.id) {
|
|
handleConfirmDelete(topic, e)
|
|
} else {
|
|
handleDeleteClick(topic.id, e)
|
|
}
|
|
}}>
|
|
{deletingTopicId === topic.id ? (
|
|
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
|
|
) : (
|
|
<CloseOutlined />
|
|
)}
|
|
</MenuButton>
|
|
</Tooltip>
|
|
)}
|
|
{topic.pinned && (
|
|
<MenuButton className="pin">
|
|
<PushpinOutlined />
|
|
</MenuButton>
|
|
)}
|
|
</TopicNameContainer>
|
|
{topicPrompt && (
|
|
<TopicPromptText className="prompt" title={fullTopicPrompt}>
|
|
{fullTopicPrompt}
|
|
</TopicPromptText>
|
|
)}
|
|
{showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD')}</TopicTime>}
|
|
</TopicListItem>
|
|
)
|
|
}}
|
|
</DragableList>
|
|
<div style={{ minHeight: '10px' }}></div>
|
|
</Container>
|
|
</Dropdown>
|
|
)
|
|
}
|
|
|
|
const Container = styled(Scrollbar)`
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 10px;
|
|
`
|
|
|
|
const TopicListItem = styled.div`
|
|
padding: 7px 12px;
|
|
border-radius: var(--list-item-border-radius);
|
|
font-size: 13px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
position: relative;
|
|
cursor: pointer;
|
|
position: relative;
|
|
width: calc(var(--assistants-width) - 20px);
|
|
.menu {
|
|
opacity: 0;
|
|
color: var(--color-text-3);
|
|
}
|
|
&:hover {
|
|
background-color: var(--color-list-item-hover);
|
|
}
|
|
&.active {
|
|
background-color: var(--color-list-item);
|
|
.menu {
|
|
opacity: 1;
|
|
&:hover {
|
|
color: var(--color-text-2);
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
const TopicNameContainer = styled.div`
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 4px;
|
|
justify-content: space-between;
|
|
`
|
|
|
|
const TopicName = styled.div`
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 1;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
font-size: 13px;
|
|
`
|
|
|
|
const PendingIndicator = styled.div.attrs({
|
|
className: 'animation-pulse'
|
|
})`
|
|
--pulse-size: 5px;
|
|
width: 5px;
|
|
height: 5px;
|
|
position: absolute;
|
|
left: 3px;
|
|
top: 15px;
|
|
border-radius: 50%;
|
|
background-color: var(--color-primary);
|
|
`
|
|
|
|
const TopicPromptText = styled.div`
|
|
color: var(--color-text-2);
|
|
font-size: 12px;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
~ .prompt-text {
|
|
margin-top: 10px;
|
|
}
|
|
`
|
|
|
|
const TopicTime = styled.div`
|
|
color: var(--color-text-3);
|
|
font-size: 12px;
|
|
font-family: ubuntu;
|
|
`
|
|
|
|
const MenuButton = styled.div`
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-width: 20px;
|
|
min-height: 20px;
|
|
.anticon {
|
|
font-size: 12px;
|
|
}
|
|
`
|
|
const QuestionIcon = styled(QuestionCircleOutlined)`
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
color: var(--color-text-3);
|
|
`
|
|
|
|
export default Topics
|