From 51b08ccb68a73bec332534e4cae54a46883f9cb1 Mon Sep 17 00:00:00 2001 From: sans Date: Mon, 14 Sep 2026 18:59:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BE=A4=E5=88=86=E6=9E=90=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=B6=88=E6=81=AF=E5=BA=93=20/=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E4=B8=AD=E5=8D=A0=E4=BD=8D=E5=9B=BE=20/=20Web=20=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=AA=97=E5=8F=A3=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 群分析: 新增 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 --- .gitignore | 5 +- hexi/core/message_utils.py | 95 +++++- .../nonebot_plugin_galgame_card/CLAUDE.md | 81 ----- .../nonebot_plugin_galgame_card/DESIGN.md | 194 ------------ .../__init__.py | 16 +- .../adapter.py | 150 ++++++++- .../config.py | 2 + .../infrastructure/config/config_manager.py | 9 + .../history_store.py | 281 +++++++++++++++++ .../service.py | 1 + .../handlers/war.py | 3 +- .../res/img/helldivers/飞鹰毒气空袭.png | Bin 0 -> 29182 bytes .../nonebot_plugin_regif/handlers/reverse.py | 8 +- .../nonebot_plugin_steam_info/steam.py | 2 +- hexi/resource/imgs/thinking.gif | Bin 0 -> 36626 bytes hexi/web/src/api/client.ts | 6 +- hexi/web/src/connection.tsx | 4 +- hexi/web/src/layout/AppLayout.tsx | 4 +- hexi/web/src/logs.tsx | 162 +++++++--- hexi/web/src/pages/logs/index.tsx | 81 ++++- hexi/web_hub/__init__.py | 59 +++- hexi/web_hub/dashboard.py | 46 ++- tests/test_group_daily_history_store.py | 284 ++++++++++++++++++ 23 files changed, 1100 insertions(+), 393 deletions(-) delete mode 100644 hexi/plugins/nonebot_plugin_galgame_card/CLAUDE.md delete mode 100644 hexi/plugins/nonebot_plugin_galgame_card/DESIGN.md create mode 100644 hexi/plugins/nonebot_plugin_group_daily_analysis/history_store.py create mode 100644 hexi/plugins/nonebot_plugin_helldivers_tools/res/img/helldivers/飞鹰毒气空袭.png create mode 100644 hexi/resource/imgs/thinking.gif create mode 100644 tests/test_group_daily_history_store.py diff --git a/.gitignore b/.gitignore index 3effee7..d5a76c1 100644 --- a/.gitignore +++ b/.gitignore @@ -78,7 +78,10 @@ bg.jpg /.ai/ /.claude/ /hexi/config/ -/CLAUDE.md + +# ---- 开发文档(插件内 CLAUDE.md / DESIGN.md,含根目录,不入库) ---- +**/CLAUDE.md +**/DESIGN.md # helldivers 图标素材&生成脚本(不入库) /dev/docs/HD2/ diff --git a/hexi/core/message_utils.py b/hexi/core/message_utils.py index 324e16e..d9dd539 100644 --- a/hexi/core/message_utils.py +++ b/hexi/core/message_utils.py @@ -1,17 +1,26 @@ +import mimetypes +from pathlib import Path + from nonebot import get_bot from nonebot import require +from nonebot.adapters import Event from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment from nonebot.log import logger +from nonebot_plugin_alconna import UniMessage +from nonebot_plugin_alconna.uniseg import Receipt, Target require("nonebot_plugin_htmlrender") from nonebot_plugin_htmlrender import md_to_pic +# 「处理中」占位动图(任务驱动通用回复用) +THINKING_GIF = Path(__file__).parents[1] / "resource" / "imgs" / "thinking.gif" + async def send_markdown( - bot: Bot, - event: MessageEvent, - markdown: str, - fallback_text: str | None = None, + bot: Bot, + event: MessageEvent, + markdown: str, + fallback_text: str | None = None, ) -> None: """发送 Markdown @@ -62,9 +71,9 @@ def get_reply_message(event: MessageEvent) -> Message: def build_forward_nodes( - self_id: int, - entries: list[tuple[str, str]], - sender_name: str | None = None, + self_id: int, + entries: list[tuple[str, str]], + sender_name: str | None = None, ) -> list[dict]: """构造合并转发节点列表 @@ -86,11 +95,11 @@ def build_forward_nodes( async def send_forward_msg( - bot: Bot, - event: MessageEvent, - entries: list[tuple[str, str]], - fallback: str, - sender_name: str | None = None, + bot: Bot, + event: MessageEvent, + entries: list[tuple[str, str]], + fallback: str, + sender_name: str | None = None, ): """群聊发送合并转发消息,私聊或发送失败时回退纯文本 @@ -108,3 +117,65 @@ async def send_forward_msg( except Exception as e: logger.warning(f"合并转发发送失败,回退文本: {e}") await bot.send(event, fallback) + + +async def common_proc_reply( + message_id: str | None = None, + *, + target: Event | Target | None = None, + image: str | Path = THINKING_GIF, + text: str | None = None, +) -> Receipt | None: + """任务驱动通用回复:引用指定消息并回一张「处理中」占位图(UniMessage 版) + + 引用哪条消息 / 发到哪: + - 事件处理中(有上下文):不传参数即引用当前触发消息,发回当前会话; + - 传 message_id 则引用指定消息(仍需事件上下文或用 target 指定会话); + - 后台任务(无事件上下文):必须显式传 target(Event 或 alconna Target), + 如 `Target("872490448")`(群)/`Target("2931589710", private=True)`(私聊); + 此时不传 message_id 即为不带引用的普通发送。 + + Args: + message_id: 要引用的消息 id,默认当前事件消息 + target: 发送目标(Event/Target);仅后台任务需要 + image: 占位图路径(或 http(s) 链接),默认 hexi/resource/imgs/thinking.gif + text: 附加文本,为空只发图 + + Returns: + alconna Receipt: 可 `.recall()` 撤回占位图、`.msg_ids` 取消息 id + (是否支持编辑见 `.editable`);发送失败返回 None + """ + + def build() -> UniMessage: + # 每次重建:alconna 的 reply_to 会把引用段插入消息自身,降级重发需干净副本 + # 本地文件转 base64 内联发送:后端(NapCat)读的是它自己 CWD 下的路径, + # file:/// 形式不可靠;base64:// 由 bot 直接携带字节,OneBot 实现通用支持 + if isinstance(image, str) and image.startswith(("http://", "https://")): + img = UniMessage.image(url=image) + else: + img = UniMessage.image( + raw=Path(image).read_bytes(), + mimetype=mimetypes.guess_type(str(image))[0], + ) + return UniMessage.text(f"{text} ") + img if text else img + + if message_id: + reply_to: str | bool = message_id + elif target is None: + reply_to = True # 从当前事件上下文取被引用消息 + else: + reply_to = False # 后台任务且未指定消息 id → 普通发送 + + try: + return await build().send(target=target, reply_to=reply_to) + except Exception as e: + if not reply_to: + logger.error(f"占位图发送失败: {e}") + return None + logger.warning(f"占位回复引用发送失败,降级为不带引用: {e}") + + try: + return await build().send(target=target) + except Exception as e: + logger.error(f"占位图发送失败: {e}") + return None diff --git a/hexi/plugins/nonebot_plugin_galgame_card/CLAUDE.md b/hexi/plugins/nonebot_plugin_galgame_card/CLAUDE.md deleted file mode 100644 index f657e6d..0000000 --- a/hexi/plugins/nonebot_plugin_galgame_card/CLAUDE.md +++ /dev/null @@ -1,81 +0,0 @@ -# CLAUDE.md — 群聊人设卡插件开发文档 - -本文件是**本插件内开发**的唯一入口文档;需要项目全局信息(Poetry 命令、启动方式、测试约定)时再查项目根目录的 CLAUDE.md。详细设计定稿见同目录 `DESIGN.md`。 - -## 插件概述 - -基于群聊语料蒸馏群成员的形象风格,生成 galgame 风格人物卡(九段画像)。 - -- **目标**:生成"人的画像",不是关系网分析;样貌参考为虚构,永远带标注 -- **隐私硬约束**:只采集 opt-in 成员;敏感数据本地正则替换为占位符,**绝不经过 LLM** -- **维度隔离**:每个 `(group_id, user_id)` 是独立人设,跨群不混 - -## 目录结构 - -``` -nonebot_plugin_galgame_card/ -├── __init__.py 入口层:on_message 采集器(鉴权/采样/调治理落库) -├── models.py 数据层:五张表(persona_group/user/chat_log/impression/summary) -├── repository.py 数据层:仓储(来源无关,所有读写唯一入口) -├── processor.py 治理层:纯函数(五维提取/噪声/脱敏),可单测 -├── config.py 插件配置(仅图片识别开关等;Web 鉴权统一走 /hub) -├── web_hub.py Web API 子应用(挂载到 /api/galgame_card,auth=hexi.web_hub.web_auth) -├── DESIGN.md 设计定稿(数据模型/流水线/九段协议/证据纪律) -└── CLAUDE.md 本文件 -``` - -**Web 管理后台**:已嵌入统一管理台 `/hub/`,前端由 `hexi/web` 渲染,API 挂载到 `/api/galgame_card`,鉴权与 /hub 共用 `hexi.web_hub.web_auth`(OAuth2 + SQLite)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。改后端需重启 bot。 - -## 核心设计(速览,细节见 DESIGN.md) - -1. **两级闸门**:群开关 `persona_group.enabled`(默认关)+ 个人 opt-in `persona_user`,都过才采集 -2. **两级流水线**:语料 →(攒够 N 条)→ 印象(LLM 自然语言,增量中间层)→(攒够 M 条)→ 画像(九段 markdown,版本化) -3. **消息五维**:内容 / 谁发的 / 发给谁(回复/@)/ 几点发的 / 被回复内容快照——只存治理后纯文本 -4. **脱敏四层**(`desensitize()`):明确模式(最长优先排序防截胡)→ 定位式(同条关键词+值)→ 跨条语境(关键词在附近消息)→ 兜底(保守替换) -5. **九段画像协议**:身份印象/性格特征/说话风格/口头禅语录/兴趣话题/相处模式/时间画像/样貌参考(虚构)/不确定信息 -6. **总结路径不进消息 handler**:LLM 调用只在调度器触发,防延迟/限流 - -## 开发命令 - -```bash -# 跑本插件测试(治理层纯函数,9+ 个用例) -poetry run pytest tests/test_persona_processor.py - -# 全量测试 -poetry run pytest - -# 启动/重启验证:PyCharm 的 "start bot" 运行配置(勿用 bat 脚本) -``` - -## 数据库 - -- orm 默认库:`data/nonebot_plugin_orm/db.sqlite3`(不是 `hexi/data/data.db`) -- 建表:bot 启动时 orm 自动 create_all(新表加在 `models.py` 里即可,重启生效) -- 配置键是 `SQLALCHEMY_DATABASE_URL`(本插件未设置,走默认库) - -## 开发注意事项(踩过的坑) - -0. **⚠️ orm 启动自动同步会清空表数据**:`.env` 里 `ALEMBIC_STARTUP_CHECK=false` 时,nonebot_plugin_orm 每次启动都 autogenerate 同步数据库模式,**模型一有变更(改 models.py)就会重建表、清空全部数据**(2026-08-11 实测踩坑,全表被清)。已在本插件 `__init__.py` 导入期把 `migrate.sync` 替换为安全空操作。**今后 schema 演进只准通过 `repository.ensure_schema()` 显式 ALTER**,改完 models.py 后要在重启前手动执行对应 ALTER(或加进 ensure_schema)。 -1. **`on_message` 必须 `block=False`**:否则事件流被拦截,群里其他插件全废 -2. **只收群消息**:handler 参数注解 `GroupMessageEvent`(类型注解即过滤器) -3. **脱敏正则排序**:身份证/银行卡必须在手机号之前,否则手机号截胡身份证数字段 -4. **定位式替换只替换值、保留关键词**:`密码是 xyz789` → `密码是 [密码]`,关键词不能丢 -5. **测试不能裸 import 包**:`__init__.py` 触发 NoneBot 初始化,用 importlib 按路径加载 processor(见 `tests/test_persona_processor.py`,与 test_rate_limit 同款) -6. **总结/印象生成**:绝不在消息 handler 里调 LLM,走调度层(apscheduler) -7. **仓储并发**:`add_summary` 版本自增有并发撞 UNIQUE 风险,调度层加锁保护 - -## 当前进度 - -- ✅ 数据层:五表 + 仓储(含群开关、滚动淘汰、版本自增) -- ✅ 采集层:消息路径(监控→鉴权→治理→脱敏→采样→落库) -- ✅ Web 管理后台:嵌入 /hub(/api/galgame_card + hexi/web 页面;群开关、参与者、数据浏览/清理) -- ⏳ QQ 命令集:`开启人设采集` / `加入人设` / `退出人设` / `查看人设`(Web 已覆盖同等功能,QQ 命令可选做) -- ⏳ 总结路径:LLM 客户端、印象生成、九段画像生成、调度触发 -- ⏳ 呈现层:人物卡展示/图片渲染 - -## 待决策点 - -- 触发阈值(印象 ≥50 条新语料 / 画像 ≥5 条新印象,⏳ 待调) -- 脱敏兜底位数(裸数字 ≥6 位默认替换 `[账号]`,保守优先;误杀多可提到 8 位,动 `BARE_DIGITS_RE`) -- 密保答案场景(中文值正则误杀率高,方案待定) -- 命令名与权限(超管/群主) diff --git a/hexi/plugins/nonebot_plugin_galgame_card/DESIGN.md b/hexi/plugins/nonebot_plugin_galgame_card/DESIGN.md deleted file mode 100644 index c175479..0000000 --- a/hexi/plugins/nonebot_plugin_galgame_card/DESIGN.md +++ /dev/null @@ -1,194 +0,0 @@ -# 群聊人设卡(Galgame 风格人物卡构建器)设计文档 - -> 本文档固化设计决策,作为各层实现的唯一依据。标注 ⏳ 的为草案/待定项。 - -## 1. 定位 - -**目标**:基于群聊发言语料,蒸馏群成员的形象风格,生成 galgame 风格的人物卡(人设)。 - -**非目标**: -- 不做关系网分析("相处模式"只是画像的一个段落,不是独立产品) -- 不做真实身份推断(年龄/职业/住址等现实信息只能进"不确定信息"段) -- 不生成真实样貌(样貌参考为虚构,永远带标注) - -**消费方**:① 展示给人看(人物卡);② 可选:作为扮演 prompt 注入(MaiBot 兼容方向)。 - -## 2. 整体架构 - -功能分层:采集 → 治理 → 存储 → 调度 → 分析 → 呈现。 - -两级流水线(借鉴 MaiBot 印象机制): - -``` -群聊消息 ──五维落库──> 语料 ──(攒够 N 条)──> 印象(LLM 自然语言) ──(攒够 M 条)──> 画像(九段协议) ──> 版本化快照 -``` - -- **语料 → 印象**:每次对"上次印象之后的新语料"生成一段自然语言印象(话题/氛围/互动),存 `persona_impression`。印象是增量中间产物,画像不重读全部原文。 -- **印象 → 画像**:从印象集 + 规则统计(@/回复 互动、活跃时段)生成九段人物卡。 -- 画像每次生成都是新版本(version +1),永久留档可对比。 - -## 3. 数据层(已实现 ✅) - -五张表,前缀 `persona_`: - -### persona_group —— 群采集开关(数据来源总闸门) - -| 字段 | 类型 | 说明 | -|---|---|---| -| group_id | BigInteger PK | 群号 | -| enabled | Boolean 默认 False | 该群是否开启采集(默认关,需显式开启) | -| updated_at | DateTime | 最后变更时间 | - -群关闭 → 该群所有人一律不采集;群开启后,个人还需 opt-in(两级闸门)。 - -### persona_user —— 参与者名单(按群 opt-in) - -| 字段 | 类型 | 说明 | -|---|---|---| -| user_id | BigInteger PK | 参与人 QQ | -| group_id | BigInteger PK | 所在群 | -| joined_at | DateTime | 加入时间 | - -### persona_chat_log —— 采集语料(五维 + 发言段链条) - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | Integer PK 自增 | 全局有序,印象覆盖区间用它表示 | -| user_id | BigInteger | 谁发的 | -| group_id | BigInteger | | -| nickname | String(64) | 群昵称快照 | -| content | Text | 内容(纯文本,已脱敏截断) | -| target_user_id | BigInteger NULL | 发给谁(ev.reply 的 sender / @ 对象;无则 NULL=群聊漫谈) | -| target_inherited | Boolean | target 是否从发言段链条继承(对上一句的解释/补充仍算发给同一对象) | -| follows_id | Integer NULL | 发言段链条:同一说话人的上一条语料 id(间隔 ≤ 5 分钟) | -| reply_to_content | Text NULL | 被回复内容快照(对方不在语料里也能知道他在回应什么) | -| created_at | DateTime | 几点发的 | - -索引:`(user_id, group_id, created_at)`。滚动保留:单用户单群上限 3000 条(⏳ 常量待定)。 - -**发言段链条**(解决"不带 @/回复 的后续补充丢目标"):`@B 借我玩玩` → 下一条 `我的意思是借号不是借人`(无显式目标)继承 target=B 并打 `target_inherited` 标记;分析层窗口组装可沿 `follows_id` 回溯整段发言。 - -### persona_impression —— 印象(两级流水线中间产物) - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | Integer PK 自增 | | -| user_id / group_id | BigInteger | | -| content | Text | LLM 生成的自然语言印象 | -| cover_from_id / cover_to_id | Integer | 覆盖的语料 id 区间(增量依据) | -| model | String(64) | 生成模型 | -| created_at | DateTime | | - -### persona_image —— 图片识别结果缓存(⏳ 多模态预留,未启用) - -| 字段 | 类型 | 说明 | -|---|---|---| -| hash | String(64) PK | 图片 hash(对应 chat_log.image_hashes) | -| description | Text | 多模态识别结果(表情包梗/截图内容) | -| model | String(64) | 识别模型 | -| recognized_at | DateTime | | - -**预留接口**:`vision.py`(BaseImageRecognizer,当前为 Noop 占位)。将来接入多模态 LLM 后:异步后台识别(绝不在消息路径同步调)、同一 hash 只识别一次(缓存复用)、失败不影响采集。识别描述供分析层窗口组装喂给总结 LLM。 - -### persona_summary —— 画像快照(版本化) - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | Integer PK 自增 | | -| user_id / group_id | BigInteger | | -| version | Integer | 每次生成 +1,同人同群唯一 | -| card_text | Text | 九段 markdown 画像原文 | -| corpus_count | Integer | 语料覆盖条数(元信息) | -| impression_count | Integer | 使用的印象条数(元信息) | -| model | String(64) | 生成模型 | -| created_at | DateTime | | - -约束:`UNIQUE(user_id, group_id, version)`(并发写入由调度层锁保护)。 - -## 4. 九段画像协议(已定稿 ✅) - -格式:markdown 固定标题 + 有界 bullet,代码可解析、人可编辑、LLM 可生成、可注入 prompt。 - -``` -# 人物卡 · {主称呼} -语料 {N} 条 · 时间跨度 {start}-{end} · 版本 v{n} · 生成于 {date} - -## 身份印象 ≤4 条 群内可见的:自称方式、群角色(吐槽役)、常用昵称 -## 性格特征 ≤6 条 毒舌但心软 / 重度拖延 / 嘴硬 -## 说话风格 ≤5 条 爱用"草"开头、句尾 wwww、先吐槽再给结论 -## 口头禅语录 ≤6 条 带原文引用:"有一说一,这个图确实带" -## 兴趣话题 ≤5 条 明日方舟(资深);聊工作→抱怨、聊感情→回避 -## 相处模式 ≤4 条 对小B互怼最多,对新人客气(一句话式,非关系网) -## 时间画像 ≤3 条 深夜 22-02 点活跃,白天潜水 -## 样貌参考 ≤3 条 🎨 虚构标注:基于气质的参考描述 -## 不确定信息 ≤3 条 疑是学生(语料出现"上课"),未证实 -``` - -- 段内 bullet 为纯文本,可带原文引用(口头禅语录段必须带原文) -- 样貌参考段**必须**带 🎨 虚构标注与设计依据("基于 XX 气质") -- 现实身份信息只能出现在"不确定信息"段 -- ⏳ 每段具体生成约束(prompt 细则)属分析层,待细化 - -## 5. 证据纪律(已定稿 ✅) - -三层防编造(借鉴 MaiBot): - -1. 印象 prompt 明文约束:"不要添加语料中没有依据的新事实" -2. 规则统计(互动对象、活跃时段)优先于 LLM 分类结果 -3. LLM 分类结果默认降级进"不确定信息"段——**模型说的不算稳定真相** - -指纹缓存(⏳):证据(印象集 + 统计)hash 未变则不重新生成画像。 - -## 6. 采集与治理(✅ 消息路径已实现,⏳ 阈值待调) - -实现位置:`__init__.py`(on_message 入口 + 鉴权 + 采样)+ `processor.py`(纯函数治理,可单测)。 - -**两级闸门**:群开关 `persona_group.enabled`(群级,默认关)+ 个人 opt-in `persona_user`(个人,群内开启才生效)。两条同时满足才采集。 - -- 群开关控制:`开启人设采集 @群` / `关闭人设采集`(⏳ 命令名待定,权限:超管/群主) -- 只采集 `persona_user` 名单内成员(opt-in),退出即停 - -**脱敏(硬约束:本地正则完成,绝不经过 LLM——LLM 只接触脱敏后文本)**: -敏感值**替换为占位符**而非丢弃整条,保留对话语境(如"借号"互动是人格素材,凭证不是)。 -实现:`processor.desensitize()`,四层,覆盖场景:借号/验证码代收/密码口令/兑换码卡密/密保/收入/联系方式变体/位置。 - -| 规则层 | 模式 | 占位符 | -|---|---|---| -| 明确模式(按最长优先排序,防截胡) | 身份证 → 银行卡 → 邮箱 → 手机号(含分隔变体) → IP → wxid → 坐标 → 车牌 | `[身份证]` `[银行卡]` `[邮箱]` `[手机号]` `[IP]` `[微信号]` `[坐标]` `[车牌]` | -| 定位式(同条"关键词+值") | `密码是 xyz789` `账号 abc123` `激活码 ABCDE-1` `验证码是 123456` `VX: xxx` `月薪 25000`(连接词支持 是/为/冒号/空格) | `[密码]` `[账号]` `[兑换码]` `[验证码]` `[微信号]` `[收入]` | -| 跨条语境 | 关键词在附近消息(如"收下验证码"→ 下一条 `123456`)→ 本条值按语境类型替换 | `[验证码]` `[密码]` `[兑换码]` | -| 兜底 | 裸长数字串 ≥6 位 → `[账号]`;字母+数字混合 ≥6 位 → `[密码]`(保守替换,宁误杀不放过) | `[账号]` `[密码]` | - -语境来源:本群最近 3 条已治理文本(复用复读检测的内存窗口)。 -待扩展场景(⏳):密保答案("你妈妈的名字")、QQ 号文本、代充代练语境。 - -- 噪声过滤:纯表情图(无文字)、复读、命令/签到、长链接轰炸 -- 长文截断(约 200 字/条) -- 采样:连续刷屏 5 秒内只记 1 条 - -## 7. 调度与触发(⏳ 草案) - -| 方式 | 条件 | -|---|---| -| 手动 | 管理员 `生成人物卡 @xxx`(强制,无视阈值) | -| 印象 | 新语料 ≥ 50 条(⏳)且距上次印象 ≥ 24h | -| 画像 | 新印象 ≥ 5 条(⏳)或语料显著增长;首次需语料 ≥ 200 条 | -| 防重入 | 同人同群生成中加锁 | - -## 8. 入口层命令集(⏳ 草案) - -`加入人设` `退出人设`(opt-in 控制)、`查看人设 @xxx`(展示九段卡)、`生成人设 @xxx`(管理员强制)。 - -## 9. 借鉴与不借鉴 MaiBot(已定稿 ✅) - -**借鉴**:两级流水线(印象机制)、九段协议格式(段落文本协议)、证据纪律三层、指纹缓存、防串人(证据绑定 user_id)。 - -**不借鉴**:向量库 + BM25 双路召回 + PPR(语料量级 SQL 直查即可)、完整 A_memorix 记忆系统、md5 person_id(QQ 号即 id)。 - -## 10. 开发阶段 - -- **Phase 1**:数据层(四表 + 仓储)✅ 本文档落盘时完成 -- **Phase 2**:治理层(采集过滤 + opt-in 命令) -- **Phase 3**:分析层(印象/画像生成,LLM 客户端) -- **Phase 4**:调度层(触发/锁)+ 呈现层(人物卡展示) -- ⏳ 后续可选:galgame 风格卡面图片渲染(协议文本为渲染源) diff --git a/hexi/plugins/nonebot_plugin_group_daily_analysis/__init__.py b/hexi/plugins/nonebot_plugin_group_daily_analysis/__init__.py index 494be5c..e3f32a2 100644 --- a/hexi/plugins/nonebot_plugin_group_daily_analysis/__init__.py +++ b/hexi/plugins/nonebot_plugin_group_daily_analysis/__init__.py @@ -17,6 +17,8 @@ from nonebot.params import CommandArg from nonebot.plugin import PluginMetadata from nonebot.rule import to_me +from ...core import message_utils + require("nonebot_plugin_alconna") from nonebot_plugin_alconna import UniMessage # noqa: E402 @@ -31,7 +33,6 @@ from .service import get_services, register_bot_adapter # noqa: E402 from .renderer import html_render # noqa: E402 from .templates import list_templates, template_exists # noqa: E402 - # ---------- 指令定义(全部要求 @ 机器人) ---------- analysis_cmd = on_command( @@ -192,7 +193,7 @@ async def _(bot: Bot, event: GroupMessageEvent): group_id = str(event.group_id) days = _parse_days(event) - await UniMessage.text("正在启动分析引擎,正在拉取最近消息...").send() + await message_utils.common_proc_reply(event.message_id) try: svc = get_services() @@ -347,6 +348,7 @@ async def _(bot: Bot, event: GroupMessageEvent): f"用户称号: {'开' if cm.get_user_title_analysis_enabled() else '关'}", f"金句分析: {'开' if cm.get_golden_quote_analysis_enabled() else '关'}", f"聊天质量: {'开' if cm.get_chat_quality_analysis_enabled() else '关'}", + f"本地记录: {'开' if cm.get_use_local_history() else '关'}", "用法:设置分析 [参数] [值],如:设置分析 天数 3", ] await UniMessage.text(chr(10).join(lines)).send() @@ -362,8 +364,12 @@ _SETTING_MAP = { "称号": ("set_user_title_analysis_enabled", str, "用户称号已{}"), "金句": ("set_golden_quote_analysis_enabled", str, "金句分析已{}"), "聊天质量": ("set_chat_quality_analysis_enabled", str, "聊天质量已{}"), + "本地记录": ("set_use_local_history", str, "本地记录已{}"), } +# 走开/关布尔语义的键(值为 _BOOL_WORDS 里的词) +_BOOL_KEYS = {"话题", "称号", "金句", "聊天质量", "本地记录"} + _BOOL_WORDS = {"开": True, "on": True, "true": True, "关": False, "off": False, "false": False} @@ -375,7 +381,9 @@ async def _(bot: Bot, event: GroupMessageEvent, args: tuple = CommandArg()): tokens = _cmd_tokens(args) if len(tokens) < 2: await UniMessage.text( - "用法:设置分析 [参数] [值]" + chr(10) + "参数:天数/窗口/最大消息/最小消息/输出格式/话题/称号/金句/聊天质量" + "用法:设置分析 [参数] [值]" + + chr(10) + + "参数:天数/窗口/最大消息/最小消息/输出格式/话题/称号/金句/聊天质量/本地记录" ).send() return key = tokens[0] @@ -396,7 +404,7 @@ async def _(bot: Bot, event: GroupMessageEvent, args: tuple = CommandArg()): await UniMessage.text(f"{key} 已设为 {v}").send() elif conv is str: v = val.lower() - if key in ("话题", "称号", "金句", "聊天质量"): + if key in _BOOL_KEYS: if v not in _BOOL_WORDS: await UniMessage.text("布尔值请填:开/关 或 on/off").send() return diff --git a/hexi/plugins/nonebot_plugin_group_daily_analysis/adapter.py b/hexi/plugins/nonebot_plugin_group_daily_analysis/adapter.py index c9beef0..6dcf790 100644 --- a/hexi/plugins/nonebot_plugin_group_daily_analysis/adapter.py +++ b/hexi/plugins/nonebot_plugin_group_daily_analysis/adapter.py @@ -3,12 +3,10 @@ from __future__ import annotations import asyncio -import base64 -import time from datetime import datetime, timedelta -from pathlib import Path from typing import Any +from . import history_store from .core.domain.value_objects.unified_group import UnifiedGroup, UnifiedMember from .core.domain.value_objects.unified_message import ( MessageContent, @@ -17,6 +15,29 @@ from .core.domain.value_objects.unified_message import ( ) from .core.utils.logger import logger +# 分页锚点字段链(后端差异实测):NapCat 的 message_seq 参数按 OB11 +# message_id(shortId 短 ID 映射) 解析,传消息内的 message_seq(内核 msgSeq) +# 命中不了映射 → NapCat 抛"消息不存在",翻页断裂;go-cqhttp/LLOneBot 等 +# 则识别 message_seq。优先 message_id,翻页无进度时依次回退。 +ANCHOR_FIELDS = ("message_id", "message_seq", "seq", "real_id") + + +def _fmt_ts(ts: int) -> str: + """时间戳转日志用的可读时间。""" + try: + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + except (OSError, OverflowError, ValueError): + return str(ts) + + +def _pick_anchor(raw: dict[str, Any], fields: tuple[str, ...], start: int): + """从消息中按字段优先级提取分页锚点值""" + for field in fields[start:]: + val = raw.get(field) + if val is not None: + return val + return None + class OneBotAdapter: """面向 NoneBot OneBot V11 的最小适配器。""" @@ -31,6 +52,8 @@ class OneBotAdapter: self.platform_id = str(self.config.get("platform_id") or "onebot") self.bot_self_ids = [str(x) for x in self.config.get("bot_self_ids", [])] self.filter_bot_messages = bool(self.config.get("filter_bot_messages", True)) + # 优先读本地消息库(learning_chat 落库),取不到再回退接口分页 + self.use_local_history = bool(self.config.get("use_local_history", True)) # —— 消息拉取 —— async def fetch_messages( @@ -52,8 +75,21 @@ class OneBotAdapter: start_ts = int( (datetime.now() - timedelta(days=days)).timestamp() ) + end_ts = int(datetime.now().timestamp()) + + # 本地消息库优先(完整且毫秒级);before_id 无法映射到时间戳,故跳过 + if self.use_local_history and not before_id: + local = await self._fetch_from_local_store( + group_id, start_ts, end_ts, max_count + ) + if local: + return local current_anchor = before_id + anchor_idx = 0 + no_progress_pages = 0 + last_earliest: dict[str, Any] | None = None + while len(all_raw) < max_count: fetch_count = min(chunk_size, max_count - len(all_raw)) params: dict[str, Any] = { @@ -85,13 +121,34 @@ class OneBotAdapter: break messages = result.get("messages", []) if not messages: - break + # 空页可能意味着当前锚点字段不被后端识别(如 NapCat 外的 + # 实现收到 message_id),换下一字段重试一次,仍空则结束。 + if current_anchor is None or last_earliest is None: + break + if no_progress_pages >= 1 or anchor_idx >= len(ANCHOR_FIELDS) - 1: + logger.warning( + "OneBot 分页拉取: 返回空页且锚点字段已耗尽,停止回溯" + ) + break + no_progress_pages += 1 + anchor_idx += 1 + current_anchor = _pick_anchor( + last_earliest, ANCHOR_FIELDS, anchor_idx + ) + if current_anchor is None: + break + logger.warning( + f"OneBot 分页拉取: 空页,锚点字段切换为 {ANCHOR_FIELDS[anchor_idx]}" + ) + continue first = messages[0] last = messages[-1] earliest = first if first.get("time", 0) <= last.get("time", 0) else last + last_earliest = earliest chunk_earliest_ts = earliest.get("time", 0) + prev_len = len(all_raw) for raw in messages: msg_time = raw.get("time", 0) msg_id = str(raw.get("message_id", "")) @@ -100,19 +157,42 @@ class OneBotAdapter: if start_ts <= msg_time <= int(datetime.now().timestamp()): all_raw.append(raw) seen_raw_ids.add(msg_id) + added = len(all_raw) - prev_len - seq_val = ( - earliest.get("message_seq") - or earliest.get("real_id") - or earliest.get("seq") - ) - mid_val = earliest.get("message_id") - new_anchor = seq_val if seq_val is not None else mid_val if chunk_earliest_ts <= start_ts: + logger.info( + f"OneBot 分页拉取: 已到达起始时间,共 {len(all_raw)} 条" + ) + break + + if added == 0: + # 本页没有新增消息:锚点不生效(后端不支持该字段)或数据已取尽。 + # 先切换锚点字段再试一次,仍无新增则结束。 + no_progress_pages += 1 + if no_progress_pages >= 2: + logger.warning( + "OneBot 分页拉取: 连续 2 页无新增,停止回溯" + ) + break + if anchor_idx < len(ANCHOR_FIELDS) - 1: + anchor_idx += 1 + logger.warning( + f"OneBot 分页拉取: 锚点字段切换为 {ANCHOR_FIELDS[anchor_idx]}" + ) + else: + no_progress_pages = 0 + + new_anchor = _pick_anchor(earliest, ANCHOR_FIELDS, anchor_idx) + if new_anchor is None: break if current_anchor and str(new_anchor) == str(current_anchor): + logger.info("OneBot 分页拉取: 锚点未位移,历史已取尽") break current_anchor = new_anchor + logger.info( + f"OneBot 分页拉取进度: {len(all_raw)} 条," + f"锚点({ANCHOR_FIELDS[anchor_idx]}): {new_anchor}" + ) await asyncio.sleep(0.05) unified: list[UnifiedMessage] = [] @@ -131,6 +211,54 @@ class OneBotAdapter: logger.warning(f"OneBot 分页获取消息失败: {e}") return [] + async def _fetch_from_local_store( + self, group_id: str, start_ts: int, end_ts: int, max_count: int + ) -> list[UnifiedMessage]: + """从本地消息库(learning_chat 落库)取群历史;不可用时返回空以回退分页。""" + try: + result = await asyncio.to_thread( + history_store.fetch_group_messages, + group_id, + start_ts, + end_ts, + max_count, + ) + except Exception as e: + logger.warning(f"本地历史读取失败,回退接口分页: {e}") + return [] + + if not result: + logger.info( + f"本地历史无数据({result.error or '窗口内无消息'})," + "回退 OneBot 分页拉取" + ) + return [] + + logger.info( + f"本地历史记录拉取: group={group_id}, source={history_store.MESSAGE_TABLE}, " + f"count={len(result.messages)}, " + f"窗口=[{_fmt_ts(start_ts)}..{_fmt_ts(end_ts)}], " + f"名称覆盖={result.names_resolved}/{len(result.messages)}, " + f"去重={result.duplicates}" + ) + if result.truncated: + window_total = ( + result.window_total if result.window_total is not None else "?" + ) + earliest = result.messages[0]["time"] if result.messages else end_ts + logger.warning( + f"本地历史截断: group={group_id}, max_messages={max_count}, " + f"窗口内共 {window_total} 条, 实际取最近 {len(result.messages)} 条, " + f"最早={_fmt_ts(earliest)}, 窗口起点={_fmt_ts(start_ts)}" + ) + + unified: list[UnifiedMessage] = [] + for raw in result.messages: + converted = self._convert_message(raw, group_id) + if converted: + unified.append(converted) + return unified + def _convert_message(self, raw: dict, group_id: str) -> UnifiedMessage | None: try: sender = raw.get("sender", {}) diff --git a/hexi/plugins/nonebot_plugin_group_daily_analysis/config.py b/hexi/plugins/nonebot_plugin_group_daily_analysis/config.py index 3b327de..a16a987 100644 --- a/hexi/plugins/nonebot_plugin_group_daily_analysis/config.py +++ b/hexi/plugins/nonebot_plugin_group_daily_analysis/config.py @@ -98,6 +98,8 @@ def _default_config() -> dict: cfg.setdefault("basic", {}).setdefault("max_messages", 1000) cfg.setdefault("basic", {}).setdefault("min_messages_threshold", 50) cfg.setdefault("basic", {}).setdefault("filter_bot_messages", True) + # 优先读本地消息库(learning_chat 落库),取不到再回退接口分页 + cfg.setdefault("basic", {}).setdefault("use_local_history", True) cfg.setdefault("analysis_features", {}).setdefault("chat_quality_analysis_enabled", False) cfg.setdefault("incremental", {}).setdefault("incremental_enabled", False) return cfg diff --git a/hexi/plugins/nonebot_plugin_group_daily_analysis/core/infrastructure/config/config_manager.py b/hexi/plugins/nonebot_plugin_group_daily_analysis/core/infrastructure/config/config_manager.py index f3607bd..7c2004e 100644 --- a/hexi/plugins/nonebot_plugin_group_daily_analysis/core/infrastructure/config/config_manager.py +++ b/hexi/plugins/nonebot_plugin_group_daily_analysis/core/infrastructure/config/config_manager.py @@ -770,6 +770,15 @@ class ConfigManager: self._ensure_group("basic")["filter_bot_messages"] = enabled self.config.save_config() + def get_use_local_history(self) -> bool: + """获取是否优先从本地消息库读取群历史。""" + return self._get_group("basic").get("use_local_history", True) + + def set_use_local_history(self, enabled: bool): + """设置是否优先从本地消息库读取群历史(关闭则始终走接口分页)。""" + self._ensure_group("basic")["use_local_history"] = enabled + self.config.save_config() + def get_html_output_dir(self) -> str: """获取HTML输出目录""" diff --git a/hexi/plugins/nonebot_plugin_group_daily_analysis/history_store.py b/hexi/plugins/nonebot_plugin_group_daily_analysis/history_store.py new file mode 100644 index 0000000..9f5fa04 --- /dev/null +++ b/hexi/plugins/nonebot_plugin_group_daily_analysis/history_store.py @@ -0,0 +1,281 @@ +"""本地群历史记录源:只读读取 learning_chat 落库的群消息。 + +`nonebot_plugin_learning_chat` 会把机器人收到的每条群消息写入 +`learning_chat_message`(与该群是否开启学习无关),比 OneBot +`get_group_msg_history` 分页更完整——部分后端只能取回一页就被截断。 +昵称/群名片从同库的 `nonebot_plugin_uninfo_*` 表离线补齐,不额外调接口。 + +模块只依赖标准库(logger 做守卫导入),便于单测用 importlib 裸加载; +读连接一律 `mode=ro`(绝不建库),任何失败都返回带 error 的空结果。 +""" + +from __future__ import annotations + +import json +import os +import sqlite3 +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +try: # 插件包内导入:走插件 logger + from .core.utils.logger import logger +except Exception: # 单测裸加载(无包上下文)时降级为标准库 logger + import logging + + logger = logging.getLogger(__name__) + +MESSAGE_TABLE = "learning_chat_message" +# nonebot_plugin_uninfo 的 SceneType.GROUP +SCENE_TYPE_GROUP = 1 +# 显式指定本地消息库位置(测试或非默认部署用) +DB_PATH_ENV = "HEXI_GROUP_DAILY_HISTORY_DB" + +_MESSAGE_SQL = ( + "SELECT id, user_id, message_id, raw_message, message, plain_text, time " + f"FROM {MESSAGE_TABLE} " + "WHERE group_id = ? AND time >= ? AND time <= ? " + "ORDER BY time DESC, id DESC LIMIT ?" +) +_COUNT_SQL = ( + f"SELECT COUNT(*) FROM {MESSAGE_TABLE} " + "WHERE group_id = ? AND time >= ? AND time <= ?" +) +# 同库 uninfo 三表:拿本群成员的 QQ 昵称与群名片 +_NAMES_SQL = ( + "SELECT u.user_id AS user_id, " + "u.user_data AS user_data, " + "s.member_data AS member_data " + "FROM nonebot_plugin_uninfo_scenemodel AS sc " + "JOIN nonebot_plugin_uninfo_sessionmodel AS s " + "ON s.scene_persist_id = sc.id " + "JOIN nonebot_plugin_uninfo_usermodel AS u " + "ON u.id = s.user_persist_id " + "WHERE sc.scene_id = ? AND sc.scene_type = ?" +) + + +@dataclass +class LocalHistoryResult: + """本地历史读取结果;空 messages 表示需要回退到接口分页。""" + + messages: list[dict[str, Any]] = field(default_factory=list) + truncated: bool = False + window_total: int | None = None + names_resolved: int = 0 + duplicates: int = 0 + error: str | None = None + + def __bool__(self) -> bool: + return bool(self.messages) + + +def _parse_sqlite_url(raw: Any) -> Path | None: + """从 SQLAlchemy 数据库配置里解析出 sqlite 文件路径。""" + if raw is None: + return None + text = str(raw).strip() + if not text.startswith("sqlite") or "memory" in text: + return None + # sqlite+aiosqlite:///D:/path/db.sqlite3 或 sqlite:///./rel/db.sqlite3 + _, _, tail = text.partition("///") + tail = tail.strip() + if not tail: + return None + try: + return Path(tail).expanduser() + except (OSError, ValueError): + return None + + +def _driver_db_path() -> Path | None: + """读取 `SQLALCHEMY_DATABASE_URL`(环境变量优先,其次 .env 配置)。""" + for key in ("SQLALCHEMY_DATABASE_URL", "sqlalchemy_database_url"): + raw = os.environ.get(key) + if raw: + return _parse_sqlite_url(raw) + try: + from nonebot import get_driver + + raw = getattr(get_driver().config, "sqlalchemy_database_url", None) + except Exception: + return None + return _parse_sqlite_url(raw) + + +def _localstore_db_path() -> Path | None: + """nonebot_plugin_orm 默认库:localstore 数据目录下的 db.sqlite3。""" + try: + from nonebot_plugin_localstore import get_data_dir + + return get_data_dir("nonebot_plugin_orm") / "db.sqlite3" + except Exception: + return None + + +def resolve_db_path() -> Path: + """解析本地消息库路径:环境变量 → ORM 配置 → localstore → 仓库默认位置。""" + override = os.environ.get(DB_PATH_ENV) + if override: + return Path(override).expanduser() + for candidate in (_driver_db_path(), _localstore_db_path()): + if candidate is not None: + return candidate + # hexi/plugins//history_store.py -> hexi/ + return ( + Path(__file__).resolve().parents[2] + / "data" + / "nonebot_plugin_orm" + / "db.sqlite3" + ) + + +def _open_readonly(path: Path) -> sqlite3.Connection: + """只读打开。路径可能含空格,必须用 as_uri 转义后的 file URI。""" + con = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=3.0) + con.row_factory = sqlite3.Row + return con + + +def _loads(raw: Any) -> dict[str, Any]: + if not raw: + return {} + if isinstance(raw, dict): + return raw + try: + data = json.loads(raw) + except (TypeError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _load_display_names(con: sqlite3.Connection, group_id: int) -> dict[str, tuple[str, str | None]]: + """取本群成员的 (昵称, 群名片);uninfo 表缺失时返回空表。""" + names: dict[str, tuple[str, str | None]] = {} + for row in con.execute(_NAMES_SQL, (str(group_id), SCENE_TYPE_GROUP)): + user_data = _loads(row["user_data"]) + member_data = _loads(row["member_data"]) + nickname = str(user_data.get("name") or "") + card = str(member_data.get("nick") or "") or None + if nickname or card: + names[str(row["user_id"])] = (nickname, card) + return names + + +def _parse_cq(cq: str) -> list[dict[str, Any]]: + """CQ 串转 OneBot 消息段(复用适配器自带解析,含反转义)。""" + try: + from nonebot.adapters.onebot.v11 import Message + + return [{"type": seg.type, "data": dict(seg.data)} for seg in Message(cq)] + except Exception as exc: + logger.debug(f"本地历史 CQ 解析失败,降级为纯文本: {exc}") + return [{"type": "text", "data": {"text": cq}}] + + +def _build_raw_message( + row: sqlite3.Row, names: dict[str, tuple[str, str | None]] +) -> dict[str, Any] | None: + """组装成 adapter._convert_message 能直接消费的 OneBot 原始结构。""" + message_id = row["message_id"] + if message_id is None: + return None + user_id = str(row["user_id"]) + nickname, card = names.get(user_id, ("", None)) + cq = row["raw_message"] or row["message"] or row["plain_text"] or "" + return { + "message_id": message_id, + "time": int(row["time"] or 0), + "sender": {"user_id": user_id, "nickname": nickname, "card": card or ""}, + "message": _parse_cq(cq), + } + + +def _fetch_group_messages( + group_id: str | int, + start_ts: int, + end_ts: int, + limit: int, + db_path: Path | str | None, +) -> LocalHistoryResult: + try: + gid = int(group_id) + except (TypeError, ValueError): + return LocalHistoryResult(error=f"群号非法: {group_id!r}") + try: + row_limit = max(1, int(limit)) + except (TypeError, ValueError): + row_limit = 1000 + + path = Path(db_path) if db_path is not None else resolve_db_path() + if not path.exists(): + return LocalHistoryResult(error=f"本地消息库不存在: {path}") + + start, end = int(start_ts), int(end_ts) + con = _open_readonly(path) + try: + # 多取一条用于判定截断(多出来的那条正是窗口内最旧的消息) + rows = con.execute(_MESSAGE_SQL, (gid, start, end, row_limit + 1)).fetchall() + truncated = len(rows) > row_limit + if truncated: + rows = rows[:row_limit] + window_total = None + if truncated: + try: + window_total = int(con.execute(_COUNT_SQL, (gid, start, end)).fetchone()[0]) + except (sqlite3.Error, TypeError, ValueError): + window_total = None + try: + names = _load_display_names(con, gid) + except sqlite3.Error as exc: + logger.debug(f"本地历史昵称补齐失败(忽略): {exc}") + names = {} + finally: + con.close() + + messages: list[dict[str, Any]] = [] + seen: set[str] = set() + duplicates = 0 + names_resolved = 0 + for row in rows: + raw = _build_raw_message(row, names) + if raw is None: + continue + message_id = str(raw["message_id"]) + if not message_id: + continue + if message_id in seen: + duplicates += 1 + continue + seen.add(message_id) + sender = raw["sender"] + if sender["nickname"] or sender["card"]: + names_resolved += 1 + messages.append(raw) + # 查询按 (time, id) 倒序取最近 N 条,翻回时间升序 + messages.reverse() + return LocalHistoryResult( + messages=messages, + truncated=truncated, + window_total=window_total, + names_resolved=names_resolved, + duplicates=duplicates, + ) + + +def fetch_group_messages( + group_id: str | int, + start_ts: int, + end_ts: int, + limit: int = 1000, + db_path: Path | str | None = None, +) -> LocalHistoryResult: + """读取 [start_ts, end_ts] 闭区间内的群消息(时间升序,最多 limit 条)。 + + 同步函数,调用方用 `asyncio.to_thread` 包;失败只返回带 error 的空结果。 + """ + try: + return _fetch_group_messages(group_id, start_ts, end_ts, limit, db_path) + except Exception as exc: # 兜底:本地库问题不该影响分析主链路 + logger.warning(f"本地历史读取异常: {exc}", exc_info=True) + return LocalHistoryResult(error=str(exc)) diff --git a/hexi/plugins/nonebot_plugin_group_daily_analysis/service.py b/hexi/plugins/nonebot_plugin_group_daily_analysis/service.py index 2bf59e5..573ecde 100644 --- a/hexi/plugins/nonebot_plugin_group_daily_analysis/service.py +++ b/hexi/plugins/nonebot_plugin_group_daily_analysis/service.py @@ -119,6 +119,7 @@ def register_bot_adapter(bot: Any, platform_id: str | None = None) -> OneBotAdap "platform_id": platform_id or "onebot", "bot_self_ids": bot_self_ids, "filter_bot_messages": config_manager.get_filter_bot_messages(), + "use_local_history": config_manager.get_use_local_history(), }, ) bot_manager.register_adapter(adapter, platform_id or "onebot") diff --git a/hexi/plugins/nonebot_plugin_helldivers_tools/handlers/war.py b/hexi/plugins/nonebot_plugin_helldivers_tools/handlers/war.py index edeb9d7..2462f12 100644 --- a/hexi/plugins/nonebot_plugin_helldivers_tools/handlers/war.py +++ b/hexi/plugins/nonebot_plugin_helldivers_tools/handlers/war.py @@ -14,6 +14,7 @@ from nonebot.internal.params import ArgPlainText from nonebot.matcher import Matcher from PIL import Image +from hexi.core import message_utils from ..services.equipment import get_equipment_by_combination, get_random_equipment from ..services.hd2_api import get_briefing_data from ..utils import gen_ms_img, pic2b64 @@ -24,7 +25,7 @@ _IMG_DIR = _BASE_DIR / "res" / "img" async def _send_war_card(matcher: Matcher, ev: MessageEvent, top_per_race: Optional[int]) -> None: - await matcher.send("正在获取前线战况,请民主的等待!") + await message_utils.common_proc_reply(ev.message_id) try: data = await get_briefing_data() png = await render_war_briefing(data, top_per_race) diff --git a/hexi/plugins/nonebot_plugin_helldivers_tools/res/img/helldivers/飞鹰毒气空袭.png b/hexi/plugins/nonebot_plugin_helldivers_tools/res/img/helldivers/飞鹰毒气空袭.png new file mode 100644 index 0000000000000000000000000000000000000000..51380798e4611bef2a44253a0522d61d9f300051 GIT binary patch literal 29182 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

7hOcNjO?lh0cqG4(C+1>E1*$&; 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 literal 0 HcmV?d00001 diff --git a/hexi/plugins/nonebot_plugin_regif/handlers/reverse.py b/hexi/plugins/nonebot_plugin_regif/handlers/reverse.py index de49de1..18f3a76 100644 --- a/hexi/plugins/nonebot_plugin_regif/handlers/reverse.py +++ b/hexi/plugins/nonebot_plugin_regif/handlers/reverse.py @@ -8,6 +8,7 @@ from nonebot.log import logger from nonebot.plugin.on import on_keyword from nonebot_plugin_alconna import UniMessage +from hexi.core import message_utils from hexi.core.message_utils import get_reply_message from ..services.gif import reverse_gif_bytes @@ -21,10 +22,11 @@ re_gif = on_keyword({"倒放"}) async def rev_gif(event: MessageEvent, bot: Bot): # 情况1,用户对需要倒放的gif进行回复 messages = get_reply_message(event) - await match_revgif(messages) + message_id = event.message_id + await match_revgif(messages,message_id) -async def match_revgif(messages): +async def match_revgif(messages,message_id): img_urls = [] for message in messages: logger.info(f"遍历消息: {message}") @@ -38,7 +40,7 @@ async def match_revgif(messages): return for url in img_urls: logger.info(f"获取到的图片链接:{url}") - await UniMessage.text("ℹ正在翻转图片序列,请稍候").send() + await message_utils.common_proc_reply(message_id) data, err = await reverse_gif_bytes(img_urls) if err: await UniMessage.text(err).send() diff --git a/hexi/plugins/nonebot_plugin_steam_info/steam.py b/hexi/plugins/nonebot_plugin_steam_info/steam.py index 1e05d7c..cf51d63 100644 --- a/hexi/plugins/nonebot_plugin_steam_info/steam.py +++ b/hexi/plugins/nonebot_plugin_steam_info/steam.py @@ -203,7 +203,7 @@ def format_api_key_stats(stats: Dict) -> str: stats 结构: {key: {"call_count": int, "last_called": str | None}} """ - lines = ["Steam API key 使用统计:"] + lines = ["\nSteam API key 使用统计:"] for key, info in stats.items(): call_count = info.get("call_count", 0) last_called = info.get("last_called") diff --git a/hexi/resource/imgs/thinking.gif b/hexi/resource/imgs/thinking.gif new file mode 100644 index 0000000000000000000000000000000000000000..9b4d192ef1440f6e5446895de43acb7cfc73199c GIT binary patch literal 36626 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 literal 0 HcmV?d00001 diff --git a/hexi/web/src/api/client.ts b/hexi/web/src/api/client.ts index 165025b..7846127 100644 --- a/hexi/web/src/api/client.ts +++ b/hexi/web/src/api/client.ts @@ -140,13 +140,17 @@ function makeSSEStream(opts: SSEStreamOpts): () => void { export function hubLogStream(opts: { since: number onLine: (line: string) => void + // 服务端检测到日志被轮转/清空时回调,调用方应丢弃已失效的旧窗口 + onReset?: () => void onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void }): () => void { let since = opts.since return makeSSEStream({ url: () => '/hub/api/logs/stream?since=' + since, onData: (obj) => { - if (obj && obj.line !== undefined) { + if (!obj) return + if (obj.reset) { opts.onReset?.(); return } + if (obj.line !== undefined) { opts.onLine(obj.line) if (typeof obj.offset === 'number') since = obj.offset } diff --git a/hexi/web/src/connection.tsx b/hexi/web/src/connection.tsx index 9429c06..6ac863b 100644 --- a/hexi/web/src/connection.tsx +++ b/hexi/web/src/connection.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' import { hubDashboardStream } from './api/client' -export type ConnStatus = '连接中' | '实时' | '重连中' | '已断开' +export type ConnStatus = '连接中' | '已连接' | '重连中' | '已断开' interface ConnState { status: ConnStatus @@ -28,7 +28,7 @@ export function ConnectionProvider({ children }: { children: ReactNode }) { if (d && d.ok !== false) { setData(d); setError(''); setLastUpdate(Date.now()) } else if (d && d.ok === false) setError(d.msg || '采集失败') }, - onStatus: (s) => setStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'), + onStatus: (s) => setStatus(s === 'open' ? '已连接' : s === 'reconnecting' ? '重连中' : '已断开'), }) return dispose }, []) diff --git a/hexi/web/src/layout/AppLayout.tsx b/hexi/web/src/layout/AppLayout.tsx index 5649030..fe3d7dd 100644 --- a/hexi/web/src/layout/AppLayout.tsx +++ b/hexi/web/src/layout/AppLayout.tsx @@ -192,9 +192,9 @@ export function AppLayout() {

- + {status} {lastUpdate && {new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}} diff --git a/hexi/web/src/logs.tsx b/hexi/web/src/logs.tsx index 8b74348..a34100d 100644 --- a/hexi/web/src/logs.tsx +++ b/hexi/web/src/logs.tsx @@ -1,22 +1,36 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react' import { hubLogs, hubLogStream } from './api/client' -// 保留的最大行数(窗口较大以容纳「加载更早」预置的历史行) -const MAX_LINES = 6000 +// 渲染窗口:日志页**始终只渲染这么多行**,先入先出——新行从尾部挤入,最旧的行从头部被挤掉。 +// 行数直接决定每次更新的 DOM 协调与布局开销,是页面的主要成本,不要随意调大。 +const WINDOW_LINES = 1000 +// 「向前翻页」每次额外取的行数:比窗口小,翻页时保留一半旧内容做重叠, +// 配合浏览器的滚动锚定,视线里的那行不会跳走。 +const PAGE_LINES = 500 + +// 新行合并刷新间隔(ms):日志突发时避免「一行一次 setState」 +const FLUSH_MS = 120 + +export interface LogLine { id: number; text: string } export interface LogInfo { file: string; size: number; total: number } interface LogState { - lines: string[] + lines: LogLine[] info: LogInfo error: string loading: boolean hasMore: boolean loadingEarlier: boolean - paused: boolean - setPaused: (v: boolean) => void + /** true = 自动滚动(跟随最新,新行实时入列);false = 冻结视图(新行暂存,回到底部再补上) */ + follow: boolean + setFollow: (v: boolean) => void + /** 自动换行:false 时长行不折行,改为横向滚动 */ + wrap: boolean + setWrap: (v: boolean) => void refresh: () => Promise - loadEarlier: () => Promise + /** 向前翻一页;返回本次前置进来的行数(0 表示没翻成),调用方据此把视图锚回原处 */ + loadEarlier: () => Promise } const Ctx = createContext({ @@ -26,62 +40,110 @@ const Ctx = createContext({ loading: false, hasMore: false, loadingEarlier: false, - paused: false, - setPaused: () => {}, + follow: true, + setFollow: () => {}, + wrap: true, + setWrap: () => {}, refresh: async () => {}, - loadEarlier: async () => {}, + loadEarlier: async () => 0, }) /** * 日志数据与日志流常驻 Provider:首次进入(任意页面)时拉取一次, * SSE 流保持不断,页面之间切换只读缓存数据,不重建连接、不显示加载动画。 + * + * 窗口模型:内存里始终只有 WINDOW_LINES 行(先入先出)。 + * - 自动滚动:新行进窗口尾部,最旧的行被挤掉(tail -f 行为) + * - 冻结视图:新行只进暂存区(同样保留最新 WINDOW_LINES 行),窗口不变, + * 回到底部时一次性补上 + * - 向前翻页:滚动到顶部时取更早的 PAGE_LINES 行,从尾部挤掉同样多的新行, + * 窗口行数不变(反过来的先入先出) */ export function LogProvider({ children }: { children: ReactNode }) { - const [lines, setLines] = useState([]) + const [lines, setLines] = useState([]) const [info, setInfo] = useState({ file: '', size: 0, total: 0 }) const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [hasMore, setHasMore] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) - const [paused, setPaused] = useState(false) + const [follow, setFollow] = useState(true) + const [wrap, setWrap] = useState(true) const sinceRef = useRef(0) const earliestRef = useRef(0) - const pausedRef = useRef(false) - const pendingRef = useRef([]) + const followRef = useRef(true) + const frozenRef = useRef([]) const disposeRef = useRef<(() => void) | null>(null) const loadingEarlierRef = useRef(false) - const append = useCallback((line: string) => { - setLines(prev => [...prev, line].slice(-MAX_LINES)) + // 行 id:作为 React key 保持稳定(用下标做 key 时,窗口一滑动整列表都要 diff) + const idRef = useRef(0) + // 待入列的新行与合并刷新定时器 + const bufferRef = useRef([]) + const timerRef = useRef(null) + + const toItems = useCallback((texts: string[]): LogLine[] => { + const items: LogLine[] = [] + for (const text of texts) items.push({ id: idRef.current++, text }) + return items }, []) + const flush = useCallback(() => { + timerRef.current = null + const buffered = bufferRef.current + if (!buffered.length) return + bufferRef.current = [] + // 一次涌入超过整个窗口时(日志突发/整文件重放),只有最后 WINDOW_LINES 行留得下, + // 先裁掉前面的,避免为注定被挤掉的行白渲染一遍 + const kept = buffered.length > WINDOW_LINES ? buffered.slice(-WINDOW_LINES) : buffered + // 副作用放在 setState 之外:StrictMode 下 updater 可能被调用两次 + const items = toItems(kept) + setLines(prev => { + const next = prev.concat(items) + return next.length > WINDOW_LINES ? next.slice(-WINDOW_LINES) : next + }) + }, [toItems]) + + const enqueue = useCallback((line: string) => { + if (!followRef.current) { + // 冻结视图期间只暂存,且同样只保留最新 WINDOW_LINES 行 + frozenRef.current.push(line) + if (frozenRef.current.length > WINDOW_LINES) { + frozenRef.current.splice(0, frozenRef.current.length - WINDOW_LINES) + } + return + } + bufferRef.current.push(line) + if (timerRef.current == null) timerRef.current = window.setTimeout(flush, FLUSH_MS) + }, [flush]) + const connect = useCallback(() => { if (disposeRef.current) disposeRef.current() disposeRef.current = hubLogStream({ since: sinceRef.current, - onLine: (line) => { - if (pausedRef.current) { - pendingRef.current.push(line) - // 暂停期间只保留最近若干行,避免恢复时一次性渲染超大数组 - if (pendingRef.current.length > 500) pendingRef.current.splice(0, pendingRef.current.length - 500) - return - } - append(line) + onLine: enqueue, + onReset: () => { + // 服务端日志被轮转/清空:丢弃已失效的旧窗口,从新文件继续 + if (timerRef.current != null) { window.clearTimeout(timerRef.current); timerRef.current = null } + bufferRef.current = [] + frozenRef.current = [] + setLines([]) + earliestRef.current = 0 + setHasMore(false) }, }) - }, [append]) + }, [enqueue]) const refresh = useCallback(async () => { setLoading(true) setError('') try { - const d = await hubLogs(500) + const d = await hubLogs(WINDOW_LINES) if (!d) throw new Error('读取日志失败') if (d.ok === false) throw new Error(d.msg || '读取日志失败') - setLines(d.lines || []) + setLines(toItems(d.lines || [])) setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 }) - // stream 从文件尾续读(end);「加载更早」从窗口起点向前翻(offset) + // stream 从文件尾续读(end);向前翻页从窗口起点继续往前(offset) sinceRef.current = d.end || 0 earliestRef.current = d.offset || 0 setHasMore((d.offset || 0) > 0) @@ -89,47 +151,61 @@ export function LogProvider({ children }: { children: ReactNode }) { } catch (e: any) { setError(e.message || '读取日志失败') } finally { setLoading(false) } - }, [connect]) + }, [connect, toItems]) - const loadEarlier = useCallback(async () => { + const loadEarlier = useCallback(async (): Promise => { const before = earliestRef.current - if (!before || loadingEarlierRef.current) return + if (!before || loadingEarlierRef.current) return 0 loadingEarlierRef.current = true setLoadingEarlier(true) setError('') try { - const d = await hubLogs(500, before) + const d = await hubLogs(PAGE_LINES, before) if (!d || d.ok === false) throw new Error((d && d.msg) || '读取日志失败') - setLines(prev => [...(d.lines || []), ...prev].slice(-MAX_LINES)) + const items = toItems(d.lines || []) + setLines(prev => { + const next = items.concat(prev) + // 往前翻时从尾部(较新的行)挤掉:窗口行数不变,新的行让位给历史 + return next.length > WINDOW_LINES ? next.slice(0, WINDOW_LINES) : next + }) earliestRef.current = d.offset || 0 setHasMore((d.offset || 0) > 0) + return items.length } catch (e: any) { setError(e.message || '读取日志失败') + return 0 } finally { loadingEarlierRef.current = false setLoadingEarlier(false) } - }, []) + }, [toItems]) // 首次进入就建立并保持;provider 卸载(登出/离开 /hub)时才断开 useEffect(() => { refresh() - return () => { if (disposeRef.current) disposeRef.current() } + return () => { + if (disposeRef.current) disposeRef.current() + // 组件卸载时清掉待刷新定时器,避免对已卸载组件 setState + if (timerRef.current != null) { window.clearTimeout(timerRef.current); timerRef.current = null } + } }, [refresh]) - useEffect(() => { pausedRef.current = paused }, [paused]) - useEffect(() => { - // 恢复接收时,把暂停期间暂存的行补进来 - if (!paused && pendingRef.current.length) { - const flush = pendingRef.current - pendingRef.current = [] - setLines(prev => [...prev, ...flush].slice(-MAX_LINES)) + followRef.current = follow + // 恢复自动滚动时,把冻结期间暂存的行一次性补上(仍只保留 WINDOW_LINES 行) + if (follow && frozenRef.current.length) { + const frozen = frozenRef.current + frozenRef.current = [] + const items = toItems(frozen) + setLines(prev => { + const next = prev.concat(items) + return next.length > WINDOW_LINES ? next.slice(-WINDOW_LINES) : next + }) } - }, [paused]) + }, [follow, toItems]) return ( - + {children} ) diff --git a/hexi/web/src/pages/logs/index.tsx b/hexi/web/src/pages/logs/index.tsx index 830f67e..5db1999 100644 --- a/hexi/web/src/pages/logs/index.tsx +++ b/hexi/web/src/pages/logs/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import { Button, Card, Spinner } from '@heroui/react' import { useLogs } from '../../logs' import { fmtBytes } from '../../lib/format' @@ -67,7 +67,9 @@ function parseAnsi(text: string): { text: string; style: CSSProperties }[] { return out } -function AnsiLine({ text }: { text: string }) { +// 记忆化:窗口内已渲染过的行必须能整体跳过,否则每次更新都要把整窗口的 +// ANSI 解析 + DOM 协调重做一遍。 +const AnsiLine = memo(function AnsiLine({ text }: { text: string }) { const parts = parseAnsi(text) const hasAnsi = parts.some((p) => Boolean(p.style.color || p.style.backgroundColor || p.style.fontWeight || p.style.textDecoration)) if (hasAnsi) { @@ -78,13 +80,16 @@ function AnsiLine({ text }: { text: string }) { const color = m && LEVEL_COLORS[m[1]] if (color) return {text} return <>{text} -} +}) export default function LogsPage() { // 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈 - const { lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, loadEarlier } = useLogs() - const [follow, setFollow] = useState(true) + const { lines, info, error, loading, hasMore, loadingEarlier, follow, setFollow, wrap, setWrap, loadEarlier } = useLogs() const scrollRef = useRef(null) + // 一次「滚到顶」只翻一页,必须离开顶部后才会再次触发(避免连环翻页) + const topLoadBlockRef = useRef(false) + // 本次前置进来的行数:等 DOM 更新后据此把视图锚回原来的位置 + const pendingAnchorRef = useRef(null) useEffect(() => { if (follow && scrollRef.current) { @@ -92,19 +97,56 @@ export default function LogsPage() { } }, [lines, follow]) + // 向前翻页后把新行(它们被插在顶部)往上推出视野,让原本正在看的那一行留在原处。 + // 窗口行数固定,翻页前后总高度几乎不变,浏览器自带的滚动锚定在 scrollTop=0 时也不生效, + // 所以这里手动按元素位置锚定。 + useEffect(() => { + const n = pendingAnchorRef.current + if (n == null) return + const el = scrollRef.current + if (!el) { pendingAnchorRef.current = null; return } + const first = el.firstElementChild as HTMLElement | null + const target = el.children[n] as HTMLElement | undefined + if (!first || !target) return // DOM 还没更新,等下一次 lines 变化再试 + el.scrollTop = target.offsetTop - first.offsetTop + pendingAnchorRef.current = null + }, [lines]) + + const handleLoadEarlier = useCallback(async () => { + const n = await loadEarlier() + if (n > 0) pendingAnchorRef.current = n + }, [loadEarlier]) + + const onScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + if (atBottom) { + // 回到底部即恢复跟随(否则新日志会把视图拽走,永远看不了历史) + if (!follow) setFollow(true) + return + } + if (follow) setFollow(false) + if (el.scrollTop > 8) { + topLoadBlockRef.current = false + return + } + if (!topLoadBlockRef.current && hasMore && !loadingEarlier) { + topLoadBlockRef.current = true + handleLoadEarlier() + } + }, [follow, hasMore, loadingEarlier, handleLoadEarlier, setFollow]) + return (

查看日志

-

实时读取 bot 运行日志(SSE 推送)

+

实时读取 bot 运行日志(SSE 推送,固定窗口滚动,向上滚动自动加载更早)

- {hasMore && ( - - )} - - + +
@@ -113,16 +155,25 @@ export default function LogsPage() {
- 日志流 + + 日志流 + {loadingEarlier && 加载更早…} + {info.file && {info.file} · {fmtBytes(info.size)} · 共 {info.total} 行}
-
+
{loading && !lines.length ? (
- ) : lines.length ? lines.map((ln, i) => ( -
+ ) : lines.length ? lines.map(ln => ( +
)) : (
暂无日志…
)} diff --git a/hexi/web_hub/__init__.py b/hexi/web_hub/__init__.py index 72569ed..ca3faf4 100644 --- a/hexi/web_hub/__init__.py +++ b/hexi/web_hub/__init__.py @@ -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( "

HeXi Web Hub

前端未构建,请在 hexi/web 执行 " "npm run build。

" @@ -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 diff --git a/hexi/web_hub/dashboard.py b/hexi/web_hub/dashboard.py index 2de528e..77d2e95 100644 --- a/hexi/web_hub/dashboard.py +++ b/hexi/web_hub/dashboard.py @@ -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") diff --git a/tests/test_group_daily_history_store.py b/tests/test_group_daily_history_store.py new file mode 100644 index 0000000..8eef99f --- /dev/null +++ b/tests/test_group_daily_history_store.py @@ -0,0 +1,284 @@ +"""群分析本地消息源 (history_store) 单元测试 + +history_store 只依赖标准库,故用 importlib 按文件路径裸加载 +(与 test_rate_limit.py 同法),避免触发插件包 __init__ 的 NoneBot 初始化。 +""" + +import importlib.util +import json +import sqlite3 +import sys +from pathlib import Path + +import pytest + +_MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "hexi" + / "plugins" + / "nonebot_plugin_group_daily_analysis" + / "history_store.py" +) + +GROUP_ID = 872490448 +BASE_TS = 1_800_000_000 + + +@pytest.fixture(scope="module") +def hs(): + """以独立模块名加载 history_store.py,避免与插件包 __init__ 冲突""" + spec = importlib.util.spec_from_file_location( + "history_store_under_test", _MODULE_PATH + ) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + yield module + sys.modules.pop(spec.name, None) + + +def _create_db(path: Path, *, with_uninfo: bool = True) -> sqlite3.Connection: + """按真实列名建 learning_chat_message(可选带 uninfo 三表)""" + con = sqlite3.connect(path) + con.execute( + """ + CREATE TABLE learning_chat_message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + message_id BIGINT NOT NULL, + message TEXT NOT NULL, + raw_message TEXT NOT NULL, + plain_text TEXT NOT NULL, + time INTEGER NOT NULL + ) + """ + ) + con.execute( + "CREATE INDEX ix_message_group_time" + " ON learning_chat_message (group_id, time)" + ) + if with_uninfo: + con.execute( + "CREATE TABLE nonebot_plugin_uninfo_scenemodel" + " (id INTEGER PRIMARY KEY, scene_id VARCHAR(64), scene_type INTEGER)" + ) + con.execute( + "CREATE TABLE nonebot_plugin_uninfo_sessionmodel" + " (id INTEGER PRIMARY KEY, scene_persist_id INTEGER," + " user_persist_id INTEGER, member_data JSON)" + ) + con.execute( + "CREATE TABLE nonebot_plugin_uninfo_usermodel" + " (id INTEGER PRIMARY KEY, user_id VARCHAR(64), user_data JSON)" + ) + return con + + +def _add_message( + con: sqlite3.Connection, + *, + message_id: int, + user_id: int = 1001, + ts: int = BASE_TS, + raw: str = "hi", + plain_text: str | None = None, + group_id: int = GROUP_ID, +) -> None: + con.execute( + "INSERT INTO learning_chat_message" + " (group_id, user_id, message_id, message, raw_message, plain_text, time)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + group_id, + user_id, + message_id, + raw, + raw, + raw if plain_text is None else plain_text, + ts, + ), + ) + + +def _add_member(con, user_id: str, nickname: str, card: str | None) -> None: + """写入 uninfo 场景/用户/会话三条记录(群场景 scene_type=1)""" + con.execute( + "INSERT OR IGNORE INTO nonebot_plugin_uninfo_scenemodel" + " (id, scene_id, scene_type) VALUES (1, ?, 1)", + (str(GROUP_ID),), + ) + cur = con.execute( + "INSERT INTO nonebot_plugin_uninfo_usermodel (user_id, user_data)" + " VALUES (?, ?)", + (user_id, json.dumps({"name": nickname, "nick": ""}, ensure_ascii=False)), + ) + con.execute( + "INSERT INTO nonebot_plugin_uninfo_sessionmodel" + " (scene_persist_id, user_persist_id, member_data) VALUES (1, ?, ?)", + (cur.lastrowid, json.dumps({"nick": card or ""}, ensure_ascii=False)), + ) + + +def _build(path: Path, rows: list[dict], *, with_uninfo: bool = True) -> None: + con = _create_db(path, with_uninfo=with_uninfo) + for row in rows: + _add_message(con, **row) + con.commit() + con.close() + + +def test_cq_parsed_into_onebot_segments(hs, tmp_path): + db = tmp_path / "cq.db" + _build( + db, + [ + { + "message_id": 1, + "raw": "[CQ:reply,id=5][CQ:at,qq=2]hi" + "[CQ:image,file=a.jpg,url=http://x?a=1&b=2]", + } + ], + ) + + res = hs.fetch_group_messages(GROUP_ID, BASE_TS - 1, BASE_TS + 1, 100, db_path=db) + + assert res and res.error is None + segs = res.messages[0]["message"] + assert [s["type"] for s in segs] == ["reply", "at", "text", "image"] + assert segs[0]["data"] == {"id": "5"} + assert segs[1]["data"] == {"qq": "2"} + assert segs[2]["data"] == {"text": "hi"} + assert segs[3]["data"]["url"] == "http://x?a=1&b=2" # CQ 反转义 + # 交给 adapter._convert_message 的字段形状 + assert res.messages[0]["sender"]["user_id"] == "1001" + + +def test_window_is_inclusive(hs, tmp_path): + db = tmp_path / "window.db" + _build( + db, + [ + {"message_id": 1, "ts": BASE_TS - 1}, + {"message_id": 2, "ts": BASE_TS}, + {"message_id": 3, "ts": BASE_TS + 1}, + ], + ) + + res = hs.fetch_group_messages(GROUP_ID, BASE_TS, BASE_TS, 100, db_path=db) + + # 两端闭区间:起始时间戳上的消息必须被取到(增量水位依赖这一点) + assert [m["message_id"] for m in res.messages] == [2] + + +def test_same_second_sorted_by_id_ascending(hs, tmp_path): + db = tmp_path / "order.db" + _build( + db, + [ + {"message_id": 11, "ts": BASE_TS}, + {"message_id": 12, "ts": BASE_TS}, + {"message_id": 13, "ts": BASE_TS}, + ], + ) + + res = hs.fetch_group_messages(GROUP_ID, BASE_TS, BASE_TS, 100, db_path=db) + + assert [m["message_id"] for m in res.messages] == [11, 12, 13] + + +def test_duplicate_message_id_deduped(hs, tmp_path): + db = tmp_path / "dup.db" + _build(db, [{"message_id": 7}, {"message_id": 7}]) + + res = hs.fetch_group_messages(GROUP_ID, BASE_TS - 1, BASE_TS + 1, 100, db_path=db) + + assert len(res.messages) == 1 + assert res.duplicates == 1 + + +def test_limit_truncates_and_keeps_newest(hs, tmp_path): + db = tmp_path / "trunc.db" + _build( + db, + [ + {"message_id": 1, "ts": BASE_TS}, + {"message_id": 2, "ts": BASE_TS + 1}, + {"message_id": 3, "ts": BASE_TS + 2}, + ], + ) + + res = hs.fetch_group_messages(GROUP_ID, BASE_TS - 1, BASE_TS + 5, 2, db_path=db) + + assert res.truncated is True + assert res.window_total == 3 + assert [m["message_id"] for m in res.messages] == [2, 3] # 丢最旧的 + + +def test_names_resolved_from_uninfo(hs, tmp_path): + db = tmp_path / "names.db" + con = _create_db(db) + _add_member(con, "1001", "昵称甲", "群名片甲") + _add_member(con, "1002", "昵称乙", "") + _add_message(con, message_id=1, user_id=1001) + _add_message(con, message_id=2, user_id=1002) + _add_message(con, message_id=3, user_id=1003) # 未在 uninfo 中 + con.commit() + con.close() + + res = hs.fetch_group_messages(GROUP_ID, BASE_TS - 1, BASE_TS + 1, 100, db_path=db) + + senders = {m["message_id"]: m["sender"] for m in res.messages} + assert senders[1]["nickname"] == "昵称甲" + assert senders[1]["card"] == "群名片甲" + assert senders[2]["nickname"] == "昵称乙" + assert senders[2]["card"] == "" # 空群名片交给 _convert_message 归一为 None + assert senders[3]["nickname"] == "" and senders[3]["card"] == "" + assert res.names_resolved == 2 + + # 群名片空串在源数据里就归一为 None + con = hs._open_readonly(db) + try: + assert hs._load_display_names(con, GROUP_ID)["1002"] == ("昵称乙", None) + finally: + con.close() + + +def test_missing_db_reports_error_without_creating(hs, tmp_path): + missing = tmp_path / "nope.sqlite3" + + res = hs.fetch_group_messages( + GROUP_ID, BASE_TS - 1, BASE_TS + 1, 10, db_path=missing + ) + + assert not res + assert res.error and "不存在" in res.error + assert not missing.exists() # mode=ro 不得建库 + + +def test_missing_table_is_not_fatal(hs, tmp_path): + db = tmp_path / "empty.db" + con = sqlite3.connect(db) + con.execute("CREATE TABLE unrelated (x INTEGER)") + con.commit() + con.close() + + res = hs.fetch_group_messages(GROUP_ID, BASE_TS - 1, BASE_TS + 1, 10, db_path=db) + + assert not res + assert res.error and "learning_chat_message" in res.error + + +def test_other_group_filtered_out(hs, tmp_path): + db = tmp_path / "other.db" + _build( + db, + [ + {"message_id": 1}, + {"message_id": 2, "group_id": GROUP_ID + 1}, + ], + ) + + res = hs.fetch_group_messages(GROUP_ID, BASE_TS - 1, BASE_TS + 1, 100, db_path=db) + + assert [m["message_id"] for m in res.messages] == [1]