feat(gateway): 双轨规范体系完善 — 新增 7 个模块 spec (dashboard/xmpp_bot/health_service/watchdog/chat_bridge/session_router/usage_collector) + 修复 3 个已有 spec + dashboard status_reason + Platform ejabberd 集成

This commit is contained in:
hmo
2026-07-17 00:33:44 +08:00
parent 79e8480f3c
commit c3d9f0e661
13 changed files with 916 additions and 101 deletions
+96 -9
View File
@@ -65,6 +65,22 @@ AGENTS_YAML = CONFIG_DIR / "agents.yaml"
PYTHON = os.environ.get("PYTHON", sys.executable)
SCRIPTS_DIR = _GATEWAY_DIR / "scripts"
# Linux agents on local 246 — use systemctl
LOCAL_LINUX_HOSTS = ("192.168.1.246", "127.0.0.1", "localhost")
def _is_local_linux(agent):
"""Check if agent is on the same Linux machine (manageable via systemctl)."""
return agent.get("platform") == "linux" and agent.get("host", "") in LOCAL_LINUX_HOSTS
def _agent_bot_service(agent):
"""Derive systemd service name from agent JID localpart (e.g. mohe → xmpp-mohe)."""
jid = agent.get("jid", "")
localpart = jid.split("@")[0] if "@" in jid else ""
return f"xmpp-{localpart}" if localpart else ""
# Auto-recovery: restart after this many consecutive offline checks
AUTO_RECOVER_THRESHOLD = 3
_offline_counter: dict[str, int] = {}
@@ -270,6 +286,7 @@ def api_agents():
host = agent.get("host", "")
# --- Presence ---
ejabberd_query_ok = online_jids is not None and len(online_jids) > 0
xmpp_in_ejabberd = jid in online_jids if online_jids else None
# --- Local process (Windows only) ---
@@ -283,6 +300,26 @@ def api_agents():
health = _xmpp_health()
xmpp_connected = health.get("xmpp_connected", False)
# --- Status reason ---
status_reason = ""
if xmpp_in_ejabberd is None and not online_jids:
status_reason = "ejabberd SSH 查询失败,MUC 降级检测也无结果"
elif xmpp_in_ejabberd is False:
status_reason = "XMPP JID 未登录 ejabberd"
elif xmpp_in_ejabberd is None:
status_reason = "无法检测 XMPP 状态"
elif xmpp_in_ejabberd is True:
status_reason = "OK"
if platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"):
if status_reason == "OK":
status_reason = "OK"
elif xmpp_connected:
status_reason = "Bot 运行中,但 XMPP 未登录 ejabberd"
elif local_pid:
status_reason = "Bot 进程存在但 XMPP 连接断开"
else:
status_reason = "Bot 进程不存在"
# --- Service status ---
services = []
for svc in agent.get("services", []):
@@ -338,7 +375,7 @@ def api_agents():
status = "unknown"
# --- Auto-recovery ---
if status == "offline" and platform == "windows":
if status == "offline" and (platform == "windows" or (platform == "linux" and host in LOCAL_LINUX_HOSTS)):
_offline_counter[agent_id] = _offline_counter.get(agent_id, 0) + 1
if _offline_counter[agent_id] >= AUTO_RECOVER_THRESHOLD:
log.warning(f"Auto-recovery: restarting {agent_id}")
@@ -354,13 +391,15 @@ def api_agents():
"platform": platform,
"host": host,
"status": status,
"status_reason": status_reason,
"xmpp_connected": xmpp_connected,
"pid": local_pid,
"last_message": None,
"message_count_5min": message_count,
"errors": 0,
"offline_checks": _offline_counter.get(agent_id, 0),
"restartable": platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"),
"restartable": (platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost")) or
(platform == "linux" and host in LOCAL_LINUX_HOSTS),
"services": services,
})
@@ -371,6 +410,20 @@ def _try_auto_recover(agent):
agent_id = agent["id"]
platform = agent.get("platform", "")
host = agent.get("host", "")
# Linux local → systemctl start
if platform == "linux" and host in LOCAL_LINUX_HOSTS:
svc = _agent_bot_service(agent)
if not svc:
return
try:
subprocess.run(["sudo", "systemctl", "start", svc], capture_output=True, timeout=15)
log.info(f"Auto-restarted {svc} for {agent_id}")
except Exception as e:
log.error(f"Auto-restart failed for {svc}: {e}")
return
# Windows local → subprocess
if platform != "windows" or host not in ("192.168.1.16", "127.0.0.1", "localhost"):
return
for svc in agent.get("services", []):
@@ -415,6 +468,25 @@ def api_agent_start(agent_id):
agent = next((a for a in agents_config if a["id"] == agent_id), None)
if not agent:
return jsonify({"ok": False, "error": "Agent not found"}), 404
# Linux local → systemctl start
if _is_local_linux(agent):
svc = _agent_bot_service(agent)
if not svc:
return jsonify({"ok": False, "error": "Cannot derive service name"}), 400
try:
r = subprocess.run(["sudo", "systemctl", "start", svc], capture_output=True, timeout=15)
if r.returncode == 0:
_offline_counter[agent_id] = 0
log.info(f"Started {svc} for {agent_id}")
return jsonify({"ok": True, "service": svc})
else:
err = r.stderr.decode().strip()
return jsonify({"ok": False, "error": err}), 500
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
# Windows local → subprocess
if agent.get("platform") != "windows":
return jsonify({"ok": False, "error": "Remote restart not supported yet"}), 400
started = []
@@ -445,6 +517,24 @@ def api_agent_stop(agent_id):
agent = next((a for a in agents_config if a["id"] == agent_id), None)
if not agent:
return jsonify({"ok": False, "error": "Agent not found"}), 404
# Linux local → systemctl stop
if _is_local_linux(agent):
svc = _agent_bot_service(agent)
if not svc:
return jsonify({"ok": False, "error": "Cannot derive service name"}), 400
try:
r = subprocess.run(["sudo", "systemctl", "stop", svc], capture_output=True, timeout=15)
if r.returncode == 0:
log.info(f"Stopped {svc} for {agent_id}")
return jsonify({"ok": True, "service": svc})
else:
err = r.stderr.decode().strip()
return jsonify({"ok": False, "error": err}), 500
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
# Windows local → taskkill
if agent.get("platform") != "windows":
return jsonify({"ok": False, "error": "Remote stop not supported yet"}), 400
processes = _get_local_processes()
@@ -472,14 +562,11 @@ def api_agent_restart(agent_id):
PLATFORM_SERVICES = [
{"id": "wechat_bridge", "name": "WeChat Bridge", "type": "ChannelBridge",
"desc": "bridges WeChat to mohe's hermes gateway",
"health_url": "http://192.168.1.16:5801/health"},
{"id": "api_proxy", "name": "API Proxy", "type": "APIRouter",
"desc": "proxies volcengine API with retry/fallback",
"host": "192.168.1.16", "port": 8787},
{"id": "wechat_bridge", "name": "莫荷微信 (Linux)", "type": "ChannelBridge",
"desc": "246 Docker wechatbot-webhook → webhook → Hermes Gateway",
"host": "192.168.1.246", "port": 3001},
{"id": "article_processor", "name": "文章抓取服务 (5810)", "type": "wechat-fetch",
"desc": "fetches wechat article content + OCR",
"desc": "fetches wechat article content + OCR (DrissionPage)",
"host": "192.168.1.16", "health_url": "http://192.168.1.16:5810/health"},
]
+12 -42
View File
@@ -1,50 +1,20 @@
{
"module": "api_proxy",
"version": "1.0",
"purpose": "API Proxy 运行在 Windows (192.168.1.16:8787),为 VolcEngine(火山引擎)等外部 API 提供代理转发、重试和降级能力。",
"ui_location": "Infrastructure tab → Platform → API Proxy",
"status": "deprecated",
"purpose": "API Proxy:8787)原用于代理 VolcEngine / opencode-go 等外部 API。已不再使用:火山模型已停用,OpenCode 连接 bug 已修复。代码保留但不再运行。",
"ui_location": "已从 Infrastructure → Platform 中移除",
"human_help": {
"title": "API ProxyAPI 代理 :8787",
"title": "API Proxy — ⛔ 已停用",
"description": [
"API Proxy 是一个轻量级反向代理,专门用于转发 VolcEngine(火山引擎)DeepSeek API 的请求。",
"主要功能:",
"• 请求代理——将内部请求转发到 VolcEngine 端点",
"• 自动重试——遇到网络抖动时自动重试",
"• 失败降级——主 API 不可用时自动切换到备用端点",
"简单说:它就是 AI API 的'中间人',保证请求能发出去、失败能重来。"
],
"usage": [
"监控状态:在 Infrastructure → Platform 下查看状态指示灯",
"如果状态为 stoppedAI 请求会直接失败",
"需要先确认 WeChat Bridge 正常运行"
],
"troubleshooting": [
"如果 API Proxy 停止:检查 Windows 上 api_proxy.py 进程是否在运行",
"排障命令:检查端口 8787 是否监听",
"重试策略:超时 5s,最多重试 2 次,失败后尝试备用端点"
],
"related": "WeChat Bridge(依赖 API Proxy 访问 AI)、Ejabberd(底层通信)"
},
"ai_spec": {
"apis": [
{"method": "POST", "path": "/v1/chat/completions", "returns": "OpenAI 兼容的 chat completion 响应"},
{"method": "GET", "path": "/health", "returns": "{ok, status}"}
],
"dependencies": [
"依赖 VolcEngine(火山引擎)API 可用",
"依赖 Windows 网络连通性"
],
"constraints": [
"仅用于 AI 模型 API 调用,不代理其他流量",
"重试策略:5s 超时 + 2 次重试 + 备用端点自动切换",
"端口 8787 在 Windows 上监听"
],
"related_files": [
"gateway/scripts/api_proxy.py — 实际运行脚本",
"gateway/scripts/templates/dashboard.html — fI() 状态展示",
"gateway/scripts/specs/api_proxy.json — 本 spec 文件"
"此服务已不再使用。原功能:",
"• 代理 VolcEngine DeepSeek API → 火山已停用",
"• 代理 opencode-go-new/old → 原 bug 已修复",
"代码保留在 gateway/scripts/api_proxy.py 但已不运行。"
]
},
"ai_spec": {
"status": "deprecated",
"note": "不再使用,dashboard 中已移除监控。如需恢复:重新加入 PLATFORM_SERVICES 并启动 api_proxy.py。"
}
}
+21 -19
View File
@@ -1,6 +1,6 @@
{
"module": "article_processor",
"version": "1.0",
"version": "1.1",
"purpose": "文章抓取服务运行在 Windows (192.168.1.16:5810),负责抓取微信公众号全文链接并转换为纯文本,支持 OCR 图片识别。",
"ui_location": "Infrastructure tab → Platform → 文章抓取服务 (5810)",
@@ -8,12 +8,11 @@
"title": "文章抓取服务 (:5810)",
"description": [
"这是整个系统的'阅读器'。当莫荷或小小莫需要阅读一个微信公众号文章链接时,由这个服务负责:",
"① 用 Chrome CDP 打开链接",
"② 等页面加载完成",
"③ 提取全文内容转为 Markdown",
"④ 如果包含图片,调用 GLM-OCR 识别图片文字",
"⑤ 返回结构化内容给请求方",
"依赖微信的 CDP session:如果微信未登录,抓取会失败('等待登录超时')。"
"① wechat_fetcher.pyDrissionPage 版)用本地 Chrome + 持久微信 profile 渲染页面",
"② 等页面加载完成,提取全文转为 Markdown",
"③ 如果包含图片,调用 SenseNova 视觉 API 识别图片文字",
"④ 返回结构化内容给请求方",
"依赖微信 mp.weixin.qq.com 的读者登录态(持久 profile 中已保存),不用每次扫码。"
],
"usage": [
"监控状态:Infrastructure → Platform 下查看状态和最近抓取信息",
@@ -22,32 +21,35 @@
],
"troubleshooting": [
"如果状态 stopped:检查 Windows 上 article_processor.py 进程",
"如果抓取失败返回 '等待登录超时':微信 CDP session 已过期,需要重新登录",
"如果图片 OCR 失败:检查 GLM-OCR 服务是否可用",
"如果抓取失败返回 '等待登录超时':Chrome profile 的读者会话已过期,运行 wechat_fetcher.py --login 重新扫码",
"如果图片 OCR 失败:检查 SenseNova API key 是否有效",
"日志位置:gateway/logs/article_processor.log"
],
"related": "WeChat Bridge(依赖文章抓取服务处理微信中的链接)"
"related": "莫荷微信(依赖文章抓取服务处理微信中的链接)"
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/health", "returns": "{ok, service, port, ocr_model, status}", "desc": "服务状态 + OCR 模型信息"},
{"method": "GET", "path": "/logs?lines=N", "returns": "{ok, lines[]}", "desc": "最近 N 行日志"},
{"method": "POST", "path": "/fetch", "body": "{\"url\":\"...\"}", "returns": "{ok, title, content, images[]}"}
{"method": "POST", "path": "/process", "body": "{\"url\":\"...\"}", "returns": "{ok, title, content, images[]}", "desc": "抓取并处理文章(主端点)"}
],
"dependencies": [
"依赖 Chrome CDP session(微信登录态",
"依赖 GLM-OCR 服务识别图片文字",
"依赖网络连通性访问微信公众号"
"依赖本地 Chrome + 持久 profileDrissionPage 驱动",
"依赖 mp.weixin.qq.com 读者登录态(持久 cookie",
"依赖 SenseNova 视觉 API 识别图片文字",
"依赖网络连通性访问微信公众号和服务端"
],
"constraints": [
"CDP session 过期后需要重新登录微信才能恢复",
"OCR 使用 GLM-OCR-8bit 模型",
"Linux 上访问 Windows 的此服务时需要 EasyTier VPN 连通"
"DrissionPage 驱动本地 Chrome(非无头,可见浏览器),微信会检测无头模式",
"Chrome profile 持久化在 wechat_fetcher 配置目录,会话过期需重新扫码",
"OCR 使用 SenseNovaWindows 自包含,不依赖小果/246",
"Linux 上访问此服务时需要 EasyTier VPN 连通"
],
"related_files": [
"gateway/scripts/article_processor.py — 实际运行脚本",
"gateway/scripts/templates/dashboard.html — fI() 状态展示(health_data",
"projects/self-growing-knowledge/scripts/article_processor.py — HTTP 服务端",
"projects/self-growing-knowledge/scripts/wechat_fetcher.py — DrissionPage 实际抓取",
"gateway/scripts/templates/dashboard.html — fI() 状态展示",
"gateway/scripts/specs/article_processor.json — 本 spec 文件"
]
}
+80
View File
@@ -0,0 +1,80 @@
{
"module": "chat_bridge",
"version": "2.0",
"purpose": "Chat Bridge — 直接 HTTP API 调用 OpenCode serve session,带模型 fallback + session 持久化。消息双写到 bridge_context.jsonl(即时上下文注入)和 opencode.dbsession_search 可回溯)。上下文窗口上限 200 条,超出用 session_search。支持 TUI 活跃 session 追踪。",
"ui_location": "无直接 UI — 被 xmpp_bot.py 调用",
"human_help": {
"title": "Chat Bridge — Session 桥接",
"description": [
"chat_bridge.py 是 XMPP 消息和 OpenCode serve session 之间的桥梁。它把群聊/私聊消息发送到 OpenCode API,获取 AI 回复,支持多模型 fallback。",
"消息双写:① bridge_context.jsonl — 立即注入到下次 API 调用的上下文 ② opencode.db — 持久化存储,session_search 可回溯。",
"TUI 追踪:当 xxm 在 TUI 中与用户对话,mark_active_tui_session() 记录活跃 session ID。bot 处理群消息时会注入 TUI 上下文,让 LLM 知道用户在 TUI 讨论了什么。",
"模型 fallback:主模型(deepseek-v4-pro)→ 超时/失败 → fallback 模型。失败不丢消息,降级处理。"
],
"usage": [
"1. 创建实例: bridge = SessionBridge(session_id='ses_xxm_xmpp')",
"2. 发送消息: bridge.chat(message, source='xmpp', sender='hmo') → AI 回复文本",
"3. TUI 追踪: mark_active_tui_session('ses_xxx') → bot 自动注入 TUI 上下文",
"4. 上下文注入: bridge 自动读取最近 200 条 session 消息 + TUI 活跃 session 内容",
"5. 日志: gateway/logs/bridge.log"
],
"troubleshooting": [
"如果 API 调用超时: 检查 OpenCode serve 是否在线 (localhost:4096),模型 API 是否可用",
"如果回复乱码: 检查上下文注入是否过长(上限200条),model fallback 是否降级到较差模型",
"如果 TUI 上下文不生效: 检查 _.active_tui_session.json 是否存在且未过期(1小时)"
]
},
"ai_spec": {
"apis": [
{"method": "POST", "path": "OpenCode API /v1/chat/completions", "returns": "AI 回复文本", "note": "直接 HTTP 调用 OpenCode serve — 非 OpenAI 兼容格式"},
{"method": "POST", "path": "OpenCode API fallback model", "returns": "AI 回复文本", "note": "主模型超时/失败后降级到备用模型"}
],
"dependencies": [
"OpenCode serve session (localhost:4096) — API 后端",
"opencode.db (SQLite) — session 持久化存储(~/.local/share/opencode/opencode.db",
"session_router.py — extract_session_context() + 命令协议",
"requests — HTTP 库(已经设了 no_proxy='*'",
"Model API — deepseek-v4-pro (主) + fallback model"
],
"architecture": {
"flow": "XMPP消息 → SessionBridge.chat() → ① 读取最近200条 context (SQLite) → ② 注入 TUI 活跃 session 内容 → ③ 构建 prompt → ④ POST OpenCode API → ⑤ 解析回复 → ⑥ 双写 (bridge_context.jsonl + opencode.db) → 返回文本",
"dual_write": "每条消息同时写入 bridge_context.jsonl 和 opencode.db — 前者即时上下文,后者持久可回溯",
"tui_tracking": "mark_active_tui_session() 写 JSON 文件 → get_active_tui_session() 读取 → 1h 自动过期",
"fallback": "主模型超时/失败 → 切换到 fallback 模型 — 不丢消息"
},
"constraints": [
"上下文窗口硬上限 200 条 — 防止 prompt 过长",
"TUI session 1h 自动过期 — 匹配 Hermes state_meta 模式",
"no_proxy='*' — 确保本地 API 调用不走代理",
"双写操作需要文件系统写入权限 — temp/ 和 opencode.db"
],
"must_not": [
"不要在 API 调用失败时返回空 — 至少返回错误描述",
"不要超过 200 条上下文 — 可能导致 prompt 截断",
"不要修改 opencode.db 的表结构 — 只读/只插入,不 DDL"
],
"related_modules": [
{"module": "xmpp_bot", "relation": "xmpp_bot 通过 SessionBridge 发送消息到 OpenCode API — chat_bridge 是 xmpp_bot 的 LLM 调用层"},
{"module": "session_router", "relation": "session_router 包装 SessionBridge,增加命令路由 + 多 channel 支持"}
],
"tests": [
{"id": "CB01", "name": "chat() 返回非空字符串", "endpoint": "bridge.chat('hello') → str"},
{"id": "CB02", "name": "TUI session 1h 过期", "endpoint": "设 timestamp 2h 前 → get_active_tui_session() 返回 None"},
{"id": "CB03", "name": "消息双写成功", "endpoint": "chat() 后检查 bridge_context.jsonl 和 opencode.db 都有新记录"}
],
"known_issues": [
"API 调用是同步阻塞的 — 不适用于高并发场景(但 XMPP 消息量小,问题不大)",
"双写数据可能不一致 — 如果仅一个写入成功(极少发生)"
],
"related_files": [
"gateway/scripts/chat_bridge.py — 本文件 (754行)",
"gateway/scripts/session_router.py — extract_session_context()",
"gateway/temp/bridge_context.jsonl — 即时上下文文件",
"~/.local/share/opencode/opencode.db — Session 持久化 DB",
"gateway/temp/.active_tui_session.json — TUI session 追踪文件",
"gateway/logs/bridge.log — 桥接日志"
]
}
}
+189
View File
@@ -0,0 +1,189 @@
{
"module": "dashboard",
"version": "2.0",
"purpose": "AgentsMeeting 管理门户 — Flask app on :5803。监控所有 Agent 的在线状态(跨平台 SSH ejabberdctl + xmpp_bot HTTP API + 本地进程检测)、提供 Web UItabs: Agents/Kanban/Infrastructure/开发原则)、管理 Agent 启停(systemctl on Linux, subprocess on Windows)、EasyTier/RDP 远程控制代理、OpenCode Go 用量监控、Kanban 看板、自修复流水线、git 提交历史等。",
"ui_location": "主 Dashboard UI (http://127.0.0.1:5803)",
"human_help": {
"title": "AgentsMeeting Dashboard — 管理门户",
"description": [
"AgentsMeeting Dashboard 是所有 Agentxxm/mohe/zhiwei/xiaoguo)的统一管理门户,部署在 192.168.1.246:5803,同时运行在 Windows 开发机 127.0.0.1:5803。",
"核心功能:Agent 在线状态监控(跨平台 SSH ejabberdctl + xmpp_bot HTTP API)、Agent 一键启停、系统健康仪表板(F 健康)、Kanban 看板、Infrastructure 基础设施控制(EasyTier VPN / RDP 远程桌面 / OpenCode Go 用量监控)。",
"状态检测三层:① 权威层 — SSH docker exec ejabberdctl connected_users(查谁连着 XMPP 服务器,跨平台可信)② 桥接层 — xmpp_bot /health API(查本地 bot 连接状态)③ 本地层 — 进程扫描(查 Windows 上 python 进程是否存在)。",
"自动恢复:连续 3 次检测到离线后,自动重启本地 Agent 服务(Windows: subprocess.Popen; Linux: systemctl start)。",
"双轨文档体系:每个功能模块在 specs/{module}.json 维护 human_help? 按钮—人类看)与 ai_spec(§ 按钮—AI 看),一源双出。",
"页面刷新:Dashboard 每 10 秒自动拉取 Agent 状态 + Infrastructure 数据;Kanban 每 15 秒独立刷新。"
],
"participants": [
{"name": "莫荷", "device": "Linux 192.168.1.246", "role": "生产环境 dashboard 部署机(systemd 管理) + 公网入口(ags.yoin.fun → nginx → :5803", "note": "AGENTSMEETING_ROOT 环境变量覆盖项目路径(/home/hmo/projects/AgentsMeeting"},
{"name": "小小莫", "device": "Windows 192.168.1.16", "role": "开发环境 dashboard + xmpp_bot HTTP bridge (:5802) + EasyTier/RDP 控制代理", "note": "dashboard 本地运行用于开发和测试"},
{"name": "ejabberd", "device": "246 Docker", "role": "XMPP 服务器 — 权威在线状态源", "note": "通过 SSH docker exec ejabberdctl connected_users 查询(Windows)或直接 docker execLinux"},
{"name": "老莫", "device": "任意浏览器", "role": "Dashboard 使用者", "note": "访问 http://192.168.1.246:5803 或 公网 https://ags.yoin.fun"}
],
"usage": [
"1. 浏览器打开 http://192.168.1.246:5803(内网)或 https://ags.yoin.fun(公网)",
"2. Agents tab:查看所有 Agent 在线状态 + 启停 + 日志。JD 显示在 XMPP 连接 → 绿点在线;显示'离线'→ 点 Restart 尝试恢复。",
"3. Kanban tab:查看团队任务看板(数据源 ~/.hermes/kanban.db),支持按状态/负责人筛选",
"4. Infrastructure tabPlatform 服务状态 + EasyTier VPN 开关 + RDP 远程桌面开关 + OpenCode Go 用量卡片",
"5. F 健康 tab:系统健康仪表板 — 服务概览 / 异常告警 / 全部服务分层展示(通信层/AI网关/辅助服务/定时任务)",
"6. G 规范 tab:开发规范文档(dev-spec.md+ git 历史",
"7. H 需求 tab:产品需求文档(PRD.md+ git 历史",
"8. K 测试 tab:自动测试报告(通过 tests_api.py 运行)",
"9. 点击模块旁边的 ? 按钮看人类使用说明,§ 按钮看 AI spec"
],
"troubleshooting": [
"如果 Dashboard 打不开:检查 5803 端口是否监听 — netstat -ano | findstr 5803Linux 检查 systemctl status dashboard",
"如果 Agent 全部显示离线:检查 ejabberd SSH 连接 — ssh hmo@192.168.1.246 'docker exec ejabberd ejabberdctl connected_users'",
"如果 EasyTier/RDP 按钮无反应:检查 xmpp_bot HTTP bridge (:5802) 是否在线 + _BRIDGE_KEY 是否匹配",
"如果 OpenCode Go Usage 卡片无数据:检查 usage_collector.py 脚本是否存在 + temp/usage_stats.json 是否生成",
"如果 Kanban 无任务:检查 ~/.hermes/kanban.db 是否存在且可读",
"如果 spec 弹窗空白:检查 specs/{module}.json 文件是否存在,格式是否正确",
"Dashboard 启动失败:检查是否已有实例运行(proc_guard PID 锁)→ 删除 temp/.dashboard.pid"
],
"related": "依赖 ejabberd (XMPP 服务器), xmpp_bot (HTTP bridge), usage_collector (用量采集), tests_api (测试运行器), specs/ (双轨文档体系)"
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/", "returns": "text/html — dashboard.html 前端页面", "note": "不返回 JSON,直接 serve 模板"},
{"method": "GET", "path": "/api/agents", "returns": "[{id, name, display_name, jid, platform, host, status, status_reason, xmpp_connected, pid, services, message_count_5min, errors, offline_checks, restartable}]", "note": "核心端点 — 三层状态检测 + 自动恢复计数器"},
{"method": "GET", "path": "/api/ejabberd", "returns": "{alive, xmpp_bot_connected, online_jids, bot_jid}", "note": "SSH docker exec ejabberdctl connected_users"},
{"method": "GET", "path": "/api/agents/<agent_id>/logs?lines=50", "returns": "{lines:[...]}", "note": "尾读日志文件(最新 50 条)"},
{"method": "POST", "path": "/api/agents/<agent_id>/start", "returns": "{ok, started:[...] | service:...}", "note": "启动 Agent — Linux: systemctl start xmpp-{jid}; Windows: subprocess.Popen"},
{"method": "POST", "path": "/api/agents/<agent_id>/stop", "returns": "{ok, stopped:[...] | service:...}", "note": "停止 Agent — Linux: systemctl stop; Windows: taskkill /f /pid"},
{"method": "POST", "path": "/api/agents/<agent_id>/restart", "returns": "组合 stop 后 2s sleep 再 start", "note": "先调 api_agent_stop → sleep 2s → api_agent_start"},
{"method": "GET", "path": "/api/platform", "returns": "[{id, name, type, desc, status, health_data}]", "note": "Platform 服务状态 — 微信桥接 + 文章处理 (5810) 健康检查"},
{"method": "GET", "path": "/api/service/5810/logs", "returns": "{lines:[...]}", "note": "代理 article-processor 的日志(从 Windows:5810 拉到 dashboard"},
{"method": "GET", "path": "/api/kanban", "returns": "{tasks:[{id, title, body, status, assignee, created_by, created_at}], db_exists, count}", "note": "读取 ~/.hermes/kanban.db SQLite 数据库"},
{"method": "GET", "path": "/api/git", "returns": "{ok, log:[...], dirty, branch, source, method}", "note": "最近 git 提交历史 + 分支 + 是否有未提交改动 — 用 git log 或降级到 reflog 解析"},
{"method": "GET", "path": "/api/services", "returns": "{services:[{name, port, health, watchdog, pid_lock, type, depends_on}], watched, total}", "note": "服务功能树 + watchdog 覆盖矩阵"},
{"method": "GET", "path": "/api/monitor", "returns": "{tier1, tier2, tasks:[{name, status}], platform}", "note": "Tier1/Tier2 最新检查结果 + 定时任务状态(schtasks on Windows, systemd timer/crontab on Linux"},
{"method": "GET", "path": "/api/metagrowth", "returns": "{total_fixes, completed_fixes, commit_count, meta_growth_enabled, description}", "note": "元成长回路(Phase 3 规划中)"},
{"method": "GET", "path": "/api/todos", "returns": "{todos:[...], executor_log_tail, count}", "note": "自修复流水线 TODO 执行记录(health_todos.jsonl+ executor 日志"},
{"method": "GET", "path": "/api/expected", "returns": "{expected:[...], actual:{name:status}}", "note": "期望状态 vs 实际状态矩阵 — 端口扫描 + 进程检测 + 定时任务检查"},
{"method": "GET", "path": "/api/easytier", "returns": "{status:{windows, 246}, virtual_ips, windows_running}", "proxied_to": "xmpp_bot /easytier action=status via _bridge_post"},
{"method": "POST", "path": "/api/easytier/toggle", "body": "{\"action\":\"start|stop\"}", "returns": "{ok, message}", "proxied_to": "xmpp_bot /easytier via _bridge_post"},
{"method": "GET", "path": "/api/rdp", "returns": "{rdp_enabled, tunnel_running, rdp_port, tunnel_port, public_endpoint}", "proxied_to": "xmpp_bot /rdp action=status via _bridge_post"},
{"method": "POST", "path": "/api/rdp/toggle", "body": "{\"action\":\"start|stop\"}", "returns": "{ok, message}", "proxied_to": "xmpp_bot /rdp via _bridge_post"},
{"method": "GET", "path": "/api/usage", "returns": "{ok, accounts:[{key_id, workspace_id, label, rolling, weekly, monthly, last_update, session_expired, error}], last_refresh_iso", "note": "直读 temp/usage_stats.json 缓存"},
{"method": "POST", "path": "/api/usage/refresh", "body": "{}", "returns": "{ok, message}", "note": "触发异步 subprocess 运行 usage_collector.py"},
{"method": "GET", "path": "/api/module-spec/<module>", "returns": "specs/{module}.json 内容", "note": "双轨文档 — 人类 help + AI spec 生成"},
{"method": "GET", "path": "/api/module-specs", "returns": "{specs:[{module, purpose, version}]}", "note": "列出所有可用 spec"},
{"method": "GET", "path": "/api/spec", "returns": "{content, path} — dev-spec.md 全文", "note": "G 规范 tab 内容"},
{"method": "GET", "path": "/api/spec/history", "returns": "{log:[...], count} — git log", "note": "dev-spec.md 的 git 提交历史"},
{"method": "GET", "path": "/api/prd", "returns": "{content, path} — PRD.md 全文", "note": "H 需求 tab 内容"},
{"method": "GET", "path": "/api/prd/history", "returns": "{log:[...], count} — git log", "note": "PRD.md 的 git 提交历史"},
{"method": "GET", "path": "/api/tests", "returns": "tests_api.run_tests() 返回的测试结果", "note": "K 测试 tab — 通过 from tests_api import run_tests 调用"},
{"method": "GET", "path": "/api/health", "returns": "{ok, time, xmpp_bot_alive, ejabberd_alive}", "note": "健康检查端点 — xmpp_bot + ejabberd 双重验证"}
],
"dependencies": [
"ejabberd on 192.168.1.246:5222 — 权威在线状态源(SSH docker exec ejabberdctl connected_users",
"xmpp_bot HTTP bridge on Windows 192.168.1.16:5802 — EasyTier/RDP 代理 + /health + /muc",
"_bridge_post() + _BRIDGE_KEY = 'xxm_bridge_8f3a2c' — 与 xmpp_bot HTTP bridge 的认证机制",
"config/agents.yaml — Agent 实例注册配置(JID/platform/host/services",
"proc_guard.py — PID 锁(防重复启动 dashboard 自身)",
"gateway/scripts/specs/*.json — 双轨文档体系(/api/module-spec/<module> 读取)",
"docs/dev-spec.md — G 规范 tab 内容",
"docs/PRD.md — H 需求 tab 内容",
"tests_api.py — K 测试 tabfrom tests_api import run_tests",
"~/.hermes/kanban.db — Kanban 看板数据(SQLite",
"gateway/temp/usage_stats.json — OpenCode Go 用量缓存(usage_collector.py 生成)",
"gateway/temp/health_todos.jsonl — 自修复流水线 TODO 记录",
"gateway/temp/last_health_check.json — Tier1 健康检查结果",
"gateway/temp/last_daily_health.json — Tier2 日报健康检查结果",
"gateway/logs/ — 日志聚合目录(_tail_logs 读取)",
"gateway/scripts/templates/dashboard.html — 前端 HTML(单文件,内嵌 CSS+JS",
"Python 3.10+ — Flask + yaml + sqlite3 + stdlib only(无额外 pip 依赖)",
"SSH 免密登录(Windows: ssh hmo@192.168.1.246 — ejabberdctl 查询需要",
"systemctlLinux — Agent 启停管理(systemctl start/stop xmpp-{jid}",
"nginx on 246 — 公网反向代理(ags.yoin.fun → :5803"
],
"architecture": {
"flow": "Dashboard(Flask :5803) → ① ejabberd SSH docker exec → ② xmpp_bot HTTP API :5802 → ③ 本地进程扫描 → 汇总 agent 状态 → JSON API → dashboard.html 前端渲染",
"detection_layers": [
"Layer 1 (权威): SSH docker exec ejabberdctl connected_users — 跨平台可信, 查到谁在 XMPP 登录",
"Layer 2 (桥接): xmpp_bot /health API — 本地 bot 连接状态(Windows专用)",
"Layer 3 (本地): 进程扫描 (ps aux | grep) — 进程存在性检测(Windows专用)",
"Fallback: xmpp_bot /muc API — MUC 参与者列表(备选,R01 已知不稳定)"
],
"auto_recovery": {
"mechanism": "AUTO_RECOVER_THRESHOLD=3 — 连续 3 次检测离线后自动重启",
"linux": "sudo systemctl start xmpp-{jid}",
"windows": "subprocess.Popen(python script_path) 非阻塞启动",
"counter_reset": "一旦状态变回 online_offline_counter[agent_id] = 0"
},
"bridge_proxy": {
"mechanism": "_bridge_post(path, payload) → POST to XMPP_BRIDGE_URL with X-Api-Key → xmpp_bot on Windows",
"used_by": "EasyTier toggle (/api/easytier/toggle), RDP toggle (/api/rdp/toggle)"
},
"pid_lock": "guard('dashboard') — 从 proc_guard 导入,防重复启动。锁文件在 gateway/temp/.dashboard.pid",
"usage_auto_timer": "后台线程每 5 分钟自动触发 usage_collector.py,保持用量数据新鲜 + OpenCode Go cookies 保活",
"frontend": "dashboard.html 单文件 SPA — CSS 变量暗色主题 + 原生 JS (无框架) + tabs 切换 + 10s 自动轮询 + spec 弹窗 (?/§ 按钮)"
},
"constraints": [
"dashboard.html 是单文件 SPA — 不要引入 npm/webpack/vite 等前端框架,保持零依赖",
"后端使用 stdlib only — Flask + yaml + sqlite3,不要加额外的 pip 包(除非有充分理由)",
"PID 锁机制 — 启动前调用 guard('dashboard'),退出时自动清理锁文件",
"跨平台兼容 — sys.platform 检测 win32/linuxSSH vs 直接 docker exec 分支处理",
"AGENTSMEETING_ROOT 环境变量 — 在 Linux systemd 部署中覆盖项目路径(/home/hmo/projects/AgentsMeeting",
"XMPP_BRIDGE_URL 默认 http://192.168.1.16:5802 — 可通过环境变量覆盖",
"EJABBERD_HOST 默认 192.168.1.246 — 可通过环境变量覆盖",
"不要硬编码 workspace ID 或 API keys — 都从 config/accounts.json 或环境变量读取",
"SSH 免密登录是 ejabberdctl 查询的前提 — BatchMode=yes, 不提示密码",
"usage auto-timer 初始延迟 15s(等 dashboard 稳定后再启动)"
],
"must_not": [
"不要修改 wechat_agent.py 的 SCRIPT_NAMES 映射而不更新 auto-recovery 逻辑",
"不要在 status_reason 为空时显示空 div — 前端跳过 'OK' 和空字符串",
"不要在前端硬编码 agent 信息 — 全部从 /api/agents 动态加载",
"不要把 _BRIDGE_KEY 提交到 git — 虽然已在 dashboard.py 中写死,部署前应改为环境变量",
"不要在 EasyTier/RDP toggle 端点中直接操作 VPN/RDP — 必须通过 _bridge_post 代理到 xmpp_bot",
"不要让 dashboard 的 proc_guard 与 xmpp_bot 的锁冲突 — 各用各的 guard name"
],
"related_modules": [
{"module": "agents", "relation": "Agent 注册与生命周期管理 — dashboard 通过 /api/agents 展示 agent 状态,通过 /api/agents/<id>/start|stop|restart 管理启停"},
{"module": "api_proxy", "relation": "API 代理服务 (:8787) 与 dashboard 共享 Agent 概念但独立运行"},
{"module": "easytier", "relation": "EasyTier VPN 状态通过 dashboard 的 _bridge_post 代理到 xmpp_bot — 共享相同的 bridge key 和 HTTP 端口"},
{"module": "rdp", "relation": "RDP 远程桌面状态通过 dashboard 的 _bridge_post 代理 — 与 EasyTier 共用同一个 xmpp_bot HTTP bridge"},
{"module": "usage_monitor", "relation": "OpenCode Go 用量监控 — dashboard 通过 /api/usage 读取 usage_collector 生成的缓存 + /api/usage/refresh 触发采集"},
{"module": "health", "relation": "F 健康 tab — dashboard 通过 /api/services + /api/expected + /api/monitor 三个端点汇总健康数据"},
{"module": "kanban", "relation": "Kanban 看板 — dashboard 通过读取 ~/.hermes/kanban.db SQLite 数据库展示任务"},
{"module": "tests", "relation": "K 测试 tab — dashboard 通过 from tests_api import run_tests 调用测试套件"},
{"module": "dev_spec", "relation": "G 规范 tab — dashboard 通过 /api/spec 读取 dev-spec.md 全文展示"},
{"module": "prd", "relation": "H 需求 tab — dashboard 通过 /api/prd 读取 PRD.md 全文展示"}
],
"tests": [
{"id": "DB01", "name": "GET / 返回 dashboard.html", "endpoint": "GET / — 期望返回 text/html 200"},
{"id": "DB02", "name": "GET /api/agents 返回 agent 数组", "endpoint": "GET /api/agents — 期望返回数组,每项含 id/status/status_reason/services"},
{"id": "DB03", "name": "POST /api/agents/<id>/restart 返回 ok", "endpoint": "POST restart → 期望 ok=true 或符合预期错误"},
{"id": "DB04", "name": "GET /api/health 返回 xmpp_bot_alive + ejabberd_alive", "endpoint": "GET /api/health → 检查字段存在"},
{"id": "DB05", "name": "GET /api/module-spec/dashboard 返回本 spec", "endpoint": "GET /api/module-spec/dashboard → 期望 module='dashboard'"},
{"id": "DB06", "name": "GET /api/module-specs 返回包含 dashboard 的列表", "endpoint": "GET /api/module-specs → specs 数组中应有 module='dashboard'"},
{"id": "DB07", "name": "EasyTier proxy 在 xmpp_bot 不可达时正确返回错误", "endpoint": "GET /api/easytier → 即使 bridge 不可达也应返回 JSON(不 crash"},
{"id": "DB08", "name": "RDP proxy 在 xmpp_bot 不可达时正确返回错误", "endpoint": "GET /api/rdp → 同上,graceful degradation"},
{"id": "DB09", "name": "Dashboard 不因单个 agent 检测失败而崩溃", "endpoint": "GET /api/agents → 如果 ejabberd SSH 失败,应降级到 muc_participants 或 unknown 状态"}
],
"known_issues": [
"R01: MUC join 超时 — MUC 参与者列表不可靠,但 dashboard 只用它做 fallback,所以影响有限",
"Windows 端 SSH 依赖免密登录 — 如果 key 丢失或权限不对,ejabberdctl 查询会失败,导致所有 agent 显示 unknown",
"EasyTier/RDP 代理依赖 xmpp_bot 在 Windows 上运行 — bot 挂了这些按钮就失效",
"dashboard.html 是单文件 ~490 行 JS — 未来扩展可能需要拆分但暂不考虑(保持部署简单)",
"auto-recovery 仅支持 Windows 本地进程和 Linux 本地 systemd — Mac/远程 Linux 暂不支持自动恢复",
"usage monitor 在 Linux 246 上运行时需要 CDP proxy — 如果 Chrome 没有以 --remote-debugging-port=9222 启动,采集会失败",
"Kanban 看板直接读 ~/.hermes/kanban.db — 如果 Hermes 升级后 DB schema 变更,需要同步更新 SELECT 语句"
],
"related_files": [
"gateway/scripts/dashboard.py — 本文件(Flask 后端,1377行)",
"gateway/scripts/templates/dashboard.html — 前端 UI(单文件 SPA490行)",
"gateway/scripts/specs/dashboard.json — 本 spec 文件",
"gateway/scripts/proc_guard.py — PID 锁(dashboard 启动时调用 guard('dashboard')",
"gateway/scripts/tests_api.py — K 测试 tab 调用的测试套件",
"gateway/scripts/usage_collector.py — OpenCode Go 用量采集脚本(dashboard auto-timer 触发)",
"config/agents.yaml — Agent 实例注册配置",
"docs/dev-spec.md — G 规范 tab 文档",
"docs/PRD.md — H 需求 tab 文档",
"~/.hermes/kanban.db — Kanban 看板数据",
"gateway/temp/ — 运行时临时文件(health_todos.jsonl, last_health_check.json, usage_stats.json, .dashboard.pid 等)"
]
}
}
+88
View File
@@ -0,0 +1,88 @@
{
"module": "health_service",
"version": "2.0",
"purpose": "Windows Service — xxm Bot Health Monitor。替代 Task Scheduler 方式的 health_check_xxm.py,通过 HTTP health check (GET :5807/health) 监控 xmpp bot 状态,自动重启失败的 bot。作为 Windows 服务运行(services.msc 管理),零控制台窗口。",
"ui_location": "F 健康 tab → 定时任务区域 → agents-health-check",
"human_help": {
"title": "xxm Bot Health Service — Windows 健康监控",
"description": [
"health_service.py 是一个 Windows Service,替代旧的 Task Scheduler 调度方式。它通过 HTTP 健康检查监控 xmpp bot(端口 5807),当检测到 bot 不健康时自动重启它。",
"与旧 health_check_xxm.py 的区别:① 零控制台窗口(纯 Windows Service)② 无 WMIC/tasklist 轮询,纯 HTTP 检查 ③ 通过 services.msc 统一管理 ④ PID 锁防重复。",
"健康检查:每 30s 向 127.0.0.1:5807/health 发 GET 请求,检查 xmpp_connected 字段。如果请求失败或 xmpp_connected=false,触发重启。",
"重启流程:用存储的 PID 杀旧进程 → 等 3s → subprocess.Popen 启动新 bot → 等 5s → 验证健康。"
],
"usage": [
"1. 安装: python health_service.py install(需要 Admin 权限)",
"2. 启动: python health_service.py start 或 net start xxm-health",
"3. 停止: python health_service.py stop 或 net stop xxm-health",
"4. 管理: services.msc → 找到 'AgentsMeeting xxm Health Service' → 启停/禁用",
"5. 卸载: python health_service.py remove",
"6. 日志位置: gateway/logs/health_service.log"
],
"troubleshooting": [
"如果服务无法启动: 检查是否已有实例在运行(proc_guard PID 锁)→ 删除 temp/.health_service_xxm.pid",
"如果 bot 反复重启: 检查 5807 端口健康端点是否可访问 — curl http://127.0.0.1:5807/health?key=xxm_bridge_8f3a2c",
"如果服务安装失败: 确保以 Admin 身份运行 + win32serviceutil 已安装 (pywin32)",
"如果日志为空: 检查 gateway/logs/health_service.log 文件权限",
"端口从 5802 改为 5807: xmpp_bot HTTP bridge 独立运行在 5802xxm bot health 在 5807"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "http://127.0.0.1:5807/health?key=<BRIDGE_KEY>", "returns": "JSON — xmpp_connected 字段", "note": "被监控端 — xmpp_agent_core.py 的 /health 端点"},
{"method": "N/A (Windows Service CLI)", "path": "python health_service.py install|start|stop|remove", "returns": "Service 安装/启停", "note": "win32serviceutil.HandleCommandLine"}
],
"dependencies": [
"pywin32 (win32serviceutil, win32service, win32event, servicemanager) — Windows Service 框架",
"Python 3.10 (C:\\Users\\hmo\\AppData\\Local\\Programs\\Python\\Python310\\python.exe)",
"xmpp_agent_core.py — 被监控的 bot 脚本 (启动参数: --agent xxm)",
"proc_guard.py — PID 锁 (guard('health_service_xxm'))",
"xmpp_bot HTTP bridge (:5807) — health 端点 (通过 xmpp_agent_core.py 的 /health)"
],
"architecture": {
"flow": "Windows Service 启动 → proc_guard 锁 → 初始健康检查 → 30s 循环: WaitForSingleObject(30s) → HTTP 检查 5807/health → 不健康则 _restart_bot()",
"restart_flow": "taskkill /f /pid <旧PID> → sleep 3s → subprocess.Popen(python xmpp_agent_core.py --agent xxm) → sleep 5s → 验证健康",
"health_check": "urllib GET:5807/health?key=<BRIDGE_KEY> → 解析 JSON → 检查 xmpp_connected 字段",
"pid_lock": "guard('health_service_xxm') — 确保只有一个 health_service 实例在运行",
"port_change": "5802 → 5807 — xmpp_bot HTTP bridge 独立占用 5802xxm bot health 移到 5807"
},
"constraints": [
"必须是 Windows Service — 不能在 Linux 上运行(依赖 pywin32",
"必须以 Admin 权限安装/启动 — win32serviceutil 需要",
"PID 锁名称固定为 'health_service_xxm' — 不要与 xmpp_bot 的 'xmpp_bot' 锁冲突",
"BRIDGE_KEY 与 dashboard 的 _BRIDGE_KEY 必须一致",
"bot 启动使用 CREATE_NO_WINDOW flag — 不弹出控制台窗口",
"stdout/stderr 重定向到 DEVNULL — 所有输出通过 logging 记录"
],
"must_not": [
"不要用 tasklist/WMIC 轮询进程 — 改用 HTTP health check 替代",
"不要修改 _BRIDGE_API_KEY 而不同步到 dashboard.py 和 xmpp_agent_core.py",
"不要让 PID 锁与 xmpp_bot 或 watchdog 冲突 — 各用各的 guard name"
],
"related_modules": [
{"module": "dashboard", "relation": "dashboard 的 F 健康 tab 检查 health_service 的定时任务状态"},
{"module": "health", "relation": "health_service 是 F 健康 tab 中 'agents-health-check' 定时任务的实际实现"},
{"module": "xmpp_bot", "relation": "health_service 监控的 xmpp_bot 是 Windows XMPP 通信的核心"}
],
"tests": [
{"id": "HS01", "name": "健康端点可访问", "endpoint": "GET :5807/health → xmpp_connected 字段存在"},
{"id": "HS02", "name": "服务安装成功", "endpoint": "python health_service.py install → 在 services.msc 中可见"},
{"id": "HS03", "name": "PID 锁防重复", "endpoint": "两次启动 health_service → 第二次应被 proc_guard 拦截"},
{"id": "HS04", "name": "不健康时自动重启", "endpoint": "手动杀 bot 进程 → 30s 内 health_service 应检测到并重启"}
],
"known_issues": [
"pywin32 依赖 — Python 3.10 需预先安装 pip install pywin32",
"端口 5807 vs 5802 混乱 — xmpp_bot.py (独立 slixmpp bot) 用 5802xmpp_agent_core.py (统一 core) 的 /health 在 5807",
"仅 Windows 可用 — Linux 上 bot 健康由 systemd 管理的 watchdog 负责"
],
"related_files": [
"gateway/scripts/health_service.py — 本文件 (187行)",
"gateway/scripts/proc_guard.py — PID 锁",
"gateway/scripts/xmpp_agent_core.py — 被监控的 bot 核心 (项目根目录)",
"gateway/scripts/specs/health_service.json — 本 spec",
"gateway/logs/health_service.log — 服务日志"
]
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"module": "session_router",
"version": "2.0",
"purpose": "Session Router — 多通道 session 路由 + 命令循环。为 XMPP/VC/微信等通道提供类 TUI 的 session 体验:auto 模式自动绑定最近活跃 session、NL 切换 session'切换到xxx')、LLM 驱动的 ##command## 系统。",
"ui_location": "无直接 UI — 被 chat_bridge.py 和 xmpp_bot.py 使用",
"human_help": {
"title": "Session Router — 多通道 Session 路由",
"description": [
"session_router.py 是一个消息路由器,让多个通信通道(XMPP/VC/微信)能像 TUI 一样使用 OpenCode session:自动绑定活跃 session、自然语言切换 session、支持 LLM 命令系统。",
"命令系统:LLM 回复中的 ##command## 被拦截执行而非直接回复。支持 ##list_sessions##(列 session)、##switch_session:ID##(切换 session)、##select_session:1-5##(用户选择 session)等。",
"选择模式:当用户用自然语言说'切换到xxx session'时,router 列出匹配的 session 让用户选择,选择超时 120s。",
"消息循环(command loop):LLM 回复后检查是否包含 ##command## → 如果包含,执行命令 → 结果追加到上下文 → 重新调用 LLM → 直到无命令或达到 MAX_LOOPS=10。"
],
"usage": [
"1. 创建: router = SessionRouter(bridge=SessionBridge(...), default_session='ses_xxm_xmpp')",
"2. 路由消息: router.route(channel='xmpp', sender='hmo', message='你好') → AI 回复",
"3. NL 切换: 用户在消息中说'切换到xxx session' → router 匹配并切换",
"4. 命令: LLM 回复中包含 ##list_sessions## → router 拦截执行而非返回用户",
"5. 选择超时: SELECTION_TIMEOUT = 120s — 超时后选择模式自动取消"
],
"troubleshooting": [
"如果命令循环死循环: MAX_LOOPS=10 限制 — 超过后直接返回最后结果",
"如果 session 切换失败: 检查 opencode.db 是否可读 — DB_PATH = ~/.local/share/opencode/opencode.db",
"如果选择模式卡住: 120s 超时后自动取消"
]
},
"ai_spec": {
"apis": [
{"method": "N/A (内部路由)", "path": "router.route(channel, sender, message)", "returns": "AI 回复文本", "note": "主入口 — 处理消息 → 调用 LLM → 解析命令 → 循环直到无命令"},
{"method": "N/A (工具函数)", "path": "extract_session_context(session_id, limit=200)", "returns": "formatted context string", "note": "从 opencode.db 提取最近 N 条会话上下文"}
],
"dependencies": [
"opencode.db (SQLite) — ~/.local/share/opencode/opencode.db",
"chat_bridge.py — SessionBridge (LLM 调用)",
"SQLite3 stdlib — 数据库读取",
"threading — 选择模式超时管理"
],
"architecture": {
"flow": "消息 → ① 检查选择模式(pending user choice) → ② extract_session_context (200条) → ③ 构建 prompt → ④ SessionBridge.chat() → ⑤ 解析回复查找 ##command## → ⑥ 有命令 → 执行命令 → 结果追加 → 回到④ → 无命令 → 返回回复",
"command_loop": "最多 10 次循环 — 每次 LLM 回复后检查 CMD_RE regex (##\\w+(?::[^#\\n]*)?##) → 拦截执行 → 追加结果 → 重新调用 LLM",
"selection_mode": "用户请求切换 session → 列出匹配 session → 设置 selection_timer (120s) → 等待用户选择 → 超时自动取消",
"context_extraction": "SQLite 查询 messages 表 → 按 role 格式化 ('用户:' / '小小莫:') → 最近 200 条"
},
"constraints": [
"MAX_LOOPS = 10 — 防止命令循环无限执行",
"SELECTION_TIMEOUT = 120s — 用户选择超时",
"RECENT_MSG_LIMIT = 200 — 上下文上限",
"SESSION_LIST_LIMIT = 15 — 列表显示最多 15 条",
"DB_PATH 依赖于系统 opencode 安装路径 — ~/.local/share/opencode/opencode.db"
],
"must_not": [
"不要在命令循环中无限 loop — MAX_LOOPS 是硬限制",
"不要修改 opencode.db 的 messages 表结构 — 只读查询",
"不要合并不同 channel 的消息 — 每条消息标记 source 标签"
],
"related_modules": [
{"module": "chat_bridge", "relation": "session_router 包装 SessionBridge,增加命令路由层"},
{"module": "xmpp_bot", "relation": "xmpp_bot 使用 SessionRouter 进行消息路由和命令处理"}
],
"tests": [
{"id": "SR01", "name": "extract_session_context 返回格式化上下文", "endpoint": "extract_session_context('ses_test') → '用户: xxx\\n小小莫: xxx'"},
{"id": "SR02", "name": "命令循环最多 10 次", "endpoint": "注入始终返回 ##dummy## 的 LLM → 10 次后停止"},
{"id": "SR03", "name": "选择模式 120s 超时", "endpoint": "设置选择模式 → 等 130s → 选择自动取消"}
],
"known_issues": [
"命令循环依赖 LLM 回复格式 — 如果 LLM 返回非标准 ##command## 格式,会直接当作回复",
"openCode.db schema 可能会随 opencode 升级变化 — 如果 messages 表结构变了,extract_session_context 需要更新"
],
"related_files": [
"gateway/scripts/session_router.py — 本文件 (635行)",
"gateway/scripts/chat_bridge.py — SessionBridge 实例",
"~/.local/share/opencode/opencode.db — Session 数据源"
]
}
}
@@ -0,0 +1,96 @@
{
"module": "usage_collector",
"version": "3.0",
"purpose": "OpenCode Go 订阅用量采集脚本 — Hybrid v3.0。混合采集:主模式 HTTP + stored cookies4 个账号同时采集),辅助模式 CDP(仅 Chrome 当前登录账号补充/验证)。从 opencode.ai SSR HTML 解析 rolling/weekly/monthly 用量百分比和倒计时,写入 temp/usage_stats.json。支持 --daemon 持续采集(每 5 分钟)。",
"ui_location": "Infrastructure tab → OpenCode Go Usage section(通过 dashboard.py 的 /api/usage 和 /api/usage/refresh",
"human_help": {
"title": "OpenCode Go Usage Collector — 用量采集脚本",
"description": [
"usage_collector.py 是一个 Python 脚本,从 opencode.ai 的 SSR HTML 中解析 4 个订阅账号的用量数据(5h rolling / weekly / monthly),写入 JSON 缓存供 dashboard 展示。",
"混合采集模式:① 主模式 — HTTP + stored cookies,可同时采集所有账号(无需 Chrome 登录态切换),快但需要预先提取 cookies ② 辅助模式 — CDP 通过 Chrome tab 获取,只能采集当前登录账号,慢但不需要预先提取 cookieshttpOnly cookies 由浏览器自动携带)。",
"Cookie 提取:一次性操作 — python extract_cookies.py key1 --workspace-id wrk_XXX → cookies/key1.json。每个账号登录后执行一次。",
"SSR 解析:主模式用 per-metric regex 解析 HTML 中的 usagePercent + resetInSec(处理嵌套 $R[N]={} 格式);CDP 辅助模式通过 CDP /eval 执行 JS fetch 获取完整 HTML 并解析。"
],
"usage": [
"1. 首次配置: 逐个登录 4 个 opencode 账号 → python extract_cookies.py key{N} --workspace-id wrk_XXX",
"2. 采集: python usage_collector.py(一次性采集全部账号)",
"3. 守护: python usage_collector.py --daemon(每 5 分钟自动采集)",
"4. 仅 CDP: python usage_collector.py --cdp-only(仅采集 Chrome 当前登录账号)",
"5. 打印结果: python usage_collector.py --print",
"6. Dashboard: GET /api/usage(读缓存)/ POST /api/usage/refresh(触发采集)"
],
"troubleshooting": [
"如果某账号采集失败: 检查 cookies/{key_id}.json 是否存在且有效 — Session 可能已过期",
"如果 CDP 模式不可用: 确认 Chrome 以 --remote-debugging-port=9222 启动 + cdp-proxy.mjs (3456) 在运行",
"如果 SSR 解析失败: opencode.ai 的 SSR hydration 格式可能已变更 — 需要更新 regex",
"如果 usage_stats.json 为空: 检查脚本是否有文件写入权限 + TEMP_DIR 是否存在",
"如果采集结果过旧: 检查 --daemon 是否在运行,或手动触发 Refresh"
]
},
"ai_spec": {
"apis": [
{"method": "N/A (CLI)", "path": "python usage_collector.py [--daemon|--cdp-only|--print]", "returns": "写 temp/usage_stats.json + stdout 日志", "note": "CLI 脚本,非 HTTP API"},
{"method": "CDP call", "path": "localhost:3456/targets + /eval", "returns": "SSR HTML (CDP mode)", "note": "通过 cdp-proxy.mjs 与 Chrome 通信"},
{"method": "HTTP GET", "path": "https://opencode.ai/workspace/{ws_id}/go", "returns": "SSR HTML (HTTP mode)", "note": "带 Cookie header (从 cookies/{key_id}.json 读取)"}
],
"dependencies": [
"usage_monitor/accounts.json — 账号 workspace ID × key_id × label 配置 (.gitignore)",
"usage_monitor/cookies/{key_id}.json — 存储的 cookies (httpOnly auth cookie, .gitignore)",
"cdp-proxy.mjs (localhost:3456) — Chrome 远程调试代理 (CDP 辅助模式)",
"Chrome (Windows) with --remote-debugging-port=9222 — 浏览器 + 登录态",
"Python 3.10+ (stdlib only: urllib, json, re, logging, http.cookiejar)",
"temp/usage_stats.json — 输出缓存 (dashboard.py 直接读取)",
"opencode.ai API — 无公开 API,唯一数据来源是 SSR hydration script"
],
"architecture": {
"flow": "主模式: 遍历 accounts.json → 读取 cookies/{key_id}.json → HTTP GET opencode.ai workspace page → 正则解析 SSR HTML → CDP 辅助: Chrome tab → fetch(location.href) → 解析 → 合并结果 → 写 usage_stats.json",
"dual_collection": "主模式(HTTP cookies)可同时采所有账号 / CDP 辅助(Chrome browser)仅当前登录账号 / 两种结果合并: CDP 优先,HTTP 补充",
"regex_parsing": "per-metric regex 匹配 rollingUsage:$R[N]={status,usagePercent,resetInSec} — 处理嵌套 $R[N]={} 而非简单 blob regex",
"daemon_mode": "每 5 分钟自动采集 — time.sleep(300) 循环",
"cookie_rotation": "不同账号有独立 cookie 文件 — cookies/key1.json, cookies/key2.json, ... — cookie 文件通过 extract_cookies.py 生成"
},
"constraints": [
"accounts.json 不可提交 git — 已加入 .gitignore",
"cookies/*.json 不可提交 git — 含 httpOnly auth cookie",
"采集频率 ≥ 5min — 过密会触发 opencode.ai 风控",
"不要用 blob regex 解析 SSR — 嵌套 $R[N]={} 会导致 [^}]* 在内层 } 处断",
"CDP 辅助模式仅在主模式失败或需验证时使用 — 依赖 Chrome 登录态",
"Cookie 过期后需重新提取 — extract_cookies.py 每个账号执行一次"
],
"must_not": [
"不要把 accounts.json 或 cookies/*.json 提交到 git",
"不要在采集脚本中硬编码 workspace ID — 必须从 accounts.json 读取",
"不要尝试用 Python requests + 提取的 cookies 单独采集 — httpOnly cookies 无法通过 document.cookie 提取",
"不要用 blob regex 解析 SSR — 用 per-metric regex"
],
"related_modules": [
{"module": "usage_monitor", "relation": "usage_collector 是 usage_monitor 模块的数据采集层 — 采集结果供 dashboard 的 usage_monitor 展示"},
{"module": "dashboard", "relation": "dashboard 通过 /api/usage/refresh 触发采集 + /api/usage 读取缓存 + auto-timer 自动触发"},
{"module": "xmpp_bot", "relation": "xmpp_bot 的 /usage 端点也可触发采集(旧版用法,新版 dashboard 直接 subprocess 调用)"}
],
"tests": [
{"id": "UC01", "name": "accounts.json 解析正确", "endpoint": "读取 accounts.json → 过滤出有 workspace_id 的记录"},
{"id": "UC02", "name": "HTTP 主模式采集返回有效数据", "endpoint": "带 cookie 的 HTTP GET → 解析 usagePercent > 0"},
{"id": "UC03", "name": "CDP 辅助模式在 Chrome 启动时可用", "endpoint": "CDP /targets → 找到 opencode.ai tab"},
{"id": "UC04", "name": "输出 usage_stats.json 格式正确", "endpoint": "采集后检查 JSON schema: accounts 数组 + last_refresh_iso"},
{"id": "UC05", "name": "--daemon 模式每 5 分钟采集", "endpoint": "--daemon 启动后连续 2 次采集间隔 ≈ 300s"}
],
"known_issues": [
"opencode.ai Go 用量目前无公开 API — PR #16513 未上线,数据唯一来源是 SSR hydration script",
"Cookie 有效期: opencode.ai session cookies 可能随时过期 → session_expired 标记提醒用户重新登录",
"CDP proxy 依赖: 若 Chrome 关闭或 cdp-proxy.mjs 未运行,CDP 辅助模式将失败",
"HTML 返回大小: CDP /eval 返回完整 SSR HTML (~16KB),可用带宽消耗"
],
"related_files": [
"gateway/scripts/usage_collector.py — 本文件 (742行, v3.0)",
"gateway/scripts/usage_monitor/accounts.json — 账号配置 (.gitignore)",
"gateway/scripts/usage_monitor/cookies/{key_id}.json — Cookie 文件 (.gitignore)",
"gateway/scripts/usage_monitor/extract_cookies.py — Cookie 提取工具",
"gateway/temp/usage_stats.json — 采集结果缓存",
"gateway/scripts/specs/usage_collector.json — 本 spec",
".opencode/skills/web-access/scripts/cdp-proxy.mjs — CDP proxy (3456)"
]
}
}
+31 -23
View File
@@ -1,46 +1,54 @@
{
"module": "wechat_bridge",
"version": "1.0",
"purpose": "WeChat Bridge 服务运行在 Windows (192.168.1.16:5801),负责将微信消息桥接到 Hermes Gateway (246:8642),实现老爸在微信上与莫荷的双向通信。",
"ui_location": "Infrastructure tab → Platform → WeChat Bridge",
"version": "2.0",
"purpose": "微信桥接通道,由 246 Linux 独立完成。Docker wechatbot-webhook 容器扫码登录后收发微信消息,经 webhook 接收器转发到 Hermes Gateway 处理。Windows 只参与文章抓取(5810。",
"ui_location": "Infrastructure tab → Platform → 莫荷微信 (Linux)",
"human_help": {
"title": "WeChat Bridge(微信桥接 :5801",
"title": "莫荷微信(Linux :3001 / webhook :5804",
"description": [
"这是整个系统的核心通道。部署在 Windows 上,接收微信消息后通过 HTTP POST 转发到 Linux (246) 上的 Hermes API,处理完成后再通过 wxhelper 将回复发回微信。",
"简单说:老爸在微信上发消息 → WeChat Bridge 接收 → 传给 Hermes AI 处理 → 回复发回微信。",
"它是一切的起点——没了它,微信通道就断了。"
"这是整个系统的微信通道。部署在 246 Linux 上:",
"• docker-wechatbot-webhook 容器(:3001)— Web 微信协议,扫码登录",
"• wechat_webhook.py:5804)— 接收容器推送的消息,转发到 Hermes API",
"• Hermes Gateway:8642)— AI 处理并生成回复",
"• 回复经 webhook → Docker 容器发回微信",
"Windows 不再运行微信客户端或 wxhelper,唯一的参与是 5810 文章抓取服务。"
],
"usage": [
"监控状态:Infrastructure → Platform 下查看状态指示灯(绿=运行,红=停止",
"如果状态为 stopped:需要检查 Windows 上的 wechat_agent.py 进程",
"启动脚本:start-bot-server.bat(自动启动 opencode serve + wechat_agent"
"监控状态:Infrastructure → Platform 下查看状态指示灯(绿=容器运行,红=容器未启动",
"检查的是 246:3001 端口(Docker 容器)",
"如果掉线:访问 http://192.168.1.246:3001/login 重新扫码"
],
"troubleshooting": [
"如果 WeChat Bridge 停止:先检查 Windows 上 wxhelper 是否注入成功",
"如果 Hermes 不回复:检查 Linux 上 Hermes Gateway (:8642) 是否存活",
"如果消息延迟:消息走 HTTP 桥,网络延迟通常 < 2s",
"如果启动了但状态还是 stopped:页面每 5-10s 自动刷新,等一会"
"如果状态 stoppedSSH 到 246 执行 docker ps 检查 wxBotWebhook 容器是否运行",
"启动容器:docker start wxBotWebhook",
"如果需要重新扫码:http://192.168.1.246:3001/login?token=your_token",
"Web 协议约 2 天掉线一次,需重新扫码。容器已设置 --restart unless-stopped 自动重启",
"wechat_webhook.py 由 systemd 管理:sudo systemctl status wechat-webhook"
],
"related": "EjabberdXMPP 底层通信)、F 健康 tab(系统整体健康"
"related": "文章抓取服务(供微信通道读取链接内容"
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/health", "returns": "{ok, status} — 服务健康检查"}
{"method": "TCP", "port": 3001, "desc": "Docker wechatbot-webhook 容器(Websocket 通信)"},
{"method": "POST", "port": 5804, "desc": "wechat_webhook.py 接收消息(本地转发)"},
{"method": "POST", "path": "/webhook/msg/v2", "port": 3001, "desc": "发送消息到微信(Docker API"}
],
"dependencies": [
"依赖 wxhelper DLL 注入微信 3.9.5.81",
"依赖 Hermes API (:8642) 存活才能处理消息",
"依赖 EasyTier VPN 打通网络(Windows ↔ Linux"
"246 Linux 服务器运行中",
"Docker 容器 wxBotWebhook 运行中",
"wechat_webhook.py systemd 服务运行中",
"Hermes Gateway (:8642) 存活才能处理消息"
],
"constraints": [
"只支持 3.9.5.81 x64 微信版本",
"注入 wxhelper 后如果微信重启需重新注入",
"Python 请用 3.10Miniconda3 3.13 的 encodings 模块损坏)"
"Web 微信协议约 2 天掉线一次,需要重新扫码",
"Windows 不再参与微信收发(旧 wechat_agent.py 已停用)",
"网络依赖 EasyTier VPN 打通 Windows ↔ Linux"
],
"related_files": [
"gateway/scripts/wechat_agent.py — 实际运行脚本",
"gateway/linux/wechat_webhook.py — Webhook 接收器",
"gateway/linux/README.md — 架构说明",
"gateway/scripts/templates/dashboard.html — fI() 状态展示",
"gateway/scripts/specs/wechat_bridge.json — 本 spec 文件"
]
+110
View File
@@ -0,0 +1,110 @@
{
"module": "xmpp_bot",
"version": "2.0",
"purpose": "XMPP Bot — 笑笑(xxm@yoin.fun)。连接 ejabberd via slixmpp,桥接 XMPP 消息到 OpenCode serve session,提供 HTTP bridge (:5802) 给 dashboard 代理 EasyTier/RDP/health 查询。支持 MUC 群聊、私有聊天、消息去重、协调者协议、MAM 恢复守护。",
"ui_location": "Agents tab → xxm Agent 卡片 + Services 标签",
"human_help": {
"title": "XMPP Bot — xxm 消息收发",
"description": [
"xmpp_bot.py 是 Windows 开发机上运行的 XMPP 消息机器人,JID: xxm@yoin.fun。它连接 ejabberd XMPP 服务器,把群聊和私聊消息桥接到 OpenCode serve session,让 AI 能参与 XMPP 群聊对话。",
"核心功能:XMPP 消息收发(MUC 群聊 + 私聊)、HTTP bridge 服务(:5802,提供 /health /muc /easytier /rdp /usage 端点)、消息去重(100条缓存)、协调者协议(coordinator/GRANT/REVOKE in-band 信令)、5分钟沉默 cooldown。",
"去重机制:通过 XMPP stanza message ID 去重,100 条缓存用完自动清空。防止 MAM 恢复和实时消息重复处理。",
"协调者协议:mohe 是默认协调者。hmo 可切换 coordinator={name}。支持 GRANT(授权一次发言)和 REVOKE(禁言5分钟)。全部通过 XMPP in-band 消息信令,无需外部 DB。",
"HTTP bridge 安全:/easytier /rdp /usage 等端点需要 X-Api-Key 认证(与 dashboard 的 _BRIDGE_KEY 匹配)。"
],
"participants": [
{"name": "小小莫", "device": "Windows 192.168.1.16", "role": "xmpp_bot 运行者 + OpenCode serve session 所有者", "note": "bot 跑在本地 Python 进程中"},
{"name": "ejabberd", "device": "246 Docker", "role": "XMPP 服务器 — 消息路由", "note": "192.168.1.246:5222"},
{"name": "莫荷", "device": "Linux 192.168.1.246", "role": "默认协调者 + dashboard 使用者", "note": "dashboard 通过 _bridge_post 代理到 xmpp_bot HTTP bridge"}
],
"usage": [
"1. 启动 bot: python xmpp_bot.py(自动获取 PID 锁,防重复)",
"2. bot 自动连接 ejabberd 并加入 MUC 群聊: coregroup + jujidina",
"3. 私聊消息: 直接发送到 OpenCode session → AI 处理 → 通过 XMPP 回复",
"4. 群聊消息: 通过 MUC 接收 → 去重 + 协调者检查 → AI 处理 → MUC 回复",
"5. 协调者控制: hmo 在群里发 '[GRANT:xxm]' 授权/取消禁言,'[REVOKE:xxm]' 禁言5分钟",
"6. 沉默命令: 对 bot 说 '闭嘴'/'别说话'/'安静' → 5分钟静默",
"7. HTTP bridge: GET :5802/health 查连接状态,POST :5802/easytier 控制 VPN"
],
"troubleshooting": [
"如果 bot 不回复消息: 检查 OpenCode serve session 是否在线 (localhost:4096) — session_attach 超时5min",
"如果群聊收不到: 检查 MUC join 是否成功 — 看日志是否有 'joined MUC' 或 MAM recovery 是否卡住",
"如果 HTTP bridge 不可达: 检查 5802 端口 — netstat -ano | findstr 5802",
"如果 dashboard EasyTier/RDP 无响应: 检查 _BRIDGE_KEY 是否匹配 — dashboard 和 xmpp_bot 必须一致",
"如果消息重复: 去重缓存100条上限后自动清空 — 极少数情况下可能漏过去,通常无害",
"如果 bot 启动失败: 检查 proc_guard PID 锁 — 删除 temp/.xmpp_bot.pid 后重试"
],
"related": "依赖 chat_bridge.py (SessionBridge) + session_router.py (SessionRouter) + proc_guard.py (PID 锁)"
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/health", "returns": "{ok, xmpp_connected, ejabberd_alive, bot_jid, uptime_seconds}", "note": "XMPP 连接状态 + bot 自检"},
{"method": "GET", "path": "/muc", "returns": "{rooms: {room_name: {participants: [{jid, nick}]}}}", "note": "MUC 群聊参与者列表(R01 已知不稳定)"},
{"method": "POST", "path": "/easytier", "body": "{\"action\":\"status|start|stop\"}", "returns": "{ok, running, ...}", "note": "EasyTier VPN 控制 — 需 X-Api-Key"},
{"method": "POST", "path": "/rdp", "body": "{\"action\":\"status|start|stop\"}", "returns": "{ok, rdp_enabled, tunnel_running}", "note": "RDP 远程桌面控制 — 需 X-Api-Key"},
{"method": "POST", "path": "/usage", "body": "{\"action\":\"status|collect_now\"}", "returns": "{ok, accounts, ...}", "note": "OpenCode Go 用量查询/采集 — 需 X-Api-Key"},
{"method": "POST", "path": "/send", "body": "{\"message\":\"text\"}", "returns": "{ok}", "note": "发送群聊消息"}
],
"dependencies": [
"slixmpp — XMPP 客户端库 (slixmpp.ClientXMPP)",
"chat_bridge.py — SessionBridge: 连接 OpenCode serve session (ses_xxm_xmpp)",
"session_router.py — SessionRouter: 消息路由 + 命令分发 + 协调者逻辑",
"proc_guard.py — PID 锁 (guard('xmpp_bot'))",
"ejabberd 192.168.1.246:5222 — XMPP 服务器",
"OpenCode serve session (localhost:4096) — AI 处理后端",
"MUC rooms: coregroup@conference.yoin.fun + jujidina@conference.yoin.fun"
],
"architecture": {
"flow": "XMPP消息 → slixmpp → 去重 (_is_duplicate) → MAM恢复守护 → 协调者信令解析 → SessionRouter → SessionBridge → OpenCode serve → AI回复 → XMPP发送",
"http_bridge": "HTTP server on :5802 (独立线程) — /health /muc /easytier /rdp /usage /send",
"dedup": "threading.Lock 保护的 set — 100条缓存,满了清空",
"mam_recovery": "启动后30s内收到的群消息被丢弃 (MAM历史回放保护) — 超时后强制禁用",
"coordinator": "in-band XMPP 信令 — hmo切换coordinator / GRANT临时授权 / REVOKE禁言5min",
"shutup": "关键词匹配 '闭嘴'/'别说话'/'安静' → 5分钟不回复"
},
"constraints": [
"PID 锁必须获取成功才能启动 — guard('xmpp_bot')",
"HTTP bridge 的 /easytier /rdp /usage 需要 X-Api-Key (与 dashboard _BRIDGE_KEY 一致)",
"MUC join 时需处理 DNS 解析超时 (conference.yoin.fun) — 用 try/except 包裹",
"去重缓存上限 100 — 足够处理正常流量,极端情况下清空后可能漏1-2条",
"MAM 恢复超时 30s — 如果 ejabberd 没发 MAM history,不会永远卡住",
"不要用 threading 处理消息 — slixmpp 是 asyncio 驱动的"
],
"must_not": [
"不要把 _BRIDGE_KEY 硬编码到 dashboard.py 和 xmpp_bot.py 之外的任何文件",
"不要在 MAM 恢复期间处理群消息 — 会导致 AI 收到重复/过时上下文",
"不要修改去重逻辑的锁结构 — threading.Lock 必须保持一致"
],
"related_modules": [
{"module": "dashboard", "relation": "dashboard 通过 _bridge_post 代理 EasyTier/RDP/usage 到 xmpp_bot HTTP bridge"},
{"module": "easytier", "relation": "xmpp_bot 提供 /easytier 端点给 dashboard 代理 — bot 是 EasyTier 控制的实际执行者"},
{"module": "rdp", "relation": "xmpp_bot 提供 /rdp 端点 — bot 是 RDP 控制的实际执行者"},
{"module": "usage_monitor", "relation": "xmpp_bot 提供 /usage 端点 — 旧版用法,新版 dashboard 直接读本地 usage_stats.json"},
{"module": "agents", "relation": "xxm agent 的核心通信层 — dashboard 通过 ejabberdctl 查询 xmpp_bot 的 JID 在线状态"}
],
"tests": [
{"id": "XB01", "name": "GET /health 返回 xmpp_connected + ejabberd_alive", "endpoint": "GET :5802/health"},
{"id": "XB02", "name": "GET /muc 返回群聊参与者列表", "endpoint": "GET :5802/muc"},
{"id": "XB03", "name": "POST /easytier status 需认证", "endpoint": "POST :5802/easytier → 无 key 应 401"},
{"id": "XB04", "name": "POST /send 发送消息成功", "endpoint": "POST :5802/send → ok=true"},
{"id": "XB05", "name": "去重: 相同 message ID 第二次被跳过", "endpoint": "注入重复 msg_id → _is_duplicate 返回 True"}
],
"known_issues": [
"R01: MUC join 超时 — conference.yoin.fun DNS 偶尔不可达,需 raw presence 双保险",
"slixmpp 依赖 Python asyncio — 与 Flask (同步 threading) 混跑时偶尔死锁",
"HTTP bridge 无 HTTPS — 仅局域网内使用,不暴露公网",
"去重清空时极少数重复消息可能漏过 — 100条缓存未命中时当做新消息处理"
],
"related_files": [
"gateway/scripts/xmpp_bot.py — 本文件 (943行)",
"gateway/scripts/chat_bridge.py — SessionBridge",
"gateway/scripts/session_router.py — SessionRouter + 协调者逻辑",
"gateway/scripts/proc_guard.py — PID 锁",
"gateway/scripts/specs/xmpp_bot.json — 本 spec",
"gateway/logs/xmpp_bot.log — 运行日志",
"gateway/temp/.xmpp_bot.pid — PID 锁文件"
]
}
}
+95
View File
@@ -0,0 +1,95 @@
{
"module": "xmpp_watchdog",
"version": "2.0",
"purpose": "多服务看门狗 — 每 30s 轮询监控 xmpp_agent_core + article_processor 等核心服务,检测进程存活 + 端口监听 + HTTP 健康端点,自动重启崩溃服务并执行日志轮转。PID 锁防重复,日志自动 rotate5MB 阈值)。",
"ui_location": "F 健康 tab → 全部服务 → 'watchdog' 行 + 定时任务",
"human_help": {
"title": "Watchdog — 多服务看门狗",
"description": [
"xmpp_watchdog.py 是多服务看门狗,监控 Windows 上运行的核心服务(xmpp_agent_core / article_processor),每 30 秒检查一次,发现崩溃自动重启。",
"三重检测:① 进程存活(tasklist 查 PID)② 端口监听(netstat -ano 查 LISTENING)③ HTTP 健康端点(GET /health 查返回)。任一层失败即触发重启。",
"日志轮转:每 15 分钟自动 rotate 所有被监控服务的日志文件(5MB 阈值),防止磁盘占满。",
"服务定义:从 service_registry.py 读取 SERVICES 列表,配置灵活。当前配置监控 xmpp_agent_core (:5807) 和 article_processor (:5810)。"
],
"usage": [
"1. 启动: python xmpp_watchdog.py(后台进程,无控制台窗口)",
"2. 自动发现: 遍历 SERVICES 列表,检查现有 PID/端口,不存在则自动启动",
"3. 循环监控: 每 30s 检查进程+端口+HTTP 三方健康",
"4. 状态报告: 每 5 分钟输出一次所有服务状态摘要",
"5. 日志位置: gateway/logs/watchdog.log",
"6. PID 文件: gateway/temp/.multi_watchdog.pid"
],
"troubleshooting": [
"如果看门狗不启动: 检查 PID 锁 — 删除 temp/.multi_watchdog.pid",
"如果某服务反复重启: 看 watchdog.log 了解具体失败原因(HTTP 超时/端口不监听/进程崩溃)",
"如果误杀服务: 401/403 HTTP 响应被当作服务正常(auth required = 服务活着),只有连接拒绝或超时才触发重启",
"如果日志未轮转: 检查文件大小是否超过 5MB 阈值,文件权限是否正常"
]
},
"ai_spec": {
"apis": [
{"method": "N/A", "path": "内部监控循环 (30s)", "returns": "状态日志 + 自动重启", "note": "无外部 HTTP API — 纯监控进程"},
{"method": "HTTP call", "path": "各服务的 /health 端点", "returns": "status 200 + JSON body", "note": "通过 urllib 调用各被监控服务的健康端点"}
],
"dependencies": [
"service_registry.py — SERVICES 列表定义(每个服务的 script/args/workdir/pid_file/port/health_url/log_files",
"proc_guard.py — PID 锁(间接使用,被监控服务各自也有)",
"Python 3.10 (C:\\Users\\hmo\\AppData\\Local\\Programs\\Python\\Python310\\python.exe)",
"Windows tasklist + netstat + wmic — 底层进程/端口检测工具",
"被监控服务的 /health HTTP 端点 — URL 由 service_registry 配置"
],
"architecture": {
"flow": "启动 → 遍历 SERVICES → 检查现有 PID/端口 → 不存在则 start_service() → 主循环: 30s sleep → check_service() → 不健康则 restart",
"detection_layers": [
"Layer 1: tasklist /FI 'PID eq N' — 进程存活检测",
"Layer 2: netstat -ano — 端口监听检测",
"Layer 3: urllib GET /health — HTTP 健康端点检测",
"401/403 special: HTTP auth 错误 ≠ 服务挂了 — 服务活着但需要认证"
],
"restart_flow": "kill_service (taskkill all matching cmdlines) → sleep 3s → subprocess.Popen(script + args) → 写 PID 文件 → sleep 5s → 验证",
"log_rotation": "每15分钟检查 — 单个日志 >5MB → 保留 .1 和 .2 两个历史备份",
"health_grace": "401/403 响应当做服务正常 — 只有连接拒绝/超时/非200才重启",
"multi_service": "所有服务在同一个 while True 循环中并行监控 — 一个挂了不影响其他"
},
"constraints": [
"必须从 service_registry.py 读取服务定义 — 不要硬编码服务列表",
"PID 锁文件路径由 service_registry 配置 — watchdog 本身用 .multi_watchdog.pid",
"日志轮转阈值 5MB — 保留 .1 和 .2 两个备份文件再覆盖",
"kill_service 使用 wmic 查 command line — 避免杀错同名进程",
"状态报告间隔: 10个循环 (5min) 或 any_restart 触发"
],
"must_not": [
"不要修改 service_registry.SERVICES 而不更新 watchdog 的 SERVICES 字典",
"不要用 tasklist 直接查进程名杀进程 — 必须通过 wmic 验证 command line 包含正确的脚本名",
"不要同时运行两个 watchdog 实例 — PID 锁保证互斥"
],
"related_modules": [
{"module": "health", "relation": "watchdog 在 F 健康 tab 的 '全部服务' 中显示为 'watchdog' 行"},
{"module": "dashboard", "relation": "dashboard 的 F 健康 tab 通过 /api/expected 检查 watchdog 进程是否存在"},
{"module": "health_service", "relation": "health_service 专注监控 xmpp_botwatchdog 监控更广泛的服务集合"},
{"module": "api_proxy", "relation": "watchdog 也可能监控 api_proxy 服务 —— 在 service_registry 中配置"}
],
"tests": [
{"id": "WD01", "name": "进程存活检测正确", "endpoint": "is_process_alive: tasklist 查已知 PID → 返回 True"},
{"id": "WD02", "name": "端口监听检测正确", "endpoint": "netstat -ano 查已知端口 → 检查 LISTENING"},
{"id": "WD03", "name": "HTTP 健康检查正确处理 401", "endpoint": "GET /health 返回 401 → 不触发重启"},
{"id": "WD04", "name": "挂掉的服务被重启", "endpoint": "手动 kill 某服务 → 30s 内 watchdog 应检测并重启"},
{"id": "WD05", "name": "日志轮转触发", "endpoint": "日志文件 >5MB → 生成 .1 和 .2 备份"}
],
"known_issues": [
"wmic 命令在 Windows 11 可能已废弃 — 未来需切换到 Get-CimInstance 或 PowerShell",
"kill_service 依赖准确的 script_match 字符串 — 如果脚本名变更但未更新 service_registry,会杀错进程",
"watchdog 本身挂了的话没有其他进程监控它 — 这是一个鸡蛋问题(health_service 部分覆盖 xmpp_bot 监控)"
],
"related_files": [
"gateway/scripts/xmpp_watchdog.py — 本文件 (264行)",
"gateway/scripts/service_registry.py — SERVICES 列表定义",
"gateway/scripts/proc_guard.py — PID 锁(被各监控服务使用)",
"gateway/scripts/specs/xmpp_watchdog.json — 本 spec",
"gateway/logs/watchdog.log — 看门狗日志",
"gateway/temp/.multi_watchdog.pid — PID 锁文件"
]
}
}
+12 -8
View File
@@ -44,9 +44,10 @@ h1{font-size:32px;font-weight:600;color:var(--accent);margin-bottom:4px}.subtitl
.lp{display:none;border-top:1px solid var(--border);padding:12px 16px;background:#0a0e14}
.lp.open{display:block}.lh{font-size:18px;color:var(--dim);margin-bottom:8px;display:flex;justify-content:space-between}
.lc{font:11px/1.6 "Cascadia Code",Consolas,monospace;color:var(--dim);max-height:300px;overflow-y:auto;background:#06080c;border:1px solid var(--border);border-radius:6px;padding:10px;white-space:pre-wrap}
.help-btn{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;border:1px solid var(--border);background:var(--card);color:var(--dim);cursor:pointer;font-size:14px;line-height:1;margin-left:8px;flex-shrink:0}
.help-btn{display:flex;align-items:center;justify-content:center;width:20px;height:20px;min-width:20px;max-width:20px;min-height:20px;max-height:20px;border-radius:50%;border:1px solid var(--border);background:var(--card);color:var(--dim);cursor:pointer;font-size:14px;line-height:1;margin-left:8px;flex-shrink:0;overflow:hidden}
.i .help-btn{margin-left:0}
.help-btn:hover{border-color:var(--accent);color:var(--accent)}
.help-btn.ai{color:var(--yellow);border-color:var(--yellow)}
.help-btn.ai{color:var(--yellow);border-color:var(--yellow);font-size:12px}
.help-btn.ai:hover{background:var(--yellow);color:#000}
.spec-modal{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.7);z-index:1000;display:none;align-items:center;justify-content:center}
.spec-modal.show{display:flex}
@@ -61,13 +62,15 @@ h1{font-size:32px;font-weight:600;color:var(--accent);margin-bottom:4px}.subtitl
.spec-modal .modal-box pre{background:var(--bg);padding:12px;border-radius:6px;font-size:15px;overflow-x:auto;margin:8px 0}
.toast{position:fixed;top:16px;right:16px;padding:10px 16px;border-radius:6px;font-size:19px;z-index:999;opacity:0;pointer-events:none}
.toast.show{opacity:1}.toast.ok{background:#238636;color:#fff}.toast.err{background:#da3633;color:#fff}
.ps{margin-top:24px}.ps h2{font-size:18px;color:var(--dim);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
.ps{margin-top:24px}.ps h2{font-size:18px;color:var(--dim);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border);display:flex;align-items:center}
.psv{display:flex;gap:8px;flex-wrap:wrap}
.psv .i{background:var(--card);border:1px solid var(--border);border-radius:6px;padding:8px 12px;display:flex;align-items:center;gap:8px;font-size:18px}
h2{display:flex;align-items:center}
.psv .i .d{width:6px;height:6px;border-radius:50%}.psv .i .d.ok{background:var(--green)}.psv .i .d.stopped{background:var(--dim)}
.section{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:12px}
.section h2{font-size:19px;font-weight:600;color:var(--accent)}
.section h2{font-size:19px;font-weight:600;color:var(--accent);display:flex;align-items:center}
.mono{font-family:"Cascadia Code",Consolas,monospace;font-size:18px}
.ar{font-size:16px;color:var(--yellow);margin-top:4px}
.tag{display:inline-block;padding:1px 8px;border-radius:8px;font-size:19px;font-weight:600}
.tag.g{background:#3fb95020;color:var(--green)}.tag.r{background:#f8514920;color:var(--red)}
.tag.y{background:#d2992220;color:var(--yellow)}.tag.d{background:#8b949e20;color:var(--dim)}
@@ -144,7 +147,8 @@ var L=new Set();
async function fa(){try{var r=await fetch('/api/agents'),d=await r.json(),on=d.filter(function(a){return a.status==='online'}).length,er=d.reduce(function(s,a){return s+(a.errors||0)},0);
var h='<h2>Agents 总览<span class="help-btn" onclick="showModuleHelp(\'agents\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'agents\',\'ai\')" title="AI Spec">§</span></h2><div class="stats"><div class="stat-c g"><div class="n">'+on+'/'+d.length+'</div><div class="l">Agents Online</div></div><div class="stat-c '+(er>0?'y':'g')+'"><div class="n">'+(er||0)+'</div><div class="l">Errors</div></div><div class="stat-c g"><div class="n">'+d.length+'</div><div class="l">Total</div></div></div><div class="agents">';
for(var i=0;i<d.length;i++){var a=d[i];var st=a.status==='online'?'online':a.status==='degraded'?'degraded':'offline';var pb={windows:'w',linux:'l',mac:'m'}[a.platform]||'';
h+='<div class="ac '+st+'"><div class="ah" onclick="tgL(\''+a.id+'\')"><span class="sd '+(a.status==='online'?'g':a.status==='degraded'?'y':'r')+'"></span><div class="ai"><div class="an">'+esc(a.display_name||a.name)+(pb?' <span class="b '+pb+'">'+esc(a.platform)+'</span>':'')+'</div><div class="am">'+esc(a.jid||'')+(a.host?' &middot; '+esc(a.host):'')+'</div></div><div class="as"><div class="v">'+(a.message_count_5min||0)+'</div><div class="l">msg/5m</div></div><div class="aa"><button class="btn s" onclick="event.stopPropagation();doAct(\''+a.id+'\',\'start\')" '+(a.status!=='offline'||!a.restartable?'disabled':'')+'>Start</button><button class="btn x" onclick="event.stopPropagation();doAct(\''+a.id+'\',\'stop\')" '+(a.status==='offline'||!a.restartable?'disabled':'')+'>Stop</button><button class="btn r" onclick="event.stopPropagation();doAct(\''+a.id+'\',\'restart\')" '+(!a.restartable?'disabled':'')+'>Restart</button><button class="btn l" onclick="event.stopPropagation();tgL(\''+a.id+'\')">Logs</button></div></div><div class="svcs">';
var rs=a.status_reason&&a.status_reason!=='OK'?'<div class="ar">'+esc(a.status_reason)+'</div>':'';
h+='<div class="ac '+st+'"><div class="ah" onclick="tgL(\''+a.id+'\')"><span class="sd '+(a.status==='online'?'g':a.status==='degraded'?'y':'r')+'"></span><div class="ai"><div class="an">'+esc(a.display_name||a.name)+(pb?' <span class="b '+pb+'">'+esc(a.platform)+'</span>':'')+'</div><div class="am">'+esc(a.jid||'')+(a.host?' &middot; '+esc(a.host):'')+rs+'</div></div><div class="as"><div class="v">'+(a.message_count_5min||0)+'</div><div class="l">msg/5m</div></div><div class="aa"><button class="btn s" onclick="event.stopPropagation();doAct(\''+a.id+'\',\'start\')" '+(a.status!=='offline'||!a.restartable?'disabled':'')+'>Start</button><button class="btn x" onclick="event.stopPropagation();doAct(\''+a.id+'\',\'stop\')" '+(a.status==='offline'||!a.restartable?'disabled':'')+'>Stop</button><button class="btn r" onclick="event.stopPropagation();doAct(\''+a.id+'\',\'restart\')" '+(!a.restartable?'disabled':'')+'>Restart</button><button class="btn l" onclick="event.stopPropagation();tgL(\''+a.id+'\')">Logs</button></div></div><div class="svcs">';
if(a.services){for(var k=0;k<a.services.length;k++){var s=a.services[k];h+='<span class="st"><span class="d '+(s.status||'stopped')+'"></span>'+esc(s.type)+(s.port?':<span class="p">'+s.port+'</span>':'')+'</span>';}}
h+='</div><div class="lp'+(L.has(a.id)?' open':'')+'" id="lp-'+a.id+'"><div class="lh"><span>Logs</span><button class="btn l" onclick="event.stopPropagation();fL(\''+a.id+'\')">Refresh</button></div><div class="lc" id="lc-'+a.id+'">Click Logs to load</div></div></div>';}
h+='</div>';qs('ct-agents').innerHTML=h;}catch(e){}}
@@ -281,11 +285,11 @@ async function fI(){
try{var r=await fetch('/api/platform'),d=await r.json();
h+='<div class="ps"><h2>Platform<span class="help-btn" onclick="showModuleHelp(\'infra\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'infra\',\'ai\')" title="AI Spec">§</span></h2><div class="psv">';
for(var i=0;i<d.length;i++){var s=d[i];h+='<div class="i"><span class="d '+(s.status==='running'?'ok':'stopped')+'"></span><span class="sn">'+esc(s.name)+'</span><span class="help-btn" onclick="showModuleHelp(\''+s.id+'\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\''+s.id+'\',\'ai\')" title="AI Spec">§</span></div>';}
try{var r2=await fetch('/api/ejabberd'),ej=await r2.json();var ejA=ej.alive||ej.xmpp_bot_connected;
h+='<div class="i"><span class="d '+(ejA?'ok':'stopped')+'"></span><span class="sn">Ejabberd XMPP</span><span class="help-btn" onclick="showModuleHelp(\'ejabberd\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'ejabberd\',\'ai\')" title="AI Spec">§</span></div>';
}catch(ejErr){}
h+='</div></div>';
}catch(e){}
try{var r2=await fetch('/api/ejabberd'),e=await r2.json();var ea=e.alive||e.xmpp_bot_connected;
h+='<div class="ps"><h2>Ejabberd<span class="help-btn" onclick="showModuleHelp(\'ejabberd\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'ejabberd\',\'ai\')" title="AI Spec">§</span></h2><div class="psv"><div class="i"><span class="d '+(ea?'ok':'stopped')+'"></span>'+(ea?'ALIVE':'DOWN')+'</div></div></div>';
}catch(e2){}
pe.innerHTML=h;
/* --- EasyTier section: create once, update state each cycle (no flicker) --- */
@@ -0,0 +1,9 @@
{
"_comment": "OpenCode Go usage accounts config — workspace IDs × cookie file paths. Cookies saved per-account via CDP capture during login. IMPORTANT: do NOT commit this file (it has workspace IDs and labels for production accounts).",
"accounts": [
{"label": "key1 (Google)" , "workspace_id": "", "key_id": "key1"},
{"label": "key2 (Google)" , "workspace_id": "", "key_id": "key2"},
{"label": "key3 (GitHub)" , "workspace_id": "", "key_id": "key3"},
{"label": "key4 (GitHub new)" , "workspace_id": "", "key_id": "key4"}
]
}