v0.1.4
parent
65a96a60dc
commit
b1dd8d5e16
|
|
@ -42,4 +42,4 @@ REDIS_DEFAULT_TTL=60
|
||||||
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
||||||
|
|
||||||
# Max upload size for backend validation (MB)
|
# Max upload size for backend validation (MB)
|
||||||
UPLOAD_MAX_MB=100
|
UPLOAD_MAX_MB=200
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,24 @@ class BotConfigManager:
|
||||||
"sendToolHints": bool(bot_data.get("send_tool_hints", False)),
|
"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] = {
|
config_data: Dict[str, Any] = {
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
|
|
@ -64,6 +82,8 @@ class BotConfigManager:
|
||||||
},
|
},
|
||||||
"channels": channels_cfg,
|
"channels": channels_cfg,
|
||||||
}
|
}
|
||||||
|
if tools_cfg:
|
||||||
|
config_data["tools"] = tools_cfg
|
||||||
|
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
channel_type = (channel.get("channel_type") or "").strip()
|
channel_type = (channel.get("channel_type") or "").strip()
|
||||||
|
|
@ -149,7 +169,6 @@ class BotConfigManager:
|
||||||
**extra,
|
**extra,
|
||||||
}
|
}
|
||||||
|
|
||||||
config_path = os.path.join(dot_nanobot_dir, "config.json")
|
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(config_data, f, indent=4, ensure_ascii=False)
|
json.dump(config_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,95 @@ class BotDockerManager:
|
||||||
print(f"[DockerManager] Error stopping bot {bot_id}: {e}")
|
print(f"[DockerManager] Error stopping bot {bot_id}: {e}")
|
||||||
return False
|
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:
|
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."""
|
"""Send a command to dashboard channel with robust container-local delivery."""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
|
|
|
||||||
323
backend/main.py
323
backend/main.py
|
|
@ -134,6 +134,17 @@ class BotToolsConfigUpdateRequest(BaseModel):
|
||||||
tools_config: Optional[Dict[str, Any]] = None
|
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):
|
class BotEnvParamsUpdateRequest(BaseModel):
|
||||||
env_params: Optional[Dict[str, str]] = None
|
env_params: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
@ -865,6 +876,263 @@ def _normalize_env_params(raw: Any) -> Dict[str, str]:
|
||||||
return rows
|
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]:
|
def _parse_env_params(raw: Any) -> Dict[str, str]:
|
||||||
return _normalize_env_params(raw)
|
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")
|
@app.get("/api/bots/{bot_id}/env-params")
|
||||||
def get_bot_env_params(bot_id: str, session: Session = Depends(get_session)):
|
def get_bot_env_params(bot_id: str, session: Session = Depends(get_session)):
|
||||||
bot = session.get(BotInstance, bot_id)
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { ReactNode, SelectHTMLAttributes } from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import './lucent-select.css';
|
||||||
|
|
||||||
|
interface LucentSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
||||||
|
wrapperClassName?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LucentSelect({ wrapperClassName, className, children, disabled, ...props }: LucentSelectProps) {
|
||||||
|
return (
|
||||||
|
<div className={`lucent-select-wrap ${disabled ? 'is-disabled' : ''} ${wrapperClassName || ''}`.trim()}>
|
||||||
|
<select
|
||||||
|
{...props}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`lucent-select-native ${className || ''}`.trim()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} className="lucent-select-caret" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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%);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ export const channelsEn = {
|
||||||
enabled: 'Enabled',
|
enabled: 'Enabled',
|
||||||
saveChannel: 'Save',
|
saveChannel: 'Save',
|
||||||
addChannel: 'Add',
|
addChannel: 'Add',
|
||||||
|
selectChannelType: 'Select channel type',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
sendProgress: 'sendProgress',
|
sendProgress: 'sendProgress',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const channelsZhCn = {
|
||||||
enabled: '启用',
|
enabled: '启用',
|
||||||
saveChannel: '保存',
|
saveChannel: '保存',
|
||||||
addChannel: '新增',
|
addChannel: '新增',
|
||||||
|
selectChannelType: '请选择渠道类型',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
remove: '删除',
|
remove: '删除',
|
||||||
sendProgress: 'sendProgress',
|
sendProgress: 'sendProgress',
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ export const dashboardEn = {
|
||||||
params: 'Model',
|
params: 'Model',
|
||||||
channels: 'Channels',
|
channels: 'Channels',
|
||||||
skills: 'Skills',
|
skills: 'Skills',
|
||||||
|
mcp: 'MCP',
|
||||||
tools: 'Tools',
|
tools: 'Tools',
|
||||||
skillsPanel: 'Skills Panel',
|
skillsPanel: 'Skills Panel',
|
||||||
skillsEmpty: 'No skills.',
|
skillsEmpty: 'No skills.',
|
||||||
|
|
@ -115,6 +116,28 @@ export const dashboardEn = {
|
||||||
envParamsSaved: 'Env params saved.',
|
envParamsSaved: 'Env params saved.',
|
||||||
envParamsSaveFail: 'Failed to save env params.',
|
envParamsSaveFail: 'Failed to save env params.',
|
||||||
envParamsHint: 'Restart bot to apply updated env vars.',
|
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.',
|
toolsLoadFail: 'Failed to load tool skills.',
|
||||||
toolsAddFail: 'Failed to add tool.',
|
toolsAddFail: 'Failed to add tool.',
|
||||||
toolsRemoveFail: 'Failed to remove tool.',
|
toolsRemoveFail: 'Failed to remove tool.',
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ export const dashboardZhCn = {
|
||||||
params: '模型',
|
params: '模型',
|
||||||
channels: '渠道',
|
channels: '渠道',
|
||||||
skills: '技能',
|
skills: '技能',
|
||||||
|
mcp: 'MCP',
|
||||||
tools: '工具',
|
tools: '工具',
|
||||||
skillsPanel: '技能面板',
|
skillsPanel: '技能面板',
|
||||||
skillsEmpty: '暂无技能。',
|
skillsEmpty: '暂无技能。',
|
||||||
|
|
@ -115,6 +116,28 @@ export const dashboardZhCn = {
|
||||||
envParamsSaved: '环境变量已保存。',
|
envParamsSaved: '环境变量已保存。',
|
||||||
envParamsSaveFail: '环境变量保存失败。',
|
envParamsSaveFail: '环境变量保存失败。',
|
||||||
envParamsHint: '修改后需重启 Bot 才会生效。',
|
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: '读取工具技能失败。',
|
toolsLoadFail: '读取工具技能失败。',
|
||||||
toolsAddFail: '新增工具失败。',
|
toolsAddFail: '新增工具失败。',
|
||||||
toolsRemoveFail: '移除工具失败。',
|
toolsRemoveFail: '移除工具失败。',
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { dashboardZhCn } from '../../i18n/dashboard.zh-cn';
|
||||||
import { dashboardEn } from '../../i18n/dashboard.en';
|
import { dashboardEn } from '../../i18n/dashboard.en';
|
||||||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||||
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||||
|
import { LucentSelect } from '../../components/lucent/LucentSelect';
|
||||||
|
|
||||||
interface BotDashboardModuleProps {
|
interface BotDashboardModuleProps {
|
||||||
onOpenCreateWizard?: () => void;
|
onOpenCreateWizard?: () => void;
|
||||||
|
|
@ -33,6 +34,7 @@ type RuntimeViewMode = 'visual' | 'text';
|
||||||
type CompactPanelTab = 'chat' | 'runtime';
|
type CompactPanelTab = 'chat' | 'runtime';
|
||||||
type QuotedReply = { id?: number; text: string; ts: number };
|
type QuotedReply = { id?: number; text: string; ts: number };
|
||||||
const BOT_LIST_PAGE_SIZE = 8;
|
const BOT_LIST_PAGE_SIZE = 8;
|
||||||
|
const EMPTY_CHANNEL_PICKER = '__none__';
|
||||||
|
|
||||||
interface WorkspaceNode {
|
interface WorkspaceNode {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -116,6 +118,41 @@ interface CronJobsResponse {
|
||||||
jobs: CronJob[];
|
jobs: CronJob[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MCPServerConfig {
|
||||||
|
type?: 'streamableHttp' | 'sse' | string;
|
||||||
|
url?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
toolTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPConfigResponse {
|
||||||
|
bot_id: string;
|
||||||
|
mcp_servers?: Record<string, MCPServerConfig>;
|
||||||
|
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 {
|
interface BotChannel {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
bot_id: string;
|
bot_id: string;
|
||||||
|
|
@ -687,6 +724,7 @@ export function BotDashboardModule({
|
||||||
const [showParamModal, setShowParamModal] = useState(false);
|
const [showParamModal, setShowParamModal] = useState(false);
|
||||||
const [showChannelModal, setShowChannelModal] = useState(false);
|
const [showChannelModal, setShowChannelModal] = useState(false);
|
||||||
const [showSkillsModal, setShowSkillsModal] = useState(false);
|
const [showSkillsModal, setShowSkillsModal] = useState(false);
|
||||||
|
const [showMcpModal, setShowMcpModal] = useState(false);
|
||||||
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
|
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
|
||||||
const [showCronModal, setShowCronModal] = useState(false);
|
const [showCronModal, setShowCronModal] = useState(false);
|
||||||
const [showAgentModal, setShowAgentModal] = useState(false);
|
const [showAgentModal, setShowAgentModal] = useState(false);
|
||||||
|
|
@ -703,6 +741,8 @@ export function BotDashboardModule({
|
||||||
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
|
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
|
||||||
const [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping'>>({});
|
const [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping'>>({});
|
||||||
const chatBottomRef = useRef<HTMLDivElement | null>(null);
|
const chatBottomRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const chatScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const chatAutoFollowRef = useRef(true);
|
||||||
const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceNode[]>([]);
|
const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceNode[]>([]);
|
||||||
const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState<WorkspaceNode[]>([]);
|
const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState<WorkspaceNode[]>([]);
|
||||||
const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false);
|
const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false);
|
||||||
|
|
@ -730,11 +770,14 @@ export function BotDashboardModule({
|
||||||
const [isSkillUploading, setIsSkillUploading] = useState(false);
|
const [isSkillUploading, setIsSkillUploading] = useState(false);
|
||||||
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
|
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [envParams, setEnvParams] = useState<BotEnvParams>({});
|
const [envParams, setEnvParams] = useState<BotEnvParams>({});
|
||||||
|
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
|
||||||
|
const [mcpTestByIndex, setMcpTestByIndex] = useState<Record<number, MCPTestState>>({});
|
||||||
const [envDraftKey, setEnvDraftKey] = useState('');
|
const [envDraftKey, setEnvDraftKey] = useState('');
|
||||||
const [envDraftValue, setEnvDraftValue] = useState('');
|
const [envDraftValue, setEnvDraftValue] = useState('');
|
||||||
const [envDraftVisible, setEnvDraftVisible] = useState(false);
|
const [envDraftVisible, setEnvDraftVisible] = useState(false);
|
||||||
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
|
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
|
||||||
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
||||||
|
const [isSavingMcp, setIsSavingMcp] = useState(false);
|
||||||
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
|
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
|
||||||
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
|
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
|
||||||
const [localDockerImages, setLocalDockerImages] = useState<DockerImage[]>([]);
|
const [localDockerImages, setLocalDockerImages] = useState<DockerImage[]>([]);
|
||||||
|
|
@ -743,7 +786,7 @@ export function BotDashboardModule({
|
||||||
sendToolHints: false,
|
sendToolHints: false,
|
||||||
});
|
});
|
||||||
const [uploadMaxMb, setUploadMaxMb] = useState(100);
|
const [uploadMaxMb, setUploadMaxMb] = useState(100);
|
||||||
const [newChannelType, setNewChannelType] = useState<ChannelType>('feishu');
|
const [newChannelType, setNewChannelType] = useState<ChannelType | ''>('');
|
||||||
const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
|
const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
|
||||||
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
|
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
|
||||||
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
|
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
|
||||||
|
|
@ -1498,9 +1541,28 @@ export function BotDashboardModule({
|
||||||
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
}, [command, pendingAttachments.length, quotedReply, isUploadingAttachments]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
chatBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
chatAutoFollowRef.current = true;
|
||||||
}, [selectedBotId, conversation.length]);
|
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
|
||||||
|
}, [selectedBotId, syncChatScrollToBottom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatAutoFollowRef.current) return;
|
||||||
|
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
|
||||||
|
}, [conversation.length, syncChatScrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuotedReply(null);
|
setQuotedReply(null);
|
||||||
|
|
@ -1898,6 +1960,147 @@ export function BotDashboardModule({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadBotMcpConfig = async (botId: string) => {
|
||||||
|
if (!botId) return;
|
||||||
|
try {
|
||||||
|
const res = await axios.get<MCPConfigResponse>(`${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<string, unknown>)['X-Bot-Id'] || '').trim(),
|
||||||
|
botSecret: String((headers as Record<string, unknown>)['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<MCPServerDraft>) => {
|
||||||
|
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<number, MCPTestState> = {};
|
||||||
|
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<boolean> => {
|
||||||
|
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<MCPTestResponse>(`${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<string, MCPServerConfig> = {};
|
||||||
|
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) => {
|
const removeBotSkill = async (skill: WorkspaceSkillOption) => {
|
||||||
if (!selectedBot) return;
|
if (!selectedBot) return;
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
|
|
@ -2020,7 +2223,7 @@ export function BotDashboardModule({
|
||||||
};
|
};
|
||||||
|
|
||||||
const addChannel = async () => {
|
const addChannel = async () => {
|
||||||
if (!selectedBot || !addableChannelTypes.includes(newChannelType)) return;
|
if (!selectedBot || !newChannelType || !addableChannelTypes.includes(newChannelType)) return;
|
||||||
setIsSavingChannel(true);
|
setIsSavingChannel(true);
|
||||||
try {
|
try {
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, {
|
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, {
|
||||||
|
|
@ -2032,8 +2235,7 @@ export function BotDashboardModule({
|
||||||
extra_config: {},
|
extra_config: {},
|
||||||
});
|
});
|
||||||
await loadChannels(selectedBot.id);
|
await loadChannels(selectedBot.id);
|
||||||
const rest = addableChannelTypes.filter((t) => t !== newChannelType);
|
setNewChannelType('');
|
||||||
if (rest.length > 0) setNewChannelType(rest[0]);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const msg = error?.response?.data?.detail || t.channelAddFail;
|
const msg = error?.response?.data?.detail || t.channelAddFail;
|
||||||
notify(msg, { tone: 'error' });
|
notify(msg, { tone: 'error' });
|
||||||
|
|
@ -2101,12 +2303,13 @@ export function BotDashboardModule({
|
||||||
if (ctype === 'telegram') {
|
if (ctype === 'telegram') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input className="input" type="password" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
|
<input className="input" type="password" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={lc.proxy}
|
placeholder={lc.proxy}
|
||||||
value={String((channel.extra_config || {}).proxy || '')}
|
value={String((channel.extra_config || {}).proxy || '')}
|
||||||
onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
|
onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
<input
|
<input
|
||||||
|
|
@ -2126,10 +2329,10 @@ export function BotDashboardModule({
|
||||||
if (ctype === 'feishu') {
|
if (ctype === 'feishu') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
|
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
|
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||||
<input className="input" placeholder={lc.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} />
|
<input className="input" placeholder={lc.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" />
|
||||||
<input className="input" placeholder={lc.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} />
|
<input className="input" placeholder={lc.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2137,8 +2340,8 @@ export function BotDashboardModule({
|
||||||
if (ctype === 'dingtalk') {
|
if (ctype === 'dingtalk') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
|
<input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||||
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
|
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2146,8 +2349,8 @@ export function BotDashboardModule({
|
||||||
if (ctype === 'slack') {
|
if (ctype === 'slack') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
|
<input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||||
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
|
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2155,8 +2358,8 @@ export function BotDashboardModule({
|
||||||
if (ctype === 'qq') {
|
if (ctype === 'qq') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
|
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
|
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3051,7 +3254,7 @@ export function BotDashboardModule({
|
||||||
{selectedBot ? (
|
{selectedBot ? (
|
||||||
<div className="ops-chat-shell">
|
<div className="ops-chat-shell">
|
||||||
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
|
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
|
||||||
<div className="ops-chat-scroll">
|
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onChatScroll}>
|
||||||
{conversation.length === 0 ? (
|
{conversation.length === 0 ? (
|
||||||
<div className="ops-chat-empty">
|
<div className="ops-chat-empty">
|
||||||
{t.noConversation}
|
{t.noConversation}
|
||||||
|
|
@ -3348,6 +3551,19 @@ export function BotDashboardModule({
|
||||||
<Hammer size={14} />
|
<Hammer size={14} />
|
||||||
<span>{t.skills}</span>
|
<span>{t.skills}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="ops-more-item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setRuntimeMenuOpen(false);
|
||||||
|
if (!selectedBot) return;
|
||||||
|
void loadBotMcpConfig(selectedBot.id);
|
||||||
|
setShowMcpModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Boxes size={14} />
|
||||||
|
<span>{t.mcp}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="ops-more-item"
|
className="ops-more-item"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|
@ -3657,8 +3873,7 @@ export function BotDashboardModule({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="field-label">{t.baseImageReadonly}</label>
|
<label className="field-label">{t.baseImageReadonly}</label>
|
||||||
<select
|
<LucentSelect
|
||||||
className="select"
|
|
||||||
value={editForm.image_tag}
|
value={editForm.image_tag}
|
||||||
onChange={(e) => setEditForm((p) => ({ ...p, image_tag: e.target.value }))}
|
onChange={(e) => setEditForm((p) => ({ ...p, image_tag: e.target.value }))}
|
||||||
>
|
>
|
||||||
|
|
@ -3667,7 +3882,7 @@ export function BotDashboardModule({
|
||||||
{img.label}
|
{img.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</LucentSelect>
|
||||||
{baseImageOptions.find((opt) => opt.tag === editForm.image_tag)?.needsRegister ? (
|
{baseImageOptions.find((opt) => opt.tag === editForm.image_tag)?.needsRegister ? (
|
||||||
<div className="field-label" style={{ color: 'var(--warning)' }}>
|
<div className="field-label" style={{ color: 'var(--warning)' }}>
|
||||||
{isZh ? '该镜像尚未登记,保存时会自动加入镜像注册表。' : 'This image is not registered yet. It will be auto-registered on save.'}
|
{isZh ? '该镜像尚未登记,保存时会自动加入镜像注册表。' : 'This image is not registered yet. It will be auto-registered on save.'}
|
||||||
|
|
@ -3727,14 +3942,14 @@ export function BotDashboardModule({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="field-label">Provider</label>
|
<label className="field-label">Provider</label>
|
||||||
<select className="select" value={editForm.llm_provider} onChange={(e) => onBaseProviderChange(e.target.value)}>
|
<LucentSelect value={editForm.llm_provider} onChange={(e) => onBaseProviderChange(e.target.value)}>
|
||||||
<option value="openrouter">openrouter</option>
|
<option value="openrouter">openrouter</option>
|
||||||
<option value="dashscope">dashscope (aliyun qwen)</option>
|
<option value="dashscope">dashscope (aliyun qwen)</option>
|
||||||
<option value="openai">openai</option>
|
<option value="openai">openai</option>
|
||||||
<option value="deepseek">deepseek</option>
|
<option value="deepseek">deepseek</option>
|
||||||
<option value="kimi">kimi (moonshot)</option>
|
<option value="kimi">kimi (moonshot)</option>
|
||||||
<option value="minimax">minimax</option>
|
<option value="minimax">minimax</option>
|
||||||
</select>
|
</LucentSelect>
|
||||||
|
|
||||||
<label className="field-label">{t.modelName}</label>
|
<label className="field-label">{t.modelName}</label>
|
||||||
<input className="input" value={editForm.llm_model} onChange={(e) => setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} />
|
<input className="input" value={editForm.llm_model} onChange={(e) => setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} />
|
||||||
|
|
@ -3887,19 +4102,22 @@ export function BotDashboardModule({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<select
|
<LucentSelect
|
||||||
className="select"
|
value={newChannelType || EMPTY_CHANNEL_PICKER}
|
||||||
value={newChannelType}
|
onChange={(e) => {
|
||||||
onChange={(e) => setNewChannelType(e.target.value as ChannelType)}
|
const next = String(e.target.value || '');
|
||||||
|
setNewChannelType(next === EMPTY_CHANNEL_PICKER ? '' : (next as ChannelType));
|
||||||
|
}}
|
||||||
disabled={addableChannelTypes.length === 0 || isSavingChannel}
|
disabled={addableChannelTypes.length === 0 || isSavingChannel}
|
||||||
>
|
>
|
||||||
|
<option value={EMPTY_CHANNEL_PICKER} disabled>{lc.selectChannelType}</option>
|
||||||
{addableChannelTypes.map((t) => (
|
{addableChannelTypes.map((t) => (
|
||||||
<option key={t} value={t}>{t}</option>
|
<option key={t} value={t}>{t}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</LucentSelect>
|
||||||
<LucentIconButton
|
<LucentIconButton
|
||||||
className="btn btn-secondary btn-sm icon-btn"
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
disabled={addableChannelTypes.length === 0 || isSavingChannel}
|
disabled={addableChannelTypes.length === 0 || isSavingChannel || !newChannelType}
|
||||||
onClick={() => void addChannel()}
|
onClick={() => void addChannel()}
|
||||||
tooltip={lc.addChannel}
|
tooltip={lc.addChannel}
|
||||||
aria-label={lc.addChannel}
|
aria-label={lc.addChannel}
|
||||||
|
|
@ -3977,6 +4195,86 @@ export function BotDashboardModule({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showMcpModal && (
|
||||||
|
<div className="modal-mask" onClick={() => setShowMcpModal(false)}>
|
||||||
|
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-title-row modal-title-with-close">
|
||||||
|
<div className="modal-title-main">
|
||||||
|
<h3>{t.mcpPanel}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="modal-title-actions">
|
||||||
|
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowMcpModal(false)} tooltip={t.close} aria-label={t.close}>
|
||||||
|
<X size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>{t.mcpPanelDesc}</div>
|
||||||
|
<div className="wizard-channel-list">
|
||||||
|
{mcpServers.length === 0 ? (
|
||||||
|
<div className="ops-empty-inline">{t.mcpEmpty}</div>
|
||||||
|
) : (
|
||||||
|
mcpServers.map((row, idx) => (
|
||||||
|
<div key={`mcp-${idx}`} className="card wizard-channel-card wizard-channel-compact">
|
||||||
|
<div className="row-between" style={{ gap: 8 }}>
|
||||||
|
<strong>{t.mcpServer}</strong>
|
||||||
|
<div style={{ display: 'inline-flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => void testSingleMcpServer(row, idx)}
|
||||||
|
disabled={mcpTestByIndex[idx]?.status === 'testing' || isSavingMcp}
|
||||||
|
>
|
||||||
|
{mcpTestByIndex[idx]?.status === 'testing' ? <RefreshCw size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
<span style={{ marginLeft: 6 }}>{t.mcpTest}</span>
|
||||||
|
</button>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||||
|
onClick={() => removeMcpServer(idx)}
|
||||||
|
tooltip={t.removeSkill}
|
||||||
|
aria-label={t.removeSkill}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="field-label">{t.mcpName}</label>
|
||||||
|
<input className="input mono" value={row.name} placeholder={t.mcpNamePlaceholder} onChange={(e) => updateMcpServer(idx, { name: e.target.value })} autoComplete="off" />
|
||||||
|
<label className="field-label">{t.mcpType}</label>
|
||||||
|
<LucentSelect value={row.type} onChange={(e) => updateMcpServer(idx, { type: e.target.value as 'streamableHttp' | 'sse' })}>
|
||||||
|
<option value="streamableHttp">streamableHttp</option>
|
||||||
|
<option value="sse">sse</option>
|
||||||
|
</LucentSelect>
|
||||||
|
<label className="field-label">URL</label>
|
||||||
|
<input className="input mono" value={row.url} placeholder={t.mcpUrlPlaceholder} onChange={(e) => updateMcpServer(idx, { url: e.target.value })} autoComplete="off" />
|
||||||
|
<label className="field-label">X-Bot-Id</label>
|
||||||
|
<input className="input mono" value={row.botId} placeholder={t.mcpBotIdPlaceholder} onChange={(e) => updateMcpServer(idx, { botId: e.target.value })} autoComplete="off" />
|
||||||
|
<label className="field-label">X-Bot-Secret</label>
|
||||||
|
<input className="input" type="password" value={row.botSecret} placeholder={t.mcpBotSecretPlaceholder} onChange={(e) => updateMcpServer(idx, { botSecret: e.target.value })} autoComplete="new-password" />
|
||||||
|
<label className="field-label">{t.mcpToolTimeout}</label>
|
||||||
|
<input className="input mono" type="number" min="1" max="600" value={row.toolTimeout} onChange={(e) => updateMcpServer(idx, { toolTimeout: e.target.value })} />
|
||||||
|
{mcpTestByIndex[idx]?.status !== 'idle' ? (
|
||||||
|
<div className="field-label" style={{ color: mcpTestByIndex[idx]?.status === 'pass' ? 'var(--ok)' : mcpTestByIndex[idx]?.status === 'fail' ? 'var(--err)' : 'var(--muted)' }}>
|
||||||
|
{mcpTestByIndex[idx]?.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="row-between">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={addMcpServer}>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span>{t.addMcpServer}</span>
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => void saveBotMcpConfig()} disabled={isSavingMcp}>
|
||||||
|
{isSavingMcp ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
<span style={{ marginLeft: 6 }}>{t.saveMcpConfig}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="field-label">{t.mcpHint}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showEnvParamsModal && (
|
{showEnvParamsModal && (
|
||||||
<div className="modal-mask" onClick={() => setShowEnvParamsModal(false)}>
|
<div className="modal-mask" onClick={() => setShowEnvParamsModal(false)}>
|
||||||
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
@ -4005,6 +4303,7 @@ export function BotDashboardModule({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => upsertEnvParam(key, e.target.value)}
|
onChange={(e) => upsertEnvParam(key, e.target.value)}
|
||||||
placeholder={t.envValue}
|
placeholder={t.envValue}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<LucentIconButton
|
<LucentIconButton
|
||||||
className="btn btn-secondary btn-sm wizard-icon-btn"
|
className="btn btn-secondary btn-sm wizard-icon-btn"
|
||||||
|
|
@ -4034,6 +4333,7 @@ export function BotDashboardModule({
|
||||||
value={envDraftKey}
|
value={envDraftKey}
|
||||||
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
|
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
|
||||||
placeholder={t.envKey}
|
placeholder={t.envKey}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
|
|
@ -4041,6 +4341,7 @@ export function BotDashboardModule({
|
||||||
value={envDraftValue}
|
value={envDraftValue}
|
||||||
onChange={(e) => setEnvDraftValue(e.target.value)}
|
onChange={(e) => setEnvDraftValue(e.target.value)}
|
||||||
placeholder={t.envValue}
|
placeholder={t.envValue}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<LucentIconButton
|
<LucentIconButton
|
||||||
className="btn btn-secondary btn-sm icon-btn"
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { pickLocale } from '../../../i18n';
|
||||||
import { managementZhCn } from '../../../i18n/management.zh-cn';
|
import { managementZhCn } from '../../../i18n/management.zh-cn';
|
||||||
import { managementEn } from '../../../i18n/management.en';
|
import { managementEn } from '../../../i18n/management.en';
|
||||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||||
|
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
||||||
|
|
||||||
interface CreateBotModalProps {
|
interface CreateBotModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -100,8 +101,7 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
|
||||||
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
|
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
|
||||||
<Layers size={14} className="text-blue-500" /> {t.imageLabel}
|
<Layers size={14} className="text-blue-500" /> {t.imageLabel}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<LucentSelect
|
||||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none text-white"
|
|
||||||
onChange={(e) => setFormData({ ...formData, image_tag: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, image_tag: e.target.value })}
|
||||||
value={formData.image_tag}
|
value={formData.image_tag}
|
||||||
>
|
>
|
||||||
|
|
@ -111,7 +111,7 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{availableImages.length === 0 && <option disabled>{t.noImage}</option>}
|
{availableImages.length === 0 && <option disabled>{t.noImage}</option>}
|
||||||
</select>
|
</LucentSelect>
|
||||||
<p className="text-[10px] text-slate-600 italic">{t.imageHint}</p>
|
<p className="text-[10px] text-slate-600 italic">{t.imageHint}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -120,8 +120,7 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
|
||||||
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
|
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
|
||||||
<Cpu size={14} /> {t.providerLabel}
|
<Cpu size={14} /> {t.providerLabel}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<LucentSelect
|
||||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none text-white"
|
|
||||||
onChange={(e) => setFormData({ ...formData, llm_provider: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, llm_provider: e.target.value })}
|
||||||
>
|
>
|
||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
|
|
@ -129,7 +128,7 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
|
||||||
<option value="kimi">Kimi (Moonshot)</option>
|
<option value="kimi">Kimi (Moonshot)</option>
|
||||||
<option value="minimax">MiniMax</option>
|
<option value="minimax">MiniMax</option>
|
||||||
<option value="ollama">Ollama (Local)</option>
|
<option value="ollama">Ollama (Local)</option>
|
||||||
</select>
|
</LucentSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400 text-white">{t.modelLabel}</label>
|
<label className="text-sm font-medium text-slate-400 text-white">{t.modelLabel}</label>
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@ import { wizardZhCn } from '../../i18n/wizard.zh-cn';
|
||||||
import { wizardEn } from '../../i18n/wizard.en';
|
import { wizardEn } from '../../i18n/wizard.en';
|
||||||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||||
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||||
|
import { LucentSelect } from '../../components/lucent/LucentSelect';
|
||||||
|
|
||||||
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
|
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
|
||||||
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
|
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
|
||||||
|
const EMPTY_CHANNEL_PICKER = '__none__';
|
||||||
|
|
||||||
const FALLBACK_SOUL_MD = '# Soul\n\n你是专业的企业数字员工,表达清晰、可执行。';
|
const FALLBACK_SOUL_MD = '# Soul\n\n你是专业的企业数字员工,表达清晰、可执行。';
|
||||||
const FALLBACK_AGENTS_MD = '# Agent Instructions\n\n- 优先完成任务目标\n- 操作前先说明意图\n- 输出必须可执行\n\n## 默认输出规范\n\n- 每次执行任务时,在 workspace 中创建新目录保存本次输出。\n- 输出内容默认采用 Markdown(.md)格式。';
|
const FALLBACK_AGENTS_MD = '# Agent Instructions\n\n- 优先完成任务目标\n- 操作前先说明意图\n- 输出必须可执行\n\n## 默认输出规范\n\n- 每次执行任务时,在 workspace 中创建新目录保存本次输出。\n- 输出内容默认采用 Markdown(.md)格式。';
|
||||||
|
|
@ -144,7 +146,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
const [envDraftValue, setEnvDraftValue] = useState('');
|
const [envDraftValue, setEnvDraftValue] = useState('');
|
||||||
const [envDraftVisible, setEnvDraftVisible] = useState(false);
|
const [envDraftVisible, setEnvDraftVisible] = useState(false);
|
||||||
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
|
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
|
||||||
const [newChannelType, setNewChannelType] = useState<ChannelType>('feishu');
|
const [newChannelType, setNewChannelType] = useState<ChannelType | ''>('');
|
||||||
const [form, setForm] = useState(initialForm);
|
const [form, setForm] = useState(initialForm);
|
||||||
const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState(FALLBACK_AGENTS_MD);
|
const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState(FALLBACK_AGENTS_MD);
|
||||||
const [maxTokensDraft, setMaxTokensDraft] = useState(String(initialForm.max_tokens));
|
const [maxTokensDraft, setMaxTokensDraft] = useState(String(initialForm.max_tokens));
|
||||||
|
|
@ -399,7 +401,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
};
|
};
|
||||||
|
|
||||||
const addChannel = () => {
|
const addChannel = () => {
|
||||||
if (!addableChannelTypes.includes(newChannelType)) return;
|
if (!newChannelType || !addableChannelTypes.includes(newChannelType)) return;
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
channels: [
|
channels: [
|
||||||
|
|
@ -414,8 +416,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
const rest = addableChannelTypes.filter((t) => t !== newChannelType);
|
setNewChannelType('');
|
||||||
if (rest.length > 0) setNewChannelType(rest[0]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const upsertEnvParam = (key: string, value: string) => {
|
const upsertEnvParam = (key: string, value: string) => {
|
||||||
|
|
@ -521,6 +522,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
placeholder={lc.telegramToken}
|
placeholder={lc.telegramToken}
|
||||||
value={channel.app_secret}
|
value={channel.app_secret}
|
||||||
onChange={(e) => updateChannel(idx, { app_secret: e.target.value })}
|
onChange={(e) => updateChannel(idx, { app_secret: e.target.value })}
|
||||||
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
|
|
@ -529,6 +531,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })
|
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })
|
||||||
}
|
}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
<input
|
<input
|
||||||
|
|
@ -550,8 +553,8 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
if (channel.channel_type === 'feishu') {
|
if (channel.channel_type === 'feishu') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
|
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
|
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={lc.encryptKey}
|
placeholder={lc.encryptKey}
|
||||||
|
|
@ -559,6 +562,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })
|
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })
|
||||||
}
|
}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
|
|
@ -567,6 +571,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })
|
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })
|
||||||
}
|
}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -575,8 +580,8 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
if (channel.channel_type === 'dingtalk') {
|
if (channel.channel_type === 'dingtalk') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder={lc.clientId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
|
<input className="input" placeholder={lc.clientId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||||
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
|
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -584,8 +589,8 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
if (channel.channel_type === 'slack') {
|
if (channel.channel_type === 'slack') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder={lc.botToken} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
|
<input className="input" placeholder={lc.botToken} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||||
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
|
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -593,8 +598,8 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
if (channel.channel_type === 'qq') {
|
if (channel.channel_type === 'qq') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
|
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
|
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -706,14 +711,14 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
|
|
||||||
<div className="stack card wizard-step2-card">
|
<div className="stack card wizard-step2-card">
|
||||||
<div className="section-mini-title">{ui.modelAccess}</div>
|
<div className="section-mini-title">{ui.modelAccess}</div>
|
||||||
<select className="select" value={form.llm_provider} onChange={(e) => onProviderChange(e.target.value)}>
|
<LucentSelect value={form.llm_provider} onChange={(e) => onProviderChange(e.target.value)}>
|
||||||
<option value="openrouter">openrouter</option>
|
<option value="openrouter">openrouter</option>
|
||||||
<option value="dashscope">dashscope (aliyun qwen)</option>
|
<option value="dashscope">dashscope (aliyun qwen)</option>
|
||||||
<option value="openai">openai</option>
|
<option value="openai">openai</option>
|
||||||
<option value="deepseek">deepseek</option>
|
<option value="deepseek">deepseek</option>
|
||||||
<option value="kimi">kimi (moonshot)</option>
|
<option value="kimi">kimi (moonshot)</option>
|
||||||
<option value="minimax">minimax</option>
|
<option value="minimax">minimax</option>
|
||||||
</select>
|
</LucentSelect>
|
||||||
<input className="input" placeholder={ui.modelNamePlaceholder} value={form.llm_model} onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))} />
|
<input className="input" placeholder={ui.modelNamePlaceholder} value={form.llm_model} onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))} />
|
||||||
<input className="input" type="password" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} />
|
<input className="input" type="password" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} />
|
||||||
<input className="input" placeholder="API Base" value={form.api_base} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} />
|
<input className="input" placeholder="API Base" value={form.api_base} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} />
|
||||||
|
|
@ -917,12 +922,20 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<select className="select" value={newChannelType} onChange={(e) => setNewChannelType(e.target.value as ChannelType)} disabled={addableChannelTypes.length === 0}>
|
<LucentSelect
|
||||||
|
value={newChannelType || EMPTY_CHANNEL_PICKER}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = String(e.target.value || '');
|
||||||
|
setNewChannelType(next === EMPTY_CHANNEL_PICKER ? '' : (next as ChannelType));
|
||||||
|
}}
|
||||||
|
disabled={addableChannelTypes.length === 0}
|
||||||
|
>
|
||||||
|
<option value={EMPTY_CHANNEL_PICKER} disabled>{lc.selectChannelType}</option>
|
||||||
{addableChannelTypes.map((t) => (
|
{addableChannelTypes.map((t) => (
|
||||||
<option key={t} value={t}>{t}</option>
|
<option key={t} value={t}>{t}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</LucentSelect>
|
||||||
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" disabled={addableChannelTypes.length === 0} onClick={addChannel} tooltip={lc.addChannel} aria-label={lc.addChannel}>
|
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" disabled={addableChannelTypes.length === 0 || !newChannelType} onClick={addChannel} tooltip={lc.addChannel} aria-label={lc.addChannel}>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</LucentIconButton>
|
</LucentIconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -958,6 +971,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => upsertEnvParam(key, e.target.value)}
|
onChange={(e) => upsertEnvParam(key, e.target.value)}
|
||||||
placeholder={ui.envValue}
|
placeholder={ui.envValue}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<LucentIconButton
|
<LucentIconButton
|
||||||
className="btn btn-secondary btn-sm wizard-icon-btn"
|
className="btn btn-secondary btn-sm wizard-icon-btn"
|
||||||
|
|
@ -986,6 +1000,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
value={envDraftKey}
|
value={envDraftKey}
|
||||||
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
|
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
|
||||||
placeholder={ui.envKey}
|
placeholder={ui.envKey}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
|
|
@ -993,6 +1008,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
value={envDraftValue}
|
value={envDraftValue}
|
||||||
onChange={(e) => setEnvDraftValue(e.target.value)}
|
onChange={(e) => setEnvDraftValue(e.target.value)}
|
||||||
placeholder={ui.envValue}
|
placeholder={ui.envValue}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<LucentIconButton
|
<LucentIconButton
|
||||||
className="btn btn-secondary btn-sm icon-btn"
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue