feat: ui for mcp (#1600)

* feat: code by huntun

* chore: revert group.py

* refactor: api

* feat: adjust ui

* chore: stash

* feat: add dialog

* feat: add mcp from sse on frontend

* feat: add mcp db

* feat: semi frontend

* feat: change sse frontend

* fix: page out of control

* fix: mcp card

* fix: mcp refactor

* fix: delete description

* feat: add mcp servers

* fix: status icon

* feat: mcp-ui

* perf: remove title from mcp mgm page

* fix: delete mcp market

* feat: add i18n

* fix: run lint

* feat: add i18n

* fix: delete print function

* fix: mcp test error

* fix: i18n and mcp test

* refactor(mcp): bridge controller and db operation with service layer

* fix: try & catch & error

* fix: error message in mcp card

* feat: no longer register tool loader as component for type hints

* perf: make startup async

* feat: completely remove the fucking mcp market components and refs

* refactor: mcp server datastructure

* perf: tidy dir

* feat: perf mcp server api datastruct

* perf: ui

* perf: mcp server status checking logic

* perf: mcp server testing and refreshing

* perf: no mcp server tips

* perf: update sidebar title

* chore: update

* chore: bump langbot-plugin to 0.1.3

* chore: bump version v4.3.4

* chore: release v4.3.5

* Fix: Correct data type mismatch in AtBotRule (#1705)

Fix can't '@' in QQ group.

* chore: bump version 4.3.6

* feat: update for new events fields

* Fix/qqo (#1709)

* fix: qq official

* fix: appid

* chore: add `codecov.yml`

* chore: bump langbot-plugin to 0.1.4b2

* chore: bump version 4.3.7b1

* fix: return empty data when plugin system disabled (#1710)

* chore: bump version 4.3.7

* fix: bad Plain component init in wechatpad (#1712)

* perf: allow not set llm model (#1703)

* perf: output pipeline error in en

* fix: datetime serialization error in emit_event (#1713)

* chore: bump version 4.3.8

* perf: add component list in plugin detail dialog

* perf: store pipeline sort method

* 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 <rockchinq@gmail.com>

* chore: bump version 4.3.9

* feat: 实现企业微信智能机器人流式响应

- 重构 WecomBotClient,支持流式会话管理和队列机制
- 新增 StreamSession 和 StreamSessionManager 类管理流式上下文
- 实现 reply_message_chunk 接口支持流式输出
- 优化消息处理流程,支持异步流式响应

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: split WeCom callback handlers

* fix: langchain error

* fix: add langchain test splitter module

* 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 <rockchinq@gmail.com>

* chore: fix linter errors

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>

---------

Co-authored-by: WangCham <651122857@qq.com>
Co-authored-by: wangcham <wangcham233@gmail.com>
Co-authored-by: Thetail001 <56257172+Thetail001@users.noreply.github.com>
Co-authored-by: fdc310 <82008029+fdc310@users.noreply.github.com>
Co-authored-by: Alfons <alfonsxh@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Junyan Qin (Chin)
2025-11-04 18:49:16 +08:00
committed by GitHub
35 changed files with 2269 additions and 152 deletions

1
.gitignore vendored
View File

@@ -44,5 +44,6 @@ test.py
.venv/ .venv/
uv.lock uv.lock
/test /test
plugins.bak
coverage.xml coverage.xml
.coverage .coverage

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import quart
import traceback
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':
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
return self.success(data={'servers': servers})
elif quart.request.method == 'POST':
data = await quart.request.json
try:
uuid = await self.ap.mcp_service.create_mcp_server(data)
return self.success(data={'uuid': uuid})
except Exception as e:
traceback.print_exc()
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], 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')
if quart.request.method == 'GET':
return self.success(data={'server': server_data})
elif quart.request.method == 'PUT':
data = await quart.request.json
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':
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/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""测试MCP服务器连接"""
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})

View File

@@ -15,12 +15,14 @@ from .groups import provider as groups_provider
from .groups import platform as groups_platform from .groups import platform as groups_platform
from .groups import pipelines as groups_pipelines from .groups import pipelines as groups_pipelines
from .groups import knowledge as groups_knowledge 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)
importutil.import_modules_in_pkg(groups_provider) importutil.import_modules_in_pkg(groups_provider)
importutil.import_modules_in_pkg(groups_platform) importutil.import_modules_in_pkg(groups_platform)
importutil.import_modules_in_pkg(groups_pipelines) importutil.import_modules_in_pkg(groups_pipelines)
importutil.import_modules_in_pkg(groups_knowledge) importutil.import_modules_in_pkg(groups_knowledge)
importutil.import_modules_in_pkg(groups_resources)
class HTTPController: class HTTPController:

137
pkg/api/http/service/mcp.py Normal file
View File

@@ -0,0 +1,137 @@
from __future__ import annotations
import sqlalchemy
import uuid
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, MCPSessionStatus
class MCPService:
ap: app.Application
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))
servers = result.all()
serialized_servers = [
self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers
]
if contain_runtime_info:
for server in serialized_servers:
runtime_info = await self.get_runtime_info(server['name'])
server['runtime_info'] = runtime_info if runtime_info else None
return serialized_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))
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:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
if self.ap.tool_mgr.mcp_tool_loader:
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']
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
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(
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)
)
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.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
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(
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_name: str, server_data: dict) -> int:
"""测试 MCP 服务器连接并返回任务 ID"""
runtime_mcp_session: RuntimeMCPSession | None = None
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:
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
coroutine = runtime_mcp_session.start()
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

View File

@@ -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 pipeline as pipeline_service
from ..api.http.service import bot as bot_service from ..api.http.service import bot as bot_service
from ..api.http.service import knowledge as knowledge_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 ..discover import engine as discover_engine
from ..storage import mgr as storagemgr from ..storage import mgr as storagemgr
from ..utils import logcache from ..utils import logcache
@@ -119,6 +120,8 @@ class Application:
knowledge_service: knowledge_service.KnowledgeService = None knowledge_service: knowledge_service.KnowledgeService = None
mcp_service: mcp_service.MCPService = None
def __init__(self): def __init__(self):
pass pass

View File

@@ -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 pipeline as pipeline_service
from ...api.http.service import bot as bot_service from ...api.http.service import bot as bot_service
from ...api.http.service import knowledge as knowledge_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 ...discover import engine as discover_engine
from ...storage import mgr as storagemgr from ...storage import mgr as storagemgr
from ...utils import logcache from ...utils import logcache
@@ -126,5 +127,8 @@ class BuildAppStage(stage.BootingStage):
knowledge_service_inst = knowledge_service.KnowledgeService(ap) knowledge_service_inst = knowledge_service.KnowledgeService(ap)
ap.knowledge_service = knowledge_service_inst ap.knowledge_service = knowledge_service_inst
mcp_service_inst = mcp_service.MCPService(ap)
ap.mcp_service = mcp_service_inst
ctrl = controller.Controller(ap) ctrl = controller.Controller(ap)
ap.ctrl = ctrl ap.ctrl = ctrl

View File

@@ -156,7 +156,7 @@ class TaskWrapper:
'state': self.task._state, 'state': self.task._state,
'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None, 'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None,
'exception_traceback': exception_traceback, '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,
}, },
} }

View File

@@ -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(),
)

View File

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

View File

@@ -58,7 +58,7 @@ class PluginRuntimeConnector:
async def heartbeat_loop(self): async def heartbeat_loop(self):
while True: while True:
await asyncio.sleep(10) await asyncio.sleep(20)
try: try:
await self.ping_plugin_runtime() await self.ping_plugin_runtime()
self.ap.logger.debug('Heartbeat to plugin runtime success.') self.ap.logger.debug('Heartbeat to plugin runtime success.')

View File

@@ -59,7 +59,7 @@ class ModelManager:
try: try:
await self.load_llm_model(llm_model) await self.load_llm_model(llm_model)
except provider_errors.RequesterNotFoundError as e: 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: except Exception as e:
self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}') 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)) result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
embedding_models = result.all() embedding_models = result.all()
for embedding_model in embedding_models: for embedding_model in embedding_models:
try:
await self.load_embedding_model(embedding_model) 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( async def init_runtime_llm_model(
self, self,
@@ -107,6 +114,9 @@ class ModelManager:
elif isinstance(model_info, dict): elif isinstance(model_info, dict):
model_info = persistence_model.EmbeddingModel(**model_info) 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) requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config)
await requester_inst.initialize() await requester_inst.initialize()

View File

@@ -1,7 +1,11 @@
from __future__ import annotations from __future__ import annotations
import enum
import typing import typing
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
import traceback
import sqlalchemy
import asyncio
from mcp import ClientSession, StdioServerParameters from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
@@ -10,6 +14,13 @@ from mcp.client.sse import sse_client
from .. import loader from .. import loader
from ....core import app from ....core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool 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: class RuntimeMCPSession:
@@ -27,16 +38,26 @@ class RuntimeMCPSession:
functions: list[resource_tool.LLMTool] = [] functions: list[resource_tool.LLMTool] = []
def __init__(self, server_name: str, server_config: dict, ap: app.Application): enable: bool
# connected: bool
status: MCPSessionStatus
last_test_error_message: str
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
self.server_name = server_name self.server_name = server_name
self.server_config = server_config self.server_config = server_config
self.ap = ap self.ap = ap
self.enable = enable
self.session = None self.session = None
self.exit_stack = AsyncExitStack() self.exit_stack = AsyncExitStack()
self.functions = [] self.functions = []
self.status = MCPSessionStatus.CONNECTING
self.last_test_error_message = ''
async def _init_stdio_python_server(self): async def _init_stdio_python_server(self):
server_params = StdioServerParameters( server_params = StdioServerParameters(
command=self.server_config['command'], command=self.server_config['command'],
@@ -58,6 +79,7 @@ class RuntimeMCPSession:
self.server_config['url'], self.server_config['url'],
headers=self.server_config.get('headers', {}), headers=self.server_config.get('headers', {}),
timeout=self.server_config.get('timeout', 10), timeout=self.server_config.get('timeout', 10),
sse_read_timeout=self.server_config.get('ssereadtimeout', 30),
) )
) )
@@ -67,9 +89,11 @@ class RuntimeMCPSession:
await self.session.initialize() await self.session.initialize()
async def initialize(self): async def start(self):
self.ap.logger.debug(f'初始化 MCP 会话: {self.server_name} {self.server_config}') if not self.enable:
return
try:
if self.server_config['mode'] == 'stdio': if self.server_config['mode'] == 'stdio':
await self._init_stdio_python_server() await self._init_stdio_python_server()
elif self.server_config['mode'] == 'sse': elif self.server_config['mode'] == 'sse':
@@ -77,9 +101,21 @@ class RuntimeMCPSession:
else: else:
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}') raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
await self.refresh()
self.status = MCPSessionStatus.CONNECTED
self.last_test_error_message = ''
except Exception as e:
self.status = MCPSessionStatus.ERROR
self.last_test_error_message = str(e)
raise e
async def refresh(self):
self.functions.clear()
tools = await self.session.list_tools() tools = await self.session.list_tools()
self.ap.logger.debug(f'获取 MCP 工具: {tools}') self.ap.logger.debug(f'Refresh MCP tools: {tools}')
for tool in tools.tools: for tool in tools.tools:
@@ -101,58 +137,201 @@ class RuntimeMCPSession:
) )
) )
def get_tools(self) -> list[resource_tool.LLMTool]:
return self.functions
def get_runtime_info_dict(self) -> dict:
return {
'status': self.status.value,
'error_message': self.last_test_error_message,
'tool_count': len(self.get_tools()),
'tools': [
{
'name': tool.name,
'description': tool.description,
}
for tool in self.get_tools()
],
}
async def shutdown(self): 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') # @loader.loader_class('mcp')
class MCPLoader(loader.ToolLoader): class MCPLoader(loader.ToolLoader):
"""MCP 工具加载器。 """MCP 工具加载器。
在此加载器中管理所有与 MCP Server 的连接。 在此加载器中管理所有与 MCP Server 的连接。
""" """
sessions: dict[str, RuntimeMCPSession] = {} sessions: dict[str, RuntimeMCPSession]
_last_listed_functions: list[resource_tool.LLMTool] = [] _last_listed_functions: list[resource_tool.LLMTool]
_hosted_mcp_tasks: list[asyncio.Task]
def __init__(self, ap: app.Application): def __init__(self, ap: app.Application):
super().__init__(ap) super().__init__(ap)
self.sessions = {} self.sessions = {}
self._last_listed_functions = [] self._last_listed_functions = []
self._hosted_mcp_tasks = []
async def initialize(self): async def initialize(self):
for server_config in self.ap.instance_config.data.get('mcp', {}).get('servers', []): await self.load_mcp_servers_from_db()
if not server_config['enable']:
continue async def load_mcp_servers_from_db(self):
session = RuntimeMCPSession(server_config['name'], server_config, self.ap) self.ap.logger.info('Loading MCP servers from db...')
await session.initialize()
# self.ap.event_loop.create_task(session.initialize()) self.sessions = {}
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
servers = result.all()
for server in servers:
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
task = asyncio.create_task(self.host_mcp_server(config))
self._hosted_mcp_tasks.append(task)
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 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"]})')
async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:
"""加载 MCP 服务器到运行时
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': name,
'mode': mode,
'enable': enable,
**extra_args,
}
session = RuntimeMCPSession(name, mixed_config, enable, self.ap)
return session
async def get_tools(self) -> list[resource_tool.LLMTool]: async def get_tools(self) -> list[resource_tool.LLMTool]:
all_functions = [] all_functions = []
for session in self.sessions.values(): for session in self.sessions.values():
all_functions.extend(session.functions) all_functions.extend(session.get_tools())
self._last_listed_functions = all_functions self._last_listed_functions = all_functions
return all_functions return all_functions
async def has_tool(self, name: str) -> bool: async def has_tool(self, name: str) -> bool:
return name in [f.name for f in self._last_listed_functions] """检查工具是否存在"""
for session in self.sessions.values():
for function in session.get_tools():
if function.name == name:
return True
return False
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any: async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
for server_name, session in self.sessions.items(): """执行工具调用"""
for function in session.functions: for session in self.sessions.values():
for function in session.get_tools():
if function.name == name: if function.name == name:
return await function.func(**parameters) 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'未找到工具: {name}') raise ValueError(f'Tool not found: {name}')
async def remove_mcp_server(self, server_name: str):
"""移除 MCP 服务器"""
if server_name not in self.sessions:
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.get_tools()) 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.get_tools()),
'tool_names': [f.name for f in session.get_tools()],
}
return info
async def shutdown(self): async def shutdown(self):
"""关闭工具""" """关闭所有工具"""
for session in self.sessions.values(): self.ap.logger.info('Shutting down all MCP sessions...')
for server_name, session in list(self.sessions.items()):
try:
await session.shutdown() 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')

View File

@@ -7,7 +7,7 @@ from .. import loader
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool 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): class PluginToolLoader(loader.ToolLoader):
"""插件工具加载器。 """插件工具加载器。

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
import typing import typing
from ...core import app from ...core import app
from . import loader as tools_loader
from ...utils import importutil from ...utils import importutil
from . import loaders 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 import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
importutil.import_modules_in_pkg(loaders) importutil.import_modules_in_pkg(loaders)
@@ -16,25 +16,24 @@ class ToolManager:
ap: app.Application 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): def __init__(self, ap: app.Application):
self.ap = ap self.ap = ap
self.all_functions = []
self.loaders = []
async def initialize(self): async def initialize(self):
for loader_cls in tools_loader.preregistered_loaders: self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap)
loader_inst = loader_cls(self.ap) await self.plugin_tool_loader.initialize()
await loader_inst.initialize() self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
self.loaders.append(loader_inst) await self.mcp_tool_loader.initialize()
async def get_all_tools(self) -> list[resource_tool.LLMTool]: async def get_all_tools(self) -> list[resource_tool.LLMTool]:
"""获取所有函数""" """获取所有函数"""
all_functions: list[resource_tool.LLMTool] = [] all_functions: list[resource_tool.LLMTool] = []
for loader in self.loaders: all_functions.extend(await self.plugin_tool_loader.get_tools())
all_functions.extend(await loader.get_tools()) all_functions.extend(await self.mcp_tool_loader.get_tools())
return all_functions return all_functions
@@ -93,13 +92,14 @@ class ToolManager:
async def execute_func_call(self, name: str, parameters: dict) -> typing.Any: async def execute_func_call(self, name: str, parameters: dict) -> typing.Any:
"""执行函数调用""" """执行函数调用"""
for loader in self.loaders: if await self.plugin_tool_loader.has_tool(name):
if await loader.has_tool(name): return await self.plugin_tool_loader.invoke_tool(name, parameters)
return await 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: else:
raise ValueError(f'未找到工具: {name}') raise ValueError(f'未找到工具: {name}')
async def shutdown(self): async def shutdown(self):
"""关闭所有工具""" """关闭所有工具"""
for loader in self.loaders: await self.plugin_tool_loader.shutdown()
await loader.shutdown() await self.mcp_tool_loader.shutdown()

View File

@@ -10,8 +10,6 @@ command:
concurrency: concurrency:
pipeline: 20 pipeline: 20
session: 1 session: 1
mcp:
servers: []
proxy: proxy:
http: '' http: ''
https: '' https: ''

View File

@@ -23,6 +23,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-context-menu": "^2.2.15", "@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",

View File

@@ -115,7 +115,6 @@ export default function BotForm({
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
function setBotFormValues() { function setBotFormValues() {

View File

@@ -65,7 +65,6 @@ export default function HomeSidebar({
console.error('Failed to fetch GitHub star count:', error); console.error('Failed to fetch GitHub star count:', error);
}); });
return () => console.log('sidebar.unmounted'); return () => console.log('sidebar.unmounted');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
function handleChildClick(child: SidebarChildVO) { function handleChildClick(child: SidebarChildVO) {

View File

@@ -63,7 +63,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
useEffect(() => { useEffect(() => {
initData(); initData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
function initData() { function initData() {

View File

@@ -172,7 +172,6 @@ function MarketPageContent({
// 初始加载 // 初始加载
useEffect(() => { useEffect(() => {
fetchPlugins(1, false, true); fetchPlugins(1, false, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 搜索功能 // 搜索功能

View File

@@ -0,0 +1,29 @@
import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
export class MCPCardVO {
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
status: MCPSessionStatus;
tools: number;
error?: string;
constructor(data: MCPServer) {
this.name = data.name;
this.mode = data.mode;
this.enable = data.enable;
// Determine status from runtime_info
if (!data.runtime_info) {
this.status = MCPSessionStatus.ERROR;
this.tools = 0;
} 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 = data.runtime_info.status;
this.tools = 0;
this.error = data.runtime_info.error_message;
}
}
}

View File

@@ -0,0 +1,113 @@
'use client';
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';
export default function MCPComponent({
onEditServer,
}: {
askInstallServer?: (githubURL: string) => void;
onEditServer?: (serverName: string) => void;
}) {
const { t } = useTranslation();
const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);
const [loading, setLoading] = useState(false);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(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
.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);
});
}
return (
<div className="w-full h-full">
{/* 已安装的服务器列表 */}
<div className="w-full px-[0.8rem] pt-[0rem] gap-4">
{loading ? (
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
{t('mcp.loading')}
</div>
) : installedServers.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="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
</svg>
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem]">
{installedServers.map((server, index) => (
<div key={`${server.name}-${index}`}>
<MCPCardComponent
cardVO={server}
onCardClick={() => {
if (onEditServer) {
onEditServer(server.name);
}
}}
onRefresh={fetchInstalledServers}
/>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
import { useState, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
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, Loader2 } from 'lucide-react';
import { MCPSessionStatus } from '@/app/infra/entities/api';
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);
const [toolsCount, setToolsCount] = useState(cardVO.tools);
const [status, setStatus] = useState(cardVO.status);
useEffect(() => {
setStatus(cardVO.status);
setToolsCount(cardVO.tools);
setEnabled(cardVO.enable);
}, [cardVO.status, cardVO.tools, cardVO.enable]);
function handleEnable(checked: boolean) {
setSwitchEnable(false);
httpClient
.toggleMCPServer(cardVO.name, checked)
.then(() => {
setEnabled(checked);
toast.success(t('mcp.saveSuccess'));
onRefresh();
setSwitchEnable(true);
})
.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);
setTesting(false);
if (taskResp.runtime.exception) {
toast.error(
t('mcp.refreshFailed') + taskResp.runtime.exception,
);
} else {
toast.success(t('mcp.refreshSuccess'));
}
// Refresh to get updated runtime_info
onRefresh();
}
});
}, 1000);
})
.catch((err) => {
toast.error(t('mcp.refreshFailed') + err.message);
setTesting(false);
});
}
return (
<div
className="w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] dark:shadow-none p-[1.2rem] cursor-pointer transition-all duration-200 hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.1)] dark:hover:shadow-none"
onClick={onCardClick}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="64"
height="64"
fill="rgba(70,146,221,1)"
>
<path d="M17.6567 14.8284L16.2425 13.4142L17.6567 12C19.2188 10.4379 19.2188 7.90524 17.6567 6.34314C16.0946 4.78105 13.5619 4.78105 11.9998 6.34314L10.5856 7.75736L9.17139 6.34314L10.5856 4.92893C12.9287 2.58578 16.7277 2.58578 19.0709 4.92893C21.414 7.27208 21.414 11.0711 19.0709 13.4142L17.6567 14.8284ZM14.8282 17.6569L13.414 19.0711C11.0709 21.4142 7.27189 21.4142 4.92875 19.0711C2.5856 16.7279 2.5856 12.9289 4.92875 10.5858L6.34296 9.17157L7.75717 10.5858L6.34296 12C4.78086 13.5621 4.78086 16.0948 6.34296 17.6569C7.90506 19.2189 10.4377 19.2189 11.9998 17.6569L13.414 16.2426L14.8282 17.6569ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></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="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
{cardVO.name}
</div>
</div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
{!enabled ? (
// 未启用 - 橙色
<div className="flex flex-row items-center gap-[0.4rem]">
<Ban className="w-4 h-4 text-orange-500 dark:text-orange-400" />
<div className="text-sm text-orange-500 dark:text-orange-400 font-medium">
{t('mcp.statusDisabled')}
</div>
</div>
) : status === MCPSessionStatus.CONNECTED ? (
// 连接成功 - 显示工具数量
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<Wrench className="w-5 h-5" />
<div className="text-base text-black dark:text-[#f0f0f0] font-medium">
{t('mcp.toolCount', { count: toolsCount })}
</div>
</div>
) : status === MCPSessionStatus.CONNECTING ? (
// 连接中 - 蓝色加载
<div className="flex flex-row items-center gap-[0.4rem]">
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
{t('mcp.connecting')}
</div>
</div>
) : (
// 连接失败 - 红色
<div className="flex flex-row items-center gap-[0.4rem]">
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
{t('mcp.connectionFailed')}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-col items-center justify-between h-full">
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<Switch
className="cursor-pointer"
checked={enabled}
onCheckedChange={handleEnable}
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}
>
<RefreshCcw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>
</DialogHeader>
<DialogDescription>{t('mcp.confirmDeleteServer')}</DialogDescription>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={handleDelete}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,666 @@
'use client';
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';
import { z } from 'zod';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
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';
import {
MCPServerRuntimeInfo,
MCPTool,
MCPServer,
MCPSessionStatus,
} from '@/app/infra/entities/api';
// Status Display Component - 在测试中、连接中或连接失败时使用
function StatusDisplay({
testing,
runtimeInfo,
t,
}: {
testing: boolean;
runtimeInfo: MCPServerRuntimeInfo;
t: (key: string) => string;
}) {
if (testing) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.testing')}</span>
</div>
);
}
// 连接中
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.connecting')}</span>
</div>
);
}
// 连接失败
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-red-600">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">{t('mcp.connectionFailed')}</span>
</div>
{runtimeInfo.error_message && (
<div className="text-sm text-red-500 pl-7">
{runtimeInfo.error_message}
</div>
)}
</div>
);
}
// Tools List Component
function ToolsList({ tools }: { tools: MCPTool[] }) {
return (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{tools.map((tool, index) => (
<Card key={index} className="py-3 shadow-none">
<CardHeader>
<CardTitle className="text-sm">{tool.name}</CardTitle>
{tool.description && (
<CardDescription className="text-xs">
{tool.description}
</CardDescription>
)}
</CardHeader>
</Card>
))}
</div>
);
}
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<ReturnType<typeof getFormSchema>> & {
timeout: number;
ssereadtimeout: number;
};
interface MCPFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverName?: string | null;
isEditMode?: boolean;
onSuccess?: () => void;
onDelete?: () => void;
}
export default function MCPFormDialog({
open,
onOpenChange,
serverName,
isEditMode = false,
onSuccess,
onDelete,
}: MCPFormDialogProps) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
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 [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
null,
);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// 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([]);
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);
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);
}
// 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);
toast.error(t('mcp.loadFailed'));
}
}
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
// Convert extra_args to headers - all values must be strings according to MCPServerExtraArgsSSE
const headers: Record<string, string> = {};
value.extra_args?.forEach((arg) => {
// Convert all values to strings to match MCPServerExtraArgsSSE.headers type
headers[arg.key] = String(arg.value);
});
try {
const serverConfig: Omit<
MCPServer,
'uuid' | 'created_at' | 'updated_at' | 'runtime_info'
> = {
name: value.name,
mode: 'sse' as const,
enable: true,
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(serverConfig);
toast.success(t('mcp.createSuccess'));
}
handleDialogClose(false);
onSuccess?.();
} catch (error) {
console.error('Failed to save MCP server:', error);
toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed'));
}
}
async function testMcp() {
setMcpTesting(true);
try {
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'));
}
const interval = setInterval(async () => {
try {
const taskResp = await httpClient.getAsyncTask(task_id);
if (taskResp.runtime?.done) {
clearInterval(interval);
setMcpTesting(false);
if (taskResp.runtime.exception) {
const errorMsg =
taskResp.runtime.exception || t('mcp.unknownError');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
setRuntimeInfo({
status: MCPSessionStatus.ERROR,
error_message: errorMsg,
tool_count: 0,
tools: [],
});
} else {
if (isEditMode) {
await loadServerForEdit(form.getValues('name'));
}
toast.success(t('mcp.testSuccess'));
}
}
} catch (err) {
clearInterval(interval);
setMcpTesting(false);
const errorMsg = (err as Error).message || t('mcp.getTaskFailed');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
}
}, 1000);
} catch (err) {
setMcpTesting(false);
const errorMsg = (err as Error).message || t('mcp.unknownError');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
}
}
const addExtraArg = () => {
const newArgs = [
...extraArgs,
{ key: '', type: 'string' as const, value: '' },
];
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs);
};
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);
};
const handleDialogClose = (open: boolean) => {
onOpenChange(open);
if (!open) {
form.reset();
setExtraArgs([]);
setRuntimeInfo(null);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
</DialogTitle>
</DialogHeader>
{isEditMode && runtimeInfo && (
<div className="mb-4 space-y-3">
{/* 测试中或连接失败时显示状态 */}
{(mcpTesting ||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
<div className="p-3 rounded-lg border">
<StatusDisplay
testing={mcpTesting}
runtimeInfo={runtimeInfo}
t={t}
/>
</div>
)}
{/* 连接成功时只显示工具列表 */}
{!mcpTesting &&
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
runtimeInfo.tools?.length > 0 && (
<ToolsList tools={runtimeInfo.tools} />
)}
</div>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.url')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.timeout')}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ssereadtimeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.sseTimeoutDescription')}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t('models.extraParameters')}</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Select
value={arg.type}
onValueChange={(value) =>
updateExtraArg(index, 'type', value)
}
>
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.type')} />
</SelectTrigger>
<SelectContent className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectItem value="string">
{t('models.string')}
</SelectItem>
<SelectItem value="number">
{t('models.number')}
</SelectItem>
<SelectItem value="boolean">
{t('models.boolean')}
</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<button
type="button"
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeExtraArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{t('models.addParameter')}
</Button>
</div>
<FormDescription>
{t('mcp.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
<DialogFooter>
{isEditMode && onDelete && (
<Button
type="button"
variant="destructive"
onClick={onDelete}
>
{t('common.delete')}
</Button>
)}
<Button type="submit">
{isEditMode ? t('common.save') : t('common.submit')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => handleDialogClose(false)}
>
{t('common.cancel')}
</Button>
</DialogFooter>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -3,7 +3,9 @@ import PluginInstalledComponent, {
PluginInstalledComponentRef, PluginInstalledComponentRef,
} from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent'; } from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent';
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent'; import MarketPage from '@/app/home/plugins/components/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 styles from './plugins.module.css';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -29,7 +31,7 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -46,11 +48,11 @@ enum PluginInstallStatus {
export default function PluginConfigPage() { export default function PluginConfigPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [modalOpen, setModalOpen] = useState(false);
// const [sortModalOpen, setSortModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('installed'); const [activeTab, setActiveTab] = useState('installed');
const [modalOpen, setModalOpen] = useState(false);
const [installSource, setInstallSource] = useState<string>('local'); const [installSource, setInstallSource] = useState<string>('local');
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
const [mcpSSEModalOpen, setMcpSSEModalOpen] = useState(false);
const [pluginInstallStatus, setPluginInstallStatus] = const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT); useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
@@ -59,8 +61,13 @@ export default function PluginConfigPage() {
const [pluginSystemStatus, setPluginSystemStatus] = const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null); useState<ApiRespPluginSystemStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true); const [statusLoading, setStatusLoading] = useState(true);
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [editingServerName, setEditingServerName] = useState<string | null>(
null,
);
const [isEditMode, setIsEditMode] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => { useEffect(() => {
const fetchPluginSystemStatus = async () => { const fetchPluginSystemStatus = async () => {
@@ -81,19 +88,15 @@ export default function PluginConfigPage() {
function watchTask(taskId: number) { function watchTask(taskId: number) {
let alreadySuccess = false; let alreadySuccess = false;
console.log('taskId:', taskId);
// 每秒拉取一次任务状态
const interval = setInterval(() => { const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((resp) => { httpClient.getAsyncTask(taskId).then((resp) => {
console.log('task status:', resp);
if (resp.runtime.done) { if (resp.runtime.done) {
clearInterval(interval); clearInterval(interval);
if (resp.runtime.exception) { if (resp.runtime.exception) {
setInstallError(resp.runtime.exception); setInstallError(resp.runtime.exception);
setPluginInstallStatus(PluginInstallStatus.ERROR); setPluginInstallStatus(PluginInstallStatus.ERROR);
} else { } else {
// success
if (!alreadySuccess) { if (!alreadySuccess) {
toast.success(t('plugins.installSuccess')); toast.success(t('plugins.installSuccess'));
alreadySuccess = true; alreadySuccess = true;
@@ -107,18 +110,18 @@ export default function PluginConfigPage() {
}, 1000); }, 1000);
} }
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
function handleModalConfirm() { function handleModalConfirm() {
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any installPlugin(installSource, installInfo as Record<string, unknown>);
} }
function installPlugin( const installPlugin = useCallback(
installSource: string, (installSource: string, installInfo: Record<string, unknown>) => {
installInfo: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
) {
setPluginInstallStatus(PluginInstallStatus.INSTALLING); setPluginInstallStatus(PluginInstallStatus.INSTALLING);
if (installSource === 'github') { if (installSource === 'github') {
httpClient httpClient
.installPluginFromGithub(installInfo.url) .installPluginFromGithub((installInfo as { url: string }).url)
.then((resp) => { .then((resp) => {
const taskId = resp.task_id; const taskId = resp.task_id;
watchTask(taskId); watchTask(taskId);
@@ -130,7 +133,7 @@ export default function PluginConfigPage() {
}); });
} else if (installSource === 'local') { } else if (installSource === 'local') {
httpClient httpClient
.installPluginFromLocal(installInfo.file) .installPluginFromLocal((installInfo as { file: File }).file)
.then((resp) => { .then((resp) => {
const taskId = resp.task_id; const taskId = resp.task_id;
watchTask(taskId); watchTask(taskId);
@@ -143,16 +146,18 @@ export default function PluginConfigPage() {
} else if (installSource === 'marketplace') { } else if (installSource === 'marketplace') {
httpClient httpClient
.installPluginFromMarketplace( .installPluginFromMarketplace(
installInfo.plugin_author, (installInfo as { plugin_author: string }).plugin_author,
installInfo.plugin_name, (installInfo as { plugin_name: string }).plugin_name,
installInfo.plugin_version, (installInfo as { plugin_version: string }).plugin_version,
) )
.then((resp) => { .then((resp) => {
const taskId = resp.task_id; const taskId = resp.task_id;
watchTask(taskId); watchTask(taskId);
}); });
} }
} },
[watchTask],
);
const validateFileType = (file: File): boolean => { const validateFileType = (file: File): boolean => {
const allowedExtensions = ['.lbpkg', '.zip']; const allowedExtensions = ['.lbpkg', '.zip'];
@@ -177,7 +182,7 @@ export default function PluginConfigPage() {
setInstallError(null); setInstallError(null);
installPlugin('local', { file }); installPlugin('local', { file });
}, },
[t, pluginSystemStatus], [t, pluginSystemStatus, installPlugin],
); );
const handleFileSelect = useCallback(() => { const handleFileSelect = useCallback(() => {
@@ -192,7 +197,7 @@ export default function PluginConfigPage() {
if (file) { if (file) {
uploadPluginFile(file); uploadPluginFile(file);
} }
// 清空input值以便可以重复选择同一个文件
event.target.value = ''; event.target.value = '';
}, },
[uploadPluginFile], [uploadPluginFile],
@@ -234,7 +239,6 @@ export default function PluginConfigPage() {
[uploadPluginFile, isPluginSystemReady, t], [uploadPluginFile, isPluginSystemReady, t],
); );
// 插件系统未启用的状态显示
const renderPluginDisabledState = () => ( const renderPluginDisabledState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]"> <div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
<Power className="w-16 h-16 text-gray-400 mb-4" /> <Power className="w-16 h-16 text-gray-400 mb-4" />
@@ -247,7 +251,6 @@ export default function PluginConfigPage() {
</div> </div>
); );
// 插件系统连接异常的状态显示
const renderPluginConnectionErrorState = () => ( const renderPluginConnectionErrorState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]"> <div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
<svg <svg
@@ -269,7 +272,6 @@ export default function PluginConfigPage() {
</div> </div>
); );
// 加载状态显示
const renderLoadingState = () => ( const renderLoadingState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] pt-[10vh]"> <div className="flex flex-col items-center justify-center h-[60vh] pt-[10vh]">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
@@ -278,7 +280,6 @@ export default function PluginConfigPage() {
</div> </div>
); );
// 根据状态返回不同的内容
if (statusLoading) { if (statusLoading) {
return renderLoadingState(); return renderLoadingState();
} }
@@ -316,27 +317,42 @@ export default function PluginConfigPage() {
{t('plugins.marketplace')} {t('plugins.marketplace')}
</TabsTrigger> </TabsTrigger>
)} )}
<TabsTrigger
value="mcp-servers"
className="px-6 py-4 cursor-pointer"
>
{t('mcp.title')}
</TabsTrigger>
</TabsList> </TabsList>
<div className="flex flex-row justify-end items-center"> <div className="flex flex-row justify-end items-center">
{/* <Button
variant="outline"
className="px-6 py-4 cursor-pointer mr-2"
onClick={() => {
// setSortModalOpen(true);
}}
>
{t('plugins.arrange')}
</Button> */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="default" className="px-6 py-4 cursor-pointer"> <Button variant="default" className="px-6 py-4 cursor-pointer">
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
{t('plugins.install')} {activeTab === 'mcp-servers'
? t('mcp.add')
: t('plugins.install')}
<ChevronDownIcon className="ml-2 w-4 h-4" /> <ChevronDownIcon className="ml-2 w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{activeTab === 'mcp-servers' ? (
<>
<DropdownMenuItem
onClick={() => {
setActiveTab('mcp-servers');
setIsEditMode(false);
setEditingServerName(null);
setMcpSSEModalOpen(true);
}}
>
<PlusIcon className="w-4 h-4" />
{t('mcp.createServer')}
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem onClick={handleFileSelect}> <DropdownMenuItem onClick={handleFileSelect}>
<UploadIcon className="w-4 h-4" /> <UploadIcon className="w-4 h-4" />
{t('plugins.uploadLocal')} {t('plugins.uploadLocal')}
@@ -351,6 +367,8 @@ export default function PluginConfigPage() {
{t('plugins.marketplace')} {t('plugins.marketplace')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -372,6 +390,16 @@ export default function PluginConfigPage() {
}} }}
/> />
</TabsContent> </TabsContent>
<TabsContent value="mcp-servers">
<MCPServerComponent
key={refreshKey}
onEditServer={(serverName) => {
setEditingServerName(serverName);
setIsEditMode(true);
setMcpSSEModalOpen(true);
}}
/>
</TabsContent>
</Tabs> </Tabs>
<Dialog open={modalOpen} onOpenChange={setModalOpen}> <Dialog open={modalOpen} onOpenChange={setModalOpen}>
@@ -435,7 +463,6 @@ export default function PluginConfigPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 拖拽提示覆盖层 */}
{isDragOver && ( {isDragOver && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50 pointer-events-none"> <div className="fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50 pointer-events-none">
<div className="bg-white rounded-lg p-8 shadow-lg border-2 border-dashed border-gray-500"> <div className="bg-white rounded-lg p-8 shadow-lg border-2 border-dashed border-gray-500">
@@ -449,13 +476,32 @@ export default function PluginConfigPage() {
</div> </div>
)} )}
{/* <PluginSortDialog <MCPFormDialog
open={sortModalOpen} open={mcpSSEModalOpen}
onOpenChange={setSortModalOpen} onOpenChange={setMcpSSEModalOpen}
onSortComplete={() => { serverName={editingServerName}
pluginInstalledRef.current?.refreshPluginList(); isEditMode={isEditMode}
onSuccess={() => {
setEditingServerName(null);
setIsEditMode(false);
setRefreshKey((prev) => prev + 1);
}} }}
/> */} onDelete={() => {
setShowDeleteConfirmModal(true);
}}
/>
<MCPDeleteConfirmDialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
serverName={editingServerName}
onSuccess={() => {
setMcpSSEModalOpen(false);
setEditingServerName(null);
setIsEditMode(false);
setRefreshKey((prev) => prev + 1);
}}
/>
</div> </div>
); );
} }

View File

@@ -308,3 +308,49 @@ export interface RetrieveResult {
export interface ApiRespKnowledgeBaseRetrieve { export interface ApiRespKnowledgeBaseRetrieve {
results: RetrieveResult[]; results: RetrieveResult[];
} }
// MCP
export interface ApiRespMCPServers {
servers: MCPServer[];
}
export interface ApiRespMCPServer {
server: MCPServer;
}
export interface MCPServerExtraArgsSSE {
url: string;
headers: Record<string, string>;
timeout: number;
ssereadtimeout: number;
}
export enum MCPSessionStatus {
CONNECTING = 'connecting',
CONNECTED = 'connected',
ERROR = 'error',
}
export interface MCPServerRuntimeInfo {
status: MCPSessionStatus;
error_message: string;
tool_count: number;
tools: MCPTool[];
}
export interface MCPServer {
uuid?: string;
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
extra_args: MCPServerExtraArgsSSE;
runtime_info?: MCPServerRuntimeInfo;
created_at?: string;
updated_at?: string;
}
export interface MCPTool {
name: string;
description: string;
parameters?: object;
}

View File

@@ -33,6 +33,9 @@ import {
ApiRespProviderEmbeddingModel, ApiRespProviderEmbeddingModel,
EmbeddingModel, EmbeddingModel,
ApiRespPluginSystemStatus, ApiRespPluginSystemStatus,
ApiRespMCPServers,
ApiRespMCPServer,
MCPServer,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest'; import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -491,6 +494,58 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/plugins/${author}/${name}/upgrade`); 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: MCPServer): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/servers', server);
}
public updateMCPServer(
serverName: string,
server: Partial<MCPServer>,
): 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}`, {
enable: target_enabled,
});
}
public testMCPServer(
serverName: string,
serverData: object,
): Promise<AsyncTaskCreatedResp> {
return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData);
}
public installMCPServerFromGithub(
source: string,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/install/github', { source });
}
public installMCPServerFromSSE(
source: object,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/servers', { source });
}
// ============ System API ============ // ============ System API ============
public getSystemInfo(): Promise<ApiRespSystemInfo> { public getSystemInfo(): Promise<ApiRespSystemInfo> {
return this.get('/api/v1/system/info'); return this.get('/api/v1/system/info');

View File

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

View File

@@ -7,9 +7,71 @@ import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Dialog({ function Dialog({
onOpenChange,
open,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />; 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 (
<DialogPrimitive.Root
data-slot="dialog"
open={open}
{...props}
onOpenChange={handleOpenChange}
/>
);
} }
function DialogTrigger({ function DialogTrigger({
@@ -60,7 +122,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', '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, className,
)} )}
onInteractOutside={() => {}}
{...props} {...props}
> >
{children} {children}

View File

@@ -151,7 +151,7 @@ const enUS = {
logs: 'Logs', logs: 'Logs',
}, },
plugins: { plugins: {
title: 'Plugins', title: 'Extensions',
description: description:
'Install and configure plugins to extend LangBot functionality', 'Install and configure plugins to extend LangBot functionality',
createPlugin: 'Create Plugin', createPlugin: 'Create Plugin',
@@ -286,6 +286,83 @@ const enUS = {
markAsReadSuccess: 'Marked as read', markAsReadSuccess: 'Marked as read',
markAsReadFailed: 'Mark as read failed', markAsReadFailed: 'Mark as read failed',
}, },
mcp: {
title: 'MCP',
createServer: 'Add 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',
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',
url: 'URL',
headers: 'Headers',
timeout: 'Timeout',
addArgument: 'Add Argument',
addEnvVar: 'Add Environment Variable',
addHeader: 'Add Header',
keyName: 'Key Name',
value: 'Value',
testing: 'Testing...',
connecting: 'Connecting...',
testSuccess: 'Test successful',
testFailed: 'Test failed: ',
testError: 'Test error',
refreshSuccess: 'Refresh successful',
refreshFailed: 'Refresh failed: ',
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',
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',
loading: 'Loading...',
starCount: 'Stars: {{count}}',
install: 'Install',
installFromGithub: 'Install MCP Server from GitHub',
add: 'Add',
name: 'Name',
nameRequired: 'Name cannot be empty',
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',
updateFailed: 'Update failed: ',
},
pipelines: { pipelines: {
title: 'Pipelines', title: 'Pipelines',
description: description:

View File

@@ -153,7 +153,7 @@ const jaJP = {
logs: 'ログ', logs: 'ログ',
}, },
plugins: { plugins: {
title: 'プラグイン', title: '拡張機能',
description: 'LangBotの機能を拡張するプラグインをインストール・設定', description: 'LangBotの機能を拡張するプラグインをインストール・設定',
createPlugin: 'プラグインを作成', createPlugin: 'プラグインを作成',
editPlugin: 'プラグインを編集', editPlugin: 'プラグインを編集',
@@ -287,6 +287,83 @@ const jaJP = {
markAsReadSuccess: '既読に設定しました', markAsReadSuccess: '既読に設定しました',
markAsReadFailed: '既読に設定に失敗しました', markAsReadFailed: '既読に設定に失敗しました',
}, },
mcp: {
title: 'MCP',
createServer: 'MCPサーバーを追加',
editServer: 'MCPサーバーを編集',
deleteServer: 'MCPサーバーを削除',
confirmDeleteServer: 'このMCPサーバーを削除してもよろしいですか',
confirmDeleteTitle: 'MCPサーバーを削除',
getServerListError: 'MCPサーバーリストの取得に失敗しました',
serverName: 'サーバー名',
serverMode: '接続モード',
stdio: 'Stdioモード',
sse: 'SSEモード',
noServerInstalled: 'MCPサーバーが設定されていません',
serverNameRequired: 'サーバー名は必須です',
commandRequired: 'コマンドは必須です',
urlRequired: 'URLは必須です',
timeoutMustBePositive: 'タイムアウトは正の数でなければなりません',
command: 'コマンド',
args: '引数',
env: '環境変数',
url: 'URL',
headers: 'ヘッダー',
timeout: 'タイムアウト',
addArgument: '引数を追加',
addEnvVar: '環境変数を追加',
addHeader: 'ヘッダーを追加',
keyName: 'キー名',
value: '値',
testing: 'テスト中...',
connecting: '接続中...',
testSuccess: '刷新に成功しました',
testFailed: '刷新に失敗しました:',
testError: '刷新エラー',
refreshSuccess: '刷新に成功しました',
refreshFailed: '刷新に失敗しました:',
connectionSuccess: '接続に成功しました',
connectionFailed: '接続に失敗しました',
toolsFound: '個のツール',
unknownError: '不明なエラー',
noToolsFound: 'ツールが見つかりません',
parseResultFailed: 'テスト結果の解析に失敗しました',
noResultReturned: 'テスト結果が返されませんでした',
getTaskFailed: 'タスクステータスの取得に失敗しました',
noTaskId: 'タスクIDを取得できませんでした',
deleteSuccess: '削除に成功しました',
deleteFailed: '削除に失敗しました:',
deleteError: '削除に失敗しました:',
saveSuccess: '保存に成功しました',
saveError: '保存に失敗しました:',
createSuccess: '作成に成功しました',
createFailed: '作成に失敗しました:',
createError: '作成に失敗しました:',
loadFailed: '読み込みに失敗しました',
modifyFailed: '変更に失敗しました:',
toolCount: 'ツール:{{count}}',
statusConnected: '接続済み',
statusDisconnected: '未接続',
statusError: '接続エラー',
statusDisabled: '無効',
loading: '読み込み中...',
starCount: 'スター:{{count}}',
install: 'インストール',
installFromGithub: 'GitHubからMCPサーバーをインストール',
add: '追加',
name: '名前',
nameRequired: '名前は必須です',
sseTimeout: 'SSEタイムアウト',
sseTimeoutDescription: 'SSE接続を確立するためのタイムアウト',
extraParametersDescription:
'MCPサーバーの特定の動作を設定するための追加パラメータ',
timeoutMustBeNumber: 'タイムアウトは数値である必要があります',
timeoutNonNegative: 'タイムアウトは負の数にできません',
sseTimeoutMustBeNumber: 'SSEタイムアウトは数値である必要があります',
sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません',
updateSuccess: '更新に成功しました',
updateFailed: '更新に失敗しました:',
},
pipelines: { pipelines: {
title: 'パイプライン', title: 'パイプライン',
description: description:

View File

@@ -148,7 +148,7 @@ const zhHans = {
logs: '日志', logs: '日志',
}, },
plugins: { plugins: {
title: '插件管理', title: '插件扩展',
description: '安装和配置用于扩展 LangBot 功能的插件', description: '安装和配置用于扩展 LangBot 功能的插件',
createPlugin: '创建插件', createPlugin: '创建插件',
editPlugin: '编辑插件', editPlugin: '编辑插件',
@@ -272,6 +272,82 @@ const zhHans = {
markAsReadSuccess: '已标记为已读', markAsReadSuccess: '已标记为已读',
markAsReadFailed: '标记为已读失败', markAsReadFailed: '标记为已读失败',
}, },
mcp: {
title: 'MCP',
createServer: '添加 MCP 服务器',
editServer: '修改 MCP 服务器',
deleteServer: '删除 MCP 服务器',
confirmDeleteServer: '你确定要删除此 MCP 服务器吗?',
confirmDeleteTitle: '删除 MCP 服务器',
getServerListError: '获取 MCP 服务器列表失败:',
serverName: '服务器名称',
serverMode: '连接模式',
stdio: 'Stdio模式',
sse: 'SSE模式',
noServerInstalled: '暂未配置任何 MCP 服务器',
serverNameRequired: '服务器名称不能为空',
commandRequired: '命令不能为空',
urlRequired: 'URL 不能为空',
timeoutMustBePositive: '超时时间必须是正数',
command: '命令',
args: '参数',
env: '环境变量',
url: 'URL地址',
headers: '请求头',
timeout: '超时时间',
addArgument: '添加参数',
addEnvVar: '添加环境变量',
addHeader: '添加请求头',
keyName: '键名',
value: '值',
testing: '测试中...',
connecting: '连接中...',
testSuccess: '测试成功',
testFailed: '测试失败:',
testError: '刷新出错',
refreshSuccess: '刷新成功',
refreshFailed: '刷新失败:',
connectionSuccess: '连接成功',
connectionFailed: '连接失败',
toolsFound: '个工具',
unknownError: '未知错误',
noToolsFound: '未找到任何工具',
parseResultFailed: '解析测试结果失败',
noResultReturned: '测试未返回结果',
getTaskFailed: '获取任务状态失败',
noTaskId: '未获取到任务ID',
deleteSuccess: '删除成功',
deleteFailed: '删除失败:',
deleteError: '删除失败:',
saveSuccess: '保存成功',
saveError: '保存失败:',
createSuccess: '创建成功',
createFailed: '创建失败:',
createError: '创建失败:',
loadFailed: '加载失败',
modifyFailed: '修改失败:',
toolCount: '工具:{{count}}',
statusConnected: '已打开',
statusDisconnected: '未打开',
statusError: '连接错误',
statusDisabled: '已禁用',
loading: '加载中...',
starCount: '星标:{{count}}',
install: '安装',
installFromGithub: '从Github安装MCP服务器',
add: '添加',
name: '名称',
nameRequired: '名称不能为空',
sseTimeout: 'SSE超时时间',
sseTimeoutDescription: '用于建立SSE连接的超时时间',
extraParametersDescription: '额外参数用于配置MCP服务器的特定行为',
timeoutMustBeNumber: '超时时间必须是数字',
timeoutNonNegative: '超时时间不能为负数',
sseTimeoutMustBeNumber: 'SSE超时时间必须是数字',
sseTimeoutNonNegative: 'SSE超时时间不能为负数',
updateSuccess: '更新成功',
updateFailed: '更新失败:',
},
pipelines: { pipelines: {
title: '流水线', title: '流水线',
description: '流水线定义了对消息事件的处理流程,用于绑定到机器人', description: '流水线定义了对消息事件的处理流程,用于绑定到机器人',

View File

@@ -148,7 +148,7 @@ const zhHant = {
logs: '日誌', logs: '日誌',
}, },
plugins: { plugins: {
title: '外掛管理', title: '外掛擴展',
description: '安裝和設定用於擴展 LangBot 功能的外掛', description: '安裝和設定用於擴展 LangBot 功能的外掛',
createPlugin: '建立外掛', createPlugin: '建立外掛',
editPlugin: '編輯外掛', editPlugin: '編輯外掛',
@@ -271,6 +271,82 @@ const zhHant = {
markAsReadSuccess: '已標記為已讀', markAsReadSuccess: '已標記為已讀',
markAsReadFailed: '標記為已讀失敗', markAsReadFailed: '標記為已讀失敗',
}, },
mcp: {
title: 'MCP',
createServer: '新增MCP伺服器',
editServer: '編輯MCP伺服器',
deleteServer: '刪除MCP伺服器',
confirmDeleteServer: '您確定要刪除此MCP伺服器嗎',
confirmDeleteTitle: '刪除MCP伺服器',
getServerListError: '取得MCP伺服器清單失敗',
serverName: '伺服器名稱',
serverMode: '連接模式',
stdio: 'Stdio模式',
sse: 'SSE模式',
noServerInstalled: '暫未設定任何MCP伺服器',
serverNameRequired: '伺服器名稱不能為空',
commandRequired: '命令不能為空',
urlRequired: 'URL不能為空',
timeoutMustBePositive: '逾時時間必須是正數',
command: '命令',
args: '參數',
env: '環境變數',
url: 'URL位址',
headers: '請求標頭',
timeout: '逾時時間',
addArgument: '新增參數',
addEnvVar: '新增環境變數',
addHeader: '新增請求標頭',
keyName: '鍵名',
value: '值',
testing: '測試中...',
connecting: '連接中...',
testSuccess: '測試成功',
testFailed: '刷新失敗:',
testError: '刷新出錯',
refreshSuccess: '刷新成功',
refreshFailed: '刷新失敗:',
connectionSuccess: '連接成功',
connectionFailed: '連接失敗',
toolsFound: '個工具',
unknownError: '未知錯誤',
noToolsFound: '未找到任何工具',
parseResultFailed: '解析測試結果失敗',
noResultReturned: '測試未返回結果',
getTaskFailed: '獲取任務狀態失敗',
noTaskId: '未獲取到任務ID',
deleteSuccess: '刪除成功',
deleteFailed: '刪除失敗:',
deleteError: '刪除失敗:',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
createSuccess: '建立成功',
createFailed: '建立失敗:',
createError: '建立失敗:',
loadFailed: '載入失敗',
modifyFailed: '修改失敗:',
toolCount: '工具:{{count}}',
statusConnected: '已開啟',
statusDisconnected: '未開啟',
statusError: '連接錯誤',
statusDisabled: '已停用',
loading: '載入中...',
starCount: '星標:{{count}}',
install: '安裝',
installFromGithub: '從Github安裝MCP伺服器',
add: '新增',
name: '名稱',
nameRequired: '名稱不能為空',
sseTimeout: 'SSE逾時時間',
sseTimeoutDescription: '用於建立SSE連接的逾時時間',
extraParametersDescription: '額外參數用於設定MCP伺服器的特定行為',
timeoutMustBeNumber: '逾時時間必須是數字',
timeoutNonNegative: '逾時時間不能為負數',
sseTimeoutMustBeNumber: 'SSE逾時時間必須是數字',
sseTimeoutNonNegative: 'SSE逾時時間不能為負數',
updateSuccess: '更新成功',
updateFailed: '更新失敗:',
},
pipelines: { pipelines: {
title: '流程線', title: '流程線',
description: '流程線定義了對訊息事件的處理流程,用於綁定到機器人', description: '流程線定義了對訊息事件的處理流程,用於綁定到機器人',