Add HeXi bot codebase: custom plugins, web frontends, tests

- hexi core: message handling, rate limiting, cooldown, plugin manager
- Custom plugins: BF stats, daily check-in, quotes, persona cards, etc.
- Community plugins vendored under hexi/plugins with local fixes
- Web admin frontends (learning-chat, persona-admin), unified hexi/web
- Tests for rate_limit/cooldown/memes/persona; poetry.lock

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -0,0 +1,82 @@
# 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`)
- 密保答案场景(中文值正则误杀率高,方案待定)
- 命令名与权限(超管/群主)
@@ -0,0 +1,194 @@
# 群聊人设卡(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 风格卡面图片渲染(协议文本为渲染源)
@@ -0,0 +1,187 @@
"""群聊人设卡 —— 基于群聊语料蒸馏成员形象,生成 galgame 风格人物卡
消息入口:on_message 监听全部消息事件,类型注解过滤出群聊,
两级闸门(群开关 + 个人 opt-in)通过后,治理层处理落库。
当前进度:数据层 + 采集层(消息路径)完成;总结路径(印象/画像)待实现。
"""
import json
import time
from collections import deque
from typing import Optional
from nonebot import get_driver, on_message, require
require("nonebot_plugin_orm")
from nonebot.adapters.onebot.v11 import GroupMessageEvent
from nonebot.plugin import PluginMetadata
# ── 禁用 nonebot_plugin_orm 的启动自动同步 ─────────────────────────────
# .env 里 ALEMBIC_STARTUP_CHECK=false 时,orm 插件每次启动都会 autogenerate
# 同步数据库模式;模型一有变更(改 models.py)就会清空整表数据(已实测踩坑)。
# 本插件的 schema 演进统一走 repository.ensure_schema() 显式迁移,
# 因此把 migrate.sync 替换为安全空操作。改动必须在插件导入期完成
# (早于 orm 的 on_startup 钩子执行)。
from nonebot import logger as _logger
try:
import nonebot_plugin_orm.migrate as _orm_migrate
def _safe_sync(*_args, **_kwargs):
# 同步函数:orm 用 greenlet_spawn 同步调用 migrate.sync,
# 若写成 async def 会产生 never-awaited coroutine 警告
_logger.debug("galgame_card | 已禁用 orm 自动同步(schema 演进由 ensure_schema 接管)")
_orm_migrate.sync = _safe_sync
except ImportError:
_logger.warning("galgame_card | 无法禁用 orm 自动同步,请检查 nonebot_plugin_orm 是否安装")
from . import models # noqa: F401 # 确保表结构注册
from . import repository, processor
from . import web # noqa: F401 # Web 管理后台(/galgame_card)
from hexi.web_config import register_plugin_config # noqa: E402
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
from . import web_hub # noqa: E402
def _apply_galgame_config(values: dict) -> None:
"""Web 保存后热刷新:直接改 config 模块的 pydantic 对象,无需重启。"""
import sys
conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config")
if conf_mod is None:
return
conf = getattr(conf_mod, "config", None)
if conf is None:
return
for key, value in values.items():
try:
setattr(conf, key, value)
except Exception: # noqa: BLE001
pass
def _get_galgame_config_values() -> dict:
"""Web 表单回填当前生效值。"""
import sys
conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config")
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")
return {k: getattr(conf, k, None) for k in keys}
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},
]
},
apply=_apply_galgame_config,
getter=_get_galgame_config_values,
)
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/galgame_card
register_web_plugin(
"galgame_card",
"群聊人设卡",
"user",
lambda: web_hub.build_admin_app(),
module_name=__name__,
)
__plugin_meta__ = PluginMetadata(
name="群聊人设卡",
description="基于群聊语料蒸馏成员形象,生成 galgame 风格人物卡",
usage="开发中……",
)
# 采样/复读检测的内存状态(不进库)
_recent_texts: dict[int, deque[str]] = {} # group_id -> 最近消息文本
_last_store: dict[tuple[int, int], float] = {} # (group_id, user_id) -> 上次落库时间戳
def _is_repeat(group_id: int, content: str) -> bool:
"""复读检测:内容在最近 N 条里出现过则丢弃"""
queue = _recent_texts.setdefault(group_id, deque(maxlen=processor.REPEAT_WINDOW))
if content in queue:
return True
queue.append(content)
return False
def _throttled(group_id: int, user_id: int) -> bool:
"""刷屏采样:5 秒内同一个人只落库一次"""
key = (group_id, user_id)
now = time.time()
if now - _last_store.get(key, 0) < processor.THROTTLE_SECONDS:
return True
_last_store[key] = now
return False
_driver = get_driver()
@_driver.on_startup
async def _ensure_schema():
"""老库迁移(persona_group.group_name 列),幂等"""
await repository.ensure_schema()
collector = on_message(block=False) # 监听全部消息事件,不拦截其他插件
@collector.handle()
async def collect(event: GroupMessageEvent):
# 鉴权:两级闸门(群开关 → 个人 opt-in)
if not await repository.is_group_enabled(event.group_id):
return
if not await repository.is_joined(event.user_id, event.group_id):
return
# 机器人自己(如转发)不收
if event.user_id == event.self_id:
return
command_starts = get_driver().config.command_start
# 治理:文本 / 图片 / 表情任一存在才记录
if not processor.has_recordable_content(event):
return
text = processor.filter_content(
processor.extract_text(event), command_starts=command_starts
)
image_hashes = processor.extract_image_hashes(event)
content = processor.build_content(
text or "", len(image_hashes), processor.count_faces(event)
)
if content is None:
return
# 脱敏:敏感值替换为占位符(语境 = 本群最近几条已治理文本,用于"验证码/密码"类强化)
context = " ".join(_recent_texts.get(event.group_id, ()))
content = processor.desensitize(content, context)
# 采样:复读 + 刷屏
if _is_repeat(event.group_id, content):
return
if _throttled(event.group_id, event.user_id):
return
await repository.add_chat_log(
user_id=event.user_id,
group_id=event.group_id,
content=content,
nickname=event.sender.card or event.sender.nickname or "",
target_user_id=processor.extract_target_user_id(event),
reply_to_content=processor.extract_reply_to_content(event),
image_count=len(image_hashes),
image_hashes=json.dumps(image_hashes, ensure_ascii=False) if image_hashes else None,
)
@@ -0,0 +1,19 @@
"""插件配置(Web 管理后台)
env 变量名与字段名对应(大写):WEB_ENABLE / WEB_USERNAME / WEB_PASSWORD / WEB_SECRET_KEY
"""
from nonebot import get_plugin_config
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
config = get_plugin_config(Config)
@@ -0,0 +1,127 @@
"""群聊人设卡 —— 数据层:表结构定义(与 DESIGN.md §3 对应)"""
from datetime import datetime
from typing import Optional
from nonebot import require
require("nonebot_plugin_orm")
from nonebot_plugin_orm import Model
from sqlalchemy import BigInteger, Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
class PersonaGroup(Model):
"""管理群列表 + 采集开关
一行 = 一个被管理的群(由用户在 Web 后台从 OneBot 群列表中选择加入);
enabled 控制该群是否采集(群关闭则群里所有人都不采集)。
主页面/采集器只读这张表,不依赖 OneBot 在线。
"""
__tablename__ = "persona_group"
group_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
group_name: Mapped[str] = mapped_column(String(64), default="") # 群名快照(添加时记录)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
class PersonaUser(Model):
"""人设计划参与者(按群 opt-in,一行一人一群)"""
__tablename__ = "persona_user"
user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
group_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
class PersonaChatLog(Model):
"""采集语料(五维):内容 / 谁发的 / 发给谁 / 几点发的 / 被回复内容快照
只存治理层处理后的纯文本;数据来源无关(群事件、历史导入均可写入)。
"""
__tablename__ = "persona_chat_log"
__table_args__ = (
Index("ix_persona_log_user_time", "user_id", "group_id", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger) # 谁发的
group_id: Mapped[int] = mapped_column(BigInteger)
nickname: Mapped[str] = mapped_column(String(64), default="") # 群昵称快照
content: Mapped[str] = mapped_column(Text) # 内容
target_user_id: Mapped[Optional[int]] = mapped_column( # 发给谁(回复/@ 对象,或继承自链条)
BigInteger, nullable=True
)
target_inherited: Mapped[bool] = mapped_column( # target 是否从发言段链条继承
Boolean, default=False
)
follows_id: Mapped[Optional[int]] = mapped_column( # 同一说话人的上一条语料 id(发言段链条)
Integer, nullable=True
)
image_count: Mapped[int] = mapped_column(Integer, default=0) # 本条图片数
image_hashes: Mapped[Optional[str]] = mapped_column( # 图片 hash 列表 JSON(表情包去重识别)
Text, nullable=True
)
reply_to_content: Mapped[Optional[str]] = mapped_column( # 被回复内容快照
Text, nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) # 几点发的
class PersonaImpression(Model):
"""印象:语料 → LLM 自然语言印象(两级流水线中间产物,画像的输入)"""
__tablename__ = "persona_impression"
__table_args__ = (
Index("ix_persona_impression_user_time", "user_id", "group_id", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger)
group_id: Mapped[int] = mapped_column(BigInteger)
content: Mapped[str] = mapped_column(Text) # 自然语言印象(话题/氛围/互动)
cover_from_id: Mapped[int] = mapped_column(Integer, default=0) # 覆盖语料起点 id
cover_to_id: Mapped[int] = mapped_column(Integer, default=0) # 覆盖语料终点 id(增量依据)
model: Mapped[str] = mapped_column(String(64), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
class PersonaImage(Model):
"""图片识别结果缓存(多模态预留):同一 hash 只识别一次
当前未启用识别(vision.recognizer 为占位实现),表结构先行预留。
"""
__tablename__ = "persona_image"
hash: Mapped[str] = mapped_column(String(64), primary_key=True) # 图片 hash(与 chat_log.image_hashes 对应)
description: Mapped[str] = mapped_column(Text, default="") # 多模态识别结果
model: Mapped[str] = mapped_column(String(64), default="") # 识别模型
recognized_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
class PersonaSummary(Model):
"""画像快照:九段 markdown 协议(DESIGN.md §4),版本化永久留档"""
__tablename__ = "persona_summary"
__table_args__ = (
UniqueConstraint(
"user_id", "group_id", "version", name="uq_persona_summary_user_group_version"
),
Index("ix_persona_summary_user_group", "user_id", "group_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger)
group_id: Mapped[int] = mapped_column(BigInteger)
version: Mapped[int] = mapped_column(Integer)
card_text: Mapped[str] = mapped_column(Text) # 九段 markdown 画像原文
corpus_count: Mapped[int] = mapped_column(Integer, default=0) # 元信息:语料覆盖条数
impression_count: Mapped[int] = mapped_column(Integer, default=0) # 元信息:印象条数
model: Mapped[str] = mapped_column(String(64), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
@@ -0,0 +1,218 @@
"""治理层:纯函数处理(无状态、可单测)
命中即弃的规则返回 None;`__init__.py` 入口层只负责组装这些函数。
"""
import re
from typing import Iterable, Optional
from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message
# 敏感数据替换(本地正则,绝不经过 LLM —— DESIGN.md §6 硬约束)
# 值类型正则
CODE_RE = re.compile(r"(?<!\d)\d{4,8}(?!\d)") # 验证码/短数字串
BARE_DIGITS_RE = re.compile(r"(?<!\d)\d{6,}(?!\d)") # 裸长数字串
# 字母+数字混合串(至少各一位、总长 ≥6),如 abc1234
MIXED_RE = re.compile(
r"(?<![A-Za-z0-9])(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{6,}(?![A-Za-z0-9])"
)
TOKEN_RE = re.compile(r"[A-Za-z0-9._-]{4,}") # 账号/密码值(定位式用)
CODE_TOKEN_RE = re.compile(r"[A-Za-z0-9-]{6,}") # 兑换码值(定位式用)
# 明确模式(无语境也确定替换;按"最长/最特异优先"排序,
# 否则手机号等短模式会先截胡身份证/银行卡里的数字段)
CLEAR_PATTERNS = (
(re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)"), "[身份证]"),
(re.compile(r"(?<!\d)\d{16,19}(?!\d)"), "[银行卡]"),
(re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"), "[邮箱]"),
(re.compile(r"(?<!\d)1[3-9]\d[-\s]?\d{4}[-\s]?\d{4}(?!\d)"), "[手机号]"), # 含 138-0013-8000 变体
(re.compile(r"(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)"), "[IP]"),
(re.compile(r"wxid_[A-Za-z0-9_-]{6,}"), "[微信号]"),
(re.compile(r"(?<!\d)(?:\d{1,3}\.\d+)[,,\s](?:\d{1,3}\.\d+)(?!\d)"), "[坐标]"),
( # 车牌:省份简称+字母+数字
re.compile(
r"[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领]"
r"[A-HJ-NP-Z][A-Z0-9]{5,6}"
),
"[车牌]",
),
)
# 定位式(同条消息:关键词 + 分隔/连接词 + 值 → 精准替换,替换后兜底不再命中)
_SEP = r"[::\s是为]*" # 冒号/空格/是/为 等连接词
KEY_VALUE_PATTERNS = (
(re.compile(rf"(?:兑换码|激活码|卡密|cdk|代充码){_SEP}([A-Za-z0-9-]{{6,}})", re.I), "[兑换码]"),
(re.compile(rf"(?:验证码|校验码){_SEP}(\d{{4,8}})"), "[验证码]"),
(
re.compile(rf"(?:密码|口令|pwd|password|支付密码|二级密码){_SEP}([A-Za-z0-9._-]{{4,}})", re.I),
"[密码]",
),
(re.compile(rf"(?:账号|帐号){_SEP}([A-Za-z0-9._-]{{4,}})"), "[账号]"),
(re.compile(rf"(?:VX|vx|微信){_SEP}([A-Za-z0-9_-]{{6,}})"), "[微信号]"),
(re.compile(rf"(?:月薪|工资|薪资|年薪){_SEP}([\d,.]+(?:万)?)"), "[收入]"),
)
# 跨条语境(关键词在附近消息 → 本条的值按语境替换)
CROSS_HINTS = (
(re.compile(r"兑换码|激活码|卡密|cdk|代充", re.I), "[兑换码]", CODE_TOKEN_RE),
(re.compile(r"验证码|校验码|captcha", re.I), "[验证码]", CODE_RE),
(re.compile(r"密码|口令|pwd|password", re.I), "[密码]", MIXED_RE),
)
MAX_CONTENT_LEN = 200 # 长文截断
MAX_REPLY_CTX_LEN = 200 # 被回复快照截断
URL_FLOOD_LIMIT = 3 # 长链接轰炸判定
REPEAT_WINDOW = 3 # 复读检测窗口(最近 N 条内出现过即弃)
THROTTLE_SECONDS = 5 # 刷屏采样:同人同群 N 秒内只落库一次
def _message_to_text(message: Message) -> str:
"""消息段 → 纯文本:Text 拼接,At 转 @qq,其余(图/表情/引用)丢弃"""
parts = []
for seg in message:
if seg.type == "text":
parts.append(seg.data.get("text", ""))
elif seg.type == "at":
qq = str(seg.data.get("qq", ""))
if qq and qq not in {"all", "0"}:
parts.append(f"@{qq}")
return "".join(parts).strip()
def extract_text(event: GroupMessageEvent) -> str:
return _message_to_text(event.message)
# 图片 CQ 码 file 字段里的 32 位 hash(表情包去重识别用)
IMG_HASH_RE = re.compile(r"[0-9a-f]{32}")
def image_hash_from_file(file: str, url: str = "") -> str:
"""从图片 CQ 码提取稳定标识:优先 file 里的 32 位 hash,回退 url 文件名"""
f = str(file or "")
m = IMG_HASH_RE.search(f.lower())
if m:
return m.group(0)
u = str(url or "").strip()
if u:
name = u.rstrip("/").split("/")[-1]
m = IMG_HASH_RE.search(name.lower())
if m:
return m.group(0)
return name[:64] or "image"
return f[:64] or "image"
def extract_image_hashes(event: GroupMessageEvent) -> list[str]:
"""本条消息里的图片标识列表(同一张图反复发 = 标志性表情包)"""
return [
image_hash_from_file(
str(seg.data.get("file", "") or ""), str(seg.data.get("url", "") or "")
)
for seg in event.message
if seg.type == "image"
]
def count_faces(event: GroupMessageEvent) -> int:
"""QQ 原生表情数量(只记数量,不记 id→名字映射)"""
return sum(1 for seg in event.message if seg.type == "face")
def has_recordable_content(event: GroupMessageEvent) -> bool:
"""文本 / 图片 / 表情 任一存在即可记录(纯引用等空消息除外)"""
for seg in event.message:
if seg.type == "text" and seg.data.get("text", "").strip():
return True
if seg.type in {"image", "face"}:
return True
return False
def build_content(text: str, image_count: int = 0, face_count: int = 0) -> Optional[str]:
"""组装落库内容:文本 + 图片/表情标记;全空返回 None"""
parts: list[str] = []
if text:
parts.append(text)
if image_count and face_count:
parts.append(f"[图片×{image_count}][表情×{face_count}]")
elif image_count:
parts.append(f"[图片×{image_count}]")
elif face_count:
parts.append(f"[表情×{face_count}]")
if not parts:
return None
return " ".join(parts)
def extract_target_user_id(event: GroupMessageEvent) -> Optional[int]:
"""发给谁:回复的 sender 优先,其次首个 @"""
if event.reply and event.reply.sender:
return int(event.reply.sender.user_id)
for seg in event.message:
if seg.type == "at":
qq = str(seg.data.get("qq", ""))
if qq.isdigit():
return int(qq)
return None
def extract_reply_to_content(event: GroupMessageEvent) -> Optional[str]:
"""被回复内容快照(适配器事件直接带原文)"""
if not event.reply:
return None
text = _message_to_text(event.reply.message)
return text[:MAX_REPLY_CTX_LEN] or None
def desensitize(content: str, context_text: str = "") -> str:
"""敏感数据替换为占位符(本地正则,不经过 LLM)
context_text 为附近消息拼接文本,用于跨条语境强化
(如"收下验证码"在 A 的消息、验证码值在 B 的消息)。
保守替换:宁误杀不放过(占位符不影响人格总结)。
"""
out = content
# 1) 明确模式:手机号/身份证/银行卡/邮箱/IP/微信号/坐标/车牌
for pattern, placeholder in CLEAR_PATTERNS:
out = pattern.sub(placeholder, out)
# 2) 定位式:同条"关键词 + 值"精准替换,只替换值、保留关键词
# (占位符不含字母数字,替换后兜底不再命中)
for pattern, placeholder in KEY_VALUE_PATTERNS:
out = pattern.sub(
lambda m: m.group(0)[: m.start(1) - m.start(0)] + placeholder, out
)
# 3) 跨条语境:关键词在附近消息 → 本条的值按语境类型替换
hint = f"{content} {context_text}"
for hint_re, placeholder, value_re in CROSS_HINTS:
if hint_re.search(hint):
out = value_re.sub(placeholder, out)
# 4) 兜底:无语境的长串(保守替换)
out = BARE_DIGITS_RE.sub("[账号]", out)
out = MIXED_RE.sub("[密码]", out)
return out
def is_command(content: str, command_starts: Iterable[str]) -> bool:
"""命令前缀命中(空前缀不算,避免全量误杀)"""
starts = [s for s in command_starts if s]
return any(content.startswith(s) for s in starts)
def is_url_flood(content: str) -> bool:
return content.count("http") >= URL_FLOOD_LIMIT
def truncate(content: str, limit: int = MAX_CONTENT_LEN) -> str:
return content[:limit]
def filter_content(content: str, command_starts: Iterable[str]) -> Optional[str]:
"""噪声治理:空/命令/链接轰炸 → None;否则截断返回(脱敏由 desensitize 单独做)"""
if not content:
return None
if is_command(content, command_starts):
return None
if is_url_flood(content):
return None
return truncate(content)
@@ -0,0 +1,577 @@
"""群聊人设卡 —— 数据层:仓储
只做存储读写,不关心数据来源(群事件、历史导入走同一个入口)。
业务层与数据来源层都只通过这里的函数访问数据库。
"""
from datetime import datetime
from typing import Optional
from nonebot import require
require("nonebot_plugin_orm")
from nonebot_plugin_orm import get_session
from sqlalchemy import delete, func, select
from .models import (
PersonaChatLog,
PersonaGroup,
PersonaImage,
PersonaImpression,
PersonaSummary,
PersonaUser,
)
# 单用户单群语料滚动保留上限(超出淘汰最旧,DESIGN.md §6)
ROLLING_WINDOW = 3000
# 发言段链条:同一说话人两条消息间隔不超过该秒数,视为同一段发言
FOLLOWS_MAX_GAP = 300 # 5 分钟
# ---------- 管理群列表(数据来源总闸门) ----------
async def ensure_schema() -> None:
"""schema 管理(幂等):补列 + 建新表。
orm 自动同步已被禁用(见 __init__.py),且 orm 的启动建表早于本插件
模型导入(新表不会自动建),因此建表/补列都走这里。
"""
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
statements = [
"ALTER TABLE persona_group ADD COLUMN group_name VARCHAR(64) DEFAULT ''",
"ALTER TABLE persona_chat_log ADD COLUMN follows_id INTEGER",
"ALTER TABLE persona_chat_log ADD COLUMN target_inherited BOOLEAN DEFAULT 0",
"ALTER TABLE persona_chat_log ADD COLUMN image_count INTEGER DEFAULT 0",
"ALTER TABLE persona_chat_log ADD COLUMN image_hashes TEXT",
]
async with get_session() as session:
for stmt in statements:
try:
await session.execute(text(stmt))
await session.commit()
except OperationalError:
pass # 列已存在
# 幂等创建缺失的表(本插件全部模型共用同一 metadata)
def _create_tables(sync_session) -> None:
PersonaImage.metadata.create_all(sync_session.connection())
await session.run_sync(_create_tables)
async def list_groups() -> list[PersonaGroup]:
"""全部被管理的群(数据库直读,不依赖 OneBot)"""
async with get_session() as session:
result = await session.execute(
select(PersonaGroup).order_by(PersonaGroup.group_id)
)
return list(result.scalars().all())
async def add_group(group_id: int, group_name: str = "") -> None:
"""加入管理列表(幂等:已存在则更新名称),默认不采集"""
async with get_session() as session:
row = await session.get(PersonaGroup, group_id)
if row is None:
session.add(PersonaGroup(group_id=group_id, group_name=group_name))
elif group_name and row.group_name != group_name:
row.group_name = group_name
await session.commit()
async def remove_group(group_id: int) -> None:
"""移出管理列表(数据保留,重新加入可续上)"""
async with get_session() as session:
await session.execute(
delete(PersonaGroup).where(PersonaGroup.group_id == group_id)
)
await session.commit()
async def backfill_group_names(names: dict[int, str]) -> None:
"""用 OneBot 群列表回填已管理群的名称(仅在拉取群列表时顺带做)"""
async with get_session() as session:
rows = (await session.execute(select(PersonaGroup))).scalars().all()
changed = False
for row in rows:
name = names.get(row.group_id, "")
if name and row.group_name != name:
row.group_name = name
changed = True
if changed:
await session.commit()
async def set_group_enabled(group_id: int, enabled: bool) -> None:
"""开/关某群的采集(幂等:不存在则创建,存在则更新)"""
async with get_session() as session:
row = await session.get(PersonaGroup, group_id)
if row is None:
session.add(PersonaGroup(group_id=group_id, enabled=enabled))
else:
row.enabled = enabled
row.updated_at = datetime.now()
await session.commit()
async def is_group_enabled(group_id: int) -> bool:
async with get_session() as session:
result = await session.execute(
select(PersonaGroup).where(PersonaGroup.group_id == group_id)
)
row = result.scalar()
return bool(row and row.enabled)
async def enabled_groups() -> list[int]:
"""全部开启采集的群(调度/采集层扫描用)"""
async with get_session() as session:
result = await session.execute(
select(PersonaGroup.group_id).where(PersonaGroup.enabled.is_(True))
)
return [row[0] for row in result.all()]
# ---------- 参与者 ----------
async def is_joined(user_id: int, group_id: int) -> bool:
async with get_session() as session:
result = await session.execute(
select(PersonaUser).where(
PersonaUser.user_id == user_id, PersonaUser.group_id == group_id
)
)
return result.scalar() is not None
async def join(user_id: int, group_id: int) -> None:
async with get_session() as session:
session.add(PersonaUser(user_id=user_id, group_id=group_id))
await session.commit()
async def leave(user_id: int, group_id: int) -> None:
async with get_session() as session:
await session.execute(
delete(PersonaUser).where(
PersonaUser.user_id == user_id, PersonaUser.group_id == group_id
)
)
await session.commit()
async def joined_users(group_id: int) -> list[int]:
"""某群全部参与者(调度层扫描用)"""
async with get_session() as session:
result = await session.execute(
select(PersonaUser.user_id).where(PersonaUser.group_id == group_id)
)
return [row[0] for row in result.all()]
# ---------- 语料 ----------
async def add_chat_log(
user_id: int,
group_id: int,
content: str,
nickname: str = "",
target_user_id: Optional[int] = None,
reply_to_content: Optional[str] = None,
image_count: int = 0,
image_hashes: Optional[str] = None,
) -> int:
"""落库一条语料,返回新语料 id
发言段链条:同一说话人的上一条语料记为 follows_id(间隔 ≤ FOLLOWS_MAX_GAP);
本条无显式目标(回复/@)时从链条继承 target_user_id——对上面那句的解释/补充
仍视为发给同一对象,不丢语境。
"""
async with get_session() as session:
prev = (
await session.execute(
select(PersonaChatLog)
.where(
PersonaChatLog.user_id == user_id,
PersonaChatLog.group_id == group_id,
)
.order_by(PersonaChatLog.id.desc())
.limit(1)
)
).scalar()
follows_id = None
inherited = False
if prev is not None and (
datetime.now() - prev.created_at
).total_seconds() <= FOLLOWS_MAX_GAP:
follows_id = prev.id
if target_user_id is None:
target_user_id = prev.target_user_id
inherited = target_user_id is not None
log = PersonaChatLog(
user_id=user_id,
group_id=group_id,
nickname=nickname,
content=content,
target_user_id=target_user_id,
target_inherited=inherited,
follows_id=follows_id,
image_count=image_count,
image_hashes=image_hashes,
reply_to_content=reply_to_content,
)
session.add(log)
await session.commit()
await session.refresh(log)
return log.id
async def count_chat_log(user_id: int, group_id: int) -> int:
async with get_session() as session:
result = await session.execute(
select(func.count(PersonaChatLog.id)).where(
PersonaChatLog.user_id == user_id, PersonaChatLog.group_id == group_id
)
)
return result.scalar_one()
async def latest_chat_logs(
user_id: int, group_id: int, limit: int, before_id: Optional[int] = None
) -> list[PersonaChatLog]:
"""按 id 倒序取最新 limit 条;before_id 用于翻页"""
stmt = (
select(PersonaChatLog)
.where(PersonaChatLog.user_id == user_id, PersonaChatLog.group_id == group_id)
.order_by(PersonaChatLog.id.desc())
.limit(limit)
)
if before_id is not None:
stmt = stmt.where(PersonaChatLog.id <= before_id)
async with get_session() as session:
result = await session.execute(stmt)
return list(result.scalars().all())
async def chat_logs_since(
user_id: int, group_id: int, since_id: int, limit: int
) -> list[PersonaChatLog]:
"""按 id 正序取 id > since_id 的语料(增量印象生成的输入)"""
async with get_session() as session:
result = await session.execute(
select(PersonaChatLog)
.where(
PersonaChatLog.user_id == user_id,
PersonaChatLog.group_id == group_id,
PersonaChatLog.id > since_id,
)
.order_by(PersonaChatLog.id.asc())
.limit(limit)
)
return list(result.scalars().all())
async def prune_chat_log(user_id: int, group_id: int, keep: int = ROLLING_WINDOW) -> int:
"""淘汰滚动窗口外的旧语料,返回删除条数"""
async with get_session() as session:
result = await session.execute(
select(PersonaChatLog.id)
.where(
PersonaChatLog.user_id == user_id, PersonaChatLog.group_id == group_id
)
.order_by(PersonaChatLog.id.desc())
.offset(keep)
)
stale_ids = [row[0] for row in result.all()]
if stale_ids:
await session.execute(
delete(PersonaChatLog).where(PersonaChatLog.id.in_(stale_ids))
)
await session.commit()
return len(stale_ids)
# ---- 统计与分页(Web 管理后台用) ----
async def count_chat_log_group(group_id: int) -> int:
async with get_session() as session:
result = await session.execute(
select(func.count(PersonaChatLog.id)).where(
PersonaChatLog.group_id == group_id
)
)
return result.scalar_one()
async def count_impressions_group(group_id: int) -> int:
async with get_session() as session:
result = await session.execute(
select(func.count(PersonaImpression.id)).where(
PersonaImpression.group_id == group_id
)
)
return result.scalar_one()
async def count_summaries_group(group_id: int) -> int:
async with get_session() as session:
result = await session.execute(
select(func.count(PersonaSummary.id)).where(
PersonaSummary.group_id == group_id
)
)
return result.scalar_one()
async def corpus_counts_by_group() -> dict[int, int]:
"""各群语料条数(一次聚合查询,替代逐群 count)"""
async with get_session() as session:
result = await session.execute(
select(PersonaChatLog.group_id, func.count(PersonaChatLog.id)).group_by(
PersonaChatLog.group_id
)
)
return {int(row[0]): int(row[1]) for row in result.all()}
async def impression_counts_by_group() -> dict[int, int]:
async with get_session() as session:
result = await session.execute(
select(PersonaImpression.group_id, func.count(PersonaImpression.id)).group_by(
PersonaImpression.group_id
)
)
return {int(row[0]): int(row[1]) for row in result.all()}
async def summary_counts_by_group() -> dict[int, int]:
async with get_session() as session:
result = await session.execute(
select(PersonaSummary.group_id, func.count(PersonaSummary.id)).group_by(
PersonaSummary.group_id
)
)
return {int(row[0]): int(row[1]) for row in result.all()}
async def participant_counts_by_group() -> dict[int, int]:
async with get_session() as session:
result = await session.execute(
select(PersonaUser.group_id, func.count(PersonaUser.user_id)).group_by(
PersonaUser.group_id
)
)
return {int(row[0]): int(row[1]) for row in result.all()}
async def chat_log_stats_by_user(group_id: int) -> list[tuple[int, int]]:
"""群内每人语料条数((user_id, count),按条数倒序)"""
async with get_session() as session:
result = await session.execute(
select(PersonaChatLog.user_id, func.count(PersonaChatLog.id))
.where(PersonaChatLog.group_id == group_id)
.group_by(PersonaChatLog.user_id)
.order_by(func.count(PersonaChatLog.id).desc())
)
return [(int(row[0]), int(row[1])) for row in result.all()]
async def list_logs(
group_id: int,
user_id: Optional[int] = None,
offset: int = 0,
limit: int = 50,
) -> list[PersonaChatLog]:
"""语料分页(倒序,新在前)"""
stmt = select(PersonaChatLog).where(PersonaChatLog.group_id == group_id)
if user_id is not None:
stmt = stmt.where(PersonaChatLog.user_id == user_id)
stmt = stmt.order_by(PersonaChatLog.id.desc()).offset(offset).limit(limit)
async with get_session() as session:
result = await session.execute(stmt)
return list(result.scalars().all())
async def count_logs_filtered(group_id: int, user_id: Optional[int] = None) -> int:
stmt = select(func.count(PersonaChatLog.id)).where(
PersonaChatLog.group_id == group_id
)
if user_id is not None:
stmt = stmt.where(PersonaChatLog.user_id == user_id)
async with get_session() as session:
result = await session.execute(stmt)
return result.scalar_one()
async def list_impressions_group(
group_id: int, offset: int = 0, limit: int = 50
) -> list[PersonaImpression]:
async with get_session() as session:
result = await session.execute(
select(PersonaImpression)
.where(PersonaImpression.group_id == group_id)
.order_by(PersonaImpression.id.desc())
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
async def list_summaries_group(group_id: int) -> list[PersonaSummary]:
async with get_session() as session:
result = await session.execute(
select(PersonaSummary)
.where(PersonaSummary.group_id == group_id)
.order_by(PersonaSummary.version.desc())
)
return list(result.scalars().all())
async def delete_logs(group_id: int, user_id: Optional[int] = None) -> int:
"""删除语料(可只删某人),返回删除条数"""
stmt = delete(PersonaChatLog).where(PersonaChatLog.group_id == group_id)
if user_id is not None:
stmt = stmt.where(PersonaChatLog.user_id == user_id)
async with get_session() as session:
result = await session.execute(stmt)
await session.commit()
return result.rowcount or 0
async def clear_group_data(group_id: int) -> None:
"""清空某群全部数据:语料 + 印象 + 画像 + 参与者(群开关行保留)"""
async with get_session() as session:
await session.execute(delete(PersonaChatLog).where(PersonaChatLog.group_id == group_id))
await session.execute(delete(PersonaImpression).where(PersonaImpression.group_id == group_id))
await session.execute(delete(PersonaSummary).where(PersonaSummary.group_id == group_id))
await session.execute(delete(PersonaUser).where(PersonaUser.group_id == group_id))
await session.commit()
# ---------- 印象 ----------
async def add_impression(
user_id: int,
group_id: int,
content: str,
cover_from_id: int,
cover_to_id: int,
model: str = "",
) -> None:
async with get_session() as session:
session.add(
PersonaImpression(
user_id=user_id,
group_id=group_id,
content=content,
cover_from_id=cover_from_id,
cover_to_id=cover_to_id,
model=model,
)
)
await session.commit()
async def latest_impression(user_id: int, group_id: int) -> Optional[PersonaImpression]:
async with get_session() as session:
result = await session.execute(
select(PersonaImpression)
.where(
PersonaImpression.user_id == user_id,
PersonaImpression.group_id == group_id,
)
.order_by(PersonaImpression.id.desc())
.limit(1)
)
return result.scalar()
async def count_impressions(user_id: int, group_id: int) -> int:
async with get_session() as session:
result = await session.execute(
select(func.count(PersonaImpression.id)).where(
PersonaImpression.user_id == user_id,
PersonaImpression.group_id == group_id,
)
)
return result.scalar_one()
async def list_impressions(
user_id: int, group_id: int, limit: int
) -> list[PersonaImpression]:
"""按 id 正序取最近 limit 条印象(画像生成的输入,最旧在前)"""
async with get_session() as session:
result = await session.execute(
select(PersonaImpression)
.where(
PersonaImpression.user_id == user_id,
PersonaImpression.group_id == group_id,
)
.order_by(PersonaImpression.id.desc())
.limit(limit)
)
return list(reversed(result.scalars().all()))
# ---------- 画像 ----------
async def latest_summary(user_id: int, group_id: int) -> Optional[PersonaSummary]:
async with get_session() as session:
result = await session.execute(
select(PersonaSummary)
.where(
PersonaSummary.user_id == user_id,
PersonaSummary.group_id == group_id,
)
.order_by(PersonaSummary.version.desc())
.limit(1)
)
return result.scalar()
async def add_summary(
user_id: int,
group_id: int,
card_text: str,
corpus_count: int = 0,
impression_count: int = 0,
model: str = "",
) -> int:
"""新增画像版本,version 自动 +1,返回新版本号。
注意:并发调用可能撞 UNIQUE(user_id, group_id, version),由调度层加锁保护。
"""
prev = await latest_summary(user_id, group_id)
version = (prev.version + 1) if prev else 1
async with get_session() as session:
session.add(
PersonaSummary(
user_id=user_id,
group_id=group_id,
version=version,
card_text=card_text,
corpus_count=corpus_count,
impression_count=impression_count,
model=model,
)
)
await session.commit()
return version
async def list_summaries(user_id: int, group_id: int) -> list[PersonaSummary]:
"""全部历史版本(版本对比用),旧版在前"""
async with get_session() as session:
result = await session.execute(
select(PersonaSummary)
.where(
PersonaSummary.user_id == user_id,
PersonaSummary.group_id == group_id,
)
.order_by(PersonaSummary.version.asc())
)
return list(result.scalars().all())
@@ -0,0 +1,34 @@
"""图片多模态识别接口(预留,暂未实现)
将来接入多模态 LLM 识别图片内容(表情包梗、截图内容、图片类型等),
识别结果按 hash 缓存到 persona_image 表,分析层组装窗口时把图片描述
带给总结 LLM("他发了一张猫猫梗图" ≫ "[图片×1]")。
实现原则:
1. 识别走异步后台任务,绝不在消息采集路径同步调用 LLM
2. 同一 hash 只识别一次(表情包反复发不重复花钱)
3. 识别失败不影响采集(结果可空)
"""
from typing import Optional
from nonebot import logger
class BaseImageRecognizer:
"""图片识别基类:子类实现 describe"""
async def describe(self, image_url: str) -> Optional[str]:
raise NotImplementedError
class NoopImageRecognizer(BaseImageRecognizer):
"""占位实现:未启用时使用,什么都不做"""
async def describe(self, image_url: str) -> Optional[str]:
return None
# 当前识别器实例(预留:接入多模态 LLM 时替换为真实实现)
recognizer: BaseImageRecognizer = NoopImageRecognizer()
logger.debug("galgame_card | 图片识别接口已预留(当前为占位实现,未启用)")
@@ -0,0 +1,266 @@
"""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")
@@ -0,0 +1,370 @@
<!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>
@@ -0,0 +1,12 @@
<!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
@@ -0,0 +1,30 @@
{
"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"
}
}
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1,112 @@
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>
);
}
@@ -0,0 +1,92 @@
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;
};
}
@@ -0,0 +1,24 @@
@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;
}
@@ -0,0 +1,20 @@
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>
);
@@ -0,0 +1,171 @@
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>
);
}
@@ -0,0 +1,334 @@
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>
);
}
@@ -0,0 +1,227 @@
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>
);
}
@@ -0,0 +1,58 @@
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>
);
}
@@ -0,0 +1,71 @@
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>
);
}
@@ -0,0 +1,12 @@
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()],
};
@@ -0,0 +1,20 @@
{
"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"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "/galgame_card/",
plugins: [react()],
});
@@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
"""群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
旧版独立后台 /galgame_card(JWT 自鉴权)仍保留可用,前端由 hub 渲染。
"""
from __future__ import annotations
from typing import Optional
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from hexi.web_auth import require_admin
from . import repository
from .config import config
API = require_admin
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():
try:
from nonebot import get_adapter
from nonebot.adapters.onebot.v11 import Adapter
bots = get_adapter(Adapter).bots
return next(iter(bots.values()), None)
except Exception: # noqa: BLE001
return None
def build_admin_app() -> FastAPI | None:
"""构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。
在 hub 统一登录态下可用;未启用 Web 时返回 None。
"""
if not config.web_enable:
return None
app = FastAPI(title="Galgame Card API")
@app.get("/groups", dependencies=[API])
async def groups():
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("/groups/discover", dependencies=[API])
async def discover_groups():
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("/groups", dependencies=[API])
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("/groups/{group_id}/enabled", dependencies=[API])
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("/groups/{group_id}/participants", dependencies=[API])
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("/groups/{group_id}/participants", dependencies=[API])
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("/groups/{group_id}/participants/{user_id}", dependencies=[API])
async def remove_participant(group_id: int, user_id: int):
await repository.leave(user_id, group_id)
return _ok(msg="已移除")
@app.get("/groups/{group_id}/logs", dependencies=[API])
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("/groups/{group_id}/impressions", dependencies=[API])
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("/groups/{group_id}/summaries", dependencies=[API])
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("/groups/{group_id}/logs", dependencies=[API])
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("/groups/{group_id}/data", dependencies=[API])
async def clear_group(group_id: int):
await repository.clear_group_data(group_id)
return _ok(msg="已清空该群全部数据")
@app.delete("/groups/{group_id}", dependencies=[API])
async def remove_group(group_id: int):
await repository.remove_group(group_id)
return _ok(msg="已移出管理列表(数据保留,重新加入可续上)")
@app.get("/settings", dependencies=[API])
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,
},
}
)
return app