feat: use tabs

wip

wip

wip

wip
This commit is contained in:
kangfenmao 2025-06-15 14:10:36 +08:00
parent 202504fd17
commit 4317f4b672
25 changed files with 619 additions and 252 deletions

View File

@ -5,6 +5,7 @@ import { Provider } from 'react-redux'
import { HashRouter } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import TabsContainer from './components/Tabs/TabsContainer'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
@ -26,7 +27,9 @@ function App(): React.ReactElement {
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Routes />
<TabsContainer>
<Routes />
</TabsContainer>
</HashRouter>
</TopViewContainer>
</PersistGate>

View File

@ -1,111 +1,31 @@
import { IpcChannel } from '@shared/IpcChannel'
import { AnimatePresence, easeInOut, motion } from 'framer-motion'
import { useEffect } from 'react'
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import { Route, Routes } from 'react-router-dom'
import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
import McpServersPage from './pages/mcp-servers'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
const WILDCARD_ROUTES = ['/settings', '/paintings', '/mcp-servers']
const RouteContainer = () => {
const navigate = useNavigate()
const location = useLocation()
const isHomePage = location.pathname === '/'
// 获取当前路径的主路由部分
const mainPath = WILDCARD_ROUTES.find((route) => location.pathname.startsWith(route))
// 使用主路由作为 key这样同一主路由下的切换不会触发动画
const animationKey = mainPath || location.pathname
useEffect(() => {
window.api.navigation.url(location.pathname)
}, [location.pathname])
useEffect(() => {
window.electron.ipcRenderer.on(IpcChannel.Navigation_Close, () => navigate('/'))
}, [navigate])
return (
<Container>
<HomePageWrapper />
<AnimatePresence mode="wait">
{!isHomePage && (
<PageContainer
key={animationKey}
initial="initial"
animate="animate"
exit="exit"
variants={pageVariants}
transition={pageTransition}>
<Routes location={location}>
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/mcp-servers/*" element={<McpServersPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</PageContainer>
)}
</AnimatePresence>
</Container>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/mcp-servers/*" element={<McpServersPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
</Routes>
)
}
const Container = styled.div`
position: relative;
width: 100%;
height: 100%;
min-width: 0;
overflow: hidden;
`
const HomePageWrapper = styled(HomePage)`
display: flex;
width: 100%;
height: 100%;
`
const PageContainer = styled(motion.div)`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-base);
display: flex;
width: 100%;
height: 100%;
z-index: 10;
will-change: transform;
backface-visibility: hidden;
transform-style: preserve-3d;
perspective: 1000px;
-webkit-font-smoothing: subpixel-antialiased;
`
const pageTransition = {
type: 'tween' as const,
duration: 0.25,
ease: easeInOut
}
const pageVariants = {
initial: { translateY: '100%' },
animate: { translateY: '0%' },
exit: { translateY: '100%' }
}
export default RouteContainer

View File

@ -57,23 +57,10 @@
--navbar-background-win: rgba(20, 20, 20, 0.75);
--navbar-background: #1f1f1f;
--navbar-height: 42px;
--sidebar-width: 50px;
--status-bar-height: 40px;
--input-bar-height: 100px;
--assistants-width: 275px;
--topic-list-width: 275px;
--settings-width: 250px;
--scrollbar-width: 5px;
--chat-background: transparent;
--chat-background-user: rgba(255, 255, 255, 0.08);
--chat-background-assistant: transparent;
--chat-text-user: var(--color-black);
--list-item-border-radius: 8px;
--border-width: 0.5px;
}
[theme-mode='light'] {
@ -139,17 +126,4 @@
--chat-background-user: rgba(0, 0, 0, 0.045);
--chat-background-assistant: transparent;
--chat-text-user: var(--color-text);
--border-width: 0.5px;
}
[transparent-window='true'] {
&[theme-mode='light'] {
--color-list-item: rgba(255, 255, 255, 0.8);
--color-list-item-hover: rgba(255, 255, 255, 0.4);
}
&[theme-mode='dark'] {
--color-list-item: rgba(255, 255, 255, 0.1);
--color-list-item-hover: rgba(255, 255, 255, 0.05);
}
}

View File

@ -1,3 +1,4 @@
@use './variables.scss';
@use './color.scss';
@use './font.scss';
@use './markdown.scss';

View File

@ -0,0 +1,18 @@
:root {
--navbar-height: 42px;
--sidebar-width: 50px;
--status-bar-height: 40px;
--input-bar-height: 100px;
--assistants-width: 275px;
--topic-list-width: 275px;
--settings-width: 250px;
--scrollbar-width: 5px;
--list-item-border-radius: 15px;
--border-width: 0.5px;
--main-height: calc(100vh - var(--navbar-height) - 6px);
--border-width: 0.5px;
}

View File

@ -0,0 +1,237 @@
import { PlusOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import type { Tab } from '@renderer/store/tabs'
import { addTab, removeTab, setActiveTab } from '@renderer/store/tabs'
import {
FileSearch,
Folder,
Home,
Languages,
LayoutGrid,
Palette,
Settings,
Sparkle,
SquareTerminal,
X
} from 'lucide-react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
interface TabsContainerProps {
children: React.ReactNode
}
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
switch (tabId) {
case 'home':
return <Home size={14} />
case 'agents':
return <Sparkle size={14} />
case 'translate':
return <Languages size={14} />
case 'paintings':
return <Palette size={14} />
case 'apps':
return <LayoutGrid size={14} />
case 'knowledge':
return <FileSearch size={14} />
case 'mcp':
return <SquareTerminal size={14} />
case 'files':
return <Folder size={14} />
case 'settings':
return <Settings size={14} />
default:
return null
}
}
const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const { t } = useTranslation()
const location = useLocation()
const navigate = useNavigate()
const dispatch = useAppDispatch()
const tabs = useAppSelector((state) => state.tabs.tabs)
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
const isFullscreen = useFullscreen()
const getTabId = (path: string): string => {
if (path === '/') return 'home'
const segments = path.split('/')
return segments[1] // 获取第一个路径段作为 id
}
const shouldCreateTab = (path: string) => {
if (path === '/') return false
return !tabs.some((tab) => tab.id === getTabId(path))
}
useEffect(() => {
const tabId = getTabId(location.pathname)
const currentTab = tabs.find((tab) => tab.id === tabId)
if (!currentTab && shouldCreateTab(location.pathname)) {
dispatch(
addTab({
id: tabId,
path: location.pathname
})
)
} else if (currentTab) {
dispatch(setActiveTab(currentTab.id))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, location.pathname])
const closeTab = (tabId: string) => {
const tabToClose = tabs.find((tab) => tab.id === tabId)
if (!tabToClose) return
if (tabs.length === 1) return
if (tabId === activeTabId) {
const remainingTabs = tabs.filter((tab) => tab.id !== tabId)
const lastTab = remainingTabs[remainingTabs.length - 1]
navigate(lastTab.path)
}
dispatch(removeTab(tabId))
}
const handleAddTab = () => {
navigate('/launchpad')
}
return (
<Container>
<TabsBar $isFullscreen={isFullscreen}>
{tabs.map((tab) => (
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => navigate(tab.path)}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
<TabTitle>{t(`title.${tab.id}`)}</TabTitle>
</TabHeader>
{tab.id !== 'home' && (
<CloseButton
className="close-button"
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}>
<X size={12} />
</CloseButton>
)}
</Tab>
))}
<AddTabButton onClick={handleAddTab}>
<PlusOutlined />
</AddTabButton>
</TabsBar>
<TabContent>{children}</TabContent>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`
const TabsBar = styled.div<{ $isFullscreen: boolean }>`
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '80px' : '8px')};
-webkit-app-region: drag;
height: var(--navbar-height);
`
const Tab = styled.div<{ active?: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
background: ${(props) => (props.active ? 'var(--color-background)' : 'transparent')};
border-radius: 8px;
cursor: pointer;
user-select: none;
-webkit-app-region: none;
min-width: 100px;
transition: background 0.2s;
.close-button {
opacity: 0;
transition: opacity 0.2s;
margin-right: -2px;
}
&:hover {
background: ${(props) => (props.active ? 'var(--color-background)' : 'var(--color-background-soft)')};
.close-button {
opacity: 1;
}
}
`
const TabHeader = styled.div`
display: flex;
align-items: center;
gap: 6px;
`
const TabIcon = styled.span`
display: flex;
align-items: center;
margin-right: 6px;
color: var(--color-text-2);
`
const TabTitle = styled.span`
color: var(--color-text);
font-size: 13px;
margin-right: 8px;
display: flex;
align-items: center;
`
const CloseButton = styled.span`
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
`
const AddTabButton = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
cursor: pointer;
color: var(--color-text);
-webkit-app-region: none;
&:hover {
background: var(--color-background-soft);
border-radius: 8px;
}
`
const TabContent = styled.div`
display: flex;
flex: 1;
overflow: hidden;
width: calc(100vw - 12px);
margin: 6px;
margin-top: 0;
border-radius: 8px;
overflow: hidden;
`
export default TabsContainer

View File

@ -1,10 +1,8 @@
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
import { isLinux, isWindows } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { Button } from 'antd'
import { CircleArrowLeft, X } from 'lucide-react'
import type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
import { useNavigate } from 'react-router-dom'
import styled, { keyframes } from 'styled-components'
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
@ -31,41 +29,11 @@ export const NavbarMain: FC<Props> = ({ children, ...props }) => {
return (
<NavbarMainContainer {...props} $isFullscreen={isFullscreen}>
<CloseIcon />
{children}
<MacCloseIcon />
</NavbarMainContainer>
)
}
const MacCloseIcon = () => {
const navigate = useNavigate()
if (!isMac) {
return null
}
return <AnimatedButton type="text" icon={<X size={18} />} onClick={() => navigate('/')} className="nodrag" />
}
const CloseIcon = () => {
const navigate = useNavigate()
if (isMac) {
return null
}
return (
<Button
type="text"
onClick={() => navigate('/')}
className="nodrag"
style={{ marginRight: 2, marginLeft: 5 }}
icon={<CircleArrowLeft size={20} color="var(--color-icon)" style={{ marginTop: 2 }} />}
/>
)
}
const NavbarContainer = styled.div`
min-width: 100%;
display: flex;
@ -95,11 +63,10 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
max-height: var(--navbar-height);
min-height: var(--navbar-height);
justify-content: space-between;
padding-left: ${({ $isFullscreen }) => ($isFullscreen ? '10px' : isMac ? '70px' : '10px')};
font-weight: bold;
color: var(--color-text-1);
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '135px' : isLinux ? '120px' : '12px')};
-webkit-app-region: drag;
padding: 0 12px;
`
const NavbarCenterContainer = styled.div`

View File

@ -42,7 +42,6 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// Set initial theme and OS attributes on body
document.body.setAttribute('os', isMac ? 'mac' : 'windows')
document.body.setAttribute('theme-mode', actualTheme)
document.body.setAttribute('transparent-window', transparentWindow ? 'true' : 'false')
// if theme is old auto, then set theme to system
// we can delete this after next big release

View File

@ -1,12 +1,15 @@
import NavigationService from '@renderer/services/NavigationService'
import { useAppSelector } from '@renderer/store'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addTab, Tab } from '@renderer/store/tabs'
import { useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLocation, useNavigate } from 'react-router-dom'
const NavigationHandler: React.FC = () => {
const dispatch = useAppDispatch()
const location = useLocation()
const navigate = useNavigate()
const tabs = useAppSelector((state) => state.tabs.tabs)
const showSettingsShortcutEnabled = useAppSelector(
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
@ -32,6 +35,20 @@ const NavigationHandler: React.FC = () => {
}
)
// 初始化 home tab
useEffect(() => {
if (tabs.length === 0) {
const homeTab: Tab = {
id: 'home',
titleKey: 'title.home',
title: '',
path: '/',
iconType: 'home'
}
dispatch(addTab(homeTab))
}
}, [dispatch, tabs.length])
return null
}

View File

@ -1,5 +1,4 @@
import { isLocalAi } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
@ -12,17 +11,14 @@ import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice'
import { useRuntime } from './useRuntime'
import { useSettings } from './useSettings'
import useUpdateHandler from './useUpdateHandler'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl, language, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
const { minappShow } = useRuntime()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
const { theme } = useTheme()
useEffect(() => {
document.getElementById('spinner')?.remove()
@ -61,15 +57,6 @@ export function useAppInit() {
i18n.changeLanguage(language || navigator.language || defaultLanguage)
}, [language])
useEffect(() => {
if (minappShow) {
window.root.style.background = 'var(--color-background)'
return
}
window.root.style.background = !minappShow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
}, [minappShow, theme])
useEffect(() => {
if (isLocalAi) {
const model = JSON.parse(import.meta.env.VITE_RENDERER_INTEGRATED_MODEL)

View File

@ -0,0 +1,64 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import type { Tab } from '@renderer/store/tabs'
import { addTab, removeTab, setActiveTab, updateTab } from '@renderer/store/tabs'
import { useNavigate } from 'react-router-dom'
export function useTabs() {
const navigate = useNavigate()
const dispatch = useAppDispatch()
const tabs = useAppSelector((state) => state.tabs.tabs)
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
const activeTab = useAppSelector((state) => state.tabs.tabs.find((tab) => tab.id === activeTabId))
const getTabId = (path: string): string => {
if (path === '/') return 'home'
const segments = path.split('/')
return segments[1]
}
const shouldCreateTab = (path: string) => {
if (path === '/') return false
return !tabs.some((tab) => tab.id === getTabId(path))
}
const addNewTab = (tab: Tab) => {
dispatch(addTab(tab))
navigate(tab.path)
}
const closeTab = (tabId: string) => {
if (tabs.length === 1) return
if (tabId === activeTabId) {
const remainingTabs = tabs.filter((tab) => tab.id !== tabId)
const lastTab = remainingTabs[remainingTabs.length - 1]
navigate(lastTab.path)
}
dispatch(removeTab(tabId))
}
const switchTab = (tabId: string) => {
const tab = tabs.find((tab) => tab.id === tabId)
if (tab) {
dispatch(setActiveTab(tabId))
navigate(tab.path)
}
}
const updateCurrentTab = (updates: Partial<Tab>) => {
dispatch(updateTab({ id: activeTabId, updates }))
}
return {
tabs,
activeTab,
activeTabId,
addNewTab,
closeTab,
switchTab,
getTabId,
shouldCreateTab,
updateCurrentTab
}
}

View File

@ -1,5 +1,17 @@
{
"translation": {
"title": {
"home": "Home",
"agents": "Agents",
"paintings": "Paintings",
"translate": "Translate",
"files": "Files",
"knowledge": "Knowledge Base",
"apps": "Apps",
"mcp-servers": "MCP Servers",
"settings": "Settings",
"launchpad": "Launchpad"
},
"agents": {
"add.button": "Add to Assistant",
"add.knowledge_base": "Knowledge Base",

View File

@ -1,5 +1,17 @@
{
"translation": {
"title": {
"home": "ホーム",
"agents": "エージェント",
"paintings": "ペインティング",
"translate": "翻訳",
"files": "ファイル",
"knowledge": "ナレッジベース",
"apps": "アプリ",
"mcp-servers": "MCP サーバー",
"settings": "設定",
"launchpad": "ランチパッド"
},
"agents": {
"add.button": "アシスタントに追加",
"add.knowledge_base": "ナレッジベース",

View File

@ -1,5 +1,17 @@
{
"translation": {
"title": {
"home": "Главная",
"agents": "Агенты",
"paintings": "Рисунки",
"translate": "Перевод",
"files": "Файлы",
"knowledge": "База знаний",
"apps": "Приложения",
"mcp-servers": "MCP серверы",
"settings": "Настройки",
"launchpad": "Запуск"
},
"agents": {
"add.button": "Добавить в ассистента",
"add.knowledge_base": "База знаний",

View File

@ -1,5 +1,17 @@
{
"translation": {
"title": {
"home": "首页",
"agents": "智能体",
"paintings": "绘画",
"translate": "翻译",
"files": "文件",
"knowledge": "知识库",
"apps": "小程序",
"mcp-servers": "MCP 服务器",
"settings": "设置",
"launchpad": "启动台"
},
"agents": {
"add.button": "添加到助手",
"add.knowledge_base": "知识库",

View File

@ -1,5 +1,17 @@
{
"translation": {
"title": {
"home": "主頁",
"agents": "智能體",
"paintings": "繪畫",
"translate": "翻譯",
"files": "文件",
"knowledge": "知識庫",
"apps": "小程序",
"mcp-servers": "MCP 伺服器",
"settings": "設定",
"launchpad": "啟動台"
},
"agents": {
"add.button": "新增到助手",
"add.knowledge_base": "知識庫",

View File

@ -7,7 +7,6 @@ import type { MenuProps } from 'antd'
import { Dropdown, message } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router'
import styled from 'styled-components'
interface Props {
@ -23,15 +22,8 @@ const App: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
const isPinned = pinned.some((p) => p.id === app.id)
const isVisible = minapps.some((m) => m.id === app.id)
const location = useLocation()
const navigate = useNavigate()
const isHome = location.pathname === '/'
const handleClick = () => {
if (!isHome) {
setTimeout(() => navigate('/'), 300)
}
openMinappKeepAlive(app)
onClick?.()
}

View File

@ -113,7 +113,7 @@ const Chat: FC = () => {
}
const Main = styled(Flex)`
height: calc(100vh - var(--navbar-height));
height: calc(100vh - var(--navbar-height) - 50px);
transform: translateZ(0);
position: relative;
`

View File

@ -1,23 +1,15 @@
import { Navbar } from '@renderer/components/app/Navbar'
import NarrowModeIcon from '@renderer/components/Icons/NarrowModeIcon'
import { PanelLeftIcon } from '@renderer/components/Icons/PanelIcons'
import { NavbarMain } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
import { isMac } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChat } from '@renderer/hooks/useChat'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { modelGenerating } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants } from '@renderer/hooks/useStore'
import { useAppDispatch } from '@renderer/store'
import { setNarrowMode } from '@renderer/store/settings'
import { Tooltip } from 'antd'
import { t } from 'i18next'
import { LayoutGrid, Search } from 'lucide-react'
import { PanelLeft, PanelRight, Search } from 'lucide-react'
import { FC } from 'react'
import { useNavigate } from 'react-router'
import styled from 'styled-components'
import SelectModelButton from './components/SelectModelButton'
@ -27,70 +19,31 @@ const ChatNavbar: FC = () => {
const { activeAssistant } = useChat()
const { assistant } = useAssistant(activeAssistant.id)
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const isFullscreen = useFullscreen()
const { sidebarIcons, narrowMode } = useSettings()
const dispatch = useAppDispatch()
const navigate = useNavigate()
useShortcut('search_message', SearchPopup.show)
const handleNarrowModeToggle = async () => {
await modelGenerating()
dispatch(setNarrowMode(!narrowMode))
}
return (
<Navbar className="home-navbar">
<NavbarContainer $isFullscreen={isFullscreen} $showSidebar={showAssistants} className="home-navbar-right">
<HStack alignItems="center" gap={8}>
{!showAssistants && (
<NavbarIcon onClick={() => toggleShowAssistants()}>
<PanelLeftIcon size={18} expanded={false} />
</NavbarIcon>
)}
<SelectModelButton assistant={assistant} />
</HStack>
<HStack alignItems="center" gap={8}>
<UpdateAppButton />
<Tooltip title={t('history.title')} mouseEnterDelay={0.8}>
<NavbarMain className="home-navbar" style={{ minHeight: 50 }}>
<HStack alignItems="center" gap={8}>
<NavbarIcon onClick={() => toggleShowAssistants()}>
{showAssistants ? <PanelLeft size={18} /> : <PanelRight size={18} />}
</NavbarIcon>
<SelectModelButton assistant={assistant} />
</HStack>
<HStack alignItems="center" gap={8}>
<UpdateAppButton />
{isMac && (
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={() => SearchPopup.show()}>
<Search size={18} />
</NarrowIcon>
</Tooltip>
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={handleNarrowModeToggle}>
<NarrowModeIcon isNarrowMode={narrowMode} />
</NarrowIcon>
</Tooltip>
{sidebarIcons.visible.includes('minapp') && (
<Tooltip title={t('minapp.title')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={() => navigate('/apps')}>
<LayoutGrid size={18} />
</NarrowIcon>
</Tooltip>
)}
</HStack>
</NavbarContainer>
</Navbar>
)}
</HStack>
</NavbarMain>
)
}
const NavbarContainer = styled.div<{ $isFullscreen: boolean; $showSidebar: boolean }>`
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
height: var(--navbar-height);
max-height: var(--navbar-height);
min-height: var(--navbar-height);
justify-content: space-between;
margin-left: ${({ $showSidebar }) => ($showSidebar ? '15px' : isMac ? '75px' : '15px')};
font-weight: bold;
color: var(--color-text-1);
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '15px' : isWindows ? '140px' : isLinux ? '120px' : '15px')};
-webkit-app-region: drag;
`
export const NavbarIcon = styled.div`
-webkit-app-region: none;
border-radius: 8px;

View File

@ -40,7 +40,6 @@ import styled from 'styled-components'
import AssistantsTab from '../Tabs/AssistantsTab'
import AssistantItem from '../Tabs/components/AssistantItem'
import TopicsTab from '../Tabs/TopicsTab'
import MainNavbar from './MainNavbar'
import {
Container,
MainMenu,
@ -177,7 +176,6 @@ const MainSidebar: FC = () => {
opacity: showAssistants ? 1 : 0,
overflow: showAssistants ? 'initial' : 'hidden'
}}>
<MainNavbar />
<MainMenu>
<SidebarSearch onSearch={setSearchValue} />
<MainMenuItem active={isAppMenuExpanded} onClick={() => setIsAppMenuExpanded(!isAppMenuExpanded)}>
@ -305,6 +303,7 @@ const MainContainer = styled.div`
const AssistantContainer = styled.div`
margin: 4px 10px;
display: flex;
margin-top: 0;
`
const UserMenu = styled.div`

View File

@ -52,7 +52,10 @@ export const Container = styled.div<{ transparent?: boolean }>`
width: var(--assistants-width);
max-width: var(--assistants-width);
border-right: 0.5px solid var(--color-border);
height: 100vh;
height: var(--main-height);
min-height: var(--main-height);
background: var(--color-background);
padding-top: 10px;
`
export const MainMenu = styled.div`

View File

@ -0,0 +1,118 @@
import { useSettings } from '@renderer/hooks/useSettings'
import { FileSearch, Folder, Languages, LayoutGrid, Palette, Sparkle, SquareTerminal } from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
const LaunchpadPage: FC = () => {
const navigate = useNavigate()
const { t } = useTranslation()
const { defaultPaintingProvider } = useSettings()
const appMenuItems = [
{
icon: <Sparkle size={32} className="icon" />,
text: t('agents.title'),
path: '/agents',
bgColor: 'linear-gradient(135deg, #6366F1, #4F46E5)' // AI助手靛蓝渐变代表智能和科技
},
{
icon: <Languages size={32} className="icon" />,
text: t('translate.title'),
path: '/translate',
bgColor: 'linear-gradient(135deg, #06B6D4, #0EA5E9)' // 翻译:明亮的青蓝色,代表沟通和流畅
},
{
icon: <Palette size={32} className="icon" />,
text: t('paintings.title'),
path: `/paintings/${defaultPaintingProvider}`,
bgColor: 'linear-gradient(135deg, #EC4899, #F472B6)' // 绘画:活力粉色,代表创造力和艺术
},
{
icon: <LayoutGrid size={32} className="icon" />,
text: t('minapp.title'),
path: '/apps',
bgColor: 'linear-gradient(135deg, #8B5CF6, #A855F7)' // 小程序:紫色,代表多功能和灵活性
},
{
icon: <FileSearch size={32} className="icon" />,
text: t('knowledge.title'),
path: '/knowledge',
bgColor: 'linear-gradient(135deg, #10B981, #34D399)' // 知识库:翠绿色,代表生长和知识
},
{
icon: <SquareTerminal size={32} className="icon" />,
text: t('settings.mcp.title'),
path: '/mcp-servers',
bgColor: 'linear-gradient(135deg, #3B82F6, #60A5FA)' // MCP服务器科技蓝代表专业和稳定
},
{
icon: <Folder size={32} className="icon" />,
text: t('files.title'),
path: '/files',
bgColor: 'linear-gradient(135deg, #F59E0B, #FBBF24)' // 文件:金色,代表资源和重要性
}
]
return (
<Container>
<Grid>
{appMenuItems.map((item) => (
<AppIcon key={item.path} onClick={() => navigate(item.path)}>
<IconWrapper bgColor={item.bgColor}>{item.icon}</IconWrapper>
<AppName>{item.text}</AppName>
</AppIcon>
))}
</Grid>
</Container>
)
}
const Container = styled.div`
width: 100%;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-background);
`
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 40px;
padding: 40px;
max-width: 800px;
`
const AppIcon = styled.div`
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
gap: 12px;
`
const IconWrapper = styled.div<{ bgColor: string }>`
width: 64px;
height: 64px;
border-radius: 16px;
background: ${(props) => props.bgColor};
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
.icon {
color: white;
}
`
const AppName = styled.div`
font-size: 14px;
color: var(--color-text);
text-align: center;
`
export default LaunchpadPage

View File

@ -1,4 +1,3 @@
import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar'
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
import { Spin } from 'antd'
import {
@ -38,9 +37,6 @@ const SettingsPage: FC = () => {
return (
<Container>
<NavbarMain>
<NavbarCenter style={{ borderRight: 'none' }}>{t('settings.title')}</NavbarCenter>
</NavbarMain>
<ContentContainer id="content-container">
<SettingMenus>
<MenuItemLink to="/settings/provider">

View File

@ -22,6 +22,7 @@ import runtime from './runtime'
import selectionStore from './selectionStore'
import settings from './settings'
import shortcuts from './shortcuts'
import tabs from './tabs'
import topics from './topics'
import websearch from './websearch'
@ -42,6 +43,7 @@ const rootReducer = combineReducers({
copilot,
selectionStore,
topics,
tabs,
// messages: messagesReducer,
messages: newMessagesReducer,
messageBlocks: messageBlocksReducer,
@ -53,7 +55,7 @@ const persistedReducer = persistReducer(
key: 'cherry-studio',
storage,
version: 114,
blacklist: ['runtime', 'messages', 'messageBlocks'],
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},
rootReducer

View File

@ -0,0 +1,57 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface Tab {
id: string
path: string
}
interface TabsState {
tabs: Tab[]
activeTabId: string
}
const initialState: TabsState = {
tabs: [
{
id: 'home',
path: '/'
}
],
activeTabId: 'home'
}
const tabsSlice = createSlice({
name: 'tabs',
initialState,
reducers: {
addTab: (state, action: PayloadAction<Tab>) => {
const existingTab = state.tabs.find((tab) => tab.path === action.payload.path)
if (!existingTab) {
state.tabs.push(action.payload)
}
state.activeTabId = action.payload.id
},
removeTab: (state, action: PayloadAction<string>) => {
const index = state.tabs.findIndex((tab) => tab.id === action.payload)
if (index !== -1) {
state.tabs.splice(index, 1)
// 如果关闭的是当前标签页,则切换到最后一个标签页
if (action.payload === state.activeTabId) {
state.activeTabId = state.tabs[state.tabs.length - 1].id
}
}
},
updateTab: (state, action: PayloadAction<{ id: string; updates: Partial<Tab> }>) => {
const tab = state.tabs.find((tab) => tab.id === action.payload.id)
if (tab) {
Object.assign(tab, action.payload.updates)
}
},
setActiveTab: (state, action: PayloadAction<string>) => {
state.activeTabId = action.payload
}
}
})
export const { addTab, removeTab, setActiveTab, updateTab } = tabsSlice.actions
export default tabsSlice.reducer