mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 23:10:20 +08:00
refactor: improve mcp server list (#9257)
* refactor: mcp server list
* feat: add a delete button, improve button style
* refactor(McpServerList): extract McpServerCard
* feat: show numbers in tab titles
* refactor(preload): 明确getServerVersion返回类型为Promise<string | null>
* refactor(hooks): 移除MCPServer数组的类型断言
* refactor(MCPSettings): 简化服务器标签的渲染逻辑
* Revert "refactor(MCPSettings): 简化服务器标签的渲染逻辑"
This reverts commit 1dd08909af.
* doc: add comments
---------
Co-authored-by: icarus <eurfelux@gmail.com>
This commit is contained in:
parent
f3884af4b9
commit
263166c9d1
@ -295,7 +295,8 @@ const api = {
|
||||
return ipcRenderer.invoke(IpcChannel.Mcp_UploadDxt, buffer, file.name)
|
||||
},
|
||||
abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId),
|
||||
getServerVersion: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
|
||||
getServerVersion: (server: MCPServer): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
|
||||
},
|
||||
python: {
|
||||
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import store, { RootState, useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
@ -16,7 +16,7 @@ window.electron.ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPSer
|
||||
NavigationService.navigate?.(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)
|
||||
})
|
||||
|
||||
const selectMcpServers = (state) => state.mcp.servers
|
||||
const selectMcpServers = (state: RootState) => state.mcp.servers
|
||||
const selectActiveMcpServers = createSelector([selectMcpServers], (servers) =>
|
||||
servers.filter((server) => server.isActive)
|
||||
)
|
||||
|
||||
181
src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx
Normal file
181
src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
import { getMcpTypeLabel } from '@renderer/i18n/label'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Badge, Button, Switch, Tag } from 'antd'
|
||||
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface McpServerCardProps {
|
||||
server: MCPServer
|
||||
version?: string | null
|
||||
isLoading: boolean
|
||||
onToggle: (active: boolean) => void
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
onOpenUrl: (url: string) => void
|
||||
}
|
||||
|
||||
const McpServerCard: FC<McpServerCardProps> = ({
|
||||
server,
|
||||
version,
|
||||
isLoading,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenUrl
|
||||
}) => {
|
||||
const handleOpenUrl = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (server.providerUrl) {
|
||||
onOpenUrl(server.providerUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContainer $isActive={server.isActive}>
|
||||
<ServerHeader>
|
||||
<ServerName>
|
||||
{server.logoUrl && <ServerLogo src={server.logoUrl} alt={`${server.name} logo`} />}
|
||||
<ServerNameText>{server.name}</ServerNameText>
|
||||
{version && <VersionBadge count={version} color="blue" />}
|
||||
{server.providerUrl && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<SquareArrowOutUpRight size={14} />}
|
||||
className="nodrag"
|
||||
onClick={handleOpenUrl}
|
||||
/>
|
||||
)}
|
||||
</ServerName>
|
||||
<ToolbarWrapper onClick={(e) => e.stopPropagation()}>
|
||||
<Switch value={server.isActive} key={server.id} loading={isLoading} onChange={onToggle} size="small" />
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<DeleteIcon size={16} className="lucide-custom" />}
|
||||
className="nodrag"
|
||||
danger
|
||||
onClick={onDelete}
|
||||
/>
|
||||
<Button type="text" shape="circle" icon={<Settings2 size={16} />} className="nodrag" onClick={onEdit} />
|
||||
</ToolbarWrapper>
|
||||
</ServerHeader>
|
||||
<ServerDescription>{server.description}</ServerDescription>
|
||||
<ServerFooter>
|
||||
<ServerTag color="processing">{getMcpTypeLabel(server.type ?? 'stdio')}</ServerTag>
|
||||
{server.provider && <ServerTag color="success">{server.provider}</ServerTag>}
|
||||
{server.tags
|
||||
?.filter((tag): tag is string => typeof tag === 'string') // Avoid existing non-string tags crash the UI
|
||||
.map((tag) => (
|
||||
<ServerTag key={tag} color="default">
|
||||
{tag}
|
||||
</ServerTag>
|
||||
))}
|
||||
</ServerFooter>
|
||||
</CardContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// Styled components
|
||||
const CardContainer = styled.div<{ $isActive: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: var(--list-item-border-radius);
|
||||
padding: 10px 16px;
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--color-background);
|
||||
margin-bottom: 5px;
|
||||
height: 125px;
|
||||
cursor: pointer;
|
||||
opacity: ${(props) => (props.$isActive ? 1 : 0.6)};
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const ServerHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
|
||||
const ServerName = styled.div`
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const ServerNameText = styled.span`
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
const ServerLogo = styled.img`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const ToolbarWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
> :first-child {
|
||||
margin-right: 4px;
|
||||
}
|
||||
`
|
||||
|
||||
const ServerDescription = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
height: 50px;
|
||||
`
|
||||
|
||||
const ServerFooter = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: flex-start;
|
||||
margin-top: 10px;
|
||||
`
|
||||
|
||||
const ServerTag = styled(Tag)`
|
||||
border-radius: 20px;
|
||||
margin: 0;
|
||||
`
|
||||
|
||||
const VersionBadge = styled(Badge)`
|
||||
.ant-badge-count {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 0 5px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 8px;
|
||||
min-width: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`
|
||||
|
||||
export default McpServerCard
|
||||
@ -3,12 +3,11 @@ import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import { EditIcon, RefreshIcon } from '@renderer/components/Icons'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { getMcpTypeLabel } from '@renderer/i18n/label'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { formatMcpError } from '@renderer/utils/error'
|
||||
import { Badge, Button, Dropdown, Empty, Switch, Tag } from 'antd'
|
||||
import { MonitorCheck, Plus, Settings2, SquareArrowOutUpRight } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, Dropdown, Empty } from 'antd'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
@ -19,10 +18,11 @@ import BuiltinMCPServerList from './BuiltinMCPServerList'
|
||||
import EditMcpJsonPopup from './EditMcpJsonPopup'
|
||||
import InstallNpxUv from './InstallNpxUv'
|
||||
import McpMarketList from './McpMarketList'
|
||||
import McpServerCard from './McpServerCard'
|
||||
import SyncServersPopup from './SyncServersPopup'
|
||||
|
||||
const McpServersList: FC = () => {
|
||||
const { mcpServers, addMCPServer, updateMcpServers, updateMCPServer } = useMCPServers()
|
||||
const { mcpServers, addMCPServer, deleteMCPServer, updateMcpServers, updateMCPServer } = useMCPServers()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false)
|
||||
@ -88,6 +88,30 @@ const McpServersList: FC = () => {
|
||||
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-list' })
|
||||
}, [addMCPServer, navigate, t])
|
||||
|
||||
const onDeleteMcpServer = useCallback(
|
||||
async (server: MCPServer) => {
|
||||
try {
|
||||
window.modal.confirm({
|
||||
title: t('settings.mcp.deleteServer'),
|
||||
content: t('settings.mcp.deleteServerConfirm'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
await window.api.mcp.removeServer(server)
|
||||
deleteMCPServer(server.id)
|
||||
window.message.success({ content: t('settings.mcp.deleteSuccess'), key: 'mcp-list' })
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
window.message.error({
|
||||
content: `${t('settings.mcp.deleteError')}: ${error.message}`,
|
||||
key: 'mcp-list'
|
||||
})
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t]
|
||||
)
|
||||
|
||||
const onSyncServers = useCallback(() => {
|
||||
SyncServersPopup.show(mcpServers)
|
||||
}, [mcpServers])
|
||||
@ -134,6 +158,35 @@ const McpServersList: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'manual',
|
||||
label: t('settings.mcp.addServer.create'),
|
||||
onClick: () => {
|
||||
onAddMcpServer()
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'json',
|
||||
label: t('settings.mcp.addServer.importFrom.json'),
|
||||
onClick: () => {
|
||||
setModalType('json')
|
||||
setIsAddModalVisible(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'dxt',
|
||||
label: t('settings.mcp.addServer.importFrom.dxt'),
|
||||
onClick: () => {
|
||||
setModalType('dxt')
|
||||
setIsAddModalVisible(true)
|
||||
}
|
||||
}
|
||||
],
|
||||
[onAddMcpServer, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<Container ref={scrollRef}>
|
||||
<ListHeader>
|
||||
@ -145,31 +198,7 @@ const McpServersList: FC = () => {
|
||||
<InstallNpxUv mini />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'manual',
|
||||
label: t('settings.mcp.addServer.create'),
|
||||
onClick: () => {
|
||||
onAddMcpServer()
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'json',
|
||||
label: t('settings.mcp.addServer.importFrom.json'),
|
||||
onClick: () => {
|
||||
setModalType('json')
|
||||
setIsAddModalVisible(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'dxt',
|
||||
label: t('settings.mcp.addServer.importFrom.dxt'),
|
||||
onClick: () => {
|
||||
setModalType('dxt')
|
||||
setIsAddModalVisible(true)
|
||||
}
|
||||
}
|
||||
]
|
||||
items: menuItems
|
||||
}}
|
||||
trigger={['click']}>
|
||||
<Button icon={<Plus size={16} />} type="default" shape="round">
|
||||
@ -183,61 +212,17 @@ const McpServersList: FC = () => {
|
||||
</ListHeader>
|
||||
<DraggableList style={{ width: '100%' }} list={mcpServers} onUpdate={updateMcpServers}>
|
||||
{(server: MCPServer) => (
|
||||
<ServerCard
|
||||
key={server.id}
|
||||
onClick={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}>
|
||||
<ServerHeader>
|
||||
<ServerName>
|
||||
{server.logoUrl && <ServerLogo src={server.logoUrl} alt={`${server.name} logo`} />}
|
||||
<ServerNameText>{server.name}</ServerNameText>
|
||||
{serverVersions[server.id] && <VersionBadge count={serverVersions[server.id]} color="blue" />}
|
||||
{server.providerUrl && (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => window.open(server.providerUrl, '_blank')}
|
||||
icon={<SquareArrowOutUpRight size={14} />}
|
||||
className="nodrag"
|
||||
style={{ fontSize: 13, height: 28, borderRadius: 20 }}></Button>
|
||||
)}
|
||||
<ServerIcon>
|
||||
<MonitorCheck size={16} color={server.isActive ? 'var(--color-primary)' : 'var(--color-text-3)'} />
|
||||
</ServerIcon>
|
||||
</ServerName>
|
||||
<StatusIndicator onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
value={server.isActive}
|
||||
key={server.id}
|
||||
loading={loadingServerIds.has(server.id)}
|
||||
onChange={(checked) => handleToggleActive(server, checked)}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
icon={<Settings2 size={16} />}
|
||||
type="text"
|
||||
onClick={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}
|
||||
/>
|
||||
</StatusIndicator>
|
||||
</ServerHeader>
|
||||
<ServerDescription>{server.description}</ServerDescription>
|
||||
<ServerFooter>
|
||||
<Tag color="processing" style={{ borderRadius: 20, margin: 0, fontWeight: 500 }}>
|
||||
{getMcpTypeLabel(server.type ?? 'stdio')}
|
||||
</Tag>
|
||||
{server.provider && (
|
||||
<Tag color="success" style={{ borderRadius: 20, margin: 0, fontWeight: 500 }}>
|
||||
{server.provider}
|
||||
</Tag>
|
||||
)}
|
||||
{server.tags
|
||||
?.filter((tag): tag is string => typeof tag === 'string')
|
||||
.map((tag) => (
|
||||
<Tag key={tag} color="default" style={{ borderRadius: 20, margin: 0 }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</ServerFooter>
|
||||
</ServerCard>
|
||||
<div onClick={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}>
|
||||
<McpServerCard
|
||||
server={server}
|
||||
version={serverVersions[server.id]}
|
||||
isLoading={loadingServerIds.has(server.id)}
|
||||
onToggle={(active) => handleToggleActive(server, active)}
|
||||
onDelete={() => onDeleteMcpServer(server)}
|
||||
onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}
|
||||
onOpenUrl={(url) => window.open(url, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DraggableList>
|
||||
{mcpServers.length === 0 && (
|
||||
@ -287,104 +272,10 @@ const ListHeader = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const ServerCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: var(--list-item-border-radius);
|
||||
padding: 10px 16px;
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--color-background);
|
||||
margin-bottom: 5px;
|
||||
height: 125px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const ServerLogo = styled.img`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const ServerHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
|
||||
const ServerIcon = styled.div`
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
`
|
||||
|
||||
const ServerName = styled.div`
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const ServerNameText = styled.span`
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
const StatusIndicator = styled.div`
|
||||
margin-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const ServerDescription = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
height: 50px;
|
||||
`
|
||||
|
||||
const ServerFooter = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: flex-start;
|
||||
margin-top: 10px;
|
||||
`
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const VersionBadge = styled(Badge)`
|
||||
.ant-badge-count {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 0 5px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 8px;
|
||||
min-width: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`
|
||||
|
||||
export default McpServersList
|
||||
|
||||
@ -5,7 +5,7 @@ import { useMCPServer, useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription'
|
||||
import { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
|
||||
import { formatMcpError } from '@renderer/utils/error'
|
||||
import { Badge, Button, Flex, Form, Input, Radio, Select, Switch, Tabs } from 'antd'
|
||||
import { Badge, Button, Flex, Form, Input, Radio, Select, Switch, Tabs, TabsProps } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { ChevronDown, SaveIcon } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
@ -492,7 +492,7 @@ const McpSettings: React.FC = () => {
|
||||
[server, updateMCPServer]
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
const tabs: TabsProps['items'] = [
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('settings.mcp.tabs.general'),
|
||||
@ -705,7 +705,7 @@ const McpSettings: React.FC = () => {
|
||||
tabs.push(
|
||||
{
|
||||
key: 'tools',
|
||||
label: t('settings.mcp.tabs.tools'),
|
||||
label: t('settings.mcp.tabs.tools') + (tools.length > 0 ? ` (${tools.length})` : ''),
|
||||
children: (
|
||||
<MCPToolsSection
|
||||
tools={tools}
|
||||
@ -717,12 +717,12 @@ const McpSettings: React.FC = () => {
|
||||
},
|
||||
{
|
||||
key: 'prompts',
|
||||
label: t('settings.mcp.tabs.prompts'),
|
||||
label: t('settings.mcp.tabs.prompts') + (prompts.length > 0 ? ` (${prompts.length})` : ''),
|
||||
children: <MCPPromptsSection prompts={prompts} />
|
||||
},
|
||||
{
|
||||
key: 'resources',
|
||||
label: t('settings.mcp.tabs.resources'),
|
||||
label: t('settings.mcp.tabs.resources') + (resources.length > 0 ? ` (${resources.length})` : ''),
|
||||
children: <MCPResourcesSection resources={resources} />
|
||||
}
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user