feat: add settings window functionality and shortcuts

- Introduced a new IPC channel for showing the settings window.
- Registered IPC handler for the settings window in the main process.
- Updated shortcut service to include a shortcut for opening the settings window.
- Modified the settings popup to use the new IPC method for displaying the settings.
- Adjusted the main sidebar to navigate to the settings window using the new IPC call.
- Enhanced the navbar to accommodate the new settings window functionality.
This commit is contained in:
suyao 2025-07-11 16:05:57 +08:00
parent 762732af9d
commit e4434eb7c8
No known key found for this signature in database
14 changed files with 597 additions and 238 deletions

View File

@ -246,5 +246,8 @@ export enum IpcChannel {
// Navigation
Navigation_Url = 'navigation:url',
Navigation_Close = 'navigation:close'
Navigation_Close = 'navigation:close',
// Settings Window
SettingsWindow_Show = 'settings-window:show'
}

View File

@ -31,6 +31,7 @@ import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { SettingsWindowService } from './services/SettingsWindowService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import storeSyncService from './services/StoreSyncService'
import { themeService } from './services/ThemeService'
@ -583,4 +584,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Navigation_Url, (_, url: string) => {
CacheService.set('navigation-url', url)
})
// Settings Window
SettingsWindowService.registerIpcHandler()
}

View File

@ -0,0 +1,149 @@
import { is } from '@electron-toolkit/utils'
import { isLinux, isMac } from '@main/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow, nativeTheme } from 'electron'
import { join } from 'path'
import icon from '../../../build/icon.png?asset'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
export class SettingsWindowService {
private static instance: SettingsWindowService | null = null
private settingsWindow: BrowserWindow | null = null
public static getInstance(): SettingsWindowService {
if (!SettingsWindowService.instance) {
SettingsWindowService.instance = new SettingsWindowService()
}
return SettingsWindowService.instance
}
public createSettingsWindow(defaultTab?: string): BrowserWindow {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.settingsWindow.show()
this.settingsWindow.focus()
return this.settingsWindow
}
this.settingsWindow = new BrowserWindow({
width: 1000,
height: 700,
minWidth: 900,
minHeight: 600,
show: false,
autoHideMenuBar: true,
transparent: false,
vibrancy: 'sidebar',
visualEffectState: 'active',
titleBarStyle: 'hidden',
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
darkTheme: nativeTheme.shouldUseDarkColors,
trafficLightPosition: { x: 12, y: 12 },
...(isLinux ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true,
allowRunningInsecureContent: true,
backgroundThrottling: false
}
})
this.setupSettingsWindow()
this.loadSettingsWindowContent(defaultTab)
return this.settingsWindow
}
private setupSettingsWindow() {
if (!this.settingsWindow) return
this.settingsWindow.on('ready-to-show', () => {
this.settingsWindow?.show()
})
this.settingsWindow.on('closed', () => {
this.settingsWindow = null
})
this.settingsWindow.on('close', () => {
// Clean up when window is closed
})
// Handle theme changes
nativeTheme.on('updated', () => {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.settingsWindow.setTitleBarOverlay(
nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight
)
}
})
}
private loadSettingsWindowContent(defaultTab?: string) {
if (!this.settingsWindow) return
const queryParam = defaultTab ? `?tab=${defaultTab}` : ''
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
this.settingsWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/settingsWindow.html' + queryParam)
} else {
this.settingsWindow.loadFile(join(__dirname, '../renderer/settingsWindow.html'))
if (defaultTab) {
this.settingsWindow.webContents.once('did-finish-load', () => {
this.settingsWindow?.webContents.send(IpcChannel.SettingsWindow_Show, { defaultTab })
})
}
}
}
public showSettingsWindow(defaultTab?: string) {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
if (this.settingsWindow.isMinimized()) {
this.settingsWindow.restore()
}
if (!isLinux) {
this.settingsWindow.setVisibleOnAllWorkspaces(true)
}
this.settingsWindow.show()
this.settingsWindow.focus()
if (!isLinux) {
this.settingsWindow.setVisibleOnAllWorkspaces(false)
}
} else {
this.createSettingsWindow(defaultTab)
}
}
public hideSettingsWindow() {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.settingsWindow.hide()
}
}
public closeSettingsWindow() {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.settingsWindow.close()
}
}
public getSettingsWindow(): BrowserWindow | null {
return this.settingsWindow
}
public static registerIpcHandler() {
const { ipcMain } = require('electron')
const service = SettingsWindowService.getInstance()
ipcMain.handle(IpcChannel.SettingsWindow_Show, (_, options?: { defaultTab?: string }) => {
service.showSettingsWindow(options?.defaultTab)
})
}
}
export const settingsWindowService = SettingsWindowService.getInstance()

View File

@ -5,10 +5,12 @@ import Logger from 'electron-log'
import { configManager } from './ConfigManager'
import selectionService from './SelectionService'
import { settingsWindowService } from './SettingsWindowService'
import { windowService } from './WindowService'
let showAppAccelerator: string | null = null
let showMiniWindowAccelerator: string | null = null
let showSettingsAccelerator: string | null = null
let selectionAssistantToggleAccelerator: string | null = null
let selectionAssistantSelectTextAccelerator: string | null = null
@ -26,6 +28,10 @@ function getShortcutHandler(shortcut: Shortcut) {
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
case 'zoom_reset':
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
case 'show_settings':
return () => {
settingsWindowService.showSettingsWindow()
}
case 'show_app':
return () => {
windowService.toggleMainWindow()
@ -146,9 +152,13 @@ export function registerShortcuts(window: BrowserWindow) {
// only register universal shortcuts when needed
if (
onlyUniversalShortcuts &&
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
shortcut.key
)
![
'show_app',
'mini_window',
'show_settings',
'selection_assistant_toggle',
'selection_assistant_select_text'
].includes(shortcut.key)
) {
return
}
@ -171,6 +181,10 @@ export function registerShortcuts(window: BrowserWindow) {
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'show_settings':
showSettingsAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_toggle':
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
break
@ -222,6 +236,12 @@ export function registerShortcuts(window: BrowserWindow) {
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (showSettingsAccelerator) {
const handler = getShortcutHandler({ key: 'show_settings' } as Shortcut)
const accelerator = convertShortcutFormat(showSettingsAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantToggleAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
@ -258,6 +278,7 @@ export function unregisterAllShortcuts() {
try {
showAppAccelerator = null
showMiniWindowAccelerator = null
showSettingsAccelerator = null
selectionAssistantToggleAccelerator = null
selectionAssistantSelectTextAccelerator = null
windowOnHandlers.forEach((handlers, window) => {

View File

@ -322,7 +322,21 @@ const api = {
url: (url: string) => ipcRenderer.invoke(IpcChannel.Navigation_Url, url)
},
setDisableHardwareAcceleration: (isDisable: boolean) =>
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable)
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
// Settings Window
showSettingsWindow: (options?: { defaultTab?: string }) =>
ipcRenderer.invoke(IpcChannel.SettingsWindow_Show, options),
on: (channel: string, func: any) => {
const listener = (_event: Electron.IpcRendererEvent, ...args: any[]) => {
func(...args)
}
ipcRenderer.on(channel, listener)
return () => {
ipcRenderer.off(channel, listener)
}
}
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio</title>
<style>
html,
body {
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/settings/entryPoint.tsx"></script>
</body>
</html>

View File

@ -1,230 +1,24 @@
import AboutSettings from '@renderer/pages/settings/AboutSettings'
import DataSettings from '@renderer/pages/settings/DataSettings/DataSettings'
import DisplaySettings from '@renderer/pages/settings/DisplaySettings/DisplaySettings'
import GeneralSettings from '@renderer/pages/settings/GeneralSettings'
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
import ProvidersList from '@renderer/pages/settings/ProviderSettings'
import QuickAssistantSettings from '@renderer/pages/settings/QuickAssistantSettings'
import QuickPhraseSettings from '@renderer/pages/settings/QuickPhraseSettings'
import SelectionAssistantSettings from '@renderer/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings'
import ShortcutSettings from '@renderer/pages/settings/ShortcutSettings'
import WebSearchSettings from '@renderer/pages/settings/WebSearchSettings'
import { Modal, Spin } from 'antd'
import {
Cloud,
Command,
Globe,
HardDrive,
Info,
MonitorCog,
Package,
Rocket,
Settings2,
TextCursorInput,
Zap
} from 'lucide-react'
import React, { Suspense, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { TopView } from '../TopView'
type SettingsTab =
| 'provider'
| 'model'
| 'web-search'
| 'general'
| 'display'
| 'shortcut'
| 'quickAssistant'
| 'selectionAssistant'
| 'data'
| 'about'
| 'quickPhrase'
interface SettingsPopupShowParams {
defaultTab?: SettingsTab
export interface SettingsPopupShowParams {
defaultTab?:
| 'provider'
| 'model'
| 'tool'
| 'general'
| 'display'
| 'shortcut'
| 'quickAssistant'
| 'selectionAssistant'
| 'data'
| 'about'
| 'quickPhrase'
}
interface Props extends SettingsPopupShowParams {
resolve?: (value: any) => void
}
const SettingsPopupContainer: React.FC<Props> = ({ defaultTab = 'provider', resolve }) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<SettingsTab>(defaultTab)
const [open, setOpen] = useState(true)
const menuItems = [
{ key: 'provider', icon: <Cloud size={18} />, label: t('settings.provider.title') },
{ key: 'model', icon: <Package size={18} />, label: t('settings.model') },
{ key: 'web-search', icon: <Globe size={18} />, label: t('settings.websearch.title') },
{ key: 'general', icon: <Settings2 size={18} />, label: t('settings.general') },
{ key: 'display', icon: <MonitorCog size={18} />, label: t('settings.display.title') },
{ key: 'shortcut', icon: <Command size={18} />, label: t('settings.shortcuts.title') },
{ key: 'quickAssistant', icon: <Rocket size={18} />, label: t('settings.quickAssistant.title') },
{ key: 'selectionAssistant', icon: <TextCursorInput size={18} />, label: t('selection.name') },
{ key: 'quickPhrase', icon: <Zap size={18} />, label: t('settings.quickPhrase.title') },
{ key: 'data', icon: <HardDrive size={18} />, label: t('settings.data.title') },
{ key: 'about', icon: <Info size={18} />, label: t('settings.about') }
] as const
const renderContent = () => {
switch (activeTab) {
case 'provider':
return (
<Suspense fallback={<Spin />}>
<ProvidersList />
</Suspense>
)
case 'model':
return <ModelSettings />
case 'web-search':
return <WebSearchSettings />
case 'general':
return <GeneralSettings />
case 'display':
return <DisplaySettings />
case 'shortcut':
return <ShortcutSettings />
case 'quickAssistant':
return <QuickAssistantSettings />
case 'selectionAssistant':
return <SelectionAssistantSettings />
case 'data':
return <DataSettings />
case 'about':
return <AboutSettings />
case 'quickPhrase':
return <QuickPhraseSettings />
default:
return <ProvidersList />
}
}
const onCancel = () => {
setOpen(false)
}
const onAfterClose = () => {
resolve && resolve(null)
TopView.hide(TopViewKey)
}
// 设置全局隐藏方法
SettingsPopup.hide = onCancel
return (
<StyledModal
title={t('settings.title')}
open={open}
onCancel={onCancel}
afterClose={onAfterClose}
footer={null}
width={1000}
centered
destroyOnClose>
<ContentContainer>
<SettingMenus>
{menuItems.map((item) => (
<MenuItem
key={item.key}
className={activeTab === item.key ? 'active' : ''}
onClick={() => setActiveTab(item.key as SettingsTab)}>
{item.icon}
{item.label}
</MenuItem>
))}
</SettingMenus>
<SettingContent>{renderContent()}</SettingContent>
</ContentContainer>
</StyledModal>
)
}
const TopViewKey = 'SettingsPopup'
export default class SettingsPopup {
static hide() {
TopView.hide(TopViewKey)
// Settings window is now independent, user can close it manually
}
static show(props: SettingsPopupShowParams = {}) {
return new Promise<any>((resolve) => {
TopView.show(<SettingsPopupContainer {...props} resolve={resolve} />, TopViewKey)
})
return window.api.showSettingsWindow({ defaultTab: props.defaultTab })
}
}
const StyledModal = styled(Modal)`
.ant-modal-content {
height: 80vh;
display: flex;
flex-direction: column;
}
.ant-modal-body {
flex: 1;
padding: 0;
overflow: hidden;
}
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
height: 100%;
`
const SettingMenus = styled.div`
display: flex;
flex-direction: column;
min-width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
padding: 10px;
user-select: none;
background: var(--color-background);
`
const MenuItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 6px 10px;
width: 100%;
cursor: pointer;
border-radius: var(--list-item-border-radius);
font-weight: 500;
transition: all 0.2s ease-in-out;
border: 0.5px solid transparent;
margin-bottom: 5px;
.anticon {
font-size: 16px;
opacity: 0.8;
}
.iconfont {
font-size: 18px;
line-height: 18px;
opacity: 0.7;
margin-left: -1px;
}
&:hover {
background: var(--color-background-soft);
}
&.active {
background: var(--color-background-soft);
border: 0.5px solid var(--color-border);
}
`
const SettingContent = styled.div`
display: flex;
height: 100%;
flex: 1;
overflow: auto;
`

View File

@ -1,5 +1,6 @@
import { isLinux, isWin } from '@renderer/config/constant'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useShowAssistants } from '@renderer/hooks/useStore'
import type { FC, HTMLAttributes, PropsWithChildren } from 'react'
import styled from 'styled-components'
@ -24,9 +25,10 @@ export const NavbarRight: FC<Props> = ({ children, ...props }) => {
export const NavbarMain: FC<Props> = ({ children, ...props }) => {
const isFullscreen = useFullscreen()
const { showAssistants } = useShowAssistants()
return (
<NavbarMainContainer {...props} $isFullscreen={isFullscreen}>
<NavbarMainContainer {...props} $isFullscreen={isFullscreen} $showAssistants={showAssistants}>
{children}
</NavbarMainContainer>
)
@ -51,7 +53,7 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
justify-content: flex-end;
`
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean; $showAssistants: boolean }>`
flex: 1;
display: flex;
flex-direction: row;
@ -65,6 +67,7 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
color: var(--color-text-1);
-webkit-app-region: drag;
padding: 0 12px;
padding-left: ${({ $showAssistants }) => (isMac && !$showAssistants ? '70px' : '10px')};
`
const NavbarCenterContainer = styled.div`

View File

@ -266,7 +266,7 @@ const MainSidebar: FC = () => {
key: 'settings',
label: t('settings.title'),
icon: <Settings size={16} className="icon" />,
onClick: () => navigate('/settings/provider')
onClick: () => window.api.showSettingsWindow({ defaultTab: 'provider' })
}
]
}}>

View File

@ -52,8 +52,8 @@ 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: var(--main-height);
min-height: var(--main-height);
height: calc(var(--main-height) - 50px);
min-height: calc(var(--main-height) - 50px);
background: var(--color-background);
padding-top: 10px;
margin-top: 50px;

View File

@ -1,4 +1,4 @@
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { Center } from '@renderer/components/Layout'
@ -84,7 +84,7 @@ const OpenedMinapps: FC = () => {
<TabsContainer className="TabsContainer" style={{ marginBottom: 4 }}>
<Divider />
<TabsWrapper>
<DragableList
<DraggableList
list={sortedApps}
onUpdate={(newList) => {
// 只更新固定应用的顺序
@ -144,7 +144,7 @@ const OpenedMinapps: FC = () => {
</Dropdown>
)
}}
</DragableList>
</DraggableList>
{isEmpty(sortedApps) && (
<Center>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />

View File

@ -42,6 +42,7 @@ import { findIndex } from 'lodash'
import { FC, startTransition, useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
interface TopicsTabProps {
@ -49,12 +50,14 @@ interface TopicsTabProps {
style?: React.CSSProperties
}
const Topics: FC<TopicsTabProps> = ({ searchValue, style }) => {
const Topics: FC<TopicsTabProps> = ({ searchValue }) => {
const { activeAssistant, activeTopic, setActiveTopic } = useChat()
const { assistants } = useAssistants()
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(activeAssistant.id)
const { t } = useTranslation()
const { showTopicTime, pinTopicsToTop, topicPosition } = useSettings()
const { showTopicTime, pinTopicsToTop } = useSettings()
const navigate = useNavigate()
const location = useLocation()
const topics = useTopicsForAssistant(activeAssistant.id)
@ -173,9 +176,13 @@ const Topics: FC<TopicsTabProps> = ({ searchValue, style }) => {
// await modelGenerating()
startTransition(() => {
setActiveTopic(topic)
// 如果当前不在聊天页面,导航到聊天页面
if (location.pathname !== '/') {
navigate('/')
}
})
},
[setActiveTopic]
[setActiveTopic, location.pathname, navigate]
)
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)

View File

@ -0,0 +1,262 @@
import '@renderer/i18n'
import '@renderer/databases'
import StyleSheetManager from '@renderer/context/StyleSheetManager'
import { useSettings } from '@renderer/hooks/useSettings'
import AboutSettings from '@renderer/pages/settings/AboutSettings'
import DataSettings from '@renderer/pages/settings/DataSettings/DataSettings'
import DisplaySettings from '@renderer/pages/settings/DisplaySettings/DisplaySettings'
import GeneralSettings from '@renderer/pages/settings/GeneralSettings'
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
import ProvidersList from '@renderer/pages/settings/ProviderSettings'
import QuickAssistantSettings from '@renderer/pages/settings/QuickAssistantSettings'
import QuickPhraseSettings from '@renderer/pages/settings/QuickPhraseSettings'
import SelectionAssistantSettings from '@renderer/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings'
import ShortcutSettings from '@renderer/pages/settings/ShortcutSettings'
import ToolSettings from '@renderer/pages/settings/ToolSettings'
import { Spin } from 'antd'
import {
Cloud,
Command,
HardDrive,
Info,
MonitorCog,
Package,
PencilRuler,
Rocket,
Settings2,
TextCursorInput,
Zap
} from 'lucide-react'
import React, { Suspense, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
type SettingsTab =
| 'provider'
| 'model'
| 'tool'
| 'general'
| 'display'
| 'shortcut'
| 'quickAssistant'
| 'selectionAssistant'
| 'data'
| 'about'
| 'quickPhrase'
// Inner component that uses hooks after Redux is initialized
function SettingsWindowContent(): React.ReactElement {
const { t } = useTranslation()
const { customCss } = useSettings()
const [activeTab, setActiveTab] = useState<SettingsTab>('provider')
// Remove spinner after component mounts
useEffect(() => {
document.getElementById('spinner')?.remove()
console.timeEnd('settings-init')
}, [])
// Handle custom CSS injection
useEffect(() => {
let customCssElement = document.getElementById('user-defined-custom-css') as HTMLStyleElement
if (customCssElement) {
customCssElement.remove()
}
if (customCss) {
customCssElement = document.createElement('style')
customCssElement.id = 'user-defined-custom-css'
customCssElement.textContent = customCss
document.head.appendChild(customCssElement)
}
}, [customCss])
useEffect(() => {
// Parse URL parameters for initial tab
const urlParams = new URLSearchParams(window.location.search)
const tab = urlParams.get('tab')
if (tab && isValidTab(tab)) {
setActiveTab(tab as SettingsTab)
}
}, [])
const isValidTab = (tab: string): boolean => {
const validTabs = [
'provider',
'model',
'tool',
'general',
'display',
'shortcut',
'quickAssistant',
'selectionAssistant',
'data',
'about',
'quickPhrase'
]
return validTabs.includes(tab)
}
const menuItems = [
{ key: 'provider', icon: <Cloud size={18} />, label: t('settings.provider.title') },
{ key: 'model', icon: <Package size={18} />, label: t('settings.model') },
{ key: 'tool', icon: <PencilRuler size={18} />, label: t('settings.tool.title') },
{ key: 'general', icon: <Settings2 size={18} />, label: t('settings.general') },
{ key: 'display', icon: <MonitorCog size={18} />, label: t('settings.display.title') },
{ key: 'shortcut', icon: <Command size={18} />, label: t('settings.shortcuts.title') },
{ key: 'quickAssistant', icon: <Rocket size={18} />, label: t('settings.quickAssistant.title') },
{ key: 'selectionAssistant', icon: <TextCursorInput size={18} />, label: t('selection.name') },
{ key: 'quickPhrase', icon: <Zap size={18} />, label: t('settings.quickPhrase.title') },
{ key: 'data', icon: <HardDrive size={18} />, label: t('settings.data.title') },
{ key: 'about', icon: <Info size={18} />, label: t('settings.about') }
] as const
const renderContent = () => {
switch (activeTab) {
case 'provider':
return (
<Suspense fallback={<Spin />}>
<ProvidersList />
</Suspense>
)
case 'model':
return <ModelSettings />
case 'tool':
return <ToolSettings />
case 'general':
return <GeneralSettings />
case 'display':
return <DisplaySettings />
case 'shortcut':
return <ShortcutSettings />
case 'quickAssistant':
return <QuickAssistantSettings />
case 'selectionAssistant':
return <SelectionAssistantSettings />
case 'data':
return <DataSettings />
case 'about':
return <AboutSettings />
case 'quickPhrase':
return <QuickPhraseSettings />
default:
return <ProvidersList />
}
}
return (
<StyleSheetManager>
<Container>
<TitleBar>
<Title>{t('settings.title')}</Title>
</TitleBar>
<ContentContainer>
<SettingMenus>
{menuItems.map((item) => (
<MenuItem
key={item.key}
className={activeTab === item.key ? 'active' : ''}
onClick={() => setActiveTab(item.key as SettingsTab)}>
{item.icon}
{item.label}
</MenuItem>
))}
</SettingMenus>
<SettingContent>{renderContent()}</SettingContent>
</ContentContainer>
</Container>
</StyleSheetManager>
)
}
const SettingsWindowApp: React.FC = () => {
return <SettingsWindowContent />
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background: var(--color-background);
`
const TitleBar = styled.div`
display: flex;
align-items: center;
height: 40px;
padding: 0 20px;
background: var(--color-background);
border-bottom: 0.5px solid var(--color-border);
-webkit-app-region: drag;
user-select: none;
`
const Title = styled.h1`
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
height: calc(100vh - 40px);
`
const SettingMenus = styled.div`
display: flex;
flex-direction: column;
min-width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
padding: 10px;
user-select: none;
background: var(--color-background);
`
const MenuItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 6px 10px;
width: 100%;
cursor: pointer;
border-radius: var(--list-item-border-radius);
font-weight: 500;
transition: all 0.2s ease-in-out;
border: 0.5px solid transparent;
margin-bottom: 5px;
.anticon {
font-size: 16px;
opacity: 0.8;
}
.iconfont {
font-size: 18px;
line-height: 18px;
opacity: 0.7;
margin-left: -1px;
}
&:hover {
background: var(--color-background-soft);
}
&.active {
background: var(--color-background-soft);
border: 0.5px solid var(--color-border);
}
`
const SettingContent = styled.div`
display: flex;
height: 100%;
flex: 1;
overflow: auto;
`
export default SettingsWindowApp

View File

@ -0,0 +1,79 @@
import '@renderer/assets/styles/index.scss'
import '@ant-design/v5-patch-for-react-19'
import KeyvStorage from '@kangfenmao/keyv-storage'
import AntdProvider from '@renderer/context/AntdProvider'
import { CodeStyleProvider } from '@renderer/context/CodeStyleProvider'
import { ThemeProvider } from '@renderer/context/ThemeProvider'
import NavigationService from '@renderer/services/NavigationService'
import storeSyncService from '@renderer/services/StoreSyncService'
import store, { persistor } from '@renderer/store'
import { message, Modal } from 'antd'
import { FC } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { MemoryRouter, useNavigate } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import SettingsWindowApp from './SettingsWindowApp'
/**
* This function is required for model API
* eg. BaseProviders.ts
* Although the coupling is too strong, we have no choice but to load it
* In multi-window handling, decoupling is needed
*/
function initKeyv() {
window.keyv = new KeyvStorage()
window.keyv.init()
}
initKeyv()
//subscribe to store sync
storeSyncService.subscribe()
// Navigation wrapper component to set up navigation service
const NavigationWrapper: FC<{ children: React.ReactNode }> = ({ children }) => {
const navigate = useNavigate()
// Set up navigation service for the settings window
NavigationService.setNavigate(navigate)
return <>{children}</>
}
const App: FC = () => {
// 设置窗口需要显示各种操作的提示消息如API连接测试、保存成功等
// messageContextHolder 为 Ant Design 的全局消息提示提供上下文容器
const [messageApi, messageContextHolder] = message.useMessage()
const [modal, modalContextHolder] = Modal.useModal()
// Set up global APIs for the settings window
window.message = messageApi
window.modal = modal
return (
<Provider store={store}>
<ThemeProvider>
<AntdProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<MemoryRouter initialEntries={['/settings/provider']}>
<NavigationWrapper>
{/* 消息提示容器,用于显示设置操作的反馈信息 */}
{messageContextHolder}
{modalContextHolder}
<SettingsWindowApp />
</NavigationWrapper>
</MemoryRouter>
</PersistGate>
</CodeStyleProvider>
</AntdProvider>
</ThemeProvider>
</Provider>
)
}
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(<App />)