feat: 群分析本地消息库 / 处理中占位图 / Web 日志窗口重构

- 群分析: 新增 history_store 只读本地消息源(读 learning_chat 落库,含 uninfo
  昵称补齐/去重/截断判定),适配器优先读本地库、失败回退 OneBot 分页;分页
  锚点字段回退链修复 NapCat 传 message_seq 翻页断裂;新增「本地记录」开关
- core/message_utils: 新增 common_proc_reply 占位图通用回复(引用消息 + 处理中
  动图,支持后台任务显式指定 target),群分析/战况/倒放改用
- web_hub + web: 日志页改固定窗口滚动 + 翻页锚定 + 自动换行,SSE 日志轮转发
  reset 帧,入口 HTML no-cache,行数统计增量缓存,CPU 改非阻塞采样,登录信息缓存
- 插件内 CLAUDE.md / DESIGN.md 不入库(.gitignore),galgame_card 两份文档取消
  跟踪(文件保留在磁盘)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:59:01 +08:00
co-authored by Claude Code
parent 662c28cb2f
commit 51b08ccb68
23 changed files with 1100 additions and 393 deletions
+51 -8
View File
@@ -46,6 +46,10 @@ basic_path = Path(__file__).resolve().parent
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
WEB_DIST = Path(__file__).resolve().parents[1] / "web" / "dist"
# 入口 HTML 的响应头:必须每次回源校验。否则重新构建后浏览器会沿用缓存的
# 旧 index.html(进而加载已被删除的旧 hashed bundle),Web 端一直跑旧代码。
_NO_CACHE = {"Cache-Control": "no-cache"}
def _hub_version() -> str:
"""管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。"""
try:
@@ -239,8 +243,34 @@ def _tail_log_lines(
return lines, start, size
# 行数统计缓存:路径 -> (已统计到的字节偏移, 行数)。日志只追加不重写,
# 因此每次只需扫「自上次统计以来新增的字节」,不必重扫整个文件。
_COUNT_CACHE: dict[str, tuple[int, int]] = {}
# SSE 单轮最多读取的字节数:避免日志突发时一次性把大量行灌给前端
_LOG_STREAM_CHUNK = 1 << 20
def _count_log_lines(path: Path) -> int:
"""分块统计全文件行数(仅换行计数,不全量载入)。"""
"""统计全文件行数(仅换行计数,不全量载入)。
首次或检测到文件被截断时全量扫描;之后按增量累加——否则每次翻页
都要在事件循环里把整个日志(可达 20MB)重扫一遍。
"""
key = str(path)
size = path.stat().st_size
cached = _COUNT_CACHE.get(key)
if cached is not None:
done, count = cached
if done == size:
return count
if 0 < done < size:
with open(path, "rb") as f:
f.seek(done)
chunk = f.read()
count += chunk.count(b"\n")
_COUNT_CACHE[key] = (done + len(chunk), count)
return count
count = 0
with open(path, "rb") as f:
while True:
@@ -248,6 +278,7 @@ def _count_log_lines(path: Path) -> int:
if not chunk:
break
count += chunk.count(b"\n")
_COUNT_CACHE[key] = (size, count)
return count
@@ -530,7 +561,7 @@ def build_hub_app() -> FastAPI:
async def dashboard_stream(
_: dict = Depends(get_current_user),
):
"""Dashboard 状态 SSE 实时推送(每 2 秒采集一次)。"""
"""Dashboard 状态 SSE 实时推送(每 5 秒采集一次)。"""
async def gen():
while True:
@@ -540,7 +571,7 @@ def build_hub_app() -> FastAPI:
except Exception as e: # noqa: BLE001
payload = {"ok": False, "msg": f"采集失败: {e}"}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await asyncio.sleep(2)
await asyncio.sleep(5)
return StreamingResponse(gen(), media_type="text/event-stream")
@@ -549,7 +580,12 @@ def build_hub_app() -> FastAPI:
since: int = 0,
_: dict = Depends(get_current_user),
):
"""SSE 实时日志流:从 since(字节偏移)增量推送新日志行。"""
"""SSE 实时日志流:从 since(字节偏移)增量推送新日志行。
检测到日志被轮转/清空(since 超出当前文件大小)时发一帧 {"reset": true}
让前端丢掉已失效的旧窗口,然后从新文件末尾继续——不能回退到 0,
否则重连的客户端会把整个文件重放一遍(20MB ≈ 十万行)。
"""
root = Path(__file__).resolve().parents[2] # 仓库根目录
candidates = [
root / "_bot_run.log",
@@ -570,12 +606,14 @@ def build_hub_app() -> FastAPI:
await asyncio.sleep(1.0)
continue
if current > size:
current = 0 # 日志被轮转/截断
# 日志被轮转/清空:跳到新文件末尾并通知前端丢掉旧窗口
current = size
yield 'data: {"reset": true}\n\n'
if current < size:
try:
with open(path, "rb") as f:
f.seek(current)
raw = f.read()
raw = f.read(_LOG_STREAM_CHUNK)
except OSError:
await asyncio.sleep(1.0)
continue
@@ -594,6 +632,9 @@ def build_hub_app() -> FastAPI:
line = raw_line.decode("gbk", errors="replace")
payload = {"line": line.rstrip("\r"), "offset": current}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
elif len(raw) >= _LOG_STREAM_CHUNK:
# 单行超过单轮读取上限:整块丢弃,否则游标永远不前进
current += len(raw)
await asyncio.sleep(0.5)
beat += 1
if beat >= 30: # 每 ~15s 一次心跳保活
@@ -622,7 +663,9 @@ def build_hub_app() -> FastAPI:
@app.get("/")
async def index():
if (WEB_DIST / "index.html").exists():
return FileResponse(WEB_DIST / "index.html")
# no-cache:入口 HTML 必须每次回源校验,否则重新构建后浏览器会继续
# 用缓存的旧 index.html 去加载已被替换掉的旧 hashed bundle(dist/assets)。
return FileResponse(WEB_DIST / "index.html", headers=_NO_CACHE)
return HTMLResponse(
"<h1>HeXi Web Hub</h1><p>前端未构建,请在 <code>hexi/web</code> 执行 "
"<code>npm run build</code>。</p>"
@@ -634,7 +677,7 @@ def build_hub_app() -> FastAPI:
if path and target.is_file() and target.is_relative_to(WEB_DIST.resolve()):
return FileResponse(target)
if (WEB_DIST / "index.html").exists():
return FileResponse(WEB_DIST / "index.html")
return FileResponse(WEB_DIST / "index.html", headers=_NO_CACHE)
raise HTTPException(status_code=404, detail="页面不存在")
return app
+32 -14
View File
@@ -6,7 +6,10 @@
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
结果做 1 秒短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
结果做短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
CPU 采样走非阻塞模式(interval=None,与上一次调用求差):带 interval 的
psutil.cpu_percent/Process.cpu_percent 会真的 sleep 住调用线程,而 SSE 循环
每轮都调用一次,会凭空常驻烧掉约四分之一个核。
"""
from __future__ import annotations
@@ -28,21 +31,31 @@ _MODULE_START = time.time()
# 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量
_NET_LAST: dict = {"time": None, "sent": None, "recv": None}
# 采集结果短缓存(多标签页 / SSE 每 2s 循环共用一份结果)
# 采集结果短缓存(多标签页 / SSE 循环共用一份结果)
_DASH_CACHE: dict = {"time": 0.0, "data": None}
_DASH_CACHE_TTL = 1.0
_DASH_CACHE_TTL = 2.0
# 进程对象与 CPU 采样必须跨调用复用:psutil 的非阻塞模式(interval=None)
# 是「与上一次调用」求差,换对象或首次调用只会拿到 0.0。
_PROC = psutil.Process()
psutil.cpu_percent(None, True) # 预热 CPU 采样(首次调用恒为 0.0)
_PROC.cpu_percent(None) # 预热进程采样
# bot 昵称(get_login_info)基本不变,长缓存避免每轮都打一次 OneBot API
_LOGIN_CACHE: dict[str, tuple[float, str]] = {}
_LOGIN_TTL = 600.0
def _cpu_sync() -> dict:
try:
per_core = psutil.cpu_percent(0.3, True)
per_core = psutil.cpu_percent(None, True)
except TypeError:
# 兼容部分平台不接受 percpu 参数
per_core = []
if per_core:
percent = sum(per_core) / len(per_core)
else:
percent = psutil.cpu_percent(0.3)
percent = psutil.cpu_percent(None)
info: dict = {
"percent": round(float(percent), 1),
"per_core": [round(float(x), 1) for x in (per_core or [])],
@@ -84,8 +97,8 @@ def _memory_sync() -> dict:
def _process_sync() -> dict:
try:
proc = psutil.Process()
cpu = proc.cpu_percent(0.2)
proc = _PROC
cpu = proc.cpu_percent(None)
info = {
"pid": proc.pid,
"name": proc.name(),
@@ -253,13 +266,18 @@ async def _collect_bots() -> list:
"msg_sent": None,
}
if ws_connected:
try:
login = await bot.get_login_info()
item["nick"] = login.get("nickname") or item["nick"]
except Exception as e: # noqa: BLE001
logger.warning(
f"获取登录信息失败({bot.self_id}): {type(e).__name__}: {e}"
)
cached = _LOGIN_CACHE.get(bot.self_id)
if cached and time.time() - cached[0] < _LOGIN_TTL:
item["nick"] = cached[1]
else:
try:
login = await bot.get_login_info()
item["nick"] = login.get("nickname") or item["nick"]
_LOGIN_CACHE[bot.self_id] = (time.time(), item["nick"])
except Exception as e: # noqa: BLE001
logger.warning(
f"获取登录信息失败({bot.self_id}): {type(e).__name__}: {e}"
)
try:
status = await bot.get_status()
item["online"] = status.get("online")