From 058c42ce275c0e67a85a5025f9b408422495bf7f Mon Sep 17 00:00:00 2001 From: hmo Date: Mon, 20 Jul 2026 00:07:28 +0800 Subject: [PATCH] feat(health): recent XMPP conversation log panel + stale error fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User requirement: health tab should show recent XMPP conversations. - bot hooks log_xmpp on inbound (on_msg) and outbound (_deliver_loop) so real chats land in xmpp_messages.jsonl (was: only cron/scanner) - index.html health tab: new '最近对话' panel (last 10 msgs, dir arrow, preview, status, time); refreshHealth updates it incrementally - last_error now shows age and resolved state: once a successful outbound happens after an error, it's shown gray as '已恢复' instead of alarming red forever; unresolved errors still red - health() status no longer degraded by errors that were later resolved by successful outbound --- deploy/bot/xmpp_agent_core.py | 11 ++++++ scripts/verify_health_fix.py | 14 ++++++++ static/index.html | 66 ++++++++++++++++++++++++++++++++--- xmpp_logger.py | 10 ++++-- 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 scripts/verify_health_fix.py diff --git a/deploy/bot/xmpp_agent_core.py b/deploy/bot/xmpp_agent_core.py index cd7a5324..8709d782 100644 --- a/deploy/bot/xmpp_agent_core.py +++ b/deploy/bot/xmpp_agent_core.py @@ -147,6 +147,14 @@ _inbound_lock = threading.Lock() RECENT_SENT_MAX = 50 +# ── XMPP 消息日志(dashboard 健康Tab「最近对话」数据源)── +try: + sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..')) + from xmpp_logger import log_xmpp as _log_xmpp +except Exception: + def _log_xmpp(*a, **kw): + pass + def _rs(p): """Parse agent arg from sys.argv, returns agent name string.""" @@ -283,6 +291,7 @@ class XmppAgent(slixmpp.ClientXMPP): log.info(f"📩 收到图片(URL body): from={msg['from']} url={body[:80]}") body = f"[IMAGE] {body}" log.info(f"📩 收到: from={msg['from']} type={msg['type']} body={body[:60]}") + _log_xmpp('in', sender_str := str(msg['from']), f"{AGENT_NAME}@yoin.fun", body) if ('[executor]' in body and 'gateway_zhiwei' in body): log.info(f"过滤 executor 消息: {body[:80]}...") return @@ -322,8 +331,10 @@ def _deliver_loop(bot): bot.send_message(mto=to, mbody=body, mtype=mtype) asyncio.run(_send(target, text, msg_type)) bot.mark_sent(text) + _log_xmpp('out', f"{AGENT_NAME}@yoin.fun", target, text) log.info(f" 已发送到 {target}: {text[:80]}") except Exception as e: + _log_xmpp('out', f"{AGENT_NAME}@yoin.fun", target, text, status='error', error=str(e)[:150]) log.error(f" 发送到 {target} 失败: {e}") time.sleep(0.3) except Exception as e: diff --git a/scripts/verify_health_fix.py b/scripts/verify_health_fix.py new file mode 100644 index 00000000..2f99043b --- /dev/null +++ b/scripts/verify_health_fix.py @@ -0,0 +1,14 @@ +import urllib.request, json +d = json.loads(urllib.request.urlopen('http://127.0.0.1:8899/api/xmpp/health', timeout=120).read()) +ba = d.get('bot_activity', {}) +print('status:', d.get('status')) +print('last_error:', (ba.get('last_error') or '')[:80]) +print('last_error_age_sec:', ba.get('last_error_age_sec')) +print('last_error_resolved:', ba.get('last_error_resolved')) +print('last_outbound_age_sec:', ba.get('last_outbound_age_sec')) +print('---') +m = json.loads(urllib.request.urlopen('http://127.0.0.1:8899/api/xmpp/messages', timeout=30).read()) +msgs = m.get('messages', []) +print('messages count:', len(msgs)) +for x in msgs[:5]: + print(' ', x.get('timestamp'), x.get('direction'), (x.get('body_preview') or '')[:50]) \ No newline at end of file diff --git a/static/index.html b/static/index.html index fc5709a0..4c275241 100644 --- a/static/index.html +++ b/static/index.html @@ -1462,6 +1462,9 @@ async function renderHealth() { // XMPP 通道健康(独立 fetch,不阻塞主流程) let xmpp = null; try { xmpp = await fetchJSON('/api/xmpp/health'); } catch(e) {} + // 最近 XMPP 对话日志(bot 挂钩 log_xmpp 后的真实聊天记录) + let xmppMsgs = []; + try { const md = await fetchJSON('/api/xmpp/messages'); xmppMsgs = md.messages || []; } catch(e) {} const svcs = services.services || []; const summary = services.summary || { ok: 0, total: 0 }; @@ -1534,7 +1537,12 @@ async function renderHealth() { html += '
⚠️ 错误
' + (ba.errors || 0) + '
'; html += ''; if (ba.last_error) { - html += '
' + ba.last_error + '
'; + const errAge = ba.last_error_age_sec >= 0 ? Math.round(ba.last_error_age_sec/60) + '分钟前' : ''; + if (ba.last_error_resolved) { + html += '
⚠️ 历史错误(' + errAge + ',已恢复): ' + ba.last_error + '
'; + } else { + html += '
⚠️ ' + errAge + ': ' + ba.last_error + '
'; + } } // Hermes Gateway + LLM Provider @@ -1549,6 +1557,27 @@ async function renderHealth() { html += '
🧠 LLM Provider
' + (llm.status || '?') + ''; if (llm.error) html += '
' + llm.error + ''; html += '
'; + + // ── 最近 XMPP 对话日志 ── + html += '
'; + html += '
💬 最近对话(' + xmppMsgs.length + ' 条)
'; + if (xmppMsgs.length === 0) { + html += '
暂无记录 — bot 重启后对话开始采集
'; + } else { + html += '
'; + xmppMsgs.slice(0, 10).forEach(m => { + const isIn = m.direction === 'in'; + const arrow = isIn ? '📩' : '📤'; + const dirColor = isIn ? 'text-[#58a6ff]' : 'text-[#3fb950]'; + const statusMark = m.status === 'ok' ? '' : ' [' + m.status + ']'; + const who = isIn ? (m.from || '').split('@')[0] : '知微'; + const t = (m.timestamp || '').slice(11, 16); + const preview = (m.body_preview || '').replace(/' + arrow + ' ' + who + '' + preview + statusMark + '' + t + '
'; + }); + html += '
'; + } + html += ''; html += ''; } el.innerHTML = html; @@ -1563,9 +1592,10 @@ async function refreshHealth() { const el = document.getElementById('tab-health'); if (!el || el.classList.contains('hidden') || !el.dataset.rendered) return; try { - const [services, xmpp] = await Promise.all([ + const [services, xmpp, msgData] = await Promise.all([ fetchJSON('/api/services'), - fetchJSON('/api/xmpp/health') + fetchJSON('/api/xmpp/health'), + fetchJSON('/api/xmpp/messages').catch(() => null) ]); const svcs = (services.services || []); const sum = services.summary || {}; @@ -1613,12 +1643,40 @@ async function refreshHealth() { if (bi) bi.textContent = ba.inbound || 0; if (bo) { bo.textContent = ba.outbound || 0; bo.className = 'font-mono ' + (ba.outbound ? 'text-[#3fb950]' : (ba.inbound ? 'text-[#f85149]' : 'text-slate-500')); } if (be) { be.textContent = ba.errors || 0; be.className = 'font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500'); } - if (bl) bl.textContent = ba.last_error || ''; + if (bl) { + const errAge = ba.last_error_age_sec >= 0 ? Math.round(ba.last_error_age_sec/60) + '分钟前' : ''; + if (ba.last_error && ba.last_error_resolved) { + bl.textContent = '⚠️ 历史错误(' + errAge + ',已恢复): ' + ba.last_error; + bl.className = 'text-xs text-slate-500 mt-2 p-2 bg-slate-800/30 rounded'; + } else if (ba.last_error) { + bl.textContent = '⚠️ ' + errAge + ': ' + ba.last_error; + bl.className = 'text-xs text-[#f85149] mt-2 p-2 bg-red-900/20 rounded'; + } else { + bl.textContent = ''; + } + } // LLM Provider const llm = xmpp.llm_provider || {}; const ls = document.getElementById('hLlmStatus'), le = document.getElementById('hLlmErr'); if (ls) { ls.textContent = llm.status || '?'; ls.className = 'font-mono ' + (llm.status === 'ok' ? 'text-[#3fb950]' : 'text-[#f85149]'); } if (le) le.textContent = llm.error || ''; + + // 最近对话列表 + const msgBox = document.getElementById('hXmppMsgs'); + if (msgBox && msgData && msgData.messages) { + let mh = ''; + msgData.messages.slice(0, 10).forEach(m => { + const isIn = m.direction === 'in'; + const arrow = isIn ? '📩' : '📤'; + const dirColor = isIn ? 'text-[#58a6ff]' : 'text-[#3fb950]'; + const statusMark = m.status === 'ok' ? '' : ' [' + m.status + ']'; + const who = isIn ? (m.from || '').split('@')[0] : '知微'; + const t = (m.timestamp || '').slice(11, 16); + const preview = (m.body_preview || '').replace(/' + arrow + ' ' + who + '' + preview + statusMark + '' + t + ''; + }); + msgBox.innerHTML = mh; + } } // 更新时间戳 diff --git a/xmpp_logger.py b/xmpp_logger.py index cb44533e..2175d21c 100644 --- a/xmpp_logger.py +++ b/xmpp_logger.py @@ -306,6 +306,7 @@ def health(): last_inbound_ts = max((_line_epoch(l) for l in inbound), default=0) last_outbound_ts = max((_line_epoch(l) for l in outbound), default=0) + last_error_ts = max((_line_epoch(l) for l in errors), default=0) result["bot_activity"] = { "inbound": len(inbound), "outbound": len(outbound), "errors": len(errors), "last_error": errors[-1][:250] if errors else None, @@ -313,9 +314,14 @@ def health(): "last_outbound": outbound[-1][:200] if outbound else None, "last_inbound_age_sec": int(now_epoch - last_inbound_ts) if last_inbound_ts else -1, "last_outbound_age_sec": int(now_epoch - last_outbound_ts) if last_outbound_ts else -1, + "last_error_age_sec": int(now_epoch - last_error_ts) if last_error_ts else -1, + # 错误之后已有成功出站 → 该错误已被覆盖,不算当前问题 + "last_error_resolved": bool(last_error_ts and last_outbound_ts > last_error_ts), } - if errors and not outbound: result["status"] = "degraded" - if inbound and not outbound: result["status"] = "degraded" + # 只有未覆盖的错误才降级状态 + unresolved_error = last_error_ts and not (last_outbound_ts > last_error_ts) + if errors and not outbound and unresolved_error: result["status"] = "degraded" + if inbound and not outbound and unresolved_error: result["status"] = "degraded" except Exception: result["bot_activity"] = {"error": "journalctl failed"}