From b1dd8d5e16bfbe4e7de2bef9f0f394baa7b36f52 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 11 Mar 2026 20:55:42 +0800 Subject: [PATCH] v0.1.4 --- .env.prod.example | 2 +- backend/core/config_manager.py | 21 +- backend/core/docker_manager.py | 89 +++++ backend/main.py | 323 ++++++++++++++++ .../src/components/lucent/LucentSelect.tsx | 23 ++ .../src/components/lucent/lucent-select.css | 49 +++ frontend/src/i18n/channels.en.ts | 1 + frontend/src/i18n/channels.zh-cn.ts | 1 + frontend/src/i18n/dashboard.en.ts | 23 ++ frontend/src/i18n/dashboard.zh-cn.ts | 23 ++ .../modules/dashboard/BotDashboardModule.tsx | 359 ++++++++++++++++-- .../management/components/CreateBotModal.tsx | 11 +- .../modules/onboarding/BotWizardModule.tsx | 50 ++- 13 files changed, 921 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/lucent/LucentSelect.tsx create mode 100644 frontend/src/components/lucent/lucent-select.css diff --git a/.env.prod.example b/.env.prod.example index 4730d47..7d64a0d 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -42,4 +42,4 @@ REDIS_DEFAULT_TTL=60 PANEL_ACCESS_PASSWORD=change_me_panel_password # Max upload size for backend validation (MB) -UPLOAD_MAX_MB=100 +UPLOAD_MAX_MB=200 diff --git a/backend/core/config_manager.py b/backend/core/config_manager.py index 35819ba..64f9979 100644 --- a/backend/core/config_manager.py +++ b/backend/core/config_manager.py @@ -50,6 +50,24 @@ class BotConfigManager: "sendToolHints": bool(bot_data.get("send_tool_hints", False)), } + existing_config: Dict[str, Any] = {} + config_path = os.path.join(dot_nanobot_dir, "config.json") + if os.path.isfile(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + existing_config = loaded + except Exception: + existing_config = {} + + existing_tools = existing_config.get("tools") + tools_cfg: Dict[str, Any] = dict(existing_tools) if isinstance(existing_tools, dict) else {} + if "mcp_servers" in bot_data: + mcp_servers = bot_data.get("mcp_servers") + if isinstance(mcp_servers, dict): + tools_cfg["mcpServers"] = mcp_servers + config_data: Dict[str, Any] = { "agents": { "defaults": { @@ -64,6 +82,8 @@ class BotConfigManager: }, "channels": channels_cfg, } + if tools_cfg: + config_data["tools"] = tools_cfg for channel in channels: channel_type = (channel.get("channel_type") or "").strip() @@ -149,7 +169,6 @@ class BotConfigManager: **extra, } - config_path = os.path.join(dot_nanobot_dir, "config.json") with open(config_path, "w", encoding="utf-8") as f: json.dump(config_data, f, indent=4, ensure_ascii=False) diff --git a/backend/core/docker_manager.py b/backend/core/docker_manager.py index 8584674..600e134 100644 --- a/backend/core/docker_manager.py +++ b/backend/core/docker_manager.py @@ -211,6 +211,95 @@ class BotDockerManager: print(f"[DockerManager] Error stopping bot {bot_id}: {e}") return False + def probe_http_from_container( + self, + bot_id: str, + url: str, + method: str = "GET", + headers: Optional[Dict[str, str]] = None, + body_json: Optional[Dict[str, Any]] = None, + timeout_seconds: int = 10, + ) -> Dict[str, Any]: + if not self.client: + return {"ok": False, "message": "Docker client is not available"} + try: + container = self.client.containers.get(f"worker_{bot_id}") + container.reload() + if container.status != "running": + return {"ok": False, "message": f"Bot container is {container.status}"} + except docker.errors.NotFound: + return {"ok": False, "message": "Bot container not found"} + except Exception as e: + return {"ok": False, "message": f"Failed to inspect bot container: {e}"} + + safe_method = str(method or "GET").strip().upper() + if safe_method not in {"GET", "POST"}: + safe_method = "GET" + timeout = max(1, min(int(timeout_seconds or 10), 30)) + payload = { + "url": str(url or "").strip(), + "method": safe_method, + "headers": headers or {}, + "body_json": body_json if isinstance(body_json, dict) else None, + "timeout": timeout, + } + payload_b64 = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii") + py_script = ( + "import base64, json, os, urllib.request, urllib.error\n" + "cfg = json.loads(base64.b64decode(os.environ['DASHBOARD_HTTP_PROBE_B64']).decode('utf-8'))\n" + "url = str(cfg.get('url') or '').strip()\n" + "method = str(cfg.get('method') or 'GET').upper()\n" + "headers = cfg.get('headers') or {}\n" + "timeout = int(cfg.get('timeout') or 10)\n" + "data = None\n" + "if method == 'POST':\n" + " body = cfg.get('body_json')\n" + " if not isinstance(body, dict):\n" + " body = {}\n" + " data = json.dumps(body, ensure_ascii=False).encode('utf-8')\n" + " if 'Content-Type' not in headers:\n" + " headers['Content-Type'] = 'application/json'\n" + "req = urllib.request.Request(url, data=data, headers=headers, method=method)\n" + "result = {'ok': False, 'status_code': None, 'content_type': '', 'body_preview': '', 'message': ''}\n" + "try:\n" + " with urllib.request.urlopen(req, timeout=timeout) as resp:\n" + " body = resp.read(1024).decode('utf-8', 'ignore')\n" + " result.update({'ok': True, 'status_code': int(getattr(resp, 'status', 200) or 200), 'content_type': str(resp.headers.get('content-type') or ''), 'body_preview': body[:512], 'message': 'ok'})\n" + "except urllib.error.HTTPError as e:\n" + " body = ''\n" + " try:\n" + " body = e.read(1024).decode('utf-8', 'ignore')\n" + " except Exception:\n" + " body = ''\n" + " result.update({'ok': False, 'status_code': int(e.code or 0), 'content_type': str((e.headers or {}).get('content-type') or ''), 'body_preview': body[:512], 'message': f'HTTPError: {e.code}'})\n" + "except Exception as e:\n" + " result.update({'ok': False, 'status_code': None, 'content_type': '', 'body_preview': '', 'message': f'{type(e).__name__}: {e}'})\n" + "print(json.dumps(result, ensure_ascii=False))\n" + ) + + py_bins = ["python3", "python"] + last_error = "" + for py_bin in py_bins: + try: + exec_result = container.exec_run( + [py_bin, "-c", py_script], + environment={"DASHBOARD_HTTP_PROBE_B64": payload_b64}, + ) + except Exception as e: + last_error = f"exec {py_bin} failed: {e}" + continue + output = exec_result.output.decode("utf-8", errors="ignore") if isinstance(exec_result.output, (bytes, bytearray)) else str(exec_result.output) + if exec_result.exit_code != 0: + last_error = f"exec {py_bin} exit={exec_result.exit_code}: {output[:300]}" + continue + try: + parsed = json.loads(output.strip() or "{}") + if isinstance(parsed, dict): + return parsed + except Exception: + last_error = f"exec {py_bin} returned non-json: {output[:300]}" + return {"ok": False, "message": last_error or "Failed to run probe in bot container"} + def send_command(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: """Send a command to dashboard channel with robust container-local delivery.""" if not self.client: diff --git a/backend/main.py b/backend/main.py index d7b6039..f991041 100644 --- a/backend/main.py +++ b/backend/main.py @@ -134,6 +134,17 @@ class BotToolsConfigUpdateRequest(BaseModel): tools_config: Optional[Dict[str, Any]] = None +class BotMcpConfigUpdateRequest(BaseModel): + mcp_servers: Optional[Dict[str, Any]] = None + + +class BotMcpConfigTestRequest(BaseModel): + type: Optional[str] = None + url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + tool_timeout: Optional[int] = None + + class BotEnvParamsUpdateRequest(BaseModel): env_params: Optional[Dict[str, str]] = None @@ -865,6 +876,263 @@ def _normalize_env_params(raw: Any) -> Dict[str, str]: return rows +_MCP_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") + + +def _normalize_mcp_servers(raw: Any) -> Dict[str, Dict[str, Any]]: + if not isinstance(raw, dict): + return {} + rows: Dict[str, Dict[str, Any]] = {} + for server_name, server_cfg in raw.items(): + name = str(server_name or "").strip() + if not name or not _MCP_SERVER_NAME_RE.match(name): + continue + if not isinstance(server_cfg, dict): + continue + + url = str(server_cfg.get("url") or "").strip() + if not url: + continue + + transport_type = str(server_cfg.get("type") or "streamableHttp").strip() + if transport_type not in {"streamableHttp", "sse"}: + transport_type = "streamableHttp" + + headers_raw = server_cfg.get("headers") + headers: Dict[str, str] = {} + if isinstance(headers_raw, dict): + for k, v in headers_raw.items(): + hk = str(k or "").strip() + if not hk: + continue + headers[hk] = str(v or "").strip() + + timeout_raw = server_cfg.get("toolTimeout", 60) + try: + timeout = int(timeout_raw) + except Exception: + timeout = 60 + timeout = max(1, min(timeout, 600)) + + rows[name] = { + "type": transport_type, + "url": url, + "headers": headers, + "toolTimeout": timeout, + } + return rows + + +def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict[str, Any]: + transport_type = str(cfg.get("type") or "streamableHttp").strip() + if transport_type not in {"streamableHttp", "sse"}: + transport_type = "streamableHttp" + url = str(cfg.get("url") or "").strip() + headers_raw = cfg.get("headers") + headers: Dict[str, str] = {} + if isinstance(headers_raw, dict): + for k, v in headers_raw.items(): + key = str(k or "").strip() + if key: + headers[key] = str(v or "").strip() + timeout_raw = cfg.get("toolTimeout", 10) + try: + timeout_s = max(1, min(int(timeout_raw), 30)) + except Exception: + timeout_s = 10 + + if not url: + return { + "ok": False, + "transport": transport_type, + "status_code": None, + "message": "MCP url is required", + "probe_from": "validation", + } + + probe_payload = { + "jsonrpc": "2.0", + "id": "dashboard-probe", + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "dashboard-nanobot", "version": "0.1.4"}, + }, + } + + if bot_id: + if transport_type == "sse": + probe_headers = dict(headers) + probe_headers.setdefault("Accept", "text/event-stream") + probe = docker_manager.probe_http_from_container( + bot_id=bot_id, + url=url, + method="GET", + headers=probe_headers, + body_json=None, + timeout_seconds=timeout_s, + ) + status_code = probe.get("status_code") + content_type = str(probe.get("content_type") or "") + message = str(probe.get("message") or "").strip() + if status_code in {401, 403}: + return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP SSE endpoint", "content_type": content_type, "probe_from": "bot-container"} + if status_code == 404: + return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint not found", "content_type": content_type, "probe_from": "bot-container"} + if isinstance(status_code, int) and status_code >= 500: + return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint server error", "content_type": content_type, "probe_from": "bot-container"} + if not probe.get("ok"): + return {"ok": False, "transport": transport_type, "status_code": status_code, "message": message or "Failed to connect MCP SSE endpoint from bot container", "content_type": content_type, "probe_from": "bot-container"} + if "text/event-stream" not in content_type.lower(): + return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Endpoint reachable, but content-type is not text/event-stream", "content_type": content_type, "probe_from": "bot-container"} + return {"ok": True, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint is reachable", "content_type": content_type, "probe_from": "bot-container"} + + probe_headers = dict(headers) + probe_headers.setdefault("Content-Type", "application/json") + probe_headers.setdefault("Accept", "application/json, text/event-stream") + probe = docker_manager.probe_http_from_container( + bot_id=bot_id, + url=url, + method="POST", + headers=probe_headers, + body_json=probe_payload, + timeout_seconds=timeout_s, + ) + status_code = probe.get("status_code") + message = str(probe.get("message") or "").strip() + if status_code in {401, 403}: + return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP endpoint", "probe_from": "bot-container"} + if status_code == 404: + return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP endpoint not found", "probe_from": "bot-container"} + if isinstance(status_code, int) and status_code >= 500: + return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP endpoint server error", "probe_from": "bot-container"} + if probe.get("ok") and status_code in {200, 201, 202, 204, 400, 405, 415, 422}: + reachability_msg = "MCP endpoint is reachable" if status_code in {200, 201, 202, 204} else "MCP endpoint is reachable (request format not fully accepted by probe)" + return {"ok": True, "transport": transport_type, "status_code": status_code, "message": reachability_msg, "probe_from": "bot-container"} + return {"ok": False, "transport": transport_type, "status_code": status_code, "message": message or "Unexpected response from MCP endpoint", "probe_from": "bot-container"} + + try: + with httpx.Client(timeout=httpx.Timeout(timeout_s), follow_redirects=True) as client: + if transport_type == "sse": + req_headers = dict(headers) + req_headers.setdefault("Accept", "text/event-stream") + resp = client.get(url, headers=req_headers) + content_type = str(resp.headers.get("content-type") or "") + if resp.status_code in {401, 403}: + return { + "ok": False, + "transport": transport_type, + "status_code": resp.status_code, + "message": "Auth failed for MCP SSE endpoint", + "content_type": content_type, + "probe_from": "backend-host", + } + if resp.status_code == 404: + return { + "ok": False, + "transport": transport_type, + "status_code": resp.status_code, + "message": "MCP SSE endpoint not found", + "content_type": content_type, + "probe_from": "backend-host", + } + if resp.status_code >= 500: + return { + "ok": False, + "transport": transport_type, + "status_code": resp.status_code, + "message": "MCP SSE endpoint server error", + "content_type": content_type, + "probe_from": "backend-host", + } + if "text/event-stream" not in content_type.lower(): + return { + "ok": False, + "transport": transport_type, + "status_code": resp.status_code, + "message": "Endpoint reachable, but content-type is not text/event-stream", + "content_type": content_type, + "probe_from": "backend-host", + } + return { + "ok": True, + "transport": transport_type, + "status_code": resp.status_code, + "message": "MCP SSE endpoint is reachable", + "content_type": content_type, + "probe_from": "backend-host", + } + + req_headers = dict(headers) + req_headers.setdefault("Content-Type", "application/json") + req_headers.setdefault("Accept", "application/json, text/event-stream") + resp = client.post(url, headers=req_headers, json=probe_payload) + if resp.status_code in {401, 403}: + return { + "ok": False, + "transport": transport_type, + "status_code": resp.status_code, + "message": "Auth failed for MCP endpoint", + "probe_from": "backend-host", + } + if resp.status_code == 404: + return { + "ok": False, + "transport": transport_type, + "status_code": resp.status_code, + "message": "MCP endpoint not found", + "probe_from": "backend-host", + } + if resp.status_code >= 500: + return { + "ok": False, + "transport": transport_type, + "status_code": resp.status_code, + "message": "MCP endpoint server error", + "probe_from": "backend-host", + } + if resp.status_code in {200, 201, 202, 204}: + return { + "ok": True, + "transport": transport_type, + "status_code": resp.status_code, + "message": "MCP endpoint is reachable", + "probe_from": "backend-host", + } + if resp.status_code in {400, 405, 415, 422}: + return { + "ok": True, + "transport": transport_type, + "status_code": resp.status_code, + "message": "MCP endpoint is reachable (request format not fully accepted by probe)", + "probe_from": "backend-host", + } + return { + "ok": False, + "transport": transport_type, + "status_code": resp.status_code, + "message": "Unexpected response from MCP endpoint", + "probe_from": "backend-host", + } + except httpx.TimeoutException: + return { + "ok": False, + "transport": transport_type, + "status_code": None, + "message": "MCP endpoint timeout", + "probe_from": "backend-host", + } + except Exception as exc: + return { + "ok": False, + "transport": transport_type, + "status_code": None, + "message": f"MCP probe failed: {type(exc).__name__}: {exc}", + "probe_from": "backend-host", + } + + def _parse_env_params(raw: Any) -> Dict[str, str]: return _normalize_env_params(raw) @@ -1956,6 +2224,61 @@ def update_bot_tools_config(bot_id: str, payload: BotToolsConfigUpdateRequest, s ) +@app.get("/api/bots/{bot_id}/mcp-config") +def get_bot_mcp_config(bot_id: str, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + config_data = _read_bot_config(bot_id) + tools_cfg = config_data.get("tools") + if not isinstance(tools_cfg, dict): + tools_cfg = {} + mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers")) + return { + "bot_id": bot_id, + "mcp_servers": mcp_servers, + "restart_required": True, + } + + +@app.put("/api/bots/{bot_id}/mcp-config") +def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + config_data = _read_bot_config(bot_id) + if not isinstance(config_data, dict): + config_data = {} + tools_cfg = config_data.get("tools") + if not isinstance(tools_cfg, dict): + tools_cfg = {} + mcp_servers = _normalize_mcp_servers(payload.mcp_servers or {}) + tools_cfg["mcpServers"] = mcp_servers + config_data["tools"] = tools_cfg + _write_bot_config(bot_id, config_data) + _invalidate_bot_detail_cache(bot_id) + return { + "status": "updated", + "bot_id": bot_id, + "mcp_servers": mcp_servers, + "restart_required": True, + } + + +@app.post("/api/bots/{bot_id}/mcp-config/test") +def test_bot_mcp_config(bot_id: str, payload: BotMcpConfigTestRequest, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + cfg = { + "type": str(payload.type or "streamableHttp").strip(), + "url": str(payload.url or "").strip(), + "headers": payload.headers or {}, + "toolTimeout": payload.tool_timeout if payload.tool_timeout is not None else 10, + } + return _probe_mcp_server(cfg, bot_id=bot_id) + + @app.get("/api/bots/{bot_id}/env-params") def get_bot_env_params(bot_id: str, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) diff --git a/frontend/src/components/lucent/LucentSelect.tsx b/frontend/src/components/lucent/LucentSelect.tsx new file mode 100644 index 0000000..d7f30aa --- /dev/null +++ b/frontend/src/components/lucent/LucentSelect.tsx @@ -0,0 +1,23 @@ +import type { ReactNode, SelectHTMLAttributes } from 'react'; +import { ChevronDown } from 'lucide-react'; +import './lucent-select.css'; + +interface LucentSelectProps extends Omit, 'size'> { + wrapperClassName?: string; + children: ReactNode; +} + +export function LucentSelect({ wrapperClassName, className, children, disabled, ...props }: LucentSelectProps) { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/lucent/lucent-select.css b/frontend/src/components/lucent/lucent-select.css new file mode 100644 index 0000000..0f683e7 --- /dev/null +++ b/frontend/src/components/lucent/lucent-select.css @@ -0,0 +1,49 @@ +.lucent-select-wrap { + position: relative; + display: inline-flex; + width: 100%; + min-width: 0; +} + +.lucent-select-native { + width: 100%; + min-height: 36px; + border-radius: 12px; + border: 1px solid color-mix(in oklab, var(--line) 78%, var(--brand) 22%); + background: color-mix(in oklab, var(--panel-soft) 80%, var(--panel) 20%); + color: var(--text); + font-size: 14px; + font-weight: 600; + padding: 0 34px 0 12px; + outline: 0; + appearance: none; + transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.lucent-select-native:hover { + border-color: color-mix(in oklab, var(--brand) 46%, var(--line) 54%); +} + +.lucent-select-native:focus { + border-color: color-mix(in oklab, var(--brand) 68%, var(--line) 32%); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--brand) 24%, transparent); +} + +.lucent-select-wrap.is-disabled .lucent-select-native, +.lucent-select-native:disabled { + opacity: 0.62; + cursor: not-allowed; +} + +.lucent-select-caret { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--icon-muted); + pointer-events: none; +} + +.app-shell[data-theme='light'] .lucent-select-native { + background: color-mix(in oklab, var(--panel-soft) 92%, white 8%); +} diff --git a/frontend/src/i18n/channels.en.ts b/frontend/src/i18n/channels.en.ts index eecb96b..7ac402d 100644 --- a/frontend/src/i18n/channels.en.ts +++ b/frontend/src/i18n/channels.en.ts @@ -10,6 +10,7 @@ export const channelsEn = { enabled: 'Enabled', saveChannel: 'Save', addChannel: 'Add', + selectChannelType: 'Select channel type', close: 'Close', remove: 'Remove', sendProgress: 'sendProgress', diff --git a/frontend/src/i18n/channels.zh-cn.ts b/frontend/src/i18n/channels.zh-cn.ts index ecf654f..b01be71 100644 --- a/frontend/src/i18n/channels.zh-cn.ts +++ b/frontend/src/i18n/channels.zh-cn.ts @@ -10,6 +10,7 @@ export const channelsZhCn = { enabled: '启用', saveChannel: '保存', addChannel: '新增', + selectChannelType: '请选择渠道类型', close: '关闭', remove: '删除', sendProgress: 'sendProgress', diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index cd3e02c..f2e2bd8 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -95,6 +95,7 @@ export const dashboardEn = { params: 'Model', channels: 'Channels', skills: 'Skills', + mcp: 'MCP', tools: 'Tools', skillsPanel: 'Skills Panel', skillsEmpty: 'No skills.', @@ -115,6 +116,28 @@ export const dashboardEn = { envParamsSaved: 'Env params saved.', envParamsSaveFail: 'Failed to save env params.', envParamsHint: 'Restart bot to apply updated env vars.', + mcpPanel: 'MCP Configuration', + mcpPanelDesc: 'Configure MCP servers (HTTP/SSE) for this bot. Use dedicated X-Bot-Id / X-Bot-Secret per bot.', + mcpEmpty: 'No MCP servers configured.', + mcpServer: 'MCP Server', + mcpName: 'Server Name', + mcpNamePlaceholder: 'e.g. biz_mcp', + mcpType: 'Transport Type', + mcpUrlPlaceholder: 'e.g. http://mcp.internal:9001/mcp', + mcpBotIdPlaceholder: 'e.g. mula_bot_b02', + mcpBotSecretPlaceholder: 'Secret for this bot identity', + mcpToolTimeout: 'Tool Timeout (seconds)', + mcpTest: 'Test Connectivity', + mcpTesting: 'Testing connectivity...', + mcpTestPass: 'Connectivity test passed.', + mcpTestFail: 'Connectivity test failed.', + mcpTestNeedUrl: 'Please provide MCP URL first.', + mcpTestBlockSave: 'MCP connectivity test failed. Save is blocked.', + addMcpServer: 'Add MCP Server', + saveMcpConfig: 'Save MCP Config', + mcpSaved: 'MCP config saved.', + mcpSaveFail: 'Failed to save MCP config.', + mcpHint: 'Restart bot to apply MCP changes.', toolsLoadFail: 'Failed to load tool skills.', toolsAddFail: 'Failed to add tool.', toolsRemoveFail: 'Failed to remove tool.', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index 2566114..92c2bed 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -95,6 +95,7 @@ export const dashboardZhCn = { params: '模型', channels: '渠道', skills: '技能', + mcp: 'MCP', tools: '工具', skillsPanel: '技能面板', skillsEmpty: '暂无技能。', @@ -115,6 +116,28 @@ export const dashboardZhCn = { envParamsSaved: '环境变量已保存。', envParamsSaveFail: '环境变量保存失败。', envParamsHint: '修改后需重启 Bot 才会生效。', + mcpPanel: 'MCP 配置', + mcpPanelDesc: '配置该 Bot 的 MCP Servers(HTTP/SSE)。建议每个 Bot 使用独立的 X-Bot-Id / X-Bot-Secret。', + mcpEmpty: '暂无 MCP Server。', + mcpServer: 'MCP Server', + mcpName: '服务名称', + mcpNamePlaceholder: '如 biz_mcp', + mcpType: '传输类型', + mcpUrlPlaceholder: '如 http://mcp.internal:9001/mcp', + mcpBotIdPlaceholder: '如 mula_bot_b02', + mcpBotSecretPlaceholder: '输入该 Bot 对应的密钥', + mcpToolTimeout: 'Tool Timeout(秒)', + mcpTest: '测试连通性', + mcpTesting: '连通性测试中...', + mcpTestPass: '连通性测试通过。', + mcpTestFail: '连通性测试失败。', + mcpTestNeedUrl: '请先填写 MCP URL。', + mcpTestBlockSave: '存在未通过的 MCP 连通性测试,已阻止保存。', + addMcpServer: '新增 MCP Server', + saveMcpConfig: '保存 MCP 配置', + mcpSaved: 'MCP 配置已保存。', + mcpSaveFail: 'MCP 配置保存失败。', + mcpHint: '保存后需重启 Bot 才会生效。', toolsLoadFail: '读取工具技能失败。', toolsAddFail: '新增工具失败。', toolsRemoveFail: '移除工具失败。', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index afa6a59..3e86f93 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -18,6 +18,7 @@ import { dashboardZhCn } from '../../i18n/dashboard.zh-cn'; import { dashboardEn } from '../../i18n/dashboard.en'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { LucentIconButton } from '../../components/lucent/LucentIconButton'; +import { LucentSelect } from '../../components/lucent/LucentSelect'; interface BotDashboardModuleProps { onOpenCreateWizard?: () => void; @@ -33,6 +34,7 @@ type RuntimeViewMode = 'visual' | 'text'; type CompactPanelTab = 'chat' | 'runtime'; type QuotedReply = { id?: number; text: string; ts: number }; const BOT_LIST_PAGE_SIZE = 8; +const EMPTY_CHANNEL_PICKER = '__none__'; interface WorkspaceNode { name: string; @@ -116,6 +118,41 @@ interface CronJobsResponse { jobs: CronJob[]; } +interface MCPServerConfig { + type?: 'streamableHttp' | 'sse' | string; + url?: string; + headers?: Record; + toolTimeout?: number; +} + +interface MCPConfigResponse { + bot_id: string; + mcp_servers?: Record; + restart_required?: boolean; +} + +interface MCPTestResponse { + ok: boolean; + transport?: string; + status_code?: number | null; + message?: string; + probe_from?: string; +} + +interface MCPTestState { + status: 'idle' | 'testing' | 'pass' | 'fail'; + message: string; +} + +interface MCPServerDraft { + name: string; + type: 'streamableHttp' | 'sse'; + url: string; + botId: string; + botSecret: string; + toolTimeout: string; +} + interface BotChannel { id: string | number; bot_id: string; @@ -687,6 +724,7 @@ export function BotDashboardModule({ const [showParamModal, setShowParamModal] = useState(false); const [showChannelModal, setShowChannelModal] = useState(false); const [showSkillsModal, setShowSkillsModal] = useState(false); + const [showMcpModal, setShowMcpModal] = useState(false); const [showEnvParamsModal, setShowEnvParamsModal] = useState(false); const [showCronModal, setShowCronModal] = useState(false); const [showAgentModal, setShowAgentModal] = useState(false); @@ -703,6 +741,8 @@ export function BotDashboardModule({ const [interruptingByBot, setInterruptingByBot] = useState>({}); const [controlStateByBot, setControlStateByBot] = useState>({}); const chatBottomRef = useRef(null); + const chatScrollRef = useRef(null); + const chatAutoFollowRef = useRef(true); const [workspaceEntries, setWorkspaceEntries] = useState([]); const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState([]); const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false); @@ -730,11 +770,14 @@ export function BotDashboardModule({ const [isSkillUploading, setIsSkillUploading] = useState(false); const skillZipPickerRef = useRef(null); const [envParams, setEnvParams] = useState({}); + const [mcpServers, setMcpServers] = useState([]); + const [mcpTestByIndex, setMcpTestByIndex] = useState>({}); const [envDraftKey, setEnvDraftKey] = useState(''); const [envDraftValue, setEnvDraftValue] = useState(''); const [envDraftVisible, setEnvDraftVisible] = useState(false); const [envVisibleByKey, setEnvVisibleByKey] = useState>({}); const [isSavingChannel, setIsSavingChannel] = useState(false); + const [isSavingMcp, setIsSavingMcp] = useState(false); const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false); const [availableImages, setAvailableImages] = useState([]); const [localDockerImages, setLocalDockerImages] = useState([]); @@ -743,7 +786,7 @@ export function BotDashboardModule({ sendToolHints: false, }); const [uploadMaxMb, setUploadMaxMb] = useState(100); - const [newChannelType, setNewChannelType] = useState('feishu'); + const [newChannelType, setNewChannelType] = useState(''); const [runtimeViewMode, setRuntimeViewMode] = useState('visual'); const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); const [compactPanelTab, setCompactPanelTab] = useState('chat'); @@ -1498,9 +1541,28 @@ export function BotDashboardModule({ return () => window.removeEventListener('beforeunload', onBeforeUnload); }, [command, pendingAttachments.length, quotedReply, isUploadingAttachments]); + const syncChatScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => { + const box = chatScrollRef.current; + if (!box) return; + box.scrollTo({ top: box.scrollHeight, behavior }); + }, []); + + const onChatScroll = useCallback(() => { + const box = chatScrollRef.current; + if (!box) return; + const distanceToBottom = box.scrollHeight - box.scrollTop - box.clientHeight; + chatAutoFollowRef.current = distanceToBottom <= 64; + }, []); + useEffect(() => { - chatBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); - }, [selectedBotId, conversation.length]); + chatAutoFollowRef.current = true; + requestAnimationFrame(() => syncChatScrollToBottom('auto')); + }, [selectedBotId, syncChatScrollToBottom]); + + useEffect(() => { + if (!chatAutoFollowRef.current) return; + requestAnimationFrame(() => syncChatScrollToBottom('auto')); + }, [conversation.length, syncChatScrollToBottom]); useEffect(() => { setQuotedReply(null); @@ -1898,6 +1960,147 @@ export function BotDashboardModule({ }); }; + const loadBotMcpConfig = async (botId: string) => { + if (!botId) return; + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config`); + const rows = res.data?.mcp_servers && typeof res.data.mcp_servers === 'object' ? res.data.mcp_servers : {}; + const drafts: MCPServerDraft[] = Object.entries(rows).map(([name, cfg]) => { + const headers = cfg?.headers && typeof cfg.headers === 'object' ? cfg.headers : {}; + return { + name: String(name || '').trim(), + type: String(cfg?.type || 'streamableHttp') === 'sse' ? 'sse' : 'streamableHttp', + url: String(cfg?.url || '').trim(), + botId: String((headers as Record)['X-Bot-Id'] || '').trim(), + botSecret: String((headers as Record)['X-Bot-Secret'] || '').trim(), + toolTimeout: String(Number(cfg?.toolTimeout || 60) || 60), + }; + }); + setMcpServers(drafts); + setMcpTestByIndex({}); + } catch { + setMcpServers([]); + setMcpTestByIndex({}); + } + }; + + const addMcpServer = () => { + setMcpServers((prev) => ([ + ...prev, + { + name: '', + type: 'streamableHttp', + url: '', + botId: '', + botSecret: '', + toolTimeout: '60', + }, + ])); + setMcpTestByIndex((prev) => ({ ...prev, [mcpServers.length]: { status: 'idle', message: '' } })); + }; + + const updateMcpServer = (index: number, patch: Partial) => { + setMcpServers((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row))); + setMcpTestByIndex((prev) => ({ ...prev, [index]: { status: 'idle', message: '' } })); + }; + + const removeMcpServer = (index: number) => { + setMcpServers((prev) => prev.filter((_, i) => i !== index)); + setMcpTestByIndex((prev) => { + const next: Record = {}; + Object.entries(prev).forEach(([key, val]) => { + const idx = Number(key); + if (idx < index) next[idx] = val; + if (idx > index) next[idx - 1] = val; + }); + return next; + }); + }; + + const testSingleMcpServer = async (row: MCPServerDraft, index: number): Promise => { + if (!selectedBot) return false; + const url = String(row.url || '').trim(); + if (!url) { + setMcpTestByIndex((prev) => ({ + ...prev, + [index]: { status: 'fail', message: t.mcpTestNeedUrl }, + })); + return false; + } + const timeout = Math.max(1, Math.min(600, Number(row.toolTimeout || 60) || 60)); + setMcpTestByIndex((prev) => ({ + ...prev, + [index]: { status: 'testing', message: t.mcpTesting }, + })); + try { + const res = await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/mcp-config/test`, { + type: row.type === 'sse' ? 'sse' : 'streamableHttp', + url, + headers: { + 'X-Bot-Id': String(row.botId || '').trim(), + 'X-Bot-Secret': String(row.botSecret || '').trim(), + }, + tool_timeout: timeout, + }); + const ok = Boolean(res.data?.ok); + const baseMsg = String(res.data?.message || '').trim() || (ok ? t.mcpTestPass : t.mcpTestFail); + const probeFrom = String(res.data?.probe_from || '').trim(); + const msg = probeFrom ? `${baseMsg} (${probeFrom})` : baseMsg; + setMcpTestByIndex((prev) => ({ + ...prev, + [index]: { status: ok ? 'pass' : 'fail', message: msg }, + })); + return ok; + } catch (error: any) { + const msg = error?.response?.data?.detail || t.mcpTestFail; + setMcpTestByIndex((prev) => ({ + ...prev, + [index]: { status: 'fail', message: String(msg) }, + })); + return false; + } + }; + + const saveBotMcpConfig = async () => { + if (!selectedBot) return; + const mcp_servers: Record = {}; + const testQueue: Array<{ index: number; row: MCPServerDraft }> = []; + for (const [index, row] of mcpServers.entries()) { + const name = String(row.name || '').trim(); + const url = String(row.url || '').trim(); + if (!name || !url) continue; + const timeout = Math.max(1, Math.min(600, Number(row.toolTimeout || 60) || 60)); + testQueue.push({ index, row }); + mcp_servers[name] = { + type: row.type === 'sse' ? 'sse' : 'streamableHttp', + url, + headers: { + 'X-Bot-Id': String(row.botId || '').trim(), + 'X-Bot-Secret': String(row.botSecret || '').trim(), + }, + toolTimeout: timeout, + }; + } + setIsSavingMcp(true); + try { + for (const item of testQueue) { + const ok = await testSingleMcpServer(item.row, item.index); + if (!ok) { + notify(t.mcpTestBlockSave, { tone: 'error' }); + setIsSavingMcp(false); + return; + } + } + await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/mcp-config`, { mcp_servers }); + setShowMcpModal(false); + notify(t.mcpSaved, { tone: 'success' }); + } catch (error: any) { + notify(error?.response?.data?.detail || t.mcpSaveFail, { tone: 'error' }); + } finally { + setIsSavingMcp(false); + } + }; + const removeBotSkill = async (skill: WorkspaceSkillOption) => { if (!selectedBot) return; const ok = await confirm({ @@ -2020,7 +2223,7 @@ export function BotDashboardModule({ }; const addChannel = async () => { - if (!selectedBot || !addableChannelTypes.includes(newChannelType)) return; + if (!selectedBot || !newChannelType || !addableChannelTypes.includes(newChannelType)) return; setIsSavingChannel(true); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, { @@ -2032,8 +2235,7 @@ export function BotDashboardModule({ extra_config: {}, }); await loadChannels(selectedBot.id); - const rest = addableChannelTypes.filter((t) => t !== newChannelType); - if (rest.length > 0) setNewChannelType(rest[0]); + setNewChannelType(''); } catch (error: any) { const msg = error?.response?.data?.detail || t.channelAddFail; notify(msg, { tone: 'error' }); @@ -2101,12 +2303,13 @@ export function BotDashboardModule({ if (ctype === 'telegram') { return ( <> - updateChannelLocal(idx, { app_secret: e.target.value })} /> + updateChannelLocal(idx, { app_secret: e.target.value })} autoComplete="new-password" /> updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })} + autoComplete="off" />