From 00de61695801a38975deb6ec304bd7031b5c1d42 Mon Sep 17 00:00:00 2001
From: Teo
Date: Sun, 30 Mar 2025 13:56:34 +0800
Subject: [PATCH 01/37] refactor(files): Reconstruct file system UI (#4100)
* refactor(files): Reconstruct file system UI
* refactor(knowledge): replace Card components with CustomCollapse for better UI structure
* refactor(files): update folder icon from FolderOpenOutlined to FolderOpenFilled
* feat(components): add CustomCollapse component for enhanced collapsible UI
* refactor(files): implement virtual scrolling in FileList and KnowledgeContent components
---
src/renderer/src/assets/styles/animation.scss | 4 +-
.../src/components/CustomCollapse.tsx | 44 ++
src/renderer/src/pages/files/FileItem.tsx | 145 +++++++
src/renderer/src/pages/files/FileList.tsx | 167 ++++++++
src/renderer/src/pages/files/FilesPage.tsx | 207 +++++-----
src/renderer/src/pages/files/GeminiFiles.tsx | 105 ++---
.../src/pages/knowledge/KnowledgeContent.tsx | 381 +++++++++---------
7 files changed, 708 insertions(+), 345 deletions(-)
create mode 100644 src/renderer/src/components/CustomCollapse.tsx
create mode 100644 src/renderer/src/pages/files/FileItem.tsx
create mode 100644 src/renderer/src/pages/files/FileList.tsx
diff --git a/src/renderer/src/assets/styles/animation.scss b/src/renderer/src/assets/styles/animation.scss
index 5d02acfc80..eb6cb3592a 100644
--- a/src/renderer/src/assets/styles/animation.scss
+++ b/src/renderer/src/assets/styles/animation.scss
@@ -1,4 +1,4 @@
-@keyframes pulse {
+@keyframes animation-pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5);
}
@@ -14,5 +14,5 @@
.animation-pulse {
--pulse-color: 59, 130, 246;
--pulse-size: 8px;
- animation: pulse 1.5s infinite;
+ animation: animation-pulse 1.5s infinite;
}
diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx
new file mode 100644
index 0000000000..dc15babf02
--- /dev/null
+++ b/src/renderer/src/components/CustomCollapse.tsx
@@ -0,0 +1,44 @@
+import { Collapse } from 'antd'
+import { FC, memo } from 'react'
+
+interface CustomCollapseProps {
+ label: string
+ extra: React.ReactNode
+ children: React.ReactNode
+}
+
+const CustomCollapse: FC = ({ label, extra, children }) => {
+ const CollapseStyle = {
+ background: 'transparent',
+ border: '0.5px solid var(--color-border)'
+ }
+ const CollapseItemStyles = {
+ header: {
+ padding: '8px 16px',
+ alignItems: 'center',
+ justifyContent: 'space-between'
+ },
+ body: {
+ borderTop: '0.5px solid var(--color-border)'
+ }
+ }
+ return (
+
+ )
+}
+
+export default memo(CustomCollapse)
diff --git a/src/renderer/src/pages/files/FileItem.tsx b/src/renderer/src/pages/files/FileItem.tsx
new file mode 100644
index 0000000000..79baa857c7
--- /dev/null
+++ b/src/renderer/src/pages/files/FileItem.tsx
@@ -0,0 +1,145 @@
+import {
+ FileExcelFilled,
+ FileImageFilled,
+ FileMarkdownFilled,
+ FilePdfFilled,
+ FilePptFilled,
+ FileTextFilled,
+ FileUnknownFilled,
+ FileWordFilled,
+ FileZipFilled,
+ FolderOpenFilled,
+ GlobalOutlined,
+ LinkOutlined
+} from '@ant-design/icons'
+import { Flex } from 'antd'
+import React, { memo } from 'react'
+import styled from 'styled-components'
+
+interface FileItemProps {
+ fileInfo: {
+ name: React.ReactNode | string
+ ext: string
+ extra?: React.ReactNode | string
+ actions: React.ReactNode
+ }
+}
+
+const getFileIcon = (type?: string) => {
+ if (!type) return
+
+ const ext = type.toLowerCase()
+
+ if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
+ return
+ }
+
+ if (['.doc', '.docx'].includes(ext)) {
+ return
+ }
+ if (['.xls', '.xlsx'].includes(ext)) {
+ return
+ }
+ if (['.ppt', '.pptx'].includes(ext)) {
+ return
+ }
+ if (ext === '.pdf') {
+ return
+ }
+ if (['.md', '.markdown'].includes(ext)) {
+ return
+ }
+
+ if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
+ return
+ }
+
+ if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
+ return
+ }
+
+ if (['.url'].includes(ext)) {
+ return
+ }
+
+ if (['.sitemap'].includes(ext)) {
+ return
+ }
+
+ if (['.folder'].includes(ext)) {
+ return
+ }
+
+ return
+}
+
+const FileItem: React.FC = ({ fileInfo }) => {
+ const { name, ext, extra, actions } = fileInfo
+
+ return (
+
+
+ {getFileIcon(ext)}
+
+ {name}
+ {extra && {extra}}
+
+ {actions}
+
+
+ )
+}
+
+const FileItemCard = styled.div`
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 8px;
+ overflow: hidden;
+ border: 0.5px solid var(--color-border);
+ flex-shrink: 0;
+ transition: box-shadow 0.2s ease;
+ --shadow-color: rgba(0, 0, 0, 0.05);
+ &:hover {
+ box-shadow:
+ 0 10px 15px -3px var(--shadow-color),
+ 0 4px 6px -4px var(--shadow-color);
+ }
+ body[theme-mode='dark'] & {
+ --shadow-color: rgba(255, 255, 255, 0.02);
+ }
+`
+
+const CardContent = styled.div`
+ padding: 8px 16px;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+`
+
+const FileIcon = styled.div`
+ color: var(--color-text-3);
+ font-size: 32px;
+`
+
+const FileName = styled.div`
+ font-size: 15px;
+ font-weight: bold;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+ transition: color 0.2s ease;
+ span {
+ font-size: 15px;
+ font-weight: bold;
+ }
+ &:hover {
+ color: var(--color-primary);
+ }
+`
+
+const FileInfo = styled.div`
+ font-size: 13px;
+ color: var(--color-text-2);
+`
+
+export default memo(FileItem)
diff --git a/src/renderer/src/pages/files/FileList.tsx b/src/renderer/src/pages/files/FileList.tsx
new file mode 100644
index 0000000000..d81b5d50f3
--- /dev/null
+++ b/src/renderer/src/pages/files/FileList.tsx
@@ -0,0 +1,167 @@
+import FileManager from '@renderer/services/FileManager'
+import { FileType, FileTypes } from '@renderer/types'
+import { formatFileSize } from '@renderer/utils'
+import { Col, Image, Row, Spin } from 'antd'
+import { t } from 'i18next'
+import VirtualList from 'rc-virtual-list'
+import React, { memo } from 'react'
+import styled from 'styled-components'
+
+import FileItem from './FileItem'
+import GeminiFiles from './GeminiFiles'
+
+interface FileItemProps {
+ id: FileTypes | 'all' | string
+ list: {
+ key: FileTypes | 'all' | string
+ file: React.ReactNode
+ files?: FileType[]
+ count?: number
+ size: string
+ ext: string
+ created_at: string
+ actions: React.ReactNode
+ }[]
+ files?: FileType[]
+}
+
+const FileList: React.FC = ({ id, list, files }) => {
+ if (id === FileTypes.IMAGE && files?.length && files?.length > 0) {
+ return (
+
+
+
+ {files?.map((file) => (
+
+
+
+
+
+ {
+ const img = e.target as HTMLImageElement
+ img.parentElement?.classList.add('loaded')
+ }}
+ />
+
+ {formatFileSize(file.size)}
+
+
+
+ ))}
+
+
+
+ )
+ }
+
+ if (id.startsWith('gemini_')) {
+ return
+ }
+
+ return (
+
+ {(item) => (
+
+
+
+ )}
+
+ )
+}
+
+const ImageWrapper = styled.div`
+ position: relative;
+ aspect-ratio: 1;
+ overflow: hidden;
+ border-radius: 8px;
+ background-color: var(--color-background-soft);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 0.5px solid var(--color-border);
+
+ .ant-image {
+ height: 100%;
+ width: 100%;
+ opacity: 0;
+ transition:
+ opacity 0.3s ease,
+ transform 0.3s ease;
+
+ &.loaded {
+ opacity: 1;
+ }
+ }
+
+ &:hover {
+ .ant-image.loaded {
+ transform: scale(1.05);
+ }
+
+ div:last-child {
+ opacity: 1;
+ }
+ }
+`
+
+const LoadingWrapper = styled.div`
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--color-background-soft);
+`
+
+const ImageInfo = styled.div`
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.6);
+ color: white;
+ padding: 5px 8px;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ font-size: 12px;
+
+ > div:first-child {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+`
+
+export default memo(FileList)
diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx
index 4ad8f7314b..bbbb770a1c 100644
--- a/src/renderer/src/pages/files/FilesPage.tsx
+++ b/src/renderer/src/pages/files/FilesPage.tsx
@@ -1,14 +1,15 @@
import {
DeleteOutlined,
EditOutlined,
- EllipsisOutlined,
+ ExclamationCircleOutlined,
FileImageOutlined,
FilePdfOutlined,
- FileTextOutlined
+ FileTextOutlined,
+ SortAscendingOutlined,
+ SortDescendingOutlined
} from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
-import Scrollbar from '@renderer/components/Scrollbar'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import FileManager from '@renderer/services/FileManager'
@@ -16,18 +17,23 @@ import store from '@renderer/store'
import { FileType, FileTypes } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import type { MenuProps } from 'antd'
-import { Button, Dropdown, Menu } from 'antd'
+import { Button, Empty, Flex, Menu, Popconfirm } from 'antd'
import dayjs from 'dayjs'
import { useLiveQuery } from 'dexie-react-hooks'
-import { FC, useMemo, useState } from 'react'
+import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
-import ContentView from './ContentView'
+import FileList from './FileList'
+
+type SortField = 'created_at' | 'size' | 'name'
+type SortOrder = 'asc' | 'desc'
const FilesPage: FC = () => {
const { t } = useTranslation()
const [fileType, setFileType] = useState('document')
+ const [sortField, setSortField] = useState('created_at')
+ const [sortOrder, setSortOrder] = useState('desc')
const { providers } = useProviders()
const geminiProviders = providers.filter((provider) => provider.type === 'gemini')
@@ -42,6 +48,24 @@ const FilesPage: FC = () => {
})
}
+ const sortFiles = (files: FileType[]) => {
+ return [...files].sort((a, b) => {
+ let comparison = 0
+ switch (sortField) {
+ case 'created_at':
+ comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix()
+ break
+ case 'size':
+ comparison = a.size - b.size
+ break
+ case 'name':
+ comparison = a.origin_name.localeCompare(b.origin_name)
+ break
+ }
+ return sortOrder === 'asc' ? comparison : -comparison
+ })
+ }
+
const files = useLiveQuery(() => {
if (fileType === 'all') {
return db.files.orderBy('count').toArray().then(tempFilesSort)
@@ -49,6 +73,8 @@ const FilesPage: FC = () => {
return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
}, [fileType])
+ const sortedFiles = files ? sortFiles(files) : []
+
const handleDelete = async (fileId: string) => {
const file = await FileManager.getFile(fileId)
@@ -89,95 +115,34 @@ const FilesPage: FC = () => {
}
}
- const getActionMenu = (fileId: string): MenuProps['items'] => [
- {
- key: 'rename',
- icon: ,
- label: t('files.edit'),
- onClick: () => handleRename(fileId)
- },
- {
- key: 'delete',
- icon: ,
- label: t('files.delete'),
- danger: true,
- onClick: () => {
- window.modal.confirm({
- title: t('files.delete.title'),
- content: t('files.delete.content'),
- centered: true,
- okButtonProps: { danger: true },
- onOk: () => handleDelete(fileId)
- })
- }
- }
- ]
-
- const dataSource = files?.map((file) => {
+ const dataSource = sortedFiles?.map((file) => {
return {
key: file.id,
- file: (
- window.api.file.openPath(file.path)}>
- {FileManager.formatFileName(file)}
-
- ),
+ file: window.api.file.openPath(file.path)}>{FileManager.formatFileName(file)},
size: formatFileSize(file.size),
size_bytes: file.size,
count: file.count,
+ path: file.path,
+ ext: file.ext,
created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
created_at_unix: dayjs(file.created_at).unix(),
actions: (
-
- } />
-
+
+ } onClick={() => handleRename(file.id)} />
+ handleDelete(file.id)}
+ icon={}>
+ } />
+
+
)
}
})
- const columns = useMemo(
- () => [
- {
- title: t('files.name'),
- dataIndex: 'file',
- key: 'file',
- width: '300px'
- },
- {
- title: t('files.size'),
- dataIndex: 'size',
- key: 'size',
- width: '80px',
- sorter: (a: { size_bytes: number }, b: { size_bytes: number }) => b.size_bytes - a.size_bytes,
- align: 'center'
- },
- {
- title: t('files.count'),
- dataIndex: 'count',
- key: 'count',
- width: '60px',
- sorter: (a: { count: number }, b: { count: number }) => b.count - a.count,
- align: 'center'
- },
- {
- title: t('files.created_at'),
- dataIndex: 'created_at',
- key: 'created_at',
- width: '120px',
- align: 'center',
- sorter: (a: { created_at_unix: number }, b: { created_at_unix: number }) =>
- b.created_at_unix - a.created_at_unix
- },
- {
- title: t('files.actions'),
- dataIndex: 'actions',
- key: 'actions',
- width: '80px',
- align: 'center'
- }
- ],
- [t]
- )
-
const menuItems = [
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: },
{ key: FileTypes.IMAGE, label: t('files.image'), icon: },
@@ -199,9 +164,31 @@ const FilesPage: FC = () => {
-
-
-
+
+
+ {['created_at', 'size', 'name'].map((field) => (
+ {
+ if (sortField === field) {
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
+ } else {
+ setSortField(field as 'created_at' | 'size' | 'name')
+ setSortOrder('desc')
+ }
+ }}>
+ {t(`files.${field}`)}
+ {sortField === field && (sortOrder === 'desc' ? : )}
+
+ ))}
+
+ {dataSource && dataSource?.length > 0 ? (
+
+ ) : (
+
+ )}
+
)
@@ -214,6 +201,20 @@ const Container = styled.div`
height: calc(100vh - var(--navbar-height));
`
+const MainContent = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+`
+
+const SortContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ border-bottom: 0.5px solid var(--color-border);
+`
+
const ContentContainer = styled.div`
display: flex;
flex: 1;
@@ -221,19 +222,6 @@ const ContentContainer = styled.div`
min-height: 100%;
`
-const TableContainer = styled(Scrollbar)`
- padding: 15px;
- display: flex;
- width: 100%;
- flex-direction: column;
-`
-
-const FileNameText = styled.div`
- font-size: 14px;
- color: var(--color-text);
- cursor: pointer;
-`
-
const SideNav = styled.div`
width: var(--assistants-width);
border-right: 0.5px solid var(--color-border);
@@ -266,4 +254,25 @@ const SideNav = styled.div`
}
`
+const SortButton = styled(Button)<{ active?: boolean }>`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 12px;
+ height: 30px;
+ border-radius: var(--list-item-border-radius);
+ border: 0.5px solid ${(props) => (props.active ? 'var(--color-border)' : 'transparent')};
+ background-color: ${(props) => (props.active ? 'var(--color-background-soft)' : 'transparent')};
+ color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')};
+
+ &:hover {
+ background-color: var(--color-background-soft);
+ color: var(--color-text);
+ }
+
+ .anticon {
+ font-size: 12px;
+ }
+`
+
export default FilesPage
diff --git a/src/renderer/src/pages/files/GeminiFiles.tsx b/src/renderer/src/pages/files/GeminiFiles.tsx
index ff60c8751a..a664856bb6 100644
--- a/src/renderer/src/pages/files/GeminiFiles.tsx
+++ b/src/renderer/src/pages/files/GeminiFiles.tsx
@@ -2,12 +2,13 @@ import { DeleteOutlined } from '@ant-design/icons'
import type { FileMetadataResponse } from '@google/generative-ai/server'
import { useProvider } from '@renderer/hooks/useProvider'
import { runAsyncFunction } from '@renderer/utils'
-import { Table } from 'antd'
-import type { ColumnsType } from 'antd/es/table'
+import { Spin } from 'antd'
+import dayjs from 'dayjs'
import { FC, useCallback, useEffect, useState } from 'react'
-import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
+import FileItem from './FileItem'
+
interface GeminiFilesProps {
id: string
}
@@ -15,7 +16,6 @@ interface GeminiFilesProps {
const GeminiFiles: FC = ({ id }) => {
const { provider } = useProvider(id)
const [files, setFiles] = useState([])
- const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const fetchFiles = useCallback(async () => {
@@ -23,51 +23,6 @@ const GeminiFiles: FC = ({ id }) => {
files && setFiles(files.filter((file) => file.state === 'ACTIVE'))
}, [provider])
- const columns: ColumnsType = [
- {
- title: t('files.name'),
- dataIndex: 'displayName',
- key: 'displayName'
- },
- {
- title: t('files.type'),
- dataIndex: 'mimeType',
- key: 'mimeType'
- },
- {
- title: t('files.size'),
- dataIndex: 'sizeBytes',
- key: 'sizeBytes',
- render: (size: string) => `${(parseInt(size) / 1024 / 1024).toFixed(2)} MB`
- },
- {
- title: t('files.created_at'),
- dataIndex: 'createTime',
- key: 'createTime',
- render: (time: string) => new Date(time).toLocaleString()
- },
- {
- title: t('files.actions'),
- dataIndex: 'actions',
- key: 'actions',
- align: 'center',
- render: (_, record) => {
- return (
- {
- setFiles(files.filter((file) => file.name !== record.name))
- window.api.gemini.deleteFile(provider.apiKey, record.name).catch((error) => {
- console.error('Failed to delete file:', error)
- setFiles((prev) => [...prev, record])
- })
- }}
- />
- )
- }
- }
- ]
-
useEffect(() => {
runAsyncFunction(async () => {
try {
@@ -86,13 +41,61 @@ const GeminiFiles: FC = ({ id }) => {
setFiles([])
}, [id])
+ if (loading) {
+ return (
+
+
+
+
+
+ )
+ }
+
return (
-
+
+ {files.map((file) => (
+ {
+ setFiles(files.filter((f) => f.name !== file.name))
+ window.api.gemini.deleteFile(provider.apiKey, file.name).catch((error) => {
+ console.error('Failed to delete file:', error)
+ setFiles((prev) => [...prev, file])
+ })
+ }}
+ />
+ )
+ }}
+ />
+ ))}
+
)
}
-const Container = styled.div``
+const Container = styled.div`
+ width: 100%;
+`
+
+const FileListContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`
+
+const LoadingWrapper = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 200px;
+`
export default GeminiFiles
diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx
index ada10179b7..88f87e5301 100644
--- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx
+++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx
@@ -2,10 +2,6 @@ import {
CopyOutlined,
DeleteOutlined,
EditOutlined,
- FileTextOutlined,
- FolderOutlined,
- GlobalOutlined,
- LinkOutlined,
PlusOutlined,
RedoOutlined,
SearchOutlined,
@@ -19,24 +15,29 @@ import { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileManager from '@renderer/services/FileManager'
import { getProviderName } from '@renderer/services/ProviderService'
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
+import { formatFileSize } from '@renderer/utils'
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
-import { Alert, Button, Card, Divider, Dropdown, message, Tag, Tooltip, Typography, Upload } from 'antd'
+import { Alert, Button, Divider, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd'
+import dayjs from 'dayjs'
+import VirtualList from 'rc-virtual-list'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
+import CustomCollapse from '../../components/CustomCollapse'
+import FileItem from '../files/FileItem'
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
import StatusIcon from './components/StatusIcon'
const { Dragger } = Upload
-const { Title } = Typography
interface KnowledgeContentProps {
selectedBase: KnowledgeBase
}
const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts]
+
const KnowledgeContent: FC = ({ selectedBase }) => {
const { t } = useTranslation()
@@ -234,13 +235,14 @@ const KnowledgeContent: FC = ({ selectedBase }) => {
{!providerName && (
)}
-
-
- {t('files.title')}
- } onClick={handleAddFile} disabled={disabled}>
+
+ } onClick={handleAddFile} disabled={disabled}>
{t('knowledge.add_file')}
-
+ }>
handleDrop([file as File])}
@@ -252,86 +254,123 @@ const KnowledgeContent: FC = ({ selectedBase }) => {
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
-
-
- {fileItems.reverse().map((item) => {
- const file = item.content as FileType
- return (
-
-
-
-
- window.api.file.openPath(file.path)}>
-
- {file.origin_name}
-
-
-
-
- {item.uniqueId && } onClick={() => refreshItem(item)} />}
-
-
-
-
-
-
- )
- })}
-
+
+ {fileItems.length === 0 ? (
+
+ ) : (
+
+ {(item) => {
+ const file = item.content as FileType
+ return (
+
+ window.api.file.openPath(file.path)}>
+
+ {file.origin_name}
+
+
+ ),
+ ext: file.ext,
+ extra: `${dayjs(file.created_at).format('MM-DD HH:mm')} · ${formatFileSize(file.size)}`,
+ actions: (
+
+ {item.uniqueId && (
+ } onClick={() => refreshItem(item)} />
+ )}
+
+
+
+
+ )
+ }}
+ />
+
+ )
+ }}
+
+ )}
+
+
-
-
- {t('knowledge.directories')}
- } onClick={handleAddDirectory} disabled={disabled}>
+ } onClick={handleAddDirectory} disabled={disabled}>
{t('knowledge.add_directory')}
-
+ }>
+ {directoryItems.length === 0 && }
{directoryItems.reverse().map((item) => (
-
-
-
-
+ window.api.file.openPath(item.content as string)}>
{item.content as string}
-
-
- {item.uniqueId && } onClick={() => refreshItem(item)} />}
-
-
-
-
-
-
+ ),
+ ext: '.folder',
+ extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
+ actions: (
+
+ {item.uniqueId && } onClick={() => refreshItem(item)} />}
+
+
+
+
+ )
+ }}
+ />
))}
-
+
-
-
- {t('knowledge.urls')}
- } onClick={handleAddUrl} disabled={disabled}>
+ } onClick={handleAddUrl} disabled={disabled}>
{t('knowledge.add_url')}
-
+ }>
+ {urlItems.length === 0 && }
{urlItems.reverse().map((item) => (
-
-
-
-
+ = ({ selectedBase }) => {
-
-
- {item.uniqueId && } onClick={() => refreshItem(item)} />}
-
-
-
-
-
-
+ ),
+ ext: '.url',
+ extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
+ actions: (
+
+ {item.uniqueId && } onClick={() => refreshItem(item)} />}
+
+
+
+
+ )
+ }}
+ />
))}
-
+
-
-
- {t('knowledge.sitemaps')}
- } onClick={handleAddSitemap} disabled={disabled}>
+ } onClick={handleAddSitemap} disabled={disabled}>
{t('knowledge.add_sitemap')}
-
+ }>
+ {sitemapItems.length === 0 && }
{sitemapItems.reverse().map((item) => (
-
-
-
-
+
@@ -399,51 +443,64 @@ const KnowledgeContent: FC = ({ selectedBase }) => {
-
-
- {item.uniqueId && } onClick={() => refreshItem(item)} />}
-
-
-
-
-
-
+ ),
+ ext: '.sitemap',
+ extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
+ actions: (
+
+ {item.uniqueId && } onClick={() => refreshItem(item)} />}
+
+
+
+
+ )
+ }}
+ />
))}
-
+
-
-
- {t('knowledge.notes')}
- } onClick={handleAddNote} disabled={disabled}>
+ } onClick={handleAddNote} disabled={disabled}>
{t('knowledge.add_note')}
-
+ }>
+ {noteItems.length === 0 && }
{noteItems.reverse().map((note) => (
-
-
- handleEditNote(note)} style={{ cursor: 'pointer' }}>
- {(note.content as string).slice(0, 50)}...
-
-
-
-
-
+ handleEditNote(note)}>{(note.content as string).slice(0, 50)}...,
+ ext: '.txt',
+ extra: `${dayjs(note.created_at).format('MM-DD HH:mm')}`,
+ actions: (
+
+ handleEditNote(note)} icon={} />
+
+
+
+ removeItem(note)} icon={} />
+
+ )
+ }}
+ />
))}
-
+
@@ -498,69 +555,9 @@ const MainContent = styled(Scrollbar)`
padding-bottom: 50px;
padding: 15px;
position: relative;
-`
-
-const FileSection = styled.div`
- display: flex;
- flex-direction: column;
-`
-
-const ContentSection = styled.div`
- margin-top: 20px;
- display: flex;
- flex-direction: column;
- gap: 10px;
-
- .ant-input-textarea {
- background: var(--color-background-soft);
- border-radius: 8px;
- }
-`
-
-const TitleWrapper = styled.div`
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 5px;
- background-color: var(--color-background-soft);
- padding: 5px 20px;
- min-height: 45px;
- border-radius: 6px;
- .ant-typography {
- margin-bottom: 0;
- }
-`
-
-const FileListSection = styled.div`
- margin-top: 20px;
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 8px;
-`
-
-const ItemCard = styled(Card)`
- background-color: transparent;
- border: none;
- .ant-card-body {
- padding: 0 20px;
- }
-`
-
-const ItemContent = styled.div`
- display: flex;
- align-items: center;
- justify-content: space-between;
gap: 16px;
`
-const ItemInfo = styled.div`
- display: flex;
- align-items: center;
- gap: 8px;
- flex: 1;
-`
-
const IndexSection = styled.div`
margin-top: 20px;
display: flex;
@@ -602,10 +599,12 @@ const ModelInfo = styled.div`
color: var(--color-text-2);
}
`
+
const FlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
+ margin-top: 16px;
`
const FlexAlignCenter = styled.div`
@@ -620,10 +619,6 @@ const ClickableSpan = styled.span`
width: 0;
`
-const FileIcon = styled(FileTextOutlined)`
- font-size: 16px;
-`
-
const BottomSpacer = styled.div`
min-height: 20px;
`
From 9e977f4b350d4ec401ad8dd4bd54fdb9ff54cd07 Mon Sep 17 00:00:00 2001
From: Hao He <57698783+Harris-H@users.noreply.github.com>
Date: Sun, 30 Mar 2025 13:58:52 +0800
Subject: [PATCH 02/37] feat: Add keyboard navigation and selection
highlighting for AddAssistantPopup (#4022)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(AddAssistantPopup): 添加键盘导航和选中项高亮功能
* feat(AddAssistantPopup): 为所有项添加相同宽度的透明边框,避免布局跳动。
---
.../components/Popups/AddAssistantPopup.tsx | 101 ++++++++++++++----
1 file changed, 82 insertions(+), 19 deletions(-)
diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx
index d2479c0508..7d3ec6a04f 100644
--- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx
+++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx
@@ -9,7 +9,7 @@ import { Agent, Assistant } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
import { take } from 'lodash'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -30,6 +30,8 @@ const PopupContainer: React.FC = ({ resolve }) => {
const inputRef = useRef(null)
const systemAgents = useSystemAgents()
const loadingRef = useRef(false)
+ const [selectedIndex, setSelectedIndex] = useState(0)
+ const containerRef = useRef(null)
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
@@ -52,25 +54,80 @@ const PopupContainer: React.FC = ({ resolve }) => {
return filtered
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
- const onCreateAssistant = async (agent: Agent) => {
- if (loadingRef.current) {
- return
+ // 重置选中索引当搜索或列表内容变更时
+ useEffect(() => {
+ setSelectedIndex(0)
+ }, [agents.length, searchText])
+
+ const onCreateAssistant = useCallback(
+ async (agent: Agent) => {
+ if (loadingRef.current) {
+ return
+ }
+
+ loadingRef.current = true
+ let assistant: Assistant
+
+ if (agent.id === 'default') {
+ assistant = { ...agent, id: uuid() }
+ addAssistant(assistant)
+ } else {
+ assistant = await createAssistantFromAgent(agent)
+ }
+
+ setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
+ resolve(assistant)
+ setOpen(false)
+ },
+ [resolve, addAssistant, setOpen]
+ ) // 添加函数内使用的依赖项
+ // 键盘导航处理
+ useEffect(() => {
+ if (!open) return
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const displayedAgents = take(agents, 100)
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault()
+ setSelectedIndex((prev) => (prev >= displayedAgents.length - 1 ? 0 : prev + 1))
+ break
+ case 'ArrowUp':
+ e.preventDefault()
+ setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
+ break
+ case 'Enter':
+ // 如果焦点在输入框且有搜索内容,则默认选择第一项
+ if (document.activeElement === inputRef.current?.input && searchText.trim()) {
+ e.preventDefault()
+ onCreateAssistant(displayedAgents[selectedIndex])
+ }
+ // 否则选择当前选中项
+ else if (selectedIndex >= 0 && selectedIndex < displayedAgents.length) {
+ e.preventDefault()
+ onCreateAssistant(displayedAgents[selectedIndex])
+ }
+ break
+ }
}
- loadingRef.current = true
- let assistant: Assistant
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [open, selectedIndex, agents, searchText, onCreateAssistant])
- if (agent.id === 'default') {
- assistant = { ...agent, id: uuid() }
- addAssistant(assistant)
- } else {
- assistant = await createAssistantFromAgent(agent)
+ // 确保选中项在可视区域
+ useEffect(() => {
+ if (containerRef.current) {
+ const agentItems = containerRef.current.querySelectorAll('.agent-item')
+ if (agentItems[selectedIndex]) {
+ agentItems[selectedIndex].scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest'
+ })
+ }
}
-
- setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
- resolve(assistant)
- setOpen(false)
- }
+ }, [selectedIndex])
const onCancel = () => {
setOpen(false)
@@ -121,12 +178,13 @@ const PopupContainer: React.FC = ({ resolve }) => {
/>
-
- {take(agents, 100).map((agent) => (
+
+ {take(agents, 100).map((agent, index) => (
onCreateAssistant(agent)}
- className={agent.id === 'default' ? 'default' : ''}>
+ className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
+ onMouseEnter={() => setSelectedIndex(index)}>
Date: Sun, 30 Mar 2025 14:08:14 +0800
Subject: [PATCH 03/37] chore(store): update migration logic and increment
version to 87
* Updated migration functions to include error handling for provider additions.
* Incremented the version number in the persisted reducer configuration.
---
src/renderer/src/store/index.ts | 2 +-
src/renderer/src/store/migrate.ts | 774 +++++++++++++++++++-----------
2 files changed, 489 insertions(+), 287 deletions(-)
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index b4ad565bf2..53a3aba335 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
- version: 86,
+ version: 87,
blacklist: ['runtime', 'messages'],
migrate
},
diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts
index 6861f1f52f..c35693c66f 100644
--- a/src/renderer/src/store/migrate.ts
+++ b/src/renderer/src/store/migrate.ts
@@ -35,420 +35,609 @@ function addProvider(state: RootState, id: string) {
const migrateConfig = {
'2': (state: RootState) => {
- addProvider(state, 'yi')
- return state
+ try {
+ addProvider(state, 'yi')
+ return state
+ } catch (error) {
+ return state
+ }
},
'3': (state: RootState) => {
- addProvider(state, 'zhipu')
- return state
+ try {
+ addProvider(state, 'zhipu')
+ return state
+ } catch (error) {
+ return state
+ }
},
'4': (state: RootState) => {
- addProvider(state, 'ollama')
- return state
+ try {
+ addProvider(state, 'ollama')
+ return state
+ } catch (error) {
+ return state
+ }
},
'5': (state: RootState) => {
- addProvider(state, 'moonshot')
- return state
+ try {
+ addProvider(state, 'moonshot')
+ return state
+ } catch (error) {
+ return state
+ }
},
'6': (state: RootState) => {
- addProvider(state, 'openrouter')
- return state
+ try {
+ addProvider(state, 'openrouter')
+ return state
+ } catch (error) {
+ return state
+ }
},
'7': (state: RootState) => {
- return {
- ...state,
- settings: {
- ...state.settings,
- language: navigator.language
+ try {
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ language: navigator.language
+ }
}
+ } catch (error) {
+ return state
}
},
'8': (state: RootState) => {
- const fixAssistantName = (assistant: Assistant) => {
- if (isEmpty(assistant.name)) {
- assistant.name = i18n.t(`assistant.${assistant.id}.name`)
- }
-
- assistant.topics = assistant.topics.map((topic) => {
- if (isEmpty(topic.name)) {
- topic.name = i18n.t(`assistant.${assistant.id}.topic.name`)
+ try {
+ const fixAssistantName = (assistant: Assistant) => {
+ if (isEmpty(assistant.name)) {
+ assistant.name = i18n.t(`assistant.${assistant.id}.name`)
}
- return topic
- })
- return assistant
- }
+ assistant.topics = assistant.topics.map((topic) => {
+ if (isEmpty(topic.name)) {
+ topic.name = i18n.t(`assistant.${assistant.id}.topic.name`)
+ }
+ return topic
+ })
- return {
- ...state,
- assistants: {
- ...state.assistants,
- defaultAssistant: fixAssistantName(state.assistants.defaultAssistant),
- assistants: state.assistants.assistants.map((assistant) => fixAssistantName(assistant))
+ return assistant
}
+
+ return {
+ ...state,
+ assistants: {
+ ...state.assistants,
+ defaultAssistant: fixAssistantName(state.assistants.defaultAssistant),
+ assistants: state.assistants.assistants.map((assistant) => fixAssistantName(assistant))
+ }
+ }
+ } catch (error) {
+ return state
}
},
'9': (state: RootState) => {
- return {
- ...state,
- llm: {
- ...state.llm,
- providers: state.llm.providers.map((provider) => {
- if (provider.id === 'zhipu' && provider.models[0] && provider.models[0].id === 'llama3-70b-8192') {
- provider.models = SYSTEM_MODELS.zhipu
- }
- return provider
- })
+ try {
+ return {
+ ...state,
+ llm: {
+ ...state.llm,
+ providers: state.llm.providers.map((provider) => {
+ if (provider.id === 'zhipu' && provider.models[0] && provider.models[0].id === 'llama3-70b-8192') {
+ provider.models = SYSTEM_MODELS.zhipu
+ }
+ return provider
+ })
+ }
}
+ } catch (error) {
+ return state
}
},
'10': (state: RootState) => {
- addProvider(state, 'baichuan')
- return state
+ try {
+ addProvider(state, 'baichuan')
+ return state
+ } catch (error) {
+ return state
+ }
},
'11': (state: RootState) => {
- addProvider(state, 'dashscope')
- addProvider(state, 'anthropic')
- return state
+ try {
+ addProvider(state, 'dashscope')
+ addProvider(state, 'anthropic')
+ return state
+ } catch (error) {
+ return state
+ }
},
'12': (state: RootState) => {
- addProvider(state, 'aihubmix')
- return state
+ try {
+ addProvider(state, 'aihubmix')
+ return state
+ } catch (error) {
+ return state
+ }
},
'13': (state: RootState) => {
- return {
- ...state,
- assistants: {
- ...state.assistants,
- defaultAssistant: {
- ...state.assistants.defaultAssistant,
- name: ['Default Assistant', '默认助手'].includes(state.assistants.defaultAssistant.name)
- ? i18n.t(`assistant.default.name`)
- : state.assistants.defaultAssistant.name
+ try {
+ return {
+ ...state,
+ assistants: {
+ ...state.assistants,
+ defaultAssistant: {
+ ...state.assistants.defaultAssistant,
+ name: ['Default Assistant', '默认助手'].includes(state.assistants.defaultAssistant.name)
+ ? i18n.t(`assistant.default.name`)
+ : state.assistants.defaultAssistant.name
+ }
}
}
+ } catch (error) {
+ return state
}
},
'14': (state: RootState) => {
- return {
- ...state,
- settings: {
- ...state.settings,
- showAssistants: true,
- proxyUrl: undefined
+ try {
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ showAssistants: true,
+ proxyUrl: undefined
+ }
}
+ } catch (error) {
+ return state
}
},
'15': (state: RootState) => {
- return {
- ...state,
- settings: {
- ...state.settings,
- userName: '',
- showMessageDivider: true
+ try {
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ userName: '',
+ showMessageDivider: true
+ }
}
+ } catch (error) {
+ return state
}
},
'16': (state: RootState) => {
- return {
- ...state,
- settings: {
- ...state.settings,
- messageFont: 'system',
- showInputEstimatedTokens: false
+ try {
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ messageFont: 'system',
+ showInputEstimatedTokens: false
+ }
}
+ } catch (error) {
+ return state
}
},
'17': (state: RootState) => {
- return {
- ...state,
- settings: {
- ...state.settings,
- theme: 'auto'
+ try {
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ theme: 'auto'
+ }
}
+ } catch (error) {
+ return state
}
},
'19': (state: RootState) => {
- return {
- ...state,
- agents: {
- agents: []
- },
- llm: {
- ...state.llm,
- settings: {
- ollama: {
- keepAliveTime: 5
+ try {
+ return {
+ ...state,
+ agents: {
+ agents: []
+ },
+ llm: {
+ ...state.llm,
+ settings: {
+ ollama: {
+ keepAliveTime: 5
+ }
}
}
}
+ } catch (error) {
+ return state
}
},
'20': (state: RootState) => {
- return {
- ...state,
- settings: {
- ...state.settings,
- fontSize: 14
+ try {
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ fontSize: 14
+ }
}
+ } catch (error) {
+ return state
}
},
'21': (state: RootState) => {
- addProvider(state, 'gemini')
- addProvider(state, 'stepfun')
- addProvider(state, 'doubao')
- return state
+ try {
+ addProvider(state, 'gemini')
+ addProvider(state, 'stepfun')
+ addProvider(state, 'doubao')
+ return state
+ } catch (error) {
+ return state
+ }
},
'22': (state: RootState) => {
- addProvider(state, 'minimax')
- return state
+ try {
+ addProvider(state, 'minimax')
+ return state
+ } catch (error) {
+ return state
+ }
},
'23': (state: RootState) => {
- return {
- ...state,
- settings: {
- ...state.settings,
- showTopics: true,
- windowStyle: 'transparent'
+ try {
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ showTopics: true,
+ windowStyle: 'transparent'
+ }
}
+ } catch (error) {
+ return state
}
},
'24': (state: RootState) => {
- return {
- ...state,
- assistants: {
- ...state.assistants,
- assistants: state.assistants.assistants.map((assistant) => ({
- ...assistant,
- topics: assistant.topics.map((topic) => ({
- ...topic,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString()
+ try {
+ return {
+ ...state,
+ assistants: {
+ ...state.assistants,
+ assistants: state.assistants.assistants.map((assistant) => ({
+ ...assistant,
+ topics: assistant.topics.map((topic) => ({
+ ...topic,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ }))
}))
- }))
- },
- settings: {
- ...state.settings,
- topicPosition: 'right'
+ },
+ settings: {
+ ...state.settings,
+ topicPosition: 'right'
+ }
}
+ } catch (error) {
+ return state
}
},
'25': (state: RootState) => {
- addProvider(state, 'github')
- return state
+ try {
+ addProvider(state, 'github')
+ return state
+ } catch (error) {
+ return state
+ }
},
'26': (state: RootState) => {
- addProvider(state, 'ocoolai')
- return state
+ try {
+ addProvider(state, 'ocoolai')
+ return state
+ } catch (error) {
+ return state
+ }
},
'27': (state: RootState) => {
- return {
- ...state,
- settings: {
- ...state.settings,
- renderInputMessageAsMarkdown: true
+ try {
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ renderInputMessageAsMarkdown: true
+ }
}
+ } catch (error) {
+ return state
}
},
'28': (state: RootState) => {
- addProvider(state, 'together')
- addProvider(state, 'fireworks')
- addProvider(state, 'zhinao')
- addProvider(state, 'hunyuan')
- addProvider(state, 'nvidia')
+ try {
+ addProvider(state, 'together')
+ addProvider(state, 'fireworks')
+ addProvider(state, 'zhinao')
+ addProvider(state, 'hunyuan')
+ addProvider(state, 'nvidia')
+ return state
+ } catch (error) {
+ return state
+ }
},
'29': (state: RootState) => {
- return {
- ...state,
- assistants: {
- ...state.assistants,
- assistants: state.assistants.assistants.map((assistant) => {
- assistant.topics = assistant.topics.map((topic) => ({
- ...topic,
- assistantId: assistant.id
- }))
- return assistant
- })
+ try {
+ return {
+ ...state,
+ assistants: {
+ ...state.assistants,
+ assistants: state.assistants.assistants.map((assistant) => {
+ assistant.topics = assistant.topics.map((topic) => ({
+ ...topic,
+ assistantId: assistant.id
+ }))
+ return assistant
+ })
+ }
}
+ } catch (error) {
+ return state
}
},
'30': (state: RootState) => {
- addProvider(state, 'azure-openai')
- return state
+ try {
+ addProvider(state, 'azure-openai')
+ return state
+ } catch (error) {
+ return state
+ }
},
'31': (state: RootState) => {
- return {
- ...state,
- llm: {
- ...state.llm,
- providers: state.llm.providers.map((provider) => {
- if (provider.id === 'azure-openai') {
- provider.models = provider.models.map((model) => ({ ...model, provider: 'azure-openai' }))
- }
- return provider
- })
+ try {
+ return {
+ ...state,
+ llm: {
+ ...state.llm,
+ providers: state.llm.providers.map((provider) => {
+ if (provider.id === 'azure-openai') {
+ provider.models = provider.models.map((model) => ({ ...model, provider: 'azure-openai' }))
+ }
+ return provider
+ })
+ }
}
+ } catch (error) {
+ return state
}
},
'32': (state: RootState) => {
- addProvider(state, 'hunyuan')
- return state
+ try {
+ addProvider(state, 'hunyuan')
+ return state
+ } catch (error) {
+ return state
+ }
},
'33': (state: RootState) => {
- state.assistants.defaultAssistant.type = 'assistant'
+ try {
+ state.assistants.defaultAssistant.type = 'assistant'
- state.agents.agents.forEach((agent) => {
- agent.type = 'agent'
- // @ts-ignore eslint-disable-next-line
- delete agent.group
- })
+ state.agents.agents.forEach((agent) => {
+ agent.type = 'agent'
+ // @ts-ignore eslint-disable-next-line
+ delete agent.group
+ })
- return {
- ...state,
- assistants: {
- ...state.assistants,
- assistants: [...state.assistants.assistants].map((assistant) => {
- // @ts-ignore eslint-disable-next-line
- delete assistant.group
- return {
- ...assistant,
- id: assistant.id.length === 36 ? assistant.id : uuid(),
- type: assistant.type === 'system' ? assistant.type : 'assistant'
- }
- })
+ return {
+ ...state,
+ assistants: {
+ ...state.assistants,
+ assistants: [...state.assistants.assistants].map((assistant) => {
+ // @ts-ignore eslint-disable-next-line
+ delete assistant.group
+ return {
+ ...assistant,
+ id: assistant.id.length === 36 ? assistant.id : uuid(),
+ type: assistant.type === 'system' ? assistant.type : 'assistant'
+ }
+ })
+ }
}
+ } catch (error) {
+ return state
}
},
'34': (state: RootState) => {
- state.assistants.assistants.forEach((assistant) => {
- assistant.topics.forEach((topic) => {
- topic.assistantId = assistant.id
- runAsyncFunction(async () => {
- const _topic = await db.topics.get(topic.id)
- if (_topic) {
- const messages = (_topic?.messages || []).map((message) => ({ ...message, assistantId: assistant.id }))
- db.topics.put({ ..._topic, messages }, topic.id)
- }
+ try {
+ state.assistants.assistants.forEach((assistant) => {
+ assistant.topics.forEach((topic) => {
+ topic.assistantId = assistant.id
+ runAsyncFunction(async () => {
+ const _topic = await db.topics.get(topic.id)
+ if (_topic) {
+ const messages = (_topic?.messages || []).map((message) => ({ ...message, assistantId: assistant.id }))
+ db.topics.put({ ..._topic, messages }, topic.id)
+ }
+ })
})
})
- })
- return state
+ return state
+ } catch (error) {
+ return state
+ }
},
'35': (state: RootState) => {
- state.settings.mathEngine = 'KaTeX'
- return state
+ try {
+ state.settings.mathEngine = 'KaTeX'
+ return state
+ } catch (error) {
+ return state
+ }
},
'36': (state: RootState) => {
- state.settings.topicPosition = 'left'
- return state
+ try {
+ state.settings.topicPosition = 'left'
+ return state
+ } catch (error) {
+ return state
+ }
},
'37': (state: RootState) => {
- state.settings.messageStyle = 'plain'
- return state
+ try {
+ state.settings.messageStyle = 'plain'
+ return state
+ } catch (error) {
+ return state
+ }
},
'38': (state: RootState) => {
- addProvider(state, 'grok')
- addProvider(state, 'hyperbolic')
- addProvider(state, 'mistral')
- return state
+ try {
+ addProvider(state, 'grok')
+ addProvider(state, 'hyperbolic')
+ addProvider(state, 'mistral')
+ return state
+ } catch (error) {
+ return state
+ }
},
'39': (state: RootState) => {
- state.settings.codeStyle = 'auto'
- return state
+ try {
+ state.settings.codeStyle = 'auto'
+ return state
+ } catch (error) {
+ return state
+ }
},
'40': (state: RootState) => {
- state.settings.tray = true
- return state
+ try {
+ state.settings.tray = true
+ return state
+ } catch (error) {
+ return state
+ }
},
'41': (state: RootState) => {
- state.llm.providers.forEach((provider) => {
- if (provider.id === 'gemini') {
- provider.type = 'gemini'
- } else if (provider.id === 'anthropic') {
- provider.type = 'anthropic'
- } else {
- provider.type = 'openai'
- }
- })
- return state
+ try {
+ state.llm.providers.forEach((provider) => {
+ if (provider.id === 'gemini') {
+ provider.type = 'gemini'
+ } else if (provider.id === 'anthropic') {
+ provider.type = 'anthropic'
+ } else {
+ provider.type = 'openai'
+ }
+ })
+ return state
+ } catch (error) {
+ return state
+ }
},
'42': (state: RootState) => {
- state.settings.proxyMode = state.settings.proxyUrl ? 'custom' : 'none'
- return state
+ try {
+ state.settings.proxyMode = state.settings.proxyUrl ? 'custom' : 'none'
+ return state
+ } catch (error) {
+ return state
+ }
},
'43': (state: RootState) => {
- if (state.settings.proxyMode === 'none') {
- state.settings.proxyMode = 'system'
+ try {
+ if (state.settings.proxyMode === 'none') {
+ state.settings.proxyMode = 'system'
+ }
+ return state
+ } catch (error) {
+ return state
}
- return state
},
'44': (state: RootState) => {
- state.settings.translateModelPrompt = TRANSLATE_PROMPT
- return state
+ try {
+ state.settings.translateModelPrompt = TRANSLATE_PROMPT
+ return state
+ } catch (error) {
+ return state
+ }
},
'45': (state: RootState) => {
state.settings.enableTopicNaming = true
return state
},
'46': (state: RootState) => {
- if (
- state.settings?.translateModelPrompt?.includes(
- 'If the target language is the same as the source language, do not translate'
- )
- ) {
- state.settings.translateModelPrompt = TRANSLATE_PROMPT
+ try {
+ if (
+ state.settings?.translateModelPrompt?.includes(
+ 'If the target language is the same as the source language, do not translate'
+ )
+ ) {
+ state.settings.translateModelPrompt = TRANSLATE_PROMPT
+ }
+ return state
+ } catch (error) {
+ return state
}
- return state
},
'47': (state: RootState) => {
- state.llm.providers.forEach((provider) => {
- provider.models.forEach((model) => {
- model.group = getDefaultGroupName(model.id)
+ try {
+ state.llm.providers.forEach((provider) => {
+ provider.models.forEach((model) => {
+ model.group = getDefaultGroupName(model.id)
+ })
})
- })
- return state
+ return state
+ } catch (error) {
+ return state
+ }
},
'48': (state: RootState) => {
- if (state.shortcuts) {
- state.shortcuts.shortcuts.forEach((shortcut) => {
- shortcut.system = shortcut.key !== 'new_topic'
- })
- state.shortcuts.shortcuts.push({
- key: 'toggle_show_assistants',
- shortcut: [isMac ? 'Command' : 'Ctrl', '['],
- editable: true,
- enabled: true,
- system: false
- })
- state.shortcuts.shortcuts.push({
- key: 'toggle_show_topics',
- shortcut: [isMac ? 'Command' : 'Ctrl', ']'],
- editable: true,
- enabled: true,
- system: false
- })
+ try {
+ if (state.shortcuts) {
+ state.shortcuts.shortcuts.forEach((shortcut) => {
+ shortcut.system = shortcut.key !== 'new_topic'
+ })
+ state.shortcuts.shortcuts.push({
+ key: 'toggle_show_assistants',
+ shortcut: [isMac ? 'Command' : 'Ctrl', '['],
+ editable: true,
+ enabled: true,
+ system: false
+ })
+ state.shortcuts.shortcuts.push({
+ key: 'toggle_show_topics',
+ shortcut: [isMac ? 'Command' : 'Ctrl', ']'],
+ editable: true,
+ enabled: true,
+ system: false
+ })
+ }
+ return state
+ } catch (error) {
+ return state
}
- return state
},
'49': (state: RootState) => {
- state.settings.pasteLongTextThreshold = 1500
- if (state.shortcuts) {
- state.shortcuts.shortcuts = [
- ...state.shortcuts.shortcuts,
- {
- key: 'copy_last_message',
- shortcut: [isMac ? 'Command' : 'Ctrl', 'Shift', 'C'],
- editable: true,
- enabled: false,
- system: false
- }
- ]
+ try {
+ state.settings.pasteLongTextThreshold = 1500
+ if (state.shortcuts) {
+ state.shortcuts.shortcuts = [
+ ...state.shortcuts.shortcuts,
+ {
+ key: 'copy_last_message',
+ shortcut: [isMac ? 'Command' : 'Ctrl', 'Shift', 'C'],
+ editable: true,
+ enabled: false,
+ system: false
+ }
+ ]
+ }
+ return state
+ } catch (error) {
+ return state
}
- return state
},
'50': (state: RootState) => {
- addProvider(state, 'jina')
- return state
+ try {
+ addProvider(state, 'jina')
+ return state
+ } catch (error) {
+ return state
+ }
},
'51': (state: RootState) => {
state.settings.topicNamingPrompt = ''
@@ -634,24 +823,28 @@ const migrateConfig = {
}
},
'67': (state: RootState) => {
- if (state.minapps) {
- const xiaoyi = DEFAULT_MIN_APPS.find((app) => app.id === 'xiaoyi')
- if (xiaoyi) {
- state.minapps.enabled.push(xiaoyi)
+ try {
+ if (state.minapps) {
+ const xiaoyi = DEFAULT_MIN_APPS.find((app) => app.id === 'xiaoyi')
+ if (xiaoyi) {
+ state.minapps.enabled.push(xiaoyi)
+ }
}
+
+ addProvider(state, 'modelscope')
+ addProvider(state, 'lmstudio')
+ addProvider(state, 'perplexity')
+ addProvider(state, 'infini')
+ addProvider(state, 'dmxapi')
+
+ state.llm.settings.lmstudio = {
+ keepAliveTime: 5
+ }
+
+ return state
+ } catch (error) {
+ return state
}
-
- addProvider(state, 'modelscope')
- addProvider(state, 'lmstudio')
- addProvider(state, 'perplexity')
- addProvider(state, 'infini')
- addProvider(state, 'dmxapi')
-
- state.llm.settings.lmstudio = {
- keepAliveTime: 5
- }
-
- return state
},
'68': (state: RootState) => {
try {
@@ -946,6 +1139,15 @@ const migrateConfig = {
}
return state
+ },
+ '87': (state: RootState) => {
+ try {
+ state.settings.maxKeepAliveMinapps = 3
+ state.settings.showOpenedMinappsInSidebar = true
+ return state
+ } catch (error) {
+ return state
+ }
}
}
From 94eb7f3a3452b6d769a119e933b80e912a6d13b4 Mon Sep 17 00:00:00 2001
From: kangfenmao
Date: Sun, 30 Mar 2025 14:32:57 +0800
Subject: [PATCH 04/37] refactor(knowledge): enhance CustomCollapse component
and improve UI consistency
* Updated CustomCollapse to accept React nodes for labels, allowing for more flexible content.
* Replaced static labels with CollapseLabel component to display item counts.
* Introduced EmptyView component for consistent empty state representation across collapsible sections.
* Removed unnecessary styles and improved button click handling to prevent event propagation.
---
.../src/components/CustomCollapse.tsx | 3 +-
src/renderer/src/pages/files/FileItem.tsx | 2 -
.../src/pages/knowledge/KnowledgeContent.tsx | 83 +++++++++++++++----
3 files changed, 66 insertions(+), 22 deletions(-)
diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx
index dc15babf02..95db85268c 100644
--- a/src/renderer/src/components/CustomCollapse.tsx
+++ b/src/renderer/src/components/CustomCollapse.tsx
@@ -2,7 +2,7 @@ import { Collapse } from 'antd'
import { FC, memo } from 'react'
interface CustomCollapseProps {
- label: string
+ label: React.ReactNode
extra: React.ReactNode
children: React.ReactNode
}
@@ -24,7 +24,6 @@ const CustomCollapse: FC = ({ label, extra, children }) =>
}
return (
= ({ selectedBase }) => {
)}
}
extra={
- } onClick={handleAddFile} disabled={disabled}>
+ }
+ onClick={(e) => {
+ e.stopPropagation()
+ handleAddFile()
+ }}
+ disabled={disabled}>
{t('knowledge.add_file')}
}>
@@ -257,7 +265,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => {
{fileItems.length === 0 ? (
-
+
) : (
= ({ selectedBase }) => {
}
extra={
- } onClick={handleAddDirectory} disabled={disabled}>
+ }
+ onClick={(e) => {
+ e.stopPropagation()
+ handleAddDirectory()
+ }}
+ disabled={disabled}>
{t('knowledge.add_directory')}
}>
- {directoryItems.length === 0 && }
+ {directoryItems.length === 0 && }
{directoryItems.reverse().map((item) => (
= ({ selectedBase }) => {
}
extra={
- } onClick={handleAddUrl} disabled={disabled}>
+ }
+ onClick={(e) => {
+ e.stopPropagation()
+ handleAddUrl()
+ }}
+ disabled={disabled}>
{t('knowledge.add_url')}
}>
- {urlItems.length === 0 && }
+ {urlItems.length === 0 && }
{urlItems.reverse().map((item) => (
= ({ selectedBase }) => {
}
extra={
- } onClick={handleAddSitemap} disabled={disabled}>
+ }
+ onClick={(e) => {
+ e.stopPropagation()
+ handleAddSitemap()
+ }}
+ disabled={disabled}>
{t('knowledge.add_sitemap')}
}>
- {sitemapItems.length === 0 && }
+ {sitemapItems.length === 0 && }
{sitemapItems.reverse().map((item) => (
= ({ selectedBase }) => {
}
extra={
- } onClick={handleAddNote} disabled={disabled}>
+ }
+ onClick={(e) => {
+ e.stopPropagation()
+ handleAddNote()
+ }}
+ disabled={disabled}>
{t('knowledge.add_note')}
}>
- {noteItems.length === 0 && }
+ {noteItems.length === 0 && }
{noteItems.reverse().map((note) => (
= ({ selectedBase }) => {
))}
-
-
@@ -548,6 +582,19 @@ const KnowledgeContent: FC
= ({ selectedBase }) => {
)
}
+const EmptyView = () =>
+
+const CollapseLabel = ({ label, count }: { label: string; count: number }) => {
+ return (
+
+
+
+ {count}
+
+
+ )
+}
+
const MainContent = styled(Scrollbar)`
display: flex;
width: 100%;
From 2a4c512e49582e0dbd6a6785e34c5d5866114a4b Mon Sep 17 00:00:00 2001
From: kangfenmao
Date: Sun, 30 Mar 2025 14:37:20 +0800
Subject: [PATCH 05/37] refactor(BackupManager): switch to stream-based file
writing for improved performance
* Updated BackupManager to use streams for writing data to temporary and backup files, enhancing efficiency and error handling.
* Replaced synchronous file writing with asynchronous stream operations to prevent blocking the event loop.
---
src/main/services/BackupManager.ts | 22 ++++++++++++++++++----
1 file changed, 18 insertions(+), 4 deletions(-)
diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts
index a775fddf10..a97b37e6a7 100644
--- a/src/main/services/BackupManager.ts
+++ b/src/main/services/BackupManager.ts
@@ -87,9 +87,16 @@ class BackupManager {
await fs.ensureDir(this.tempDir)
onProgress({ stage: 'preparing', progress: 0, total: 100 })
- // 将 data 写入临时文件
+ // 使用流的方式写入 data.json
const tempDataPath = path.join(this.tempDir, 'data.json')
- await fs.writeFile(tempDataPath, data)
+ await new Promise((resolve, reject) => {
+ const writeStream = fs.createWriteStream(tempDataPath)
+ writeStream.write(data)
+ writeStream.end()
+
+ writeStream.on('finish', () => resolve())
+ writeStream.on('error', (error) => reject(error))
+ })
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
// 复制 Data 目录到临时目录
@@ -208,8 +215,15 @@ class BackupManager {
fs.mkdirSync(this.backupDir, { recursive: true })
}
- // sync为同步写,无须await
- fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
+ // 使用流的方式写入文件
+ await new Promise((resolve, reject) => {
+ const writeStream = fs.createWriteStream(backupedFilePath)
+ writeStream.write(retrievedFile as Buffer)
+ writeStream.end()
+
+ writeStream.on('finish', () => resolve())
+ writeStream.on('error', (error) => reject(error))
+ })
return await this.restore(_, backupedFilePath)
} catch (error: any) {
From a2f9067908ab21e89329a86cdc116a068512f63f Mon Sep 17 00:00:00 2001
From: kangfenmao
Date: Sun, 30 Mar 2025 14:39:43 +0800
Subject: [PATCH 06/37] chore(version): 1.1.17
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 2ffd5b7b49..8635696b4b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
- "version": "1.1.16",
+ "version": "1.1.17",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
From 57c1b59a51ecb48082418c99d6d4319ec5b85dd0 Mon Sep 17 00:00:00 2001
From: suyao
Date: Sun, 30 Mar 2025 23:44:50 +0800
Subject: [PATCH 07/37] fix(models): reorganize gemini websearch model lists
---
src/renderer/src/config/models.ts | 52 ++++++++++++++++++++-----------
1 file changed, 34 insertions(+), 18 deletions(-)
diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts
index 6f473aa960..0507bebaa0 100644
--- a/src/renderer/src/config/models.ts
+++ b/src/renderer/src/config/models.ts
@@ -165,7 +165,15 @@ const visionAllowedModels = [
'gemma-3(?:-[\\w-]+)'
]
-const visionExcludedModels = ['gpt-4-\\d+-preview', 'gpt-4-turbo-preview', 'gpt-4-32k', 'gpt-4-\\d+', 'o1-mini', 'o1-preview', 'AIDC-AI/Marco-o1']
+const visionExcludedModels = [
+ 'gpt-4-\\d+-preview',
+ 'gpt-4-turbo-preview',
+ 'gpt-4-32k',
+ 'gpt-4-\\d+',
+ 'o1-mini',
+ 'o1-preview',
+ 'AIDC-AI/Marco-o1'
+]
export const VISION_REGEX = new RegExp(
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
'i'
@@ -203,7 +211,13 @@ export const FUNCTION_CALLING_MODELS = [
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
]
-const FUNCTION_CALLING_EXCLUDED_MODELS = ['aqa(?:-[\\w-]+)?', 'imagen(?:-[\\w-]+)?', 'o1-mini', 'o1-preview', 'AIDC-AI/Marco-o1']
+const FUNCTION_CALLING_EXCLUDED_MODELS = [
+ 'aqa(?:-[\\w-]+)?',
+ 'imagen(?:-[\\w-]+)?',
+ 'o1-mini',
+ 'o1-preview',
+ 'AIDC-AI/Marco-o1'
+]
export const FUNCTION_CALLING_REGEX = new RegExp(
`\\b(?!(?:${FUNCTION_CALLING_EXCLUDED_MODELS.join('|')})\\b)(?:${FUNCTION_CALLING_MODELS.join('|')})\\b`,
@@ -1944,6 +1958,17 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
export const GENERATE_IMAGE_MODELS = ['gemini-2.0-flash-exp-image-generation', 'gemini-2.0-flash-exp']
+export const GEMINI_SEARCH_MODELS = [
+ 'gemini-2.0-flash',
+ 'gemini-2.0-flash-lite',
+ 'gemini-2.0-flash-exp',
+ 'gemini-2.0-flash-001',
+ 'gemini-2.0-pro-exp-02-05',
+ 'gemini-2.0-pro-exp',
+ 'gemini-2.5-pro-exp',
+ 'gemini-2.5-pro-exp-03-25'
+]
+
export function isTextToImageModel(model: Model): boolean {
return TEXT_TO_IMAGE_REGEX.test(model.id)
}
@@ -2048,34 +2073,25 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
+ if (provider.id === 'aihubmix') {
+ const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search']
+ return models.includes(model?.id)
+ }
+
if (provider?.type === 'openai') {
- if (model?.id?.includes('gemini-2.0-flash-exp')) {
+ if (GEMINI_SEARCH_MODELS.includes(model?.id)) {
return true
}
}
if (provider.id === 'gemini' || provider?.type === 'gemini') {
- const models = [
- 'gemini-2.0-flash',
- 'gemini-2.0-flash-exp',
- 'gemini-2.0-flash-001',
- 'gemini-2.0-pro-exp-02-05',
- 'gemini-2.0-pro-exp',
- 'gemini-2.5-pro-exp',
- 'gemini-2.5-pro-exp-03-25'
- ]
- return models.includes(model?.id)
+ return GEMINI_SEARCH_MODELS.includes(model?.id)
}
if (provider.id === 'hunyuan') {
return model?.id !== 'hunyuan-lite'
}
- if (provider.id === 'aihubmix') {
- const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search']
- return models.includes(model?.id)
- }
-
if (provider.id === 'zhipu') {
return model?.id?.startsWith('glm-4-')
}
From 1ce86c11ca64b3584d9b659576625942fa086a58 Mon Sep 17 00:00:00 2001
From: fullex <106392080+0xfullex@users.noreply.github.com>
Date: Mon, 31 Mar 2025 09:24:49 +0800
Subject: [PATCH 08/37] fix: zoomfactor should not change when resize (#4159)
* fix: zoomfactor should not change when resize
* add linux fallback support
---
src/main/services/WindowService.ts | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts
index 928bb8d379..f510f81488 100644
--- a/src/main/services/WindowService.ts
+++ b/src/main/services/WindowService.ts
@@ -163,6 +163,25 @@ export class WindowService {
mainWindow.webContents.send('fullscreen-status-changed', false)
})
+ // set the zoom factor again when the window is going to resize
+ //
+ // this is a workaround for the known bug that
+ // the zoom factor is reset to cached value when window is resized after routing to other page
+ // see: https://github.com/electron/electron/issues/10572
+ //
+ mainWindow.on('will-resize', () => {
+ mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
+ })
+
+ // ARCH: as `will-resize` is only for Win & Mac,
+ // linux has the same problem, use `resize` listener instead
+ // but `resize` will fliker the ui
+ if (isLinux) {
+ mainWindow.on('resize', () => {
+ mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
+ })
+ }
+
// 添加Escape键退出全屏的支持
mainWindow.webContents.on('before-input-event', (event, input) => {
// 当按下Escape键且窗口处于全屏状态时退出全屏
From 9f11e7c22b65bd86ff9c916bd0c419c6aa2f0a65 Mon Sep 17 00:00:00 2001
From: one
Date: Mon, 31 Mar 2025 03:08:30 +0800
Subject: [PATCH 09/37] perf(Tabs): improve responsiveness when switching items
rapidly
---
.../src/pages/home/Tabs/AssistantItem.tsx | 15 ++++++++++-----
src/renderer/src/pages/home/Tabs/TopicsTab.tsx | 6 ++++--
2 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/src/renderer/src/pages/home/Tabs/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/AssistantItem.tsx
index c85a13eb28..5345bcf29f 100644
--- a/src/renderer/src/pages/home/Tabs/AssistantItem.tsx
+++ b/src/renderer/src/pages/home/Tabs/AssistantItem.tsx
@@ -13,7 +13,7 @@ import { hasTopicPendingRequests } from '@renderer/utils/queue'
import { Dropdown } from 'antd'
import { ItemType } from 'antd/es/menu/interface'
import { omit } from 'lodash'
-import { FC, useCallback, useEffect, useState } from 'react'
+import { FC, startTransition, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -114,11 +114,16 @@ const AssistantItem: FC = ({ assistant, isActive, onSwitch,
const handleSwitch = useCallback(async () => {
await modelGenerating()
- if (topicPosition === 'left' && clickAssistantToShowTopic) {
- EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
+ if (clickAssistantToShowTopic) {
+ if (topicPosition === 'left') {
+ EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
+ }
+ onSwitch(assistant)
+ } else {
+ startTransition(() => {
+ onSwitch(assistant)
+ })
}
-
- onSwitch(assistant)
}, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition])
const assistantName = assistant.name || t('chat.default.name')
diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx
index b638af15c2..d92b0d4509 100644
--- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx
+++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx
@@ -37,7 +37,7 @@ import { hasTopicPendingRequests } from '@renderer/utils/queue'
import { Dropdown, MenuProps, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { findIndex } from 'lodash'
-import { FC, useCallback, useMemo, useRef, useState } from 'react'
+import { FC, startTransition, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -146,7 +146,9 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic
const onSwitchTopic = useCallback(
async (topic: Topic) => {
// await modelGenerating()
- setActiveTopic(topic)
+ startTransition(() => {
+ setActiveTopic(topic)
+ })
},
[setActiveTopic]
)
From 72c5de3b811093359293af83c3f40d1eee0b11eb Mon Sep 17 00:00:00 2001
From: fullex <0xfullex@gmail.com>
Date: Mon, 31 Mar 2025 15:01:41 +0800
Subject: [PATCH 10/37] optimize: reduce animation gpu load of sidebar minapp
---
src/renderer/src/components/app/Sidebar.tsx | 23 ++++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx
index 0854215665..47498d1a08 100644
--- a/src/renderer/src/components/app/Sidebar.tsx
+++ b/src/renderer/src/components/app/Sidebar.tsx
@@ -361,6 +361,7 @@ const Icon = styled.div<{ theme: string }>`
justify-content: center;
align-items: center;
border-radius: 50%;
+ box-sizing: border-box;
-webkit-app-region: none;
border: 0.5px solid transparent;
.iconfont,
@@ -392,18 +393,34 @@ const Icon = styled.div<{ theme: string }>`
@keyframes borderBreath {
0% {
- border-color: var(--color-primary-mute);
+ opacity: 0.1;
}
50% {
- border-color: var(--color-primary);
+ opacity: 1;
}
100% {
- border-color: var(--color-primary-mute);
+ opacity: 0.1;
}
}
&.opened-animation {
+ position: relative;
+ }
+
+ &.opened-animation::after {
+ content: '';
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ border-radius: inherit;
+ opacity: 0;
+ will-change: opacity;
border: 0.5px solid var(--color-primary);
+ /* NOTICE: although we have optimized for the performance,
+ * the infinite animation will still consume a little GPU resources,
+ * it's a trade-off balance between performance and animation smoothness*/
animation: borderBreath 4s ease-in-out infinite;
}
`
From 5223a3c5a6f2eb61a872e1730d7bedfcef5257b0 Mon Sep 17 00:00:00 2001
From: fullex <0xfullex@gmail.com>
Date: Mon, 31 Mar 2025 12:15:57 +0800
Subject: [PATCH 11/37] feat: minapp show© current REAL url and can open it
---
.../MinApp/MinappPopupContainer.tsx | 76 ++++++++++++++++---
.../components/MinApp/WebviewContainer.tsx | 10 ++-
src/renderer/src/i18n/locales/en-us.json | 3 +-
src/renderer/src/i18n/locales/ja-jp.json | 3 +-
src/renderer/src/i18n/locales/ru-ru.json | 3 +-
src/renderer/src/i18n/locales/zh-cn.json | 3 +-
src/renderer/src/i18n/locales/zh-tw.json | 3 +-
7 files changed, 84 insertions(+), 17 deletions(-)
diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx
index 6b668795d2..18ebb171ed 100644
--- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx
+++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx
@@ -1,6 +1,7 @@
import {
CloseOutlined,
CodeOutlined,
+ CopyOutlined,
ExportOutlined,
MinusOutlined,
PushpinOutlined,
@@ -42,6 +43,9 @@ const MinappPopupContainer: React.FC = () => {
const [isPopupShow, setIsPopupShow] = useState(true)
/** whether the current minapp is ready */
const [isReady, setIsReady] = useState(false)
+ /** the current REAL url of the minapp
+ * different from the app preset url, because user may navigate in minapp */
+ const [currentUrl, setCurrentUrl] = useState(null)
/** store the last minapp id and show status */
const lastMinappId = useRef(null)
@@ -59,6 +63,11 @@ const MinappPopupContainer: React.FC = () => {
/** set the popup display status */
useEffect(() => {
if (minappShow) {
+ // init the current url
+ if (currentMinappId && currentAppInfo) {
+ setCurrentUrl(currentAppInfo.url)
+ }
+
setIsPopupShow(true)
if (webviewLoadedRefs.current.get(currentMinappId)) {
@@ -168,6 +177,13 @@ const MinappPopupContainer: React.FC = () => {
}
}
+ /** the callback function to handle the webview navigate to new url */
+ const handleWebviewNavigate = (appid: string, url: string) => {
+ if (appid === currentMinappId) {
+ setCurrentUrl(url)
+ }
+ }
+
/** will open the devtools of the minapp */
const handleOpenDevTools = (appid: string) => {
const webview = webviewRefs.current.get(appid)
@@ -187,12 +203,9 @@ const MinappPopupContainer: React.FC = () => {
}
}
- /** only open the current url */
- const handleOpenLink = (appid: string) => {
- const webview = webviewRefs.current.get(appid)
- if (webview) {
- window.api.openWebsite(webview.getURL())
- }
+ /** open the giving url in browser */
+ const handleOpenLink = (url: string) => {
+ window.api.openWebsite(url)
}
/** toggle the pin status of the minapp */
@@ -205,11 +218,41 @@ const MinappPopupContainer: React.FC = () => {
}
/** Title bar of the popup */
- const Title = ({ appInfo }: { appInfo: AppInfo | null }) => {
+ const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => {
if (!appInfo) return null
+
+ const handleCopyUrl = (event: any, url: string) => {
+ //don't show app-wide context menu
+ event.preventDefault()
+ navigator.clipboard
+ .writeText(url)
+ .then(() => {
+ window.message.success('URL ' + t('message.copy.success'))
+ })
+ .catch(() => {
+ window.message.error('URL ' + t('message.copy.failed'))
+ })
+ }
+
return (
- {appInfo.name}
+
+ {url ?? appInfo.url}
+
+ {t('minapp.popup.rightclick_copyurl')}
+
+ }
+ mouseEnterDelay={0.8}
+ placement="rightBottom"
+ styles={{
+ root: {
+ maxWidth: '400px'
+ }
+ }}>
+ handleCopyUrl(e, url ?? appInfo.url)}>{appInfo.name}
+
handleReload(appInfo.id)}>
@@ -228,7 +271,7 @@ const MinappPopupContainer: React.FC = () => {
)}
{appInfo.canOpenExternalLink && (
- handleOpenLink(appInfo.id)}>
+ handleOpenLink(url ?? appInfo.url)}>
@@ -266,6 +309,7 @@ const MinappPopupContainer: React.FC = () => {
url={app.url}
onSetRefCallback={handleWebviewSetRef}
onLoadedCallback={handleWebviewLoaded}
+ onNavigateCallback={handleWebviewNavigate}
/>
))
@@ -275,7 +319,7 @@ const MinappPopupContainer: React.FC = () => {
return (
}
+ title={}
placement="bottom"
onClose={handlePopupMinimize}
open={isPopupShow}
@@ -321,8 +365,18 @@ const TitleText = styled.div`
font-size: 14px;
color: var(--color-text-1);
margin-right: 10px;
- user-select: none;
+ -webkit-app-region: no-drag;
`
+
+const TitleTextTooltip = styled.span`
+ font-size: 0.8rem;
+
+ .icon-copy {
+ font-size: 0.7rem;
+ padding-right: 5px;
+ }
+`
+
const ButtonsGroup = styled.div`
display: flex;
flex-direction: row;
diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx
index f56cdc08cd..203122e01c 100644
--- a/src/renderer/src/components/MinApp/WebviewContainer.tsx
+++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx
@@ -11,12 +11,14 @@ const WebviewContainer = memo(
appid,
url,
onSetRefCallback,
- onLoadedCallback
+ onLoadedCallback,
+ onNavigateCallback
}: {
appid: string
url: string
onSetRefCallback: (appid: string, element: WebviewTag | null) => void
onLoadedCallback: (appid: string) => void
+ onNavigateCallback: (appid: string, url: string) => void
}) => {
const webviewRef = useRef(null)
@@ -47,8 +49,13 @@ const WebviewContainer = memo(
onLoadedCallback(appid)
}
+ const handleNavigate = (event: any) => {
+ onNavigateCallback(appid, event.url)
+ }
+
webviewRef.current.addEventListener('new-window', handleNewWindow)
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
+ webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
// we set the url when the webview is ready
webviewRef.current.src = url
@@ -56,6 +63,7 @@ const WebviewContainer = memo(
return () => {
webviewRef.current?.removeEventListener('new-window', handleNewWindow)
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
+ webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
}
// because the appid and url are enough, no need to add onLoadedCallback
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json
index cbdb67872a..a2054b9fcc 100644
--- a/src/renderer/src/i18n/locales/en-us.json
+++ b/src/renderer/src/i18n/locales/en-us.json
@@ -547,7 +547,8 @@
"close": "Close MinApp",
"minimize": "Minimize MinApp",
"devtools": "Developer Tools",
- "openExternal": "Open in Browser"
+ "openExternal": "Open in Browser",
+ "rightclick_copyurl": "Right-click to copy URL"
},
"sidebar.add.title": "Add to sidebar",
"sidebar.remove.title": "Remove from sidebar",
diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json
index 0c7c5a9b24..442b4c0a98 100644
--- a/src/renderer/src/i18n/locales/ja-jp.json
+++ b/src/renderer/src/i18n/locales/ja-jp.json
@@ -547,7 +547,8 @@
"close": "ミニアプリを閉じる",
"minimize": "ミニアプリを最小化",
"devtools": "開発者ツール",
- "openExternal": "ブラウザで開く"
+ "openExternal": "ブラウザで開く",
+ "rightclick_copyurl": "右クリックでURLをコピー"
},
"sidebar.add.title": "サイドバーに追加",
"sidebar.remove.title": "サイドバーから削除",
diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json
index 70cdbbe0e7..636317d10f 100644
--- a/src/renderer/src/i18n/locales/ru-ru.json
+++ b/src/renderer/src/i18n/locales/ru-ru.json
@@ -547,7 +547,8 @@
"close": "Закрыть встроенное приложение",
"minimize": "Свернуть встроенное приложение",
"devtools": "Инструменты разработчика",
- "openExternal": "Открыть в браузере"
+ "openExternal": "Открыть в браузере",
+ "rightclick_copyurl": "ПКМ → Копировать URL"
},
"sidebar.add.title": "Добавить в боковую панель",
"sidebar.remove.title": "Удалить из боковой панели",
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index 9b8197d119..c7f6eb5385 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -547,7 +547,8 @@
"close": "关闭小程序",
"minimize": "最小化小程序",
"devtools": "开发者工具",
- "openExternal": "在浏览器中打开"
+ "openExternal": "在浏览器中打开",
+ "rightclick_copyurl": "右键复制URL"
},
"sidebar.add.title": "添加到侧边栏",
"sidebar.remove.title": "从侧边栏移除",
diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json
index 9694681659..222b1f0d89 100644
--- a/src/renderer/src/i18n/locales/zh-tw.json
+++ b/src/renderer/src/i18n/locales/zh-tw.json
@@ -547,7 +547,8 @@
"close": "關閉小工具",
"minimize": "最小化小工具",
"devtools": "開發者工具",
- "openExternal": "在瀏覽器中開啟"
+ "openExternal": "在瀏覽器中開啟",
+ "rightclick_copyurl": "右鍵複製URL"
},
"sidebar.add.title": "新增到側邊欄",
"sidebar.remove.title": "從側邊欄移除",
From 2da8a731240b76687c10b64adcff161772adf625 Mon Sep 17 00:00:00 2001
From: MyPrototypeWhat <43230886+MyPrototypeWhat@users.noreply.github.com>
Date: Mon, 31 Mar 2025 18:07:50 +0800
Subject: [PATCH 12/37] =?UTF-8?q?feat(MCP):=20add=20auto-install=20server?=
=?UTF-8?q?=20configuration=20and=20migration=20for=20ve=E2=80=A6=20(#4156?=
=?UTF-8?q?)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(MCP): add auto-install server configuration and migration for version 87
* update persistReducer version
---
src/renderer/src/store/index.ts | 2 +-
src/renderer/src/store/mcp.ts | 19 ++++++++++++++++---
src/renderer/src/store/migrate.ts | 17 +++++++++++++++++
src/renderer/src/utils/mcp-tools.ts | 19 +++++++++++++++++++
4 files changed, 53 insertions(+), 4 deletions(-)
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index 53a3aba335..1d9d3cff08 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
- version: 87,
+ version: 88,
blacklist: ['runtime', 'messages'],
migrate
},
diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts
index 554e386516..9b8e69ce85 100644
--- a/src/renderer/src/store/mcp.ts
+++ b/src/renderer/src/store/mcp.ts
@@ -1,8 +1,20 @@
-import { createSlice, PayloadAction } from '@reduxjs/toolkit'
-import { MCPConfig, MCPServer } from '@renderer/types'
+import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
+import { nanoid } from '@reduxjs/toolkit'
+import type { MCPConfig, MCPServer } from '@renderer/types'
const initialState: MCPConfig = {
- servers: []
+ servers: [
+ {
+ id: nanoid(),
+ name: 'mcp-auto-install',
+ description: 'Automatically install MCP services (Beta version)',
+ baseUrl: '',
+ command: 'npx',
+ args: ['-y', '@mcpmarket/mcp-auto-install', 'connect', '--json'],
+ env: {},
+ isActive: false
+ }
+ ]
}
const mcpSlice = createSlice({
@@ -47,5 +59,6 @@ export const { getActiveServers, getAllServers } = mcpSlice.selectors
// Type-safe selector for accessing this slice from the root state
export const selectMCP = (state: { mcp: MCPConfig }) => state.mcp
+export { mcpSlice }
// Export the reducer as default export
export default mcpSlice.reducer
diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts
index c35693c66f..bc9512c840 100644
--- a/src/renderer/src/store/migrate.ts
+++ b/src/renderer/src/store/migrate.ts
@@ -12,6 +12,7 @@ import { createMigrate } from 'redux-persist'
import { RootState } from '.'
import { INITIAL_PROVIDERS, moveProvider } from './llm'
+import { mcpSlice } from './mcp'
import { DEFAULT_SIDEBAR_ICONS } from './settings'
// remove logo base64 data to reduce the size of the state
@@ -1148,6 +1149,22 @@ const migrateConfig = {
} catch (error) {
return state
}
+ },
+ '88': (state: RootState) => {
+ try {
+ if (state?.mcp?.servers) {
+ const hasAutoInstall = state.mcp.servers.some((server) => server.name === 'mcp-auto-install')
+ if (!hasAutoInstall) {
+ const defaultServer = mcpSlice.getInitialState().servers[0]
+ state.mcp.servers = [{ ...defaultServer, id: nanoid() }, ...state.mcp.servers]
+ }
+ }
+ } catch (error) {
+ console.error(error)
+ return state
+ }
+
+ return state
}
}
diff --git a/src/renderer/src/utils/mcp-tools.ts b/src/renderer/src/utils/mcp-tools.ts
index 6d099a354e..3b0110cb0b 100644
--- a/src/renderer/src/utils/mcp-tools.ts
+++ b/src/renderer/src/utils/mcp-tools.ts
@@ -1,6 +1,8 @@
import { Tool, ToolUnion, ToolUseBlock } from '@anthropic-ai/sdk/resources'
import { FunctionCall, FunctionDeclaration, SchemaType, Tool as geminiToool } from '@google/generative-ai'
+import { nanoid } from '@reduxjs/toolkit'
import store from '@renderer/store'
+import { addMCPServer } from '@renderer/store/mcp'
import { MCPServer, MCPTool, MCPToolResponse } from '@renderer/types'
import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources'
@@ -126,6 +128,23 @@ export async function callMCPTool(tool: MCPTool): Promise {
})
console.log(`[MCP] Tool called: ${tool.serverName} ${tool.name}`, resp)
+
+ if (tool.serverName === 'mcp-auto-install') {
+ if (resp.data) {
+ const mcpServer: MCPServer = {
+ id: nanoid(),
+ name: resp.data.name,
+ description: resp.data.description,
+ baseUrl: resp.data.baseUrl,
+ command: resp.data.command,
+ args: resp.data.args,
+ env: resp.data.env,
+ isActive: false
+ }
+ store.dispatch(addMCPServer(mcpServer))
+ }
+ }
+
return resp
} catch (e) {
console.error(`[MCP] Error calling Tool: ${tool.serverName} ${tool.name}`, e)
From 8a7db19e7339eda7a883fafa59f8b9cde4dbe80b Mon Sep 17 00:00:00 2001
From: fullex <106392080+0xfullex@users.noreply.github.com>
Date: Mon, 31 Mar 2025 21:07:16 +0800
Subject: [PATCH 13/37] fix: Resolve a series of miniWindow display issues and
improve app behavior across platforms (#3072)
---
src/main/services/ShortcutService.ts | 13 +-
src/main/services/WindowService.ts | 133 ++++++++++++++----
src/renderer/src/i18n/locales/en-us.json | 2 +-
src/renderer/src/i18n/locales/ja-jp.json | 2 +-
src/renderer/src/i18n/locales/ru-ru.json | 2 +-
src/renderer/src/i18n/locales/zh-cn.json | 2 +-
src/renderer/src/i18n/locales/zh-tw.json | 2 +-
.../src/pages/settings/GeneralSettings.tsx | 2 +-
8 files changed, 112 insertions(+), 46 deletions(-)
diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts
index c4bad34de3..03caa02d24 100644
--- a/src/main/services/ShortcutService.ts
+++ b/src/main/services/ShortcutService.ts
@@ -23,17 +23,8 @@ function getShortcutHandler(shortcut: Shortcut) {
configManager.setZoomFactor(1)
}
case 'show_app':
- return (window: BrowserWindow) => {
- if (window.isVisible()) {
- if (window.isFocused()) {
- window.hide()
- } else {
- window.focus()
- }
- } else {
- window.show()
- window.focus()
- }
+ return () => {
+ windowService.toggleMainWindow()
}
case 'mini_window':
return () => {
diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts
index f510f81488..f72c5b1b1c 100644
--- a/src/main/services/WindowService.ts
+++ b/src/main/services/WindowService.ts
@@ -16,6 +16,9 @@ export class WindowService {
private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null
private wasFullScreen: boolean = false
+ //hacky-fix: store the focused status of mainWindow before miniWindow shows
+ //to restore the focus status when miniWindow hides
+ private wasMainWindowFocused: boolean = false
private selectionMenuWindow: BrowserWindow | null = null
private lastSelectedText: string = ''
private contextMenu: Menu | null = null
@@ -30,6 +33,7 @@ export class WindowService {
public createMainWindow(): BrowserWindow {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.show()
+ this.mainWindow.focus()
return this.mainWindow
}
@@ -56,7 +60,7 @@ export class WindowService {
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
trafficLightPosition: { x: 8, y: 12 },
- ...(process.platform === 'linux' ? { icon } : {}),
+ ...(isLinux ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
@@ -68,6 +72,12 @@ export class WindowService {
this.setupMainWindow(this.mainWindow, mainWindowState)
+ //preload miniWindow to resolve series of issues about miniWindow in Mac
+ const enableQuickAssistant = configManager.getEnableQuickAssistant()
+ if (enableQuickAssistant && !this.miniWindow) {
+ this.miniWindow = this.createMiniWindow(true)
+ }
+
return this.mainWindow
}
@@ -148,6 +158,8 @@ export class WindowService {
// show window only when laucn to tray not set
const isLaunchToTray = configManager.getLaunchToTray()
if (!isLaunchToTray) {
+ //[mac]hacky-fix: miniWindow set visibleOnFullScreen:true will cause dock icon disappeared
+ app.dock?.show()
mainWindow.show()
}
})
@@ -305,9 +317,8 @@ export class WindowService {
event.preventDefault()
mainWindow.hide()
- if (isMac && isTrayOnClose) {
- app.dock?.hide() //for mac to hide to tray
- }
+ //for mac users, should hide dock icon if close to tray
+ app.dock?.hide()
})
mainWindow.on('closed', () => {
@@ -328,44 +339,48 @@ export class WindowService {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
if (this.mainWindow.isMinimized()) {
- return this.mainWindow.restore()
+ this.mainWindow.restore()
+ return
}
+ //[macOS] Known Issue
+ // setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
+ // AppleScript may be a solution, but it's not worth
+ this.mainWindow.setVisibleOnAllWorkspaces(true)
this.mainWindow.show()
this.mainWindow.focus()
+ this.mainWindow.setVisibleOnAllWorkspaces(false)
} else {
this.mainWindow = this.createMainWindow()
- this.mainWindow.focus()
}
-
- //for mac users, when window is shown, should show dock icon (dock may be set to hide when launch)
- app.dock?.show()
}
- public showMiniWindow() {
- const enableQuickAssistant = configManager.getEnableQuickAssistant()
-
- if (!enableQuickAssistant) {
+ public toggleMainWindow() {
+ // should not toggle main window when in full screen
+ if (this.wasFullScreen) {
return
}
- if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
- this.selectionMenuWindow.hide()
- }
-
- if (this.miniWindow && !this.miniWindow.isDestroyed()) {
- if (this.miniWindow.isMinimized()) {
- this.miniWindow.restore()
+ if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) {
+ if (this.mainWindow.isFocused()) {
+ // if tray is enabled, hide the main window, else do nothing
+ if (configManager.getTray()) {
+ this.mainWindow.hide()
+ app.dock?.hide()
+ }
+ } else {
+ this.mainWindow.focus()
}
- this.miniWindow.show()
- this.miniWindow.center()
- this.miniWindow.focus()
return
}
+ this.showMainWindow()
+ }
+
+ public createMiniWindow(isPreload: boolean = false): BrowserWindow {
this.miniWindow = new BrowserWindow({
width: 500,
height: 520,
- show: true,
+ show: false,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'under-window',
@@ -375,6 +390,11 @@ export class WindowService {
alwaysOnTop: true,
resizable: false,
useContentSize: true,
+ ...(isMac ? { type: 'panel' } : {}),
+ skipTaskbar: true,
+ minimizable: false,
+ maximizable: false,
+ fullscreenable: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
@@ -383,8 +403,23 @@ export class WindowService {
}
})
+ //miniWindow should show in current desktop
+ this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
+ //make miniWindow always on top of fullscreen apps with level set
+ this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1)
+
+ this.miniWindow.on('ready-to-show', () => {
+ if (isPreload) {
+ return
+ }
+
+ this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
+ this.miniWindow?.center()
+ this.miniWindow?.show()
+ })
+
this.miniWindow.on('blur', () => {
- this.miniWindow?.hide()
+ this.hideMiniWindow()
})
this.miniWindow.on('closed', () => {
@@ -410,9 +445,48 @@ export class WindowService {
hash: '#/mini'
})
}
+
+ return this.miniWindow
+ }
+
+ public showMiniWindow() {
+ const enableQuickAssistant = configManager.getEnableQuickAssistant()
+
+ if (!enableQuickAssistant) {
+ return
+ }
+
+ if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
+ this.selectionMenuWindow.hide()
+ }
+
+ if (this.miniWindow && !this.miniWindow.isDestroyed()) {
+ this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
+
+ if (this.miniWindow.isMinimized()) {
+ this.miniWindow.restore()
+ }
+ this.miniWindow.show()
+ return
+ }
+
+ this.miniWindow = this.createMiniWindow()
}
public hideMiniWindow() {
+ //hacky-fix:[mac/win] previous window(not self-app) should be focused again after miniWindow hide
+ if (isWin) {
+ this.miniWindow?.minimize()
+ this.miniWindow?.hide()
+ return
+ } else if (isMac) {
+ this.miniWindow?.hide()
+ if (!this.wasMainWindowFocused) {
+ app.hide()
+ }
+ return
+ }
+
this.miniWindow?.hide()
}
@@ -421,11 +495,12 @@ export class WindowService {
}
public toggleMiniWindow() {
- if (this.miniWindow) {
- this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show()
- } else {
- this.showMiniWindow()
+ if (this.miniWindow && !this.miniWindow.isDestroyed() && this.miniWindow.isVisible()) {
+ this.hideMiniWindow()
+ return
}
+
+ this.showMiniWindow()
}
public showSelectionMenu(bounds: { x: number; y: number }) {
diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json
index a2054b9fcc..8a5e85f477 100644
--- a/src/renderer/src/i18n/locales/en-us.json
+++ b/src/renderer/src/i18n/locales/en-us.json
@@ -1190,7 +1190,7 @@
"reset_defaults_confirm": "Are you sure you want to reset all shortcuts?",
"reset_to_default": "Reset to Default",
"search_message": "Search Message",
- "show_app": "Show App",
+ "show_app": "Show/Hide App",
"show_settings": "Open Settings",
"title": "Keyboard Shortcuts",
"toggle_new_context": "Clear Context",
diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json
index 442b4c0a98..d775707a10 100644
--- a/src/renderer/src/i18n/locales/ja-jp.json
+++ b/src/renderer/src/i18n/locales/ja-jp.json
@@ -1189,7 +1189,7 @@
"reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?",
"reset_to_default": "デフォルトにリセット",
"search_message": "メッセージを検索",
- "show_app": "アプリを表示",
+ "show_app": "アプリを表示/非表示",
"show_settings": "設定を開く",
"title": "ショートカット",
"toggle_new_context": "コンテキストをクリア",
diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json
index 636317d10f..de4f0a669c 100644
--- a/src/renderer/src/i18n/locales/ru-ru.json
+++ b/src/renderer/src/i18n/locales/ru-ru.json
@@ -1189,7 +1189,7 @@
"reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?",
"reset_to_default": "Сбросить настройки по умолчанию",
"search_message": "Поиск сообщения",
- "show_app": "Показать приложение",
+ "show_app": "Показать/скрыть приложение",
"show_settings": "Открыть настройки",
"title": "Горячие клавиши",
"toggle_new_context": "Очистить контекст",
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index c7f6eb5385..3ff6a79dad 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -1190,7 +1190,7 @@
"reset_defaults_confirm": "确定要重置所有快捷键吗?",
"reset_to_default": "重置为默认",
"search_message": "搜索消息",
- "show_app": "显示应用",
+ "show_app": "显示/隐藏应用",
"show_settings": "打开设置",
"title": "快捷方式",
"toggle_new_context": "清除上下文",
diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json
index 222b1f0d89..1402780596 100644
--- a/src/renderer/src/i18n/locales/zh-tw.json
+++ b/src/renderer/src/i18n/locales/zh-tw.json
@@ -1189,7 +1189,7 @@
"reset_defaults_confirm": "確定要重設所有快捷鍵嗎?",
"reset_to_default": "重設為預設",
"search_message": "搜尋訊息",
- "show_app": "顯示應用程式",
+ "show_app": "顯示/隱藏應用程式",
"show_settings": "開啟設定",
"title": "快速方式",
"toggle_new_context": "清除上下文",
diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx
index 65ef1102e9..34dfd06232 100644
--- a/src/renderer/src/pages/settings/GeneralSettings.tsx
+++ b/src/renderer/src/pages/settings/GeneralSettings.tsx
@@ -175,7 +175,7 @@ const GeneralSettings: FC = () => {
{t('settings.tray.onclose')}
- updateTrayOnClose(checked)} disabled={!tray} />
+ updateTrayOnClose(checked)} />
From a5b0480418aa13635f6da7a01d819fc7ae6273dd Mon Sep 17 00:00:00 2001
From: LiuVaayne <10231735+vaayne@users.noreply.github.com>
Date: Mon, 31 Mar 2025 21:10:33 +0800
Subject: [PATCH 14/37] Feat/assistant level mcp (#4220)
---
src/renderer/src/config/models.ts | 2 +-
src/renderer/src/i18n/locales/en-us.json | 5 +
src/renderer/src/i18n/locales/ja-jp.json | 5 +
src/renderer/src/i18n/locales/ru-ru.json | 5 +
src/renderer/src/i18n/locales/zh-cn.json | 5 +
src/renderer/src/i18n/locales/zh-tw.json | 5 +
.../src/pages/home/Inputbar/Inputbar.tsx | 14 +-
.../AssistantMCPSettings.tsx | 198 ++++++++++++++++++
.../settings/AssistantSettings/index.tsx | 14 +-
src/renderer/src/types/index.ts | 1 +
10 files changed, 251 insertions(+), 3 deletions(-)
create mode 100644 src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx
diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts
index 0507bebaa0..ac20258ddf 100644
--- a/src/renderer/src/config/models.ts
+++ b/src/renderer/src/config/models.ts
@@ -205,7 +205,7 @@ export const FUNCTION_CALLING_MODELS = [
'claude',
'qwen',
'hunyuan',
- 'deepseek-ai/',
+ 'deepseek',
'glm-4(?:-[\\w-]+)?',
'learnlm(?:-[\\w-]+)?',
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json
index 8a5e85f477..f60db5dcee 100644
--- a/src/renderer/src/i18n/locales/en-us.json
+++ b/src/renderer/src/i18n/locales/en-us.json
@@ -46,6 +46,11 @@
"search": "Search assistants...",
"settings.default_model": "Default Model",
"settings.knowledge_base": "Knowledge Base Settings",
+ "settings.mcp": "MCP Servers",
+ "settings.mcp.enableFirst": "Enable this server in MCP settings first",
+ "settings.mcp.title": "MCP Settings",
+ "settings.mcp.noServersAvailable": "No MCP servers available. Add servers in settings",
+ "settings.mcp.description": "Default enabled MCP servers",
"settings.model": "Model Settings",
"settings.preset_messages": "Preset Messages",
"settings.prompt": "Prompt Settings",
diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json
index d775707a10..f7d92b1e4a 100644
--- a/src/renderer/src/i18n/locales/ja-jp.json
+++ b/src/renderer/src/i18n/locales/ja-jp.json
@@ -44,6 +44,11 @@
"save.success": "保存に成功しました",
"save.title": "エージェントに保存",
"search": "アシスタントを検索...",
+ "settings.mcp": "MCP サーバー",
+ "settings.mcp.enableFirst": "まず MCP 設定でこのサーバーを有効にしてください",
+ "settings.mcp.title": "MCP 設定",
+ "settings.mcp.noServersAvailable": "利用可能な MCP サーバーがありません。設定でサーバーを追加してください",
+ "settings.mcp.description": "デフォルトで有効な MCP サーバー",
"settings.default_model": "デフォルトモデル",
"settings.knowledge_base": "ナレッジベース設定",
"settings.model": "モデル設定",
diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json
index de4f0a669c..d28f562cea 100644
--- a/src/renderer/src/i18n/locales/ru-ru.json
+++ b/src/renderer/src/i18n/locales/ru-ru.json
@@ -44,6 +44,11 @@
"save.success": "Успешно сохранено",
"save.title": "Сохранить в агента",
"search": "Поиск ассистентов...",
+ "settings.mcp": "Серверы MCP",
+ "settings.mcp.enableFirst": "Сначала включите этот сервер в настройках MCP",
+ "settings.mcp.title": "Настройки MCP",
+ "settings.mcp.noServersAvailable": "Нет доступных серверов MCP. Добавьте серверы в настройках",
+ "settings.mcp.description": "Серверы MCP, включенные по умолчанию",
"settings.default_model": "Модель по умолчанию",
"settings.knowledge_base": "Настройки базы знаний",
"settings.model": "Настройки модели",
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index 3ff6a79dad..343b8ad070 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -44,6 +44,11 @@
"save.success": "保存成功",
"save.title": "保存到智能体",
"search": "搜索助手",
+ "settings.mcp": "MCP 服务器",
+ "settings.mcp.enableFirst": "请先在 MCP 设置中启用此服务器",
+ "settings.mcp.title": "MCP 设置",
+ "settings.mcp.noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器",
+ "settings.mcp.description": "默认启用的 MCP 服务器",
"settings.default_model": "默认模型",
"settings.knowledge_base": "知识库设置",
"settings.model": "模型设置",
diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json
index 1402780596..d71503a07e 100644
--- a/src/renderer/src/i18n/locales/zh-tw.json
+++ b/src/renderer/src/i18n/locales/zh-tw.json
@@ -44,6 +44,11 @@
"save.success": "儲存成功",
"save.title": "儲存到智慧代理人",
"search": "搜尋助手...",
+ "settings.mcp": "MCP 伺服器",
+ "settings.mcp.enableFirst": "請先在 MCP 設定中啟用此伺服器",
+ "settings.mcp.title": "MCP 設定",
+ "settings.mcp.noServersAvailable": "無可用 MCP 伺服器。請在設定中新增伺服器",
+ "settings.mcp.description": "預設啟用的 MCP 伺服器",
"settings.default_model": "預設模型",
"settings.knowledge_base": "知識庫設定",
"settings.model": "模型設定",
diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
index eb96e1384c..9e674daa12 100644
--- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
+++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
@@ -91,7 +91,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) =
const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([])
const [mentionModels, setMentionModels] = useState([])
- const [enabledMCPs, setEnabledMCPs] = useState([])
+ const [enabledMCPs, setEnabledMCPs] = useState(assistant.mcpServers || [])
const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [textareaHeight, setTextareaHeight] = useState()
@@ -145,6 +145,15 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}, [textareaHeight])
+ // reset state when assistant changes
+ useEffect(() => {
+ // Reset to assistant default model
+ assistant.defaultModel && setModel(assistant.defaultModel)
+
+ // Reset to assistant knowledge mcp servers
+ setEnabledMCPs(assistant.mcpServers || [])
+ }, [assistant, setModel])
+
const sendMessage = useCallback(async () => {
if (inputEmpty || loading) {
return
@@ -323,8 +332,11 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) =
await db.topics.add({ id: topic.id, messages: [] })
await addAssistantMessagesToTopic({ assistant, topic })
+ // Clear previous state
// Reset to assistant default model
assistant.defaultModel && setModel(assistant.defaultModel)
+ // Reset to assistant knowledge mcp servers
+ setEnabledMCPs(assistant.mcpServers || [])
addTopic(topic)
setActiveTopic(topic)
diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx
new file mode 100644
index 0000000000..5f44b424e6
--- /dev/null
+++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx
@@ -0,0 +1,198 @@
+import { InfoCircleOutlined } from '@ant-design/icons'
+import { Box } from '@renderer/components/Layout'
+import { useMCPServers } from '@renderer/hooks/useMCPServers'
+import { Assistant, AssistantSettings } from '@renderer/types'
+import { Empty, Switch, Tooltip } from 'antd'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+
+export interface MCPServer {
+ id: string
+ name: string
+ description?: string
+ baseUrl?: string
+ command?: string
+ args?: string[]
+ env?: Record
+ isActive: boolean
+}
+
+interface Props {
+ assistant: Assistant
+ updateAssistant: (assistant: Assistant) => void
+ updateAssistantSettings: (settings: AssistantSettings) => void
+}
+
+const AssistantMCPSettings: React.FC = ({ assistant, updateAssistant }) => {
+ const { t } = useTranslation()
+
+ const { mcpServers: allMcpServers } = useMCPServers()
+ const onUpdate = (ids: string[]) => {
+ const mcpServers = ids
+ .map((id) => allMcpServers.find((server) => server.id === id))
+ .filter((server): server is MCPServer => server !== undefined && server.isActive)
+ const _assistant = { ...assistant, mcpServers }
+ updateAssistant(_assistant)
+ }
+
+ const handleServerToggle = (serverId: string) => {
+ const currentServerIds = assistant.mcpServers?.map((server) => server.id) || []
+
+ if (currentServerIds.includes(serverId)) {
+ // Remove server if it's already enabled
+ onUpdate(currentServerIds.filter((id) => id !== serverId))
+ } else {
+ // Add server if it's not enabled
+ onUpdate([...currentServerIds, serverId])
+ }
+ }
+
+ const enabledCount = assistant.mcpServers?.length || 0
+
+ return (
+
+
+
+ {t('assistants.settings.mcp.title')}
+
+
+
+
+ {allMcpServers.length > 0 && (
+
+ {enabledCount} / {allMcpServers.length} {t('settings.mcp.active')}
+
+ )}
+
+
+ {allMcpServers.length > 0 ? (
+
+ {allMcpServers.map((server) => {
+ const isEnabled = assistant.mcpServers?.some((s) => s.id === server.id) || false
+
+ return (
+
+
+ {server.name}
+ {server.description && {server.description}}
+ {server.baseUrl && {server.baseUrl}}
+
+
+ handleServerToggle(server.id)}
+ size="small"
+ />
+
+
+ )
+ })}
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
+const Container = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 12px;
+ background-color: ${(props) => props.theme.colors?.background || '#f9f9f9'};
+ border-radius: 8px;
+`
+
+const HeaderContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+`
+
+const InfoIcon = styled(InfoCircleOutlined)`
+ margin-left: 6px;
+ font-size: 14px;
+ color: ${(props) => props.theme.colors?.textSecondary || '#8c8c8c'};
+ cursor: help;
+`
+
+const EnabledCount = styled.span`
+ font-size: 12px;
+ color: ${(props) => props.theme.colors?.textSecondary || '#8c8c8c'};
+`
+
+const EmptyContainer = styled.div`
+ display: flex;
+ flex: 1;
+ justify-content: center;
+ align-items: center;
+ padding: 40px 0;
+`
+
+const ServerList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ overflow-y: auto;
+ padding: 4px;
+`
+
+const ServerItem = styled.div<{ isEnabled: boolean }>`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ border-radius: 8px;
+ background-color: ${(props) => props.theme.colors?.cardBackground || '#fff'};
+ border: 1px solid ${(props) => props.theme.colors?.border || '#e6e6e6'};
+ transition: all 0.2s ease;
+
+ &:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ transform: translateY(-1px);
+ }
+
+ opacity: ${(props) => (props.isEnabled ? 1 : 0.7)};
+`
+
+const ServerInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+`
+
+const ServerName = styled.div`
+ font-weight: 600;
+ margin-bottom: 4px;
+`
+
+const ServerDescription = styled.div`
+ font-size: 0.85rem;
+ color: ${(props) => props.theme.colors?.textSecondary || '#8c8c8c'};
+ margin-bottom: 3px;
+`
+
+const ServerUrl = styled.div`
+ font-size: 0.8rem;
+ color: ${(props) => props.theme.colors?.textTertiary || '#bfbfbf'};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`
+
+export default AssistantMCPSettings
diff --git a/src/renderer/src/pages/settings/AssistantSettings/index.tsx b/src/renderer/src/pages/settings/AssistantSettings/index.tsx
index 6276f90f99..1f68ba4be7 100644
--- a/src/renderer/src/pages/settings/AssistantSettings/index.tsx
+++ b/src/renderer/src/pages/settings/AssistantSettings/index.tsx
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AssistantKnowledgeBaseSettings from './AssistantKnowledgeBaseSettings'
+import AssistantMCPSettings from './AssistantMCPSettings'
import AssistantMessagesSettings from './AssistantMessagesSettings'
import AssistantModelSettings from './AssistantModelSettings'
import AssistantPromptSettings from './AssistantPromptSettings'
@@ -19,7 +20,7 @@ interface AssistantSettingPopupShowParams {
tab?: AssistantSettingPopupTab
}
-type AssistantSettingPopupTab = 'prompt' | 'model' | 'messages' | 'knowledge_base'
+type AssistantSettingPopupTab = 'prompt' | 'model' | 'messages' | 'knowledge_base' | 'mcp'
interface Props extends AssistantSettingPopupShowParams {
resolve: (assistant: Assistant) => void
@@ -68,6 +69,10 @@ const AssistantSettingPopupContainer: React.FC = ({ resolve, tab, ...prop
showKnowledgeIcon && {
key: 'knowledge_base',
label: t('assistants.settings.knowledge_base')
+ },
+ {
+ key: 'mcp',
+ label: t('assistants.settings.mcp')
}
].filter(Boolean) as { key: string; label: string }[]
@@ -133,6 +138,13 @@ const AssistantSettingPopupContainer: React.FC = ({ resolve, tab, ...prop
updateAssistantSettings={updateAssistantSettings}
/>
)}
+ {menu === 'mcp' && (
+
+ )}
diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts
index 6903c183f5..1fe0e2d10d 100644
--- a/src/renderer/src/types/index.ts
+++ b/src/renderer/src/types/index.ts
@@ -17,6 +17,7 @@ export type Assistant = {
messages?: AssistantMessage[]
enableWebSearch?: boolean
enableGenerateImage?: boolean
+ mcpServers?: MCPServer[]
}
export type AssistantMessage = {
From 3dc4947e26df76aebb4ed68946406fbee87160ea Mon Sep 17 00:00:00 2001
From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com>
Date: Mon, 31 Mar 2025 21:11:28 +0800
Subject: [PATCH 15/37] optimize: Sticky CopyButton in CodeBlock (#4205)
---
.../src/pages/home/Markdown/Artifacts.tsx | 1 +
.../src/pages/home/Markdown/CodeBlock.tsx | 22 ++++++++++++++++---
.../src/pages/home/Markdown/Markdown.tsx | 3 ++-
.../src/pages/home/Messages/Message.tsx | 2 +-
.../src/pages/home/Messages/MessageGroup.tsx | 5 +++--
.../src/pages/home/Messages/Messages.tsx | 4 +++-
6 files changed, 29 insertions(+), 8 deletions(-)
diff --git a/src/renderer/src/pages/home/Markdown/Artifacts.tsx b/src/renderer/src/pages/home/Markdown/Artifacts.tsx
index 09b8243dcd..746eb170c5 100644
--- a/src/renderer/src/pages/home/Markdown/Artifacts.tsx
+++ b/src/renderer/src/pages/home/Markdown/Artifacts.tsx
@@ -75,6 +75,7 @@ const Container = styled.div`
display: flex;
flex-direction: row;
gap: 8px;
+ padding-bottom: 10px;
`
export default Artifacts
diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx
index 75ea868c7c..3f56633d9e 100644
--- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx
+++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx
@@ -98,12 +98,20 @@ const CodeBlock: React.FC = ({ children, className }) => {
)}
{'<' + language.toUpperCase() + '>'}
-
+
+
+
+
{showDownloadButton && }
{codeWrappable && setIsUnwrapped(!isUnwrapped)} />}
-
+
`
.shiki {
@@ -376,4 +386,10 @@ const DownloadWrapper = styled.div`
}
`
+const StickyWrapper = styled.div`
+ position: sticky;
+ top: 28px;
+ z-index: 10;
+`
+
export default memo(CodeBlock)
diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx
index 257b52c1be..bf3039354e 100644
--- a/src/renderer/src/pages/home/Markdown/Markdown.tsx
+++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx
@@ -64,7 +64,8 @@ const Markdown: FC = ({ message, citationsData }) => {
return
},
code: CodeBlock,
- img: ImagePreview
+ img: ImagePreview,
+ pre: (props: any) =>
} as Partial
if (messageContent.includes('