main
mula.liu 2026-03-11 20:55:42 +08:00
parent 65a96a60dc
commit b1dd8d5e16
13 changed files with 921 additions and 54 deletions

View File

@ -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

View File

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

View File

@ -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:

View File

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

View File

@ -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>
);
}

View File

@ -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%);
}

View File

@ -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',

View File

@ -10,6 +10,7 @@ export const channelsZhCn = {
enabled: '启用', enabled: '启用',
saveChannel: '保存', saveChannel: '保存',
addChannel: '新增', addChannel: '新增',
selectChannelType: '请选择渠道类型',
close: '关闭', close: '关闭',
remove: '删除', remove: '删除',
sendProgress: 'sendProgress', sendProgress: 'sendProgress',

View File

@ -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.',

View File

@ -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 ServersHTTP/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: '移除工具失败。',

View File

@ -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"

View File

@ -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>

View File

@ -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"