From 9473ddc76280f971faaa1f1932d8be7f0b10300a Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Sat, 11 Oct 2025 16:07:35 +0800 Subject: [PATCH] feature: unified assistant tab (#10590) * feature: unified assistant tab * refactor(TagGroup): make TagsContainer component internal by removing export * refactor(components): migrate styled-components to cn utility classes Replace styled-components with cn utility classes from @heroui/react for better maintainability and performance * refactor(AssistantsTab): split AssistantsTab into smaller hooks and components * fix: click agent item should jump to topic tab * feat: add AddButton component and refactor usage across tabs - Introduced a new AddButton component for consistent UI across different tabs. - Replaced existing button implementations with AddButton in Sessions, Topics, and UnifiedAddButton components. - Removed unnecessary margin from AssistantsTab's container for improved layout. --------- Co-authored-by: icarus Co-authored-by: kangfenmao --- .../src/pages/home/Tabs/AssistantsTab.tsx | 160 +++++++++++++++++- .../pages/home/Tabs/components/AddButton.tsx | 24 +++ .../pages/home/Tabs/components/AgentItem.tsx | 159 ++++++++++------- .../home/Tabs/components/AgentSection.tsx | 39 ----- .../home/Tabs/components/AssistantItem.tsx | 143 +++++++++------- .../pages/home/Tabs/components/Sessions.tsx | 11 +- .../pages/home/Tabs/components/TagGroup.tsx | 63 +++++++ .../src/pages/home/Tabs/components/Topics.tsx | 35 +--- .../home/Tabs/components/UnifiedAddButton.tsx | 61 +++++++ .../home/Tabs/components/UnifiedList.tsx | 108 ++++++++++++ .../home/Tabs/components/UnifiedTagGroups.tsx | 132 +++++++++++++++ .../pages/home/Tabs/hooks/useActiveAgent.ts | 19 +++ .../home/Tabs/hooks/useUnifiedGrouping.ts | 140 +++++++++++++++ .../pages/home/Tabs/hooks/useUnifiedItems.ts | 73 ++++++++ .../home/Tabs/hooks/useUnifiedSorting.ts | 56 ++++++ src/renderer/src/store/assistants.ts | 8 +- 16 files changed, 1020 insertions(+), 211 deletions(-) create mode 100644 src/renderer/src/pages/home/Tabs/components/AddButton.tsx delete mode 100644 src/renderer/src/pages/home/Tabs/components/AgentSection.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/TagGroup.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/UnifiedList.tsx create mode 100644 src/renderer/src/pages/home/Tabs/components/UnifiedTagGroups.tsx create mode 100644 src/renderer/src/pages/home/Tabs/hooks/useActiveAgent.ts create mode 100644 src/renderer/src/pages/home/Tabs/hooks/useUnifiedGrouping.ts create mode 100644 src/renderer/src/pages/home/Tabs/hooks/useUnifiedItems.ts create mode 100644 src/renderer/src/pages/home/Tabs/hooks/useUnifiedSorting.ts diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 0fef9a968f..3d871d76ea 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -1,10 +1,26 @@ +import { Alert, Spinner } from '@heroui/react' import Scrollbar from '@renderer/components/Scrollbar' -import { Assistant } from '@renderer/types' -import { FC, useRef } from 'react' +import { useAgents } from '@renderer/hooks/agents/useAgents' +import { useAssistants } from '@renderer/hooks/useAssistant' +import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets' +import { useRuntime } from '@renderer/hooks/useRuntime' +import { useSettings } from '@renderer/hooks/useSettings' +import { useAssistantsTabSortType } from '@renderer/hooks/useStore' +import { useTags } from '@renderer/hooks/useTags' +import { useAppDispatch } from '@renderer/store' +import { addIknowAction } from '@renderer/store/runtime' +import { Assistant, AssistantsSortType } from '@renderer/types' +import { FC, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { AgentSection } from './components/AgentSection' -import Assistants from './components/Assistants' +import UnifiedAddButton from './components/UnifiedAddButton' +import { UnifiedList } from './components/UnifiedList' +import { UnifiedTagGroups } from './components/UnifiedTagGroups' +import { useActiveAgent } from './hooks/useActiveAgent' +import { useUnifiedGrouping } from './hooks/useUnifiedGrouping' +import { useUnifiedItems } from './hooks/useUnifiedItems' +import { useUnifiedSorting } from './hooks/useUnifiedSorting' interface AssistantsTabProps { activeAssistant: Assistant @@ -13,12 +29,143 @@ interface AssistantsTabProps { onCreateDefaultAssistant: () => void } +const ALERT_KEY = 'enable_api_server_to_use_agent' + const AssistantsTab: FC = (props) => { + const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props const containerRef = useRef(null) + const { t } = useTranslation() + const { apiServer } = useSettings() + const { iknow, chat } = useRuntime() + const dispatch = useAppDispatch() + + // Agent related hooks + const { agents, deleteAgent, isLoading: agentsLoading, error: agentsError } = useAgents() + const { activeAgentId } = chat + const { setActiveAgentId } = useActiveAgent() + + // Assistant related hooks + const { assistants, removeAssistant, copyAssistant, updateAssistants } = useAssistants() + const { addAssistantPreset } = useAssistantPresets() + const { collapsedTags, toggleTagCollapse } = useTags() + const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType() + const [dragging, setDragging] = useState(false) + + // Unified items management + const { unifiedItems, handleUnifiedListReorder } = useUnifiedItems({ + agents, + assistants, + apiServerEnabled: apiServer.enabled, + agentsLoading, + agentsError, + updateAssistants + }) + + // Sorting + const { sortByPinyinAsc, sortByPinyinDesc } = useUnifiedSorting({ + unifiedItems, + updateAssistants + }) + + // Grouping + const { groupedUnifiedItems, handleUnifiedGroupReorder } = useUnifiedGrouping({ + unifiedItems, + assistants, + agents, + apiServerEnabled: apiServer.enabled, + agentsLoading, + agentsError, + updateAssistants + }) + + useEffect(() => { + if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServer.enabled) { + setActiveAgentId(agents[0].id) + } + }, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServer.enabled]) + + const onDeleteAssistant = useCallback( + (assistant: Assistant) => { + const remaining = assistants.filter((a) => a.id !== assistant.id) + if (assistant.id === activeAssistant?.id) { + const newActive = remaining[remaining.length - 1] + newActive ? setActiveAssistant(newActive) : onCreateDefaultAssistant() + } + removeAssistant(assistant.id) + }, + [activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant] + ) + + const handleSortByChange = useCallback( + (sortType: AssistantsSortType) => { + setAssistantsTabSortType(sortType) + }, + [setAssistantsTabSortType] + ) + return ( - - + {!apiServer.enabled && !iknow[ALERT_KEY] && ( + { + dispatch(addIknowAction(ALERT_KEY)) + }} + /> + )} + + + + {agentsLoading && } + {apiServer.enabled && agentsError && } + + {assistantsTabSortType === 'tags' ? ( + setDragging(true)} + onDragEnd={() => setDragging(false)} + onToggleTagCollapse={toggleTagCollapse} + onAssistantSwitch={setActiveAssistant} + onAssistantDelete={onDeleteAssistant} + onAgentDelete={deleteAgent} + onAgentPress={setActiveAgentId} + addPreset={addAssistantPreset} + copyAssistant={copyAssistant} + onCreateDefaultAssistant={onCreateDefaultAssistant} + handleSortByChange={handleSortByChange} + sortByPinyinAsc={sortByPinyinAsc} + sortByPinyinDesc={sortByPinyinDesc} + /> + ) : ( + setDragging(true)} + onDragEnd={() => setDragging(false)} + onAssistantSwitch={setActiveAssistant} + onAssistantDelete={onDeleteAssistant} + onAgentDelete={deleteAgent} + onAgentPress={setActiveAgentId} + addPreset={addAssistantPreset} + copyAssistant={copyAssistant} + onCreateDefaultAssistant={onCreateDefaultAssistant} + handleSortByChange={handleSortByChange} + sortByPinyinAsc={sortByPinyinAsc} + sortByPinyinDesc={sortByPinyinDesc} + /> + )} + + {!dragging &&
}
) } @@ -27,7 +174,6 @@ const Container = styled(Scrollbar)` display: flex; flex-direction: column; padding: 10px; - margin-top: 3px; ` export default AssistantsTab diff --git a/src/renderer/src/pages/home/Tabs/components/AddButton.tsx b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx new file mode 100644 index 0000000000..1195710ae2 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx @@ -0,0 +1,24 @@ +import { Button, ButtonProps, cn } from '@heroui/react' +import { PlusIcon } from 'lucide-react' +import { FC } from 'react' + +interface Props extends ButtonProps { + children: React.ReactNode +} + +const AddButton: FC = ({ children, className, ...props }) => { + return ( + + ) +} + +export default AddButton diff --git a/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx b/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx index a2057cc995..80c4b6db9d 100644 --- a/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx @@ -1,11 +1,14 @@ -import { Button, Chip, cn } from '@heroui/react' +import { cn } from '@heroui/react' import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import { useSessions } from '@renderer/hooks/agents/useSessions' +import { useSettings } from '@renderer/hooks/useSettings' import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup' import { AgentLabel } from '@renderer/pages/settings/AgentSettings/shared' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { AgentEntity } from '@renderer/types' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu' -import { FC, memo } from 'react' +import { Bot } from 'lucide-react' +import { FC, memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' // const logger = loggerService.withContext('AgentItem') @@ -20,81 +23,107 @@ interface AgentItemProps { const AgentItem: FC = ({ agent, isActive, onDelete, onPress }) => { const { t } = useTranslation() const { sessions } = useSessions(agent.id) + const { clickAssistantToShowTopic, topicPosition } = useSettings() + + const handlePress = useCallback(() => { + // Show session sidebar if setting is enabled (reusing the assistant setting for consistency) + if (clickAssistantToShowTopic) { + if (topicPosition === 'left') { + EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR) + } + } + onPress() + }, [clickAssistantToShowTopic, topicPosition, onPress]) return ( - <> - - - - + + + + + - {isActive && ( - - {sessions.length} - - )} - - - - - { - // onOpen() - await AgentSettingsPopup.show({ - agentId: agent.id - }) - }}> - - {t('common.edit')} - - { - window.modal.confirm({ - title: t('agent.delete.title'), - content: t('agent.delete.content'), - centered: true, - okButtonProps: { danger: true }, - onOk: () => onDelete(agent) - }) - }}> - - {t('common.delete')} - - - - {/* */} - + + + + {isActive ? {sessions.length} : } + + + + + { + // onOpen() + await AgentSettingsPopup.show({ + agentId: agent.id + }) + }}> + + {t('common.edit')} + + { + window.modal.confirm({ + title: t('agent.delete.title'), + content: t('agent.delete.content'), + centered: true, + okButtonProps: { danger: true }, + onOk: () => onDelete(agent) + }) + }}> + + {t('common.delete')} + + + ) } -const ButtonContainer: React.FC> = ({ className, children, ...props }) => ( - + )} + {...props} + /> ) -const AssistantNameRow: React.FC> = ({ className, ...props }) => ( +export const AssistantNameRow: React.FC> = ({ className, ...props }) => (
+) + +export const AgentNameWrapper: React.FC> = ({ className, ...props }) => ( +
+) + +export const MenuButton: React.FC> = ({ className, ...props }) => ( +
+) + +export const SessionCount: React.FC> = ({ className, ...props }) => ( +
) diff --git a/src/renderer/src/pages/home/Tabs/components/AgentSection.tsx b/src/renderer/src/pages/home/Tabs/components/AgentSection.tsx deleted file mode 100644 index 14b51e78d8..0000000000 --- a/src/renderer/src/pages/home/Tabs/components/AgentSection.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Alert } from '@heroui/react' -import { useRuntime } from '@renderer/hooks/useRuntime' -import { useSettings } from '@renderer/hooks/useSettings' -import { useAppDispatch } from '@renderer/store' -import { addIknowAction } from '@renderer/store/runtime' -import { useTranslation } from 'react-i18next' - -import { Agents } from './Agents' -import { SectionName } from './SectionName' - -const ALERT_KEY = 'enable_api_server_to_use_agent' - -export const AgentSection = () => { - const { t } = useTranslation() - const { apiServer } = useSettings() - const { iknow } = useRuntime() - const dispatch = useAppDispatch() - - if (!apiServer.enabled) { - if (iknow[ALERT_KEY]) return null - return ( - { - dispatch(addIknowAction(ALERT_KEY)) - }} - /> - ) - } - - return ( -
- - -
- ) -} diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx index 3dd1bbfd15..a6abd960c7 100644 --- a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx @@ -1,3 +1,4 @@ +import { cn } from '@heroui/react' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import EmojiIcon from '@renderer/components/EmojiIcon' import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' @@ -28,9 +29,8 @@ import { Tag, Tags } from 'lucide-react' -import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react' +import { FC, memo, PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' import * as tinyPinyin from 'tiny-pinyin' import AssistantTagsPopup from './AssistantTagsPopup' @@ -46,6 +46,8 @@ interface AssistantItemProps { copyAssistant: (assistant: Assistant) => void onTagClick?: (tag: string) => void handleSortByChange?: (sortType: AssistantsSortType) => void + sortByPinyinAsc?: () => void + sortByPinyinDesc?: () => void } const AssistantItem: FC = ({ @@ -56,7 +58,9 @@ const AssistantItem: FC = ({ onDelete, addPreset, copyAssistant, - handleSortByChange + handleSortByChange, + sortByPinyinAsc: externalSortByPinyinAsc, + sortByPinyinDesc: externalSortByPinyinDesc }) => { const { t } = useTranslation() const { allTags } = useTags() @@ -78,14 +82,19 @@ const AssistantItem: FC = ({ setIsPending(hasPending) }, [isActive, assistant.topics]) - const sortByPinyinAsc = useCallback(() => { + // Local sort functions + const localSortByPinyinAsc = useCallback(() => { updateAssistants(sortAssistantsByPinyin(assistants, true)) }, [assistants, updateAssistants]) - const sortByPinyinDesc = useCallback(() => { + const localSortByPinyinDesc = useCallback(() => { updateAssistants(sortAssistantsByPinyin(assistants, false)) }, [assistants, updateAssistants]) + // Use external sort functions if provided, otherwise use local ones + const sortByPinyinAsc = externalSortByPinyinAsc || localSortByPinyinAsc + const sortByPinyinDesc = externalSortByPinyinDesc || localSortByPinyinDesc + const menuItems = useMemo( () => getMenuItems({ @@ -145,7 +154,7 @@ const AssistantItem: FC = ({ menu={{ items: menuItems }} trigger={['contextMenu']} popupRender={(menu) =>
e.stopPropagation()}>{menu}
}> - + {assistantIconType === 'model' ? ( >) => ( +
+ {children} +
+) - &:hover { - background-color: var(--color-list-item-hover); - } - &.active { - background-color: var(--color-list-item); - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - } -` +const AssistantNameRow = ({ + children, + className, + ...props +}: PropsWithChildren<{} & React.HTMLAttributes>) => ( +
+ {children} +
+) -const AssistantNameRow = styled.div` - color: var(--color-text); - font-size: 13px; - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; -` +const AssistantName = ({ + children, + className, + ...props +}: PropsWithChildren<{} & React.HTMLAttributes>) => ( +
+ {children} +
+) -const AssistantName = styled.div` - font-size: 13px; -` +const MenuButton = ({ + children, + className, + ...props +}: PropsWithChildren<{} & React.HTMLAttributes>) => ( +
+ {children} +
+) -const MenuButton = styled.div` - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - min-width: 22px; - height: 22px; - min-height: 22px; - border-radius: 11px; - position: absolute; - background-color: var(--color-background); - right: 9px; - top: 6px; - padding: 0 5px; - border: 0.5px solid var(--color-border); -` - -const TopicCount = styled.div` - color: var(--color-text); - font-size: 10px; - border-radius: 10px; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; -` +const TopicCount = ({ + children, + className, + ...props +}: PropsWithChildren<{} & React.HTMLAttributes>) => ( +
+ {children} +
+) export default memo(AssistantItem) diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index f2e7d8f65d..8f7d9aa941 100644 --- a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx @@ -1,4 +1,4 @@ -import { Alert, Button, Spinner } from '@heroui/react' +import { Alert, Spinner } from '@heroui/react' import { DynamicVirtualList } from '@renderer/components/VirtualList' import { useAgent } from '@renderer/hooks/agents/useAgent' import { useSessions } from '@renderer/hooks/agents/useSessions' @@ -13,10 +13,10 @@ import { import { CreateSessionForm } from '@renderer/types' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { AnimatePresence, motion } from 'framer-motion' -import { Plus } from 'lucide-react' import { memo, useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import AddButton from './AddButton' import SessionItem from './SessionItem' // const logger = loggerService.withContext('SessionsTab') @@ -115,12 +115,9 @@ const Sessions: React.FC = ({ agentId }) => { transition={{ duration: 0.3 }} className="sessions-tab flex h-full w-full flex-col p-2"> - + {/* h-9 */} diff --git a/src/renderer/src/pages/home/Tabs/components/TagGroup.tsx b/src/renderer/src/pages/home/Tabs/components/TagGroup.tsx new file mode 100644 index 0000000000..6f751e65d0 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/TagGroup.tsx @@ -0,0 +1,63 @@ +import { DownOutlined, RightOutlined } from '@ant-design/icons' +import { cn } from '@heroui/react' +import { Tooltip } from 'antd' +import { FC, ReactNode } from 'react' + +interface TagGroupProps { + tag: string + isCollapsed: boolean + onToggle: (tag: string) => void + showTitle?: boolean + children: ReactNode +} + +export const TagGroup: FC = ({ tag, isCollapsed, onToggle, showTitle = true, children }) => { + return ( + + {showTitle && ( + onToggle(tag)}> + + + {isCollapsed ? ( + + ) : ( + + )} + {tag} + + + + + )} + {!isCollapsed &&
{children}
} +
+ ) +} + +const TagsContainer: FC> = ({ children, ...props }) => ( +
+ {children} +
+) + +const GroupTitle: FC> = ({ children, ...props }) => ( +
+ {children} +
+) + +const GroupTitleName: FC> = ({ children, ...props }) => ( +
+ {children} +
+) + +const GroupTitleDivider: FC> = (props) => ( +
+) diff --git a/src/renderer/src/pages/home/Tabs/components/Topics.tsx b/src/renderer/src/pages/home/Tabs/components/Topics.tsx index dc699395b7..d7214ff450 100644 --- a/src/renderer/src/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Topics.tsx @@ -41,7 +41,6 @@ import { PackagePlus, PinIcon, PinOffIcon, - PlusIcon, Save, Sparkles, UploadIcon, @@ -52,6 +51,8 @@ import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import styled from 'styled-components' +import AddButton from './AddButton' + interface Props { assistant: Assistant activeTopic: Topic @@ -497,13 +498,12 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se className="topics-tab" list={sortedTopics} onUpdate={updateTopics} - style={{ height: '100%', padding: '13px 0 10px 10px' }} + style={{ height: '100%', padding: '11px 0 10px 10px' }} itemContainerStyle={{ paddingBottom: '8px' }} header={ - EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> - + EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} className="mb-2"> {t('chat.add.topic.title')} - + }> {(topic) => { const isActive = topic.id === activeTopic?.id @@ -740,31 +740,6 @@ const FulfilledIndicator = styled.div.attrs({ 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; diff --git a/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx b/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx new file mode 100644 index 0000000000..91af2219a7 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx @@ -0,0 +1,61 @@ +import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react' +import { AgentModal } from '@renderer/components/Popups/agent/AgentModal' +import { Bot, MessageSquare } from 'lucide-react' +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import AddButton from './AddButton' + +interface UnifiedAddButtonProps { + onCreateAssistant: () => void +} + +const UnifiedAddButton: FC = ({ onCreateAssistant }) => { + const { t } = useTranslation() + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const [isAgentModalOpen, setIsAgentModalOpen] = useState(false) + + const handleAddAssistant = () => { + setIsPopoverOpen(false) + onCreateAssistant() + } + + const handleAddAgent = () => { + setIsPopoverOpen(false) + setIsAgentModalOpen(true) + } + + return ( +
+ + + {t('chat.add.assistant.title')} + + +
+ + +
+
+
+ + setIsAgentModalOpen(false)} /> +
+ ) +} + +export default UnifiedAddButton diff --git a/src/renderer/src/pages/home/Tabs/components/UnifiedList.tsx b/src/renderer/src/pages/home/Tabs/components/UnifiedList.tsx new file mode 100644 index 0000000000..2d8cef69e5 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/UnifiedList.tsx @@ -0,0 +1,108 @@ +import { DraggableList } from '@renderer/components/DraggableList' +import { Assistant, AssistantsSortType } from '@renderer/types' +import { FC, useCallback } from 'react' + +import { UnifiedItem } from '../hooks/useUnifiedItems' +import AgentItem from './AgentItem' +import AssistantItem from './AssistantItem' + +interface UnifiedListProps { + items: UnifiedItem[] + activeAssistantId: string + activeAgentId: string | null + sortBy: AssistantsSortType + onReorder: (newList: UnifiedItem[]) => void + onDragStart: () => void + onDragEnd: () => void + onAssistantSwitch: (assistant: Assistant) => void + onAssistantDelete: (assistant: Assistant) => void + onAgentDelete: (agentId: string) => void + onAgentPress: (agentId: string) => void + addPreset: (assistant: Assistant) => void + copyAssistant: (assistant: Assistant) => void + onCreateDefaultAssistant: () => void + handleSortByChange: (sortType: AssistantsSortType) => void + sortByPinyinAsc: () => void + sortByPinyinDesc: () => void +} + +export const UnifiedList: FC = (props) => { + const { + items, + activeAssistantId, + activeAgentId, + sortBy, + onReorder, + onDragStart, + onDragEnd, + onAssistantSwitch, + onAssistantDelete, + onAgentDelete, + onAgentPress, + addPreset, + copyAssistant, + onCreateDefaultAssistant, + handleSortByChange, + sortByPinyinAsc, + sortByPinyinDesc + } = props + + const renderUnifiedItem = useCallback( + (item: UnifiedItem) => { + if (item.type === 'agent') { + return ( + onAgentDelete(item.data.id)} + onPress={() => onAgentPress(item.data.id)} + /> + ) + } else { + return ( + + ) + } + }, + [ + activeAgentId, + activeAssistantId, + sortBy, + onAssistantSwitch, + onAssistantDelete, + onAgentDelete, + onAgentPress, + addPreset, + copyAssistant, + onCreateDefaultAssistant, + handleSortByChange, + sortByPinyinAsc, + sortByPinyinDesc + ] + ) + + return ( + `${item.type}-${item.data.id}`} + onUpdate={onReorder} + onDragStart={onDragStart} + onDragEnd={onDragEnd}> + {renderUnifiedItem} + + ) +} diff --git a/src/renderer/src/pages/home/Tabs/components/UnifiedTagGroups.tsx b/src/renderer/src/pages/home/Tabs/components/UnifiedTagGroups.tsx new file mode 100644 index 0000000000..6c3b1fe9a1 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/UnifiedTagGroups.tsx @@ -0,0 +1,132 @@ +import { DraggableList } from '@renderer/components/DraggableList' +import { Assistant, AssistantsSortType } from '@renderer/types' +import { FC, useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +import { UnifiedItem } from '../hooks/useUnifiedItems' +import AgentItem from './AgentItem' +import AssistantItem from './AssistantItem' +import { TagGroup } from './TagGroup' + +interface GroupedItems { + tag: string + items: UnifiedItem[] +} + +interface UnifiedTagGroupsProps { + groupedItems: GroupedItems[] + activeAssistantId: string + activeAgentId: string | null + sortBy: AssistantsSortType + collapsedTags: Record + onGroupReorder: (tag: string, newList: UnifiedItem[]) => void + onDragStart: () => void + onDragEnd: () => void + onToggleTagCollapse: (tag: string) => void + onAssistantSwitch: (assistant: Assistant) => void + onAssistantDelete: (assistant: Assistant) => void + onAgentDelete: (agentId: string) => void + onAgentPress: (agentId: string) => void + addPreset: (assistant: Assistant) => void + copyAssistant: (assistant: Assistant) => void + onCreateDefaultAssistant: () => void + handleSortByChange: (sortType: AssistantsSortType) => void + sortByPinyinAsc: () => void + sortByPinyinDesc: () => void +} + +export const UnifiedTagGroups: FC = (props) => { + const { + groupedItems, + activeAssistantId, + activeAgentId, + sortBy, + collapsedTags, + onGroupReorder, + onDragStart, + onDragEnd, + onToggleTagCollapse, + onAssistantSwitch, + onAssistantDelete, + onAgentDelete, + onAgentPress, + addPreset, + copyAssistant, + onCreateDefaultAssistant, + handleSortByChange, + sortByPinyinAsc, + sortByPinyinDesc + } = props + + const { t } = useTranslation() + + const renderUnifiedItem = useCallback( + (item: UnifiedItem) => { + if (item.type === 'agent') { + return ( + onAgentDelete(item.data.id)} + onPress={() => onAgentPress(item.data.id)} + /> + ) + } else { + return ( + + ) + } + }, + [ + activeAgentId, + activeAssistantId, + sortBy, + onAssistantSwitch, + onAssistantDelete, + onAgentDelete, + onAgentPress, + addPreset, + copyAssistant, + onCreateDefaultAssistant, + handleSortByChange, + sortByPinyinAsc, + sortByPinyinDesc + ] + ) + + return ( +
+ {groupedItems.map((group) => ( + + `${item.type}-${item.data.id}`} + onUpdate={(newList) => onGroupReorder(group.tag, newList)} + onDragStart={onDragStart} + onDragEnd={onDragEnd}> + {renderUnifiedItem} + + + ))} +
+ ) +} diff --git a/src/renderer/src/pages/home/Tabs/hooks/useActiveAgent.ts b/src/renderer/src/pages/home/Tabs/hooks/useActiveAgent.ts new file mode 100644 index 0000000000..8f65af437d --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/hooks/useActiveAgent.ts @@ -0,0 +1,19 @@ +import { useAgentSessionInitializer } from '@renderer/hooks/agents/useAgentSessionInitializer' +import { useAppDispatch } from '@renderer/store' +import { setActiveAgentId as setActiveAgentIdAction } from '@renderer/store/runtime' +import { useCallback } from 'react' + +export const useActiveAgent = () => { + const dispatch = useAppDispatch() + const { initializeAgentSession } = useAgentSessionInitializer() + + const setActiveAgentId = useCallback( + async (id: string) => { + dispatch(setActiveAgentIdAction(id)) + await initializeAgentSession(id) + }, + [dispatch, initializeAgentSession] + ) + + return { setActiveAgentId } +} diff --git a/src/renderer/src/pages/home/Tabs/hooks/useUnifiedGrouping.ts b/src/renderer/src/pages/home/Tabs/hooks/useUnifiedGrouping.ts new file mode 100644 index 0000000000..8ea07f57e1 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/hooks/useUnifiedGrouping.ts @@ -0,0 +1,140 @@ +import { useAppDispatch } from '@renderer/store' +import { setUnifiedListOrder } from '@renderer/store/assistants' +import { AgentEntity, Assistant } from '@renderer/types' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { UnifiedItem } from './useUnifiedItems' + +interface UseUnifiedGroupingOptions { + unifiedItems: UnifiedItem[] + assistants: Assistant[] + agents: AgentEntity[] + apiServerEnabled: boolean + agentsLoading: boolean + agentsError: Error | null + updateAssistants: (assistants: Assistant[]) => void +} + +export const useUnifiedGrouping = (options: UseUnifiedGroupingOptions) => { + const { unifiedItems, assistants, agents, apiServerEnabled, agentsLoading, agentsError, updateAssistants } = options + const { t } = useTranslation() + const dispatch = useAppDispatch() + + // Group unified items by tags + const groupedUnifiedItems = useMemo(() => { + const groups = new Map() + + unifiedItems.forEach((item) => { + if (item.type === 'agent') { + // Agents go to untagged group + const groupKey = t('assistants.tags.untagged') + if (!groups.has(groupKey)) { + groups.set(groupKey, []) + } + groups.get(groupKey)!.push(item) + } else { + // Assistants use their tags + const tags = item.data.tags?.length ? item.data.tags : [t('assistants.tags.untagged')] + tags.forEach((tag) => { + if (!groups.has(tag)) { + groups.set(tag, []) + } + groups.get(tag)!.push(item) + }) + } + }) + + // Sort groups: untagged first, then tagged groups + const untaggedKey = t('assistants.tags.untagged') + const sortedGroups = Array.from(groups.entries()).sort(([tagA], [tagB]) => { + if (tagA === untaggedKey) return -1 + if (tagB === untaggedKey) return 1 + return 0 + }) + + return sortedGroups.map(([tag, items]) => ({ tag, items })) + }, [unifiedItems, t]) + + const handleUnifiedGroupReorder = useCallback( + (tag: string, newGroupList: UnifiedItem[]) => { + // Extract only assistants from the new list for updating + const newAssistants = newGroupList.filter((item) => item.type === 'assistant').map((item) => item.data) + + // Update assistants state + let insertIndex = 0 + const updatedAssistants = assistants.map((a) => { + const tags = a.tags?.length ? a.tags : [t('assistants.tags.untagged')] + if (tags.includes(tag)) { + const replaced = newAssistants[insertIndex] + insertIndex += 1 + return replaced || a + } + return a + }) + updateAssistants(updatedAssistants) + + // Rebuild unified order and save to Redux + const newUnifiedItems: UnifiedItem[] = [] + const availableAgents = new Map() + const availableAssistants = new Map() + + if (apiServerEnabled && !agentsLoading && !agentsError) { + agents.forEach((agent) => availableAgents.set(agent.id, agent)) + } + updatedAssistants.forEach((assistant) => availableAssistants.set(assistant.id, assistant)) + + // Reconstruct order based on current groupedUnifiedItems structure + groupedUnifiedItems.forEach((group) => { + if (group.tag === tag) { + // Use the new group list for this tag + newGroupList.forEach((item) => { + newUnifiedItems.push(item) + if (item.type === 'agent') { + availableAgents.delete(item.data.id) + } else { + availableAssistants.delete(item.data.id) + } + }) + } else { + // Keep existing order for other tags + group.items.forEach((item) => { + newUnifiedItems.push(item) + if (item.type === 'agent') { + availableAgents.delete(item.data.id) + } else { + availableAssistants.delete(item.data.id) + } + }) + } + }) + + // Add any remaining items + availableAgents.forEach((agent) => newUnifiedItems.push({ type: 'agent', data: agent })) + availableAssistants.forEach((assistant) => newUnifiedItems.push({ type: 'assistant', data: assistant })) + + // Save to Redux + const orderToSave = newUnifiedItems.map((item) => ({ + type: item.type, + id: item.data.id + })) + dispatch(setUnifiedListOrder(orderToSave)) + }, + [ + assistants, + t, + updateAssistants, + apiServerEnabled, + agentsLoading, + agentsError, + agents, + groupedUnifiedItems, + dispatch + ] + ) + + return { + groupedUnifiedItems, + handleUnifiedGroupReorder + } +} diff --git a/src/renderer/src/pages/home/Tabs/hooks/useUnifiedItems.ts b/src/renderer/src/pages/home/Tabs/hooks/useUnifiedItems.ts new file mode 100644 index 0000000000..09c5cc7bb8 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/hooks/useUnifiedItems.ts @@ -0,0 +1,73 @@ +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { setUnifiedListOrder } from '@renderer/store/assistants' +import { AgentEntity, Assistant } from '@renderer/types' +import { useCallback, useMemo } from 'react' + +export type UnifiedItem = { type: 'agent'; data: AgentEntity } | { type: 'assistant'; data: Assistant } + +interface UseUnifiedItemsOptions { + agents: AgentEntity[] + assistants: Assistant[] + apiServerEnabled: boolean + agentsLoading: boolean + agentsError: Error | null + updateAssistants: (assistants: Assistant[]) => void +} + +export const useUnifiedItems = (options: UseUnifiedItemsOptions) => { + const { agents, assistants, apiServerEnabled, agentsLoading, agentsError, updateAssistants } = options + const dispatch = useAppDispatch() + const unifiedListOrder = useAppSelector((state) => state.assistants.unifiedListOrder || []) + + // Create unified items list (agents + assistants) with saved order + const unifiedItems = useMemo(() => { + const items: UnifiedItem[] = [] + + // Collect all available items + const availableAgents = new Map() + const availableAssistants = new Map() + + if (apiServerEnabled && !agentsLoading && !agentsError) { + agents.forEach((agent) => availableAgents.set(agent.id, agent)) + } + assistants.forEach((assistant) => availableAssistants.set(assistant.id, assistant)) + + // Apply saved order + unifiedListOrder.forEach((item) => { + if (item.type === 'agent' && availableAgents.has(item.id)) { + items.push({ type: 'agent', data: availableAgents.get(item.id)! }) + availableAgents.delete(item.id) + } else if (item.type === 'assistant' && availableAssistants.has(item.id)) { + items.push({ type: 'assistant', data: availableAssistants.get(item.id)! }) + availableAssistants.delete(item.id) + } + }) + + // Add new items (not in saved order) to the end + availableAgents.forEach((agent) => items.push({ type: 'agent', data: agent })) + availableAssistants.forEach((assistant) => items.push({ type: 'assistant', data: assistant })) + + return items + }, [agents, assistants, apiServerEnabled, agentsLoading, agentsError, unifiedListOrder]) + + const handleUnifiedListReorder = useCallback( + (newList: UnifiedItem[]) => { + // Save the unified order to Redux + const orderToSave = newList.map((item) => ({ + type: item.type, + id: item.data.id + })) + dispatch(setUnifiedListOrder(orderToSave)) + + // Extract and update assistants order + const newAssistants = newList.filter((item) => item.type === 'assistant').map((item) => item.data) + updateAssistants(newAssistants) + }, + [dispatch, updateAssistants] + ) + + return { + unifiedItems, + handleUnifiedListReorder + } +} diff --git a/src/renderer/src/pages/home/Tabs/hooks/useUnifiedSorting.ts b/src/renderer/src/pages/home/Tabs/hooks/useUnifiedSorting.ts new file mode 100644 index 0000000000..317376fa0d --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/hooks/useUnifiedSorting.ts @@ -0,0 +1,56 @@ +import { useAppDispatch } from '@renderer/store' +import { setUnifiedListOrder } from '@renderer/store/assistants' +import { Assistant } from '@renderer/types' +import { useCallback } from 'react' +import * as tinyPinyin from 'tiny-pinyin' + +import { UnifiedItem } from './useUnifiedItems' + +interface UseUnifiedSortingOptions { + unifiedItems: UnifiedItem[] + updateAssistants: (assistants: Assistant[]) => void +} + +export const useUnifiedSorting = (options: UseUnifiedSortingOptions) => { + const { unifiedItems, updateAssistants } = options + const dispatch = useAppDispatch() + + const sortUnifiedItemsByPinyin = useCallback((items: UnifiedItem[], isAscending: boolean) => { + return [...items].sort((a, b) => { + const nameA = a.type === 'agent' ? a.data.name || a.data.id : a.data.name + const nameB = b.type === 'agent' ? b.data.name || b.data.id : b.data.name + const pinyinA = tinyPinyin.convertToPinyin(nameA, '', true) + const pinyinB = tinyPinyin.convertToPinyin(nameB, '', true) + return isAscending ? pinyinA.localeCompare(pinyinB) : pinyinB.localeCompare(pinyinA) + }) + }, []) + + const sortByPinyinAsc = useCallback(() => { + const sorted = sortUnifiedItemsByPinyin(unifiedItems, true) + const orderToSave = sorted.map((item) => ({ + type: item.type, + id: item.data.id + })) + dispatch(setUnifiedListOrder(orderToSave)) + // Also update assistants order + const newAssistants = sorted.filter((item) => item.type === 'assistant').map((item) => item.data) + updateAssistants(newAssistants) + }, [unifiedItems, sortUnifiedItemsByPinyin, dispatch, updateAssistants]) + + const sortByPinyinDesc = useCallback(() => { + const sorted = sortUnifiedItemsByPinyin(unifiedItems, false) + const orderToSave = sorted.map((item) => ({ + type: item.type, + id: item.data.id + })) + dispatch(setUnifiedListOrder(orderToSave)) + // Also update assistants order + const newAssistants = sorted.filter((item) => item.type === 'assistant').map((item) => item.data) + updateAssistants(newAssistants) + }, [unifiedItems, sortUnifiedItemsByPinyin, dispatch, updateAssistants]) + + return { + sortByPinyinAsc, + sortByPinyinDesc + } +} diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts index 0e5dd3b30e..6fa570be17 100644 --- a/src/renderer/src/store/assistants.ts +++ b/src/renderer/src/store/assistants.ts @@ -13,6 +13,7 @@ export interface AssistantsState { tagsOrder: string[] collapsedTags: Record presets: AssistantPreset[] + unifiedListOrder: Array<{ type: 'agent' | 'assistant'; id: string }> } const initialState: AssistantsState = { @@ -20,7 +21,8 @@ const initialState: AssistantsState = { assistants: [getDefaultAssistant()], tagsOrder: [], collapsedTags: {}, - presets: [] + presets: [], + unifiedListOrder: [] } const assistantsSlice = createSlice({ @@ -96,6 +98,9 @@ const assistantsSlice = createSlice({ [tag]: !prev[tag] } }, + setUnifiedListOrder: (state, action: PayloadAction>) => { + state.unifiedListOrder = action.payload + }, addTopic: (state, action: PayloadAction<{ assistantId: string; topic: Topic }>) => { const topic = action.payload.topic topic.createdAt = topic.createdAt || new Date().toISOString() @@ -244,6 +249,7 @@ export const { setTagsOrder, updateAssistantSettings, updateTagCollapse, + setUnifiedListOrder, setAssistantPresets, addAssistantPreset, removeAssistantPreset,