mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
feat(topics): add topic manage mode for batch operations (#11952)
* feat(topics): add topic manage mode for batch operations - Add topic manage mode with batch delete and move operations - Implement search functionality within manage mode with keyword matching - Create reusable AssistantAvatar component for consistent icon display - Add assistant icons to move-to dropdown menus - Include selection badge with clear selection tooltip - Add delete confirmation dialog with danger button styling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(TopicManageMode): convert styled components to Tailwind CSS - Replace styled-components with Tailwind CSS for ManagePanel, ManagePanelContent, ManageIconButton, and other UI elements. - Update button styling to use Tailwind classes for improved consistency and maintainability. - Enhance component structure with functional components and props for better reusability. * style(Topics): update HeaderIconButton dimensions and border radius - Increased dimensions of HeaderIconButton from 28px to 32px for improved visibility. - Updated border radius to use a CSS variable for consistency with other UI elements. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bdd272b7cd
commit
99d7223a0a
34
src/renderer/src/components/Avatar/AssistantAvatar.tsx
Normal file
34
src/renderer/src/components/Avatar/AssistantAvatar.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import EmojiIcon from '@renderer/components/EmojiIcon'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import type { Assistant } from '@renderer/types'
|
||||
import { getLeadingEmoji } from '@renderer/utils'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import ModelAvatar from './ModelAvatar'
|
||||
|
||||
interface AssistantAvatarProps {
|
||||
assistant: Assistant
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AssistantAvatar: FC<AssistantAvatarProps> = ({ assistant, size = 24, className }) => {
|
||||
const { assistantIconType } = useSettings()
|
||||
const defaultModel = getDefaultModel()
|
||||
|
||||
const assistantName = useMemo(() => assistant.name || '', [assistant.name])
|
||||
|
||||
if (assistantIconType === 'model') {
|
||||
return <ModelAvatar model={assistant.model || defaultModel} size={size} className={className} />
|
||||
}
|
||||
|
||||
if (assistantIconType === 'emoji') {
|
||||
return <EmojiIcon emoji={assistant.emoji || getLeadingEmoji(assistantName)} size={size} className={className} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default AssistantAvatar
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "Export to Yuque"
|
||||
},
|
||||
"list": "Topic List",
|
||||
"manage": {
|
||||
"clear_selection": "Clear Selection",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "Are you sure you want to delete {{count}} selected topic(s)? This action cannot be undone.",
|
||||
"title": "Delete Topics"
|
||||
},
|
||||
"success": "Deleted {{count}} topic(s)"
|
||||
},
|
||||
"deselect_all": "Deselect All",
|
||||
"error": {
|
||||
"at_least_one": "At least one topic must be kept"
|
||||
},
|
||||
"move": {
|
||||
"button": "Move",
|
||||
"placeholder": "Select target assistant",
|
||||
"success": "Moved {{count}} topic(s)"
|
||||
},
|
||||
"pinned": "Pinned Topics",
|
||||
"selected_count": "{{count}} selected",
|
||||
"title": "Manage Topics",
|
||||
"unpinned": "Unpinned Topics"
|
||||
},
|
||||
"move_to": "Move to",
|
||||
"new": "New Topic",
|
||||
"pin": "Pin Topic",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "Topic Prompts",
|
||||
"tips": "Topic Prompts: Additional supplementary prompts provided for the current topic"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search topics...",
|
||||
"title": "Search"
|
||||
},
|
||||
"title": "Topics",
|
||||
"unpin": "Unpin Topic"
|
||||
},
|
||||
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "导出到语雀"
|
||||
},
|
||||
"list": "话题列表",
|
||||
"manage": {
|
||||
"clear_selection": "取消选择",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "确定要删除选中的 {{count}} 个话题吗?此操作不可撤销。",
|
||||
"title": "删除话题"
|
||||
},
|
||||
"success": "已删除 {{count}} 个话题"
|
||||
},
|
||||
"deselect_all": "取消全选",
|
||||
"error": {
|
||||
"at_least_one": "至少需要保留一个话题"
|
||||
},
|
||||
"move": {
|
||||
"button": "移动",
|
||||
"placeholder": "选择目标助手",
|
||||
"success": "已移动 {{count}} 个话题"
|
||||
},
|
||||
"pinned": "已固定的话题",
|
||||
"selected_count": "已选择 {{count}} 个",
|
||||
"title": "管理话题",
|
||||
"unpinned": "未固定的话题"
|
||||
},
|
||||
"move_to": "移动到",
|
||||
"new": "开始新对话",
|
||||
"pin": "固定话题",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "话题提示词",
|
||||
"tips": "话题提示词:针对当前话题提供额外的补充提示词"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索话题...",
|
||||
"title": "搜索"
|
||||
},
|
||||
"title": "话题",
|
||||
"unpin": "取消固定"
|
||||
},
|
||||
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "匯出到語雀"
|
||||
},
|
||||
"list": "話題列表",
|
||||
"manage": {
|
||||
"clear_selection": "取消選擇",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "確定要刪除選中的 {{count}} 個話題嗎?此操作不可撤銷。",
|
||||
"title": "刪除話題"
|
||||
},
|
||||
"success": "已刪除 {{count}} 個話題"
|
||||
},
|
||||
"deselect_all": "取消全選",
|
||||
"error": {
|
||||
"at_least_one": "至少需要保留一個話題"
|
||||
},
|
||||
"move": {
|
||||
"button": "移動",
|
||||
"placeholder": "選擇目標助手",
|
||||
"success": "已移動 {{count}} 個話題"
|
||||
},
|
||||
"pinned": "已固定的話題",
|
||||
"selected_count": "已選擇 {{count}} 個",
|
||||
"title": "管理話題",
|
||||
"unpinned": "未固定的話題"
|
||||
},
|
||||
"move_to": "移動到",
|
||||
"new": "開始新對話",
|
||||
"pin": "固定話題",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "話題提示詞",
|
||||
"tips": "話題提示詞:針對目前話題提供額外的補充提示詞"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜尋話題...",
|
||||
"title": "搜尋"
|
||||
},
|
||||
"title": "話題",
|
||||
"unpin": "取消固定"
|
||||
},
|
||||
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "Nach Yuque exportieren"
|
||||
},
|
||||
"list": "Themenliste",
|
||||
"manage": {
|
||||
"clear_selection": "Auswahl aufheben",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "Sind Sie sicher, dass Sie {{count}} ausgewähltes Thema bzw. ausgewählte Themen löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"title": "Themen löschen"
|
||||
},
|
||||
"success": "{{count}} Thema/Themen gelöscht"
|
||||
},
|
||||
"deselect_all": "Alle abwählen",
|
||||
"error": {
|
||||
"at_least_one": "Mindestens ein Thema muss beibehalten werden"
|
||||
},
|
||||
"move": {
|
||||
"button": "Bewegen",
|
||||
"placeholder": "Ziel auswählen",
|
||||
"success": "{{count}} Thema(s) verschoben"
|
||||
},
|
||||
"pinned": "Angepinnte Themen",
|
||||
"selected_count": "{{count}} ausgewählt",
|
||||
"title": "Themen verwalten",
|
||||
"unpinned": "Losgelöste Themen"
|
||||
},
|
||||
"move_to": "Verschieben nach",
|
||||
"new": "Neues Gespräch starten",
|
||||
"pin": "Thema anheften",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "Themen-Prompt",
|
||||
"tips": "Themen-Prompt: Bietet zusätzliche ergänzende Prompts für das aktuelle Thema"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Themen durchsuchen...",
|
||||
"title": "Suche"
|
||||
},
|
||||
"title": "Thema",
|
||||
"unpin": "Anheften aufheben"
|
||||
},
|
||||
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "Εξαγωγή στο Yuque"
|
||||
},
|
||||
"list": "Λίστα θεμάτων",
|
||||
"manage": {
|
||||
"clear_selection": "Καθαρισμός Επιλογής",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "Είσαι βέβαιος ότι θέλεις να διαγράψεις {{count}} επιλεγμένο(α) θέμα(τα); Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
|
||||
"title": "Διαγραφή Θεμάτων"
|
||||
},
|
||||
"success": "Διαγράφηκαν {{count}} θέμα(τα)"
|
||||
},
|
||||
"deselect_all": "Αποεπιλογή όλων",
|
||||
"error": {
|
||||
"at_least_one": "Τουλάχιστον ένα θέμα πρέπει να διατηρηθεί"
|
||||
},
|
||||
"move": {
|
||||
"button": "Μετακίνηση",
|
||||
"placeholder": "Επιλέξτε στόχο",
|
||||
"success": "Μετακινήθηκαν {{count}} θέματα"
|
||||
},
|
||||
"pinned": "Καρφιτσωμένα Θέματα",
|
||||
"selected_count": "{{count}} επιλεγμένα",
|
||||
"title": "Διαχείριση Θεμάτων",
|
||||
"unpinned": "Ξεκαρφωμένα Θέματα"
|
||||
},
|
||||
"move_to": "Μετακίνηση στο",
|
||||
"new": "Ξεκινήστε νέα συζήτηση",
|
||||
"pin": "Σταθερά θέματα",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "Προσδοκώμενα όρια",
|
||||
"tips": "Προσδοκώμενα όρια: προσθέτει επιπλέον επιστημονικές προσθήκες για το παρόν θέμα"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Αναζήτηση θεμάτων...",
|
||||
"title": "Αναζήτηση"
|
||||
},
|
||||
"title": "Θέματα",
|
||||
"unpin": "Ξεκαρφίτσωμα"
|
||||
},
|
||||
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "Exportar a Yuque"
|
||||
},
|
||||
"list": "Lista de temas",
|
||||
"manage": {
|
||||
"clear_selection": "Borrar selección",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "¿Estás seguro de que quieres eliminar {{count}} tema(s) seleccionado(s)? Esta acción no se puede deshacer.",
|
||||
"title": "Eliminar Temas"
|
||||
},
|
||||
"success": "Eliminado(s) {{count}} tema(s)"
|
||||
},
|
||||
"deselect_all": "Deseleccionar todo",
|
||||
"error": {
|
||||
"at_least_one": "Al menos se debe mantener un tema."
|
||||
},
|
||||
"move": {
|
||||
"button": "Mover",
|
||||
"placeholder": "Seleccionar asistente de destino",
|
||||
"success": "Movido(s) {{count}} tema(s)"
|
||||
},
|
||||
"pinned": "Temas Fijados",
|
||||
"selected_count": "{{count}} seleccionado",
|
||||
"title": "Administrar Temas",
|
||||
"unpinned": "Temas no fijados"
|
||||
},
|
||||
"move_to": "Mover a",
|
||||
"new": "Iniciar nueva conversación",
|
||||
"pin": "Fijar tema",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "Palabras clave del tema",
|
||||
"tips": "Palabras clave del tema: proporcionar indicaciones adicionales para el tema actual"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar temas...",
|
||||
"title": "Buscar"
|
||||
},
|
||||
"title": "Tema",
|
||||
"unpin": "Quitar fijación"
|
||||
},
|
||||
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "Exporter vers Yuque"
|
||||
},
|
||||
"list": "Liste des sujets",
|
||||
"manage": {
|
||||
"clear_selection": "Effacer la sélection",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "Êtes-vous sûr de vouloir supprimer {{count}} sujet(s) sélectionné(s) ? Cette action est irréversible.",
|
||||
"title": "Supprimer des sujets"
|
||||
},
|
||||
"success": "Supprimé {{count}} sujet(s)"
|
||||
},
|
||||
"deselect_all": "Tout désélectionner",
|
||||
"error": {
|
||||
"at_least_one": "Au moins un sujet doit être conservé"
|
||||
},
|
||||
"move": {
|
||||
"button": "Déplacer",
|
||||
"placeholder": "Sélectionner la cible",
|
||||
"success": "Déplacé {{count}} sujet(s)"
|
||||
},
|
||||
"pinned": "Sujets épinglés",
|
||||
"selected_count": "{{count}} sélectionné",
|
||||
"title": "Gérer les sujets",
|
||||
"unpinned": "Sujets non épinglés"
|
||||
},
|
||||
"move_to": "Déplacer vers",
|
||||
"new": "Commencer une nouvelle conversation",
|
||||
"pin": "Fixer le sujet",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "Indicateurs de sujet",
|
||||
"tips": "Indicateurs de sujet : fournir des indications supplémentaires pour le sujet actuel"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher des sujets...",
|
||||
"title": "Rechercher"
|
||||
},
|
||||
"title": "Sujet",
|
||||
"unpin": "Annuler le fixage"
|
||||
},
|
||||
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "語雀にエクスポート"
|
||||
},
|
||||
"list": "トピックリスト",
|
||||
"manage": {
|
||||
"clear_selection": "選択をクリア",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "{{count}}件の選択したトピックを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"title": "トピックを削除"
|
||||
},
|
||||
"success": "{{count}}件のトピックを削除しました"
|
||||
},
|
||||
"deselect_all": "すべての選択を解除",
|
||||
"error": {
|
||||
"at_least_one": "少なくとも1つのトピックは保持されなければなりません"
|
||||
},
|
||||
"move": {
|
||||
"button": "移動",
|
||||
"placeholder": "対象を選択",
|
||||
"success": "{{count}}件のトピックを移動しました"
|
||||
},
|
||||
"pinned": "ピン留めされたトピック",
|
||||
"selected_count": "{{count}} 選択済み",
|
||||
"title": "トピックを管理",
|
||||
"unpinned": "ピン留めされていないトピック"
|
||||
},
|
||||
"move_to": "移動先",
|
||||
"new": "新しいトピック",
|
||||
"pin": "トピックを固定",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "トピック提示語",
|
||||
"tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "トピックを検索...",
|
||||
"title": "検索"
|
||||
},
|
||||
"title": "トピック",
|
||||
"unpin": "固定解除"
|
||||
},
|
||||
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "Exportar para Yuque"
|
||||
},
|
||||
"list": "Lista de tópicos",
|
||||
"manage": {
|
||||
"clear_selection": "Limpar Seleção",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "Tem certeza de que deseja excluir {{count}} tópico(s) selecionado(s)? Esta ação não pode ser desfeita.",
|
||||
"title": "Excluir Tópicos"
|
||||
},
|
||||
"success": "Excluído(s) {{count}} tópico(s)"
|
||||
},
|
||||
"deselect_all": "Desmarcar Todos",
|
||||
"error": {
|
||||
"at_least_one": "Pelo menos um tópico deve ser mantido"
|
||||
},
|
||||
"move": {
|
||||
"button": "Mover",
|
||||
"placeholder": "Selecionar assistente de destino",
|
||||
"success": "Movido(s) {{count}} tópico(s)"
|
||||
},
|
||||
"pinned": "Tópicos Fixados",
|
||||
"selected_count": "{{count}} selecionado",
|
||||
"title": "Gerenciar Tópicos",
|
||||
"unpinned": "Tópicos Desafixados"
|
||||
},
|
||||
"move_to": "Mover para",
|
||||
"new": "Começar nova conversa",
|
||||
"pin": "Fixar tópico",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "Prompt do tópico",
|
||||
"tips": "Prompt do tópico: fornecer prompts adicionais para o tópico atual"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Pesquisar tópicos...",
|
||||
"title": "Pesquisar"
|
||||
},
|
||||
"title": "Tópicos",
|
||||
"unpin": "Desfixar"
|
||||
},
|
||||
|
||||
@ -1027,6 +1027,29 @@
|
||||
"yuque": "Экспорт в Yuque"
|
||||
},
|
||||
"list": "Список топиков",
|
||||
"manage": {
|
||||
"clear_selection": "Очистить выбор",
|
||||
"delete": {
|
||||
"confirm": {
|
||||
"content": "Вы уверены, что хотите удалить выбранные темы ({{count}})? Это действие нельзя отменить.",
|
||||
"title": "Удалить темы"
|
||||
},
|
||||
"success": "Удалено {{count}} тем(ы)"
|
||||
},
|
||||
"deselect_all": "Снять выделение со всех",
|
||||
"error": {
|
||||
"at_least_one": "Должна быть сохранена хотя бы одна тема"
|
||||
},
|
||||
"move": {
|
||||
"button": "Переместить",
|
||||
"placeholder": "Выберите цель",
|
||||
"success": "Перемещено {{count}} тем(ы)"
|
||||
},
|
||||
"pinned": "Закреплённые темы",
|
||||
"selected_count": "{{count}} выбрано",
|
||||
"title": "Управление темами",
|
||||
"unpinned": "Откреплённые темы"
|
||||
},
|
||||
"move_to": "Переместить в",
|
||||
"new": "Новый топик",
|
||||
"pin": "Закрепленные темы",
|
||||
@ -1037,6 +1060,10 @@
|
||||
"label": "Тематические подсказки",
|
||||
"tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Искать темы...",
|
||||
"title": "Поиск"
|
||||
},
|
||||
"title": "Топики",
|
||||
"unpin": "Открепленные темы"
|
||||
},
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import EmojiIcon from '@renderer/components/EmojiIcon'
|
||||
import AssistantAvatar from '@renderer/components/Avatar/AssistantAvatar'
|
||||
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTags } from '@renderer/hooks/useTags'
|
||||
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
|
||||
import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
|
||||
import type { Assistant, AssistantsSortType } from '@renderer/types'
|
||||
import { cn, getLeadingEmoji, uuid } from '@renderer/utils'
|
||||
import { cn, uuid } from '@renderer/utils'
|
||||
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Dropdown } from 'antd'
|
||||
@ -67,8 +65,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const { allTags } = useTags()
|
||||
const { removeAllTopics } = useAssistant(assistant.id)
|
||||
const { clickAssistantToShowTopic, topicPosition, assistantIconType, setAssistantIconType } = useSettings()
|
||||
const defaultModel = getDefaultModel()
|
||||
const { clickAssistantToShowTopic, topicPosition, setAssistantIconType } = useSettings()
|
||||
const { assistants, updateAssistants } = useAssistants()
|
||||
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
@ -166,20 +163,11 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
||||
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
|
||||
<Container onClick={handleSwitch} isActive={isActive}>
|
||||
<AssistantNameRow className="name" title={fullAssistantName}>
|
||||
{assistantIconType === 'model' ? (
|
||||
<ModelAvatar
|
||||
model={assistant.model || defaultModel}
|
||||
<AssistantAvatar
|
||||
assistant={assistant}
|
||||
size={24}
|
||||
className={isPending && !isActive ? 'animation-pulse' : ''}
|
||||
/>
|
||||
) : (
|
||||
assistantIconType === 'emoji' && (
|
||||
<EmojiIcon
|
||||
emoji={assistant.emoji || getLeadingEmoji(assistantName)}
|
||||
className={isPending && !isActive ? 'animation-pulse' : ''}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<AssistantName className="text-nowrap">{assistantName}</AssistantName>
|
||||
</AssistantNameRow>
|
||||
{isActive && (
|
||||
|
||||
411
src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx
Normal file
411
src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx
Normal file
@ -0,0 +1,411 @@
|
||||
import AssistantAvatar from '@renderer/components/Avatar/AssistantAvatar'
|
||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||
import { TopicManager } from '@renderer/hooks/useTopic'
|
||||
import type { Assistant, Topic } from '@renderer/types'
|
||||
import { cn } from '@renderer/utils'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { CheckSquare, FolderOpen, Search, Square, Trash2, XIcon } from 'lucide-react'
|
||||
import type { FC, PropsWithChildren, Ref } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface TopicManageModeState {
|
||||
isManageMode: boolean
|
||||
selectedIds: Set<string>
|
||||
searchText: string
|
||||
enterManageMode: () => void
|
||||
exitManageMode: () => void
|
||||
toggleSelectTopic: (topicId: string) => void
|
||||
setSelectedIds: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
setSearchText: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing topic selection state
|
||||
*/
|
||||
export function useTopicManageMode(): TopicManageModeState {
|
||||
const [isManageMode, setIsManageMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const enterManageMode = useCallback(() => {
|
||||
setIsManageMode(true)
|
||||
setSelectedIds(new Set())
|
||||
setSearchText('')
|
||||
}, [])
|
||||
|
||||
const exitManageMode = useCallback(() => {
|
||||
setIsManageMode(false)
|
||||
setSelectedIds(new Set())
|
||||
setSearchText('')
|
||||
}, [])
|
||||
|
||||
const toggleSelectTopic = useCallback((topicId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(topicId)) {
|
||||
next.delete(topicId)
|
||||
} else {
|
||||
next.add(topicId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isManageMode,
|
||||
selectedIds,
|
||||
searchText,
|
||||
enterManageMode,
|
||||
exitManageMode,
|
||||
toggleSelectTopic,
|
||||
setSelectedIds,
|
||||
setSearchText
|
||||
}
|
||||
}
|
||||
|
||||
interface TopicManagePanelProps {
|
||||
assistant: Assistant
|
||||
assistants: Assistant[]
|
||||
activeTopic: Topic
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
removeTopic: (topic: Topic) => void
|
||||
moveTopic: (topic: Topic, toAssistant: Assistant) => void
|
||||
manageState: TopicManageModeState
|
||||
filteredTopics: Topic[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom panel component for topic management mode
|
||||
*/
|
||||
export const TopicManagePanel: React.FC<TopicManagePanelProps> = ({
|
||||
assistant,
|
||||
assistants,
|
||||
activeTopic,
|
||||
setActiveTopic,
|
||||
removeTopic,
|
||||
moveTopic,
|
||||
manageState,
|
||||
filteredTopics
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { isManageMode, selectedIds, searchText, exitManageMode, setSelectedIds, setSearchText } = manageState
|
||||
const [isSearchMode, setIsSearchMode] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Topics that can be selected (non-pinned, and filtered when in search mode)
|
||||
const selectableTopics = useMemo(() => {
|
||||
const baseTopics = isSearchMode ? filteredTopics : assistant.topics
|
||||
return baseTopics.filter((topic) => !topic.pinned)
|
||||
}, [assistant.topics, filteredTopics, isSearchMode])
|
||||
|
||||
// Check if all selectable topics are selected
|
||||
const isAllSelected = useMemo(() => {
|
||||
return selectableTopics.length > 0 && selectableTopics.every((topic) => selectedIds.has(topic.id))
|
||||
}, [selectableTopics, selectedIds])
|
||||
|
||||
// Other assistants for move operation
|
||||
const otherAssistants = useMemo(() => assistants.filter((a) => a.id !== assistant.id), [assistants, assistant.id])
|
||||
|
||||
// Handle select all / deselect all
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (isAllSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(selectableTopics.map((topic) => topic.id)))
|
||||
}
|
||||
}, [isAllSelected, selectableTopics, setSelectedIds])
|
||||
|
||||
// Handle clear selection
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [setSelectedIds])
|
||||
|
||||
// Handle delete selected topics
|
||||
const handleDeleteSelected = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
|
||||
const remainingTopics = assistant.topics.filter((topic) => !selectedIds.has(topic.id))
|
||||
if (remainingTopics.length === 0) {
|
||||
window.toast.error(t('chat.topics.manage.error.at_least_one'))
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await window.modal.confirm({
|
||||
title: t('chat.topics.manage.delete.confirm.title'),
|
||||
content: t('chat.topics.manage.delete.confirm.content', { count: selectedIds.size }),
|
||||
centered: true,
|
||||
okButtonProps: { danger: true }
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
await modelGenerating()
|
||||
|
||||
const deletedCount = selectedIds.size
|
||||
for (const id of selectedIds) {
|
||||
const topic = assistant.topics.find((t) => t.id === id)
|
||||
if (topic) {
|
||||
await TopicManager.removeTopic(id)
|
||||
removeTopic(topic)
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to first remaining topic if current topic was deleted
|
||||
if (selectedIds.has(activeTopic.id)) {
|
||||
setActiveTopic(remainingTopics[0])
|
||||
}
|
||||
|
||||
window.toast.success(t('chat.topics.manage.delete.success', { count: deletedCount }))
|
||||
exitManageMode()
|
||||
}, [selectedIds, assistant.topics, removeTopic, activeTopic.id, setActiveTopic, t, exitManageMode])
|
||||
|
||||
// Handle move selected topics to another assistant
|
||||
const handleMoveSelected = useCallback(
|
||||
async (targetAssistantId: string) => {
|
||||
if (selectedIds.size === 0) return
|
||||
|
||||
const targetAssistant = assistants.find((a) => a.id === targetAssistantId)
|
||||
if (!targetAssistant) return
|
||||
|
||||
const remainingTopics = assistant.topics.filter((topic) => !selectedIds.has(topic.id))
|
||||
if (remainingTopics.length === 0) {
|
||||
window.toast.error(t('chat.topics.manage.error.at_least_one'))
|
||||
return
|
||||
}
|
||||
|
||||
await modelGenerating()
|
||||
|
||||
const movedCount = selectedIds.size
|
||||
for (const id of selectedIds) {
|
||||
const topic = assistant.topics.find((t) => t.id === id)
|
||||
if (topic) {
|
||||
moveTopic(topic, targetAssistant)
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to first remaining topic if current topic was moved
|
||||
if (selectedIds.has(activeTopic.id)) {
|
||||
setActiveTopic(remainingTopics[0])
|
||||
}
|
||||
|
||||
window.toast.success(t('chat.topics.manage.move.success', { count: movedCount }))
|
||||
exitManageMode()
|
||||
},
|
||||
[selectedIds, assistant.topics, assistants, moveTopic, activeTopic.id, setActiveTopic, t, exitManageMode]
|
||||
)
|
||||
|
||||
// Enter search mode
|
||||
const enterSearchMode = useCallback(() => {
|
||||
setIsSearchMode(true)
|
||||
}, [])
|
||||
|
||||
// Exit search mode
|
||||
const exitSearchMode = useCallback(() => {
|
||||
setIsSearchMode(false)
|
||||
setSearchText('')
|
||||
}, [setSearchText])
|
||||
|
||||
// Focus input when entering search mode
|
||||
useEffect(() => {
|
||||
if (isSearchMode && searchInputRef.current) {
|
||||
searchInputRef.current.focus()
|
||||
}
|
||||
}, [isSearchMode])
|
||||
|
||||
// Reset search mode when exiting manage mode
|
||||
useEffect(() => {
|
||||
if (!isManageMode) {
|
||||
setIsSearchMode(false)
|
||||
}
|
||||
}, [isManageMode])
|
||||
|
||||
// Handle search input keydown
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
exitSearchMode()
|
||||
}
|
||||
},
|
||||
[exitSearchMode]
|
||||
)
|
||||
|
||||
if (!isManageMode) return null
|
||||
|
||||
// Search mode UI
|
||||
if (isSearchMode) {
|
||||
return (
|
||||
<ManagePanel>
|
||||
<ManagePanelContent>
|
||||
<LeftGroup>
|
||||
<Tooltip title={isAllSelected ? t('chat.topics.manage.deselect_all') : t('common.select_all')}>
|
||||
<ManageIconButton onClick={handleSelectAll}>
|
||||
{isAllSelected ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</ManageIconButton>
|
||||
</Tooltip>
|
||||
{selectedIds.size > 0 && (
|
||||
<Tooltip title={t('chat.topics.manage.clear_selection')}>
|
||||
<SelectedBadge onClick={handleClearSelection}>{selectedIds.size}</SelectedBadge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</LeftGroup>
|
||||
<SearchInputWrapper>
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder={t('chat.topics.search.placeholder')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
</SearchInputWrapper>
|
||||
<Tooltip title={t('common.close')}>
|
||||
<ManageIconButton onClick={exitSearchMode}>
|
||||
<XIcon size={16} />
|
||||
</ManageIconButton>
|
||||
</Tooltip>
|
||||
</ManagePanelContent>
|
||||
</ManagePanel>
|
||||
)
|
||||
}
|
||||
|
||||
// Normal manage mode UI
|
||||
return (
|
||||
<ManagePanel>
|
||||
<ManagePanelContent>
|
||||
<LeftGroup>
|
||||
<Tooltip title={isAllSelected ? t('chat.topics.manage.deselect_all') : t('common.select_all')}>
|
||||
<ManageIconButton onClick={handleSelectAll}>
|
||||
{isAllSelected ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</ManageIconButton>
|
||||
</Tooltip>
|
||||
{selectedIds.size > 0 && (
|
||||
<Tooltip title={t('chat.topics.manage.clear_selection')}>
|
||||
<SelectedBadge onClick={handleClearSelection}>{selectedIds.size}</SelectedBadge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</LeftGroup>
|
||||
<RightGroup>
|
||||
<Tooltip title={t('chat.topics.search.title')}>
|
||||
<ManageIconButton onClick={enterSearchMode}>
|
||||
<Search size={16} />
|
||||
</ManageIconButton>
|
||||
</Tooltip>
|
||||
{otherAssistants.length > 0 && (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: otherAssistants.map((a) => ({
|
||||
key: a.id,
|
||||
label: a.name,
|
||||
icon: <AssistantAvatar assistant={a} size={18} />,
|
||||
onClick: () => handleMoveSelected(a.id),
|
||||
disabled: selectedIds.size === 0
|
||||
}))
|
||||
}}
|
||||
trigger={['click']}
|
||||
disabled={selectedIds.size === 0}>
|
||||
<Tooltip title={t('chat.topics.move_to')}>
|
||||
<ManageIconButton disabled={selectedIds.size === 0}>
|
||||
<FolderOpen size={16} />
|
||||
</ManageIconButton>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
)}
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<ManageIconButton danger onClick={handleDeleteSelected} disabled={selectedIds.size === 0}>
|
||||
<Trash2 size={16} />
|
||||
</ManageIconButton>
|
||||
</Tooltip>
|
||||
<ManageDivider />
|
||||
<Tooltip title={t('common.cancel')}>
|
||||
<ManageIconButton onClick={exitManageMode}>
|
||||
<XIcon size={16} />
|
||||
</ManageIconButton>
|
||||
</Tooltip>
|
||||
</RightGroup>
|
||||
</ManagePanelContent>
|
||||
</ManagePanel>
|
||||
)
|
||||
}
|
||||
|
||||
// Tailwind components
|
||||
const ManagePanel: FC<PropsWithChildren> = ({ children }) => (
|
||||
<div className="absolute bottom-[15px] left-[12px] z-[100] flex w-[calc(var(--assistants-width)-24px)] flex-row items-center rounded-xl bg-[var(--color-background)] px-3 py-2 shadow-[0_4px_12px_rgba(0,0,0,0.15),0_0_0_1px_var(--color-border)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const ManagePanelContent: FC<PropsWithChildren> = ({ children }) => (
|
||||
<div className="flex w-full min-w-0 flex-row items-center gap-1 overflow-hidden">{children}</div>
|
||||
)
|
||||
|
||||
interface ManageIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
const ManageIconButton: FC<PropsWithChildren<ManageIconButtonProps>> = ({
|
||||
children,
|
||||
className,
|
||||
danger,
|
||||
disabled,
|
||||
...props
|
||||
}) => (
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border-none bg-transparent text-[var(--color-text-2)] transition-all duration-200',
|
||||
disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
|
||||
!disabled && !danger && 'hover:bg-[var(--color-background-mute)] hover:text-[var(--color-text-1)]',
|
||||
danger && 'text-[var(--color-error)]',
|
||||
danger && !disabled && 'hover:bg-[var(--color-error)] hover:text-white [&:hover>svg]:text-white',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
const ManageDivider: FC = () => <div className="mx-1 h-5 w-px bg-[var(--color-border)]" />
|
||||
|
||||
const LeftGroup: FC<PropsWithChildren> = ({ children }) => <div className="flex items-center gap-1">{children}</div>
|
||||
|
||||
const RightGroup: FC<PropsWithChildren> = ({ children }) => (
|
||||
<div className="ml-auto flex items-center gap-1">{children}</div>
|
||||
)
|
||||
|
||||
const SelectedBadge: FC<PropsWithChildren<React.HTMLAttributes<HTMLSpanElement>>> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<span
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex h-[18px] min-w-[18px] cursor-pointer items-center justify-center rounded-[9px] bg-[var(--color-primary)] px-[5px] font-medium text-[11px] text-white transition-opacity duration-200 hover:opacity-[0.85]',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
const SearchInputWrapper: FC<PropsWithChildren> = ({ children }) => (
|
||||
<div className="mx-1 flex min-w-0 flex-1 items-center gap-1">{children}</div>
|
||||
)
|
||||
|
||||
interface SearchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
ref?: Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
const SearchInput: FC<SearchInputProps> = ({ className, ref, ...props }) => (
|
||||
<input
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-7 min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] text-[var(--color-text-1)] outline-none placeholder:text-[var(--color-text-3)]',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
export default TopicManagePanel
|
||||
@ -1,3 +1,4 @@
|
||||
import AssistantAvatar from '@renderer/components/Avatar/AssistantAvatar'
|
||||
import { DraggableVirtualList } from '@renderer/components/DraggableList'
|
||||
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
|
||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||
@ -37,8 +38,10 @@ import dayjs from 'dayjs'
|
||||
import { findIndex } from 'lodash'
|
||||
import {
|
||||
BrushCleaning,
|
||||
CheckSquare,
|
||||
FolderOpen,
|
||||
HelpCircle,
|
||||
ListChecks,
|
||||
MenuIcon,
|
||||
NotebookPen,
|
||||
PackagePlus,
|
||||
@ -46,6 +49,7 @@ import {
|
||||
PinOffIcon,
|
||||
Save,
|
||||
Sparkles,
|
||||
Square,
|
||||
UploadIcon,
|
||||
XIcon
|
||||
} from 'lucide-react'
|
||||
@ -55,6 +59,7 @@ import { useDispatch, useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AddButton from './AddButton'
|
||||
import { TopicManagePanel, useTopicManageMode } from './TopicManageMode'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
@ -81,6 +86,10 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
||||
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
|
||||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
|
||||
|
||||
// 管理模式状态
|
||||
const manageState = useTopicManageMode()
|
||||
const { isManageMode, selectedIds, searchText, enterManageMode, exitManageMode, toggleSelectTopic } = manageState
|
||||
|
||||
const { startEdit, isEditing, inputProps } = useInPlaceEdit({
|
||||
onSave: (name: string) => {
|
||||
const topic = assistant.topics.find((t) => t.id === editingTopicId)
|
||||
@ -437,6 +446,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
||||
.map((a) => ({
|
||||
label: a.name,
|
||||
key: a.id,
|
||||
icon: <AssistantAvatar assistant={a} size={18} />,
|
||||
onClick: () => onMoveTopic(topic, a)
|
||||
}))
|
||||
})
|
||||
@ -492,28 +502,60 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
||||
return assistant.topics
|
||||
}, [assistant.topics, pinTopicsToTop])
|
||||
|
||||
// Filter topics based on search text (only in manage mode)
|
||||
// Supports: case-insensitive, space-separated keywords (all must match)
|
||||
const deferredSearchText = useDeferredValue(searchText)
|
||||
const filteredTopics = useMemo(() => {
|
||||
if (!isManageMode || !deferredSearchText.trim()) {
|
||||
return sortedTopics
|
||||
}
|
||||
// Split by spaces and filter out empty strings
|
||||
const keywords = deferredSearchText
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((k) => k.length > 0)
|
||||
if (keywords.length === 0) {
|
||||
return sortedTopics
|
||||
}
|
||||
// All keywords must match (AND logic)
|
||||
return sortedTopics.filter((topic) => {
|
||||
const lowerName = topic.name.toLowerCase()
|
||||
return keywords.every((keyword) => lowerName.includes(keyword))
|
||||
})
|
||||
}, [sortedTopics, deferredSearchText, isManageMode])
|
||||
|
||||
const singlealone = topicPosition === 'right' && position === 'right'
|
||||
|
||||
return (
|
||||
<>
|
||||
<DraggableVirtualList
|
||||
className="topics-tab"
|
||||
list={sortedTopics}
|
||||
list={filteredTopics}
|
||||
onUpdate={updateTopics}
|
||||
style={{ height: '100%', padding: '9px 0 10px 10px' }}
|
||||
style={{ height: '100%', padding: '8px 0 10px 10px', paddingBottom: isManageMode ? 70 : 10 }}
|
||||
itemContainerStyle={{ paddingBottom: '8px' }}
|
||||
header={
|
||||
<>
|
||||
<HeaderRow>
|
||||
<AddButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
|
||||
{t('chat.add.topic.title')}
|
||||
</AddButton>
|
||||
<div className="my-1"></div>
|
||||
</>
|
||||
}>
|
||||
<Tooltip title={t('chat.topics.manage.title')} mouseEnterDelay={0.5}>
|
||||
<HeaderIconButton
|
||||
onClick={isManageMode ? exitManageMode : enterManageMode}
|
||||
className={isManageMode ? 'active' : ''}>
|
||||
<ListChecks size={14} />
|
||||
</HeaderIconButton>
|
||||
</Tooltip>
|
||||
</HeaderRow>
|
||||
}
|
||||
disabled={isManageMode}>
|
||||
{(topic) => {
|
||||
const isActive = topic.id === activeTopic?.id
|
||||
const topicName = topic.name.replace('`', '')
|
||||
const topicPrompt = topic.prompt
|
||||
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
|
||||
const isSelected = selectedIds.has(topic.id)
|
||||
const canSelect = !topic.pinned
|
||||
|
||||
const getTopicNameClassName = () => {
|
||||
if (isRenaming(topic.id)) return 'shimmer'
|
||||
@ -521,29 +563,62 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
||||
return ''
|
||||
}
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (isManageMode) {
|
||||
if (canSelect) {
|
||||
toggleSelectTopic(topic.id)
|
||||
}
|
||||
} else {
|
||||
onSwitchTopic(topic)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
|
||||
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']} disabled={isManageMode}>
|
||||
<TopicListItem
|
||||
onContextMenu={() => setTargetTopic(topic)}
|
||||
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
|
||||
onClick={editingTopicId === topic.id && isEditing ? undefined : () => onSwitchTopic(topic)}
|
||||
className={classNames(
|
||||
isActive && !isManageMode ? 'active' : '',
|
||||
singlealone ? 'singlealone' : '',
|
||||
isManageMode && isSelected ? 'selected' : '',
|
||||
isManageMode && !canSelect ? 'disabled' : ''
|
||||
)}
|
||||
onClick={editingTopicId === topic.id && isEditing ? undefined : handleItemClick}
|
||||
style={{
|
||||
borderRadius,
|
||||
cursor: editingTopicId === topic.id && isEditing ? 'default' : 'pointer'
|
||||
cursor:
|
||||
editingTopicId === topic.id && isEditing
|
||||
? 'default'
|
||||
: isManageMode && !canSelect
|
||||
? 'not-allowed'
|
||||
: 'pointer'
|
||||
}}>
|
||||
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
||||
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
|
||||
<TopicNameContainer>
|
||||
{isManageMode && (
|
||||
<SelectIcon className={!canSelect ? 'disabled' : ''}>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={16} color="var(--color-primary)" />
|
||||
) : (
|
||||
<Square size={16} color="var(--color-text-3)" />
|
||||
)}
|
||||
</SelectIcon>
|
||||
)}
|
||||
{editingTopicId === topic.id && isEditing ? (
|
||||
<TopicEditInput {...inputProps} onClick={(e) => e.stopPropagation()} />
|
||||
) : (
|
||||
<TopicName
|
||||
className={getTopicNameClassName()}
|
||||
title={topicName}
|
||||
onDoubleClick={() => {
|
||||
onDoubleClick={
|
||||
isManageMode
|
||||
? undefined
|
||||
: () => {
|
||||
setEditingTopicId(topic.id)
|
||||
startEdit(topic.name)
|
||||
}}>
|
||||
}
|
||||
}>
|
||||
{topicName}
|
||||
</TopicName>
|
||||
)}
|
||||
@ -587,12 +662,27 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
||||
{fullTopicPrompt}
|
||||
</TopicPromptText>
|
||||
)}
|
||||
{showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>}
|
||||
{showTopicTime && (
|
||||
<TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>
|
||||
)}
|
||||
</TopicListItem>
|
||||
</Dropdown>
|
||||
)
|
||||
}}
|
||||
</DraggableVirtualList>
|
||||
|
||||
{/* 管理模式底部面板 */}
|
||||
<TopicManagePanel
|
||||
assistant={assistant}
|
||||
assistants={assistants}
|
||||
activeTopic={activeTopic}
|
||||
setActiveTopic={setActiveTopic}
|
||||
removeTopic={removeTopic}
|
||||
moveTopic={moveTopic}
|
||||
manageState={manageState}
|
||||
filteredTopics={filteredTopics}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -640,6 +730,15 @@ const TopicListItem = styled.div`
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-primary-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`
|
||||
|
||||
const TopicNameContainer = styled.div`
|
||||
@ -648,7 +747,6 @@ const TopicNameContainer = styled.div`
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 20px;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
const TopicName = styled.div`
|
||||
@ -659,6 +757,8 @@ const TopicName = styled.div`
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
will-change: background-position, width;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
|
||||
--color-shimmer-mid: var(--color-text-1);
|
||||
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
|
||||
@ -765,3 +865,49 @@ const MenuButton = styled.div`
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
const HeaderRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-right: 10px;
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
|
||||
const HeaderIconButton = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-2);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SelectIcon = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 4px;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`
|
||||
|
||||
@ -145,6 +145,7 @@ const Container = styled.div`
|
||||
width: var(--assistants-width);
|
||||
transition: width 0.3s;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
position: relative;
|
||||
|
||||
&.right {
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user