feat: refactor MCPSettings and add new components for server management
- Introduced a new AddMcpServerPopup component for adding and editing MCP servers, improving modularity and reusability. - Created NpxSearch component to handle npm package searches, integrating with the existing MCPSettings for enhanced functionality. - Updated MCPSettings to utilize the new components, streamlining the server management interface. - Added localization support for new UI elements in multiple languages, enhancing user experience.
This commit is contained in:
parent
e0e1d285e4
commit
bb25522798
@ -31,6 +31,8 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
const [messageApi, messageContextHolder] = message.useMessage()
|
||||
const [modal, modalContextHolder] = Modal.useModal()
|
||||
|
||||
console.debug('TopViewContainer', elements)
|
||||
|
||||
useAppInit()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -835,7 +835,10 @@
|
||||
"usage": "Usage",
|
||||
"npm": "NPM",
|
||||
"version": "Version",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"scope_required": "Please enter npm scope",
|
||||
"no_packages": "No packages found",
|
||||
"search_error": "Search error"
|
||||
}
|
||||
},
|
||||
"messages.divider": "Show divider between messages",
|
||||
|
||||
@ -835,7 +835,10 @@
|
||||
"usage": "使用法",
|
||||
"npm": "NPM",
|
||||
"version": "バージョン",
|
||||
"actions": "アクション"
|
||||
"actions": "アクション",
|
||||
"scope_required": "npm スコープを入力してください",
|
||||
"no_packages": "パッケージが見つかりません",
|
||||
"search_error": "パッケージの検索に失敗しました"
|
||||
}
|
||||
},
|
||||
"messages.divider": "メッセージ間に区切り線を表示",
|
||||
|
||||
@ -835,7 +835,10 @@
|
||||
"usage": "Использование",
|
||||
"npm": "NPM",
|
||||
"version": "Версия",
|
||||
"actions": "Действия"
|
||||
"actions": "Действия",
|
||||
"scope_required": "Пожалуйста, введите область npm",
|
||||
"no_packages": "Ничего не найдено",
|
||||
"search_error": "Ошибка поиска"
|
||||
}
|
||||
},
|
||||
"messages.divider": "Показывать разделитель между сообщениями",
|
||||
|
||||
@ -835,7 +835,10 @@
|
||||
"usage": "用法",
|
||||
"npm": "NPM",
|
||||
"version": "版本",
|
||||
"actions": "操作"
|
||||
"actions": "操作",
|
||||
"scope_required": "请输入 npm 作用域",
|
||||
"no_packages": "未找到包",
|
||||
"search_error": "搜索失败"
|
||||
}
|
||||
},
|
||||
"messages.divider": "消息分割线",
|
||||
|
||||
@ -835,7 +835,10 @@
|
||||
"usage": "用法",
|
||||
"npm": "NPM",
|
||||
"version": "版本",
|
||||
"actions": "操作"
|
||||
"actions": "操作",
|
||||
"scope_required": "請輸入 npm 作用域",
|
||||
"no_packages": "未找到包",
|
||||
"search_error": "搜索失敗"
|
||||
}
|
||||
},
|
||||
"messages.divider": "訊息間顯示分隔線",
|
||||
|
||||
@ -1,497 +0,0 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, QuestionCircleOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Button, Card, Form, Input, Modal, Radio, Space, Spin, Switch, Table, Tag, Tooltip, Typography } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { npxFinder } from 'npx-scope-finder'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
|
||||
|
||||
interface MCPFormValues {
|
||||
name: string
|
||||
description?: string
|
||||
serverType: 'sse' | 'stdio'
|
||||
baseUrl?: string
|
||||
command?: string
|
||||
args?: string
|
||||
env?: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
usage: string
|
||||
npmLink: string
|
||||
fullName: string
|
||||
}
|
||||
|
||||
const MCPSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { Paragraph, Text } = Typography
|
||||
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false)
|
||||
const [editingServer, setEditingServer] = useState<MCPServer | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [form] = Form.useForm<MCPFormValues>()
|
||||
const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio')
|
||||
|
||||
// Add new state variables for npm scope search
|
||||
const [npmScope, setNpmScope] = useState('')
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
|
||||
// Add new function to handle npm scope search
|
||||
const handleNpmSearch = async () => {
|
||||
if (!npmScope.trim()) {
|
||||
window.message.warning('Please enter an npm scope')
|
||||
return
|
||||
}
|
||||
|
||||
setSearchLoading(true)
|
||||
try {
|
||||
// Call npxFinder to search for packages
|
||||
const packages = await npxFinder(npmScope)
|
||||
|
||||
// Map the packages to our desired format
|
||||
const formattedResults = packages.map((pkg) => {
|
||||
return {
|
||||
key: pkg.name,
|
||||
name: pkg.name || '',
|
||||
description: pkg.description || 'No description available',
|
||||
version: pkg.version || 'Latest',
|
||||
usage: `npx ${pkg.name}`,
|
||||
npmLink: pkg.links?.npm || `https://www.npmjs.com/package/${pkg.name}`,
|
||||
fullName: pkg.name || ''
|
||||
}
|
||||
})
|
||||
|
||||
setSearchResults(formattedResults)
|
||||
|
||||
if (formattedResults.length === 0) {
|
||||
window.message.info('No packages found for this scope')
|
||||
}
|
||||
} catch (error: any) {
|
||||
window.message.error(`Failed to search npm packages: ${error.message}`)
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch the serverType field to update the form layout dynamically
|
||||
useEffect(() => {
|
||||
const type = form.getFieldValue('serverType')
|
||||
if (type) {
|
||||
setServerType(type)
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const showAddModal = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ serverType: 'stdio', isActive: true })
|
||||
setServerType('stdio')
|
||||
setIsModalVisible(true)
|
||||
}
|
||||
|
||||
const showEditModal = (server: MCPServer) => {
|
||||
setEditingServer(server)
|
||||
// Determine server type based on server properties
|
||||
const serverType = server.baseUrl ? 'sse' : 'stdio'
|
||||
setServerType(serverType)
|
||||
|
||||
form.setFieldsValue({
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
serverType: serverType,
|
||||
baseUrl: server.baseUrl || '',
|
||||
command: server.command || '',
|
||||
args: server.args ? server.args.join('\n') : '',
|
||||
env: server.env
|
||||
? Object.entries(server.env)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
: '',
|
||||
isActive: server.isActive
|
||||
})
|
||||
setIsModalVisible(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false)
|
||||
form.resetFields()
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const mcpServer: MCPServer = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
isActive: values.isActive
|
||||
}
|
||||
|
||||
if (values.serverType === 'sse') {
|
||||
mcpServer.baseUrl = values.baseUrl
|
||||
} else {
|
||||
mcpServer.command = values.command
|
||||
mcpServer.args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : []
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
if (values.env) {
|
||||
values.env.split('\n').forEach((line) => {
|
||||
if (line.trim()) {
|
||||
const [key, ...chunks] = line.split('=')
|
||||
const value = chunks.join('=')
|
||||
if (key && value) {
|
||||
env[key.trim()] = value.trim()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
mcpServer.env = Object.keys(env).length > 0 ? env : undefined
|
||||
}
|
||||
|
||||
if (editingServer) {
|
||||
try {
|
||||
await window.api.mcp.updateServer(mcpServer)
|
||||
window.message.success(t('settings.mcp.updateSuccess'))
|
||||
setLoading(false)
|
||||
setIsModalVisible(false)
|
||||
form.resetFields()
|
||||
} catch (error: any) {
|
||||
window.message.error(`${t('settings.mcp.updateError')}: ${error.message}`)
|
||||
setLoading(false)
|
||||
}
|
||||
} else {
|
||||
// Check for duplicate name
|
||||
if (mcpServers.some((server: MCPServer) => server.name === mcpServer.name)) {
|
||||
window.message.error(t('settings.mcp.duplicateName'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.mcp.addServer(mcpServer)
|
||||
window.message.success(t('settings.mcp.addSuccess'))
|
||||
setLoading(false)
|
||||
setIsModalVisible(false)
|
||||
form.resetFields()
|
||||
} catch (error: any) {
|
||||
window.message.error(`${t('settings.mcp.addError')}: ${error.message}`)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (serverName: string) => {
|
||||
window.modal.confirm({
|
||||
title: t('settings.mcp.confirmDelete'),
|
||||
content: t('settings.mcp.confirmDeleteMessage'),
|
||||
okText: t('common.delete'),
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await window.api.mcp.deleteServer(serverName)
|
||||
window.message.success(t('settings.mcp.deleteSuccess'))
|
||||
} catch (error: any) {
|
||||
window.message.error(`${t('settings.mcp.deleteError')}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleActive = async (name: string, isActive: boolean) => {
|
||||
try {
|
||||
await window.api.mcp.setServerActive(name, isActive)
|
||||
} catch (error: any) {
|
||||
window.message.error(`${t('settings.mcp.toggleError')}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('settings.mcp.name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '300px',
|
||||
render: (text: string, record: MCPServer) => <Text strong={record.isActive}>{text}</Text>
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.type'),
|
||||
key: 'type',
|
||||
width: '100px',
|
||||
render: (_: any, record: MCPServer) => <Tag color="cyan">{record.baseUrl ? 'SSE' : 'STDIO'}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.description'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: 'auto',
|
||||
render: (text: string) => {
|
||||
if (!text) {
|
||||
return (
|
||||
<Text type="secondary" italic>
|
||||
{t('common.description')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Paragraph
|
||||
ellipsis={{
|
||||
rows: 1,
|
||||
expandable: 'collapsible',
|
||||
symbol: t('common.more'),
|
||||
onExpand: () => {}, // Empty callback required for proper functionality
|
||||
tooltip: true
|
||||
}}
|
||||
style={{ marginBottom: 0 }}>
|
||||
{text}
|
||||
</Paragraph>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.active'),
|
||||
dataIndex: 'isActive',
|
||||
key: 'isActive',
|
||||
width: '100px',
|
||||
render: (isActive: boolean, record: MCPServer) => (
|
||||
<Switch checked={isActive} onChange={(checked) => handleToggleActive(record.name, checked)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.actions'),
|
||||
key: 'actions',
|
||||
width: '100px',
|
||||
render: (_: any, record: MCPServer) => (
|
||||
<Space>
|
||||
<Tooltip title={t('common.edit')}>
|
||||
<Button type="primary" ghost icon={<EditOutlined />} onClick={() => showEditModal(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.name)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
// Create a CSS class for inactive rows instead of using jsx global
|
||||
const inactiveRowStyle = {
|
||||
opacity: 0.7,
|
||||
backgroundColor: theme === 'dark' ? '#1a1a1a' : '#f5f5f5'
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>
|
||||
{t('settings.mcp.title')}
|
||||
<Tooltip title={t('settings.mcp.config_description')}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 8, fontSize: 14 }} />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<Paragraph type="secondary" style={{ margin: '0 0 20px 0' }}>
|
||||
{t('settings.mcp.config_description')}
|
||||
</Paragraph>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={showAddModal}>
|
||||
{t('settings.mcp.addServer')}
|
||||
</Button>
|
||||
<Text type="secondary">
|
||||
{mcpServers.length}{' '}
|
||||
{mcpServers.length === 1 ? t('settings.mcp.serverSingular') : t('settings.mcp.serverPlural')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ background: theme === 'dark' ? '#1f1f1f' : '#fff' }}
|
||||
styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
dataSource={mcpServers}
|
||||
columns={columns}
|
||||
rowKey="name"
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('settings.mcp.noServers') }}
|
||||
rowClassName={(record) => (!record.isActive ? 'inactive-row' : '')}
|
||||
onRow={(record) => ({
|
||||
style: !record.isActive ? inactiveRowStyle : {}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.mcp.npx_list.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<Paragraph type="secondary" style={{ margin: '0 0 20px 0' }}>
|
||||
{t('settings.mcp.npx_list.desc')}
|
||||
</Paragraph>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder={t('settings.mcp.npx_list.scope_placeholder')}
|
||||
value={npmScope}
|
||||
onChange={(e) => setNpmScope(e.target.value)}
|
||||
onPressEnter={handleNpmSearch}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleNpmSearch} disabled={searchLoading}>
|
||||
{t('settings.mcp.npx_list.search')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{searchLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<Table<SearchResult>
|
||||
dataSource={searchResults}
|
||||
columns={[
|
||||
{
|
||||
title: t('settings.mcp.npx_list.package_name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '200px'
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.npx_list.description'),
|
||||
key: 'description',
|
||||
render: (_, record: SearchResult) => (
|
||||
<Space direction="vertical" size="small">
|
||||
<Text>{record.description}</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('settings.mcp.npx_list.usage')}: {record.usage}
|
||||
</Text>
|
||||
<a href={record.npmLink} target="_blank" rel="noopener noreferrer" style={{ fontSize: '12px' }}>
|
||||
{record.npmLink}
|
||||
</a>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.npx_list.version'),
|
||||
dataIndex: 'version',
|
||||
key: 'version',
|
||||
width: '100px'
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.npx_list.actions'),
|
||||
key: 'actions',
|
||||
width: '100px',
|
||||
render: (_, record: SearchResult) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
// 创建一个临时的 MCP 服务器对象
|
||||
const tempServer: MCPServer = {
|
||||
name: record.name,
|
||||
description: `${record.description}\n\n${t('settings.mcp.npx_list.usage')}: ${record.usage}\n${t('settings.mcp.npx_list.npm')}: ${record.npmLink}`,
|
||||
command: 'npx',
|
||||
args: ['-y', record.fullName],
|
||||
isActive: true
|
||||
}
|
||||
|
||||
// 使用 showEditModal 函数设置表单值并显示弹窗
|
||||
showEditModal(tempServer)
|
||||
}}>
|
||||
{t('settings.mcp.addServer')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
</SettingGroup>
|
||||
|
||||
<Modal
|
||||
title={editingServer ? t('settings.mcp.editServer') : t('settings.mcp.addServer')}
|
||||
open={isModalVisible}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
maskClosable={false}
|
||||
width={600}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('settings.mcp.name')}
|
||||
rules={[{ required: true, message: t('settings.mcp.nameRequired') }]}>
|
||||
<Input disabled={!!editingServer} placeholder={t('common.name')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label={t('settings.mcp.description')}>
|
||||
<TextArea rows={2} placeholder={t('common.description')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="serverType" label={t('settings.mcp.type')} rules={[{ required: true }]} initialValue="stdio">
|
||||
<Radio.Group
|
||||
onChange={(e) => setServerType(e.target.value)}
|
||||
options={[
|
||||
{ label: 'SSE (Server-Sent Events)', value: 'sse' },
|
||||
{ label: 'STDIO (Standard Input/Output)', value: 'stdio' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{serverType === 'sse' && (
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label={t('settings.mcp.url')}
|
||||
rules={[{ required: serverType === 'sse', message: t('settings.mcp.baseUrlRequired') }]}
|
||||
tooltip={t('settings.mcp.baseUrlTooltip')}>
|
||||
<Input placeholder="http://localhost:3000/sse" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{serverType === 'stdio' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="command"
|
||||
label={t('settings.mcp.command')}
|
||||
rules={[{ required: serverType === 'stdio', message: t('settings.mcp.commandRequired') }]}>
|
||||
<Input placeholder="uvx or npx" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
|
||||
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
|
||||
<TextArea rows={3} placeholder={`KEY1=value1\nKEY2=value2`} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item name="isActive" label={t('settings.mcp.active')} valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default MCPSettings
|
||||
@ -0,0 +1,237 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Form, Input, Modal, Radio, Switch } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface ShowParams {
|
||||
server?: MCPServer
|
||||
create?: boolean
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
interface MCPFormValues {
|
||||
name: string
|
||||
description?: string
|
||||
serverType: 'sse' | 'stdio'
|
||||
baseUrl?: string
|
||||
command?: string
|
||||
args?: string
|
||||
env?: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ server, create, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio')
|
||||
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
||||
const [form] = Form.useForm<MCPFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (server) {
|
||||
// Determine server type based on server properties
|
||||
const serverType = server.baseUrl ? 'sse' : 'stdio'
|
||||
setServerType(serverType)
|
||||
|
||||
form.setFieldsValue({
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
serverType: serverType,
|
||||
baseUrl: server.baseUrl || '',
|
||||
command: server.command || '',
|
||||
args: server.args ? server.args.join('\n') : '',
|
||||
env: server.env
|
||||
? Object.entries(server.env)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
: '',
|
||||
isActive: server.isActive
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Watch the serverType field to update the form layout dynamically
|
||||
useEffect(() => {
|
||||
const type = form.getFieldValue('serverType')
|
||||
type && setServerType(type)
|
||||
}, [form])
|
||||
|
||||
const onOK = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const mcpServer: MCPServer = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
isActive: values.isActive
|
||||
}
|
||||
|
||||
if (values.serverType === 'sse') {
|
||||
mcpServer.baseUrl = values.baseUrl
|
||||
} else {
|
||||
mcpServer.command = values.command
|
||||
mcpServer.args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : []
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
if (values.env) {
|
||||
values.env.split('\n').forEach((line) => {
|
||||
if (line.trim()) {
|
||||
const [key, ...chunks] = line.split('=')
|
||||
const value = chunks.join('=')
|
||||
if (key && value) {
|
||||
env[key.trim()] = value.trim()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
mcpServer.env = Object.keys(env).length > 0 ? env : undefined
|
||||
}
|
||||
|
||||
if (server && !create) {
|
||||
try {
|
||||
await window.api.mcp.updateServer(mcpServer)
|
||||
window.message.success(t('settings.mcp.updateSuccess'))
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
form.resetFields()
|
||||
} catch (error: any) {
|
||||
window.message.error(`${t('settings.mcp.updateError')}: ${error.message}`)
|
||||
setLoading(false)
|
||||
}
|
||||
} else {
|
||||
// Check for duplicate name
|
||||
if (mcpServers.some((server: MCPServer) => server.name === mcpServer.name)) {
|
||||
window.message.error(t('settings.mcp.duplicateName'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.mcp.addServer(mcpServer)
|
||||
window.message.success(t('settings.mcp.addSuccess'))
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
form.resetFields()
|
||||
} catch (error: any) {
|
||||
window.message.error(`${t('settings.mcp.addError')}: ${error.message}`)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
console.log('onCancel')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
console.log('onClose')
|
||||
resolve({})
|
||||
}
|
||||
|
||||
AddMcpServerPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={server ? t('settings.mcp.editServer') : t('settings.mcp.addServer')}
|
||||
open={open}
|
||||
onOk={onOK}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
confirmLoading={loading}
|
||||
maskClosable={false}
|
||||
width={600}
|
||||
transitionName="ant-move-down"
|
||||
centered>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('settings.mcp.name')}
|
||||
rules={[{ required: true, message: t('settings.mcp.nameRequired') }]}>
|
||||
<Input disabled={!!server} placeholder={t('common.name')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label={t('settings.mcp.description')}>
|
||||
<TextArea rows={2} placeholder={t('common.description')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="serverType" label={t('settings.mcp.type')} rules={[{ required: true }]} initialValue="stdio">
|
||||
<Radio.Group
|
||||
onChange={(e) => setServerType(e.target.value)}
|
||||
options={[
|
||||
{ label: 'SSE (Server-Sent Events)', value: 'sse' },
|
||||
{ label: 'STDIO (Standard Input/Output)', value: 'stdio' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{serverType === 'sse' && (
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label={t('settings.mcp.url')}
|
||||
rules={[{ required: serverType === 'sse', message: t('settings.mcp.baseUrlRequired') }]}
|
||||
tooltip={t('settings.mcp.baseUrlTooltip')}>
|
||||
<Input placeholder="http://localhost:3000/sse" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{serverType === 'stdio' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="command"
|
||||
label={t('settings.mcp.command')}
|
||||
rules={[{ required: serverType === 'stdio', message: t('settings.mcp.commandRequired') }]}>
|
||||
<Input placeholder="uvx or npx" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
|
||||
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
|
||||
<TextArea rows={3} placeholder={`KEY1=value1\nKEY2=value2`} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item name="isActive" label={t('settings.mcp.active')} valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'AddMcpServerPopup'
|
||||
|
||||
export default class AddMcpServerPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: ShowParams = {}) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
160
src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx
Normal file
160
src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Button, Input, Space, Spin, Table, Typography } from 'antd'
|
||||
import { npxFinder } from 'npx-scope-finder'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingTitle } from '..'
|
||||
import AddMcpServerPopup from './AddMcpServerPopup'
|
||||
|
||||
interface SearchResult {
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
usage: string
|
||||
npmLink: string
|
||||
fullName: string
|
||||
}
|
||||
|
||||
const NpxSearch: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const { Paragraph, Text } = Typography
|
||||
|
||||
// Add new state variables for npm scope search
|
||||
const [npmScope, setNpmScope] = useState('')
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
|
||||
// Add new function to handle npm scope search
|
||||
const handleNpmSearch = async () => {
|
||||
if (!npmScope.trim()) {
|
||||
window.message.warning(t('settings.mcp.npx_list.scope_required'))
|
||||
return
|
||||
}
|
||||
|
||||
setSearchLoading(true)
|
||||
try {
|
||||
// Call npxFinder to search for packages
|
||||
const packages = await npxFinder(npmScope)
|
||||
|
||||
// Map the packages to our desired format
|
||||
const formattedResults = packages.map((pkg) => {
|
||||
return {
|
||||
key: pkg.name,
|
||||
name: pkg.name || '',
|
||||
description: pkg.description || 'No description available',
|
||||
version: pkg.version || 'Latest',
|
||||
usage: `npx ${pkg.name}`,
|
||||
npmLink: pkg.links?.npm || `https://www.npmjs.com/package/${pkg.name}`,
|
||||
fullName: pkg.name || ''
|
||||
}
|
||||
})
|
||||
|
||||
setSearchResults(formattedResults)
|
||||
|
||||
if (formattedResults.length === 0) {
|
||||
window.message.info(t('settings.mcp.npx_list.no_packages'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
window.message.error(`${t('settings.mcp.npx_list.search_error')}: ${error.message}`)
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.mcp.npx_list.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<Paragraph type="secondary" style={{ margin: '0 0 20px 0' }}>
|
||||
{t('settings.mcp.npx_list.desc')}
|
||||
</Paragraph>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 10 }}>
|
||||
<Input
|
||||
placeholder={t('settings.mcp.npx_list.scope_placeholder')}
|
||||
value={npmScope}
|
||||
onChange={(e) => setNpmScope(e.target.value)}
|
||||
onPressEnter={handleNpmSearch}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleNpmSearch} disabled={searchLoading}>
|
||||
{t('settings.mcp.npx_list.search')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{searchLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<Table<SearchResult>
|
||||
dataSource={searchResults}
|
||||
columns={[
|
||||
{
|
||||
title: t('settings.mcp.npx_list.package_name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '200px'
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.npx_list.description'),
|
||||
key: 'description',
|
||||
render: (_, record: SearchResult) => (
|
||||
<Space direction="vertical" size="small">
|
||||
<Text>{record.description}</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('settings.mcp.npx_list.usage')}: {record.usage}
|
||||
</Text>
|
||||
<a href={record.npmLink} target="_blank" rel="noopener noreferrer" style={{ fontSize: '12px' }}>
|
||||
{record.npmLink}
|
||||
</a>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.npx_list.version'),
|
||||
dataIndex: 'version',
|
||||
key: 'version',
|
||||
width: '100px'
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.npx_list.actions'),
|
||||
key: 'actions',
|
||||
width: '100px',
|
||||
render: (_, record: SearchResult) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
// 创建一个临时的 MCP 服务器对象
|
||||
const tempServer: MCPServer = {
|
||||
name: record.name,
|
||||
description: `${record.description}\n\n${t('settings.mcp.npx_list.usage')}: ${record.usage}\n${t('settings.mcp.npx_list.npm')}: ${record.npmLink}`,
|
||||
command: 'npx',
|
||||
args: ['-y', record.fullName],
|
||||
isActive: true
|
||||
}
|
||||
|
||||
// 使用 showEditModal 函数设置表单值并显示弹窗
|
||||
AddMcpServerPopup.show({ server: tempServer, create: true })
|
||||
}}>
|
||||
{t('settings.mcp.addServer')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default NpxSearch
|
||||
172
src/renderer/src/pages/settings/MCPSettings/index.tsx
Normal file
172
src/renderer/src/pages/settings/MCPSettings/index.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Button, Card, Space, Switch, Table, Tag, Tooltip, Typography } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
|
||||
import AddMcpServerPopup from './AddMcpServerPopup'
|
||||
import NpxSearch from './NpxSearch'
|
||||
|
||||
const MCPSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { Paragraph, Text } = Typography
|
||||
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
||||
|
||||
const handleDelete = (serverName: string) => {
|
||||
window.modal.confirm({
|
||||
title: t('settings.mcp.confirmDelete'),
|
||||
content: t('settings.mcp.confirmDeleteMessage'),
|
||||
okText: t('common.delete'),
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await window.api.mcp.deleteServer(serverName)
|
||||
window.message.success(t('settings.mcp.deleteSuccess'))
|
||||
} catch (error: any) {
|
||||
window.message.error(`${t('settings.mcp.deleteError')}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleActive = async (name: string, isActive: boolean) => {
|
||||
try {
|
||||
await window.api.mcp.setServerActive(name, isActive)
|
||||
} catch (error: any) {
|
||||
window.message.error(`${t('settings.mcp.toggleError')}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('settings.mcp.name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '300px',
|
||||
render: (text: string, record: MCPServer) => <Text strong={record.isActive}>{text}</Text>
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.type'),
|
||||
key: 'type',
|
||||
width: '100px',
|
||||
render: (_: any, record: MCPServer) => <Tag color="cyan">{record.baseUrl ? 'SSE' : 'STDIO'}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.description'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: 'auto',
|
||||
render: (text: string) => {
|
||||
if (!text) {
|
||||
return (
|
||||
<Text type="secondary" italic>
|
||||
{t('common.description')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Paragraph
|
||||
ellipsis={{
|
||||
rows: 1,
|
||||
expandable: 'collapsible',
|
||||
symbol: t('common.more'),
|
||||
onExpand: () => {}, // Empty callback required for proper functionality
|
||||
tooltip: true
|
||||
}}
|
||||
style={{ marginBottom: 0 }}>
|
||||
{text}
|
||||
</Paragraph>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.active'),
|
||||
dataIndex: 'isActive',
|
||||
key: 'isActive',
|
||||
width: '100px',
|
||||
render: (isActive: boolean, record: MCPServer) => (
|
||||
<Switch checked={isActive} onChange={(checked) => handleToggleActive(record.name, checked)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.actions'),
|
||||
key: 'actions',
|
||||
width: '100px',
|
||||
render: (_: any, record: MCPServer) => (
|
||||
<Space>
|
||||
<Tooltip title={t('common.edit')}>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => AddMcpServerPopup.show({ server: record })}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.name)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
// Create a CSS class for inactive rows instead of using jsx global
|
||||
const inactiveRowStyle = {
|
||||
opacity: 0.7,
|
||||
backgroundColor: theme === 'dark' ? '#1a1a1a' : '#f5f5f5'
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>
|
||||
{t('settings.mcp.title')}
|
||||
<Tooltip title={t('settings.mcp.config_description')}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 8, fontSize: 14 }} />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<Paragraph type="secondary" style={{ margin: '0 0 20px 0' }}>
|
||||
{t('settings.mcp.config_description')}
|
||||
</Paragraph>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => AddMcpServerPopup.show()}>
|
||||
{t('settings.mcp.addServer')}
|
||||
</Button>
|
||||
<Text type="secondary">
|
||||
{mcpServers.length}{' '}
|
||||
{mcpServers.length === 1 ? t('settings.mcp.serverSingular') : t('settings.mcp.serverPlural')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ background: theme === 'dark' ? '#1f1f1f' : '#fff' }}
|
||||
styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
dataSource={mcpServers}
|
||||
columns={columns}
|
||||
rowKey="name"
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('settings.mcp.noServers') }}
|
||||
rowClassName={(record) => (!record.isActive ? 'inactive-row' : '')}
|
||||
onRow={(record) => ({
|
||||
style: !record.isActive ? inactiveRowStyle : {}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
</SettingGroup>
|
||||
<NpxSearch />
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default MCPSettings
|
||||
Loading…
x
Reference in New Issue
Block a user