mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: code by huntun
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ test.py
|
||||
.venv/
|
||||
uv.lock
|
||||
/test
|
||||
plugins.bak
|
||||
@@ -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)
|
||||
|
||||
143
pkg/api/http/controller/groups/market.py
Normal file
143
pkg/api/http/controller/groups/market.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
import datetime
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('market', '/api/v1/market')
|
||||
class MarketRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/plugins', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""获取插件市场列表"""
|
||||
# data = await quart.request.json
|
||||
# page = data.get('page', 1)
|
||||
# page_size = data.get('page_size', 10)
|
||||
# query = data.get('query', '')
|
||||
# sort_by = data.get('sort_by', 'stars')
|
||||
# sort_order = data.get('sort_order', 'DESC')
|
||||
|
||||
# # 这里是获取插件列表的实现
|
||||
# # 实际项目中这部分会连接到真实的插件市场API或数据库
|
||||
# # 这里我们只是返回一些假数据作为示例
|
||||
|
||||
# # 模拟延迟
|
||||
# import asyncio
|
||||
|
||||
# await asyncio.sleep(0.5)
|
||||
|
||||
# 返回结果
|
||||
return self.success(data={'plugins': [], 'total': 0})
|
||||
|
||||
@self.route('/mcp', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""获取MCP服务器市场列表"""
|
||||
data = await quart.request.json
|
||||
page = data.get('page', 1)
|
||||
page_size = data.get('page_size', 10)
|
||||
query = data.get('query', '')
|
||||
sort_by = data.get('sort_by', 'stars')
|
||||
sort_order = data.get('sort_order', 'DESC')
|
||||
|
||||
# 这里是获取MCP服务器列表的实现
|
||||
# 实际项目中这部分会连接到真实的MCP市场API或数据库
|
||||
# 这里我们只是返回一些假数据作为示例
|
||||
|
||||
# 模拟延迟
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 生成假数据
|
||||
servers = []
|
||||
|
||||
# 只在有搜索关键词或排序时才返回数据
|
||||
if query or sort_by:
|
||||
now = datetime.datetime.now().isoformat()
|
||||
yesterday = (datetime.datetime.now() - datetime.timedelta(days=1)).isoformat()
|
||||
|
||||
test_servers = [
|
||||
{
|
||||
'ID': 1,
|
||||
'CreatedAt': yesterday,
|
||||
'UpdatedAt': now,
|
||||
'DeletedAt': None,
|
||||
'name': 'Google Maps MCP',
|
||||
'author': 'langbot-community',
|
||||
'description': 'Google Maps integration for LangBot, providing geocoding and directions capabilities.',
|
||||
'repository': 'langbot-community/google-maps-mcp',
|
||||
'artifacts_path': '',
|
||||
'stars': 124,
|
||||
'downloads': 342,
|
||||
'status': 'initialized',
|
||||
'synced_at': now,
|
||||
'pushed_at': now,
|
||||
'version': '1.0.0',
|
||||
},
|
||||
{
|
||||
'ID': 2,
|
||||
'CreatedAt': yesterday,
|
||||
'UpdatedAt': now,
|
||||
'DeletedAt': None,
|
||||
'name': 'Weather MCP',
|
||||
'author': 'langbot-community',
|
||||
'description': 'Weather integration for LangBot, providing current weather and forecasts.',
|
||||
'repository': 'langbot-community/weather-mcp',
|
||||
'artifacts_path': '',
|
||||
'stars': 85,
|
||||
'downloads': 215,
|
||||
'status': 'initialized',
|
||||
'synced_at': now,
|
||||
'pushed_at': yesterday,
|
||||
'version': '1.1.0',
|
||||
},
|
||||
{
|
||||
'ID': 3,
|
||||
'CreatedAt': yesterday,
|
||||
'UpdatedAt': now,
|
||||
'DeletedAt': None,
|
||||
'name': 'Serper Search MCP',
|
||||
'author': 'langbot-developers',
|
||||
'description': 'Serper Search integration for LangBot, providing advanced web search capabilities.',
|
||||
'repository': 'langbot-developers/serper-search-mcp',
|
||||
'artifacts_path': '',
|
||||
'stars': 67,
|
||||
'downloads': 178,
|
||||
'status': 'initialized',
|
||||
'synced_at': now,
|
||||
'pushed_at': yesterday,
|
||||
'version': '0.9.0',
|
||||
},
|
||||
]
|
||||
|
||||
# 应用搜索过滤
|
||||
if query:
|
||||
query = query.lower()
|
||||
servers = [
|
||||
s
|
||||
for s in test_servers
|
||||
if query in s['name'].lower()
|
||||
or query in s['description'].lower()
|
||||
or query in s['author'].lower()
|
||||
]
|
||||
else:
|
||||
servers = test_servers
|
||||
|
||||
# 应用排序
|
||||
reverse = sort_order.upper() == 'DESC'
|
||||
if sort_by == 'stars':
|
||||
servers = sorted(servers, key=lambda s: s['stars'], reverse=reverse)
|
||||
elif sort_by == 'created_at':
|
||||
servers = sorted(servers, key=lambda s: s['CreatedAt'], reverse=reverse)
|
||||
elif sort_by == 'pushed_at':
|
||||
servers = sorted(servers, key=lambda s: s['pushed_at'], reverse=reverse)
|
||||
|
||||
# 应用分页
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
servers = servers[start_idx:end_idx]
|
||||
|
||||
# 返回结果
|
||||
return self.success(data={'servers': servers, 'total': len(servers)})
|
||||
351
pkg/api/http/controller/groups/mcp.py
Normal file
351
pkg/api/http/controller/groups/mcp.py
Normal file
@@ -0,0 +1,351 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
import asyncio
|
||||
|
||||
from .....core import taskmgr
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('mcp', '/api/v1/mcp')
|
||||
class MCPRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/servers', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""获取MCP服务器列表"""
|
||||
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
|
||||
return self.success(data={'servers': []})
|
||||
|
||||
servers = self.ap.provider_cfg.data.get('mcp', {}).get('servers', [])
|
||||
|
||||
# 获取每个服务器的状态和工具信息
|
||||
mcp_loader = None
|
||||
for loader_name, loader in self.ap.tool_mgr.loaders.items():
|
||||
if loader_name == 'mcp':
|
||||
mcp_loader = loader
|
||||
break
|
||||
|
||||
servers_with_status = []
|
||||
for server in servers:
|
||||
server_info = {
|
||||
'name': server['name'],
|
||||
'mode': server['mode'],
|
||||
'enable': server['enable'],
|
||||
'config': server,
|
||||
'status': 'disconnected',
|
||||
'tools': [],
|
||||
'error': None,
|
||||
}
|
||||
|
||||
# 检查服务器连接状态
|
||||
if mcp_loader and server['name'] in mcp_loader.sessions:
|
||||
session = mcp_loader.sessions[server['name']]
|
||||
server_info['status'] = 'connected'
|
||||
server_info['tools'] = [
|
||||
{'name': func.name, 'description': func.description, 'parameters': func.parameters}
|
||||
for func in session.functions
|
||||
]
|
||||
elif server['enable']:
|
||||
server_info['status'] = 'error'
|
||||
server_info['error'] = 'Failed to connect'
|
||||
|
||||
servers_with_status.append(server_info)
|
||||
|
||||
return self.success(data={'servers': servers_with_status})
|
||||
|
||||
@self.route('/servers', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""创建MCP服务器配置"""
|
||||
data = await quart.request.json
|
||||
|
||||
# 验证必填字段
|
||||
required_fields = ['name', 'mode']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return self.http_status(400, -1, f'Missing required field: {field}')
|
||||
|
||||
# 检查provider_cfg是否可用
|
||||
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
|
||||
return self.http_status(500, -1, 'Provider configuration not available')
|
||||
|
||||
# 获取当前配置
|
||||
mcp_config = self.ap.provider_cfg.data.get('mcp', {'servers': []})
|
||||
servers = mcp_config['servers']
|
||||
|
||||
# 检查服务器名称是否重复
|
||||
for server in servers:
|
||||
if server['name'] == data['name']:
|
||||
return self.http_status(400, -1, 'Server name already exists')
|
||||
|
||||
# 创建新服务器配置
|
||||
new_server = {
|
||||
'name': data['name'],
|
||||
'mode': data['mode'],
|
||||
'enable': data.get('enable', True),
|
||||
}
|
||||
|
||||
# 根据模式添加配置
|
||||
if data['mode'] == 'stdio':
|
||||
new_server.update(
|
||||
{'command': data.get('command', ''), 'args': data.get('args', []), 'env': data.get('env', {})}
|
||||
)
|
||||
elif data['mode'] == 'sse':
|
||||
new_server.update(
|
||||
{'url': data.get('url', ''), 'headers': data.get('headers', {}), 'timeout': data.get('timeout', 10)}
|
||||
)
|
||||
|
||||
# 添加到配置
|
||||
servers.append(new_server)
|
||||
self.ap.provider_cfg.data['mcp'] = mcp_config
|
||||
|
||||
# 保存配置
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
|
||||
# 如果启用,尝试重新加载MCP loader
|
||||
if new_server['enable']:
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self._reload_mcp_loader(ctx),
|
||||
kind='mcp-operation',
|
||||
name=f'mcp-reload-{new_server["name"]}',
|
||||
label=f'Reloading MCP loader for {new_server["name"]}',
|
||||
context=ctx,
|
||||
)
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
"""获取、更新或删除MCP服务器配置"""
|
||||
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
|
||||
return self.http_status(500, -1, 'Provider configuration not available')
|
||||
|
||||
mcp_config = self.ap.provider_cfg.data.get('mcp', {'servers': []})
|
||||
servers = mcp_config['servers']
|
||||
|
||||
# 查找服务器
|
||||
server_index = None
|
||||
for i, server in enumerate(servers):
|
||||
if server['name'] == server_name:
|
||||
server_index = i
|
||||
break
|
||||
|
||||
if server_index is None:
|
||||
return self.http_status(404, -1, 'Server not found')
|
||||
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'server': servers[server_index]})
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
server = servers[server_index]
|
||||
|
||||
# 更新配置
|
||||
server.update(
|
||||
{
|
||||
'enable': data.get('enable', server.get('enable', True)),
|
||||
}
|
||||
)
|
||||
|
||||
# 根据模式更新特定配置
|
||||
if server['mode'] == 'stdio':
|
||||
server.update(
|
||||
{
|
||||
'command': data.get('command', server.get('command', '')),
|
||||
'args': data.get('args', server.get('args', [])),
|
||||
'env': data.get('env', server.get('env', {})),
|
||||
}
|
||||
)
|
||||
elif server['mode'] == 'sse':
|
||||
server.update(
|
||||
{
|
||||
'url': data.get('url', server.get('url', '')),
|
||||
'headers': data.get('headers', server.get('headers', {})),
|
||||
'timeout': data.get('timeout', server.get('timeout', 10)),
|
||||
}
|
||||
)
|
||||
|
||||
# 保存配置
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
|
||||
# 重新加载MCP loader
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self._reload_mcp_loader(ctx),
|
||||
kind='mcp-operation',
|
||||
name=f'mcp-reload-{server_name}',
|
||||
label=f'Reloading MCP loader for {server_name}',
|
||||
context=ctx,
|
||||
)
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
# 删除服务器
|
||||
servers.pop(server_index)
|
||||
self.ap.provider_cfg.data['mcp'] = mcp_config
|
||||
|
||||
# 保存配置
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
|
||||
# 重新加载MCP loader
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self._reload_mcp_loader(ctx),
|
||||
kind='mcp-operation',
|
||||
name=f'mcp-remove-{server_name}',
|
||||
label=f'Removing MCP server {server_name}',
|
||||
context=ctx,
|
||||
)
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/servers/<server_name>/toggle', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
"""切换MCP服务器启用状态"""
|
||||
data = await quart.request.json
|
||||
target_enabled = data.get('target_enabled')
|
||||
|
||||
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
|
||||
return self.http_status(500, -1, 'Provider configuration not available')
|
||||
|
||||
mcp_config = self.ap.provider_cfg.data.get('mcp', {'servers': []})
|
||||
servers = mcp_config['servers']
|
||||
|
||||
# 查找并更新服务器
|
||||
for server in servers:
|
||||
if server['name'] == server_name:
|
||||
server['enable'] = target_enabled
|
||||
break
|
||||
else:
|
||||
return self.http_status(404, -1, 'Server not found')
|
||||
|
||||
# 保存配置
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
|
||||
# 重新加载MCP loader
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self._reload_mcp_loader(ctx),
|
||||
kind='mcp-operation',
|
||||
name=f'mcp-toggle-{server_name}',
|
||||
label=f'Toggling MCP server {server_name}',
|
||||
context=ctx,
|
||||
)
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
"""测试MCP服务器连接"""
|
||||
if not self.ap or not self.ap.provider_cfg or not self.ap.provider_cfg.data:
|
||||
return self.http_status(500, -1, 'Provider configuration not available')
|
||||
|
||||
mcp_config = self.ap.provider_cfg.data.get('mcp', {'servers': []})
|
||||
servers = mcp_config['servers']
|
||||
|
||||
# 查找服务器配置
|
||||
server_config = None
|
||||
for server in servers:
|
||||
if server['name'] == server_name:
|
||||
server_config = server
|
||||
break
|
||||
|
||||
if server_config is None:
|
||||
return self.http_status(404, -1, 'Server not found')
|
||||
|
||||
# 创建测试任务
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self._test_mcp_server(server_config, ctx),
|
||||
kind='mcp-operation',
|
||||
name=f'mcp-test-{server_name}',
|
||||
label=f'Testing MCP server {server_name}',
|
||||
context=ctx,
|
||||
)
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""从GitHub安装MCP服务器"""
|
||||
data = await quart.request.json
|
||||
source = data.get('source')
|
||||
|
||||
if not source:
|
||||
return self.http_status(400, -1, 'Missing source parameter')
|
||||
|
||||
# 创建安装任务
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self._install_mcp_from_github(source, ctx),
|
||||
kind='mcp-operation',
|
||||
name='install-mcp-github',
|
||||
label=f'Installing MCP from GitHub: {source}',
|
||||
context=ctx,
|
||||
)
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
async def _reload_mcp_loader(self, ctx: taskmgr.TaskContext):
|
||||
"""重新加载MCP loader"""
|
||||
try:
|
||||
ctx.current_action = 'Stopping existing MCP sessions'
|
||||
# 停止现有的MCP会话
|
||||
mcp_loader = None
|
||||
for loader_name, loader in self.ap.tool_mgr.loaders.items():
|
||||
if loader_name == 'mcp':
|
||||
mcp_loader = loader
|
||||
break
|
||||
|
||||
if mcp_loader:
|
||||
await mcp_loader.shutdown()
|
||||
|
||||
ctx.current_action = 'Reloading MCP configuration'
|
||||
# 重新加载MCP loader
|
||||
await self.ap.tool_mgr.reload_loader('mcp')
|
||||
|
||||
ctx.current_action = 'MCP loader reloaded successfully'
|
||||
|
||||
except Exception as e:
|
||||
ctx.current_action = f'Failed to reload MCP loader: {str(e)}'
|
||||
raise e
|
||||
|
||||
async def _test_mcp_server(self, server_config: dict, ctx: taskmgr.TaskContext):
|
||||
"""测试MCP服务器连接"""
|
||||
try:
|
||||
from .....provider.tools.loaders.mcp import RuntimeMCPSession
|
||||
|
||||
ctx.current_action = f'Testing connection to {server_config["name"]}'
|
||||
|
||||
# 创建临时会话进行测试
|
||||
session = RuntimeMCPSession(server_config['name'], server_config, self.ap)
|
||||
await session.initialize()
|
||||
|
||||
# 获取工具列表作为测试
|
||||
tools_count = len(session.functions)
|
||||
ctx.current_action = f'Successfully connected. Found {tools_count} tools.'
|
||||
|
||||
# 关闭测试会话
|
||||
await session.shutdown()
|
||||
|
||||
return {'status': 'success', 'tools_count': tools_count}
|
||||
|
||||
except Exception as e:
|
||||
ctx.current_action = f'Connection test failed: {str(e)}'
|
||||
raise e
|
||||
|
||||
async def _install_mcp_from_github(self, source: str, ctx: taskmgr.TaskContext):
|
||||
"""从GitHub安装MCP服务器的实现"""
|
||||
try:
|
||||
ctx.current_action = f'Installing MCP server from {source}'
|
||||
|
||||
# 这里是安装逻辑的占位符
|
||||
# 实际实现将包括克隆仓库、解析配置、安装依赖等步骤
|
||||
|
||||
# 模拟安装过程
|
||||
|
||||
await asyncio.sleep(2) # 模拟安装过程
|
||||
|
||||
# 返回成功结果
|
||||
return {'status': 'success', 'message': f'Successfully installed MCP server from {source}'}
|
||||
|
||||
except Exception as e:
|
||||
ctx.current_action = f'Failed to install MCP server: {str(e)}'
|
||||
raise e
|
||||
@@ -22,6 +22,7 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_
|
||||
from ..logger import EventLogger
|
||||
|
||||
|
||||
|
||||
# 语音功能相关异常定义
|
||||
class VoiceConnectionError(Exception):
|
||||
"""语音连接基础异常"""
|
||||
|
||||
251
web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx
Normal file
251
web/src/app/home/plugins/mcp-market/MCPMarketComponent.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import styles from '@/app/home/plugins/plugins.module.css';
|
||||
import { MCPMarketCardVO } from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO';
|
||||
import MCPMarketCardComponent from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent';
|
||||
import { spaceClient } from '@/app/infra/http/HttpClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export default function MCPMarketComponent({
|
||||
askInstallServer,
|
||||
}: {
|
||||
askInstallServer: (githubURL: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [marketServerList, setMarketServerList] = useState<MCPMarketCardVO[]>(
|
||||
[],
|
||||
);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [nowPage, setNowPage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sortByValue, setSortByValue] = useState<string>('pushed_at');
|
||||
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
|
||||
const searchTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const pageSize = 12;
|
||||
|
||||
useEffect(() => {
|
||||
initData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function initData() {
|
||||
getServerList();
|
||||
}
|
||||
|
||||
function onInputSearchKeyword(keyword: string) {
|
||||
setSearchKeyword(keyword);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (searchTimeout.current) {
|
||||
clearTimeout(searchTimeout.current);
|
||||
}
|
||||
|
||||
// 设置新的定时器
|
||||
searchTimeout.current = setTimeout(() => {
|
||||
setNowPage(1);
|
||||
getServerList(1, keyword);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function getServerList(
|
||||
page: number = nowPage,
|
||||
keyword: string = searchKeyword,
|
||||
sortBy: string = sortByValue,
|
||||
sortOrder: string = sortOrderValue,
|
||||
) {
|
||||
setLoading(true);
|
||||
spaceClient
|
||||
.getMCPMarketServers(page, pageSize, keyword, sortBy, sortOrder)
|
||||
.then((res) => {
|
||||
setMarketServerList(
|
||||
res.servers.map((marketServer) => {
|
||||
let repository = marketServer.repository;
|
||||
if (repository.startsWith('https://github.com/')) {
|
||||
repository = repository.replace('https://github.com/', '');
|
||||
}
|
||||
|
||||
if (repository.startsWith('github.com/')) {
|
||||
repository = repository.replace('github.com/', '');
|
||||
}
|
||||
|
||||
const author = repository.split('/')[0];
|
||||
const name = repository.split('/')[1];
|
||||
return new MCPMarketCardVO({
|
||||
author: author,
|
||||
description: marketServer.description,
|
||||
githubURL: `https://github.com/${repository}`,
|
||||
name: name,
|
||||
serverId: String(marketServer.ID),
|
||||
starCount: marketServer.stars,
|
||||
version:
|
||||
'version' in marketServer
|
||||
? String(marketServer.version)
|
||||
: '1.0.0', // 如果没有提供版本,则默认为1.0.0
|
||||
});
|
||||
}),
|
||||
);
|
||||
setTotalCount(res.total);
|
||||
setLoading(false);
|
||||
console.log('market servers:', res);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(t('mcp.getServerListError'), error);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
setNowPage(page);
|
||||
getServerList(page);
|
||||
}
|
||||
|
||||
function handleSortChange(value: string) {
|
||||
const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim());
|
||||
setSortByValue(newSortBy);
|
||||
setSortOrderValue(newSortOrder);
|
||||
setNowPage(1);
|
||||
getServerList(1, searchKeyword, newSortBy, newSortOrder);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.marketComponentBody}`}>
|
||||
<div className="flex items-center justify-start mb-2 mt-2 pl-[0.8rem] pr-[0.8rem]">
|
||||
<Input
|
||||
style={{
|
||||
width: '300px',
|
||||
}}
|
||||
value={searchKeyword}
|
||||
placeholder={t('mcp.searchServer')}
|
||||
onChange={(e) => onInputSearchKeyword(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={`${sortByValue},${sortOrderValue}`}
|
||||
onValueChange={handleSortChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] ml-2 cursor-pointer">
|
||||
<SelectValue placeholder={t('mcp.sortBy')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stars,DESC">{t('mcp.mostStars')}</SelectItem>
|
||||
<SelectItem value="created_at,DESC">
|
||||
{t('mcp.recentlyAdded')}
|
||||
</SelectItem>
|
||||
<SelectItem value="pushed_at,DESC">
|
||||
{t('mcp.recentlyUpdated')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center justify-end ml-2">
|
||||
{totalCount > 0 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem className="cursor-pointer">
|
||||
<PaginationPrevious
|
||||
onClick={() => handlePageChange(nowPage - 1)}
|
||||
className={
|
||||
nowPage <= 1 ? 'pointer-events-none opacity-50' : ''
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{/* 如果总页数大于5,则只显示5页,如果总页数小于5,则显示所有页 */}
|
||||
{(() => {
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const maxVisiblePages = 5;
|
||||
let startPage = Math.max(
|
||||
1,
|
||||
nowPage - Math.floor(maxVisiblePages / 2),
|
||||
);
|
||||
const endPage = Math.min(
|
||||
totalPages,
|
||||
startPage + maxVisiblePages - 1,
|
||||
);
|
||||
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, i) => {
|
||||
const pageNum = startPage + i;
|
||||
return (
|
||||
<PaginationItem
|
||||
key={pageNum}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<PaginationLink
|
||||
isActive={pageNum === nowPage}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
>
|
||||
<span className="text-black select-none">
|
||||
{pageNum}
|
||||
</span>
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
})()}
|
||||
|
||||
<PaginationItem className="cursor-pointer">
|
||||
<PaginationNext
|
||||
onClick={() => handlePageChange(nowPage + 1)}
|
||||
className={
|
||||
nowPage >= Math.ceil(totalCount / pageSize)
|
||||
? 'pointer-events-none opacity-50'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.pluginListContainer}`}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{t('mcp.loading')}
|
||||
</div>
|
||||
) : marketServerList.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{t('mcp.noMatchingServers')}
|
||||
</div>
|
||||
) : (
|
||||
marketServerList.map((vo, index) => (
|
||||
<div key={`${vo.serverId}-${index}`}>
|
||||
<MCPMarketCardComponent
|
||||
cardVO={vo}
|
||||
installServer={(githubURL) => {
|
||||
askInstallServer(githubURL);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MCPMarketCardVO } from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function MCPMarketCardComponent({
|
||||
cardVO,
|
||||
installServer,
|
||||
}: {
|
||||
cardVO: MCPMarketCardVO;
|
||||
installServer: (serverURL: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function handleInstallClick(serverURL: string) {
|
||||
installServer(serverURL);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem]">
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
<svg
|
||||
className="w-16 h-16 text-[#2288ee]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M13.5 2C13.5 2.82843 14.1716 3.5 15 3.5C15.8284 3.5 16.5 2.82843 16.5 2C16.5 1.17157 15.8284 0.5 15 0.5C14.1716 0.5 13.5 1.17157 13.5 2ZM8.5 8C8.5 8.82843 9.17157 9.5 10 9.5C10.8284 9.5 11.5 8.82843 11.5 8C11.5 7.17157 10.8284 6.5 10 6.5C9.17157 6.5 8.5 7.17157 8.5 8ZM1.5 14C1.5 14.8284 2.17157 15.5 3 15.5C3.82843 15.5 4.5 14.8284 4.5 14C4.5 13.1716 3.82843 12.5 3 12.5C2.17157 12.5 1.5 13.1716 1.5 14ZM19.5 14C19.5 14.8284 20.1716 15.5 21 15.5C21.8284 15.5 22.5 14.8284 22.5 14C22.5 13.1716 21.8284 12.5 21 12.5C20.1716 12.5 19.5 13.1716 19.5 14ZM8.5 20C8.5 20.8284 9.17157 21.5 10 21.5C10.8284 21.5 11.5 20.8284 11.5 20C11.5 19.1716 10.8284 19 10 19C9.17157 19 8.5 19.1716 8.5 20ZM2.5 8L6.5 8L6.5 10L2.5 10L2.5 8ZM13.5 8L17.5 8L17.5 10L13.5 10L13.5 8ZM8.5 2L8.5 6L10.5 6L10.5 2L8.5 2ZM8.5 14L8.5 18L10.5 18L10.5 14L8.5 14ZM2.5 14L6.5 14L6.5 16L2.5 16L2.5 14ZM13.5 14L17.5 14L17.5 16L13.5 16L13.5 14Z"></path>
|
||||
</svg>
|
||||
|
||||
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="text-[0.7rem] text-[#666]">
|
||||
{cardVO.author} /{' '}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
|
||||
<div className="text-[1.2rem] text-black">{cardVO.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[0.8rem] text-[#666] line-clamp-2">
|
||||
{cardVO.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row items-start justify-between gap-[0.6rem]">
|
||||
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
||||
<svg
|
||||
className="w-[1.2rem] h-[1.2rem] text-[#ffcd27]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z"></path>
|
||||
</svg>
|
||||
<div className="text-base text-[#ffcd27] font-medium">
|
||||
{t('mcp.starCount', { count: cardVO.starCount })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
||||
<svg
|
||||
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
onClick={() => window.open(cardVO.githubURL, '_blank')}
|
||||
>
|
||||
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
|
||||
</svg>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleInstallClick(cardVO.githubURL);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t('mcp.install')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
47
web/src/app/home/plugins/mcp/MCPCardVO.ts
Normal file
47
web/src/app/home/plugins/mcp/MCPCardVO.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { MCPServer, MCPServerConfig } from '@/app/infra/entities/api';
|
||||
|
||||
export class MCPCardVO {
|
||||
name: string;
|
||||
mode: 'stdio' | 'sse';
|
||||
enable: boolean;
|
||||
status: 'connected' | 'disconnected' | 'error';
|
||||
tools: number;
|
||||
error?: string;
|
||||
config: MCPServerConfig;
|
||||
|
||||
constructor(data: MCPServer) {
|
||||
this.name = data.name;
|
||||
this.mode = data.mode;
|
||||
this.enable = data.enable;
|
||||
this.status = data.status;
|
||||
this.tools = data.tools.length;
|
||||
this.error = data.error;
|
||||
this.config = data.config;
|
||||
}
|
||||
|
||||
getStatusColor(): string {
|
||||
switch (this.status) {
|
||||
case 'connected':
|
||||
return 'text-green-600';
|
||||
case 'disconnected':
|
||||
return 'text-gray-500';
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
switch (this.status) {
|
||||
case 'connected':
|
||||
return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
case 'disconnected':
|
||||
return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
case 'error':
|
||||
return 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
default:
|
||||
return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
}
|
||||
}
|
||||
}
|
||||
217
web/src/app/home/plugins/mcp/MCPComponent.tsx
Normal file
217
web/src/app/home/plugins/mcp/MCPComponent.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO';
|
||||
import MCPCardComponent from '@/app/home/plugins/mcp/mcp-card/MCPCardComponent';
|
||||
import MCPForm from '@/app/home/plugins/mcp/mcp-form/MCPForm';
|
||||
import styles from '@/app/home/plugins/plugins.module.css';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface MCPComponentRef {
|
||||
refreshServerList: () => void;
|
||||
createServer: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const MCPComponent = forwardRef<MCPComponentRef>((props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [serverList, setServerList] = useState<MCPCardVO[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [selectedServer, setSelectedServer] = useState<MCPCardVO | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState<boolean>(false);
|
||||
const [serverToDelete, setServerToDelete] = useState<MCPCardVO | null>(null);
|
||||
const [deleting, setDeleting] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
initData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function initData() {
|
||||
getServerList();
|
||||
}
|
||||
|
||||
function getServerList() {
|
||||
httpClient
|
||||
.getMCPServers()
|
||||
.then((value) => {
|
||||
setServerList(value.servers.map((server) => new MCPCardVO(server)));
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t('mcp.getServerListError') + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refreshServerList: getServerList,
|
||||
createServer: () => {
|
||||
setSelectedServer(null);
|
||||
setModalOpen(true);
|
||||
},
|
||||
}));
|
||||
|
||||
function handleServerClick(server: MCPCardVO) {
|
||||
setSelectedServer(server);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDeleteClick(server: MCPCardVO, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setServerToDelete(server);
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!serverToDelete) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await httpClient.deleteMCPServer(serverToDelete.name);
|
||||
const taskId = response.task_id;
|
||||
|
||||
// 监控任务状态
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((taskResp) => {
|
||||
if (taskResp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
setDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
|
||||
if (taskResp.runtime.exception) {
|
||||
toast.error(t('mcp.deleteError') + taskResp.runtime.exception);
|
||||
} else {
|
||||
toast.success(t('mcp.deleteSuccess'));
|
||||
getServerList();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error: unknown) {
|
||||
setDeleting(false);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t('mcp.deleteError') + errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{serverList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
|
||||
<svg
|
||||
className="h-[3rem] w-[3rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M13.5 2C13.5 2.82843 14.1716 3.5 15 3.5C15.8284 3.5 16.5 2.82843 16.5 2C16.5 1.17157 15.8284 0.5 15 0.5C14.1716 0.5 13.5 1.17157 13.5 2ZM8.5 8C8.5 8.82843 9.17157 9.5 10 9.5C10.8284 9.5 11.5 8.82843 11.5 8C11.5 7.17157 10.8284 6.5 10 6.5C9.17157 6.5 8.5 7.17157 8.5 8ZM1.5 14C1.5 14.8284 2.17157 15.5 3 15.5C3.82843 15.5 4.5 14.8284 4.5 14C4.5 13.1716 3.82843 12.5 3 12.5C2.17157 12.5 1.5 13.1716 1.5 14ZM19.5 14C19.5 14.8284 20.1716 15.5 21 15.5C21.8284 15.5 22.5 14.8284 22.5 14C22.5 13.1716 21.8284 12.5 21 12.5C20.1716 12.5 19.5 13.1716 19.5 14ZM8.5 20C8.5 20.8284 9.17157 21.5 10 21.5C10.8284 21.5 11.5 20.8284 11.5 20C11.5 19.1716 10.8284 19 10 19C9.17157 19 8.5 19.1716 8.5 20ZM2.5 8L6.5 8L6.5 10L2.5 10L2.5 8ZM13.5 8L17.5 8L17.5 10L13.5 10L13.5 8ZM8.5 2L8.5 6L10.5 6L10.5 2L8.5 2ZM8.5 14L8.5 18L10.5 18L10.5 14L8.5 14ZM2.5 14L6.5 14L6.5 16L2.5 16L2.5 14ZM13.5 14L17.5 14L17.5 16L13.5 16L13.5 14Z"></path>
|
||||
</svg>
|
||||
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.pluginListContainer}`}>
|
||||
{serverList.map((vo, index) => {
|
||||
return (
|
||||
<div key={index} className="relative group">
|
||||
<MCPCardComponent
|
||||
cardVO={vo}
|
||||
onCardClick={() => handleServerClick(vo)}
|
||||
onRefresh={getServerList}
|
||||
/>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-red-500 hover:bg-red-600 text-white rounded-full p-1"
|
||||
onClick={(e) => handleDeleteClick(vo, e)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑配置对话框 */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle>
|
||||
{selectedServer ? t('mcp.editServer') : t('mcp.createServer')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<MCPForm
|
||||
serverName={selectedServer?.name}
|
||||
isEdit={!!selectedServer}
|
||||
onFormSubmit={() => {
|
||||
setModalOpen(false);
|
||||
getServerList();
|
||||
}}
|
||||
onFormCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('mcp.deleteServer')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('mcp.confirmDeleteServer', { name: serverToDelete?.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={deleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{deleting ? t('plugins.deleting') : t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default MCPComponent;
|
||||
196
web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx
Normal file
196
web/src/app/home/plugins/mcp/mcp-card/MCPCardComponent.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO';
|
||||
import { useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function MCPCardComponent({
|
||||
cardVO,
|
||||
onCardClick,
|
||||
onRefresh,
|
||||
}: {
|
||||
cardVO: MCPCardVO;
|
||||
onCardClick: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(cardVO.enable);
|
||||
const [switchEnable, setSwitchEnable] = useState(true);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
function handleEnable(e: React.MouseEvent) {
|
||||
e.stopPropagation(); // 阻止事件冒泡
|
||||
setSwitchEnable(false);
|
||||
httpClient
|
||||
.toggleMCPServer(cardVO.name, !enabled)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
// 监控任务状态
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((taskResp) => {
|
||||
if (taskResp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
if (taskResp.runtime.exception) {
|
||||
toast.error(t('mcp.modifyFailed') + taskResp.runtime.exception);
|
||||
} else {
|
||||
setEnabled(!enabled);
|
||||
toast.success(t('mcp.saveSuccess'));
|
||||
onRefresh();
|
||||
}
|
||||
setSwitchEnable(true);
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('mcp.modifyFailed') + err.message);
|
||||
setSwitchEnable(true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleTest(e: React.MouseEvent) {
|
||||
e.stopPropagation(); // 阻止事件冒泡
|
||||
setTesting(true);
|
||||
httpClient
|
||||
.testMCPServer(cardVO.name)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
// 监控任务状态
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((taskResp) => {
|
||||
if (taskResp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
if (taskResp.runtime.exception) {
|
||||
toast.error(t('mcp.testFailed') + taskResp.runtime.exception);
|
||||
} else {
|
||||
toast.success(t('mcp.testSuccess'));
|
||||
onRefresh();
|
||||
}
|
||||
setTesting(false);
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('mcp.testFailed') + err.message);
|
||||
setTesting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer"
|
||||
onClick={onCardClick}
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
<svg
|
||||
className="w-16 h-16 text-[#2288ee]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M13.5 2C13.5 2.82843 14.1716 3.5 15 3.5C15.8284 3.5 16.5 2.82843 16.5 2C16.5 1.17157 15.8284 0.5 15 0.5C14.1716 0.5 13.5 1.17157 13.5 2ZM8.5 8C8.5 8.82843 9.17157 9.5 10 9.5C10.8284 9.5 11.5 8.82843 11.5 8C11.5 7.17157 10.8284 6.5 10 6.5C9.17157 6.5 8.5 7.17157 8.5 8ZM1.5 14C1.5 14.8284 2.17157 15.5 3 15.5C3.82843 15.5 4.5 14.8284 4.5 14C4.5 13.1716 3.82843 12.5 3 12.5C2.17157 12.5 1.5 13.1716 1.5 14ZM19.5 14C19.5 14.8284 20.1716 15.5 21 15.5C21.8284 15.5 22.5 14.8284 22.5 14C22.5 13.1716 21.8284 12.5 21 12.5C20.1716 12.5 19.5 13.1716 19.5 14ZM8.5 20C8.5 20.8284 9.17157 21.5 10 21.5C10.8284 21.5 11.5 20.8284 11.5 20C11.5 19.1716 10.8284 19 10 19C9.17157 19 8.5 19.1716 8.5 20ZM2.5 8L6.5 8L6.5 10L2.5 10L2.5 8ZM13.5 8L17.5 8L17.5 10L13.5 10L13.5 8ZM8.5 2L8.5 6L10.5 6L10.5 2L8.5 2ZM8.5 14L8.5 18L10.5 18L10.5 14L8.5 14ZM2.5 14L6.5 14L6.5 16L2.5 16L2.5 14ZM13.5 14L17.5 14L17.5 16L13.5 16L13.5 14Z"></path>
|
||||
</svg>
|
||||
|
||||
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
|
||||
<div className="text-[1.2rem] text-black">{cardVO.name}</div>
|
||||
<Badge variant="outline" className="text-[0.7rem]">
|
||||
{cardVO.mode.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-start gap-[0.4rem] mt-1">
|
||||
<svg
|
||||
className={`w-4 h-4 ${cardVO.getStatusColor()}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={cardVO.getStatusIcon()}
|
||||
/>
|
||||
</svg>
|
||||
<div className={`text-[0.8rem] ${cardVO.getStatusColor()}`}>
|
||||
{cardVO.status === 'connected' && t('mcp.statusConnected')}
|
||||
{cardVO.status === 'disconnected' &&
|
||||
t('mcp.statusDisconnected')}
|
||||
{cardVO.status === 'error' && t('mcp.statusError')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cardVO.error && (
|
||||
<div className="text-[0.7rem] text-red-500 line-clamp-2 mt-1">
|
||||
{cardVO.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
|
||||
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
||||
<svg
|
||||
className="w-[1.2rem] h-[1.2rem] text-black"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M5.32943 3.27158C6.56252 2.8332 7.9923 3.10749 8.97927 4.09446C10.1002 5.21537 10.3019 6.90741 9.5843 8.23385L20.293 18.9437L18.8788 20.3579L8.16982 9.64875C6.84325 10.3669 5.15069 10.1654 4.02952 9.04421C3.04227 8.05696 2.7681 6.62665 3.20701 5.39332L5.44373 7.63C6.02952 8.21578 6.97927 8.21578 7.56505 7.63C8.15084 7.04421 8.15084 6.09446 7.56505 5.50868L5.32943 3.27158ZM15.6968 5.15512L18.8788 3.38736L20.293 4.80157L18.5252 7.98355L16.7574 8.3371L14.6361 10.4584L13.2219 9.04421L15.3432 6.92289L15.6968 5.15512ZM8.97927 13.2868L10.3935 14.7011L5.09018 20.0044C4.69966 20.3949 4.06649 20.3949 3.67597 20.0044C3.31334 19.6417 3.28744 19.0699 3.59826 18.6774L3.67597 18.5902L8.97927 13.2868Z" />
|
||||
</svg>
|
||||
<div className="text-base text-black font-medium">
|
||||
{t('mcp.toolCount', { count: cardVO.tools })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-between h-full">
|
||||
<div className="flex items-center justify-center">
|
||||
<Switch
|
||||
className="cursor-pointer"
|
||||
checked={enabled}
|
||||
onClick={(e) => handleEnable(e)}
|
||||
disabled={!switchEnable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-[0.4rem]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-8 w-8"
|
||||
onClick={(e) => handleTest(e)}
|
||||
disabled={testing}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-600 ${
|
||||
testing ? 'animate-spin' : ''
|
||||
}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
409
web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx
Normal file
409
web/src/app/home/plugins/mcp/mcp-form/MCPForm.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { MCPServerConfig } from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlusIcon, TrashIcon } from 'lucide-react';
|
||||
|
||||
interface MCPFormProps {
|
||||
serverName?: string;
|
||||
isEdit?: boolean;
|
||||
onFormSubmit: () => void;
|
||||
onFormCancel: () => void;
|
||||
}
|
||||
|
||||
export default function MCPForm({
|
||||
serverName,
|
||||
isEdit = false,
|
||||
onFormSubmit,
|
||||
onFormCancel,
|
||||
}: MCPFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState<MCPServerConfig>({
|
||||
name: '',
|
||||
mode: 'stdio',
|
||||
enable: true,
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
url: '',
|
||||
headers: {},
|
||||
timeout: 10,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && serverName) {
|
||||
loadServerConfig();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEdit, serverName]);
|
||||
|
||||
async function loadServerConfig() {
|
||||
try {
|
||||
const response = await httpClient.getMCPServer(serverName!);
|
||||
setFormData(response.server.config);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t('mcp.getServerListError') + errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange(field: keyof MCPServerConfig, value: unknown) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
function addArrayItem(field: 'args', value: string = '') {
|
||||
const currentArray = formData[field] as string[];
|
||||
handleInputChange(field, [...currentArray, value]);
|
||||
}
|
||||
|
||||
function updateArrayItem(field: 'args', index: number, value: string) {
|
||||
const currentArray = formData[field] as string[];
|
||||
const newArray = [...currentArray];
|
||||
newArray[index] = value;
|
||||
handleInputChange(field, newArray);
|
||||
}
|
||||
|
||||
function removeArrayItem(field: 'args', index: number) {
|
||||
const currentArray = formData[field] as string[];
|
||||
const newArray = currentArray.filter((_, i) => i !== index);
|
||||
handleInputChange(field, newArray);
|
||||
}
|
||||
|
||||
function addObjectItem(
|
||||
field: 'env' | 'headers',
|
||||
key: string = '',
|
||||
value: string = '',
|
||||
) {
|
||||
const currentObj = formData[field] as Record<string, string>;
|
||||
handleInputChange(field, {
|
||||
...currentObj,
|
||||
[key]: value,
|
||||
});
|
||||
}
|
||||
|
||||
function updateObjectItem(
|
||||
field: 'env' | 'headers',
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
value: string,
|
||||
) {
|
||||
const currentObj = formData[field] as Record<string, string>;
|
||||
const newObj = { ...currentObj };
|
||||
if (oldKey !== newKey) {
|
||||
delete newObj[oldKey];
|
||||
}
|
||||
newObj[newKey] = value;
|
||||
handleInputChange(field, newObj);
|
||||
}
|
||||
|
||||
function removeObjectItem(field: 'env' | 'headers', key: string) {
|
||||
const currentObj = formData[field] as Record<string, string>;
|
||||
const newObj = { ...currentObj };
|
||||
delete newObj[key];
|
||||
handleInputChange(field, newObj);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
// 验证表单
|
||||
if (!formData.name.trim()) {
|
||||
toast.error(t('mcp.serverNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.mode === 'stdio' && !formData.command?.trim()) {
|
||||
toast.error(t('mcp.commandRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.mode === 'sse' && !formData.url?.trim()) {
|
||||
toast.error(t('mcp.urlRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
let taskId: number;
|
||||
|
||||
if (isEdit) {
|
||||
const response = await httpClient.updateMCPServer(
|
||||
serverName!,
|
||||
formData,
|
||||
);
|
||||
taskId = response.task_id;
|
||||
} else {
|
||||
const response = await httpClient.createMCPServer(formData);
|
||||
taskId = response.task_id;
|
||||
}
|
||||
|
||||
// 监控任务状态
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((taskResp) => {
|
||||
if (taskResp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
setLoading(false);
|
||||
|
||||
if (taskResp.runtime.exception) {
|
||||
toast.error(
|
||||
(isEdit ? t('mcp.saveError') : t('mcp.createError')) +
|
||||
taskResp.runtime.exception,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
isEdit ? t('mcp.saveSuccess') : t('mcp.createSuccess'),
|
||||
);
|
||||
onFormSubmit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error: unknown) {
|
||||
setLoading(false);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
(isEdit ? t('mcp.saveError') : t('mcp.createError')) + errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 基础配置 */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">{t('mcp.serverName')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
disabled={isEdit}
|
||||
placeholder={t('mcp.serverName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="enable">{t('common.enable')}</Label>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Switch
|
||||
id="enable"
|
||||
checked={formData.enable}
|
||||
onCheckedChange={(checked) =>
|
||||
handleInputChange('enable', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t('mcp.serverMode')}</Label>
|
||||
<Tabs
|
||||
value={formData.mode}
|
||||
onValueChange={(value) =>
|
||||
handleInputChange('mode', value as 'stdio' | 'sse')
|
||||
}
|
||||
className="mt-2"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="stdio">{t('mcp.stdio')}</TabsTrigger>
|
||||
<TabsTrigger value="sse">{t('mcp.sse')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stdio" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<Label htmlFor="command">{t('mcp.command')}</Label>
|
||||
<Input
|
||||
id="command"
|
||||
value={formData.command || ''}
|
||||
onChange={(e) => handleInputChange('command', e.target.value)}
|
||||
placeholder="python -m your_mcp_server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t('mcp.args')}</Label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{(formData.args || []).map((arg, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={arg}
|
||||
onChange={(e) =>
|
||||
updateArrayItem('args', index, e.target.value)
|
||||
}
|
||||
placeholder="参数"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeArrayItem('args', index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addArrayItem('args')}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
{t('mcp.addArgument')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t('mcp.env')}</Label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{Object.entries(formData.env || {}).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateObjectItem('env', key, e.target.value, value)
|
||||
}
|
||||
placeholder={t('mcp.keyName')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
updateObjectItem('env', key, key, e.target.value)
|
||||
}
|
||||
placeholder={t('mcp.value')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeObjectItem('env', key)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addObjectItem('env')}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
{t('mcp.addEnvVar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sse" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<Label htmlFor="url">{t('mcp.url')}</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={formData.url || ''}
|
||||
onChange={(e) => handleInputChange('url', e.target.value)}
|
||||
placeholder="http://localhost:3000/sse"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="timeout">{t('mcp.timeout')}</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={formData.timeout || 10}
|
||||
onChange={(e) =>
|
||||
handleInputChange('timeout', parseInt(e.target.value) || 10)
|
||||
}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t('mcp.headers')}</Label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{Object.entries(formData.headers || {}).map(
|
||||
([key, value]) => (
|
||||
<div key={key} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateObjectItem(
|
||||
'headers',
|
||||
key,
|
||||
e.target.value,
|
||||
value,
|
||||
)
|
||||
}
|
||||
placeholder={t('mcp.keyName')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
updateObjectItem(
|
||||
'headers',
|
||||
key,
|
||||
key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={t('mcp.value')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeObjectItem('headers', key)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addObjectItem('headers')}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
{t('mcp.addHeader')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import PluginInstalledComponent, {
|
||||
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent';
|
||||
import MarketPage from '@/app/home/plugins/plugin-market/PluginMarketComponent';
|
||||
// import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
|
||||
import PluginMarketComponent from '@/app/home/plugins/plugin-market/PluginMarketComponent';
|
||||
import MCPComponent, {
|
||||
MCPComponentRef,
|
||||
} from '@/app/home/plugins/mcp/MCPComponent';
|
||||
import MCPMarketComponent from '@/app/home/plugins/mcp-market/MCPMarketComponent';
|
||||
import styles from './plugins.module.css';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -46,20 +51,27 @@ enum PluginInstallStatus {
|
||||
|
||||
export default function PluginConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('installed');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
// const [sortModalOpen, setSortModalOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('installed');
|
||||
const [installSource, setInstallSource] = useState<string>('local');
|
||||
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const [sortModalOpen, setSortModalOpen] = useState(false);
|
||||
// const [mcpModalOpen, setMcpModalOpen] = useState(false);
|
||||
const [mcpMarketInstallModalOpen, setMcpMarketInstallModalOpen] =
|
||||
useState(false);
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
|
||||
const [mcpInstallStatus, setMcpInstallStatus] = useState<PluginInstallStatus>(
|
||||
PluginInstallStatus.WAIT_INPUT,
|
||||
);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [mcpInstallError, setMcpInstallError] = useState<string | null>(null);
|
||||
const [githubURL, setGithubURL] = useState('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,11 +118,17 @@ export default function PluginConfigPage() {
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
const [mcpGithubURL, setMcpGithubURL] = useState('');
|
||||
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
||||
const mcpComponentRef = useRef<MCPComponentRef>(null);
|
||||
|
||||
function handleModalConfirm() {
|
||||
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
function handleMcpModalConfirm() {
|
||||
installMcpServer(mcpGithubURL);
|
||||
}
|
||||
function installPlugin(
|
||||
installSource: string,
|
||||
installInfo: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
@@ -291,6 +309,46 @@ export default function PluginConfigPage() {
|
||||
return renderPluginConnectionErrorState();
|
||||
}
|
||||
|
||||
function installMcpServer(url: string) {
|
||||
setMcpInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
httpClient
|
||||
.installMCPServerFromGithub(url)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
|
||||
let alreadySuccess = false;
|
||||
console.log('taskId:', taskId);
|
||||
|
||||
// 每秒拉取一次任务状态
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((resp) => {
|
||||
console.log('task status:', resp);
|
||||
if (resp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
if (resp.runtime.exception) {
|
||||
setMcpInstallError(resp.runtime.exception);
|
||||
setMcpInstallStatus(PluginInstallStatus.ERROR);
|
||||
} else {
|
||||
// success
|
||||
if (!alreadySuccess) {
|
||||
toast.success(t('mcp.installSuccess'));
|
||||
alreadySuccess = true;
|
||||
}
|
||||
setMcpGithubURL('');
|
||||
setMcpMarketInstallModalOpen(false);
|
||||
mcpComponentRef.current?.refreshServerList();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('error when install mcp server:', err);
|
||||
setMcpInstallError(err.message);
|
||||
setMcpInstallStatus(PluginInstallStatus.ERROR);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.pageContainer} ${isDragOver ? 'bg-blue-50' : ''}`}
|
||||
@@ -316,6 +374,9 @@ export default function PluginConfigPage() {
|
||||
{t('plugins.marketplace')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="mcp" className="px-6 py-4 cursor-pointer">
|
||||
MCP
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
@@ -372,6 +433,19 @@ export default function PluginConfigPage() {
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp">
|
||||
<MCPComponent ref={mcpComponentRef} />
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp-market">
|
||||
<MCPMarketComponent
|
||||
askInstallServer={(githubURL) => {
|
||||
setMcpGithubURL(githubURL);
|
||||
setMcpMarketInstallModalOpen(true);
|
||||
setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setMcpInstallError(null);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
@@ -456,6 +530,66 @@ export default function PluginConfigPage() {
|
||||
pluginInstalledRef.current?.refreshPluginList();
|
||||
}}
|
||||
/> */}
|
||||
|
||||
{/* MCP Server 安装对话框 */}
|
||||
{/* <Dialog
|
||||
open={mcpMarketInstallModalOpen}
|
||||
onOpenChange={setMcpMarketInstallModalOpen}
|
||||
>
|
||||
<DialogContent className="w-[500px] p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-4">
|
||||
<GithubIcon className="size-6" />
|
||||
<span>{t('mcp.installFromGithub')}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('mcp.onlySupportGithub')}</p>
|
||||
<Input
|
||||
placeholder={t('mcp.enterGithubLink')}
|
||||
value={mcpGithubURL}
|
||||
onChange={(e) => setMcpGithubURL(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mcpInstallStatus === PluginInstallStatus.INSTALLING && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('mcp.installing')}</p>
|
||||
</div>
|
||||
)}
|
||||
{mcpInstallStatus === PluginInstallStatus.ERROR && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('mcp.installFailed')}</p>
|
||||
<p className="mb-2 text-red-500">{mcpInstallError}</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setMcpMarketInstallModalOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleMcpModalConfirm}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{mcpInstallStatus === PluginInstallStatus.ERROR && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setMcpMarketInstallModalOpen(false)}
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,3 +308,66 @@ export interface RetrieveResult {
|
||||
export interface ApiRespKnowledgeBaseRetrieve {
|
||||
results: RetrieveResult[];
|
||||
}
|
||||
|
||||
// MCP
|
||||
export interface ApiRespMCPServers {
|
||||
servers: MCPServer[];
|
||||
}
|
||||
|
||||
export interface ApiRespMCPServer {
|
||||
server: MCPServer;
|
||||
}
|
||||
|
||||
export interface MCPServer {
|
||||
name: string;
|
||||
mode: 'stdio' | 'sse';
|
||||
enable: boolean;
|
||||
config: MCPServerConfig;
|
||||
status: 'connected' | 'disconnected' | 'error';
|
||||
tools: MCPTool[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MCPServerConfig {
|
||||
name: string;
|
||||
mode: 'stdio' | 'sse';
|
||||
enable: boolean;
|
||||
// stdio mode
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
// sse mode
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface MCPTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: object;
|
||||
}
|
||||
|
||||
// MCP Market
|
||||
export interface MCPMarketResponse {
|
||||
servers: MCPMarketServer[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MCPMarketServer {
|
||||
ID: number;
|
||||
CreatedAt: string; // ISO 8601 格式日期
|
||||
UpdatedAt: string;
|
||||
DeletedAt: string | null;
|
||||
name: string;
|
||||
author: string;
|
||||
description: string;
|
||||
repository: string; // GitHub 仓库路径
|
||||
artifacts_path: string;
|
||||
stars: number;
|
||||
downloads: number;
|
||||
status: 'initialized' | 'mounted';
|
||||
synced_at: string;
|
||||
pushed_at: string; // 最后一次代码推送时间
|
||||
version?: string;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ import {
|
||||
ApiRespProviderEmbeddingModel,
|
||||
EmbeddingModel,
|
||||
ApiRespPluginSystemStatus,
|
||||
ApiRespMCPServers,
|
||||
ApiRespMCPServer,
|
||||
MCPServerConfig,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
@@ -488,6 +491,67 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.post(`/api/v1/plugins/${author}/${name}/upgrade`);
|
||||
}
|
||||
|
||||
// ============ MCP API ============
|
||||
public getMCPServers(): Promise<ApiRespMCPServers> {
|
||||
return this.get('/api/v1/mcp/servers');
|
||||
}
|
||||
|
||||
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
|
||||
return this.get(`/api/v1/mcp/servers/${serverName}`);
|
||||
}
|
||||
|
||||
public createMCPServer(
|
||||
server: MCPServerConfig,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/mcp/servers', server);
|
||||
}
|
||||
|
||||
public updateMCPServer(
|
||||
serverName: string,
|
||||
server: Partial<MCPServerConfig>,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.put(`/api/v1/mcp/servers/${serverName}`, server);
|
||||
}
|
||||
|
||||
public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
|
||||
return this.delete(`/api/v1/mcp/servers/${serverName}`);
|
||||
}
|
||||
|
||||
public toggleMCPServer(
|
||||
serverName: string,
|
||||
target_enabled: boolean,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.put(`/api/v1/mcp/servers/${serverName}/toggle`, {
|
||||
target_enabled,
|
||||
});
|
||||
}
|
||||
|
||||
public testMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post(`/api/v1/mcp/servers/${serverName}/test`);
|
||||
}
|
||||
|
||||
// public getMCPMarketServers(
|
||||
// page: number,
|
||||
// page_size: number,
|
||||
// query: string,
|
||||
// sort_by: string = 'stars',
|
||||
// sort_order: string = 'DESC',
|
||||
// ): Promise<MCPMarketResponse> {
|
||||
// return this.post(`/api/v1/market/mcp`, {
|
||||
// page,
|
||||
// page_size,
|
||||
// query,
|
||||
// sort_by,
|
||||
// sort_order,
|
||||
// });
|
||||
// }
|
||||
|
||||
public installMCPServerFromGithub(
|
||||
source: string,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/mcp/install/github', { source });
|
||||
}
|
||||
|
||||
// ============ System API ============
|
||||
public getSystemInfo(): Promise<ApiRespSystemInfo> {
|
||||
return this.get('/api/v1/system/info');
|
||||
|
||||
141
web/src/components/ui/alert-dialog.tsx
Normal file
141
web/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -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: '流水线定义了对消息事件的处理流程,用于绑定到机器人',
|
||||
|
||||
Reference in New Issue
Block a user