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
|
||||
|
||||
Reference in New Issue
Block a user