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:
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user