feat: code by huntun

This commit is contained in:
Junyan Qin
2025-08-06 21:57:43 +08:00
parent ed869f7e81
commit c0d56aa905
17 changed files with 2205 additions and 4 deletions

3
.gitignore vendored
View File

@@ -43,4 +43,5 @@ test.py
/web_ui
.venv/
uv.lock
/test
/test
plugins.bak

View File

@@ -93,7 +93,10 @@ class RouterGroup(abc.ABC):
return self.http_status(500, -2, str(e))
new_f = handler_error
new_f.__name__ = (self.name + rule).replace('/', '__')
# 为不同的HTTP方法生成不同的端点名称
methods = options.get('methods', ['GET'])
method_suffix = '_'.join(methods)
new_f.__name__ = (self.name + rule + '_' + method_suffix).replace('/', '__')
new_f.__doc__ = f.__doc__
self.quart_app.route(rule, **options)(new_f)

View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import quart
import datetime
from .. import group
@group.group_class('market', '/api/v1/market')
class MarketRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/plugins', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取插件市场列表"""
# data = await quart.request.json
# page = data.get('page', 1)
# page_size = data.get('page_size', 10)
# query = data.get('query', '')
# sort_by = data.get('sort_by', 'stars')
# sort_order = data.get('sort_order', 'DESC')
# # 这里是获取插件列表的实现
# # 实际项目中这部分会连接到真实的插件市场API或数据库
# # 这里我们只是返回一些假数据作为示例
# # 模拟延迟
# import asyncio
# await asyncio.sleep(0.5)
# 返回结果
return self.success(data={'plugins': [], 'total': 0})
@self.route('/mcp', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取MCP服务器市场列表"""
data = await quart.request.json
page = data.get('page', 1)
page_size = data.get('page_size', 10)
query = data.get('query', '')
sort_by = data.get('sort_by', 'stars')
sort_order = data.get('sort_order', 'DESC')
# 这里是获取MCP服务器列表的实现
# 实际项目中这部分会连接到真实的MCP市场API或数据库
# 这里我们只是返回一些假数据作为示例
# 模拟延迟
import asyncio
await asyncio.sleep(0.5)
# 生成假数据
servers = []
# 只在有搜索关键词或排序时才返回数据
if query or sort_by:
now = datetime.datetime.now().isoformat()
yesterday = (datetime.datetime.now() - datetime.timedelta(days=1)).isoformat()
test_servers = [
{
'ID': 1,
'CreatedAt': yesterday,
'UpdatedAt': now,
'DeletedAt': None,
'name': 'Google Maps MCP',
'author': 'langbot-community',
'description': 'Google Maps integration for LangBot, providing geocoding and directions capabilities.',
'repository': 'langbot-community/google-maps-mcp',
'artifacts_path': '',
'stars': 124,
'downloads': 342,
'status': 'initialized',
'synced_at': now,
'pushed_at': now,
'version': '1.0.0',
},
{
'ID': 2,
'CreatedAt': yesterday,
'UpdatedAt': now,
'DeletedAt': None,
'name': 'Weather MCP',
'author': 'langbot-community',
'description': 'Weather integration for LangBot, providing current weather and forecasts.',
'repository': 'langbot-community/weather-mcp',
'artifacts_path': '',
'stars': 85,
'downloads': 215,
'status': 'initialized',
'synced_at': now,
'pushed_at': yesterday,
'version': '1.1.0',
},
{
'ID': 3,
'CreatedAt': yesterday,
'UpdatedAt': now,
'DeletedAt': None,
'name': 'Serper Search MCP',
'author': 'langbot-developers',
'description': 'Serper Search integration for LangBot, providing advanced web search capabilities.',
'repository': 'langbot-developers/serper-search-mcp',
'artifacts_path': '',
'stars': 67,
'downloads': 178,
'status': 'initialized',
'synced_at': now,
'pushed_at': yesterday,
'version': '0.9.0',
},
]
# 应用搜索过滤
if query:
query = query.lower()
servers = [
s
for s in test_servers
if query in s['name'].lower()
or query in s['description'].lower()
or query in s['author'].lower()
]
else:
servers = test_servers
# 应用排序
reverse = sort_order.upper() == 'DESC'
if sort_by == 'stars':
servers = sorted(servers, key=lambda s: s['stars'], reverse=reverse)
elif sort_by == 'created_at':
servers = sorted(servers, key=lambda s: s['CreatedAt'], reverse=reverse)
elif sort_by == 'pushed_at':
servers = sorted(servers, key=lambda s: s['pushed_at'], reverse=reverse)
# 应用分页
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
servers = servers[start_idx:end_idx]
# 返回结果
return self.success(data={'servers': servers, 'total': len(servers)})

View File

@@ -0,0 +1,351 @@
from __future__ import annotations
import quart
import asyncio
from .....core import taskmgr
from .. import group
@group.group_class('mcp', '/api/v1/mcp')
class MCPRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/servers', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取MCP服务器列表"""
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
return self.success(data={'servers': []})
servers = self.ap.provider_cfg.data.get('mcp', {}).get('servers', [])
# 获取每个服务器的状态和工具信息
mcp_loader = None
for loader_name, loader in self.ap.tool_mgr.loaders.items():
if loader_name == 'mcp':
mcp_loader = loader
break
servers_with_status = []
for server in servers:
server_info = {
'name': server['name'],
'mode': server['mode'],
'enable': server['enable'],
'config': server,
'status': 'disconnected',
'tools': [],
'error': None,
}
# 检查服务器连接状态
if mcp_loader and server['name'] in mcp_loader.sessions:
session = mcp_loader.sessions[server['name']]
server_info['status'] = 'connected'
server_info['tools'] = [
{'name': func.name, 'description': func.description, 'parameters': func.parameters}
for func in session.functions
]
elif server['enable']:
server_info['status'] = 'error'
server_info['error'] = 'Failed to connect'
servers_with_status.append(server_info)
return self.success(data={'servers': servers_with_status})
@self.route('/servers', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""创建MCP服务器配置"""
data = await quart.request.json
# 验证必填字段
required_fields = ['name', 'mode']
for field in required_fields:
if field not in data:
return self.http_status(400, -1, f'Missing required field: {field}')
# 检查provider_cfg是否可用
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
return self.http_status(500, -1, 'Provider configuration not available')
# 获取当前配置
mcp_config = self.ap.provider_cfg.data.get('mcp', {'servers': []})
servers = mcp_config['servers']
# 检查服务器名称是否重复
for server in servers:
if server['name'] == data['name']:
return self.http_status(400, -1, 'Server name already exists')
# 创建新服务器配置
new_server = {
'name': data['name'],
'mode': data['mode'],
'enable': data.get('enable', True),
}
# 根据模式添加配置
if data['mode'] == 'stdio':
new_server.update(
{'command': data.get('command', ''), 'args': data.get('args', []), 'env': data.get('env', {})}
)
elif data['mode'] == 'sse':
new_server.update(
{'url': data.get('url', ''), 'headers': data.get('headers', {}), 'timeout': data.get('timeout', 10)}
)
# 添加到配置
servers.append(new_server)
self.ap.provider_cfg.data['mcp'] = mcp_config
# 保存配置
await self.ap.provider_cfg.dump_config()
# 如果启用尝试重新加载MCP loader
if new_server['enable']:
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._reload_mcp_loader(ctx),
kind='mcp-operation',
name=f'mcp-reload-{new_server["name"]}',
label=f'Reloading MCP loader for {new_server["name"]}',
context=ctx,
)
return self.success(data={'task_id': wrapper.id})
return self.success()
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""获取、更新或删除MCP服务器配置"""
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
return self.http_status(500, -1, 'Provider configuration not available')
mcp_config = self.ap.provider_cfg.data.get('mcp', {'servers': []})
servers = mcp_config['servers']
# 查找服务器
server_index = None
for i, server in enumerate(servers):
if server['name'] == server_name:
server_index = i
break
if server_index is None:
return self.http_status(404, -1, 'Server not found')
if quart.request.method == 'GET':
return self.success(data={'server': servers[server_index]})
elif quart.request.method == 'PUT':
data = await quart.request.json
server = servers[server_index]
# 更新配置
server.update(
{
'enable': data.get('enable', server.get('enable', True)),
}
)
# 根据模式更新特定配置
if server['mode'] == 'stdio':
server.update(
{
'command': data.get('command', server.get('command', '')),
'args': data.get('args', server.get('args', [])),
'env': data.get('env', server.get('env', {})),
}
)
elif server['mode'] == 'sse':
server.update(
{
'url': data.get('url', server.get('url', '')),
'headers': data.get('headers', server.get('headers', {})),
'timeout': data.get('timeout', server.get('timeout', 10)),
}
)
# 保存配置
await self.ap.provider_cfg.dump_config()
# 重新加载MCP loader
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._reload_mcp_loader(ctx),
kind='mcp-operation',
name=f'mcp-reload-{server_name}',
label=f'Reloading MCP loader for {server_name}',
context=ctx,
)
return self.success(data={'task_id': wrapper.id})
elif quart.request.method == 'DELETE':
# 删除服务器
servers.pop(server_index)
self.ap.provider_cfg.data['mcp'] = mcp_config
# 保存配置
await self.ap.provider_cfg.dump_config()
# 重新加载MCP loader
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._reload_mcp_loader(ctx),
kind='mcp-operation',
name=f'mcp-remove-{server_name}',
label=f'Removing MCP server {server_name}',
context=ctx,
)
return self.success(data={'task_id': wrapper.id})
@self.route('/servers/<server_name>/toggle', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""切换MCP服务器启用状态"""
data = await quart.request.json
target_enabled = data.get('target_enabled')
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
return self.http_status(500, -1, 'Provider configuration not available')
mcp_config = self.ap.provider_cfg.data.get('mcp', {'servers': []})
servers = mcp_config['servers']
# 查找并更新服务器
for server in servers:
if server['name'] == server_name:
server['enable'] = target_enabled
break
else:
return self.http_status(404, -1, 'Server not found')
# 保存配置
await self.ap.provider_cfg.dump_config()
# 重新加载MCP loader
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._reload_mcp_loader(ctx),
kind='mcp-operation',
name=f'mcp-toggle-{server_name}',
label=f'Toggling MCP server {server_name}',
context=ctx,
)
return self.success(data={'task_id': wrapper.id})
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""测试MCP服务器连接"""
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
return self.http_status(500, -1, 'Provider configuration not available')
mcp_config = self.ap.provider_cfg.data.get('mcp', {'servers': []})
servers = mcp_config['servers']
# 查找服务器配置
server_config = None
for server in servers:
if server['name'] == server_name:
server_config = server
break
if server_config is None:
return self.http_status(404, -1, 'Server not found')
# 创建测试任务
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._test_mcp_server(server_config, ctx),
kind='mcp-operation',
name=f'mcp-test-{server_name}',
label=f'Testing MCP server {server_name}',
context=ctx,
)
return self.success(data={'task_id': wrapper.id})
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""从GitHub安装MCP服务器"""
data = await quart.request.json
source = data.get('source')
if not source:
return self.http_status(400, -1, 'Missing source parameter')
# 创建安装任务
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._install_mcp_from_github(source, ctx),
kind='mcp-operation',
name='install-mcp-github',
label=f'Installing MCP from GitHub: {source}',
context=ctx,
)
return self.success(data={'task_id': wrapper.id})
async def _reload_mcp_loader(self, ctx: taskmgr.TaskContext):
"""重新加载MCP loader"""
try:
ctx.current_action = 'Stopping existing MCP sessions'
# 停止现有的MCP会话
mcp_loader = None
for loader_name, loader in self.ap.tool_mgr.loaders.items():
if loader_name == 'mcp':
mcp_loader = loader
break
if mcp_loader:
await mcp_loader.shutdown()
ctx.current_action = 'Reloading MCP configuration'
# 重新加载MCP loader
await self.ap.tool_mgr.reload_loader('mcp')
ctx.current_action = 'MCP loader reloaded successfully'
except Exception as e:
ctx.current_action = f'Failed to reload MCP loader: {str(e)}'
raise e
async def _test_mcp_server(self, server_config: dict, ctx: taskmgr.TaskContext):
"""测试MCP服务器连接"""
try:
from .....provider.tools.loaders.mcp import RuntimeMCPSession
ctx.current_action = f'Testing connection to {server_config["name"]}'
# 创建临时会话进行测试
session = RuntimeMCPSession(server_config['name'], server_config, self.ap)
await session.initialize()
# 获取工具列表作为测试
tools_count = len(session.functions)
ctx.current_action = f'Successfully connected. Found {tools_count} tools.'
# 关闭测试会话
await session.shutdown()
return {'status': 'success', 'tools_count': tools_count}
except Exception as e:
ctx.current_action = f'Connection test failed: {str(e)}'
raise e
async def _install_mcp_from_github(self, source: str, ctx: taskmgr.TaskContext):
"""从GitHub安装MCP服务器的实现"""
try:
ctx.current_action = f'Installing MCP server from {source}'
# 这里是安装逻辑的占位符
# 实际实现将包括克隆仓库、解析配置、安装依赖等步骤
# 模拟安装过程
await asyncio.sleep(2) # 模拟安装过程
# 返回成功结果
return {'status': 'success', 'message': f'Successfully installed MCP server from {source}'}
except Exception as e:
ctx.current_action = f'Failed to install MCP server: {str(e)}'
raise e

View File

@@ -22,6 +22,7 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_
from ..logger import EventLogger
# 语音功能相关异常定义
class VoiceConnectionError(Exception):
"""语音连接基础异常"""

View File

@@ -0,0 +1,251 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import styles from '@/app/home/plugins/plugins.module.css';
import { MCPMarketCardVO } from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO';
import MCPMarketCardComponent from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent';
import { spaceClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export default function MCPMarketComponent({
askInstallServer,
}: {
askInstallServer: (githubURL: string) => void;
}) {
const { t } = useTranslation();
const [marketServerList, setMarketServerList] = useState<MCPMarketCardVO[]>(
[],
);
const [totalCount, setTotalCount] = useState(0);
const [nowPage, setNowPage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [loading, setLoading] = useState(false);
const [sortByValue, setSortByValue] = useState<string>('pushed_at');
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
const searchTimeout = useRef<NodeJS.Timeout | null>(null);
const pageSize = 12;
useEffect(() => {
initData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function initData() {
getServerList();
}
function onInputSearchKeyword(keyword: string) {
setSearchKeyword(keyword);
// 清除之前的定时器
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
// 设置新的定时器
searchTimeout.current = setTimeout(() => {
setNowPage(1);
getServerList(1, keyword);
}, 500);
}
function getServerList(
page: number = nowPage,
keyword: string = searchKeyword,
sortBy: string = sortByValue,
sortOrder: string = sortOrderValue,
) {
setLoading(true);
spaceClient
.getMCPMarketServers(page, pageSize, keyword, sortBy, sortOrder)
.then((res) => {
setMarketServerList(
res.servers.map((marketServer) => {
let repository = marketServer.repository;
if (repository.startsWith('https://github.com/')) {
repository = repository.replace('https://github.com/', '');
}
if (repository.startsWith('github.com/')) {
repository = repository.replace('github.com/', '');
}
const author = repository.split('/')[0];
const name = repository.split('/')[1];
return new MCPMarketCardVO({
author: author,
description: marketServer.description,
githubURL: `https://github.com/${repository}`,
name: name,
serverId: String(marketServer.ID),
starCount: marketServer.stars,
version:
'version' in marketServer
? String(marketServer.version)
: '1.0.0', // 如果没有提供版本则默认为1.0.0
});
}),
);
setTotalCount(res.total);
setLoading(false);
console.log('market servers:', res);
})
.catch((error) => {
console.error(t('mcp.getServerListError'), error);
setLoading(false);
});
}
function handlePageChange(page: number) {
setNowPage(page);
getServerList(page);
}
function handleSortChange(value: string) {
const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim());
setSortByValue(newSortBy);
setSortOrderValue(newSortOrder);
setNowPage(1);
getServerList(1, searchKeyword, newSortBy, newSortOrder);
}
return (
<div className={`${styles.marketComponentBody}`}>
<div className="flex items-center justify-start mb-2 mt-2 pl-[0.8rem] pr-[0.8rem]">
<Input
style={{
width: '300px',
}}
value={searchKeyword}
placeholder={t('mcp.searchServer')}
onChange={(e) => onInputSearchKeyword(e.target.value)}
/>
<Select
value={`${sortByValue},${sortOrderValue}`}
onValueChange={handleSortChange}
>
<SelectTrigger className="w-[180px] ml-2 cursor-pointer">
<SelectValue placeholder={t('mcp.sortBy')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="stars,DESC">{t('mcp.mostStars')}</SelectItem>
<SelectItem value="created_at,DESC">
{t('mcp.recentlyAdded')}
</SelectItem>
<SelectItem value="pushed_at,DESC">
{t('mcp.recentlyUpdated')}
</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center justify-end ml-2">
{totalCount > 0 && (
<Pagination>
<PaginationContent>
<PaginationItem className="cursor-pointer">
<PaginationPrevious
onClick={() => handlePageChange(nowPage - 1)}
className={
nowPage <= 1 ? 'pointer-events-none opacity-50' : ''
}
/>
</PaginationItem>
{/* 如果总页数大于5则只显示5页如果总页数小于5则显示所有页 */}
{(() => {
const totalPages = Math.ceil(totalCount / pageSize);
const maxVisiblePages = 5;
let startPage = Math.max(
1,
nowPage - Math.floor(maxVisiblePages / 2),
);
const endPage = Math.min(
totalPages,
startPage + maxVisiblePages - 1,
);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
return Array.from(
{ length: endPage - startPage + 1 },
(_, i) => {
const pageNum = startPage + i;
return (
<PaginationItem
key={pageNum}
className="cursor-pointer"
>
<PaginationLink
isActive={pageNum === nowPage}
onClick={() => handlePageChange(pageNum)}
>
<span className="text-black select-none">
{pageNum}
</span>
</PaginationLink>
</PaginationItem>
);
},
);
})()}
<PaginationItem className="cursor-pointer">
<PaginationNext
onClick={() => handlePageChange(nowPage + 1)}
className={
nowPage >= Math.ceil(totalCount / pageSize)
? 'pointer-events-none opacity-50'
: ''
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</div>
<div className={`${styles.pluginListContainer}`}>
{loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
{t('mcp.loading')}
</div>
) : marketServerList.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
{t('mcp.noMatchingServers')}
</div>
) : (
marketServerList.map((vo, index) => (
<div key={`${vo.serverId}-${index}`}>
<MCPMarketCardComponent
cardVO={vo}
installServer={(githubURL) => {
askInstallServer(githubURL);
}}
/>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { MCPMarketCardVO } from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
export default function MCPMarketCardComponent({
cardVO,
installServer,
}: {
cardVO: MCPMarketCardVO;
installServer: (serverURL: string) => void;
}) {
const { t } = useTranslation();
function handleInstallClick(serverURL: string) {
installServer(serverURL);
}
return (
<div className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem]">
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<svg
className="w-16 h-16 text-[#2288ee]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13.5 2C13.5 2.82843 14.1716 3.5 15 3.5C15.8284 3.5 16.5 2.82843 16.5 2C16.5 1.17157 15.8284 0.5 15 0.5C14.1716 0.5 13.5 1.17157 13.5 2ZM8.5 8C8.5 8.82843 9.17157 9.5 10 9.5C10.8284 9.5 11.5 8.82843 11.5 8C11.5 7.17157 10.8284 6.5 10 6.5C9.17157 6.5 8.5 7.17157 8.5 8ZM1.5 14C1.5 14.8284 2.17157 15.5 3 15.5C3.82843 15.5 4.5 14.8284 4.5 14C4.5 13.1716 3.82843 12.5 3 12.5C2.17157 12.5 1.5 13.1716 1.5 14ZM19.5 14C19.5 14.8284 20.1716 15.5 21 15.5C21.8284 15.5 22.5 14.8284 22.5 14C22.5 13.1716 21.8284 12.5 21 12.5C20.1716 12.5 19.5 13.1716 19.5 14ZM8.5 20C8.5 20.8284 9.17157 21.5 10 21.5C10.8284 21.5 11.5 20.8284 11.5 20C11.5 19.1716 10.8284 19 10 19C9.17157 19 8.5 19.1716 8.5 20ZM2.5 8L6.5 8L6.5 10L2.5 10L2.5 8ZM13.5 8L17.5 8L17.5 10L13.5 10L13.5 8ZM8.5 2L8.5 6L10.5 6L10.5 2L8.5 2ZM8.5 14L8.5 18L10.5 18L10.5 14L8.5 14ZM2.5 14L6.5 14L6.5 16L2.5 16L2.5 14ZM13.5 14L17.5 14L17.5 16L13.5 16L13.5 14Z"></path>
</svg>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-col items-start justify-start">
<div className="text-[0.7rem] text-[#666]">
{cardVO.author} /{' '}
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
<div className="text-[1.2rem] text-black">{cardVO.name}</div>
</div>
</div>
<div className="text-[0.8rem] text-[#666] line-clamp-2">
{cardVO.description}
</div>
</div>
<div className="w-full flex flex-row items-start justify-between gap-[0.6rem]">
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<svg
className="w-[1.2rem] h-[1.2rem] text-[#ffcd27]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z"></path>
</svg>
<div className="text-base text-[#ffcd27] font-medium">
{t('mcp.starCount', { count: cardVO.starCount })}
</div>
</div>
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<svg
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
onClick={() => window.open(cardVO.githubURL, '_blank')}
>
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
</svg>
<Button
variant="default"
size="sm"
onClick={() => {
handleInstallClick(cardVO.githubURL);
}}
className="cursor-pointer"
>
{t('mcp.install')}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
export interface IMCPMarketCardVO {
serverId: string;
author: string;
name: string;
description: string;
starCount: number;
githubURL: string;
version: string;
}
export class MCPMarketCardVO implements IMCPMarketCardVO {
serverId: string;
description: string;
name: string;
author: string;
githubURL: string;
starCount: number;
version: string;
constructor(prop: IMCPMarketCardVO) {
this.description = prop.description;
this.name = prop.name;
this.author = prop.author;
this.githubURL = prop.githubURL;
this.starCount = prop.starCount;
this.serverId = prop.serverId;
this.version = prop.version;
}
}

View File

@@ -0,0 +1,47 @@
import { MCPServer, MCPServerConfig } from '@/app/infra/entities/api';
export class MCPCardVO {
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
status: 'connected' | 'disconnected' | 'error';
tools: number;
error?: string;
config: MCPServerConfig;
constructor(data: MCPServer) {
this.name = data.name;
this.mode = data.mode;
this.enable = data.enable;
this.status = data.status;
this.tools = data.tools.length;
this.error = data.error;
this.config = data.config;
}
getStatusColor(): string {
switch (this.status) {
case 'connected':
return 'text-green-600';
case 'disconnected':
return 'text-gray-500';
case 'error':
return 'text-red-600';
default:
return 'text-gray-500';
}
}
getStatusIcon(): string {
switch (this.status) {
case 'connected':
return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z';
case 'disconnected':
return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
case 'error':
return 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';
default:
return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
}
}
}

View File

@@ -0,0 +1,217 @@
'use client';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO';
import MCPCardComponent from '@/app/home/plugins/mcp/mcp-card/MCPCardComponent';
import MCPForm from '@/app/home/plugins/mcp/mcp-form/MCPForm';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
export interface MCPComponentRef {
refreshServerList: () => void;
createServer: () => void;
}
// eslint-disable-next-line react/display-name
const MCPComponent = forwardRef<MCPComponentRef>((props, ref) => {
const { t } = useTranslation();
const [serverList, setServerList] = useState<MCPCardVO[]>([]);
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [selectedServer, setSelectedServer] = useState<MCPCardVO | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState<boolean>(false);
const [serverToDelete, setServerToDelete] = useState<MCPCardVO | null>(null);
const [deleting, setDeleting] = useState<boolean>(false);
useEffect(() => {
initData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function initData() {
getServerList();
}
function getServerList() {
httpClient
.getMCPServers()
.then((value) => {
setServerList(value.servers.map((server) => new MCPCardVO(server)));
})
.catch((error) => {
toast.error(t('mcp.getServerListError') + error.message);
});
}
useImperativeHandle(ref, () => ({
refreshServerList: getServerList,
createServer: () => {
setSelectedServer(null);
setModalOpen(true);
},
}));
function handleServerClick(server: MCPCardVO) {
setSelectedServer(server);
setModalOpen(true);
}
function handleDeleteClick(server: MCPCardVO, e: React.MouseEvent) {
e.stopPropagation();
setServerToDelete(server);
setDeleteDialogOpen(true);
}
async function confirmDelete() {
if (!serverToDelete) return;
setDeleting(true);
try {
const response = await httpClient.deleteMCPServer(serverToDelete.name);
const taskId = response.task_id;
// 监控任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
setDeleting(false);
setDeleteDialogOpen(false);
if (taskResp.runtime.exception) {
toast.error(t('mcp.deleteError') + taskResp.runtime.exception);
} else {
toast.success(t('mcp.deleteSuccess'));
getServerList();
}
}
});
}, 1000);
} catch (error: unknown) {
setDeleting(false);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t('mcp.deleteError') + errorMessage);
}
}
return (
<>
{serverList.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13.5 2C13.5 2.82843 14.1716 3.5 15 3.5C15.8284 3.5 16.5 2.82843 16.5 2C16.5 1.17157 15.8284 0.5 15 0.5C14.1716 0.5 13.5 1.17157 13.5 2ZM8.5 8C8.5 8.82843 9.17157 9.5 10 9.5C10.8284 9.5 11.5 8.82843 11.5 8C11.5 7.17157 10.8284 6.5 10 6.5C9.17157 6.5 8.5 7.17157 8.5 8ZM1.5 14C1.5 14.8284 2.17157 15.5 3 15.5C3.82843 15.5 4.5 14.8284 4.5 14C4.5 13.1716 3.82843 12.5 3 12.5C2.17157 12.5 1.5 13.1716 1.5 14ZM19.5 14C19.5 14.8284 20.1716 15.5 21 15.5C21.8284 15.5 22.5 14.8284 22.5 14C22.5 13.1716 21.8284 12.5 21 12.5C20.1716 12.5 19.5 13.1716 19.5 14ZM8.5 20C8.5 20.8284 9.17157 21.5 10 21.5C10.8284 21.5 11.5 20.8284 11.5 20C11.5 19.1716 10.8284 19 10 19C9.17157 19 8.5 19.1716 8.5 20ZM2.5 8L6.5 8L6.5 10L2.5 10L2.5 8ZM13.5 8L17.5 8L17.5 10L13.5 10L13.5 8ZM8.5 2L8.5 6L10.5 6L10.5 2L8.5 2ZM8.5 14L8.5 18L10.5 18L10.5 14L8.5 14ZM2.5 14L6.5 14L6.5 16L2.5 16L2.5 14ZM13.5 14L17.5 14L17.5 16L13.5 16L13.5 14Z"></path>
</svg>
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
</div>
) : (
<div className={`${styles.pluginListContainer}`}>
{serverList.map((vo, index) => {
return (
<div key={index} className="relative group">
<MCPCardComponent
cardVO={vo}
onCardClick={() => handleServerClick(vo)}
onRefresh={getServerList}
/>
{/* 删除按钮 */}
<button
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-red-500 hover:bg-red-600 text-white rounded-full p-1"
onClick={(e) => handleDeleteClick(vo, e)}
>
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
);
})}
</div>
)}
{/* 编辑配置对话框 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle>
{selectedServer ? t('mcp.editServer') : t('mcp.createServer')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<MCPForm
serverName={selectedServer?.name}
isEdit={!!selectedServer}
onFormSubmit={() => {
setModalOpen(false);
getServerList();
}}
onFormCancel={() => {
setModalOpen(false);
}}
/>
</div>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('mcp.deleteServer')}</AlertDialogTitle>
<AlertDialogDescription>
{t('mcp.confirmDeleteServer', { name: serverToDelete?.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={deleting}
className="bg-red-600 hover:bg-red-700"
>
{deleting ? t('plugins.deleting') : t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
});
export default MCPComponent;

View File

@@ -0,0 +1,196 @@
import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO';
import { useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
export default function MCPCardComponent({
cardVO,
onCardClick,
onRefresh,
}: {
cardVO: MCPCardVO;
onCardClick: () => void;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(cardVO.enable);
const [switchEnable, setSwitchEnable] = useState(true);
const [testing, setTesting] = useState(false);
function handleEnable(e: React.MouseEvent) {
e.stopPropagation(); // 阻止事件冒泡
setSwitchEnable(false);
httpClient
.toggleMCPServer(cardVO.name, !enabled)
.then((resp) => {
const taskId = resp.task_id;
// 监控任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
if (taskResp.runtime.exception) {
toast.error(t('mcp.modifyFailed') + taskResp.runtime.exception);
} else {
setEnabled(!enabled);
toast.success(t('mcp.saveSuccess'));
onRefresh();
}
setSwitchEnable(true);
}
});
}, 1000);
})
.catch((err) => {
toast.error(t('mcp.modifyFailed') + err.message);
setSwitchEnable(true);
});
}
function handleTest(e: React.MouseEvent) {
e.stopPropagation(); // 阻止事件冒泡
setTesting(true);
httpClient
.testMCPServer(cardVO.name)
.then((resp) => {
const taskId = resp.task_id;
// 监控任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
if (taskResp.runtime.exception) {
toast.error(t('mcp.testFailed') + taskResp.runtime.exception);
} else {
toast.success(t('mcp.testSuccess'));
onRefresh();
}
setTesting(false);
}
});
}, 1000);
})
.catch((err) => {
toast.error(t('mcp.testFailed') + err.message);
setTesting(false);
});
}
return (
<div
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer"
onClick={onCardClick}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<svg
className="w-16 h-16 text-[#2288ee]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13.5 2C13.5 2.82843 14.1716 3.5 15 3.5C15.8284 3.5 16.5 2.82843 16.5 2C16.5 1.17157 15.8284 0.5 15 0.5C14.1716 0.5 13.5 1.17157 13.5 2ZM8.5 8C8.5 8.82843 9.17157 9.5 10 9.5C10.8284 9.5 11.5 8.82843 11.5 8C11.5 7.17157 10.8284 6.5 10 6.5C9.17157 6.5 8.5 7.17157 8.5 8ZM1.5 14C1.5 14.8284 2.17157 15.5 3 15.5C3.82843 15.5 4.5 14.8284 4.5 14C4.5 13.1716 3.82843 12.5 3 12.5C2.17157 12.5 1.5 13.1716 1.5 14ZM19.5 14C19.5 14.8284 20.1716 15.5 21 15.5C21.8284 15.5 22.5 14.8284 22.5 14C22.5 13.1716 21.8284 12.5 21 12.5C20.1716 12.5 19.5 13.1716 19.5 14ZM8.5 20C8.5 20.8284 9.17157 21.5 10 21.5C10.8284 21.5 11.5 20.8284 11.5 20C11.5 19.1716 10.8284 19 10 19C9.17157 19 8.5 19.1716 8.5 20ZM2.5 8L6.5 8L6.5 10L2.5 10L2.5 8ZM13.5 8L17.5 8L17.5 10L13.5 10L13.5 8ZM8.5 2L8.5 6L10.5 6L10.5 2L8.5 2ZM8.5 14L8.5 18L10.5 18L10.5 14L8.5 14ZM2.5 14L6.5 14L6.5 16L2.5 16L2.5 14ZM13.5 14L17.5 14L17.5 16L13.5 16L13.5 14Z"></path>
</svg>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
<div className="text-[1.2rem] text-black">{cardVO.name}</div>
<Badge variant="outline" className="text-[0.7rem]">
{cardVO.mode.toUpperCase()}
</Badge>
</div>
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] mt-1">
<svg
className={`w-4 h-4 ${cardVO.getStatusColor()}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={cardVO.getStatusIcon()}
/>
</svg>
<div className={`text-[0.8rem] ${cardVO.getStatusColor()}`}>
{cardVO.status === 'connected' && t('mcp.statusConnected')}
{cardVO.status === 'disconnected' &&
t('mcp.statusDisconnected')}
{cardVO.status === 'error' && t('mcp.statusError')}
</div>
</div>
{cardVO.error && (
<div className="text-[0.7rem] text-red-500 line-clamp-2 mt-1">
{cardVO.error}
</div>
)}
</div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<svg
className="w-[1.2rem] h-[1.2rem] text-black"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.32943 3.27158C6.56252 2.8332 7.9923 3.10749 8.97927 4.09446C10.1002 5.21537 10.3019 6.90741 9.5843 8.23385L20.293 18.9437L18.8788 20.3579L8.16982 9.64875C6.84325 10.3669 5.15069 10.1654 4.02952 9.04421C3.04227 8.05696 2.7681 6.62665 3.20701 5.39332L5.44373 7.63C6.02952 8.21578 6.97927 8.21578 7.56505 7.63C8.15084 7.04421 8.15084 6.09446 7.56505 5.50868L5.32943 3.27158ZM15.6968 5.15512L18.8788 3.38736L20.293 4.80157L18.5252 7.98355L16.7574 8.3371L14.6361 10.4584L13.2219 9.04421L15.3432 6.92289L15.6968 5.15512ZM8.97927 13.2868L10.3935 14.7011L5.09018 20.0044C4.69966 20.3949 4.06649 20.3949 3.67597 20.0044C3.31334 19.6417 3.28744 19.0699 3.59826 18.6774L3.67597 18.5902L8.97927 13.2868Z" />
</svg>
<div className="text-base text-black font-medium">
{t('mcp.toolCount', { count: cardVO.tools })}
</div>
</div>
</div>
</div>
<div className="flex flex-col items-center justify-between h-full">
<div className="flex items-center justify-center">
<Switch
className="cursor-pointer"
checked={enabled}
onClick={(e) => handleEnable(e)}
disabled={!switchEnable}
/>
</div>
<div className="flex items-center justify-center gap-[0.4rem]">
<Button
variant="ghost"
size="sm"
className="p-1 h-8 w-8"
onClick={(e) => handleTest(e)}
disabled={testing}
>
<svg
className={`w-4 h-4 text-gray-600 ${
testing ? 'animate-spin' : ''
}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,409 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { httpClient } from '@/app/infra/http/HttpClient';
import { MCPServerConfig } from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { PlusIcon, TrashIcon } from 'lucide-react';
interface MCPFormProps {
serverName?: string;
isEdit?: boolean;
onFormSubmit: () => void;
onFormCancel: () => void;
}
export default function MCPForm({
serverName,
isEdit = false,
onFormSubmit,
onFormCancel,
}: MCPFormProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<MCPServerConfig>({
name: '',
mode: 'stdio',
enable: true,
command: '',
args: [],
env: {},
url: '',
headers: {},
timeout: 10,
});
useEffect(() => {
if (isEdit && serverName) {
loadServerConfig();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, serverName]);
async function loadServerConfig() {
try {
const response = await httpClient.getMCPServer(serverName!);
setFormData(response.server.config);
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t('mcp.getServerListError') + errorMessage);
}
}
function handleInputChange(field: keyof MCPServerConfig, value: unknown) {
setFormData((prev) => ({
...prev,
[field]: value,
}));
}
function addArrayItem(field: 'args', value: string = '') {
const currentArray = formData[field] as string[];
handleInputChange(field, [...currentArray, value]);
}
function updateArrayItem(field: 'args', index: number, value: string) {
const currentArray = formData[field] as string[];
const newArray = [...currentArray];
newArray[index] = value;
handleInputChange(field, newArray);
}
function removeArrayItem(field: 'args', index: number) {
const currentArray = formData[field] as string[];
const newArray = currentArray.filter((_, i) => i !== index);
handleInputChange(field, newArray);
}
function addObjectItem(
field: 'env' | 'headers',
key: string = '',
value: string = '',
) {
const currentObj = formData[field] as Record<string, string>;
handleInputChange(field, {
...currentObj,
[key]: value,
});
}
function updateObjectItem(
field: 'env' | 'headers',
oldKey: string,
newKey: string,
value: string,
) {
const currentObj = formData[field] as Record<string, string>;
const newObj = { ...currentObj };
if (oldKey !== newKey) {
delete newObj[oldKey];
}
newObj[newKey] = value;
handleInputChange(field, newObj);
}
function removeObjectItem(field: 'env' | 'headers', key: string) {
const currentObj = formData[field] as Record<string, string>;
const newObj = { ...currentObj };
delete newObj[key];
handleInputChange(field, newObj);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// 验证表单
if (!formData.name.trim()) {
toast.error(t('mcp.serverNameRequired'));
return;
}
if (formData.mode === 'stdio' && !formData.command?.trim()) {
toast.error(t('mcp.commandRequired'));
return;
}
if (formData.mode === 'sse' && !formData.url?.trim()) {
toast.error(t('mcp.urlRequired'));
return;
}
setLoading(true);
try {
let taskId: number;
if (isEdit) {
const response = await httpClient.updateMCPServer(
serverName!,
formData,
);
taskId = response.task_id;
} else {
const response = await httpClient.createMCPServer(formData);
taskId = response.task_id;
}
// 监控任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
setLoading(false);
if (taskResp.runtime.exception) {
toast.error(
(isEdit ? t('mcp.saveError') : t('mcp.createError')) +
taskResp.runtime.exception,
);
} else {
toast.success(
isEdit ? t('mcp.saveSuccess') : t('mcp.createSuccess'),
);
onFormSubmit();
}
}
});
}, 1000);
} catch (error: unknown) {
setLoading(false);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
(isEdit ? t('mcp.saveError') : t('mcp.createError')) + errorMessage,
);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* 基础配置 */}
<div className="space-y-4">
<div>
<Label htmlFor="name">{t('mcp.serverName')}</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
disabled={isEdit}
placeholder={t('mcp.serverName')}
/>
</div>
<div>
<Label htmlFor="enable">{t('common.enable')}</Label>
<div className="flex items-center space-x-2 mt-2">
<Switch
id="enable"
checked={formData.enable}
onCheckedChange={(checked) =>
handleInputChange('enable', checked)
}
/>
</div>
</div>
<div>
<Label>{t('mcp.serverMode')}</Label>
<Tabs
value={formData.mode}
onValueChange={(value) =>
handleInputChange('mode', value as 'stdio' | 'sse')
}
className="mt-2"
>
<TabsList>
<TabsTrigger value="stdio">{t('mcp.stdio')}</TabsTrigger>
<TabsTrigger value="sse">{t('mcp.sse')}</TabsTrigger>
</TabsList>
<TabsContent value="stdio" className="space-y-4 mt-4">
<div>
<Label htmlFor="command">{t('mcp.command')}</Label>
<Input
id="command"
value={formData.command || ''}
onChange={(e) => handleInputChange('command', e.target.value)}
placeholder="python -m your_mcp_server"
/>
</div>
<div>
<Label>{t('mcp.args')}</Label>
<div className="space-y-2 mt-2">
{(formData.args || []).map((arg, index) => (
<div key={index} className="flex items-center space-x-2">
<Input
value={arg}
onChange={(e) =>
updateArrayItem('args', index, e.target.value)
}
placeholder="参数"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeArrayItem('args', index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addArrayItem('args')}
>
<PlusIcon className="h-4 w-4 mr-2" />
{t('mcp.addArgument')}
</Button>
</div>
</div>
<div>
<Label>{t('mcp.env')}</Label>
<div className="space-y-2 mt-2">
{Object.entries(formData.env || {}).map(([key, value]) => (
<div key={key} className="flex items-center space-x-2">
<Input
value={key}
onChange={(e) =>
updateObjectItem('env', key, e.target.value, value)
}
placeholder={t('mcp.keyName')}
className="flex-1"
/>
<Input
value={value}
onChange={(e) =>
updateObjectItem('env', key, key, e.target.value)
}
placeholder={t('mcp.value')}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeObjectItem('env', key)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addObjectItem('env')}
>
<PlusIcon className="h-4 w-4 mr-2" />
{t('mcp.addEnvVar')}
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="sse" className="space-y-4 mt-4">
<div>
<Label htmlFor="url">{t('mcp.url')}</Label>
<Input
id="url"
value={formData.url || ''}
onChange={(e) => handleInputChange('url', e.target.value)}
placeholder="http://localhost:3000/sse"
/>
</div>
<div>
<Label htmlFor="timeout">{t('mcp.timeout')}</Label>
<Input
id="timeout"
type="number"
value={formData.timeout || 10}
onChange={(e) =>
handleInputChange('timeout', parseInt(e.target.value) || 10)
}
placeholder="10"
/>
</div>
<div>
<Label>{t('mcp.headers')}</Label>
<div className="space-y-2 mt-2">
{Object.entries(formData.headers || {}).map(
([key, value]) => (
<div key={key} className="flex items-center space-x-2">
<Input
value={key}
onChange={(e) =>
updateObjectItem(
'headers',
key,
e.target.value,
value,
)
}
placeholder={t('mcp.keyName')}
className="flex-1"
/>
<Input
value={value}
onChange={(e) =>
updateObjectItem(
'headers',
key,
key,
e.target.value,
)
}
placeholder={t('mcp.value')}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeObjectItem('headers', key)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
),
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addObjectItem('headers')}
>
<PlusIcon className="h-4 w-4 mr-2" />
{t('mcp.addHeader')}
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={loading}>
{loading ? t('common.saving') : t('common.save')}
</Button>
</div>
</form>
);
}

View File

@@ -4,6 +4,11 @@ import PluginInstalledComponent, {
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent';
import MarketPage from '@/app/home/plugins/plugin-market/PluginMarketComponent';
// import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
import PluginMarketComponent from '@/app/home/plugins/plugin-market/PluginMarketComponent';
import MCPComponent, {
MCPComponentRef,
} from '@/app/home/plugins/mcp/MCPComponent';
import MCPMarketComponent from '@/app/home/plugins/mcp-market/MCPMarketComponent';
import styles from './plugins.module.css';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
@@ -46,20 +51,27 @@ enum PluginInstallStatus {
export default function PluginConfigPage() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('installed');
const [modalOpen, setModalOpen] = useState(false);
// const [sortModalOpen, setSortModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('installed');
const [installSource, setInstallSource] = useState<string>('local');
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
const [sortModalOpen, setSortModalOpen] = useState(false);
// const [mcpModalOpen, setMcpModalOpen] = useState(false);
const [mcpMarketInstallModalOpen, setMcpMarketInstallModalOpen] =
useState(false);
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [mcpInstallStatus, setMcpInstallStatus] = useState<PluginInstallStatus>(
PluginInstallStatus.WAIT_INPUT,
);
const [installError, setInstallError] = useState<string | null>(null);
const [mcpInstallError, setMcpInstallError] = useState<string | null>(null);
const [githubURL, setGithubURL] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -106,11 +118,17 @@ export default function PluginConfigPage() {
});
}, 1000);
}
const [mcpGithubURL, setMcpGithubURL] = useState('');
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const mcpComponentRef = useRef<MCPComponentRef>(null);
function handleModalConfirm() {
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
}
function handleMcpModalConfirm() {
installMcpServer(mcpGithubURL);
}
function installPlugin(
installSource: string,
installInfo: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -291,6 +309,46 @@ export default function PluginConfigPage() {
return renderPluginConnectionErrorState();
}
function installMcpServer(url: string) {
setMcpInstallStatus(PluginInstallStatus.INSTALLING);
httpClient
.installMCPServerFromGithub(url)
.then((resp) => {
const taskId = resp.task_id;
let alreadySuccess = false;
console.log('taskId:', taskId);
// 每秒拉取一次任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((resp) => {
console.log('task status:', resp);
if (resp.runtime.done) {
clearInterval(interval);
if (resp.runtime.exception) {
setMcpInstallError(resp.runtime.exception);
setMcpInstallStatus(PluginInstallStatus.ERROR);
} else {
// success
if (!alreadySuccess) {
toast.success(t('mcp.installSuccess'));
alreadySuccess = true;
}
setMcpGithubURL('');
setMcpMarketInstallModalOpen(false);
mcpComponentRef.current?.refreshServerList();
}
}
});
}, 1000);
})
.catch((err) => {
console.log('error when install mcp server:', err);
setMcpInstallError(err.message);
setMcpInstallStatus(PluginInstallStatus.ERROR);
});
}
return (
<div
className={`${styles.pageContainer} ${isDragOver ? 'bg-blue-50' : ''}`}
@@ -316,6 +374,9 @@ export default function PluginConfigPage() {
{t('plugins.marketplace')}
</TabsTrigger>
)}
<TabsTrigger value="mcp" className="px-6 py-4 cursor-pointer">
MCP
</TabsTrigger>
</TabsList>
<div className="flex flex-row justify-end items-center">
@@ -372,6 +433,19 @@ export default function PluginConfigPage() {
}}
/>
</TabsContent>
<TabsContent value="mcp">
<MCPComponent ref={mcpComponentRef} />
</TabsContent>
<TabsContent value="mcp-market">
<MCPMarketComponent
askInstallServer={(githubURL) => {
setMcpGithubURL(githubURL);
setMcpMarketInstallModalOpen(true);
setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT);
setMcpInstallError(null);
}}
/>
</TabsContent>
</Tabs>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
@@ -456,6 +530,66 @@ export default function PluginConfigPage() {
pluginInstalledRef.current?.refreshPluginList();
}}
/> */}
{/* MCP Server 安装对话框 */}
{/* <Dialog
open={mcpMarketInstallModalOpen}
onOpenChange={setMcpMarketInstallModalOpen}
>
<DialogContent className="w-[500px] p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<GithubIcon className="size-6" />
<span>{t('mcp.installFromGithub')}</span>
</DialogTitle>
</DialogHeader>
{mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div className="mt-4">
<p className="mb-2">{t('mcp.onlySupportGithub')}</p>
<Input
placeholder={t('mcp.enterGithubLink')}
value={mcpGithubURL}
onChange={(e) => setMcpGithubURL(e.target.value)}
className="mb-4"
/>
</div>
)}
{mcpInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4">
<p className="mb-2">{t('mcp.installing')}</p>
</div>
)}
{mcpInstallStatus === PluginInstallStatus.ERROR && (
<div className="mt-4">
<p className="mb-2">{t('mcp.installFailed')}</p>
<p className="mb-2 text-red-500">{mcpInstallError}</p>
</div>
)}
<DialogFooter>
{mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<>
<Button
variant="outline"
onClick={() => setMcpMarketInstallModalOpen(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleMcpModalConfirm}>
{t('common.confirm')}
</Button>
</>
)}
{mcpInstallStatus === PluginInstallStatus.ERROR && (
<Button
variant="default"
onClick={() => setMcpMarketInstallModalOpen(false)}
>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog> */}
</div>
);
}

View File

@@ -308,3 +308,66 @@ export interface RetrieveResult {
export interface ApiRespKnowledgeBaseRetrieve {
results: RetrieveResult[];
}
// MCP
export interface ApiRespMCPServers {
servers: MCPServer[];
}
export interface ApiRespMCPServer {
server: MCPServer;
}
export interface MCPServer {
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
config: MCPServerConfig;
status: 'connected' | 'disconnected' | 'error';
tools: MCPTool[];
error?: string;
}
export interface MCPServerConfig {
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
// stdio mode
command?: string;
args?: string[];
env?: Record<string, string>;
// sse mode
url?: string;
headers?: Record<string, string>;
timeout?: number;
}
export interface MCPTool {
name: string;
description: string;
parameters: object;
}
// MCP Market
export interface MCPMarketResponse {
servers: MCPMarketServer[];
total: number;
}
export interface MCPMarketServer {
ID: number;
CreatedAt: string; // ISO 8601 格式日期
UpdatedAt: string;
DeletedAt: string | null;
name: string;
author: string;
description: string;
repository: string; // GitHub 仓库路径
artifacts_path: string;
stars: number;
downloads: number;
status: 'initialized' | 'mounted';
synced_at: string;
pushed_at: string; // 最后一次代码推送时间
version?: string;
}

View File

@@ -33,6 +33,9 @@ import {
ApiRespProviderEmbeddingModel,
EmbeddingModel,
ApiRespPluginSystemStatus,
ApiRespMCPServers,
ApiRespMCPServer,
MCPServerConfig,
} from '@/app/infra/entities/api';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -488,6 +491,67 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/plugins/${author}/${name}/upgrade`);
}
// ============ MCP API ============
public getMCPServers(): Promise<ApiRespMCPServers> {
return this.get('/api/v1/mcp/servers');
}
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
return this.get(`/api/v1/mcp/servers/${serverName}`);
}
public createMCPServer(
server: MCPServerConfig,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/servers', server);
}
public updateMCPServer(
serverName: string,
server: Partial<MCPServerConfig>,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, server);
}
public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
return this.delete(`/api/v1/mcp/servers/${serverName}`);
}
public toggleMCPServer(
serverName: string,
target_enabled: boolean,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}/toggle`, {
target_enabled,
});
}
public testMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
return this.post(`/api/v1/mcp/servers/${serverName}/test`);
}
// public getMCPMarketServers(
// page: number,
// page_size: number,
// query: string,
// sort_by: string = 'stars',
// sort_order: string = 'DESC',
// ): Promise<MCPMarketResponse> {
// return this.post(`/api/v1/market/mcp`, {
// page,
// page_size,
// query,
// sort_by,
// sort_order,
// });
// }
public installMCPServerFromGithub(
source: string,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/install/github', { source });
}
// ============ System API ============
public getSystemInfo(): Promise<ApiRespSystemInfo> {
return this.get('/api/v1/system/info');

View File

@@ -0,0 +1,141 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -270,6 +270,70 @@ const zhHans = {
markAsReadSuccess: '已标记为已读',
markAsReadFailed: '标记为已读失败',
},
mcp: {
title: 'MCP管理',
description: '管理Model Context Protocol (MCP) 服务器扩展AI能力',
createServer: '创建MCP服务器',
editServer: '编辑MCP服务器',
deleteServer: '删除MCP服务器',
getServerListError: '获取MCP服务器列表失败',
serverName: '服务器名称',
serverMode: '连接模式',
stdio: 'Stdio模式',
sse: 'SSE模式',
serverConfig: 'MCP服务器配置',
noServerInstalled: '暂未配置任何MCP服务器',
serverNameRequired: '服务器名称不能为空',
commandRequired: '命令不能为空',
urlRequired: 'URL不能为空',
command: '命令',
args: '参数',
env: '环境变量',
url: 'URL地址',
headers: '请求头',
timeout: '超时时间',
addArgument: '添加参数',
addEnvVar: '添加环境变量',
addHeader: '添加请求头',
keyName: '键名',
value: '值',
connected: '已连接',
disconnected: '未连接',
error: '错误',
testConnection: '测试连接',
testing: '测试中...',
testSuccess: '连接测试成功',
testFailed: '连接测试失败:',
confirmDeleteServer: '你确定要删除MCP服务器{{name}})吗?',
deleteSuccess: '删除成功',
deleteError: '删除失败:',
saveSuccess: '保存成功',
saveError: '保存失败:',
createSuccess: '创建成功',
createError: '创建失败:',
modifyFailed: '修改失败:',
toolCount: '工具:{{count}}',
statusConnected: '已连接',
statusDisconnected: '未连接',
statusError: '连接错误',
serverStatus: '服务器状态',
marketplace: 'MCP商店',
searchServer: '搜索MCP服务器',
sortBy: '排序方式',
mostStars: '最多星标',
recentlyAdded: '最近添加',
recentlyUpdated: '最近更新',
loading: '加载中...',
noMatchingServers: '没有匹配的MCP服务器',
starCount: '星标:{{count}}',
install: '安装',
installing: '安装中...',
installSuccess: 'MCP服务器安装成功',
installFailed: 'MCP服务器安装失败',
installFromGithub: '从Github安装MCP服务器',
onlySupportGithub: '目前仅支持从Github安装MCP服务器',
enterGithubLink: '输入Github仓库链接',
},
pipelines: {
title: '流水线',
description: '流水线定义了对消息事件的处理流程,用于绑定到机器人',