v0.1.4-p2
parent
34f8a49bba
commit
bee2d8294b
|
|
@ -243,3 +243,26 @@ def mark_bot_topic_item_read(bot_id: str, item_id: int, session: Session = Depen
|
|||
"bot_id": bot_id,
|
||||
"item": _topic_item_to_dict(row),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/bots/{bot_id}/topic-items/{item_id}")
|
||||
def delete_bot_topic_item(bot_id: str, item_id: int, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
row = session.exec(
|
||||
select(TopicItem)
|
||||
.where(TopicItem.bot_id == bot_id)
|
||||
.where(TopicItem.id == item_id)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Topic item not found")
|
||||
payload = _topic_item_to_dict(row)
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return {
|
||||
"status": "deleted",
|
||||
"bot_id": bot_id,
|
||||
"item": payload,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ class BotConfigManager:
|
|||
for d in [dot_nanobot_dir, workspace_dir, memory_dir, skills_dir]:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
provider_name = (bot_data.get("llm_provider") or "openrouter").strip().lower()
|
||||
raw_provider_name = (bot_data.get("llm_provider") or "openrouter").strip().lower()
|
||||
provider_name = raw_provider_name
|
||||
model_name = (bot_data.get("llm_model") or "openai/gpt-4o-mini").strip()
|
||||
api_key = (bot_data.get("api_key") or "").strip()
|
||||
api_base = (bot_data.get("api_base") or "").strip() or None
|
||||
|
|
@ -36,8 +37,15 @@ class BotConfigManager:
|
|||
"qwen": "dashscope",
|
||||
"aliyun-qwen": "dashscope",
|
||||
"moonshot": "kimi",
|
||||
# Xunfei Spark provides OpenAI-compatible endpoint.
|
||||
"xunfei": "openai",
|
||||
"iflytek": "openai",
|
||||
"xfyun": "openai",
|
||||
}
|
||||
provider_name = provider_alias.get(provider_name, provider_name)
|
||||
if provider_name == "openai" and raw_provider_name in {"xunfei", "iflytek", "xfyun"}:
|
||||
if model_name and "/" not in model_name:
|
||||
model_name = f"openai/{model_name}"
|
||||
|
||||
provider_cfg: Dict[str, Any] = {
|
||||
"apiKey": api_key,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import threading
|
|||
import time
|
||||
import codecs
|
||||
import base64
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
import json
|
||||
|
||||
|
|
@ -25,6 +26,87 @@ class BotDockerManager:
|
|||
self.active_monitors = {}
|
||||
self._last_delivery_error: Dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
def _build_http_probe_payload_b64(
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
body_json: Optional[Dict[str, Any]] = None,
|
||||
timeout_seconds: int = 10,
|
||||
) -> str:
|
||||
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,
|
||||
}
|
||||
return base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _http_probe_python_script() -> str:
|
||||
return (
|
||||
"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"
|
||||
)
|
||||
|
||||
def _run_http_probe_exec(self, container, payload_b64: str) -> Dict[str, Any]:
|
||||
py_script = self._http_probe_python_script()
|
||||
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 container"}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_resource_limits(
|
||||
cpu_cores: Optional[float],
|
||||
|
|
@ -231,74 +313,56 @@ class BotDockerManager:
|
|||
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"
|
||||
payload_b64 = self._build_http_probe_payload_b64(
|
||||
url=url,
|
||||
method=method,
|
||||
headers=headers,
|
||||
body_json=body_json,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
return self._run_http_probe_exec(container, payload_b64)
|
||||
|
||||
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 probe_http_via_temporary_container(
|
||||
self,
|
||||
image_tag: 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"}
|
||||
image = str(image_tag or self.base_image).strip() or self.base_image
|
||||
payload_b64 = self._build_http_probe_payload_b64(
|
||||
url=url,
|
||||
method=method,
|
||||
headers=headers,
|
||||
body_json=body_json,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
container = None
|
||||
try:
|
||||
container = self.client.containers.run(
|
||||
image=image,
|
||||
name=f"dashboard_probe_{uuid.uuid4().hex[:10]}",
|
||||
command=["sh", "-c", "sleep 45"],
|
||||
detach=True,
|
||||
tty=False,
|
||||
stdin_open=False,
|
||||
network_mode="bridge",
|
||||
)
|
||||
return self._run_http_probe_exec(container, payload_b64)
|
||||
except docker.errors.ImageNotFound:
|
||||
return {"ok": False, "message": f"Probe image not found: {image}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "message": f"Failed to run temporary probe container: {e}"}
|
||||
finally:
|
||||
if container is not None:
|
||||
try:
|
||||
container.remove(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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."""
|
||||
|
|
|
|||
148
backend/main.py
148
backend/main.py
|
|
@ -526,6 +526,8 @@ def _provider_defaults(provider: str) -> tuple[str, str]:
|
|||
return "openrouter", "https://openrouter.ai/api/v1"
|
||||
if p in {"dashscope", "aliyun", "qwen", "aliyun-qwen"}:
|
||||
return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
if p in {"xunfei", "iflytek", "xfyun"}:
|
||||
return "openai", "https://spark-api-open.xf-yun.com/v1"
|
||||
if p in {"kimi", "moonshot"}:
|
||||
return "kimi", "https://api.moonshot.cn/v1"
|
||||
if p in {"minimax"}:
|
||||
|
|
@ -1294,6 +1296,122 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
|
|||
}
|
||||
|
||||
|
||||
def _probe_mcp_server_for_start(cfg: Dict[str, Any], image_tag: str) -> 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-start-probe",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "dashboard-nanobot", "version": "0.1.4"},
|
||||
},
|
||||
}
|
||||
|
||||
def _with_body_preview(message: str, preview: Any) -> str:
|
||||
text = str(message or "").strip()
|
||||
body = " ".join(str(preview or "").strip().split())
|
||||
if not body:
|
||||
return text
|
||||
body = body[:240]
|
||||
return f"{text}: {body}" if text else body
|
||||
|
||||
if transport_type == "sse":
|
||||
probe_headers = dict(headers)
|
||||
probe_headers.setdefault("Accept", "text/event-stream")
|
||||
probe = docker_manager.probe_http_via_temporary_container(
|
||||
image_tag=image_tag,
|
||||
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()
|
||||
body_preview = probe.get("body_preview")
|
||||
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": "temp-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": "temp-container"}
|
||||
if isinstance(status_code, int) and status_code >= 500:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview("MCP SSE endpoint server error", body_preview), "content_type": content_type, "probe_from": "temp-container"}
|
||||
if not probe.get("ok"):
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview(message or "Failed to connect MCP SSE endpoint from temporary probe container", body_preview), "content_type": content_type, "probe_from": "temp-container"}
|
||||
if "text/event-stream" not in content_type.lower():
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview("Endpoint reachable, but content-type is not text/event-stream", body_preview), "content_type": content_type, "probe_from": "temp-container"}
|
||||
return {"ok": True, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint is reachable", "content_type": content_type, "probe_from": "temp-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_via_temporary_container(
|
||||
image_tag=image_tag,
|
||||
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()
|
||||
body_preview = probe.get("body_preview")
|
||||
if status_code in {401, 403}:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP endpoint", "probe_from": "temp-container"}
|
||||
if status_code == 404:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP endpoint not found", "probe_from": "temp-container"}
|
||||
if isinstance(status_code, int) and status_code >= 500:
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview("MCP endpoint server error", body_preview), "probe_from": "temp-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": "temp-container"}
|
||||
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview(message or "Unexpected response from MCP endpoint", body_preview), "probe_from": "temp-container"}
|
||||
|
||||
|
||||
def _preflight_mcp_servers_for_start(bot_id: str, image_tag: str) -> List[str]:
|
||||
config_data = _read_bot_config(bot_id)
|
||||
tools_cfg = config_data.get("tools") if isinstance(config_data, dict) else {}
|
||||
if not isinstance(tools_cfg, dict):
|
||||
return []
|
||||
mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
|
||||
failures: List[str] = []
|
||||
for server_name, cfg in mcp_servers.items():
|
||||
result = _probe_mcp_server_for_start(cfg, image_tag=image_tag)
|
||||
if result.get("ok"):
|
||||
continue
|
||||
message = str(result.get("message") or "MCP precheck failed").strip()
|
||||
probe_from = str(result.get("probe_from") or "temp-container").strip()
|
||||
failures.append(f"{server_name}: {message} [{probe_from}]")
|
||||
return failures
|
||||
|
||||
|
||||
def _parse_env_params(raw: Any) -> Dict[str, str]:
|
||||
return _normalize_env_params(raw)
|
||||
|
||||
|
|
@ -1370,6 +1488,9 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
|
|||
llm_model = str(agents_defaults.get("model") or "")
|
||||
api_key = str(provider_cfg.get("apiKey") or "").strip()
|
||||
api_base = str(provider_cfg.get("apiBase") or "").strip()
|
||||
api_base_lower = api_base.lower()
|
||||
if llm_provider == "openai" and ("spark-api-open.xf-yun.com" in api_base_lower or "xf-yun.com" in api_base_lower):
|
||||
llm_provider = "xunfei"
|
||||
|
||||
soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD)
|
||||
resources = _read_bot_resources(bot.id, config_data=config_data)
|
||||
|
|
@ -1963,7 +2084,7 @@ async def test_provider(payload: dict):
|
|||
normalized_provider, default_base = _provider_defaults(provider)
|
||||
base = (api_base or default_base).rstrip("/")
|
||||
|
||||
if normalized_provider not in {"openrouter", "dashscope", "kimi", "minimax"}:
|
||||
if normalized_provider not in {"openrouter", "dashscope", "kimi", "minimax", "openai", "deepseek"}:
|
||||
raise HTTPException(status_code=400, detail=f"provider not supported for test: {provider}")
|
||||
|
||||
if not base:
|
||||
|
|
@ -3481,13 +3602,34 @@ async def websocket_endpoint(websocket: WebSocket, bot_id: str):
|
|||
await websocket.close(code=4404, reason="Bot not found")
|
||||
return
|
||||
|
||||
await manager.connect(bot_id, websocket)
|
||||
connected = False
|
||||
try:
|
||||
await manager.connect(bot_id, websocket)
|
||||
connected = True
|
||||
except Exception as exc:
|
||||
logger.warning("websocket connect failed bot_id=%s detail=%s", bot_id, exc)
|
||||
try:
|
||||
await websocket.close(code=1011, reason="WebSocket accept failed")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
docker_manager.ensure_monitor(bot_id, docker_callback)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(bot_id, websocket)
|
||||
pass
|
||||
except RuntimeError as exc:
|
||||
# Client may drop before handshake settles; treat as benign disconnect.
|
||||
msg = str(exc or "").lower()
|
||||
if "need to call \"accept\" first" not in msg and "not connected" not in msg:
|
||||
logger.exception("websocket runtime error bot_id=%s", bot_id)
|
||||
except Exception:
|
||||
logger.exception("websocket unexpected error bot_id=%s", bot_id)
|
||||
finally:
|
||||
if connected:
|
||||
manager.disconnect(bot_id, websocket)
|
||||
|
||||
|
||||
def _main_server_options() -> tuple[str, int, bool]:
|
||||
|
|
|
|||
|
|
@ -424,6 +424,14 @@ const providerPresets: Record<string, { model: string; apiBase?: string; note: {
|
|||
en: 'MiniMax endpoint, model example: MiniMax-Text-01',
|
||||
},
|
||||
},
|
||||
xunfei: {
|
||||
model: 'astron-code-latest',
|
||||
apiBase: 'https://spark-api-open.xf-yun.com/v1',
|
||||
note: {
|
||||
'zh-cn': '讯飞星火(OpenAI 兼容)接口,模型示例 astron-code-latest',
|
||||
en: 'Xunfei Spark (OpenAI-compatible), model example: astron-code-latest',
|
||||
},
|
||||
},
|
||||
};
|
||||
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack', 'email'];
|
||||
const RUNTIME_STALE_MS = 45000;
|
||||
|
|
@ -1076,6 +1084,7 @@ export function BotDashboardModule({
|
|||
const [topicFeedLoadingMore, setTopicFeedLoadingMore] = useState(false);
|
||||
const [topicFeedError, setTopicFeedError] = useState('');
|
||||
const [topicFeedReadSavingById, setTopicFeedReadSavingById] = useState<Record<number, boolean>>({});
|
||||
const [topicFeedDeleteSavingById, setTopicFeedDeleteSavingById] = useState<Record<number, boolean>>({});
|
||||
const [topicFeedUnreadCount, setTopicFeedUnreadCount] = useState(0);
|
||||
const [topicDetailOpen, setTopicDetailOpen] = useState(false);
|
||||
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
|
||||
|
|
@ -2752,6 +2761,36 @@ export function BotDashboardModule({
|
|||
}
|
||||
};
|
||||
|
||||
const deleteTopicFeedItem = async (item: TopicFeedItem) => {
|
||||
if (!selectedBot) return;
|
||||
const targetId = Number(item?.id);
|
||||
if (!Number.isFinite(targetId) || targetId <= 0) return;
|
||||
const displayName = String(item?.title || item?.topic_key || targetId).trim() || String(targetId);
|
||||
const ok = await confirm({
|
||||
title: t.delete,
|
||||
message: isZh ? `确认删除这条主题消息?\n${displayName}` : `Delete this Topic item?\n${displayName}`,
|
||||
tone: 'warning',
|
||||
});
|
||||
if (!ok) return;
|
||||
setTopicFeedDeleteSavingById((prev) => ({ ...prev, [targetId]: true }));
|
||||
try {
|
||||
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/topic-items/${targetId}`);
|
||||
setTopicFeedItems((prev) => prev.filter((row) => Number(row.id) !== targetId));
|
||||
if (!Boolean(item?.is_read)) {
|
||||
setTopicFeedUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
notify(isZh ? '主题消息已删除。' : 'Topic item deleted.', { tone: 'success' });
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '删除主题消息失败。' : 'Failed to delete topic item.'), { tone: 'error' });
|
||||
} finally {
|
||||
setTopicFeedDeleteSavingById((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[targetId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadChannels = async (botId: string) => {
|
||||
if (!botId) return;
|
||||
const res = await axios.get<BotChannel[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
|
||||
|
|
@ -3625,8 +3664,8 @@ export function BotDashboardModule({
|
|||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
|
||||
updateBotStatus(id, 'RUNNING');
|
||||
await refresh();
|
||||
} catch {
|
||||
notify(t.startFail, { tone: 'error' });
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || t.startFail, { tone: 'error' });
|
||||
} finally {
|
||||
setOperatingBotId(null);
|
||||
setControlStateByBot((prev) => {
|
||||
|
|
@ -3656,8 +3695,8 @@ export function BotDashboardModule({
|
|||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
|
||||
updateBotStatus(id, 'RUNNING');
|
||||
await refresh();
|
||||
} catch {
|
||||
notify(t.restartFail, { tone: 'error' });
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || t.restartFail, { tone: 'error' });
|
||||
} finally {
|
||||
setOperatingBotId(null);
|
||||
setControlStateByBot((prev) => {
|
||||
|
|
@ -4280,6 +4319,7 @@ export function BotDashboardModule({
|
|||
setTopicFeedNextCursor(null);
|
||||
setTopicFeedError('');
|
||||
setTopicFeedReadSavingById({});
|
||||
setTopicFeedDeleteSavingById({});
|
||||
setTopicFeedUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
|
|
@ -4297,6 +4337,7 @@ export function BotDashboardModule({
|
|||
setTopicFeedNextCursor(null);
|
||||
setTopicFeedError('');
|
||||
setTopicFeedReadSavingById({});
|
||||
setTopicFeedDeleteSavingById({});
|
||||
let cancelled = false;
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
|
|
@ -4937,9 +4978,11 @@ export function BotDashboardModule({
|
|||
nextCursor={topicFeedNextCursor}
|
||||
error={topicFeedError}
|
||||
readSavingById={topicFeedReadSavingById}
|
||||
deleteSavingById={topicFeedDeleteSavingById}
|
||||
onTopicChange={setTopicFeedTopicKey}
|
||||
onRefresh={() => void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey })}
|
||||
onMarkRead={(itemId) => void markTopicFeedItemRead(itemId)}
|
||||
onDeleteItem={(item) => void deleteTopicFeedItem(item)}
|
||||
onLoadMore={() => void loadTopicFeed({ append: true, cursor: topicFeedNextCursor, topicKey: topicFeedTopicKey })}
|
||||
onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)}
|
||||
onOpenTopicSettings={() => {
|
||||
|
|
@ -5681,6 +5724,7 @@ export function BotDashboardModule({
|
|||
<option value="deepseek">deepseek</option>
|
||||
<option value="kimi">kimi (moonshot)</option>
|
||||
<option value="minimax">minimax</option>
|
||||
<option value="xunfei">xunfei (spark)</option>
|
||||
</LucentSelect>
|
||||
|
||||
<label className="field-label">{t.modelName}</label>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Eye, RefreshCw, X } from 'lucide-react';
|
||||
import { Eye, RefreshCw, Trash2, X } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
|
@ -40,9 +40,11 @@ interface TopicFeedPanelProps {
|
|||
nextCursor: number | null;
|
||||
error: string;
|
||||
readSavingById: Record<number, boolean>;
|
||||
deleteSavingById: Record<number, boolean>;
|
||||
onTopicChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
onMarkRead: (itemId: number) => void;
|
||||
onDeleteItem: (item: TopicFeedItem) => void;
|
||||
onLoadMore: () => void;
|
||||
onOpenWorkspacePath: (path: string) => void;
|
||||
onOpenTopicSettings?: () => void;
|
||||
|
|
@ -139,9 +141,11 @@ export function TopicFeedPanel({
|
|||
nextCursor,
|
||||
error,
|
||||
readSavingById,
|
||||
deleteSavingById,
|
||||
onTopicChange,
|
||||
onRefresh,
|
||||
onMarkRead,
|
||||
onDeleteItem,
|
||||
onLoadMore,
|
||||
onOpenWorkspacePath,
|
||||
onOpenTopicSettings,
|
||||
|
|
@ -289,15 +293,24 @@ export function TopicFeedPanel({
|
|||
</span>
|
||||
<div className="ops-topic-feed-item-actions">
|
||||
{rawContent ? (
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => setDetailState({ itemId, fallbackTitle: card.title, fallbackContent: rawContent })}
|
||||
tooltip={isZh ? '查看详情' : 'View details'}
|
||||
aria-label={isZh ? '查看详情' : 'View details'}
|
||||
>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => setDetailState({ itemId, fallbackTitle: card.title, fallbackContent: rawContent })}
|
||||
tooltip={isZh ? '查看详情' : 'View details'}
|
||||
aria-label={isZh ? '查看详情' : 'View details'}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</LucentIconButton>
|
||||
) : null}
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
disabled={Boolean(deleteSavingById[itemId])}
|
||||
onClick={() => onDeleteItem(item)}
|
||||
tooltip={isZh ? '删除消息' : 'Delete item'}
|
||||
aria-label={isZh ? '删除消息' : 'Delete item'}
|
||||
>
|
||||
{deleteSavingById[itemId] ? <RefreshCw size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
||||
</LucentIconButton>
|
||||
{unread ? (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
|
|
|
|||
|
|
@ -87,6 +87,14 @@ const providerPresets: Record<string, { model: string; note: { 'zh-cn': string;
|
|||
},
|
||||
apiBase: 'https://api.minimax.chat/v1',
|
||||
},
|
||||
xunfei: {
|
||||
model: 'astron-code-latest',
|
||||
note: {
|
||||
'zh-cn': '讯飞星火(OpenAI 兼容)接口,模型示例 astron-code-latest。',
|
||||
en: 'Xunfei Spark (OpenAI-compatible), model example: astron-code-latest.',
|
||||
},
|
||||
apiBase: 'https://spark-api-open.xf-yun.com/v1',
|
||||
},
|
||||
};
|
||||
|
||||
const initialForm = {
|
||||
|
|
@ -712,6 +720,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
<option value="deepseek">deepseek</option>
|
||||
<option value="kimi">kimi (moonshot)</option>
|
||||
<option value="minimax">minimax</option>
|
||||
<option value="xunfei">xunfei (spark)</option>
|
||||
</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 }))} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue