Compare commits

...
14 Commits
Author SHA1 Message Date
sansenhoshiandClaude Code 614e1d7e00 chore(bf-bot): 更新 BF 系列通知图
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:17 +08:00
sansenhoshiandClaude Code fc2e542418 chore(deps): 加载 nonebot_plugin_doroending
插件已装进 venv,补进 pyproject 的 plugins 列表才会被 nonebot 加载。
已用 bot.py 同款初始化流程(不含 nonebot.run)验证:全部插件加载成功,
doroending 注册 4 个 matcher。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:17 +08:00
sansenhoshiandClaude Code 023dc1cb5d feat(web): 停止/重启 bot 的等待相位全局化 / 覆盖层收进 BotActionProvider
重启入口有两个 —— 顶栏账号菜单(任何页面可点)和设置页「进程控制」。以前两边
各写一套:设置页只有本页的 fixed 覆盖层,顶栏只弹个 toast。于是从插件页/日志页
点「重启 bot」时界面照常可点,请求全打在已经退出的旧进程上。

- 新增 src/botAction.tsx:Provider 持 phase(null / stop / restart)与 readyCount,
  覆盖层由 AppLayout 在 <Outlet/> 之外统一渲染,任何页面触发都盖得住;
  纯等待逻辑仍在 lib/botAction.ts,两边共用一份
- 设置页删掉本地的 restarting 态与两份覆盖层,只负责发起 + 用 phase 禁用按钮;
  改为消费 readyCount 在回连后重新拉数据(停机期间配置是旧的)
- 未挂载 Provider 时 run() 直接抛错,避免静默无覆盖层

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:15 +08:00
sansenhoshiandClaude Code 574216645c feat(video-analysis): 本地直发成功且群策略要下载链接时补传 S3
本地直发经 hexi/core/outbound_media 钩子改写后已真正可用,此前它因协议端不在本机
恒失败,public_url 因此总有值(每次实际都落到第 2 级 S3)。本地通道通了以后若不补,
public_url 会恒为 None,entry.py 的 `if pub and policy.sends_link` 会让「发送下载
链接」静默失效。

- _local_sent / _public_link_for 把「取链接」与「发送」解耦:只有策略真要链接
  (policy.sends_link)时才补一次 S3 上传,其余情况一概不碰 S3 —— 媒体不再进
  S3 归档是有意的,temp 保留交给 cleanup
- 补传失败只记日志返回 None:发送本身已经成功,不能反过来判失败
- tests/test_video_sender_link.py:覆盖 sends_link 开/关、多文件逐个补传、
  拿不到 public_url、上传抛异常、policy 为 None 五条路径

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:12 +08:00
sansenhoshiandClaude Code ed75a66982 feat(core): 出站媒体内联化全局钩子 / 本地文件改写为协议端可达 URL
协议端不在本机,而 NoneBot 的 f2s() 会把 Path 转成 file:///D:/...(file:// 的
语义是「协议端那台机器上的路径」),必然 ENOENT。受影响的不止本仓调用点,还有
装不了改不动的 pip 社区插件(本次报错来自 nonebot_plugin_doroending),只能全局拦截。

- 新增 hexi/core/outbound_media.py:挂 Bot.on_calling_api(公开钩子、适配器不覆盖),
  在真正调用适配器前原地改写发送类 API 的 data:默认改写成
  http://<本机可达IP>:<端口>/media/<token> 由协议端主动回拉(地址与 /hub 首页
  「协议端配对」同源,零新配置),拿不到可达地址时回退 base64://
- 文件上传类(upload_group_file / upload_private_file)单独处理:file 在顶层且
  只做 URL 不做 base64(几十上百 MB 不适合内联),覆盖 video_analysis 群文件
  「本地直传」兜底与 alconna 的 $onebot11:file
- 媒体路由 get_app().mount("/media", sub) 复用现有端口不新开服务;免鉴权是必须的
  (协议端登录不了 hub),安全模型改为「路径由 bot 改写那一刻自己登记、请求方只能
  出示不可猜 token」,配短 TTL;_mounted 为 False 时绝不发 URL
- 钩子内每个段独立 try/except,任何异常只告警不抛,绝不反过来把发送搞挂
- message_utils.common_proc_reply 占位图不再自己转 base64,改传 path= 交给钩子,
  避免两处各有一套策略
- tests/test_outbound_media.py:用 tmp_path 造真实文件而非 mock 文件系统,重点覆盖
  file:// 解析(空格/中文编码、手工未转义的字面 %XX)与 walker 各消息形态

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:09 +08:00
sansenhoshiandClaude Code 4b5e8b8b2e feat(web): 顶栏账号菜单接入 bot 启停 / 插件页分区 / 首页协议端配对信息
- 重启/关闭 bot 从设置页提到顶栏账号下拉(破坏性操作弹确认框),等待回连逻辑抽成
  lib/botAction.ts 供两处共用:先等旧进程让出端口,再以 dashboard SSE 真收到一帧为准,
  只看 HTTP 200 会在"端口起来但插件未挂接"时误判就绪
- 插件管理页按 带 Web 页面 / 带配置项 / 都没有 分区,卡片加类型与 Web/配置 徽章
- 首页「BOT 协议端信息」分两半:左半登录账号,右半 NoneBot 接入信息
  (IP / 运行端口 / WS 路径 / 访问令牌,均可一键复制,另给整条反向 WS 地址),
  监听地址非 0.0.0.0 时提示仅本机可连;「BOT 后端信息」移到自己一行
- dashboard.py 新增 endpoint 段:读 driver.config 的 host/port、驱动实际注册的
  反向 WS 路由(自动跳过根路径与尾斜杠别名)、适配器令牌与本机网卡 IP

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:52 +08:00
sansenhoshiandClaude Code 814c1994b6 feat(mc-server-status): 服务器列表 Web 管理页 / 数据层抽离 / mcstatus 13 适配
- services/servers.py:校验 + 读写 + 增删改收敛成一份数据层(顶层不 import nonebot,
  可脱离运行时单测),群命令与 Web API 都只经它改数据,校验不再各写一套
- web_hub.py + /hub/mc_server_status 页面:按群列出服务器,抽屉新增/编辑(改名)/
  测试连接,行内删除(删空一台连带删群);每条路由自带 require_admin
- handlers/server.py 改为薄封装,只在需要命令特有提示时自己判断
- services/mc.py 的图标属性名兼容 mcstatus ≥13 的 icon(旧版叫 favicon,
  读错会把整条查询打成"查询失败")
- tests/test_mc_servers.py:数据层归一/改名/删群/坏文件兜底 + 图标解码两版属性名

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:46 +08:00
sansenhoshiandClaude Code 4badcfcf32 feat(video-analysis): 群策略 v3 / 群文件投递通道 / Web 管理页
- policy.py:per-group 正交策略(自动解析 / 自动策略 / 禁用策略 / 存储 A·B·C /
  公网 / 链接 / 群文件 + 平台限定),list.json v1/v2 → v3 自动迁移,
  写入统一走 PolicyStore(加锁 + .tmp 原子替换 + 字段归一)
- 群文件并行通道 group_file.py:打包 zip(可选 pyzipper AES-256)后优先走 S3 预签名、
  本地直传兜底;设了密码但 pyzipper 不可用就放弃上传,不退化成明文
- list_proc.py 收敛到「视频策略」统一入口,权限判定改走 policy
- Web 管理页 /hub/video_analysis(群策略 + 链接解析面板)与 services/web_jobs.py
  (只复用纯函数层,Web 上下文不发消息;内存任务表 + 并发闸门 + 超时)
- 媒体命名统一到 utils.py({作者}_{作者id}/{作品名}[_短码]),cleanup 回收空目录
- 测试:policy / 命名 / 群文件 / web_jobs 四组

顺带 pyproject 的 pytest 加 testpaths=tests(避免收进 debug/ 下的调试脚本)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:32 +08:00
sansenhoshiandClaude Code 51b08ccb68 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>
2026-09-14 18:59:01 +08:00
sansenhoshiandClaude Code 662c28cb2f chore(deps): 补齐运行时依赖声明
代码里直接 import、但 requirements.txt / pyproject.toml 都没声明的包补齐:
beautifulsoup4、opencv-contrib-python、curl_cffi、yt-dlp、boto3、cloudscraper、
cjk-textwrap、lxml、jieba、ruamel.yaml、pytz、psutil、py-cpuinfo、numpy、
nonebot-plugin-htmlrender、taskgroup(仅 py<3.11)。
其中多数此前只是靠别的包传递依赖带上来的(如 curl_cffi 来自 yt-dlp 的 extra),
换环境或升级就会缺。

同时清理无任何引用的 chardet、gradio_client、aiohttp-retry、qrcode,
并把 playwright 下限与 pyproject 对齐(1.44 → 1.50)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:58:52 +08:00
sansenhoshi 131b92b319 结构调整
视频解析多图/多媒体结构 消息体适配
2026-09-08 14:25:32 +08:00
sansenhoshiandClaude Code 30899688a7 feat(video-analysis): 多文件媒体改走合并转发
抖音 note/小红书等多图、图+视频混合、多视频(动图)作品,下载产物
超过一个文件时直接拼成一条消息发送会失败(视频段不能与其他段混合),
统一改为合并转发: 一个图/视频一个节点,本地文件优先、S3 预签名链接
兜底; 单文件作品保持原有直发链路,多级发送链(本地→S3→回退)不变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 14:21:57 +08:00
sansenhoshi 8c1a106230 新战备 2026-09-03 15:45:14 +08:00
sansenhoshi 2849a6250c 项目改造 2026-09-03 15:44:57 +08:00
183 changed files with 9620 additions and 15279 deletions
+4 -1
View File
@@ -78,7 +78,10 @@ bg.jpg
/.ai/
/.claude/
/hexi/config/
/CLAUDE.md
# ---- 开发文档(插件内 CLAUDE.md / DESIGN.md,含根目录,不入库) ----
**/CLAUDE.md
**/DESIGN.md
# helldivers 图标素材&生成脚本(不入库)
/dev/docs/HD2/
+6 -4
View File
@@ -34,13 +34,13 @@
| 插件 | 功能 | 主要命令 |
| --- | --- | --- |
| `nonebot_plugin_hexi_core` | 核心库:消息解析、权限分级、限流器 (token bucket)、命令冷却、热插件管理 | — |
| `hexi/core`(核心模块) | 核心库:消息解析、权限分级、限流器 (token bucket)、命令冷却、热插件管理(**非插件**) | — |
| `nonebot_plugin_video_analysis` | 视频链接解析 | — |
| `nonebot_plugin_regif` | GIF 注册与处理 | — |
| `nonebot_plugin_picfinder_take` | 识图(Playwright 驱动) | — |
| `nonebot_plugin_picstatus` | 头像状态图生成 | — |
| `nonebot_plugin_group_daily_analysis` | 群聊行为分析报告 | `/群分析` |
| `nonebot_plugin_web_hub` | 统一 Web 管理入口 | — |
| `hexi/web_hub`(核心模块) | 统一 Web 管理入口 `/hub`(**非插件**) | — |
## 快速开始
@@ -88,13 +88,15 @@ poetry run python bot.py
HeXi/
├── bot.py pyproject.toml README.md
├── hexi/
│ ├── plugins/ # 全部插件(nonebot.load_plugins("hexi") 加载)
│ ├── plugins/ # 全部插件(pyproject [tool.nonebot] plugin_dirs 加载)
│ ├── core/ # 机器人核心模块(非插件):工具库/插件控制面/热拔插管理
│ ├── web_hub/ # 机器人核心模块:统一 Web 管理台 + 鉴权/插件注册/配置标准
│ ├── data/ # 运行数据(不入库)
│ ├── config/ # 业务配置(不入库)
│ ├── resource/ # 全局静态资源(fonts/ 字体、menu_config 等)
│ ├── web/ # 统一 Web 前端(React + Vite,构建产物 dist/ 供 /hub 挂载)
│ ├── logs/ # 日志(不入库)
│ └── web_*.py # Web 管理相关(鉴权、插件注册)
│ └── web_hub/ # 见上:统一 Web 管理台/鉴权/插件注册/配置标准
├── docs/ # 插件开发标准文档
├── tests/ # pytest 测试
└── plugin_template/ # 新插件脚手架模板
+6 -2
View File
@@ -55,10 +55,14 @@ driver = nonebot.get_driver()
# 注册适配器
driver.register_adapter(ONEBOTV11Adapter)
# 加载插件
nonebot.load_plugins("hexi")
# 加载插件(hexi/plugins 下的自定义插件 + pyproject 声明的社区插件)
nonebot.load_from_toml("pyproject.toml")
# 核心模块(已从 hexi/plugins 移出,不再是插件):在插件管理器建立后再导入,
# 避免在插件管理器创建前 require 社区插件导致重复注册/“not loaded as a plugin”。
import hexi.core # noqa: F401,E402
import hexi.web_hub # noqa: F401,E402
if __name__ == "__main__":
logger.warning("hexi?启动!")
nonebot.run() # 直接运行,不需要传参
+2
View File
@@ -3,6 +3,8 @@
> 依据 《插件结构标准》(docs/plugin-structure-standard.md) 与 《插件配置文件标准》(docs/plugin-config-standard.md),
> 并参照脚手架模板 `../plugin_template/nonebot_plugin_template`。
> 说明:初版为**审计报告**;后续按用户确认已开始落地改造(见下方「本轮已落地改动」)。
>
> **后续变动**:其中 `hexi_core` 与 `web_hub` 已从 `hexi/plugins/` 移出,改为机器人核心模块 `hexi/core`、`hexi/web_hub`(非插件),本报告中这两项的“插件”表述仅供参考。
---
+16 -12
View File
@@ -20,8 +20,12 @@
```text
hexi/
├── web_config.py # 配置标准核心:schema 注册 / 值库 / 保存热刷新
├── config_standard.py # pydantic Config 一键接入的辅助封装
├── web_hub/ # 统一 Web 管理台 / 鉴权 / 配置标准
│ ├── web_config.py # 配置标准核心:schema 注册 / 值库 / 保存热刷新
│ ├── config_standard.py # pydantic Config 一键接入的辅助封装
│ ├── web_auth.py # 统一 Web 鉴权(OAuth2 + SQLite)
│ ├── web_plugin_registry.py # 统一 Web 插件注册中心
│ └── web_hub_auth.py # 兼容 shim
├── config/
│ └── plugin_config.json # 统一值库:{ "<plugin_id>": { key: value, ... } }
└── web/ # /hub 前端(通用 schema 表单渲染器)
@@ -143,7 +147,7 @@ hexi/
```python
# 在插件 __init__.py 里
from hexi.config_standard import register_model_config
from hexi.web_hub.config_standard import register_model_config
from .config import config
register_model_config(
@@ -168,7 +172,7 @@ register_model_config(
### 方式 B:非 pydantic 插件手动注册
```python
from hexi.web_config import register_plugin_config
from hexi.web_hub.web_config import register_plugin_config
def _get(): # 返回当前生效值 dict
return {"field": get_my_cur_value("field")}
@@ -209,7 +213,7 @@ register_plugin_config(
2. **写值库** `plugin_config.json`(原子写)。
3. **写 .env**:非 list 字段写入 `os.environ` + `../../.env`(保证重启仍生效);list/path/object 跳过,避免 `str(list)` 破坏重启解析。
4. **调 `apply(values)`**:把值热应用到插件运行态对象(list/path/object 也在此生效)。
5. 若插件未提供 apply,则 `hot_reload(plugin_id)` 让插件重载。
5. 若插件未提供 apply,则 `hot_reload(plugin_id)` 让插件重载;**library/非 application 插件不受热重载**(见《插件类型分类》,热拔插会破坏依赖它的插件)。
**为什么 apply 与 env 都做**:NoneBot 的 `get_driver().config` 在启动时即固定,重载插件也读不到新 env;所以运行期必须 apply,重启靠 env。
@@ -220,7 +224,7 @@ register_plugin_config(
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/plugins` | 所有已注册 Web 插件 |
| GET | `/api/plugins/catalog` | 所有应用插件 + `has_config/has_web/web_path` |
| GET | `/api/plugins/catalog` | 所有 application/library 插件(含带配置项的 library)+ `type/has_config/has_web/web_path` |
| GET | `/api/plugins/{id}/config` | 读 `{schema, values, revision}`(登录);secret 值只返回 `****` |
| POST | `/api/plugins/{id}/config` | 保存 `{ "revision": n, "values": { key: value } }`;冲突 409,校验失败 422 |
| DELETE | `/api/plugins/{id}/config` | 清空该插件覆盖,恢复默认(可选) |
@@ -242,7 +246,7 @@ register_plugin_config(
## 9. 权限与安全
- 全部配置接口走 `hexi.web_auth.require_admin`(OAuth2 + SQLite)。
- 全部配置接口走 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite)。
- 配置 POST 必须携带 GET 返回的 `revision`;缺失返回 428,冲突返回 409。
- 只允许 `plugin_id` 存在于注册表,未注册返回 `ok:false`(防任意写入)。
- 部署时必须显式设置 Web 管理员凭据;禁止生产环境使用默认的 `admin/admin`。
@@ -277,7 +281,7 @@ register_plugin_config(
```python
# hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py
from hexi.config_standard import register_model_config
from hexi.web_hub.config_standard import register_model_config
from .config import config as _hd2_config
register_model_config(
@@ -295,7 +299,7 @@ register_model_config(
## 12. 已接入与待接入
- **已接入**:`helldivers_tools`、`mc_server_status`、`video_analysis`、`steam_info`、`picstatus`、`galgame_card`。
- **未接入(建议后续)**:`learning_chat`、`group_daily_analysis`(已有独自 Web 配置,避免冲突)、`picfinder_take`、`bf_bot`、`group_tools`、`hexi_core`(模块级常量/SUPERUSERS,运行期热更复杂)。
- **未接入(建议后续)**:`learning_chat`、`group_daily_analysis`(已有独自 Web 配置,避免冲突)、`picfinder_take`、`bf_bot`、`group_tools`、`hexi/core`(模块级常量/SUPERUSERS,运行期热更复杂)。
---
@@ -316,7 +320,7 @@ register_model_config(
### 13.2 通用注册 API(任意来源)
```python
from hexi.config_standard import register_config_items
from hexi.web_hub.config_standard import register_config_items
register_config_items(
__name__, # = NoneBot 插件模块名
@@ -339,7 +343,7 @@ register_config_items(
Web 保存后 apply 会把新值写回来源(模块属性/dict/自定义),但若插件在别处是用 `from .config import X` **值拷贝**进来的量,不受影响。要真正运行期生效,插件在读配置处改用统一 API:
```python
from hexi.web_config import get_effective_value
from hexi.web_hub.web_config import get_effective_value
limit = get_effective_value("hexi.plugins.nonebot_plugin_picfinder_take", "DAILY_LIMIT", 50)
if not check_quota(limit): ...
@@ -395,7 +399,7 @@ if not check_quota(limit): ...
### 14.3 一键注册 API
```python
from hexi.config_standard import register_object_set
from hexi.web_hub.config_standard import register_object_set
register_object_set(
__name__, # NoneBot 插件模块名
+5 -4
View File
@@ -84,6 +84,7 @@ nonebot_plugin_xxx/
## 4. `__init__.py` 标准职责(固定顺序)
1. `__plugin_meta__ = PluginMetadata(name=..., description=..., usage=..., type="application")`。
> **type 取值(NoneBot 发布规范)**:`application`(向机器人用户提供功能,支持热插拔/热重载)或 `library`(为其他插件提供能力,不可热拔插)。新插件默认 `application`。本仓库的 `hexi/core`、`hexi/web_hub` 已从插件目录移出,作为机器人核心模块(**非插件**),直接不参与热拔插。
2. 声明依赖 `require("nonebot_plugin_alconna")` 等。
3. 显式导入子模块(`from . import handlers, services, utils`),注册 matcher/handler。
4. **配置注册**(在 config 就绪后):`register_model_config`(pydantic Config)或 `register_config_items` / `register_object_set`(来源无关)。
@@ -108,7 +109,7 @@ config = get_plugin_config(Config)
### 4.2 来源无关型(模块常量 / 配置文件 / DB)
```python
from hexi.config_standard import register_config_items, register_object_set
from hexi.web_hub.config_standard import register_config_items, register_object_set
register_object_set(__name__, "groups", [ {item_schema...} ], key_field="group_id",
getter=get_groups, setter=set_groups, nosave=True)
```
@@ -133,20 +134,20 @@ register_object_set(__name__, "groups", [ {item_schema...} ], key_field="group_i
- `services/`:业务编排,可依赖 repository / 外部 API;把「一个业务动作」收敛到一个方法。
- **禁止** `from x import *`、`except: pass`、`print()`;用 `logger`。
- async handler 内避免同步阻塞(`requests` / `time.sleep` / 同步爬虫);需要就 `asyncio.to_thread`。
- 命令触发:需要 @ 用 `rule=to_me()`;全局命令注意与其它插件冲突;高开销命令加冷却/限频(`hexi_core` 的 `cooldown/rate_limit`)。
- 命令触发:需要 @ 用 `rule=to_me()`;全局命令注意与其它插件冲突;高开销命令加冷却/限频(`hexi/core` 的 `cooldown/rate_limit`)。
- 外部 API:统一超时 + 重试 + 失败降级/用户提示。
## 8. Web 接入标准
- 需要独立 Web 页:`register_web_plugin(id, name, icon, lambda: build_app(), module_name=__name__)`,hub 启动自动挂载 `/api/<id>`。
- 需要 Web 配置:`register_plugin_config` / `register_model_config` / `register_config_items` / `register_object_set`(schema 驱动表单)。
- 鉴权统一用 `hexi.web_auth.require_admin`(OAuth2 + SQLite),不要自造一套。
- 鉴权统一用 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite),不要自造一套。
- 敏感字段 `secret=True`,前端掩码;写回允许明文。
## 9. 插件迁移检查清单
- [ ] 包名改为 `nonebot_plugin_*`,位置在 `../../hexi/plugins`。
- [ ] 有 `__plugin_meta__`,`type="application"`。
- [ ] 有 `__plugin_meta__`,`type="application"`(核心/框架插件标 `library`,不参与热拔插)。
- [ ] 配置已接入统一标准(Web 能读能改,运行期/重启生效)。
- [ ] 命令在 `handlers/`,业务在 `services/`,工具在 `utils/`。
- [ ] 数据访问集中(repository / services),原子写 / 事务。
+75
View File
@@ -0,0 +1,75 @@
# HeXi 插件类型分类(library / application)
> 依据 NoneBot 发布规范:`type` 是插件类别,发布必填。当前有效类别:
> - `library`:为其他插件编写提供功能(核心库/框架),**不参与热拔插/热重载**。
> - `application`:向机器人用户提供功能,**可热拔插/热重载**。
>
> 本仓库自定义插件统一在 `hexi/plugins/`(经 `nonebot.load_plugins("hexi")` 加载);
> pip 社区插件由其自带元数据决定类别,本仓库不改动,但热拔插管理器会按类别保护。
>
> **变动**:原 `nonebot_plugin_hexi_core` / `nonebot_plugin_web_hub` 已从 `hexi/plugins/` 移出,现为机器人核心模块 `hexi/core`、`hexi/web_hub`(**非插件**),不再参与插件类型分类与热拔插,故下表不列出。
---
## 自定义插件(`hexi/plugins/`)
| 插件(模块名) | type | 说明 |
|---|---|---|
| `hexi.plugins.nonebot_plugin_bf_bot` | `application` | 战地系列战绩查询(BF3/4/1/5/2042/6)。 |
| `hexi.plugins.nonebot_plugin_steam_info` | `application` | Steam 信息播报/查询。 |
| `hexi.plugins.nonebot_plugin_mc_server_status` | `application` | Minecraft 服务器状态查询。 |
| `hexi.plugins.nonebot_plugin_ncm_saying` | `application` | 网易云热评。 |
| `hexi.plugins.nonebot_plugin_helldivers_tools` | `application` | 绝地潜兵 2 前线战况/战备。 |
| `hexi.plugins.nonebot_plugin_learning_chat` | `application` | 马尔可夫链群聊学习/复读。 |
| `hexi.plugins.nonebot_plugin_galgame_card` | `application` | 群聊人设卡生成/展示。 |
| `hexi.plugins.nonebot_plugin_group_tools` | `application` | 群管理工具集。 |
| `hexi.plugins.nonebot_plugin_group_daily_analysis` | `application` | 群聊行为分析报告。 |
| `hexi.plugins.nonebot_plugin_dailywife` | `application` | 每日随机抽取群友。 |
| `hexi.plugins.nonebot_plugin_deer_pipe` | `application` | 每日打卡(鹿)。 |
| `hexi.plugins.nonebot_plugin_dice` | `application` | 掷骰子/结婚判定。 |
| `hexi.plugins.nonebot_plugin_makeaquote` | `application` | 名人/语录图生成。 |
| `hexi.plugins.nonebot_plugin_random_jm_code` | `application` | 随机 JM 码。 |
| `hexi.plugins.nonebot_plugin_video_analysis` | `application` | 视频链接解析。 |
| `hexi.plugins.nonebot_plugin_regif` | `application` | GIF 倒放/处理。 |
| `hexi.plugins.nonebot_plugin_picfinder_take` | `application` | 二次元搜图。 |
| `hexi.plugins.nonebot_plugin_picstatus` | `application` | 设备状态图。 |
| `hexi.plugins.nonebot_plugin_huoziyinshua` | `application` | otto 活字印刷(语音合成)。 |
| `hexi.plugins.memes_ops` | `application` | 给 `nonebot_plugin_memes` 追加裸词选项(用户侧语法)。 |
| `hexi.plugins.nonebot_plugin_deadlock` | - | **停用**:整文件被注释,不注册任何功能(历史遗留)。 |
| `hexi.plugins.nonebot_plugin_brash_general_supercredits_tools` | - | **空壳**:未完成占位,`__init__.py` 仅注释,无功能。 |
---
## pip 社区插件(`pyproject.toml` 声明)
这些插件由安装包自带的 `__plugin_meta__` 决定类别,本仓库不改动;其中的 library 型插件同样受热拔插保护。
| 插件 | 常见类别 | 说明 |
|---|---|---|
| `nonebot_plugin_apscheduler` | library | 定时任务框架。 |
| `nonebot_plugin_alconna` | library | 命令解析/消息框架。 |
| `nonebot_plugin_saa` | library | 跨平台发送辅助。 |
| `nonebot_plugin_session` | library | 会话/状态管理。 |
| `nonebot_plugin_userinfo` | library | 用户信息接口。 |
| `nonebot_plugin_user` | application | 用户数据接口(含绑定/查看命令)。 |
| `nonebot_plugin_datastore` | library | 数据存储底座。 |
| `nonebot_plugin_orm` | library | ORM 底座。 |
| `nonebot_plugin_fix_qq_img_ssl` | library | 图片 SSL 修复补丁。 |
| `nonebot_plugin_wordcloud` | application | 词云生成。 |
| `nonebot_plugin_memes` | application | 表情包。 |
| `nonebot_plugin_multincm` | application | 多源网抑云点歌。 |
| `nonebot_plugin_random_stereotypes` | application | 随机刻板印象。 |
| `nonebot_plugin_rollpig` | application | 滚猪。 |
> 说明:社区插件类别以其安装包 `PluginMetadata.type` 为准,若与本表不符请以包内元数据为准。
---
## 热拔插保护
`../../hexi/core/plugin_manager.py` 中的 `hot_load / hot_unload / hot_reload` 会先判断目标插件类别:
- 目标为 `application` → 正常热拔插。
- 其余(`library` / 未声明类别,含根包无 meta 但被依赖的核心/框架,如 alconna)→ **拒绝**并记录 warning(热拔插会使依赖它的插件运行混乱)。
`hexi/web_hub/web_config.py` 保存配置触发的 `hot_reload` 同样受此保护:非 application 插件若未提供 `apply` 回调,将不会执行热重载。
@@ -31,8 +31,8 @@ nonebot_plugin_template/
## Web 子应用
- `web/admin.py::build_admin_app()` 返回一个 FastAPI 实例,经 `register_web_plugin` 由 hub 挂载到 `/api/template`。
- 鉴权统一 `hexi.web_auth.require_admin`;示例端点:`GET /ping`、`GET /config`(读插件配置)。
- 想要更丰富的管理页:前端放 `web/dist/`,在 admin.py 里挂 `StaticFiles` + SPA 兜底即可(参考 `nonebot_plugin_web_hub`)。
- 鉴权统一 `hexi.web_hub.web_auth.require_admin`;示例端点:`GET /ping`、`GET /config`(读插件配置)。
- 想要更丰富的管理页:前端放 `web/dist/`,在 admin.py 里挂 `StaticFiles` + SPA 兜底即可(参考 `hexi/web_hub`)。
## 约定
- `plugin_id` = 模块名(自动取 `__package__`;web 子应用用 `__package__.rsplit(".", 1)[0]`)。
@@ -24,7 +24,7 @@ from . import config as _config # noqa: E402,F401
from . import handlers as _handlers # noqa: E402,F401
from . import web as _web # noqa: E402,F401
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
_config.register_config()
@@ -11,7 +11,7 @@ from __future__ import annotations
from nonebot import get_plugin_config
from pydantic import BaseModel
from hexi.config_standard import register_config_items, register_model_config, register_object_set
from hexi.web_hub.config_standard import register_config_items, register_model_config, register_object_set
# plugin_id = NoneBot 插件模块名
_PLUGIN_ID = __package__
@@ -8,7 +8,7 @@ from nonebot.rule import to_me
from nonebot.params import CommandArg
from nonebot_plugin_alconna import UniMessage
from hexi.web_config import get_effective_value
from hexi.web_hub.web_config import get_effective_value
from ..services.main import do_something
@@ -2,7 +2,7 @@
from __future__ import annotations
from hexi.web_config import get_effective_value
from hexi.web_hub.web_config import get_effective_value
from ..repository import get_record, save_record
@@ -2,9 +2,9 @@
- 由 `__init__.py` 里 `register_web_plugin("template", ..., module_name=__name__)` 注册,
hub 启动时自动挂载到 `/api/template`。
- 鉴权统一走 `hexi.web_auth.require_admin`(OAuth2 + SQLite);注意它本身是 FastAPI 的
- 鉴权统一走 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite);注意它本身是 FastAPI 的
`Depends` 实例,直接用 `_: dict = API` 注入,不要再包一层 `Depends(...)`。
- 可直接读写 `hexi.web_config` 的插件配置。
- 可直接读写 `hexi.web_hub.web_config` 的插件配置。
"""
from __future__ import annotations
@@ -13,7 +13,7 @@ from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from hexi import web_config
from hexi.web_auth import require_admin as API
from hexi.web_hub.web_auth import require_admin as API
# 插件模块名(去掉 .web):= NoneBot 模块名 = plugin_id
_PLUGIN_ID = __package__.rsplit(".", 1)[0]
+1
View File
@@ -0,0 +1 @@
# 机器人核心目录
@@ -1,18 +1,11 @@
from nonebot import get_driver, logger
from nonebot.plugin import PluginMetadata
__plugin_meta__ = PluginMetadata(
name="HeXi 核心工具",
description="HeXi 公共工具库:权限分级、消息解析、限频器、命令冷却、插件热插拔等",
usage="工具库 + 帮助/插件管理命令(帮助、插件列表、加载/卸载/重载插件)",
type="application",
)
# 显式导入子模块注册 matcher(load_plugins 只加载到插件包层,不会自动导入包内模块)
# 显式导入子模块注册 matcher/工具(hexi/core 为机器人核心模块,非 NoneBot 插件)
from . import ( # noqa: E402,F401
cooldown,
custom_utils,
message_utils,
outbound_media,
plugin_control,
plugin_manager,
rate_limit,
@@ -24,3 +17,9 @@ async def _startup_plugin_gate() -> None:
"""等所有插件 matcher 注册完成后,把统一 filter 规则注入到 application 插件。"""
n = plugin_control.instrument_plugin_gate()
logger.info(f"插件控制面: 已注入 {n} 条 gateway 规则")
@get_driver().on_startup
async def _startup_outbound_media() -> None:
"""挂载本机媒体服务:协议端据此回拉 bot 生成的本地文件(内部已处理降级)。"""
outbound_media.mount_media_endpoint()
@@ -7,7 +7,7 @@
- "global":所有人共享一个 CD
用法一(装饰器,包在 on_command 外面):
from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown
from hexi.core.cooldown import cooldown
# 单用户 10s CD,群聊/私聊共用
bf_cmd = cooldown(10, hint="查询太频繁了,{secs} 秒后再试")(on_command("bf3"))
@@ -19,7 +19,7 @@
...
用法二(手动查询/触发,自定义提示文本):
from hexi.plugins.nonebot_plugin_hexi_core.cooldown import Cooldown
from hexi.core.cooldown import Cooldown
cd = Cooldown(10)
if cd.in_cd(ev.get_user_id()):
@@ -36,7 +36,7 @@ from nonebot.adapters import Event
from nonebot.dependencies import Dependent
from nonebot.matcher import Matcher
from hexi.plugins.nonebot_plugin_hexi_core.rate_limit import RateLimiter, get_limiter
from hexi.core.rate_limit import RateLimiter, get_limiter
# 无 name 时的实例序号,保证同一进程内各实例的注册表 key 互不冲突
_cd_seq = itertools.count(1)
@@ -134,7 +134,7 @@ if hexi.is_superuser(event): # 超级用户
# 架构设计
```
hexi/plugins/nonebot_plugin_hexi_core/
hexi/core/
├── __init__.py # 插件入口,导出 hexi 对象
├── poke.py # 戳一戳
├── forward.py # 合并转发消息构建器
@@ -1,17 +1,25 @@
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 +70,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 +94,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 +116,64 @@ 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 会把引用段插入消息自身,降级重发需干净副本
# 本地文件统一交给 hexi.core.outbound_media 的 call_api 钩子改写
# (URL 优先/base64 兜底),这里不再自己转 base64——占位图本就是发图,
# 走同一套策略才不会出现「一处能发一处不能发」。关掉 HEXI_INLINE_MEDIA
# 的语义是「协议端就在本机」,此时 file:// 同样是有效的。
if isinstance(image, str) and image.startswith(("http://", "https://")):
img = UniMessage.image(url=image)
else:
img = UniMessage.image(path=Path(image))
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
+487
View File
@@ -0,0 +1,487 @@
"""出站媒体内联化:把本地文件改写为协议端可达的 URL(或 base64 兜底)。
生产环境的 OneBot 协议端不在本机:给 API 传本地路径时,NoneBot 的 `f2s()`
会把 `Path` 转成 `file:///D:/...`(`str` 则原样透传)——而 `file://` 的语义是
「**协议端那台机器上的路径**」,协议端 stat 必然 ENOENT。受影响的不止本仓插件,
还有 pip 装的社区插件(如 nonebot_plugin_doroending),它们的代码改不了,
只能全局拦截。
本模块挂在 `Bot.on_calling_api`(NoneBot 公开钩子,`call_api` 的必经之路,
`bot.send` / alconna `finish` / 直接 `send_group_msg` 全从这里过)上,
在真正调用适配器前改写发送类 API 里的本地文件:
1. **默认改写为 `http://<本机可达IP>:<端口>/media/<token>`**,由协议端主动来拉。
地址取自 `driver.config` 的 host/port + 本机网卡,与 /hub 首页「协议端配对」
展示的是同一来源;反向 WS 部署下协议端本就连得到这个地址(它连的就是它),
所以不需要任何新配置。体积不受 base64 膨胀影响,视频/多媒体转发尤其受益。
2. **拿不到可达地址时回退 `base64://`**:字节由 bot 直接携带,不依赖协议端
反过来拨通本机的任何假设,跨 NAT/隧道也成立。
不改写 `http(s)://` / `base64://` / 不存在的路径——收到的图片 id(如 `{abc}.jpg`)
和协议端自己的缓存路径都靠这条自然放行。任何异常只告警不抛:
拦截层绝不能反过来把发送搞挂。
两类 API 的处理方式不同:
- **消息发送**(`send_*_msg` / `send_*_forward_msg`):媒体在消息段里,递归遍历
后按上面两级改写(URL 优先,base64 兜底)。
- **文件上传**(`upload_group_file` / `upload_private_file`):`file` 在顶层,
且**只做 URL 不做 base64**——文件体积不适合内联。覆盖这条让
`video_analysis` 的群文件「本地直传」兜底和 alconna 的 `$onebot11:file`
一起受益(后者传的是 `Path.as_posix()` 裸路径)。
"""
from __future__ import annotations
import asyncio
import os
import secrets
import socket
import time
from base64 import b64encode
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.request import url2pathname
from nonebot.adapters import Bot as BaseBot
# 用 nonebot.log 而非 `from nonebot import logger`:与 rate_limit 保持一致,
# 模块级 import 保持干净,测试才能 importlib 按路径裸加载(不经包 __init__)。
from nonebot.log import logger
try: # NoneBot 内部路径:V11/V12 的 MessageSegment 都继承自它
from nonebot.internal.adapter.message import MessageSegment as BaseMessageSegment
except Exception: # pragma: no cover - 内部路径变更时的兜底,见 _as_segment
BaseMessageSegment = None # type: ignore[assignment,misc]
# 消息段里代表「本地/远端媒体文件」的 type(file 字段语义)
MEDIA_SEG_TYPES = frozenset({"image", "record", "video"})
# OneBot V11 发送类 API(与 picstatus misc_statistics 的清单一致,另加合并转发)。
_SEND_APIS = frozenset(
{
"send_private_msg",
"send_group_msg",
"send_msg",
"send_private_forward_msg",
"send_group_forward_msg",
"send_forward_msg",
}
)
# 文件上传类 API:file 字段在**顶层**而不是消息段里,所以走单独一小段逻辑,
# 且**只做 URL、不做 base64 兜底**——文件动辄几十上百 MB,内联会撑爆内存和
# WS 帧,OneBot 的文件上传语义本来也不是为内联设计的。
_FILE_UPLOAD_APIS = frozenset({"upload_group_file", "upload_private_file"})
# 只有 OneBot 的 file 字段认 file:// / base64:// 语义;bot.py 当前只注册 V11
# (pyproject.toml 虽列了 V12,但代码里没装适配器)。
_ONEBOT_NAMES = frozenset({"OneBot V11"})
# 通配绑定:值本身不可拨号,需要换成具体网卡地址
_WILDCARD_HOSTS = frozenset({"", "0.0.0.0", "::", "*"})
_MAX_DEPTH = 8
_MAX_TOKENS = 256
_DEFAULT_TTL = 300.0
_DEFAULT_MAX_MB = 4.0
# token -> (文件路径, 过期时刻 monotonic)
_tokens: dict[str, tuple[Path, float]] = {}
# 媒体服务是否真的挂上了:没挂上就不能发 URL(否则协议端必然 404)
_mounted = False
# ---------------------------------------------------------------- 环境开关
def _env_flag(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() not in {"", "0", "false", "no", "off"}
def _enabled() -> bool:
"""总开关(默认开)。关闭的语义是「协议端就在本机」,此时 file:// 是有效的。"""
return _env_flag("HEXI_INLINE_MEDIA", True)
def _url_enabled() -> bool:
"""URL 优先模式(默认开);关掉则一律走 base64。"""
return _env_flag("HEXI_INLINE_MEDIA_URL", True)
def _ttl() -> float:
try:
return float(os.environ.get("HEXI_MEDIA_URL_TTL", "") or _DEFAULT_TTL)
except ValueError:
return _DEFAULT_TTL
def _max_inline_mb() -> float:
try:
return float(os.environ.get("HEXI_INLINE_MEDIA_MAX_MB", "") or _DEFAULT_MAX_MB)
except ValueError:
return _DEFAULT_MAX_MB
# ---------------------------------------------------------------- 路径解析
def local_file_of(value: Any) -> Path | None:
"""把 file 字段值解析成本机真实存在的文件路径;不是本地文件则 None。
- `http(s)://` / `base64://` → None(原样放行)
- `file://` → `url2pathname` 还原
- 其余按原始路径候选(`f2s()` 对 `str` 是原样透传的)
- 不存在 / 非法 → None(收到的图片 id、协议端缓存路径靠这条放行)
"""
if not isinstance(value, str) or not value:
return None
if value.startswith(("http://", "https://", "base64://")):
return None
candidates: list[str] = []
if value.startswith("file://"):
# 关键陷阱:不能直接 unquote(urlparse(uri).path)——Windows 上会得到
# 前导斜杠的 `/D:/...`,is_file() 恒为 False 而**静默不转换**,
# 只在跨机时才暴露。url2pathname 才能正确还原成 `D:\\...`。
try:
uri_path = urlparse(value).path
candidates.append(url2pathname(uri_path))
except Exception:
return None
# 有人手工拼 file:// 且不做百分号转义(video_analysis._file_uri 即如此:
# `"file:///" + path.replace("\\", "/")`)。对已经未转义的串再 unquote,
# 文件名含字面 %XX 时会认错,所以把原始路径也列为候选兜底。
if len(uri_path) > 2 and uri_path[0] == "/" and uri_path[2] == ":":
candidates.append(uri_path[1:]) # /D:/x → D:/x
else:
# 裸路径:f2s() 对 str 原样透传;alconna 的 $onebot11:file 走 as_posix()
candidates.append(value)
for raw in candidates:
try:
path = Path(raw)
if path.is_file():
return path
except (OSError, ValueError):
continue
return None
# ---------------------------------------------------------------- 媒体服务
def _is_private(ip: str) -> bool:
parts = ip.split(".")
if len(parts) != 4:
return False
try:
a, b = int(parts[0]), int(parts[1])
except ValueError:
return False
return a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168)
def _local_ips() -> list[str]:
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。
与 hexi/web_hub/dashboard.py 的 `_local_ips` 同源逻辑(那边供 /hub 首页
「协议端配对」展示,即用户已经验证过能用的地址);此处刻意不 import,
避免 hexi.core → hexi.web_hub 的反向依赖。
"""
ips: list[str] = []
try:
import psutil
for addrs in psutil.net_if_addrs().values():
for addr in addrs:
if addr.family == socket.AF_INET:
ips.append(str(addr.address or ""))
except Exception: # noqa: BLE001 - psutil 缺失/异常时退回 getaddrinfo
pass
if not ips:
try:
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
ips.extend(info[4][0] for info in infos)
except OSError:
pass
out: list[str] = []
for ip in ips:
ip = ip.strip()
if not ip or ":" in ip or ip.count(".") < 3:
continue
# 环回与链路本地(169.254.x)协议端访问不到,排除
if ip.startswith(("127.", "169.254.")):
continue
if ip not in out:
out.append(ip)
out.sort(key=lambda ip: (not _is_private(ip), ip))
return out
def _listen_addr() -> tuple[str, int] | None:
"""本机媒体服务的对外可达地址(host, port)。"""
try:
from nonebot import get_driver
config = get_driver().config
port = int(getattr(config, "port", 0) or 0)
host = str(getattr(config, "host", "") or "").strip()
except Exception: # noqa: BLE001
return None
if not port:
return None
if host not in _WILDCARD_HOSTS:
# 显式绑定(如 dev 的 127.0.0.1)直接用
return host, port
for ip in _local_ips():
return ip, port
return None
def _base_url() -> str | None:
"""媒体服务基地址;未挂载/无可达地址时 None(调用方据此回退 base64)。"""
if not _mounted:
return None
override = os.environ.get("HEXI_MEDIA_BASE_URL", "").strip()
if override:
return override.rstrip("/")
addr = _listen_addr()
if addr is None:
return None
host, port = addr
return f"http://{host}:{port}"
def _gc() -> None:
now = time.monotonic()
for token, (_, expires) in list(_tokens.items()):
if expires <= now:
del _tokens[token]
while len(_tokens) >= _MAX_TOKENS:
_tokens.pop(next(iter(_tokens)))
def _publish(path: Path) -> str:
"""登记一个一次性 token(值随机不可猜),返回它。"""
_gc()
token = secrets.token_urlsafe(16)
_tokens[token] = (path, time.monotonic() + _ttl())
return token
def mount_media_endpoint() -> None:
"""把媒体路由挂到现有 ASGI app 上(同一个端口,不新开服务)。
免鉴权是**必须**的——协议端登录不了 hub——所以安全模型改为
「路径由 bot 在改写那一刻自己登记,请求方只能出示不可猜 token、
根本无法表达路径」,天然没有目录穿越面,配合短 TTL。
"""
global _mounted
if not _enabled():
logger.info("出站媒体内联化已禁用 (HEXI_INLINE_MEDIA=false),按原值发送")
return
if _mounted:
return
try:
from fastapi import FastAPI
from nonebot import get_app
sub = FastAPI(
title="HeXi Outbound Media",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
@sub.get("/{token}")
async def media(token: str):
from fastapi import HTTPException
from fastapi.responses import FileResponse
entry = _tokens.get(token)
if entry is None:
raise HTTPException(status_code=404, detail="媒体不存在或已过期")
path, expires = entry
if expires <= time.monotonic():
_tokens.pop(token, None)
raise HTTPException(status_code=404, detail="媒体链接已过期")
if not path.is_file():
raise HTTPException(status_code=404, detail="文件已不存在")
# no-store:过期媒体不该被中间层缓存住
return FileResponse(path, headers={"Cache-Control": "no-store"})
get_app().mount("/media", sub)
_mounted = True
logger.info("出站媒体服务已挂载: /media/<token>")
except Exception as e: # noqa: BLE001 - 挂载失败只降级,不影响启动
logger.warning(
f"出站媒体服务挂载失败,将回退 base64: {type(e).__name__}: {e}"
)
# ---------------------------------------------------------------- 改写
async def _to_base64(path: Path) -> str:
# 同步 IO 挪到线程,别在事件循环里读文件
data = await asyncio.to_thread(path.read_bytes)
size_mb = len(data) / 1048576
limit = _max_inline_mb()
if size_mb > limit:
# 超阈值仍要发:不转是 100% 必失败,转了才可能成功
logger.warning(
f"出站媒体内联: {path.name} 体积 {size_mb:.1f}MB 超过 "
f"HEXI_INLINE_MEDIA_MAX_MB={limit:g},仍以 base64 发送"
)
return f"base64://{b64encode(data).decode()}"
async def rewrite_file(value: Any) -> str | None:
"""改写单个 file 字段值;无需改写(或改写失败)返回 None。"""
path = local_file_of(value)
if path is None:
return None
try:
if _url_enabled():
base = _base_url()
if base:
return f"{base}/media/{_publish(path)}"
return await _to_base64(path)
except Exception as e: # noqa: BLE001 - 单个段失败只告警,按原值发送
logger.warning(f"出站媒体改写失败,按原值发送 {path}: {type(e).__name__}: {e}")
return None
async def rewrite_upload_file(value: Any) -> str | None:
"""文件上传类 API 的 file 字段改写;无需改写返回 None。
只做 URL,**不做 base64 兜底**:拿不到可达地址就原样放行,行为与改动前
一致(不保证成功,但绝不会比原来更糟)。`HEXI_INLINE_MEDIA_URL=false`
同样会让这里放行——那个开关的语义是「媒体服务这条路不可用」,文件上传
依赖同一条路,理应一起关掉。
"""
if not _url_enabled():
return None
path = local_file_of(value)
if path is None:
return None
try:
base = _base_url()
if not base:
return None
return f"{base}/media/{_publish(path)}"
except Exception as e: # noqa: BLE001
logger.warning(f"群/私聊文件改写失败,按原值发送 {path}: {type(e).__name__}: {e}")
return None
def _as_segment(node: Any) -> tuple[str, dict] | None:
"""识别消息段对象 → (type, data);不是消息段返回 None。
MessageSegment 不是 dict 子类(但实现了 keys/get),所以单靠 isinstance(dict)
认不出来;这里以 NoneBot 的公共基类为准,并留一条结构兜底以防内部路径变更。
"""
if BaseMessageSegment is not None and isinstance(node, BaseMessageSegment):
seg_type, seg_data = node.type, node.data
else:
seg_type = getattr(node, "type", None)
seg_data = getattr(node, "data", None)
if isinstance(seg_type, str) and isinstance(seg_data, dict):
return seg_type, seg_data
return None
async def _walk_segment(seg_type: str, seg_data: dict, depth: int) -> None:
if seg_type in MEDIA_SEG_TYPES:
rewritten = await rewrite_file(seg_data.get("file"))
if rewritten is not None:
seg_data["file"] = rewritten
# 合并转发节点:content 可能是 str / list[dict] / Message,里面还可能嵌媒体
# (MessageSegment.node_custom 把 Message 原样塞进 data["content"])
for key in ("content", "messages"):
if key in seg_data:
await _walk(seg_data[key], depth + 1)
async def _walk(node: Any, depth: int = 0) -> None:
"""深度受限的通用遍历,原地改写。
形态是散的(Message / 纯 dict 段 / list[dict] / 转发节点的三种 content),
写死形状必漏,所以按结构特征递归而不是枚举。
"""
if node is None or depth > _MAX_DEPTH:
return
# str/bytes 是叶子;数字等标量也直接跳过
if isinstance(node, (str, bytes, int, float, bool)):
return
seg = _as_segment(node)
if seg is not None:
await _walk_segment(seg[0], seg[1], depth)
return
if isinstance(node, (list, tuple)): # 含 Message(list 子类)
for item in node:
await _walk(item, depth + 1)
return
if isinstance(node, dict):
seg_type, seg_data = node.get("type"), node.get("data")
if isinstance(seg_type, str) and isinstance(seg_data, dict):
# 段字典({"type": "image", "data": {...}} / node 字典)
await _walk_segment(seg_type, seg_data, depth)
return
for value in node.values():
await _walk(value, depth + 1)
# ---------------------------------------------------------------- 钩子
@BaseBot.on_calling_api
async def _inline_outbound_media(bot: BaseBot, api: str, data: dict[str, Any]) -> None:
"""call_api 前置钩子:把出站 API 里的本地文件改写成可跨机访问的形式。
钩子拿到的是 `_call_api` 之前**同一个可变 data dict**,且 hook 的 task group
在 `_call_api` 之前 await 完成,所以原地改生效。
"""
if not _enabled():
return
is_send = api in _SEND_APIS
is_upload = api in _FILE_UPLOAD_APIS
if not (is_send or is_upload) or not isinstance(data, dict):
return
try:
if bot.adapter.get_name() not in _ONEBOT_NAMES:
return
except Exception: # noqa: BLE001
return
if is_upload:
# 文件上传的 file 在顶层(不是消息段);name/folder 等参数一律不碰
try:
rewritten = await rewrite_upload_file(data.get("file"))
except Exception as e: # noqa: BLE001 - 绝不反过来搞挂发送
logger.warning(f"出站媒体改写异常(upload.file),按原值发送: {e}")
return
if rewritten is not None:
data["file"] = rewritten
return
for key in ("message", "messages"):
if key in data:
try:
await _walk(data[key])
except Exception as e: # noqa: BLE001 - 绝不反过来搞挂发送
logger.warning(f"出站媒体改写异常({key}),按原值发送: {e}")
@@ -27,7 +27,7 @@ from nonebot import get_loaded_plugins, logger
from nonebot.internal.matcher import matchers as matchers_registry
from nonebot.rule import Rule
_PLUGIN_ROOT = Path(__file__).resolve().parents[2] # hexi/
_PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hexi/
DATA_DIR = _PLUGIN_ROOT / "data"
STORE_PATH = DATA_DIR / "plugin_control.json"
@@ -153,7 +153,7 @@ def remove_plugin(plugin_id: str) -> None:
def list_plugins() -> list[dict[str, Any]]:
"""枚举所有 application 插件(或注册了配置 schema 的插件)及其控制面状态。"""
from nonebot.plugin import get_loaded_plugins
from hexi.web_config import has_schema
from hexi.web_hub.web_config import has_schema
result: list[dict[str, Any]] = []
seen: set[str] = set()
@@ -174,6 +174,7 @@ def list_plugins() -> list[dict[str, Any]]:
"name": meta.name,
"description": meta.description or "",
"usage": meta.usage or "",
"type": meta.type,
"enabled": ctl["global"]["enabled"],
"chat": ctl["global"]["chat"],
"groups": {gid: lvl for gid, lvl in ctl["groups"].items()},
@@ -16,7 +16,6 @@ from nonebot.params import CommandArg
from nonebot.permission import SUPERUSER
from nonebot.plugin import (
Plugin,
PluginMetadata,
_plugins,
get_loaded_plugins,
get_plugin_by_module_name,
@@ -26,16 +25,10 @@ from nonebot.rule import Rule, to_me
require("nonebot_plugin_alconna")
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.message_utils import send_forward_msg
from hexi.core.message_utils import send_forward_msg
from . import plugin_control
__plugin_meta__ = PluginMetadata(
name="插件管理",
description="热插拔插件:运行时加载/卸载/重载插件(仅超管)",
usage="插件列表 / 加载插件 <模块名> / 卸载插件 <模块名> / 重载插件 <模块名>",
type="application",
)
# 插件热拔插管理(hexi.core 核心模块,非 NoneBot 插件)
def _forward_sender_name() -> str:
"""合并转发节点的发送者昵称(用机器人配置昵称,插件名写进 content)"""
nickname = get_driver().config.nickname
@@ -94,8 +87,33 @@ def _unregister_plugin(plugin: Plugin) -> None:
parent.sub_plugins.discard(plugin)
def _is_library(name: str) -> bool:
"""判断模块是否归属「非 application」插件(library / 未声明类别)。
只有 application 插件可热拔插;library(含根包未声明类别但被依赖的核心/框架)
都应保护,热拔插会破坏依赖它的插件运行态。
优先级:NoneBot 插件注册表 > sys.modules 回溯(含父包 __plugin_meta__)。
"""
plugin = get_plugin_by_module_name(name)
if plugin is not None:
meta = plugin.metadata
return not (meta and meta.type == "application")
parts = name.split(".")
for i in range(len(parts), 0, -1):
module = sys.modules.get(".".join(parts[:i]))
if module is not None:
meta = getattr(module, "__plugin_meta__", None)
if meta is not None:
return meta.type != "application"
return False
def hot_load(name: str) -> bool:
"""热加载插件模块(直接 import,matcher 随之注册)"""
if _is_library(name):
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,不支持热加载")
return False
if name in _hot_plugins:
return False
if any(p.module_name == name for p in get_loaded_plugins()):
@@ -115,7 +133,14 @@ def hot_unload(name: str) -> bool:
不限于热加载的插件:启动时加载的插件(hexi/plugins 与 pyproject.toml
声明)同样支持卸载,以 NoneBot 插件注册表为准查找。
安全限制:非 application 插件(library/未声明类别,常为核心库/框架)被其他
插件依赖,热卸载会让依赖方引用悬空并导致运行混乱,故拒绝卸载。
"""
if _is_library(name):
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热卸载会使依赖它的插件运行混乱,已拒绝")
return False
plugin = get_plugin_by_module_name(name)
if plugin is None and name not in _hot_plugins:
return False
@@ -148,6 +173,9 @@ def hot_unload(name: str) -> bool:
def hot_reload(name: str) -> bool:
"""热重载插件:先卸载再加载"""
if _is_library(name):
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热重载会使依赖它的插件运行混乱,已拒绝")
return False
hot_unload(name)
return hot_load(name)
@@ -3,7 +3,7 @@
纯逻辑实现,不依赖 NoneBot 运行时;同步(requests)与异步(httpx/aiohttp)均可用。
用法:
from hexi.plugins.nonebot_plugin_hexi_core import rate_limit
from hexi.core import rate_limit
# 异步请求前取令牌(无令牌时等待,最多等 timeout 秒,超时返回 False)
if not await rate_limit.acquire("steam", rate=1, capacity=2):
+1
View File
@@ -0,0 +1 @@
# 机器人插件目录
@@ -29,6 +29,7 @@ __plugin_meta__ = PluginMetadata(
解绑: /解绑 (解除你的QQ与当前绑定的EAID)
修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID)
""".strip(),
type="application",
extra={
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 199 KiB

@@ -1,82 +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_ENABLE/WEB_USERNAME/WEB_PASSWORD/WEB_SECRET_KEY)
├── web.py Web 管理后台后端(/galgame_card/api/*,JWT 登录,仿 learning_chat)
├── web/index.html 管理后台前端(单文件,无需构建)
├── DESIGN.md 设计定稿(数据模型/流水线/九段协议/证据纪律)
└── CLAUDE.md 本文件
```
**Web 管理后台**:`http://<host>:<port>/galgame_card`(默认 admin/galgame,可在 .env 改 WEB_*)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。路由在 `@driver.on_startup` 里注册,改后端需重启 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 管理后台:/galgame_card(群开关、参与者、数据浏览/清理;Chrome 已实测)
- ⏳ QQ 命令集:`开启人设采集` / `加入人设` / `退出人设` / `查看人设`(Web 已覆盖同等功能,QQ 命令可选做)
- ⏳ 总结路径:LLM 客户端、印象生成、九段画像生成、调度触发
- ⏳ 呈现层:人物卡展示/图片渲染
## 待决策点
- 触发阈值(印象 ≥50 条新语料 / 画像 ≥5 条新印象,⏳ 待调)
- 脱敏兜底位数(裸数字 ≥6 位默认替换 `[账号]`,保守优先;误杀多可提到 8 位,动 `BARE_DIGITS_RE`)
- 密保答案场景(中文值正则误杀率高,方案待定)
- 命令名与权限(超管/群主)
@@ -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 风格卡面图片渲染(协议文本为渲染源)
@@ -28,11 +28,10 @@ except ImportError:
from . import models # noqa: F401 # 确保表结构注册
from . import repository, processor
from . import web # noqa: F401 # Web 管理后台(/galgame_card)
from . import handlers as _handlers # noqa: E402
from hexi.web_config import register_plugin_config # noqa: E402
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
from hexi.web_hub.web_config import register_plugin_config # noqa: E402
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
from . import web_hub # noqa: E402
@@ -57,7 +56,7 @@ def _get_galgame_config_values() -> dict:
conf = getattr(conf_mod, "config", None) if conf_mod else None
if conf is None:
return {}
keys = ("web_enable", "web_username", "web_password", "web_secret_key", "image_vision_enabled")
keys = ("image_vision_enabled",)
return {k: getattr(conf, k, None) for k in keys}
@@ -65,10 +64,6 @@ register_plugin_config(
__name__,
{
"fields": [
{"key": "web_enable", "label": "Web 后台启用", "type": "bool", "default": True},
{"key": "web_username", "label": "Web 登录账号", "type": "string", "default": "admin"},
{"key": "web_password", "label": "Web 登录密码", "type": "password", "default": "galgame", "secret": True},
{"key": "web_secret_key", "label": "Web 签名密钥", "type": "password", "default": "galgame-card-secret-key", "secret": True},
{"key": "image_vision_enabled", "label": "图片识别启用", "type": "bool", "default": False},
]
},
@@ -1,6 +1,6 @@
"""插件配置(Web 管理后台)
"""插件配置(图片识别等运行时行为)。
env 变量名与字段名对应(大写):WEB_ENABLE / WEB_USERNAME / WEB_PASSWORD / WEB_SECRET_KEY
access 由统一 Web 管理台 /hub 提供,插件不再自持 Web 鉴权/开关。
"""
from nonebot import get_plugin_config
@@ -8,10 +8,6 @@ from pydantic import BaseModel
class Config(BaseModel):
web_enable: bool = True
web_username: str = "admin"
web_password: str = "galgame"
web_secret_key: str = "galgame-card-secret-key"
# 图片多模态识别(预留接口,当前未启用;将来接入后置 True)
image_vision_enabled: bool = False
@@ -1,266 +0,0 @@
"""Web 管理后台:群开关 / 参与者 / 数据浏览(挂在 bot 端口 /galgame_card 路径)
仿 nonebot_plugin_learning_chat 的网页模式:JWT 登录 + /galgame_card/api/* 路由 + 静态页。
"""
import datetime
from pathlib import Path
from typing import Optional
from fastapi import Depends, Header, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from jose import jwt
from nonebot import get_adapter, get_app, get_driver, logger
from nonebot.adapters.onebot.v11 import Adapter, Bot
from pydantic import BaseModel
from . import repository
from .config import config
API_PREFIX = "/galgame_card/api"
WEB_DIR = Path(__file__).parent / "web"
driver = get_driver()
def authentication():
def inner(token: Optional[str] = Header(None, alias="Authorization")):
try:
payload = jwt.decode(token, config.web_secret_key, algorithms="HS256")
if not payload.get("username") or payload["username"] != config.web_username:
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
except (jwt.JWTError, jwt.ExpiredSignatureError, AttributeError):
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
return Depends(inner)
class LoginModel(BaseModel):
username: str
password: str
def _ok(data=None, msg: str = "ok") -> JSONResponse:
return JSONResponse({"status": 0, "msg": msg, "data": data})
def _err(code: int, msg: str) -> JSONResponse:
return JSONResponse({"status": code, "msg": msg})
async def _first_bot() -> Optional[Bot]:
bots = get_adapter(Adapter).bots
return next(iter(bots.values()), None)
@driver.on_startup
async def init_web():
if not config.web_enable:
return
app = get_app()
@app.post(f"{API_PREFIX}/login")
async def login(user: LoginModel):
if user.username != config.web_username or user.password != config.web_password:
return _err(-100, "登录失败,请确认账号密码无误")
token = jwt.encode(
{
"username": user.username,
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12),
},
config.web_secret_key,
algorithm="HS256",
)
return _ok({"token": token}, "登录成功")
@app.get(f"{API_PREFIX}/groups", dependencies=[authentication()])
async def groups():
"""管理中的群:数据库直读,不依赖 OneBot"""
rows = await repository.list_groups()
corpus = await repository.corpus_counts_by_group()
impressions = await repository.impression_counts_by_group()
summaries = await repository.summary_counts_by_group()
participants = await repository.participant_counts_by_group()
result = []
for row in rows:
gid = row.group_id
result.append(
{
"group_id": gid,
"group_name": row.group_name,
"enabled": row.enabled,
"participants": participants.get(gid, 0),
"corpus_count": corpus.get(gid, 0),
"impression_count": impressions.get(gid, 0),
"summary_count": summaries.get(gid, 0),
}
)
return _ok(result)
@app.get(f"{API_PREFIX}/groups/discover", dependencies=[authentication()])
async def discover_groups():
"""OneBot 全部群("添加群"选择器用),顺带回填已管理群名称"""
bot = await _first_bot()
if bot is None:
return _err(-100, "未连接 OneBot,无法获取群列表")
group_list = await bot.get_group_list()
managed_ids = {row.group_id for row in await repository.list_groups()}
result = [
{
"group_id": int(g["group_id"]),
"group_name": g.get("group_name", ""),
"managed": int(g["group_id"]) in managed_ids,
}
for g in group_list
]
await repository.backfill_group_names(
{int(g["group_id"]): g.get("group_name", "") for g in group_list}
)
return _ok(result)
@app.post(f"{API_PREFIX}/groups", dependencies=[authentication()])
async def add_group(data: dict):
gid = int(data.get("group_id") or 0)
if gid <= 0:
return _err(400, "group_id 不合法")
await repository.add_group(gid, str(data.get("group_name") or "").strip())
return _ok(msg="已加入管理列表")
@app.post(f"{API_PREFIX}/groups/{{group_id}}/enabled", dependencies=[authentication()])
async def set_group_enabled(group_id: int, data: dict):
await repository.set_group_enabled(group_id, bool(data.get("enabled")))
return _ok(msg="已开启采集" if data.get("enabled") else "已关闭采集")
@app.get(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
async def participants(group_id: int):
members = await repository.joined_users(group_id)
stats = dict(await repository.chat_log_stats_by_user(group_id))
result = [
{"user_id": uid, "corpus_count": stats.get(uid, 0)} for uid in members
]
return _ok(result)
@app.post(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
async def add_participant(group_id: int, data: dict):
user_id = int(data.get("user_id") or 0)
if user_id <= 0:
return _err(400, "user_id 不合法")
if await repository.is_joined(user_id, group_id):
return _err(400, "已在人设名单中")
await repository.join(user_id, group_id)
return _ok(msg="已加入")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/participants/{{user_id}}", dependencies=[authentication()])
async def remove_participant(group_id: int, user_id: int):
await repository.leave(user_id, group_id)
return _ok(msg="已移除")
@app.get(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
async def logs(group_id: int, user_id: Optional[int] = None, page: int = 1, per_page: int = 50):
offset = max(0, (page - 1) * per_page)
items = await repository.list_logs(group_id, user_id, offset, per_page)
total = await repository.count_logs_filtered(group_id, user_id)
result = [
{
"id": row.id,
"user_id": row.user_id,
"nickname": row.nickname,
"content": row.content,
"target_user_id": row.target_user_id,
"target_inherited": row.target_inherited,
"follows_id": row.follows_id,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok({"items": result, "total": total})
@app.get(f"{API_PREFIX}/groups/{{group_id}}/impressions", dependencies=[authentication()])
async def impressions(group_id: int, page: int = 1, per_page: int = 50):
offset = max(0, (page - 1) * per_page)
items = await repository.list_impressions_group(group_id, offset, per_page)
result = [
{
"id": row.id,
"user_id": row.user_id,
"content": row.content,
"cover_from_id": row.cover_from_id,
"cover_to_id": row.cover_to_id,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok({"items": result, "total": await repository.count_impressions_group(group_id)})
@app.get(f"{API_PREFIX}/groups/{{group_id}}/summaries", dependencies=[authentication()])
async def summaries(group_id: int):
items = await repository.list_summaries_group(group_id)
result = [
{
"version": row.version,
"card_text": row.card_text,
"corpus_count": row.corpus_count,
"impression_count": row.impression_count,
"model": row.model,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok(result)
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
async def delete_logs(group_id: int, user_id: Optional[int] = None):
count = await repository.delete_logs(group_id, user_id)
return _ok(msg=f"已删除 {count} 条语料")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/data", dependencies=[authentication()])
async def clear_group(group_id: int):
await repository.clear_group_data(group_id)
return _ok(msg="已清空该群全部数据")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}", dependencies=[authentication()])
async def remove_group(group_id: int):
await repository.remove_group(group_id)
return _ok(msg="已移出管理列表(数据保留,重新加入可续上)")
@app.get(f"{API_PREFIX}/settings", dependencies=[authentication()])
async def settings():
from . import processor
return _ok(
{
"web_username": config.web_username,
"web_enable": config.web_enable,
"thresholds": {
"max_content_len": processor.MAX_CONTENT_LEN,
"repeat_window": processor.REPEAT_WINDOW,
"throttle_seconds": processor.THROTTLE_SECONDS,
"rolling_window": repository.ROLLING_WINDOW,
},
}
)
# React 管理后台(web/persona-admin 构建产物)
dist_dir = WEB_DIR / "persona-admin" / "dist"
if (dist_dir / "index.html").exists():
app.mount(
"/galgame_card/assets",
StaticFiles(directory=dist_dir / "assets"),
name="galgame_card_assets",
)
@app.get("/galgame_card")
@app.get("/galgame_card/")
async def admin_page():
return FileResponse(dist_dir / "index.html")
else:
# 构建产物缺失时回退到旧版单文件页面
@app.get("/galgame_card")
@app.get("/galgame_card/")
async def admin_page():
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
return HTMLResponse(html)
logger.info("群聊人设卡 | Web 管理后台已挂载: /galgame_card")
@@ -1,370 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>群聊人设卡 · 管理后台</title>
<style>
:root { --bg:#1a1d24; --panel:#232833; --border:#333a47; --text:#dde2ea; --muted:#8a94a6;
--accent:#5b8cff; --danger:#ff5c5c; --ok:#3ecf8e; --sidebar:#1e222b; }
* { box-sizing:border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--text); font:14px/1.6 "Microsoft YaHei",sans-serif; }
h1 { font-size:20px; }
h2 { font-size:16px; margin:16px 0 10px; }
.card { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:16px; }
table { width:100%; border-collapse:collapse; }
th,td { padding:8px 10px; text-align:left; border-bottom:1px solid var(--border); }
th { color:var(--muted); font-weight:normal; font-size:12px; }
tr:hover td { background:rgba(255,255,255,.03); }
button { background:var(--accent); color:#fff; border:none; border-radius:6px; padding:6px 14px; cursor:pointer; font-size:13px; }
button:hover { opacity:.85; }
button.danger { background:var(--danger); }
button.ghost { background:transparent; border:1px solid var(--border); color:var(--muted); }
input,select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:6px 10px; font-size:13px; }
.switch { position:relative; width:42px; height:22px; display:inline-block; cursor:pointer; }
.switch input { display:none; }
.switch .track { position:absolute; inset:0; background:var(--border); border-radius:11px; transition:.2s; }
.switch .track::after { content:""; position:absolute; width:16px; height:16px; background:#fff; border-radius:50%; top:3px; left:3px; transition:.2s; }
.switch input:checked + .track { background:var(--ok); }
.switch input:checked + .track::after { left:23px; }
.muted { color:var(--muted); font-size:12px; }
.tabs { display:flex; gap:6px; margin-bottom:12px; }
.tabs button.active { background:var(--accent); }
pre { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:12px; white-space:pre-wrap; word-break:break-all; font-size:12px; max-height:400px; overflow:auto; }
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
.hidden { display:none; }
.msg { margin:8px 0; font-size:13px; }
.msg.err { color:var(--danger); }
.msg.ok { color:var(--ok); }
/* 登录 */
.login-wrap { max-width:320px; margin:80px auto; }
.login-wrap .card { padding:28px; }
.login-wrap input { width:100%; margin-bottom:12px; padding:9px; }
.login-wrap button { width:100%; padding:9px; }
/* 侧栏 + 主体 */
#mainView { display:flex; min-height:100vh; }
.sidebar { width:200px; flex-shrink:0; background:var(--sidebar); border-right:1px solid var(--border);
display:flex; flex-direction:column; position:sticky; top:0; height:100vh; }
.brand { padding:20px 18px 14px; font-size:16px; font-weight:bold; border-bottom:1px solid var(--border); }
.nav { flex:1; padding:10px 0; }
.nav a { display:block; padding:10px 18px; color:var(--muted); text-decoration:none; font-size:14px; border-left:3px solid transparent; }
.nav a:hover { color:var(--text); background:rgba(255,255,255,.03); }
.nav a.active { color:var(--text); background:rgba(91,140,255,.12); border-left-color:var(--accent); }
.sidebar-footer { padding:14px 18px; border-top:1px solid var(--border); }
.sidebar-footer button { width:100%; background:transparent; border:1px solid var(--border); color:var(--muted); }
.sidebar-footer button:hover { color:var(--danger); border-color:var(--danger); }
.content { flex:1; padding:24px; min-width:0; }
.page-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
</style>
</head>
<body>
<div id="loginView" class="login-wrap hidden">
<div class="card">
<h1 style="text-align:center">群聊人设卡</h1>
<input id="username" placeholder="用户名">
<input id="password" type="password" placeholder="密码">
<button onclick="login()">登 录</button>
<div id="loginMsg" class="msg"></div>
</div>
</div>
<div id="mainView" class="hidden">
<aside class="sidebar">
<div class="brand">群聊人设卡</div>
<nav class="nav">
<a href="#/groups">群管理</a>
<a href="#/data">数据浏览</a>
<a href="#/settings">设置</a>
</nav>
<div class="sidebar-footer"><button onclick="logout()">退出登录</button></div>
</aside>
<main class="content" id="content"></main>
</div>
<script>
const API = "/galgame_card/api";
let TOKEN = localStorage.getItem("gc_token") || "";
/* ---------- 基础 ---------- */
async function api(path, options = {}) {
const res = await fetch(API + path, {
...options,
headers: { "Content-Type": "application/json", "Authorization": TOKEN, ...(options.headers || {}) },
});
const data = await res.json();
if (data.status !== 0) {
if (data.status === 400) showLogin();
throw new Error(data.msg || "请求失败");
}
return data.data;
}
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c])); }
function pageHead(title, extra = "") {
return `<div class="page-head"><h1>${esc(title)}</h1><div>${extra}</div></div>`;
}
function showLogin() {
document.getElementById("loginView").classList.remove("hidden");
document.getElementById("mainView").classList.add("hidden");
}
function showMain() {
document.getElementById("loginView").classList.add("hidden");
document.getElementById("mainView").classList.remove("hidden");
}
async function login() {
const msg = document.getElementById("loginMsg");
try {
const data = await api("/login", { method: "POST", body: JSON.stringify({
username: document.getElementById("username").value.trim(),
password: document.getElementById("password").value }) });
TOKEN = data.token; localStorage.setItem("gc_token", TOKEN);
msg.className = "msg ok"; msg.textContent = "登录成功";
showMain(); location.hash = "#/groups"; route();
} catch (e) { msg.className = "msg err"; msg.textContent = e.message; }
}
function logout() { localStorage.removeItem("gc_token"); TOKEN = ""; showLogin(); }
/* ---------- 路由:侧栏 + 主体 ---------- */
const routes = { "#/groups": renderGroupList, "#/data": renderDataBrowser, "#/settings": renderSettings };
function route() {
const hash = location.hash || "#/groups";
document.querySelectorAll(".nav a").forEach(a => a.classList.toggle("active", a.getAttribute("href") === hash));
const content = document.getElementById("content");
const m = hash.match(/^#\/groups\/(\d+)/);
if (m) { renderGroupDetail(Number(m[1])); return; }
(routes[hash] || renderGroupList)(content);
}
/* ---------- 群管理 ---------- */
async function renderGroupList(content) {
content.innerHTML = pageHead("群管理", '<button class="ghost" onclick="route()">刷新</button>') +
`<div class="card"><h2>群采集开关</h2>
<table><thead><tr><th>群</th><th>开关</th><th>参与者</th><th>语料</th><th>印象</th><th>画像</th><th></th></tr></thead>
<tbody id="groupRows"></tbody></table><div id="listMsg" class="msg"></div></div>`;
const tbody = document.getElementById("groupRows");
document.getElementById("listMsg").textContent = "加载中…";
try {
const groups = await api("/groups");
tbody.innerHTML = groups.map(g => `<tr>
<td><b>${esc(g.group_name)}</b><br><span class="muted">${g.group_id}</span></td>
<td><label class="switch"><input type="checkbox" ${g.enabled ? "checked" : ""} onchange="toggleGroup(${g.group_id}, this.checked)"><span class="track"></span></label></td>
<td>${g.participants}</td><td>${g.corpus_count}</td><td>${g.impression_count}</td><td>${g.summary_count}</td>
<td><a href="#/groups/${g.group_id}"><button>管理</button></a></td></tr>`).join("");
document.getElementById("listMsg").textContent = "";
} catch (e) { document.getElementById("listMsg").className = "msg err"; document.getElementById("listMsg").textContent = e.message; }
}
async function toggleGroup(gid, enabled) {
try { await api(`/groups/${gid}/enabled`, { method: "POST", body: JSON.stringify({ enabled }) }); }
catch (e) { alert(e.message); route(); }
}
/* ---------- 群详情(管理) ---------- */
async function renderGroupDetail(gid) {
const content = document.getElementById("content");
content.innerHTML = pageHead(`群管理 #${gid}`,
`<a href="#/groups"><button class="ghost">← 返回</button></a>
<button class="danger" onclick="clearGroup(${gid})">清空该群全部数据</button>`) +
`<div class="card"><h2>参与者</h2>
<div class="row"><input id="addUserId" placeholder="QQ 号" type="number">
<button onclick="addParticipant(${gid})">加入人设名单</button>
<span class="muted">仅名单内成员会被采集</span></div>
<table><thead><tr><th>QQ</th><th>语料条数</th><th></th></tr></thead><tbody id="participantRows"></tbody></table></div>
<div class="card"><div class="tabs">
<button id="tabLogs" class="active" onclick="switchTab('logs')">语料</button>
<button id="tabImpressions" onclick="switchTab('impressions')">印象</button>
<button id="tabSummaries" onclick="switchTab('summaries')">画像</button>
</div>
<div id="tabLogsView">
<div class="row"><input id="logUserId" placeholder="按 QQ 过滤" type="number">
<button onclick="loadLogs(${gid}, 1)">查询</button>
<button class="danger" onclick="clearLogs(${gid})">删除全部语料</button></div>
<table><thead><tr><th>ID</th><th>发送者</th><th>发给</th><th>时间</th><th>内容(已脱敏)</th></tr></thead>
<tbody id="logRows"></tbody></table>
<div class="row" id="logPager" style="margin-top:10px"></div>
</div>
<div id="tabImpressionsView" class="hidden">
<table><thead><tr><th>ID</th><th>用户</th><th>覆盖语料区间</th><th>时间</th><th>印象内容</th></tr></thead>
<tbody id="impressionRows"></tbody></table>
</div>
<div id="tabSummariesView" class="hidden"><div id="summaryList"></div></div>
</div>`;
window._gid = gid; window._logPage = 1;
loadParticipants(gid); switchTab("logs"); loadLogs(gid, 1);
}
async function loadParticipants(gid) {
const tbody = document.getElementById("participantRows");
try {
const items = await api(`/groups/${gid}/participants`);
tbody.innerHTML = items.map(p => `<tr><td>${p.user_id}</td><td>${p.corpus_count}</td>
<td><button class="danger" onclick="removeParticipant(${gid}, ${p.user_id})">移除</button></td></tr>`).join("")
|| `<tr><td colspan="3" class="muted">暂无参与者</td></tr>`;
} catch (e) { alert(e.message); }
}
async function addParticipant(gid) {
const uid = document.getElementById("addUserId").value.trim();
if (!uid) return;
try { await api(`/groups/${gid}/participants`, { method: "POST", body: JSON.stringify({ user_id: Number(uid) }) });
document.getElementById("addUserId").value = ""; loadParticipants(gid); }
catch (e) { alert(e.message); }
}
async function removeParticipant(gid, uid) {
if (!confirm(`移除 ${uid}?其语料保留在库中`)) return;
await api(`/groups/${gid}/participants/${uid}`, { method: "DELETE" });
loadParticipants(gid);
}
function switchTab(tab) {
["logs", "impressions", "summaries"].forEach(t => {
const name = t[0].toUpperCase() + t.slice(1);
document.getElementById(`tab${name}`).classList.toggle("active", t === tab);
document.getElementById(`tab${name}View`).classList.toggle("hidden", t !== tab);
});
const gid = window._gid;
if (tab === "logs") loadLogs(gid, 1);
if (tab === "impressions") loadImpressions(gid);
if (tab === "summaries") loadSummaries(gid);
}
async function loadLogs(gid, page) {
window._logPage = page;
const tbody = document.getElementById("logRows");
const uid = document.getElementById("logUserId").value.trim();
const q = new URLSearchParams({ page, per_page: 50 });
if (uid) q.set("user_id", uid);
try {
const data = await api(`/groups/${gid}/logs?${q}`);
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${esc(r.nickname)}<br><span class="muted">${r.user_id}</span></td>
<td>${r.target_user_id ? "→ " + r.target_user_id : '<span class="muted">—</span>'}</td><td>${r.created_at}</td>
<td>${esc(r.content)}</td></tr>`).join("") || `<tr><td colspan="5" class="muted">暂无语料</td></tr>`;
const pages = Math.max(1, Math.ceil(data.total / 50));
document.getElementById("logPager").innerHTML =
`<button class="ghost" ${page <= 1 ? "disabled" : ""} onclick="loadLogs(${gid}, ${page - 1})">上一页</button>
<span class="muted">第 ${page}/${pages} 页 · 共 ${data.total} 条</span>
<button class="ghost" ${page >= pages ? "disabled" : ""} onclick="loadLogs(${gid}, ${page + 1})">下一页</button>`;
} catch (e) { alert(e.message); }
}
async function clearLogs(gid) {
if (!confirm("删除该群全部语料?印象/画像保留")) return;
try { await api(`/groups/${gid}/logs`, { method: "DELETE" }); loadLogs(gid, 1); } catch (e) { alert(e.message); }
}
async function loadImpressions(gid) {
const tbody = document.getElementById("impressionRows");
try {
const data = await api(`/groups/${gid}/impressions?page=1&per_page=100`);
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${r.user_id}</td>
<td>#${r.cover_from_id} ~ #${r.cover_to_id}</td><td>${r.created_at}</td><td>${esc(r.content)}</td></tr>`).join("")
|| `<tr><td colspan="5" class="muted">暂无印象</td></tr>`;
} catch (e) { alert(e.message); }
}
async function loadSummaries(gid) {
const box = document.getElementById("summaryList");
try {
const items = await api(`/groups/${gid}/summaries`);
box.innerHTML = items.map(s => `<h3 style="margin:14px 0 6px">v${s.version} · ${s.created_at} · 语料 ${s.corpus_count} 条 · 印象 ${s.impression_count} 条</h3><pre>${esc(s.card_text)}</pre>`).join("")
|| `<p class="muted">暂无画像(语料积累后由总结路径生成)</p>`;
} catch (e) { alert(e.message); }
}
async function clearGroup(gid) {
if (!confirm("确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复")) return;
try { await api(`/groups/${gid}`, { method: "DELETE" }); location.hash = "#/groups"; route(); } catch (e) { alert(e.message); }
}
/* ---------- 数据浏览(跨群) ---------- */
async function renderDataBrowser(content) {
content.innerHTML = pageHead("数据浏览") +
`<div class="card"><div class="row">
<select id="dataGroupSelect" onchange="dataSelectGroup()"><option value="">选择群…</option></select>
<span class="muted" id="dataGroupInfo"></span></div>
<div class="tabs">
<button id="dTabLogs" class="active" onclick="dataTab('logs')">语料</button>
<button id="dTabImpressions" onclick="dataTab('impressions')">印象</button>
<button id="dTabSummaries" onclick="dataTab('summaries')">画像</button>
</div>
<div id="dTabLogsView">
<table><thead><tr><th>ID</th><th>发送者</th><th>发给</th><th>时间</th><th>内容(已脱敏)</th></tr></thead>
<tbody id="dLogRows"><tr><td colspan="5" class="muted">请先选择群</td></tr></tbody></table>
</div>
<div id="dTabImpressionsView" class="hidden">
<table><thead><tr><th>ID</th><th>用户</th><th>覆盖语料区间</th><th>时间</th><th>印象内容</th></tr></thead>
<tbody id="dImpressionRows"></tbody></table>
</div>
<div id="dTabSummariesView" class="hidden"><div id="dSummaryList"></div></div>
</div>`;
try {
const groups = await api("/groups");
document.getElementById("dataGroupSelect").innerHTML =
`<option value="">选择群…</option>` + groups.map(g => `<option value="${g.group_id}">${esc(g.group_name)}(${g.group_id})</option>`).join("");
} catch (e) { alert(e.message); }
}
async function dataSelectGroup() {
const gid = Number(document.getElementById("dataGroupSelect").value);
window._dataGid = gid;
document.getElementById("dataGroupInfo").textContent = gid ? "" : "";
if (!gid) return;
dataTab("logs");
}
function dataTab(tab) {
["logs", "impressions", "summaries"].forEach(t => {
const name = "d" + t[0].toUpperCase() + t.slice(1);
document.getElementById(`dTab${t[0].toUpperCase()}${t.slice(1)}`).classList.toggle("active", t === tab);
document.getElementById(`dTab${t[0].toUpperCase()}${t.slice(1)}View`).classList.toggle("hidden", t !== tab);
});
const gid = window._dataGid;
if (!gid) return;
if (tab === "logs") loadDataLogs(gid);
if (tab === "impressions") loadDataImpressions(gid);
if (tab === "summaries") loadDataSummaries(gid);
}
async function loadDataLogs(gid) {
const tbody = document.getElementById("dLogRows");
try {
const data = await api(`/groups/${gid}/logs?page=1&per_page=100`);
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${esc(r.nickname)}<br><span class="muted">${r.user_id}</span></td>
<td>${r.target_user_id ? "→ " + r.target_user_id : '<span class="muted">—</span>'}</td><td>${r.created_at}</td>
<td>${esc(r.content)}</td></tr>`).join("")
|| `<tr><td colspan="5" class="muted">暂无数据</td></tr>`;
} catch (e) { alert(e.message); }
}
async function loadDataImpressions(gid) {
const tbody = document.getElementById("dImpressionRows");
try {
const data = await api(`/groups/${gid}/impressions?page=1&per_page=100`);
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${r.user_id}</td>
<td>#${r.cover_from_id} ~ #${r.cover_to_id}</td><td>${r.created_at}</td><td>${esc(r.content)}</td></tr>`).join("")
|| `<tr><td colspan="5" class="muted">暂无数据</td></tr>`;
} catch (e) { alert(e.message); }
}
async function loadDataSummaries(gid) {
const box = document.getElementById("dSummaryList");
try {
const items = await api(`/groups/${gid}/summaries`);
box.innerHTML = items.map(s => `<h3 style="margin:14px 0 6px">v${s.version} · ${s.created_at} · 语料 ${s.corpus_count} 条 · 印象 ${s.impression_count} 条</h3><pre>${esc(s.card_text)}</pre>`).join("")
|| `<p class="muted">暂无数据</p>`;
} catch (e) { alert(e.message); }
}
/* ---------- 设置 ---------- */
async function renderSettings(content) {
content.innerHTML = pageHead("设置") + `<div class="card" id="settingsBody"><p class="muted">加载中…</p></div>`;
try {
const s = await api("/settings");
const rows = [
["Web 登录账号", s.web_username],
["Web 后台启用", s.web_enable ? "是" : "否"],
["长文截断上限", `${s.thresholds.max_content_len} 字`],
["复读检测窗口", `${s.thresholds.repeat_window} 条`],
["刷屏采样间隔", `${s.thresholds.throttle_seconds} 秒`],
["单用户语料滚动上限", `${s.thresholds.rolling_window} 条`],
];
document.getElementById("settingsBody").innerHTML =
`<h2>当前配置(只读)</h2>
<table>${rows.map(r => `<tr><th style="width:200px">${r[0]}</th><td>${esc(r[1])}</td></tr>`).join("")}</table>
<p class="muted" style="margin-top:12px">阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME / WEB_PASSWORD。</p>`;
} catch (e) { document.getElementById("settingsBody").innerHTML = `<p class="msg err">${esc(e.message)}</p>`; }
}
/* ---------- 启动 ---------- */
window.addEventListener("hashchange", route);
if (TOKEN) { showMain(); route(); } else { showLogin(); }
</script>
</body>
</html>
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>群聊人设卡 · 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,30 +0,0 @@
{
"name": "persona-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@heroui/react": "^2.8.0",
"@heroui/theme": "^2.4.26",
"framer-motion": "^11.11.17",
"next-themes": "^0.4.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^6.0.3"
}
}
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -1,112 +0,0 @@
import { useEffect } from "react";
import {
Navigate,
NavLink,
Outlet,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router-dom";
import { Button, Tooltip } from "@heroui/react";
import { useTheme } from "next-themes";
import { clearToken, hasToken } from "./api";
import Login from "./pages/Login";
import GroupList from "./pages/GroupList";
import GroupDetail from "./pages/GroupDetail";
import DataBrowser from "./pages/DataBrowser";
import Settings from "./pages/Settings";
const NAV_ITEMS = [
{ to: "/groups", label: "群管理", icon: "◈" },
{ to: "/data", label: "数据浏览", icon: "▤" },
{ to: "/settings", label: "设置", icon: "⚙" },
];
function Layout() {
const navigate = useNavigate();
const location = useLocation();
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
useEffect(() => {
if (!hasToken()) navigate("/login", { replace: true });
}, [location.pathname]);
const logout = () => {
clearToken();
navigate("/login", { replace: true });
};
return (
<div className="flex h-screen overflow-hidden">
<aside className="w-56 shrink-0 border-r border-default-100 bg-content1 flex flex-col">
<div className="px-5 py-5 text-lg font-bold tracking-wide border-b border-default-100">
群聊人设卡
</div>
<nav className="flex-1 px-3 py-4 space-y-1">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-colors ${
isActive
? "bg-primary/15 text-primary font-medium"
: "text-default-500 hover:text-foreground hover:bg-default-100/50"
}`
}
>
<span className="w-4 text-center">{item.icon}</span>
{item.label}
</NavLink>
))}
</nav>
<div className="px-3 py-4 border-t border-default-100 flex items-center gap-2">
<Tooltip content={isDark ? "切换亮色" : "切换暗色"} placement="right">
<Button
isIconOnly
variant="light"
size="sm"
onPress={() => setTheme(isDark ? "light" : "dark")}
aria-label="切换主题"
>
{isDark ? "☀" : "🌙"}
</Button>
</Tooltip>
<Tooltip content="退出登录" placement="right">
<Button
fullWidth
variant="light"
color="danger"
size="sm"
onPress={logout}
className="justify-start"
>
⏻ 退出登录
</Button>
</Tooltip>
</div>
</aside>
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
);
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/groups" replace />} />
<Route path="/groups" element={<GroupList />} />
<Route path="/groups/:gid" element={<GroupDetail />} />
<Route path="/data" element={<DataBrowser />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/groups" replace />} />
</Route>
</Routes>
);
}
@@ -1,92 +0,0 @@
const API = "/galgame_card/api";
let token: string = localStorage.getItem("gc_token") || "";
export function setToken(t: string) {
token = t;
localStorage.setItem("gc_token", t);
}
export function clearToken() {
token = "";
localStorage.removeItem("gc_token");
}
export function hasToken() {
return !!token;
}
export async function api<T = any>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(API + path, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: token,
...(options.headers || {}),
},
});
const data = await res.json();
if (data.status !== 0) {
if (data.status === 400) {
clearToken();
window.location.hash = "#/login";
}
throw new Error(data.msg || "请求失败");
}
return data.data as T;
}
/* ---------- 类型 ---------- */
export interface GroupInfo {
group_id: number;
group_name: string;
enabled: boolean;
participants: number;
corpus_count: number;
impression_count: number;
summary_count: number;
}
export interface DiscoverGroup {
group_id: number;
group_name: string;
managed: boolean;
}
export interface Participant {
user_id: number;
corpus_count: number;
}
export interface LogItem {
id: number;
user_id: number;
nickname: string;
content: string;
target_user_id: number | null;
target_inherited: boolean;
follows_id: number | null;
created_at: string;
}
export interface ImpressionItem {
id: number;
user_id: number;
content: string;
cover_from_id: number;
cover_to_id: number;
created_at: string;
}
export interface SummaryItem {
version: number;
card_text: string;
corpus_count: number;
impression_count: number;
model: string;
created_at: string;
}
export interface SettingsInfo {
web_username: string;
web_enable: boolean;
thresholds: {
max_content_len: number;
repeat_window: number;
throttle_seconds: number;
rolling_window: number;
};
}
@@ -1,24 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html.dark {
color-scheme: dark;
}
body {
@apply bg-background text-foreground;
font-family: "Microsoft YaHei", -apple-system, sans-serif;
}
/* 侧栏滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
@apply bg-default-700 rounded-full;
}
::-webkit-scrollbar-track {
background: transparent;
}
@@ -1,20 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { HeroUIProvider, ToastProvider } from "@heroui/react";
import { ThemeProvider } from "next-themes";
import { HashRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<HeroUIProvider>
<ToastProvider toastProps={{ placement: "top-center" }} />
<HashRouter>
<App />
</HashRouter>
</HeroUIProvider>
</ThemeProvider>
</React.StrictMode>
);
@@ -1,171 +0,0 @@
import { useEffect, useState } from "react";
import {
Card,
CardBody,
Select,
SelectItem,
Spinner,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@heroui/react";
import { addToast } from "@heroui/react";
import { api, GroupInfo, ImpressionItem, LogItem, SummaryItem } from "../api";
export default function DataBrowser() {
const [groups, setGroups] = useState<GroupInfo[]>([]);
const [gid, setGid] = useState<number>(0);
const [tab, setTab] = useState("logs");
const [logs, setLogs] = useState<LogItem[]>([]);
const [impressions, setImpressions] = useState<ImpressionItem[]>([]);
const [summaries, setSummaries] = useState<SummaryItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
api<GroupInfo[]>("/groups")
.then(setGroups)
.catch((e: any) => addToast({ title: e.message, color: "danger" }));
}, []);
useEffect(() => {
if (!gid) return;
setLoading(true);
const loaders: Record<string, Promise<any>> = {
logs: api(`/groups/${gid}/logs?page=1&per_page=100`).then((d: any) =>
setLogs(d.items)
),
impressions: api(`/groups/${gid}/impressions?page=1&per_page=100`).then((d: any) =>
setImpressions(d.items)
),
summaries: api(`/groups/${gid}/summaries`).then(setSummaries),
};
Promise.all([loaders.logs, loaders.impressions, loaders.summaries]).finally(() =>
setLoading(false)
);
}, [gid]);
return (
<div>
<h1 className="text-xl font-bold mb-5">数据浏览</h1>
<Card className="bg-content1">
<CardBody>
<Select
label="选择群"
placeholder="选择要浏览的群"
className="max-w-sm mb-4"
variant="bordered"
selectedKeys={gid ? [String(gid)] : []}
onSelectionChange={(keys) => {
const k = Array.from(keys)[0];
if (k) setGid(Number(k));
}}
>
{groups.map((g) => (
<SelectItem key={String(g.group_id)}>
{g.group_name || `群 ${g.group_id}`}({g.group_id})
</SelectItem>
))}
</Select>
{!gid && <p className="text-default-500 py-6">请先选择群</p>}
{loading && gid && (
<div className="flex justify-center py-16">
<Spinner />
</div>
)}
{!loading && gid && (
<Tabs selectedKey={tab} onSelectionChange={(k) => setTab(String(k))}>
<Tab key="logs" title="语料">
<Table aria-label="语料" removeWrapper>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>发送者</TableColumn>
<TableColumn>发给</TableColumn>
<TableColumn>时间</TableColumn>
<TableColumn>内容(已脱敏)</TableColumn>
</TableHeader>
<TableBody items={logs} emptyContent={gid ? "暂无数据" : "请先选择群"}>
{(l) => (
<TableRow key={l.id}>
<TableCell>{l.id}</TableCell>
<TableCell>
<div>{l.nickname || "—"}</div>
<div className="text-xs text-default-500">{l.user_id}</div>
</TableCell>
<TableCell>
{l.target_user_id ? (
<span>
→ {l.target_user_id}
{l.target_inherited && (
<span className="text-xs text-warning ml-1">延续</span>
)}
</span>
) : (
<span className="text-default-500">—</span>
)}
</TableCell>
<TableCell className="text-default-500">{l.created_at}</TableCell>
<TableCell className="max-w-xl break-all">{l.content}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Tab>
<Tab key="impressions" title="印象">
<Table aria-label="印象" removeWrapper>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>覆盖语料区间</TableColumn>
<TableColumn>时间</TableColumn>
<TableColumn>印象内容</TableColumn>
</TableHeader>
<TableBody items={impressions} emptyContent={gid ? "暂无数据" : "请先选择群"}>
{(r) => (
<TableRow key={r.id}>
<TableCell>{r.id}</TableCell>
<TableCell>{r.user_id}</TableCell>
<TableCell className="text-default-500">
#{r.cover_from_id} ~ #{r.cover_to_id}
</TableCell>
<TableCell className="text-default-500">{r.created_at}</TableCell>
<TableCell className="max-w-xl break-all">{r.content}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Tab>
<Tab key="summaries" title="画像">
{summaries.length === 0 ? (
<p className="text-default-500 py-6">{gid ? "暂无数据" : "请先选择群"}</p>
) : (
summaries.map((s) => (
<div key={s.version} className="mb-4">
<div className="text-sm text-default-500 mb-2">
v{s.version} · {s.created_at} · 语料 {s.corpus_count} 条 · 印象{" "}
{s.impression_count} 条
</div>
<pre className="bg-content2 border border-default-100 rounded-xl p-4 text-sm whitespace-pre-wrap break-all max-h-96 overflow-auto">
{s.card_text}
</pre>
</div>
))
)}
</Tab>
</Tabs>
)}
</CardBody>
</Card>
</div>
);
}
@@ -1,334 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
Button,
Card,
CardBody,
Input,
Spinner,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@heroui/react";
import { addToast } from "@heroui/react";
import { api, ImpressionItem, LogItem, Participant, SummaryItem } from "../api";
export default function GroupDetail() {
const { gid } = useParams();
const gidNum = Number(gid);
const [participants, setParticipants] = useState<Participant[]>([]);
const [addUid, setAddUid] = useState("");
const [tab, setTab] = useState("logs");
const [logs, setLogs] = useState<LogItem[]>([]);
const [logTotal, setLogTotal] = useState(0);
const [logPage, setLogPage] = useState(1);
const [logUid, setLogUid] = useState("");
const [impressions, setImpressions] = useState<ImpressionItem[]>([]);
const [summaries, setSummaries] = useState<SummaryItem[]>([]);
const [loading, setLoading] = useState(true);
const loadParticipants = useCallback(async () => {
try {
setParticipants(await api<Participant[]>(`/groups/${gidNum}/participants`));
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
}, [gidNum]);
const loadLogs = useCallback(
async (page: number) => {
const q = new URLSearchParams({ page: String(page), per_page: "50" });
if (logUid) q.set("user_id", logUid);
try {
const data = await api<{ items: LogItem[]; total: number }>(
`/groups/${gidNum}/logs?${q}`
);
setLogs(data.items);
setLogTotal(data.total);
setLogPage(page);
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
},
[gidNum, logUid]
);
const loadImpressions = useCallback(async () => {
try {
const data = await api<{ items: ImpressionItem[] }>(
`/groups/${gidNum}/impressions?page=1&per_page=100`
);
setImpressions(data.items);
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
}, [gidNum]);
const loadSummaries = useCallback(async () => {
try {
setSummaries(await api<SummaryItem[]>(`/groups/${gidNum}/summaries`));
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
}, [gidNum]);
useEffect(() => {
setLoading(true);
Promise.all([loadParticipants(), loadLogs(1)]).finally(() => setLoading(false));
}, [loadParticipants, loadLogs]);
useEffect(() => {
if (tab === "impressions") loadImpressions();
if (tab === "summaries") loadSummaries();
}, [tab]);
/* ---------- 操作 ---------- */
const addParticipant = async () => {
const uid = Number(addUid.trim());
if (!uid) return;
try {
await api(`/groups/${gidNum}/participants`, {
method: "POST",
body: JSON.stringify({ user_id: uid }),
});
setAddUid("");
loadParticipants();
addToast({ title: `已加入 ${uid}`, color: "success" });
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const removeParticipant = async (uid: number) => {
if (!confirm(`移除 ${uid}?其语料保留在库中`)) return;
try {
await api(`/groups/${gidNum}/participants/${uid}`, { method: "DELETE" });
loadParticipants();
addToast({ title: "已移除", color: "success" });
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const clearLogs = async () => {
if (!confirm("删除该群全部语料?印象/画像保留")) return;
try {
await api(`/groups/${gidNum}/logs`, { method: "DELETE" });
loadLogs(1);
addToast({ title: "已删除全部语料", color: "success" });
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const clearGroup = async () => {
if (!confirm("确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复")) return;
try {
await api(`/groups/${gidNum}/data`, { method: "DELETE" });
addToast({ title: "已清空", color: "success" });
setTimeout(() => (window.location.hash = "#/groups"), 600);
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const pages = Math.max(1, Math.ceil(logTotal / 50));
if (loading) {
return (
<div className="flex justify-center py-24">
<Spinner label="加载中…" />
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-5">
<Link to="/groups">
<Button size="sm" variant="flat" isIconOnly aria-label="返回">
←
</Button>
</Link>
<h1 className="text-xl font-bold flex-1">群管理 #{gidNum}</h1>
<Button size="sm" color="danger" variant="flat" onPress={clearGroup}>
清空该群全部数据
</Button>
</div>
{/* 参与者 */}
<Card className="bg-content1 mb-5">
<CardBody className="gap-3">
<h2 className="font-semibold">参与者</h2>
<div className="flex gap-2 max-w-md">
<Input
placeholder="QQ 号"
type="number"
value={addUid}
onValueChange={setAddUid}
variant="bordered"
size="sm"
onKeyDown={(e) => e.key === "Enter" && addParticipant()}
/>
<Button size="sm" color="primary" onPress={addParticipant}>
加入人设名单
</Button>
</div>
<Table aria-label="参与者" removeWrapper>
<TableHeader>
<TableColumn>QQ</TableColumn>
<TableColumn>语料条数</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={participants} emptyContent="暂无参与者">
{(p) => (
<TableRow key={p.user_id}>
<TableCell>{p.user_id}</TableCell>
<TableCell>{p.corpus_count}</TableCell>
<TableCell>
<Button size="sm" color="danger" variant="light" onPress={() => removeParticipant(p.user_id)}>
移除
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
{/* 数据页签 */}
<Card className="bg-content1">
<CardBody>
<Tabs selectedKey={tab} onSelectionChange={(k) => setTab(String(k))}>
<Tab key="logs" title="语料">
<div className="flex items-center gap-2 mb-3">
<Input
placeholder="按 QQ 过滤"
type="number"
value={logUid}
onValueChange={setLogUid}
variant="bordered"
size="sm"
className="max-w-40"
/>
<Button size="sm" variant="flat" onPress={() => loadLogs(1)}>
查询
</Button>
<Button size="sm" color="danger" variant="flat" onPress={clearLogs}>
删除全部语料
</Button>
</div>
<Table aria-label="语料" removeWrapper>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>发送者</TableColumn>
<TableColumn>发给</TableColumn>
<TableColumn>时间</TableColumn>
<TableColumn>内容(已脱敏)</TableColumn>
</TableHeader>
<TableBody items={logs} emptyContent="暂无语料">
{(l) => (
<TableRow key={l.id}>
<TableCell>{l.id}</TableCell>
<TableCell>
<div>{l.nickname || "—"}</div>
<div className="text-xs text-default-500">{l.user_id}</div>
</TableCell>
<TableCell>
{l.target_user_id ? (
<span>
→ {l.target_user_id}
{l.target_inherited && (
<span className="text-xs text-warning ml-1">延续</span>
)}
</span>
) : (
<span className="text-default-500">—</span>
)}
</TableCell>
<TableCell className="text-default-500">{l.created_at}</TableCell>
<TableCell className="max-w-xl break-all">{l.content}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center gap-3 mt-3">
<Button
size="sm"
variant="flat"
isDisabled={logPage <= 1}
onPress={() => loadLogs(logPage - 1)}
>
上一页
</Button>
<span className="text-sm text-default-500">
第 {logPage}/{pages} 页 · 共 {logTotal} 条
</span>
<Button
size="sm"
variant="flat"
isDisabled={logPage >= pages}
onPress={() => loadLogs(logPage + 1)}
>
下一页
</Button>
</div>
</Tab>
<Tab key="impressions" title="印象">
<Table aria-label="印象" removeWrapper>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>覆盖语料区间</TableColumn>
<TableColumn>时间</TableColumn>
<TableColumn>印象内容</TableColumn>
</TableHeader>
<TableBody items={impressions} emptyContent="暂无印象">
{(r) => (
<TableRow key={r.id}>
<TableCell>{r.id}</TableCell>
<TableCell>{r.user_id}</TableCell>
<TableCell className="text-default-500">
#{r.cover_from_id} ~ #{r.cover_to_id}
</TableCell>
<TableCell className="text-default-500">{r.created_at}</TableCell>
<TableCell className="max-w-xl break-all">{r.content}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Tab>
<Tab key="summaries" title="画像">
{summaries.length === 0 ? (
<p className="text-default-500 py-6">暂无画像(语料积累后由总结路径生成)</p>
) : (
summaries.map((s) => (
<div key={s.version} className="mb-4">
<div className="text-sm text-default-500 mb-2">
v{s.version} · {s.created_at} · 语料 {s.corpus_count} 条 · 印象{" "}
{s.impression_count} 条
</div>
<pre className="bg-content2 border border-default-100 rounded-xl p-4 text-sm whitespace-pre-wrap break-all max-h-96 overflow-auto">
{s.card_text}
</pre>
</div>
))
)}
</Tab>
</Tabs>
</CardBody>
</Card>
</div>
);
}
@@ -1,227 +0,0 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
Button,
Card,
CardBody,
Checkbox,
CheckboxGroup,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Switch,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
useDisclosure,
} from "@heroui/react";
import { addToast } from "@heroui/react";
import { api, DiscoverGroup, GroupInfo } from "../api";
export default function GroupList() {
const [groups, setGroups] = useState<GroupInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// 添加群弹窗
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [discoverList, setDiscoverList] = useState<DiscoverGroup[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [discoverLoading, setDiscoverLoading] = useState(false);
const [adding, setAdding] = useState(false);
const load = async () => {
setLoading(true);
setError("");
try {
setGroups(await api<GroupInfo[]>("/groups"));
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const toggle = async (g: GroupInfo, enabled: boolean) => {
try {
await api(`/groups/${g.group_id}/enabled`, {
method: "POST",
body: JSON.stringify({ enabled }),
});
g.enabled = enabled;
setGroups([...groups]);
addToast({
title: enabled ? "已开启采集" : "已关闭采集",
description: `${g.group_name || g.group_id}`,
color: enabled ? "success" : "warning",
});
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
const removeGroup = async (g: GroupInfo) => {
if (!confirm(`将 ${g.group_name || g.group_id} 移出管理列表?其数据保留`)) return;
try {
await api(`/groups/${g.group_id}`, { method: "DELETE" });
load();
addToast({ title: "已移出管理列表", color: "success" });
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
}
};
/* ---------- 添加群 ---------- */
const openDiscover = async () => {
onOpen();
setDiscoverLoading(true);
setSelected([]);
try {
setDiscoverList(await api<DiscoverGroup[]>("/groups/discover"));
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
} finally {
setDiscoverLoading(false);
}
};
const confirmAdd = async () => {
const picked = discoverList.filter(
(g) => selected.includes(String(g.group_id)) && !g.managed
);
if (picked.length === 0) return;
setAdding(true);
try {
for (const g of picked) {
await api("/groups", {
method: "POST",
body: JSON.stringify({ group_id: g.group_id, group_name: g.group_name }),
});
}
addToast({ title: `已加入 ${picked.length} 个群`, color: "success" });
onOpenChange(false);
load();
} catch (e: any) {
addToast({ title: e.message, color: "danger" });
} finally {
setAdding(false);
}
};
return (
<div>
<div className="flex items-center justify-between mb-5">
<h1 className="text-xl font-bold">群管理</h1>
<div className="flex items-center gap-2">
<Button size="sm" variant="flat" onPress={load}>
刷新
</Button>
<Button size="sm" color="primary" onPress={openDiscover}>
+ 添加群
</Button>
</div>
</div>
<Card className="bg-content1">
<CardBody>
{loading ? (
<div className="flex justify-center py-16">
<Spinner label="加载中…" />
</div>
) : error ? (
<p className="text-danger text-center py-8">{error}</p>
) : (
<Table aria-label="管理群列表" removeWrapper>
<TableHeader>
<TableColumn>群</TableColumn>
<TableColumn>采集开关</TableColumn>
<TableColumn>参与者</TableColumn>
<TableColumn>语料</TableColumn>
<TableColumn>印象</TableColumn>
<TableColumn>画像</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={groups} emptyContent="还没有管理的群,点右上角「添加群」">
{(g) => (
<TableRow key={g.group_id}>
<TableCell>
<div className="font-medium">{g.group_name || `群 ${g.group_id}`}</div>
<div className="text-xs text-default-500">{g.group_id}</div>
</TableCell>
<TableCell>
<Switch size="sm" isSelected={g.enabled} onValueChange={(v) => toggle(g, v)} />
</TableCell>
<TableCell>{g.participants}</TableCell>
<TableCell>{g.corpus_count}</TableCell>
<TableCell>{g.impression_count}</TableCell>
<TableCell>{g.summary_count}</TableCell>
<TableCell>
<div className="flex gap-2">
<Link to={`/groups/${g.group_id}`}>
<Button size="sm" color="primary" variant="flat">
管理
</Button>
</Link>
<Button size="sm" color="danger" variant="light" onPress={() => removeGroup(g)}>
移除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardBody>
</Card>
{/* 添加群弹窗 */}
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="lg" scrollBehavior="inside">
<ModalContent>
<ModalHeader>添加群到管理列表</ModalHeader>
<ModalBody>
{discoverLoading ? (
<div className="flex justify-center py-10">
<Spinner label="正在从 OneBot 获取群列表…" />
</div>
) : (
<CheckboxGroup value={selected} onValueChange={setSelected}>
{discoverList.map((g) => (
<div key={g.group_id} className="flex items-center justify-between py-1">
<Checkbox value={String(g.group_id)} isDisabled={g.managed}>
{g.group_name || `群 ${g.group_id}`}
<span className="text-xs text-default-500 ml-2">{g.group_id}</span>
</Checkbox>
{g.managed && <span className="text-xs text-success">已管理</span>}
</div>
))}
</CheckboxGroup>
)}
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={() => onOpenChange(false)}>
取消
</Button>
<Button
color="primary"
isDisabled={selected.length === 0 || discoverLoading}
isLoading={adding}
onPress={confirmAdd}
>
加入管理({selected.filter((s) => !discoverList.find((g) => String(g.group_id) === s)?.managed).length})
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
@@ -1,58 +0,0 @@
import { useState } from "react";
import { Button, Card, CardBody, Input } from "@heroui/react";
import { useNavigate } from "react-router-dom";
import { api, setToken } from "../api";
export default function Login() {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const submit = async () => {
setLoading(true);
setError("");
try {
const data = await api<{ token: string }>("/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setToken(data.token);
navigate("/groups", { replace: true });
} catch (e: any) {
setError(e.message || "登录失败");
} finally {
setLoading(false);
}
};
return (
<div className="h-screen flex items-center justify-center bg-content2">
<Card className="w-80 bg-content1" shadow="lg">
<CardBody className="gap-4 p-8">
<h1 className="text-xl font-bold text-center mb-2">群聊人设卡</h1>
<Input
label="用户名"
value={username}
onValueChange={setUsername}
variant="bordered"
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
<Input
label="密码"
type="password"
value={password}
onValueChange={setPassword}
variant="bordered"
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
{error && <p className="text-danger text-sm text-center">{error}</p>}
<Button color="primary" isLoading={loading} onPress={submit}>
登 录
</Button>
</CardBody>
</Card>
</div>
);
}
@@ -1,71 +0,0 @@
import { useEffect, useState } from "react";
import { Card, CardBody, Spinner, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@heroui/react";
import { api, SettingsInfo } from "../api";
export default function Settings() {
const [info, setInfo] = useState<SettingsInfo | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api<SettingsInfo>("/settings")
.then(setInfo)
.catch((e: any) => setError(e.message));
}, []);
return (
<div>
<h1 className="text-xl font-bold mb-5">设置</h1>
<Card className="bg-content1 max-w-2xl">
<CardBody>
{error ? (
<p className="text-danger">{error}</p>
) : !info ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : (
<>
<h2 className="font-semibold mb-3">当前配置(只读)</h2>
<Table aria-label="配置" removeWrapper>
<TableHeader>
<TableColumn>项</TableColumn>
<TableColumn>值</TableColumn>
</TableHeader>
<TableBody>
<TableRow key="user">
<TableCell>Web 登录账号</TableCell>
<TableCell>{info.web_username}</TableCell>
</TableRow>
<TableRow key="enable">
<TableCell>Web 后台启用</TableCell>
<TableCell>{info.web_enable ? "是" : "否"}</TableCell>
</TableRow>
<TableRow key="len">
<TableCell>长文截断上限</TableCell>
<TableCell>{info.thresholds.max_content_len} 字</TableCell>
</TableRow>
<TableRow key="repeat">
<TableCell>复读检测窗口</TableCell>
<TableCell>{info.thresholds.repeat_window} 条</TableCell>
</TableRow>
<TableRow key="throttle">
<TableCell>刷屏采样间隔</TableCell>
<TableCell>{info.thresholds.throttle_seconds} 秒</TableCell>
</TableRow>
<TableRow key="rolling">
<TableCell>单用户语料滚动上限</TableCell>
<TableCell>{info.thresholds.rolling_window} 条</TableCell>
</TableRow>
</TableBody>
</Table>
<p className="text-default-500 text-sm mt-4">
阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME /
WEB_PASSWORD。
</p>
</>
)}
</CardBody>
</Card>
</div>
);
}
@@ -1,12 +0,0 @@
import { heroui } from "@heroui/theme";
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
plugins: [heroui()],
};
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
@@ -1,7 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "/galgame_card/",
plugins: [react()],
});
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
旧版独立后台 /galgame_card(JWT 自鉴权)仍保留可用,前端由 hub 渲染。
统一鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
前端由统一管理台 hexi/web 渲染。
"""
from __future__ import annotations
@@ -12,10 +12,9 @@ from typing import Optional
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from hexi.web_auth import require_admin
from hexi.web_hub.web_auth import require_admin
from . import repository
from .config import config
API = require_admin
@@ -42,11 +41,8 @@ async def _first_bot():
def build_admin_app() -> FastAPI | None:
"""构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。
在 hub 统一登录态下可用;未启用 Web 时返回 None。
鉴权由 hub 统一登录态提供(hexi.web_hub.web_auth)。
"""
if not config.web_enable:
return None
app = FastAPI(title="Galgame Card API")
@app.get("/groups", dependencies=[API])
@@ -213,8 +209,6 @@ def build_admin_app() -> FastAPI | None:
return _ok(
{
"web_username": config.web_username,
"web_enable": config.web_enable,
"thresholds": {
"max_content_len": processor.MAX_CONTENT_LEN,
"repeat_window": processor.REPEAT_WINDOW,
@@ -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
@@ -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", {})
@@ -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
@@ -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输出目录"""
@@ -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/<plugin>/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))
@@ -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")
@@ -8,7 +8,7 @@ from __future__ import annotations
import sys as _sys
from hexi.config_standard import register_config_items
from hexi.web_hub.config_standard import register_config_items
from . import constants
@@ -10,7 +10,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
from nonebot.rule import Rule, is_type
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from hexi.core.custom_utils import check_manage
from ...services.moderation import group_silence, member_silence, silence
from ...utils.message import get_at_id, has_at_all
@@ -6,7 +6,7 @@ from nonebot import on_fullmatch, on_startswith, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from hexi.core.custom_utils import check_manage
from ...services.profile import card_edit, group_name, title_get
from ...utils.message import get_at_id
@@ -2,7 +2,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from hexi.core.custom_utils import check_manage
from ..utils.permissions import is_bot_manage, self_member_info
from ..utils.text import parse_seconds
@@ -2,7 +2,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from hexi.core.custom_utils import check_manage
from ..utils.permissions import is_bot_manage, self_member_info
@@ -3,7 +3,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import (
from hexi.core.custom_utils import (
check_bot_admin,
check_manage,
)
@@ -16,7 +16,7 @@ from .utils.war_renderer import render_war_briefing # noqa: F401
from .utils import gen_ms_img, pic2b64 # noqa: F401
from .services.equipment_store import init_db as _init_equipment_db
from .web import stratagem_admin as _stratagem_admin
from hexi.web_plugin_registry import register_web_plugin
from hexi.web_hub.web_plugin_registry import register_web_plugin
__plugin_meta__ = PluginMetadata(
name="绝地潜兵小助手",
@@ -47,7 +47,7 @@ register_web_plugin(
logger.info("战备管理 Web 插件已注册(/api/helldivers)")
# 统一配置标准:把 API 相关配置暴露给 Web 管理台(/hub 插件配置)
from hexi.config_standard import register_model_config # noqa: E402
from hexi.web_hub.config_standard import register_model_config # noqa: E402
from .config import config as _hd2_config # noqa: E402
register_model_config(
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -153,14 +153,34 @@ def count_stratagems() -> int:
return int(conn.execute("SELECT COUNT(*) AS n FROM stratagems").fetchone()["n"])
def list_stratagems_page(page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
def list_stratagems_page(
page: int = 1,
page_size: int = 20,
keyword: str = "",
type: str = "",
backpack: str = "",
) -> tuple[list[dict[str, Any]], int]:
init_db()
total = count_stratagems()
offset = (page - 1) * page_size
where: list[str] = []
params: list[Any] = []
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
where.append("(name LIKE ? OR name_en LIKE ? OR alias LIKE ?)")
params.extend([like, like, like])
if type:
where.append("type = ?")
params.append(type)
if backpack in ("0", "1"):
where.append("backpack = ?")
params.append(int(backpack))
where_sql = (" WHERE " + " AND ".join(where)) if where else ""
with _connect() as conn:
total = int(conn.execute(f"SELECT COUNT(*) FROM stratagems{where_sql}", params).fetchone()[0])
offset = (page - 1) * page_size
rows = conn.execute(
"SELECT * FROM stratagems ORDER BY id LIMIT ? OFFSET ?",
(page_size, offset),
f"SELECT * FROM stratagems{where_sql} ORDER BY id LIMIT ? OFFSET ?",
(*params, page_size, offset),
).fetchall()
return [_row_to_item(r) for r in rows], total
@@ -34,10 +34,20 @@ def _equip_icon(equip: dict[str, Any]) -> str:
raw = equip.get("path") or ""
if not raw:
return ""
p = basic_path / raw.replace("\\", "/")
if not p.exists():
return ""
return f"<img class='eq-ic' src='{_rel(p)}' alt='' />"
rel = raw.replace("\\", "/")
# 兼容两种路径存法:插件根相对路径 res/img/...(新/播种数据),
# 以及 res/img 目录相对路径 img/...(旧库迁移前遗留),统一找真实文件。
candidates = [rel]
if rel.startswith("res/"):
candidates.append(rel[len("res/"):])
else:
candidates.append("res/" + rel)
candidates.append("res/img/" + rel)
for cand in candidates:
p = basic_path / cand
if p.exists():
return f"<img class='eq-ic' src='{_rel(p)}' alt='' />"
return ""
def _item_html(equip: dict[str, Any]) -> str:
@@ -1,21 +1,30 @@
# -*- coding: utf-8 -*-
"""SVG -> PNG 转换 + 可选 HD2 风格边框,用于战备图标上传。"""
"""SVG -> PNG 转换。
与 docs/HD2/run.py 一致:把上传的单张 SVG 按输出尺寸(默认 550x550)栅格化成
透明背景 PNG,不做批量,也不叠加边框。
优先用 cairosvg(与 run.py 完全一致);若运行环境缺 cairo 原生库,则回退到
Chromium canvas 渲染(透明背景、同尺寸),保证 Web 后台上传始终可用。
"""
from __future__ import annotations
import base64
import io
import logging
import re
from pathlib import Path
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
try:
import cairosvg # type: ignore
_HAS_CAIROSVG = True
except Exception: # noqa: BLE001
cairosvg = None
_HAS_CAIROSVG = False
SCHEMA_SIZE = 550 # 与随包 PNG 图标一致
TYPE_COLORS = {
"blue": "#6bb7ea",
"red": "#fe6d6a",
"green": "#7ecb3f",
}
basic_path = Path(__file__).resolve().parents[1]
IMG_DIR = basic_path / "res" / "img" / "helldivers"
@@ -23,49 +32,13 @@ IMG_DIR = basic_path / "res" / "img" / "helldivers"
def sanitize_filename(name: str) -> str:
"""把战备名转成安全的 Windows 文件名(保留中文/空格,去掉非法字符)。"""
s = re.sub(r'[\\/:*?"<>|]', "_", name).strip()
s = re.sub(r'[\/:*?"<>|]', "_", name).strip()
s = re.sub(r"\s+", " ", s)
return s or "strategem"
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
h = hex_color.lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
def _apply_frame(src_png: bytes, size: int, frame_color: str) -> bytes:
"""把透明的 SVG 画面叠到 HD2 风格画布上:彩色边框 + 深色内底 + 居中图形。"""
rgb = _hex_to_rgb(frame_color)
img = Image.open(io.BytesIO(src_png)).convert("RGBA")
canvas = Image.new("RGBA", (size, size), rgb + (255,))
draw = ImageDraw.Draw(canvas)
border = max(10, size // 20)
# 外圈用类型色,内部深色
draw.rectangle([0, 0, size - 1, size - 1], fill=rgb + (255,))
draw.rectangle([border, border, size - 1 - border, size - 1 - border], fill=(12, 12, 12, 255))
# 去掉透明边距再居中
alpha = img.getchannel("A")
bbox = alpha.getbbox()
glyph = img.crop(bbox) if bbox else img
inner = size - 2 * border
avail = int(inner * 0.82)
glyph.thumbnail((avail, avail), Image.LANCZOS)
ox = (size - glyph.width) // 2
oy = (size - glyph.height) // 2
canvas.alpha_composite(glyph, (ox, oy))
out = io.BytesIO()
canvas.save(out, format="PNG")
return out.getvalue()
async def _render_svg(svg_text: str, size: int) -> bytes:
"""用 Chromium 的 canvas 把 SVG 光栅化为带透明通道的 PNG。
用 toDataURL 导出而非截图,避免 omit_background 在部分环境把透明背景
拍成白色,从而保住透明 alpha。
"""
async def _render_svg_canvas(svg_text: str, size: int) -> bytes:
"""用 Chromium canvas 把 SVG 光栅化为带透明通道的 PNG(兜底方案)。"""
from playwright.async_api import async_playwright
data_uri = "data:image/svg+xml;base64," + base64.b64encode(svg_text.encode("utf-8")).decode()
@@ -92,17 +65,28 @@ async def _render_svg(svg_text: str, size: int) -> bytes:
await page.set_content("<!-- svg to png -->")
data_url = await page.evaluate(script)
await browser.close()
if not data_url:
raise ValueError("SVG 转换失败")
return base64.b64decode(data_url.split(",", 1)[1])
async def svg_to_png(svg_text: str, framed: bool = False, frame_color: str | None = None, size: int = SCHEMA_SIZE) -> bytes:
"""SVG -> PNG。framed=True 时叠加 HD2 风格边框到类型色。"""
async def svg_to_png(svg_text: str, size: int = SCHEMA_SIZE) -> bytes:
"""SVG -> PNG(透明背景,默认 550x550)。
参考 docs/HD2/run.py:cairosvg.svg2png 按 output_width/output_height 栅格化到目标尺寸。
"""
if not svg_text.strip():
raise ValueError("SVG 内容为空")
png = await _render_svg(svg_text, size)
if framed:
png = _apply_frame(png, size, frame_color or TYPE_COLORS["blue"])
return png
if _HAS_CAIROSVG:
try:
return cairosvg.svg2png(
bytestring=svg_text.encode("utf-8"),
output_width=size,
output_height=size,
)
except Exception as e: # noqa: BLE001
logger.warning(f"cairosvg 转换失败,改用 canvas 兜底: {type(e).__name__}: {e}")
return await _render_svg_canvas(svg_text, size)
def save_icon_png(png_bytes: bytes, name: str) -> str:
@@ -2,7 +2,7 @@
"""战备管理 API:作为统一 Web 的一个模块,挂到 /api/helldivers。
- 不再自己出 HTML,页面由统一前端 (hexi/web) 渲染。
- 鉴权统一走 hexi.web_auth(OAuth2 + SQLite)。
- 鉴权统一走 hexi.web_hub.web_auth(OAuth2 + SQLite)。
"""
from __future__ import annotations
@@ -12,7 +12,7 @@ from pathlib import Path
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from hexi.web_auth import require_admin
from hexi.web_hub.web_auth import require_admin
from ..services.equipment_store import (
create_stratagem,
@@ -24,7 +24,7 @@ from ..services.equipment_store import (
list_stratagems_page,
update_stratagem,
)
from ..utils.icon_utils import TYPE_COLORS, save_icon_png, svg_to_png
from ..utils.icon_utils import save_icon_png, svg_to_png
basic_path = Path(__file__).resolve().parents[1]
@@ -39,10 +39,10 @@ def build_admin_app() -> FastAPI | None:
auth = require_admin
@app.get("/stratagems")
async def api_list(page: int = 1, page_size: int = 20, _: dict = auth):
async def api_list(page: int = 1, page_size: int = 20, q: str = "", type: str = "", backpack: str = "", _: dict = auth):
page = max(1, page)
page_size = min(max(1, page_size), 100)
items, total = list_stratagems_page(page, page_size)
items, total = list_stratagems_page(page, page_size, keyword=q, type=type, backpack=backpack)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@app.post("/stratagems")
@@ -86,8 +86,17 @@ def build_admin_app() -> FastAPI | None:
@app.get("/img/{path:path}")
async def icon_file(path: str):
# 兼容多种存量路径:res/img/...(插件根相对,新)、img/...(旧库,res/img 相对)、
# 以及直接/相对 res/img 的路径,统一归一化到 res/img 下的相对路径再解析。
rel = path.replace("\\", "/").lstrip("/")
if rel.startswith("res/img/"):
rel = rel[len("res/img/"):]
elif rel.startswith("res/"):
rel = rel[len("res/"):]
elif rel.startswith("img/"):
rel = rel[len("img/"):]
base = (basic_path / "res" / "img").resolve()
target = (basic_path / "res" / "img" / path).resolve()
target = (basic_path / "res" / "img" / rel).resolve()
if not target.is_relative_to(base) or not target.exists():
raise HTTPException(status_code=404, detail="图片不存在")
return FileResponse(target)
@@ -96,13 +105,11 @@ def build_admin_app() -> FastAPI | None:
async def api_upload_icon(seed: int, request: Request, _: dict = auth):
body = await _get_body(request)
svg = (body.get("svg") or "").strip()
framed = bool(body.get("framed"))
item = get_stratagem(seed)
if item is None:
raise HTTPException(status_code=404, detail="未找到该战备")
frame_color = TYPE_COLORS.get(item.get("type"), "#6bb7ea")
try:
png = await svg_to_png(svg, framed=framed, frame_color=frame_color)
png = await svg_to_png(svg)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -6,9 +6,8 @@ from nonebot.plugin import PluginMetadata
require("nonebot_plugin_orm")
from .config import NICKNAME, config_manager, register_config as _register_learning_config # noqa: E402
from . import web_api # noqa: E402
from . import web_hub # noqa: E402
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/learning_chat
register_web_plugin(
@@ -49,13 +49,6 @@ class ChatGroupConfig(BaseModel):
class ChatConfig(BaseModel):
total_enable: bool = Field(default=True, alias="群聊学习总开关")
enable_web: bool = Field(default=True, alias="启用后台管理")
web_username: str = Field(default="chat", alias="后台管理用户名")
web_password: str = Field(default="admin", alias="后台管理密码")
web_secret_key: str = Field(
default="49c294d32f69b732ef6447c18379451ce1738922a75cd1d4812ef150318a2ed0",
alias="后台管理token密钥",
)
ban_words: List[str] = Field(default_factory=list, alias="全局屏蔽词")
ban_users: List[int] = Field(default_factory=list, alias="全局屏蔽用户")
KEYWORDS_SIZE: int = Field(default=3, alias="单句关键词分词数量")
@@ -118,9 +111,9 @@ def register_config() -> None:
"""把顶层 ChatConfig 顶层字段接入统一 Web 配置标准(/hub 可读可改)。
getter/setter 直接读写 config_manager.config,并保存到 learning_chat.yml。
权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json(避免与自有后台互相覆盖)。
权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json。
"""
from hexi.config_standard import register_config_items
from hexi.web_hub.config_standard import register_config_items
def _get(key):
return getattr(config_manager.config, key)
@@ -131,10 +124,6 @@ def register_config() -> None:
fields = [
{"key": "total_enable", "label": "群聊学习总开关", "type": "bool"},
{"key": "enable_web", "label": "启用后台管理", "type": "bool"},
{"key": "web_username", "label": "后台管理用户名", "type": "string"},
{"key": "web_password", "label": "后台管理密码", "type": "password", "secret": True},
{"key": "web_secret_key", "label": "后台签名密钥", "type": "password", "secret": True},
{"key": "ban_words", "label": "全局屏蔽词", "type": "text", "item_type": "str"},
{"key": "ban_users", "label": "全局屏蔽用户", "type": "text", "item_type": "int"},
{"key": "KEYWORDS_SIZE", "label": "单句关键词分词数量", "type": "int"},
@@ -6,7 +6,7 @@ import time
from dataclasses import dataclass
from functools import cached_property, cmp_to_key
# from ..nonebot_plugin_hexi_core.MessageUtils import send_poke
# from ..core.MessageUtils import send_poke
try:
import jieba_fast.analyse as jieba_analyse
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -1,5 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Nonebot-Plugin-Learning-Chat 控制台" />
<title>Learning-Chat 控制台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,27 +0,0 @@
{
"name": "learning-chat-admin",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.5.0",
"element-plus": "^2.14.3",
"pinia": "^2.1.6",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^4.5.0",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vue-tsc": "^1.8.19"
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

@@ -1,146 +0,0 @@
<template>
<el-config-provider>
<!-- 登录页: 无框架布局 -->
<RouterView v-if="!requiresAuth" />
<!-- 主界面: 侧边栏 + 顶栏 + 内容区 -->
<el-container v-else class="app-container">
<el-aside width="220px" class="app-aside">
<div class="aside-title">Learning-Chat 控制台</div>
<el-menu :default-active="route.path" router class="aside-menu">
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<span>配置</span>
</el-menu-item>
<el-menu-item index="/messages">
<el-icon><ChatDotRound /></el-icon>
<span>群聊消息</span>
</el-menu-item>
<el-menu-item index="/contexts">
<el-icon><Collection /></el-icon>
<span>学习内容</span>
</el-menu-item>
<el-menu-item index="/answers">
<el-icon><ChatLineRound /></el-icon>
<span>回复内容</span>
</el-menu-item>
<el-menu-item index="/blacklist">
<el-icon><CircleClose /></el-icon>
<span>禁用列表</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container class="app-body">
<el-header class="app-header" height="60px">
<span class="header-title">Nonebot-Plugin-Learning-Chat</span>
<div class="header-right">
<el-tag size="small" type="primary" effect="plain">{{ authStore.username }}</el-tag>
<el-button size="small" type="danger" plain @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-main class="app-main">
<RouterView />
</el-main>
</el-container>
</el-container>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
// 依据路由而非登录态切换布局, 避免登录瞬间闪现后台框架
const requiresAuth = computed(() => route.meta.requiresAuth !== false);
// token 到期自动登出(守卫已拦截"已过期"情况, 这里覆盖"使用中到期")
onMounted(() => {
authStore.scheduleAutoLogout(() => {
ElMessage.warning('登录已过期,请重新登录');
if (router.currentRoute.value.path !== '/login') {
router.push('/login');
}
});
});
const handleLogout = async () => {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
type: 'warning',
confirmButtonText: '退出',
cancelButtonText: '取消',
});
} catch {
return; // 用户取消
}
authStore.logout();
router.push('/login');
};
</script>
<style scoped>
.app-container {
height: 100vh;
}
.app-aside {
background: #fff;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
}
.aside-title {
height: 60px;
line-height: 60px;
padding: 0 20px;
font-size: 15px;
font-weight: 600;
color: #303133;
border-bottom: 1px solid #f0f2f5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aside-menu {
flex: 1;
border-right: none;
}
.app-body {
min-width: 0;
}
.app-header {
background: #fff;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.app-main {
background: #f5f7fa;
overflow-y: auto;
}
</style>
@@ -1,204 +0,0 @@
import axios, { AxiosError } from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';
import { useAuthStore } from '@/stores/auth';
import type {
ApiResponse,
BlacklistItem,
ChatAnswer,
ChatContext,
ChatMessage,
GlobalConfig,
GroupConfig,
GroupInfo,
LoginResult,
PaginatedData,
} from '@/types';
/** 带状态码的请求错误 */
export class ApiError extends Error {
status: number;
/** 认证失效类错误: 已由全局拦截器提示并跳转登录, 视图层不再重复弹窗 */
authHandled = false;
constructor(message: string, status = 0) {
super(message);
this.status = status;
}
}
let redirectingToLogin = false;
/** 认证失效: 清除登录态并跳转登录页(避免并发失败时重复跳转) */
function handleUnauthorized() {
const authStore = useAuthStore();
authStore.logout();
if (router.currentRoute.value.path !== '/login' && !redirectingToLogin) {
redirectingToLogin = true;
ElMessage.warning('登录已失效,请重新登录');
router.push('/login').finally(() => {
redirectingToLogin = false;
});
}
}
class ApiClient {
private axiosInstance;
constructor() {
this.axiosInstance = axios.create({
baseURL: '/learning_chat/api',
timeout: 30000,
});
// 请求: 附加 token 头(后端契约: authentication() 读取 token header)
this.axiosInstance.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.token = authStore.token;
}
return config;
});
// 响应: 三态归一化
// 1. {"status": 0, "msg", "data"} → 成功, 返回整个信封(data 由调用方取)
// 2. {"status": !=0, "msg"} → 业务失败(HTTP 200 内)
// 3. {"detail": ...}(HTTP 400/422)→ 认证失效/参数错误
// 4. 裸 dict(全局/分群配置 GET)→ 直接返回
this.axiosInstance.interceptors.response.use(
(response) => {
const data = response.data;
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (typeof data.detail !== 'undefined') {
handleUnauthorized();
const err = new ApiError(String(data.detail), response.status);
err.authHandled = true;
throw err;
}
if (typeof data.status === 'number') {
if (data.status === 0) return data;
throw new ApiError(String(data.msg || '操作失败'), data.status);
}
}
return data;
},
(error: AxiosError) => {
const status = error.response?.status;
const data = error.response?.data as { detail?: unknown } | undefined;
const isAuthError = status === 400 || status === 422;
// 后端认证失效契约为 HTTP 400/422 + {"detail"}
if (isAuthError) {
handleUnauthorized();
}
const detail = data?.detail;
const message =
typeof detail === 'string'
? detail
: status === 0
? '无法连接到服务器'
: error.message || '网络请求失败';
const apiError = new ApiError(message, status ?? 0);
apiError.authHandled = isAuthError;
return Promise.reject(apiError);
}
);
}
/** 登录 */
async login(username: string, password: string): Promise<ApiResponse<LoginResult>> {
return this.axiosInstance.post('/login', { username, password });
}
/** 获取群列表 */
async getGroupList(): Promise<ApiResponse<{ group_list: GroupInfo[] }>> {
return this.axiosInstance.get('/get_group_list');
}
/** 获取全局配置(裸 dict) */
async getGlobalConfig(): Promise<GlobalConfig> {
return this.axiosInstance.get('/chat_global_config');
}
/** 保存全局配置 */
async saveGlobalConfig(data: GlobalConfig): Promise<ApiResponse> {
return this.axiosInstance.post('/chat_global_config', data);
}
/** 获取分群配置(裸 dict, 概率为百分比) */
async getGroupConfig(groupId: number | string): Promise<GroupConfig> {
return this.axiosInstance.get(`/chat_group_config?group_id=${groupId}`);
}
/** 保存分群配置(groupId 传 'all' 表示保存至所有群) */
async saveGroupConfig(groupId: number | string, data: GroupConfig): Promise<ApiResponse> {
return this.axiosInstance.post(`/chat_group_config?group_id=${groupId}`, data);
}
/** 获取聊天消息 */
async getMessages(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
group_id?: string;
user_id?: string;
message?: string;
}): Promise<ApiResponse<PaginatedData<ChatMessage>>> {
return this.axiosInstance.get('/get_chat_messages', { params });
}
/** 获取学习内容 */
async getContexts(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
keywords?: string;
}): Promise<ApiResponse<PaginatedData<ChatContext>>> {
return this.axiosInstance.get('/get_chat_contexts', { params });
}
/** 获取回复 */
async getAnswers(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
context_id?: number;
keywords?: string;
}): Promise<ApiResponse<PaginatedData<ChatAnswer>>> {
return this.axiosInstance.get('/get_chat_answers', { params });
}
/** 获取禁用列表 */
async getBlacklist(params: {
page?: number;
perPage?: number;
keywords?: string;
bans?: string;
}): Promise<ApiResponse<PaginatedData<BlacklistItem>>> {
return this.axiosInstance.get('/get_chat_blacklist', { params });
}
/** 删除(type: message | context | answer | blacklist) */
async deleteChat(type: string, id: number): Promise<ApiResponse> {
return this.axiosInstance.delete(`/delete_chat?type=${type}&id=${id}`);
}
/** 禁用(type: message | context | answer) */
async banChat(type: string, id: number): Promise<ApiResponse> {
return this.axiosInstance.put(`/ban_chat?type=${type}&id=${id}`);
}
/** 批量删除/取消禁用(type: answer | blacklist | context | message, id 可选) */
async deleteAll(type: string, id?: number): Promise<ApiResponse> {
let url = `/delete_all?type=${type}`;
if (id !== undefined) {
url += `&id=${id}`;
}
return this.axiosInstance.put(url);
}
}
export default new ApiClient();
@@ -1,54 +0,0 @@
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="640px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div class="full-content">{{ content }}</div>
<template #footer>
<el-button type="primary" plain @click="copyContent">复制内容</el-button>
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
const props = defineProps<{
modelValue: boolean;
title?: string;
content?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
const copyContent = async () => {
try {
await navigator.clipboard.writeText(props.content || '');
ElMessage.success('已复制到剪贴板');
} catch {
ElMessage.error('复制失败,请手动选择复制');
}
};
</script>
<style scoped>
.full-content {
max-height: 60vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.7;
color: #303133;
background: #f5f7fa;
border-radius: 6px;
padding: 12px;
user-select: text;
}
</style>
@@ -1,22 +0,0 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import router from '@/router';
import App from '@/App.vue';
import '@/style.css';
// 移除旧版「API 地址设置」功能残留(同源架构下该功能已废弃, 写死的地址会导致跨域失败)
localStorage.removeItem('API_BASE_URL');
const app = createApp(App);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
app.mount('#app');
@@ -1,105 +0,0 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
// 懒加载视图
const Login = () => import('@/views/Login.vue');
const Config = () => import('@/views/Config.vue');
const Messages = () => import('@/views/Messages.vue');
const Contexts = () => import('@/views/Contexts.vue');
const Answers = () => import('@/views/Answers.vue');
const Blacklist = () => import('@/views/Blacklist.vue');
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: () => {
const authStore = useAuthStore();
return authStore.isAuthenticated ? '/config' : '/login';
},
},
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresAuth: false },
},
{
path: '/config',
name: 'Config',
component: Config,
meta: { requiresAuth: true },
},
{
path: '/messages',
name: 'Messages',
component: Messages,
meta: { requiresAuth: true },
},
{
path: '/contexts',
name: 'Contexts',
component: Contexts,
meta: { requiresAuth: true },
},
{
path: '/answers',
name: 'Answers',
component: Answers,
meta: { requiresAuth: true },
},
{
path: '/blacklist',
name: 'Blacklist',
component: Blacklist,
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
redirect: '/login',
meta: { requiresAuth: false },
},
];
const router = createRouter({
// base 与 vite.config.ts 的 base 保持一致, 否则 SPA 导航会产生不带前缀的根路径 URL, 刷新即 404
history: createWebHistory('/learning_chat/'),
routes,
});
// 全局路由守卫
router.beforeEach((to, _from) => {
const authStore = useAuthStore();
// token 已过期(按 JWT exp 主动检测) -> 立即登出跳登录页
if (authStore.token) {
const exp = authStore.getTokenExpiry(authStore.token);
if (exp !== null && exp * 1000 <= Date.now()) {
authStore.logout();
if (to.path !== '/login') {
ElMessage.warning('登录已过期,请重新登录');
return { path: '/login', query: { redirect: to.fullPath } };
}
}
}
const requiresAuth = to.meta.requiresAuth;
// 需要登录但未认证 -> 跳登录页
if (requiresAuth && !authStore.isAuthenticated) {
return {
path: '/login',
query: { redirect: to.fullPath },
};
}
// 已登录但访问登录页 -> 跳配置页
if (to.path === '/login' && authStore.isAuthenticated) {
return '/config';
}
// 其他情况放行
return true;
});
export default router;
@@ -1,81 +0,0 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useAuthStore = defineStore('auth', () => {
// 初始化时从 localStorage 读取
const token = ref<string>(localStorage.getItem('token') || '');
const username = ref<string>(localStorage.getItem('username') || '');
const isAuthenticated = computed(() => !!token.value);
let expireTimer: ReturnType<typeof setTimeout> | null = null;
/** 解析 JWT payload 中的 exp(unix 秒); 非 JWT 或解析失败返回 null */
const getTokenExpiry = (t: string): number | null => {
try {
const payload = t.split('.')[1];
if (!payload) return null;
const json = JSON.parse(
atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
);
return typeof json.exp === 'number' ? json.exp : null;
} catch {
return null;
}
};
/**
* 注册到期自动登出:
* - token 已过期 → 立即 logout 并返回 true
* - 未过期 → 在 exp 时刻触发 onExpired 回调
*/
const scheduleAutoLogout = (onExpired?: () => void): boolean => {
if (expireTimer) {
clearTimeout(expireTimer);
expireTimer = null;
}
if (!token.value) return false;
const exp = getTokenExpiry(token.value);
if (exp === null) return false;
const remainingMs = exp * 1000 - Date.now();
if (remainingMs <= 0) {
logout();
return true;
}
expireTimer = setTimeout(() => {
logout();
onExpired?.();
}, remainingMs);
return false;
};
const setAuth = (newToken: string, newUsername: string) => {
token.value = newToken;
username.value = newUsername;
localStorage.setItem('token', newToken);
localStorage.setItem('username', newUsername);
// 登录即武装到期定时器
scheduleAutoLogout();
};
const logout = () => {
if (expireTimer) {
clearTimeout(expireTimer);
expireTimer = null;
}
token.value = '';
username.value = '';
localStorage.removeItem('token');
localStorage.removeItem('username');
};
return {
token,
username,
isAuthenticated,
getTokenExpiry,
scheduleAutoLogout,
setAuth,
logout,
};
});
@@ -1,79 +0,0 @@
/* 全局样式 — Element Plus 自带 reset, 这里只保留主题与少量通用类 */
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: #f5f7fa;
color: #303133;
}
/* 页面级白色卡片容器(列表页) */
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
/* 页面顶部说明条 */
.page-tip {
margin-bottom: 16px;
}
/* 搜索栏与操作行 */
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.toolbar .el-input {
width: 160px;
}
.toolbar .toolbar-wide {
width: 240px;
}
/* 分页容器 */
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 16px;
}
/* 表格内可点击查看完整内容的长文本单元格 */
.clickable-cell {
cursor: pointer;
color: #606266;
transition: color 0.2s;
}
.clickable-cell:hover {
color: var(--el-color-primary);
text-decoration: underline;
}
/* 单行省略(配合可点击单元格使用) */
.cell-ellipsis {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 表单辅助说明文字 */
.help-text {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin-top: 4px;
}
@@ -1,97 +0,0 @@
/**
* 与后端 web_api.py 契约一致的类型定义
*/
/** 后端统一信封(列表/操作类接口); 配置 GET 返回裸 dict, 认证错误返回 {"detail"} */
export interface ApiResponse<T = unknown> {
status: number;
msg: string;
data?: T;
}
export interface PaginatedData<T> {
items: T[];
total: number;
}
export interface LoginResult {
token: string;
}
export interface GroupInfo {
label: string;
value: number;
}
export interface ChatMessage {
id: number;
message_id: string | number;
group_id: number;
user_id: number;
raw_message: string;
message?: string;
/** unix 秒整数 */
time: number;
}
export interface ChatContext {
id: number;
keywords: string;
/** unix 秒整数 */
time: number;
count: number;
}
export interface ChatAnswer {
id: number;
group_id: number;
keywords: string;
/** unix 秒整数 */
time: number;
count: number;
context_id: number;
/** 旧数据可能为 null */
messages: Array<{ msg: string }> | null;
}
export interface BlacklistItem {
id: number;
keywords: string;
bans: string;
global_ban?: boolean;
ban_group_id?: number[];
}
export interface GlobalConfig {
total_enable: boolean;
enable_web: boolean;
web_username: string;
web_password: string;
web_secret_key: string;
KEYWORDS_SIZE: number;
cross_group_threshold: number;
learn_max_count: number;
ban_words: string[];
ban_users: string[];
dictionary: string[];
}
export interface GroupConfig {
enable: boolean;
answer_threshold: number;
answer_threshold_weights: number[];
repeat_threshold: number;
/** 0-100 百分比(后端 GET ×100 / POST ÷100 契约) */
break_probability: number;
speak_enable: boolean;
speak_threshold: number;
speak_min_interval: number;
/** 0-100 百分比 */
speak_continuously_probability: number;
speak_continuously_max_len: number;
/** 0-100 百分比 */
speak_poke_probability: number;
ban_words: string[];
ban_users: string[];
member_list: GroupInfo[];
}
@@ -1,14 +0,0 @@
/** 后端 time 为 unix 秒整数, 格式化为本地时间(兼容毫秒/字符串) */
export function formatTime(time: number | string | null | undefined): string {
if (time === null || time === undefined || time === '') return '';
let t = Number(time);
if (Number.isNaN(t)) return String(time);
if (t < 1e12) t *= 1000; // 秒 → 毫秒
return new Date(t).toLocaleString('zh-CN');
}
/** 从错误对象提取可读消息(ApiError/普通 Error/未知值) */
export function errorMessage(err: unknown, fallback: string): string {
if (err instanceof Error && err.message) return err.message;
return fallback;
}
@@ -1,12 +0,0 @@
import { ElMessage } from 'element-plus';
import { ApiError } from '@/api/client';
import { errorMessage } from './format';
/**
* 展示错误提示。
* 认证失效类错误已由全局拦截器统一提示并跳转登录, 这里跳过, 避免重复弹窗。
*/
export function showError(err: unknown, fallback: string): void {
if (err instanceof ApiError && err.authHandled) return;
ElMessage.error(errorMessage(err, fallback));
}
@@ -1,264 +0,0 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人已学习到的所有回复。</p>
<p>点击「禁用」可以将该回复进行禁用;点击「删除」可以删除该回复。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="answers" stripe border @sort-change="handleSortChange">
<el-table-column type="expand">
<template #default="{ row }">
<div class="answers-detail">
<div v-for="(m, i) in (row.messages || [])" :key="i" class="answer-msg">
{{ m.msg }}
</div>
<el-empty
v-if="!row.messages || row.messages.length === 0"
description="无消息内容"
:image-size="50"
/>
</div>
</template>
</el-table-column>
<el-table-column label="内容/关键词" min-width="180" sortable="custom">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`回复 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column prop="group_id" label="群ID" width="110" />
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column prop="count" label="次数" width="90" sortable="custom" />
<el-table-column label="消息预览" min-width="240">
<template #default="{ row }">
<div class="msg-preview clickable-cell" @click="showDetail(`完整消息 - ${row.keywords}`, (row.messages || []).map((m) => m.msg).join('\n'))">
<div v-for="(m, i) in (row.messages || []).slice(0, 2)" :key="i" class="msg-preview-line">
{{ m.msg }}
</div>
<div v-if="(row.messages || []).length > 2" class="msg-preview-more">
+{{ row.messages!.length - 2 }} 更多(点击查看全部)
</div>
<span v-if="!row.messages || row.messages.length === 0" class="msg-preview-empty">无消息内容</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无回复数据" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchAnswers"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatAnswer } from '@/types';
const answers = ref<ChatAnswer[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const sortColumn = ref<'keywords' | 'time' | 'count'>('count');
const sortOrder = ref<'asc' | 'desc'>('desc');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchAnswers = async () => {
loading.value = true;
try {
const res = await apiClient.getAnswers({
page: page.value,
perPage,
orderBy: sortColumn.value,
orderDir: sortOrder.value,
keywords: searchKeywords.value,
});
answers.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取回复列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchAnswers();
};
const handleSortChange = ({
prop,
order,
}: {
prop: string;
order: 'ascending' | 'descending' | null;
}) => {
if (!prop) return;
sortColumn.value = prop as 'keywords' | 'time' | 'count';
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
page.value = 1;
fetchAnswers();
};
const handleDelete = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('answer', row.id);
ElMessage.success('已删除');
fetchAnswers();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('answer', row.id);
ElMessage.success('已禁用');
fetchAnswers();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有回复吗?这是不可逆的操作!',
'删除所有回复',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('answer');
ElMessage.success('已删除所有回复');
fetchAnswers();
} catch (err) {
showError(err, '删除失败');
}
};
// 初始加载
fetchAnswers();
</script>
<style scoped>
.answers-detail {
padding: 8px 24px;
}
.answer-msg {
padding: 6px 10px;
background: #f5f7fa;
border-radius: 4px;
border-left: 3px solid #67c23a;
margin-bottom: 6px;
font-size: 13px;
color: #606266;
word-break: break-all;
}
.answer-msg:last-child {
margin-bottom: 0;
}
.msg-preview-line {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.msg-preview-more {
font-size: 12px;
color: #409eff;
}
.msg-preview-empty {
font-size: 12px;
color: #c0c4cc;
}
</style>
@@ -1,170 +0,0 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人被禁用的内容/关键词。</p>
<p>可以取消禁用,使其能够重新继续学习;不能在此添加禁用,只能在配置中添加屏蔽词来达到禁用效果。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
@keyup.enter="handleSearch"
/>
<el-input
v-model="searchBans"
placeholder="搜索禁用群"
clearable
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">取消所有禁用</el-button>
</div>
<el-table v-loading="loading" :data="blacklist" stripe border>
<el-table-column label="内容/关键词" min-width="240">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`禁用项 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column label="已禁用的群" min-width="160">
<template #default="{ row }">
<el-tag v-if="row.global_ban" type="danger" effect="plain">全局禁用</el-tag>
<el-tag v-else-if="row.bans" type="info" effect="plain">{{ row.bans }}</el-tag>
<span v-else class="bans-empty">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button size="small" type="success" plain @click="handleUnban(row)">取消禁用</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无禁用项目" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchBlacklist"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { showError } from '@/utils/notify';
import type { BlacklistItem } from '@/types';
const blacklist = ref<BlacklistItem[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const searchBans = ref('');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchBlacklist = async () => {
loading.value = true;
try {
const res = await apiClient.getBlacklist({
page: page.value,
perPage,
keywords: searchKeywords.value,
bans: searchBans.value,
});
blacklist.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取禁用列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchBlacklist();
};
const handleUnban = async (row: BlacklistItem) => {
try {
await ElMessageBox.confirm(`确定要取消禁用 "${row.keywords}" 吗?`, '取消禁用', {
type: 'warning',
confirmButtonText: '取消禁用',
cancelButtonText: '返回',
});
} catch {
return;
}
try {
await apiClient.deleteChat('blacklist', row.id);
ElMessage.success('已取消禁用');
fetchBlacklist();
} catch (err) {
showError(err, '取消禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm('确定要取消所有禁用吗?', '取消所有禁用', {
type: 'warning',
confirmButtonText: '全部取消',
cancelButtonText: '返回',
});
} catch {
return;
}
try {
await apiClient.deleteAll('blacklist');
ElMessage.success('已取消所有禁用');
fetchBlacklist();
} catch (err) {
showError(err, '取消禁用失败');
}
};
// 初始加载
fetchBlacklist();
</script>
<style scoped>
.bans-empty {
color: #c0c4cc;
}
</style>
@@ -1,586 +0,0 @@
<template>
<div class="config-container">
<el-tabs v-model="activeTab">
<!-- ==================== 全局配置 ==================== -->
<el-tab-pane label="全局配置" name="global">
<div v-loading="loading" class="config-body">
<template v-if="globalConfig">
<el-card shadow="never" class="config-card">
<template #header>基本设置</template>
<el-form label-position="top">
<el-form-item label="群聊学习总开关">
<el-switch
v-model="globalConfig.total_enable"
active-text="开启"
inactive-text="关闭"
/>
<div class="help-text">关闭后,全局都将不会再学习和回复</div>
</el-form-item>
<el-form-item label="后台管理总开关">
<el-switch
v-model="globalConfig.enable_web"
active-text="开启"
inactive-text="关闭"
/>
<div class="help-text">是否开启本后台管理</div>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="config-card">
<template #header>认证设置</template>
<el-form label-position="top">
<el-form-item label="后台管理用户名">
<el-input v-model="globalConfig.web_username" placeholder="e.g. chat" />
<div class="help-text">登录本后台管理所需要的用户名</div>
</el-form-item>
<el-form-item label="后台管理密码">
<el-input
v-model="globalConfig.web_password"
type="password"
show-password
placeholder="输入密码"
/>
<div class="help-text">登录本后台管理所需要的密码</div>
</el-form-item>
<el-form-item label="后台管理Token密钥">
<el-input v-model="globalConfig.web_secret_key" placeholder="输入密钥" />
<div class="help-text">用于加密验证Token的密钥</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
<template #header>学习参数</template>
<el-form label-position="top">
<el-form-item label="单句关键词数量">
<el-input-number v-model="globalConfig.KEYWORDS_SIZE" :min="2" controls-position="right" />
<div class="help-text">单句语句标签数量,建议保持默认为3</div>
</el-form-item>
<el-form-item label="跨群回复阈值">
<el-input-number v-model="globalConfig.cross_group_threshold" :min="1" controls-position="right" />
<div class="help-text">当学习到的一种回复在N个群都有,就变为全局回复</div>
</el-form-item>
<el-form-item label="最高学习次数">
<el-input-number v-model="globalConfig.learn_max_count" :min="2" controls-position="right" />
<div class="help-text">值越高,这个回复就会越容易进行回复</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
<template #header>屏蔽设置</template>
<el-form label-position="top">
<el-form-item label="全局屏蔽词">
<div class="tag-list">
<el-tag
v-for="(word, i) in globalConfig.ban_words"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.ban_words.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="banWordInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.ban_words, banWordInput)"
/>
</div>
<div class="help-text">含有这些词的消息不会学习和回复</div>
</el-form-item>
<el-form-item label="全局屏蔽用户">
<div class="tag-list">
<el-tag
v-for="(user, i) in globalConfig.ban_users"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.ban_users.splice(i, 1)"
>
{{ user }}
</el-tag>
<el-input
v-model="banUserInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.ban_users, banUserInput)"
/>
</div>
<div class="help-text">和这些用户有关的消息不会学习和回复</div>
</el-form-item>
<el-form-item label="自定义词典">
<div class="tag-list">
<el-tag
v-for="(word, i) in globalConfig.dictionary"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.dictionary.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="dictionaryInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.dictionary, dictionaryInput)"
/>
</div>
<div class="help-text">添加自定义词语,提高学习的准确性</div>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="saveGlobal">保存</el-button>
<el-button @click="resetGlobal">重置</el-button>
</div>
</template>
</div>
</el-tab-pane>
<!-- ==================== 分群配置 ==================== -->
<el-tab-pane label="分群配置" name="group">
<div class="page-card group-select-row">
<span class="group-select-label">选择群:</span>
<el-select
v-model="selectedGroupId"
placeholder="-- 请选择群 --"
clearable
style="flex: 1"
@change="handleGroupChange"
>
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
</el-select>
</div>
<div v-if="selectedGroupId" v-loading="groupLoading" class="config-body">
<template v-if="groupConfig">
<el-card shadow="never" class="config-card">
<template #header>基本设置</template>
<el-form label-position="top">
<el-form-item label="群聊学习开关">
<el-switch v-model="groupConfig.enable" active-text="开启" inactive-text="关闭" />
<div class="help-text">针对该群的学习开关</div>
</el-form-item>
<el-form-item v-if="groupConfig.enable" label="回复阈值">
<el-input-number v-model="groupConfig.answer_threshold" :min="2" controls-position="right" />
<div class="help-text">学习成功所需要的次数,值越低学得越快</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>回复阈值权重</template>
<el-form label-position="top">
<el-form-item label="设置权重(%)">
<div class="weight-list">
<div v-for="(weight, i) in groupConfig.answer_threshold_weights" :key="i" class="weight-row">
<el-input-number v-model="groupConfig.answer_threshold_weights[i]" :min="1" :max="100" controls-position="right" />
<el-button size="small" type="danger" plain @click="removeWeight(i)">删除</el-button>
</div>
<el-button size="small" type="success" plain @click="addWeight">+ 添加权重</el-button>
</div>
<div class="help-text">影响回复阈值的计算方式</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>复读设置</template>
<el-form label-position="top">
<el-form-item label="复读阈值">
<el-input-number v-model="groupConfig.repeat_threshold" :min="2" controls-position="right" />
<div class="help-text">N个人复读后机器人就会跟着复读</div>
</el-form-item>
<el-form-item label="打断复读概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.break_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">达到复读阈值时,打断复读的概率</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>主动发言设置</template>
<el-form label-position="top">
<el-form-item label="主动发言开关">
<el-switch v-model="groupConfig.speak_enable" active-text="开启" inactive-text="关闭" />
<div class="help-text">是否允许机器人在该群主动发言</div>
</el-form-item>
<template v-if="groupConfig.speak_enable">
<el-form-item label="主动发言阈值">
<el-input-number v-model="groupConfig.speak_threshold" :min="0" controls-position="right" />
<div class="help-text">值越低,主动发言的可能性越高</div>
</el-form-item>
<el-form-item label="主动发言最小间隔">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_min_interval" :min="0" controls-position="right" />
<span class="suffix-text">秒</span>
</div>
<div class="help-text">进行主动发言的最小时间间隔</div>
</el-form-item>
<el-form-item label="连续主动发言概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_continuously_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">触发主动发言时,连续发言的概率</div>
</el-form-item>
<el-form-item label="最大连续发言句数">
<el-input-number v-model="groupConfig.speak_continuously_max_len" :min="1" controls-position="right" />
<div class="help-text">连续主动发言的最大句数</div>
</el-form-item>
<el-form-item label="戳一戳概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_poke_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">主动发言时附带戳一戳的概率</div>
</el-form-item>
</template>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>屏蔽设置</template>
<el-form label-position="top">
<el-form-item label="屏蔽词">
<div class="tag-list">
<el-tag
v-for="(word, i) in groupConfig.ban_words"
:key="i"
type="primary"
effect="plain"
closable
@close="groupConfig.ban_words.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="groupBanWordInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(groupConfig.ban_words, groupBanWordInput)"
/>
</div>
<div class="help-text">含有这些词的消息不会学习</div>
</el-form-item>
<el-form-item label="屏蔽用户">
<div class="tag-list">
<el-tag
v-for="(userId, i) in groupConfig.ban_users"
:key="i"
type="primary"
effect="plain"
closable
@close="groupConfig.ban_users.splice(i, 1)"
>
{{ formatUserId(userId) }}
</el-tag>
<el-select
v-model="pendingMember"
size="small"
class="member-select"
placeholder="-- 选择用户添加 --"
clearable
@change="addMember"
>
<el-option v-for="m in groupConfig.member_list" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</div>
<div class="help-text">和这些用户有关的消息不会学习</div>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="saveGroup">保存</el-button>
<el-button type="success" plain :loading="saving" @click="saveGroupAll">保存至所有群</el-button>
<el-button @click="resetGroup">重置</el-button>
</div>
</template>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, type Ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import { showError } from '@/utils/notify';
import type { GlobalConfig, GroupConfig, GroupInfo } from '@/types';
const activeTab = ref<'global' | 'group'>('global');
// ==================== 全局配置 ====================
const loading = ref(false);
const saving = ref(false);
const globalConfig = ref<GlobalConfig | null>(null);
let globalBackup: GlobalConfig | null = null;
const banWordInput = ref('');
const banUserInput = ref('');
const dictionaryInput = ref('');
const fetchGlobalConfig = async () => {
loading.value = true;
try {
globalConfig.value = await apiClient.getGlobalConfig();
// 深拷贝快照, 重置时恢复(修复原浅拷贝 bug)
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
} catch (err) {
showError(err, '获取全局配置失败');
} finally {
loading.value = false;
}
};
const saveGlobal = async () => {
if (!globalConfig.value) return;
saving.value = true;
try {
await apiClient.saveGlobalConfig(globalConfig.value);
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
ElMessage.success('保存成功');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const resetGlobal = () => {
if (globalBackup) {
globalConfig.value = JSON.parse(JSON.stringify(globalBackup));
}
};
// ==================== 分群配置 ====================
const groups = ref<GroupInfo[]>([]);
const selectedGroupId = ref<number | null>(null);
const groupLoading = ref(false);
const groupConfig = ref<GroupConfig | null>(null);
let groupBackup: GroupConfig | null = null;
const groupBanWordInput = ref('');
const pendingMember = ref<number | null>(null);
const fetchGroupList = async () => {
try {
const res = await apiClient.getGroupList();
groups.value = res.data!.group_list;
} catch (err) {
showError(err, '获取群列表失败');
}
};
const handleGroupChange = (value: number | null) => {
if (value === null) {
groupConfig.value = null;
return;
}
fetchGroupConfig(value);
};
const fetchGroupConfig = async (groupId: number) => {
groupLoading.value = true;
try {
groupConfig.value = await apiClient.getGroupConfig(groupId);
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
} catch (err) {
groupConfig.value = null;
showError(err, '获取分群配置失败');
} finally {
groupLoading.value = false;
}
};
const saveGroup = async () => {
if (!groupConfig.value || selectedGroupId.value === null) return;
saving.value = true;
try {
await apiClient.saveGroupConfig(selectedGroupId.value, groupConfig.value);
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
ElMessage.success('保存成功');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const saveGroupAll = async () => {
if (!groupConfig.value) return;
try {
await ElMessageBox.confirm('确定要将当前配置保存至所有群吗?', '保存至所有群', {
type: 'warning',
confirmButtonText: '保存',
cancelButtonText: '取消',
});
} catch {
return;
}
saving.value = true;
try {
await apiClient.saveGroupConfig('all', groupConfig.value);
ElMessage.success('已保存至所有群');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const resetGroup = () => {
if (groupBackup) {
groupConfig.value = JSON.parse(JSON.stringify(groupBackup));
}
};
// ==================== 通用 ====================
/** 动态标签输入: 回车添加, 重复值不添加 */
const addTag = (list: string[], input: Ref<string>) => {
const value = input.value.trim();
if (value && !list.includes(value)) {
list.push(value);
}
input.value = '';
};
const addWeight = () => {
if (!groupConfig.value) return;
groupConfig.value.answer_threshold_weights.push(25);
};
const removeWeight = (index: number) => {
if (!groupConfig.value) return;
if (groupConfig.value.answer_threshold_weights.length > 1) {
groupConfig.value.answer_threshold_weights.splice(index, 1);
}
};
const addMember = (value: number | null) => {
if (!groupConfig.value || value === null) return;
const v = String(value);
if (!groupConfig.value.ban_users.includes(v)) {
groupConfig.value.ban_users.push(v);
}
pendingMember.value = null;
};
const formatUserId = (userId: string | number) => {
const member = groupConfig.value?.member_list.find(
(m) => m.value.toString() === userId.toString()
);
return member?.label || String(userId);
};
onMounted(() => {
fetchGlobalConfig();
fetchGroupList();
});
</script>
<style scoped>
.config-container {
max-width: 1000px;
margin: 0 auto;
}
.config-body {
min-height: 100px;
}
.config-card {
margin-bottom: 16px;
border-radius: 8px;
}
.config-card .el-form {
max-width: 520px;
}
.config-card :deep(.el-input-number) {
width: 100%;
}
.suffix-wrap {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.suffix-text {
color: #909399;
font-size: 13px;
flex-shrink: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.tag-input {
width: 200px;
}
.member-select {
width: 220px;
}
.weight-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.weight-row {
display: flex;
align-items: center;
gap: 8px;
}
.weight-row .el-input-number {
width: 180px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.group-select-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 16px 20px;
}
.group-select-label {
font-weight: 500;
color: #303133;
flex-shrink: 0;
}
</style>
@@ -1,280 +0,0 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人所学习的内容。</p>
<p>点击「回复列表」可以查看该条内容已学习到的回复;点击「禁用」可以禁用该学习;点击「删除」可以删除该学习。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="contexts" stripe border @sort-change="handleSortChange">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="内容/关键词" min-width="240" sortable="custom">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`学习内容 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column prop="count" label="已学次数" width="110" sortable="custom" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" plain @click="showAnswers(row)">回复列表</el-button>
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无学习内容" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchContexts"
/>
</div>
</div>
<!-- 回复列表弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="`回复列表 - ${currentContext?.keywords ?? ''}`"
width="640px"
destroy-on-close
@closed="dialogVisible = false; currentContext = null; contextAnswers = []"
>
<div v-loading="answersLoading">
<el-table v-if="contextAnswers.length" :data="contextAnswers" size="small" stripe border>
<el-table-column prop="keywords" label="回复内容" min-width="180" show-overflow-tooltip />
<el-table-column prop="count" label="次数" width="80" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBanAnswer(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDeleteAnswer(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else-if="!answersLoading" description="暂无回复" :image-size="80" />
</div>
</el-dialog>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatAnswer, ChatContext } from '@/types';
const contexts = ref<ChatContext[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const sortColumn = ref<'keywords' | 'time' | 'count'>('time');
const sortOrder = ref<'asc' | 'desc'>('desc');
const dialogVisible = ref(false);
const currentContext = ref<ChatContext | null>(null);
const contextAnswers = ref<ChatAnswer[]>([]);
const answersLoading = ref(false);
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchContexts = async () => {
loading.value = true;
try {
const res = await apiClient.getContexts({
page: page.value,
perPage,
orderBy: sortColumn.value,
orderDir: sortOrder.value,
keywords: searchKeywords.value,
});
contexts.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取学习内容失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchContexts();
};
const handleSortChange = ({
prop,
order,
}: {
prop: string;
order: 'ascending' | 'descending' | null;
}) => {
if (!prop) return;
sortColumn.value = prop as 'keywords' | 'time' | 'count';
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
page.value = 1;
fetchContexts();
};
const handleDelete = async (row: ChatContext) => {
try {
await ElMessageBox.confirm(`确定要删除学习内容 "${row.keywords}" 吗?`, '删除学习内容', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('context', row.id);
ElMessage.success('已删除');
fetchContexts();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatContext) => {
try {
await ElMessageBox.confirm(`确定要禁用学习内容 "${row.keywords}" 吗?`, '禁用学习内容', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('context', row.id);
ElMessage.success('已禁用');
fetchContexts();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有学习内容吗?这是不可逆的操作!',
'删除所有学习内容',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('context');
ElMessage.success('已删除所有学习内容');
fetchContexts();
} catch (err) {
showError(err, '删除失败');
}
};
const showAnswers = async (row: ChatContext) => {
currentContext.value = row;
contextAnswers.value = [];
dialogVisible.value = true;
answersLoading.value = true;
try {
const res = await apiClient.getAnswers({ context_id: row.id, perPage: 100 });
contextAnswers.value = res.data!.items;
} catch (err) {
showError(err, '获取回复列表失败');
} finally {
answersLoading.value = false;
}
};
const handleDeleteAnswer = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('answer', row.id);
ElMessage.success('已删除');
if (currentContext.value) showAnswers(currentContext.value);
} catch (err) {
showError(err, '删除失败');
}
};
const handleBanAnswer = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('answer', row.id);
ElMessage.success('已禁用');
if (currentContext.value) showAnswers(currentContext.value);
} catch (err) {
showError(err, '禁用失败');
}
};
// 初始加载
fetchContexts();
</script>
@@ -1,159 +0,0 @@
<template>
<div class="login-wrapper">
<el-card class="login-card" shadow="always">
<div class="logo-container">
<img
:src="LOGO_URL"
alt="Nonebot-Plugin-Learning-Chat"
class="logo"
@error="logoFailed = true"
/>
</div>
<h1 class="app-title">Nonebot-Plugin-Learning-Chat 控制台</h1>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
size="large"
@submit.prevent
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
:disabled="loading"
autofocus
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
:disabled="loading"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-alert
v-if="error"
:title="error"
type="error"
:closable="false"
show-icon
class="login-error"
/>
<el-button
type="primary"
class="login-button"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { FormInstance, FormRules } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
import apiClient from '@/api/client';
const LOGO_URL =
'https://s3.sansenhoshi.top/s-file-trans/%E5%9B%BE%E7%89%87/icon/123.jpg';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const formRef = ref<FormInstance>();
const loading = ref(false);
const error = ref('');
const logoFailed = ref(false);
const form = reactive({ username: '', password: '' });
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
const handleLogin = async () => {
error.value = '';
if (!formRef.value) return;
const valid = await formRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
const res = await apiClient.login(form.username.trim(), form.password);
authStore.setAuth(res.data!.token, form.username.trim());
// 登录后跳回被拦截前的页面(如 token 过期时重定向来的路径)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
await router.push(redirect);
} catch (err) {
error.value = err instanceof Error ? err.message : '登录失败,请重试';
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-wrapper {
width: 100%;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: 12px;
padding: 12px 8px;
}
.logo-container {
text-align: center;
margin-bottom: 20px;
}
.logo {
width: 90px;
height: 90px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.app-title {
text-align: center;
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0 0 28px;
line-height: 1.4;
}
.login-error {
margin-bottom: 16px;
}
.login-button {
width: 100%;
margin-top: 4px;
}
</style>
@@ -1,188 +0,0 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人收到的聊天记录。</p>
<p>点击「禁用」可以将某条聊天记录进行禁用;点击「删除」可以删除某条记录。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input v-model="searchGroupId" placeholder="群ID" clearable />
<el-input v-model="searchUserId" placeholder="用户ID" clearable />
<el-input
v-model="searchMessage"
placeholder="消息内容"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="messages" stripe border>
<el-table-column prop="message_id" label="消息ID" width="130" show-overflow-tooltip />
<el-table-column prop="group_id" label="群ID" width="110" />
<el-table-column prop="user_id" label="用户ID" width="110" />
<el-table-column label="消息" min-width="240">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.raw_message || row.message"
@click="showDetail(`消息 #${row.message_id}`, row.raw_message || row.message)"
>
{{ row.raw_message || row.message }}
</span>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无消息数据" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchMessages"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatMessage } from '@/types';
const messages = ref<ChatMessage[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchGroupId = ref('');
const searchUserId = ref('');
const searchMessage = ref('');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchMessages = async () => {
loading.value = true;
try {
const res = await apiClient.getMessages({
page: page.value,
perPage,
group_id: searchGroupId.value,
user_id: searchUserId.value,
message: searchMessage.value,
});
messages.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取消息失败');
} finally {
loading.value = false;
}
};
// 搜索必须重置到第 1 页, 否则可能停留在超范围的页码上
const handleSearch = () => {
page.value = 1;
fetchMessages();
};
const handleDelete = async (row: ChatMessage) => {
try {
await ElMessageBox.confirm(`确定要删除消息 "${row.raw_message || row.message}" 吗?`, '删除消息', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('message', row.id);
ElMessage.success('已删除');
fetchMessages();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatMessage) => {
try {
await ElMessageBox.confirm(`确定要禁用这条消息吗?`, '禁用消息', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('message', row.id);
ElMessage.success('已禁用');
fetchMessages();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有消息吗?这是不可逆的操作!',
'删除所有消息',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('message');
ElMessage.success('已删除所有消息');
fetchMessages();
} catch (err) {
showError(err, '删除失败');
}
};
// 初始加载
fetchMessages();
</script>
@@ -1,5 +0,0 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
@@ -1,14 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

Some files were not shown because too many files have changed in this diff Show More