diff --git a/src/renderer/src/components/Avatar/AssistantAvatar.tsx b/src/renderer/src/components/Avatar/AssistantAvatar.tsx new file mode 100644 index 0000000000..7ab87e633e --- /dev/null +++ b/src/renderer/src/components/Avatar/AssistantAvatar.tsx @@ -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 = ({ assistant, size = 24, className }) => { + const { assistantIconType } = useSettings() + const defaultModel = getDefaultModel() + + const assistantName = useMemo(() => assistant.name || '', [assistant.name]) + + if (assistantIconType === 'model') { + return + } + + if (assistantIconType === 'emoji') { + return + } + + return null +} + +export default AssistantAvatar diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 3d12402e61..b6f4b50cd6 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index f8222c4123..ceb8cad739 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "取消固定" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 27eac8065d..f150f5aef3 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "取消固定" }, diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 6fd98193ac..074b53c4da 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -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" }, diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 0f002836ff..5f7b00f3be 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -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": "Ξεκαρφίτσωμα" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 18832aeca5..66746875d9 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -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" }, diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index e31cba94dd..76efea8e3d 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -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" }, diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 93d4219e22..d9e62b6229 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -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": "固定解除" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 11564b14bc..69c2ae2609 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -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" }, diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index f0d9937dba..8ef955addd 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -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": "Открепленные темы" }, diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx index 61c1eeeb34..92d5e88dae 100644 --- a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx @@ -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 = ({ 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 = ({ popupRender={(menu) =>
e.stopPropagation()}>{menu}
}> - {assistantIconType === 'model' ? ( - - ) : ( - assistantIconType === 'emoji' && ( - - ) - )} + {assistantName} {isActive && ( diff --git a/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx b/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx new file mode 100644 index 0000000000..08c47311fb --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx @@ -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 + searchText: string + enterManageMode: () => void + exitManageMode: () => void + toggleSelectTopic: (topicId: string) => void + setSelectedIds: React.Dispatch>> + setSearchText: React.Dispatch> +} + +/** + * Hook for managing topic selection state + */ +export function useTopicManageMode(): TopicManageModeState { + const [isManageMode, setIsManageMode] = useState(false) + const [selectedIds, setSelectedIds] = useState>(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 = ({ + 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(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 ( + + + + + + {isAllSelected ? : } + + + {selectedIds.size > 0 && ( + + {selectedIds.size} + + )} + + + setSearchText(e.target.value)} + onKeyDown={handleSearchKeyDown} + /> + + + + + + + + + ) + } + + // Normal manage mode UI + return ( + + + + + + {isAllSelected ? : } + + + {selectedIds.size > 0 && ( + + {selectedIds.size} + + )} + + + + + + + + {otherAssistants.length > 0 && ( + ({ + key: a.id, + label: a.name, + icon: , + onClick: () => handleMoveSelected(a.id), + disabled: selectedIds.size === 0 + })) + }} + trigger={['click']} + disabled={selectedIds.size === 0}> + + + + + + + )} + + + + + + + + + + + + + + + ) +} + +// Tailwind components +const ManagePanel: FC = ({ children }) => ( +
+ {children} +
+) + +const ManagePanelContent: FC = ({ children }) => ( +
{children}
+) + +interface ManageIconButtonProps extends React.ButtonHTMLAttributes { + danger?: boolean +} + +const ManageIconButton: FC> = ({ + children, + className, + danger, + disabled, + ...props +}) => ( + +) + +const ManageDivider: FC = () =>
+ +const LeftGroup: FC = ({ children }) =>
{children}
+ +const RightGroup: FC = ({ children }) => ( +
{children}
+) + +const SelectedBadge: FC>> = ({ + children, + className, + ...props +}) => ( + + {children} + +) + +const SearchInputWrapper: FC = ({ children }) => ( +
{children}
+) + +interface SearchInputProps extends React.InputHTMLAttributes { + ref?: Ref +} + +const SearchInput: FC = ({ className, ref, ...props }) => ( + +) + +export default TopicManagePanel diff --git a/src/renderer/src/pages/home/Tabs/components/Topics.tsx b/src/renderer/src/pages/home/Tabs/components/Topics.tsx index 7284f9167c..29232e65d9 100644 --- a/src/renderer/src/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Topics.tsx @@ -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 = ({ assistant: _assistant, activeTopic, se const deleteTimerRef = useRef(null) const [editingTopicId, setEditingTopicId] = useState(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 = ({ assistant: _assistant, activeTopic, se .map((a) => ({ label: a.name, key: a.id, + icon: , onClick: () => onMoveTopic(topic, a) })) }) @@ -492,107 +502,187 @@ export const Topics: React.FC = ({ 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 ( - - EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> - {t('chat.add.topic.title')} - -
- - }> - {(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 '' + <> + + EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> + {t('chat.add.topic.title')} + + + + + + + } + 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 - return ( - - setTargetTopic(topic)} - className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')} - onClick={editingTopicId === topic.id && isEditing ? undefined : () => onSwitchTopic(topic)} - style={{ - borderRadius, - cursor: editingTopicId === topic.id && isEditing ? 'default' : 'pointer' - }}> - {isPending(topic.id) && !isActive && } - {isFulfilled(topic.id) && !isActive && } - - {editingTopicId === topic.id && isEditing ? ( - e.stopPropagation()} /> - ) : ( - { - setEditingTopicId(topic.id) - startEdit(topic.name) - }}> - {topicName} - + const getTopicNameClassName = () => { + if (isRenaming(topic.id)) return 'shimmer' + if (isNewlyRenamed(topic.id)) return 'typing' + return '' + } + + const handleItemClick = () => { + if (isManageMode) { + if (canSelect) { + toggleSelectTopic(topic.id) + } + } else { + onSwitchTopic(topic) + } + } + + return ( + + setTargetTopic(topic)} + className={classNames( + isActive && !isManageMode ? 'active' : '', + singlealone ? 'singlealone' : '', + isManageMode && isSelected ? 'selected' : '', + isManageMode && !canSelect ? 'disabled' : '' )} - {!topic.pinned && ( - - {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} -
- }> - { - if (e.ctrlKey || e.metaKey) { - handleConfirmDelete(topic, e) - } else if (deletingTopicId === topic.id) { - handleConfirmDelete(topic, e) - } else { - handleDeleteClick(topic.id, e) - } - }}> - {deletingTopicId === topic.id ? ( - + onClick={editingTopicId === topic.id && isEditing ? undefined : handleItemClick} + style={{ + borderRadius, + cursor: + editingTopicId === topic.id && isEditing + ? 'default' + : isManageMode && !canSelect + ? 'not-allowed' + : 'pointer' + }}> + {isPending(topic.id) && !isActive && } + {isFulfilled(topic.id) && !isActive && } + + {isManageMode && ( + + {isSelected ? ( + ) : ( - + )} + + )} + {editingTopicId === topic.id && isEditing ? ( + e.stopPropagation()} /> + ) : ( + { + setEditingTopicId(topic.id) + startEdit(topic.name) + } + }> + {topicName} + + )} + {!topic.pinned && ( + + {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} + + }> + { + if (e.ctrlKey || e.metaKey) { + handleConfirmDelete(topic, e) + } else if (deletingTopicId === topic.id) { + handleConfirmDelete(topic, e) + } else { + handleDeleteClick(topic.id, e) + } + }}> + {deletingTopicId === topic.id ? ( + + ) : ( + + )} + + + )} + {topic.pinned && ( + + - + )} + + {topicPrompt && ( + + {fullTopicPrompt} + )} - {topic.pinned && ( - - - + {showTopicTime && ( + {dayjs(topic.createdAt).format('MM/DD HH:mm')} )} - - {topicPrompt && ( - - {fullTopicPrompt} - - )} - {showTopicTime && {dayjs(topic.createdAt).format('MM/DD HH:mm')}} - - - ) - }} - + + + ) + }} + + + {/* 管理模式底部面板 */} + + ) } @@ -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; + } +` diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index 6317ff3bca..4504550a16 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -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));