From c0d56aa90545ef57e33e6dc1fffb89386a6d6d93 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 6 Aug 2025 21:57:43 +0800 Subject: [PATCH] feat: code by huntun --- .gitignore | 3 +- pkg/api/http/controller/group.py | 5 +- pkg/api/http/controller/groups/market.py | 143 ++++++ pkg/api/http/controller/groups/mcp.py | 351 +++++++++++++++ pkg/platform/sources/discord.py | 1 + .../plugins/mcp-market/MCPMarketComponent.tsx | 251 +++++++++++ .../MCPMarketCardComponent.tsx | 87 ++++ .../mcp-market-card/MCPMarketCardVO.ts | 29 ++ web/src/app/home/plugins/mcp/MCPCardVO.ts | 47 ++ web/src/app/home/plugins/mcp/MCPComponent.tsx | 217 ++++++++++ .../plugins/mcp/mcp-card/MCPCardComponent.tsx | 196 +++++++++ .../app/home/plugins/mcp/mcp-form/MCPForm.tsx | 409 ++++++++++++++++++ web/src/app/home/plugins/page.tsx | 138 +++++- web/src/app/infra/entities/api/index.ts | 63 +++ web/src/app/infra/http/BackendClient.ts | 64 +++ web/src/components/ui/alert-dialog.tsx | 141 ++++++ web/src/i18n/locales/zh-Hans.ts | 64 +++ 17 files changed, 2205 insertions(+), 4 deletions(-) create mode 100644 pkg/api/http/controller/groups/market.py create mode 100644 pkg/api/http/controller/groups/mcp.py create mode 100644 web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx create mode 100644 web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent.tsx create mode 100644 web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO.ts create mode 100644 web/src/app/home/plugins/mcp/MCPCardVO.ts create mode 100644 web/src/app/home/plugins/mcp/MCPComponent.tsx create mode 100644 web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx create mode 100644 web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx create mode 100644 web/src/components/ui/alert-dialog.tsx diff --git a/.gitignore b/.gitignore index db62bdca..3bceec2f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ test.py /web_ui .venv/ uv.lock -/test \ No newline at end of file +/test +plugins.bak \ No newline at end of file diff --git a/pkg/api/http/controller/group.py b/pkg/api/http/controller/group.py index 8ab4f4d9..dfa03d6d 100644 --- a/pkg/api/http/controller/group.py +++ b/pkg/api/http/controller/group.py @@ -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) diff --git a/pkg/api/http/controller/groups/market.py b/pkg/api/http/controller/groups/market.py new file mode 100644 index 00000000..ed909ef2 --- /dev/null +++ b/pkg/api/http/controller/groups/market.py @@ -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)}) diff --git a/pkg/api/http/controller/groups/mcp.py b/pkg/api/http/controller/groups/mcp.py new file mode 100644 index 00000000..21fcb530 --- /dev/null +++ b/pkg/api/http/controller/groups/mcp.py @@ -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/', 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//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//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 diff --git a/pkg/platform/sources/discord.py b/pkg/platform/sources/discord.py index 933961de..98791260 100644 --- a/pkg/platform/sources/discord.py +++ b/pkg/platform/sources/discord.py @@ -22,6 +22,7 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_ from ..logger import EventLogger + # 语音功能相关异常定义 class VoiceConnectionError(Exception): """语音连接基础异常""" diff --git a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx new file mode 100644 index 00000000..7cb90d76 --- /dev/null +++ b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx @@ -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( + [], + ); + const [totalCount, setTotalCount] = useState(0); + const [nowPage, setNowPage] = useState(1); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + const [sortByValue, setSortByValue] = useState('pushed_at'); + const [sortOrderValue, setSortOrderValue] = useState('DESC'); + const searchTimeout = useRef(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 ( +
+
+ onInputSearchKeyword(e.target.value)} + /> + + + +
+ {totalCount > 0 && ( + + + + handlePageChange(nowPage - 1)} + className={ + nowPage <= 1 ? 'pointer-events-none opacity-50' : '' + } + /> + + + {/* 如果总页数大于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 ( + + handlePageChange(pageNum)} + > + + {pageNum} + + + + ); + }, + ); + })()} + + + handlePageChange(nowPage + 1)} + className={ + nowPage >= Math.ceil(totalCount / pageSize) + ? 'pointer-events-none opacity-50' + : '' + } + /> + + + + )} +
+
+ +
+ {loading ? ( +
+ {t('mcp.loading')} +
+ ) : marketServerList.length === 0 ? ( +
+ {t('mcp.noMatchingServers')} +
+ ) : ( + marketServerList.map((vo, index) => ( +
+ { + askInstallServer(githubURL); + }} + /> +
+ )) + )} +
+
+ ); +} diff --git a/web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent.tsx b/web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent.tsx new file mode 100644 index 00000000..330fe228 --- /dev/null +++ b/web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent.tsx @@ -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 ( +
+
+ + + + +
+
+
+
+ {cardVO.author} /{' '} +
+
+
{cardVO.name}
+
+
+ +
+ {cardVO.description} +
+
+ +
+
+ + + +
+ {t('mcp.starCount', { count: cardVO.starCount })} +
+
+ +
+ window.open(cardVO.githubURL, '_blank')} + > + + + +
+
+
+
+
+ ); +} diff --git a/web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO.ts b/web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO.ts new file mode 100644 index 00000000..433171c6 --- /dev/null +++ b/web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO.ts @@ -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; + } +} diff --git a/web/src/app/home/plugins/mcp/MCPCardVO.ts b/web/src/app/home/plugins/mcp/MCPCardVO.ts new file mode 100644 index 00000000..c35f5508 --- /dev/null +++ b/web/src/app/home/plugins/mcp/MCPCardVO.ts @@ -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'; + } + } +} diff --git a/web/src/app/home/plugins/mcp/MCPComponent.tsx b/web/src/app/home/plugins/mcp/MCPComponent.tsx new file mode 100644 index 00000000..9bc83127 --- /dev/null +++ b/web/src/app/home/plugins/mcp/MCPComponent.tsx @@ -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((props, ref) => { + const { t } = useTranslation(); + const [serverList, setServerList] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [selectedServer, setSelectedServer] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [serverToDelete, setServerToDelete] = useState(null); + const [deleting, setDeleting] = useState(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 ? ( +
+ + + +
{t('mcp.noServerInstalled')}
+
+ ) : ( +
+ {serverList.map((vo, index) => { + return ( +
+ handleServerClick(vo)} + onRefresh={getServerList} + /> + + {/* 删除按钮 */} + +
+ ); + })} +
+ )} + + {/* 编辑配置对话框 */} + + + + + {selectedServer ? t('mcp.editServer') : t('mcp.createServer')} + + +
+ { + setModalOpen(false); + getServerList(); + }} + onFormCancel={() => { + setModalOpen(false); + }} + /> +
+
+
+ + {/* 删除确认对话框 */} + + + + {t('mcp.deleteServer')} + + {t('mcp.confirmDeleteServer', { name: serverToDelete?.name })} + + + + + {t('common.cancel')} + + + {deleting ? t('plugins.deleting') : t('common.delete')} + + + + + + ); +}); + +export default MCPComponent; diff --git a/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx new file mode 100644 index 00000000..7a7953e6 --- /dev/null +++ b/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx @@ -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 ( +
+
+ + + + +
+
+
+
+
{cardVO.name}
+ + {cardVO.mode.toUpperCase()} + +
+
+ +
+ + + +
+ {cardVO.status === 'connected' && t('mcp.statusConnected')} + {cardVO.status === 'disconnected' && + t('mcp.statusDisconnected')} + {cardVO.status === 'error' && t('mcp.statusError')} +
+
+ + {cardVO.error && ( +
+ {cardVO.error} +
+ )} +
+ +
+
+ + + +
+ {t('mcp.toolCount', { count: cardVO.tools })} +
+
+
+
+ +
+
+ handleEnable(e)} + disabled={!switchEnable} + /> +
+ +
+ +
+
+
+
+ ); +} diff --git a/web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx b/web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx new file mode 100644 index 00000000..33ee60b3 --- /dev/null +++ b/web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx @@ -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({ + 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; + handleInputChange(field, { + ...currentObj, + [key]: value, + }); + } + + function updateObjectItem( + field: 'env' | 'headers', + oldKey: string, + newKey: string, + value: string, + ) { + const currentObj = formData[field] as Record; + 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; + 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 ( +
+ {/* 基础配置 */} +
+
+ + handleInputChange('name', e.target.value)} + disabled={isEdit} + placeholder={t('mcp.serverName')} + /> +
+ +
+ +
+ + handleInputChange('enable', checked) + } + /> +
+
+ +
+ + + handleInputChange('mode', value as 'stdio' | 'sse') + } + className="mt-2" + > + + {t('mcp.stdio')} + {t('mcp.sse')} + + + +
+ + handleInputChange('command', e.target.value)} + placeholder="python -m your_mcp_server" + /> +
+ +
+ +
+ {(formData.args || []).map((arg, index) => ( +
+ + updateArrayItem('args', index, e.target.value) + } + placeholder="参数" + /> + +
+ ))} + +
+
+ +
+ +
+ {Object.entries(formData.env || {}).map(([key, value]) => ( +
+ + updateObjectItem('env', key, e.target.value, value) + } + placeholder={t('mcp.keyName')} + className="flex-1" + /> + + updateObjectItem('env', key, key, e.target.value) + } + placeholder={t('mcp.value')} + className="flex-1" + /> + +
+ ))} + +
+
+
+ + +
+ + handleInputChange('url', e.target.value)} + placeholder="http://localhost:3000/sse" + /> +
+ +
+ + + handleInputChange('timeout', parseInt(e.target.value) || 10) + } + placeholder="10" + /> +
+ +
+ +
+ {Object.entries(formData.headers || {}).map( + ([key, value]) => ( +
+ + updateObjectItem( + 'headers', + key, + e.target.value, + value, + ) + } + placeholder={t('mcp.keyName')} + className="flex-1" + /> + + updateObjectItem( + 'headers', + key, + key, + e.target.value, + ) + } + placeholder={t('mcp.value')} + className="flex-1" + /> + +
+ ), + )} + +
+
+
+
+
+
+ +
+ + +
+
+ ); +} diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index bac823cb..1f159fb8 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -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('local'); const [installInfo, setInstallInfo] = useState>({}); // 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.WAIT_INPUT); + const [mcpInstallStatus, setMcpInstallStatus] = useState( + PluginInstallStatus.WAIT_INPUT, + ); const [installError, setInstallError] = useState(null); + const [mcpInstallError, setMcpInstallError] = useState(null); const [githubURL, setGithubURL] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [pluginSystemStatus, setPluginSystemStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(true); - const pluginInstalledRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { @@ -106,11 +118,17 @@ export default function PluginConfigPage() { }); }, 1000); } + const [mcpGithubURL, setMcpGithubURL] = useState(''); + const pluginInstalledRef = useRef(null); + const mcpComponentRef = useRef(null); function handleModalConfirm() { installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any } + function handleMcpModalConfirm() { + installMcpServer(mcpGithubURL); + } function installPlugin( installSource: string, installInfo: Record, // 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 (
)} + + MCP +
@@ -372,6 +433,19 @@ export default function PluginConfigPage() { }} /> + + + + + { + setMcpGithubURL(githubURL); + setMcpMarketInstallModalOpen(true); + setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT); + setMcpInstallError(null); + }} + /> + @@ -456,6 +530,66 @@ export default function PluginConfigPage() { pluginInstalledRef.current?.refreshPluginList(); }} /> */} + + {/* MCP Server 安装对话框 */} + {/* + + + + + {t('mcp.installFromGithub')} + + + {mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && ( +
+

{t('mcp.onlySupportGithub')}

+ setMcpGithubURL(e.target.value)} + className="mb-4" + /> +
+ )} + {mcpInstallStatus === PluginInstallStatus.INSTALLING && ( +
+

{t('mcp.installing')}

+
+ )} + {mcpInstallStatus === PluginInstallStatus.ERROR && ( +
+

{t('mcp.installFailed')}

+

{mcpInstallError}

+
+ )} + + {mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && ( + <> + + + + )} + {mcpInstallStatus === PluginInstallStatus.ERROR && ( + + )} + +
+
*/}
); } diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 31ffdc23..cda8c7ba 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -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; + // sse mode + url?: string; + headers?: Record; + 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; +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 2ed83b41..41b84a8f 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -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 { + return this.get('/api/v1/mcp/servers'); + } + + public getMCPServer(serverName: string): Promise { + return this.get(`/api/v1/mcp/servers/${serverName}`); + } + + public createMCPServer( + server: MCPServerConfig, + ): Promise { + return this.post('/api/v1/mcp/servers', server); + } + + public updateMCPServer( + serverName: string, + server: Partial, + ): Promise { + return this.put(`/api/v1/mcp/servers/${serverName}`, server); + } + + public deleteMCPServer(serverName: string): Promise { + return this.delete(`/api/v1/mcp/servers/${serverName}`); + } + + public toggleMCPServer( + serverName: string, + target_enabled: boolean, + ): Promise { + return this.put(`/api/v1/mcp/servers/${serverName}/toggle`, { + target_enabled, + }); + } + + public testMCPServer(serverName: string): Promise { + 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 { + // return this.post(`/api/v1/market/mcp`, { + // page, + // page_size, + // query, + // sort_by, + // sort_order, + // }); + // } + + public installMCPServerFromGithub( + source: string, + ): Promise { + return this.post('/api/v1/mcp/install/github', { source }); + } + // ============ System API ============ public getSystemInfo(): Promise { return this.get('/api/v1/system/info'); diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..d8d2f15d --- /dev/null +++ b/web/src/components/ui/alert-dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 6b26b767..0e90cfc7 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '流水线定义了对消息事件的处理流程,用于绑定到机器人',