dashboard-nanobot/bot-images/dashboard.py

154 lines
5.8 KiB
Python

import asyncio
import json
from types import SimpleNamespace
from typing import Any
from aiohttp import web
from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
class DashboardChannel(BaseChannel):
"""
专门为管理面板设计的渠道。
它充当机器人内部总线 (Bus) 与宿主机面板之间的桥梁。
"""
name = "dashboard"
display_name = "Dashboard"
@classmethod
def default_config(cls) -> dict[str, Any]:
return {
"enabled": False,
"host": "0.0.0.0",
"port": 9000,
"allow_from": ["*"],
}
@classmethod
def _coerce_config(cls, config: Any) -> Any:
if config is None:
return SimpleNamespace(**cls.default_config())
if isinstance(config, dict):
merged = cls.default_config()
merged.update(config)
if "allowFrom" in config and "allow_from" not in config:
merged["allow_from"] = config.get("allowFrom")
return SimpleNamespace(**merged)
return config
def __init__(
self,
config: Any,
bus: MessageBus,
host: str | None = None,
port: int | None = None,
):
config_obj = self._coerce_config(config)
super().__init__(config_obj, bus)
self.host = host if host is not None else getattr(config_obj, "host", "0.0.0.0")
self.port = port if port is not None else getattr(config_obj, "port", 9000)
self.runner: web.AppRunner | None = None
self._chat_tasks: set[asyncio.Task[Any]] = set()
async def start(self) -> None:
"""启动 Dashboard HTTP 服务"""
app = web.Application()
app.router.add_post("/chat", self._handle_chat)
self.runner = web.AppRunner(app)
await self.runner.setup()
site = web.TCPSite(self.runner, self.host, self.port)
await site.start()
self._running = True
logger.info(f"🚀 Dashboard Channel 代理已上线,监听端口: {self.port}")
async def stop(self) -> None:
"""停止服务"""
if self.runner:
await self.runner.cleanup()
self.runner = None
for task in list(self._chat_tasks):
task.cancel()
self._chat_tasks.clear()
self._running = False
logger.info("Dashboard Channel 已下线")
async def send(self, message: OutboundMessage) -> None:
"""
从总线 (Bus) 接收机器人发出的所有消息,并结构化输出到 stdout。
"""
media = [str(v).strip().replace("\\", "/") for v in (message.media or []) if str(v).strip()]
if not message.content and not media:
return
# 核心:从元数据识别消息类型(进度更新 vs 最终回复)
metadata = message.metadata or {}
is_progress = metadata.get("_progress", False)
is_tool_hint = metadata.get("_tool_hint", False)
payload = {
"type": "BUS_EVENT",
"source": "dashboard_channel",
"is_progress": is_progress,
"is_tool": is_tool_hint,
"content": message.content,
"media": media,
}
usage = metadata.get("usage")
if isinstance(usage, dict):
payload["usage"] = usage
request_id = str(metadata.get("request_id") or "").strip()
if request_id:
payload["request_id"] = request_id
provider = str(metadata.get("provider") or "").strip()
if provider:
payload["provider"] = provider
model = str(metadata.get("model") or "").strip()
if model:
payload["model"] = model
# 使用 JSON 格式输出,方便面板后端精准解析,告别正则
print(f"\n__DASHBOARD_DATA_START__{json.dumps(payload, ensure_ascii=False)}__DASHBOARD_DATA_END__\n", flush=True)
async def _dispatch_chat_message(self, user_message: str, media: list[str]) -> None:
try:
await self._handle_message(
sender_id="user",
chat_id="direct",
content=user_message,
media=media,
)
except Exception as e:
logger.error(f"❌ Dashboard Channel 后台处理指令失败: {e}")
async def _handle_chat(self, request: web.Request) -> web.Response:
"""处理来自面板的指令入站"""
try:
data = await request.json()
user_message = str(data.get("message") or "").strip()
raw_media = data.get("media") or []
media = [str(v).strip().replace("\\", "/") for v in raw_media if str(v).strip()] if isinstance(raw_media, list) else []
if not user_message and not media:
return web.json_response({"status": "error", "reason": "empty message and media"}, status=400)
if not user_message:
user_message = "[attachment message]"
# 调试日志:打印收到的原始消息长度和前 20 个字符,确保中文未乱码
logger.info(f"📥 [Dashboard Channel] 收到指令 (len={len(user_message)}): {user_message[:20]}...")
# 先确认收件,避免面板投递请求被后续 LLM/工具处理链路拖到超时。
task = asyncio.create_task(self._dispatch_chat_message(user_message, media))
self._chat_tasks.add(task)
task.add_done_callback(self._chat_tasks.discard)
return web.json_response({"status": "ok"})
except Exception as e:
logger.error(f"❌ Dashboard Channel 接收指令失败: {e}")
return web.json_response({"status": "error", "reason": str(e)}, status=500)