feat: separate Agent into independent module with dedicated page and route (#13420)

### What this PR does

Before this PR:
Agent and Assistant were mixed in a single `UnifiedList` in the home
page sidebar. Users had difficulty discovering the Agent entry. The two
have fundamentally different data models (Redux vs SQLite), interaction
patterns (topic vs session), and component hierarchies, yet shared the
same UI components and Redux state (`activeTopicOrSession`).

After this PR:
Agent is separated into an independent module with its own sidebar
entry, route (`/agents`), page, navbar, and side panel. The home page
only handles Assistants. Navigation between the two is handled via
sidebar icons and routes, not Redux state.

Fixes #13329

### Why we need it and why it was done in this way

The following tradeoffs were made:
- `AgentSettingsTab` was moved to `pages/agents/` and is used by the
`AgentChatNavbar/Tools/SettingsButton` drawer.
- `Unified*` components were renamed to `Assistant*` (via `git mv`)
rather than just removing agent code, to make naming accurate and
improve readability.
- `activeTopicOrSession` was removed from Redux runtime store entirely,
replaced by route-based detection where needed, avoiding stale state
issues.

The following alternatives were considered:
- Keeping a shared `UnifiedList` with a filter — rejected because the
data models and interaction patterns are too different, leading to
excessive conditional logic.
- Using a tab within the home page — rejected per issue spec; agents
need their own sidebar entry and dedicated page for discoverability.

### Breaking changes

None. This is an internal UI refactoring. The Redux migration (v200 →
v201) automatically adds the `'agents'` sidebar icon to existing user
configurations.

### Special notes for your reviewer

**New directory structure — `pages/agents/`:**
- `AgentPage.tsx` — Top-level page with apiServer.enabled guard (early
return with alert when server disabled)
- `AgentChat.tsx` — Chat area with loading state management (Spin during
initialization, alerts for select/create states)
- `AgentNavbar.tsx` — Top navbar for left-sidebar layout mode
- `AgentSidePanel.tsx` — Left panel with agent list and sessions tabs
- `AgentSettingsTab.tsx` — Moved from home, used by AgentChatNavbar
settings drawer
- `components/AgentChatNavbar/` — Mirrors `home/components/ChatNavBar/`
structure:
  - `index.tsx` — Sidebar toggle buttons + content wrapper
- `AgentContent.tsx` — Agent → Session → Model → Workspace breadcrumb
navigation
- `Tools/` — Agent-specific toolbar with `SettingsButton` (uses
`AgentSettingsTab`)
- `OpenExternalAppButton.tsx`, `SessionWorkspaceMeta.tsx` —
AgentContent-only components
- `components/` — Migrated agent-only components: `AgentItem`,
`AgentSessionInputbar`, `AgentSessionMessages`,
`SelectAgentBaseModelButton`, `SessionItem`, `Sessions`

**Hook consolidation:**
- Merged two duplicate `useActiveAgent` hooks into one at
`hooks/agents/useActiveAgent` — now returns both `{ agent, error,
isLoading, setActiveAgentId }`
- Deleted `home/Tabs/hooks/useActiveAgent.ts`

**Loading state improvements:**
- `AgentPage` top-level guard prevents all child components from
rendering when apiServer is disabled
- `AgentChat` tracks `isInitializing` covering: server starting → agents
loading → agent loading → session initializing → auto-select pending
- `useAgentSessionInitializer` now distinguishes `undefined` (not
initialized) vs `null` (initialized, no sessions) in
`activeSessionIdMap`, preventing premature "create session" alerts

**Shared component extraction:**
- `AddButton` moved from `home/Tabs/components/` to
`renderer/src/components/` (used by both pages)

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: You have left the code cleaner than you found it (Boy
Scout Rule)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [x] Documentation: A user-guide update was considered and is present
(link) or not required. Check this only when the PR introduces or
changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code before requesting review
from others

### Release note

```release-note
Added a dedicated "Agents" page accessible from the sidebar, separating Agents from the Assistants list for improved discoverability and clearer navigation.
```

---------

Signed-off-by: icarus <eurfelux@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
Phantom
2026-03-14 17:05:22 +08:00
committed by GitHub
parent ea694c630c
commit 9c3c990365
78 changed files with 1490 additions and 990 deletions

1
.gitignore vendored
View File

@@ -64,6 +64,7 @@ CLAUDE.local.md
coverage
.vitest-cache
vitest.config.*.timestamp-*
.context/vitest-temp/
# TypeScript incremental build
.tsbuildinfo

View File

@@ -9,6 +9,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import TabsContainer from './components/Tab/TabContainer'
import NavigationHandler from './handler/NavigationHandler'
import { useNavbarPosition } from './hooks/useSettings'
import AgentPage from './pages/agents/AgentPage'
import CodeToolsPage from './pages/code/CodeToolsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
@@ -31,6 +32,7 @@ const Router: FC = () => {
<ErrorBoundary>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentPage />} />
<Route path="/store" element={<AssistantPresetsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />

View File

@@ -7,7 +7,7 @@ import { permissionModeCards } from '@renderer/config/agent'
import { isWin } from '@renderer/config/constant'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton'
import SelectAgentBaseModelButton from '@renderer/pages/agents/components/SelectAgentBaseModelButton'
import type {
AddAgentForm,
AgentEntity,

View File

@@ -28,6 +28,7 @@ import {
LayoutGrid,
Monitor,
Moon,
MousePointerClick,
NotepadText,
Palette,
Settings,
@@ -86,9 +87,12 @@ const getTabIcon = (
return <LayoutGrid size={14} />
}
// TODO: Add TabId as type instead of string
switch (tabId) {
case 'home':
return <Home size={14} />
case 'agents':
return <MousePointerClick size={14} />
case 'store':
return <Sparkle size={14} />
case 'translate':
@@ -240,35 +244,38 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
gap={'6px'}
onSortEnd={onSortEnd}
className="tabs-sortable"
renderItem={(tab) => (
<Tab
key={tab.id}
active={tab.id === activeTabId}
onClick={() => handleTabClick(tab)}
onAuxClick={(e) => {
if (e.button === 1 && tab.id !== 'home') {
e.preventDefault()
e.stopPropagation()
closeTab(tab.id)
}
}}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
</TabHeader>
{tab.id !== 'home' && (
<CloseButton
className="close-button"
data-no-dnd
onClick={(e) => {
renderItem={(tab) => {
const isClosable = tab.id !== 'home' && tab.id !== 'agents'
return (
<Tab
key={tab.id}
active={tab.id === activeTabId}
onClick={() => handleTabClick(tab)}
onAuxClick={(e) => {
if (e.button === 1 && isClosable) {
e.preventDefault()
e.stopPropagation()
closeTab(tab.id)
}}>
<X size={12} />
</CloseButton>
)}
</Tab>
)}
}
}}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
</TabHeader>
{isClosable && (
<CloseButton
className="close-button"
data-no-dnd
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}>
<X size={12} />
</CloseButton>
)}
</Tab>
)
}}
/>
<AddTabButton onClick={handleAddTab} className={classNames({ active: activeTabId === 'launchpad' })}>
<PlusOutlined />

View File

@@ -22,6 +22,7 @@ import {
MessageSquare,
Monitor,
Moon,
MousePointerClick,
NotepadText,
Palette,
Settings,
@@ -126,10 +127,11 @@ const MainMenus: FC = () => {
const { theme } = useTheme()
const isRoute = (path: string): string => (pathname === path && !minappShow ? 'active' : '')
const isRoutes = (path: string): string => (pathname.startsWith(path) && !minappShow ? 'active' : '')
const isRoutes = (path: string): string => (pathname.startsWith(path) && path !== '/' && !minappShow ? 'active' : '')
const iconMap = {
assistants: <MessageSquare size={18} className="icon" />,
agents: <MousePointerClick size={18} className="icon" />,
store: <Sparkle size={18} className="icon" />,
paintings: <Palette size={18} className="icon" />,
translate: <Languages size={18} className="icon" />,
@@ -143,6 +145,7 @@ const MainMenus: FC = () => {
const pathMap = {
assistants: '/',
agents: '/agents',
store: '/store',
paintings: `/paintings/${defaultPaintingProvider}`,
translate: '/translate',

View File

@@ -6,6 +6,7 @@ import type { SidebarIcon } from '@renderer/types'
*/
export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
'assistants',
'agents',
'store',
'paintings',
'translate',

View File

@@ -1,8 +1,24 @@
import { useAppDispatch } from '@renderer/store'
import { setActiveAgentId as setActiveAgentIdAction } from '@renderer/store/runtime'
import { useCallback } from 'react'
import { useRuntime } from '../useRuntime'
import { useAgent } from './useAgent'
import { useAgentSessionInitializer } from './useAgentSessionInitializer'
export const useActiveAgent = () => {
const { chat } = useRuntime()
const { activeAgentId } = chat
return useAgent(activeAgentId)
const dispatch = useAppDispatch()
const { initializeAgentSession } = useAgentSessionInitializer()
const setActiveAgentId = useCallback(
async (id: string) => {
dispatch(setActiveAgentIdAction(id))
await initializeAgentSession(id)
},
[dispatch, initializeAgentSession]
)
return { ...useAgent(activeAgentId), setActiveAgentId }
}

View File

@@ -1,8 +1,8 @@
import { loggerService } from '@logger'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import { useCallback, useEffect } from 'react'
import { setActiveSessionIdAction } from '@renderer/store/runtime'
import { useCallback, useEffect, useRef } from 'react'
import { useAgentClient } from './useAgentClient'
@@ -19,6 +19,10 @@ export const useAgentSessionInitializer = () => {
const { chat } = useRuntime()
const { activeAgentId, activeSessionIdMap } = chat
// Use a ref to keep the callback stable across activeSessionIdMap changes
const activeSessionIdMapRef = useRef(activeSessionIdMap)
activeSessionIdMapRef.current = activeSessionIdMap
/**
* Initialize session for the given agent by loading its sessions
* and setting the latest one as active
@@ -28,11 +32,9 @@ export const useAgentSessionInitializer = () => {
if (!agentId) return
try {
// Check if this agent already has an active session
const currentSessionId = activeSessionIdMap[agentId]
if (currentSessionId) {
// Session already exists, just switch to session view
dispatch(setActiveTopicOrSessionAction('session'))
// Check if this agent has already been initialized (key exists in map)
if (agentId in activeSessionIdMapRef.current) {
// Already initialized, nothing to do
return
}
@@ -46,19 +48,15 @@ export const useAgentSessionInitializer = () => {
// Set the latest session as active
dispatch(setActiveSessionIdAction({ agentId, sessionId: latestSession.id }))
dispatch(setActiveTopicOrSessionAction('session'))
} else {
// No sessions exist, we might want to create one
// But for now, just switch to session view and let the Sessions component handle it
dispatch(setActiveTopicOrSessionAction('session'))
// Mark as initialized with no session (null vs undefined distinction)
dispatch(setActiveSessionIdAction({ agentId, sessionId: null }))
}
} catch (error) {
logger.error('Failed to initialize agent session:', error as Error)
// Even if loading fails, switch to session view
dispatch(setActiveTopicOrSessionAction('session'))
}
},
[client, dispatch, activeSessionIdMap]
[client, dispatch]
)
/**
@@ -66,13 +64,12 @@ export const useAgentSessionInitializer = () => {
*/
useEffect(() => {
if (activeAgentId) {
// Check if we need to initialize this agent's session
const hasActiveSession = activeSessionIdMap[activeAgentId]
if (!hasActiveSession) {
// Check if we need to initialize this agent's session (key not yet in map)
if (!(activeAgentId in activeSessionIdMapRef.current)) {
initializeAgentSession(activeAgentId)
}
}
}, [activeAgentId, activeSessionIdMap, initializeAgentSession])
}, [activeAgentId, initializeAgentSession])
return {
initializeAgentSession

View File

@@ -98,7 +98,7 @@ export const useAgents = () => {
)
return {
agents: data ?? [],
agents: data,
error,
isLoading,
addAgent,

View File

@@ -2,7 +2,7 @@ import { loggerService } from '@logger'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useAppDispatch } from '@renderer/store'
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import { setActiveSessionIdAction } from '@renderer/store/runtime'
import type { CreateSessionForm } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -36,7 +36,6 @@ export const useCreateDefaultSession = (agentId: string | null) => {
if (created) {
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
dispatch(setActiveTopicOrSessionAction('session'))
}
return created

View File

@@ -1,5 +1,6 @@
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setApiServerRunningAction } from '@renderer/store/runtime'
import { setApiServerEnabled as setApiServerEnabledAction } from '@renderer/store/settings'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -34,10 +35,17 @@ export const useApiServer = () => {
const apiServerConfig = useAppSelector((state) => state.settings.apiServer)
const dispatch = useAppDispatch()
// Initial state - no longer optimistic, wait for actual status
const [apiServerRunning, setApiServerRunning] = useState(false)
const apiServerRunning = useAppSelector((state) => state.runtime.apiServerRunning)
// Is checking the API server status
const [apiServerLoading, setApiServerLoading] = useState(true)
const setApiServerRunning = useCallback(
(running: boolean) => {
dispatch(setApiServerRunningAction(running))
},
[dispatch]
)
const setApiServerEnabled = useCallback(
(enabled: boolean) => {
dispatch(setApiServerEnabledAction(enabled))
@@ -59,7 +67,7 @@ export const useApiServer = () => {
} finally {
setApiServerLoading(false)
}
}, [apiServerConfig.enabled, setApiServerEnabled])
}, [apiServerConfig.enabled, setApiServerEnabled, setApiServerLoading, setApiServerRunning])
const startApiServer = useCallback(async () => {
if (apiServerLoading) return
@@ -78,7 +86,7 @@ export const useApiServer = () => {
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, setApiServerEnabled, t])
}, [apiServerLoading, setApiServerEnabled, setApiServerLoading, setApiServerRunning, t])
const stopApiServer = useCallback(async () => {
if (apiServerLoading) return
@@ -97,7 +105,7 @@ export const useApiServer = () => {
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, setApiServerEnabled, t])
}, [apiServerLoading, setApiServerEnabled, setApiServerLoading, setApiServerRunning, t])
const restartApiServer = useCallback(async () => {
if (apiServerLoading) return
@@ -116,7 +124,7 @@ export const useApiServer = () => {
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, checkApiServerStatus, setApiServerEnabled, t])
}, [apiServerLoading, checkApiServerStatus, setApiServerEnabled, setApiServerLoading, t])
useEffect(() => {
checkApiServerStatus()

View File

@@ -150,7 +150,8 @@ const titleKeyMap = {
paintings: 'title.paintings',
settings: 'title.settings',
translate: 'title.translate',
openclaw: 'openclaw.title'
openclaw: 'openclaw.title',
agents: 'agent.sidebar_title'
} as const
export const getTitleLabel = (key: string): string => {
@@ -181,6 +182,7 @@ export const getThemeModeLabel = (key: string): string => {
const sidebarIconKeyMap = {
assistants: 'assistants.title',
agents: 'agent.sidebar_title',
store: 'assistants.presets.title',
paintings: 'paintings.title',
translate: 'translate.title',

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "Edit Agent"
},
"empty": {
"description": "Create an agent to handle complex tasks with AI-powered tools",
"title": "No agents yet"
},
"get": {
"error": {
"failed": "Failed to get the agent.",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "Agents",
"todo": {
"panel": {
"title": "{{completed}}/{{total}} tasks completed"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "Enable API Server to use agents."
"enable_and_start": "Enable & Start",
"enable_server": "Enable API Server to use agents.",
"enable_server_description": "The API server must be enabled for agents to work. You can enable it directly or configure it in settings.",
"server_not_running": "API Server is enabled but not running. Please check the server configuration.",
"server_not_running_description": "The API server needs to be running for agents to work. You can start it directly or check the settings."
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "Create an agent to get started",
"create_session": "Create a session",
"select_agent": "Select an agent"
},
@@ -1389,6 +1399,7 @@
"selected": "Selected",
"selectedItems": "Selected {{count}} items",
"selectedMessages": "Selected {{count}} messages",
"sessions": "Sessions",
"settings": "Settings",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "编辑 Agent"
},
"empty": {
"description": "创建一个 Agent让 AI 帮你处理复杂任务",
"title": "还没有 Agent"
},
"get": {
"error": {
"failed": "获取智能体失败",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "智能体",
"todo": {
"panel": {
"title": "已完成 {{completed}}/{{total}} 个任务"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "启用 API 服务器以使用智能体功能"
"enable_and_start": "启用并启动",
"enable_server": "请启用 API 服务器以使用智能体功能",
"enable_server_description": "智能体功能需要启用 API 服务器。你可以直接启用,或前往设置页面进行配置。",
"server_not_running": "API 服务器已启用但未运行,请检查服务器配置。",
"server_not_running_description": "智能体功能需要 API 服务器运行。你可以直接启动服务器,或前往设置页面检查配置。"
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "请创建一个智能体以开始使用",
"create_session": "请创建会话",
"select_agent": "请选择智能体"
},
@@ -1389,6 +1399,7 @@
"selected": "已选择",
"selectedItems": "已选择 {{count}} 项",
"selectedMessages": "选中 {{count}} 条消息",
"sessions": "会话",
"settings": "设置",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "編輯 Agent"
},
"empty": {
"description": "建立 Agent 以透過 AI 驅動的工具處理複雜任務",
"title": "尚無 Agent"
},
"get": {
"error": {
"failed": "無法取得 Agent。",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "Agents",
"todo": {
"panel": {
"title": "已完成 {{completed}}/{{total}} 個任務"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "啟用 API 伺服器以使用 Agent。"
"enable_and_start": "啟用並啟動",
"enable_server": "啟用 API 伺服器以使用 Agent。",
"enable_server_description": "智能體功能需要啟用 API 伺服器。你可以直接啟用,或前往設定頁面進行配置。",
"server_not_running": "API 伺服器已啟用但未執行。請檢查伺服器設定。",
"server_not_running_description": "智能體功能需要 API 伺服器運行。你可以直接啟動伺服器,或前往設定頁面檢查配置。"
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "建立 Agent 以開始",
"create_session": "建立工作階段",
"select_agent": "選擇一個智能體"
},
@@ -1389,6 +1399,7 @@
"selected": "已選擇",
"selectedItems": "已選擇 {{count}} 項",
"selectedMessages": "已選取 {{count}} 則訊息",
"sessions": "會話",
"settings": "設定",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "Agent bearbeiten"
},
"empty": {
"description": "Erstellen Sie einen Agenten zur Bewältigung komplexer Aufgaben mit KI-gestützten Tools.",
"title": "Noch keine Agenten"
},
"get": {
"error": {
"failed": "Agent abrufen fehlgeschlagen",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "Agenten",
"todo": {
"panel": {
"title": "{{completed}}/{{total}} Aufgaben abgeschlossen"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "Bitte aktivieren Sie den API-Server, um Agent-Funktionen zu verwenden"
"enable_and_start": "Aktivieren & Starten",
"enable_server": "Bitte aktivieren Sie den API-Server, um Agent-Funktionen zu verwenden",
"enable_server_description": "Der API-Server muss aktiviert sein, damit Agents funktionieren. Sie können ihn direkt aktivieren oder in den Einstellungen konfigurieren.",
"server_not_running": "API-Server ist aktiviert, läuft aber nicht. Bitte überprüfen Sie die Serverkonfiguration.",
"server_not_running_description": "Der API-Server muss laufen, damit die Agents funktionieren. Sie können ihn direkt starten oder die Einstellungen überprüfen."
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "Erstellen Sie einen Agenten, um loszulegen.",
"create_session": "Erstelle eine Sitzung",
"select_agent": "Wählen Sie einen Agenten"
},
@@ -1389,6 +1399,7 @@
"selected": "Ausgewählt",
"selectedItems": "{{count}} Elemente ausgewählt",
"selectedMessages": "{{count}} Nachrichten ausgewählt",
"sessions": "Sitzungen",
"settings": "Einstellungen",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "Επεξεργαστής Agent"
},
"empty": {
"description": "Δημιουργήστε έναν πράκτορα για τον χειρισμό σύνθετων εργασιών με εργαλεία που υποστηρίζονται από τεχνητή νοημοσύνη.",
"title": "Δεν υπάρχουν ακόμα πράκτορες"
},
"get": {
"error": {
"failed": "Αποτυχία λήψης του πράκτορα.",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "Πράκτορες",
"todo": {
"panel": {
"title": "{{completed}}/{{total}} εργασίες ολοκληρώθηκαν"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "Ενεργοποίηση του διακομιστή API για χρήση πρακτόρων."
"enable_and_start": "Ενεργοποίηση & Εκκίνηση",
"enable_server": "Ενεργοποίηση του διακομιστή API για χρήση πρακτόρων.",
"enable_server_description": "Ο διακομιστής API πρέπει να είναι ενεργοποιημένος για να λειτουργήσουν οι πράκτορες. Μπορείτε να τον ενεργοποιήσετε απευθείας ή να τον ρυθμίσετε στις ρυθμίσεις.",
"server_not_running": "Ο API Server είναι ενεργοποιημένος αλλά δεν εκτελείται. Παρακαλούμε ελέγξτε τη διαμόρφωση του διακομιστή.",
"server_not_running_description": "Ο διακομιστής API πρέπει να βρίσκεται σε λειτουργία για να λειτουργήσουν οι πράκτορες. Μπορείτε να τον εκκινήσετε απευθείας ή να ελέγξετε τις ρυθμίσεις."
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "Δημιουργήστε έναν πράκτορα για να ξεκινήσετε.",
"create_session": "Δημιουργία συνεδρίας",
"select_agent": "Επιλέξτε έναν πράκτορα"
},
@@ -1389,6 +1399,7 @@
"selected": "Επιλεγμένο",
"selectedItems": "Επιλέχθηκαν {{count}} αντικείμενα",
"selectedMessages": "Επιλέχθηκαν {{count}} μηνύματα",
"sessions": "Συνεδρίες",
"settings": "Ρυθμίσεις",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "Agent de edición"
},
"empty": {
"description": "Crea un agente para gestionar tareas complejas con herramientas impulsadas por IA.",
"title": "Aún no hay agentes"
},
"get": {
"error": {
"failed": "No se pudo obtener el agente.",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "Agentes",
"todo": {
"panel": {
"title": "{{completed}}/{{total}} tareas completadas"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "Habilitar el servidor API para usar agentes."
"enable_and_start": "Habilitar e iniciar",
"enable_server": "Habilitar el servidor API para usar agentes.",
"enable_server_description": "El servidor de la API debe estar habilitado para que los agentes funcionen. Puede habilitarlo directamente o configurarlo en los ajustes.",
"server_not_running": "El servidor API está habilitado pero no se está ejecutando. Por favor, compruebe la configuración del servidor.",
"server_not_running_description": "El servidor de la API debe estar en ejecución para que los agentes funcionen. Puede iniciarlo directamente o comprobar los ajustes."
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "Crea un agente para empezar",
"create_session": "Crear una sesión",
"select_agent": "Selecciona un agente"
},
@@ -1389,6 +1399,7 @@
"selected": "Seleccionado",
"selectedItems": "{{count}} elementos seleccionados",
"selectedMessages": "{{count}} mensajes seleccionados",
"sessions": "Sesiones",
"settings": "Configuración",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "Éditer Agent"
},
"empty": {
"description": "Créez un agent pour gérer des tâches complexes avec des outils alimentés par l'IA",
"title": "Pas encore d'agents"
},
"get": {
"error": {
"failed": "Échec de l'obtention de l'agent.",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "Agents",
"todo": {
"panel": {
"title": "{{completed}}/{{total}} tâches terminées"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "Permettre au serveur API d'utiliser des agents."
"enable_and_start": "Activer & Démarrer",
"enable_server": "Permettre au serveur API d'utiliser des agents.",
"enable_server_description": "Le serveur API doit être activé pour que les agents fonctionnent. Vous pouvez l'activer directement ou le configurer dans les paramètres.",
"server_not_running": "Le serveur API est activé mais ne fonctionne pas. Veuillez vérifier la configuration du serveur.",
"server_not_running_description": "Le serveur API doit être en cours d'exécution pour que les agents fonctionnent. Vous pouvez le démarrer directement ou vérifier les paramètres."
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "Créer un agent pour commencer",
"create_session": "Créer une session",
"select_agent": "Sélectionnez un agent"
},
@@ -1389,6 +1399,7 @@
"selected": "Sélectionné",
"selectedItems": "{{count}} éléments sélectionnés",
"selectedMessages": "{{count}} messages sélectionnés",
"sessions": "Sessions",
"settings": "Paramètres",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "編集エージェント"
},
"empty": {
"description": "AI搭載ツールで複雑なタスクを処理するエージェントを作成する",
"title": "エージェントがまだありません"
},
"get": {
"error": {
"failed": "エージェントの取得に失敗しました。",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "エージェント",
"todo": {
"panel": {
"title": "{{completed}}/{{total}} タスク完了"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "APIサーバーがエージェントを使用できるようにする。"
"enable_and_start": "有効化 & 開始",
"enable_server": "APIサーバーがエージェントを使用できるようにする。",
"enable_server_description": "エージェントを動作させるには、API サーバーを有効にする必要があります。直接有効にするか、設定から構成することができます。",
"server_not_running": "APIサーバーは有効になっていますが、実行されていません。サーバー設定を確認してください。",
"server_not_running_description": "エージェントを動作させるには、APIサーバーが稼働している必要があります。直接起動するか、設定を確認してください。"
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "開始するにはエージェントを作成してください",
"create_session": "セッションを作成",
"select_agent": "エージェントを選択してください"
},
@@ -1389,6 +1399,7 @@
"selected": "選択済み",
"selectedItems": "{{count}}件の項目を選択しました",
"selectedMessages": "{{count}}件のメッセージを選択しました",
"sessions": "セッション",
"settings": "設定",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "Agent Editor"
},
"empty": {
"description": "Crie um agente para lidar com tarefas complexas com ferramentas baseadas em IA",
"title": "Ainda não há agentes"
},
"get": {
"error": {
"failed": "Falha ao obter o agente.",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "Agentes",
"todo": {
"panel": {
"title": "{{completed}}/{{total}} tarefas concluídas"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "Ativar o Servidor de API para usar agentes."
"enable_and_start": "Ativar & Iniciar",
"enable_server": "Ativar o Servidor de API para usar agentes.",
"enable_server_description": "O servidor de API deve estar habilitado para que os agentes funcionem. Você pode habilitá-lo diretamente ou configurá-lo nas configurações.",
"server_not_running": "O Servidor API está ativado, mas não está em execução. Verifique a configuração do servidor.",
"server_not_running_description": "O servidor da API precisa estar em execução para que os agentes funcionem. Você pode iniciá-lo diretamente ou verificar as configurações."
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "Crie um agente para começar",
"create_session": "Criar uma sessão",
"select_agent": "Selecione um agente"
},
@@ -1389,6 +1399,7 @@
"selected": "Selecionado",
"selectedItems": "{{count}} itens selecionados",
"selectedMessages": "{{count}} mensagens selecionadas",
"sessions": "Sessões",
"settings": "Configurações",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "Editează agentul"
},
"empty": {
"description": "Creează un agent pentru a gestiona sarcini complexe cu instrumente bazate pe IA",
"title": "Niciun agent încă"
},
"get": {
"error": {
"failed": "Nu s-a putut obține agentul.",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "Agenți",
"todo": {
"panel": {
"title": "{{completed}}/{{total}} sarcini finalizate"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "Activează serverul API pentru a folosi agenți."
"enable_and_start": "Activează și pornește",
"enable_server": "Activează serverul API pentru a folosi agenți.",
"enable_server_description": "Serverul API trebuie să fie activat pentru ca agenții să funcționeze. Îl puteți activa direct sau îl puteți configura în setări.",
"server_not_running": "Serverul API este activat, dar nu rulează. Vă rugăm să verificați configurația serverului.",
"server_not_running_description": "Serverul API trebuie să ruleze pentru ca agenții să funcționeze. Îl puteți porni direct sau puteți verifica setările."
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "Creează un agent pentru a începe",
"create_session": "Creează o sesiune",
"select_agent": "Selectați un agent"
},
@@ -1389,6 +1399,7 @@
"selected": "Selectat",
"selectedItems": "{{count}} elemente selectate",
"selectedMessages": "{{count}} mesaje selectate",
"sessions": "Sesiuni",
"settings": "Setări",
"sort": {
"pinyin": {

View File

@@ -38,6 +38,10 @@
"edit": {
"title": "Редактировать агент"
},
"empty": {
"description": "Создайте агента для решения сложных задач с помощью инструментов на базе ИИ",
"title": "Агентов пока нет"
},
"get": {
"error": {
"failed": "Не удалось получить агента.",
@@ -295,6 +299,7 @@
}
}
},
"sidebar_title": "Агенты",
"todo": {
"panel": {
"title": "{{completed}}/{{total}} задач выполнено"
@@ -395,7 +400,11 @@
}
},
"warning": {
"enable_server": "Разрешить серверу API использовать агентов."
"enable_and_start": "Включить и запустить",
"enable_server": "Разрешить серверу API использовать агентов.",
"enable_server_description": "Для работы агентов необходимо включить API-сервер. Вы можете включить его напрямую или в настройках.",
"server_not_running": "Сервер API включен, но не запущен. Пожалуйста, проверьте конфигурацию сервера.",
"server_not_running_description": "Для работы агентов необходимо, чтобы API-сервер был запущен. Вы можете запустить его напрямую или проверить настройки."
}
},
"apiServer": {
@@ -753,6 +762,7 @@
}
},
"alerts": {
"create_agent": "Создайте агента, чтобы начать работу",
"create_session": "Создать сессию",
"select_agent": "Выберите агента"
},
@@ -1389,6 +1399,7 @@
"selected": "Выбрано",
"selectedItems": "Выбрано {{count}} элементов",
"selectedMessages": "Выбрано {{count}} сообщений",
"sessions": "Сессии",
"settings": "Настройки",
"sort": {
"pinyin": {

View File

@@ -0,0 +1,145 @@
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowTopics } from '@renderer/hooks/useStore'
import { cn } from '@renderer/utils'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Alert, Spin } from 'antd'
import { AnimatePresence, motion } from 'motion/react'
import type { PropsWithChildren } from 'react'
import { useTranslation } from 'react-i18next'
import { PinnedTodoPanel } from '../home/Inputbar/components/PinnedTodoPanel'
import ChatNavigation from '../home/Messages/ChatNavigation'
import NarrowLayout from '../home/Messages/NarrowLayout'
import AgentChatNavbar from './components/AgentChatNavbar'
import AgentSessionInputbar from './components/AgentSessionInputbar'
import AgentSessionMessages from './components/AgentSessionMessages'
import Sessions from './components/Sessions'
const AgentChat = () => {
const { t } = useTranslation()
const { messageNavigation, topicPosition } = useSettings()
const { showTopics } = useShowTopics()
const { chat } = useRuntime()
const { activeAgentId, activeSessionIdMap } = chat
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
// undefined = session not yet initialized, null = initialized but no sessions
const isSessionInitialized = !activeAgentId || activeAgentId in activeSessionIdMap
const { agent: activeAgent, isLoading: isAgentLoading } = useActiveAgent()
const { isLoading: isAgentsLoading, agents } = useAgents()
const { createDefaultSession } = useCreateDefaultSession(activeAgentId)
// Don't show select/create alerts while data is still loading
// apiServerRunning is guaranteed by AgentPage guard
const isInitializing =
isAgentsLoading || isAgentLoading || !isSessionInitialized || !agents || (!activeAgentId && agents.length > 0)
const showRightSessions = topicPosition === 'right' && showTopics && !!activeAgentId
useShortcut(
'new_topic',
() => {
void createDefaultSession()
},
{
enabled: true,
preventDefault: true,
enableOnFormTags: true
}
)
if (isInitializing) {
return (
<Container className="flex flex-1 flex-col items-center justify-center">
<Spin />
</Container>
)
}
// Initialized — agents.length === 0 is handled by AgentPage
if (!activeAgentId) {
return (
<Container className="flex flex-1 flex-col justify-between">
<div className="flex h-full w-full items-center justify-center">
<Alert type="info" message={t('chat.alerts.select_agent')} style={{ margin: '5px 16px' }} />
</div>
</Container>
)
}
if (!activeSessionId) {
return (
<Container className="flex flex-1 flex-col justify-between">
<div className="flex h-full w-full items-center justify-center">
<Alert type="warning" message={t('chat.alerts.create_session')} style={{ margin: '5px 16px' }} />
</div>
</Container>
)
}
return (
<Container>
<QuickPanelProvider>
{/* Main Chat */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Header */}
<div className="flex h-fit w-full min-w-0">
{activeAgent && <AgentChatNavbar className="min-w-0" activeAgent={activeAgent} />}
</div>
{/* Messages */}
<div className="translate-z-0 relative flex w-full flex-1 flex-col justify-between overflow-y-auto">
<AgentSessionMessages agentId={activeAgentId} sessionId={activeSessionId} />
<div className="mt-auto px-4.5 pb-2">
<NarrowLayout>
<PinnedTodoPanel topicId={buildAgentSessionTopicId(activeSessionId)} />
</NarrowLayout>
</div>
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
</div>
{/* Inputbar */}
<AgentSessionInputbar agentId={activeAgentId} sessionId={activeSessionId} />
</div>
</QuickPanelProvider>
{/* Sessions Panel */}
<AnimatePresence initial={false}>
{showRightSessions && (
<motion.div
key="right-sessions"
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'var(--assistants-width)', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="overflow-hidden">
<div className="flex h-full w-(--assistants-width) flex-col overflow-hidden">
<Sessions agentId={activeAgentId!} />
</div>
</motion.div>
)}
</AnimatePresence>
</Container>
)
}
const Container = ({ children, className }: PropsWithChildren<{ className?: string }>) => {
const { isTopNavbar } = useNavbarPosition()
return (
<div
className={cn(
'flex flex-1 overflow-hidden',
isTopNavbar && 'rounded-tl-[10px] rounded-bl-[10px] bg-(--color-background)',
className
)}>
{children}
</div>
)
}
export default AgentChat

View File

@@ -0,0 +1,117 @@
import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import NavbarIcon from '@renderer/components/NavbarIcon'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { modelGenerating } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { useAppDispatch } from '@renderer/store'
import { setNarrowMode } from '@renderer/store/settings'
import { Tooltip } from 'antd'
import { t } from 'i18next'
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import UpdateAppButton from '../home/components/UpdateAppButton'
import AgentSidePanelDrawer from './components/AgentSidePanelDrawer'
const AgentNavbar = () => {
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { showTopics, toggleShowTopics } = useShowTopics()
const { narrowMode, topicPosition } = useSettings()
const dispatch = useAppDispatch()
useShortcut('toggle_show_assistants', toggleShowAssistants)
useShortcut('search_message', () => {
SearchPopup.show()
})
const handleNarrowModeToggle = async () => {
await modelGenerating()
dispatch(setNarrowMode(!narrowMode))
}
return (
<Navbar className="agent-navbar">
<AnimatePresence initial={false}>
{showAssistants && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden', display: 'flex', flexDirection: 'row' }}>
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={toggleShowAssistants}>
<PanelLeftClose size={18} />
</NavbarIcon>
</Tooltip>
</NavbarLeft>
</motion.div>
)}
</AnimatePresence>
{!showAssistants && (
<NavbarLeft
style={{
justifyContent: 'flex-start',
borderRight: 'none',
paddingLeft: 0,
paddingRight: 0,
minWidth: 'auto'
}}>
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8} placement="right">
<NavbarIcon onClick={() => toggleShowAssistants()}>
<PanelRightClose size={18} />
</NavbarIcon>
</Tooltip>
<NavbarIcon onClick={() => AgentSidePanelDrawer.show()} style={{ marginRight: 5 }}>
<Menu size={18} />
</NavbarIcon>
</NavbarLeft>
)}
<NavbarCenter></NavbarCenter>
<NavbarRight
style={{
justifyContent: 'flex-end',
flex: 'none',
position: 'relative',
paddingRight: '15px',
minWidth: 'auto'
}}
className="agent-navbar-right">
<HStack alignItems="center" gap={6}>
<UpdateAppButton />
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NavbarIcon className="max-[1000px]:hidden" onClick={() => SearchPopup.show()}>
<Search size={18} />
</NavbarIcon>
</Tooltip>
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NavbarIcon className="max-[1000px]:hidden" onClick={handleNarrowModeToggle}>
<i className="iconfont icon-icon-adaptive-width"></i>
</NavbarIcon>
</Tooltip>
{topicPosition === 'right' && !showTopics && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
<NavbarIcon onClick={toggleShowTopics}>
<PanelLeftClose size={18} />
</NavbarIcon>
</Tooltip>
)}
{topicPosition === 'right' && showTopics && (
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={2}>
<NavbarIcon onClick={toggleShowTopics}>
<PanelRightClose size={18} />
</NavbarIcon>
</Tooltip>
)}
</HStack>
</NavbarRight>
</Navbar>
)
}
export default AgentNavbar

View File

@@ -0,0 +1,105 @@
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiServer } from '@renderer/hooks/useApiServer'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { cn } from '@renderer/utils'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SECOND_MIN_WINDOW_WIDTH } from '@shared/config/constant'
import { AnimatePresence, motion } from 'motion/react'
import type { PropsWithChildren } from 'react'
import { useEffect } from 'react'
import AgentChat from './AgentChat'
import AgentNavbar from './AgentNavbar'
import AgentSidePanel from './AgentSidePanel'
import { AgentEmpty, AgentServerDisabled, AgentServerStopped } from './components/status'
const AgentPage = () => {
const { isLeftNavbar } = useNavbarPosition()
const { showAssistants } = useShowAssistants()
const { showTopics } = useShowTopics()
const { topicPosition } = useSettings()
const { chat } = useRuntime()
const { activeAgentId } = chat
const { agents } = useAgents()
const { setActiveAgentId } = useActiveAgent()
const { apiServerConfig, apiServerRunning, apiServerLoading } = useApiServer()
// Auto-select first agent when none is active
useEffect(() => {
if (!activeAgentId && agents && agents.length > 0) {
setActiveAgentId(agents[0].id)
}
}, [activeAgentId, agents, setActiveAgentId])
useEffect(() => {
const canMinimize = topicPosition === 'left' ? !showAssistants : !showAssistants && !showTopics
window.api.window.setMinimumSize(canMinimize ? SECOND_MIN_WINDOW_WIDTH : MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
return () => {
window.api.window.resetMinimumSize()
}
}, [showAssistants, showTopics, topicPosition])
if (!apiServerConfig.enabled) {
return (
<Container className="bg-background">
<AgentServerDisabled />
</Container>
)
}
if (!apiServerLoading && !apiServerRunning) {
return (
<Container className="bg-background">
<AgentServerStopped />
</Container>
)
}
if (agents && agents.length === 0) {
return (
<Container className="bg-background">
<AgentEmpty />
</Container>
)
}
return (
<Container>
{isLeftNavbar && <AgentNavbar />}
<div
id={isLeftNavbar ? 'content-container' : undefined}
className="flex min-w-0 flex-1 shrink flex-row overflow-hidden">
<AnimatePresence initial={false}>
{showAssistants && (
<ErrorBoundary>
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'var(--assistants-width)', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}>
<AgentSidePanel />
</motion.div>
</ErrorBoundary>
)}
</AnimatePresence>
<ErrorBoundary>
<AgentChat />
</ErrorBoundary>
</div>
</Container>
)
}
const Container = ({ children, className }: PropsWithChildren<{ className?: string }>) => {
return (
<div id="agent-page" className={cn('flex flex-1 flex-col overflow-hidden', className)}>
{children}
</div>
)
}
export default AgentPage

View File

@@ -1,4 +1,3 @@
import { loggerService } from '@logger'
import EditableNumber from '@renderer/components/EditableNumber'
import Scrollbar from '@renderer/components/Scrollbar'
import Selector from '@renderer/components/Selector'
@@ -6,7 +5,6 @@ import { HelpTooltip } from '@renderer/components/TooltipIcons'
import { UNKNOWN } from '@renderer/config/translate'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import useTranslate from '@renderer/hooks/useTranslate'
import { SettingDivider, SettingRow, SettingRowTitle } from '@renderer/pages/settings'
@@ -46,11 +44,7 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const logger = loggerService.withContext('AgentSettingsTab')
const AgentSettingsTab = () => {
const { chat } = useRuntime()
const { messageStyle, fontSize, language } = useSettings()
const { theme } = useTheme()
const { themeNames } = useCodeStyle()
@@ -116,12 +110,6 @@ const AgentSettingsTab = () => {
[dispatch, theme, codeEditor.enabled]
)
const isAgentSettings = chat.activeTopicOrSession === 'session'
if (!isAgentSettings) {
logger.warn('AgentSettingsTab is rendered when not session activated.')
return null
}
return (
<Container className="settings-tab">
<CollapsibleSettingGroup title={t('settings.messages.title')} defaultExpanded={true}>

View File

@@ -0,0 +1,138 @@
import AddButton from '@renderer/components/AddButton'
import AgentModalPopup from '@renderer/components/Popups/agent/AgentModal'
import Scrollbar from '@renderer/components/Scrollbar'
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiServer } from '@renderer/hooks/useApiServer'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import type { AgentEntity } from '@renderer/types'
import { cn } from '@renderer/utils'
import type { FC } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AgentItem from './components/AgentItem'
import Sessions from './components/Sessions'
interface AgentSidePanelProps {
onSelectItem?: () => void
}
const AgentSidePanel = ({ onSelectItem }: AgentSidePanelProps) => {
const { t } = useTranslation()
const { agents, deleteAgent, isLoading, error } = useAgents()
const { apiServerRunning, startApiServer } = useApiServer()
const { chat } = useRuntime()
const { activeAgentId } = chat
const { setActiveAgentId } = useActiveAgent()
const { isLeftNavbar } = useNavbarPosition()
const { topicPosition } = useSettings()
const sessionsOnRight = topicPosition === 'right'
const [tab, setTab] = useState<'agents' | 'sessions'>('agents')
const handleAgentPress = useCallback(
(agentId: string) => {
setActiveAgentId(agentId)
onSelectItem?.()
},
[setActiveAgentId, onSelectItem]
)
const handleAddAgent = useCallback(() => {
!apiServerRunning && startApiServer()
AgentModalPopup.show({
afterSubmit: (agent: AgentEntity) => {
setActiveAgentId(agent.id)
}
})
}, [apiServerRunning, startApiServer, setActiveAgentId])
return (
<div
className="flex flex-col overflow-hidden"
style={{
width: 'var(--assistants-width)',
height: 'calc(100vh - var(--navbar-height))',
borderRight: isLeftNavbar ? '0.5px solid var(--color-border)' : 'none',
backgroundColor: isLeftNavbar ? 'var(--color-background)' : undefined
}}>
{/* Tabs */}
{!sessionsOnRight && (
<div
className="mx-3 flex border-(--color-border) border-b bg-transparent py-1.5 pt-0.5"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<TabButton active={tab === 'agents'} onClick={() => setTab('agents')}>
{t('agent.sidebar_title')}
</TabButton>
<TabButton active={tab === 'sessions'} onClick={() => setTab('sessions')}>
{t('common.sessions')}
</TabButton>
</div>
)}
{/* Content */}
<div className="flex flex-1 flex-col overflow-hidden">
{(sessionsOnRight || tab === 'agents') && (
<Scrollbar className="flex flex-col py-3">
<div className="-mt-0.5 mb-1.5 px-2.5">
<AddButton onClick={handleAddAgent}>{t('agent.sidebar_title')}</AddButton>
</div>
<div className="flex flex-col gap-0.5 px-2.5">
{isLoading && (
<div className="p-5 text-center text-(--color-text-secondary) text-[13px]">{t('common.loading')}</div>
)}
{error && <div className="p-5 text-center text-(--color-error) text-[13px]">{error.message}</div>}
{!isLoading &&
!error &&
agents?.map((agent) => (
<AgentItem
key={agent.id}
agent={agent}
isActive={agent.id === activeAgentId}
onDelete={() => deleteAgent(agent.id)}
onPress={() => handleAgentPress(agent.id)}
/>
))}
</div>
</Scrollbar>
)}
{!sessionsOnRight && tab === 'sessions' && activeAgentId && (
<Sessions agentId={activeAgentId} onSelectItem={onSelectItem} />
)}
{!sessionsOnRight && tab === 'sessions' && !activeAgentId && (
<div className="flex flex-1 items-center justify-center p-5 text-(--color-text-secondary) text-[13px]">
{t('chat.alerts.select_agent')}
</div>
)}
</div>
</div>
)
}
const TabButton: FC<{ active: boolean; onClick: () => void; children: React.ReactNode }> = ({
active,
onClick,
children
}) => (
<button
type="button"
onClick={onClick}
className={cn(
'relative mx-0.5 flex flex-1 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-[13px]',
'h-7.5',
'hover:text-(--color-text)',
'active:scale-[0.98]',
active ? 'font-semibold text-(--color-text)' : 'font-normal text-(--color-text-secondary)',
// Underline indicator via pseudo-element
'after:-translate-x-1/2 after:-bottom-2 after:absolute after:left-1/2 after:h-0.75 after:rounded-sm after:transition-all after:duration-200 after:ease-in-out',
active
? 'after:w-7.5 after:bg-(--color-primary)'
: 'after:w-0 after:bg-(--color-primary) hover:after:w-4 hover:after:bg-(--color-primary-soft)'
)}>
{children}
</button>
)
export default AgentSidePanel

View File

@@ -1,22 +1,32 @@
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import NavbarIcon from '@renderer/components/NavbarIcon'
import { useActiveSession } from '@renderer/hooks/agents/useActiveSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useNavbarPosition } from '@renderer/hooks/useSettings'
import { useShowAssistants } from '@renderer/hooks/useStore'
import { AgentSettingsPopup, SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import { AgentLabel, SessionLabel } from '@renderer/pages/settings/AgentSettings/shared'
import type { AgentEntity, ApiModel } from '@renderer/types'
import { Tooltip } from 'antd'
import { t } from 'i18next'
import { ChevronRight } from 'lucide-react'
import { Menu, PanelLeftClose, PanelRightClose } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback } from 'react'
import SelectAgentBaseModelButton from '../../SelectAgentBaseModelButton'
import Tools from '../Tools'
import AgentSidePanelDrawer from '../AgentSidePanelDrawer'
import SelectAgentBaseModelButton from '../SelectAgentBaseModelButton'
import OpenExternalAppButton from './OpenExternalAppButton'
import SessionWorkspaceMeta from './SessionWorkspaceMeta'
import Tools from './Tools'
type AgentContentProps = {
activeAgent: AgentEntity
}
const AgentContent = ({ activeAgent }: AgentContentProps) => {
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { isTopNavbar } = useNavbarPosition()
const { session: activeSession } = useActiveSession()
const { updateModel } = useUpdateSession(activeAgent?.id ?? null)
@@ -29,9 +39,36 @@ const AgentContent = ({ activeAgent }: AgentContentProps) => {
)
return (
<>
<div className="min-w-0 shrink overflow-x-auto pr-2">
<HorizontalScrollContainer className="ml-2 min-w-0 flex-initial">
<div className="flex w-full justify-between pr-2">
<div className="flex min-w-0 shrink items-center">
{isTopNavbar && showAssistants && (
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={toggleShowAssistants}>
<PanelLeftClose size={18} />
</NavbarIcon>
</Tooltip>
)}
{isTopNavbar && !showAssistants && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8} placement="right">
<NavbarIcon onClick={() => toggleShowAssistants()} style={{ marginRight: 8 }}>
<PanelRightClose size={18} />
</NavbarIcon>
</Tooltip>
)}
<AnimatePresence initial={false}>
{!showAssistants && isTopNavbar && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}>
<NavbarIcon onClick={() => AgentSidePanelDrawer.show()} style={{ marginRight: 5 }}>
<Menu size={18} />
</NavbarIcon>
</motion.div>
)}
</AnimatePresence>
<HorizontalScrollContainer className="ml-2 min-w-0 flex-initial shrink">
<div className="flex flex-nowrap items-center gap-2">
{/* Agent Label */}
<div
@@ -88,7 +125,7 @@ const AgentContent = ({ activeAgent }: AgentContentProps) => {
)}
<Tools />
</div>
</>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import NavbarIcon from '@renderer/components/NavbarIcon'
import { Drawer, Tooltip } from 'antd'
import { t } from 'i18next'
import { Settings2 } from 'lucide-react'
import { useState } from 'react'
import AgentSettingsTab from '../../../AgentSettingsTab'
const SettingsButton = () => {
const [settingsOpen, setSettingsOpen] = useState(false)
return (
<>
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => setSettingsOpen(true)}>
<Settings2 size={18} />
</NavbarIcon>
</Tooltip>
<Drawer
placement="right"
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
width="var(--assistants-width)"
closable={false}
styles={{ body: { padding: 0, paddingTop: 'var(--navbar-height)' } }}>
<AgentSettingsTab />
</Drawer>
</>
)
}
export default SettingsButton

View File

@@ -0,0 +1,69 @@
import { HStack } from '@renderer/components/Layout'
import NavbarIcon from '@renderer/components/NavbarIcon'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { modelGenerating } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useShowTopics } from '@renderer/hooks/useStore'
import { useAppDispatch } from '@renderer/store'
import { setNarrowMode } from '@renderer/store/settings'
import { Tooltip } from 'antd'
import { PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SettingsButton from './SettingsButton'
const Tools = () => {
const { t } = useTranslation()
const { showTopics, toggleShowTopics } = useShowTopics()
const { isTopNavbar } = useNavbarPosition()
const { topicPosition, narrowMode } = useSettings()
const dispatch = useAppDispatch()
const handleNarrowModeToggle = async () => {
await modelGenerating()
dispatch(setNarrowMode(!narrowMode))
}
return (
<HStack alignItems="center" gap={8}>
<SettingsButton />
{isTopNavbar && (
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={handleNarrowModeToggle}>
<i className="iconfont icon-icon-adaptive-width"></i>
</NarrowIcon>
</Tooltip>
)}
{isTopNavbar && (
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => SearchPopup.show()}>
<Search size={18} />
</NavbarIcon>
</Tooltip>
)}
{isTopNavbar && topicPosition === 'right' && !showTopics && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
<NavbarIcon onClick={toggleShowTopics}>
<PanelLeftClose size={18} />
</NavbarIcon>
</Tooltip>
)}
{isTopNavbar && topicPosition === 'right' && showTopics && (
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={2}>
<NavbarIcon onClick={toggleShowTopics}>
<PanelRightClose size={18} />
</NavbarIcon>
</Tooltip>
)}
</HStack>
)
}
const NarrowIcon = styled(NavbarIcon)`
@media (max-width: 1000px) {
display: none;
}
`
export default Tools

View File

@@ -0,0 +1,45 @@
import { NavbarHeader } from '@renderer/components/app/Navbar'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { AgentEntity } from '@renderer/types'
import { cn } from '@renderer/utils'
import AgentContent from './AgentContent'
interface Props {
activeAgent: AgentEntity
className?: string
}
const AgentChatNavbar = ({ activeAgent, className }: Props) => {
const { toggleShowAssistants } = useShowAssistants()
const { topicPosition } = useSettings()
const { toggleShowTopics } = useShowTopics()
useShortcut('toggle_show_assistants', toggleShowAssistants)
useShortcut('toggle_show_topics', () => {
if (topicPosition === 'right') {
toggleShowTopics()
} else {
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
}
})
useShortcut('search_message', () => {
SearchPopup.show()
})
return (
<NavbarHeader className={cn('agent-navbar h-(--navbar-height)', className)}>
<div className="flex h-full min-w-0 flex-1 shrink items-center overflow-auto">
<AgentContent activeAgent={activeAgent} />
</div>
</NavbarHeader>
)
}
export default AgentChatNavbar

View File

@@ -8,7 +8,6 @@ import { cn } from '@renderer/utils'
import type { MenuProps } from 'antd'
import { Dropdown, Tooltip } from 'antd'
import { Bot, MoreVertical } from 'lucide-react'
import type { FC } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -21,7 +20,7 @@ interface AgentItemProps {
onPress: () => void
}
const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) => {
const AgentItem = ({ agent, isActive, onDelete, onPress }: AgentItemProps) => {
const { t } = useTranslation()
const { clickAssistantToShowTopic, topicPosition, assistantIconType } = useSettings()
const [isHovered, setIsHovered] = useState(false)
@@ -87,7 +86,7 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
trigger={['click']}
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
<MenuButton onClick={handleMenuButtonClick}>
<MoreVertical size={14} className="text-[var(--color-text-secondary)]" />
<MoreVertical size={14} className="text-(--color-text-secondary)" />
</MenuButton>
</Dropdown>
)}
@@ -105,9 +104,9 @@ export const Container: React.FC<{ isActive?: boolean } & React.HTMLAttributes<H
}) => (
<div
className={cn(
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border border-transparent px-2',
!isActive && 'hover:bg-[var(--color-list-item-hover)]',
isActive && 'bg-[var(--color-list-item)] shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
'relative flex h-9.25 w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-(--list-item-border-radius) border border-transparent px-2',
!isActive && 'hover:bg-(--color-list-item-hover)',
isActive && 'bg-(--color-list-item) shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
className
)}
{...props}
@@ -116,7 +115,7 @@ export const Container: React.FC<{ isActive?: boolean } & React.HTMLAttributes<H
export const AssistantNameRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn('flex min-w-0 flex-1 flex-row items-center gap-2 text-[13px] text-[var(--color-text)]', className)}
className={cn('flex min-w-0 flex-1 flex-row items-center gap-2 text-(--color-text) text-[13px]', className)}
{...props}
/>
)
@@ -128,7 +127,7 @@ export const AgentNameWrapper: React.FC<React.HTMLAttributes<HTMLDivElement>> =
export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'flex h-[22px] min-h-[22px] min-w-[22px] flex-row items-center justify-center rounded-[11px] border-[0.5px] border-[var(--color-border)] bg-[var(--color-background)] px-[5px]',
'flex h-5.5 min-h-5.5 min-w-5.5 flex-row items-center justify-center rounded-[11px] border-(--color-border) border-[0.5px] bg-(--color-background) px-1.25',
className
)}
{...props}
@@ -148,7 +147,7 @@ export const BotIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ ...pro
export const SessionCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn('flex flex-row items-center justify-center rounded-full text-[var(--color-text)] text-xs', className)}
className={cn('flex flex-row items-center justify-center rounded-full text-(--color-text) text-xs', className)}
{...props}
/>
)

View File

@@ -10,6 +10,17 @@ import { getModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import { useTextareaResize } from '@renderer/hooks/useTextareaResize'
import { useTimer } from '@renderer/hooks/useTimer'
import { InputbarCore } from '@renderer/pages/home/Inputbar/components/InputbarCore'
import {
InputbarToolsProvider,
useInputbarToolsDispatch,
useInputbarToolsInternalDispatch,
useInputbarToolsState
} from '@renderer/pages/home/Inputbar/context/InputbarToolsProvider'
import InputbarTools from '@renderer/pages/home/Inputbar/InputbarTools'
import { getInputbarConfig } from '@renderer/pages/home/Inputbar/registry'
import type { ToolContext } from '@renderer/pages/home/Inputbar/types'
import { TopicType } from '@renderer/pages/home/Inputbar/types'
import { CacheService } from '@renderer/services/CacheService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
@@ -32,18 +43,6 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { v4 as uuid } from 'uuid'
import { InputbarCore } from './components/InputbarCore'
import {
InputbarToolsProvider,
useInputbarToolsDispatch,
useInputbarToolsInternalDispatch,
useInputbarToolsState
} from './context/InputbarToolsProvider'
import InputbarTools from './InputbarTools'
import { getInputbarConfig } from './registry'
import type { ToolContext } from './types'
import { TopicType } from './types'
const logger = loggerService.withContext('AgentSessionInputbar')
const DRAFT_CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
@@ -55,7 +54,7 @@ type Props = {
sessionId: string
}
const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const AgentSessionInputbar = ({ agentId, sessionId }: Props) => {
const { session } = useSession(agentId, sessionId)
// FIXME: 不应该使用ref将action传到context提供给tool权宜之计
const actionsRef = useRef({

View File

@@ -4,19 +4,17 @@ import { useSession } from '@renderer/hooks/agents/useSession'
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useSettings } from '@renderer/hooks/useSettings'
import MessageAnchorLine from '@renderer/pages/home/Messages/MessageAnchorLine'
import MessageGroup from '@renderer/pages/home/Messages/MessageGroup'
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
import PermissionModeDisplay from '@renderer/pages/home/Messages/PermissionModeDisplay'
import { MessagesContainer, ScrollContainer } from '@renderer/pages/home/Messages/shared'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getGroupedMessages } from '@renderer/services/MessagesService'
import { type Topic, TopicType } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Spin } from 'antd'
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import styled from 'styled-components'
import MessageAnchorLine from './MessageAnchorLine'
import MessageGroup from './MessageGroup'
import NarrowLayout from './NarrowLayout'
import PermissionModeDisplay from './PermissionModeDisplay'
import { MessagesContainer, ScrollContainer } from './shared'
const logger = loggerService.withContext('AgentSessionMessages')
@@ -25,7 +23,7 @@ type Props = {
sessionId: string
}
const AgentSessionMessages: React.FC<Props> = ({ agentId, sessionId }) => {
const AgentSessionMessages = ({ agentId, sessionId }: Props) => {
const { session } = useSession(agentId, sessionId)
const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId])
// Use the same hook as Messages.tsx for consistent behavior
@@ -101,9 +99,9 @@ const AgentSessionMessages: React.FC<Props> = ({ agentId, sessionId }) => {
) : session ? (
<PermissionModeDisplay session={session} agentId={agentId} />
) : (
<LoadingState>
<div className="flex items-center justify-center py-5">
<Spin size="small" />
</LoadingState>
</div>
)}
</ScrollContainer>
</ContextMenu>
@@ -113,13 +111,6 @@ const AgentSessionMessages: React.FC<Props> = ({ agentId, sessionId }) => {
)
}
const LoadingState = styled.div`
display: flex;
justify-content: center;
align-items: center;
padding: 20px 0;
`
const FALLBACK_TIMESTAMP = '1970-01-01T00:00:00.000Z'
export default memo(AgentSessionMessages)

View File

@@ -0,0 +1,71 @@
import { TopView } from '@renderer/components/TopView'
import { isMac } from '@renderer/config/constant'
import { useTimer } from '@renderer/hooks/useTimer'
import { Drawer } from 'antd'
import { useState } from 'react'
import AgentSidePanel from '../AgentSidePanel'
interface Props {
resolve: () => void
}
const PopupContainer = ({ resolve }: Props) => {
const [open, setOpen] = useState(true)
const { setTimeoutTimer } = useTimer()
const onClose = () => {
setOpen(false)
setTimeoutTimer('onClose', resolve, 300)
}
AgentSidePanelDrawer.hide = onClose
return (
<Drawer
title={null}
height="100vh"
placement="left"
open={open}
onClose={onClose}
style={{ width: 'var(--assistants-width)' }}
styles={{
header: { display: 'none' },
body: {
display: 'flex',
padding: 0,
paddingTop: isMac ? 'var(--navbar-height)' : 0,
height: 'calc(100vh - var(--navbar-height))',
overflow: 'hidden',
backgroundColor: 'var(--color-background-opacity)'
},
wrapper: {
width: 'var(--assistants-width)'
}
}}>
<AgentSidePanel onSelectItem={onClose} />
</Drawer>
)
}
const TopViewKey = 'AgentSidePanelDrawer'
export default class AgentSidePanelDrawer {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show() {
return new Promise<void>((resolve) => {
TopView.show(
<PopupContainer
resolve={() => {
resolve()
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@@ -11,7 +11,7 @@ import { apiModelAdapter } from '@renderer/utils/model'
import type { ButtonProps } from 'antd'
import { Button } from 'antd'
import { ChevronsUpDown } from 'lucide-react'
import type { CSSProperties, FC } from 'react'
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
@@ -34,7 +34,7 @@ interface Props {
containerClassName?: string
}
const SelectAgentBaseModelButton: FC<Props> = ({
const SelectAgentBaseModelButton = ({
agentBase: agent,
onSelect,
isDisabled,
@@ -45,7 +45,7 @@ const SelectAgentBaseModelButton: FC<Props> = ({
fontSize = 12,
iconSize = 14,
containerClassName
}) => {
}: Props) => {
const { t } = useTranslation()
const model = useApiModel({ id: agent?.model })
@@ -89,7 +89,7 @@ const SelectAgentBaseModelButton: FC<Props> = ({
<div className={containerClassName || 'flex w-full items-center gap-1.5'}>
<div className="flex flex-1 items-center gap-1.5 overflow-x-hidden">
<ModelAvatar model={model ? apiModelAdapter(model) : undefined} size={avatarSize} />
<span className="truncate text-[var(--color-text)]">
<span className="truncate text-(--color-text)">
{model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
</span>
</div>

View File

@@ -16,7 +16,6 @@ import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import type { MenuProps } from 'antd'
import { Dropdown, Tooltip } from 'antd'
import { MenuIcon, Sparkles, XIcon } from 'lucide-react'
import type { FC } from 'react'
import React, { memo, startTransition, useDeferredValue, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -31,7 +30,7 @@ interface SessionItemProps {
onPress: () => void
}
const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress }) => {
const SessionItem = ({ session, agentId, onDelete, onPress }: SessionItemProps) => {
const { t } = useTranslation()
const { chat } = useRuntime()
const { updateSession } = useUpdateSession(agentId)

View File

@@ -1,10 +1,11 @@
import AddButton from '@renderer/components/AddButton'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
import { newMessagesActions } from '@renderer/store/newMessage'
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import { setActiveSessionIdAction } from '@renderer/store/runtime'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { formatErrorMessage } from '@renderer/utils/error'
import { Alert, Button, Spin } from 'antd'
@@ -14,17 +15,17 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AddButton from './AddButton'
import SessionItem from './SessionItem'
interface SessionsProps {
agentId: string
onSelectItem?: () => void
}
const LOAD_MORE_THRESHOLD = 100
const SCROLL_THROTTLE_DELAY = 150
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const Sessions = ({ agentId, onSelectItem }: SessionsProps) => {
const { t } = useTranslation()
const { sessions, isLoading, error, deleteSession, hasMore, loadMore, isLoadingMore, isValidating, reload } =
useSessions(agentId)
@@ -77,7 +78,6 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const setActiveSessionId = useCallback(
(agentId: string, sessionId: string | null) => {
dispatch(setActiveSessionIdAction({ agentId, sessionId }))
dispatch(setActiveTopicOrSessionAction('session'))
},
[dispatch]
)
@@ -172,7 +172,10 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
session={session}
agentId={agentId}
onDelete={() => handleDeleteSession(session.id)}
onPress={() => setActiveSessionId(agentId, session.id)}
onPress={() => {
setActiveSessionId(agentId, session.id)
onSelectItem?.()
}}
/>
)}
</StyledVirtualList>

View File

@@ -0,0 +1,41 @@
import AgentModalPopup from '@renderer/components/Popups/agent/AgentModal'
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
import { useApiServer } from '@renderer/hooks/useApiServer'
import type { AgentEntity } from '@renderer/types'
import { Button } from 'antd'
import { Bot, Plus } from 'lucide-react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import AgentStatusScreen from './AgentStatusScreen'
const AgentEmpty = () => {
const { t } = useTranslation()
const { apiServerRunning, startApiServer } = useApiServer()
const { setActiveAgentId } = useActiveAgent()
const handleAddAgent = useCallback(() => {
!apiServerRunning && startApiServer()
AgentModalPopup.show({
afterSubmit: (agent: AgentEntity) => {
setActiveAgentId(agent.id)
}
})
}, [apiServerRunning, startApiServer, setActiveAgentId])
return (
<AgentStatusScreen
icon={Bot}
iconClassName="text-(--color-text-secondary)"
title={t('agent.empty.title')}
description={t('agent.empty.description')}
actions={
<Button type="default" icon={<Plus size={16} />} onClick={handleAddAgent}>
{t('agent.add.title')}
</Button>
}
/>
)
}
export default AgentEmpty

View File

@@ -0,0 +1,39 @@
import { useApiServer } from '@renderer/hooks/useApiServer'
import { Button } from 'antd'
import { ServerOff, Settings } from 'lucide-react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import AgentStatusScreen from './AgentStatusScreen'
const AgentServerDisabled = () => {
const { t } = useTranslation()
const { startApiServer } = useApiServer()
const navigate = useNavigate()
const handleGoToSettings = useCallback(() => {
navigate('/settings/api-server')
}, [navigate])
return (
<AgentStatusScreen
icon={ServerOff}
iconClassName="text-(--color-status-warning)"
title={t('agent.warning.enable_server')}
description={t('agent.warning.enable_server_description')}
actions={
<>
<Button type="primary" onClick={startApiServer}>
{t('agent.warning.enable_and_start')}
</Button>
<Button type="default" icon={<Settings size={16} />} onClick={handleGoToSettings}>
{t('common.go_to_settings')}
</Button>
</>
}
/>
)
}
export default AgentServerDisabled

View File

@@ -0,0 +1,39 @@
import { useApiServer } from '@renderer/hooks/useApiServer'
import { Button } from 'antd'
import { ServerCrash, Settings } from 'lucide-react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import AgentStatusScreen from './AgentStatusScreen'
const AgentServerStopped = () => {
const { t } = useTranslation()
const { startApiServer } = useApiServer()
const navigate = useNavigate()
const handleGoToSettings = useCallback(() => {
navigate('/settings/api-server')
}, [navigate])
return (
<AgentStatusScreen
icon={ServerCrash}
iconClassName="text-(--color-error)"
title={t('agent.warning.server_not_running')}
description={t('agent.warning.server_not_running_description')}
actions={
<>
<Button type="primary" onClick={startApiServer}>
{t('apiServer.actions.start')}
</Button>
<Button type="default" icon={<Settings size={16} />} onClick={handleGoToSettings}>
{t('common.go_to_settings')}
</Button>
</>
}
/>
)
}
export default AgentServerStopped

View File

@@ -0,0 +1,30 @@
import type { LucideIcon } from 'lucide-react'
import { motion } from 'motion/react'
import type { ReactNode } from 'react'
interface AgentStatusScreenProps {
icon: LucideIcon
iconClassName: string
title: string
description: string
actions: ReactNode
}
const AgentStatusScreen = ({ icon: Icon, iconClassName, title, description, actions }: AgentStatusScreenProps) => {
return (
<motion.div
className="flex h-full w-full flex-col items-center justify-center gap-4"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, ease: 'easeOut' }}>
<Icon size={56} strokeWidth={1.2} className={iconClassName} />
<div className="flex flex-col items-center gap-2">
<h3 className="m-0 font-medium text-(--color-text) text-base">{title}</h3>
<p className="m-0 max-w-xs text-center text-(--color-text-secondary) text-sm">{description}</p>
</div>
<div className="flex gap-3">{actions}</div>
</motion.div>
)
}
export default AgentStatusScreen

View File

@@ -0,0 +1,3 @@
export { default as AgentEmpty } from './AgentEmpty'
export { default as AgentServerDisabled } from './AgentServerDisabled'
export { default as AgentServerStopped } from './AgentServerStopped'

View File

@@ -7,10 +7,8 @@ import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { SelectModelPopup } from '@renderer/components/Popups/SelectModelPopup'
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
import { isEmbeddingModel, isRerankModel, isWebSearchModel } from '@renderer/config/models'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowTopics } from '@renderer/hooks/useStore'
@@ -18,24 +16,19 @@ import { useTimer } from '@renderer/hooks/useTimer'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { Assistant, Model, Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Alert, Flex } from 'antd'
import { Flex } from 'antd'
import { debounce } from 'lodash'
import { AnimatePresence, motion } from 'motion/react'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import React, { useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ChatNavbar from './components/ChatNavBar'
import AgentSessionInputbar from './Inputbar/AgentSessionInputbar'
import { PinnedTodoPanel } from './Inputbar/components/PinnedTodoPanel'
import Inputbar from './Inputbar/Inputbar'
import AgentSessionMessages from './Messages/AgentSessionMessages'
import ChatNavigation from './Messages/ChatNavigation'
import Messages from './Messages/Messages'
import NarrowLayout from './Messages/NarrowLayout'
import Tabs from './Tabs'
const logger = loggerService.withContext('Chat')
@@ -54,12 +47,6 @@ const Chat: FC<Props> = (props) => {
const { showTopics } = useShowTopics()
const { isMultiSelectMode } = useChatContext(props.activeTopic)
const { isTopNavbar } = useNavbarPosition()
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId, activeSessionIdMap } = chat
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
const { apiServer } = useSettings()
const sessionAgentId = activeTopicOrSession === 'session' ? activeAgentId : null
const { createDefaultSession } = useCreateDefaultSession(sessionAgentId)
const mainRef = React.useRef<HTMLDivElement>(null)
const contentSearchRef = React.useRef<ContentSearchRef>(null)
@@ -98,21 +85,6 @@ const Chat: FC<Props> = (props) => {
}
})
useShortcut(
'new_topic',
() => {
if (activeTopicOrSession !== 'session' || !activeAgentId) {
return
}
void createDefaultSession()
},
{
enabled: activeTopicOrSession === 'session',
preventDefault: true,
enableOnFormTags: true
}
)
useShortcut('select_model', async () => {
const modelFilter = (m: Model) => !isEmbeddingModel(m) && !isRerankModel(m)
const selectedModel = await SelectModelPopup.show({ model: assistant?.model, filter: modelFilter })
@@ -178,20 +150,6 @@ const Chat: FC<Props> = (props) => {
const mainHeight = isTopNavbar ? 'calc(100vh - var(--navbar-height) - 6px)' : 'calc(100vh - var(--navbar-height))'
// TODO: more info
const AgentInvalid = useCallback(() => {
return <Alert type="warning" message={t('chat.alerts.select_agent')} style={{ margin: '5px 16px' }} />
}, [t])
// TODO: more info
const SessionInvalid = useCallback(() => {
return (
<div className="flex h-full w-full items-center justify-center">
<Alert type="warning" message={t('chat.alerts.create_session')} style={{ margin: '5px 16px' }} />
</div>
)
}, [t])
return (
<Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
<HStack>
@@ -217,47 +175,23 @@ const Chat: FC<Props> = (props) => {
<div
className="flex flex-1 flex-col justify-between"
style={{ height: `calc(${mainHeight} - var(--navbar-height))` }}>
{activeTopicOrSession === 'topic' && (
<>
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</>
)}
{activeTopicOrSession === 'session' && !activeAgentId && <AgentInvalid />}
{activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && <SessionInvalid />}
{activeTopicOrSession === 'session' && activeAgentId && activeSessionId && (
<>
{!apiServer.enabled ? (
<Alert type="warning" message={t('agent.warning.enable_server')} style={{ margin: '5px 16px' }} />
) : (
<>
<AgentSessionMessages agentId={activeAgentId} sessionId={activeSessionId} />
<PinnedTodoPanelWrapper>
<NarrowLayout>
<PinnedTodoPanel topicId={buildAgentSessionTopicId(activeSessionId)} />
</NarrowLayout>
</PinnedTodoPanelWrapper>
</>
)}
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
<AgentSessionInputbar agentId={activeAgentId} sessionId={activeSessionId} />
</>
)}
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
</div>
</QuickPanelProvider>
@@ -311,9 +245,4 @@ const Main = styled(Flex)`
position: relative;
`
const PinnedTodoPanelWrapper = styled.div`
margin-top: auto;
padding: 0 18px 8px 18px;
`
export default Chat

View File

@@ -1,12 +1,9 @@
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { useAgentSessionInitializer } from '@renderer/hooks/agents/useAgentSessionInitializer'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useActiveTopic } from '@renderer/hooks/useTopic'
import NavigationService from '@renderer/services/NavigationService'
import { newMessagesActions } from '@renderer/store/newMessage'
import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { Assistant, Topic } from '@renderer/types'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SECOND_MIN_WINDOW_WIDTH } from '@shared/config/constant'
import { AnimatePresence, motion } from 'motion/react'
@@ -27,9 +24,6 @@ const HomePage: FC = () => {
const navigate = useNavigate()
const { isLeftNavbar } = useNavbarPosition()
// Initialize agent session hook
useAgentSessionInitializer()
const location = useLocation()
const state = location.state
@@ -39,26 +33,20 @@ const HomePage: FC = () => {
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id ?? '', state?.topic)
const { showAssistants, showTopics, topicPosition } = useSettings()
const dispatch = useDispatch()
const { chat } = useRuntime()
const { activeTopicOrSession } = chat
_activeAssistant = activeAssistant
const setActiveAssistant = useCallback(
// TODO: allow to set it as null.
(newAssistant: Assistant) => {
if (newAssistant.id === activeAssistant?.id) return
startTransition(() => {
_setActiveAssistant(newAssistant)
if (newAssistant.id !== 'fake') {
dispatch(setActiveAgentId(null))
}
// 同步更新 active topic避免不必要的重新渲染
const newTopic = newAssistant.topics[0]
_setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic))
})
},
[_setActiveTopic, activeAssistant?.id, dispatch]
[_setActiveTopic, activeAssistant?.id]
)
const setActiveTopic = useCallback(
@@ -66,7 +54,6 @@ const HomePage: FC = () => {
startTransition(() => {
_setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic))
dispatch(newMessagesActions.setTopicFulfilled({ topicId: newTopic.id, fulfilled: false }))
dispatch(setActiveTopicOrSessionAction('topic'))
})
},
[_setActiveTopic, dispatch]
@@ -100,7 +87,6 @@ const HomePage: FC = () => {
setActiveTopic={setActiveTopic}
setActiveAssistant={setActiveAssistant}
position="left"
activeTopicOrSession={activeTopicOrSession}
/>
)}
<ContentContainer id={isLeftNavbar ? 'content-container' : undefined}>

View File

@@ -21,6 +21,7 @@ import { Sparkle } from 'lucide-react'
import type { FC } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import styled from 'styled-components'
import MessageTokens from './MessageTokens'
@@ -43,9 +44,10 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, topic, isGro
const { theme } = useTheme()
const { userName, sidebarIcons } = useSettings()
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId } = chat
const { activeAgentId } = chat
const { agent } = useAgent(activeAgentId)
const isAgentView = activeTopicOrSession === 'session'
const { pathname } = useLocation()
const isAgentView = pathname.startsWith('/agents')
const { t } = useTranslation()
const { isBubbleStyle } = useMessageStyle()
const { openMinappById } = useMinappPopup()

View File

@@ -26,16 +26,9 @@ interface Props {
setActiveTopic: (topic: Topic) => void
setActiveAssistant: (assistant: Assistant) => void
position: 'left' | 'right'
activeTopicOrSession?: 'topic' | 'session'
}
const HeaderNavbar: FC<Props> = ({
activeAssistant,
setActiveAssistant,
activeTopic,
setActiveTopic,
activeTopicOrSession
}) => {
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => {
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { topicPosition, narrowMode } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics()
@@ -121,10 +114,9 @@ const HeaderNavbar: FC<Props> = ({
<NavbarRight
style={{
justifyContent: 'flex-end',
flex: activeTopicOrSession === 'topic' ? 1 : 'none',
flex: 1,
position: 'relative',
paddingRight: '15px',
minWidth: activeTopicOrSession === 'topic' ? '' : 'auto'
paddingRight: '15px'
}}
className="home-navbar-right">
<HStack alignItems="center" gap={6}>

View File

@@ -1,24 +1,21 @@
import { createSelector } from '@reduxjs/toolkit'
import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiServer } from '@renderer/hooks/useApiServer'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
import { useTags } from '@renderer/hooks/useTags'
import type { Assistant, AssistantsSortType, Topic } from '@renderer/types'
import type { RootState } from '@renderer/store'
import { useAppSelector } from '@renderer/store'
import type { Assistant, AssistantsSortType } from '@renderer/types'
import type { FC } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import * as tinyPinyin from 'tiny-pinyin'
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'
import AssistantAddButton from './components/AssistantAddButton'
import { AssistantList } from './components/AssistantList'
import { AssistantTagGroups } from './components/AssistantTagGroups'
interface AssistantsTabProps {
activeAssistant: Assistant
@@ -27,52 +24,89 @@ interface AssistantsTabProps {
onCreateDefaultAssistant: () => void
}
const selectTagsOrder = createSelector(
[(state: RootState) => state.assistants],
(assistants) => assistants.tagsOrder ?? []
)
const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props
const containerRef = useRef<HTMLDivElement>(null)
const { apiServerConfig } = useApiServer()
const apiServerEnabled = apiServerConfig.enabled
const { chat } = useRuntime()
const { t } = useTranslation()
// 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,
agentsLoading,
agentsError,
updateAssistants
})
const savedTagsOrder = useAppSelector(selectTagsOrder)
// Sorting
const { sortByPinyinAsc, sortByPinyinDesc } = useUnifiedSorting({
unifiedItems,
updateAssistants
})
const sortByPinyin = useCallback(
(isAscending: boolean) => {
const sorted = [...assistants].sort((a, b) => {
const pinyinA = tinyPinyin.convertToPinyin(a.name, '', true)
const pinyinB = tinyPinyin.convertToPinyin(b.name, '', true)
return isAscending ? pinyinA.localeCompare(pinyinB) : pinyinB.localeCompare(pinyinA)
})
updateAssistants(sorted)
},
[assistants, updateAssistants]
)
const sortByPinyinAsc = useCallback(() => sortByPinyin(true), [sortByPinyin])
const sortByPinyinDesc = useCallback(() => sortByPinyin(false), [sortByPinyin])
// Grouping
const { groupedUnifiedItems, handleUnifiedGroupReorder } = useUnifiedGrouping({
unifiedItems,
assistants,
agents,
apiServerEnabled,
agentsLoading,
agentsError,
updateAssistants
})
const groupedAssistantItems = useMemo(() => {
const groups = new Map<string, Assistant[]>()
assistants.forEach((assistant) => {
const tags = assistant.tags?.length ? assistant.tags : [t('assistants.tags.untagged')]
tags.forEach((tag) => {
if (!groups.has(tag)) {
groups.set(tag, [])
}
groups.get(tag)!.push(assistant)
})
})
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
if (savedTagsOrder.length > 0) {
const indexA = savedTagsOrder.indexOf(tagA)
const indexB = savedTagsOrder.indexOf(tagB)
if (indexA !== -1 && indexB !== -1) return indexA - indexB
if (indexA !== -1) return -1
if (indexB !== -1) return 1
}
return 0
})
return sortedGroups.map(([tag, items]) => ({ tag, items }))
}, [assistants, t, savedTagsOrder])
const handleAssistantGroupReorder = useCallback(
(tag: string, newGroupList: Assistant[]) => {
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 = newGroupList[insertIndex]
insertIndex += 1
return replaced || a
}
return a
})
updateAssistants(updatedAssistants)
},
[assistants, t, updateAssistants]
)
const onDeleteAssistant = useCallback(
(assistant: Assistant) => {
@@ -98,53 +132,22 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
[setAssistantsTabSortType]
)
const handleAgentPress = useCallback(
(agentId: string) => {
setActiveAgentId(agentId)
// TODO: should allow it to be null
setActiveAssistant({
id: 'fake',
name: '',
prompt: '',
topics: [
{
id: 'fake',
assistantId: 'fake',
name: 'fake',
createdAt: '',
updatedAt: '',
messages: []
} as unknown as Topic
],
type: 'chat'
})
},
[setActiveAgentId, setActiveAssistant]
)
return (
<Container className="assistants-tab" ref={containerRef}>
<UnifiedAddButton
onCreateAssistant={onCreateAssistant}
setActiveAssistant={setActiveAssistant}
setActiveAgentId={setActiveAgentId}
/>
<AssistantAddButton onCreateAssistant={onCreateAssistant} />
{assistantsTabSortType === 'tags' ? (
<UnifiedTagGroups
groupedItems={groupedUnifiedItems}
<AssistantTagGroups
groupedItems={groupedAssistantItems}
activeAssistantId={activeAssistant.id}
activeAgentId={activeAgentId}
sortBy={assistantsTabSortType}
collapsedTags={collapsedTags}
onGroupReorder={handleUnifiedGroupReorder}
onGroupReorder={handleAssistantGroupReorder}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}
onToggleTagCollapse={toggleTagCollapse}
onAssistantSwitch={setActiveAssistant}
onAssistantDelete={onDeleteAssistant}
onAgentDelete={deleteAgent}
onAgentPress={handleAgentPress}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
@@ -153,18 +156,15 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
sortByPinyinDesc={sortByPinyinDesc}
/>
) : (
<UnifiedList
items={unifiedItems}
<AssistantList
items={assistants}
activeAssistantId={activeAssistant.id}
activeAgentId={activeAgentId}
sortBy={assistantsTabSortType}
onReorder={handleUnifiedListReorder}
onReorder={updateAssistants}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}
onAssistantSwitch={setActiveAssistant}
onAssistantDelete={onDeleteAssistant}
onAgentDelete={deleteAgent}
onAgentPress={handleAgentPress}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}

View File

@@ -1,37 +0,0 @@
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { cn } from '@renderer/utils'
import { Alert } from 'antd'
import { AnimatePresence, motion } from 'framer-motion'
import type { FC } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Sessions from './components/Sessions'
interface SessionsTabProps {}
const SessionsTab: FC<SessionsTabProps> = () => {
const { chat } = useRuntime()
const { activeAgentId } = chat
const { t } = useTranslation()
const { apiServer } = useSettings()
if (!apiServer.enabled) {
return <Alert type="warning" message={t('agent.warning.enable_server')} style={{ margin: 10 }} />
}
if (!activeAgentId) {
return <Alert type="warning" message={'Select an agent'} style={{ margin: 10 }} />
}
return (
<AnimatePresence mode="wait">
<motion.div className={cn('overflow-hidden', 'h-full')}>
<Sessions agentId={activeAgentId} />
</motion.div>
</AnimatePresence>
)
}
export default memo(SessionsTab)

View File

@@ -1,11 +1,7 @@
import { useRuntime } from '@renderer/hooks/useRuntime'
import type { Assistant, Topic } from '@renderer/types'
import type { FC } from 'react'
import { Topics } from './components/Topics'
import SessionsTab from './SessionsTab'
// const logger = loggerService.withContext('TopicsTab')
interface Props {
assistant: Assistant
@@ -15,15 +11,7 @@ interface Props {
}
const TopicsTab: FC<Props> = (props) => {
const { chat } = useRuntime()
const { activeTopicOrSession } = chat
if (activeTopicOrSession === 'topic') {
return <Topics {...props} />
}
if (activeTopicOrSession === 'session') {
return <SessionsTab />
}
return 'Not a valid state.'
return <Topics {...props} />
}
export default TopicsTab

View File

@@ -0,0 +1,19 @@
import AddButton from '@renderer/components/AddButton'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface AssistantAddButtonProps {
onCreateAssistant: () => void
}
const AssistantAddButton: FC<AssistantAddButtonProps> = ({ onCreateAssistant }) => {
const { t } = useTranslation()
return (
<div className="-mt-0.5 mb-1.5">
<AddButton onClick={onCreateAssistant}>{t('chat.add.assistant.title')}</AddButton>
</div>
)
}
export default AssistantAddButton

View File

@@ -6,8 +6,6 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useTags } from '@renderer/hooks/useTags'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
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, uuid } from '@renderer/utils'
import { hasTopicPendingRequests } from '@renderer/utils/queue'
@@ -70,7 +68,6 @@ const AssistantItem: FC<AssistantItemProps> = ({
const [isPending, setIsPending] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const dispatch = useAppDispatch()
useEffect(() => {
if (isActive) {
@@ -140,8 +137,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
}
}
onSwitch(assistant)
dispatch(setActiveTopicOrSessionAction('topic'))
}, [clickAssistantToShowTopic, onSwitch, assistant, dispatch, topicPosition])
}, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition])
const assistantName = useMemo(() => assistant.name || t('chat.default.name'), [assistant.name, t])
const fullAssistantName = useMemo(
@@ -177,7 +173,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
trigger={['click']}
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
<MenuButton onClick={handleMenuButtonClick}>
<MoreVertical size={14} className="text-[var(--color-text-secondary)]" />
<MoreVertical size={14} className="text-(--color-text-secondary)" />
</MenuButton>
</Dropdown>
)}
@@ -402,9 +398,9 @@ const Container = ({
<div
{...props}
className={cn(
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border-[0.5px] border-transparent px-2',
!isActive && 'hover:bg-[var(--color-list-item-hover)]',
isActive && 'bg-[var(--color-list-item)] shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
'relative flex h-9.25 w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-(--list-item-border-radius) border-[0.5px] border-transparent px-2',
!isActive && 'hover:bg-(--color-list-item-hover)',
isActive && 'bg-(--color-list-item) shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
className
)}>
{children}
@@ -418,7 +414,7 @@ const AssistantNameRow = ({
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn('flex min-w-0 flex-1 flex-row items-center gap-2 text-[13px] text-[var(--color-text)]', className)}>
className={cn('flex min-w-0 flex-1 flex-row items-center gap-2 text-(--color-text) text-[13px]', className)}>
{children}
</div>
)
@@ -443,7 +439,7 @@ const MenuButton = ({
<div
{...props}
className={cn(
'absolute top-[6px] right-[9px] flex h-[22px] min-h-[22px] min-w-[22px] flex-row items-center justify-center rounded-[11px] border-[0.5px] border-[var(--color-border)] bg-[var(--color-background)] px-[5px]',
'absolute top-1.5 right-2.25 flex h-5.5 min-h-5.5 min-w-5.5 flex-row items-center justify-center rounded-[11px] border-(--color-border) border-[0.5px] bg-(--color-background) px-1.25',
className
)}>
{children}

View File

@@ -0,0 +1,86 @@
import { DraggableList } from '@renderer/components/DraggableList'
import type { Assistant, AssistantsSortType } from '@renderer/types'
import type { FC } from 'react'
import { useCallback } from 'react'
import AssistantItem from './AssistantItem'
interface AssistantListProps {
items: Assistant[]
activeAssistantId: string
sortBy: AssistantsSortType
onReorder: (newList: Assistant[]) => void
onDragStart: () => void
onDragEnd: () => void
onAssistantSwitch: (assistant: Assistant) => void
onAssistantDelete: (assistant: Assistant) => void
addPreset: (assistant: Assistant) => void
copyAssistant: (assistant: Assistant) => void
onCreateDefaultAssistant: () => void
handleSortByChange: (sortType: AssistantsSortType) => void
sortByPinyinAsc: () => void
sortByPinyinDesc: () => void
}
export const AssistantList: FC<AssistantListProps> = (props) => {
const {
items,
activeAssistantId,
sortBy,
onReorder,
onDragStart,
onDragEnd,
onAssistantSwitch,
onAssistantDelete,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
} = props
const renderAssistantItem = useCallback(
(assistant: Assistant) => {
return (
<AssistantItem
key={`assistant-${assistant.id}`}
assistant={assistant}
isActive={assistant.id === activeAssistantId}
sortBy={sortBy}
onSwitch={onAssistantSwitch}
onDelete={onAssistantDelete}
addPreset={addPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)
},
[
activeAssistantId,
sortBy,
onAssistantSwitch,
onAssistantDelete,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
]
)
return (
<DraggableList
list={items}
itemKey={(assistant) => `assistant-${assistant.id}`}
onUpdate={onReorder}
onDragStart={onDragStart}
onDragEnd={onDragEnd}>
{renderAssistantItem}
</DraggableList>
)
}

View File

@@ -4,30 +4,25 @@ import type { FC } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import type { UnifiedItem } from '../hooks/useUnifiedItems'
import AgentItem from './AgentItem'
import AssistantItem from './AssistantItem'
import { TagGroup } from './TagGroup'
interface GroupedItems {
tag: string
items: UnifiedItem[]
items: Assistant[]
}
interface UnifiedTagGroupsProps {
interface AssistantTagGroupsProps {
groupedItems: GroupedItems[]
activeAssistantId: string
activeAgentId: string | null
sortBy: AssistantsSortType
collapsedTags: Record<string, boolean>
onGroupReorder: (tag: string, newList: UnifiedItem[]) => void
onGroupReorder: (tag: string, newList: Assistant[]) => 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
@@ -36,11 +31,10 @@ interface UnifiedTagGroupsProps {
sortByPinyinDesc: () => void
}
export const UnifiedTagGroups: FC<UnifiedTagGroupsProps> = (props) => {
export const AssistantTagGroups: FC<AssistantTagGroupsProps> = (props) => {
const {
groupedItems,
activeAssistantId,
activeAgentId,
sortBy,
collapsedTags,
onGroupReorder,
@@ -49,8 +43,6 @@ export const UnifiedTagGroups: FC<UnifiedTagGroupsProps> = (props) => {
onToggleTagCollapse,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
@@ -61,45 +53,30 @@ export const UnifiedTagGroups: FC<UnifiedTagGroupsProps> = (props) => {
const { t } = useTranslation()
const renderUnifiedItem = useCallback(
(item: UnifiedItem) => {
if (item.type === 'agent') {
return (
<AgentItem
key={`agent-${item.data.id}`}
agent={item.data}
isActive={item.data.id === activeAgentId}
onDelete={() => onAgentDelete(item.data.id)}
onPress={() => onAgentPress(item.data.id)}
/>
)
} else {
return (
<AssistantItem
key={`assistant-${item.data.id}`}
assistant={item.data}
isActive={item.data.id === activeAssistantId}
sortBy={sortBy}
onSwitch={onAssistantSwitch}
onDelete={onAssistantDelete}
addPreset={addPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)
}
const renderAssistantItem = useCallback(
(assistant: Assistant) => {
return (
<AssistantItem
key={`assistant-${assistant.id}`}
assistant={assistant}
isActive={assistant.id === activeAssistantId}
sortBy={sortBy}
onSwitch={onAssistantSwitch}
onDelete={onAssistantDelete}
addPreset={addPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)
},
[
activeAgentId,
activeAssistantId,
sortBy,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
@@ -120,11 +97,11 @@ export const UnifiedTagGroups: FC<UnifiedTagGroupsProps> = (props) => {
showTitle={group.tag !== t('assistants.tags.untagged')}>
<DraggableList
list={group.items}
itemKey={(item) => `${item.type}-${item.data.id}`}
itemKey={(assistant) => `assistant-${assistant.id}`}
onUpdate={(newList) => onGroupReorder(group.tag, newList)}
onDragStart={onDragStart}
onDragEnd={onDragEnd}>
{renderUnifiedItem}
{renderAssistantItem}
</DraggableList>
</TagGroup>
))}

View File

@@ -1,3 +1,4 @@
import AddButton from '@renderer/components/AddButton'
import AssistantAvatar from '@renderer/components/Avatar/AssistantAvatar'
import type { DraggableVirtualListRef } from '@renderer/components/DraggableList'
import { DraggableVirtualList } from '@renderer/components/DraggableList'
@@ -59,7 +60,6 @@ import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
import AddButton from './AddButton'
import { TopicManagePanel, useTopicManageMode } from './TopicManageMode'
interface Props {

View File

@@ -1,71 +0,0 @@
import AddAssistantOrAgentPopup from '@renderer/components/Popups/AddAssistantOrAgentPopup'
import AgentModalPopup from '@renderer/components/Popups/agent/AgentModal'
import { useApiServer } from '@renderer/hooks/useApiServer'
import { useAppDispatch } from '@renderer/store'
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { AgentEntity, Assistant, Topic } from '@renderer/types'
import type { FC } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import AddButton from './AddButton'
interface UnifiedAddButtonProps {
onCreateAssistant: () => void
setActiveAssistant: (a: Assistant) => void
setActiveAgentId: (id: string) => void
}
const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant, setActiveAssistant, setActiveAgentId }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const { apiServerRunning, startApiServer } = useApiServer()
const afterCreate = useCallback(
(a: AgentEntity) => {
// TODO: should allow it to be null
setActiveAssistant({
id: 'fake',
name: '',
prompt: '',
topics: [
{
id: 'fake',
assistantId: 'fake',
name: 'fake',
createdAt: '',
updatedAt: '',
messages: []
} as unknown as Topic
],
type: 'chat'
})
setActiveAgentId(a.id)
dispatch(setActiveTopicOrSessionAction('session'))
},
[dispatch, setActiveAgentId, setActiveAssistant]
)
const handleAddButtonClick = async () => {
AddAssistantOrAgentPopup.show({
onSelect: (type) => {
if (type === 'assistant') {
onCreateAssistant()
}
if (type === 'agent') {
!apiServerRunning && startApiServer()
AgentModalPopup.show({ afterSubmit: afterCreate })
}
}
})
}
return (
<div className="-mt-[2px] mb-[6px]">
<AddButton onClick={handleAddButtonClick}>{t('chat.add.assistant.title')}</AddButton>
</div>
)
}
export default UnifiedAddButton

View File

@@ -1,109 +0,0 @@
import { DraggableList } from '@renderer/components/DraggableList'
import type { Assistant, AssistantsSortType } from '@renderer/types'
import type { FC } from 'react'
import { useCallback } from 'react'
import type { 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<UnifiedListProps> = (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 (
<AgentItem
key={`agent-${item.data.id}`}
agent={item.data}
isActive={item.data.id === activeAgentId}
onDelete={() => onAgentDelete(item.data.id)}
onPress={() => onAgentPress(item.data.id)}
/>
)
} else {
return (
<AssistantItem
key={`assistant-${item.data.id}`}
assistant={item.data}
isActive={item.data.id === activeAssistantId}
sortBy={sortBy}
onSwitch={onAssistantSwitch}
onDelete={onAssistantDelete}
addPreset={addPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)
}
},
[
activeAgentId,
activeAssistantId,
sortBy,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
]
)
return (
<DraggableList
list={items}
itemKey={(item) => `${item.type}-${item.data.id}`}
onUpdate={onReorder}
onDragStart={onDragStart}
onDragEnd={onDragEnd}>
{renderUnifiedItem}
</DraggableList>
)
}

View File

@@ -1,19 +0,0 @@
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 }
}

View File

@@ -1,163 +0,0 @@
import { createSelector } from '@reduxjs/toolkit'
import type { RootState } from '@renderer/store'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import type { AgentEntity, Assistant } from '@renderer/types'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type { 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()
// Selector to get tagsOrder from Redux store
const selectTagsOrder = useMemo(
() => createSelector([(state: RootState) => state.assistants], (assistants) => assistants.tagsOrder ?? []),
[]
)
const savedTagsOrder = useAppSelector(selectTagsOrder)
// Group unified items by tags
const groupedUnifiedItems = useMemo(() => {
const groups = new Map<string, UnifiedItem[]>()
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 by savedTagsOrder
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
if (savedTagsOrder.length > 0) {
const indexA = savedTagsOrder.indexOf(tagA)
const indexB = savedTagsOrder.indexOf(tagB)
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB
}
if (indexA !== -1) return -1
if (indexB !== -1) return 1
}
return 0
})
return sortedGroups.map(([tag, items]) => ({ tag, items }))
}, [unifiedItems, t, savedTagsOrder])
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<string, AgentEntity>()
const availableAssistants = new Map<string, Assistant>()
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
}
}

View File

@@ -1,75 +0,0 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import type { 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<string, AgentEntity>()
const availableAssistants = new Map<string, Assistant>()
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 beginning
const newItems: UnifiedItem[] = []
availableAgents.forEach((agent) => newItems.push({ type: 'agent', data: agent }))
availableAssistants.forEach((assistant) => newItems.push({ type: 'assistant', data: assistant }))
items.unshift(...newItems)
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
}
}

View File

@@ -1,56 +0,0 @@
import { useAppDispatch } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import type { Assistant } from '@renderer/types'
import { useCallback } from 'react'
import * as tinyPinyin from 'tiny-pinyin'
import type { 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
}
}

View File

@@ -3,8 +3,6 @@ import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch } from '@renderer/store'
import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { Assistant, Topic } from '@renderer/types'
import type { Tab } from '@renderer/types/chat'
import { classNames, uuid } from '@renderer/utils'
@@ -43,7 +41,6 @@ const HomeTabs: FC<Props> = ({
const { toggleShowTopics } = useShowTopics()
const { isLeftNavbar } = useNavbarPosition()
const { t } = useTranslation()
const dispatch = useAppDispatch()
const [tab, setTab] = useState<Tab>(position === 'left' ? _tab || 'assistants' : 'topic')
const borderStyle = '0.5px solid var(--color-border)'
@@ -62,8 +59,6 @@ const HomeTabs: FC<Props> = ({
const assistant = await AddAssistantPopup.show()
if (assistant) {
setActiveAssistant(assistant)
dispatch(setActiveAgentId(null))
dispatch(setActiveTopicOrSessionAction('topic'))
}
}
@@ -71,8 +66,6 @@ const HomeTabs: FC<Props> = ({
const assistant = { ...defaultAssistant, id: uuid() }
addAssistant(assistant)
setActiveAssistant(assistant)
dispatch(setActiveAgentId(null))
dispatch(setActiveTopicOrSessionAction('topic'))
}
useEffect(() => {

View File

@@ -1,9 +1,6 @@
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
import { useRuntime } from '@renderer/hooks/useRuntime'
import type { Assistant } from '@renderer/types'
import type { FC } from 'react'
import AgentContent from './AgentContent'
import TopicContent from './TopicContent'
interface Props {
@@ -11,14 +8,9 @@ interface Props {
}
const ChatNavbarContent: FC<Props> = ({ assistant }) => {
const { chat } = useRuntime()
const { activeTopicOrSession } = chat
const { agent: activeAgent } = useActiveAgent()
return (
<div className="flex min-w-0 flex-1 items-center justify-between">
{activeTopicOrSession === 'topic' && <TopicContent assistant={assistant} />}
{activeTopicOrSession === 'session' && activeAgent && <AgentContent activeAgent={activeAgent} />}
<TopicContent assistant={assistant} />
</div>
)
}

View File

@@ -1,4 +1,3 @@
import { useRuntime } from '@renderer/hooks/useRuntime'
import type { Assistant } from '@renderer/types'
import { Drawer, Tooltip } from 'antd'
import { t } from 'i18next'
@@ -7,7 +6,7 @@ import type { FC } from 'react'
import { useState } from 'react'
import NavbarIcon from '../../../../../components/NavbarIcon'
import { AgentSettingsTab, AssistantSettingsTab } from './SettingsTab'
import { AssistantSettingsTab } from './SettingsTab'
interface Props {
assistant?: Assistant
@@ -15,10 +14,6 @@ interface Props {
const SettingsButton: FC<Props> = ({ assistant }) => {
const [settingsOpen, setSettingsOpen] = useState(false)
const { chat } = useRuntime()
const isTopicSettings = chat.activeTopicOrSession === 'topic'
const isAgentSettings = chat.activeTopicOrSession === 'session'
return (
<>
@@ -34,8 +29,7 @@ const SettingsButton: FC<Props> = ({ assistant }) => {
width="var(--assistants-width)"
closable={false}
styles={{ body: { padding: 0, paddingTop: 'var(--navbar-height)' } }}>
{isTopicSettings && assistant && <AssistantSettingsTab assistant={assistant} />}
{isAgentSettings && <AgentSettingsTab />}
{assistant && <AssistantSettingsTab assistant={assistant} />}
</Drawer>
</>
)

View File

@@ -1,4 +1,3 @@
import { loggerService } from '@logger'
import EditableNumber from '@renderer/components/EditableNumber'
import Scrollbar from '@renderer/components/Scrollbar'
import Selector from '@renderer/components/Selector'
@@ -9,7 +8,6 @@ import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import useTranslate from '@renderer/hooks/useTranslate'
import { SettingDivider, SettingRow, SettingRowTitle } from '@renderer/pages/settings'
@@ -62,14 +60,11 @@ import styled from 'styled-components'
import GroqSettingsGroup from './GroqSettingsGroup'
import OpenAISettingsGroup from './OpenAISettingsGroup'
const logger = loggerService.withContext('AssistantSettingsTab')
interface Props {
assistant: Assistant
}
const AssistantSettingsTab = (props: Props) => {
const { chat } = useRuntime()
const { assistant } = useAssistant(props.assistant.id)
const { provider } = useProvider(assistant.model.provider)
@@ -150,13 +145,6 @@ const AssistantSettingsTab = (props: Props) => {
isSupportServiceTierProvider(provider) ||
(isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider))
const isTopicSettings = chat.activeTopicOrSession === 'topic'
if (!isTopicSettings) {
logger.warn('AssistantSettingsTab is rendered when not topic activated.')
return null
}
return (
<Container className="settings-tab">
{showOpenAiSettings && (

View File

@@ -1,4 +1,3 @@
import AgentSettingsTab from './AgentSettingsTab'
import AssistantSettingsTab from './AssistantSettingsTab'
export { AgentSettingsTab, AssistantSettingsTab }
export { AssistantSettingsTab }

View File

@@ -1,5 +1,5 @@
import { HelpTooltip } from '@renderer/components/TooltipIcons'
import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton'
import SelectAgentBaseModelButton from '@renderer/pages/agents/components/SelectAgentBaseModelButton'
import type { AgentBaseWithId, ApiModel, UpdateAgentFunctionUnion } from '@renderer/types'
import { useTranslation } from 'react-i18next'

View File

@@ -14,6 +14,7 @@ import {
Languages,
LayoutGrid,
MessageSquareQuote,
MousePointerClick,
NotepadText,
Palette,
Sparkle
@@ -117,6 +118,7 @@ const SidebarIconsManager: FC<SidebarIconsManagerProps> = ({
() =>
({
assistants: <MessageSquareQuote size={16} />,
agents: <MousePointerClick size={16} />,
store: <Sparkle size={16} />,
paintings: <Palette size={16} />,
translate: <Languages size={16} />,

View File

@@ -31,6 +31,7 @@ export interface AssistantsState {
tagsOrder: string[]
collapsedTags: Record<string, boolean>
presets: AssistantPreset[]
/** @deprecated should be removed in v2 */
unifiedListOrder: Array<{ type: 'agent' | 'assistant'; id: string }>
}

View File

@@ -86,7 +86,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 202,
version: 203,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},

View File

@@ -3315,6 +3315,39 @@ const migrateConfig = {
logger.error('migrate 202 error', error as Error)
return state
}
},
'203': (state: RootState) => {
try {
if (state.settings && state.settings.sidebarIcons) {
// Add 'agents' to visible icons if not already present
if (!state.settings.sidebarIcons.visible.includes('agents')) {
// Insert after 'assistants' if present, otherwise append
const assistantsIndex = state.settings.sidebarIcons.visible.indexOf('assistants')
if (assistantsIndex !== -1) {
state.settings.sidebarIcons.visible = [
...state.settings.sidebarIcons.visible.slice(0, assistantsIndex + 1),
'agents',
...state.settings.sidebarIcons.visible.slice(assistantsIndex + 1)
]
} else {
state.settings.sidebarIcons.visible = [...state.settings.sidebarIcons.visible, 'agents']
}
}
}
// Add 'agents' tab if not already present
if (state.tabs && !state.tabs.tabs.some((tab: { id: string }) => tab.id === 'agents')) {
const homeIndex = state.tabs.tabs.findIndex((tab: { id: string }) => tab.id === 'home')
const insertIndex = homeIndex !== -1 ? homeIndex + 1 : state.tabs.tabs.length
state.tabs.tabs.splice(insertIndex, 0, { id: 'agents', path: '/agents' })
}
logger.info('migrate 203 success')
return state
} catch (error) {
logger.error('migrate 203 error', error as Error)
return state
}
}
}

View File

@@ -29,8 +29,6 @@ export interface ChatState {
/** UI state. Map agent id to active session id.
* null represents no active session */
activeSessionIdMap: Record<string, string | null>
/** meanwhile active Assistants or Agents */
activeTopicOrSession: 'topic' | 'session'
/** topic ids that are currently being renamed */
renamingTopics: string[]
/** topic ids that are newly renamed */
@@ -77,6 +75,9 @@ export interface RuntimeState {
detectedRegion: MinAppRegion | null
/** Query whether a task is processing or not. undefined and false share same semantics. */
loadingMap: Record<string, boolean>
// Migrated from useApiServer, it's global state now
/** Is the api server running */
apiServerRunning: boolean
}
export interface ExportState {
@@ -112,7 +113,6 @@ const initialState: RuntimeState = {
selectedMessageIds: [],
activeTopic: null,
activeAgentId: null,
activeTopicOrSession: 'topic',
activeSessionIdMap: {},
renamingTopics: [],
newlyRenamedTopics: []
@@ -121,7 +121,8 @@ const initialState: RuntimeState = {
activeSearches: {}
},
detectedRegion: null,
loadingMap: {}
loadingMap: {},
apiServerRunning: false
}
const runtimeSlice = createSlice({
@@ -188,9 +189,6 @@ const runtimeSlice = createSlice({
const { agentId, sessionId } = action.payload
state.chat.activeSessionIdMap[agentId] = sessionId
},
setActiveTopicOrSessionAction: (state, action: PayloadAction<'topic' | 'session'>) => {
state.chat.activeTopicOrSession = action.payload
},
setRenamingTopics: (state, action: PayloadAction<string[]>) => {
state.chat.renamingTopics = action.payload
},
@@ -218,6 +216,9 @@ const runtimeSlice = createSlice({
},
setDetectedRegion: (state, action: PayloadAction<MinAppRegion | null>) => {
state.detectedRegion = action.payload
},
setApiServerRunningAction: (state, action: PayloadAction<boolean>) => {
state.apiServerRunning = action.payload
}
}
})
@@ -242,7 +243,6 @@ export const {
setActiveTopic,
setActiveAgentId,
setActiveSessionIdAction,
setActiveTopicOrSessionAction,
setRenamingTopics,
setNewlyRenamedTopics,
startLoadingAction,
@@ -251,7 +251,8 @@ export const {
setActiveSearches,
setWebSearchStatus,
// Region detection
setDetectedRegion
setDetectedRegion,
setApiServerRunningAction
} = runtimeSlice.actions
export default runtimeSlice.reducer

View File

@@ -32,6 +32,10 @@ const initialState: TabsState = {
{
id: 'home',
path: '/'
},
{
id: 'agents',
path: '/agents'
}
],
activeTabId: 'home'

View File

@@ -657,6 +657,7 @@ export const isAutoDetectionMethod = (method: string): method is AutoDetectionMe
export type SidebarIcon =
| 'assistants'
| 'agents'
| 'store'
| 'paintings'
| 'translate'