v0.1.4
parent
65a96a60dc
commit
b1dd8d5e16
|
|
@ -42,4 +42,4 @@ REDIS_DEFAULT_TTL=60
|
|||
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
||||
|
||||
# Max upload size for backend validation (MB)
|
||||
UPLOAD_MAX_MB=100
|
||||
UPLOAD_MAX_MB=200
|
||||
|
|
|
|||
|
|
@ -50,6 +50,24 @@ class BotConfigManager:
|
|||
"sendToolHints": bool(bot_data.get("send_tool_hints", False)),
|
||||
}
|
||||
|
||||
existing_config: Dict[str, Any] = {}
|
||||
config_path = os.path.join(dot_nanobot_dir, "config.json")
|
||||
if os.path.isfile(config_path):
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
existing_config = loaded
|
||||
except Exception:
|
||||
existing_config = {}
|
||||
|
||||
existing_tools = existing_config.get("tools")
|
||||
tools_cfg: Dict[str, Any] = dict(existing_tools) if isinstance(existing_tools, dict) else {}
|
||||
if "mcp_servers" in bot_data:
|
||||
mcp_servers = bot_data.get("mcp_servers")
|
||||
if isinstance(mcp_servers, dict):
|
||||
tools_cfg["mcpServers"] = mcp_servers
|
||||
|
||||
config_data: Dict[str, Any] = {
|
||||
"agents": {
|
||||
"defaults": {
|
||||
|
|
@ -64,6 +82,8 @@ class BotConfigManager:
|
|||
},
|
||||
"channels": channels_cfg,
|
||||
}
|
||||
if tools_cfg:
|
||||
config_data["tools"] = tools_cfg
|
||||
|
||||
for channel in channels:
|
||||
channel_type = (channel.get("channel_type") or "").strip()
|
||||
|
|
@ -149,7 +169,6 @@ class BotConfigManager:
|
|||
**extra,
|
||||
}
|
||||
|
||||
config_path = os.path.join(dot_nanobot_dir, "config.json")
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -211,6 +211,95 @@ class BotDockerManager:
|
|||
print(f"[DockerManager] Error stopping bot {bot_id}: {e}")
|
||||
return False
|
||||
|
||||
def probe_http_from_container(
|
||||
self,
|
||||
bot_id: str,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
body_json: Optional[Dict[str, Any]] = None,
|
||||
timeout_seconds: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
if not self.client:
|
||||
return {"ok": False, "message": "Docker client is not available"}
|
||||
try:
|
||||
container = self.client.containers.get(f"worker_{bot_id}")
|
||||
container.reload()
|
||||
if container.status != "running":
|
||||
return {"ok": False, "message": f"Bot container is {container.status}"}
|
||||
except docker.errors.NotFound:
|
||||
return {"ok": False, "message": "Bot container not found"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "message": f"Failed to inspect bot container: {e}"}
|
||||
|
||||
safe_method = str(method or "GET").strip().upper()
|
||||
if safe_method not in {"GET", "POST"}:
|
||||
safe_method = "GET"
|
||||
timeout = max(1, min(int(timeout_seconds or 10), 30))
|
||||
payload = {
|
||||
"url": str(url or "").strip(),
|
||||
"method": safe_method,
|
||||
"headers": headers or {},
|
||||
"body_json": body_json if isinstance(body_json, dict) else None,
|
||||
"timeout": timeout,
|
||||
}
|
||||
payload_b64 = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii")
|
||||
py_script = (
|
||||
"import base64, json, os, urllib.request, urllib.error\n"
|
||||
"cfg = json.loads(base64.b64decode(os.environ['DASHBOARD_HTTP_PROBE_B64']).decode('utf-8'))\n"
|
||||
"url = str(cfg.get('url') or '').strip()\n"
|
||||
"method = str(cfg.get('method') or 'GET').upper()\n"
|
||||
"headers = cfg.get('headers') or {}\n"
|
||||
"timeout = int(cfg.get('timeout') or 10)\n"
|
||||
"data = None\n"
|
||||
"if method == 'POST':\n"
|
||||
" body = cfg.get('body_json')\n"
|
||||
" if not isinstance(body, dict):\n"
|
||||
" body = {}\n"
|
||||
" data = json.dumps(body, ensure_ascii=False).encode('utf-8')\n"
|
||||
" if 'Content-Type' not in headers:\n"
|
||||
" headers['Content-Type'] = 'application/json'\n"
|
||||
"req = urllib.request.Request(url, data=data, headers=headers, method=method)\n"
|
||||
"result = {'ok': False, 'status_code': None, 'content_type': '', 'body_preview': '', 'message': ''}\n"
|
||||
"try:\n"
|
||||
" with urllib.request.urlopen(req, timeout=timeout) as resp:\n"
|
||||
" body = resp.read(1024).decode('utf-8', 'ignore')\n"
|
||||
" result.update({'ok': True, 'status_code': int(getattr(resp, 'status', 200) or 200), 'content_type': str(resp.headers.get('content-type') or ''), 'body_preview': body[:512], 'message': 'ok'})\n"
|
||||
"except urllib.error.HTTPError as e:\n"
|
||||
" body = ''\n"
|
||||
" try:\n"
|
||||
" body = e.read(1024).decode('utf-8', 'ignore')\n"
|
||||
" except Exception:\n"
|
||||
" body = ''\n"
|
||||
" result.update({'ok': False, 'status_code': int(e.code or 0), 'content_type': str((e.headers or {}).get('content-type') or ''), 'body_preview': body[:512], 'message': f'HTTPError: {e.code}'})\n"
|
||||
"except Exception as e:\n"
|
||||
" result.update({'ok': False, 'status_code': None, 'content_type': '', 'body_preview': '', 'message': f'{type(e).__name__}: {e}'})\n"
|
||||
"print(json.dumps(result, ensure_ascii=False))\n"
|
||||
)
|
||||
|
||||
py_bins = ["python3", "python"]
|
||||
last_error = ""
|
||||
for py_bin in py_bins:
|
||||
try:
|
||||
exec_result = container.exec_run(
|
||||
[py_bin, "-c", py_script],
|
||||
environment={"DASHBOARD_HTTP_PROBE_B64": payload_b64},
|
||||
)
|
||||
except Exception as e:
|
||||
last_error = f"exec {py_bin} failed: {e}"
|
||||
continue
|
||||
output = exec_result.output.decode("utf-8", errors="ignore") if isinstance(exec_result.output, (bytes, bytearray)) else str(exec_result.output)
|
||||
if exec_result.exit_code != 0:
|
||||
last_error = f"exec {py_bin} exit={exec_result.exit_code}: {output[:300]}"
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(output.strip() or "{}")
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
last_error = f"exec {py_bin} returned non-json: {output[:300]}"
|
||||
return {"ok": False, "message": last_error or "Failed to run probe in bot container"}
|
||||
|
||||
def send_command(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool:
|
||||
"""Send a command to dashboard channel with robust container-local delivery."""
|
||||
if not self.client:
|
||||
|
|
|
|||
323
backend/main.py
323
backend/main.py
|
|
@ -134,6 +134,17 @@ class BotToolsConfigUpdateRequest(BaseModel):
|
|||
tools_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class BotMcpConfigUpdateRequest(BaseModel):
|
||||
mcp_servers: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class BotMcpConfigTestRequest(BaseModel):
|
||||
type: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
tool_timeout: Optional[int] = None
|
||||
|
||||
|
||||
class BotEnvParamsUpdateRequest(BaseModel):
|
||||
env_params: Optional[Dict[str, str]] = None
|
||||
|
||||
|
|
@ -865,6 +876,263 @@ def _normalize_env_params(raw: Any) -> Dict[str, str]:
|
|||
return rows
|
||||
|
||||
|
||||
_MCP_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
|
||||
|
||||
|
||||
def _normalize_mcp_servers(raw: Any) -> Dict[str, Dict[str, Any]]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
rows: Dict[str, Dict[str, Any]] = {}
|
||||
for server_name, server_cfg in raw.items():
|
||||
name = str(server_name or "").strip()
|
||||
if not name or not _MCP_SERVER_NAME_RE.match(name):
|
||||
continue
|
||||
if not isinstance(server_cfg, dict):
|
||||
continue
|
||||
|
||||
url = str(server_cfg.get("url") or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
|
||||
transport_type = str(server_cfg.get("type") or "streamableHttp").strip()
|
||||
if transport_type not in {"streamableHttp", "sse"}:
|
||||
transport_type = "streamableHttp"
|
||||
|
||||
headers_raw = server_cfg.get("headers")
|
||||
headers: Dict[str, str] = {}
|
||||
if isinstance(headers_raw, dict):
|
||||
for k, v in headers_raw.items():
|
||||
hk = str(k or "").strip()
|
||||
if not hk:
|
||||
continue
|
||||
headers[hk] = str(v or "").strip()
|
||||
|
||||
timeout_raw = server_cfg.get("toolTimeout", 60)
|
||||
try:
|
||||
timeout = int(timeout_raw)
|
||||
except Exception:
|
||||
timeout = 60
|
||||
timeout = max(1, min(timeout, 600))
|
||||
|
||||
rows[name] = {
|
||||
"type": transport_type,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
"toolTimeout": timeout,
|
||||
}
|
||||
return rows
|
||||
|
||||
|
||||
def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
transport_type = str(cfg.get("type") or "streamableHttp").strip()
|
||||
if transport_type not in {"streamableHttp", "sse"}:
|
||||
transport_type = "streamableHttp"
|
||||
url = str(cfg.get("url") or "").strip()
|
||||
headers_raw = cfg.get("headers")
|
||||
headers: Dict[str, str] = {}
|
||||
if isinstance(headers_raw, dict):
|
||||
for k, v in headers_raw.items():
|
||||
key = str(k or "").strip()
|
||||
if key:
|
||||
headers[key] = str(v or "").strip()
|
||||
timeout_raw = cfg.get("toolTimeout", 10)
|
||||
try:
|
||||
timeout_s = max(1, min(int(timeout_raw), 30))
|
||||
except Exception:
|
||||
timeout_s = 10
|
||||
|
||||
if not url:
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": None,
|
||||
"message": "MCP url is required",
|
||||
"probe_from": "validation",
|
||||
}
|
||||
|
||||
probe_payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "dashboard-probe",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "dashboard-nanobot", "version": "0.1.4"},
|
||||
},
|
||||
}
|
||||
|
||||
if bot_id:
|
||||
if transport_type == "sse":
|
||||
probe_headers = dict(headers)
|
||||
probe_headers.setdefault("Accept", "text/event-stream")
|
||||
probe = docker_manager.probe_http_from_container(
|
||||
bot_id=bot_id,
|
||||
url=url,
|
||||
method="GET",
|
||||
headers=probe_headers,
|
||||
body_json=None,
|
||||
timeout_seconds=timeout_s,
|
||||
)
|
||||
status_code = probe.get("status_code")
|
||||
content_type = str(probe.get("content_type") or "")
|
||||
message = str(probe.get("message") or "").strip()
|
||||
if status_code in {401, 403}:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP SSE endpoint", "content_type": content_type, "probe_from": "bot-container"}
|
||||
if status_code == 404:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint not found", "content_type": content_type, "probe_from": "bot-container"}
|
||||
if isinstance(status_code, int) and status_code >= 500:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint server error", "content_type": content_type, "probe_from": "bot-container"}
|
||||
if not probe.get("ok"):
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": message or "Failed to connect MCP SSE endpoint from bot container", "content_type": content_type, "probe_from": "bot-container"}
|
||||
if "text/event-stream" not in content_type.lower():
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Endpoint reachable, but content-type is not text/event-stream", "content_type": content_type, "probe_from": "bot-container"}
|
||||
return {"ok": True, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint is reachable", "content_type": content_type, "probe_from": "bot-container"}
|
||||
|
||||
probe_headers = dict(headers)
|
||||
probe_headers.setdefault("Content-Type", "application/json")
|
||||
probe_headers.setdefault("Accept", "application/json, text/event-stream")
|
||||
probe = docker_manager.probe_http_from_container(
|
||||
bot_id=bot_id,
|
||||
url=url,
|
||||
method="POST",
|
||||
headers=probe_headers,
|
||||
body_json=probe_payload,
|
||||
timeout_seconds=timeout_s,
|
||||
)
|
||||
status_code = probe.get("status_code")
|
||||
message = str(probe.get("message") or "").strip()
|
||||
if status_code in {401, 403}:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP endpoint", "probe_from": "bot-container"}
|
||||
if status_code == 404:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP endpoint not found", "probe_from": "bot-container"}
|
||||
if isinstance(status_code, int) and status_code >= 500:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP endpoint server error", "probe_from": "bot-container"}
|
||||
if probe.get("ok") and status_code in {200, 201, 202, 204, 400, 405, 415, 422}:
|
||||
reachability_msg = "MCP endpoint is reachable" if status_code in {200, 201, 202, 204} else "MCP endpoint is reachable (request format not fully accepted by probe)"
|
||||
return {"ok": True, "transport": transport_type, "status_code": status_code, "message": reachability_msg, "probe_from": "bot-container"}
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": message or "Unexpected response from MCP endpoint", "probe_from": "bot-container"}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(timeout_s), follow_redirects=True) as client:
|
||||
if transport_type == "sse":
|
||||
req_headers = dict(headers)
|
||||
req_headers.setdefault("Accept", "text/event-stream")
|
||||
resp = client.get(url, headers=req_headers)
|
||||
content_type = str(resp.headers.get("content-type") or "")
|
||||
if resp.status_code in {401, 403}:
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "Auth failed for MCP SSE endpoint",
|
||||
"content_type": content_type,
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
if resp.status_code == 404:
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "MCP SSE endpoint not found",
|
||||
"content_type": content_type,
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
if resp.status_code >= 500:
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "MCP SSE endpoint server error",
|
||||
"content_type": content_type,
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
if "text/event-stream" not in content_type.lower():
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "Endpoint reachable, but content-type is not text/event-stream",
|
||||
"content_type": content_type,
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "MCP SSE endpoint is reachable",
|
||||
"content_type": content_type,
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
|
||||
req_headers = dict(headers)
|
||||
req_headers.setdefault("Content-Type", "application/json")
|
||||
req_headers.setdefault("Accept", "application/json, text/event-stream")
|
||||
resp = client.post(url, headers=req_headers, json=probe_payload)
|
||||
if resp.status_code in {401, 403}:
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "Auth failed for MCP endpoint",
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
if resp.status_code == 404:
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "MCP endpoint not found",
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
if resp.status_code >= 500:
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "MCP endpoint server error",
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
if resp.status_code in {200, 201, 202, 204}:
|
||||
return {
|
||||
"ok": True,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "MCP endpoint is reachable",
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
if resp.status_code in {400, 405, 415, 422}:
|
||||
return {
|
||||
"ok": True,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "MCP endpoint is reachable (request format not fully accepted by probe)",
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": resp.status_code,
|
||||
"message": "Unexpected response from MCP endpoint",
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": None,
|
||||
"message": "MCP endpoint timeout",
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport_type,
|
||||
"status_code": None,
|
||||
"message": f"MCP probe failed: {type(exc).__name__}: {exc}",
|
||||
"probe_from": "backend-host",
|
||||
}
|
||||
|
||||
|
||||
def _parse_env_params(raw: Any) -> Dict[str, str]:
|
||||
return _normalize_env_params(raw)
|
||||
|
||||
|
|
@ -1956,6 +2224,61 @@ def update_bot_tools_config(bot_id: str, payload: BotToolsConfigUpdateRequest, s
|
|||
)
|
||||
|
||||
|
||||
@app.get("/api/bots/{bot_id}/mcp-config")
|
||||
def get_bot_mcp_config(bot_id: str, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
config_data = _read_bot_config(bot_id)
|
||||
tools_cfg = config_data.get("tools")
|
||||
if not isinstance(tools_cfg, dict):
|
||||
tools_cfg = {}
|
||||
mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"mcp_servers": mcp_servers,
|
||||
"restart_required": True,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/bots/{bot_id}/mcp-config")
|
||||
def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
config_data = _read_bot_config(bot_id)
|
||||
if not isinstance(config_data, dict):
|
||||
config_data = {}
|
||||
tools_cfg = config_data.get("tools")
|
||||
if not isinstance(tools_cfg, dict):
|
||||
tools_cfg = {}
|
||||
mcp_servers = _normalize_mcp_servers(payload.mcp_servers or {})
|
||||
tools_cfg["mcpServers"] = mcp_servers
|
||||
config_data["tools"] = tools_cfg
|
||||
_write_bot_config(bot_id, config_data)
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
return {
|
||||
"status": "updated",
|
||||
"bot_id": bot_id,
|
||||
"mcp_servers": mcp_servers,
|
||||
"restart_required": True,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/bots/{bot_id}/mcp-config/test")
|
||||
def test_bot_mcp_config(bot_id: str, payload: BotMcpConfigTestRequest, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
cfg = {
|
||||
"type": str(payload.type or "streamableHttp").strip(),
|
||||
"url": str(payload.url or "").strip(),
|
||||
"headers": payload.headers or {},
|
||||
"toolTimeout": payload.tool_timeout if payload.tool_timeout is not None else 10,
|
||||
}
|
||||
return _probe_mcp_server(cfg, bot_id=bot_id)
|
||||
|
||||
|
||||
@app.get("/api/bots/{bot_id}/env-params")
|
||||
def get_bot_env_params(bot_id: str, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
saveChannel: 'Save',
|
||||
addChannel: 'Add',
|
||||
selectChannelType: 'Select channel type',
|
||||
close: 'Close',
|
||||
remove: 'Remove',
|
||||
sendProgress: 'sendProgress',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const channelsZhCn = {
|
|||
enabled: '启用',
|
||||
saveChannel: '保存',
|
||||
addChannel: '新增',
|
||||
selectChannelType: '请选择渠道类型',
|
||||
close: '关闭',
|
||||
remove: '删除',
|
||||
sendProgress: 'sendProgress',
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export const dashboardEn = {
|
|||
params: 'Model',
|
||||
channels: 'Channels',
|
||||
skills: 'Skills',
|
||||
mcp: 'MCP',
|
||||
tools: 'Tools',
|
||||
skillsPanel: 'Skills Panel',
|
||||
skillsEmpty: 'No skills.',
|
||||
|
|
@ -115,6 +116,28 @@ export const dashboardEn = {
|
|||
envParamsSaved: 'Env params saved.',
|
||||
envParamsSaveFail: 'Failed to save env params.',
|
||||
envParamsHint: 'Restart bot to apply updated env vars.',
|
||||
mcpPanel: 'MCP Configuration',
|
||||
mcpPanelDesc: 'Configure MCP servers (HTTP/SSE) for this bot. Use dedicated X-Bot-Id / X-Bot-Secret per bot.',
|
||||
mcpEmpty: 'No MCP servers configured.',
|
||||
mcpServer: 'MCP Server',
|
||||
mcpName: 'Server Name',
|
||||
mcpNamePlaceholder: 'e.g. biz_mcp',
|
||||
mcpType: 'Transport Type',
|
||||
mcpUrlPlaceholder: 'e.g. http://mcp.internal:9001/mcp',
|
||||
mcpBotIdPlaceholder: 'e.g. mula_bot_b02',
|
||||
mcpBotSecretPlaceholder: 'Secret for this bot identity',
|
||||
mcpToolTimeout: 'Tool Timeout (seconds)',
|
||||
mcpTest: 'Test Connectivity',
|
||||
mcpTesting: 'Testing connectivity...',
|
||||
mcpTestPass: 'Connectivity test passed.',
|
||||
mcpTestFail: 'Connectivity test failed.',
|
||||
mcpTestNeedUrl: 'Please provide MCP URL first.',
|
||||
mcpTestBlockSave: 'MCP connectivity test failed. Save is blocked.',
|
||||
addMcpServer: 'Add MCP Server',
|
||||
saveMcpConfig: 'Save MCP Config',
|
||||
mcpSaved: 'MCP config saved.',
|
||||
mcpSaveFail: 'Failed to save MCP config.',
|
||||
mcpHint: 'Restart bot to apply MCP changes.',
|
||||
toolsLoadFail: 'Failed to load tool skills.',
|
||||
toolsAddFail: 'Failed to add tool.',
|
||||
toolsRemoveFail: 'Failed to remove tool.',
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export const dashboardZhCn = {
|
|||
params: '模型',
|
||||
channels: '渠道',
|
||||
skills: '技能',
|
||||
mcp: 'MCP',
|
||||
tools: '工具',
|
||||
skillsPanel: '技能面板',
|
||||
skillsEmpty: '暂无技能。',
|
||||
|
|
@ -115,6 +116,28 @@ export const dashboardZhCn = {
|
|||
envParamsSaved: '环境变量已保存。',
|
||||
envParamsSaveFail: '环境变量保存失败。',
|
||||
envParamsHint: '修改后需重启 Bot 才会生效。',
|
||||
mcpPanel: 'MCP 配置',
|
||||
mcpPanelDesc: '配置该 Bot 的 MCP Servers(HTTP/SSE)。建议每个 Bot 使用独立的 X-Bot-Id / X-Bot-Secret。',
|
||||
mcpEmpty: '暂无 MCP Server。',
|
||||
mcpServer: 'MCP Server',
|
||||
mcpName: '服务名称',
|
||||
mcpNamePlaceholder: '如 biz_mcp',
|
||||
mcpType: '传输类型',
|
||||
mcpUrlPlaceholder: '如 http://mcp.internal:9001/mcp',
|
||||
mcpBotIdPlaceholder: '如 mula_bot_b02',
|
||||
mcpBotSecretPlaceholder: '输入该 Bot 对应的密钥',
|
||||
mcpToolTimeout: 'Tool Timeout(秒)',
|
||||
mcpTest: '测试连通性',
|
||||
mcpTesting: '连通性测试中...',
|
||||
mcpTestPass: '连通性测试通过。',
|
||||
mcpTestFail: '连通性测试失败。',
|
||||
mcpTestNeedUrl: '请先填写 MCP URL。',
|
||||
mcpTestBlockSave: '存在未通过的 MCP 连通性测试,已阻止保存。',
|
||||
addMcpServer: '新增 MCP Server',
|
||||
saveMcpConfig: '保存 MCP 配置',
|
||||
mcpSaved: 'MCP 配置已保存。',
|
||||
mcpSaveFail: 'MCP 配置保存失败。',
|
||||
mcpHint: '保存后需重启 Bot 才会生效。',
|
||||
toolsLoadFail: '读取工具技能失败。',
|
||||
toolsAddFail: '新增工具失败。',
|
||||
toolsRemoveFail: '移除工具失败。',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { dashboardZhCn } from '../../i18n/dashboard.zh-cn';
|
|||
import { dashboardEn } from '../../i18n/dashboard.en';
|
||||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||
import { LucentSelect } from '../../components/lucent/LucentSelect';
|
||||
|
||||
interface BotDashboardModuleProps {
|
||||
onOpenCreateWizard?: () => void;
|
||||
|
|
@ -33,6 +34,7 @@ type RuntimeViewMode = 'visual' | 'text';
|
|||
type CompactPanelTab = 'chat' | 'runtime';
|
||||
type QuotedReply = { id?: number; text: string; ts: number };
|
||||
const BOT_LIST_PAGE_SIZE = 8;
|
||||
const EMPTY_CHANNEL_PICKER = '__none__';
|
||||
|
||||
interface WorkspaceNode {
|
||||
name: string;
|
||||
|
|
@ -116,6 +118,41 @@ interface CronJobsResponse {
|
|||
jobs: CronJob[];
|
||||
}
|
||||
|
||||
interface MCPServerConfig {
|
||||
type?: 'streamableHttp' | 'sse' | string;
|
||||
url?: string;
|
||||
headers?: Record<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 {
|
||||
id: string | number;
|
||||
bot_id: string;
|
||||
|
|
@ -687,6 +724,7 @@ export function BotDashboardModule({
|
|||
const [showParamModal, setShowParamModal] = useState(false);
|
||||
const [showChannelModal, setShowChannelModal] = useState(false);
|
||||
const [showSkillsModal, setShowSkillsModal] = useState(false);
|
||||
const [showMcpModal, setShowMcpModal] = useState(false);
|
||||
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
|
||||
const [showCronModal, setShowCronModal] = useState(false);
|
||||
const [showAgentModal, setShowAgentModal] = useState(false);
|
||||
|
|
@ -703,6 +741,8 @@ export function BotDashboardModule({
|
|||
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
|
||||
const [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping'>>({});
|
||||
const chatBottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const chatScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const chatAutoFollowRef = useRef(true);
|
||||
const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceNode[]>([]);
|
||||
const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState<WorkspaceNode[]>([]);
|
||||
const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false);
|
||||
|
|
@ -730,11 +770,14 @@ export function BotDashboardModule({
|
|||
const [isSkillUploading, setIsSkillUploading] = useState(false);
|
||||
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
|
||||
const [envParams, setEnvParams] = useState<BotEnvParams>({});
|
||||
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
|
||||
const [mcpTestByIndex, setMcpTestByIndex] = useState<Record<number, MCPTestState>>({});
|
||||
const [envDraftKey, setEnvDraftKey] = useState('');
|
||||
const [envDraftValue, setEnvDraftValue] = useState('');
|
||||
const [envDraftVisible, setEnvDraftVisible] = useState(false);
|
||||
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
|
||||
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
||||
const [isSavingMcp, setIsSavingMcp] = useState(false);
|
||||
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
|
||||
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
|
||||
const [localDockerImages, setLocalDockerImages] = useState<DockerImage[]>([]);
|
||||
|
|
@ -743,7 +786,7 @@ export function BotDashboardModule({
|
|||
sendToolHints: false,
|
||||
});
|
||||
const [uploadMaxMb, setUploadMaxMb] = useState(100);
|
||||
const [newChannelType, setNewChannelType] = useState<ChannelType>('feishu');
|
||||
const [newChannelType, setNewChannelType] = useState<ChannelType | ''>('');
|
||||
const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
|
||||
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
|
||||
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
|
||||
|
|
@ -1498,9 +1541,28 @@ export function BotDashboardModule({
|
|||
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
}, [command, pendingAttachments.length, quotedReply, isUploadingAttachments]);
|
||||
|
||||
const syncChatScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
|
||||
const box = chatScrollRef.current;
|
||||
if (!box) return;
|
||||
box.scrollTo({ top: box.scrollHeight, behavior });
|
||||
}, []);
|
||||
|
||||
const onChatScroll = useCallback(() => {
|
||||
const box = chatScrollRef.current;
|
||||
if (!box) return;
|
||||
const distanceToBottom = box.scrollHeight - box.scrollTop - box.clientHeight;
|
||||
chatAutoFollowRef.current = distanceToBottom <= 64;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
chatBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [selectedBotId, conversation.length]);
|
||||
chatAutoFollowRef.current = true;
|
||||
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
|
||||
}, [selectedBotId, syncChatScrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatAutoFollowRef.current) return;
|
||||
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
|
||||
}, [conversation.length, syncChatScrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuotedReply(null);
|
||||
|
|
@ -1898,6 +1960,147 @@ export function BotDashboardModule({
|
|||
});
|
||||
};
|
||||
|
||||
const loadBotMcpConfig = async (botId: string) => {
|
||||
if (!botId) return;
|
||||
try {
|
||||
const res = await axios.get<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) => {
|
||||
if (!selectedBot) return;
|
||||
const ok = await confirm({
|
||||
|
|
@ -2020,7 +2223,7 @@ export function BotDashboardModule({
|
|||
};
|
||||
|
||||
const addChannel = async () => {
|
||||
if (!selectedBot || !addableChannelTypes.includes(newChannelType)) return;
|
||||
if (!selectedBot || !newChannelType || !addableChannelTypes.includes(newChannelType)) return;
|
||||
setIsSavingChannel(true);
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, {
|
||||
|
|
@ -2032,8 +2235,7 @@ export function BotDashboardModule({
|
|||
extra_config: {},
|
||||
});
|
||||
await loadChannels(selectedBot.id);
|
||||
const rest = addableChannelTypes.filter((t) => t !== newChannelType);
|
||||
if (rest.length > 0) setNewChannelType(rest[0]);
|
||||
setNewChannelType('');
|
||||
} catch (error: any) {
|
||||
const msg = error?.response?.data?.detail || t.channelAddFail;
|
||||
notify(msg, { tone: 'error' });
|
||||
|
|
@ -2101,12 +2303,13 @@ export function BotDashboardModule({
|
|||
if (ctype === 'telegram') {
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
className="input"
|
||||
placeholder={lc.proxy}
|
||||
value={String((channel.extra_config || {}).proxy || '')}
|
||||
onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<label className="field-label">
|
||||
<input
|
||||
|
|
@ -2126,10 +2329,10 @@ export function BotDashboardModule({
|
|||
if (ctype === 'feishu') {
|
||||
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" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: 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 } })} />
|
||||
<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.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 })} 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 } })} 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 } })} autoComplete="off" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2137,8 +2340,8 @@ export function BotDashboardModule({
|
|||
if (ctype === 'dingtalk') {
|
||||
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" type="password" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: 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 })} autoComplete="new-password" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2146,8 +2349,8 @@ export function BotDashboardModule({
|
|||
if (ctype === 'slack') {
|
||||
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" type="password" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: 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 })} autoComplete="new-password" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2155,8 +2358,8 @@ export function BotDashboardModule({
|
|||
if (ctype === 'qq') {
|
||||
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" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: 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 })} autoComplete="new-password" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3051,7 +3254,7 @@ export function BotDashboardModule({
|
|||
{selectedBot ? (
|
||||
<div className="ops-chat-shell">
|
||||
<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 ? (
|
||||
<div className="ops-chat-empty">
|
||||
{t.noConversation}
|
||||
|
|
@ -3348,6 +3551,19 @@ export function BotDashboardModule({
|
|||
<Hammer size={14} />
|
||||
<span>{t.skills}</span>
|
||||
</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
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
|
|
@ -3657,8 +3873,7 @@ export function BotDashboardModule({
|
|||
/>
|
||||
|
||||
<label className="field-label">{t.baseImageReadonly}</label>
|
||||
<select
|
||||
className="select"
|
||||
<LucentSelect
|
||||
value={editForm.image_tag}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, image_tag: e.target.value }))}
|
||||
>
|
||||
|
|
@ -3667,7 +3882,7 @@ export function BotDashboardModule({
|
|||
{img.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LucentSelect>
|
||||
{baseImageOptions.find((opt) => opt.tag === editForm.image_tag)?.needsRegister ? (
|
||||
<div className="field-label" style={{ color: 'var(--warning)' }}>
|
||||
{isZh ? '该镜像尚未登记,保存时会自动加入镜像注册表。' : 'This image is not registered yet. It will be auto-registered on save.'}
|
||||
|
|
@ -3727,14 +3942,14 @@ export function BotDashboardModule({
|
|||
</div>
|
||||
</div>
|
||||
<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="dashscope">dashscope (aliyun qwen)</option>
|
||||
<option value="openai">openai</option>
|
||||
<option value="deepseek">deepseek</option>
|
||||
<option value="kimi">kimi (moonshot)</option>
|
||||
<option value="minimax">minimax</option>
|
||||
</select>
|
||||
</LucentSelect>
|
||||
|
||||
<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} />
|
||||
|
|
@ -3887,19 +4102,22 @@ export function BotDashboardModule({
|
|||
</div>
|
||||
|
||||
<div className="row-between">
|
||||
<select
|
||||
className="select"
|
||||
value={newChannelType}
|
||||
onChange={(e) => setNewChannelType(e.target.value as ChannelType)}
|
||||
<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 || isSavingChannel}
|
||||
>
|
||||
<option value={EMPTY_CHANNEL_PICKER} disabled>{lc.selectChannelType}</option>
|
||||
{addableChannelTypes.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</LucentSelect>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
disabled={addableChannelTypes.length === 0 || isSavingChannel}
|
||||
disabled={addableChannelTypes.length === 0 || isSavingChannel || !newChannelType}
|
||||
onClick={() => void addChannel()}
|
||||
tooltip={lc.addChannel}
|
||||
aria-label={lc.addChannel}
|
||||
|
|
@ -3977,6 +4195,86 @@ export function BotDashboardModule({
|
|||
</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 && (
|
||||
<div className="modal-mask" onClick={() => setShowEnvParamsModal(false)}>
|
||||
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||
|
|
@ -4005,6 +4303,7 @@ export function BotDashboardModule({
|
|||
value={value}
|
||||
onChange={(e) => upsertEnvParam(key, e.target.value)}
|
||||
placeholder={t.envValue}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm wizard-icon-btn"
|
||||
|
|
@ -4034,6 +4333,7 @@ export function BotDashboardModule({
|
|||
value={envDraftKey}
|
||||
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
|
||||
placeholder={t.envKey}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
|
|
@ -4041,6 +4341,7 @@ export function BotDashboardModule({
|
|||
value={envDraftValue}
|
||||
onChange={(e) => setEnvDraftValue(e.target.value)}
|
||||
placeholder={t.envValue}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { pickLocale } from '../../../i18n';
|
|||
import { managementZhCn } from '../../../i18n/management.zh-cn';
|
||||
import { managementEn } from '../../../i18n/management.en';
|
||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
||||
|
||||
interface CreateBotModalProps {
|
||||
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">
|
||||
<Layers size={14} className="text-blue-500" /> {t.imageLabel}
|
||||
</label>
|
||||
<select
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none text-white"
|
||||
<LucentSelect
|
||||
onChange={(e) => setFormData({ ...formData, image_tag: e.target.value })}
|
||||
value={formData.image_tag}
|
||||
>
|
||||
|
|
@ -111,7 +111,7 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
|
|||
</option>
|
||||
))}
|
||||
{availableImages.length === 0 && <option disabled>{t.noImage}</option>}
|
||||
</select>
|
||||
</LucentSelect>
|
||||
<p className="text-[10px] text-slate-600 italic">{t.imageHint}</p>
|
||||
</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">
|
||||
<Cpu size={14} /> {t.providerLabel}
|
||||
</label>
|
||||
<select
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none text-white"
|
||||
<LucentSelect
|
||||
onChange={(e) => setFormData({ ...formData, llm_provider: e.target.value })}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
|
|
@ -129,7 +128,7 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
|
|||
<option value="kimi">Kimi (Moonshot)</option>
|
||||
<option value="minimax">MiniMax</option>
|
||||
<option value="ollama">Ollama (Local)</option>
|
||||
</select>
|
||||
</LucentSelect>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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 { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||
import { LucentSelect } from '../../components/lucent/LucentSelect';
|
||||
|
||||
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
|
||||
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
|
||||
const EMPTY_CHANNEL_PICKER = '__none__';
|
||||
|
||||
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)格式。';
|
||||
|
|
@ -144,7 +146,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
const [envDraftValue, setEnvDraftValue] = useState('');
|
||||
const [envDraftVisible, setEnvDraftVisible] = useState(false);
|
||||
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
|
||||
const [newChannelType, setNewChannelType] = useState<ChannelType>('feishu');
|
||||
const [newChannelType, setNewChannelType] = useState<ChannelType | ''>('');
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState(FALLBACK_AGENTS_MD);
|
||||
const [maxTokensDraft, setMaxTokensDraft] = useState(String(initialForm.max_tokens));
|
||||
|
|
@ -399,7 +401,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
};
|
||||
|
||||
const addChannel = () => {
|
||||
if (!addableChannelTypes.includes(newChannelType)) return;
|
||||
if (!newChannelType || !addableChannelTypes.includes(newChannelType)) return;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
channels: [
|
||||
|
|
@ -414,8 +416,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
},
|
||||
],
|
||||
}));
|
||||
const rest = addableChannelTypes.filter((t) => t !== newChannelType);
|
||||
if (rest.length > 0) setNewChannelType(rest[0]);
|
||||
setNewChannelType('');
|
||||
};
|
||||
|
||||
const upsertEnvParam = (key: string, value: string) => {
|
||||
|
|
@ -521,6 +522,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
placeholder={lc.telegramToken}
|
||||
value={channel.app_secret}
|
||||
onChange={(e) => updateChannel(idx, { app_secret: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
|
|
@ -529,6 +531,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
onChange={(e) =>
|
||||
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<label className="field-label">
|
||||
<input
|
||||
|
|
@ -550,8 +553,8 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
if (channel.channel_type === 'feishu') {
|
||||
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" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: 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 })} autoComplete="new-password" />
|
||||
<input
|
||||
className="input"
|
||||
placeholder={lc.encryptKey}
|
||||
|
|
@ -559,6 +562,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
onChange={(e) =>
|
||||
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
|
|
@ -567,6 +571,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
onChange={(e) =>
|
||||
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') {
|
||||
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" type="password" placeholder={lc.clientSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: 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 })} autoComplete="new-password" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -584,8 +589,8 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
if (channel.channel_type === 'slack') {
|
||||
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" type="password" placeholder={lc.appToken} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: 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 })} autoComplete="new-password" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -593,8 +598,8 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
if (channel.channel_type === 'qq') {
|
||||
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" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: 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 })} autoComplete="new-password" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -706,14 +711,14 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
|
||||
<div className="stack card wizard-step2-card">
|
||||
<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="dashscope">dashscope (aliyun qwen)</option>
|
||||
<option value="openai">openai</option>
|
||||
<option value="deepseek">deepseek</option>
|
||||
<option value="kimi">kimi (moonshot)</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" 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 }))} />
|
||||
|
|
@ -917,12 +922,20 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
</div>
|
||||
|
||||
<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) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" disabled={addableChannelTypes.length === 0} onClick={addChannel} tooltip={lc.addChannel} aria-label={lc.addChannel}>
|
||||
</LucentSelect>
|
||||
<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} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
|
|
@ -958,6 +971,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
value={value}
|
||||
onChange={(e) => upsertEnvParam(key, e.target.value)}
|
||||
placeholder={ui.envValue}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm wizard-icon-btn"
|
||||
|
|
@ -986,6 +1000,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
value={envDraftKey}
|
||||
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
|
||||
placeholder={ui.envKey}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
|
|
@ -993,6 +1008,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
value={envDraftValue}
|
||||
onChange={(e) => setEnvDraftValue(e.target.value)}
|
||||
placeholder={ui.envValue}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
|
|
|
|||
Loading…
Reference in New Issue