From c0d56aa90545ef57e33e6dc1fffb89386a6d6d93 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 6 Aug 2025 21:57:43 +0800 Subject: [PATCH 001/144] 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: '流水线定义了对消息事件的处理流程,用于绑定到机器人', From 70ad92ca1674d842c9de206e01c9112afe23d49d Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 6 Aug 2025 22:26:44 +0800 Subject: [PATCH 002/144] chore: revert group.py --- pkg/api/http/controller/group.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/api/http/controller/group.py b/pkg/api/http/controller/group.py index dfa03d6d..8ab4f4d9 100644 --- a/pkg/api/http/controller/group.py +++ b/pkg/api/http/controller/group.py @@ -93,10 +93,7 @@ class RouterGroup(abc.ABC): return self.http_status(500, -2, str(e)) new_f = handler_error - # 为不同的HTTP方法生成不同的端点名称 - methods = options.get('methods', ['GET']) - method_suffix = '_'.join(methods) - new_f.__name__ = (self.name + rule + '_' + method_suffix).replace('/', '__') + new_f.__name__ = (self.name + rule).replace('/', '__') new_f.__doc__ = f.__doc__ self.quart_app.route(rule, **options)(new_f) From 0f35458cf7dd05941ab7c04c92295534b8701c92 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 7 Aug 2025 09:06:24 +0800 Subject: [PATCH 003/144] refactor: api --- pkg/api/http/controller/groups/mcp.py | 192 +++++++++++++------------- 1 file changed, 98 insertions(+), 94 deletions(-) diff --git a/pkg/api/http/controller/groups/mcp.py b/pkg/api/http/controller/groups/mcp.py index 21fcb530..2392c8a4 100644 --- a/pkg/api/http/controller/groups/mcp.py +++ b/pkg/api/http/controller/groups/mcp.py @@ -10,110 +10,114 @@ 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) + @self.route('/servers', methods=['GET', 'POST'], 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': []}) + if quart.request.method == 'GET': + 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', []) + 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 + # 获取每个服务器的状态和工具信息 + 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, + 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}) + elif quart.request.method == 'POST': + 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 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' + # 根据模式添加配置 + 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_with_status.append(server_info) + # 添加到配置 + servers.append(new_server) + self.ap.provider_cfg.data['mcp'] = mcp_config - return self.success(data={'servers': servers_with_status}) + # 保存配置 + await self.ap.provider_cfg.dump_config() - @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() + # 如果启用,尝试重新加载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}) + else: + return self.success() + else: + return self.http_status(405, -1, 'Method not allowed') @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: From 0b527868bc2a4c527616b71097684e37f636f44f Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 30 Sep 2025 00:21:13 +0800 Subject: [PATCH 004/144] feat: adjust ui --- web/package.json | 1 + .../plugins/mcp-market/MCPMarketComponent.tsx | 74 +++++------ web/src/app/home/plugins/mcp/MCPComponent.tsx | 2 +- web/src/app/home/plugins/page.tsx | 4 +- web/src/app/infra/http/BackendClient.ts | 120 +++++++++--------- 5 files changed, 101 insertions(+), 100 deletions(-) diff --git a/web/package.json b/web/package.json index abdc435b..36e6acf3 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.0.1", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-context-menu": "^2.2.15", "@radix-ui/react-dialog": "^1.1.14", diff --git a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx index 7cb90d76..e1388ef5 100644 --- a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx +++ b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx @@ -4,7 +4,7 @@ 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 { spaceClient } from '@/app/infra/http/HttpClient'; import { useTranslation } from 'react-i18next'; import { Input } from '@/components/ui/input'; import { @@ -72,44 +72,44 @@ export default function MCPMarketComponent({ 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/', ''); - } + // 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/', ''); - } + // 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); - }); + // 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) { diff --git a/web/src/app/home/plugins/mcp/MCPComponent.tsx b/web/src/app/home/plugins/mcp/MCPComponent.tsx index 9bc83127..63299b95 100644 --- a/web/src/app/home/plugins/mcp/MCPComponent.tsx +++ b/web/src/app/home/plugins/mcp/MCPComponent.tsx @@ -122,7 +122,7 @@ const MCPComponent = forwardRef((props, ref) => { viewBox="0 0 24 24" fill="currentColor" > - +
{t('mcp.noServerInstalled')}
diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 1f159fb8..3edfe1e2 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -375,8 +375,8 @@ export default function PluginConfigPage() { )} - MCP - + MCP +
diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 41b84a8f..f27cea9d 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -491,66 +491,66 @@ 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 }); - } + // ============ 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 { From 0f39a3164852672539e0c16f4c3818d39d3733e2 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sat, 11 Oct 2025 19:10:56 +0800 Subject: [PATCH 005/144] chore: stash --- .../http/controller/groups/resources/mcp.py | 355 ++++++++++++++++++ pkg/entity/persistence/mcp.py | 20 + pkg/provider/modelmgr/modelmgr.py | 14 +- pkg/provider/tools/loaders/mcp.py | 70 +++- 4 files changed, 447 insertions(+), 12 deletions(-) create mode 100644 pkg/api/http/controller/groups/resources/mcp.py create mode 100644 pkg/entity/persistence/mcp.py diff --git a/pkg/api/http/controller/groups/resources/mcp.py b/pkg/api/http/controller/groups/resources/mcp.py new file mode 100644 index 00000000..f444639c --- /dev/null +++ b/pkg/api/http/controller/groups/resources/mcp.py @@ -0,0 +1,355 @@ +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', 'POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """获取MCP服务器列表""" + if quart.request.method == 'GET': + 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}) + elif quart.request.method == 'POST': + 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}) + else: + return self.success() + else: + return self.http_status(405, -1, 'Method not allowed') + + @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/entity/persistence/mcp.py b/pkg/entity/persistence/mcp.py new file mode 100644 index 00000000..74478dc7 --- /dev/null +++ b/pkg/entity/persistence/mcp.py @@ -0,0 +1,20 @@ +import sqlalchemy + +from .base import Base + + +class MCPServer(Base): + __tablename__ = 'mcp_servers' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) + mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse + extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/pkg/provider/modelmgr/modelmgr.py b/pkg/provider/modelmgr/modelmgr.py index d649b41e..f0bec0a5 100644 --- a/pkg/provider/modelmgr/modelmgr.py +++ b/pkg/provider/modelmgr/modelmgr.py @@ -59,7 +59,7 @@ class ModelManager: try: await self.load_llm_model(llm_model) except provider_errors.RequesterNotFoundError as e: - self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping model {llm_model.uuid}') + self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping llm model {llm_model.uuid}') except Exception as e: self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}') @@ -67,7 +67,14 @@ class ModelManager: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel)) embedding_models = result.all() for embedding_model in embedding_models: - await self.load_embedding_model(embedding_model) + try: + await self.load_embedding_model(embedding_model) + except provider_errors.RequesterNotFoundError as e: + self.ap.logger.warning( + f'Requester {e.requester_name} not found, skipping embedding model {embedding_model.uuid}' + ) + except Exception as e: + self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}') async def init_runtime_llm_model( self, @@ -107,6 +114,9 @@ class ModelManager: elif isinstance(model_info, dict): model_info = persistence_model.EmbeddingModel(**model_info) + if model_info.requester not in self.requester_dict: + raise provider_errors.RequesterNotFoundError(model_info.requester) + requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config) await requester_inst.initialize() diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 36fa9751..8677f41c 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -2,6 +2,7 @@ from __future__ import annotations import typing from contextlib import AsyncExitStack +import traceback from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -9,7 +10,9 @@ from mcp.client.sse import sse_client from .. import loader from ....core import app +from ....entity.persistence import mcp as persistence_mcp import langbot_plugin.api.entities.builtin.resource.tool as resource_tool +import sqlalchemy class RuntimeMCPSession: @@ -27,11 +30,13 @@ class RuntimeMCPSession: functions: list[resource_tool.LLMTool] = [] - def __init__(self, server_name: str, server_config: dict, ap: app.Application): + enable: bool + + def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application): self.server_name = server_name self.server_config = server_config self.ap = ap - + self.enable = enable self.session = None self.exit_stack = AsyncExitStack() @@ -68,6 +73,12 @@ class RuntimeMCPSession: await self.session.initialize() async def initialize(self): + pass + + async def start(self): + if not self.enable: + return + self.ap.logger.debug(f'初始化 MCP 会话: {self.server_name} {self.server_config}') if self.server_config['mode'] == 'stdio': @@ -123,13 +134,45 @@ class MCPLoader(loader.ToolLoader): self._last_listed_functions = [] async def initialize(self): - for server_config in self.ap.instance_config.data.get('mcp', {}).get('servers', []): - if not server_config['enable']: - continue - session = RuntimeMCPSession(server_config['name'], server_config, self.ap) - await session.initialize() - # self.ap.event_loop.create_task(session.initialize()) - self.sessions[server_config['name']] = session + await self.load_mcp_servers_from_db() + + async def load_mcp_servers_from_db(self): + self.ap.logger.info('Loading MCP servers from db...') + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) + servers = result.all() + for server in servers: + try: + await self.load_mcp_server(server) + except Exception as e: + self.ap.logger.error(f'Failed to load MCP server {server.name}: {e}\n{traceback.format_exc()}') + + async def init_runtime_mcp_session( + self, + server_entity: persistence_mcp.MCPServer | sqlalchemy.Row[persistence_mcp.MCPServer] | dict, + ): + if isinstance(server_entity, sqlalchemy.Row): + server_entity = persistence_mcp.MCPServer(**server_entity._mapping) + elif isinstance(server_entity, dict): + server_entity = persistence_mcp.MCPServer(**server_entity) + + mixed_config = { + 'name': server_entity.name, + 'mode': server_entity.mode, + 'enable': server_entity.enable, + **server_entity.extra_args, + } + + session = RuntimeMCPSession(server_entity.name, mixed_config, server_entity.enable, self.ap) + await session.initialize() + + return session + + async def load_mcp_server( + self, + server_entity: persistence_mcp.MCPServer | sqlalchemy.Row[persistence_mcp.MCPServer] | dict, + ): + session = await self.init_runtime_mcp_session(server_entity) + self.sessions[server_entity.name] = session async def get_tools(self) -> list[resource_tool.LLMTool]: all_functions = [] @@ -150,7 +193,14 @@ class MCPLoader(loader.ToolLoader): if function.name == name: return await function.func(**parameters) - raise ValueError(f'未找到工具: {name}') + raise ValueError(f'Tool not found: {name}') + + async def remove_mcp_server(self, server_name: str): + if server_name not in self.sessions: + raise ValueError(f'MCP server {server_name} not found') + + session = self.sessions.pop(server_name) + await session.shutdown() async def shutdown(self): """关闭工具""" From d65f862c36b360f0f3bff4d89cfb0e195d4e6947 Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Mon, 13 Oct 2025 18:21:46 +0800 Subject: [PATCH 006/144] feat: add dialog --- web/src/app/home/plugins/page.tsx | 381 ++++++++++++++++++++++++++---- web/src/i18n/locales/zh-Hans.ts | 2 + 2 files changed, 335 insertions(+), 48 deletions(-) diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 3edfe1e2..94a96478 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -34,7 +34,7 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { useState, useRef, useCallback, useEffect } from 'react'; +import React, { useState, useRef, useCallback, useEffect, use } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -60,11 +60,16 @@ export default function PluginConfigPage() { // const [mcpModalOpen, setMcpModalOpen] = useState(false); const [mcpMarketInstallModalOpen, setMcpMarketInstallModalOpen] = useState(false); + const [mcpSSEInstallModalOpen, setMcpSSEInstallModalOpen] = useState(false); + const [mcpDescription,setMcpDescription] = useState(''); const [pluginInstallStatus, setPluginInstallStatus] = useState(PluginInstallStatus.WAIT_INPUT); const [mcpInstallStatus, setMcpInstallStatus] = useState( PluginInstallStatus.WAIT_INPUT, ); + const [mcpSSEHeaders,setMcpSSEHeaders] = useState('') + const [mcpName,setMcpName] = useState('') + const [mcpTimeout,setMcpTimeout] = useState('') const [installError, setInstallError] = useState(null); const [mcpInstallError, setMcpInstallError] = useState(null); const [githubURL, setGithubURL] = useState(''); @@ -119,6 +124,8 @@ export default function PluginConfigPage() { }, 1000); } const [mcpGithubURL, setMcpGithubURL] = useState(''); + const [mcpSSEURL, setMcpSSEURL] = useState(''); + const [mcpInstallConfig, setMcpInstallConfig] = useState | null>(null); const pluginInstalledRef = useRef(null); const mcpComponentRef = useRef(null); @@ -309,10 +316,12 @@ export default function PluginConfigPage() { return renderPluginConnectionErrorState(); } - function installMcpServer(url: string) { + function installMcpServer(url: string, config?: Record) { setMcpInstallStatus(PluginInstallStatus.INSTALLING); - httpClient - .installMCPServerFromGithub(url) + // NOTE: backend currently only accepts url. If backend accepts config in future, + // replace this call with: httpClient.installMCPServerFromGithub(url, config) + console.log('installing mcp server with config:', config); + httpClient.installMCPServerFromGithub(url) .then((resp) => { const taskId = resp.task_id; @@ -374,9 +383,9 @@ export default function PluginConfigPage() { {t('plugins.marketplace')} )} - - MCP - + + {t('mcp.marketplace')} +
@@ -393,24 +402,52 @@ export default function PluginConfigPage() { - - - {t('plugins.uploadLocal')} - - {systemInfo.enable_marketplace && ( - { - setActiveTab('market'); - }} - > - - {t('plugins.marketplace')} - + {activeTab === 'mcp-market' ? ( + <> + {/* { + setActiveTab('mcp-market'); + setMcpMarketInstallModalOpen(true); + setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT); + setMcpInstallError(null); + setMcpGithubURL(''); + }} + > + + {t('mcp.installFromGithub')} + */} + { + setActiveTab('mcp-market'); + // setMcpMarketInstallModalOpen(true); + }} + > + + {t('mcp.createServer')} + + + ) : ( + <> + + + {t('plugins.uploadLocal')} + + {systemInfo.enable_marketplace && ( + { + setActiveTab('market'); + }} + > + + {t('plugins.marketplace')} + + )} + )} @@ -530,30 +567,91 @@ export default function PluginConfigPage() { pluginInstalledRef.current?.refreshPluginList(); }} /> */} - - {/* MCP Server 安装对话框 */} - {/* - + - - {t('mcp.installFromGithub')} + + {t('mcp.installFromSSE')} - {mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && ( -
-

{t('mcp.onlySupportGithub')}

+ +
+
+ + setMcpName(e.target.value)} + className='mb-1' + /> +
+
+ +
+
+ setMcpGithubURL(e.target.value)} - className="mb-4" + placeholder={t('mcp.descriptionExplain')} + value={mcpDescription} + onChange={(e) => setMcpDescription(e.target.value)} + className='mb-1' />
- )} +
+ + {/* form fields */} +
+
+ + setMcpSSEURL(e.target.value)} + className="mb-1" + /> +
+
+ +
+
+ + setMcpSSEHeaders(e.target.value)} + className="mb-1" + /> +
+
+ +
+
+ + setMcpTimeout(e.target.value)} + className="mb-1" + /> +
+
+ {mcpInstallStatus === PluginInstallStatus.INSTALLING && (

{t('mcp.installing')}

@@ -565,31 +663,218 @@ export default function PluginConfigPage() {

{mcpInstallError}

)} + - {mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && ( + {(mcpInstallStatus === PluginInstallStatus.WAIT_INPUT || + mcpInstallStatus === PluginInstallStatus.ERROR) && ( <> - )} - {mcpInstallStatus === PluginInstallStatus.ERROR && ( - + + +
+ + {/* MCP Server 从github安装对话框(表单) */} + + + + + + {t('mcp.installFromGithub')} + + + + {/* form fields */} +
+
+ + setMcpGithubURL(e.target.value)} + className="mb-1" + /> +
+ +
+ + + setMcpInstallConfig((c) => ({ ...(c || {}), displayName: e.target.value })) + } + className="mb-1" + /> +
+ +
+
+ + + setMcpInstallConfig((c) => ({ ...(c || {}), port: e.target.value })) + } + /> +
+
+ + + setMcpInstallConfig((c) => ({ ...(c || {}), env: e.target.value })) + } + /> +
+
+ +
+ + + setMcpInstallConfig((c) => ({ ...(c || {}), adminToken: e.target.value })) + } + /> +
+ +
+ + ) => + setMcpInstallConfig((c) => ({ ...(c || {}), extraConfig: e.target.value })) + } + /> +

+ {t('mcp.extraConfigHint', 'Optional JSON string for advanced config')} +

+
+
+ + {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/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 0e90cfc7..c481607e 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -333,6 +333,8 @@ const zhHans = { installFromGithub: '从Github安装MCP服务器', onlySupportGithub: '目前仅支持从Github安装MCP服务器', enterGithubLink: '输入Github仓库链接', + add: '添加', + name:'名称', }, pipelines: { title: '流水线', From 68372a4b7af7fa07eec9d594b79ba2c653db2c52 Mon Sep 17 00:00:00 2001 From: wangcham Date: Mon, 13 Oct 2025 12:51:58 +0000 Subject: [PATCH 007/144] feat: add mcp from sse on frontend --- web/src/app/home/plugins/page.tsx | 114 ++++++++++++++++++------ web/src/app/infra/http/BackendClient.ts | 6 ++ web/src/i18n/locales/zh-Hans.ts | 13 +++ 3 files changed, 104 insertions(+), 29 deletions(-) diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 94a96478..9122c77b 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -41,6 +41,9 @@ import { useTranslation } from 'react-i18next'; import { PluginV4 } from '@/app/infra/entities/plugin'; import { systemInfo } from '@/app/infra/http/HttpClient'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; +import { set } from 'lodash'; +import { passiveEventSupported } from '@tanstack/react-table'; +import { config } from 'process'; enum PluginInstallStatus { WAIT_INPUT = 'wait_input', @@ -69,7 +72,7 @@ export default function PluginConfigPage() { ); const [mcpSSEHeaders,setMcpSSEHeaders] = useState('') const [mcpName,setMcpName] = useState('') - const [mcpTimeout,setMcpTimeout] = useState('') + const [mcpTimeout,setMcpTimeout] = useState(60) const [installError, setInstallError] = useState(null); const [mcpInstallError, setMcpInstallError] = useState(null); const [githubURL, setGithubURL] = useState(''); @@ -125,6 +128,7 @@ export default function PluginConfigPage() { } const [mcpGithubURL, setMcpGithubURL] = useState(''); const [mcpSSEURL, setMcpSSEURL] = useState(''); + const [mcpSSEConfig, setMcpSSEConfig] = useState | null>(null); const [mcpInstallConfig, setMcpInstallConfig] = useState | null>(null); const pluginInstalledRef = useRef(null); const mcpComponentRef = useRef(null); @@ -134,7 +138,7 @@ export default function PluginConfigPage() { } function handleMcpModalConfirm() { - installMcpServer(mcpGithubURL); + installMcpServerFromSSE(mcpSSEConfig ?? {}); } function installPlugin( installSource: string, @@ -316,18 +320,14 @@ export default function PluginConfigPage() { return renderPluginConnectionErrorState(); } - function installMcpServer(url: string, config?: Record) { + function installMcpServerFromSSE(config?: Record) { setMcpInstallStatus(PluginInstallStatus.INSTALLING); - // NOTE: backend currently only accepts url. If backend accepts config in future, - // replace this call with: httpClient.installMCPServerFromGithub(url, config) - console.log('installing mcp server with config:', config); - httpClient.installMCPServerFromGithub(url) + console.log('installing mcp server from sse with config:', config); + httpClient.installMCPServerFromSSE(config ?? {}) .then((resp) => { const taskId = resp.task_id; - let alreadySuccess = false; console.log('taskId:', taskId); - // 每秒拉取一次任务状态 const interval = setInterval(() => { httpClient.getAsyncTask(taskId).then((resp) => { @@ -343,8 +343,12 @@ export default function PluginConfigPage() { toast.success(t('mcp.installSuccess')); alreadySuccess = true; } - setMcpGithubURL(''); - setMcpMarketInstallModalOpen(false); + setMcpSSEInstallModalOpen(false); + setMcpName(''); + setMcpDescription(''); + setMcpSSEURL(''); + setMcpSSEHeaders(''); + setMcpTimeout(60); mcpComponentRef.current?.refreshServerList(); } } @@ -358,6 +362,48 @@ export default function PluginConfigPage() { }); } + // function installMcpServer(url: string, config?: Record) { + // setMcpInstallStatus(PluginInstallStatus.INSTALLING); + // // NOTE: backend currently only accepts url. If backend accepts config in future, + // // replace this call with: httpClient.installMCPServerFromGithub(url, config) + // console.log('installing mcp server with config:', config); + // 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 (
{ setActiveTab('mcp-market'); - // setMcpMarketInstallModalOpen(true); + setMcpSSEInstallModalOpen(true); + setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT); + setMcpInstallError(null); }} > @@ -586,8 +634,8 @@ export default function PluginConfigPage() { - setMcpName(e.target.value)} className='mb-1' @@ -598,10 +646,10 @@ export default function PluginConfigPage() {
setMcpDescription(e.target.value)} className='mb-1' @@ -613,7 +661,7 @@ export default function PluginConfigPage() {
setMcpSSEHeaders(e.target.value)} className="mb-1" @@ -645,8 +693,8 @@ export default function PluginConfigPage() { setMcpTimeout(e.target.value)} + value={mcpTimeout || 60} + onChange={(e) => setMcpTimeout(Number(e.target.value))} className="mb-1" />
@@ -676,6 +724,10 @@ export default function PluginConfigPage() { setMcpInstallError(null); setMcpInstallConfig(null); setMcpSSEURL('') + setMcpName('') + setMcpTimeout(60) + setMcpDescription('') + setMcpSSEHeaders('') }} > {t('common.cancel')} @@ -687,19 +739,23 @@ export default function PluginConfigPage() { toast.error(t('mcp.urlRequired')); return; } - if (!mcpName) ( - toast.error(t('')) - ) - - - + if (!mcpName) { + toast.error(t('mcp.nameRequired')); + } + if (!mcpTimeout) { + toast.error(t('mcp.timeoutRequired')); + } const configToSend = { - + name: mcpSSEConfig?.name, + description: mcpSSEConfig?.description, + sse_url: mcpSSEConfig?.sse_url, + sse_headers: mcpSSEConfig?.sse_headers, + timeout: Number(mcpSSEConfig?.timeout) || 60, }; handleMcpModalConfirm(); // call installer (for now installMcpServer will log config and call backend with url only) - installMcpServer(mcpGithubURL, configToSend); + installMcpServerFromSSE(configToSend); }} > {t('common.confirm')} @@ -865,7 +921,7 @@ export default function PluginConfigPage() { handleMcpModalConfirm(); // call installer (for now installMcpServer will log config and call backend with url only) - installMcpServer(mcpGithubURL, configToSend); + // installMcpServer(mcpGithubURL, configToSend); }} > {t('common.confirm')} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index f27cea9d..fed5c00e 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -552,6 +552,12 @@ export class BackendClient extends BaseHttpClient { return this.post('/api/v1/mcp/install/github', { source }); } + public installMCPServerFromSSE( + source: {}, + ): Promise { + return this.post('/api/v1/mcp/install/sse', { source }); + } + // ============ System API ============ public getSystemInfo(): Promise { return this.get('/api/v1/system/info'); diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index c481607e..d81b7386 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1,3 +1,4 @@ + const zhHans = { common: { login: '登录', @@ -335,6 +336,18 @@ const zhHans = { enterGithubLink: '输入Github仓库链接', add: '添加', name:'名称', + nameExplained:'用于区分不同的MCP服务器实例', + mcpDescription:'描述', + descriptionExplained:'简要描述这个MCP服务器的功能或用途', + sseURL:'SSE URL', + sseHeaders:'SSE Headers', + nameRequired:'名称不能为空', + sseURLRequired:'SSE URL不能为空', + enterSSELink:'输入SSE URL', + timeoutRequired:'超时时间不能为空', + headersExample:'示例: Authorization: Bearer token123', + enterTimeout:'输入超时时间,单位为毫秒', + installFromSSE:'从SSE安装', }, pipelines: { title: '流水线', From 7be226d3fa5920614181f154126d94cd918f5770 Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Wed, 15 Oct 2025 18:42:05 +0800 Subject: [PATCH 008/144] feat: add mcp db --- pkg/api/http/controller/groups/mcp.py | 361 +++++++------------------- pkg/entity/persistence/mcp.py | 1 + 2 files changed, 89 insertions(+), 273 deletions(-) diff --git a/pkg/api/http/controller/groups/mcp.py b/pkg/api/http/controller/groups/mcp.py index 2392c8a4..dc40f383 100644 --- a/pkg/api/http/controller/groups/mcp.py +++ b/pkg/api/http/controller/groups/mcp.py @@ -1,11 +1,18 @@ from __future__ import annotations +import time +import uuid import quart import asyncio +import sqlalchemy + +from pkg.entity.persistence.mcp import MCPServer + from .....core import taskmgr from .. import group +from sqlalchemy import insert @group.group_class('mcp', '/api/v1/mcp') class MCPRouterGroup(group.RouterGroup): @@ -14,312 +21,137 @@ class MCPRouterGroup(group.RouterGroup): async def _() -> str: """获取MCP服务器列表""" if quart.request.method == 'GET': - 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 - + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(MCPServer).order_by(MCPServer.created_at.desc()) + ) + servers = [self.ap.persistence_mgr.serialize_model(MCPServer, row) for row in result.scalars().all()] + servers_with_status = [] for server in servers: + if servers['enable']: + status = 'enabled' + else: + status = 'disabled' + + # 这里先写成开关状态,先不写连接状态 server_info = { 'name': server['name'], 'mode': server['mode'], 'enable': server['enable'], - 'config': server, - 'status': 'disconnected', - 'tools': [], - 'error': None, + 'description': server.get('description',''), + 'extra_args': server.get('extra_args',{}), + 'status': status, } - - # 检查服务器连接状态 - 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}) + elif quart.request.method == 'POST': 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') - + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(MCPServer).where(MCPServer.name == data['name']) + ) + if result.first() is not None: + return self.http_status(400, -1, 'Server name already exists') + # 创建新服务器配置 new_server = { + 'uuid': str(uuid.uuid4()), 'name': data['name'], 'mode': data['mode'], - 'enable': data.get('enable', True), + 'enable': data.get('enable', False), + 'description': data.get('description',''), + 'extra_args': { + 'url':data.get('url',''), + 'headers':data.get('headers',{}), + 'timeout':data.get('timeout',60), + }, } - # 根据模式添加配置 - 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}) - else: - return self.success() - else: - return self.http_status(405, -1, 'Method not allowed') + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(MCPServer).values(new_server) + ) @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: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(MCPServer).where(MCPServer.name == server_name) + ) + server = result.first() + if server is None: return self.http_status(404, -1, 'Server not found') - + if quart.request.method == 'GET': - return self.success(data={'server': servers[server_index]}) - + server_data = self.ap.persistence_mgr.serialize_model(MCPServer, server) + return self.success(data={'server': server_data}) + elif quart.request.method == 'PUT': data = await quart.request.json - server = servers[server_index] + update_data = { + 'enable': data.get('enable', server.enable), + 'description': data.get('description', server.description), + } - # 更新配置 - server.update( - { - 'enable': data.get('enable', server.get('enable', True)), - } + extra_args = server.extra_args or {} + if server.mode == 'sse': + extra_args.update({ + 'url': data.get('url', extra_args.get('url','')), + 'headers': data.get('headers', extra_args.get('headers',{})), + 'timeout': data.get('timeout', extra_args.get('timeout',60)), + }) + update_data['extra_args'] = extra_args + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(MCPServer).where(MCPServer.name == server_name).values(update_data) ) - # 根据模式更新特定配置 - 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}) - + return self.success() + 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, + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(MCPServer).where(MCPServer.name == server_name) ) - 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}) - + return self.success() + @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: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(MCPServer).where(MCPServer.name == server_name) + ) + server = result.first() + if server 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), + self._test_mcp_server(server, 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): + + async def _test_mcp_server(self, server: MCPServer, ctx: taskmgr.TaskContext): """测试MCP服务器连接""" try: from .....provider.tools.loaders.mcp import RuntimeMCPSession - ctx.current_action = f'Testing connection to {server_config["name"]}' + ctx.current_action = f'Testing connection to {server.name}' # 创建临时会话进行测试 - session = RuntimeMCPSession(server_config['name'], server_config, self.ap) + session = RuntimeMCPSession(server.name, { + 'name': server.name, + 'mode': server.mode, + 'enable': server.enable, + 'description': server.description, + 'extra_args': server.extra_args or {}, + }, self.ap) await session.initialize() # 获取工具列表作为测试 @@ -334,22 +166,5 @@ class MCPRouterGroup(group.RouterGroup): 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/entity/persistence/mcp.py b/pkg/entity/persistence/mcp.py index 74478dc7..8f2869f3 100644 --- a/pkg/entity/persistence/mcp.py +++ b/pkg/entity/persistence/mcp.py @@ -8,6 +8,7 @@ class MCPServer(Base): uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + description = sqlalchemy.Column(sqlalchemy.Text, nullable=True) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) From 760db38c119895c742989dc8a37d9d6273ff4c47 Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Tue, 21 Oct 2025 16:18:03 +0800 Subject: [PATCH 009/144] feat: semi frontend --- pkg/api/http/controller/groups/mcp.py | 53 ++++++++++-------- web/src/app/home/plugins/page.tsx | 74 ++++++++----------------- web/src/app/infra/http/BackendClient.ts | 2 +- 3 files changed, 54 insertions(+), 75 deletions(-) diff --git a/pkg/api/http/controller/groups/mcp.py b/pkg/api/http/controller/groups/mcp.py index dc40f383..b92267e9 100644 --- a/pkg/api/http/controller/groups/mcp.py +++ b/pkg/api/http/controller/groups/mcp.py @@ -1,5 +1,6 @@ from __future__ import annotations import time +import traceback import uuid import quart @@ -48,31 +49,37 @@ class MCPRouterGroup(group.RouterGroup): elif quart.request.method == 'POST': data = await quart.request.json - + data = data['source'] + try: # 检查服务器名称是否重复 - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(MCPServer).where(MCPServer.name == data['name']) - ) - if result.first() is not None: - return self.http_status(400, -1, 'Server name already exists') - - # 创建新服务器配置 - new_server = { - 'uuid': str(uuid.uuid4()), - 'name': data['name'], - 'mode': data['mode'], - 'enable': data.get('enable', False), - 'description': data.get('description',''), - 'extra_args': { - 'url':data.get('url',''), - 'headers':data.get('headers',{}), - 'timeout':data.get('timeout',60), - }, - } + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(MCPServer).where(MCPServer.name == data['name']) + ) + if result.first() is not None: + return self.http_status(400, -1, 'Server name already exists') + + # 创建新服务器配置 + new_server = { + 'uuid': str(uuid.uuid4()), + 'name': data['name'], + 'mode': 'sse', + 'enable': data.get('enable', False), + 'description': data.get('description',''), + 'extra_args': { + 'url':data.get('url',''), + 'headers':data.get('headers',{}), + 'timeout':data.get('timeout',60), + }, + } - await self.ap.persistence_mgr.execute_async( - sqlalchemy.insert(MCPServer).values(new_server) - ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(MCPServer).values(new_server) + ) + + return self.success() + + except Exception as e: + print(traceback.format_exc()) @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 9122c77b..37fd57c4 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -43,7 +43,7 @@ import { systemInfo } from '@/app/infra/http/HttpClient'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { set } from 'lodash'; import { passiveEventSupported } from '@tanstack/react-table'; -import { config } from 'process'; + enum PluginInstallStatus { WAIT_INPUT = 'wait_input', @@ -324,36 +324,21 @@ export default function PluginConfigPage() { setMcpInstallStatus(PluginInstallStatus.INSTALLING); console.log('installing mcp server from sse with config:', config); httpClient.installMCPServerFromSSE(config ?? {}) - .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; - } - setMcpSSEInstallModalOpen(false); - setMcpName(''); - setMcpDescription(''); - setMcpSSEURL(''); - setMcpSSEHeaders(''); - setMcpTimeout(60); - mcpComponentRef.current?.refreshServerList(); - } - } - }); - }, 1000); + .then((resp:any) => { + if (resp && resp.status === 'success') { + console.log('MCP server installed successfully'); + toast.success(t('mcp.installSuccess')); + setMcpSSEURL(''); + setMcpName(''); + setMcpDescription(''); + setMcpSSEHeaders(''); + setMcpTimeout(60); + setMcpSSEInstallModalOpen(false); + mcpComponentRef.current?.refreshServerList(); + } else { + setMcpInstallError(t('mcp.installFailed')); + setMcpInstallStatus(PluginInstallStatus.ERROR); + } }) .catch((err) => { console.log('error when install mcp server:', err); @@ -672,19 +657,7 @@ export default function PluginConfigPage() {
-
-
- - setMcpSSEHeaders(e.target.value)} - className="mb-1" - /> -
-
+
@@ -746,14 +719,13 @@ export default function PluginConfigPage() { toast.error(t('mcp.timeoutRequired')); } const configToSend = { - name: mcpSSEConfig?.name, - description: mcpSSEConfig?.description, - sse_url: mcpSSEConfig?.sse_url, - sse_headers: mcpSSEConfig?.sse_headers, - timeout: Number(mcpSSEConfig?.timeout) || 60, + name: mcpName, + description: mcpDescription, + sse_url: mcpSSEURL, + sse_headers: mcpSSEHeaders, + timeout: Number(mcpTimeout) || 60, }; - - handleMcpModalConfirm(); + // handleMcpModalConfirm(); // call installer (for now installMcpServer will log config and call backend with url only) installMcpServerFromSSE(configToSend); }} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index fed5c00e..152a5082 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -555,7 +555,7 @@ export class BackendClient extends BaseHttpClient { public installMCPServerFromSSE( source: {}, ): Promise { - return this.post('/api/v1/mcp/install/sse', { source }); + return this.post('/api/v1/mcp/servers', { source }); } // ============ System API ============ From 345eccf04ca2cf71d7f77acd36b6860bc554f39c Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Wed, 22 Oct 2025 19:09:39 +0800 Subject: [PATCH 010/144] feat: change sse frontend --- .../models/component/llm-form/LLMForm.tsx | 2 +- web/src/app/home/plugins/page.tsx | 738 +++++++++--------- web/src/i18n/locales/zh-Hans.ts | 1 + 3 files changed, 361 insertions(+), 380 deletions(-) diff --git a/web/src/app/home/models/component/llm-form/LLMForm.tsx b/web/src/app/home/models/component/llm-form/LLMForm.tsx index 6d46da6d..2b663fa5 100644 --- a/web/src/app/home/models/component/llm-form/LLMForm.tsx +++ b/web/src/app/home/models/component/llm-form/LLMForm.tsx @@ -304,7 +304,7 @@ export default function LLMForm({ onLLMDeleted(); toast.success(t('models.deleteSuccess')); }) - .catch((err) => { + .catch ((err) => { toast.error(t('models.deleteError') + err.message); }); } diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 37fd57c4..4f071593 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -43,6 +43,20 @@ import { systemInfo } from '@/app/infra/http/HttpClient'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { set } from 'lodash'; import { passiveEventSupported } from '@tanstack/react-table'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@radix-ui/react-select'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { number, z } from 'zod'; +import { DialogDescription } from '@radix-ui/react-dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; enum PluginInstallStatus { @@ -52,7 +66,23 @@ enum PluginInstallStatus { ERROR = 'error', } -export default function PluginConfigPage() { +export default function PluginConfigPage( + { + editMode, + initMCPId, + onFormSubmit, + onFormCancel, + onMcpDeleted, + }: + { + editMode: boolean; + initMCPId?: string; + onFormSubmit: () => void; + onFormCancel: () => void; + onMcpDeleted: () => void; + } +) { + const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('installed'); const [modalOpen, setModalOpen] = useState(false); @@ -63,16 +93,9 @@ export default function PluginConfigPage() { // const [mcpModalOpen, setMcpModalOpen] = useState(false); const [mcpMarketInstallModalOpen, setMcpMarketInstallModalOpen] = useState(false); - const [mcpSSEInstallModalOpen, setMcpSSEInstallModalOpen] = useState(false); - const [mcpDescription,setMcpDescription] = useState(''); + const [mcpSSEModalOpen, setMcpSSEModalOpen] = useState(false); const [pluginInstallStatus, setPluginInstallStatus] = useState(PluginInstallStatus.WAIT_INPUT); - const [mcpInstallStatus, setMcpInstallStatus] = useState( - PluginInstallStatus.WAIT_INPUT, - ); - const [mcpSSEHeaders,setMcpSSEHeaders] = useState('') - const [mcpName,setMcpName] = useState('') - const [mcpTimeout,setMcpTimeout] = useState(60) const [installError, setInstallError] = useState(null); const [mcpInstallError, setMcpInstallError] = useState(null); const [githubURL, setGithubURL] = useState(''); @@ -81,7 +104,77 @@ export default function PluginConfigPage() { useState(null); const [statusLoading, setStatusLoading] = useState(true); const fileInputRef = useRef(null); - + const addExtraArg = () => { + setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]); + }; + const getExtraArgSchema = (t: (key: string) => string) => + z + .object({ + key: z.string().min(1, { message: t('models.keyNameRequired') }), + type: z.enum(['string', 'number', 'boolean']), + value: z.string(), + }) + .superRefine((data, ctx) => { + if (data.type === 'number' && isNaN(Number(data.value))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('models.mustBeValidNumber'), + path: ['value'], + }); + } + if ( + data.type === 'boolean' && + data.value !== 'true' && + data.value !== 'false' + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('models.mustBeTrueOrFalse'), + path: ['value'], + }); + } + }); + const removeExtraArg = (index: number) => { + const newArgs = extraArgs.filter((_, i) => i !== index); + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); + }; + const getFormSchema = (t: (key: string) => string) => + z.object({ + name: z.string().min(1, { message: t('mcp.nameRequired') }), + timeout: z.number().min(30, { message: t('mcp.timeoutMin30') }), + ssereadtimeout: z.number().min(300, { message: t('mcp.sseTimeoutMin300') }), + url: z.string().min(1, { message: t('mcp.requestURLRequired') }), + extra_args: z.array(getExtraArgSchema(t)).optional(), + }); + const formSchema = getFormSchema(t); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + url: '', + timeout: 30, + ssereadtimeout: 300, + extra_args: [], + }, + }); + const [extraArgs, setExtraArgs] = useState< + { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] + >([]); + const updateExtraArg = ( + index: number, + field: 'key' | 'type' | 'value', + value: string, + ) => { + const newArgs = [...extraArgs]; + newArgs[index] = { + ...newArgs[index], + [field]: value, + }; + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); + }; + const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); useEffect(() => { const fetchPluginSystemStatus = async () => { try { @@ -132,14 +225,11 @@ export default function PluginConfigPage() { const [mcpInstallConfig, setMcpInstallConfig] = useState | null>(null); const pluginInstalledRef = useRef(null); const mcpComponentRef = useRef(null); + const [mcpTesting, setMcpTesting] = useState(false); function handleModalConfirm() { installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any } - - function handleMcpModalConfirm() { - installMcpServerFromSSE(mcpSSEConfig ?? {}); - } function installPlugin( installSource: string, installInfo: Record, // eslint-disable-line @typescript-eslint/no-explicit-any @@ -183,6 +273,53 @@ export default function PluginConfigPage() { } } + function deleteMCPServer() { + + } + + function handleFormSubmit(value: z.infer) { + const extraArgsObj: Record = {}; + value.extra_args?.forEach( + (arg: { key: string; type: string; value: string }) => { + if (arg.type === 'number') { + extraArgsObj[arg.key] = Number(arg.value); + } else if (arg.type === 'boolean') { + extraArgsObj[arg.key] = arg.value === 'true'; + } else { + extraArgsObj[arg.key] = arg.value; + } + }, + ); + } + + function testMcp() { + setMcpTesting(true); + const extraArgsObj: Record = {}; + form + .getValues('extra_args') + ?.forEach((arg: { key: string; type: string; value: string }) => { + if (arg.type === 'number') { + extraArgsObj[arg.key] = Number(arg.value); + } else if (arg.type === 'boolean') { + extraArgsObj[arg.key] = arg.value === 'true'; + } else { + extraArgsObj[arg.key] = arg.value; + } + }); + httpClient.testMCPServer( + form.getValues('name'), + ).then((res) => { + console.log(res); + toast.success(t('models.testSuccess')); + }) + .catch(() => { + toast.error(t('models.testError')); + }) + .finally(() => { + setMcpTesting(false); + }); + } + const validateFileType = (file: File): boolean => { const allowedExtensions = ['.lbpkg', '.zip']; const fileName = file.name.toLowerCase(); @@ -320,74 +457,6 @@ export default function PluginConfigPage() { return renderPluginConnectionErrorState(); } - function installMcpServerFromSSE(config?: Record) { - setMcpInstallStatus(PluginInstallStatus.INSTALLING); - console.log('installing mcp server from sse with config:', config); - httpClient.installMCPServerFromSSE(config ?? {}) - .then((resp:any) => { - if (resp && resp.status === 'success') { - console.log('MCP server installed successfully'); - toast.success(t('mcp.installSuccess')); - setMcpSSEURL(''); - setMcpName(''); - setMcpDescription(''); - setMcpSSEHeaders(''); - setMcpTimeout(60); - setMcpSSEInstallModalOpen(false); - mcpComponentRef.current?.refreshServerList(); - } else { - setMcpInstallError(t('mcp.installFailed')); - setMcpInstallStatus(PluginInstallStatus.ERROR); - } - }) - .catch((err) => { - console.log('error when install mcp server:', err); - setMcpInstallError(err.message); - setMcpInstallStatus(PluginInstallStatus.ERROR); - }); - } - - // function installMcpServer(url: string, config?: Record) { - // setMcpInstallStatus(PluginInstallStatus.INSTALLING); - // // NOTE: backend currently only accepts url. If backend accepts config in future, - // // replace this call with: httpClient.installMCPServerFromGithub(url, config) - // console.log('installing mcp server with config:', config); - // 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 (
{ setActiveTab('mcp-market'); - setMcpSSEInstallModalOpen(true); - setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT); - setMcpInstallError(null); + setMcpSSEModalOpen(true); }} > @@ -511,7 +578,7 @@ export default function PluginConfigPage() { askInstallServer={(githubURL) => { setMcpGithubURL(githubURL); setMcpMarketInstallModalOpen(true); - setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT); + // setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT); setMcpInstallError(null); }} /> @@ -593,316 +660,229 @@ export default function PluginConfigPage() {
)} - {/* { - pluginInstalledRef.current?.refreshPluginList(); - }} - /> */} - - {/* 通过sse安装MCP服务器 */} - - +
+ + - - - {t('mcp.installFromSSE')} - + {t('plugins.confirmDeleteTitle')} - -
-
- - setMcpName(e.target.value)} - className='mb-1' - /> -
-
- -
-
- - setMcpDescription(e.target.value)} - className='mb-1' - /> -
-
- - {/* form fields */} -
-
- - setMcpSSEURL(e.target.value)} - className="mb-1" - /> -
-
- - - -
-
- - setMcpTimeout(Number(e.target.value))} - className="mb-1" - /> -
-
- - {mcpInstallStatus === PluginInstallStatus.INSTALLING && ( -
-

{t('mcp.installing')}

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

{t('mcp.installFailed')}

-

{mcpInstallError}

-
- )} - + + {t('plugins.deleteConfirmation')} + - {(mcpInstallStatus === PluginInstallStatus.WAIT_INPUT || - mcpInstallStatus === PluginInstallStatus.ERROR) && ( - <> - - - - )} +
-
- - {/* MCP Server 从github安装对话框(表单) */} - - + + + + - - - {t('mcp.installFromGithub')} + + {t('mcp.createServer')} - - {/* form fields */} -
-
- - setMcpGithubURL(e.target.value)} - className="mb-1" +
+ +
+ ( + + {t('mcp.name')} + + + + + + )} /> -
- -
- - - setMcpInstallConfig((c) => ({ ...(c || {}), displayName: e.target.value })) - } - className="mb-1" - /> -
- -
-
- - - setMcpInstallConfig((c) => ({ ...(c || {}), port: e.target.value })) - } + + ( + + + {t('mcp.url')} + + + + + + + )} /> -
-
- - - setMcpInstallConfig((c) => ({ ...(c || {}), env: e.target.value })) - } + + ( + + + {t('mcp.timeout')} + + + + + + + ) + } /> -
-
-
- - - setMcpInstallConfig((c) => ({ ...(c || {}), adminToken: e.target.value })) - } - /> -
+ + ( + + + {t('mcp.ssereadtimeout')} + + + + + + + ) + } + /> -
- - ) => - setMcpInstallConfig((c) => ({ ...(c || {}), extraConfig: e.target.value })) - } - /> -

- {t('mcp.extraConfigHint', 'Optional JSON string for advanced config')} -

-
-
- - {mcpInstallStatus === PluginInstallStatus.INSTALLING && ( -
-

{t('mcp.installing')}

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

{t('mcp.installFailed')}

-

{mcpInstallError}

-
- )} - - - {(mcpInstallStatus === PluginInstallStatus.WAIT_INPUT || - mcpInstallStatus === PluginInstallStatus.ERROR) && ( - <> - - +
+ ))} + - +
+ + {t('llm.extraParametersDescription')} + + + + + + {editMode && ( + )} + + + + + + +
+ + - -
+ +
+
); } diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index d81b7386..cac04c99 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -348,6 +348,7 @@ const zhHans = { headersExample:'示例: Authorization: Bearer token123', enterTimeout:'输入超时时间,单位为毫秒', installFromSSE:'从SSE安装', + sseTimeout:'SSE超时时间' }, pipelines: { title: '流水线', From 6ba9b6973dd3f5ee9e72e348af5fddf457194608 Mon Sep 17 00:00:00 2001 From: wangcham Date: Wed, 22 Oct 2025 13:37:53 +0000 Subject: [PATCH 011/144] fix: page out of control --- web/src/app/home/plugins/page.tsx | 137 ++++++++++++++++++++++-- web/src/app/infra/http/BackendClient.ts | 2 +- web/src/components/ui/dialog.tsx | 55 +++++++++- 3 files changed, 183 insertions(+), 11 deletions(-) diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 4f071593..be4d6969 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -68,19 +68,19 @@ enum PluginInstallStatus { export default function PluginConfigPage( { - editMode, + editMode = false, initMCPId, onFormSubmit, onFormCancel, onMcpDeleted, }: { - editMode: boolean; + editMode?: boolean; initMCPId?: string; - onFormSubmit: () => void; - onFormCancel: () => void; - onMcpDeleted: () => void; - } + onFormSubmit?: () => void; + onFormCancel?: () => void; + onMcpDeleted?: () => void; + } = {} ) { const { t } = useTranslation(); @@ -227,6 +227,93 @@ export default function PluginConfigPage( const mcpComponentRef = useRef(null); const [mcpTesting, setMcpTesting] = useState(false); + // 强制清理 body 样式以修复 Dialog 关闭后点击失效的问题 + useEffect(() => { + console.log('[Dialog Debug] States:', { mcpSSEModalOpen, modalOpen, showDeleteConfirmModal }); + + if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { + console.log('[Dialog Debug] All dialogs closed, cleaning up body styles...'); + console.log('[Dialog Debug] Before cleanup - body.style.pointerEvents:', document.body.style.pointerEvents); + console.log('[Dialog Debug] Before cleanup - body.style.overflow:', document.body.style.overflow); + + const cleanup = () => { + // 强制移除 body 上可能残留的样式 + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + + // 如果 removeProperty 不起作用,强制设置为空字符串 + if (document.body.style.pointerEvents === 'none') { + document.body.style.pointerEvents = ''; + } + if (document.body.style.overflow === 'hidden') { + document.body.style.overflow = ''; + } + + console.log('[Dialog Debug] After cleanup - body.style.pointerEvents:', document.body.style.pointerEvents); + console.log('[Dialog Debug] After cleanup - body.style.overflow:', document.body.style.overflow); + + // 检查计算后的样式 + const computedStyle = window.getComputedStyle(document.body); + console.log('[Dialog Debug] Computed pointerEvents:', computedStyle.pointerEvents); + }; + + // 多次清理以确保覆盖 Radix 的设置 + cleanup(); + const timer1 = setTimeout(cleanup, 0); + const timer2 = setTimeout(cleanup, 50); + const timer3 = setTimeout(cleanup, 100); + const timer4 = setTimeout(cleanup, 200); + const timer5 = setTimeout(cleanup, 300); + + return () => { + clearTimeout(timer1); + clearTimeout(timer2); + clearTimeout(timer3); + clearTimeout(timer4); + clearTimeout(timer5); + }; + } + }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); + + // 额外的全局清理:定期检查并清理 + useEffect(() => { + const interval = setInterval(() => { + if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { + if (document.body.style.pointerEvents === 'none') { + console.log('[Global Cleanup] Found stale pointer-events, cleaning...'); + document.body.style.removeProperty('pointer-events'); + document.body.style.pointerEvents = ''; + } + } + }, 500); + + return () => clearInterval(interval); + }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); + + // MutationObserver:监视 body 的 style 变化 + useEffect(() => { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'style') { + if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { + if (document.body.style.pointerEvents === 'none') { + console.log('[MutationObserver] Detected pointer-events being set to none, reverting...'); + document.body.style.removeProperty('pointer-events'); + document.body.style.pointerEvents = ''; + } + } + } + }); + }); + + observer.observe(document.body, { + attributes: true, + attributeFilter: ['style'], + }); + + return () => observer.disconnect(); + }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); + function handleModalConfirm() { installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any } @@ -277,7 +364,7 @@ export default function PluginConfigPage( } - function handleFormSubmit(value: z.infer) { + async function handleFormSubmit(value: z.infer) { const extraArgsObj: Record = {}; value.extra_args?.forEach( (arg: { key: string; type: string; value: string }) => { @@ -290,6 +377,35 @@ export default function PluginConfigPage( } }, ); + + try { + // 构造符合 MCPServerConfig 类型的数据 + const serverConfig = { + name: value.name, + mode: 'sse' as const, + enable: true, + url: value.url, + headers: extraArgsObj as Record, + timeout: value.timeout, + }; + + await httpClient.createMCPServer(serverConfig); + + toast.success(t('mcp.createSuccess')); + + // 只有在异步操作成功后才关闭对话框 + setMcpSSEModalOpen(false); + + // 重置表单 + form.reset(); + setExtraArgs([]); + + // 调用回调通知父组件刷新 + onFormSubmit?.(); + } catch (error) { + console.error('Failed to create MCP server:', error); + toast.error(t('mcp.createFailed')); + } } function testMcp() { @@ -872,7 +988,12 @@ export default function PluginConfigPage( diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 152a5082..8c533ab2 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -503,7 +503,7 @@ export class BackendClient extends BaseHttpClient { public createMCPServer( server: MCPServerConfig, ): Promise { - return this.post('/api/v1/mcp/servers', server); + return this.post('/api/v1/mcp/servers', { source: server }); } public updateMCPServer( diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 7caae6a2..91823fd2 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -7,9 +7,61 @@ import { XIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; function Dialog({ + onOpenChange, + open, ...props }: React.ComponentProps) { - return ; + const handleOpenChange = React.useCallback((isOpen: boolean) => { + onOpenChange?.(isOpen); + + // 当对话框关闭时,确保清理 body 样式 + if (!isOpen) { + // 立即清理 + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + + // 延迟再次清理,确保覆盖 Radix 的设置 + setTimeout(() => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }, 0); + + setTimeout(() => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }, 50); + + setTimeout(() => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }, 150); + } + }, [onOpenChange]); + + // 使用 effect 监控 open 状态变化 + React.useEffect(() => { + if (open === false) { + const cleanup = () => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }; + + cleanup(); + const timer1 = setTimeout(cleanup, 0); + const timer2 = setTimeout(cleanup, 50); + const timer3 = setTimeout(cleanup, 150); + const timer4 = setTimeout(cleanup, 300); + + return () => { + clearTimeout(timer1); + clearTimeout(timer2); + clearTimeout(timer3); + clearTimeout(timer4); + }; + } + }, [open]); + + return ; } function DialogTrigger({ @@ -60,7 +112,6 @@ function DialogContent({ 'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', className, )} - onInteractOutside={() => {}} {...props} > {children} From d0a3dee083cfa196d872c366c115e2b658e3a234 Mon Sep 17 00:00:00 2001 From: wangcham Date: Thu, 23 Oct 2025 22:30:53 +0800 Subject: [PATCH 012/144] fix: mcp card --- .../home/plugins/mcp-market/MCPMarketComponent.tsx | 12 +++++++++++- web/src/app/home/plugins/page.tsx | 12 ++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx index e1388ef5..441f1853 100644 --- a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx +++ b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx @@ -23,6 +23,8 @@ import { SelectValue, } from '@/components/ui/select'; +import { httpClient, HttpClient } from '@/app/infra/http/HttpClient'; + export default function MCPMarketComponent({ askInstallServer, }: { @@ -71,7 +73,15 @@ export default function MCPMarketComponent({ sortBy: string = sortByValue, sortOrder: string = sortOrderValue, ) { - setLoading(true); + // setLoading(true); + + // 获取后端的 MCP Market 服务器列表 + httpClient.getMCPServers().then( + ); + + + + // spaceClient // .getMCPMarketServers(page, pageSize, keyword, sortBy, sortOrder) // .then((res) => { diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index be4d6969..29768703 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -599,8 +599,8 @@ export default function PluginConfigPage( {t('plugins.marketplace')} )} - - {t('mcp.marketplace')} + + {t('mcp.title')} @@ -618,12 +618,12 @@ export default function PluginConfigPage( - {activeTab === 'mcp-market' ? ( + {activeTab === 'mcp-servers' ? ( <> {/* { @@ -639,7 +639,7 @@ export default function PluginConfigPage( */} { - setActiveTab('mcp-market'); + setActiveTab('mcp-servers'); setMcpSSEModalOpen(true); }} > @@ -689,7 +689,7 @@ export default function PluginConfigPage( - + { setMcpGithubURL(githubURL); From 075091ed06443e659dca2dcc82c70ef5dae0ff2d Mon Sep 17 00:00:00 2001 From: wangcham Date: Thu, 23 Oct 2025 15:47:44 +0000 Subject: [PATCH 013/144] fix: mcp refactor --- pkg/api/http/controller/groups/mcp.py | 31 +- .../models/component/llm-form/LLMForm.tsx | 2 +- .../plugins/mcp-market/MCPMarketComponent.tsx | 265 ++++---- web/src/app/home/plugins/page.tsx | 631 ++++++++++-------- web/src/app/infra/http/BackendClient.ts | 2 +- web/src/components/ui/dialog.tsx | 54 +- web/src/i18n/locales/zh-Hans.ts | 29 +- 7 files changed, 577 insertions(+), 437 deletions(-) diff --git a/pkg/api/http/controller/groups/mcp.py b/pkg/api/http/controller/groups/mcp.py index b92267e9..01f91f1d 100644 --- a/pkg/api/http/controller/groups/mcp.py +++ b/pkg/api/http/controller/groups/mcp.py @@ -25,23 +25,42 @@ class MCPRouterGroup(group.RouterGroup): result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(MCPServer).order_by(MCPServer.created_at.desc()) ) - servers = [self.ap.persistence_mgr.serialize_model(MCPServer, row) for row in result.scalars().all()] - + raw_results = result.all() + servers = [self.ap.persistence_mgr.serialize_model(MCPServer, row) for row in raw_results] + servers_with_status = [] for server in servers: - if servers['enable']: + # 设置状态 + if server['enable']: status = 'enabled' else: status = 'disabled' - # 这里先写成开关状态,先不写连接状态 + # 构建 config 对象 (前端期望的格式) + extra_args = server.get('extra_args', {}) + config = { + 'name': server['name'], + 'mode': server['mode'], + 'enable': server['enable'], + } + + # 根据模式添加相应的配置 + if server['mode'] == 'sse': + config['url'] = extra_args.get('url', '') + config['headers'] = extra_args.get('headers', {}) + config['timeout'] = extra_args.get('timeout', 60) + elif server['mode'] == 'stdio': + config['command'] = extra_args.get('command', '') + config['args'] = extra_args.get('args', []) + config['env'] = extra_args.get('env', {}) + server_info = { 'name': server['name'], 'mode': server['mode'], 'enable': server['enable'], - 'description': server.get('description',''), - 'extra_args': server.get('extra_args',{}), 'status': status, + 'tools': [], # 暂时返回空数组,需要连接到MCP服务器才能获取工具列表 + 'config': config, } servers_with_status.append(server_info) diff --git a/web/src/app/home/models/component/llm-form/LLMForm.tsx b/web/src/app/home/models/component/llm-form/LLMForm.tsx index 2b663fa5..6d46da6d 100644 --- a/web/src/app/home/models/component/llm-form/LLMForm.tsx +++ b/web/src/app/home/models/component/llm-form/LLMForm.tsx @@ -304,7 +304,7 @@ export default function LLMForm({ onLLMDeleted(); toast.success(t('models.deleteSuccess')); }) - .catch ((err) => { + .catch((err) => { toast.error(t('models.deleteError') + err.message); }); } diff --git a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx index 441f1853..a529d67f 100644 --- a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx +++ b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx @@ -1,47 +1,51 @@ 'use client'; -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState } 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 { MCPMarketCardVO } from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO'; +// import MCPMarketCardComponent from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent'; +import MCPCardComponent from '@/app/home/plugins/mcp/mcp-card/MCPCardComponent'; +import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO'; // 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'; +// 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'; -import { httpClient, HttpClient } from '@/app/infra/http/HttpClient'; +import { httpClient } from '@/app/infra/http/HttpClient'; export default function MCPMarketComponent({ - askInstallServer, + onEditServer, }: { - askInstallServer: (githubURL: string) => void; + askInstallServer?: (githubURL: string) => void; + onEditServer?: (serverName: string) => void; }) { const { t } = useTranslation(); - const [marketServerList, setMarketServerList] = useState( - [], - ); - const [totalCount, setTotalCount] = useState(0); - const [nowPage, setNowPage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); + // const [marketServerList, setMarketServerList] = useState( + // [], + // ); + const [installedServers, setInstalledServers] = 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; + // const [sortByValue, setSortByValue] = useState('pushed_at'); + // const [sortOrderValue, setSortOrderValue] = useState('DESC'); + // const searchTimeout = useRef(null); + // const pageSize = 12; useEffect(() => { initData(); @@ -49,95 +53,131 @@ export default function MCPMarketComponent({ }, []); function initData() { - getServerList(); + fetchInstalledServers(); + // getServerList(); // GitHub 市场功能暂时注释 } - function onInputSearchKeyword(keyword: string) { - setSearchKeyword(keyword); - - // 清除之前的定时器 - if (searchTimeout.current) { - clearTimeout(searchTimeout.current); - } - - // 设置新的定时器 - searchTimeout.current = setTimeout(() => { - setNowPage(1); - getServerList(1, keyword); - }, 500); + function fetchInstalledServers() { + setLoading(true); + httpClient + .getMCPServers() + .then((resp) => { + const servers = resp.servers.map((server) => new MCPCardVO(server)); + setInstalledServers(servers); + setLoading(false); + }) + .catch((error) => { + console.error('Failed to fetch MCP servers:', error); + setLoading(false); + }); } - function getServerList( - page: number = nowPage, - keyword: string = searchKeyword, - sortBy: string = sortByValue, - sortOrder: string = sortOrderValue, - ) { - // setLoading(true); + // GitHub 市场功能暂时注释 + // function onInputSearchKeyword(keyword: string) { + // setSearchKeyword(keyword); + // if (searchTimeout.current) { + // clearTimeout(searchTimeout.current); + // } + // searchTimeout.current = setTimeout(() => { + // setNowPage(1); + // getServerList(1, keyword); + // }, 500); + // } - // 获取后端的 MCP Market 服务器列表 - httpClient.getMCPServers().then( - ); - - + // function getServerList( + // page: number = nowPage, + // keyword: string = searchKeyword, + // sortBy: string = sortByValue, + // sortOrder: string = sortOrderValue, + // ) { + // // GitHub 安装功能暂时注释 + // // 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', + // // }); + // // }), + // // ); + // // setTotalCount(res.total); + // // setLoading(false); + // // console.log('market servers:', res); + // // }) + // // .catch((error) => { + // // console.error(t('mcp.getServerListError'), error); + // // setLoading(false); + // // }); + // } - - // 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/', ''); - // } + // function handlePageChange(page: number) { + // setNowPage(page); + // getServerList(page); + // } - // 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); - } + // 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 (
-
+ {/* 已安装的服务器列表 */} +
+

+ {t('mcp.installedServers')} +

+
+ {loading ? ( +
+ {t('mcp.loading')} +
+ ) : installedServers.length === 0 ? ( +
+ {t('mcp.noInstalledServers')} +
+ ) : ( + installedServers.map((server, index) => ( +
+ { + if (onEditServer) { + onEditServer(server.name); + } + }} + onRefresh={fetchInstalledServers} + /> +
+ )) + )} +
+
+ + {/* GitHub 市场功能暂时注释 */} + {/*
- {/* 如果总页数大于5,则只显示5页,如果总页数小于5,则显示所有页 */} {(() => { const totalPages = Math.ceil(totalCount / pageSize); const maxVisiblePages = 5; @@ -255,7 +294,7 @@ export default function MCPMarketComponent({
)) )} -
+
*/}
); } diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 29768703..38f70b85 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -4,7 +4,6 @@ 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'; @@ -34,19 +33,23 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import React, { useState, useRef, useCallback, useEffect, use } from 'react'; +import React, { useState, useRef, useCallback, useEffect } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { PluginV4 } from '@/app/infra/entities/plugin'; import { systemInfo } from '@/app/infra/http/HttpClient'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; -import { set } from 'lodash'; -import { passiveEventSupported } from '@tanstack/react-table'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@radix-ui/react-select'; -import { useForm } from 'react-hook-form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@radix-ui/react-select'; +import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { number, z } from 'zod'; +import { z } from 'zod'; import { DialogDescription } from '@radix-ui/react-dialog'; import { Form, @@ -58,7 +61,6 @@ import { FormMessage, } from '@/components/ui/form'; - enum PluginInstallStatus { WAIT_INPUT = 'wait_input', ASK_CONFIRM = 'ask_confirm', @@ -66,38 +68,16 @@ enum PluginInstallStatus { ERROR = 'error', } -export default function PluginConfigPage( - { - editMode = false, - initMCPId, - onFormSubmit, - onFormCancel, - onMcpDeleted, - }: - { - editMode?: boolean; - initMCPId?: string; - onFormSubmit?: () => void; - onFormCancel?: () => void; - onMcpDeleted?: () => void; - } = {} -) { - +export default function PluginConfigPage() { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('installed'); const [modalOpen, setModalOpen] = useState(false); - // const [sortModalOpen, setSortModalOpen] = useState(false); 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 [mcpSSEModalOpen, setMcpSSEModalOpen] = useState(false); const [pluginInstallStatus, setPluginInstallStatus] = 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] = @@ -134,7 +114,7 @@ export default function PluginConfigPage( }); } }); - const removeExtraArg = (index: number) => { + const removeExtraArg = (index: number) => { const newArgs = extraArgs.filter((_, i) => i !== index); setExtraArgs(newArgs); form.setValue('extra_args', newArgs); @@ -143,7 +123,9 @@ export default function PluginConfigPage( z.object({ name: z.string().min(1, { message: t('mcp.nameRequired') }), timeout: z.number().min(30, { message: t('mcp.timeoutMin30') }), - ssereadtimeout: z.number().min(300, { message: t('mcp.sseTimeoutMin300') }), + ssereadtimeout: z + .number() + .min(300, { message: t('mcp.sseTimeoutMin300') }), url: z.string().min(1, { message: t('mcp.requestURLRequired') }), extra_args: z.array(getExtraArgSchema(t)).optional(), }); @@ -219,22 +201,35 @@ export default function PluginConfigPage( }); }, 1000); } - const [mcpGithubURL, setMcpGithubURL] = useState(''); - const [mcpSSEURL, setMcpSSEURL] = useState(''); - const [mcpSSEConfig, setMcpSSEConfig] = useState | null>(null); - const [mcpInstallConfig, setMcpInstallConfig] = useState | null>(null); const pluginInstalledRef = useRef(null); const mcpComponentRef = useRef(null); const [mcpTesting, setMcpTesting] = useState(false); + const [editingServerName, setEditingServerName] = useState( + null, + ); + const [isEditMode, setIsEditMode] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); // 强制清理 body 样式以修复 Dialog 关闭后点击失效的问题 useEffect(() => { - console.log('[Dialog Debug] States:', { mcpSSEModalOpen, modalOpen, showDeleteConfirmModal }); + console.log('[Dialog Debug] States:', { + mcpSSEModalOpen, + modalOpen, + showDeleteConfirmModal, + }); if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { - console.log('[Dialog Debug] All dialogs closed, cleaning up body styles...'); - console.log('[Dialog Debug] Before cleanup - body.style.pointerEvents:', document.body.style.pointerEvents); - console.log('[Dialog Debug] Before cleanup - body.style.overflow:', document.body.style.overflow); + console.log( + '[Dialog Debug] All dialogs closed, cleaning up body styles...', + ); + console.log( + '[Dialog Debug] Before cleanup - body.style.pointerEvents:', + document.body.style.pointerEvents, + ); + console.log( + '[Dialog Debug] Before cleanup - body.style.overflow:', + document.body.style.overflow, + ); const cleanup = () => { // 强制移除 body 上可能残留的样式 @@ -249,12 +244,21 @@ export default function PluginConfigPage( document.body.style.overflow = ''; } - console.log('[Dialog Debug] After cleanup - body.style.pointerEvents:', document.body.style.pointerEvents); - console.log('[Dialog Debug] After cleanup - body.style.overflow:', document.body.style.overflow); + console.log( + '[Dialog Debug] After cleanup - body.style.pointerEvents:', + document.body.style.pointerEvents, + ); + console.log( + '[Dialog Debug] After cleanup - body.style.overflow:', + document.body.style.overflow, + ); // 检查计算后的样式 const computedStyle = window.getComputedStyle(document.body); - console.log('[Dialog Debug] Computed pointerEvents:', computedStyle.pointerEvents); + console.log( + '[Dialog Debug] Computed pointerEvents:', + computedStyle.pointerEvents, + ); }; // 多次清理以确保覆盖 Radix 的设置 @@ -280,7 +284,9 @@ export default function PluginConfigPage( const interval = setInterval(() => { if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { if (document.body.style.pointerEvents === 'none') { - console.log('[Global Cleanup] Found stale pointer-events, cleaning...'); + console.log( + '[Global Cleanup] Found stale pointer-events, cleaning...', + ); document.body.style.removeProperty('pointer-events'); document.body.style.pointerEvents = ''; } @@ -294,10 +300,15 @@ export default function PluginConfigPage( useEffect(() => { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { - if (mutation.type === 'attributes' && mutation.attributeName === 'style') { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'style' + ) { if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { if (document.body.style.pointerEvents === 'none') { - console.log('[MutationObserver] Detected pointer-events being set to none, reverting...'); + console.log( + '[MutationObserver] Detected pointer-events being set to none, reverting...', + ); document.body.style.removeProperty('pointer-events'); document.body.style.pointerEvents = ''; } @@ -360,8 +371,63 @@ export default function PluginConfigPage( } } - function deleteMCPServer() { - + async function deleteMCPServer() { + if (!editingServerName) return; + + try { + await httpClient.deleteMCPServer(editingServerName); + toast.success(t('mcp.deleteSuccess')); + + // 关闭所有对话框 + setShowDeleteConfirmModal(false); + setMcpSSEModalOpen(false); + + // 重置状态 + form.reset(); + setExtraArgs([]); + setEditingServerName(null); + setIsEditMode(false); + + // 刷新服务器列表 + setRefreshKey((prev) => prev + 1); + } catch (error) { + console.error('Failed to delete server:', error); + toast.error(t('mcp.deleteFailed')); + } + } + + // 加载服务器数据用于编辑 + async function loadServerForEdit(serverName: string) { + try { + const resp = await httpClient.getMCPServer(serverName); + const server = resp.server; + + // 填充表单数据 + form.setValue('name', server.name); + form.setValue('url', server.config.url || ''); + form.setValue('timeout', server.config.timeout || 30); + form.setValue('ssereadtimeout', 300); // 默认值,如果后端有返回则使用后端的 + + // 填充 headers 作为 extra_args + if (server.config.headers) { + const headers = Object.entries(server.config.headers).map( + ([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + }), + ); + setExtraArgs(headers); + form.setValue('extra_args', headers); + } + + setEditingServerName(serverName); + setIsEditMode(true); + setMcpSSEModalOpen(true); + } catch (error) { + console.error('Failed to load server:', error); + toast.error(t('mcp.loadFailed')); + } } async function handleFormSubmit(value: z.infer) { @@ -389,22 +455,30 @@ export default function PluginConfigPage( timeout: value.timeout, }; - await httpClient.createMCPServer(serverConfig); - - toast.success(t('mcp.createSuccess')); + if (isEditMode && editingServerName) { + // 编辑模式:更新服务器 + await httpClient.updateMCPServer(editingServerName, serverConfig); + toast.success(t('mcp.updateSuccess')); + } else { + // 创建模式:新建服务器 + await httpClient.createMCPServer(serverConfig); + toast.success(t('mcp.createSuccess')); + } // 只有在异步操作成功后才关闭对话框 setMcpSSEModalOpen(false); - // 重置表单 + // 重置表单和状态 form.reset(); setExtraArgs([]); + setEditingServerName(null); + setIsEditMode(false); - // 调用回调通知父组件刷新 - onFormSubmit?.(); + // 刷新服务器列表 + setRefreshKey((prev) => prev + 1); } catch (error) { - console.error('Failed to create MCP server:', error); - toast.error(t('mcp.createFailed')); + console.error('Failed to save MCP server:', error); + toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')); } } @@ -422,9 +496,9 @@ export default function PluginConfigPage( extraArgsObj[arg.key] = arg.value; } }); - httpClient.testMCPServer( - form.getValues('name'), - ).then((res) => { + httpClient + .testMCPServer(form.getValues('name')) + .then((res) => { console.log(res); toast.success(t('models.testSuccess')); }) @@ -573,7 +647,6 @@ export default function PluginConfigPage( return renderPluginConnectionErrorState(); } - return (
)} - - {t('mcp.title')} - + + {t('mcp.title')} +
@@ -618,7 +694,9 @@ export default function PluginConfigPage( @@ -637,9 +715,13 @@ export default function PluginConfigPage( {t('mcp.installFromGithub')} */} - { setActiveTab('mcp-servers'); + setIsEditMode(false); + setEditingServerName(null); + form.reset(); + setExtraArgs([]); setMcpSSEModalOpen(true); }} > @@ -691,11 +773,9 @@ export default function PluginConfigPage( { - setMcpGithubURL(githubURL); - setMcpMarketInstallModalOpen(true); - // setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT); - setMcpInstallError(null); + key={refreshKey} + onEditServer={(serverName) => { + loadServerForEdit(serverName); }} /> @@ -781,229 +861,222 @@ export default function PluginConfigPage( open={showDeleteConfirmModal} onOpenChange={setShowDeleteConfirmModal} > - - - {t('plugins.confirmDeleteTitle')} - - - {t('plugins.deleteConfirmation')} - - - - - + + + {t('plugins.confirmDeleteTitle')} + + + {t('plugins.deleteConfirmation')} + + + + + - + { + setMcpSSEModalOpen(open); + if (!open) { + // 关闭对话框时重置编辑状态 + setIsEditMode(false); + setEditingServerName(null); + form.reset(); + setExtraArgs([]); + } + }} > - - - - {t('mcp.createServer')} - - -
- -
- ( - - {t('mcp.name')} - - - - - - )} - /> - - ( - - - {t('mcp.url')} - - - - - - - )} - /> - - ( - - - {t('mcp.timeout')} - - - - - - - ) - } - /> - - - ( + + + + {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} + + + + +
+ ( - - {t('mcp.ssereadtimeout')} - + {t('mcp.name')} - + - + - ) - } + )} /> - - {t('models.extraParameters')} -
- {extraArgs.map((arg, index) => ( -
- - updateExtraArg(index, 'key', e.target.value) - } - /> - - - updateExtraArg(index, 'value', e.target.value) - } - /> - +
+ ))} + -
- ))} - -
- - {t('llm.extraParametersDescription')} - - - + {t('models.addParameter')} + +
+ + {t('llm.extraParametersDescription')} + + + - - {editMode && ( - - )} + + {isEditMode && ( + + )} - + - + - - -
- - - + + +
+ + + -
-
+ + ); } diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 8c533ab2..6c106a29 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -553,7 +553,7 @@ export class BackendClient extends BaseHttpClient { } public installMCPServerFromSSE( - source: {}, + source: object, ): Promise { return this.post('/api/v1/mcp/servers', { source }); } diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 91823fd2..743c4b87 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -11,32 +11,35 @@ function Dialog({ open, ...props }: React.ComponentProps) { - const handleOpenChange = React.useCallback((isOpen: boolean) => { - onOpenChange?.(isOpen); + const handleOpenChange = React.useCallback( + (isOpen: boolean) => { + onOpenChange?.(isOpen); - // 当对话框关闭时,确保清理 body 样式 - if (!isOpen) { - // 立即清理 - document.body.style.removeProperty('pointer-events'); - document.body.style.removeProperty('overflow'); - - // 延迟再次清理,确保覆盖 Radix 的设置 - setTimeout(() => { + // 当对话框关闭时,确保清理 body 样式 + if (!isOpen) { + // 立即清理 document.body.style.removeProperty('pointer-events'); document.body.style.removeProperty('overflow'); - }, 0); - setTimeout(() => { - document.body.style.removeProperty('pointer-events'); - document.body.style.removeProperty('overflow'); - }, 50); + // 延迟再次清理,确保覆盖 Radix 的设置 + setTimeout(() => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }, 0); - setTimeout(() => { - document.body.style.removeProperty('pointer-events'); - document.body.style.removeProperty('overflow'); - }, 150); - } - }, [onOpenChange]); + setTimeout(() => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }, 50); + + setTimeout(() => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }, 150); + } + }, + [onOpenChange], + ); // 使用 effect 监控 open 状态变化 React.useEffect(() => { @@ -61,7 +64,14 @@ function Dialog({ } }, [open]); - return ; + return ( + + ); } function DialogTrigger({ diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index cac04c99..d4932e26 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1,4 +1,3 @@ - const zhHans = { common: { login: '登录', @@ -335,20 +334,20 @@ const zhHans = { onlySupportGithub: '目前仅支持从Github安装MCP服务器', enterGithubLink: '输入Github仓库链接', add: '添加', - name:'名称', - nameExplained:'用于区分不同的MCP服务器实例', - mcpDescription:'描述', - descriptionExplained:'简要描述这个MCP服务器的功能或用途', - sseURL:'SSE URL', - sseHeaders:'SSE Headers', - nameRequired:'名称不能为空', - sseURLRequired:'SSE URL不能为空', - enterSSELink:'输入SSE URL', - timeoutRequired:'超时时间不能为空', - headersExample:'示例: Authorization: Bearer token123', - enterTimeout:'输入超时时间,单位为毫秒', - installFromSSE:'从SSE安装', - sseTimeout:'SSE超时时间' + name: '名称', + nameExplained: '用于区分不同的MCP服务器实例', + mcpDescription: '描述', + descriptionExplained: '简要描述这个MCP服务器的功能或用途', + sseURL: 'SSE URL', + sseHeaders: 'SSE Headers', + nameRequired: '名称不能为空', + sseURLRequired: 'SSE URL不能为空', + enterSSELink: '输入SSE URL', + timeoutRequired: '超时时间不能为空', + headersExample: '示例: Authorization: Bearer token123', + enterTimeout: '输入超时时间,单位为毫秒', + installFromSSE: '从SSE安装', + sseTimeout: 'SSE超时时间', }, pipelines: { title: '流水线', From 72ca62eae4b9e37b9744e7942b9dde748806430e Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Fri, 24 Oct 2025 20:37:48 +0800 Subject: [PATCH 014/144] fix: delete description --- pkg/api/http/controller/groups/mcp.py | 3 -- pkg/entity/persistence/mcp.py | 1 - web/src/app/home/plugins/page.tsx | 60 ++++++++++++++----------- web/src/app/infra/entities/api/index.ts | 1 + 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/pkg/api/http/controller/groups/mcp.py b/pkg/api/http/controller/groups/mcp.py index 01f91f1d..9d85925a 100644 --- a/pkg/api/http/controller/groups/mcp.py +++ b/pkg/api/http/controller/groups/mcp.py @@ -83,7 +83,6 @@ class MCPRouterGroup(group.RouterGroup): 'name': data['name'], 'mode': 'sse', 'enable': data.get('enable', False), - 'description': data.get('description',''), 'extra_args': { 'url':data.get('url',''), 'headers':data.get('headers',{}), @@ -118,7 +117,6 @@ class MCPRouterGroup(group.RouterGroup): data = await quart.request.json update_data = { 'enable': data.get('enable', server.enable), - 'description': data.get('description', server.description), } extra_args = server.extra_args or {} @@ -175,7 +173,6 @@ class MCPRouterGroup(group.RouterGroup): 'name': server.name, 'mode': server.mode, 'enable': server.enable, - 'description': server.description, 'extra_args': server.extra_args or {}, }, self.ap) await session.initialize() diff --git a/pkg/entity/persistence/mcp.py b/pkg/entity/persistence/mcp.py index 8f2869f3..74478dc7 100644 --- a/pkg/entity/persistence/mcp.py +++ b/pkg/entity/persistence/mcp.py @@ -8,7 +8,6 @@ class MCPServer(Base): uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) - description = sqlalchemy.Column(sqlalchemy.Text, nullable=True) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 38f70b85..5fe06c4c 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -396,39 +396,44 @@ export default function PluginConfigPage() { } } - // 加载服务器数据用于编辑 async function loadServerForEdit(serverName: string) { - try { - const resp = await httpClient.getMCPServer(serverName); - const server = resp.server; + try { + const resp = await httpClient.getMCPServer(serverName); + const server = resp.server ?? resp; // 有的接口包了一层,有的直接返回对象 - // 填充表单数据 - form.setValue('name', server.name); - form.setValue('url', server.config.url || ''); - form.setValue('timeout', server.config.timeout || 30); - form.setValue('ssereadtimeout', 300); // 默认值,如果后端有返回则使用后端的 + console.log('Loaded server for edit:', server); - // 填充 headers 作为 extra_args - if (server.config.headers) { - const headers = Object.entries(server.config.headers).map( - ([key, value]) => ({ - key, - type: 'string' as const, - value: String(value), - }), - ); - setExtraArgs(headers); - form.setValue('extra_args', headers); - } + // 填充表单数据 + form.setValue('name', server.name); + form.setValue('url', server.extra_args?.url || ''); + form.setValue('timeout', server.extra_args?.timeout || 30); + form.setValue('ssereadtimeout', 300); - setEditingServerName(serverName); - setIsEditMode(true); - setMcpSSEModalOpen(true); - } catch (error) { - console.error('Failed to load server:', error); - toast.error(t('mcp.loadFailed')); + // 填充 headers + if (server.extra_args?.headers) { + const headers = Object.entries(server.extra_args.headers).map( + ([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + }), + ); + setExtraArgs(headers); + form.setValue('extra_args', headers); } + + //在这里返回mcp里的tools + const tools = await httpClient.getMCPTools(server.name); + + setEditingServerName(serverName); + setIsEditMode(true); + setMcpSSEModalOpen(true); + } catch (error) { + console.error('Failed to load server:', error); + toast.error(t('mcp.loadFailed')); } +} + async function handleFormSubmit(value: z.infer) { const extraArgsObj: Record = {}; @@ -895,6 +900,7 @@ export default function PluginConfigPage() { } }} > + diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index cda8c7ba..c8252385 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -319,6 +319,7 @@ export interface ApiRespMCPServer { } export interface MCPServer { + extra_args: any; name: string; mode: 'stdio' | 'sse'; enable: boolean; From e3821b3f0909cf12eb7aa0f0cf761aabd53aabf5 Mon Sep 17 00:00:00 2001 From: wangcham Date: Fri, 24 Oct 2025 17:48:44 +0000 Subject: [PATCH 015/144] feat: add mcp servers --- pkg/api/http/controller/groups/mcp.py | 42 ++- pkg/core/taskmgr.py | 2 +- .../plugins/mcp-market/MCPMarketComponent.tsx | 19 +- web/src/app/home/plugins/mcp/MCPCardVO.ts | 3 +- .../plugins/mcp/mcp-card/MCPCardComponent.tsx | 65 ++-- web/src/app/home/plugins/page.tsx | 338 +++++++++++++++--- web/src/app/infra/http/BackendClient.ts | 4 +- web/src/i18n/locales/zh-Hans.ts | 10 +- 8 files changed, 396 insertions(+), 87 deletions(-) diff --git a/pkg/api/http/controller/groups/mcp.py b/pkg/api/http/controller/groups/mcp.py index 9d85925a..f2abb9da 100644 --- a/pkg/api/http/controller/groups/mcp.py +++ b/pkg/api/http/controller/groups/mcp.py @@ -29,6 +29,13 @@ class MCPRouterGroup(group.RouterGroup): servers = [self.ap.persistence_mgr.serialize_model(MCPServer, row) for row in raw_results] servers_with_status = [] + # 获取MCP工具加载器 + mcp_loader = None + for loader in self.ap.tool_mgr.loaders: + if loader.__class__.__name__ == 'MCPLoader': + mcp_loader = loader + break + for server in servers: # 设置状态 if server['enable']: @@ -54,12 +61,18 @@ class MCPRouterGroup(group.RouterGroup): config['args'] = extra_args.get('args', []) config['env'] = extra_args.get('env', {}) + # 从运行中的会话获取工具数量 + tools_count = 0 + if mcp_loader and hasattr(mcp_loader, 'sessions') and server['name'] in mcp_loader.sessions: + session = mcp_loader.sessions[server['name']] + tools_count = len(session.functions) + server_info = { 'name': server['name'], 'mode': server['mode'], 'enable': server['enable'], 'status': status, - 'tools': [], # 暂时返回空数组,需要连接到MCP服务器才能获取工具列表 + 'tools': tools_count, # 从运行中的会话获取工具数量 'config': config, } servers_with_status.append(server_info) @@ -87,6 +100,7 @@ class MCPRouterGroup(group.RouterGroup): 'url':data.get('url',''), 'headers':data.get('headers',{}), 'timeout':data.get('timeout',60), + 'ssereadtimeout':data.get('ssereadtimeout',300), }, } @@ -96,7 +110,7 @@ class MCPRouterGroup(group.RouterGroup): return self.success() - except Exception as e: + except Exception: print(traceback.format_exc()) @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) @@ -125,6 +139,7 @@ class MCPRouterGroup(group.RouterGroup): 'url': data.get('url', extra_args.get('url','')), 'headers': data.get('headers', extra_args.get('headers',{})), 'timeout': data.get('timeout', extra_args.get('timeout',60)), + 'ssereadtimeout': data.get('ssereadtimeout', extra_args.get('ssereadtimeout',300)), }) update_data['extra_args'] = extra_args @@ -167,26 +182,33 @@ class MCPRouterGroup(group.RouterGroup): from .....provider.tools.loaders.mcp import RuntimeMCPSession ctx.current_action = f'Testing connection to {server.name}' - + print(server) # 创建临时会话进行测试 session = RuntimeMCPSession(server.name, { - 'name': server.name, - 'mode': server.mode, - 'enable': server.enable, - 'extra_args': server.extra_args or {}, - }, self.ap) - await session.initialize() + 'name': server.name, + 'mode': server.mode, + 'enable': server.enable, + 'url': server.extra_args.get('url',''), + 'headers': server.extra_args.get('headers',{}), + 'timeout': server.extra_args.get('timeout',60), + },enable=True, ap=self.ap) + await session.start() # 获取工具列表作为测试 tools_count = len(session.functions) + + tool_name_list = [] + for function in session.functions: + tool_name_list.append(function.name) ctx.current_action = f'Successfully connected. Found {tools_count} tools.' # 关闭测试会话 await session.shutdown() - return {'status': 'success', 'tools_count': tools_count} + return {'status': 'success', 'tools_count': tools_count,'tools_names_lists':tool_name_list} except Exception as e: + print(traceback.format_exc()) ctx.current_action = f'Connection test failed: {str(e)}' raise e diff --git a/pkg/core/taskmgr.py b/pkg/core/taskmgr.py index ca6eb029..4eee7104 100644 --- a/pkg/core/taskmgr.py +++ b/pkg/core/taskmgr.py @@ -156,7 +156,7 @@ class TaskWrapper: 'state': self.task._state, 'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None, 'exception_traceback': exception_traceback, - 'result': self.assume_result().__str__() if self.assume_result() is not None else None, + 'result': self.assume_result() if self.assume_result() is not None else None, }, } diff --git a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx index a529d67f..d8839678 100644 --- a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx +++ b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx @@ -29,9 +29,11 @@ import { httpClient } from '@/app/infra/http/HttpClient'; export default function MCPMarketComponent({ onEditServer, + toolsCountCache = {}, }: { askInstallServer?: (githubURL: string) => void; onEditServer?: (serverName: string) => void; + toolsCountCache?: Record; }) { const { t } = useTranslation(); // const [marketServerList, setMarketServerList] = useState( @@ -52,6 +54,12 @@ export default function MCPMarketComponent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 当工具数量缓存变化时,重新获取服务器列表 + useEffect(() => { + fetchInstalledServers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toolsCountCache]); + function initData() { fetchInstalledServers(); // getServerList(); // GitHub 市场功能暂时注释 @@ -62,7 +70,14 @@ export default function MCPMarketComponent({ httpClient .getMCPServers() .then((resp) => { - const servers = resp.servers.map((server) => new MCPCardVO(server)); + const servers = resp.servers.map((server) => { + const vo = new MCPCardVO(server); + // 如果缓存中有工具数量,使用缓存值覆盖 + if (toolsCountCache[server.name] !== undefined) { + vo.tools = toolsCountCache[server.name]; + } + return vo; + }); setInstalledServers(servers); setLoading(false); }) @@ -147,7 +162,7 @@ export default function MCPMarketComponent({ {/* 已安装的服务器列表 */}

- {t('mcp.installedServers')} + {t('mcp.title')}

{loading ? ( diff --git a/web/src/app/home/plugins/mcp/MCPCardVO.ts b/web/src/app/home/plugins/mcp/MCPCardVO.ts index c35f5508..45cca9ba 100644 --- a/web/src/app/home/plugins/mcp/MCPCardVO.ts +++ b/web/src/app/home/plugins/mcp/MCPCardVO.ts @@ -14,7 +14,8 @@ export class MCPCardVO { this.mode = data.mode; this.enable = data.enable; this.status = data.status; - this.tools = data.tools.length; + // tools可能是数组或数字 + this.tools = Array.isArray(data.tools) ? data.tools.length : (data.tools || 0); this.error = data.error; this.config = data.config; } diff --git a/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx index 7a7953e6..c2d1dfcb 100644 --- a/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx +++ b/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx @@ -20,30 +20,17 @@ export default function MCPCardComponent({ const [enabled, setEnabled] = useState(cardVO.enable); const [switchEnable, setSwitchEnable] = useState(true); const [testing, setTesting] = useState(false); + const [toolsCount, setToolsCount] = useState(cardVO.tools); - function handleEnable(e: React.MouseEvent) { - e.stopPropagation(); // 阻止事件冒泡 + function handleEnable(checked: boolean) { 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); + .toggleMCPServer(cardVO.name, checked) + .then(() => { + setEnabled(checked); + toast.success(t('mcp.saveSuccess')); + onRefresh(); + setSwitchEnable(true); }) .catch((err) => { toast.error(t('mcp.modifyFailed') + err.message); @@ -66,7 +53,32 @@ export default function MCPCardComponent({ if (taskResp.runtime.exception) { toast.error(t('mcp.testFailed') + taskResp.runtime.exception); } else { - toast.success(t('mcp.testSuccess')); + // 解析测试结果获取工具数量 + try { + let result: { + status?: string; + tools_count?: number; + tools_names_lists?: string[]; + error?: string; + }; + + const rawResult: any = taskResp.runtime.result; + if (typeof rawResult === 'string') { + result = JSON.parse(rawResult.replace(/'/g, '"')); + } else { + result = rawResult as typeof result; + } + + if (result.tools_count !== undefined) { + setToolsCount(result.tools_count); + toast.success(t('mcp.testSuccess') + ` - ${result.tools_count} ${t('mcp.toolsFound')}`); + } else { + toast.success(t('mcp.testSuccess')); + } + } catch (parseError) { + console.error('Failed to parse test result:', parseError); + toast.success(t('mcp.testSuccess')); + } onRefresh(); } setTesting(false); @@ -147,18 +159,21 @@ export default function MCPCardComponent({
- {t('mcp.toolCount', { count: cardVO.tools })} + {t('mcp.toolCount', { count: toolsCount })}
-
+
e.stopPropagation()} + > handleEnable(e)} + onCheckedChange={handleEnable} disabled={!switchEnable} />
diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 5fe06c4c..6d02627c 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -42,11 +42,12 @@ import { systemInfo } from '@/app/infra/http/HttpClient'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { Select, - SelectContent, - SelectItem, SelectTrigger, SelectValue, -} from '@radix-ui/react-select'; + SelectContent, + SelectItem, +} from "@/components/ui/select" + import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -173,7 +174,7 @@ export default function PluginConfigPage() { fetchPluginSystemStatus(); }, [t]); - + //这个是旧版本的测试github url,下面重写了一个新版本的watchTask函数,用来检测Mcp function watchTask(taskId: number) { let alreadySuccess = false; console.log('taskId:', taskId); @@ -200,7 +201,53 @@ export default function PluginConfigPage() { } }); }, 1000); + } + + function watchTestMCPTask(taskId: number) { + let alreadyHandled = false; + console.log('Watching MCP test task:', taskId); + + const interval = setInterval(() => { + httpClient.getAsyncTask(taskId).then((resp) => { + console.log('task status:', resp); + + // 若任务已完成 + if (resp.runtime && resp.runtime.done) { + clearInterval(interval); + + if (resp.runtime.exception) { + // 任务失败 + toast.error(`测试失败: ${resp.runtime.exception}`); + } else if (resp.runtime.result) { + // 任务成功 + const result = resp.runtime.result as { + status?: string; + tools_count?: number; + tools_names_lists?: string[]; + error?: string; + }; + const names = result.tools_names_lists || []; + + if (!alreadyHandled) { + alreadyHandled = true; + const names = result.tools_names_lists || []; + toast.success(`连接成功,找到 ${names.length} 个工具`); + console.log('工具列表:', names); + } + } else { + // 没结果但标记为完成 + toast.error('测试任务完成但未返回结果'); + } + } + }).catch((err) => { + console.error('任务状态获取失败:', err); + toast.error('获取任务状态失败'); + clearInterval(interval); + }); + }, 1000); +} + const pluginInstalledRef = useRef(null); const mcpComponentRef = useRef(null); const [mcpTesting, setMcpTesting] = useState(false); @@ -210,6 +257,14 @@ export default function PluginConfigPage() { const [isEditMode, setIsEditMode] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + // MCP测试结果状态 + const [mcpTestStatus, setMcpTestStatus] = useState<'idle' | 'testing' | 'success' | 'failed'>('idle'); + const [mcpToolNames, setMcpToolNames] = useState([]); + const [mcpTestError, setMcpTestError] = useState(''); + + // 缓存每个服务器测试后的工具数量 + const [serverToolsCache, setServerToolsCache] = useState>({}); + // 强制清理 body 样式以修复 Dialog 关闭后点击失效的问题 useEffect(() => { console.log('[Dialog Debug] States:', { @@ -397,43 +452,140 @@ export default function PluginConfigPage() { } async function loadServerForEdit(serverName: string) { - try { - const resp = await httpClient.getMCPServer(serverName); - const server = resp.server ?? resp; // 有的接口包了一层,有的直接返回对象 + try { + const resp = await httpClient.getMCPServer(serverName); + const server = resp.server ?? resp; // 有的接口包了一层,有的直接返回对象 + console.log('Loaded server for edit:', server); - console.log('Loaded server for edit:', server); + // 填充表单数据 + form.setValue('name', server.name); + form.setValue('url', server.extra_args?.url || ''); + form.setValue('timeout', server.extra_args?.timeout || 30); + form.setValue('ssereadtimeout', server.extra_args?.ssereadtimeout || 300); - // 填充表单数据 - form.setValue('name', server.name); - form.setValue('url', server.extra_args?.url || ''); - form.setValue('timeout', server.extra_args?.timeout || 30); - form.setValue('ssereadtimeout', 300); + // 填充 headers + if (server.extra_args?.headers) { + const headers = Object.entries(server.extra_args.headers).map( + ([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + }), + ); + setExtraArgs(headers); + form.setValue('extra_args', headers); + } - // 填充 headers - if (server.extra_args?.headers) { - const headers = Object.entries(server.extra_args.headers).map( - ([key, value]) => ({ - key, - type: 'string' as const, - value: String(value), - }), - ); - setExtraArgs(headers); - form.setValue('extra_args', headers); + // 重置测试状态 + setMcpTestStatus('testing'); + setMcpToolNames([]); + setMcpTestError(''); + + // 打开对话框 + setEditingServerName(serverName); + setIsEditMode(true); + setMcpSSEModalOpen(true); + + // 在这里测试工具连接状态 + try { + const res = await httpClient.testMCPServer(server.name); + if (res.task_id) { + const taskId = res.task_id; + + // 监听任务完成 + const interval = setInterval(() => { + httpClient.getAsyncTask(taskId).then((taskResp) => { + console.log('Task response:', taskResp); + + if (taskResp.runtime && taskResp.runtime.done) { + clearInterval(interval); + + console.log('Task completed. Runtime:', taskResp.runtime); + console.log('Result:', taskResp.runtime.result); + console.log('Exception:', taskResp.runtime.exception); + + if (taskResp.runtime.exception) { + // 测试失败 + console.log('Test failed with exception'); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError(taskResp.runtime.exception || '未知错误'); + } else if (taskResp.runtime.result) { + // 测试成功 - 后端可能返回字符串或对象 + try { + let result: { + status?: string; + tools_count?: number; + tools_names_lists?: string[]; + error?: string; + }; + + // 如果result是字符串,需要先解析 + const rawResult: any = taskResp.runtime.result; + if (typeof rawResult === 'string') { + console.log('Result is string, parsing...'); + result = JSON.parse(rawResult.replace(/'/g, '"')); + } else { + result = rawResult as typeof result; + } + + console.log('Parsed result:', result); + console.log('tools_names_lists:', result.tools_names_lists); + console.log('tools_names_lists length:', result.tools_names_lists?.length); + + if (result.tools_names_lists && result.tools_names_lists.length > 0) { + console.log('Test success with', result.tools_names_lists.length, 'tools'); + setMcpTestStatus('success'); + setMcpToolNames(result.tools_names_lists); + // 保存工具数量到缓存 + setServerToolsCache(prev => ({ + ...prev, + [server.name]: result.tools_names_lists!.length + })); + } else { + console.log('Test failed: no tools found'); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('未找到任何工具'); + } + } catch (parseError) { + console.error('Failed to parse result:', parseError); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('解析测试结果失败'); + } + } else { + // 没结果 + console.log('Test failed: no result'); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('测试未返回结果'); + } + } + }).catch((err) => { + console.error('获取任务状态失败:', err); + clearInterval(interval); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError(err.message || '获取任务状态失败'); + }); + }, 1000); + } else { + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('未获取到任务ID'); + } + } catch (error) { + console.error('Failed to test server:', error); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError((error as Error).message || '测试连接时发生错误'); + } + } catch (error) { + console.error('Failed to load server:', error); + toast.error(t('mcp.loadFailed')); } - - //在这里返回mcp里的tools - const tools = await httpClient.getMCPTools(server.name); - - setEditingServerName(serverName); - setIsEditMode(true); - setMcpSSEModalOpen(true); - } catch (error) { - console.error('Failed to load server:', error); - toast.error(t('mcp.loadFailed')); } -} - async function handleFormSubmit(value: z.infer) { const extraArgsObj: Record = {}; @@ -458,6 +610,7 @@ export default function PluginConfigPage() { url: value.url, headers: extraArgsObj as Record, timeout: value.timeout, + ssereadtimeout: value.ssereadtimeout, }; if (isEditMode && editingServerName) { @@ -782,6 +935,7 @@ export default function PluginConfigPage() { onEditServer={(serverName) => { loadServerForEdit(serverName); }} + toolsCountCache={serverToolsCache} /> @@ -868,10 +1022,10 @@ export default function PluginConfigPage() { > - {t('plugins.confirmDeleteTitle')} + {t('mcp.confirmDeleteTitle')} - {t('plugins.deleteConfirmation')} + {t('mcp.confirmDeleteServer')}
- {t('llm.extraParametersDescription')} + {t('mcp.extraParametersDescription')} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 6c106a29..77439691 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -521,8 +521,8 @@ export class BackendClient extends BaseHttpClient { serverName: string, target_enabled: boolean, ): Promise { - return this.put(`/api/v1/mcp/servers/${serverName}/toggle`, { - target_enabled, + return this.put(`/api/v1/mcp/servers/${serverName}`, { + enable: target_enabled, }); } diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index d4932e26..91d49597 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -276,6 +276,8 @@ const zhHans = { createServer: '创建MCP服务器', editServer: '编辑MCP服务器', deleteServer: '删除MCP服务器', + confirmDeleteServer: '你确定要删除此MCP服务器吗?', + confirmDeleteTitle: '删除MCP服务器', getServerListError: '获取MCP服务器列表失败:', serverName: '服务器名称', serverMode: '连接模式', @@ -304,7 +306,9 @@ const zhHans = { testing: '测试中...', testSuccess: '连接测试成功', testFailed: '连接测试失败:', - confirmDeleteServer: '你确定要删除MCP服务器({{name}})吗?', + connectionSuccess: '连接成功', + connectionFailed: '连接失败', + toolsFound: '个工具', deleteSuccess: '删除成功', deleteError: '删除失败:', saveSuccess: '保存成功', @@ -336,8 +340,6 @@ const zhHans = { add: '添加', name: '名称', nameExplained: '用于区分不同的MCP服务器实例', - mcpDescription: '描述', - descriptionExplained: '简要描述这个MCP服务器的功能或用途', sseURL: 'SSE URL', sseHeaders: 'SSE Headers', nameRequired: '名称不能为空', @@ -348,6 +350,8 @@ const zhHans = { enterTimeout: '输入超时时间,单位为毫秒', installFromSSE: '从SSE安装', sseTimeout: 'SSE超时时间', + sseTimeoutDescription: '用于建立SSE连接的超时时间', + extraParametersDescription: '额外参数,用于配置MCP服务器的特定行为', }, pipelines: { title: '流水线', From 8345edd9f7e0fa5807e2267b37cff8346957e309 Mon Sep 17 00:00:00 2001 From: wangcham Date: Sat, 25 Oct 2025 01:58:52 +0000 Subject: [PATCH 016/144] fix: status icon --- web/src/app/home/plugins/mcp/MCPCardVO.ts | 15 +- web/src/app/home/plugins/mcp/MCPComponent.tsx | 131 ++++++++++++++++-- .../plugins/mcp/mcp-card/MCPCardComponent.tsx | 111 +++++++++++---- web/src/app/infra/entities/api/index.ts | 2 +- web/src/i18n/locales/zh-Hans.ts | 1 + 5 files changed, 221 insertions(+), 39 deletions(-) diff --git a/web/src/app/home/plugins/mcp/MCPCardVO.ts b/web/src/app/home/plugins/mcp/MCPCardVO.ts index 45cca9ba..67330b61 100644 --- a/web/src/app/home/plugins/mcp/MCPCardVO.ts +++ b/web/src/app/home/plugins/mcp/MCPCardVO.ts @@ -4,7 +4,7 @@ export class MCPCardVO { name: string; mode: 'stdio' | 'sse'; enable: boolean; - status: 'connected' | 'disconnected' | 'error'; + status: 'connected' | 'disconnected' | 'error' | 'disabled'; tools: number; error?: string; config: MCPServerConfig; @@ -13,9 +13,14 @@ export class MCPCardVO { this.name = data.name; this.mode = data.mode; this.enable = data.enable; - this.status = data.status; + // 将后端返回的 "enabled" 状态映射为 "connected" + this.status = (data.status as string) === 'enabled' + ? 'connected' + : data.status; // tools可能是数组或数字 - this.tools = Array.isArray(data.tools) ? data.tools.length : (data.tools || 0); + this.tools = Array.isArray(data.tools) + ? data.tools.length + : data.tools || 0; this.error = data.error; this.config = data.config; } @@ -28,6 +33,8 @@ export class MCPCardVO { return 'text-gray-500'; case 'error': return 'text-red-600'; + case 'disabled': + return 'text-gray-400'; default: return 'text-gray-500'; } @@ -41,6 +48,8 @@ export class MCPCardVO { 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'; + case 'disabled': + return 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636'; 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 index 63299b95..b89b743f 100644 --- a/web/src/app/home/plugins/mcp/MCPComponent.tsx +++ b/web/src/app/home/plugins/mcp/MCPComponent.tsx @@ -31,7 +31,7 @@ export interface MCPComponentRef { } // eslint-disable-next-line react/display-name -const MCPComponent = forwardRef((props, ref) => { +const MCPComponent = forwardRef((_props, ref) => { const { t } = useTranslation(); const [serverList, setServerList] = useState([]); const [modalOpen, setModalOpen] = useState(false); @@ -39,6 +39,9 @@ const MCPComponent = forwardRef((props, ref) => { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [serverToDelete, setServerToDelete] = useState(null); const [deleting, setDeleting] = useState(false); + const [autoTestTriggered, setAutoTestTriggered] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [testingServers, setTestingServers] = useState>(new Set()); useEffect(() => { initData(); @@ -46,22 +49,130 @@ const MCPComponent = forwardRef((props, ref) => { }, []); function initData() { - getServerList(); + getServerList(true); } - function getServerList() { + function getServerList(shouldAutoTest: boolean = false) { + console.log('[MCP] Fetching server list...'); httpClient .getMCPServers() .then((value) => { - setServerList(value.servers.map((server) => new MCPCardVO(server))); + const servers = value.servers.map((server) => new MCPCardVO(server)); + console.log( + '[MCP] Server list updated:', + servers.map((s) => ({ + name: s.name, + status: s.status, + tools: s.tools, + })), + ); + setServerList(servers); + + // 自动测试:仅在初始加载且还未触发过自动测试时执行 + if (shouldAutoTest && !autoTestTriggered && servers.length > 0) { + setAutoTestTriggered(true); + testAllServers(servers); + } }) .catch((error) => { toast.error(t('mcp.getServerListError') + error.message); }); } + async function testAllServers(servers: MCPCardVO[]) { + // 为每个服务器启动测试 + console.log('[MCP] Starting tests for all servers:', servers.length); + const testPromises = servers.map((server) => testServer(server.name)); + + // 等待所有测试完成 + try { + await Promise.all(testPromises); + console.log('[MCP] All tests completed, refreshing server list...'); + // 所有测试完成后,延迟1秒再刷新,确保后端状态已更新 + setTimeout(() => { + console.log('[MCP] Refreshing server list after tests'); + getServerList(false); + }, 1000); + } catch (err) { + console.error('[MCP] Some tests failed:', err); + // 即使有失败,也要刷新列表 + setTimeout(() => { + console.log('[MCP] Refreshing server list after test failures'); + getServerList(false); + }, 1000); + } + } + + function testServer(serverName: string): Promise { + return new Promise((resolve, reject) => { + // 标记为正在测试 + console.log(`[MCP] Starting test for server: ${serverName}`); + setTestingServers((prev) => new Set(prev).add(serverName)); + + httpClient + .testMCPServer(serverName) + .then((resp) => { + const taskId = resp.task_id; + console.log( + `[MCP] Test task created for ${serverName}, task_id: ${taskId}`, + ); + // 监控任务状态 + const interval = setInterval(() => { + httpClient + .getAsyncTask(taskId) + .then((taskResp) => { + if (taskResp.runtime.done) { + clearInterval(interval); + // 标记测试完成 + setTestingServers((prev) => { + const newSet = new Set(prev); + newSet.delete(serverName); + return newSet; + }); + + if (taskResp.runtime.exception) { + console.error( + `[MCP] Test failed for ${serverName}:`, + taskResp.runtime.exception, + ); + reject(new Error(taskResp.runtime.exception)); + } else { + console.log( + `[MCP] Test completed successfully for ${serverName}`, + ); + resolve(); + } + } + }) + .catch((err) => { + clearInterval(interval); + setTestingServers((prev) => { + const newSet = new Set(prev); + newSet.delete(serverName); + return newSet; + }); + console.error( + `[MCP] Error monitoring task for ${serverName}:`, + err, + ); + reject(err); + }); + }, 1000); + }) + .catch((err) => { + console.error(`[MCP] Failed to start test for ${serverName}:`, err); + setTestingServers((prev) => { + const newSet = new Set(prev); + newSet.delete(serverName); + return newSet; + }); + reject(err); + }); + }); + } + useImperativeHandle(ref, () => ({ - refreshServerList: getServerList, + refreshServerList: () => getServerList(false), createServer: () => { setSelectedServer(null); setModalOpen(true); @@ -99,7 +210,7 @@ const MCPComponent = forwardRef((props, ref) => { toast.error(t('mcp.deleteError') + taskResp.runtime.exception); } else { toast.success(t('mcp.deleteSuccess')); - getServerList(); + getServerList(false); } } }); @@ -128,13 +239,13 @@ const MCPComponent = forwardRef((props, ref) => {
) : (
- {serverList.map((vo, index) => { + {serverList.map((vo) => { return ( -
+
handleServerClick(vo)} - onRefresh={getServerList} + onRefresh={() => getServerList(false)} /> {/* 删除按钮 */} @@ -177,7 +288,7 @@ const MCPComponent = forwardRef((props, ref) => { isEdit={!!selectedServer} onFormSubmit={() => { setModalOpen(false); - getServerList(); + getServerList(false); }} onFormCancel={() => { setModalOpen(false); diff --git a/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx index c2d1dfcb..1dd5cb2e 100644 --- a/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx +++ b/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx @@ -1,5 +1,5 @@ import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; @@ -21,6 +21,51 @@ export default function MCPCardComponent({ const [switchEnable, setSwitchEnable] = useState(true); const [testing, setTesting] = useState(false); const [toolsCount, setToolsCount] = useState(cardVO.tools); + const [status, setStatus] = useState(cardVO.status); + const [error, setError] = useState(cardVO.error); + + // 响应cardVO的变化,更新本地状态 + useEffect(() => { + console.log(`[MCPCard ${cardVO.name}] Status updated:`, { + status: cardVO.status, + tools: cardVO.tools, + error: cardVO.error, + }); + setStatus(cardVO.status); + setError(cardVO.error); + setToolsCount(cardVO.tools); + setEnabled(cardVO.enable); + }, [cardVO.name, cardVO.status, cardVO.error, cardVO.tools, cardVO.enable]); + + function getStatusColor(): string { + switch (status) { + case 'connected': + return 'text-green-600'; + case 'disconnected': + return 'text-gray-500'; + case 'error': + return 'text-red-600'; + case 'disabled': + return 'text-gray-400'; + default: + return 'text-gray-500'; + } + } + + function getStatusIcon(): string { + switch (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'; + case 'disabled': + return 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636'; + default: + return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z'; + } + } function handleEnable(checked: boolean) { setSwitchEnable(false); @@ -55,23 +100,39 @@ export default function MCPCardComponent({ } else { // 解析测试结果获取工具数量 try { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; + const rawResult = taskResp.runtime.result as + | string + | { + status?: string; + tools_count?: number; + tools_names_lists?: string[]; + error?: string; + } + | undefined; - const rawResult: any = taskResp.runtime.result; - if (typeof rawResult === 'string') { - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult as typeof result; - } + if (rawResult) { + let result: { + status?: string; + tools_count?: number; + tools_names_lists?: string[]; + error?: string; + }; - if (result.tools_count !== undefined) { - setToolsCount(result.tools_count); - toast.success(t('mcp.testSuccess') + ` - ${result.tools_count} ${t('mcp.toolsFound')}`); + if (typeof rawResult === 'string') { + result = JSON.parse(rawResult.replace(/'/g, '"')); + } else { + result = rawResult; + } + + if (result.tools_count !== undefined) { + setToolsCount(result.tools_count); + toast.success( + t('mcp.testSuccess') + + ` - ${result.tools_count} ${t('mcp.toolsFound')}`, + ); + } else { + toast.success(t('mcp.testSuccess')); + } } else { toast.success(t('mcp.testSuccess')); } @@ -120,7 +181,7 @@ export default function MCPCardComponent({
-
- {cardVO.status === 'connected' && t('mcp.statusConnected')} - {cardVO.status === 'disconnected' && - t('mcp.statusDisconnected')} - {cardVO.status === 'error' && t('mcp.statusError')} +
+ {status === 'connected' && t('mcp.statusConnected')} + {status === 'disconnected' && t('mcp.statusDisconnected')} + {status === 'error' && t('mcp.statusError')} + {status === 'disabled' && t('mcp.statusDisabled')}
- {cardVO.error && ( + {error && (
- {cardVO.error} + {error}
)}
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index c8252385..677c91ab 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -324,7 +324,7 @@ export interface MCPServer { mode: 'stdio' | 'sse'; enable: boolean; config: MCPServerConfig; - status: 'connected' | 'disconnected' | 'error'; + status: 'connected' | 'disconnected' | 'error' | 'disabled'; tools: MCPTool[]; error?: string; } diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 91d49597..82942deb 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -320,6 +320,7 @@ const zhHans = { statusConnected: '已连接', statusDisconnected: '未连接', statusError: '连接错误', + statusDisabled: '已禁用', serverStatus: '服务器状态', marketplace: 'MCP商店', searchServer: '搜索MCP服务器', From d86b884cab4d5d3f7636b0949a1b0a3e197fd18c Mon Sep 17 00:00:00 2001 From: wangcham Date: Sat, 25 Oct 2025 02:28:20 +0000 Subject: [PATCH 017/144] feat: mcp-ui --- pkg/provider/tools/loaders/mcp.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 8677f41c..721e0782 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -172,6 +172,7 @@ class MCPLoader(loader.ToolLoader): server_entity: persistence_mcp.MCPServer | sqlalchemy.Row[persistence_mcp.MCPServer] | dict, ): session = await self.init_runtime_mcp_session(server_entity) + await session.start() self.sessions[server_entity.name] = session async def get_tools(self) -> list[resource_tool.LLMTool]: @@ -188,7 +189,7 @@ class MCPLoader(loader.ToolLoader): return name in [f.name for f in self._last_listed_functions] async def invoke_tool(self, name: str, parameters: dict) -> typing.Any: - for server_name, session in self.sessions.items(): + for session in self.sessions.values(): for function in session.functions: if function.name == name: return await function.func(**parameters) From 9f2f1cd5777b38b238e0985186b9f9c1afbffd5e Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 26 Oct 2025 23:39:34 +0900 Subject: [PATCH 018/144] perf: remove title from mcp mgm page --- web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx index d8839678..28f6ff86 100644 --- a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx +++ b/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx @@ -161,9 +161,6 @@ export default function MCPMarketComponent({
{/* 已安装的服务器列表 */}
-

- {t('mcp.title')} -

{loading ? (
From 166eebabff6329f63326445f4c629efee7a4245c Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Tue, 28 Oct 2025 13:11:09 +0800 Subject: [PATCH 019/144] fix: delete mcp market --- .../MCPServerComponent.tsx} | 2 +- .../MCPServerCardComponent.tsx} | 2 +- .../mcp-market-card/MCPServerCardVO.ts} | 0 web/src/app/home/plugins/page.tsx | 119 +++++++++--------- 4 files changed, 64 insertions(+), 59 deletions(-) rename web/src/app/home/plugins/{mcp-market/MCPMarketComponent.tsx => mcp-server/MCPServerComponent.tsx} (99%) rename web/src/app/home/plugins/{mcp-market/mcp-market-card/MCPMarketCardComponent.tsx => mcp-server/mcp-market-card/MCPServerCardComponent.tsx} (98%) rename web/src/app/home/plugins/{mcp-market/mcp-market-card/MCPMarketCardVO.ts => mcp-server/mcp-market-card/MCPServerCardVO.ts} (100%) diff --git a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx similarity index 99% rename from web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx rename to web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx index 28f6ff86..83fc0851 100644 --- a/web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx @@ -168,7 +168,7 @@ export default function MCPMarketComponent({
) : installedServers.length === 0 ? (
- {t('mcp.noInstalledServers')} + {t('mcp.noServerInstalled')}
) : ( installedServers.map((server, index) => ( diff --git a/web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardComponent.tsx similarity index 98% rename from web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent.tsx rename to web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardComponent.tsx index 330fe228..2190147f 100644 --- a/web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardComponent.tsx @@ -1,4 +1,4 @@ -import { MCPMarketCardVO } from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO'; +import { MCPMarketCardVO } from '@/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardVO'; import { Button } from '@/components/ui/button'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO.ts b/web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardVO.ts similarity index 100% rename from web/src/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO.ts rename to web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardVO.ts diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 6d02627c..8793daa3 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -7,7 +7,7 @@ import MarketPage 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 MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent'; import styles from './plugins.module.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; @@ -48,7 +48,7 @@ import { SelectItem, } from "@/components/ui/select" -import { useForm } from 'react-hook-form'; +import { Resolver, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { DialogDescription } from '@radix-ui/react-dialog'; @@ -121,26 +121,48 @@ export default function PluginConfigPage() { form.setValue('extra_args', newArgs); }; const getFormSchema = (t: (key: string) => string) => - z.object({ - name: z.string().min(1, { message: t('mcp.nameRequired') }), - timeout: z.number().min(30, { message: t('mcp.timeoutMin30') }), - ssereadtimeout: z - .number() - .min(300, { message: t('mcp.sseTimeoutMin300') }), - url: z.string().min(1, { message: t('mcp.requestURLRequired') }), - extra_args: z.array(getExtraArgSchema(t)).optional(), - }); - const formSchema = getFormSchema(t); - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: '', - url: '', - timeout: 30, - ssereadtimeout: 300, - extra_args: [], - }, + z.object({ + name: z.string({ required_error: t('mcp.nameRequired') }), + timeout: z + .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) + .nonnegative({ message: t('mcp.timeoutNonNegative') }) + .default(30), + ssereadtimeout: z + .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) + .nonnegative({ message: t('mcp.sseTimeoutNonNegative') }) + .default(300), + url: z.string({ required_error: t('models.requestURLRequired') }), + extra_args: z + .array( + z.object({ + key: z.string(), + type: z.enum(['string', 'number', 'boolean']), + value: z.string(), + }) + ) + .optional(), }); + +const formSchema = getFormSchema(t); + + +type FormValues = z.infer & { + timeout: number; + ssereadtimeout: number; +}; + +const form = useForm({ + resolver: zodResolver(formSchema) as unknown as Resolver, + defaultValues: { + name: '', + url: '', + timeout: 30, + ssereadtimeout: 300, + extra_args: [], + }, +}); + + const [extraArgs, setExtraArgs] = useState< { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] >([]); @@ -274,24 +296,12 @@ export default function PluginConfigPage() { }); if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { - console.log( - '[Dialog Debug] All dialogs closed, cleaning up body styles...', - ); - console.log( - '[Dialog Debug] Before cleanup - body.style.pointerEvents:', - document.body.style.pointerEvents, - ); - console.log( - '[Dialog Debug] Before cleanup - body.style.overflow:', - document.body.style.overflow, - ); - const cleanup = () => { - // 强制移除 body 上可能残留的样式 + document.body.style.removeProperty('pointer-events'); document.body.style.removeProperty('overflow'); - // 如果 removeProperty 不起作用,强制设置为空字符串 + if (document.body.style.pointerEvents === 'none') { document.body.style.pointerEvents = ''; } @@ -334,7 +344,7 @@ export default function PluginConfigPage() { } }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); - // 额外的全局清理:定期检查并清理 + useEffect(() => { const interval = setInterval(() => { if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { @@ -454,16 +464,16 @@ export default function PluginConfigPage() { async function loadServerForEdit(serverName: string) { try { const resp = await httpClient.getMCPServer(serverName); - const server = resp.server ?? resp; // 有的接口包了一层,有的直接返回对象 + const server = resp.server ?? resp; console.log('Loaded server for edit:', server); - // 填充表单数据 + form.setValue('name', server.name); form.setValue('url', server.extra_args?.url || ''); form.setValue('timeout', server.extra_args?.timeout || 30); form.setValue('ssereadtimeout', server.extra_args?.ssereadtimeout || 300); - // 填充 headers + if (server.extra_args?.headers) { const headers = Object.entries(server.extra_args.headers).map( ([key, value]) => ({ @@ -476,23 +486,23 @@ export default function PluginConfigPage() { form.setValue('extra_args', headers); } - // 重置测试状态 + setMcpTestStatus('testing'); setMcpToolNames([]); setMcpTestError(''); - // 打开对话框 + setEditingServerName(serverName); setIsEditMode(true); setMcpSSEModalOpen(true); - // 在这里测试工具连接状态 + try { const res = await httpClient.testMCPServer(server.name); if (res.task_id) { const taskId = res.task_id; - // 监听任务完成 + const interval = setInterval(() => { httpClient.getAsyncTask(taskId).then((taskResp) => { console.log('Task response:', taskResp); @@ -505,13 +515,13 @@ export default function PluginConfigPage() { console.log('Exception:', taskResp.runtime.exception); if (taskResp.runtime.exception) { - // 测试失败 + console.log('Test failed with exception'); setMcpTestStatus('failed'); setMcpToolNames([]); setMcpTestError(taskResp.runtime.exception || '未知错误'); } else if (taskResp.runtime.result) { - // 测试成功 - 后端可能返回字符串或对象 + try { let result: { status?: string; @@ -520,7 +530,7 @@ export default function PluginConfigPage() { error?: string; }; - // 如果result是字符串,需要先解析 + const rawResult: any = taskResp.runtime.result; if (typeof rawResult === 'string') { console.log('Result is string, parsing...'); @@ -614,25 +624,21 @@ export default function PluginConfigPage() { }; if (isEditMode && editingServerName) { - // 编辑模式:更新服务器 + await httpClient.updateMCPServer(editingServerName, serverConfig); toast.success(t('mcp.updateSuccess')); } else { - // 创建模式:新建服务器 await httpClient.createMCPServer(serverConfig); toast.success(t('mcp.createSuccess')); } - // 只有在异步操作成功后才关闭对话框 setMcpSSEModalOpen(false); - // 重置表单和状态 form.reset(); setExtraArgs([]); setEditingServerName(null); setIsEditMode(false); - // 刷新服务器列表 setRefreshKey((prev) => prev + 1); } catch (error) { console.error('Failed to save MCP server:', error); @@ -706,7 +712,7 @@ export default function PluginConfigPage() { if (file) { uploadPluginFile(file); } - // 清空input值,以便可以重复选择同一个文件 + event.target.value = ''; }, [uploadPluginFile], @@ -748,7 +754,7 @@ export default function PluginConfigPage() { [uploadPluginFile, isPluginSystemReady, t], ); - // 插件系统未启用的状态显示 + const renderPluginDisabledState = () => (
@@ -761,7 +767,7 @@ export default function PluginConfigPage() {
); - // 插件系统连接异常的状态显示 + const renderPluginConnectionErrorState = () => (
); - // 加载状态显示 const renderLoadingState = () => (

@@ -792,7 +797,6 @@ export default function PluginConfigPage() {

); - // 根据状态返回不同的内容 if (statusLoading) { return renderLoadingState(); } @@ -930,7 +934,7 @@ export default function PluginConfigPage() { - { loadServerForEdit(serverName); @@ -1221,6 +1225,7 @@ export default function PluginConfigPage() { )} /> + {t('models.extraParameters')}
From 4d3610cdf7d60bd3cd23d99714fcb04ab3ddc2e4 Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Tue, 28 Oct 2025 14:14:46 +0800 Subject: [PATCH 020/144] feat: add i18n --- .../MCPServerCardComponent.tsx | 2 +- .../MCPServerCardVO.ts | 0 web/src/i18n/locales/zh-Hans.ts | 9 +++++---- 3 files changed, 6 insertions(+), 5 deletions(-) rename web/src/app/home/plugins/mcp-server/{mcp-market-card => mcp-server-card}/MCPServerCardComponent.tsx (99%) rename web/src/app/home/plugins/mcp-server/{mcp-market-card => mcp-server-card}/MCPServerCardVO.ts (100%) diff --git a/web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardComponent.tsx similarity index 99% rename from web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardComponent.tsx rename to web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardComponent.tsx index 2190147f..c28b5f4b 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardComponent.tsx @@ -1,4 +1,4 @@ -import { MCPMarketCardVO } from '@/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardVO'; +import { MCPMarketCardVO } from '@/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardVO'; import { Button } from '@/components/ui/button'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardVO.ts b/web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardVO.ts similarity index 100% rename from web/src/app/home/plugins/mcp-server/mcp-market-card/MCPServerCardVO.ts rename to web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardVO.ts diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 82942deb..bd0a6513 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -299,8 +299,8 @@ const zhHans = { addHeader: '添加请求头', keyName: '键名', value: '值', - connected: '已连接', - disconnected: '未连接', + connected: '已打开', + disconnected: '未打开', error: '错误', testConnection: '测试连接', testing: '测试中...', @@ -317,8 +317,8 @@ const zhHans = { createError: '创建失败:', modifyFailed: '修改失败:', toolCount: '工具:{{count}}', - statusConnected: '已连接', - statusDisconnected: '未连接', + statusConnected: '已打开', + statusDisconnected: '未打开', statusError: '连接错误', statusDisabled: '已禁用', serverStatus: '服务器状态', @@ -353,6 +353,7 @@ const zhHans = { sseTimeout: 'SSE超时时间', sseTimeoutDescription: '用于建立SSE连接的超时时间', extraParametersDescription: '额外参数,用于配置MCP服务器的特定行为', + updateSuccess:'更新成功', }, pipelines: { title: '流水线', From d32f783392c10bc339d7866ff4ae98179ac49b68 Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Tue, 28 Oct 2025 16:14:31 +0800 Subject: [PATCH 021/144] fix: run lint --- .../home/bots/components/bot-form/BotForm.tsx | 1 - .../components/home-sidebar/HomeSidebar.tsx | 1 - .../plugins/mcp-server/MCPServerComponent.tsx | 226 +-------- web/src/app/home/plugins/mcp/MCPCardVO.ts | 8 +- web/src/app/home/plugins/mcp/MCPComponent.tsx | 6 +- .../plugins/mcp/mcp-card/MCPCardComponent.tsx | 1 - .../app/home/plugins/mcp/mcp-form/MCPForm.tsx | 1 - web/src/app/home/plugins/page.tsx | 454 ++++++++---------- .../PluginInstalledComponent.tsx | 1 - .../plugin-market/PluginMarketComponent.tsx | 1 - web/src/app/infra/entities/api/index.ts | 2 +- web/src/i18n/locales/zh-Hans.ts | 2 +- 12 files changed, 207 insertions(+), 497 deletions(-) diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index f6aa21c0..d493b8d1 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -115,7 +115,6 @@ export default function BotForm({ useEffect(() => { setBotFormValues(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function setBotFormValues() { diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index b9489668..76f232e4 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -65,7 +65,6 @@ export default function HomeSidebar({ console.error('Failed to fetch GitHub star count:', error); }); return () => console.log('sidebar.unmounted'); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function handleChildClick(child: SidebarChildVO) { diff --git a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx index 83fc0851..1647b3cf 100644 --- a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx @@ -2,28 +2,9 @@ import { useEffect, useState } 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 MCPCardComponent from '@/app/home/plugins/mcp/mcp-card/MCPCardComponent'; import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO'; -// 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'; import { httpClient } from '@/app/infra/http/HttpClient'; @@ -36,33 +17,20 @@ export default function MCPMarketComponent({ toolsCountCache?: Record; }) { const { t } = useTranslation(); - // const [marketServerList, setMarketServerList] = useState( - // [], - // ); const [installedServers, setInstalledServers] = 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 }, []); - // 当工具数量缓存变化时,重新获取服务器列表 useEffect(() => { fetchInstalledServers(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [toolsCountCache]); function initData() { fetchInstalledServers(); - // getServerList(); // GitHub 市场功能暂时注释 } function fetchInstalledServers() { @@ -72,7 +40,7 @@ export default function MCPMarketComponent({ .then((resp) => { const servers = resp.servers.map((server) => { const vo = new MCPCardVO(server); - // 如果缓存中有工具数量,使用缓存值覆盖 + if (toolsCountCache[server.name] !== undefined) { vo.tools = toolsCountCache[server.name]; } @@ -87,75 +55,7 @@ export default function MCPMarketComponent({ }); } - // GitHub 市场功能暂时注释 - // 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, - // ) { - // // GitHub 安装功能暂时注释 - // // 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', - // // }); - // // }), - // // ); - // // 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 (
@@ -187,126 +87,6 @@ export default function MCPMarketComponent({ )}
- - {/* GitHub 市场功能暂时注释 */} - {/*
- onInputSearchKeyword(e.target.value)} - /> - - - -
- {totalCount > 0 && ( - - - - handlePageChange(nowPage - 1)} - className={ - nowPage <= 1 ? 'pointer-events-none opacity-50' : '' - } - /> - - - {(() => { - 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/MCPCardVO.ts b/web/src/app/home/plugins/mcp/MCPCardVO.ts index 67330b61..43982a58 100644 --- a/web/src/app/home/plugins/mcp/MCPCardVO.ts +++ b/web/src/app/home/plugins/mcp/MCPCardVO.ts @@ -13,11 +13,9 @@ export class MCPCardVO { this.name = data.name; this.mode = data.mode; this.enable = data.enable; - // 将后端返回的 "enabled" 状态映射为 "connected" - this.status = (data.status as string) === 'enabled' - ? 'connected' - : data.status; - // tools可能是数组或数字 + + this.status = + (data.status as string) === 'enabled' ? 'connected' : data.status; this.tools = Array.isArray(data.tools) ? data.tools.length : data.tools || 0; diff --git a/web/src/app/home/plugins/mcp/MCPComponent.tsx b/web/src/app/home/plugins/mcp/MCPComponent.tsx index b89b743f..00cf6619 100644 --- a/web/src/app/home/plugins/mcp/MCPComponent.tsx +++ b/web/src/app/home/plugins/mcp/MCPComponent.tsx @@ -30,7 +30,6 @@ export interface MCPComponentRef { createServer: () => void; } -// eslint-disable-next-line react/display-name const MCPComponent = forwardRef((_props, ref) => { const { t } = useTranslation(); const [serverList, setServerList] = useState([]); @@ -40,12 +39,10 @@ const MCPComponent = forwardRef((_props, ref) => { const [serverToDelete, setServerToDelete] = useState(null); const [deleting, setDeleting] = useState(false); const [autoTestTriggered, setAutoTestTriggered] = useState(false); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [testingServers, setTestingServers] = useState>(new Set()); useEffect(() => { initData(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function initData() { @@ -274,7 +271,6 @@ const MCPComponent = forwardRef((_props, ref) => {
)} - {/* 编辑配置对话框 */} @@ -298,7 +294,7 @@ const MCPComponent = forwardRef((_props, ref) => { - {/* 删除确认对话框 */} + diff --git a/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx index 1dd5cb2e..2cffe435 100644 --- a/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx +++ b/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx @@ -24,7 +24,6 @@ export default function MCPCardComponent({ const [status, setStatus] = useState(cardVO.status); const [error, setError] = useState(cardVO.error); - // 响应cardVO的变化,更新本地状态 useEffect(() => { console.log(`[MCPCard ${cardVO.name}] Status updated:`, { status: cardVO.status, diff --git a/web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx b/web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx index 33ee60b3..2e990235 100644 --- a/web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx +++ b/web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx @@ -43,7 +43,6 @@ export default function MCPForm({ if (isEdit && serverName) { loadServerConfig(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEdit, serverName]); async function loadServerConfig() { diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 8793daa3..a6948536 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -46,7 +46,7 @@ import { SelectValue, SelectContent, SelectItem, -} from "@/components/ui/select" +} from '@/components/ui/select'; import { Resolver, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -88,81 +88,52 @@ export default function PluginConfigPage() { const addExtraArg = () => { setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]); }; - const getExtraArgSchema = (t: (key: string) => string) => - z - .object({ - key: z.string().min(1, { message: t('models.keyNameRequired') }), - type: z.enum(['string', 'number', 'boolean']), - value: z.string(), - }) - .superRefine((data, ctx) => { - if (data.type === 'number' && isNaN(Number(data.value))) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t('models.mustBeValidNumber'), - path: ['value'], - }); - } - if ( - data.type === 'boolean' && - data.value !== 'true' && - data.value !== 'false' - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t('models.mustBeTrueOrFalse'), - path: ['value'], - }); - } - }); const removeExtraArg = (index: number) => { const newArgs = extraArgs.filter((_, i) => i !== index); setExtraArgs(newArgs); form.setValue('extra_args', newArgs); }; const getFormSchema = (t: (key: string) => string) => - z.object({ - name: z.string({ required_error: t('mcp.nameRequired') }), - timeout: z - .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) - .nonnegative({ message: t('mcp.timeoutNonNegative') }) - .default(30), - ssereadtimeout: z - .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) - .nonnegative({ message: t('mcp.sseTimeoutNonNegative') }) - .default(300), - url: z.string({ required_error: t('models.requestURLRequired') }), - extra_args: z - .array( - z.object({ - key: z.string(), - type: z.enum(['string', 'number', 'boolean']), - value: z.string(), - }) - ) - .optional(), + z.object({ + name: z.string({ required_error: t('mcp.nameRequired') }), + timeout: z + .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) + .nonnegative({ message: t('mcp.timeoutNonNegative') }) + .default(30), + ssereadtimeout: z + .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) + .nonnegative({ message: t('mcp.sseTimeoutNonNegative') }) + .default(300), + url: z.string({ required_error: t('models.requestURLRequired') }), + extra_args: z + .array( + z.object({ + key: z.string(), + type: z.enum(['string', 'number', 'boolean']), + value: z.string(), + }), + ) + .optional(), + }); + + const formSchema = getFormSchema(t); + + type FormValues = z.infer & { + timeout: number; + ssereadtimeout: number; + }; + + const form = useForm({ + resolver: zodResolver(formSchema) as unknown as Resolver, + defaultValues: { + name: '', + url: '', + timeout: 30, + ssereadtimeout: 300, + extra_args: [], + }, }); -const formSchema = getFormSchema(t); - - -type FormValues = z.infer & { - timeout: number; - ssereadtimeout: number; -}; - -const form = useForm({ - resolver: zodResolver(formSchema) as unknown as Resolver, - defaultValues: { - name: '', - url: '', - timeout: 30, - ssereadtimeout: 300, - extra_args: [], - }, -}); - - const [extraArgs, setExtraArgs] = useState< { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] >([]); @@ -223,52 +194,8 @@ const form = useForm({ } }); }, 1000); - } - function watchTestMCPTask(taskId: number) { - let alreadyHandled = false; - console.log('Watching MCP test task:', taskId); - - const interval = setInterval(() => { - httpClient.getAsyncTask(taskId).then((resp) => { - console.log('task status:', resp); - - // 若任务已完成 - if (resp.runtime && resp.runtime.done) { - clearInterval(interval); - - if (resp.runtime.exception) { - // 任务失败 - toast.error(`测试失败: ${resp.runtime.exception}`); - } else if (resp.runtime.result) { - // 任务成功 - const result = resp.runtime.result as { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; - const names = result.tools_names_lists || []; - - if (!alreadyHandled) { - alreadyHandled = true; - const names = result.tools_names_lists || []; - toast.success(`连接成功,找到 ${names.length} 个工具`); - console.log('工具列表:', names); - } - } else { - // 没结果但标记为完成 - toast.error('测试任务完成但未返回结果'); - } - } - }).catch((err) => { - console.error('任务状态获取失败:', err); - toast.error('获取任务状态失败'); - clearInterval(interval); - }); - }, 1000); -} const pluginInstalledRef = useRef(null); const mcpComponentRef = useRef(null); @@ -280,12 +207,16 @@ const form = useForm({ const [refreshKey, setRefreshKey] = useState(0); // MCP测试结果状态 - const [mcpTestStatus, setMcpTestStatus] = useState<'idle' | 'testing' | 'success' | 'failed'>('idle'); + const [mcpTestStatus, setMcpTestStatus] = useState< + 'idle' | 'testing' | 'success' | 'failed' + >('idle'); const [mcpToolNames, setMcpToolNames] = useState([]); const [mcpTestError, setMcpTestError] = useState(''); // 缓存每个服务器测试后的工具数量 - const [serverToolsCache, setServerToolsCache] = useState>({}); + const [serverToolsCache, setServerToolsCache] = useState< + Record + >({}); // 强制清理 body 样式以修复 Dialog 关闭后点击失效的问题 useEffect(() => { @@ -297,11 +228,9 @@ const form = useForm({ if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { const cleanup = () => { - document.body.style.removeProperty('pointer-events'); document.body.style.removeProperty('overflow'); - if (document.body.style.pointerEvents === 'none') { document.body.style.pointerEvents = ''; } @@ -344,7 +273,6 @@ const form = useForm({ } }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); - useEffect(() => { const interval = setInterval(() => { if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { @@ -391,51 +319,52 @@ const form = useForm({ }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); function handleModalConfirm() { - installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any - } - function installPlugin( - installSource: string, - installInfo: Record, // eslint-disable-line @typescript-eslint/no-explicit-any - ) { - setPluginInstallStatus(PluginInstallStatus.INSTALLING); - if (installSource === 'github') { - httpClient - .installPluginFromGithub(installInfo.url) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }) - .catch((err) => { - console.log('error when install plugin:', err); - setInstallError(err.message); - setPluginInstallStatus(PluginInstallStatus.ERROR); - }); - } else if (installSource === 'local') { - httpClient - .installPluginFromLocal(installInfo.file) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }) - .catch((err) => { - console.log('error when install plugin:', err); - setInstallError(err.message); - setPluginInstallStatus(PluginInstallStatus.ERROR); - }); - } else if (installSource === 'marketplace') { - httpClient - .installPluginFromMarketplace( - installInfo.plugin_author, - installInfo.plugin_name, - installInfo.plugin_version, - ) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }); - } + installPlugin(installSource, installInfo as Record); } + const installPlugin = useCallback( + (installSource: string, installInfo: Record) => { + setPluginInstallStatus(PluginInstallStatus.INSTALLING); + if (installSource === 'github') { + httpClient + .installPluginFromGithub((installInfo as { url: string }).url) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }) + .catch((err) => { + console.log('error when install plugin:', err); + setInstallError(err.message); + setPluginInstallStatus(PluginInstallStatus.ERROR); + }); + } else if (installSource === 'local') { + httpClient + .installPluginFromLocal((installInfo as { file: File }).file) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }) + .catch((err) => { + console.log('error when install plugin:', err); + setInstallError(err.message); + setPluginInstallStatus(PluginInstallStatus.ERROR); + }); + } else if (installSource === 'marketplace') { + httpClient + .installPluginFromMarketplace( + (installInfo as { plugin_author: string }).plugin_author, + (installInfo as { plugin_name: string }).plugin_name, + (installInfo as { plugin_version: string }).plugin_version, + ) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }); + } + }, + [watchTask], + ); + async function deleteMCPServer() { if (!editingServerName) return; @@ -464,121 +393,134 @@ const form = useForm({ async function loadServerForEdit(serverName: string) { try { const resp = await httpClient.getMCPServer(serverName); - const server = resp.server ?? resp; + const server = resp.server ?? resp; console.log('Loaded server for edit:', server); - + const extraArgs = server.extra_args as + | Record + | undefined; form.setValue('name', server.name); - form.setValue('url', server.extra_args?.url || ''); - form.setValue('timeout', server.extra_args?.timeout || 30); - form.setValue('ssereadtimeout', server.extra_args?.ssereadtimeout || 300); + form.setValue('url', (extraArgs?.url as string) || ''); + form.setValue('timeout', (extraArgs?.timeout as number) || 30); + form.setValue( + 'ssereadtimeout', + (extraArgs?.ssereadtimeout as number) || 300, + ); - - if (server.extra_args?.headers) { - const headers = Object.entries(server.extra_args.headers).map( - ([key, value]) => ({ - key, - type: 'string' as const, - value: String(value), - }), - ); + if (extraArgs?.headers) { + const headers = Object.entries( + extraArgs.headers as Record, + ).map(([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + })); setExtraArgs(headers); form.setValue('extra_args', headers); } - setMcpTestStatus('testing'); setMcpToolNames([]); setMcpTestError(''); - setEditingServerName(serverName); setIsEditMode(true); setMcpSSEModalOpen(true); - try { const res = await httpClient.testMCPServer(server.name); if (res.task_id) { const taskId = res.task_id; - const interval = setInterval(() => { - httpClient.getAsyncTask(taskId).then((taskResp) => { - console.log('Task response:', taskResp); + httpClient + .getAsyncTask(taskId) + .then((taskResp) => { + console.log('Task response:', taskResp); - if (taskResp.runtime && taskResp.runtime.done) { - clearInterval(interval); + if (taskResp.runtime && taskResp.runtime.done) { + clearInterval(interval); - console.log('Task completed. Runtime:', taskResp.runtime); - console.log('Result:', taskResp.runtime.result); - console.log('Exception:', taskResp.runtime.exception); + console.log('Task completed. Runtime:', taskResp.runtime); + console.log('Result:', taskResp.runtime.result); + console.log('Exception:', taskResp.runtime.exception); - if (taskResp.runtime.exception) { - - console.log('Test failed with exception'); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError(taskResp.runtime.exception || '未知错误'); - } else if (taskResp.runtime.result) { - - try { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; - - - const rawResult: any = taskResp.runtime.result; - if (typeof rawResult === 'string') { - console.log('Result is string, parsing...'); - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult as typeof result; - } - - console.log('Parsed result:', result); - console.log('tools_names_lists:', result.tools_names_lists); - console.log('tools_names_lists length:', result.tools_names_lists?.length); - - if (result.tools_names_lists && result.tools_names_lists.length > 0) { - console.log('Test success with', result.tools_names_lists.length, 'tools'); - setMcpTestStatus('success'); - setMcpToolNames(result.tools_names_lists); - // 保存工具数量到缓存 - setServerToolsCache(prev => ({ - ...prev, - [server.name]: result.tools_names_lists!.length - })); - } else { - console.log('Test failed: no tools found'); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('未找到任何工具'); - } - } catch (parseError) { - console.error('Failed to parse result:', parseError); + if (taskResp.runtime.exception) { + console.log('Test failed with exception'); setMcpTestStatus('failed'); setMcpToolNames([]); - setMcpTestError('解析测试结果失败'); + setMcpTestError(taskResp.runtime.exception || '未知错误'); + } else if (taskResp.runtime.result) { + try { + let result: { + status?: string; + tools_count?: number; + tools_names_lists?: string[]; + error?: string; + }; + + const rawResult: unknown = taskResp.runtime.result; + if (typeof rawResult === 'string') { + console.log('Result is string, parsing...'); + result = JSON.parse(rawResult.replace(/'/g, '"')); + } else { + result = rawResult as typeof result; + } + + console.log('Parsed result:', result); + console.log( + 'tools_names_lists:', + result.tools_names_lists, + ); + console.log( + 'tools_names_lists length:', + result.tools_names_lists?.length, + ); + + if ( + result.tools_names_lists && + result.tools_names_lists.length > 0 + ) { + console.log( + 'Test success with', + result.tools_names_lists.length, + 'tools', + ); + setMcpTestStatus('success'); + setMcpToolNames(result.tools_names_lists); + // 保存工具数量到缓存 + setServerToolsCache((prev) => ({ + ...prev, + [server.name]: result.tools_names_lists!.length, + })); + } else { + console.log('Test failed: no tools found'); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('未找到任何工具'); + } + } catch (parseError) { + console.error('Failed to parse result:', parseError); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('解析测试结果失败'); + } + } else { + // 没结果 + console.log('Test failed: no result'); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('测试未返回结果'); } - } else { - // 没结果 - console.log('Test failed: no result'); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('测试未返回结果'); } - } - }).catch((err) => { - console.error('获取任务状态失败:', err); - clearInterval(interval); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError(err.message || '获取任务状态失败'); - }); + }) + .catch((err) => { + console.error('获取任务状态失败:', err); + clearInterval(interval); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError(err.message || '获取任务状态失败'); + }); }, 1000); } else { setMcpTestStatus('failed'); @@ -624,7 +566,6 @@ const form = useForm({ }; if (isEditMode && editingServerName) { - await httpClient.updateMCPServer(editingServerName, serverConfig); toast.success(t('mcp.updateSuccess')); } else { @@ -697,7 +638,7 @@ const form = useForm({ setInstallError(null); installPlugin('local', { file }); }, - [t, pluginSystemStatus], + [t, pluginSystemStatus, installPlugin], ); const handleFileSelect = useCallback(() => { @@ -712,7 +653,7 @@ const form = useForm({ if (file) { uploadPluginFile(file); } - + event.target.value = ''; }, [uploadPluginFile], @@ -754,7 +695,6 @@ const form = useForm({ [uploadPluginFile, isPluginSystemReady, t], ); - const renderPluginDisabledState = () => (
@@ -767,7 +707,6 @@ const form = useForm({
); - const renderPluginConnectionErrorState = () => (
({ } }} > - @@ -1113,7 +1051,8 @@ const form = useForm({ /> - {t('mcp.connectionSuccess')} - {mcpToolNames.length} {t('mcp.toolsFound')} + {t('mcp.connectionSuccess')} - {mcpToolNames.length}{' '} + {t('mcp.toolsFound')}
@@ -1146,7 +1085,9 @@ const form = useForm({ d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> - {t('mcp.connectionFailed')} + + {t('mcp.connectionFailed')} +
{mcpTestError && (
@@ -1217,7 +1158,9 @@ const form = useForm({ type="number" placeholder={t('mcp.sseTimeoutDescription')} {...field} - onChange={(e) => field.onChange(Number(e.target.value))} + onChange={(e) => + field.onChange(Number(e.target.value)) + } /> @@ -1225,7 +1168,6 @@ const form = useForm({ )} /> - {t('models.extraParameters')}
diff --git a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx index 5581fc7a..3ef8b748 100644 --- a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx @@ -61,7 +61,6 @@ const PluginInstalledComponent = forwardRef( useEffect(() => { initData(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function initData() { diff --git a/web/src/app/home/plugins/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/plugin-market/PluginMarketComponent.tsx index dbde8774..b9835253 100644 --- a/web/src/app/home/plugins/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/plugin-market/PluginMarketComponent.tsx @@ -172,7 +172,6 @@ function MarketPageContent({ // 初始加载 useEffect(() => { fetchPlugins(1, false, true); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 搜索功能 diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 677c91ab..8a6c85cb 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -319,7 +319,7 @@ export interface ApiRespMCPServer { } export interface MCPServer { - extra_args: any; + extra_args: Record; name: string; mode: 'stdio' | 'sse'; enable: boolean; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index bd0a6513..d473a578 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -353,7 +353,7 @@ const zhHans = { sseTimeout: 'SSE超时时间', sseTimeoutDescription: '用于建立SSE连接的超时时间', extraParametersDescription: '额外参数,用于配置MCP服务器的特定行为', - updateSuccess:'更新成功', + updateSuccess: '更新成功', }, pipelines: { title: '流水线', From a055e37d3aef26957453aed2d9eb1e0e056c66f4 Mon Sep 17 00:00:00 2001 From: wangcham Date: Wed, 29 Oct 2025 14:00:45 +0000 Subject: [PATCH 022/144] feat: add i18n --- web/src/i18n/locales/en-US.ts | 85 +++++++++++++++++++++++++++++++++ web/src/i18n/locales/ja-JP.ts | 85 +++++++++++++++++++++++++++++++++ web/src/i18n/locales/zh-Hant.ts | 85 +++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+) diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 7196a63a..6c00215d 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -282,6 +282,91 @@ const enUS = { markAsReadSuccess: 'Marked as read', markAsReadFailed: 'Mark as read failed', }, + mcp: { + title: 'MCP Management', + description: 'Manage Model Context Protocol (MCP) servers to extend AI capabilities', + createServer: 'Create MCP Server', + editServer: 'Edit MCP Server', + deleteServer: 'Delete MCP Server', + confirmDeleteServer: 'Are you sure you want to delete this MCP server?', + confirmDeleteTitle: 'Delete MCP Server', + getServerListError: 'Failed to get MCP server list: ', + serverName: 'Server Name', + serverMode: 'Connection Mode', + stdio: 'Stdio Mode', + sse: 'SSE Mode', + serverConfig: 'MCP Server Configuration', + noServerInstalled: 'No MCP servers configured', + serverNameRequired: 'Server name cannot be empty', + commandRequired: 'Command cannot be empty', + urlRequired: 'URL cannot be empty', + command: 'Command', + args: 'Arguments', + env: 'Environment Variables', + url: 'URL', + headers: 'Headers', + timeout: 'Timeout', + addArgument: 'Add Argument', + addEnvVar: 'Add Environment Variable', + addHeader: 'Add Header', + keyName: 'Key Name', + value: 'Value', + connected: 'Connected', + disconnected: 'Disconnected', + error: 'Error', + testConnection: 'Test Connection', + testing: 'Testing...', + testSuccess: 'Connection test successful', + testFailed: 'Connection test failed: ', + connectionSuccess: 'Connection successful', + connectionFailed: 'Connection failed', + toolsFound: 'tools', + deleteSuccess: 'Deleted successfully', + deleteError: 'Delete failed: ', + saveSuccess: 'Saved successfully', + saveError: 'Save failed: ', + createSuccess: 'Created successfully', + createError: 'Creation failed: ', + modifyFailed: 'Modify failed: ', + toolCount: 'Tools: {{count}}', + statusConnected: 'Connected', + statusDisconnected: 'Disconnected', + statusError: 'Connection Error', + statusDisabled: 'Disabled', + serverStatus: 'Server Status', + marketplace: 'MCP Marketplace', + searchServer: 'Search MCP servers', + sortBy: 'Sort by', + mostStars: 'Most stars', + recentlyAdded: 'Recently added', + recentlyUpdated: 'Recently updated', + loading: 'Loading...', + noMatchingServers: 'No matching MCP servers', + starCount: 'Stars: {{count}}', + install: 'Install', + installing: 'Installing...', + installSuccess: 'MCP server installed successfully', + installFailed: 'MCP server installation failed', + installFromGithub: 'Install MCP Server from GitHub', + onlySupportGithub: 'Currently only supports installation from GitHub', + enterGithubLink: 'Enter GitHub repository link', + add: 'Add', + name: 'Name', + nameExplained: 'Used to distinguish different MCP server instances', + sseURL: 'SSE URL', + sseHeaders: 'SSE Headers', + nameRequired: 'Name cannot be empty', + sseURLRequired: 'SSE URL cannot be empty', + enterSSELink: 'Enter SSE URL', + timeoutRequired: 'Timeout cannot be empty', + headersExample: 'Example: Authorization: Bearer token123', + enterTimeout: 'Enter timeout in milliseconds', + installFromSSE: 'Install from SSE', + sseTimeout: 'SSE Timeout', + sseTimeoutDescription: 'Timeout for establishing SSE connection', + extraParametersDescription: 'Additional parameters for configuring specific MCP server behavior', + updateSuccess: 'Updated successfully', + }, pipelines: { title: 'Pipelines', description: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 128020c6..771b64b1 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -284,6 +284,91 @@ const jaJP = { markAsReadSuccess: '既読に設定しました', markAsReadFailed: '既読に設定に失敗しました', }, + mcp: { + title: 'MCP管理', + description: 'Model Context Protocol (MCP) サーバーを管理してAI機能を拡張', + createServer: 'MCPサーバーを作成', + editServer: 'MCPサーバーを編集', + deleteServer: 'MCPサーバーを削除', + confirmDeleteServer: 'このMCPサーバーを削除してもよろしいですか?', + confirmDeleteTitle: '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: '接続テストに失敗しました:', + connectionSuccess: '接続に成功しました', + connectionFailed: '接続に失敗しました', + toolsFound: '個のツール', + deleteSuccess: '削除に成功しました', + deleteError: '削除に失敗しました:', + saveSuccess: '保存に成功しました', + saveError: '保存に失敗しました:', + createSuccess: '作成に成功しました', + createError: '作成に失敗しました:', + modifyFailed: '変更に失敗しました:', + toolCount: 'ツール:{{count}}', + statusConnected: '接続済み', + statusDisconnected: '未接続', + statusError: '接続エラー', + statusDisabled: '無効', + 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からのインストールのみサポートしています', + enterGithubLink: 'GitHubリポジトリのリンクを入力', + add: '追加', + name: '名前', + nameExplained: '異なるMCPサーバーインスタンスを区別するために使用', + sseURL: 'SSE URL', + sseHeaders: 'SSEヘッダー', + nameRequired: '名前は必須です', + sseURLRequired: 'SSE URLは必須です', + enterSSELink: 'SSE URLを入力', + timeoutRequired: 'タイムアウトは必須です', + headersExample: '例: Authorization: Bearer token123', + enterTimeout: 'タイムアウトをミリ秒単位で入力', + installFromSSE: 'SSEからインストール', + sseTimeout: 'SSEタイムアウト', + sseTimeoutDescription: 'SSE接続を確立するためのタイムアウト', + extraParametersDescription: 'MCPサーバーの特定の動作を設定するための追加パラメータ', + updateSuccess: '更新に成功しました', + }, pipelines: { title: 'パイプライン', description: diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 89b3d57a..f956ada4 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -268,6 +268,91 @@ const zhHant = { markAsReadSuccess: '已標記為已讀', markAsReadFailed: '標記為已讀失敗', }, + mcp: { + title: 'MCP管理', + description: '管理Model Context Protocol (MCP) 伺服器,擴展AI能力', + createServer: '建立MCP伺服器', + editServer: '編輯MCP伺服器', + deleteServer: '刪除MCP伺服器', + confirmDeleteServer: '您確定要刪除此MCP伺服器嗎?', + confirmDeleteTitle: '刪除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: '連接測試失敗:', + connectionSuccess: '連接成功', + connectionFailed: '連接失敗', + toolsFound: '個工具', + deleteSuccess: '刪除成功', + deleteError: '刪除失敗:', + saveSuccess: '儲存成功', + saveError: '儲存失敗:', + createSuccess: '建立成功', + createError: '建立失敗:', + modifyFailed: '修改失敗:', + toolCount: '工具:{{count}}', + statusConnected: '已開啟', + statusDisconnected: '未開啟', + statusError: '連接錯誤', + statusDisabled: '已停用', + 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儲存庫連結', + add: '新增', + name: '名稱', + nameExplained: '用於區分不同的MCP伺服器實例', + sseURL: 'SSE URL', + sseHeaders: 'SSE Headers', + nameRequired: '名稱不能為空', + sseURLRequired: 'SSE URL不能為空', + enterSSELink: '輸入SSE URL', + timeoutRequired: '逾時時間不能為空', + headersExample: '範例: Authorization: Bearer token123', + enterTimeout: '輸入逾時時間,單位為毫秒', + installFromSSE: '從SSE安裝', + sseTimeout: 'SSE逾時時間', + sseTimeoutDescription: '用於建立SSE連接的逾時時間', + extraParametersDescription: '額外參數,用於設定MCP伺服器的特定行為', + updateSuccess: '更新成功', + }, pipelines: { title: '流程線', description: '流程線定義了對訊息事件的處理流程,用於綁定到機器人', From 4c5139e9ffd416c6abc0cf00e60381ea5bd1f543 Mon Sep 17 00:00:00 2001 From: wangcham Date: Wed, 29 Oct 2025 14:35:09 +0000 Subject: [PATCH 023/144] fix: delete print function --- pkg/api/http/controller/groups/mcp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/api/http/controller/groups/mcp.py b/pkg/api/http/controller/groups/mcp.py index f2abb9da..456b01c3 100644 --- a/pkg/api/http/controller/groups/mcp.py +++ b/pkg/api/http/controller/groups/mcp.py @@ -182,7 +182,6 @@ class MCPRouterGroup(group.RouterGroup): from .....provider.tools.loaders.mcp import RuntimeMCPSession ctx.current_action = f'Testing connection to {server.name}' - print(server) # 创建临时会话进行测试 session = RuntimeMCPSession(server.name, { 'name': server.name, From f2647316a5015073dea144132d08354994458c63 Mon Sep 17 00:00:00 2001 From: wangcham Date: Thu, 30 Oct 2025 15:01:25 +0000 Subject: [PATCH 024/144] fix: mcp test error --- web/src/app/home/plugins/page.tsx | 2 +- web/src/i18n/locales/en-US.ts | 1 + web/src/i18n/locales/ja-JP.ts | 1 + web/src/i18n/locales/zh-Hans.ts | 1 + web/src/i18n/locales/zh-Hant.ts | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index a6948536..77ccdb96 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -608,7 +608,7 @@ export default function PluginConfigPage() { toast.success(t('models.testSuccess')); }) .catch(() => { - toast.error(t('models.testError')); + toast.error(t('mcp.testError')); }) .finally(() => { setMcpTesting(false); diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 6c00215d..1d56a3b7 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -366,6 +366,7 @@ const enUS = { sseTimeoutDescription: 'Timeout for establishing SSE connection', extraParametersDescription: 'Additional parameters for configuring specific MCP server behavior', updateSuccess: 'Updated successfully', + testError: 'Connection test error', }, pipelines: { title: 'Pipelines', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 771b64b1..ecd1fa38 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -368,6 +368,7 @@ const jaJP = { sseTimeoutDescription: 'SSE接続を確立するためのタイムアウト', extraParametersDescription: 'MCPサーバーの特定の動作を設定するための追加パラメータ', updateSuccess: '更新に成功しました', + testError: '接続テスト出錯', }, pipelines: { title: 'パイプライン', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index d473a578..4f185eb2 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -306,6 +306,7 @@ const zhHans = { testing: '测试中...', testSuccess: '连接测试成功', testFailed: '连接测试失败:', + testError: '连接测试出错', connectionSuccess: '连接成功', connectionFailed: '连接失败', toolsFound: '个工具', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index f956ada4..66202459 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -352,6 +352,7 @@ const zhHant = { sseTimeoutDescription: '用於建立SSE連接的逾時時間', extraParametersDescription: '額外參數,用於設定MCP伺服器的特定行為', updateSuccess: '更新成功', + testError: '連接測試出錯' }, pipelines: { title: '流程線', From e17b0cf5c527ac3b3d646cc8bd160607bb8047eb Mon Sep 17 00:00:00 2001 From: wangcham Date: Thu, 30 Oct 2025 15:17:06 +0000 Subject: [PATCH 025/144] fix: i18n and mcp test --- web/src/app/home/plugins/page.tsx | 77 +++++++++++++++++++++++++++++-- web/src/i18n/locales/en-US.ts | 6 +++ web/src/i18n/locales/ja-JP.ts | 6 +++ web/src/i18n/locales/zh-Hans.ts | 6 +++ web/src/i18n/locales/zh-Hant.ts | 6 +++ 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 77ccdb96..389a9a75 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -605,13 +605,80 @@ export default function PluginConfigPage() { .testMCPServer(form.getValues('name')) .then((res) => { console.log(res); - toast.success(t('models.testSuccess')); + if (res.task_id) { + const taskId = res.task_id; + + const interval = setInterval(() => { + httpClient + .getAsyncTask(taskId) + .then((taskResp) => { + console.log('Test task response:', taskResp); + + if (taskResp.runtime && taskResp.runtime.done) { + clearInterval(interval); + setMcpTesting(false); + + if (taskResp.runtime.exception) { + toast.error( + t('mcp.testError') + + ': ' + + (taskResp.runtime.exception || t('mcp.unknownError')), + ); + } else if (taskResp.runtime.result) { + try { + let result: { + status?: string; + tools_count?: number; + tools_names_lists?: string[]; + error?: string; + }; + + const rawResult: unknown = taskResp.runtime.result; + if (typeof rawResult === 'string') { + result = JSON.parse(rawResult.replace(/'/g, '"')); + } else { + result = rawResult as typeof result; + } + + if ( + result.tools_names_lists && + result.tools_names_lists.length > 0 + ) { + toast.success( + t('mcp.testSuccess') + + ' - ' + + result.tools_names_lists.length + + ' ' + + t('mcp.toolsFound'), + ); + } else { + toast.error(t('mcp.testError') + ': ' + t('mcp.noToolsFound')); + } + } catch (parseError) { + console.error('Failed to parse test result:', parseError); + toast.error(t('mcp.testError') + ': ' + t('mcp.parseResultFailed')); + } + } else { + toast.error(t('mcp.testError') + ': ' + t('mcp.noResultReturned')); + } + } + }) + .catch((err) => { + console.error('获取测试任务状态失败:', err); + clearInterval(interval); + setMcpTesting(false); + toast.error(t('mcp.testError') + ': ' + (err.message || t('mcp.getTaskFailed'))); + }); + }, 1000); + } else { + setMcpTesting(false); + toast.error(t('mcp.testError') + ': ' + t('mcp.noTaskId')); + } }) - .catch(() => { - toast.error(t('mcp.testError')); - }) - .finally(() => { + .catch((err) => { + console.error('启动测试失败:', err); setMcpTesting(false); + toast.error(t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError'))); }); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 1d56a3b7..e24c44aa 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -321,6 +321,12 @@ const enUS = { connectionSuccess: 'Connection successful', connectionFailed: 'Connection failed', toolsFound: 'tools', + unknownError: 'Unknown error', + noToolsFound: 'No tools found', + parseResultFailed: 'Failed to parse test result', + noResultReturned: 'Test returned no result', + getTaskFailed: 'Failed to get task status', + noTaskId: 'No task ID obtained', deleteSuccess: 'Deleted successfully', deleteError: 'Delete failed: ', saveSuccess: 'Saved successfully', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index ecd1fa38..01b4c919 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -323,6 +323,12 @@ const jaJP = { connectionSuccess: '接続に成功しました', connectionFailed: '接続に失敗しました', toolsFound: '個のツール', + unknownError: '不明なエラー', + noToolsFound: 'ツールが見つかりません', + parseResultFailed: 'テスト結果の解析に失敗しました', + noResultReturned: 'テスト結果が返されませんでした', + getTaskFailed: 'タスクステータスの取得に失敗しました', + noTaskId: 'タスクIDを取得できませんでした', deleteSuccess: '削除に成功しました', deleteError: '削除に失敗しました:', saveSuccess: '保存に成功しました', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 4f185eb2..8fd3f57b 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -310,6 +310,12 @@ const zhHans = { connectionSuccess: '连接成功', connectionFailed: '连接失败', toolsFound: '个工具', + unknownError: '未知错误', + noToolsFound: '未找到任何工具', + parseResultFailed: '解析测试结果失败', + noResultReturned: '测试未返回结果', + getTaskFailed: '获取任务状态失败', + noTaskId: '未获取到任务ID', deleteSuccess: '删除成功', deleteError: '删除失败:', saveSuccess: '保存成功', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 66202459..88905a72 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -307,6 +307,12 @@ const zhHant = { connectionSuccess: '連接成功', connectionFailed: '連接失敗', toolsFound: '個工具', + unknownError: '未知錯誤', + noToolsFound: '未找到任何工具', + parseResultFailed: '解析測試結果失敗', + noResultReturned: '測試未返回結果', + getTaskFailed: '獲取任務狀態失敗', + noTaskId: '未獲取到任務ID', deleteSuccess: '刪除成功', deleteError: '刪除失敗:', saveSuccess: '儲存成功', From 4c0917556fbc8f6509bbf8626907f91eded9a5e4 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 2 Nov 2025 13:05:55 +0800 Subject: [PATCH 026/144] refactor(mcp): bridge controller and db operation with service layer --- pkg/api/http/controller/groups/market.py | 143 ------- pkg/api/http/controller/groups/mcp.py | 214 ---------- .../controller/groups/resources/__init__.py | 0 .../http/controller/groups/resources/mcp.py | 366 ++++-------------- pkg/api/http/controller/main.py | 2 + pkg/api/http/service/mcp.py | 63 +++ pkg/core/app.py | 3 + pkg/core/stages/build_app.py | 4 + pkg/provider/tools/toolmgr.py | 30 +- 9 files changed, 152 insertions(+), 673 deletions(-) delete mode 100644 pkg/api/http/controller/groups/market.py delete mode 100644 pkg/api/http/controller/groups/mcp.py create mode 100644 pkg/api/http/controller/groups/resources/__init__.py create mode 100644 pkg/api/http/service/mcp.py diff --git a/pkg/api/http/controller/groups/market.py b/pkg/api/http/controller/groups/market.py deleted file mode 100644 index ed909ef2..00000000 --- a/pkg/api/http/controller/groups/market.py +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index 456b01c3..00000000 --- a/pkg/api/http/controller/groups/mcp.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations -import time -import traceback -import uuid - -import quart -import asyncio - -import sqlalchemy - -from pkg.entity.persistence.mcp import MCPServer - -from .....core import taskmgr -from .. import group - -from sqlalchemy import insert - -@group.group_class('mcp', '/api/v1/mcp') -class MCPRouterGroup(group.RouterGroup): - async def initialize(self) -> None: - @self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN) - async def _() -> str: - """获取MCP服务器列表""" - if quart.request.method == 'GET': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(MCPServer).order_by(MCPServer.created_at.desc()) - ) - raw_results = result.all() - servers = [self.ap.persistence_mgr.serialize_model(MCPServer, row) for row in raw_results] - - servers_with_status = [] - # 获取MCP工具加载器 - mcp_loader = None - for loader in self.ap.tool_mgr.loaders: - if loader.__class__.__name__ == 'MCPLoader': - mcp_loader = loader - break - - for server in servers: - # 设置状态 - if server['enable']: - status = 'enabled' - else: - status = 'disabled' - - # 构建 config 对象 (前端期望的格式) - extra_args = server.get('extra_args', {}) - config = { - 'name': server['name'], - 'mode': server['mode'], - 'enable': server['enable'], - } - - # 根据模式添加相应的配置 - if server['mode'] == 'sse': - config['url'] = extra_args.get('url', '') - config['headers'] = extra_args.get('headers', {}) - config['timeout'] = extra_args.get('timeout', 60) - elif server['mode'] == 'stdio': - config['command'] = extra_args.get('command', '') - config['args'] = extra_args.get('args', []) - config['env'] = extra_args.get('env', {}) - - # 从运行中的会话获取工具数量 - tools_count = 0 - if mcp_loader and hasattr(mcp_loader, 'sessions') and server['name'] in mcp_loader.sessions: - session = mcp_loader.sessions[server['name']] - tools_count = len(session.functions) - - server_info = { - 'name': server['name'], - 'mode': server['mode'], - 'enable': server['enable'], - 'status': status, - 'tools': tools_count, # 从运行中的会话获取工具数量 - 'config': config, - } - servers_with_status.append(server_info) - - return self.success(data={'servers': servers_with_status}) - - elif quart.request.method == 'POST': - data = await quart.request.json - data = data['source'] - try: - # 检查服务器名称是否重复 - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(MCPServer).where(MCPServer.name == data['name']) - ) - if result.first() is not None: - return self.http_status(400, -1, 'Server name already exists') - - # 创建新服务器配置 - new_server = { - 'uuid': str(uuid.uuid4()), - 'name': data['name'], - 'mode': 'sse', - 'enable': data.get('enable', False), - 'extra_args': { - 'url':data.get('url',''), - 'headers':data.get('headers',{}), - 'timeout':data.get('timeout',60), - 'ssereadtimeout':data.get('ssereadtimeout',300), - }, - } - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.insert(MCPServer).values(new_server) - ) - - return self.success() - - except Exception: - print(traceback.format_exc()) - - @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) - async def _(server_name: str) -> str: - """获取、更新或删除MCP服务器配置""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(MCPServer).where(MCPServer.name == server_name) - ) - server = result.first() - if server is None: - return self.http_status(404, -1, 'Server not found') - - if quart.request.method == 'GET': - server_data = self.ap.persistence_mgr.serialize_model(MCPServer, server) - return self.success(data={'server': server_data}) - - elif quart.request.method == 'PUT': - data = await quart.request.json - update_data = { - 'enable': data.get('enable', server.enable), - } - - extra_args = server.extra_args or {} - if server.mode == 'sse': - extra_args.update({ - 'url': data.get('url', extra_args.get('url','')), - 'headers': data.get('headers', extra_args.get('headers',{})), - 'timeout': data.get('timeout', extra_args.get('timeout',60)), - 'ssereadtimeout': data.get('ssereadtimeout', extra_args.get('ssereadtimeout',300)), - }) - update_data['extra_args'] = extra_args - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.update(MCPServer).where(MCPServer.name == server_name).values(update_data) - ) - - return self.success() - - elif quart.request.method == 'DELETE': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.delete(MCPServer).where(MCPServer.name == server_name) - ) - return self.success() - - @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) - async def _(server_name: str) -> str: - """测试MCP服务器连接""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(MCPServer).where(MCPServer.name == server_name) - ) - server = result.first() - if server 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, 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}) - - async def _test_mcp_server(self, server: MCPServer, ctx: taskmgr.TaskContext): - """测试MCP服务器连接""" - try: - from .....provider.tools.loaders.mcp import RuntimeMCPSession - - ctx.current_action = f'Testing connection to {server.name}' - # 创建临时会话进行测试 - session = RuntimeMCPSession(server.name, { - 'name': server.name, - 'mode': server.mode, - 'enable': server.enable, - 'url': server.extra_args.get('url',''), - 'headers': server.extra_args.get('headers',{}), - 'timeout': server.extra_args.get('timeout',60), - },enable=True, ap=self.ap) - await session.start() - - # 获取工具列表作为测试 - tools_count = len(session.functions) - - tool_name_list = [] - for function in session.functions: - tool_name_list.append(function.name) - ctx.current_action = f'Successfully connected. Found {tools_count} tools.' - - # 关闭测试会话 - await session.shutdown() - - return {'status': 'success', 'tools_count': tools_count,'tools_names_lists':tool_name_list} - - except Exception as e: - print(traceback.format_exc()) - ctx.current_action = f'Connection test failed: {str(e)}' - raise e - - diff --git a/pkg/api/http/controller/groups/resources/__init__.py b/pkg/api/http/controller/groups/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pkg/api/http/controller/groups/resources/mcp.py b/pkg/api/http/controller/groups/resources/mcp.py index f444639c..b3bb18b5 100644 --- a/pkg/api/http/controller/groups/resources/mcp.py +++ b/pkg/api/http/controller/groups/resources/mcp.py @@ -1,9 +1,8 @@ from __future__ import annotations import quart -import asyncio -from ......core import taskmgr + from ... import group @@ -14,342 +13,107 @@ class MCPRouterGroup(group.RouterGroup): async def _() -> str: """获取MCP服务器列表""" if quart.request.method == 'GET': - 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 = await self.ap.mcp_service.get_mcp_servers() servers_with_status = [] + # 获取MCP工具加载器 + mcp_loader = self.ap.tool_mgr.mcp_tool_loader + for server in servers: + # 从运行中的会话获取工具数量 + tools_count = 0 + if mcp_loader: + session = mcp_loader.sessions.get(server['name']) + if session: + tools_count = len(session.functions) + server_info = { - 'name': server['name'], - 'mode': server['mode'], - 'enable': server['enable'], - 'config': server, - 'status': 'disconnected', - 'tools': [], - 'error': None, + **server, + 'tools': tools_count, } - - # 检查服务器连接状态 - 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}) + elif quart.request.method == 'POST': data = await quart.request.json + data = data['source'] - # 验证必填字段 - 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}') + uuid = await self.ap.mcp_service.create_mcp_server(data) - # 检查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}) - else: - return self.success() - else: - return self.http_status(405, -1, 'Method not allowed') + return self.success(data={'uuid': uuid}) @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: + server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) + if server_data is None: return self.http_status(404, -1, 'Server not found') if quart.request.method == 'GET': - return self.success(data={'server': servers[server_index]}) + return self.success(data={'server': server_data}) 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}) + await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data) + return self.success() 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}) + await self.ap.mcp_service.delete_mcp_server(server_data['uuid']) + return self.success() @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: + server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) + if server_data 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') +# TODO 这里移到service去 +# # 创建测试任务 +# ctx = taskmgr.TaskContext.new() +# wrapper = self.ap.task_mgr.create_user_task( +# self._test_mcp_server(server, 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}) - if not source: - return self.http_status(400, -1, 'Missing source parameter') +# async def _test_mcp_server(self, server: persistence_mcp.MCPServer, ctx: taskmgr.TaskContext): +# """测试MCP服务器连接""" +# try: - # 创建安装任务 - 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}) +# ctx.current_action = f'Testing connection to {server.name}' +# # 创建临时会话进行测试 +# session = RuntimeMCPSession(server.name, { +# 'name': server.name, +# 'mode': server.mode, +# 'enable': server.enable, +# 'url': server.extra_args.get('url',''), +# 'headers': server.extra_args.get('headers',{}), +# 'timeout': server.extra_args.get('timeout',60), +# },enable=True, ap=self.ap) +# await session.start() - 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 +# # 获取工具列表作为测试 +# tools_count = len(session.functions) - if mcp_loader: - await mcp_loader.shutdown() +# tool_name_list = [] +# for function in session.functions: +# tool_name_list.append(function.name) +# ctx.current_action = f'Successfully connected. Found {tools_count} tools.' - ctx.current_action = 'Reloading MCP configuration' - # 重新加载MCP loader - await self.ap.tool_mgr.reload_loader('mcp') +# # 关闭测试会话 +# await session.shutdown() - ctx.current_action = 'MCP loader reloaded successfully' +# return {'status': 'success', 'tools_count': tools_count,'tools_names_lists':tool_name_list} - 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 +# except Exception as e: +# print(traceback.format_exc()) +# ctx.current_action = f'Connection test failed: {str(e)}' +# raise e diff --git a/pkg/api/http/controller/main.py b/pkg/api/http/controller/main.py index e45b461d..4f6d30af 100644 --- a/pkg/api/http/controller/main.py +++ b/pkg/api/http/controller/main.py @@ -15,12 +15,14 @@ from .groups import provider as groups_provider from .groups import platform as groups_platform from .groups import pipelines as groups_pipelines from .groups import knowledge as groups_knowledge +from .groups import resources as groups_resources importutil.import_modules_in_pkg(groups) importutil.import_modules_in_pkg(groups_provider) importutil.import_modules_in_pkg(groups_platform) importutil.import_modules_in_pkg(groups_pipelines) importutil.import_modules_in_pkg(groups_knowledge) +importutil.import_modules_in_pkg(groups_resources) class HTTPController: diff --git a/pkg/api/http/service/mcp.py b/pkg/api/http/service/mcp.py new file mode 100644 index 00000000..3edf4123 --- /dev/null +++ b/pkg/api/http/service/mcp.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import sqlalchemy +import uuid + +from ....core import app +from ....entity.persistence import mcp as persistence_mcp + + +class MCPService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_mcp_servers(self) -> list[dict]: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) + + servers = result.all() + return [self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers] + + async def create_mcp_server(self, server_data: dict) -> str: + server_data['uuid'] = str(uuid.uuid4()) + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) + server = await self.get_mcp_server(server_data['uuid']) + + # TODO: load runtime mcp server session + + return server['uuid'] + + async def get_mcp_server(self, server_uuid: str) -> dict | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + server = result.first() + if server is None: + return None + return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + + async def get_mcp_server_by_name(self, server_name: str) -> dict | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name) + ) + server = result.first() + if server is None: + return None + return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + + async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_mcp.MCPServer) + .where(persistence_mcp.MCPServer.uuid == server_uuid) + .values(server_data) + ) + + # TODO: reload runtime mcp server session + + async def delete_mcp_server(self, server_uuid: str) -> None: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + + # TODO: remove runtime mcp server session diff --git a/pkg/core/app.py b/pkg/core/app.py index 27b780f6..62e47b74 100644 --- a/pkg/core/app.py +++ b/pkg/core/app.py @@ -22,6 +22,7 @@ from ..api.http.service import model as model_service from ..api.http.service import pipeline as pipeline_service from ..api.http.service import bot as bot_service from ..api.http.service import knowledge as knowledge_service +from ..api.http.service import mcp as mcp_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr from ..utils import logcache @@ -119,6 +120,8 @@ class Application: knowledge_service: knowledge_service.KnowledgeService = None + mcp_service: mcp_service.MCPService = None + def __init__(self): pass diff --git a/pkg/core/stages/build_app.py b/pkg/core/stages/build_app.py index 54a64ae8..8df32755 100644 --- a/pkg/core/stages/build_app.py +++ b/pkg/core/stages/build_app.py @@ -19,6 +19,7 @@ from ...api.http.service import model as model_service from ...api.http.service import pipeline as pipeline_service from ...api.http.service import bot as bot_service from ...api.http.service import knowledge as knowledge_service +from ...api.http.service import mcp as mcp_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr from ...utils import logcache @@ -126,5 +127,8 @@ class BuildAppStage(stage.BootingStage): knowledge_service_inst = knowledge_service.KnowledgeService(ap) ap.knowledge_service = knowledge_service_inst + mcp_service_inst = mcp_service.MCPService(ap) + ap.mcp_service = mcp_service_inst + ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/pkg/provider/tools/toolmgr.py b/pkg/provider/tools/toolmgr.py index 43960aba..2e918331 100644 --- a/pkg/provider/tools/toolmgr.py +++ b/pkg/provider/tools/toolmgr.py @@ -3,9 +3,9 @@ from __future__ import annotations import typing from ...core import app -from . import loader as tools_loader from ...utils import importutil from . import loaders +from .loaders import mcp as mcp_loader, plugin as plugin_loader import langbot_plugin.api.entities.builtin.resource.tool as resource_tool importutil.import_modules_in_pkg(loaders) @@ -16,25 +16,24 @@ class ToolManager: ap: app.Application - loaders: list[tools_loader.ToolLoader] + plugin_tool_loader: plugin_loader.PluginToolLoader + mcp_tool_loader: mcp_loader.MCPLoader def __init__(self, ap: app.Application): self.ap = ap - self.all_functions = [] - self.loaders = [] async def initialize(self): - for loader_cls in tools_loader.preregistered_loaders: - loader_inst = loader_cls(self.ap) - await loader_inst.initialize() - self.loaders.append(loader_inst) + self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap) + await self.plugin_tool_loader.initialize() + self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap) + await self.mcp_tool_loader.initialize() async def get_all_tools(self) -> list[resource_tool.LLMTool]: """获取所有函数""" all_functions: list[resource_tool.LLMTool] = [] - for loader in self.loaders: - all_functions.extend(await loader.get_tools()) + all_functions.extend(await self.plugin_tool_loader.get_tools()) + all_functions.extend(await self.mcp_tool_loader.get_tools()) return all_functions @@ -93,13 +92,14 @@ class ToolManager: async def execute_func_call(self, name: str, parameters: dict) -> typing.Any: """执行函数调用""" - for loader in self.loaders: - if await loader.has_tool(name): - return await loader.invoke_tool(name, parameters) + if await self.plugin_tool_loader.has_tool(name): + return await self.plugin_tool_loader.invoke_tool(name, parameters) + elif await self.mcp_tool_loader.has_tool(name): + return await self.mcp_tool_loader.invoke_tool(name, parameters) else: raise ValueError(f'未找到工具: {name}') async def shutdown(self): """关闭所有工具""" - for loader in self.loaders: - await loader.shutdown() + await self.plugin_tool_loader.shutdown() + await self.mcp_tool_loader.shutdown() From c2d752f9e9463eadbbb85542270d1b0ce8d04153 Mon Sep 17 00:00:00 2001 From: wangcham Date: Sun, 2 Nov 2025 12:37:00 +0000 Subject: [PATCH 027/144] fix: try & catch & error --- .../http/controller/groups/resources/mcp.py | 71 ++---- pkg/api/http/service/mcp.py | 221 +++++++++++++++++- pkg/core/stages/build_app.py | 1 + pkg/provider/tools/loaders/mcp.py | 155 ++++++++---- 4 files changed, 349 insertions(+), 99 deletions(-) diff --git a/pkg/api/http/controller/groups/resources/mcp.py b/pkg/api/http/controller/groups/resources/mcp.py index b3bb18b5..bd22062a 100644 --- a/pkg/api/http/controller/groups/resources/mcp.py +++ b/pkg/api/http/controller/groups/resources/mcp.py @@ -39,9 +39,11 @@ class MCPRouterGroup(group.RouterGroup): data = await quart.request.json data = data['source'] - uuid = await self.ap.mcp_service.create_mcp_server(data) - - return self.success(data={'uuid': uuid}) + try: + uuid = await self.ap.mcp_service.create_mcp_server(data) + return self.success(data={'uuid': uuid}) + except Exception as e: + return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}') @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: @@ -56,12 +58,18 @@ class MCPRouterGroup(group.RouterGroup): elif quart.request.method == 'PUT': data = await quart.request.json - await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data) - return self.success() + try: + await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data) + return self.success() + except Exception as e: + return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}') elif quart.request.method == 'DELETE': - await self.ap.mcp_service.delete_mcp_server(server_data['uuid']) - return self.success() + try: + await self.ap.mcp_service.delete_mcp_server(server_data['uuid']) + return self.success() + except Exception as e: + return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}') @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: @@ -71,49 +79,6 @@ class MCPRouterGroup(group.RouterGroup): if server_data is None: return self.http_status(404, -1, 'Server not found') - -# TODO 这里移到service去 -# # 创建测试任务 -# ctx = taskmgr.TaskContext.new() -# wrapper = self.ap.task_mgr.create_user_task( -# self._test_mcp_server(server, 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}) - -# async def _test_mcp_server(self, server: persistence_mcp.MCPServer, ctx: taskmgr.TaskContext): -# """测试MCP服务器连接""" -# try: - -# ctx.current_action = f'Testing connection to {server.name}' -# # 创建临时会话进行测试 -# session = RuntimeMCPSession(server.name, { -# 'name': server.name, -# 'mode': server.mode, -# 'enable': server.enable, -# 'url': server.extra_args.get('url',''), -# 'headers': server.extra_args.get('headers',{}), -# 'timeout': server.extra_args.get('timeout',60), -# },enable=True, ap=self.ap) -# await session.start() - -# # 获取工具列表作为测试 -# tools_count = len(session.functions) - -# tool_name_list = [] -# for function in session.functions: -# tool_name_list.append(function.name) -# ctx.current_action = f'Successfully connected. Found {tools_count} tools.' - -# # 关闭测试会话 -# await session.shutdown() - -# return {'status': 'success', 'tools_count': tools_count,'tools_names_lists':tool_name_list} - -# except Exception as e: -# print(traceback.format_exc()) -# ctx.current_action = f'Connection test failed: {str(e)}' -# raise e + + task_id = await self.ap.mcp_service.test_mcp_server(server_data['uuid']) + return self.success(data={'task_id': task_id}) diff --git a/pkg/api/http/service/mcp.py b/pkg/api/http/service/mcp.py index 3edf4123..4250d756 100644 --- a/pkg/api/http/service/mcp.py +++ b/pkg/api/http/service/mcp.py @@ -2,9 +2,107 @@ from __future__ import annotations import sqlalchemy import uuid +import traceback from ....core import app from ....entity.persistence import mcp as persistence_mcp +from ....core import taskmgr +from ....provider.tools.loaders.mcp import RuntimeMCPSession + + +class RuntimeMCPServer: + """Runtime MCP Server representation""" + + ap: app.Application + + mcp_server_entity: persistence_mcp.MCPServer + + session: RuntimeMCPSession | None = None + + def __init__(self, ap: app.Application, mcp_server_entity: persistence_mcp.MCPServer): + self.ap = ap + self.mcp_server_entity = mcp_server_entity + self.session = None + + async def initialize(self): + """初始化 MCP Server""" + if not self.mcp_server_entity.enable: + return + + # 构建配置字典 + mixed_config = { + 'name': self.mcp_server_entity.name, + 'mode': self.mcp_server_entity.mode, + 'enable': self.mcp_server_entity.enable, + **self.mcp_server_entity.extra_args, + } + + self.session = RuntimeMCPSession( + self.mcp_server_entity.name, + mixed_config, + self.mcp_server_entity.enable, + self.ap + ) + await self.session.initialize() + await self.session.start() + + async def _test_mcp_server_task(self, task_context: taskmgr.TaskContext): + """测试MCP服务器连接""" + try: + task_context.set_current_action(f'Testing connection to {self.mcp_server_entity.name}') + + # 创建临时会话进行测试 + mixed_config = { + 'name': self.mcp_server_entity.name, + 'mode': self.mcp_server_entity.mode, + 'enable': True, # 测试时强制启用 + **self.mcp_server_entity.extra_args, + } + + test_session = RuntimeMCPSession( + self.mcp_server_entity.name, + mixed_config, + enable=True, + ap=self.ap + ) + await test_session.start() + + # 获取工具列表作为测试 + tools_count = len(test_session.functions) + + tool_name_list = [] + for function in test_session.functions: + tool_name_list.append(function.name) + + task_context.set_current_action(f'Successfully connected. Found {tools_count} tools.') + + # 关闭测试会话 + await test_session.shutdown() + + return {'status': 'success', 'tools_count': tools_count, 'tools_names_lists': tool_name_list} + + except Exception as e: + self.ap.logger.error(f'Connection test failed: {str(e)}\n{traceback.format_exc()}') + task_context.set_current_action(f'Connection test failed: {str(e)}') + raise e + + async def test_connection(self) -> str: + """测试 MCP 服务器连接并返回任务 ID""" + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + self._test_mcp_server_task(task_context=ctx), + kind='mcp-operation', + name=f'mcp-test-{self.mcp_server_entity.name}', + label=f'Testing MCP server {self.mcp_server_entity.name}', + context=ctx, + ) + return wrapper.id + + async def dispose(self): + """清理资源""" + if self.session: + await self.session.shutdown() + class MCPService: @@ -13,6 +111,61 @@ class MCPService: def __init__(self, ap: app.Application) -> None: self.ap = ap + def _convert_server_entity_to_config( + self, server_entity: persistence_mcp.MCPServer | sqlalchemy.Row[persistence_mcp.MCPServer] + ) -> dict: + """将数据库实体转换为 loader 需要的配置字典 + + Args: + server_entity: 数据库查询返回的服务器实体或 Row 对象 + + Returns: + 包含服务器配置的字典 + """ + if isinstance(server_entity, sqlalchemy.Row): + server = persistence_mcp.MCPServer(**server_entity._mapping) + else: + server = server_entity + + return { + 'name': server.name, + 'mode': server.mode, + 'enable': server.enable, + 'extra_args': server.extra_args, + } + + async def initialize(self) -> None: + """初始化 MCP Service,从数据库加载所有 MCP 服务器到运行时""" + self.ap.logger.info('Initializing MCP Service and loading servers from database...') + + if not self.ap.tool_mgr or not self.ap.tool_mgr.mcp_tool_loader: + self.ap.logger.warning('MCP tool loader not available, skipping MCP servers initialization') + return + + try: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) + servers = result.all() + + loaded_count = 0 + failed_count = 0 + + for server in servers: + try: + # 将数据库实体转换为配置字典后传递给 loader + server_config = self._convert_server_entity_to_config(server) + await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config) + loaded_count += 1 + self.ap.logger.debug(f'Loaded MCP server: {server_config["name"]}') + except Exception as e: + failed_count += 1 + + server_name = getattr(server, 'name', 'unknown') + self.ap.logger.error(f'Failed to load MCP server {server_name}: {e}\n{traceback.format_exc()}') + + self.ap.logger.info(f'MCP Service initialization complete. Loaded: {loaded_count}, Failed: {failed_count}') + except Exception as e: + self.ap.logger.error(f'Failed to initialize MCP Service: {e}\n{traceback.format_exc()}') + async def get_mcp_servers(self) -> list[dict]: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) @@ -22,11 +175,16 @@ class MCPService: async def create_mcp_server(self, server_data: dict) -> str: server_data['uuid'] = str(uuid.uuid4()) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) - server = await self.get_mcp_server(server_data['uuid']) - # TODO: load runtime mcp server session + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid']) + ) + server_entity = result.first() + if server_entity and self.ap.tool_mgr.mcp_tool_loader: + server_config = self._convert_server_entity_to_config(server_entity) + await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config) - return server['uuid'] + return server_data['uuid'] async def get_mcp_server(self, server_uuid: str) -> dict | None: result = await self.ap.persistence_mgr.execute_async( @@ -47,17 +205,70 @@ class MCPService: return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None: + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + old_server = result.first() + old_server_name = old_server.name if old_server else None + + await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_mcp.MCPServer) .where(persistence_mcp.MCPServer.uuid == server_uuid) .values(server_data) ) - # TODO: reload runtime mcp server session + if self.ap.tool_mgr.mcp_tool_loader: + + if old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) + + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + updated_server = result.first() + if updated_server: + # convert entity to config dict + server_config = self._convert_server_entity_to_config(updated_server) + await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config) async def delete_mcp_server(self, server_uuid: str) -> None: + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + server = result.first() + server_name = server.name if server else None + + await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) - # TODO: remove runtime mcp server session + + if server_name and self.ap.tool_mgr.mcp_tool_loader: + if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name) + + async def test_mcp_server(self, server_uuid: str) -> str: + """测试 MCP 服务器连接并返回任务 ID""" + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + server = result.first() + if server is None: + raise ValueError(f'Server not found: {server_uuid}') + + + if isinstance(server, sqlalchemy.Row): + server_entity = persistence_mcp.MCPServer(**server._mapping) + else: + server_entity = server + + runtime_server = RuntimeMCPServer(ap=self.ap, mcp_server_entity=server_entity) + + + return await runtime_server.test_connection() diff --git a/pkg/core/stages/build_app.py b/pkg/core/stages/build_app.py index 8df32755..f2991315 100644 --- a/pkg/core/stages/build_app.py +++ b/pkg/core/stages/build_app.py @@ -129,6 +129,7 @@ class BuildAppStage(stage.BootingStage): mcp_service_inst = mcp_service.MCPService(ap) ap.mcp_service = mcp_service_inst + await mcp_service_inst.initialize() ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 721e0782..3f480f0b 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -10,9 +10,7 @@ from mcp.client.sse import sse_client from .. import loader from ....core import app -from ....entity.persistence import mcp as persistence_mcp import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -import sqlalchemy class RuntimeMCPSession: @@ -113,8 +111,14 @@ class RuntimeMCPSession: ) async def shutdown(self): - """关闭工具""" - await self.session._exit_stack.aclose() + """关闭会话并清理资源""" + try: + if self.exit_stack: + await self.exit_stack.aclose() + self.functions.clear() + self.session = None + except Exception as e: + self.ap.logger.error(f'Error shutting down MCP session {self.server_name}: {e}\n{traceback.format_exc()}') @loader.loader_class('mcp') @@ -134,46 +138,48 @@ class MCPLoader(loader.ToolLoader): self._last_listed_functions = [] async def initialize(self): - await self.load_mcp_servers_from_db() + pass - async def load_mcp_servers_from_db(self): - self.ap.logger.info('Loading MCP servers from db...') - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) - servers = result.all() - for server in servers: - try: - await self.load_mcp_server(server) - except Exception as e: - self.ap.logger.error(f'Failed to load MCP server {server.name}: {e}\n{traceback.format_exc()}') + async def init_runtime_mcp_session(self, server_config: dict): + """从服务器配置创建运行时会话 - async def init_runtime_mcp_session( - self, - server_entity: persistence_mcp.MCPServer | sqlalchemy.Row[persistence_mcp.MCPServer] | dict, - ): - if isinstance(server_entity, sqlalchemy.Row): - server_entity = persistence_mcp.MCPServer(**server_entity._mapping) - elif isinstance(server_entity, dict): - server_entity = persistence_mcp.MCPServer(**server_entity) + Args: + server_config: 服务器配置字典,必须包含: + - name: 服务器名称 + - mode: 连接模式 (stdio/sse) + - enable: 是否启用 + - extra_args: 额外的配置参数 (可选) + """ + name = server_config['name'] + mode = server_config['mode'] + enable = server_config['enable'] + extra_args = server_config.get('extra_args', {}) mixed_config = { - 'name': server_entity.name, - 'mode': server_entity.mode, - 'enable': server_entity.enable, - **server_entity.extra_args, + 'name': name, + 'mode': mode, + 'enable': enable, + **extra_args, } - session = RuntimeMCPSession(server_entity.name, mixed_config, server_entity.enable, self.ap) + session = RuntimeMCPSession(name, mixed_config, enable, self.ap) await session.initialize() return session - async def load_mcp_server( - self, - server_entity: persistence_mcp.MCPServer | sqlalchemy.Row[persistence_mcp.MCPServer] | dict, - ): - session = await self.init_runtime_mcp_session(server_entity) + async def load_mcp_server(self, server_config: dict): + """加载 MCP 服务器到运行时 + + Args: + server_config: 服务器配置字典,必须包含: + - name: 服务器名称 + - mode: 连接模式 (stdio/sse) + - enable: 是否启用 + - extra_args: 额外的配置参数 (可选) + """ + session = await self.init_runtime_mcp_session(server_config) await session.start() - self.sessions[server_entity.name] = session + self.sessions[server_config['name']] = session async def get_tools(self) -> list[resource_tool.LLMTool]: all_functions = [] @@ -186,24 +192,91 @@ class MCPLoader(loader.ToolLoader): return all_functions async def has_tool(self, name: str) -> bool: - return name in [f.name for f in self._last_listed_functions] - - async def invoke_tool(self, name: str, parameters: dict) -> typing.Any: + """检查工具是否存在""" for session in self.sessions.values(): for function in session.functions: if function.name == name: - return await function.func(**parameters) + return True + return False + + async def invoke_tool(self, name: str, parameters: dict) -> typing.Any: + """执行工具调用""" + for session in self.sessions.values(): + for function in session.functions: + if function.name == name: + self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}') + try: + result = await function.func(**parameters) + self.ap.logger.debug(f'MCP tool {name} executed successfully') + return result + except Exception as e: + self.ap.logger.error(f'Error invoking MCP tool {name}: {e}\n{traceback.format_exc()}') + raise raise ValueError(f'Tool not found: {name}') + async def reload_mcp_server(self, server_config: dict): + """重新加载 MCP 服务器(先移除再加载) + + Args: + server_config: 服务器配置字典,必须包含 name 字段 + """ + server_name = server_config['name'] + + if server_name in self.sessions: + await self.remove_mcp_server(server_name) + + # 重新加载 + await self.load_mcp_server(server_config) + async def remove_mcp_server(self, server_name: str): + """移除 MCP 服务器""" if server_name not in self.sessions: - raise ValueError(f'MCP server {server_name} not found') + self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal') + return session = self.sessions.pop(server_name) await session.shutdown() + self.ap.logger.info(f'Removed MCP server: {server_name}') + + def get_session(self, server_name: str) -> RuntimeMCPSession | None: + """获取指定名称的 MCP 会话""" + return self.sessions.get(server_name) + + def has_session(self, server_name: str) -> bool: + """检查是否存在指定名称的 MCP 会话""" + return server_name in self.sessions + + def get_all_server_names(self) -> list[str]: + """获取所有已加载的 MCP 服务器名称""" + return list(self.sessions.keys()) + + def get_server_tool_count(self, server_name: str) -> int: + """获取指定服务器的工具数量""" + session = self.get_session(server_name) + return len(session.functions) if session else 0 + + def get_all_servers_info(self) -> dict[str, dict]: + """获取所有服务器的信息""" + info = {} + for server_name, session in self.sessions.items(): + info[server_name] = { + 'name': server_name, + 'mode': session.server_config.get('mode'), + 'enable': session.enable, + 'tools_count': len(session.functions), + 'tool_names': [f.name for f in session.functions], + } + return info async def shutdown(self): - """关闭工具""" - for session in self.sessions.values(): - await session.shutdown() + """关闭所有工具""" + self.ap.logger.info('Shutting down all MCP sessions...') + for server_name, session in list(self.sessions.items()): + try: + await session.shutdown() + self.ap.logger.debug(f'Shutdown MCP session: {server_name}') + except Exception as e: + self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}') + self.sessions.clear() + self.ap.logger.info('All MCP sessions shutdown complete') From 76831579adf6c770f7b82b78830668f0e9f30ed2 Mon Sep 17 00:00:00 2001 From: wangcham Date: Sun, 2 Nov 2025 13:57:37 +0000 Subject: [PATCH 028/144] fix: error message in mcp card --- web/src/app/home/plugins/page.tsx | 21 +++++++++++++---- web/src/i18n/locales/en-US.ts | 39 ++++++++----------------------- web/src/i18n/locales/ja-JP.ts | 39 ++++++++----------------------- web/src/i18n/locales/zh-Hans.ts | 37 +++++++---------------------- web/src/i18n/locales/zh-Hant.ts | 39 ++++++++----------------------- 5 files changed, 55 insertions(+), 120 deletions(-) diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 389a9a75..6dc59ee7 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -95,16 +95,20 @@ export default function PluginConfigPage() { }; const getFormSchema = (t: (key: string) => string) => z.object({ - name: z.string({ required_error: t('mcp.nameRequired') }), + name: z + .string({ required_error: t('mcp.nameRequired') }) + .min(1, { message: t('mcp.nameRequired') }), timeout: z .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) - .nonnegative({ message: t('mcp.timeoutNonNegative') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) .default(30), ssereadtimeout: z .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) - .nonnegative({ message: t('mcp.sseTimeoutNonNegative') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) .default(300), - url: z.string({ required_error: t('models.requestURLRequired') }), + url: z + .string({ required_error: t('mcp.urlRequired') }) + .min(1, { message: t('mcp.urlRequired') }), extra_args: z .array( z.object({ @@ -1207,7 +1211,14 @@ export default function PluginConfigPage() { {t('mcp.timeout')} - + + field.onChange(Number(e.target.value)) + } + /> diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index e24c44aa..44c0ce95 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -284,7 +284,6 @@ const enUS = { }, mcp: { title: 'MCP Management', - description: 'Manage Model Context Protocol (MCP) servers to extend AI capabilities', createServer: 'Create MCP Server', editServer: 'Edit MCP Server', deleteServer: 'Delete MCP Server', @@ -295,11 +294,11 @@ const enUS = { serverMode: 'Connection Mode', stdio: 'Stdio Mode', sse: 'SSE Mode', - serverConfig: 'MCP Server Configuration', noServerInstalled: 'No MCP servers configured', serverNameRequired: 'Server name cannot be empty', commandRequired: 'Command cannot be empty', urlRequired: 'URL cannot be empty', + timeoutMustBePositive: 'Timeout must be a positive number', command: 'Command', args: 'Arguments', env: 'Environment Variables', @@ -311,13 +310,10 @@ const enUS = { addHeader: 'Add Header', keyName: 'Key Name', value: 'Value', - connected: 'Connected', - disconnected: 'Disconnected', - error: 'Error', - testConnection: 'Test Connection', testing: 'Testing...', testSuccess: 'Connection test successful', testFailed: 'Connection test failed: ', + testError: 'Connection test error', connectionSuccess: 'Connection successful', connectionFailed: 'Connection failed', toolsFound: 'tools', @@ -328,51 +324,36 @@ const enUS = { getTaskFailed: 'Failed to get task status', noTaskId: 'No task ID obtained', deleteSuccess: 'Deleted successfully', + deleteFailed: 'Delete failed: ', deleteError: 'Delete failed: ', saveSuccess: 'Saved successfully', saveError: 'Save failed: ', createSuccess: 'Created successfully', + createFailed: 'Creation failed: ', createError: 'Creation failed: ', + loadFailed: 'Load failed', modifyFailed: 'Modify failed: ', toolCount: 'Tools: {{count}}', statusConnected: 'Connected', statusDisconnected: 'Disconnected', statusError: 'Connection Error', statusDisabled: 'Disabled', - serverStatus: 'Server Status', - marketplace: 'MCP Marketplace', - searchServer: 'Search MCP servers', - sortBy: 'Sort by', - mostStars: 'Most stars', - recentlyAdded: 'Recently added', - recentlyUpdated: 'Recently updated', loading: 'Loading...', - noMatchingServers: 'No matching MCP servers', starCount: 'Stars: {{count}}', install: 'Install', - installing: 'Installing...', - installSuccess: 'MCP server installed successfully', - installFailed: 'MCP server installation failed', installFromGithub: 'Install MCP Server from GitHub', - onlySupportGithub: 'Currently only supports installation from GitHub', - enterGithubLink: 'Enter GitHub repository link', add: 'Add', name: 'Name', - nameExplained: 'Used to distinguish different MCP server instances', - sseURL: 'SSE URL', - sseHeaders: 'SSE Headers', nameRequired: 'Name cannot be empty', - sseURLRequired: 'SSE URL cannot be empty', - enterSSELink: 'Enter SSE URL', - timeoutRequired: 'Timeout cannot be empty', - headersExample: 'Example: Authorization: Bearer token123', - enterTimeout: 'Enter timeout in milliseconds', - installFromSSE: 'Install from SSE', sseTimeout: 'SSE Timeout', sseTimeoutDescription: 'Timeout for establishing SSE connection', extraParametersDescription: 'Additional parameters for configuring specific MCP server behavior', + timeoutMustBeNumber: 'Timeout must be a number', + timeoutNonNegative: 'Timeout cannot be negative', + sseTimeoutMustBeNumber: 'SSE timeout must be a number', + sseTimeoutNonNegative: 'SSE timeout cannot be negative', updateSuccess: 'Updated successfully', - testError: 'Connection test error', + updateFailed: 'Update failed: ', }, pipelines: { title: 'Pipelines', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 01b4c919..1907689e 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -286,7 +286,6 @@ const jaJP = { }, mcp: { title: 'MCP管理', - description: 'Model Context Protocol (MCP) サーバーを管理してAI機能を拡張', createServer: 'MCPサーバーを作成', editServer: 'MCPサーバーを編集', deleteServer: 'MCPサーバーを削除', @@ -297,11 +296,11 @@ const jaJP = { serverMode: '接続モード', stdio: 'Stdioモード', sse: 'SSEモード', - serverConfig: 'MCPサーバー設定', noServerInstalled: 'MCPサーバーが設定されていません', serverNameRequired: 'サーバー名は必須です', commandRequired: 'コマンドは必須です', urlRequired: 'URLは必須です', + timeoutMustBePositive: 'タイムアウトは正の数でなければなりません', command: 'コマンド', args: '引数', env: '環境変数', @@ -313,13 +312,10 @@ const jaJP = { addHeader: 'ヘッダーを追加', keyName: 'キー名', value: '値', - connected: '接続済み', - disconnected: '未接続', - error: 'エラー', - testConnection: '接続テスト', testing: 'テスト中...', testSuccess: '接続テストに成功しました', testFailed: '接続テストに失敗しました:', + testError: '接続テストエラー', connectionSuccess: '接続に成功しました', connectionFailed: '接続に失敗しました', toolsFound: '個のツール', @@ -330,51 +326,36 @@ const jaJP = { getTaskFailed: 'タスクステータスの取得に失敗しました', noTaskId: 'タスクIDを取得できませんでした', deleteSuccess: '削除に成功しました', + deleteFailed: '削除に失敗しました:', deleteError: '削除に失敗しました:', saveSuccess: '保存に成功しました', saveError: '保存に失敗しました:', createSuccess: '作成に成功しました', + createFailed: '作成に失敗しました:', createError: '作成に失敗しました:', + loadFailed: '読み込みに失敗しました', modifyFailed: '変更に失敗しました:', toolCount: 'ツール:{{count}}', statusConnected: '接続済み', statusDisconnected: '未接続', statusError: '接続エラー', statusDisabled: '無効', - 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からのインストールのみサポートしています', - enterGithubLink: 'GitHubリポジトリのリンクを入力', add: '追加', name: '名前', - nameExplained: '異なるMCPサーバーインスタンスを区別するために使用', - sseURL: 'SSE URL', - sseHeaders: 'SSEヘッダー', nameRequired: '名前は必須です', - sseURLRequired: 'SSE URLは必須です', - enterSSELink: 'SSE URLを入力', - timeoutRequired: 'タイムアウトは必須です', - headersExample: '例: Authorization: Bearer token123', - enterTimeout: 'タイムアウトをミリ秒単位で入力', - installFromSSE: 'SSEからインストール', sseTimeout: 'SSEタイムアウト', sseTimeoutDescription: 'SSE接続を確立するためのタイムアウト', extraParametersDescription: 'MCPサーバーの特定の動作を設定するための追加パラメータ', + timeoutMustBeNumber: 'タイムアウトは数値である必要があります', + timeoutNonNegative: 'タイムアウトは負の数にできません', + sseTimeoutMustBeNumber: 'SSEタイムアウトは数値である必要があります', + sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません', updateSuccess: '更新に成功しました', - testError: '接続テスト出錯', + updateFailed: '更新に失敗しました:', }, pipelines: { title: 'パイプライン', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 8fd3f57b..8cbac761 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -272,7 +272,6 @@ const zhHans = { }, mcp: { title: 'MCP管理', - description: '管理Model Context Protocol (MCP) 服务器,扩展AI能力', createServer: '创建MCP服务器', editServer: '编辑MCP服务器', deleteServer: '删除MCP服务器', @@ -283,11 +282,11 @@ const zhHans = { serverMode: '连接模式', stdio: 'Stdio模式', sse: 'SSE模式', - serverConfig: 'MCP服务器配置', noServerInstalled: '暂未配置任何MCP服务器', serverNameRequired: '服务器名称不能为空', commandRequired: '命令不能为空', urlRequired: 'URL不能为空', + timeoutMustBePositive: '超时时间必须是正数', command: '命令', args: '参数', env: '环境变量', @@ -299,10 +298,6 @@ const zhHans = { addHeader: '添加请求头', keyName: '键名', value: '值', - connected: '已打开', - disconnected: '未打开', - error: '错误', - testConnection: '测试连接', testing: '测试中...', testSuccess: '连接测试成功', testFailed: '连接测试失败:', @@ -317,50 +312,36 @@ const zhHans = { getTaskFailed: '获取任务状态失败', noTaskId: '未获取到任务ID', deleteSuccess: '删除成功', + deleteFailed: '删除失败:', deleteError: '删除失败:', saveSuccess: '保存成功', saveError: '保存失败:', createSuccess: '创建成功', + createFailed: '创建失败:', createError: '创建失败:', + loadFailed: '加载失败', modifyFailed: '修改失败:', toolCount: '工具:{{count}}', statusConnected: '已打开', statusDisconnected: '未打开', statusError: '连接错误', statusDisabled: '已禁用', - 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仓库链接', add: '添加', name: '名称', - nameExplained: '用于区分不同的MCP服务器实例', - sseURL: 'SSE URL', - sseHeaders: 'SSE Headers', nameRequired: '名称不能为空', - sseURLRequired: 'SSE URL不能为空', - enterSSELink: '输入SSE URL', - timeoutRequired: '超时时间不能为空', - headersExample: '示例: Authorization: Bearer token123', - enterTimeout: '输入超时时间,单位为毫秒', - installFromSSE: '从SSE安装', sseTimeout: 'SSE超时时间', sseTimeoutDescription: '用于建立SSE连接的超时时间', extraParametersDescription: '额外参数,用于配置MCP服务器的特定行为', + timeoutMustBeNumber: '超时时间必须是数字', + timeoutNonNegative: '超时时间不能为负数', + sseTimeoutMustBeNumber: 'SSE超时时间必须是数字', + sseTimeoutNonNegative: 'SSE超时时间不能为负数', updateSuccess: '更新成功', + updateFailed: '更新失败:', }, pipelines: { title: '流水线', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 88905a72..6c654b91 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -270,7 +270,6 @@ const zhHant = { }, mcp: { title: 'MCP管理', - description: '管理Model Context Protocol (MCP) 伺服器,擴展AI能力', createServer: '建立MCP伺服器', editServer: '編輯MCP伺服器', deleteServer: '刪除MCP伺服器', @@ -281,11 +280,11 @@ const zhHant = { serverMode: '連接模式', stdio: 'Stdio模式', sse: 'SSE模式', - serverConfig: 'MCP伺服器設定', noServerInstalled: '暫未設定任何MCP伺服器', serverNameRequired: '伺服器名稱不能為空', commandRequired: '命令不能為空', urlRequired: 'URL不能為空', + timeoutMustBePositive: '逾時時間必須是正數', command: '命令', args: '參數', env: '環境變數', @@ -297,13 +296,10 @@ const zhHant = { addHeader: '新增請求標頭', keyName: '鍵名', value: '值', - connected: '已開啟', - disconnected: '未開啟', - error: '錯誤', - testConnection: '測試連接', testing: '測試中...', testSuccess: '連接測試成功', testFailed: '連接測試失敗:', + testError: '連接測試出錯', connectionSuccess: '連接成功', connectionFailed: '連接失敗', toolsFound: '個工具', @@ -314,51 +310,36 @@ const zhHant = { getTaskFailed: '獲取任務狀態失敗', noTaskId: '未獲取到任務ID', deleteSuccess: '刪除成功', + deleteFailed: '刪除失敗:', deleteError: '刪除失敗:', saveSuccess: '儲存成功', saveError: '儲存失敗:', createSuccess: '建立成功', + createFailed: '建立失敗:', createError: '建立失敗:', + loadFailed: '載入失敗', modifyFailed: '修改失敗:', toolCount: '工具:{{count}}', statusConnected: '已開啟', statusDisconnected: '未開啟', statusError: '連接錯誤', statusDisabled: '已停用', - 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儲存庫連結', add: '新增', name: '名稱', - nameExplained: '用於區分不同的MCP伺服器實例', - sseURL: 'SSE URL', - sseHeaders: 'SSE Headers', nameRequired: '名稱不能為空', - sseURLRequired: 'SSE URL不能為空', - enterSSELink: '輸入SSE URL', - timeoutRequired: '逾時時間不能為空', - headersExample: '範例: Authorization: Bearer token123', - enterTimeout: '輸入逾時時間,單位為毫秒', - installFromSSE: '從SSE安裝', sseTimeout: 'SSE逾時時間', sseTimeoutDescription: '用於建立SSE連接的逾時時間', extraParametersDescription: '額外參數,用於設定MCP伺服器的特定行為', + timeoutMustBeNumber: '逾時時間必須是數字', + timeoutNonNegative: '逾時時間不能為負數', + sseTimeoutMustBeNumber: 'SSE逾時時間必須是數字', + sseTimeoutNonNegative: 'SSE逾時時間不能為負數', updateSuccess: '更新成功', - testError: '連接測試出錯' + updateFailed: '更新失敗:', }, pipelines: { title: '流程線', From 4d0a28a1a7be79b1e3b8c8a70ddca8e986e16995 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Mon, 3 Nov 2025 17:25:56 +0800 Subject: [PATCH 029/144] feat: no longer register tool loader as component for type hints --- pkg/api/http/controller/groups/resources/mcp.py | 1 - pkg/provider/tools/loaders/mcp.py | 2 +- pkg/provider/tools/loaders/plugin.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/api/http/controller/groups/resources/mcp.py b/pkg/api/http/controller/groups/resources/mcp.py index bd22062a..97788583 100644 --- a/pkg/api/http/controller/groups/resources/mcp.py +++ b/pkg/api/http/controller/groups/resources/mcp.py @@ -79,6 +79,5 @@ class MCPRouterGroup(group.RouterGroup): if server_data is None: return self.http_status(404, -1, 'Server not found') - task_id = await self.ap.mcp_service.test_mcp_server(server_data['uuid']) return self.success(data={'task_id': task_id}) diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 3f480f0b..0751ca42 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -121,7 +121,7 @@ class RuntimeMCPSession: self.ap.logger.error(f'Error shutting down MCP session {self.server_name}: {e}\n{traceback.format_exc()}') -@loader.loader_class('mcp') +# @loader.loader_class('mcp') class MCPLoader(loader.ToolLoader): """MCP 工具加载器。 diff --git a/pkg/provider/tools/loaders/plugin.py b/pkg/provider/tools/loaders/plugin.py index 94296470..5c702d10 100644 --- a/pkg/provider/tools/loaders/plugin.py +++ b/pkg/provider/tools/loaders/plugin.py @@ -7,7 +7,7 @@ from .. import loader import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -@loader.loader_class('plugin-tool-loader') +# @loader.loader_class('plugin-tool-loader') class PluginToolLoader(loader.ToolLoader): """插件工具加载器。 From f3199dda204e2ba3c66977fba391311cc10759f2 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Mon, 3 Nov 2025 20:16:45 +0800 Subject: [PATCH 030/144] perf: make startup async --- pkg/api/http/service/mcp.py | 88 +++---------------------------- pkg/core/stages/build_app.py | 1 - pkg/provider/tools/loaders/mcp.py | 84 +++++++++++++++++++---------- 3 files changed, 65 insertions(+), 108 deletions(-) diff --git a/pkg/api/http/service/mcp.py b/pkg/api/http/service/mcp.py index 4250d756..ea120718 100644 --- a/pkg/api/http/service/mcp.py +++ b/pkg/api/http/service/mcp.py @@ -38,10 +38,7 @@ class RuntimeMCPServer: } self.session = RuntimeMCPSession( - self.mcp_server_entity.name, - mixed_config, - self.mcp_server_entity.enable, - self.ap + self.mcp_server_entity.name, mixed_config, self.mcp_server_entity.enable, self.ap ) await self.session.initialize() await self.session.start() @@ -59,12 +56,7 @@ class RuntimeMCPServer: **self.mcp_server_entity.extra_args, } - test_session = RuntimeMCPSession( - self.mcp_server_entity.name, - mixed_config, - enable=True, - ap=self.ap - ) + test_session = RuntimeMCPSession(self.mcp_server_entity.name, mixed_config, enable=True, ap=self.ap) await test_session.start() # 获取工具列表作为测试 @@ -104,68 +96,12 @@ class RuntimeMCPServer: await self.session.shutdown() - class MCPService: ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap - def _convert_server_entity_to_config( - self, server_entity: persistence_mcp.MCPServer | sqlalchemy.Row[persistence_mcp.MCPServer] - ) -> dict: - """将数据库实体转换为 loader 需要的配置字典 - - Args: - server_entity: 数据库查询返回的服务器实体或 Row 对象 - - Returns: - 包含服务器配置的字典 - """ - if isinstance(server_entity, sqlalchemy.Row): - server = persistence_mcp.MCPServer(**server_entity._mapping) - else: - server = server_entity - - return { - 'name': server.name, - 'mode': server.mode, - 'enable': server.enable, - 'extra_args': server.extra_args, - } - - async def initialize(self) -> None: - """初始化 MCP Service,从数据库加载所有 MCP 服务器到运行时""" - self.ap.logger.info('Initializing MCP Service and loading servers from database...') - - if not self.ap.tool_mgr or not self.ap.tool_mgr.mcp_tool_loader: - self.ap.logger.warning('MCP tool loader not available, skipping MCP servers initialization') - return - - try: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) - servers = result.all() - - loaded_count = 0 - failed_count = 0 - - for server in servers: - try: - # 将数据库实体转换为配置字典后传递给 loader - server_config = self._convert_server_entity_to_config(server) - await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config) - loaded_count += 1 - self.ap.logger.debug(f'Loaded MCP server: {server_config["name"]}') - except Exception as e: - failed_count += 1 - - server_name = getattr(server, 'name', 'unknown') - self.ap.logger.error(f'Failed to load MCP server {server_name}: {e}\n{traceback.format_exc()}') - - self.ap.logger.info(f'MCP Service initialization complete. Loaded: {loaded_count}, Failed: {failed_count}') - except Exception as e: - self.ap.logger.error(f'Failed to initialize MCP Service: {e}\n{traceback.format_exc()}') - async def get_mcp_servers(self) -> list[dict]: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) @@ -180,9 +116,10 @@ class MCPService: sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid']) ) server_entity = result.first() - if server_entity and self.ap.tool_mgr.mcp_tool_loader: - server_config = self._convert_server_entity_to_config(server_entity) - await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config) + if server_entity: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity) + if self.ap.tool_mgr.mcp_tool_loader: + await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config) return server_data['uuid'] @@ -205,14 +142,12 @@ class MCPService: return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None: - result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) old_server = result.first() old_server_name = old_server.name if old_server else None - await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_mcp.MCPServer) .where(persistence_mcp.MCPServer.uuid == server_uuid) @@ -220,41 +155,36 @@ class MCPService: ) if self.ap.tool_mgr.mcp_tool_loader: - if old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) - result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) updated_server = result.first() if updated_server: # convert entity to config dict - server_config = self._convert_server_entity_to_config(updated_server) + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config) async def delete_mcp_server(self, server_uuid: str) -> None: - result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) server = result.first() server_name = server.name if server else None - await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) - if server_name and self.ap.tool_mgr.mcp_tool_loader: if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name) async def test_mcp_server(self, server_uuid: str) -> str: """测试 MCP 服务器连接并返回任务 ID""" - + result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) @@ -262,7 +192,6 @@ class MCPService: if server is None: raise ValueError(f'Server not found: {server_uuid}') - if isinstance(server, sqlalchemy.Row): server_entity = persistence_mcp.MCPServer(**server._mapping) else: @@ -270,5 +199,4 @@ class MCPService: runtime_server = RuntimeMCPServer(ap=self.ap, mcp_server_entity=server_entity) - return await runtime_server.test_connection() diff --git a/pkg/core/stages/build_app.py b/pkg/core/stages/build_app.py index f2991315..8df32755 100644 --- a/pkg/core/stages/build_app.py +++ b/pkg/core/stages/build_app.py @@ -129,7 +129,6 @@ class BuildAppStage(stage.BootingStage): mcp_service_inst = mcp_service.MCPService(ap) ap.mcp_service = mcp_service_inst - await mcp_service_inst.initialize() ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 0751ca42..fc9db294 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -3,6 +3,8 @@ from __future__ import annotations import typing from contextlib import AsyncExitStack import traceback +import sqlalchemy +import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -11,6 +13,7 @@ from mcp.client.sse import sse_client from .. import loader from ....core import app import langbot_plugin.api.entities.builtin.resource.tool as resource_tool +from ....entity.persistence import mcp as persistence_mcp class RuntimeMCPSession: @@ -77,8 +80,6 @@ class RuntimeMCPSession: if not self.enable: return - self.ap.logger.debug(f'初始化 MCP 会话: {self.server_name} {self.server_config}') - if self.server_config['mode'] == 'stdio': await self._init_stdio_python_server() elif self.server_config['mode'] == 'sse': @@ -110,6 +111,9 @@ class RuntimeMCPSession: ) ) + def get_tools(self) -> list[resource_tool.LLMTool]: + return self.functions + async def shutdown(self): """关闭会话并清理资源""" try: @@ -128,20 +132,59 @@ class MCPLoader(loader.ToolLoader): 在此加载器中管理所有与 MCP Server 的连接。 """ - sessions: dict[str, RuntimeMCPSession] = {} + sessions: dict[str, RuntimeMCPSession] - _last_listed_functions: list[resource_tool.LLMTool] = [] + _last_listed_functions: list[resource_tool.LLMTool] + + _startup_load_tasks: list[asyncio.Task] def __init__(self, ap: app.Application): super().__init__(ap) self.sessions = {} self._last_listed_functions = [] + self._startup_load_tasks = [] async def initialize(self): - pass + await self.load_mcp_servers_from_db() - async def init_runtime_mcp_session(self, server_config: dict): - """从服务器配置创建运行时会话 + async def load_mcp_servers_from_db(self): + self.ap.logger.info('Loading MCP servers from db...') + + self.sessions = {} + + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) + servers = result.all() + + for server in servers: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + + async def load_mcp_server_task(): + self.ap.logger.debug(f'Loading MCP server {server_config}') + try: + session = await self.load_mcp_server(server_config) + self.sessions[server_config['name']] = session + except Exception as e: + self.ap.logger.error( + f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' + ) + return + + self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})') + try: + await session.start() + except Exception as e: + self.ap.logger.error( + f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' + ) + return + + self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})') + + task = asyncio.create_task(load_mcp_server_task()) + self._startup_load_tasks.append(task) + + async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession: + """加载 MCP 服务器到运行时 Args: server_config: 服务器配置字典,必须包含: @@ -150,6 +193,7 @@ class MCPLoader(loader.ToolLoader): - enable: 是否启用 - extra_args: 额外的配置参数 (可选) """ + name = server_config['name'] mode = server_config['mode'] enable = server_config['enable'] @@ -167,25 +211,11 @@ class MCPLoader(loader.ToolLoader): return session - async def load_mcp_server(self, server_config: dict): - """加载 MCP 服务器到运行时 - - Args: - server_config: 服务器配置字典,必须包含: - - name: 服务器名称 - - mode: 连接模式 (stdio/sse) - - enable: 是否启用 - - extra_args: 额外的配置参数 (可选) - """ - session = await self.init_runtime_mcp_session(server_config) - await session.start() - self.sessions[server_config['name']] = session - async def get_tools(self) -> list[resource_tool.LLMTool]: all_functions = [] for session in self.sessions.values(): - all_functions.extend(session.functions) + all_functions.extend(session.get_tools()) self._last_listed_functions = all_functions @@ -194,7 +224,7 @@ class MCPLoader(loader.ToolLoader): async def has_tool(self, name: str) -> bool: """检查工具是否存在""" for session in self.sessions.values(): - for function in session.functions: + for function in session.get_tools(): if function.name == name: return True return False @@ -202,7 +232,7 @@ class MCPLoader(loader.ToolLoader): async def invoke_tool(self, name: str, parameters: dict) -> typing.Any: """执行工具调用""" for session in self.sessions.values(): - for function in session.functions: + for function in session.get_tools(): if function.name == name: self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}') try: @@ -254,7 +284,7 @@ class MCPLoader(loader.ToolLoader): def get_server_tool_count(self, server_name: str) -> int: """获取指定服务器的工具数量""" session = self.get_session(server_name) - return len(session.functions) if session else 0 + return len(session.get_tools()) if session else 0 def get_all_servers_info(self) -> dict[str, dict]: """获取所有服务器的信息""" @@ -264,8 +294,8 @@ class MCPLoader(loader.ToolLoader): 'name': server_name, 'mode': session.server_config.get('mode'), 'enable': session.enable, - 'tools_count': len(session.functions), - 'tool_names': [f.name for f in session.functions], + 'tools_count': len(session.get_tools()), + 'tool_names': [f.name for f in session.get_tools()], } return info From bc1fbfa190cc5de0db7a6208f00b428af312627b Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Mon, 3 Nov 2025 20:23:53 +0800 Subject: [PATCH 031/144] feat: completely remove the fucking mcp market components and refs --- .../plugins/{mcp => mcp-server}/MCPCardVO.ts | 2 +- .../plugins/mcp-server/MCPServerComponent.tsx | 11 +- .../mcp-card/MCPCardComponent.tsx | 2 +- .../MCPServerCardComponent.tsx | 87 ---- .../mcp-server-card/MCPServerCardVO.ts | 29 -- web/src/app/home/plugins/mcp/MCPComponent.tsx | 324 -------------- .../app/home/plugins/mcp/mcp-form/MCPForm.tsx | 408 ------------------ web/src/app/home/plugins/page.tsx | 53 +-- web/src/app/infra/entities/api/index.ts | 24 -- web/src/app/infra/http/BackendClient.ts | 16 - 10 files changed, 26 insertions(+), 930 deletions(-) rename web/src/app/home/plugins/{mcp => mcp-server}/MCPCardVO.ts (99%) rename web/src/app/home/plugins/{mcp => mcp-server}/mcp-card/MCPCardComponent.tsx (99%) delete mode 100644 web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardComponent.tsx delete mode 100644 web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardVO.ts delete mode 100644 web/src/app/home/plugins/mcp/MCPComponent.tsx delete mode 100644 web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx diff --git a/web/src/app/home/plugins/mcp/MCPCardVO.ts b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts similarity index 99% rename from web/src/app/home/plugins/mcp/MCPCardVO.ts rename to web/src/app/home/plugins/mcp-server/MCPCardVO.ts index 43982a58..48c9aa62 100644 --- a/web/src/app/home/plugins/mcp/MCPCardVO.ts +++ b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts @@ -13,7 +13,7 @@ export class MCPCardVO { this.name = data.name; this.mode = data.mode; this.enable = data.enable; - + this.status = (data.status as string) === 'enabled' ? 'connected' : data.status; this.tools = Array.isArray(data.tools) diff --git a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx index 1647b3cf..68fad521 100644 --- a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx @@ -2,13 +2,13 @@ import { useEffect, useState } from 'react'; import styles from '@/app/home/plugins/plugins.module.css'; -import MCPCardComponent from '@/app/home/plugins/mcp/mcp-card/MCPCardComponent'; -import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO'; +import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent'; +import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO'; import { useTranslation } from 'react-i18next'; import { httpClient } from '@/app/infra/http/HttpClient'; -export default function MCPMarketComponent({ +export default function MCPComponent({ onEditServer, toolsCountCache = {}, }: { @@ -20,7 +20,6 @@ export default function MCPMarketComponent({ const [installedServers, setInstalledServers] = useState([]); const [loading, setLoading] = useState(false); - useEffect(() => { initData(); }, []); @@ -40,7 +39,7 @@ export default function MCPMarketComponent({ .then((resp) => { const servers = resp.servers.map((server) => { const vo = new MCPCardVO(server); - + if (toolsCountCache[server.name] !== undefined) { vo.tools = toolsCountCache[server.name]; } @@ -55,8 +54,6 @@ export default function MCPMarketComponent({ }); } - - return (
{/* 已安装的服务器列表 */} diff --git a/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx similarity index 99% rename from web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx rename to web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx index 2cffe435..87832ce1 100644 --- a/web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx @@ -1,4 +1,4 @@ -import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO'; +import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO'; import { useState, useEffect } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { Badge } from '@/components/ui/badge'; diff --git a/web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardComponent.tsx deleted file mode 100644 index c28b5f4b..00000000 --- a/web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardComponent.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { MCPMarketCardVO } from '@/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardVO'; -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-server/mcp-server-card/MCPServerCardVO.ts b/web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardVO.ts deleted file mode 100644 index 433171c6..00000000 --- a/web/src/app/home/plugins/mcp-server/mcp-server-card/MCPServerCardVO.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/MCPComponent.tsx b/web/src/app/home/plugins/mcp/MCPComponent.tsx deleted file mode 100644 index 00cf6619..00000000 --- a/web/src/app/home/plugins/mcp/MCPComponent.tsx +++ /dev/null @@ -1,324 +0,0 @@ -'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; -} - -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); - const [autoTestTriggered, setAutoTestTriggered] = useState(false); - const [testingServers, setTestingServers] = useState>(new Set()); - - useEffect(() => { - initData(); - }, []); - - function initData() { - getServerList(true); - } - - function getServerList(shouldAutoTest: boolean = false) { - console.log('[MCP] Fetching server list...'); - httpClient - .getMCPServers() - .then((value) => { - const servers = value.servers.map((server) => new MCPCardVO(server)); - console.log( - '[MCP] Server list updated:', - servers.map((s) => ({ - name: s.name, - status: s.status, - tools: s.tools, - })), - ); - setServerList(servers); - - // 自动测试:仅在初始加载且还未触发过自动测试时执行 - if (shouldAutoTest && !autoTestTriggered && servers.length > 0) { - setAutoTestTriggered(true); - testAllServers(servers); - } - }) - .catch((error) => { - toast.error(t('mcp.getServerListError') + error.message); - }); - } - - async function testAllServers(servers: MCPCardVO[]) { - // 为每个服务器启动测试 - console.log('[MCP] Starting tests for all servers:', servers.length); - const testPromises = servers.map((server) => testServer(server.name)); - - // 等待所有测试完成 - try { - await Promise.all(testPromises); - console.log('[MCP] All tests completed, refreshing server list...'); - // 所有测试完成后,延迟1秒再刷新,确保后端状态已更新 - setTimeout(() => { - console.log('[MCP] Refreshing server list after tests'); - getServerList(false); - }, 1000); - } catch (err) { - console.error('[MCP] Some tests failed:', err); - // 即使有失败,也要刷新列表 - setTimeout(() => { - console.log('[MCP] Refreshing server list after test failures'); - getServerList(false); - }, 1000); - } - } - - function testServer(serverName: string): Promise { - return new Promise((resolve, reject) => { - // 标记为正在测试 - console.log(`[MCP] Starting test for server: ${serverName}`); - setTestingServers((prev) => new Set(prev).add(serverName)); - - httpClient - .testMCPServer(serverName) - .then((resp) => { - const taskId = resp.task_id; - console.log( - `[MCP] Test task created for ${serverName}, task_id: ${taskId}`, - ); - // 监控任务状态 - const interval = setInterval(() => { - httpClient - .getAsyncTask(taskId) - .then((taskResp) => { - if (taskResp.runtime.done) { - clearInterval(interval); - // 标记测试完成 - setTestingServers((prev) => { - const newSet = new Set(prev); - newSet.delete(serverName); - return newSet; - }); - - if (taskResp.runtime.exception) { - console.error( - `[MCP] Test failed for ${serverName}:`, - taskResp.runtime.exception, - ); - reject(new Error(taskResp.runtime.exception)); - } else { - console.log( - `[MCP] Test completed successfully for ${serverName}`, - ); - resolve(); - } - } - }) - .catch((err) => { - clearInterval(interval); - setTestingServers((prev) => { - const newSet = new Set(prev); - newSet.delete(serverName); - return newSet; - }); - console.error( - `[MCP] Error monitoring task for ${serverName}:`, - err, - ); - reject(err); - }); - }, 1000); - }) - .catch((err) => { - console.error(`[MCP] Failed to start test for ${serverName}:`, err); - setTestingServers((prev) => { - const newSet = new Set(prev); - newSet.delete(serverName); - return newSet; - }); - reject(err); - }); - }); - } - - useImperativeHandle(ref, () => ({ - refreshServerList: () => getServerList(false), - 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(false); - } - } - }); - }, 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) => { - return ( -
- handleServerClick(vo)} - onRefresh={() => getServerList(false)} - /> - - {/* 删除按钮 */} - -
- ); - })} -
- )} - - - - - - {selectedServer ? t('mcp.editServer') : t('mcp.createServer')} - - -
- { - setModalOpen(false); - getServerList(false); - }} - 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-form/MCPForm.tsx b/web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx deleted file mode 100644 index 2e990235..00000000 --- a/web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx +++ /dev/null @@ -1,408 +0,0 @@ -'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(); - } - }, [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 6dc59ee7..bca70c3c 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -4,9 +4,7 @@ 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 MCPComponent, { - MCPComponentRef, -} from '@/app/home/plugins/mcp/MCPComponent'; + import MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent'; import styles from './plugins.module.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -200,9 +198,7 @@ export default function PluginConfigPage() { }, 1000); } - const pluginInstalledRef = useRef(null); - const mcpComponentRef = useRef(null); const [mcpTesting, setMcpTesting] = useState(false); const [editingServerName, setEditingServerName] = useState( null, @@ -656,14 +652,20 @@ export default function PluginConfigPage() { t('mcp.toolsFound'), ); } else { - toast.error(t('mcp.testError') + ': ' + t('mcp.noToolsFound')); + toast.error( + t('mcp.testError') + ': ' + t('mcp.noToolsFound'), + ); } } catch (parseError) { console.error('Failed to parse test result:', parseError); - toast.error(t('mcp.testError') + ': ' + t('mcp.parseResultFailed')); + toast.error( + t('mcp.testError') + ': ' + t('mcp.parseResultFailed'), + ); } } else { - toast.error(t('mcp.testError') + ': ' + t('mcp.noResultReturned')); + toast.error( + t('mcp.testError') + ': ' + t('mcp.noResultReturned'), + ); } } }) @@ -671,7 +673,11 @@ export default function PluginConfigPage() { console.error('获取测试任务状态失败:', err); clearInterval(interval); setMcpTesting(false); - toast.error(t('mcp.testError') + ': ' + (err.message || t('mcp.getTaskFailed'))); + toast.error( + t('mcp.testError') + + ': ' + + (err.message || t('mcp.getTaskFailed')), + ); }); }, 1000); } else { @@ -682,7 +688,9 @@ export default function PluginConfigPage() { .catch((err) => { console.error('启动测试失败:', err); setMcpTesting(false); - toast.error(t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError'))); + toast.error( + t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError')), + ); }); } @@ -853,15 +861,6 @@ export default function PluginConfigPage() {
- {/* */}
diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index bca70c3c..d29c1a86 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -396,25 +396,20 @@ export default function PluginConfigPage() { const server = resp.server ?? resp; console.log('Loaded server for edit:', server); - const extraArgs = server.extra_args as - | Record - | undefined; + const extraArgs = server.extra_args; form.setValue('name', server.name); - form.setValue('url', (extraArgs?.url as string) || ''); - form.setValue('timeout', (extraArgs?.timeout as number) || 30); - form.setValue( - 'ssereadtimeout', - (extraArgs?.ssereadtimeout as number) || 300, - ); + form.setValue('url', extraArgs.url); + form.setValue('timeout', extraArgs.timeout); + form.setValue('ssereadtimeout', extraArgs.ssereadtimeout); - if (extraArgs?.headers) { - const headers = Object.entries( - extraArgs.headers as Record, - ).map(([key, value]) => ({ - key, - type: 'string' as const, - value: String(value), - })); + if (extraArgs.headers) { + const headers = Object.entries(extraArgs.headers).map( + ([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + }), + ); setExtraArgs(headers); form.setValue('extra_args', headers); } @@ -569,7 +564,17 @@ export default function PluginConfigPage() { await httpClient.updateMCPServer(editingServerName, serverConfig); toast.success(t('mcp.updateSuccess')); } else { - await httpClient.createMCPServer(serverConfig); + await httpClient.createMCPServer({ + extra_args: { + url: value.url, + headers: extraArgsObj as Record, + timeout: value.timeout, + ssereadtimeout: value.ssereadtimeout, + }, + name: value.name, + mode: 'sse' as const, + enable: true, + }); toast.success(t('mcp.createSuccess')); } @@ -927,9 +932,6 @@ export default function PluginConfigPage() { }} /> - {/* - - */} ; - name: string; - mode: 'stdio' | 'sse'; - enable: boolean; - config: MCPServerConfig; - status: 'connected' | 'disconnected' | 'error' | 'disabled'; - tools: MCPTool[]; - error?: string; +export interface MCPServerExtraArgsSSE { + url: string; + headers: Record; + timeout: number; + ssereadtimeout: number; } -export interface MCPServerConfig { +export interface MCPServerRuntimeInfo { + connected: boolean; + error_message: string; + tool_count: number; +} + +export interface MCPServer { + uuid?: string; name: string; mode: 'stdio' | 'sse'; enable: boolean; - // stdio mode - command?: string; - args?: string[]; - env?: Record; - // sse mode - url?: string; - headers?: Record; - timeout?: number; + extra_args: MCPServerExtraArgsSSE; + runtime_info?: MCPServerRuntimeInfo; + created_at?: string; + updated_at?: string; } export interface MCPTool { diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 9484ee32..3d0c3ee3 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -35,7 +35,7 @@ import { ApiRespPluginSystemStatus, ApiRespMCPServers, ApiRespMCPServer, - MCPServerConfig, + MCPServer, } from '@/app/infra/entities/api'; import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest'; import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; @@ -500,15 +500,13 @@ export class BackendClient extends BaseHttpClient { return this.get(`/api/v1/mcp/servers/${serverName}`); } - public createMCPServer( - server: MCPServerConfig, - ): Promise { - return this.post('/api/v1/mcp/servers', { source: server }); + public createMCPServer(server: MCPServer): Promise { + return this.post('/api/v1/mcp/servers', server); } public updateMCPServer( serverName: string, - server: Partial, + server: Partial, ): Promise { return this.put(`/api/v1/mcp/servers/${serverName}`, server); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 44c0ce95..873461b5 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -284,7 +284,7 @@ const enUS = { }, mcp: { title: 'MCP Management', - createServer: 'Create MCP Server', + createServer: 'Add MCP Server', editServer: 'Edit MCP Server', deleteServer: 'Delete MCP Server', confirmDeleteServer: 'Are you sure you want to delete this MCP server?', @@ -347,7 +347,8 @@ const enUS = { nameRequired: 'Name cannot be empty', sseTimeout: 'SSE Timeout', sseTimeoutDescription: 'Timeout for establishing SSE connection', - extraParametersDescription: 'Additional parameters for configuring specific MCP server behavior', + extraParametersDescription: + 'Additional parameters for configuring specific MCP server behavior', timeoutMustBeNumber: 'Timeout must be a number', timeoutNonNegative: 'Timeout cannot be negative', sseTimeoutMustBeNumber: 'SSE timeout must be a number', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 1907689e..fd8cd418 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -286,7 +286,7 @@ const jaJP = { }, mcp: { title: 'MCP管理', - createServer: 'MCPサーバーを作成', + createServer: 'MCPサーバーを追加', editServer: 'MCPサーバーを編集', deleteServer: 'MCPサーバーを削除', confirmDeleteServer: 'このMCPサーバーを削除してもよろしいですか?', @@ -349,7 +349,8 @@ const jaJP = { nameRequired: '名前は必須です', sseTimeout: 'SSEタイムアウト', sseTimeoutDescription: 'SSE接続を確立するためのタイムアウト', - extraParametersDescription: 'MCPサーバーの特定の動作を設定するための追加パラメータ', + extraParametersDescription: + 'MCPサーバーの特定の動作を設定するための追加パラメータ', timeoutMustBeNumber: 'タイムアウトは数値である必要があります', timeoutNonNegative: 'タイムアウトは負の数にできません', sseTimeoutMustBeNumber: 'SSEタイムアウトは数値である必要があります', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 8cbac761..9dcfecd3 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -272,20 +272,20 @@ const zhHans = { }, mcp: { title: 'MCP管理', - createServer: '创建MCP服务器', - editServer: '编辑MCP服务器', - deleteServer: '删除MCP服务器', - confirmDeleteServer: '你确定要删除此MCP服务器吗?', - confirmDeleteTitle: '删除MCP服务器', - getServerListError: '获取MCP服务器列表失败:', + createServer: '添加 MCP 服务器', + editServer: '修改 MCP 服务器', + deleteServer: '删除 MCP 服务器', + confirmDeleteServer: '你确定要删除此 MCP 服务器吗?', + confirmDeleteTitle: '删除 MCP 服务器', + getServerListError: '获取 MCP 服务器列表失败:', serverName: '服务器名称', serverMode: '连接模式', stdio: 'Stdio模式', sse: 'SSE模式', - noServerInstalled: '暂未配置任何MCP服务器', + noServerInstalled: '暂未配置任何 MCP 服务器', serverNameRequired: '服务器名称不能为空', commandRequired: '命令不能为空', - urlRequired: 'URL不能为空', + urlRequired: 'URL 不能为空', timeoutMustBePositive: '超时时间必须是正数', command: '命令', args: '参数', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 6c654b91..08749bc6 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -270,7 +270,7 @@ const zhHant = { }, mcp: { title: 'MCP管理', - createServer: '建立MCP伺服器', + createServer: '新增MCP伺服器', editServer: '編輯MCP伺服器', deleteServer: '刪除MCP伺服器', confirmDeleteServer: '您確定要刪除此MCP伺服器嗎?', From 8df90558abcf6a6d43e1d7f3a4da79a8e8491900 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Nov 2025 16:29:16 +0800 Subject: [PATCH 034/144] perf: tidy dir --- .../mcp-form/MCPDeleteConfirmDialog.tsx | 68 ++ .../mcp-server/mcp-form/MCPFormDialog.tsx | 713 ++++++++++++++ web/src/app/home/plugins/page.tsx | 930 +----------------- 3 files changed, 829 insertions(+), 882 deletions(-) create mode 100644 web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx create mode 100644 web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx new file mode 100644 index 00000000..cf40a20c --- /dev/null +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +interface MCPDeleteConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serverName: string | null; + onSuccess?: () => void; +} + +export default function MCPDeleteConfirmDialog({ + open, + onOpenChange, + serverName, + onSuccess, +}: MCPDeleteConfirmDialogProps) { + const { t } = useTranslation(); + + async function handleDelete() { + if (!serverName) return; + + try { + await httpClient.deleteMCPServer(serverName); + toast.success(t('mcp.deleteSuccess')); + + onOpenChange(false); + + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('Failed to delete server:', error); + toast.error(t('mcp.deleteFailed')); + } + } + + return ( + + + + {t('mcp.confirmDeleteTitle')} + + {t('mcp.confirmDeleteServer')} + + + + + + + ); +} diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx new file mode 100644 index 00000000..599ada5f --- /dev/null +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -0,0 +1,713 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Resolver, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +const getFormSchema = (t: (key: string) => string) => + z.object({ + name: z + .string({ required_error: t('mcp.nameRequired') }) + .min(1, { message: t('mcp.nameRequired') }), + timeout: z + .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) + .default(30), + ssereadtimeout: z + .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) + .default(300), + url: z + .string({ required_error: t('mcp.urlRequired') }) + .min(1, { message: t('mcp.urlRequired') }), + extra_args: z + .array( + z.object({ + key: z.string(), + type: z.enum(['string', 'number', 'boolean']), + value: z.string(), + }), + ) + .optional(), + }); + +type FormValues = z.infer> & { + timeout: number; + ssereadtimeout: number; +}; + +interface MCPFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serverName?: string | null; + isEditMode?: boolean; + onSuccess?: () => void; + onDelete?: () => void; + onUpdateToolsCache?: (serverName: string, toolsCount: number) => void; +} + +export default function MCPFormDialog({ + open, + onOpenChange, + serverName, + isEditMode = false, + onSuccess, + onDelete, + onUpdateToolsCache, +}: MCPFormDialogProps) { + const { t } = useTranslation(); + const formSchema = getFormSchema(t); + + const form = useForm({ + resolver: zodResolver(formSchema) as unknown as Resolver, + defaultValues: { + name: '', + url: '', + timeout: 30, + ssereadtimeout: 300, + extra_args: [], + }, + }); + + const [extraArgs, setExtraArgs] = useState< + { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] + >([]); + const [mcpTesting, setMcpTesting] = useState(false); + const [mcpTestStatus, setMcpTestStatus] = useState< + 'idle' | 'testing' | 'success' | 'failed' + >('idle'); + const [mcpToolNames, setMcpToolNames] = useState([]); + const [mcpTestError, setMcpTestError] = useState(''); + + // Load server data when editing + useEffect(() => { + if (open && isEditMode && serverName) { + loadServerForEdit(serverName); + } else if (open && !isEditMode) { + // Reset form when creating new server + form.reset(); + setExtraArgs([]); + setMcpTestStatus('idle'); + setMcpToolNames([]); + setMcpTestError(''); + } + }, [open, isEditMode, serverName]); + + async function loadServerForEdit(serverName: string) { + try { + const resp = await httpClient.getMCPServer(serverName); + const server = resp.server ?? resp; + + const extraArgs = server.extra_args; + form.setValue('name', server.name); + form.setValue('url', extraArgs.url); + form.setValue('timeout', extraArgs.timeout); + form.setValue('ssereadtimeout', extraArgs.ssereadtimeout); + + if (extraArgs.headers) { + const headers = Object.entries(extraArgs.headers).map( + ([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + }), + ); + setExtraArgs(headers); + form.setValue('extra_args', headers); + } + + setMcpTestStatus('testing'); + setMcpToolNames([]); + setMcpTestError(''); + + try { + const res = await httpClient.testMCPServer(server.name); + if (res.task_id) { + const taskId = res.task_id; + + const interval = setInterval(() => { + httpClient + .getAsyncTask(taskId) + .then((taskResp) => { + if (taskResp.runtime && taskResp.runtime.done) { + clearInterval(interval); + + if (taskResp.runtime.exception) { + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError(taskResp.runtime.exception || '未知错误'); + } else if (taskResp.runtime.result) { + try { + let result: { + status?: string; + tools_count?: number; + tools_names_lists?: string[]; + error?: string; + }; + + const rawResult: unknown = taskResp.runtime.result; + if (typeof rawResult === 'string') { + result = JSON.parse(rawResult.replace(/'/g, '"')); + } else { + result = rawResult as typeof result; + } + + if ( + result.tools_names_lists && + result.tools_names_lists.length > 0 + ) { + setMcpTestStatus('success'); + setMcpToolNames(result.tools_names_lists); + // Update tools cache + if (onUpdateToolsCache && serverName) { + onUpdateToolsCache( + serverName, + result.tools_names_lists.length, + ); + } + } else { + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('未找到任何工具'); + } + } catch (parseError) { + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('解析测试结果失败'); + } + } else { + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('测试未返回结果'); + } + } + }) + .catch((err) => { + clearInterval(interval); + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError(err.message || '获取任务状态失败'); + }); + }, 1000); + } else { + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError('未获取到任务ID'); + } + } catch (error) { + setMcpTestStatus('failed'); + setMcpToolNames([]); + setMcpTestError((error as Error).message || '测试连接时发生错误'); + } + } catch (error) { + console.error('Failed to load server:', error); + toast.error(t('mcp.loadFailed')); + } + } + + async function handleFormSubmit(value: z.infer) { + const extraArgsObj: Record = {}; + value.extra_args?.forEach( + (arg: { key: string; type: string; value: string }) => { + if (arg.type === 'number') { + extraArgsObj[arg.key] = Number(arg.value); + } else if (arg.type === 'boolean') { + extraArgsObj[arg.key] = arg.value === 'true'; + } else { + extraArgsObj[arg.key] = arg.value; + } + }, + ); + + try { + const serverConfig = { + name: value.name, + mode: 'sse' as const, + enable: true, + url: value.url, + headers: extraArgsObj as Record, + timeout: value.timeout, + ssereadtimeout: value.ssereadtimeout, + }; + + if (isEditMode && serverName) { + await httpClient.updateMCPServer(serverName, serverConfig); + toast.success(t('mcp.updateSuccess')); + } else { + await httpClient.createMCPServer({ + extra_args: { + url: value.url, + headers: extraArgsObj as Record, + timeout: value.timeout, + ssereadtimeout: value.ssereadtimeout, + }, + name: value.name, + mode: 'sse' as const, + enable: true, + }); + toast.success(t('mcp.createSuccess')); + } + + onOpenChange(false); + form.reset(); + setExtraArgs([]); + + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('Failed to save MCP server:', error); + toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')); + } + } + + function testMcp() { + setMcpTesting(true); + const extraArgsObj: Record = {}; + form + .getValues('extra_args') + ?.forEach((arg: { key: string; type: string; value: string }) => { + if (arg.type === 'number') { + extraArgsObj[arg.key] = Number(arg.value); + } else if (arg.type === 'boolean') { + extraArgsObj[arg.key] = arg.value === 'true'; + } else { + extraArgsObj[arg.key] = arg.value; + } + }); + httpClient + .testMCPServer(form.getValues('name')) + .then((res) => { + if (res.task_id) { + const taskId = res.task_id; + + const interval = setInterval(() => { + httpClient + .getAsyncTask(taskId) + .then((taskResp) => { + if (taskResp.runtime && taskResp.runtime.done) { + clearInterval(interval); + setMcpTesting(false); + + if (taskResp.runtime.exception) { + toast.error( + t('mcp.testError') + + ': ' + + (taskResp.runtime.exception || t('mcp.unknownError')), + ); + } else if (taskResp.runtime.result) { + try { + let result: { + status?: string; + tools_count?: number; + tools_names_lists?: string[]; + error?: string; + }; + + const rawResult: unknown = taskResp.runtime.result; + if (typeof rawResult === 'string') { + result = JSON.parse(rawResult.replace(/'/g, '"')); + } else { + result = rawResult as typeof result; + } + + if ( + result.tools_names_lists && + result.tools_names_lists.length > 0 + ) { + toast.success( + t('mcp.testSuccess') + + ' - ' + + result.tools_names_lists.length + + ' ' + + t('mcp.toolsFound'), + ); + } else { + toast.error( + t('mcp.testError') + ': ' + t('mcp.noToolsFound'), + ); + } + } catch (parseError) { + console.error('Failed to parse test result:', parseError); + toast.error( + t('mcp.testError') + ': ' + t('mcp.parseResultFailed'), + ); + } + } else { + toast.error( + t('mcp.testError') + ': ' + t('mcp.noResultReturned'), + ); + } + } + }) + .catch((err) => { + console.error('获取测试任务状态失败:', err); + clearInterval(interval); + setMcpTesting(false); + toast.error( + t('mcp.testError') + + ': ' + + (err.message || t('mcp.getTaskFailed')), + ); + }); + }, 1000); + } else { + setMcpTesting(false); + toast.error(t('mcp.testError') + ': ' + t('mcp.noTaskId')); + } + }) + .catch((err) => { + console.error('启动测试失败:', err); + setMcpTesting(false); + toast.error( + t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError')), + ); + }); + } + + const addExtraArg = () => { + setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]); + }; + + const removeExtraArg = (index: number) => { + const newArgs = extraArgs.filter((_, i) => i !== index); + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); + }; + + const updateExtraArg = ( + index: number, + field: 'key' | 'type' | 'value', + value: string, + ) => { + const newArgs = [...extraArgs]; + newArgs[index] = { + ...newArgs[index], + [field]: value, + }; + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); + }; + + return ( + { + onOpenChange(open); + if (!open) { + form.reset(); + setExtraArgs([]); + } + }} + > + + + + {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} + + + + {isEditMode && ( +
+ {mcpTestStatus === 'testing' && ( +
+ + + + + {t('mcp.testing')} +
+ )} + + {mcpTestStatus === 'success' && ( +
+
+ + + + + {t('mcp.connectionSuccess')} - {mcpToolNames.length}{' '} + {t('mcp.toolsFound')} + +
+
+ {mcpToolNames.map((toolName, index) => ( + + {toolName} + + ))} +
+
+ )} + + {mcpTestStatus === 'failed' && ( +
+
+ + + + + {t('mcp.connectionFailed')} + +
+ {mcpTestError && ( +
+ {mcpTestError} +
+ )} +
+ )} +
+ )} + +
+ +
+ ( + + {t('mcp.name')} + + + + + + )} + /> + + ( + + {t('mcp.url')} + + + + + + )} + /> + + ( + + {t('mcp.timeout')} + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + ( + + {t('mcp.sseTimeout')} + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + + {t('models.extraParameters')} +
+ {extraArgs.map((arg, index) => ( +
+ + updateExtraArg(index, 'key', e.target.value) + } + /> + + + updateExtraArg(index, 'value', e.target.value) + } + /> + +
+ ))} + +
+ + {t('mcp.extraParametersDescription')} + + +
+ + + {isEditMode && onDelete && ( + + )} + + + + + + + +
+
+ +
+
+ ); +} diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index d29c1a86..5b3f9253 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -3,9 +3,10 @@ import PluginInstalledComponent, { PluginInstalledComponentRef, } 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 MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent'; +import MCPFormDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPFormDialog'; +import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog'; import styles from './plugins.module.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; @@ -38,27 +39,6 @@ import { useTranslation } from 'react-i18next'; import { PluginV4 } from '@/app/infra/entities/plugin'; import { systemInfo } from '@/app/infra/http/HttpClient'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from '@/components/ui/select'; - -import { Resolver, useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { DialogDescription } from '@radix-ui/react-dialog'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; enum PluginInstallStatus { WAIT_INPUT = 'wait_input', @@ -83,76 +63,18 @@ export default function PluginConfigPage() { useState(null); const [statusLoading, setStatusLoading] = useState(true); const fileInputRef = useRef(null); - const addExtraArg = () => { - setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]); - }; - const removeExtraArg = (index: number) => { - const newArgs = extraArgs.filter((_, i) => i !== index); - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; - const getFormSchema = (t: (key: string) => string) => - z.object({ - name: z - .string({ required_error: t('mcp.nameRequired') }) - .min(1, { message: t('mcp.nameRequired') }), - timeout: z - .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) - .positive({ message: t('mcp.timeoutMustBePositive') }) - .default(30), - ssereadtimeout: z - .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) - .positive({ message: t('mcp.timeoutMustBePositive') }) - .default(300), - url: z - .string({ required_error: t('mcp.urlRequired') }) - .min(1, { message: t('mcp.urlRequired') }), - extra_args: z - .array( - z.object({ - key: z.string(), - type: z.enum(['string', 'number', 'boolean']), - value: z.string(), - }), - ) - .optional(), - }); - - const formSchema = getFormSchema(t); - - type FormValues = z.infer & { - timeout: number; - ssereadtimeout: number; - }; - - const form = useForm({ - resolver: zodResolver(formSchema) as unknown as Resolver, - defaultValues: { - name: '', - url: '', - timeout: 30, - ssereadtimeout: 300, - extra_args: [], - }, - }); - - const [extraArgs, setExtraArgs] = useState< - { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] - >([]); - const updateExtraArg = ( - index: number, - field: 'key' | 'type' | 'value', - value: string, - ) => { - const newArgs = [...extraArgs]; - newArgs[index] = { - ...newArgs[index], - [field]: value, - }; - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); + const [editingServerName, setEditingServerName] = useState( + null, + ); + const [isEditMode, setIsEditMode] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + // 缓存每个服务器测试后的工具数量 + const [serverToolsCache, setServerToolsCache] = useState< + Record + >({}); + useEffect(() => { const fetchPluginSystemStatus = async () => { try { @@ -169,22 +91,18 @@ export default function PluginConfigPage() { fetchPluginSystemStatus(); }, [t]); - //这个是旧版本的测试github url,下面重写了一个新版本的watchTask函数,用来检测Mcp + function watchTask(taskId: number) { 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) { setInstallError(resp.runtime.exception); setPluginInstallStatus(PluginInstallStatus.ERROR); } else { - // success if (!alreadySuccess) { toast.success(t('plugins.installSuccess')); alreadySuccess = true; @@ -199,124 +117,6 @@ export default function PluginConfigPage() { } const pluginInstalledRef = useRef(null); - const [mcpTesting, setMcpTesting] = useState(false); - const [editingServerName, setEditingServerName] = useState( - null, - ); - const [isEditMode, setIsEditMode] = useState(false); - const [refreshKey, setRefreshKey] = useState(0); - - // MCP测试结果状态 - const [mcpTestStatus, setMcpTestStatus] = useState< - 'idle' | 'testing' | 'success' | 'failed' - >('idle'); - const [mcpToolNames, setMcpToolNames] = useState([]); - const [mcpTestError, setMcpTestError] = useState(''); - - // 缓存每个服务器测试后的工具数量 - const [serverToolsCache, setServerToolsCache] = useState< - Record - >({}); - - // 强制清理 body 样式以修复 Dialog 关闭后点击失效的问题 - useEffect(() => { - console.log('[Dialog Debug] States:', { - mcpSSEModalOpen, - modalOpen, - showDeleteConfirmModal, - }); - - if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { - const cleanup = () => { - document.body.style.removeProperty('pointer-events'); - document.body.style.removeProperty('overflow'); - - if (document.body.style.pointerEvents === 'none') { - document.body.style.pointerEvents = ''; - } - if (document.body.style.overflow === 'hidden') { - document.body.style.overflow = ''; - } - - console.log( - '[Dialog Debug] After cleanup - body.style.pointerEvents:', - document.body.style.pointerEvents, - ); - console.log( - '[Dialog Debug] After cleanup - body.style.overflow:', - document.body.style.overflow, - ); - - // 检查计算后的样式 - const computedStyle = window.getComputedStyle(document.body); - console.log( - '[Dialog Debug] Computed pointerEvents:', - computedStyle.pointerEvents, - ); - }; - - // 多次清理以确保覆盖 Radix 的设置 - cleanup(); - const timer1 = setTimeout(cleanup, 0); - const timer2 = setTimeout(cleanup, 50); - const timer3 = setTimeout(cleanup, 100); - const timer4 = setTimeout(cleanup, 200); - const timer5 = setTimeout(cleanup, 300); - - return () => { - clearTimeout(timer1); - clearTimeout(timer2); - clearTimeout(timer3); - clearTimeout(timer4); - clearTimeout(timer5); - }; - } - }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); - - useEffect(() => { - const interval = setInterval(() => { - if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { - if (document.body.style.pointerEvents === 'none') { - console.log( - '[Global Cleanup] Found stale pointer-events, cleaning...', - ); - document.body.style.removeProperty('pointer-events'); - document.body.style.pointerEvents = ''; - } - } - }, 500); - - return () => clearInterval(interval); - }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); - - // MutationObserver:监视 body 的 style 变化 - useEffect(() => { - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if ( - mutation.type === 'attributes' && - mutation.attributeName === 'style' - ) { - if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { - if (document.body.style.pointerEvents === 'none') { - console.log( - '[MutationObserver] Detected pointer-events being set to none, reverting...', - ); - document.body.style.removeProperty('pointer-events'); - document.body.style.pointerEvents = ''; - } - } - } - }); - }); - - observer.observe(document.body, { - attributes: true, - attributeFilter: ['style'], - }); - - return () => observer.disconnect(); - }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); function handleModalConfirm() { installPlugin(installSource, installInfo as Record); @@ -365,340 +165,6 @@ export default function PluginConfigPage() { [watchTask], ); - async function deleteMCPServer() { - if (!editingServerName) return; - - try { - await httpClient.deleteMCPServer(editingServerName); - toast.success(t('mcp.deleteSuccess')); - - // 关闭所有对话框 - setShowDeleteConfirmModal(false); - setMcpSSEModalOpen(false); - - // 重置状态 - form.reset(); - setExtraArgs([]); - setEditingServerName(null); - setIsEditMode(false); - - // 刷新服务器列表 - setRefreshKey((prev) => prev + 1); - } catch (error) { - console.error('Failed to delete server:', error); - toast.error(t('mcp.deleteFailed')); - } - } - - async function loadServerForEdit(serverName: string) { - try { - const resp = await httpClient.getMCPServer(serverName); - const server = resp.server ?? resp; - console.log('Loaded server for edit:', server); - - const extraArgs = server.extra_args; - form.setValue('name', server.name); - form.setValue('url', extraArgs.url); - form.setValue('timeout', extraArgs.timeout); - form.setValue('ssereadtimeout', extraArgs.ssereadtimeout); - - if (extraArgs.headers) { - const headers = Object.entries(extraArgs.headers).map( - ([key, value]) => ({ - key, - type: 'string' as const, - value: String(value), - }), - ); - setExtraArgs(headers); - form.setValue('extra_args', headers); - } - - setMcpTestStatus('testing'); - setMcpToolNames([]); - setMcpTestError(''); - - setEditingServerName(serverName); - setIsEditMode(true); - setMcpSSEModalOpen(true); - - try { - const res = await httpClient.testMCPServer(server.name); - if (res.task_id) { - const taskId = res.task_id; - - const interval = setInterval(() => { - httpClient - .getAsyncTask(taskId) - .then((taskResp) => { - console.log('Task response:', taskResp); - - if (taskResp.runtime && taskResp.runtime.done) { - clearInterval(interval); - - console.log('Task completed. Runtime:', taskResp.runtime); - console.log('Result:', taskResp.runtime.result); - console.log('Exception:', taskResp.runtime.exception); - - if (taskResp.runtime.exception) { - console.log('Test failed with exception'); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError(taskResp.runtime.exception || '未知错误'); - } else if (taskResp.runtime.result) { - try { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; - - const rawResult: unknown = taskResp.runtime.result; - if (typeof rawResult === 'string') { - console.log('Result is string, parsing...'); - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult as typeof result; - } - - console.log('Parsed result:', result); - console.log( - 'tools_names_lists:', - result.tools_names_lists, - ); - console.log( - 'tools_names_lists length:', - result.tools_names_lists?.length, - ); - - if ( - result.tools_names_lists && - result.tools_names_lists.length > 0 - ) { - console.log( - 'Test success with', - result.tools_names_lists.length, - 'tools', - ); - setMcpTestStatus('success'); - setMcpToolNames(result.tools_names_lists); - // 保存工具数量到缓存 - setServerToolsCache((prev) => ({ - ...prev, - [server.name]: result.tools_names_lists!.length, - })); - } else { - console.log('Test failed: no tools found'); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('未找到任何工具'); - } - } catch (parseError) { - console.error('Failed to parse result:', parseError); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('解析测试结果失败'); - } - } else { - // 没结果 - console.log('Test failed: no result'); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('测试未返回结果'); - } - } - }) - .catch((err) => { - console.error('获取任务状态失败:', err); - clearInterval(interval); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError(err.message || '获取任务状态失败'); - }); - }, 1000); - } else { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('未获取到任务ID'); - } - } catch (error) { - console.error('Failed to test server:', error); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError((error as Error).message || '测试连接时发生错误'); - } - } catch (error) { - console.error('Failed to load server:', error); - toast.error(t('mcp.loadFailed')); - } - } - - async function handleFormSubmit(value: z.infer) { - const extraArgsObj: Record = {}; - value.extra_args?.forEach( - (arg: { key: string; type: string; value: string }) => { - if (arg.type === 'number') { - extraArgsObj[arg.key] = Number(arg.value); - } else if (arg.type === 'boolean') { - extraArgsObj[arg.key] = arg.value === 'true'; - } else { - extraArgsObj[arg.key] = arg.value; - } - }, - ); - - try { - // 构造符合 MCPServerConfig 类型的数据 - const serverConfig = { - name: value.name, - mode: 'sse' as const, - enable: true, - url: value.url, - headers: extraArgsObj as Record, - timeout: value.timeout, - ssereadtimeout: value.ssereadtimeout, - }; - - if (isEditMode && editingServerName) { - await httpClient.updateMCPServer(editingServerName, serverConfig); - toast.success(t('mcp.updateSuccess')); - } else { - await httpClient.createMCPServer({ - extra_args: { - url: value.url, - headers: extraArgsObj as Record, - timeout: value.timeout, - ssereadtimeout: value.ssereadtimeout, - }, - name: value.name, - mode: 'sse' as const, - enable: true, - }); - toast.success(t('mcp.createSuccess')); - } - - setMcpSSEModalOpen(false); - - form.reset(); - setExtraArgs([]); - setEditingServerName(null); - setIsEditMode(false); - - setRefreshKey((prev) => prev + 1); - } catch (error) { - console.error('Failed to save MCP server:', error); - toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')); - } - } - - function testMcp() { - setMcpTesting(true); - const extraArgsObj: Record = {}; - form - .getValues('extra_args') - ?.forEach((arg: { key: string; type: string; value: string }) => { - if (arg.type === 'number') { - extraArgsObj[arg.key] = Number(arg.value); - } else if (arg.type === 'boolean') { - extraArgsObj[arg.key] = arg.value === 'true'; - } else { - extraArgsObj[arg.key] = arg.value; - } - }); - httpClient - .testMCPServer(form.getValues('name')) - .then((res) => { - console.log(res); - if (res.task_id) { - const taskId = res.task_id; - - const interval = setInterval(() => { - httpClient - .getAsyncTask(taskId) - .then((taskResp) => { - console.log('Test task response:', taskResp); - - if (taskResp.runtime && taskResp.runtime.done) { - clearInterval(interval); - setMcpTesting(false); - - if (taskResp.runtime.exception) { - toast.error( - t('mcp.testError') + - ': ' + - (taskResp.runtime.exception || t('mcp.unknownError')), - ); - } else if (taskResp.runtime.result) { - try { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; - - const rawResult: unknown = taskResp.runtime.result; - if (typeof rawResult === 'string') { - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult as typeof result; - } - - if ( - result.tools_names_lists && - result.tools_names_lists.length > 0 - ) { - toast.success( - t('mcp.testSuccess') + - ' - ' + - result.tools_names_lists.length + - ' ' + - t('mcp.toolsFound'), - ); - } else { - toast.error( - t('mcp.testError') + ': ' + t('mcp.noToolsFound'), - ); - } - } catch (parseError) { - console.error('Failed to parse test result:', parseError); - toast.error( - t('mcp.testError') + ': ' + t('mcp.parseResultFailed'), - ); - } - } else { - toast.error( - t('mcp.testError') + ': ' + t('mcp.noResultReturned'), - ); - } - } - }) - .catch((err) => { - console.error('获取测试任务状态失败:', err); - clearInterval(interval); - setMcpTesting(false); - toast.error( - t('mcp.testError') + - ': ' + - (err.message || t('mcp.getTaskFailed')), - ); - }); - }, 1000); - } else { - setMcpTesting(false); - toast.error(t('mcp.testError') + ': ' + t('mcp.noTaskId')); - } - }) - .catch((err) => { - console.error('启动测试失败:', err); - setMcpTesting(false); - toast.error( - t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError')), - ); - }); - } - const validateFileType = (file: File): boolean => { const allowedExtensions = ['.lbpkg', '.zip']; const fileName = file.name.toLowerCase(); @@ -884,8 +350,6 @@ export default function PluginConfigPage() { setActiveTab('mcp-servers'); setIsEditMode(false); setEditingServerName(null); - form.reset(); - setExtraArgs([]); setMcpSSEModalOpen(true); }} > @@ -936,7 +400,9 @@ export default function PluginConfigPage() { { - loadServerForEdit(serverName); + setEditingServerName(serverName); + setIsEditMode(true); + setMcpSSEModalOpen(true); }} toolsCountCache={serverToolsCache} /> @@ -1004,7 +470,6 @@ export default function PluginConfigPage() { - {/* 拖拽提示覆盖层 */} {isDragOver && (
@@ -1018,337 +483,38 @@ export default function PluginConfigPage() {
)} -
- - - - {t('mcp.confirmDeleteTitle')} - - - {t('mcp.confirmDeleteServer')} - - - - - - + { + setEditingServerName(null); + setIsEditMode(false); + setRefreshKey((prev) => prev + 1); + }} + onDelete={() => { + setShowDeleteConfirmModal(true); + }} + onUpdateToolsCache={(serverName, toolsCount) => { + setServerToolsCache((prev) => ({ + ...prev, + [serverName]: toolsCount, + })); + }} + /> - { - setMcpSSEModalOpen(open); - if (!open) { - // 关闭对话框时重置编辑状态 - setIsEditMode(false); - setEditingServerName(null); - form.reset(); - setExtraArgs([]); - } - }} - > - - - - {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} - - - - {/* 测试结果显示区域 - 仅在编辑模式显示 */} - {isEditMode && ( -
- {mcpTestStatus === 'testing' && ( -
- - - - - {t('mcp.testing')} -
- )} - - {mcpTestStatus === 'success' && ( -
-
- - - - - {t('mcp.connectionSuccess')} - {mcpToolNames.length}{' '} - {t('mcp.toolsFound')} - -
-
- {mcpToolNames.map((toolName, index) => ( - - {toolName} - - ))} -
-
- )} - - {mcpTestStatus === 'failed' && ( -
-
- - - - - {t('mcp.connectionFailed')} - -
- {mcpTestError && ( -
- {mcpTestError} -
- )} -
- )} -
- )} - -
- -
- ( - - {t('mcp.name')} - - - - - - )} - /> - - ( - - {t('mcp.url')} - - - - - - )} - /> - - ( - - {t('mcp.timeout')} - - - field.onChange(Number(e.target.value)) - } - /> - - - - )} - /> - - ( - - {t('mcp.sseTimeout')} - - - field.onChange(Number(e.target.value)) - } - /> - - - - )} - /> - - - {t('models.extraParameters')} -
- {extraArgs.map((arg, index) => ( -
- - updateExtraArg(index, 'key', e.target.value) - } - /> - - - updateExtraArg(index, 'value', e.target.value) - } - /> - -
- ))} - -
- - {t('mcp.extraParametersDescription')} - - -
- - - {isEditMode && ( - - )} - - - - - - - -
-
- -
-
-
+ { + setMcpSSEModalOpen(false); + setEditingServerName(null); + setIsEditMode(false); + setRefreshKey((prev) => prev + 1); + }} + />
); } From 0666778fea9d9c0326e3081f287554151c2abc16 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Nov 2025 16:45:55 +0800 Subject: [PATCH 035/144] feat: perf mcp server api datastruct --- pkg/api/http/service/mcp.py | 28 +++++++++++-------------- web/src/app/infra/entities/api/index.ts | 3 ++- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/pkg/api/http/service/mcp.py b/pkg/api/http/service/mcp.py index d6e41ab4..5fcc3ec5 100644 --- a/pkg/api/http/service/mcp.py +++ b/pkg/api/http/service/mcp.py @@ -101,6 +101,12 @@ class MCPService: def __init__(self, ap: app.Application) -> None: self.ap = ap + async def get_runtime_info(self, server_name: str) -> dict | None: + session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if session: + return session.get_runtime_info_dict() + return None + async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) @@ -110,12 +116,7 @@ class MCPService: ] if contain_runtime_info: for server in serialized_servers: - session = self.ap.tool_mgr.mcp_tool_loader.get_session(server['name']) - - runtime_info = None - - if session: - runtime_info = session.get_runtime_info_dict() + runtime_info = await self.get_runtime_info(server['name']) server['runtime_info'] = runtime_info if runtime_info else None @@ -137,15 +138,6 @@ class MCPService: return server_data['uuid'] - async def get_mcp_server(self, server_uuid: str) -> dict | None: - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) - ) - server = result.first() - if server is None: - return None - return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) - async def get_mcp_server_by_name(self, server_name: str) -> dict | None: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name) @@ -153,7 +145,11 @@ class MCPService: server = result.first() if server is None: return None - return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + + runtime_info = await self.get_runtime_info(server.name) + server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + server_data['runtime_info'] = runtime_info if runtime_info else None + return server_data async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None: result = await self.ap.persistence_mgr.execute_async( diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 47f20203..f169ba4d 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -329,6 +329,7 @@ export interface MCPServerRuntimeInfo { connected: boolean; error_message: string; tool_count: number; + tools: MCPTool[]; } export interface MCPServer { @@ -345,5 +346,5 @@ export interface MCPServer { export interface MCPTool { name: string; description: string; - parameters: object; + parameters?: object; } From 3ee77363614a6a34210d5ea3add00bbc5ca6eaff Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Nov 2025 17:09:28 +0800 Subject: [PATCH 036/144] perf: ui --- .../app/home/plugins/mcp-server/MCPCardVO.ts | 22 +- .../plugins/mcp-server/MCPServerComponent.tsx | 21 +- .../mcp-server/mcp-card/MCPCardComponent.tsx | 110 ++-- .../mcp-server/mcp-form/MCPFormDialog.tsx | 530 +++++++----------- web/src/app/home/plugins/page.tsx | 12 - 5 files changed, 253 insertions(+), 442 deletions(-) diff --git a/web/src/app/home/plugins/mcp-server/MCPCardVO.ts b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts index 48c9aa62..3139f2fc 100644 --- a/web/src/app/home/plugins/mcp-server/MCPCardVO.ts +++ b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts @@ -1,4 +1,4 @@ -import { MCPServer, MCPServerConfig } from '@/app/infra/entities/api'; +import { MCPServer } from '@/app/infra/entities/api'; export class MCPCardVO { name: string; @@ -7,20 +7,24 @@ export class MCPCardVO { status: 'connected' | 'disconnected' | 'error' | 'disabled'; 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 as string) === 'enabled' ? 'connected' : data.status; - this.tools = Array.isArray(data.tools) - ? data.tools.length - : data.tools || 0; - this.error = data.error; - this.config = data.config; + // Determine status from runtime_info + if (!data.runtime_info) { + this.status = 'disconnected'; + this.tools = 0; + } else if (data.runtime_info.connected) { + this.status = 'connected'; + this.tools = data.runtime_info.tool_count || 0; + } else { + this.status = 'error'; + this.tools = 0; + this.error = data.runtime_info.error_message; + } } getStatusColor(): string { diff --git a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx index 0a0b85a6..84e90715 100644 --- a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx @@ -9,41 +9,24 @@ import { httpClient } from '@/app/infra/http/HttpClient'; export default function MCPComponent({ onEditServer, - toolsCountCache = {}, }: { askInstallServer?: (githubURL: string) => void; onEditServer?: (serverName: string) => void; - toolsCountCache?: Record; }) { const { t } = useTranslation(); const [installedServers, setInstalledServers] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { - initData(); + fetchInstalledServers(); }, []); - useEffect(() => { - fetchInstalledServers(); - }, [toolsCountCache]); - - function initData() { - fetchInstalledServers(); - } - function fetchInstalledServers() { setLoading(true); httpClient .getMCPServers() .then((resp) => { - const servers = resp.servers.map((server) => { - const vo = new MCPCardVO(server); - - if (toolsCountCache[server.name] !== undefined) { - vo.tools = toolsCountCache[server.name]; - } - return vo; - }); + const servers = resp.servers.map((server) => new MCPCardVO(server)); setInstalledServers(servers); setLoading(false); }) diff --git a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx index b83b83ed..3f933c2a 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx @@ -1,12 +1,11 @@ import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO'; import { useState, useEffect } 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'; -import { RefreshCcw, Wrench } from 'lucide-react'; +import { RefreshCcw, Wrench, Ban, AlertCircle } from 'lucide-react'; export default function MCPCardComponent({ cardVO, @@ -23,19 +22,12 @@ export default function MCPCardComponent({ const [testing, setTesting] = useState(false); const [toolsCount, setToolsCount] = useState(cardVO.tools); const [status, setStatus] = useState(cardVO.status); - const [error, setError] = useState(cardVO.error); useEffect(() => { - console.log(`[MCPCard ${cardVO.name}] Status updated:`, { - status: cardVO.status, - tools: cardVO.tools, - error: cardVO.error, - }); setStatus(cardVO.status); - setError(cardVO.error); setToolsCount(cardVO.tools); setEnabled(cardVO.enable); - }, [cardVO.name, cardVO.status, cardVO.error, cardVO.tools, cardVO.enable]); + }, [cardVO.status, cardVO.tools, cardVO.enable]); function handleEnable(checked: boolean) { setSwitchEnable(false); @@ -54,65 +46,28 @@ export default function MCPCardComponent({ } function handleTest(e: React.MouseEvent) { - e.stopPropagation(); // 阻止事件冒泡 + 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); + setTesting(false); + if (taskResp.runtime.exception) { toast.error(t('mcp.testFailed') + taskResp.runtime.exception); } else { - // 解析测试结果获取工具数量 - try { - const rawResult = taskResp.runtime.result as - | string - | { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - } - | undefined; - - if (rawResult) { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; - - if (typeof rawResult === 'string') { - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult; - } - - if (result.tools_count !== undefined) { - setToolsCount(result.tools_count); - toast.success( - t('mcp.testSuccess') + - ` - ${result.tools_count} ${t('mcp.toolsFound')}`, - ); - } else { - toast.success(t('mcp.testSuccess')); - } - } else { - toast.success(t('mcp.testSuccess')); - } - } catch (parseError) { - console.error('Failed to parse test result:', parseError); - toast.success(t('mcp.testSuccess')); - } - onRefresh(); + toast.success(t('mcp.testSuccess')); } - setTesting(false); + + // Refresh to get updated runtime_info + onRefresh(); } }); }, 1000); @@ -141,28 +96,37 @@ export default function MCPCardComponent({
-
-
-
- {cardVO.name} -
-
+
+ {cardVO.name}
- - {error && ( -
- {error} -
- )}
-
- -
- {t('mcp.toolCount', { count: toolsCount })} + {!enabled ? ( + // 未启用 - 橙色 +
+ +
+ {t('mcp.statusDisabled')} +
-
+ ) : status === 'connected' ? ( + // 连接成功 - 显示工具数量 +
+ +
+ {t('mcp.toolCount', { count: toolsCount })} +
+
+ ) : ( + // 连接失败 - 红色 +
+ +
+ {t('mcp.connectionFailed')} +
+
+ )}
diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx index 599ada5f..8fe62e83 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -13,6 +13,12 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; import { Form, FormControl, @@ -32,6 +38,98 @@ import { import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { httpClient } from '@/app/infra/http/HttpClient'; +import { + MCPServerRuntimeInfo, + MCPTool, + MCPServer, +} from '@/app/infra/entities/api'; + +// Status Display Component - 只在测试中或连接失败时使用 +function StatusDisplay({ + testing, + runtimeInfo, + t, +}: { + testing: boolean; + runtimeInfo: MCPServerRuntimeInfo; + t: (key: string) => string; +}) { + if (testing) { + return ( +
+ + + + + {t('mcp.testing')} +
+ ); + } + + // 连接失败 + return ( +
+
+ + + + {t('mcp.connectionFailed')} +
+ {runtimeInfo.error_message && ( +
+ {runtimeInfo.error_message} +
+ )} +
+ ); +} + +// Tools List Component +function ToolsList({ tools }: { tools: MCPTool[] }) { + return ( +
+ {tools.map((tool, index) => ( + + + {tool.name} + {tool.description && ( + + {tool.description} + + )} + + + ))} +
+ ); +} const getFormSchema = (t: (key: string) => string) => z.object({ @@ -72,7 +170,6 @@ interface MCPFormDialogProps { isEditMode?: boolean; onSuccess?: () => void; onDelete?: () => void; - onUpdateToolsCache?: (serverName: string, toolsCount: number) => void; } export default function MCPFormDialog({ @@ -82,7 +179,6 @@ export default function MCPFormDialog({ isEditMode = false, onSuccess, onDelete, - onUpdateToolsCache, }: MCPFormDialogProps) { const { t } = useTranslation(); const formSchema = getFormSchema(t); @@ -102,11 +198,9 @@ export default function MCPFormDialog({ { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] >([]); const [mcpTesting, setMcpTesting] = useState(false); - const [mcpTestStatus, setMcpTestStatus] = useState< - 'idle' | 'testing' | 'success' | 'failed' - >('idle'); - const [mcpToolNames, setMcpToolNames] = useState([]); - const [mcpTestError, setMcpTestError] = useState(''); + const [runtimeInfo, setRuntimeInfo] = useState( + null, + ); // Load server data when editing useEffect(() => { @@ -116,9 +210,7 @@ export default function MCPFormDialog({ // Reset form when creating new server form.reset(); setExtraArgs([]); - setMcpTestStatus('idle'); - setMcpToolNames([]); - setMcpTestError(''); + setRuntimeInfo(null); } }, [open, isEditMode, serverName]); @@ -145,88 +237,11 @@ export default function MCPFormDialog({ form.setValue('extra_args', headers); } - setMcpTestStatus('testing'); - setMcpToolNames([]); - setMcpTestError(''); - - try { - const res = await httpClient.testMCPServer(server.name); - if (res.task_id) { - const taskId = res.task_id; - - const interval = setInterval(() => { - httpClient - .getAsyncTask(taskId) - .then((taskResp) => { - if (taskResp.runtime && taskResp.runtime.done) { - clearInterval(interval); - - if (taskResp.runtime.exception) { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError(taskResp.runtime.exception || '未知错误'); - } else if (taskResp.runtime.result) { - try { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; - - const rawResult: unknown = taskResp.runtime.result; - if (typeof rawResult === 'string') { - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult as typeof result; - } - - if ( - result.tools_names_lists && - result.tools_names_lists.length > 0 - ) { - setMcpTestStatus('success'); - setMcpToolNames(result.tools_names_lists); - // Update tools cache - if (onUpdateToolsCache && serverName) { - onUpdateToolsCache( - serverName, - result.tools_names_lists.length, - ); - } - } else { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('未找到任何工具'); - } - } catch (parseError) { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('解析测试结果失败'); - } - } else { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('测试未返回结果'); - } - } - }) - .catch((err) => { - clearInterval(interval); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError(err.message || '获取任务状态失败'); - }); - }, 1000); - } else { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('未获取到任务ID'); - } - } catch (error) { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError((error as Error).message || '测试连接时发生错误'); + // Set runtime_info from server data + if (server.runtime_info) { + setRuntimeInfo(server.runtime_info); + } else { + setRuntimeInfo(null); } } catch (error) { console.error('Failed to load server:', error); @@ -235,167 +250,103 @@ export default function MCPFormDialog({ } async function handleFormSubmit(value: z.infer) { - const extraArgsObj: Record = {}; - value.extra_args?.forEach( - (arg: { key: string; type: string; value: string }) => { - if (arg.type === 'number') { - extraArgsObj[arg.key] = Number(arg.value); - } else if (arg.type === 'boolean') { - extraArgsObj[arg.key] = arg.value === 'true'; - } else { - extraArgsObj[arg.key] = arg.value; - } - }, - ); + // Convert extra_args to headers - all values must be strings according to MCPServerExtraArgsSSE + const headers: Record = {}; + value.extra_args?.forEach((arg) => { + // Convert all values to strings to match MCPServerExtraArgsSSE.headers type + headers[arg.key] = String(arg.value); + }); try { - const serverConfig = { + const serverConfig: Omit< + MCPServer, + 'uuid' | 'created_at' | 'updated_at' | 'runtime_info' + > = { name: value.name, mode: 'sse' as const, enable: true, - url: value.url, - headers: extraArgsObj as Record, - timeout: value.timeout, - ssereadtimeout: value.ssereadtimeout, + extra_args: { + url: value.url, + headers: headers, + timeout: value.timeout, + ssereadtimeout: value.ssereadtimeout, + }, }; if (isEditMode && serverName) { await httpClient.updateMCPServer(serverName, serverConfig); toast.success(t('mcp.updateSuccess')); } else { - await httpClient.createMCPServer({ - extra_args: { - url: value.url, - headers: extraArgsObj as Record, - timeout: value.timeout, - ssereadtimeout: value.ssereadtimeout, - }, - name: value.name, - mode: 'sse' as const, - enable: true, - }); + await httpClient.createMCPServer(serverConfig); toast.success(t('mcp.createSuccess')); } - onOpenChange(false); - form.reset(); - setExtraArgs([]); - - if (onSuccess) { - onSuccess(); - } + handleDialogClose(false); + onSuccess?.(); } catch (error) { console.error('Failed to save MCP server:', error); toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')); } } - function testMcp() { + async function testMcp() { + const serverName = form.getValues('name'); setMcpTesting(true); - const extraArgsObj: Record = {}; - form - .getValues('extra_args') - ?.forEach((arg: { key: string; type: string; value: string }) => { - if (arg.type === 'number') { - extraArgsObj[arg.key] = Number(arg.value); - } else if (arg.type === 'boolean') { - extraArgsObj[arg.key] = arg.value === 'true'; - } else { - extraArgsObj[arg.key] = arg.value; - } - }); - httpClient - .testMCPServer(form.getValues('name')) - .then((res) => { - if (res.task_id) { - const taskId = res.task_id; - const interval = setInterval(() => { - httpClient - .getAsyncTask(taskId) - .then((taskResp) => { - if (taskResp.runtime && taskResp.runtime.done) { - clearInterval(interval); - setMcpTesting(false); + try { + const { task_id } = await httpClient.testMCPServer(serverName); + if (!task_id) { + throw new Error(t('mcp.noTaskId')); + } - if (taskResp.runtime.exception) { - toast.error( - t('mcp.testError') + - ': ' + - (taskResp.runtime.exception || t('mcp.unknownError')), - ); - } else if (taskResp.runtime.result) { - try { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; + const interval = setInterval(async () => { + try { + const taskResp = await httpClient.getAsyncTask(task_id); - const rawResult: unknown = taskResp.runtime.result; - if (typeof rawResult === 'string') { - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult as typeof result; - } + if (taskResp.runtime?.done) { + clearInterval(interval); + setMcpTesting(false); - if ( - result.tools_names_lists && - result.tools_names_lists.length > 0 - ) { - toast.success( - t('mcp.testSuccess') + - ' - ' + - result.tools_names_lists.length + - ' ' + - t('mcp.toolsFound'), - ); - } else { - toast.error( - t('mcp.testError') + ': ' + t('mcp.noToolsFound'), - ); - } - } catch (parseError) { - console.error('Failed to parse test result:', parseError); - toast.error( - t('mcp.testError') + ': ' + t('mcp.parseResultFailed'), - ); - } - } else { - toast.error( - t('mcp.testError') + ': ' + t('mcp.noResultReturned'), - ); - } - } - }) - .catch((err) => { - console.error('获取测试任务状态失败:', err); - clearInterval(interval); - setMcpTesting(false); - toast.error( - t('mcp.testError') + - ': ' + - (err.message || t('mcp.getTaskFailed')), - ); + if (taskResp.runtime.exception) { + const errorMsg = + taskResp.runtime.exception || t('mcp.unknownError'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + setRuntimeInfo({ + connected: false, + error_message: errorMsg, + tool_count: 0, + tools: [], }); - }, 1000); - } else { + } else if (taskResp.runtime.result) { + await loadServerForEdit(serverName); + toast.success(t('mcp.testSuccess')); + } else { + toast.error( + `${t('mcp.testError')}: ${t('mcp.noResultReturned')}`, + ); + } + } + } catch (err) { + clearInterval(interval); setMcpTesting(false); - toast.error(t('mcp.testError') + ': ' + t('mcp.noTaskId')); + const errorMsg = (err as Error).message || t('mcp.getTaskFailed'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); } - }) - .catch((err) => { - console.error('启动测试失败:', err); - setMcpTesting(false); - toast.error( - t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError')), - ); - }); + }, 1000); + } catch (err) { + setMcpTesting(false); + const errorMsg = (err as Error).message || t('mcp.unknownError'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + } } const addExtraArg = () => { - setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]); + const newArgs = [ + ...extraArgs, + { key: '', type: 'string' as const, value: '' }, + ]; + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); }; const removeExtraArg = (index: number) => { @@ -410,25 +361,22 @@ export default function MCPFormDialog({ value: string, ) => { const newArgs = [...extraArgs]; - newArgs[index] = { - ...newArgs[index], - [field]: value, - }; + newArgs[index] = { ...newArgs[index], [field]: value }; setExtraArgs(newArgs); form.setValue('extra_args', newArgs); }; + const handleDialogClose = (open: boolean) => { + onOpenChange(open); + if (!open) { + form.reset(); + setExtraArgs([]); + setRuntimeInfo(null); + } + }; + return ( - { - onOpenChange(open); - if (!open) { - form.reset(); - setExtraArgs([]); - } - }} - > + @@ -436,97 +384,25 @@ export default function MCPFormDialog({ - {isEditMode && ( -
- {mcpTestStatus === 'testing' && ( -
- - - - - {t('mcp.testing')} + {isEditMode && runtimeInfo && ( +
+ {/* 测试中或连接失败时显示状态 */} + {(mcpTesting || !runtimeInfo.connected) && ( +
+
)} - {mcpTestStatus === 'success' && ( -
-
- - - - - {t('mcp.connectionSuccess')} - {mcpToolNames.length}{' '} - {t('mcp.toolsFound')} - -
-
- {mcpToolNames.map((toolName, index) => ( - - {toolName} - - ))} -
-
- )} - - {mcpTestStatus === 'failed' && ( -
-
- - - - - {t('mcp.connectionFailed')} - -
- {mcpTestError && ( -
- {mcpTestError} -
- )} -
- )} + {/* 连接成功时只显示工具列表 */} + {!mcpTesting && + runtimeInfo.connected && + runtimeInfo.tools?.length > 0 && ( + + )}
)} @@ -695,11 +571,7 @@ export default function MCPFormDialog({ diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 5b3f9253..0b4f2240 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -70,11 +70,6 @@ export default function PluginConfigPage() { const [isEditMode, setIsEditMode] = useState(false); const [refreshKey, setRefreshKey] = useState(0); - // 缓存每个服务器测试后的工具数量 - const [serverToolsCache, setServerToolsCache] = useState< - Record - >({}); - useEffect(() => { const fetchPluginSystemStatus = async () => { try { @@ -404,7 +399,6 @@ export default function PluginConfigPage() { setIsEditMode(true); setMcpSSEModalOpen(true); }} - toolsCountCache={serverToolsCache} /> @@ -496,12 +490,6 @@ export default function PluginConfigPage() { onDelete={() => { setShowDeleteConfirmModal(true); }} - onUpdateToolsCache={(serverName, toolsCount) => { - setServerToolsCache((prev) => ({ - ...prev, - [serverName]: toolsCount, - })); - }} /> Date: Tue, 4 Nov 2025 17:32:05 +0800 Subject: [PATCH 037/144] perf: mcp server status checking logic --- pkg/api/http/service/mcp.py | 9 +- pkg/provider/tools/loaders/mcp.py | 78 ++++++++---------- .../app/home/plugins/mcp-server/MCPCardVO.ts | 42 ++-------- .../plugins/mcp-server/MCPServerComponent.tsx | 36 +++++++- .../mcp-server/mcp-card/MCPCardComponent.tsx | 13 ++- .../mcp-server/mcp-form/MCPFormDialog.tsx | 82 +++++++++++++++++-- web/src/app/infra/entities/api/index.ts | 8 +- web/src/i18n/locales/en-US.ts | 1 + web/src/i18n/locales/ja-JP.ts | 1 + web/src/i18n/locales/zh-Hans.ts | 1 + web/src/i18n/locales/zh-Hant.ts | 1 + 11 files changed, 182 insertions(+), 90 deletions(-) diff --git a/pkg/api/http/service/mcp.py b/pkg/api/http/service/mcp.py index 5fcc3ec5..4ce6e5c2 100644 --- a/pkg/api/http/service/mcp.py +++ b/pkg/api/http/service/mcp.py @@ -3,6 +3,7 @@ from __future__ import annotations import sqlalchemy import uuid import traceback +import asyncio from ....core import app from ....entity.persistence import mcp as persistence_mcp @@ -124,7 +125,6 @@ class MCPService: async def create_mcp_server(self, server_data: dict) -> str: server_data['uuid'] = str(uuid.uuid4()) - print('server_data:', server_data) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) result = await self.ap.persistence_mgr.execute_async( @@ -134,7 +134,8 @@ class MCPService: if server_entity: server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity) if self.ap.tool_mgr.mcp_tool_loader: - await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config) + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) return server_data['uuid'] @@ -175,7 +176,9 @@ class MCPService: if updated_server: # convert entity to config dict server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) - await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config) + # await self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config) + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) async def delete_mcp_server(self, server_uuid: str) -> None: result = await self.ap.persistence_mgr.execute_async( diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 00dece7b..531f75f6 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -1,5 +1,6 @@ from __future__ import annotations +import enum import typing from contextlib import AsyncExitStack import traceback @@ -16,6 +17,12 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool from ....entity.persistence import mcp as persistence_mcp +class MCPSessionStatus(enum.Enum): + CONNECTING = 'connecting' + CONNECTED = 'connected' + ERROR = 'error' + + class RuntimeMCPSession: """运行时 MCP 会话""" @@ -33,7 +40,8 @@ class RuntimeMCPSession: enable: bool - connected: bool + # connected: bool + status: MCPSessionStatus last_test_error_message: str @@ -47,7 +55,7 @@ class RuntimeMCPSession: self.exit_stack = AsyncExitStack() self.functions = [] - self.connected = False + self.status = MCPSessionStatus.CONNECTING self.last_test_error_message = '' async def _init_stdio_python_server(self): @@ -117,10 +125,10 @@ class RuntimeMCPSession: ) ) - self.connected = True + self.status = MCPSessionStatus.CONNECTED self.last_test_error_message = '' except Exception as e: - self.connected = False + self.status = MCPSessionStatus.ERROR self.last_test_error_message = str(e) raise e @@ -129,7 +137,7 @@ class RuntimeMCPSession: def get_runtime_info_dict(self) -> dict: return { - 'connected': self.connected, + 'status': self.status.value, 'error_message': self.last_test_error_message, 'tool_count': len(self.get_tools()), 'tools': [ @@ -163,13 +171,13 @@ class MCPLoader(loader.ToolLoader): _last_listed_functions: list[resource_tool.LLMTool] - _startup_load_tasks: list[asyncio.Task] + _hosted_mcp_tasks: list[asyncio.Task] def __init__(self, ap: app.Application): super().__init__(ap) self.sessions = {} self._last_listed_functions = [] - self._startup_load_tasks = [] + self._hosted_mcp_tasks = [] async def initialize(self): await self.load_mcp_servers_from_db() @@ -185,30 +193,30 @@ class MCPLoader(loader.ToolLoader): for server in servers: config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) - async def load_mcp_server_task(server_config: dict): - self.ap.logger.debug(f'Loading MCP server {server_config}') - try: - session = await self.load_mcp_server(server_config) - self.sessions[server_config['name']] = session - except Exception as e: - self.ap.logger.error( - f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' - ) - return + task = asyncio.create_task(self.host_mcp_server(config)) + self._hosted_mcp_tasks.append(task) - self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})') - try: - await session.start() - except Exception as e: - self.ap.logger.error( - f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' - ) - return + async def host_mcp_server(self, server_config: dict): + self.ap.logger.debug(f'Loading MCP server {server_config}') + try: + session = await self.load_mcp_server(server_config) + self.sessions[server_config['name']] = session + except Exception as e: + self.ap.logger.error( + f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' + ) + return - self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})') + self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})') + try: + await session.start() + except Exception as e: + self.ap.logger.error( + f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' + ) + return - task = asyncio.create_task(load_mcp_server_task(config)) - self._startup_load_tasks.append(task) + self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})') async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession: """加载 MCP 服务器到运行时 @@ -271,20 +279,6 @@ class MCPLoader(loader.ToolLoader): raise ValueError(f'Tool not found: {name}') - async def reload_mcp_server(self, server_config: dict): - """重新加载 MCP 服务器(先移除再加载) - - Args: - server_config: 服务器配置字典,必须包含 name 字段 - """ - server_name = server_config['name'] - - if server_name in self.sessions: - await self.remove_mcp_server(server_name) - - # 重新加载 - await self.load_mcp_server(server_config) - async def remove_mcp_server(self, server_name: str): """移除 MCP 服务器""" if server_name not in self.sessions: diff --git a/web/src/app/home/plugins/mcp-server/MCPCardVO.ts b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts index 3139f2fc..5fe76e13 100644 --- a/web/src/app/home/plugins/mcp-server/MCPCardVO.ts +++ b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts @@ -1,10 +1,10 @@ -import { MCPServer } from '@/app/infra/entities/api'; +import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api'; export class MCPCardVO { name: string; mode: 'stdio' | 'sse'; enable: boolean; - status: 'connected' | 'disconnected' | 'error' | 'disabled'; + status: MCPSessionStatus; tools: number; error?: string; @@ -15,45 +15,15 @@ export class MCPCardVO { // Determine status from runtime_info if (!data.runtime_info) { - this.status = 'disconnected'; + this.status = MCPSessionStatus.ERROR; this.tools = 0; - } else if (data.runtime_info.connected) { - this.status = 'connected'; + } else if (data.runtime_info.status === MCPSessionStatus.CONNECTED) { + this.status = data.runtime_info.status; this.tools = data.runtime_info.tool_count || 0; } else { - this.status = 'error'; + this.status = data.runtime_info.status; this.tools = 0; this.error = data.runtime_info.error_message; } } - - getStatusColor(): string { - switch (this.status) { - case 'connected': - return 'text-green-600'; - case 'disconnected': - return 'text-gray-500'; - case 'error': - return 'text-red-600'; - case 'disabled': - return 'text-gray-400'; - 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'; - case 'disabled': - return 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636'; - 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-server/MCPServerComponent.tsx b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx index 84e90715..89b3c43f 100644 --- a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent'; import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO'; import { useTranslation } from 'react-i18next'; +import { MCPSessionStatus } from '@/app/infra/entities/api'; import { httpClient } from '@/app/infra/http/HttpClient'; @@ -16,11 +17,44 @@ export default function MCPComponent({ const { t } = useTranslation(); const [installedServers, setInstalledServers] = useState([]); const [loading, setLoading] = useState(false); + const pollingIntervalRef = useRef(null); useEffect(() => { fetchInstalledServers(); + + return () => { + // Cleanup: clear polling interval when component unmounts + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + }; }, []); + // Check if any server is connecting and start/stop polling accordingly + useEffect(() => { + const hasConnecting = installedServers.some( + (server) => server.status === MCPSessionStatus.CONNECTING, + ); + + if (hasConnecting && !pollingIntervalRef.current) { + // Start polling every 3 seconds + pollingIntervalRef.current = setInterval(() => { + fetchInstalledServers(); + }, 3000); + } else if (!hasConnecting && pollingIntervalRef.current) { + // Stop polling when no server is connecting + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [installedServers]); + function fetchInstalledServers() { setLoading(true); httpClient diff --git a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx index 3f933c2a..525e0081 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx @@ -5,7 +5,8 @@ import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import { RefreshCcw, Wrench, Ban, AlertCircle } from 'lucide-react'; +import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react'; +import { MCPSessionStatus } from '@/app/infra/entities/api'; export default function MCPCardComponent({ cardVO, @@ -110,7 +111,7 @@ export default function MCPCardComponent({ {t('mcp.statusDisabled')}
- ) : status === 'connected' ? ( + ) : status === MCPSessionStatus.CONNECTED ? ( // 连接成功 - 显示工具数量
@@ -118,6 +119,14 @@ export default function MCPCardComponent({ {t('mcp.toolCount', { count: toolsCount })}
+ ) : status === MCPSessionStatus.CONNECTING ? ( + // 连接中 - 蓝色加载 +
+ +
+ {t('mcp.connecting')} +
+
) : ( // 连接失败 - 红色
diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx index 8fe62e83..717a24a8 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Resolver, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -42,9 +42,10 @@ import { MCPServerRuntimeInfo, MCPTool, MCPServer, + MCPSessionStatus, } from '@/app/infra/entities/api'; -// Status Display Component - 只在测试中或连接失败时使用 +// Status Display Component - 在测试中、连接中或连接失败时使用 function StatusDisplay({ testing, runtimeInfo, @@ -82,6 +83,35 @@ function StatusDisplay({ ); } + // 连接中 + if (runtimeInfo.status === MCPSessionStatus.CONNECTING) { + return ( +
+ + + + + {t('mcp.connecting')} +
+ ); + } + // 连接失败 return (
@@ -201,6 +231,7 @@ export default function MCPFormDialog({ const [runtimeInfo, setRuntimeInfo] = useState( null, ); + const pollingIntervalRef = useRef(null); // Load server data when editing useEffect(() => { @@ -212,8 +243,48 @@ export default function MCPFormDialog({ setExtraArgs([]); setRuntimeInfo(null); } + + // Cleanup polling interval when dialog closes + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; }, [open, isEditMode, serverName]); + // Poll for updates when runtime_info status is CONNECTING + useEffect(() => { + if ( + !open || + !isEditMode || + !serverName || + !runtimeInfo || + runtimeInfo.status !== MCPSessionStatus.CONNECTING + ) { + // Stop polling if conditions are not met + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + return; + } + + // Start polling if not already running + if (!pollingIntervalRef.current) { + pollingIntervalRef.current = setInterval(() => { + loadServerForEdit(serverName); + }, 3000); + } + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [open, isEditMode, serverName, runtimeInfo?.status]); + async function loadServerForEdit(serverName: string) { try { const resp = await httpClient.getMCPServer(serverName); @@ -312,7 +383,7 @@ export default function MCPFormDialog({ taskResp.runtime.exception || t('mcp.unknownError'); toast.error(`${t('mcp.testError')}: ${errorMsg}`); setRuntimeInfo({ - connected: false, + status: MCPSessionStatus.ERROR, error_message: errorMsg, tool_count: 0, tools: [], @@ -387,7 +458,8 @@ export default function MCPFormDialog({ {isEditMode && runtimeInfo && (
{/* 测试中或连接失败时显示状态 */} - {(mcpTesting || !runtimeInfo.connected) && ( + {(mcpTesting || + runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
0 && ( )} diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index f169ba4d..152828fd 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -325,8 +325,14 @@ export interface MCPServerExtraArgsSSE { ssereadtimeout: number; } +export enum MCPSessionStatus { + CONNECTING = 'connecting', + CONNECTED = 'connected', + ERROR = 'error', +} + export interface MCPServerRuntimeInfo { - connected: boolean; + status: MCPSessionStatus; error_message: string; tool_count: number; tools: MCPTool[]; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 873461b5..71b70cdf 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -311,6 +311,7 @@ const enUS = { keyName: 'Key Name', value: 'Value', testing: 'Testing...', + connecting: 'Connecting...', testSuccess: 'Connection test successful', testFailed: 'Connection test failed: ', testError: 'Connection test error', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index fd8cd418..904d9de8 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -313,6 +313,7 @@ const jaJP = { keyName: 'キー名', value: '値', testing: 'テスト中...', + connecting: '接続中...', testSuccess: '接続テストに成功しました', testFailed: '接続テストに失敗しました:', testError: '接続テストエラー', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 9dcfecd3..d40bdfb9 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -299,6 +299,7 @@ const zhHans = { keyName: '键名', value: '值', testing: '测试中...', + connecting: '连接中...', testSuccess: '连接测试成功', testFailed: '连接测试失败:', testError: '连接测试出错', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 08749bc6..581cdb6c 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -297,6 +297,7 @@ const zhHant = { keyName: '鍵名', value: '值', testing: '測試中...', + connecting: '連接中...', testSuccess: '連接測試成功', testFailed: '連接測試失敗:', testError: '連接測試出錯', From 1046f3c2aa1488588c0f4d1c8f566f6e4e8f8eae Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Nov 2025 18:14:59 +0800 Subject: [PATCH 038/144] perf: mcp server testing and refreshing --- .../http/controller/groups/resources/mcp.py | 8 +- pkg/api/http/service/mcp.py | 124 ++++-------------- pkg/provider/tools/loaders/mcp.py | 51 +++---- .../mcp-server/mcp-card/MCPCardComponent.tsx | 10 +- .../mcp-server/mcp-form/MCPFormDialog.tsx | 25 ++-- web/src/app/infra/http/BackendClient.ts | 7 +- web/src/i18n/locales/en-US.ts | 8 +- web/src/i18n/locales/ja-JP.ts | 8 +- web/src/i18n/locales/zh-Hans.ts | 8 +- web/src/i18n/locales/zh-Hant.ts | 8 +- 10 files changed, 101 insertions(+), 156 deletions(-) diff --git a/pkg/api/http/controller/groups/resources/mcp.py b/pkg/api/http/controller/groups/resources/mcp.py index 46559bc9..ac91abff 100644 --- a/pkg/api/http/controller/groups/resources/mcp.py +++ b/pkg/api/http/controller/groups/resources/mcp.py @@ -57,10 +57,6 @@ class MCPRouterGroup(group.RouterGroup): @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: """测试MCP服务器连接""" - - server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) - if server_data is None: - return self.http_status(404, -1, 'Server not found') - - task_id = await self.ap.mcp_service.test_mcp_server(server_data['uuid']) + server_data = await quart.request.json + task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) return self.success(data={'task_id': task_id}) diff --git a/pkg/api/http/service/mcp.py b/pkg/api/http/service/mcp.py index 4ce6e5c2..3766e7d6 100644 --- a/pkg/api/http/service/mcp.py +++ b/pkg/api/http/service/mcp.py @@ -2,98 +2,12 @@ from __future__ import annotations import sqlalchemy import uuid -import traceback import asyncio from ....core import app from ....entity.persistence import mcp as persistence_mcp from ....core import taskmgr -from ....provider.tools.loaders.mcp import RuntimeMCPSession - - -class RuntimeMCPServer: - """Runtime MCP Server representation""" - - ap: app.Application - - mcp_server_entity: persistence_mcp.MCPServer - - session: RuntimeMCPSession | None = None - - def __init__(self, ap: app.Application, mcp_server_entity: persistence_mcp.MCPServer): - self.ap = ap - self.mcp_server_entity = mcp_server_entity - self.session = None - - async def initialize(self): - """初始化 MCP Server""" - if not self.mcp_server_entity.enable: - return - - # 构建配置字典 - mixed_config = { - 'name': self.mcp_server_entity.name, - 'mode': self.mcp_server_entity.mode, - 'enable': self.mcp_server_entity.enable, - **self.mcp_server_entity.extra_args, - } - - self.session = RuntimeMCPSession( - self.mcp_server_entity.name, mixed_config, self.mcp_server_entity.enable, self.ap - ) - await self.session.start() - - async def _test_mcp_server_task(self, task_context: taskmgr.TaskContext): - """测试MCP服务器连接""" - try: - task_context.set_current_action(f'Testing connection to {self.mcp_server_entity.name}') - - # 创建临时会话进行测试 - mixed_config = { - 'name': self.mcp_server_entity.name, - 'mode': self.mcp_server_entity.mode, - 'enable': True, # 测试时强制启用 - **self.mcp_server_entity.extra_args, - } - - test_session = RuntimeMCPSession(self.mcp_server_entity.name, mixed_config, enable=True, ap=self.ap) - await test_session.start() - - # 获取工具列表作为测试 - tools_count = len(test_session.functions) - - tool_name_list = [] - for function in test_session.functions: - tool_name_list.append(function.name) - - task_context.set_current_action(f'Successfully connected. Found {tools_count} tools.') - - # 关闭测试会话 - await test_session.shutdown() - - return {'status': 'success', 'tools_count': tools_count, 'tools_names_lists': tool_name_list} - - except Exception as e: - self.ap.logger.error(f'Connection test failed: {str(e)}\n{traceback.format_exc()}') - task_context.set_current_action(f'Connection test failed: {str(e)}') - raise e - - async def test_connection(self) -> str: - """测试 MCP 服务器连接并返回任务 ID""" - ctx = taskmgr.TaskContext.new() - wrapper = self.ap.task_mgr.create_user_task( - self._test_mcp_server_task(task_context=ctx), - kind='mcp-operation', - name=f'mcp-test-{self.mcp_server_entity.name}', - label=f'Testing MCP server {self.mcp_server_entity.name}', - context=ctx, - ) - return wrapper.id - - async def dispose(self): - """清理资源""" - if self.session: - await self.session.shutdown() +from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus class MCPService: @@ -176,7 +90,6 @@ class MCPService: if updated_server: # convert entity to config dict server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) - # await self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config) task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) @@ -195,21 +108,30 @@ class MCPService: if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name) - async def test_mcp_server(self, server_uuid: str) -> str: + async def test_mcp_server(self, server_name: str, server_data: dict) -> int: """测试 MCP 服务器连接并返回任务 ID""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) - ) - server = result.first() - if server is None: - raise ValueError(f'Server not found: {server_uuid}') + runtime_mcp_session: RuntimeMCPSession | None = None - if isinstance(server, sqlalchemy.Row): - server_entity = persistence_mcp.MCPServer(**server._mapping) + if server_name != '_': + runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if runtime_mcp_session is None: + raise ValueError(f'Server not found: {server_name}') + + if runtime_mcp_session.status == MCPSessionStatus.ERROR: + coroutine = runtime_mcp_session.start() + else: + coroutine = runtime_mcp_session.refresh() else: - server_entity = server + runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data) + coroutine = runtime_mcp_session.start() - runtime_server = RuntimeMCPServer(ap=self.ap, mcp_server_entity=server_entity) - - return await runtime_server.test_connection() + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + coroutine, + kind='mcp-operation', + name=f'mcp-test-{server_name}', + label=f'Testing MCP server {server_name}', + context=ctx, + ) + return wrapper.id diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 531f75f6..edff9e01 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -101,29 +101,7 @@ class RuntimeMCPSession: else: raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}') - tools = await self.session.list_tools() - - self.ap.logger.debug(f'获取 MCP 工具: {tools}') - - for tool in tools.tools: - - async def func(*, _tool=tool, **kwargs): - result = await self.session.call_tool(_tool.name, kwargs) - if result.isError: - raise Exception(result.content[0].text) - return result.content[0].text - - func.__name__ = tool.name - - self.functions.append( - resource_tool.LLMTool( - name=tool.name, - human_desc=tool.description, - description=tool.description, - parameters=tool.inputSchema, - func=func, - ) - ) + await self.refresh() self.status = MCPSessionStatus.CONNECTED self.last_test_error_message = '' @@ -132,6 +110,33 @@ class RuntimeMCPSession: self.last_test_error_message = str(e) raise e + async def refresh(self): + self.functions.clear() + + tools = await self.session.list_tools() + + self.ap.logger.debug(f'Refresh MCP tools: {tools}') + + for tool in tools.tools: + + async def func(*, _tool=tool, **kwargs): + result = await self.session.call_tool(_tool.name, kwargs) + if result.isError: + raise Exception(result.content[0].text) + return result.content[0].text + + func.__name__ = tool.name + + self.functions.append( + resource_tool.LLMTool( + name=tool.name, + human_desc=tool.description, + description=tool.description, + parameters=tool.inputSchema, + func=func, + ) + ) + def get_tools(self) -> list[resource_tool.LLMTool]: return self.functions diff --git a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx index 525e0081..fd19cd4b 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx @@ -51,7 +51,7 @@ export default function MCPCardComponent({ setTesting(true); httpClient - .testMCPServer(cardVO.name) + .testMCPServer(cardVO.name, {}) .then((resp) => { const taskId = resp.task_id; @@ -62,9 +62,11 @@ export default function MCPCardComponent({ setTesting(false); if (taskResp.runtime.exception) { - toast.error(t('mcp.testFailed') + taskResp.runtime.exception); + toast.error( + t('mcp.refreshFailed') + taskResp.runtime.exception, + ); } else { - toast.success(t('mcp.testSuccess')); + toast.success(t('mcp.refreshSuccess')); } // Refresh to get updated runtime_info @@ -74,7 +76,7 @@ export default function MCPCardComponent({ }, 1000); }) .catch((err) => { - toast.error(t('mcp.testFailed') + err.message); + toast.error(t('mcp.refreshFailed') + err.message); setTesting(false); }); } diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx index 717a24a8..4638bd1e 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -361,11 +361,22 @@ export default function MCPFormDialog({ } async function testMcp() { - const serverName = form.getValues('name'); setMcpTesting(true); try { - const { task_id } = await httpClient.testMCPServer(serverName); + const { task_id } = await httpClient.testMCPServer('_', { + name: form.getValues('name'), + mode: 'sse', + enable: true, + extra_args: { + url: form.getValues('url'), + timeout: form.getValues('timeout'), + ssereadtimeout: form.getValues('ssereadtimeout'), + headers: Object.fromEntries( + extraArgs.map((arg) => [arg.key, arg.value]), + ), + }, + }); if (!task_id) { throw new Error(t('mcp.noTaskId')); } @@ -388,13 +399,11 @@ export default function MCPFormDialog({ tool_count: 0, tools: [], }); - } else if (taskResp.runtime.result) { - await loadServerForEdit(serverName); - toast.success(t('mcp.testSuccess')); } else { - toast.error( - `${t('mcp.testError')}: ${t('mcp.noResultReturned')}`, - ); + if (isEditMode) { + await loadServerForEdit(form.getValues('name')); + } + toast.success(t('mcp.testSuccess')); } } } catch (err) { diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 3d0c3ee3..cc47d3fa 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -524,8 +524,11 @@ export class BackendClient extends BaseHttpClient { }); } - public testMCPServer(serverName: string): Promise { - return this.post(`/api/v1/mcp/servers/${serverName}/test`); + public testMCPServer( + serverName: string, + serverData: object, + ): Promise { + return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData); } public installMCPServerFromGithub( diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 71b70cdf..9b76fd86 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -312,9 +312,11 @@ const enUS = { value: 'Value', testing: 'Testing...', connecting: 'Connecting...', - testSuccess: 'Connection test successful', - testFailed: 'Connection test failed: ', - testError: 'Connection test error', + testSuccess: 'Test successful', + testFailed: 'Test failed: ', + testError: 'Test error', + refreshSuccess: 'Refresh successful', + refreshFailed: 'Refresh failed: ', connectionSuccess: 'Connection successful', connectionFailed: 'Connection failed', toolsFound: 'tools', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 904d9de8..487aa202 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -314,9 +314,11 @@ const jaJP = { value: '値', testing: 'テスト中...', connecting: '接続中...', - testSuccess: '接続テストに成功しました', - testFailed: '接続テストに失敗しました:', - testError: '接続テストエラー', + testSuccess: '刷新に成功しました', + testFailed: '刷新に失敗しました:', + testError: '刷新エラー', + refreshSuccess: '刷新に成功しました', + refreshFailed: '刷新に失敗しました:', connectionSuccess: '接続に成功しました', connectionFailed: '接続に失敗しました', toolsFound: '個のツール', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index d40bdfb9..20fd1f2b 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -300,9 +300,11 @@ const zhHans = { value: '值', testing: '测试中...', connecting: '连接中...', - testSuccess: '连接测试成功', - testFailed: '连接测试失败:', - testError: '连接测试出错', + testSuccess: '测试成功', + testFailed: '测试失败:', + testError: '刷新出错', + refreshSuccess: '刷新成功', + refreshFailed: '刷新失败:', connectionSuccess: '连接成功', connectionFailed: '连接失败', toolsFound: '个工具', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 581cdb6c..478433b2 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -298,9 +298,11 @@ const zhHant = { value: '值', testing: '測試中...', connecting: '連接中...', - testSuccess: '連接測試成功', - testFailed: '連接測試失敗:', - testError: '連接測試出錯', + testSuccess: '測試成功', + testFailed: '刷新失敗:', + testError: '刷新出錯', + refreshSuccess: '刷新成功', + refreshFailed: '刷新失敗:', connectionSuccess: '連接成功', connectionFailed: '連接失敗', toolsFound: '個工具', From 2ad1f97e125d9db64026400201b187de0962a6fe Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Nov 2025 18:27:37 +0800 Subject: [PATCH 039/144] perf: no mcp server tips --- .../plugins/mcp-server/MCPServerComponent.tsx | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx index 89b3c43f..704e20fc 100644 --- a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx @@ -73,16 +73,26 @@ export default function MCPComponent({ return (
{/* 已安装的服务器列表 */} -
-
- {loading ? ( -
{t('mcp.loading')}
- ) : installedServers.length === 0 ? ( -
- {t('mcp.noServerInstalled')} -
- ) : ( - installedServers.map((server, index) => ( +
+ {loading ? ( +
+ {t('mcp.loading')} +
+ ) : installedServers.length === 0 ? ( +
+ + + +
{t('mcp.noServerInstalled')}
+
+ ) : ( +
+ {installedServers.map((server, index) => (
- )) - )} -
+ ))} +
+ )}
); From d3279b9823e5be50e7e010ab21bf0a0bd214b152 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Nov 2025 18:33:13 +0800 Subject: [PATCH 040/144] perf: update sidebar title --- web/src/i18n/locales/en-US.ts | 2 +- web/src/i18n/locales/ja-JP.ts | 2 +- web/src/i18n/locales/zh-Hans.ts | 2 +- web/src/i18n/locales/zh-Hant.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 9b76fd86..d3018fd1 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -151,7 +151,7 @@ const enUS = { logs: 'Logs', }, plugins: { - title: 'Plugins', + title: 'Extensions', description: 'Install and configure plugins to extend LangBot functionality', createPlugin: 'Create Plugin', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 487aa202..766f699a 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -153,7 +153,7 @@ const jaJP = { logs: 'ログ', }, plugins: { - title: 'プラグイン', + title: '拡張機能', description: 'LangBotの機能を拡張するプラグインをインストール・設定', createPlugin: 'プラグインを作成', editPlugin: 'プラグインを編集', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 20fd1f2b..b4fc6421 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -148,7 +148,7 @@ const zhHans = { logs: '日志', }, plugins: { - title: '插件管理', + title: '插件扩展', description: '安装和配置用于扩展 LangBot 功能的插件', createPlugin: '创建插件', editPlugin: '编辑插件', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 478433b2..68b791e0 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -148,7 +148,7 @@ const zhHant = { logs: '日誌', }, plugins: { - title: '外掛管理', + title: '外掛擴展', description: '安裝和設定用於擴展 LangBot 功能的外掛', createPlugin: '建立外掛', editPlugin: '編輯外掛', From c0f04e4f2094f64b86000e28dba16fade4c1d287 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Nov 2025 18:35:21 +0800 Subject: [PATCH 041/144] chore: update --- templates/config.yaml | 2 -- web/src/i18n/locales/en-US.ts | 2 +- web/src/i18n/locales/ja-JP.ts | 2 +- web/src/i18n/locales/zh-Hans.ts | 2 +- web/src/i18n/locales/zh-Hant.ts | 2 +- 5 files changed, 4 insertions(+), 6 deletions(-) diff --git a/templates/config.yaml b/templates/config.yaml index 13916d9f..8ea0d189 100644 --- a/templates/config.yaml +++ b/templates/config.yaml @@ -10,8 +10,6 @@ command: concurrency: pipeline: 20 session: 1 -mcp: - servers: [] proxy: http: '' https: '' diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index d3018fd1..34adfcb3 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -283,7 +283,7 @@ const enUS = { markAsReadFailed: 'Mark as read failed', }, mcp: { - title: 'MCP Management', + title: 'MCP', createServer: 'Add MCP Server', editServer: 'Edit MCP Server', deleteServer: 'Delete MCP Server', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 766f699a..e6044167 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -285,7 +285,7 @@ const jaJP = { markAsReadFailed: '既読に設定に失敗しました', }, mcp: { - title: 'MCP管理', + title: 'MCP', createServer: 'MCPサーバーを追加', editServer: 'MCPサーバーを編集', deleteServer: 'MCPサーバーを削除', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b4fc6421..eaf0c5d0 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -271,7 +271,7 @@ const zhHans = { markAsReadFailed: '标记为已读失败', }, mcp: { - title: 'MCP管理', + title: 'MCP', createServer: '添加 MCP 服务器', editServer: '修改 MCP 服务器', deleteServer: '删除 MCP 服务器', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 68b791e0..3a77ee64 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -269,7 +269,7 @@ const zhHant = { markAsReadFailed: '標記為已讀失敗', }, mcp: { - title: 'MCP管理', + title: 'MCP', createServer: '新增MCP伺服器', editServer: '編輯MCP伺服器', deleteServer: '刪除MCP伺服器', From 1dd598c7beca72aac5ba1b9e26738a19e2b5dc68 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 2 Oct 2025 10:23:59 +0800 Subject: [PATCH 042/144] chore: bump langbot-plugin to 0.1.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8e76b3ae..c85288f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ dependencies = [ "langchain>=0.2.0", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.1.3b1", + "langbot-plugin==0.1.3", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", From f9f2de570f73df2ea2eafe04ea1f753b94632868 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 2 Oct 2025 10:26:48 +0800 Subject: [PATCH 043/144] chore: bump version v4.3.4 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 05b94ef4..e52f94a5 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.3' +semantic_version = 'v4.3.4' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index c85288f3..5a32ecb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.3" +version = "4.3.4" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 04e26225cda8a230de30da981304ef3d2be22521 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 2 Oct 2025 10:30:19 +0800 Subject: [PATCH 044/144] chore: release v4.3.5 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index e52f94a5..968bacdc 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.4' +semantic_version = 'v4.3.5' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 5a32ecb3..4cedc515 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.4" +version = "4.3.5" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 7138c101e3ca0f00900cdb990ab7e3d55d12f366 Mon Sep 17 00:00:00 2001 From: Thetail001 <56257172+Thetail001@users.noreply.github.com> Date: Sat, 4 Oct 2025 00:20:27 +0800 Subject: [PATCH 045/144] Fix: Correct data type mismatch in AtBotRule (#1705) Fix can't '@' in QQ group. --- pkg/pipeline/resprule/rules/atbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/pipeline/resprule/rules/atbot.py b/pkg/pipeline/resprule/rules/atbot.py index 51431519..68c3ace9 100644 --- a/pkg/pipeline/resprule/rules/atbot.py +++ b/pkg/pipeline/resprule/rules/atbot.py @@ -21,7 +21,7 @@ class AtBotRule(rule_model.GroupRespondRule): def remove_at(message_chain: platform_message.MessageChain): nonlocal found for component in message_chain.root: - if isinstance(component, platform_message.At) and component.target == query.adapter.bot_account_id: + if isinstance(component, platform_message.At) and str(component.target) == str(query.adapter.bot_account_id): message_chain.remove(component) found = True break From 869b2176a73a423219bf318ecbbba47ce3a9b8b9 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sat, 4 Oct 2025 00:22:03 +0800 Subject: [PATCH 046/144] chore: bump version 4.3.6 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 968bacdc..5cf281d3 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.5' +semantic_version = 'v4.3.6' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 4cedc515..86385e4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.5" +version = "4.3.6" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 46062bf4b937422eb667fe05d31c38e943872c24 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Mon, 6 Oct 2025 23:22:38 +0800 Subject: [PATCH 047/144] feat: update for new events fields --- pkg/pipeline/process/handlers/chat.py | 10 +++++----- pkg/pipeline/process/handlers/command.py | 18 ++++++++---------- pkg/pipeline/wrapper/wrapper.py | 10 ++++------ 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/pkg/pipeline/process/handlers/chat.py b/pkg/pipeline/process/handlers/chat.py index b6da5fa6..6e133cc9 100644 --- a/pkg/pipeline/process/handlers/chat.py +++ b/pkg/pipeline/process/handlers/chat.py @@ -9,7 +9,6 @@ from .. import handler from ... import entities from ....provider import runner as runner_module -import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.events as events from ....utils import importutil from ....provider import runners @@ -47,18 +46,19 @@ class ChatMessageHandler(handler.MessageHandler): event_ctx = await self.ap.plugin_connector.emit_event(event) is_create_card = False # 判断下是否需要创建流式卡片 + if event_ctx.is_prevented_default(): - if event_ctx.event.reply is not None: - mc = platform_message.MessageChain(event_ctx.event.reply) + if event_ctx.event.reply_message_chain is not None: + mc = event_ctx.event.reply_message_chain query.resp_messages.append(mc) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) else: - if event_ctx.event.alter is not None: + if event_ctx.event.user_message_alter is not None: # if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter - query.user_message.content = event_ctx.event.alter + query.user_message.content = event_ctx.event.user_message_alter text_length = 0 try: diff --git a/pkg/pipeline/process/handlers/command.py b/pkg/pipeline/process/handlers/command.py index 382838f8..52bcdb6f 100644 --- a/pkg/pipeline/process/handlers/command.py +++ b/pkg/pipeline/process/handlers/command.py @@ -5,7 +5,6 @@ import typing from .. import handler from ... import entities import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.events as events @@ -49,8 +48,8 @@ class CommandHandler(handler.MessageHandler): event_ctx = await self.ap.plugin_connector.emit_event(event) if event_ctx.is_prevented_default(): - if event_ctx.event.reply is not None: - mc = platform_message.MessageChain(event_ctx.event.reply) + if event_ctx.event.reply_message_chain is not None: + mc = event_ctx.event.reply_message_chain query.resp_messages.append(mc) @@ -59,11 +58,6 @@ class CommandHandler(handler.MessageHandler): yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) else: - if event_ctx.event.alter is not None: - query.message_chain = platform_message.MessageChain( - [platform_message.Plain(text=event_ctx.event.alter)] - ) - session = await self.ap.sess_mgr.get_session(query) async for ret in self.ap.cmd_mgr.execute( @@ -80,8 +74,12 @@ class CommandHandler(handler.MessageHandler): self.ap.logger.info(f'Command({query.query_id}) error: {self.cut_str(str(ret.error))}') yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) - elif (ret.text is not None or ret.image_url is not None or ret.image_base64 is not None - or ret.file_url is not None): + elif ( + ret.text is not None + or ret.image_url is not None + or ret.image_base64 is not None + or ret.file_url is not None + ): content: list[provider_message.ContentElement] = [] if ret.text is not None: diff --git a/pkg/pipeline/wrapper/wrapper.py b/pkg/pipeline/wrapper/wrapper.py index 439595e9..6267c864 100644 --- a/pkg/pipeline/wrapper/wrapper.py +++ b/pkg/pipeline/wrapper/wrapper.py @@ -80,8 +80,8 @@ class ResponseWrapper(stage.PipelineStage): new_query=query, ) else: - if event_ctx.event.reply is not None: - query.resp_message_chain.append(platform_message.MessageChain(event_ctx.event.reply)) + if event_ctx.event.reply_message_chain is not None: + query.resp_message_chain.append(event_ctx.event.reply_message_chain) else: query.resp_message_chain.append(result.get_content_platform_message_chain()) @@ -123,10 +123,8 @@ class ResponseWrapper(stage.PipelineStage): new_query=query, ) else: - if event_ctx.event.reply is not None: - query.resp_message_chain.append( - platform_message.MessageChain(text=event_ctx.event.reply) - ) + if event_ctx.event.reply_message_chain is not None: + query.resp_message_chain.append(event_ctx.event.reply_message_chain) else: query.resp_message_chain.append( From 750cc24900158deeaa0e340ebe4bc876b9277603 Mon Sep 17 00:00:00 2001 From: Guanchao Wang Date: Tue, 7 Oct 2025 00:06:07 +0800 Subject: [PATCH 048/144] Fix/qqo (#1709) * fix: qq official * fix: appid --- pkg/platform/sources/qqofficial.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/pkg/platform/sources/qqofficial.py b/pkg/platform/sources/qqofficial.py index 28a09d8c..240b46d0 100644 --- a/pkg/platform/sources/qqofficial.py +++ b/pkg/platform/sources/qqofficial.py @@ -139,19 +139,15 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter event_converter: QQOfficialEventConverter = QQOfficialEventConverter() def __init__(self, config: dict, logger: EventLogger): - self.config = config - self.logger = logger + bot = QQOfficialClient( + app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger + ) - required_keys = [ - 'appid', - 'secret', - ] - missing_keys = [key for key in required_keys if key not in config] - if missing_keys: - raise command_errors.ParamNotEnoughError('QQ官方机器人缺少相关配置项,请查看文档或联系管理员') - - self.bot = QQOfficialClient( - app_id=config['appid'], secret=config['secret'], token=config['token'], logger=self.logger + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id=config['appid'], ) async def reply_message( From f135c946bd9db00992edad994b4bdb343e48e154 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 7 Oct 2025 00:15:56 +0800 Subject: [PATCH 049/144] chore: add `codecov.yml` --- codecov.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..5dd21786 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,4 @@ +coverage: + status: + project: off + patch: off \ No newline at end of file From 360422f25e12e24d4bd83db220ca7438046402e9 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 7 Oct 2025 15:25:49 +0800 Subject: [PATCH 050/144] chore: bump langbot-plugin to 0.1.4b2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 86385e4d..57f6ca19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ dependencies = [ "langchain>=0.2.0", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.1.3", + "langbot-plugin==0.1.4b2", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", From a6072c2abb675bede0fe2c9095928c13dce5bf8f Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 7 Oct 2025 15:30:33 +0800 Subject: [PATCH 051/144] chore: bump version 4.3.7b1 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 5cf281d3..d8fd6297 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.6' +semantic_version = 'v4.3.7b1' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 57f6ca19..00927300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.6" +version = "4.3.7b1" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From c47c4994aed74e5ac05a6cddcbf376a2fa226c6b Mon Sep 17 00:00:00 2001 From: "Junyan Qin (Chin)" Date: Tue, 7 Oct 2025 16:24:38 +0800 Subject: [PATCH 052/144] fix: return empty data when plugin system disabled (#1710) --- pkg/plugin/connector.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index 9b362db2..c98bcc2a 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -18,7 +18,7 @@ from langbot_plugin.api.entities import events from langbot_plugin.api.entities import context import langbot_plugin.runtime.io.connection as base_connection from langbot_plugin.api.definition.components.manifest import ComponentManifest -from langbot_plugin.api.entities.builtin.command import context as command_context +from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from ..core import taskmgr @@ -191,6 +191,9 @@ class PluginRuntimeConnector: task_context.trace(trace) async def list_plugins(self) -> list[dict[str, Any]]: + if not self.is_enable_plugin: + return [] + return await self.handler.list_plugins() async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]: @@ -211,6 +214,7 @@ class PluginRuntimeConnector: if not self.is_enable_plugin: return event_ctx + event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=True)) event_ctx = context.EventContext.model_validate(event_ctx_result['event_context']) @@ -218,14 +222,23 @@ class PluginRuntimeConnector: return event_ctx async def list_tools(self) -> list[ComponentManifest]: + if not self.is_enable_plugin: + return [] + list_tools_data = await self.handler.list_tools() return [ComponentManifest.model_validate(tool) for tool in list_tools_data] async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]: + if not self.is_enable_plugin: + return {'error': 'Tool not found: plugin system is disabled'} + return await self.handler.call_tool(tool_name, parameters) async def list_commands(self) -> list[ComponentManifest]: + if not self.is_enable_plugin: + return [] + list_commands_data = await self.handler.list_commands() return [ComponentManifest.model_validate(command) for command in list_commands_data] @@ -233,6 +246,9 @@ class PluginRuntimeConnector: async def execute_command( self, command_ctx: command_context.ExecuteContext ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: + if not self.is_enable_plugin: + yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command)) + gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True)) async for ret in gen: From 3951cbf2663ca989410615c79def28412597c5d9 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 8 Oct 2025 14:36:48 +0800 Subject: [PATCH 053/144] chore: bump version 4.3.7 --- pkg/utils/constants.py | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index d8fd6297..1bf67aa5 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.7b1' +semantic_version = 'v4.3.7' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 00927300..0bfd56c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.7b1" +version = "4.3.7" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" @@ -62,7 +62,7 @@ dependencies = [ "langchain>=0.2.0", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.1.4b2", + "langbot-plugin==0.1.4", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", From d9e6198992b0b6b024578baf7169d4f8a79916bb Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 14:48:21 +0800 Subject: [PATCH 054/144] fix: bad Plain component init in wechatpad (#1712) --- pkg/platform/sources/wechatpad.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/pkg/platform/sources/wechatpad.py b/pkg/platform/sources/wechatpad.py index e35bad63..26d735ae 100644 --- a/pkg/platform/sources/wechatpad.py +++ b/pkg/platform/sources/wechatpad.py @@ -139,7 +139,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert pattern = r'@\S{1,20}' content_no_preifx = re.sub(pattern, '', content_no_preifx) - return platform_message.MessageChain([platform_message.Plain(content_no_preifx)]) + return platform_message.MessageChain([platform_message.Plain(text=content_no_preifx)]) async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理图像消息 (msg_type=3)""" @@ -265,7 +265,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert # 文本消息 try: if '' not in quote_data: - quote_data_message_list.append(platform_message.Plain(quote_data)) + quote_data_message_list.append(platform_message.Plain(text=quote_data)) else: # 引用消息展开 quote_data_xml = ET.fromstring(quote_data) @@ -280,7 +280,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert quote_data_message_list.extend(await self._handler_compound(None, quote_data)) except Exception as e: self.logger.error(f'处理引用消息异常 expcetion:{e}') - quote_data_message_list.append(platform_message.Plain(quote_data)) + quote_data_message_list.append(platform_message.Plain(text=quote_data)) message_list.append( platform_message.Quote( sender_id=sender_id, @@ -290,7 +290,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert if len(user_data) > 0: pattern = r'@\S{1,20}' user_data = re.sub(pattern, '', user_data) - message_list.append(platform_message.Plain(user_data)) + message_list.append(platform_message.Plain(text=user_data)) return platform_message.MessageChain(message_list) @@ -543,7 +543,6 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) ] = {} def __init__(self, config: dict, logger: EventLogger): - quart_app = quart.Quart(__name__) message_converter = WeChatPadMessageConverter(config, logger) @@ -551,15 +550,14 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) bot = WeChatPadClient(config['wechatpad_url'], config['token']) super().__init__( config=config, - logger = logger, - quart_app = quart_app, - message_converter =message_converter, - event_converter = event_converter, + logger=logger, + quart_app=quart_app, + message_converter=message_converter, + event_converter=event_converter, listeners={}, - bot_account_id ='', - name="WeChatPad", + bot_account_id='', + name='WeChatPad', bot=bot, - ) async def ws_message(self, data): From 431d515c266c7eca5218ba500e1d7b0622a89878 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 16:34:01 +0800 Subject: [PATCH 055/144] perf: allow not set llm model (#1703) --- pkg/pipeline/preproc/preproc.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/pkg/pipeline/preproc/preproc.py b/pkg/pipeline/preproc/preproc.py index ff6ffd6f..8e8e1755 100644 --- a/pkg/pipeline/preproc/preproc.py +++ b/pkg/pipeline/preproc/preproc.py @@ -35,11 +35,17 @@ class PreProcessor(stage.PipelineStage): session = await self.ap.sess_mgr.get_session(query) # When not local-agent, llm_model is None - llm_model = ( - await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model']) - if selected_runner == 'local-agent' - else None - ) + try: + llm_model = ( + await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model']) + if selected_runner == 'local-agent' + else None + ) + except ValueError: + self.ap.logger.warning( + f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured' + ) + llm_model = None conversation = await self.ap.sess_mgr.get_conversation( query, @@ -54,7 +60,7 @@ class PreProcessor(stage.PipelineStage): query.prompt = conversation.prompt.copy() query.messages = conversation.messages.copy() - if selected_runner == 'local-agent': + if selected_runner == 'local-agent' and llm_model: query.use_funcs = [] query.use_llm_model_uuid = llm_model.model_entity.uuid @@ -72,7 +78,11 @@ class PreProcessor(stage.PipelineStage): # Check if this model supports vision, if not, remove all images # TODO this checking should be performed in runner, and in this stage, the image should be reserved - if selected_runner == 'local-agent' and not llm_model.model_entity.abilities.__contains__('vision'): + if ( + selected_runner == 'local-agent' + and llm_model + and not llm_model.model_entity.abilities.__contains__('vision') + ): for msg in query.messages: if isinstance(msg.content, list): for me in msg.content: @@ -89,7 +99,9 @@ class PreProcessor(stage.PipelineStage): content_list.append(provider_message.ContentElement.from_text(me.text)) plain_text += me.text elif isinstance(me, platform_message.Image): - if selected_runner != 'local-agent' or llm_model.model_entity.abilities.__contains__('vision'): + if selected_runner != 'local-agent' or ( + llm_model and llm_model.model_entity.abilities.__contains__('vision') + ): if me.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) elif isinstance(me, platform_message.File): @@ -100,7 +112,9 @@ class PreProcessor(stage.PipelineStage): if isinstance(msg, platform_message.Plain): content_list.append(provider_message.ContentElement.from_text(msg.text)) elif isinstance(msg, platform_message.Image): - if selected_runner != 'local-agent' or llm_model.model_entity.abilities.__contains__('vision'): + if selected_runner != 'local-agent' or ( + llm_model and llm_model.model_entity.abilities.__contains__('vision') + ): if msg.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) From f3295b0fddde0f57fd5e3dea431d7dbdb350bff0 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 17:55:49 +0800 Subject: [PATCH 056/144] perf: output pipeline error in en --- pkg/pipeline/pipelinemgr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/pipeline/pipelinemgr.py b/pkg/pipeline/pipelinemgr.py index c19d5b6f..ab663293 100644 --- a/pkg/pipeline/pipelinemgr.py +++ b/pkg/pipeline/pipelinemgr.py @@ -213,7 +213,7 @@ class RuntimePipeline: await self._execute_from_stage(0, query) except Exception as e: inst_name = query.current_stage_name if query.current_stage_name else 'unknown' - self.ap.logger.error(f'处理请求时出错 query_id={query.query_id} stage={inst_name} : {e}') + self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}') self.ap.logger.error(f'Traceback: {traceback.format_exc()}') finally: self.ap.logger.debug(f'Query {query.query_id} processed') From 2c5a0a00ba2e907659529bc296f3779b37284751 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 22:37:39 +0800 Subject: [PATCH 057/144] fix: datetime serialization error in emit_event (#1713) --- pkg/plugin/connector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index c98bcc2a..96530de2 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -215,7 +215,7 @@ class PluginRuntimeConnector: if not self.is_enable_plugin: return event_ctx - event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=True)) + event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=False)) event_ctx = context.EventContext.model_validate(event_ctx_result['event_context']) From 4df372052da6dc98321f97689891b0feae9c0532 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 10 Oct 2025 22:50:57 +0800 Subject: [PATCH 058/144] chore: bump version 4.3.8 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 1bf67aa5..f395cbcb 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.7' +semantic_version = 'v4.3.8' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 0bfd56c9..26e69706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.7" +version = "4.3.8" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 5e2f677d0bc25c693f38aaeb717a919d9d799b89 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 12 Oct 2025 19:57:42 +0800 Subject: [PATCH 059/144] perf: add component list in plugin detail dialog --- .../plugin-installed/PluginCardVO.ts | 0 .../plugin-installed/PluginComponentList.tsx | 75 +++++++++++++++++++ .../PluginInstalledComponent.tsx | 6 +- .../plugin-card/PluginCardComponent.tsx | 67 +++-------------- .../plugin-form/PluginForm.tsx | 12 +++ .../plugin-market/PluginMarketComponent.tsx | 0 .../PluginDetailDialog.tsx | 0 .../PluginMarketCardComponent.tsx | 0 .../plugin-market-card/PluginMarketCardVO.ts | 0 .../plugin-sort/PluginSortDialog.tsx | 0 web/src/app/home/plugins/page.tsx | 5 +- web/src/i18n/locales/en-US.ts | 8 +- web/src/i18n/locales/ja-JP.ts | 8 +- web/src/i18n/locales/zh-Hans.ts | 8 +- web/src/i18n/locales/zh-Hant.ts | 8 +- 15 files changed, 122 insertions(+), 75 deletions(-) rename web/src/app/home/plugins/{ => components}/plugin-installed/PluginCardVO.ts (100%) create mode 100644 web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx rename web/src/app/home/plugins/{ => components}/plugin-installed/PluginInstalledComponent.tsx (97%) rename web/src/app/home/plugins/{ => components}/plugin-installed/plugin-card/PluginCardComponent.tsx (82%) rename web/src/app/home/plugins/{ => components}/plugin-installed/plugin-form/PluginForm.tsx (90%) rename web/src/app/home/plugins/{ => components}/plugin-market/PluginMarketComponent.tsx (100%) rename web/src/app/home/plugins/{ => components}/plugin-market/plugin-detail-dialog/PluginDetailDialog.tsx (100%) rename web/src/app/home/plugins/{ => components}/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx (100%) rename web/src/app/home/plugins/{ => components}/plugin-market/plugin-market-card/PluginMarketCardVO.ts (100%) rename web/src/app/home/plugins/{ => components}/plugin-sort/PluginSortDialog.tsx (100%) diff --git a/web/src/app/home/plugins/plugin-installed/PluginCardVO.ts b/web/src/app/home/plugins/components/plugin-installed/PluginCardVO.ts similarity index 100% rename from web/src/app/home/plugins/plugin-installed/PluginCardVO.ts rename to web/src/app/home/plugins/components/plugin-installed/PluginCardVO.ts diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx new file mode 100644 index 00000000..603599f8 --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx @@ -0,0 +1,75 @@ +import { PluginComponent } from '@/app/infra/entities/plugin'; +import { TFunction } from 'i18next'; +import { Wrench, AudioWaveform, Hash } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; + +export default function PluginComponentList({ + components, + showComponentName, + showTitle, + useBadge, + t, +}: { + components: PluginComponent[]; + showComponentName: boolean; + showTitle: boolean; + useBadge: boolean; + t: TFunction; +}) { + const componentKindCount: Record = {}; + + for (const component of components) { + const kind = component.manifest.manifest.kind; + if (componentKindCount[kind]) { + componentKindCount[kind]++; + } else { + componentKindCount[kind] = 1; + } + } + + const kindIconMap: Record = { + Tool: , + EventListener: , + Command: , + }; + + const componentKindList = Object.keys(componentKindCount); + + return ( + <> + {showTitle &&
{t('plugins.componentsList')}
} + {componentKindList.length > 0 && ( + <> + {componentKindList.map((kind) => { + return ( + <> + {useBadge && ( + + {kindIconMap[kind]} + {showComponentName && + t('plugins.componentName.' + kind) + ' '} + {componentKindCount[kind]} + + )} + + {!useBadge && ( +
+ {kindIconMap[kind]} + {showComponentName && + t('plugins.componentName.' + kind) + ' '} + {componentKindCount[kind]} +
+ )} + + ); + })} + + )} + + {componentKindList.length === 0 &&
{t('plugins.noComponents')}
} + + ); +} diff --git a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx similarity index 97% rename from web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx rename to web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index 3ef8b748..315e9960 100644 --- a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO'; -import PluginCardComponent from '@/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent'; -import PluginForm from '@/app/home/plugins/plugin-installed/plugin-form/PluginForm'; +import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO'; +import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent'; +import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm'; import styles from '@/app/home/plugins/plugins.module.css'; import { httpClient } from '@/app/infra/http/HttpClient'; import { diff --git a/web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx similarity index 82% rename from web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx rename to web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx index a3e7596d..457ebdc3 100644 --- a/web/src/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx @@ -1,21 +1,10 @@ -import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO'; +import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO'; import { useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { useTranslation } from 'react-i18next'; -import { TFunction } from 'i18next'; -import { - AudioWaveform, - Wrench, - Hash, - BugIcon, - ExternalLink, - Ellipsis, - Trash, - ArrowUp, -} from 'lucide-react'; +import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react'; import { getCloudServiceClientSync } from '@/app/infra/http'; import { httpClient } from '@/app/infra/http/HttpClient'; -import { PluginComponent } from '@/app/infra/entities/plugin'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -23,49 +12,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; - -function getComponentList(components: PluginComponent[], t: TFunction) { - const componentKindCount: Record = {}; - - for (const component of components) { - const kind = component.manifest.manifest.kind; - if (componentKindCount[kind]) { - componentKindCount[kind]++; - } else { - componentKindCount[kind] = 1; - } - } - - const kindIconMap: Record = { - Tool: , - EventListener: , - Command: , - }; - - const componentKindList = Object.keys(componentKindCount); - - return ( - <> -
{t('plugins.componentsList')}
- {componentKindList.length > 0 && ( - <> - {componentKindList.map((kind) => { - return ( -
- {kindIconMap[kind]} {componentKindCount[kind]} -
- ); - })} - - )} - - {componentKindList.length === 0 &&
{t('plugins.noComponents')}
} - - ); -} +import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; export default function PluginCardComponent({ cardVO, @@ -180,7 +127,13 @@ export default function PluginCardComponent({
- {getComponentList(cardVO.components, t)} +
diff --git a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx similarity index 90% rename from web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx rename to web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx index 09a79d2f..2a658f5f 100644 --- a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; +import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; export default function PluginForm({ pluginAuthor, @@ -78,6 +79,17 @@ export default function PluginForm({ }, )}
+ +
+ +
+ {pluginInfo.manifest.manifest.spec.config.length > 0 && ( Date: Sun, 12 Oct 2025 21:11:30 +0800 Subject: [PATCH 060/144] perf: store pipeline sort method --- web/src/app/home/pipelines/page.tsx | 23 ++++++++++++++++++++++- web/src/i18n/locales/en-US.ts | 1 + web/src/i18n/locales/ja-JP.ts | 1 + web/src/i18n/locales/zh-Hans.ts | 1 + web/src/i18n/locales/zh-Hant.ts | 1 + 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/web/src/app/home/pipelines/page.tsx b/web/src/app/home/pipelines/page.tsx index c7801a33..c0b3930a 100644 --- a/web/src/app/home/pipelines/page.tsx +++ b/web/src/app/home/pipelines/page.tsx @@ -29,7 +29,17 @@ export default function PluginConfigPage() { const [sortOrderValue, setSortOrderValue] = useState('DESC'); useEffect(() => { - getPipelines(); + // Load sort preference from localStorage + const savedSortBy = localStorage.getItem('pipeline_sort_by'); + const savedSortOrder = localStorage.getItem('pipeline_sort_order'); + + if (savedSortBy && savedSortOrder) { + setSortByValue(savedSortBy); + setSortOrderValue(savedSortOrder); + getPipelines(savedSortBy, savedSortOrder); + } else { + getPipelines(); + } }, []); function getPipelines( @@ -91,6 +101,11 @@ export default function PluginConfigPage() { const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim()); setSortByValue(newSortBy); setSortOrderValue(newSortOrder); + + // Save sort preference to localStorage + localStorage.setItem('pipeline_sort_by', newSortBy); + localStorage.setItem('pipeline_sort_order', newSortOrder); + getPipelines(newSortBy, newSortOrder); } @@ -135,6 +150,12 @@ export default function PluginConfigPage() { > {t('pipelines.newestCreated')} + + {t('pipelines.earliestCreated')} + Date: Fri, 17 Oct 2025 18:13:03 +0800 Subject: [PATCH 061/144] Feat/coze runner (#1714) * feat:add coze api client and coze runner and coze config * del print * fix:Change the default setting of the plugin system to true * fix:del multimodal-support config, default multimodal-support,and in cozeapi.py Obtain timeout and auto-save-history config * chore: add comment for coze.com --------- Co-authored-by: Junyan Qin --- libs/coze_server_api/__init__.py | 0 libs/coze_server_api/client.py | 192 +++++++++++++++++ pkg/provider/runners/cozeapi.py | 312 ++++++++++++++++++++++++++++ templates/config.yaml | 2 +- templates/metadata/pipeline/ai.yaml | 59 +++++- 5 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 libs/coze_server_api/__init__.py create mode 100644 libs/coze_server_api/client.py create mode 100644 pkg/provider/runners/cozeapi.py diff --git a/libs/coze_server_api/__init__.py b/libs/coze_server_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/coze_server_api/client.py b/libs/coze_server_api/client.py new file mode 100644 index 00000000..67f53736 --- /dev/null +++ b/libs/coze_server_api/client.py @@ -0,0 +1,192 @@ +import json +import asyncio +import aiohttp +import io +from typing import Dict, List, Any, AsyncGenerator +import os +from pathlib import Path + + + + +class AsyncCozeAPIClient: + def __init__(self, api_key: str, api_base: str = "https://api.coze.cn"): + self.api_key = api_key + self.api_base = api_base + self.session = None + + async def __aenter__(self): + """支持异步上下文管理器""" + await self.coze_session() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """退出时自动关闭会话""" + await self.close() + + + + async def coze_session(self): + """确保HTTP session存在""" + if self.session is None: + connector = aiohttp.TCPConnector( + ssl=False if self.api_base.startswith("http://") else True, + limit=100, + limit_per_host=30, + keepalive_timeout=30, + enable_cleanup_closed=True, + ) + timeout = aiohttp.ClientTimeout( + total=120, # 默认超时时间 + connect=30, + sock_read=120, + ) + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "text/event-stream", + } + self.session = aiohttp.ClientSession( + headers=headers, timeout=timeout, connector=connector + ) + return self.session + + async def close(self): + """显式关闭会话""" + if self.session and not self.session.closed: + await self.session.close() + self.session = None + + async def upload( + self, + file, + ) -> str: + # 处理 Path 对象 + if isinstance(file, Path): + if not file.exists(): + raise ValueError(f"File not found: {file}") + with open(file, "rb") as f: + file = f.read() + + # 处理文件路径字符串 + elif isinstance(file, str): + if not os.path.isfile(file): + raise ValueError(f"File not found: {file}") + with open(file, "rb") as f: + file = f.read() + + # 处理文件对象 + elif hasattr(file, 'read'): + file = file.read() + + session = await self.coze_session() + url = f"{self.api_base}/v1/files/upload" + + try: + file_io = io.BytesIO(file) + async with session.post( + url, + data={ + "file": file_io, + }, + timeout=aiohttp.ClientTimeout(total=60), + ) as response: + if response.status == 401: + raise Exception("Coze API 认证失败,请检查 API Key 是否正确") + + response_text = await response.text() + + + if response.status != 200: + raise Exception( + f"文件上传失败,状态码: {response.status}, 响应: {response_text}" + ) + try: + result = await response.json() + except json.JSONDecodeError: + raise Exception(f"文件上传响应解析失败: {response_text}") + + if result.get("code") != 0: + raise Exception(f"文件上传失败: {result.get('msg', '未知错误')}") + + file_id = result["data"]["id"] + return file_id + + except asyncio.TimeoutError: + raise Exception("文件上传超时") + except Exception as e: + raise Exception(f"文件上传失败: {str(e)}") + + + async def chat_messages( + self, + bot_id: str, + user_id: str, + additional_messages: List[Dict] | None = None, + conversation_id: str | None = None, + auto_save_history: bool = True, + stream: bool = True, + timeout: float = 120, + ) -> AsyncGenerator[Dict[str, Any], None]: + """发送聊天消息并返回流式响应 + + Args: + bot_id: Bot ID + user_id: 用户ID + additional_messages: 额外消息列表 + conversation_id: 会话ID + auto_save_history: 是否自动保存历史 + stream: 是否流式响应 + timeout: 超时时间 + """ + session = await self.coze_session() + url = f"{self.api_base}/v3/chat" + + payload = { + "bot_id": bot_id, + "user_id": user_id, + "stream": stream, + "auto_save_history": auto_save_history, + } + + if additional_messages: + payload["additional_messages"] = additional_messages + + params = {} + if conversation_id: + params["conversation_id"] = conversation_id + + + try: + async with session.post( + url, + json=payload, + params=params, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as response: + if response.status == 401: + raise Exception("Coze API 认证失败,请检查 API Key 是否正确") + + if response.status != 200: + raise Exception(f"Coze API 流式请求失败,状态码: {response.status}") + + + async for chunk in response.content: + chunk = chunk.decode("utf-8") + if chunk != '\n': + if chunk.startswith("event:"): + chunk_type = chunk.replace("event:", "", 1).strip() + elif chunk.startswith("data:"): + chunk_data = chunk.replace("data:", "", 1).strip() + else: + yield {"event": chunk_type, "data": json.loads(chunk_data)} + + except asyncio.TimeoutError: + raise Exception(f"Coze API 流式请求超时 ({timeout}秒)") + except Exception as e: + raise Exception(f"Coze API 流式请求失败: {str(e)}") + + + + + + diff --git a/pkg/provider/runners/cozeapi.py b/pkg/provider/runners/cozeapi.py new file mode 100644 index 00000000..6d4f02a1 --- /dev/null +++ b/pkg/provider/runners/cozeapi.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import typing +import json +import uuid +import base64 + +from .. import runner +from ...core import app +import langbot_plugin.api.entities.builtin.provider.message as provider_message +from ...utils import image +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +from libs.coze_server_api.client import AsyncCozeAPIClient + +@runner.runner_class('coze-api') +class CozeAPIRunner(runner.RequestRunner): + """Coze API 对话请求器""" + + def __init__(self, ap: app.Application, pipeline_config: dict): + self.pipeline_config = pipeline_config + self.ap = ap + self.agent_token = pipeline_config["ai"]['coze-api']['api-key'] + self.bot_id = pipeline_config["ai"]['coze-api'].get('bot-id') + self.chat_timeout = pipeline_config["ai"]['coze-api'].get('timeout') + self.auto_save_history = pipeline_config["ai"]['coze-api'].get('auto_save_history') + self.api_base = pipeline_config["ai"]['coze-api'].get('api-base') + + self.coze = AsyncCozeAPIClient( + self.agent_token, + self.api_base + ) + + def _process_thinking_content( + self, + content: str, + ) -> tuple[str, str]: + """处理思维链内容 + + Args: + content: 原始内容 + Returns: + (处理后的内容, 提取的思维链内容) + """ + remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) + thinking_content = '' + # 从 content 中提取 标签内容 + if content and '' in content and '' in content: + import re + + think_pattern = r'(.*?)' + think_matches = re.findall(think_pattern, content, re.DOTALL) + if think_matches: + thinking_content = '\n'.join(think_matches) + # 移除 content 中的 标签 + content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip() + + # 根据 remove_think 参数决定是否保留思维链 + if remove_think: + return content, '' + else: + # 如果有思维链内容,将其以 格式添加到 content 开头 + if thinking_content: + content = f'\n{thinking_content}\n\n{content}'.strip() + return content, thinking_content + + async def _preprocess_user_message(self, query: pipeline_query.Query) -> list[dict]: + """预处理用户消息,转换为Coze消息格式 + + Returns: + list[dict]: Coze消息列表 + """ + messages = [] + + if isinstance(query.user_message.content, list): + # 多模态消息处理 + content_parts = [] + + for ce in query.user_message.content: + if ce.type == 'text': + content_parts.append({"type": "text", "text": ce.text}) + elif ce.type == 'image_base64': + image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) + file_bytes = base64.b64decode(image_b64) + file_id = await self._get_file_id(file_bytes) + content_parts.append({"type": "image", "file_id": file_id}) + elif ce.type == 'file': + # 处理文件,上传到Coze + file_id = await self._get_file_id(ce.file) + content_parts.append({"type": "file", "file_id": file_id}) + + # 创建多模态消息 + if content_parts: + messages.append({ + "role": "user", + "content": json.dumps(content_parts), + "content_type": "object_string", + "meta_data": None + }) + + elif isinstance(query.user_message.content, str): + # 纯文本消息 + messages.append({ + "role": "user", + "content": query.user_message.content, + "content_type": "text", + "meta_data": None + }) + + return messages + + async def _get_file_id(self, file) -> str: + """上传文件到Coze服务 + Args: + file: 文件 + Returns: + str: 文件ID + """ + file_id = await self.coze.upload(file=file) + return file_id + + async def _chat_messages( + self, query: pipeline_query.Query + ) -> typing.AsyncGenerator[provider_message.Message, None]: + """调用聊天助手(非流式) + + 注意:由于cozepy没有提供非流式API,这里使用流式API并在结束后一次性返回完整内容 + """ + user_id = f'{query.launcher_id}_{query.sender_id}' + + # 预处理用户消息 + additional_messages = await self._preprocess_user_message(query) + + # 获取会话ID + conversation_id = None + + # 收集完整内容 + full_content = '' + full_reasoning = '' + + try: + # 调用Coze API流式接口 + async for chunk in self.coze.chat_messages( + bot_id=self.bot_id, + user_id=user_id, + additional_messages=additional_messages, + conversation_id=conversation_id, + timeout=self.chat_timeout, + auto_save_history=self.auto_save_history, + stream=True + ): + self.ap.logger.debug(f'coze-chat-stream: {chunk}') + + event_type = chunk.get('event') + data = chunk.get('data', {}) + + if event_type == 'conversation.message.delta': + # 收集内容 + if 'content' in data: + full_content += data.get('content', '') + + # 收集推理内容(如果有) + if 'reasoning_content' in data: + full_reasoning += data.get('reasoning_content', '') + + elif event_type == 'done': + # 保存会话ID + if 'conversation_id' in data: + conversation_id = data.get('conversation_id') + + elif event_type == 'error': + # 处理错误 + error_msg = f"Coze API错误: {data.get('message', '未知错误')}" + yield provider_message.Message( + role='assistant', + content=error_msg, + ) + return + + # 处理思维链内容 + content, thinking_content = self._process_thinking_content(full_content) + if full_reasoning: + remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) + if not remove_think: + content = f'\n{full_reasoning}\n\n{content}'.strip() + + # 一次性返回完整内容 + yield provider_message.Message( + role='assistant', + content=content, + ) + + # 保存会话ID + if conversation_id and query.session.using_conversation: + query.session.using_conversation.uuid = conversation_id + + except Exception as e: + self.ap.logger.error(f'Coze API错误: {str(e)}') + yield provider_message.Message( + role='assistant', + content=f'Coze API调用失败: {str(e)}', + ) + + + async def _chat_messages_chunk( + self, query: pipeline_query.Query + ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: + """调用聊天助手(流式)""" + user_id = f'{query.launcher_id}_{query.sender_id}' + + # 预处理用户消息 + additional_messages = await self._preprocess_user_message(query) + + # 获取会话ID + conversation_id = None + + start_reasoning = False + stop_reasoning = False + message_idx = 1 + is_final = False + full_content = '' + remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) + + + + try: + # 调用Coze API流式接口 + async for chunk in self.coze.chat_messages( + bot_id=self.bot_id, + user_id=user_id, + additional_messages=additional_messages, + conversation_id=conversation_id, + timeout=self.chat_timeout, + auto_save_history=self.auto_save_history, + stream=True + ): + self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}') + + event_type = chunk.get('event') + data = chunk.get('data', {}) + content = "" + + if event_type == 'conversation.message.delta': + message_idx += 1 + # 处理内容增量 + if "reasoning_content" in data and not remove_think: + + reasoning_content = data.get('reasoning_content', '') + if reasoning_content and not start_reasoning: + content = f"\n" + start_reasoning = True + content += reasoning_content + + if 'content' in data: + if data.get('content', ''): + content += data.get('content', '') + if not stop_reasoning and start_reasoning: + content = f"\n{content}" + stop_reasoning = True + + + elif event_type == 'done': + # 保存会话ID + if 'conversation_id' in data: + conversation_id = data.get('conversation_id') + if query.session.using_conversation: + query.session.using_conversation.uuid = conversation_id + is_final = True + + + elif event_type == 'error': + # 处理错误 + error_msg = f"Coze API错误: {data.get('message', '未知错误')}" + yield provider_message.MessageChunk( + role='assistant', + content=error_msg, + finish_reason='error' + ) + return + full_content += content + if message_idx % 8 == 0 or is_final: + if full_content: + yield provider_message.MessageChunk( + role='assistant', + content=full_content, + is_final=is_final + ) + + except Exception as e: + self.ap.logger.error(f'Coze API流式调用错误: {str(e)}') + yield provider_message.MessageChunk( + role='assistant', + content=f'Coze API流式调用失败: {str(e)}', + finish_reason='error' + ) + + + async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: + """运行""" + msg_seq = 0 + if await query.adapter.is_stream_output_supported(): + async for msg in self._chat_messages_chunk(query): + if isinstance(msg, provider_message.MessageChunk): + msg_seq += 1 + msg.msg_sequence = msg_seq + yield msg + else: + async for msg in self._chat_messages(query): + yield msg + + + + diff --git a/templates/config.yaml b/templates/config.yaml index 8ea0d189..366ee782 100644 --- a/templates/config.yaml +++ b/templates/config.yaml @@ -39,4 +39,4 @@ plugin: enable: true runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' enable_marketplace: true - cloud_service_url: 'https://space.langbot.app' \ No newline at end of file + cloud_service_url: 'https://space.langbot.app' diff --git a/templates/metadata/pipeline/ai.yaml b/templates/metadata/pipeline/ai.yaml index 2b69806c..e4d16a95 100644 --- a/templates/metadata/pipeline/ai.yaml +++ b/templates/metadata/pipeline/ai.yaml @@ -43,6 +43,10 @@ stages: label: en_US: Langflow API zh_Hans: Langflow API + - name: coze-api + label: + en_US: Coze API + zh_Hans: 扣子 API - name: local-agent label: en_US: Local Agent @@ -380,4 +384,57 @@ stages: zh_Hans: 可选的流程调整参数 type: json required: false - default: '{}' \ No newline at end of file + default: '{}' + - name: coze-api + label: + en_US: coze API + zh_Hans: 扣子 API + description: + en_US: Configure the Coze API of the pipeline + zh_Hans: 配置Coze API + config: + - name: api-key + label: + en_US: API Key + zh_Hans: API 密钥 + description: + en_US: The API key for the Coze server + zh_Hans: Coze服务器的 API 密钥 + type: string + required: true + - name: bot-id + label: + en_US: Bot ID + zh_Hans: 机器人 ID + description: + en_US: The ID of the bot to run + zh_Hans: 要运行的机器人 ID + type: string + required: true + - name: api-base + label: + en_US: API Base URL + zh_Hans: API 基础 URL + description: + en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com). + zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com) + type: string + default: "https://api.coze.cn" + - name: auto-save-history + label: + en_US: Auto Save History + zh_Hans: 自动保存历史 + description: + en_US: Whether to automatically save conversation history + zh_Hans: 是否自动保存对话历史 + type: boolean + default: true + - name: timeout + label: + en_US: Request Timeout + zh_Hans: 请求超时 + description: + en_US: Timeout in seconds for API requests + zh_Hans: API 请求超时时间(秒) + type: number + default: 120 \ No newline at end of file From 0d59c04151a8d4cb8902096a7a6a77151e122918 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 22 Oct 2025 18:52:45 +0800 Subject: [PATCH 062/144] chore: bump version 4.3.9 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index f395cbcb..aa557005 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.8' +semantic_version = 'v4.3.9' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index 26e69706..c0200bd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.8" +version = "4.3.9" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 2dd2abedde447a634138cd3fe0ab73d2ddaabd45 Mon Sep 17 00:00:00 2001 From: Alfons Date: Tue, 28 Oct 2025 18:12:35 +0800 Subject: [PATCH 063/144] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E6=99=BA=E8=83=BD=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E4=BA=BA=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 WecomBotClient,支持流式会话管理和队列机制 - 新增 StreamSession 和 StreamSessionManager 类管理流式上下文 - 实现 reply_message_chunk 接口支持流式输出 - 优化消息处理流程,支持异步流式响应 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- libs/wecom_ai_bot_api/api.py | 585 +++++++++++++++++++++++-------- pkg/platform/sources/wecombot.py | 44 +++ 2 files changed, 477 insertions(+), 152 deletions(-) diff --git a/libs/wecom_ai_bot_api/api.py b/libs/wecom_ai_bot_api/api.py index 6b5dc573..41d379a6 100644 --- a/libs/wecom_ai_bot_api/api.py +++ b/libs/wecom_ai_bot_api/api.py @@ -1,189 +1,445 @@ +import asyncio +import base64 import json import time +import traceback import uuid import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from typing import Any, Callable, Optional from urllib.parse import unquote -import hashlib -import traceback import httpx -from libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt -from quart import Quart, request, Response, jsonify -import langbot_plugin.api.entities.builtin.platform.message as platform_message -import asyncio -from libs.wecom_ai_bot_api import wecombotevent -from typing import Callable -import base64 from Crypto.Cipher import AES +from quart import Quart, request, Response, jsonify + +from libs.wecom_ai_bot_api import wecombotevent +from libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt from pkg.platform.logger import EventLogger +@dataclass +class StreamChunk: + """描述单次推送给企业微信的流式片段。""" + + # 需要返回给企业微信的文本内容 + content: str + + # 标记是否为最终片段,对应企业微信协议里的 finish 字段 + is_final: bool = False + + # 预留额外元信息,未来支持多模态扩展时可使用 + meta: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class StreamSession: + """维护一次企业微信流式会话的上下文。""" + + # 企业微信要求的 stream_id,用于标识后续刷新请求 + stream_id: str + + # 原始消息的 msgid,便于与流水线消息对应 + msg_id: str + + # 群聊会话标识(单聊时为空) + chat_id: Optional[str] + + # 触发消息的发送者 + user_id: Optional[str] + + # 会话创建时间 + created_at: float = field(default_factory=time.time) + + # 最近一次被访问的时间,cleanup 依据该值判断过期 + last_access: float = field(default_factory=time.time) + + # 将流水线增量结果缓存到队列,刷新请求逐条消费 + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + + # 是否已经完成(收到最终片段) + finished: bool = False + + # 缓存最近一次片段,处理重试或超时兜底 + last_chunk: Optional[StreamChunk] = None + + +class StreamSessionManager: + """管理 stream 会话的生命周期,并负责队列的生产消费。""" + + def __init__(self, logger: EventLogger, ttl: int = 60) -> None: + self.logger = logger + + self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup + self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射 + self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话 + + def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]: + if not msg_id: + return None + return self._msg_index.get(msg_id) + + def get_session(self, stream_id: str) -> Optional[StreamSession]: + return self._sessions.get(stream_id) + + def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]: + """根据企业微信回调创建或获取会话。 + + Args: + msg_json: 企业微信解密后的回调 JSON。 + + Returns: + Tuple[StreamSession, bool]: `StreamSession` 为会话实例,`bool` 指示是否为新建会话。 + + Example: + 在首次回调中调用,得到 `is_new=True` 后再触发流水线。 + """ + msg_id = msg_json.get('msgid', '') + if msg_id and msg_id in self._msg_index: + stream_id = self._msg_index[msg_id] + session = self._sessions.get(stream_id) + if session: + session.last_access = time.time() + return session, False + + stream_id = str(uuid.uuid4()) + session = StreamSession( + stream_id=stream_id, + msg_id=msg_id, + chat_id=msg_json.get('chatid'), + user_id=msg_json.get('from', {}).get('userid'), + ) + + if msg_id: + self._msg_index[msg_id] = stream_id + self._sessions[stream_id] = session + return session, True + + async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: + """向 stream 队列写入新的增量片段。 + + Args: + stream_id: 企业微信分配的流式会话 ID。 + chunk: 待发送的增量片段。 + + Returns: + bool: 当流式队列存在并成功入队时返回 True。 + + Example: + 在收到模型增量后调用 `await manager.publish('sid', StreamChunk('hello'))`。 + """ + session = self._sessions.get(stream_id) + if not session: + return False + + session.last_access = time.time() + session.last_chunk = chunk + + try: + session.queue.put_nowait(chunk) + except asyncio.QueueFull: + # 默认无界队列,此处兜底防御 + await session.queue.put(chunk) + + if chunk.is_final: + session.finished = True + + return True + + async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]: + """从队列中取出一个片段,若超时返回 None。 + + Args: + stream_id: 企业微信流式会话 ID。 + timeout: 取片段的最长等待时间(秒)。 + + Returns: + Optional[StreamChunk]: 成功时返回片段,超时或会话不存在时返回 None。 + + Example: + 企业微信刷新到达时调用,若队列有数据则立即返回 `StreamChunk`。 + """ + session = self._sessions.get(stream_id) + if not session: + return None + + session.last_access = time.time() + + try: + chunk = await asyncio.wait_for(session.queue.get(), timeout) + session.last_access = time.time() + if chunk.is_final: + session.finished = True + return chunk + except asyncio.TimeoutError: + if session.finished and session.last_chunk: + return session.last_chunk + return None + + def mark_finished(self, stream_id: str) -> None: + session = self._sessions.get(stream_id) + if session: + session.finished = True + session.last_access = time.time() + + def cleanup(self) -> None: + """定期清理过期会话,防止队列与映射无上限累积。""" + now = time.time() + expired: list[str] = [] + for stream_id, session in self._sessions.items(): + if now - session.last_access > self.ttl: + expired.append(stream_id) + + for stream_id in expired: + session = self._sessions.pop(stream_id, None) + if not session: + continue + msg_id = session.msg_id + if msg_id and self._msg_index.get(msg_id) == stream_id: + self._msg_index.pop(msg_id, None) + class WecomBotClient: - def __init__(self,Token:str,EnCodingAESKey:str,Corpid:str,logger:EventLogger): - self.Token=Token - self.EnCodingAESKey=EnCodingAESKey - self.Corpid=Corpid + def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger): + """企业微信智能机器人客户端。 + + Args: + Token: 企业微信回调验证使用的 token。 + EnCodingAESKey: 企业微信消息加解密密钥。 + Corpid: 企业 ID。 + logger: 日志记录器。 + + Example: + >>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger) + """ + + self.Token = Token + self.EnCodingAESKey = EnCodingAESKey + self.Corpid = Corpid self.ReceiveId = '' self.app = Quart(__name__) self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, - methods=['POST','GET'] + methods=['POST', 'GET'] ) self._message_handlers = { 'example': [], } - self.user_stream_map = {} self.logger = logger - self.generated_content = {} - self.msg_id_map = {} + self.generated_content: dict[str, str] = {} + self.msg_id_map: dict[str, int] = {} + self.stream_sessions = StreamSessionManager(logger=logger) + self.stream_poll_timeout = 0.5 - async def sha1_signature(token: str, timestamp: str, nonce: str, encrypt: str) -> str: - raw = "".join(sorted([token, timestamp, nonce, encrypt])) - return hashlib.sha1(raw.encode("utf-8")).hexdigest() - - async def handle_callback_request(self): + @staticmethod + def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]: + """按照企业微信协议拼装返回报文。 + + Args: + stream_id: 企业微信会话 ID。 + content: 推送的文本内容。 + finish: 是否为最终片段。 + + Returns: + dict[str, Any]: 可直接加密返回的 payload。 + + Example: + 组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。 + """ + return { + 'msgtype': 'stream', + 'stream': { + 'id': stream_id, + 'finish': finish, + 'content': content, + }, + } + + async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]: + """对响应进行加密封装并返回给企业微信。 + + Args: + payload: 待加密的响应内容。 + nonce: 企业微信回调参数中的 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 对象及状态码。 + + Example: + 在首包或刷新场景中调用以生成加密响应。 + """ + reply_plain_str = json.dumps(payload, ensure_ascii=False) + reply_timestamp = str(int(time.time())) + ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp) + if ret != 0: + await self.logger.error(f'加密失败: {ret}') + return jsonify({'error': 'encrypt_failed'}), 500 + + root = ET.fromstring(encrypt_text) + encrypt = root.find('Encrypt').text + resp = { + 'encrypt': encrypt, + } + return jsonify(resp), 200 + + async def _dispatch_event(self, event: wecombotevent.WecomBotEvent) -> None: + """异步触发流水线处理,避免阻塞首包响应。 + + Args: + event: 由企业微信消息转换的内部事件对象。 + """ try: - self.wxcpt=WXBizMsgCrypt(self.Token,self.EnCodingAESKey,'') + await self._handle_message(event) + except Exception: + await self.logger.error(traceback.format_exc()) - if request.method == "GET": + async def _handle_initial_message(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + """处理企业微信首次推送的消息,返回 stream_id 并开启流水线。 - msg_signature = unquote(request.args.get("msg_signature", "")) - timestamp = unquote(request.args.get("timestamp", "")) - nonce = unquote(request.args.get("nonce", "")) - echostr = unquote(request.args.get("echostr", "")) + Args: + msg_json: 解密后的企业微信消息 JSON。 + nonce: 企业微信回调参数 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 及状态码。 + + Example: + 首次回调时调用,立即返回带 `stream_id` 的响应。 + """ + session, is_new = self.stream_sessions.create_or_get(msg_json) + + message_data = await self.get_message(msg_json) + if message_data: + message_data['stream_id'] = session.stream_id + try: + event = wecombotevent.WecomBotEvent(message_data) + except Exception: + await self.logger.error(traceback.format_exc()) + else: + if is_new: + asyncio.create_task(self._dispatch_event(event)) + + payload = self._build_stream_payload(session.stream_id, '', False) + return await self._encrypt_and_reply(payload, nonce) + + async def _handle_stream_refresh(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + """处理企业微信的流式刷新请求,按需返回增量片段。 + + Args: + msg_json: 解密后的企业微信刷新请求。 + nonce: 企业微信回调参数 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 及状态码。 + + Example: + 在刷新请求中调用,按需返回增量片段。 + """ + stream_info = msg_json.get('stream', {}) + stream_id = stream_info.get('id', '') + if not stream_id: + await self.logger.error('刷新请求缺少 stream.id') + return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce) + + session = self.stream_sessions.get_session(stream_id) + chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout) + + if not chunk: + cached_content = None + if session and session.msg_id: + cached_content = self.generated_content.pop(session.msg_id, None) + if cached_content is not None: + chunk = StreamChunk(content=cached_content, is_final=True) + else: + payload = self._build_stream_payload(stream_id, '', False) + return await self._encrypt_and_reply(payload, nonce) + + payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) + if chunk.is_final: + self.stream_sessions.mark_finished(stream_id) + return await self._encrypt_and_reply(payload, nonce) + + async def handle_callback_request(self): + """企业微信回调入口。 + + Returns: + Quart Response: 根据请求类型返回验证、首包或刷新结果。 + + Example: + 作为 Quart 路由处理函数直接注册并使用。 + """ + try: + self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') + await self.logger.info(f'{request.method} {request.url} {str(request.args)}') + + if request.method == 'GET': + # GET 用于验证回调 URL,有效期内直接返回微信给的 echostr + msg_signature = unquote(request.args.get('msg_signature', '')) + timestamp = unquote(request.args.get('timestamp', '')) + nonce = unquote(request.args.get('nonce', '')) + echostr = unquote(request.args.get('echostr', '')) if not all([msg_signature, timestamp, nonce, echostr]): - await self.logger.error("请求参数缺失") - return Response("缺少参数", status=400) + await self.logger.error('请求参数缺失') + return Response('缺少参数', status=400) ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret != 0: - - await self.logger.error("验证URL失败") - return Response("验证失败", status=403) + await self.logger.error('验证URL失败') + return Response('验证失败', status=403) - return Response(decrypted_str, mimetype="text/plain") + return Response(decrypted_str, mimetype='text/plain') - elif request.method == "POST": - msg_signature = unquote(request.args.get("msg_signature", "")) - timestamp = unquote(request.args.get("timestamp", "")) - nonce = unquote(request.args.get("nonce", "")) + if request.method != 'POST': + return Response('', status=405) - try: - timeout = 3 - interval = 0.1 - start_time = time.monotonic() - encrypted_json = await request.get_json() - encrypted_msg = encrypted_json.get("encrypt", "") - if not encrypted_msg: - await self.logger.error("请求体中缺少 'encrypt' 字段") + self.stream_sessions.cleanup() - xml_post_data = f"" - ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) - if ret != 0: - await self.logger.error("解密失败") + msg_signature = unquote(request.args.get('msg_signature', '')) + timestamp = unquote(request.args.get('timestamp', '')) + nonce = unquote(request.args.get('nonce', '')) + encrypted_json = await request.get_json() + encrypted_msg = (encrypted_json or {}).get('encrypt', '') + if not encrypted_msg: + await self.logger.error("请求体中缺少 'encrypt' 字段") + return Response('Bad Request', status=400) - msg_json = json.loads(decrypted_xml) - - from_user_id = msg_json.get("from", {}).get("userid") - chatid = msg_json.get("chatid", "") - - message_data = await self.get_message(msg_json) - - + xml_post_data = f"" + ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) + if ret != 0: + await self.logger.error('解密失败') + return Response('解密失败', status=400) - if message_data: - try: - event = wecombotevent.WecomBotEvent(message_data) - if event: - await self._handle_message(event) - except Exception as e: - await self.logger.error(traceback.format_exc()) - print(traceback.format_exc()) + msg_json = json.loads(decrypted_xml) - start_time = time.time() - try: - if msg_json.get('chattype','') == 'single': - if from_user_id in self.user_stream_map: - stream_id = self.user_stream_map[from_user_id] - else: - stream_id =str(uuid.uuid4()) - self.user_stream_map[from_user_id] = stream_id - + if msg_json.get('msgtype') == 'stream': + # 企业微信刷新请求:尝试从队列中取出增量回复 + return await self._handle_stream_refresh(msg_json, nonce) - else: - - if chatid in self.user_stream_map: - stream_id = self.user_stream_map[chatid] - else: - stream_id = str(uuid.uuid4()) - self.user_stream_map[chatid] = stream_id - except Exception as e: - await self.logger.error(traceback.format_exc()) - print(traceback.format_exc()) - while True: - content = self.generated_content.pop(msg_json['msgid'],None) - if content: - reply_plain = { - "msgtype": "stream", - "stream": { - "id": stream_id, - "finish": True, - "content": content - } - } - reply_plain_str = json.dumps(reply_plain, ensure_ascii=False) + # 首次请求:快速返回 stream_id 并异步处理流水线 + return await self._handle_initial_message(msg_json, nonce) - reply_timestamp = str(int(time.time())) - ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp) - if ret != 0: - - await self.logger.error("加密失败"+str(ret)) - - - root = ET.fromstring(encrypt_text) - encrypt = root.find("Encrypt").text - resp = { - "encrypt": encrypt, - } - return jsonify(resp), 200 - - if time.time() - start_time > timeout: - break - - await asyncio.sleep(interval) - - if self.msg_id_map.get(message_data['msgid'], 1) == 3: - await self.logger.error('请求失效:暂不支持智能机器人超过7秒的请求,如有需求,请联系 LangBot 团队。') - return '' - - except Exception as e: - await self.logger.error(traceback.format_exc()) - print(traceback.format_exc()) - - except Exception as e: + except Exception: await self.logger.error(traceback.format_exc()) - print(traceback.format_exc()) + return Response('Internal Server Error', status=500) - - async def get_message(self,msg_json): + async def get_message(self, msg_json): message_data = {} - if msg_json.get('chattype','') == 'single': + if msg_json.get('chattype', '') == 'single': message_data['type'] = 'single' - elif msg_json.get('chattype','') == 'group': + elif msg_json.get('chattype', '') == 'group': message_data['type'] = 'group' if msg_json.get('msgtype') == 'text': - message_data['content'] = msg_json.get('text',{}).get('content') + message_data['content'] = msg_json.get('text', {}).get('content') elif msg_json.get('msgtype') == 'image': - picurl = msg_json.get('image', {}).get('url','') - base64 = await self.download_url_to_base64(picurl,self.EnCodingAESKey) - message_data['picurl'] = base64 + picurl = msg_json.get('image', {}).get('url', '') + base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey) + message_data['picurl'] = base64 elif msg_json.get('msgtype') == 'mixed': items = msg_json.get('mixed', {}).get('msg_item', []) texts = [] @@ -197,8 +453,8 @@ class WecomBotClient: if texts: message_data['content'] = "".join(texts) # 拼接所有 text if picurl: - base64 = await self.download_url_to_base64(picurl,self.EnCodingAESKey) - message_data['picurl'] = base64 # 只保留第一个 image + base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey) + message_data['picurl'] = base64 # 只保留第一个 image message_data['userid'] = msg_json.get('from', {}).get('userid', '') message_data['msgid'] = msg_json.get('msgid', '') @@ -207,7 +463,7 @@ class WecomBotClient: message_data['aibotid'] = msg_json.get('aibotid', '') return message_data - + async def _handle_message(self, event: wecombotevent.WecomBotEvent): """ 处理消息事件。 @@ -223,10 +479,46 @@ class WecomBotClient: for handler in self._message_handlers[msg_type]: await handler(event) except Exception: - print(traceback.format_exc()) + print(traceback.format_exc()) + + async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: + """将流水线片段推送到 stream 会话。 + + Args: + msg_id: 原始企业微信消息 ID。 + content: 模型产生的片段内容。 + is_final: 是否为最终片段。 + + Returns: + bool: 当成功写入流式队列时返回 True。 + + Example: + 在流水线 `reply_message_chunk` 中调用,将增量推送至企业微信。 + """ + # 根据 msg_id 找到对应 stream 会话,如果不存在说明当前消息非流式 + stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) + if not stream_id: + return False + + chunk = StreamChunk(content=content, is_final=is_final) + await self.stream_sessions.publish(stream_id, chunk) + if is_final: + self.stream_sessions.mark_finished(stream_id) + return True async def set_message(self, msg_id: str, content: str): - self.generated_content[msg_id] = content + """兼容旧逻辑:若无法流式返回则缓存最终结果。 + + Args: + msg_id: 企业微信消息 ID。 + content: 最终回复的文本内容。 + + Example: + 在非流式场景下缓存最终结果以备刷新时返回。 + """ + handled = await self.push_stream_chunk(msg_id, content, is_final=True) + if not handled: + self.generated_content[msg_id] = content def on_message(self, msg_type: str): def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]): @@ -237,7 +529,6 @@ class WecomBotClient: return decorator - async def download_url_to_base64(self, download_url, encoding_aes_key): async with httpx.AsyncClient() as client: response = await client.get(download_url) @@ -247,26 +538,22 @@ class WecomBotClient: encrypted_bytes = response.content - aes_key = base64.b64decode(encoding_aes_key + "=") # base64 补齐 iv = aes_key[:16] - cipher = AES.new(aes_key, AES.MODE_CBC, iv) decrypted = cipher.decrypt(encrypted_bytes) - pad_len = decrypted[-1] decrypted = decrypted[:-pad_len] - - if decrypted.startswith(b"\xff\xd8"): # JPEG + if decrypted.startswith(b"\xff\xd8"): # JPEG mime_type = "image/jpeg" elif decrypted.startswith(b"\x89PNG"): # PNG mime_type = "image/png" elif decrypted.startswith((b"GIF87a", b"GIF89a")): # GIF mime_type = "image/gif" - elif decrypted.startswith(b"BM"): # BMP + elif decrypted.startswith(b"BM"): # BMP mime_type = "image/bmp" elif decrypted.startswith(b"II*\x00") or decrypted.startswith(b"MM\x00*"): # TIFF mime_type = "image/tiff" @@ -276,15 +563,9 @@ class WecomBotClient: # 转 base64 base64_str = base64.b64encode(decrypted).decode("utf-8") return f"data:{mime_type};base64,{base64_str}" - async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 """ await self.app.run_task(host=host, port=port, *args, **kwargs) - - - - - diff --git a/pkg/platform/sources/wecombot.py b/pkg/platform/sources/wecombot.py index 9487b637..e5f2d1b5 100644 --- a/pkg/platform/sources/wecombot.py +++ b/pkg/platform/sources/wecombot.py @@ -117,6 +117,50 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content = await self.message_converter.yiri2target(message) await self.bot.set_message(message_source.source_platform_object.message_id, content) + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + """将流水线增量输出写入企业微信 stream 会话。 + + Args: + message_source: 流水线提供的原始消息事件。 + bot_message: 当前片段对应的模型元信息(未使用)。 + message: 需要回复的消息链。 + quote_origin: 是否引用原消息(企业微信暂不支持)。 + is_final: 标记当前片段是否为最终回复。 + + Returns: + dict: 包含 `stream` 键,标识写入是否成功。 + + Example: + 在流水线 `reply_message_chunk` 调用中自动触发,无需手动调用。 + """ + # 转换为纯文本(智能机器人当前协议仅支持文本流) + content = await self.message_converter.yiri2target(message) + msg_id = message_source.source_platform_object.message_id + + # 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑 + success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) + if not success and is_final: + # 未命中流式队列时使用旧有 set_message 兜底 + await self.bot.set_message(msg_id, content) + return {'stream': success} + + async def is_stream_output_supported(self) -> bool: + """智能机器人侧默认开启流式能力。 + + Returns: + bool: 恒定返回 True。 + + Example: + 流水线执行阶段会调用此方法以确认是否启用流式。""" + return True + async def send_message(self, target_type, target_id, message): pass From 4a02c531b20ae1e4ec86df8cbd0dc74c2d795aaa Mon Sep 17 00:00:00 2001 From: Alfonsxh Date: Tue, 28 Oct 2025 18:30:55 +0800 Subject: [PATCH 064/144] refactor: split WeCom callback handlers --- libs/wecom_ai_bot_api/api.py | 99 +++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/libs/wecom_ai_bot_api/api.py b/libs/wecom_ai_bot_api/api.py index 41d379a6..9568eab4 100644 --- a/libs/wecom_ai_bot_api/api.py +++ b/libs/wecom_ai_bot_api/api.py @@ -295,7 +295,7 @@ class WecomBotClient: except Exception: await self.logger.error(traceback.format_exc()) - async def _handle_initial_message(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信首次推送的消息,返回 stream_id 并开启流水线。 Args: @@ -324,7 +324,7 @@ class WecomBotClient: payload = self._build_stream_payload(session.stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) - async def _handle_stream_refresh(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信的流式刷新请求,按需返回增量片段。 Args: @@ -375,57 +375,64 @@ class WecomBotClient: await self.logger.info(f'{request.method} {request.url} {str(request.args)}') if request.method == 'GET': - # GET 用于验证回调 URL,有效期内直接返回微信给的 echostr - msg_signature = unquote(request.args.get('msg_signature', '')) - timestamp = unquote(request.args.get('timestamp', '')) - nonce = unquote(request.args.get('nonce', '')) - echostr = unquote(request.args.get('echostr', '')) + return await self._handle_get_callback() - if not all([msg_signature, timestamp, nonce, echostr]): - await self.logger.error('请求参数缺失') - return Response('缺少参数', status=400) + if request.method == 'POST': + return await self._handle_post_callback() - ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) - if ret != 0: - await self.logger.error('验证URL失败') - return Response('验证失败', status=403) - - return Response(decrypted_str, mimetype='text/plain') - - if request.method != 'POST': - return Response('', status=405) - - self.stream_sessions.cleanup() - - msg_signature = unquote(request.args.get('msg_signature', '')) - timestamp = unquote(request.args.get('timestamp', '')) - nonce = unquote(request.args.get('nonce', '')) - - encrypted_json = await request.get_json() - encrypted_msg = (encrypted_json or {}).get('encrypt', '') - if not encrypted_msg: - await self.logger.error("请求体中缺少 'encrypt' 字段") - return Response('Bad Request', status=400) - - xml_post_data = f"" - ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) - if ret != 0: - await self.logger.error('解密失败') - return Response('解密失败', status=400) - - msg_json = json.loads(decrypted_xml) - - if msg_json.get('msgtype') == 'stream': - # 企业微信刷新请求:尝试从队列中取出增量回复 - return await self._handle_stream_refresh(msg_json, nonce) - - # 首次请求:快速返回 stream_id 并异步处理流水线 - return await self._handle_initial_message(msg_json, nonce) + return Response('', status=405) except Exception: await self.logger.error(traceback.format_exc()) return Response('Internal Server Error', status=500) + async def _handle_get_callback(self) -> tuple[Response, int] | Response: + """处理企业微信的 GET 验证请求。""" + + msg_signature = unquote(request.args.get('msg_signature', '')) + timestamp = unquote(request.args.get('timestamp', '')) + nonce = unquote(request.args.get('nonce', '')) + echostr = unquote(request.args.get('echostr', '')) + + if not all([msg_signature, timestamp, nonce, echostr]): + await self.logger.error('请求参数缺失') + return Response('缺少参数', status=400) + + ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + if ret != 0: + await self.logger.error('验证URL失败') + return Response('验证失败', status=403) + + return Response(decrypted_str, mimetype='text/plain') + + async def _handle_post_callback(self) -> tuple[Response, int] | Response: + """处理企业微信的 POST 回调请求。""" + + self.stream_sessions.cleanup() + + msg_signature = unquote(request.args.get('msg_signature', '')) + timestamp = unquote(request.args.get('timestamp', '')) + nonce = unquote(request.args.get('nonce', '')) + + encrypted_json = await request.get_json() + encrypted_msg = (encrypted_json or {}).get('encrypt', '') + if not encrypted_msg: + await self.logger.error("请求体中缺少 'encrypt' 字段") + return Response('Bad Request', status=400) + + xml_post_data = f"" + ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) + if ret != 0: + await self.logger.error('解密失败') + return Response('解密失败', status=400) + + msg_json = json.loads(decrypted_xml) + + if msg_json.get('msgtype') == 'stream': + return await self._handle_post_followup_response(msg_json, nonce) + + return await self._handle_post_initial_response(msg_json, nonce) + async def get_message(self, msg_json): message_data = {} From b3d5b3fc8f84aa33d1b3203d896e0f38801894ac Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Thu, 30 Oct 2025 12:37:09 +0800 Subject: [PATCH 065/144] fix: langchain error --- pkg/rag/knowledge/services/chunker.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/pkg/rag/knowledge/services/chunker.py b/pkg/rag/knowledge/services/chunker.py index f169d5f1..19b1f296 100644 --- a/pkg/rag/knowledge/services/chunker.py +++ b/pkg/rag/knowledge/services/chunker.py @@ -4,6 +4,7 @@ import json from typing import List from pkg.rag.knowledge.services import base_service from pkg.core import app +from langchain_text_splitters import RecursiveCharacterTextSplitter class Chunker(base_service.BaseService): @@ -27,21 +28,6 @@ class Chunker(base_service.BaseService): """ if not text: return [] - # words = text.split() - # chunks = [] - # current_chunk = [] - - # for word in words: - # current_chunk.append(word) - # if len(current_chunk) > self.chunk_size: - # chunks.append(" ".join(current_chunk[:self.chunk_size])) - # current_chunk = current_chunk[self.chunk_size - self.chunk_overlap:] - - # if current_chunk: - # chunks.append(" ".join(current_chunk)) - - # A more robust chunking strategy (e.g., using recursive character text splitter) - from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=self.chunk_size, From 350e59fa6baf2bc63109f74936572ab6e20f54f3 Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Thu, 30 Oct 2025 12:52:11 +0800 Subject: [PATCH 066/144] fix: add langchain test splitter module --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c0200bd0..1384b22c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ dependencies = [ "ebooklib>=0.18", "html2text>=2024.2.26", "langchain>=0.2.0", + "langchain-text-splitters>=0.0.1", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", "langbot-plugin==0.1.4", From 3d12632c9fbcd080c6c7e93e0eca35a1b5d8ebc3 Mon Sep 17 00:00:00 2001 From: "Junyan Qin (Chin)" Date: Tue, 4 Nov 2025 15:33:44 +0800 Subject: [PATCH 067/144] perf: config reset logic (#1742) * fix: inherit settings from existing settings * feat: add optional data cleanup checkbox to plugin uninstall dialog (#1743) * Initial plan * Add checkbox for plugin config/storage deletion - Add delete_data parameter to backend API endpoint - Update delete_plugin flow to clean up settings and binary storage - Add checkbox in uninstall dialog using shadcn/ui - Add translations for checkbox label in all languages Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * perf: param list --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: Junyan Qin * chore: fix linter errors --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- pkg/api/http/controller/groups/plugins.py | 3 +- pkg/plugin/connector.py | 12 ++++- pkg/plugin/handler.py | 25 +++++++++- .../PluginInstalledComponent.tsx | 48 ++++++++++++++----- web/src/app/infra/http/BackendClient.ts | 5 +- web/src/i18n/locales/en-US.ts | 4 +- web/src/i18n/locales/ja-JP.ts | 3 +- web/src/i18n/locales/zh-Hans.ts | 4 +- web/src/i18n/locales/zh-Hant.ts | 1 + 9 files changed, 86 insertions(+), 19 deletions(-) diff --git a/pkg/api/http/controller/groups/plugins.py b/pkg/api/http/controller/groups/plugins.py index 4966553b..4a3f723e 100644 --- a/pkg/api/http/controller/groups/plugins.py +++ b/pkg/api/http/controller/groups/plugins.py @@ -45,9 +45,10 @@ class PluginsRouterGroup(group.RouterGroup): return self.http_status(404, -1, 'plugin not found') return self.success(data={'plugin': plugin}) elif quart.request.method == 'DELETE': + delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true' ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_connector.delete_plugin(author, plugin_name, task_context=ctx), + self.ap.plugin_connector.delete_plugin(author, plugin_name, delete_data=delete_data, task_context=ctx), kind='plugin-operation', name=f'plugin-remove-{plugin_name}', label=f'Removing plugin {plugin_name}', diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index 96530de2..4b5809fe 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -177,7 +177,11 @@ class PluginRuntimeConnector: task_context.trace(trace) async def delete_plugin( - self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None + self, + plugin_author: str, + plugin_name: str, + delete_data: bool = False, + task_context: taskmgr.TaskContext | None = None, ) -> dict[str, Any]: async for ret in self.handler.delete_plugin(plugin_author, plugin_name): current_action = ret.get('current_action', None) @@ -190,6 +194,12 @@ class PluginRuntimeConnector: if task_context is not None: task_context.trace(trace) + # Clean up plugin settings and binary storage if requested + if delete_data: + if task_context is not None: + task_context.trace('Cleaning up plugin configuration and storage...') + await self.handler.cleanup_plugin_data(plugin_author, plugin_name) + async def list_plugins(self) -> list[dict[str, Any]]: if not self.is_enable_plugin: return [] diff --git a/pkg/plugin/handler.py b/pkg/plugin/handler.py index b138fd42..da710f41 100644 --- a/pkg/plugin/handler.py +++ b/pkg/plugin/handler.py @@ -56,7 +56,9 @@ class RuntimeConnectionHandler(handler.Handler): .where(persistence_plugin.PluginSetting.plugin_name == plugin_name) ) - if result.first() is not None: + setting = result.first() + + if setting is not None: # delete plugin setting await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_plugin.PluginSetting) @@ -71,6 +73,10 @@ class RuntimeConnectionHandler(handler.Handler): plugin_name=plugin_name, install_source=install_source, install_info=install_info, + # inherit from existing setting + enabled=setting.enabled if setting is not None else True, + priority=setting.priority if setting is not None else 0, + config=setting.config if setting is not None else {}, # noqa: F821 ) ) @@ -573,6 +579,23 @@ class RuntimeConnectionHandler(handler.Handler): 'mime_type': mime_type, } + async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None: + """Cleanup plugin settings and binary storage""" + # Delete plugin settings + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_plugin.PluginSetting) + .where(persistence_plugin.PluginSetting.plugin_author == plugin_author) + .where(persistence_plugin.PluginSetting.plugin_name == plugin_name) + ) + + # Delete all binary storage for this plugin + owner = f'{plugin_author}/{plugin_name}' + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_bstorage.BinaryStorage) + .where(persistence_bstorage.BinaryStorage.owner_type == 'plugin') + .where(persistence_bstorage.BinaryStorage.owner == owner) + ) + async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]: """Call tool""" result = await self.call_action( diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index 315e9960..612151d9 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -15,6 +15,7 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { useTranslation } from 'react-i18next'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { toast } from 'sonner'; @@ -43,6 +44,7 @@ const PluginInstalledComponent = forwardRef( PluginOperationType.DELETE, ); const [targetPlugin, setTargetPlugin] = useState(null); + const [deleteData, setDeleteData] = useState(false); const asyncTask = useAsyncTask({ onSuccess: () => { @@ -108,6 +110,7 @@ const PluginInstalledComponent = forwardRef( setTargetPlugin(plugin); setOperationType(PluginOperationType.DELETE); setShowOperationModal(true); + setDeleteData(false); asyncTask.reset(); } @@ -123,7 +126,11 @@ const PluginInstalledComponent = forwardRef( const apiCall = operationType === PluginOperationType.DELETE - ? httpClient.removePlugin(targetPlugin.author, targetPlugin.name) + ? httpClient.removePlugin( + targetPlugin.author, + targetPlugin.name, + deleteData, + ) : httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name); apiCall @@ -161,16 +168,35 @@ const PluginInstalledComponent = forwardRef( {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( -
- {operationType === PluginOperationType.DELETE - ? t('plugins.confirmDeletePlugin', { - author: targetPlugin?.author ?? '', - name: targetPlugin?.name ?? '', - }) - : t('plugins.confirmUpdatePlugin', { - author: targetPlugin?.author ?? '', - name: targetPlugin?.name ?? '', - })} +
+
+ {operationType === PluginOperationType.DELETE + ? t('plugins.confirmDeletePlugin', { + author: targetPlugin?.author ?? '', + name: targetPlugin?.name ?? '', + }) + : t('plugins.confirmUpdatePlugin', { + author: targetPlugin?.author ?? '', + name: targetPlugin?.name ?? '', + })} +
+ {operationType === PluginOperationType.DELETE && ( +
+ + setDeleteData(checked === true) + } + /> + +
+ )}
)} {asyncTask.status === AsyncTaskStatus.RUNNING && ( diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index cc47d3fa..319e2bc5 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -480,8 +480,11 @@ export class BackendClient extends BaseHttpClient { public removePlugin( author: string, name: string, + deleteData: boolean = false, ): Promise { - return this.delete(`/api/v1/plugins/${author}/${name}`); + return this.delete( + `/api/v1/plugins/${author}/${name}?delete_data=${deleteData}`, + ); } public upgradePlugin( diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index cca72c0e..b81f691e 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -199,7 +199,9 @@ const enUS = { saveConfig: 'Save Config', saving: 'Saving...', confirmDeletePlugin: - 'Are you sure you want to delete the plugin ({{author}}/{{name}})? This will also delete the plugin configuration.', + 'Are you sure you want to delete the plugin ({{author}}/{{name}})?', + deleteDataCheckbox: + 'Also delete plugin configuration and persistence storage', confirmDelete: 'Confirm Delete', deleteError: 'Delete failed: ', close: 'Close', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 2d574fdd..d87b20f8 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -200,7 +200,8 @@ const jaJP = { saveConfig: '設定を保存', saving: '保存中...', confirmDeletePlugin: - 'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか?この操作により、プラグインの設定も削除されます。', + 'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか?', + deleteDataCheckbox: 'プラグイン設定と永続化ストレージも削除する', confirmDelete: '削除を確認', deleteError: '削除に失敗しました:', close: '閉じる', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index ee912e7a..dbab2842 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -191,8 +191,8 @@ const zhHans = { cancel: '取消', saveConfig: '保存配置', saving: '保存中...', - confirmDeletePlugin: - '你确定要删除插件({{author}}/{{name}})吗?这将同时删除插件的配置。', + confirmDeletePlugin: '你确定要删除插件({{author}}/{{name}})吗?', + deleteDataCheckbox: '同时删除插件配置和持久化存储', confirmDelete: '确认删除', deleteError: '删除失败:', close: '关闭', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 78ba4c94..8fbe3daa 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -192,6 +192,7 @@ const zhHant = { saveConfig: '儲存設定', saving: '儲存中...', confirmDeletePlugin: '您確定要刪除外掛({{author}}/{{name}})嗎?', + deleteDataCheckbox: '同時刪除外掛設定和持久化儲存', confirmDelete: '確認刪除', deleteError: '刪除失敗:', close: '關閉', From 7699ba3caed49a1a0cec581e95f687edf0d702f7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 21:09:14 +0800 Subject: [PATCH 068/144] feat: add supports for install plugin from GitHub repo releases Add GitHub release installation for plugins --- pkg/api/http/controller/groups/plugins.py | 136 +++++- pkg/plugin/connector.py | 42 +- web/src/app/home/plugins/page.tsx | 494 ++++++++++++++++++---- web/src/app/infra/http/BackendClient.ts | 47 +- web/src/i18n/locales/en-US.ts | 20 + web/src/i18n/locales/ja-JP.ts | 20 + web/src/i18n/locales/zh-Hans.ts | 20 + web/src/i18n/locales/zh-Hant.ts | 21 +- 8 files changed, 712 insertions(+), 88 deletions(-) diff --git a/pkg/api/http/controller/groups/plugins.py b/pkg/api/http/controller/groups/plugins.py index 4a3f723e..56d05b53 100644 --- a/pkg/api/http/controller/groups/plugins.py +++ b/pkg/api/http/controller/groups/plugins.py @@ -2,6 +2,8 @@ from __future__ import annotations import base64 import quart +import re +import httpx from .....core import taskmgr from .. import group @@ -48,7 +50,9 @@ class PluginsRouterGroup(group.RouterGroup): delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true' ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_connector.delete_plugin(author, plugin_name, delete_data=delete_data, task_context=ctx), + self.ap.plugin_connector.delete_plugin( + author, plugin_name, delete_data=delete_data, task_context=ctx + ), kind='plugin-operation', name=f'plugin-remove-{plugin_name}', label=f'Removing plugin {plugin_name}', @@ -90,23 +94,145 @@ class PluginsRouterGroup(group.RouterGroup): return quart.Response(icon_data, mimetype=mime_type) + @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Get releases from a GitHub repository URL""" + data = await quart.request.json + repo_url = data.get('repo_url', '') + + # Parse GitHub repository URL to extract owner and repo + # Supports: https://github.com/owner/repo or github.com/owner/repo + pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$' + match = re.search(pattern, repo_url) + + if not match: + return self.http_status(400, -1, 'Invalid GitHub repository URL') + + owner, repo = match.groups() + + try: + # Fetch releases from GitHub API + url = f'https://api.github.com/repos/{owner}/{repo}/releases' + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=10, + ) as client: + response = await client.get(url) + response.raise_for_status() + releases = response.json() + + # Format releases data for frontend + formatted_releases = [] + for release in releases: + formatted_releases.append( + { + 'id': release['id'], + 'tag_name': release['tag_name'], + 'name': release['name'], + 'published_at': release['published_at'], + 'prerelease': release['prerelease'], + 'draft': release['draft'], + } + ) + + return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo}) + except httpx.RequestError as e: + return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}') + + @self.route( + '/github/release-assets', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _() -> str: + """Get assets from a specific GitHub release""" + data = await quart.request.json + owner = data.get('owner', '') + repo = data.get('repo', '') + release_id = data.get('release_id', '') + + if not all([owner, repo, release_id]): + return self.http_status(400, -1, 'Missing required parameters') + + try: + # Fetch release assets from GitHub API + url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}' + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=10, + ) as client: + response = await client.get( + url, + ) + response.raise_for_status() + release = response.json() + + # Format assets data for frontend + formatted_assets = [] + for asset in release.get('assets', []): + formatted_assets.append( + { + 'id': asset['id'], + 'name': asset['name'], + 'size': asset['size'], + 'download_url': asset['browser_download_url'], + 'content_type': asset['content_type'], + } + ) + + # add zipball as a downloadable asset + # formatted_assets.append( + # { + # "id": 0, + # "name": "Source code (zip)", + # "size": -1, + # "download_url": release["zipball_url"], + # "content_type": "application/zip", + # } + # ) + + return self.success(data={'assets': formatted_assets}) + except httpx.RequestError as e: + return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}') + @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: + """Install plugin from GitHub release asset""" data = await quart.request.json + asset_url = data.get('asset_url', '') + owner = data.get('owner', '') + repo = data.get('repo', '') + release_tag = data.get('release_tag', '') + + if not asset_url: + return self.http_status(400, -1, 'Missing asset_url parameter') ctx = taskmgr.TaskContext.new() - short_source_str = data['source'][-8:] + install_info = { + 'asset_url': asset_url, + 'owner': owner, + 'repo': repo, + 'release_tag': release_tag, + 'github_url': f'https://github.com/{owner}/{repo}', + } + wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_mgr.install_plugin(data['source'], task_context=ctx), + self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx), kind='plugin-operation', name='plugin-install-github', - label=f'Installing plugin from github ...{short_source_str}', + label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}', context=ctx, ) return self.success(data={'task_id': wrapper.id}) - @self.route('/install/marketplace', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/install/marketplace', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) async def _() -> str: data = await quart.request.json diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index 4b5809fe..45aeb8a8 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -6,19 +6,24 @@ from typing import Any import typing import os import sys - +import httpx from async_lru import alru_cache from ..core import app from . import handler from ..utils import platform -from langbot_plugin.runtime.io.controllers.stdio import client as stdio_client_controller +from langbot_plugin.runtime.io.controllers.stdio import ( + client as stdio_client_controller, +) from langbot_plugin.runtime.io.controllers.ws import client as ws_client_controller from langbot_plugin.api.entities import events from langbot_plugin.api.entities import context import langbot_plugin.runtime.io.connection as base_connection from langbot_plugin.api.definition.components.manifest import ComponentManifest -from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors +from langbot_plugin.api.entities.builtin.command import ( + context as command_context, + errors as command_errors, +) from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from ..core import taskmgr @@ -71,7 +76,9 @@ class PluginRuntimeConnector: return async def new_connection_callback(connection: base_connection.Connection): - async def disconnect_callback(rchandler: handler.RuntimeConnectionHandler) -> bool: + async def disconnect_callback( + rchandler: handler.RuntimeConnectionHandler, + ) -> bool: if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime(): self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...') await self.runtime_disconnect_callback(self) @@ -98,7 +105,8 @@ class PluginRuntimeConnector: ) async def make_connection_failed_callback( - ctrl: ws_client_controller.WebSocketClientController, exc: Exception = None + ctrl: ws_client_controller.WebSocketClientController, + exc: Exception = None, ) -> None: if exc is not None: self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}') @@ -150,6 +158,25 @@ class PluginRuntimeConnector: install_info['plugin_file_key'] = file_key del install_info['plugin_file'] self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') + elif install_source == PluginInstallSource.GITHUB: + # download and transfer file + try: + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=20, + ) as client: + response = await client.get( + install_info['asset_url'], + ) + response.raise_for_status() + file_bytes = response.content + file_key = await self.handler.send_file(file_bytes, 'lbpkg') + install_info['plugin_file_key'] = file_key + self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') + except Exception as e: + self.ap.logger.error(f'Failed to download file from GitHub: {e}') + raise Exception(f'Failed to download file from GitHub: {e}') async for ret in self.handler.install_plugin(install_source.value, install_info): current_action = ret.get('current_action', None) @@ -163,7 +190,10 @@ class PluginRuntimeConnector: task_context.trace(trace) async def upgrade_plugin( - self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None + self, + plugin_author: str, + plugin_name: str, + task_context: taskmgr.TaskContext | None = None, ) -> dict[str, Any]: async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name): current_action = ret.get('current_action', None) diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index fa9d0980..37139aee 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -9,6 +9,12 @@ import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDe import styles from './plugins.module.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; import { PlusIcon, ChevronDownIcon, @@ -16,6 +22,8 @@ import { StoreIcon, Download, Power, + Github, + ChevronLeft, } from 'lucide-react'; import { DropdownMenu, @@ -41,11 +49,30 @@ import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; enum PluginInstallStatus { WAIT_INPUT = 'wait_input', + SELECT_RELEASE = 'select_release', + SELECT_ASSET = 'select_asset', ASK_CONFIRM = 'ask_confirm', INSTALLING = 'installing', ERROR = 'error', } +interface GithubRelease { + id: number; + tag_name: string; + name: string; + published_at: string; + prerelease: boolean; + draft: boolean; +} + +interface GithubAsset { + id: number; + name: string; + size: number; + download_url: string; + content_type: string; +} + export default function PluginConfigPage() { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('installed'); @@ -57,6 +84,16 @@ export default function PluginConfigPage() { useState(PluginInstallStatus.WAIT_INPUT); const [installError, setInstallError] = useState(null); const [githubURL, setGithubURL] = useState(''); + const [githubReleases, setGithubReleases] = useState([]); + const [selectedRelease, setSelectedRelease] = useState( + null, + ); + const [githubAssets, setGithubAssets] = useState([]); + const [selectedAsset, setSelectedAsset] = useState(null); + const [githubOwner, setGithubOwner] = useState(''); + const [githubRepo, setGithubRepo] = useState(''); + const [fetchingReleases, setFetchingReleases] = useState(false); + const [fetchingAssets, setFetchingAssets] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const [pluginSystemStatus, setPluginSystemStatus] = useState(null); @@ -86,6 +123,14 @@ export default function PluginConfigPage() { fetchPluginSystemStatus(); }, [t]); + function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; + } + function watchTask(taskId: number) { let alreadySuccess = false; @@ -101,7 +146,7 @@ export default function PluginConfigPage() { toast.success(t('plugins.installSuccess')); alreadySuccess = true; } - setGithubURL(''); + resetGithubState(); setModalOpen(false); pluginInstalledRef.current?.refreshPluginList(); } @@ -112,52 +157,143 @@ export default function PluginConfigPage() { const pluginInstalledRef = useRef(null); - function handleModalConfirm() { - installPlugin(installSource, installInfo as Record); + function resetGithubState() { + setGithubURL(''); + setGithubReleases([]); + setSelectedRelease(null); + setGithubAssets([]); + setSelectedAsset(null); + setGithubOwner(''); + setGithubRepo(''); + setFetchingReleases(false); + setFetchingAssets(false); } - const installPlugin = useCallback( - (installSource: string, installInfo: Record) => { - setPluginInstallStatus(PluginInstallStatus.INSTALLING); - if (installSource === 'github') { - httpClient - .installPluginFromGithub((installInfo as { url: string }).url) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }) - .catch((err) => { - console.log('error when install plugin:', err); - setInstallError(err.message); - setPluginInstallStatus(PluginInstallStatus.ERROR); - }); - } else if (installSource === 'local') { - httpClient - .installPluginFromLocal((installInfo as { file: File }).file) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }) - .catch((err) => { - console.log('error when install plugin:', err); - setInstallError(err.message); - setPluginInstallStatus(PluginInstallStatus.ERROR); - }); - } else if (installSource === 'marketplace') { - httpClient - .installPluginFromMarketplace( - (installInfo as { plugin_author: string }).plugin_author, - (installInfo as { plugin_name: string }).plugin_name, - (installInfo as { plugin_version: string }).plugin_version, - ) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }); + async function fetchGithubReleases() { + if (!githubURL.trim()) { + toast.error(t('plugins.enterRepoUrl')); + return; + } + + setFetchingReleases(true); + setInstallError(null); + + try { + const result = await httpClient.getGithubReleases(githubURL); + setGithubReleases(result.releases); + setGithubOwner(result.owner); + setGithubRepo(result.repo); + + if (result.releases.length === 0) { + toast.warning(t('plugins.noReleasesFound')); + } else { + setPluginInstallStatus(PluginInstallStatus.SELECT_RELEASE); } - }, - [watchTask], - ); + } catch (error: unknown) { + console.error('Failed to fetch GitHub releases:', error); + const errorMessage = + error instanceof Error ? error.message : String(error); + setInstallError(errorMessage || t('plugins.fetchReleasesError')); + setPluginInstallStatus(PluginInstallStatus.ERROR); + } finally { + setFetchingReleases(false); + } + } + + async function handleReleaseSelect(release: GithubRelease) { + setSelectedRelease(release); + setFetchingAssets(true); + setInstallError(null); + + try { + const result = await httpClient.getGithubReleaseAssets( + githubOwner, + githubRepo, + release.id, + ); + setGithubAssets(result.assets); + + if (result.assets.length === 0) { + toast.warning(t('plugins.noAssetsFound')); + } else { + setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET); + } + } catch (error: unknown) { + console.error('Failed to fetch GitHub release assets:', error); + const errorMessage = + error instanceof Error ? error.message : String(error); + setInstallError(errorMessage || t('plugins.fetchAssetsError')); + setPluginInstallStatus(PluginInstallStatus.ERROR); + } finally { + setFetchingAssets(false); + } + } + + function handleAssetSelect(asset: GithubAsset) { + setSelectedAsset(asset); + setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); + } + + function handleModalConfirm() { + if (installSource === 'github' && selectedAsset && selectedRelease) { + installPlugin('github', { + asset_url: selectedAsset.download_url, + owner: githubOwner, + repo: githubRepo, + release_tag: selectedRelease.tag_name, + }); + } else { + installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any + } + } + + function installPlugin( + installSource: string, + installInfo: Record, // eslint-disable-line @typescript-eslint/no-explicit-any + ) { + setPluginInstallStatus(PluginInstallStatus.INSTALLING); + if (installSource === 'github') { + httpClient + .installPluginFromGithub( + installInfo.asset_url, + installInfo.owner, + installInfo.repo, + installInfo.release_tag, + ) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }) + .catch((err) => { + console.log('error when install plugin:', err); + setInstallError(err.message); + setPluginInstallStatus(PluginInstallStatus.ERROR); + }); + } else if (installSource === 'local') { + httpClient + .installPluginFromLocal(installInfo.file) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }) + .catch((err) => { + console.log('error when install plugin:', err); + setInstallError(err.message); + setPluginInstallStatus(PluginInstallStatus.ERROR); + }); + } else if (installSource === 'marketplace') { + httpClient + .installPluginFromMarketplace( + installInfo.plugin_author, + installInfo.plugin_name, + installInfo.plugin_version, + ) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }); + } + } const validateFileType = (file: File): boolean => { const allowedExtensions = ['.lbpkg', '.zip']; @@ -353,10 +489,6 @@ export default function PluginConfigPage() { ) : ( <> - - - {t('plugins.uploadLocal')} - {systemInfo.enable_marketplace && ( { @@ -367,6 +499,22 @@ export default function PluginConfigPage() { {t('plugins.marketplace')} )} + + + {t('plugins.uploadLocal')} + + { + setInstallSource('github'); + setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT); + setInstallError(null); + resetGithubState(); + setModalOpen(true); + }} + > + + {t('plugins.installFromGithub')} + )} @@ -402,49 +550,247 @@ export default function PluginConfigPage() { - - + { + setModalOpen(open); + if (!open) { + resetGithubState(); + setInstallError(null); + } + }} + > + - + {installSource === 'github' ? ( + + ) : ( + + )} {t('plugins.installPlugin')} - {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( -
-

{t('plugins.onlySupportGithub')}

- setGithubURL(e.target.value)} - className="mb-4" - /> -
- )} - {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( -
-

- {t('plugins.askConfirm', { - name: installInfo.plugin_name, - version: installInfo.plugin_version, - })} -

-
- )} + + {/* GitHub Install Flow */} + {installSource === 'github' && + pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( +
+

{t('plugins.enterRepoUrl')}

+ setGithubURL(e.target.value)} + className="mb-4" + /> + {fetchingReleases && ( +

+ {t('plugins.fetchingReleases')} +

+ )} +
+ )} + + {installSource === 'github' && + pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && ( +
+
+

{t('plugins.selectRelease')}

+ +
+
+ {githubReleases.map((release) => ( + handleReleaseSelect(release)} + > + +
+ + {release.name || release.tag_name} + + + {t('plugins.releaseTag', { tag: release.tag_name })}{' '} + •{' '} + {t('plugins.publishedAt', { + date: new Date( + release.published_at, + ).toLocaleDateString(), + })} + +
+ {release.prerelease && ( + + {t('plugins.prerelease')} + + )} +
+
+ ))} +
+ {fetchingAssets && ( +

+ {t('plugins.loading')} +

+ )} +
+ )} + + {installSource === 'github' && + pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && ( +
+
+

{t('plugins.selectAsset')}

+ +
+ {selectedRelease && ( +
+
+ {selectedRelease.name || selectedRelease.tag_name} +
+
+ {selectedRelease.tag_name} +
+
+ )} +
+ {githubAssets.map((asset) => ( + handleAssetSelect(asset)} + > + + {asset.name} + + {t('plugins.assetSize', { + size: formatFileSize(asset.size), + })} + + + + ))} +
+
+ )} + + {/* Marketplace Install Confirm */} + {installSource === 'marketplace' && + pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( +
+

+ {t('plugins.askConfirm', { + name: installInfo.plugin_name, + version: installInfo.plugin_version, + })} +

+
+ )} + + {/* GitHub Install Confirm */} + {installSource === 'github' && + pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( +
+
+

{t('plugins.confirmInstall')}

+ +
+ {selectedRelease && selectedAsset && ( +
+
+ Repository: + + {githubOwner}/{githubRepo} + +
+
+ Release: + + {selectedRelease.tag_name} + +
+
+ File: + {selectedAsset.name} +
+
+ )} +
+ )} + + {/* Installing State */} {pluginInstallStatus === PluginInstallStatus.INSTALLING && (

{t('plugins.installing')}

)} + + {/* Error State */} {pluginInstallStatus === PluginInstallStatus.ERROR && (

{t('plugins.installFailed')}

{installError}

)} + - {(pluginInstallStatus === PluginInstallStatus.WAIT_INPUT || - pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM) && ( + {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && + installSource === 'github' && ( + <> + + + + )} + {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( <>
- {runtimeInfo.error_message && ( + {/* {runtimeInfo.error_message && (
{runtimeInfo.error_message}
- )} + )} */}
); } @@ -465,7 +465,7 @@ export default function MCPFormDialog({ {isEditMode && runtimeInfo && ( -
+
{/* 测试中或连接失败时显示状态 */} {(mcpTesting || runtimeInfo.status !== MCPSessionStatus.CONNECTED) && ( @@ -482,7 +482,14 @@ export default function MCPFormDialog({ {!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED && runtimeInfo.tools?.length > 0 && ( - + <> +
+ {t('mcp.toolCount', { + count: runtimeInfo.tools?.length || 0, + })} +
+ + )}
)} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 7d17955d..3c672665 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -342,7 +342,8 @@ const enUS = { refreshSuccess: 'Refresh successful', refreshFailed: 'Refresh failed: ', connectionSuccess: 'Connection successful', - connectionFailed: 'Connection failed', + connectionFailed: 'Connection failed, please check URL', + connectionFailedStatus: 'Connection Failed', toolsFound: 'tools', unknownError: 'Unknown error', noToolsFound: 'No tools found', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 682a6efd..804194eb 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -343,7 +343,8 @@ const jaJP = { refreshSuccess: '刷新に成功しました', refreshFailed: '刷新に失敗しました:', connectionSuccess: '接続に成功しました', - connectionFailed: '接続に失敗しました', + connectionFailed: '接続に失敗しました,URLを確認してください', + connectionFailedStatus: '接続失敗', toolsFound: '個のツール', unknownError: '不明なエラー', noToolsFound: 'ツールが見つかりません', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 4cf63476..d1543136 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -328,7 +328,8 @@ const zhHans = { refreshSuccess: '刷新成功', refreshFailed: '刷新失败:', connectionSuccess: '连接成功', - connectionFailed: '连接失败', + connectionFailed: '连接失败,请检查URL', + connectionFailedStatus: '连接失败', toolsFound: '个工具', unknownError: '未知错误', noToolsFound: '未找到任何工具', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index e9d97b30..efaf89dd 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -326,7 +326,8 @@ const zhHant = { refreshSuccess: '刷新成功', refreshFailed: '刷新失敗:', connectionSuccess: '連接成功', - connectionFailed: '連接失敗', + connectionFailed: '連接失敗,請檢查URL', + connectionFailedStatus: '連接失敗', toolsFound: '個工具', unknownError: '未知錯誤', noToolsFound: '未找到任何工具', From 777b766fffbe9c9b5905678633750a3c503edcc1 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Nov 2025 22:05:49 +0800 Subject: [PATCH 071/144] chore: bump version 4.4.0 --- pkg/utils/constants.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index aa557005..790a60d0 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = 'v4.3.9' +semantic_version = 'v4.4.0' required_database_version = 8 """Tag the version of the database schema, used to check if the database needs to be migrated""" diff --git a/pyproject.toml b/pyproject.toml index d1116a93..1f24c1b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.3.9" +version = "4.4.0" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" requires-python = ">=3.10.1,<4.0" From 94aa175c1a9acafc7624727b9b1f5b9f5fd7cb85 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 5 Nov 2025 12:11:46 +0800 Subject: [PATCH 072/144] chore: bump langbot-plugin to 0.1.7 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1f24c1b4..0b472da8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ dependencies = [ "langchain-text-splitters>=0.0.1", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.1.6", + "langbot-plugin==0.1.7", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", From 973e7bae4293cede5cea4d4931ee8a4948e32a59 Mon Sep 17 00:00:00 2001 From: Guanchao Wang Date: Wed, 5 Nov 2025 12:14:01 +0800 Subject: [PATCH 073/144] fix: wecombot id (#1747) --- libs/wecom_ai_bot_api/api.py | 12 +++++++++++- libs/wecom_ai_bot_api/wecombotevent.py | 16 +++++++++++++++- pkg/platform/sources/wecombot.py | 6 +++--- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/libs/wecom_ai_bot_api/api.py b/libs/wecom_ai_bot_api/api.py index 9568eab4..b20c6ed3 100644 --- a/libs/wecom_ai_bot_api/api.py +++ b/libs/wecom_ai_bot_api/api.py @@ -463,7 +463,17 @@ class WecomBotClient: base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey) message_data['picurl'] = base64 # 只保留第一个 image - message_data['userid'] = msg_json.get('from', {}).get('userid', '') + # Extract user information + from_info = msg_json.get('from', {}) + message_data['userid'] = from_info.get('userid', '') + message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '') + + # Extract chat/group information + if msg_json.get('chattype', '') == 'group': + message_data['chatid'] = msg_json.get('chatid', '') + # Try to get group name if available + message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '') + message_data['msgid'] = msg_json.get('msgid', '') if msg_json.get('aibotid'): diff --git a/libs/wecom_ai_bot_api/wecombotevent.py b/libs/wecom_ai_bot_api/wecombotevent.py index f2edeac7..099c58bc 100644 --- a/libs/wecom_ai_bot_api/wecombotevent.py +++ b/libs/wecom_ai_bot_api/wecombotevent.py @@ -22,7 +22,21 @@ class WecomBotEvent(dict): """ 用户id """ - return self.get('from', {}).get('userid', '') + return self.get('from', {}).get('userid', '') or self.get('userid', '') + + @property + def username(self) -> str: + """ + 用户名称 + """ + return self.get('username', '') or self.get('from', {}).get('alias', '') or self.get('from', {}).get('name', '') or self.userid + + @property + def chatname(self) -> str: + """ + 群组名称 + """ + return self.get('chatname', '') or str(self.chatid) @property def content(self) -> str: diff --git a/pkg/platform/sources/wecombot.py b/pkg/platform/sources/wecombot.py index e5f2d1b5..13dd8e92 100644 --- a/pkg/platform/sources/wecombot.py +++ b/pkg/platform/sources/wecombot.py @@ -49,7 +49,7 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.FriendMessage( sender=platform_entities.Friend( id=event.userid, - nickname='', + nickname=event.username, remark='', ), message_chain=message_chain, @@ -61,10 +61,10 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): sender = platform_entities.GroupMember( id=event.userid, permission='MEMBER', - member_name=event.userid, + member_name=event.username, group=platform_entities.Group( id=str(event.chatid), - name='', + name=event.chatname, permission=platform_entities.Permission.Member, ), special_title='', From f06e3d3efac74aa4cc7bc133e2bb3abe5008a3d4 Mon Sep 17 00:00:00 2001 From: Alfons Date: Wed, 5 Nov 2025 15:52:17 +0800 Subject: [PATCH 074/144] fix: disabling potential `thinking` param for model testing (#1733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 禁用模型默认思考功能以减少测试延迟 - 调整导入语句顺序 - 为没有显式设置 thinking 参数的模型添加禁用配置 - 避免某些模型厂商默认开启思考功能导致的测试延迟 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: 确保 extra_args 为空时也禁用思考功能 修复条件判断逻辑,当 extra_args 为空字典时也会添加思考功能禁用配置 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * perf(fe): increase default timeout * perf: llm model testing prompt --------- Co-authored-by: Claude Co-authored-by: Junyan Qin --- pkg/api/http/service/model.py | 12 +++++++++--- web/src/app/infra/http/BaseHttpClient.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/api/http/service/model.py b/pkg/api/http/service/model.py index 036c1b9c..17297ed1 100644 --- a/pkg/api/http/service/model.py +++ b/pkg/api/http/service/model.py @@ -1,13 +1,14 @@ from __future__ import annotations import uuid + import sqlalchemy +from langbot_plugin.api.entities.builtin.provider import message as provider_message from ....core import app from ....entity.persistence import model as persistence_model from ....entity.persistence import pipeline as persistence_pipeline from ....provider.modelmgr import requester as model_requester -from langbot_plugin.api.entities.builtin.provider import message as provider_message class LLMModelsService: @@ -104,12 +105,17 @@ class LLMModelsService: else: runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data) + # 有些模型厂商默认开启了思考功能,测试容易延迟 + extra_args = model_data.get('extra_args', {}) + if not extra_args or 'thinking' not in extra_args: + extra_args['thinking'] = {'type': 'disabled'} + await runtime_llm_model.requester.invoke_llm( query=None, model=runtime_llm_model, - messages=[provider_message.Message(role='user', content='Hello, world!')], + messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')], funcs=[], - extra_args=model_data.get('extra_args', {}), + extra_args=extra_args, ) diff --git a/web/src/app/infra/http/BaseHttpClient.ts b/web/src/app/infra/http/BaseHttpClient.ts index 019a54e6..cc2c31e4 100644 --- a/web/src/app/infra/http/BaseHttpClient.ts +++ b/web/src/app/infra/http/BaseHttpClient.ts @@ -38,7 +38,7 @@ export abstract class BaseHttpClient { this.instance = axios.create({ baseURL: baseURL, - timeout: 15000, + timeout: 30000, headers: { 'Content-Type': 'application/json', }, From 76a69ecc7ec19746407f36edf11da0ef0552169f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:15:15 +0800 Subject: [PATCH 075/144] Add environment variable override support for config.yaml (#1748) * Initial plan * Add environment variable override support for config.yaml Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Refactor env override code based on review feedback Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Add test for template completion with env overrides Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Move env override logic to load_config.py as requested Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * perf: add print log --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: Junyan Qin --- pkg/core/stages/load_config.py | 86 +++++ tests/unit_tests/config/__init__.py | 1 + tests/unit_tests/config/test_env_override.py | 332 +++++++++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 tests/unit_tests/config/__init__.py create mode 100644 tests/unit_tests/config/test_env_override.py diff --git a/pkg/core/stages/load_config.py b/pkg/core/stages/load_config.py index 0474b33a..2ef5623e 100644 --- a/pkg/core/stages/load_config.py +++ b/pkg/core/stages/load_config.py @@ -1,11 +1,93 @@ from __future__ import annotations import os +from typing import Any from .. import stage, app from ..bootutils import config +def _apply_env_overrides_to_config(cfg: dict) -> dict: + """Apply environment variable overrides to data/config.yaml + + Environment variables should be uppercase and use __ (double underscore) + to represent nested keys. For example: + - CONCURRENCY__PIPELINE overrides concurrency.pipeline + - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url + + Arrays and dict types are ignored. + + Args: + cfg: Configuration dictionary + + Returns: + Updated configuration dictionary + """ + + def convert_value(value: str, original_value: Any) -> Any: + """Convert string value to appropriate type based on original value + + Args: + value: String value from environment variable + original_value: Original value to infer type from + + Returns: + Converted value (falls back to string if conversion fails) + """ + if isinstance(original_value, bool): + return value.lower() in ('true', '1', 'yes', 'on') + elif isinstance(original_value, int): + try: + return int(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + elif isinstance(original_value, float): + try: + return float(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + else: + return value + + # Process environment variables + for env_key, env_value in os.environ.items(): + # Check if the environment variable is uppercase and contains __ + if not env_key.isupper(): + continue + if '__' not in env_key: + continue + + print(f'apply env overrides to config: env_key: {env_key}, env_value: {env_value}') + + # Convert environment variable name to config path + # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline'] + keys = [key.lower() for key in env_key.split('__')] + + # Navigate to the target value and validate the path + current = cfg + + for i, key in enumerate(keys): + if not isinstance(current, dict) or key not in current: + break + + if i == len(keys) - 1: + # At the final key - check if it's a scalar value + if isinstance(current[key], (dict, list)): + # Skip dict and list types + pass + else: + # Valid scalar value - convert and set it + converted_value = convert_value(env_value, current[key]) + current[key] = converted_value + else: + # Navigate deeper + current = current[key] + + return cfg + + @stage.stage_class('LoadConfigStage') class LoadConfigStage(stage.BootingStage): """Load config file stage""" @@ -54,6 +136,10 @@ class LoadConfigStage(stage.BootingStage): ap.instance_config = await config.load_yaml_config( 'data/config.yaml', 'templates/config.yaml', completion=False ) + + # Apply environment variable overrides to data/config.yaml + ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data) + await ap.instance_config.dump_config() ap.sensitive_meta = await config.load_json_config( diff --git a/tests/unit_tests/config/__init__.py b/tests/unit_tests/config/__init__.py new file mode 100644 index 00000000..b5afb6d9 --- /dev/null +++ b/tests/unit_tests/config/__init__.py @@ -0,0 +1 @@ +# Config unit tests diff --git a/tests/unit_tests/config/test_env_override.py b/tests/unit_tests/config/test_env_override.py new file mode 100644 index 00000000..d20988e9 --- /dev/null +++ b/tests/unit_tests/config/test_env_override.py @@ -0,0 +1,332 @@ +""" +Tests for environment variable override functionality in YAML config +""" + +import os +import pytest +from typing import Any + + +def _apply_env_overrides_to_config(cfg: dict) -> dict: + """Apply environment variable overrides to data/config.yaml + + Environment variables should be uppercase and use __ (double underscore) + to represent nested keys. For example: + - CONCURRENCY__PIPELINE overrides concurrency.pipeline + - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url + + Arrays and dict types are ignored. + + Args: + cfg: Configuration dictionary + + Returns: + Updated configuration dictionary + """ + def convert_value(value: str, original_value: Any) -> Any: + """Convert string value to appropriate type based on original value + + Args: + value: String value from environment variable + original_value: Original value to infer type from + + Returns: + Converted value (falls back to string if conversion fails) + """ + if isinstance(original_value, bool): + return value.lower() in ('true', '1', 'yes', 'on') + elif isinstance(original_value, int): + try: + return int(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + elif isinstance(original_value, float): + try: + return float(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + else: + return value + + # Process environment variables + for env_key, env_value in os.environ.items(): + # Check if the environment variable is uppercase and contains __ + if not env_key.isupper(): + continue + if '__' not in env_key: + continue + + # Convert environment variable name to config path + # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline'] + keys = [key.lower() for key in env_key.split('__')] + + # Navigate to the target value and validate the path + current = cfg + + for i, key in enumerate(keys): + if not isinstance(current, dict) or key not in current: + break + + if i == len(keys) - 1: + # At the final key - check if it's a scalar value + if isinstance(current[key], (dict, list)): + # Skip dict and list types + pass + else: + # Valid scalar value - convert and set it + converted_value = convert_value(env_value, current[key]) + current[key] = converted_value + else: + # Navigate deeper + current = current[key] + + return cfg + + +class TestEnvOverrides: + """Test environment variable override functionality""" + + def test_simple_string_override(self): + """Test overriding a simple string value""" + cfg = { + 'api': { + 'port': 5300 + } + } + + # Set environment variable + os.environ['API__PORT'] = '8080' + + result = _apply_env_overrides_to_config(cfg) + + assert result['api']['port'] == 8080 + + # Cleanup + del os.environ['API__PORT'] + + def test_nested_key_override(self): + """Test overriding nested keys with __ delimiter""" + cfg = { + 'concurrency': { + 'pipeline': 20, + 'session': 1 + } + } + + os.environ['CONCURRENCY__PIPELINE'] = '50' + + result = _apply_env_overrides_to_config(cfg) + + assert result['concurrency']['pipeline'] == 50 + assert result['concurrency']['session'] == 1 # Unchanged + + del os.environ['CONCURRENCY__PIPELINE'] + + def test_deep_nested_override(self): + """Test overriding deeply nested keys""" + cfg = { + 'system': { + 'jwt': { + 'expire': 604800, + 'secret': '' + } + } + } + + os.environ['SYSTEM__JWT__EXPIRE'] = '86400' + os.environ['SYSTEM__JWT__SECRET'] = 'my_secret_key' + + result = _apply_env_overrides_to_config(cfg) + + assert result['system']['jwt']['expire'] == 86400 + assert result['system']['jwt']['secret'] == 'my_secret_key' + + del os.environ['SYSTEM__JWT__EXPIRE'] + del os.environ['SYSTEM__JWT__SECRET'] + + def test_underscore_in_key(self): + """Test keys with underscores like runtime_ws_url""" + cfg = { + 'plugin': { + 'enable': True, + 'runtime_ws_url': 'ws://localhost:5400/control/ws' + } + } + + os.environ['PLUGIN__RUNTIME_WS_URL'] = 'ws://newhost:6000/ws' + + result = _apply_env_overrides_to_config(cfg) + + assert result['plugin']['runtime_ws_url'] == 'ws://newhost:6000/ws' + + del os.environ['PLUGIN__RUNTIME_WS_URL'] + + def test_boolean_conversion(self): + """Test boolean value conversion""" + cfg = { + 'plugin': { + 'enable': True, + 'enable_marketplace': False + } + } + + os.environ['PLUGIN__ENABLE'] = 'false' + os.environ['PLUGIN__ENABLE_MARKETPLACE'] = 'true' + + result = _apply_env_overrides_to_config(cfg) + + assert result['plugin']['enable'] is False + assert result['plugin']['enable_marketplace'] is True + + del os.environ['PLUGIN__ENABLE'] + del os.environ['PLUGIN__ENABLE_MARKETPLACE'] + + def test_ignore_dict_type(self): + """Test that dict types are ignored""" + cfg = { + 'database': { + 'use': 'sqlite', + 'sqlite': { + 'path': 'data/langbot.db' + } + } + } + + # Try to override a dict value - should be ignored + os.environ['DATABASE__SQLITE'] = 'new_value' + + result = _apply_env_overrides_to_config(cfg) + + # Should remain a dict, not overridden + assert isinstance(result['database']['sqlite'], dict) + assert result['database']['sqlite']['path'] == 'data/langbot.db' + + del os.environ['DATABASE__SQLITE'] + + def test_ignore_list_type(self): + """Test that list/array types are ignored""" + cfg = { + 'admins': ['admin1', 'admin2'], + 'command': { + 'enable': True, + 'prefix': ['!', '!'] + } + } + + # Try to override list values - should be ignored + os.environ['ADMINS'] = 'admin3' + os.environ['COMMAND__PREFIX'] = '?' + + result = _apply_env_overrides_to_config(cfg) + + # Should remain lists, not overridden + assert isinstance(result['admins'], list) + assert result['admins'] == ['admin1', 'admin2'] + assert isinstance(result['command']['prefix'], list) + assert result['command']['prefix'] == ['!', '!'] + + del os.environ['ADMINS'] + del os.environ['COMMAND__PREFIX'] + + def test_lowercase_env_var_ignored(self): + """Test that lowercase environment variables are ignored""" + cfg = { + 'api': { + 'port': 5300 + } + } + + os.environ['api__port'] = '8080' + + result = _apply_env_overrides_to_config(cfg) + + # Should not be overridden + assert result['api']['port'] == 5300 + + del os.environ['api__port'] + + def test_no_double_underscore_ignored(self): + """Test that env vars without __ are ignored""" + cfg = { + 'api': { + 'port': 5300 + } + } + + os.environ['APIPORT'] = '8080' + + result = _apply_env_overrides_to_config(cfg) + + # Should not be overridden + assert result['api']['port'] == 5300 + + del os.environ['APIPORT'] + + def test_nonexistent_key_ignored(self): + """Test that env vars for non-existent keys are ignored""" + cfg = { + 'api': { + 'port': 5300 + } + } + + os.environ['API__NONEXISTENT'] = 'value' + + result = _apply_env_overrides_to_config(cfg) + + # Should not create new key + assert 'nonexistent' not in result['api'] + + del os.environ['API__NONEXISTENT'] + + def test_integer_conversion(self): + """Test integer value conversion""" + cfg = { + 'concurrency': { + 'pipeline': 20 + } + } + + os.environ['CONCURRENCY__PIPELINE'] = '100' + + result = _apply_env_overrides_to_config(cfg) + + assert result['concurrency']['pipeline'] == 100 + assert isinstance(result['concurrency']['pipeline'], int) + + del os.environ['CONCURRENCY__PIPELINE'] + + def test_multiple_overrides(self): + """Test multiple environment variable overrides at once""" + cfg = { + 'api': { + 'port': 5300 + }, + 'concurrency': { + 'pipeline': 20, + 'session': 1 + }, + 'plugin': { + 'enable': False + } + } + + os.environ['API__PORT'] = '8080' + os.environ['CONCURRENCY__PIPELINE'] = '50' + os.environ['PLUGIN__ENABLE'] = 'true' + + result = _apply_env_overrides_to_config(cfg) + + assert result['api']['port'] == 8080 + assert result['concurrency']['pipeline'] == 50 + assert result['plugin']['enable'] is True + + del os.environ['API__PORT'] + del os.environ['CONCURRENCY__PIPELINE'] + del os.environ['PLUGIN__ENABLE'] + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) From 74a5e37892ae2ec3c527f9edde369fb69ca16e5a Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 5 Nov 2025 18:34:40 +0800 Subject: [PATCH 076/144] perf: plugin market layout --- .../plugin-market/PluginMarketComponent.tsx | 12 +++---- .../PluginMarketCardComponent.tsx | 36 ++++++++++--------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index b9835253..711bd71b 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -283,7 +283,7 @@ function MarketPageContent({ // }; return ( -
+
{/* 搜索框 */}
@@ -301,19 +301,19 @@ function MarketPageContent({ handleSearch(searchQuery); } }} - className="pl-10 pr-4" + className="pl-10 pr-4 text-sm sm:text-base" />
{/* 排序下拉框 */}
-
- +
+ {t('market.sortBy')}: ; + case DynamicFormItemType.TEXT: + return