mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-11 16:39:15 +08:00
refactor(home/tabs): reorganize components and implement topic/session switching
- Move Assistants and Agents components to dedicated folders - Split TopicsTab into separate Topics and SessionsTab components - Add activeTopicOrSession state handling in runtime store - Update tab switching logic to support both topics and sessions - Clean up and optimize component imports and exports
This commit is contained in:
parent
b43b4b581e
commit
d1067bb6b3
@ -1,11 +1,12 @@
|
|||||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||||
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useActiveTopic } from '@renderer/hooks/useTopic'
|
import { useActiveTopic } from '@renderer/hooks/useTopic'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
import NavigationService from '@renderer/services/NavigationService'
|
import NavigationService from '@renderer/services/NavigationService'
|
||||||
import { newMessagesActions } from '@renderer/store/newMessage'
|
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||||
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
|
import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
|
||||||
import { Assistant, Topic } from '@renderer/types'
|
import { Assistant, Topic } from '@renderer/types'
|
||||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SECOND_MIN_WINDOW_WIDTH } from '@shared/config/constant'
|
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SECOND_MIN_WINDOW_WIDTH } from '@shared/config/constant'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
@ -32,6 +33,8 @@ const HomePage: FC = () => {
|
|||||||
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id, state?.topic)
|
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id, state?.topic)
|
||||||
const { showAssistants, showTopics, topicPosition } = useSettings()
|
const { showAssistants, showTopics, topicPosition } = useSettings()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
const { chat } = useRuntime()
|
||||||
|
const { activeTopicOrSession } = chat
|
||||||
|
|
||||||
_activeAssistant = activeAssistant
|
_activeAssistant = activeAssistant
|
||||||
|
|
||||||
@ -91,6 +94,29 @@ const HomePage: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [showAssistants, showTopics, topicPosition])
|
}, [showAssistants, showTopics, topicPosition])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTopicOrSession === 'session') {
|
||||||
|
setActiveAssistant({
|
||||||
|
id: 'fake',
|
||||||
|
name: '',
|
||||||
|
prompt: '',
|
||||||
|
topics: [
|
||||||
|
{
|
||||||
|
id: 'fake',
|
||||||
|
assistantId: 'fake',
|
||||||
|
name: 'fake',
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
type: ''
|
||||||
|
})
|
||||||
|
} else if (activeTopicOrSession === 'topic') {
|
||||||
|
dispatch(setActiveAgentId('fake'))
|
||||||
|
}
|
||||||
|
}, [activeTopicOrSession, dispatch, setActiveAssistant])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container id="home-page">
|
<Container id="home-page">
|
||||||
{isLeftNavbar && (
|
{isLeftNavbar && (
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { Assistant } from '@renderer/types'
|
|||||||
import { FC, useRef } from 'react'
|
import { FC, useRef } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { Agents } from './Agents'
|
import { Agents } from './components/Agents'
|
||||||
import Assistants from './Assistants'
|
import Assistants from './components/Assistants'
|
||||||
|
|
||||||
interface AssistantsTabProps {
|
interface AssistantsTabProps {
|
||||||
activeAssistant: Assistant
|
activeAssistant: Assistant
|
||||||
|
|||||||
@ -28,7 +28,7 @@ const SessionsTab: FC<SessionsTabProps> = () => {
|
|||||||
initial={{ opacity: 0, y: 5 }}
|
initial={{ opacity: 0, y: 5 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2, duration: 0.3 }}
|
transition={{ delay: 0.2, duration: 0.3 }}
|
||||||
className="text-sm text-foreground-500">
|
className="text-foreground-500 text-sm">
|
||||||
{t('common.loading')}...
|
{t('common.loading')}...
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@ -1,56 +1,9 @@
|
|||||||
import { DraggableVirtualList } from '@renderer/components/DraggableList'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
|
|
||||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
|
||||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
|
||||||
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
|
||||||
import { isMac } from '@renderer/config/constant'
|
|
||||||
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
|
||||||
import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
|
|
||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
|
||||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
|
||||||
import { finishTopicRenaming, startTopicRenaming, 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 { newMessagesActions } from '@renderer/store/newMessage'
|
|
||||||
import { setGenerating } from '@renderer/store/runtime'
|
|
||||||
import { Assistant, Topic } from '@renderer/types'
|
import { Assistant, Topic } from '@renderer/types'
|
||||||
import { classNames, removeSpecialCharactersForFileName } from '@renderer/utils'
|
import { FC } from 'react'
|
||||||
import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy'
|
|
||||||
import {
|
import { Topics } from './components/Topics'
|
||||||
exportMarkdownToJoplin,
|
import SessionsTab from './SessionsTab'
|
||||||
exportMarkdownToSiyuan,
|
|
||||||
exportMarkdownToYuque,
|
|
||||||
exportTopicAsMarkdown,
|
|
||||||
exportTopicToNotes,
|
|
||||||
exportTopicToNotion,
|
|
||||||
topicToMarkdown
|
|
||||||
} from '@renderer/utils/export'
|
|
||||||
import { Dropdown, MenuProps, Tooltip } from 'antd'
|
|
||||||
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { findIndex } from 'lodash'
|
|
||||||
import {
|
|
||||||
BrushCleaning,
|
|
||||||
FolderOpen,
|
|
||||||
HelpCircle,
|
|
||||||
MenuIcon,
|
|
||||||
NotebookPen,
|
|
||||||
PackagePlus,
|
|
||||||
PinIcon,
|
|
||||||
PinOffIcon,
|
|
||||||
PlusIcon,
|
|
||||||
Save,
|
|
||||||
Sparkles,
|
|
||||||
UploadIcon,
|
|
||||||
XIcon
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { FC, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
// const logger = loggerService.withContext('TopicsTab')
|
// const logger = loggerService.withContext('TopicsTab')
|
||||||
|
|
||||||
@ -61,739 +14,16 @@ interface Props {
|
|||||||
position: 'left' | 'right'
|
position: 'left' | 'right'
|
||||||
}
|
}
|
||||||
|
|
||||||
const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic, position }) => {
|
const TopicsTab: FC<Props> = (props) => {
|
||||||
const { t } = useTranslation()
|
const { chat } = useRuntime()
|
||||||
const { notesPath } = useNotesSettings()
|
const { activeTopicOrSession } = chat
|
||||||
const { assistants } = useAssistants()
|
if (activeTopicOrSession === 'topic') {
|
||||||
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
|
return <Topics {...props} />
|
||||||
const { showTopicTime, pinTopicsToTop, setTopicPosition, topicPosition } = useSettings()
|
}
|
||||||
|
if (activeTopicOrSession === 'session') {
|
||||||
const renamingTopics = useSelector((state: RootState) => state.runtime.chat.renamingTopics)
|
return <SessionsTab />
|
||||||
const topicLoadingQuery = useSelector((state: RootState) => state.messages.loadingByTopic)
|
}
|
||||||
const topicFulfilledQuery = useSelector((state: RootState) => state.messages.fulfilledByTopic)
|
return 'Not a valid state.'
|
||||||
const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics)
|
|
||||||
|
|
||||||
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
|
|
||||||
|
|
||||||
const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
|
|
||||||
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
|
|
||||||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const topicEdit = useInPlaceEdit({
|
|
||||||
onSave: (name: string) => {
|
|
||||||
const topic = assistant.topics.find((t) => t.id === editingTopicId)
|
|
||||||
if (topic && name !== topic.name) {
|
|
||||||
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
|
|
||||||
updateTopic(updatedTopic)
|
|
||||||
window.toast.success(t('common.saved'))
|
|
||||||
}
|
|
||||||
setEditingTopicId(null)
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
setEditingTopicId(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPending = useCallback((topicId: string) => topicLoadingQuery[topicId], [topicLoadingQuery])
|
|
||||||
const isFulfilled = useCallback((topicId: string) => topicFulfilledQuery[topicId], [topicFulfilledQuery])
|
|
||||||
const dispatch = useDispatch()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(newMessagesActions.setTopicFulfilled({ topicId: activeTopic.id, fulfilled: false }))
|
|
||||||
}, [activeTopic.id, dispatch, topicFulfilledQuery])
|
|
||||||
|
|
||||||
const isRenaming = useCallback(
|
|
||||||
(topicId: string) => {
|
|
||||||
return renamingTopics.includes(topicId)
|
|
||||||
},
|
|
||||||
[renamingTopics]
|
|
||||||
)
|
|
||||||
|
|
||||||
const isNewlyRenamed = useCallback(
|
|
||||||
(topicId: string) => {
|
|
||||||
return newlyRenamedTopics.includes(topicId)
|
|
||||||
},
|
|
||||||
[newlyRenamedTopics]
|
|
||||||
)
|
|
||||||
|
|
||||||
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 (assistant.topics.length === 1) {
|
|
||||||
return onClearMessages(topic)
|
|
||||||
}
|
|
||||||
await modelGenerating()
|
|
||||||
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
|
|
||||||
if (topic.id === activeTopic.id) {
|
|
||||||
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1])
|
|
||||||
}
|
|
||||||
removeTopic(topic)
|
|
||||||
setDeletingTopicId(null)
|
|
||||||
},
|
|
||||||
[activeTopic.id, assistant.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(assistant.topics, (t) => t.id === topic.id)
|
|
||||||
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1])
|
|
||||||
}
|
|
||||||
removeTopic(topic)
|
|
||||||
},
|
|
||||||
[assistant.topics, removeTopic, setActiveTopic, activeTopic]
|
|
||||||
)
|
|
||||||
|
|
||||||
const onMoveTopic = useCallback(
|
|
||||||
async (topic: Topic, toAssistant: Assistant) => {
|
|
||||||
await modelGenerating()
|
|
||||||
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
|
|
||||||
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? 0 : index + 1])
|
|
||||||
moveTopic(topic, toAssistant)
|
|
||||||
},
|
|
||||||
[assistant.topics, moveTopic, setActiveTopic]
|
|
||||||
)
|
|
||||||
|
|
||||||
const onSwitchTopic = useCallback(
|
|
||||||
async (topic: Topic) => {
|
|
||||||
// await modelGenerating()
|
|
||||||
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: <Sparkles size={14} />,
|
|
||||||
disabled: isRenaming(topic.id),
|
|
||||||
async onClick() {
|
|
||||||
const messages = await TopicManager.getTopicMessages(topic.id)
|
|
||||||
if (messages.length >= 2) {
|
|
||||||
startTopicRenaming(topic.id)
|
|
||||||
try {
|
|
||||||
const summaryText = await fetchMessagesSummary({ messages, assistant })
|
|
||||||
if (summaryText) {
|
|
||||||
const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false }
|
|
||||||
updateTopic(updatedTopic)
|
|
||||||
} else {
|
|
||||||
window.toast?.error(t('message.error.fetchTopicName'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
finishTopicRenaming(topic.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('chat.topics.edit.title'),
|
|
||||||
key: 'rename',
|
|
||||||
icon: <EditIcon size={14} />,
|
|
||||||
disabled: isRenaming(topic.id),
|
|
||||||
async onClick() {
|
|
||||||
const name = await PromptPopup.show({
|
|
||||||
title: t('chat.topics.edit.title'),
|
|
||||||
message: '',
|
|
||||||
defaultValue: topic?.name || '',
|
|
||||||
extraNode: (
|
|
||||||
<div style={{ color: 'var(--color-text-3)', marginTop: 8 }}>{t('chat.topics.edit.title_tip')}</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
if (name && topic?.name !== name) {
|
|
||||||
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
|
|
||||||
updateTopic(updatedTopic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('chat.topics.prompt.label'),
|
|
||||||
key: 'topic-prompt',
|
|
||||||
icon: <PackagePlus size={14} />,
|
|
||||||
extra: (
|
|
||||||
<Tooltip title={t('chat.topics.prompt.tips')}>
|
|
||||||
<HelpCircle size={14} />
|
|
||||||
</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.unpin') : t('chat.topics.pin'),
|
|
||||||
key: 'pin',
|
|
||||||
icon: topic.pinned ? <PinOffIcon size={14} /> : <PinIcon size={14} />,
|
|
||||||
onClick() {
|
|
||||||
onPinTopic(topic)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('notes.save'),
|
|
||||||
key: 'notes',
|
|
||||||
icon: <NotebookPen size={14} />,
|
|
||||||
onClick: async () => {
|
|
||||||
exportTopicToNotes(topic, notesPath)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('chat.topics.clear.title'),
|
|
||||||
key: 'clear-messages',
|
|
||||||
icon: <BrushCleaning size={14} />,
|
|
||||||
async onClick() {
|
|
||||||
window.modal.confirm({
|
|
||||||
title: t('chat.input.clear.content'),
|
|
||||||
centered: true,
|
|
||||||
onOk: () => onClearMessages(topic)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('settings.topic.position.label'),
|
|
||||||
key: 'topic-position',
|
|
||||||
icon: <MenuIcon size={14} />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: t('settings.topic.position.left'),
|
|
||||||
key: 'left',
|
|
||||||
onClick: () => setTopicPosition('left')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('settings.topic.position.right'),
|
|
||||||
key: 'right',
|
|
||||||
onClick: () => setTopicPosition('right')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('chat.topics.copy.title'),
|
|
||||||
key: 'copy',
|
|
||||||
icon: <CopyIcon size={14} />,
|
|
||||||
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.save.label'),
|
|
||||||
key: 'save',
|
|
||||||
icon: <Save size={14} />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: t('chat.save.topic.knowledge.title'),
|
|
||||||
key: 'knowledge',
|
|
||||||
onClick: async () => {
|
|
||||||
try {
|
|
||||||
const result = await SaveToKnowledgePopup.showForTopic(topic)
|
|
||||||
if (result?.success) {
|
|
||||||
window.toast.success(t('chat.save.topic.knowledge.success', { count: result.savedCount }))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
window.toast.error(t('chat.save.topic.knowledge.error.save_failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('chat.topics.export.title'),
|
|
||||||
key: 'export',
|
|
||||||
icon: <UploadIcon size={14} />,
|
|
||||||
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.label'),
|
|
||||||
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 && assistant.topics.length > 1) {
|
|
||||||
menus.push({
|
|
||||||
label: t('chat.topics.move_to'),
|
|
||||||
key: 'move',
|
|
||||||
icon: <FolderOpen size={14} />,
|
|
||||||
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: <DeleteIcon size={14} className="lucide-custom" />,
|
|
||||||
onClick: () => onDeleteTopic(topic)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return menus
|
|
||||||
}, [
|
|
||||||
targetTopic,
|
|
||||||
t,
|
|
||||||
isRenaming,
|
|
||||||
exportMenuOptions.image,
|
|
||||||
exportMenuOptions.markdown,
|
|
||||||
exportMenuOptions.markdown_reason,
|
|
||||||
exportMenuOptions.docx,
|
|
||||||
exportMenuOptions.notion,
|
|
||||||
exportMenuOptions.yuque,
|
|
||||||
exportMenuOptions.obsidian,
|
|
||||||
exportMenuOptions.joplin,
|
|
||||||
exportMenuOptions.siyuan,
|
|
||||||
assistants,
|
|
||||||
notesPath,
|
|
||||||
assistant,
|
|
||||||
updateTopic,
|
|
||||||
activeTopic.id,
|
|
||||||
setActiveTopic,
|
|
||||||
onPinTopic,
|
|
||||||
onClearMessages,
|
|
||||||
setTopicPosition,
|
|
||||||
onMoveTopic,
|
|
||||||
onDeleteTopic
|
|
||||||
])
|
|
||||||
|
|
||||||
// Sort topics based on pinned status if pinTopicsToTop is enabled
|
|
||||||
const sortedTopics = useMemo(() => {
|
|
||||||
if (pinTopicsToTop) {
|
|
||||||
return [...assistant.topics].sort((a, b) => {
|
|
||||||
if (a.pinned && !b.pinned) return -1
|
|
||||||
if (!a.pinned && b.pinned) return 1
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return assistant.topics
|
|
||||||
}, [assistant.topics, pinTopicsToTop])
|
|
||||||
|
|
||||||
const singlealone = topicPosition === 'right' && position === 'right'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DraggableVirtualList
|
|
||||||
className="topics-tab"
|
|
||||||
list={sortedTopics}
|
|
||||||
onUpdate={updateTopics}
|
|
||||||
style={{ height: '100%', padding: '13px 0 10px 10px' }}
|
|
||||||
itemContainerStyle={{ paddingBottom: '8px' }}
|
|
||||||
header={
|
|
||||||
<AddTopicButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
|
|
||||||
<PlusIcon size={16} />
|
|
||||||
{t('chat.add.topic.title')}
|
|
||||||
</AddTopicButton>
|
|
||||||
}>
|
|
||||||
{(topic) => {
|
|
||||||
const isActive = topic.id === activeTopic?.id
|
|
||||||
const topicName = topic.name.replace('`', '')
|
|
||||||
const topicPrompt = topic.prompt
|
|
||||||
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
|
|
||||||
|
|
||||||
const getTopicNameClassName = () => {
|
|
||||||
if (isRenaming(topic.id)) return 'shimmer'
|
|
||||||
if (isNewlyRenamed(topic.id)) return 'typing'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
|
|
||||||
<TopicListItem
|
|
||||||
onContextMenu={() => setTargetTopic(topic)}
|
|
||||||
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
|
|
||||||
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
|
|
||||||
style={{
|
|
||||||
borderRadius,
|
|
||||||
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
|
|
||||||
}}>
|
|
||||||
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
|
||||||
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
|
|
||||||
<TopicNameContainer>
|
|
||||||
{editingTopicId === topic.id && topicEdit.isEditing ? (
|
|
||||||
<TopicEditInput
|
|
||||||
ref={topicEdit.inputRef}
|
|
||||||
value={topicEdit.editValue}
|
|
||||||
onChange={topicEdit.handleInputChange}
|
|
||||||
onKeyDown={topicEdit.handleKeyDown}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TopicName
|
|
||||||
className={getTopicNameClassName()}
|
|
||||||
title={topicName}
|
|
||||||
onDoubleClick={() => {
|
|
||||||
setEditingTopicId(topic.id)
|
|
||||||
topicEdit.startEdit(topic.name)
|
|
||||||
}}>
|
|
||||||
{topicName}
|
|
||||||
</TopicName>
|
|
||||||
)}
|
|
||||||
{!topic.pinned && (
|
|
||||||
<Tooltip
|
|
||||||
placement="bottom"
|
|
||||||
mouseEnterDelay={0.7}
|
|
||||||
mouseLeaveDelay={0}
|
|
||||||
title={
|
|
||||||
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
|
|
||||||
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
|
|
||||||
</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 ? (
|
|
||||||
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
|
|
||||||
) : (
|
|
||||||
<XIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />
|
|
||||||
)}
|
|
||||||
</MenuButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{topic.pinned && (
|
|
||||||
<MenuButton className="pin">
|
|
||||||
<PinIcon size={14} color="var(--color-text-3)" />
|
|
||||||
</MenuButton>
|
|
||||||
)}
|
|
||||||
</TopicNameContainer>
|
|
||||||
{topicPrompt && (
|
|
||||||
<TopicPromptText className="prompt" title={fullTopicPrompt}>
|
|
||||||
{fullTopicPrompt}
|
|
||||||
</TopicPromptText>
|
|
||||||
)}
|
|
||||||
{showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>}
|
|
||||||
</TopicListItem>
|
|
||||||
</Dropdown>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</DraggableVirtualList>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopicListItem = styled.div`
|
export default TopicsTab
|
||||||
padding: 7px 12px;
|
|
||||||
border-radius: var(--list-item-border-radius);
|
|
||||||
font-size: 13px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
cursor: pointer;
|
|
||||||
width: calc(var(--assistants-width) - 20px);
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
opacity: 0;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-list-item-hover);
|
|
||||||
transition: background-color 0.1s;
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: var(--color-list-item);
|
|
||||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
||||||
.menu {
|
|
||||||
opacity: 1;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-text-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&.singlealone {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-background-soft);
|
|
||||||
}
|
|
||||||
&.active {
|
|
||||||
border-left: 2px solid var(--color-primary);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const TopicNameContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
height: 20px;
|
|
||||||
justify-content: space-between;
|
|
||||||
`
|
|
||||||
|
|
||||||
const TopicName = styled.div`
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 13px;
|
|
||||||
position: relative;
|
|
||||||
will-change: background-position, width;
|
|
||||||
|
|
||||||
--color-shimmer-mid: var(--color-text-1);
|
|
||||||
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
|
|
||||||
|
|
||||||
&.shimmer {
|
|
||||||
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
|
|
||||||
background-size: 200% 100%;
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
animation: shimmer 3s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.typing {
|
|
||||||
display: block;
|
|
||||||
-webkit-line-clamp: unset;
|
|
||||||
-webkit-box-orient: unset;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: typewriter 0.5s steps(40, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes typewriter {
|
|
||||||
from {
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const TopicEditInput = styled.input`
|
|
||||||
background: var(--color-background);
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-1);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
padding: 2px 6px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
padding: 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
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-status-warning);
|
|
||||||
`
|
|
||||||
|
|
||||||
const FulfilledIndicator = 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-status-success);
|
|
||||||
`
|
|
||||||
|
|
||||||
const AddTopicButton = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
width: calc(100% - 10px);
|
|
||||||
padding: 7px 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-2);
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: var(--list-item-border-radius);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
margin-top: -5px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-list-item-hover);
|
|
||||||
color: var(--color-text-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
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: 11px;
|
|
||||||
`
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export default Topics
|
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
|
|||||||
import { useAgents } from '@renderer/hooks/agents/useAgents'
|
import { useAgents } from '@renderer/hooks/agents/useAgents'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setActiveAgentId as setActiveAgentIdAction } from '@renderer/store/runtime'
|
import { setActiveAgentId as setActiveAgentIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { FC, useCallback, useEffect } from 'react'
|
import { FC, useCallback, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import AgentItem from './components/AgentItem'
|
import AgentItem from './AgentItem'
|
||||||
import { SectionName } from './components/SectionName'
|
import { SectionName } from './SectionName'
|
||||||
|
|
||||||
interface AssistantsTabProps {}
|
interface AssistantsTabProps {}
|
||||||
|
|
||||||
@ -45,7 +45,10 @@ export const Agents: FC<AssistantsTabProps> = () => {
|
|||||||
agent={agent}
|
agent={agent}
|
||||||
isActive={agent.id === activeAgentId}
|
isActive={agent.id === activeAgentId}
|
||||||
onDelete={() => deleteAgent(agent.id)}
|
onDelete={() => deleteAgent(agent.id)}
|
||||||
onPress={() => setActiveAgentId(agent.id)}
|
onPress={() => {
|
||||||
|
setActiveAgentId(agent.id)
|
||||||
|
dispatch(setActiveTopicOrSessionAction('session'))
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<AgentModal
|
<AgentModal
|
||||||
@ -8,6 +8,8 @@ import { useTags } from '@renderer/hooks/useTags'
|
|||||||
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
|
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
|
||||||
import { getDefaultModel } from '@renderer/services/AssistantService'
|
import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
|
import { useAppDispatch } from '@renderer/store'
|
||||||
|
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
|
||||||
import { Assistant, AssistantsSortType } from '@renderer/types'
|
import { Assistant, AssistantsSortType } from '@renderer/types'
|
||||||
import { getLeadingEmoji, uuid } from '@renderer/utils'
|
import { getLeadingEmoji, uuid } from '@renderer/utils'
|
||||||
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
||||||
@ -64,6 +66,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
|||||||
const { assistants, updateAssistants } = useAssistants()
|
const { assistants, updateAssistants } = useAssistants()
|
||||||
|
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
@ -128,7 +131,8 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onSwitch(assistant)
|
onSwitch(assistant)
|
||||||
}, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition])
|
dispatch(setActiveTopicOrSessionAction('topic'))
|
||||||
|
}, [clickAssistantToShowTopic, onSwitch, assistant, dispatch, topicPosition])
|
||||||
|
|
||||||
const assistantName = useMemo(() => assistant.name || t('chat.default.name'), [assistant.name, t])
|
const assistantName = useMemo(() => assistant.name || t('chat.default.name'), [assistant.name, t])
|
||||||
const fullAssistantName = useMemo(
|
const fullAssistantName = useMemo(
|
||||||
|
|||||||
@ -12,8 +12,8 @@ import { FC, useCallback, useMemo, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import AssistantItem from './components/AssistantItem'
|
import AssistantItem from './AssistantItem'
|
||||||
import { SectionName } from './components/SectionName'
|
import { SectionName } from './SectionName'
|
||||||
|
|
||||||
interface AssistantsProps {
|
interface AssistantsProps {
|
||||||
activeAssistant: Assistant
|
activeAssistant: Assistant
|
||||||
795
src/renderer/src/pages/home/Tabs/components/Topics.tsx
Normal file
795
src/renderer/src/pages/home/Tabs/components/Topics.tsx
Normal file
@ -0,0 +1,795 @@
|
|||||||
|
import { DraggableVirtualList } from '@renderer/components/DraggableList'
|
||||||
|
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
|
||||||
|
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||||
|
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||||
|
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
||||||
|
import { isMac } from '@renderer/config/constant'
|
||||||
|
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
||||||
|
import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
|
||||||
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
|
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||||
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
|
import { finishTopicRenaming, startTopicRenaming, 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 { newMessagesActions } from '@renderer/store/newMessage'
|
||||||
|
import { setGenerating } from '@renderer/store/runtime'
|
||||||
|
import { Assistant, Topic } from '@renderer/types'
|
||||||
|
import { classNames, removeSpecialCharactersForFileName } from '@renderer/utils'
|
||||||
|
import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy'
|
||||||
|
import {
|
||||||
|
exportMarkdownToJoplin,
|
||||||
|
exportMarkdownToSiyuan,
|
||||||
|
exportMarkdownToYuque,
|
||||||
|
exportTopicAsMarkdown,
|
||||||
|
exportTopicToNotes,
|
||||||
|
exportTopicToNotion,
|
||||||
|
topicToMarkdown
|
||||||
|
} from '@renderer/utils/export'
|
||||||
|
import { Dropdown, MenuProps, Tooltip } from 'antd'
|
||||||
|
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { findIndex } from 'lodash'
|
||||||
|
import {
|
||||||
|
BrushCleaning,
|
||||||
|
FolderOpen,
|
||||||
|
HelpCircle,
|
||||||
|
MenuIcon,
|
||||||
|
NotebookPen,
|
||||||
|
PackagePlus,
|
||||||
|
PinIcon,
|
||||||
|
PinOffIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Save,
|
||||||
|
Sparkles,
|
||||||
|
UploadIcon,
|
||||||
|
XIcon
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assistant: Assistant
|
||||||
|
activeTopic: Topic
|
||||||
|
setActiveTopic: (topic: Topic) => void
|
||||||
|
position: 'left' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic, position }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { notesPath } = useNotesSettings()
|
||||||
|
const { assistants } = useAssistants()
|
||||||
|
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
|
||||||
|
const { showTopicTime, pinTopicsToTop, setTopicPosition, topicPosition } = useSettings()
|
||||||
|
|
||||||
|
const renamingTopics = useSelector((state: RootState) => state.runtime.chat.renamingTopics)
|
||||||
|
const topicLoadingQuery = useSelector((state: RootState) => state.messages.loadingByTopic)
|
||||||
|
const topicFulfilledQuery = useSelector((state: RootState) => state.messages.fulfilledByTopic)
|
||||||
|
const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics)
|
||||||
|
|
||||||
|
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
|
||||||
|
|
||||||
|
const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
|
||||||
|
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
|
||||||
|
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const topicEdit = useInPlaceEdit({
|
||||||
|
onSave: (name: string) => {
|
||||||
|
const topic = assistant.topics.find((t) => t.id === editingTopicId)
|
||||||
|
if (topic && name !== topic.name) {
|
||||||
|
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
|
||||||
|
updateTopic(updatedTopic)
|
||||||
|
window.toast.success(t('common.saved'))
|
||||||
|
}
|
||||||
|
setEditingTopicId(null)
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setEditingTopicId(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPending = useCallback((topicId: string) => topicLoadingQuery[topicId], [topicLoadingQuery])
|
||||||
|
const isFulfilled = useCallback((topicId: string) => topicFulfilledQuery[topicId], [topicFulfilledQuery])
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(newMessagesActions.setTopicFulfilled({ topicId: activeTopic.id, fulfilled: false }))
|
||||||
|
}, [activeTopic.id, dispatch, topicFulfilledQuery])
|
||||||
|
|
||||||
|
const isRenaming = useCallback(
|
||||||
|
(topicId: string) => {
|
||||||
|
return renamingTopics.includes(topicId)
|
||||||
|
},
|
||||||
|
[renamingTopics]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isNewlyRenamed = useCallback(
|
||||||
|
(topicId: string) => {
|
||||||
|
return newlyRenamedTopics.includes(topicId)
|
||||||
|
},
|
||||||
|
[newlyRenamedTopics]
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (assistant.topics.length === 1) {
|
||||||
|
return onClearMessages(topic)
|
||||||
|
}
|
||||||
|
await modelGenerating()
|
||||||
|
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
|
||||||
|
if (topic.id === activeTopic.id) {
|
||||||
|
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1])
|
||||||
|
}
|
||||||
|
removeTopic(topic)
|
||||||
|
setDeletingTopicId(null)
|
||||||
|
},
|
||||||
|
[activeTopic.id, assistant.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(assistant.topics, (t) => t.id === topic.id)
|
||||||
|
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1])
|
||||||
|
}
|
||||||
|
removeTopic(topic)
|
||||||
|
},
|
||||||
|
[assistant.topics, removeTopic, setActiveTopic, activeTopic]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onMoveTopic = useCallback(
|
||||||
|
async (topic: Topic, toAssistant: Assistant) => {
|
||||||
|
await modelGenerating()
|
||||||
|
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
|
||||||
|
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? 0 : index + 1])
|
||||||
|
moveTopic(topic, toAssistant)
|
||||||
|
},
|
||||||
|
[assistant.topics, moveTopic, setActiveTopic]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSwitchTopic = useCallback(
|
||||||
|
async (topic: Topic) => {
|
||||||
|
// await modelGenerating()
|
||||||
|
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: <Sparkles size={14} />,
|
||||||
|
disabled: isRenaming(topic.id),
|
||||||
|
async onClick() {
|
||||||
|
const messages = await TopicManager.getTopicMessages(topic.id)
|
||||||
|
if (messages.length >= 2) {
|
||||||
|
startTopicRenaming(topic.id)
|
||||||
|
try {
|
||||||
|
const summaryText = await fetchMessagesSummary({ messages, assistant })
|
||||||
|
if (summaryText) {
|
||||||
|
const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false }
|
||||||
|
updateTopic(updatedTopic)
|
||||||
|
} else {
|
||||||
|
window.toast?.error(t('message.error.fetchTopicName'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
finishTopicRenaming(topic.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('chat.topics.edit.title'),
|
||||||
|
key: 'rename',
|
||||||
|
icon: <EditIcon size={14} />,
|
||||||
|
disabled: isRenaming(topic.id),
|
||||||
|
async onClick() {
|
||||||
|
const name = await PromptPopup.show({
|
||||||
|
title: t('chat.topics.edit.title'),
|
||||||
|
message: '',
|
||||||
|
defaultValue: topic?.name || '',
|
||||||
|
extraNode: (
|
||||||
|
<div style={{ color: 'var(--color-text-3)', marginTop: 8 }}>{t('chat.topics.edit.title_tip')}</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (name && topic?.name !== name) {
|
||||||
|
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
|
||||||
|
updateTopic(updatedTopic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('chat.topics.prompt.label'),
|
||||||
|
key: 'topic-prompt',
|
||||||
|
icon: <PackagePlus size={14} />,
|
||||||
|
extra: (
|
||||||
|
<Tooltip title={t('chat.topics.prompt.tips')}>
|
||||||
|
<HelpCircle size={14} />
|
||||||
|
</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.unpin') : t('chat.topics.pin'),
|
||||||
|
key: 'pin',
|
||||||
|
icon: topic.pinned ? <PinOffIcon size={14} /> : <PinIcon size={14} />,
|
||||||
|
onClick() {
|
||||||
|
onPinTopic(topic)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('notes.save'),
|
||||||
|
key: 'notes',
|
||||||
|
icon: <NotebookPen size={14} />,
|
||||||
|
onClick: async () => {
|
||||||
|
exportTopicToNotes(topic, notesPath)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('chat.topics.clear.title'),
|
||||||
|
key: 'clear-messages',
|
||||||
|
icon: <BrushCleaning size={14} />,
|
||||||
|
async onClick() {
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('chat.input.clear.content'),
|
||||||
|
centered: true,
|
||||||
|
onOk: () => onClearMessages(topic)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('settings.topic.position.label'),
|
||||||
|
key: 'topic-position',
|
||||||
|
icon: <MenuIcon size={14} />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: t('settings.topic.position.left'),
|
||||||
|
key: 'left',
|
||||||
|
onClick: () => setTopicPosition('left')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('settings.topic.position.right'),
|
||||||
|
key: 'right',
|
||||||
|
onClick: () => setTopicPosition('right')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('chat.topics.copy.title'),
|
||||||
|
key: 'copy',
|
||||||
|
icon: <CopyIcon size={14} />,
|
||||||
|
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.save.label'),
|
||||||
|
key: 'save',
|
||||||
|
icon: <Save size={14} />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: t('chat.save.topic.knowledge.title'),
|
||||||
|
key: 'knowledge',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
const result = await SaveToKnowledgePopup.showForTopic(topic)
|
||||||
|
if (result?.success) {
|
||||||
|
window.toast.success(t('chat.save.topic.knowledge.success', { count: result.savedCount }))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
window.toast.error(t('chat.save.topic.knowledge.error.save_failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('chat.topics.export.title'),
|
||||||
|
key: 'export',
|
||||||
|
icon: <UploadIcon size={14} />,
|
||||||
|
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.label'),
|
||||||
|
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 && assistant.topics.length > 1) {
|
||||||
|
menus.push({
|
||||||
|
label: t('chat.topics.move_to'),
|
||||||
|
key: 'move',
|
||||||
|
icon: <FolderOpen size={14} />,
|
||||||
|
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: <DeleteIcon size={14} className="lucide-custom" />,
|
||||||
|
onClick: () => onDeleteTopic(topic)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return menus
|
||||||
|
}, [
|
||||||
|
targetTopic,
|
||||||
|
t,
|
||||||
|
isRenaming,
|
||||||
|
exportMenuOptions.image,
|
||||||
|
exportMenuOptions.markdown,
|
||||||
|
exportMenuOptions.markdown_reason,
|
||||||
|
exportMenuOptions.docx,
|
||||||
|
exportMenuOptions.notion,
|
||||||
|
exportMenuOptions.yuque,
|
||||||
|
exportMenuOptions.obsidian,
|
||||||
|
exportMenuOptions.joplin,
|
||||||
|
exportMenuOptions.siyuan,
|
||||||
|
assistants,
|
||||||
|
notesPath,
|
||||||
|
assistant,
|
||||||
|
updateTopic,
|
||||||
|
activeTopic.id,
|
||||||
|
setActiveTopic,
|
||||||
|
onPinTopic,
|
||||||
|
onClearMessages,
|
||||||
|
setTopicPosition,
|
||||||
|
onMoveTopic,
|
||||||
|
onDeleteTopic
|
||||||
|
])
|
||||||
|
|
||||||
|
// Sort topics based on pinned status if pinTopicsToTop is enabled
|
||||||
|
const sortedTopics = useMemo(() => {
|
||||||
|
if (pinTopicsToTop) {
|
||||||
|
return [...assistant.topics].sort((a, b) => {
|
||||||
|
if (a.pinned && !b.pinned) return -1
|
||||||
|
if (!a.pinned && b.pinned) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return assistant.topics
|
||||||
|
}, [assistant.topics, pinTopicsToTop])
|
||||||
|
|
||||||
|
const singlealone = topicPosition === 'right' && position === 'right'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggableVirtualList
|
||||||
|
className="topics-tab"
|
||||||
|
list={sortedTopics}
|
||||||
|
onUpdate={updateTopics}
|
||||||
|
style={{ height: '100%', padding: '13px 0 10px 10px' }}
|
||||||
|
itemContainerStyle={{ paddingBottom: '8px' }}
|
||||||
|
header={
|
||||||
|
<AddTopicButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
|
||||||
|
<PlusIcon size={16} />
|
||||||
|
{t('chat.add.topic.title')}
|
||||||
|
</AddTopicButton>
|
||||||
|
}>
|
||||||
|
{(topic) => {
|
||||||
|
const isActive = topic.id === activeTopic?.id
|
||||||
|
const topicName = topic.name.replace('`', '')
|
||||||
|
const topicPrompt = topic.prompt
|
||||||
|
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
|
||||||
|
|
||||||
|
const getTopicNameClassName = () => {
|
||||||
|
if (isRenaming(topic.id)) return 'shimmer'
|
||||||
|
if (isNewlyRenamed(topic.id)) return 'typing'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
|
||||||
|
<TopicListItem
|
||||||
|
onContextMenu={() => setTargetTopic(topic)}
|
||||||
|
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
|
||||||
|
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
|
||||||
|
style={{
|
||||||
|
borderRadius,
|
||||||
|
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
|
||||||
|
}}>
|
||||||
|
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
||||||
|
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
|
||||||
|
<TopicNameContainer>
|
||||||
|
{editingTopicId === topic.id && topicEdit.isEditing ? (
|
||||||
|
<TopicEditInput
|
||||||
|
ref={topicEdit.inputRef}
|
||||||
|
value={topicEdit.editValue}
|
||||||
|
onChange={topicEdit.handleInputChange}
|
||||||
|
onKeyDown={topicEdit.handleKeyDown}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TopicName
|
||||||
|
className={getTopicNameClassName()}
|
||||||
|
title={topicName}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setEditingTopicId(topic.id)
|
||||||
|
topicEdit.startEdit(topic.name)
|
||||||
|
}}>
|
||||||
|
{topicName}
|
||||||
|
</TopicName>
|
||||||
|
)}
|
||||||
|
{!topic.pinned && (
|
||||||
|
<Tooltip
|
||||||
|
placement="bottom"
|
||||||
|
mouseEnterDelay={0.7}
|
||||||
|
mouseLeaveDelay={0}
|
||||||
|
title={
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
|
||||||
|
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
|
||||||
|
</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 ? (
|
||||||
|
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
|
||||||
|
) : (
|
||||||
|
<XIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />
|
||||||
|
)}
|
||||||
|
</MenuButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{topic.pinned && (
|
||||||
|
<MenuButton className="pin">
|
||||||
|
<PinIcon size={14} color="var(--color-text-3)" />
|
||||||
|
</MenuButton>
|
||||||
|
)}
|
||||||
|
</TopicNameContainer>
|
||||||
|
{topicPrompt && (
|
||||||
|
<TopicPromptText className="prompt" title={fullTopicPrompt}>
|
||||||
|
{fullTopicPrompt}
|
||||||
|
</TopicPromptText>
|
||||||
|
)}
|
||||||
|
{showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>}
|
||||||
|
</TopicListItem>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</DraggableVirtualList>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
cursor: pointer;
|
||||||
|
width: calc(var(--assistants-width) - 20px);
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
opacity: 0;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-list-item-hover);
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--color-list-item);
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
.menu {
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.singlealone {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
border-left: 2px solid var(--color-primary);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const TopicNameContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 20px;
|
||||||
|
justify-content: space-between;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TopicName = styled.div`
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
position: relative;
|
||||||
|
will-change: background-position, width;
|
||||||
|
|
||||||
|
--color-shimmer-mid: var(--color-text-1);
|
||||||
|
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
|
||||||
|
|
||||||
|
&.shimmer {
|
||||||
|
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
animation: shimmer 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.typing {
|
||||||
|
display: block;
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
-webkit-box-orient: unset;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: typewriter 0.5s steps(40, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typewriter {
|
||||||
|
from {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const TopicEditInput = styled.input`
|
||||||
|
background: var(--color-background);
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 2px 6px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
padding: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
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-status-warning);
|
||||||
|
`
|
||||||
|
|
||||||
|
const FulfilledIndicator = 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-status-success);
|
||||||
|
`
|
||||||
|
|
||||||
|
const AddTopicButton = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: calc(100% - 10px);
|
||||||
|
padding: 7px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: var(--list-item-border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-top: -5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-list-item-hover);
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
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: 11px;
|
||||||
|
`
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
`
|
||||||
@ -47,7 +47,7 @@ const HomeTabs: FC<Props> = ({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { chat } = useRuntime()
|
const { chat } = useRuntime()
|
||||||
const { activeTabId: tab } = chat
|
const { activeTabId: tab, activeTopicOrSession } = chat
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const setTab = useCallback(
|
const setTab = useCallback(
|
||||||
@ -126,7 +126,7 @@ const HomeTabs: FC<Props> = ({
|
|||||||
{t('assistants.abbr')}
|
{t('assistants.abbr')}
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem active={tab === 'topic'} onClick={() => setTab('topic')}>
|
<TabItem active={tab === 'topic'} onClick={() => setTab('topic')}>
|
||||||
{t('common.topics')}
|
{activeTopicOrSession === 'topic' ? t('common.topics') : t('agent.session.label_other')}
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem active={tab === 'settings'} onClick={() => setTab('settings')}>
|
<TabItem active={tab === 'settings'} onClick={() => setTab('settings')}>
|
||||||
{t('settings.title')}
|
{t('settings.title')}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface ChatState {
|
|||||||
/** UI state. Map agent id to active session id.
|
/** UI state. Map agent id to active session id.
|
||||||
* null represents no active session */
|
* null represents no active session */
|
||||||
activeSessionId: Record<string, string | null>
|
activeSessionId: Record<string, string | null>
|
||||||
|
/** meanwhile active Assistants or Agents */
|
||||||
activeTopicOrSession: 'topic' | 'session'
|
activeTopicOrSession: 'topic' | 'session'
|
||||||
activeTabId: Tab
|
activeTabId: Tab
|
||||||
/** topic ids that are currently being renamed */
|
/** topic ids that are currently being renamed */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user