Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614e1d7e00 | ||
|
|
fc2e542418 | ||
|
|
023dc1cb5d | ||
|
|
574216645c | ||
|
|
ed75a66982 | ||
|
|
4b5e8b8b2e | ||
|
|
814c1994b6 | ||
|
|
4badcfcf32 | ||
|
|
51b08ccb68 | ||
|
|
662c28cb2f | ||
|
|
131b92b319 | ||
|
|
30899688a7 | ||
|
|
8c1a106230 | ||
|
|
2849a6250c |
@@ -78,7 +78,10 @@ bg.jpg
|
|||||||
/.ai/
|
/.ai/
|
||||||
/.claude/
|
/.claude/
|
||||||
/hexi/config/
|
/hexi/config/
|
||||||
/CLAUDE.md
|
|
||||||
|
# ---- 开发文档(插件内 CLAUDE.md / DESIGN.md,含根目录,不入库) ----
|
||||||
|
**/CLAUDE.md
|
||||||
|
**/DESIGN.md
|
||||||
|
|
||||||
# helldivers 图标素材&生成脚本(不入库)
|
# helldivers 图标素材&生成脚本(不入库)
|
||||||
/dev/docs/HD2/
|
/dev/docs/HD2/
|
||||||
|
|||||||
@@ -34,13 +34,13 @@
|
|||||||
|
|
||||||
| 插件 | 功能 | 主要命令 |
|
| 插件 | 功能 | 主要命令 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `nonebot_plugin_hexi_core` | 核心库:消息解析、权限分级、限流器 (token bucket)、命令冷却、热插件管理 | — |
|
| `hexi/core`(核心模块) | 核心库:消息解析、权限分级、限流器 (token bucket)、命令冷却、热插件管理(**非插件**) | — |
|
||||||
| `nonebot_plugin_video_analysis` | 视频链接解析 | — |
|
| `nonebot_plugin_video_analysis` | 视频链接解析 | — |
|
||||||
| `nonebot_plugin_regif` | GIF 注册与处理 | — |
|
| `nonebot_plugin_regif` | GIF 注册与处理 | — |
|
||||||
| `nonebot_plugin_picfinder_take` | 识图(Playwright 驱动) | — |
|
| `nonebot_plugin_picfinder_take` | 识图(Playwright 驱动) | — |
|
||||||
| `nonebot_plugin_picstatus` | 头像状态图生成 | — |
|
| `nonebot_plugin_picstatus` | 头像状态图生成 | — |
|
||||||
| `nonebot_plugin_group_daily_analysis` | 群聊行为分析报告 | `/群分析` |
|
| `nonebot_plugin_group_daily_analysis` | 群聊行为分析报告 | `/群分析` |
|
||||||
| `nonebot_plugin_web_hub` | 统一 Web 管理入口 | — |
|
| `hexi/web_hub`(核心模块) | 统一 Web 管理入口 `/hub`(**非插件**) | — |
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -88,13 +88,15 @@ poetry run python bot.py
|
|||||||
HeXi/
|
HeXi/
|
||||||
├── bot.py pyproject.toml README.md
|
├── bot.py pyproject.toml README.md
|
||||||
├── hexi/
|
├── hexi/
|
||||||
│ ├── plugins/ # 全部插件(nonebot.load_plugins("hexi") 加载)
|
│ ├── plugins/ # 全部插件(pyproject [tool.nonebot] plugin_dirs 加载)
|
||||||
|
│ ├── core/ # 机器人核心模块(非插件):工具库/插件控制面/热拔插管理
|
||||||
|
│ ├── web_hub/ # 机器人核心模块:统一 Web 管理台 + 鉴权/插件注册/配置标准
|
||||||
│ ├── data/ # 运行数据(不入库)
|
│ ├── data/ # 运行数据(不入库)
|
||||||
│ ├── config/ # 业务配置(不入库)
|
│ ├── config/ # 业务配置(不入库)
|
||||||
│ ├── resource/ # 全局静态资源(fonts/ 字体、menu_config 等)
|
│ ├── resource/ # 全局静态资源(fonts/ 字体、menu_config 等)
|
||||||
│ ├── web/ # 统一 Web 前端(React + Vite,构建产物 dist/ 供 /hub 挂载)
|
│ ├── web/ # 统一 Web 前端(React + Vite,构建产物 dist/ 供 /hub 挂载)
|
||||||
│ ├── logs/ # 日志(不入库)
|
│ ├── logs/ # 日志(不入库)
|
||||||
│ └── web_*.py # Web 管理相关(鉴权、插件注册)
|
│ └── web_hub/ # 见上:统一 Web 管理台/鉴权/插件注册/配置标准
|
||||||
├── docs/ # 插件开发标准文档
|
├── docs/ # 插件开发标准文档
|
||||||
├── tests/ # pytest 测试
|
├── tests/ # pytest 测试
|
||||||
└── plugin_template/ # 新插件脚手架模板
|
└── plugin_template/ # 新插件脚手架模板
|
||||||
|
|||||||
@@ -55,10 +55,14 @@ driver = nonebot.get_driver()
|
|||||||
# 注册适配器
|
# 注册适配器
|
||||||
driver.register_adapter(ONEBOTV11Adapter)
|
driver.register_adapter(ONEBOTV11Adapter)
|
||||||
|
|
||||||
# 加载插件
|
# 加载插件(hexi/plugins 下的自定义插件 + pyproject 声明的社区插件)
|
||||||
nonebot.load_plugins("hexi")
|
|
||||||
nonebot.load_from_toml("pyproject.toml")
|
nonebot.load_from_toml("pyproject.toml")
|
||||||
|
|
||||||
|
# 核心模块(已从 hexi/plugins 移出,不再是插件):在插件管理器建立后再导入,
|
||||||
|
# 避免在插件管理器创建前 require 社区插件导致重复注册/“not loaded as a plugin”。
|
||||||
|
import hexi.core # noqa: F401,E402
|
||||||
|
import hexi.web_hub # noqa: F401,E402
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger.warning("hexi?启动!")
|
logger.warning("hexi?启动!")
|
||||||
nonebot.run() # 直接运行,不需要传参
|
nonebot.run() # 直接运行,不需要传参
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
> 依据 《插件结构标准》(docs/plugin-structure-standard.md) 与 《插件配置文件标准》(docs/plugin-config-standard.md),
|
> 依据 《插件结构标准》(docs/plugin-structure-standard.md) 与 《插件配置文件标准》(docs/plugin-config-standard.md),
|
||||||
> 并参照脚手架模板 `../plugin_template/nonebot_plugin_template`。
|
> 并参照脚手架模板 `../plugin_template/nonebot_plugin_template`。
|
||||||
> 说明:初版为**审计报告**;后续按用户确认已开始落地改造(见下方「本轮已落地改动」)。
|
> 说明:初版为**审计报告**;后续按用户确认已开始落地改造(见下方「本轮已落地改动」)。
|
||||||
|
>
|
||||||
|
> **后续变动**:其中 `hexi_core` 与 `web_hub` 已从 `hexi/plugins/` 移出,改为机器人核心模块 `hexi/core`、`hexi/web_hub`(非插件),本报告中这两项的“插件”表述仅供参考。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,12 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
hexi/
|
hexi/
|
||||||
├── web_config.py # 配置标准核心:schema 注册 / 值库 / 保存热刷新
|
├── web_hub/ # 统一 Web 管理台 / 鉴权 / 配置标准
|
||||||
├── config_standard.py # pydantic Config 一键接入的辅助封装
|
│ ├── web_config.py # 配置标准核心:schema 注册 / 值库 / 保存热刷新
|
||||||
|
│ ├── config_standard.py # pydantic Config 一键接入的辅助封装
|
||||||
|
│ ├── web_auth.py # 统一 Web 鉴权(OAuth2 + SQLite)
|
||||||
|
│ ├── web_plugin_registry.py # 统一 Web 插件注册中心
|
||||||
|
│ └── web_hub_auth.py # 兼容 shim
|
||||||
├── config/
|
├── config/
|
||||||
│ └── plugin_config.json # 统一值库:{ "<plugin_id>": { key: value, ... } }
|
│ └── plugin_config.json # 统一值库:{ "<plugin_id>": { key: value, ... } }
|
||||||
└── web/ # /hub 前端(通用 schema 表单渲染器)
|
└── web/ # /hub 前端(通用 schema 表单渲染器)
|
||||||
@@ -143,7 +147,7 @@ hexi/
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# 在插件 __init__.py 里
|
# 在插件 __init__.py 里
|
||||||
from hexi.config_standard import register_model_config
|
from hexi.web_hub.config_standard import register_model_config
|
||||||
from .config import config
|
from .config import config
|
||||||
|
|
||||||
register_model_config(
|
register_model_config(
|
||||||
@@ -168,7 +172,7 @@ register_model_config(
|
|||||||
### 方式 B:非 pydantic 插件手动注册
|
### 方式 B:非 pydantic 插件手动注册
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hexi.web_config import register_plugin_config
|
from hexi.web_hub.web_config import register_plugin_config
|
||||||
|
|
||||||
def _get(): # 返回当前生效值 dict
|
def _get(): # 返回当前生效值 dict
|
||||||
return {"field": get_my_cur_value("field")}
|
return {"field": get_my_cur_value("field")}
|
||||||
@@ -209,7 +213,7 @@ register_plugin_config(
|
|||||||
2. **写值库** `plugin_config.json`(原子写)。
|
2. **写值库** `plugin_config.json`(原子写)。
|
||||||
3. **写 .env**:非 list 字段写入 `os.environ` + `../../.env`(保证重启仍生效);list/path/object 跳过,避免 `str(list)` 破坏重启解析。
|
3. **写 .env**:非 list 字段写入 `os.environ` + `../../.env`(保证重启仍生效);list/path/object 跳过,避免 `str(list)` 破坏重启解析。
|
||||||
4. **调 `apply(values)`**:把值热应用到插件运行态对象(list/path/object 也在此生效)。
|
4. **调 `apply(values)`**:把值热应用到插件运行态对象(list/path/object 也在此生效)。
|
||||||
5. 若插件未提供 apply,则 `hot_reload(plugin_id)` 让插件重载。
|
5. 若插件未提供 apply,则 `hot_reload(plugin_id)` 让插件重载;**library/非 application 插件不受热重载**(见《插件类型分类》,热拔插会破坏依赖它的插件)。
|
||||||
|
|
||||||
**为什么 apply 与 env 都做**:NoneBot 的 `get_driver().config` 在启动时即固定,重载插件也读不到新 env;所以运行期必须 apply,重启靠 env。
|
**为什么 apply 与 env 都做**:NoneBot 的 `get_driver().config` 在启动时即固定,重载插件也读不到新 env;所以运行期必须 apply,重启靠 env。
|
||||||
|
|
||||||
@@ -220,7 +224,7 @@ register_plugin_config(
|
|||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/plugins` | 所有已注册 Web 插件 |
|
| GET | `/api/plugins` | 所有已注册 Web 插件 |
|
||||||
| GET | `/api/plugins/catalog` | 所有应用插件 + `has_config/has_web/web_path` |
|
| GET | `/api/plugins/catalog` | 所有 application/library 插件(含带配置项的 library)+ `type/has_config/has_web/web_path` |
|
||||||
| GET | `/api/plugins/{id}/config` | 读 `{schema, values, revision}`(登录);secret 值只返回 `****` |
|
| GET | `/api/plugins/{id}/config` | 读 `{schema, values, revision}`(登录);secret 值只返回 `****` |
|
||||||
| POST | `/api/plugins/{id}/config` | 保存 `{ "revision": n, "values": { key: value } }`;冲突 409,校验失败 422 |
|
| POST | `/api/plugins/{id}/config` | 保存 `{ "revision": n, "values": { key: value } }`;冲突 409,校验失败 422 |
|
||||||
| DELETE | `/api/plugins/{id}/config` | 清空该插件覆盖,恢复默认(可选) |
|
| DELETE | `/api/plugins/{id}/config` | 清空该插件覆盖,恢复默认(可选) |
|
||||||
@@ -242,7 +246,7 @@ register_plugin_config(
|
|||||||
|
|
||||||
## 9. 权限与安全
|
## 9. 权限与安全
|
||||||
|
|
||||||
- 全部配置接口走 `hexi.web_auth.require_admin`(OAuth2 + SQLite)。
|
- 全部配置接口走 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite)。
|
||||||
- 配置 POST 必须携带 GET 返回的 `revision`;缺失返回 428,冲突返回 409。
|
- 配置 POST 必须携带 GET 返回的 `revision`;缺失返回 428,冲突返回 409。
|
||||||
- 只允许 `plugin_id` 存在于注册表,未注册返回 `ok:false`(防任意写入)。
|
- 只允许 `plugin_id` 存在于注册表,未注册返回 `ok:false`(防任意写入)。
|
||||||
- 部署时必须显式设置 Web 管理员凭据;禁止生产环境使用默认的 `admin/admin`。
|
- 部署时必须显式设置 Web 管理员凭据;禁止生产环境使用默认的 `admin/admin`。
|
||||||
@@ -277,7 +281,7 @@ register_plugin_config(
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py
|
# hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py
|
||||||
from hexi.config_standard import register_model_config
|
from hexi.web_hub.config_standard import register_model_config
|
||||||
from .config import config as _hd2_config
|
from .config import config as _hd2_config
|
||||||
|
|
||||||
register_model_config(
|
register_model_config(
|
||||||
@@ -295,7 +299,7 @@ register_model_config(
|
|||||||
## 12. 已接入与待接入
|
## 12. 已接入与待接入
|
||||||
|
|
||||||
- **已接入**:`helldivers_tools`、`mc_server_status`、`video_analysis`、`steam_info`、`picstatus`、`galgame_card`。
|
- **已接入**:`helldivers_tools`、`mc_server_status`、`video_analysis`、`steam_info`、`picstatus`、`galgame_card`。
|
||||||
- **未接入(建议后续)**:`learning_chat`、`group_daily_analysis`(已有独自 Web 配置,避免冲突)、`picfinder_take`、`bf_bot`、`group_tools`、`hexi_core`(模块级常量/SUPERUSERS,运行期热更复杂)。
|
- **未接入(建议后续)**:`learning_chat`、`group_daily_analysis`(已有独自 Web 配置,避免冲突)、`picfinder_take`、`bf_bot`、`group_tools`、`hexi/core`(模块级常量/SUPERUSERS,运行期热更复杂)。
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -316,7 +320,7 @@ register_model_config(
|
|||||||
### 13.2 通用注册 API(任意来源)
|
### 13.2 通用注册 API(任意来源)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hexi.config_standard import register_config_items
|
from hexi.web_hub.config_standard import register_config_items
|
||||||
|
|
||||||
register_config_items(
|
register_config_items(
|
||||||
__name__, # = NoneBot 插件模块名
|
__name__, # = NoneBot 插件模块名
|
||||||
@@ -339,7 +343,7 @@ register_config_items(
|
|||||||
Web 保存后 apply 会把新值写回来源(模块属性/dict/自定义),但若插件在别处是用 `from .config import X` **值拷贝**进来的量,不受影响。要真正运行期生效,插件在读配置处改用统一 API:
|
Web 保存后 apply 会把新值写回来源(模块属性/dict/自定义),但若插件在别处是用 `from .config import X` **值拷贝**进来的量,不受影响。要真正运行期生效,插件在读配置处改用统一 API:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hexi.web_config import get_effective_value
|
from hexi.web_hub.web_config import get_effective_value
|
||||||
|
|
||||||
limit = get_effective_value("hexi.plugins.nonebot_plugin_picfinder_take", "DAILY_LIMIT", 50)
|
limit = get_effective_value("hexi.plugins.nonebot_plugin_picfinder_take", "DAILY_LIMIT", 50)
|
||||||
if not check_quota(limit): ...
|
if not check_quota(limit): ...
|
||||||
@@ -395,7 +399,7 @@ if not check_quota(limit): ...
|
|||||||
### 14.3 一键注册 API
|
### 14.3 一键注册 API
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hexi.config_standard import register_object_set
|
from hexi.web_hub.config_standard import register_object_set
|
||||||
|
|
||||||
register_object_set(
|
register_object_set(
|
||||||
__name__, # NoneBot 插件模块名
|
__name__, # NoneBot 插件模块名
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ nonebot_plugin_xxx/
|
|||||||
## 4. `__init__.py` 标准职责(固定顺序)
|
## 4. `__init__.py` 标准职责(固定顺序)
|
||||||
|
|
||||||
1. `__plugin_meta__ = PluginMetadata(name=..., description=..., usage=..., type="application")`。
|
1. `__plugin_meta__ = PluginMetadata(name=..., description=..., usage=..., type="application")`。
|
||||||
|
> **type 取值(NoneBot 发布规范)**:`application`(向机器人用户提供功能,支持热插拔/热重载)或 `library`(为其他插件提供能力,不可热拔插)。新插件默认 `application`。本仓库的 `hexi/core`、`hexi/web_hub` 已从插件目录移出,作为机器人核心模块(**非插件**),直接不参与热拔插。
|
||||||
2. 声明依赖 `require("nonebot_plugin_alconna")` 等。
|
2. 声明依赖 `require("nonebot_plugin_alconna")` 等。
|
||||||
3. 显式导入子模块(`from . import handlers, services, utils`),注册 matcher/handler。
|
3. 显式导入子模块(`from . import handlers, services, utils`),注册 matcher/handler。
|
||||||
4. **配置注册**(在 config 就绪后):`register_model_config`(pydantic Config)或 `register_config_items` / `register_object_set`(来源无关)。
|
4. **配置注册**(在 config 就绪后):`register_model_config`(pydantic Config)或 `register_config_items` / `register_object_set`(来源无关)。
|
||||||
@@ -108,7 +109,7 @@ config = get_plugin_config(Config)
|
|||||||
|
|
||||||
### 4.2 来源无关型(模块常量 / 配置文件 / DB)
|
### 4.2 来源无关型(模块常量 / 配置文件 / DB)
|
||||||
```python
|
```python
|
||||||
from hexi.config_standard import register_config_items, register_object_set
|
from hexi.web_hub.config_standard import register_config_items, register_object_set
|
||||||
register_object_set(__name__, "groups", [ {item_schema...} ], key_field="group_id",
|
register_object_set(__name__, "groups", [ {item_schema...} ], key_field="group_id",
|
||||||
getter=get_groups, setter=set_groups, nosave=True)
|
getter=get_groups, setter=set_groups, nosave=True)
|
||||||
```
|
```
|
||||||
@@ -133,20 +134,20 @@ register_object_set(__name__, "groups", [ {item_schema...} ], key_field="group_i
|
|||||||
- `services/`:业务编排,可依赖 repository / 外部 API;把「一个业务动作」收敛到一个方法。
|
- `services/`:业务编排,可依赖 repository / 外部 API;把「一个业务动作」收敛到一个方法。
|
||||||
- **禁止** `from x import *`、`except: pass`、`print()`;用 `logger`。
|
- **禁止** `from x import *`、`except: pass`、`print()`;用 `logger`。
|
||||||
- async handler 内避免同步阻塞(`requests` / `time.sleep` / 同步爬虫);需要就 `asyncio.to_thread`。
|
- async handler 内避免同步阻塞(`requests` / `time.sleep` / 同步爬虫);需要就 `asyncio.to_thread`。
|
||||||
- 命令触发:需要 @ 用 `rule=to_me()`;全局命令注意与其它插件冲突;高开销命令加冷却/限频(`hexi_core` 的 `cooldown/rate_limit`)。
|
- 命令触发:需要 @ 用 `rule=to_me()`;全局命令注意与其它插件冲突;高开销命令加冷却/限频(`hexi/core` 的 `cooldown/rate_limit`)。
|
||||||
- 外部 API:统一超时 + 重试 + 失败降级/用户提示。
|
- 外部 API:统一超时 + 重试 + 失败降级/用户提示。
|
||||||
|
|
||||||
## 8. Web 接入标准
|
## 8. Web 接入标准
|
||||||
|
|
||||||
- 需要独立 Web 页:`register_web_plugin(id, name, icon, lambda: build_app(), module_name=__name__)`,hub 启动自动挂载 `/api/<id>`。
|
- 需要独立 Web 页:`register_web_plugin(id, name, icon, lambda: build_app(), module_name=__name__)`,hub 启动自动挂载 `/api/<id>`。
|
||||||
- 需要 Web 配置:`register_plugin_config` / `register_model_config` / `register_config_items` / `register_object_set`(schema 驱动表单)。
|
- 需要 Web 配置:`register_plugin_config` / `register_model_config` / `register_config_items` / `register_object_set`(schema 驱动表单)。
|
||||||
- 鉴权统一用 `hexi.web_auth.require_admin`(OAuth2 + SQLite),不要自造一套。
|
- 鉴权统一用 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite),不要自造一套。
|
||||||
- 敏感字段 `secret=True`,前端掩码;写回允许明文。
|
- 敏感字段 `secret=True`,前端掩码;写回允许明文。
|
||||||
|
|
||||||
## 9. 插件迁移检查清单
|
## 9. 插件迁移检查清单
|
||||||
|
|
||||||
- [ ] 包名改为 `nonebot_plugin_*`,位置在 `../../hexi/plugins`。
|
- [ ] 包名改为 `nonebot_plugin_*`,位置在 `../../hexi/plugins`。
|
||||||
- [ ] 有 `__plugin_meta__`,`type="application"`。
|
- [ ] 有 `__plugin_meta__`,`type="application"`(核心/框架插件标 `library`,不参与热拔插)。
|
||||||
- [ ] 配置已接入统一标准(Web 能读能改,运行期/重启生效)。
|
- [ ] 配置已接入统一标准(Web 能读能改,运行期/重启生效)。
|
||||||
- [ ] 命令在 `handlers/`,业务在 `services/`,工具在 `utils/`。
|
- [ ] 命令在 `handlers/`,业务在 `services/`,工具在 `utils/`。
|
||||||
- [ ] 数据访问集中(repository / services),原子写 / 事务。
|
- [ ] 数据访问集中(repository / services),原子写 / 事务。
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# HeXi 插件类型分类(library / application)
|
||||||
|
|
||||||
|
> 依据 NoneBot 发布规范:`type` 是插件类别,发布必填。当前有效类别:
|
||||||
|
> - `library`:为其他插件编写提供功能(核心库/框架),**不参与热拔插/热重载**。
|
||||||
|
> - `application`:向机器人用户提供功能,**可热拔插/热重载**。
|
||||||
|
>
|
||||||
|
> 本仓库自定义插件统一在 `hexi/plugins/`(经 `nonebot.load_plugins("hexi")` 加载);
|
||||||
|
> pip 社区插件由其自带元数据决定类别,本仓库不改动,但热拔插管理器会按类别保护。
|
||||||
|
>
|
||||||
|
> **变动**:原 `nonebot_plugin_hexi_core` / `nonebot_plugin_web_hub` 已从 `hexi/plugins/` 移出,现为机器人核心模块 `hexi/core`、`hexi/web_hub`(**非插件**),不再参与插件类型分类与热拔插,故下表不列出。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自定义插件(`hexi/plugins/`)
|
||||||
|
|
||||||
|
| 插件(模块名) | type | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `hexi.plugins.nonebot_plugin_bf_bot` | `application` | 战地系列战绩查询(BF3/4/1/5/2042/6)。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_steam_info` | `application` | Steam 信息播报/查询。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_mc_server_status` | `application` | Minecraft 服务器状态查询。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_ncm_saying` | `application` | 网易云热评。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_helldivers_tools` | `application` | 绝地潜兵 2 前线战况/战备。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_learning_chat` | `application` | 马尔可夫链群聊学习/复读。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_galgame_card` | `application` | 群聊人设卡生成/展示。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_group_tools` | `application` | 群管理工具集。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_group_daily_analysis` | `application` | 群聊行为分析报告。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_dailywife` | `application` | 每日随机抽取群友。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_deer_pipe` | `application` | 每日打卡(鹿)。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_dice` | `application` | 掷骰子/结婚判定。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_makeaquote` | `application` | 名人/语录图生成。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_random_jm_code` | `application` | 随机 JM 码。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_video_analysis` | `application` | 视频链接解析。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_regif` | `application` | GIF 倒放/处理。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_picfinder_take` | `application` | 二次元搜图。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_picstatus` | `application` | 设备状态图。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_huoziyinshua` | `application` | otto 活字印刷(语音合成)。 |
|
||||||
|
| `hexi.plugins.memes_ops` | `application` | 给 `nonebot_plugin_memes` 追加裸词选项(用户侧语法)。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_deadlock` | - | **停用**:整文件被注释,不注册任何功能(历史遗留)。 |
|
||||||
|
| `hexi.plugins.nonebot_plugin_brash_general_supercredits_tools` | - | **空壳**:未完成占位,`__init__.py` 仅注释,无功能。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## pip 社区插件(`pyproject.toml` 声明)
|
||||||
|
|
||||||
|
这些插件由安装包自带的 `__plugin_meta__` 决定类别,本仓库不改动;其中的 library 型插件同样受热拔插保护。
|
||||||
|
|
||||||
|
| 插件 | 常见类别 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `nonebot_plugin_apscheduler` | library | 定时任务框架。 |
|
||||||
|
| `nonebot_plugin_alconna` | library | 命令解析/消息框架。 |
|
||||||
|
| `nonebot_plugin_saa` | library | 跨平台发送辅助。 |
|
||||||
|
| `nonebot_plugin_session` | library | 会话/状态管理。 |
|
||||||
|
| `nonebot_plugin_userinfo` | library | 用户信息接口。 |
|
||||||
|
| `nonebot_plugin_user` | application | 用户数据接口(含绑定/查看命令)。 |
|
||||||
|
| `nonebot_plugin_datastore` | library | 数据存储底座。 |
|
||||||
|
| `nonebot_plugin_orm` | library | ORM 底座。 |
|
||||||
|
| `nonebot_plugin_fix_qq_img_ssl` | library | 图片 SSL 修复补丁。 |
|
||||||
|
| `nonebot_plugin_wordcloud` | application | 词云生成。 |
|
||||||
|
| `nonebot_plugin_memes` | application | 表情包。 |
|
||||||
|
| `nonebot_plugin_multincm` | application | 多源网抑云点歌。 |
|
||||||
|
| `nonebot_plugin_random_stereotypes` | application | 随机刻板印象。 |
|
||||||
|
| `nonebot_plugin_rollpig` | application | 滚猪。 |
|
||||||
|
|
||||||
|
> 说明:社区插件类别以其安装包 `PluginMetadata.type` 为准,若与本表不符请以包内元数据为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 热拔插保护
|
||||||
|
|
||||||
|
`../../hexi/core/plugin_manager.py` 中的 `hot_load / hot_unload / hot_reload` 会先判断目标插件类别:
|
||||||
|
|
||||||
|
- 目标为 `application` → 正常热拔插。
|
||||||
|
- 其余(`library` / 未声明类别,含根包无 meta 但被依赖的核心/框架,如 alconna)→ **拒绝**并记录 warning(热拔插会使依赖它的插件运行混乱)。
|
||||||
|
|
||||||
|
`hexi/web_hub/web_config.py` 保存配置触发的 `hot_reload` 同样受此保护:非 application 插件若未提供 `apply` 回调,将不会执行热重载。
|
||||||
@@ -31,8 +31,8 @@ nonebot_plugin_template/
|
|||||||
|
|
||||||
## Web 子应用
|
## Web 子应用
|
||||||
- `web/admin.py::build_admin_app()` 返回一个 FastAPI 实例,经 `register_web_plugin` 由 hub 挂载到 `/api/template`。
|
- `web/admin.py::build_admin_app()` 返回一个 FastAPI 实例,经 `register_web_plugin` 由 hub 挂载到 `/api/template`。
|
||||||
- 鉴权统一 `hexi.web_auth.require_admin`;示例端点:`GET /ping`、`GET /config`(读插件配置)。
|
- 鉴权统一 `hexi.web_hub.web_auth.require_admin`;示例端点:`GET /ping`、`GET /config`(读插件配置)。
|
||||||
- 想要更丰富的管理页:前端放 `web/dist/`,在 admin.py 里挂 `StaticFiles` + SPA 兜底即可(参考 `nonebot_plugin_web_hub`)。
|
- 想要更丰富的管理页:前端放 `web/dist/`,在 admin.py 里挂 `StaticFiles` + SPA 兜底即可(参考 `hexi/web_hub`)。
|
||||||
|
|
||||||
## 约定
|
## 约定
|
||||||
- `plugin_id` = 模块名(自动取 `__package__`;web 子应用用 `__package__.rsplit(".", 1)[0]`)。
|
- `plugin_id` = 模块名(自动取 `__package__`;web 子应用用 `__package__.rsplit(".", 1)[0]`)。
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from . import config as _config # noqa: E402,F401
|
|||||||
from . import handlers as _handlers # noqa: E402,F401
|
from . import handlers as _handlers # noqa: E402,F401
|
||||||
from . import web as _web # noqa: E402,F401
|
from . import web as _web # noqa: E402,F401
|
||||||
|
|
||||||
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
|
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
|
||||||
|
|
||||||
_config.register_config()
|
_config.register_config()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
from nonebot import get_plugin_config
|
from nonebot import get_plugin_config
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from hexi.config_standard import register_config_items, register_model_config, register_object_set
|
from hexi.web_hub.config_standard import register_config_items, register_model_config, register_object_set
|
||||||
|
|
||||||
# plugin_id = NoneBot 插件模块名
|
# plugin_id = NoneBot 插件模块名
|
||||||
_PLUGIN_ID = __package__
|
_PLUGIN_ID = __package__
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from nonebot.rule import to_me
|
|||||||
from nonebot.params import CommandArg
|
from nonebot.params import CommandArg
|
||||||
from nonebot_plugin_alconna import UniMessage
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
from hexi.web_config import get_effective_value
|
from hexi.web_hub.web_config import get_effective_value
|
||||||
|
|
||||||
from ..services.main import do_something
|
from ..services.main import do_something
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from hexi.web_config import get_effective_value
|
from hexi.web_hub.web_config import get_effective_value
|
||||||
|
|
||||||
from ..repository import get_record, save_record
|
from ..repository import get_record, save_record
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
- 由 `__init__.py` 里 `register_web_plugin("template", ..., module_name=__name__)` 注册,
|
- 由 `__init__.py` 里 `register_web_plugin("template", ..., module_name=__name__)` 注册,
|
||||||
hub 启动时自动挂载到 `/api/template`。
|
hub 启动时自动挂载到 `/api/template`。
|
||||||
- 鉴权统一走 `hexi.web_auth.require_admin`(OAuth2 + SQLite);注意它本身是 FastAPI 的
|
- 鉴权统一走 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite);注意它本身是 FastAPI 的
|
||||||
`Depends` 实例,直接用 `_: dict = API` 注入,不要再包一层 `Depends(...)`。
|
`Depends` 实例,直接用 `_: dict = API` 注入,不要再包一层 `Depends(...)`。
|
||||||
- 可直接读写 `hexi.web_config` 的插件配置。
|
- 可直接读写 `hexi.web_hub.web_config` 的插件配置。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,7 +13,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from hexi import web_config
|
from hexi import web_config
|
||||||
from hexi.web_auth import require_admin as API
|
from hexi.web_hub.web_auth import require_admin as API
|
||||||
|
|
||||||
# 插件模块名(去掉 .web):= NoneBot 模块名 = plugin_id
|
# 插件模块名(去掉 .web):= NoneBot 模块名 = plugin_id
|
||||||
_PLUGIN_ID = __package__.rsplit(".", 1)[0]
|
_PLUGIN_ID = __package__.rsplit(".", 1)[0]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# 机器人核心目录
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
from nonebot import get_driver, logger
|
from nonebot import get_driver, logger
|
||||||
from nonebot.plugin import PluginMetadata
|
|
||||||
|
|
||||||
__plugin_meta__ = PluginMetadata(
|
# 显式导入子模块注册 matcher/工具(hexi/core 为机器人核心模块,非 NoneBot 插件)
|
||||||
name="HeXi 核心工具",
|
|
||||||
description="HeXi 公共工具库:权限分级、消息解析、限频器、命令冷却、插件热插拔等",
|
|
||||||
usage="工具库 + 帮助/插件管理命令(帮助、插件列表、加载/卸载/重载插件)",
|
|
||||||
type="application",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 显式导入子模块注册 matcher(load_plugins 只加载到插件包层,不会自动导入包内模块)
|
|
||||||
from . import ( # noqa: E402,F401
|
from . import ( # noqa: E402,F401
|
||||||
cooldown,
|
cooldown,
|
||||||
custom_utils,
|
custom_utils,
|
||||||
message_utils,
|
message_utils,
|
||||||
|
outbound_media,
|
||||||
plugin_control,
|
plugin_control,
|
||||||
plugin_manager,
|
plugin_manager,
|
||||||
rate_limit,
|
rate_limit,
|
||||||
@@ -24,3 +17,9 @@ async def _startup_plugin_gate() -> None:
|
|||||||
"""等所有插件 matcher 注册完成后,把统一 filter 规则注入到 application 插件。"""
|
"""等所有插件 matcher 注册完成后,把统一 filter 规则注入到 application 插件。"""
|
||||||
n = plugin_control.instrument_plugin_gate()
|
n = plugin_control.instrument_plugin_gate()
|
||||||
logger.info(f"插件控制面: 已注入 {n} 条 gateway 规则")
|
logger.info(f"插件控制面: 已注入 {n} 条 gateway 规则")
|
||||||
|
|
||||||
|
|
||||||
|
@get_driver().on_startup
|
||||||
|
async def _startup_outbound_media() -> None:
|
||||||
|
"""挂载本机媒体服务:协议端据此回拉 bot 生成的本地文件(内部已处理降级)。"""
|
||||||
|
outbound_media.mount_media_endpoint()
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
- "global":所有人共享一个 CD
|
- "global":所有人共享一个 CD
|
||||||
|
|
||||||
用法一(装饰器,包在 on_command 外面):
|
用法一(装饰器,包在 on_command 外面):
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown
|
from hexi.core.cooldown import cooldown
|
||||||
|
|
||||||
# 单用户 10s CD,群聊/私聊共用
|
# 单用户 10s CD,群聊/私聊共用
|
||||||
bf_cmd = cooldown(10, hint="查询太频繁了,{secs} 秒后再试")(on_command("bf3"))
|
bf_cmd = cooldown(10, hint="查询太频繁了,{secs} 秒后再试")(on_command("bf3"))
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
...
|
...
|
||||||
|
|
||||||
用法二(手动查询/触发,自定义提示文本):
|
用法二(手动查询/触发,自定义提示文本):
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core.cooldown import Cooldown
|
from hexi.core.cooldown import Cooldown
|
||||||
|
|
||||||
cd = Cooldown(10)
|
cd = Cooldown(10)
|
||||||
if cd.in_cd(ev.get_user_id()):
|
if cd.in_cd(ev.get_user_id()):
|
||||||
@@ -36,7 +36,7 @@ from nonebot.adapters import Event
|
|||||||
from nonebot.dependencies import Dependent
|
from nonebot.dependencies import Dependent
|
||||||
from nonebot.matcher import Matcher
|
from nonebot.matcher import Matcher
|
||||||
|
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core.rate_limit import RateLimiter, get_limiter
|
from hexi.core.rate_limit import RateLimiter, get_limiter
|
||||||
|
|
||||||
# 无 name 时的实例序号,保证同一进程内各实例的注册表 key 互不冲突
|
# 无 name 时的实例序号,保证同一进程内各实例的注册表 key 互不冲突
|
||||||
_cd_seq = itertools.count(1)
|
_cd_seq = itertools.count(1)
|
||||||
@@ -134,7 +134,7 @@ if hexi.is_superuser(event): # 超级用户
|
|||||||
# 架构设计
|
# 架构设计
|
||||||
|
|
||||||
```
|
```
|
||||||
hexi/plugins/nonebot_plugin_hexi_core/
|
hexi/core/
|
||||||
├── __init__.py # 插件入口,导出 hexi 对象
|
├── __init__.py # 插件入口,导出 hexi 对象
|
||||||
├── poke.py # 戳一戳
|
├── poke.py # 戳一戳
|
||||||
├── forward.py # 合并转发消息构建器
|
├── forward.py # 合并转发消息构建器
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from nonebot import get_bot
|
from nonebot import get_bot
|
||||||
from nonebot import require
|
from nonebot import require
|
||||||
|
from nonebot.adapters import Event
|
||||||
from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment
|
from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment
|
||||||
from nonebot.log import logger
|
from nonebot.log import logger
|
||||||
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
from nonebot_plugin_alconna.uniseg import Receipt, Target
|
||||||
|
|
||||||
require("nonebot_plugin_htmlrender")
|
require("nonebot_plugin_htmlrender")
|
||||||
from nonebot_plugin_htmlrender import md_to_pic
|
from nonebot_plugin_htmlrender import md_to_pic
|
||||||
|
|
||||||
|
# 「处理中」占位动图(任务驱动通用回复用)
|
||||||
|
THINKING_GIF = Path(__file__).parents[1] / "resource" / "imgs" / "thinking.gif"
|
||||||
|
|
||||||
|
|
||||||
async def send_markdown(
|
async def send_markdown(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
event: MessageEvent,
|
event: MessageEvent,
|
||||||
markdown: str,
|
markdown: str,
|
||||||
fallback_text: str | None = None,
|
fallback_text: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""发送 Markdown
|
"""发送 Markdown
|
||||||
|
|
||||||
@@ -62,9 +70,9 @@ def get_reply_message(event: MessageEvent) -> Message:
|
|||||||
|
|
||||||
|
|
||||||
def build_forward_nodes(
|
def build_forward_nodes(
|
||||||
self_id: int,
|
self_id: int,
|
||||||
entries: list[tuple[str, str]],
|
entries: list[tuple[str, str]],
|
||||||
sender_name: str | None = None,
|
sender_name: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""构造合并转发节点列表
|
"""构造合并转发节点列表
|
||||||
|
|
||||||
@@ -86,11 +94,11 @@ def build_forward_nodes(
|
|||||||
|
|
||||||
|
|
||||||
async def send_forward_msg(
|
async def send_forward_msg(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
event: MessageEvent,
|
event: MessageEvent,
|
||||||
entries: list[tuple[str, str]],
|
entries: list[tuple[str, str]],
|
||||||
fallback: str,
|
fallback: str,
|
||||||
sender_name: str | None = None,
|
sender_name: str | None = None,
|
||||||
):
|
):
|
||||||
"""群聊发送合并转发消息,私聊或发送失败时回退纯文本
|
"""群聊发送合并转发消息,私聊或发送失败时回退纯文本
|
||||||
|
|
||||||
@@ -108,3 +116,64 @@ async def send_forward_msg(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"合并转发发送失败,回退文本: {e}")
|
logger.warning(f"合并转发发送失败,回退文本: {e}")
|
||||||
await bot.send(event, fallback)
|
await bot.send(event, fallback)
|
||||||
|
|
||||||
|
|
||||||
|
async def common_proc_reply(
|
||||||
|
message_id: str | None = None,
|
||||||
|
*,
|
||||||
|
target: Event | Target | None = None,
|
||||||
|
image: str | Path = THINKING_GIF,
|
||||||
|
text: str | None = None,
|
||||||
|
) -> Receipt | None:
|
||||||
|
"""任务驱动通用回复:引用指定消息并回一张「处理中」占位图(UniMessage 版)
|
||||||
|
|
||||||
|
引用哪条消息 / 发到哪:
|
||||||
|
- 事件处理中(有上下文):不传参数即引用当前触发消息,发回当前会话;
|
||||||
|
- 传 message_id 则引用指定消息(仍需事件上下文或用 target 指定会话);
|
||||||
|
- 后台任务(无事件上下文):必须显式传 target(Event 或 alconna Target),
|
||||||
|
如 `Target("872490448")`(群)/`Target("2931589710", private=True)`(私聊);
|
||||||
|
此时不传 message_id 即为不带引用的普通发送。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: 要引用的消息 id,默认当前事件消息
|
||||||
|
target: 发送目标(Event/Target);仅后台任务需要
|
||||||
|
image: 占位图路径(或 http(s) 链接),默认 hexi/resource/imgs/thinking.gif
|
||||||
|
text: 附加文本,为空只发图
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
alconna Receipt: 可 `.recall()` 撤回占位图、`.msg_ids` 取消息 id
|
||||||
|
(是否支持编辑见 `.editable`);发送失败返回 None
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build() -> UniMessage:
|
||||||
|
# 每次重建:alconna 的 reply_to 会把引用段插入消息自身,降级重发需干净副本
|
||||||
|
# 本地文件统一交给 hexi.core.outbound_media 的 call_api 钩子改写
|
||||||
|
# (URL 优先/base64 兜底),这里不再自己转 base64——占位图本就是发图,
|
||||||
|
# 走同一套策略才不会出现「一处能发一处不能发」。关掉 HEXI_INLINE_MEDIA
|
||||||
|
# 的语义是「协议端就在本机」,此时 file:// 同样是有效的。
|
||||||
|
if isinstance(image, str) and image.startswith(("http://", "https://")):
|
||||||
|
img = UniMessage.image(url=image)
|
||||||
|
else:
|
||||||
|
img = UniMessage.image(path=Path(image))
|
||||||
|
return UniMessage.text(f"{text} ") + img if text else img
|
||||||
|
|
||||||
|
if message_id:
|
||||||
|
reply_to: str | bool = message_id
|
||||||
|
elif target is None:
|
||||||
|
reply_to = True # 从当前事件上下文取被引用消息
|
||||||
|
else:
|
||||||
|
reply_to = False # 后台任务且未指定消息 id → 普通发送
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await build().send(target=target, reply_to=reply_to)
|
||||||
|
except Exception as e:
|
||||||
|
if not reply_to:
|
||||||
|
logger.error(f"占位图发送失败: {e}")
|
||||||
|
return None
|
||||||
|
logger.warning(f"占位回复引用发送失败,降级为不带引用: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await build().send(target=target)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"占位图发送失败: {e}")
|
||||||
|
return None
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
"""出站媒体内联化:把本地文件改写为协议端可达的 URL(或 base64 兜底)。
|
||||||
|
|
||||||
|
生产环境的 OneBot 协议端不在本机:给 API 传本地路径时,NoneBot 的 `f2s()`
|
||||||
|
会把 `Path` 转成 `file:///D:/...`(`str` 则原样透传)——而 `file://` 的语义是
|
||||||
|
「**协议端那台机器上的路径**」,协议端 stat 必然 ENOENT。受影响的不止本仓插件,
|
||||||
|
还有 pip 装的社区插件(如 nonebot_plugin_doroending),它们的代码改不了,
|
||||||
|
只能全局拦截。
|
||||||
|
|
||||||
|
本模块挂在 `Bot.on_calling_api`(NoneBot 公开钩子,`call_api` 的必经之路,
|
||||||
|
`bot.send` / alconna `finish` / 直接 `send_group_msg` 全从这里过)上,
|
||||||
|
在真正调用适配器前改写发送类 API 里的本地文件:
|
||||||
|
|
||||||
|
1. **默认改写为 `http://<本机可达IP>:<端口>/media/<token>`**,由协议端主动来拉。
|
||||||
|
地址取自 `driver.config` 的 host/port + 本机网卡,与 /hub 首页「协议端配对」
|
||||||
|
展示的是同一来源;反向 WS 部署下协议端本就连得到这个地址(它连的就是它),
|
||||||
|
所以不需要任何新配置。体积不受 base64 膨胀影响,视频/多媒体转发尤其受益。
|
||||||
|
2. **拿不到可达地址时回退 `base64://`**:字节由 bot 直接携带,不依赖协议端
|
||||||
|
反过来拨通本机的任何假设,跨 NAT/隧道也成立。
|
||||||
|
|
||||||
|
不改写 `http(s)://` / `base64://` / 不存在的路径——收到的图片 id(如 `{abc}.jpg`)
|
||||||
|
和协议端自己的缓存路径都靠这条自然放行。任何异常只告警不抛:
|
||||||
|
拦截层绝不能反过来把发送搞挂。
|
||||||
|
|
||||||
|
两类 API 的处理方式不同:
|
||||||
|
|
||||||
|
- **消息发送**(`send_*_msg` / `send_*_forward_msg`):媒体在消息段里,递归遍历
|
||||||
|
后按上面两级改写(URL 优先,base64 兜底)。
|
||||||
|
- **文件上传**(`upload_group_file` / `upload_private_file`):`file` 在顶层,
|
||||||
|
且**只做 URL 不做 base64**——文件体积不适合内联。覆盖这条让
|
||||||
|
`video_analysis` 的群文件「本地直传」兜底和 alconna 的 `$onebot11:file`
|
||||||
|
一起受益(后者传的是 `Path.as_posix()` 裸路径)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from base64 import b64encode
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import url2pathname
|
||||||
|
|
||||||
|
from nonebot.adapters import Bot as BaseBot
|
||||||
|
|
||||||
|
# 用 nonebot.log 而非 `from nonebot import logger`:与 rate_limit 保持一致,
|
||||||
|
# 模块级 import 保持干净,测试才能 importlib 按路径裸加载(不经包 __init__)。
|
||||||
|
from nonebot.log import logger
|
||||||
|
|
||||||
|
try: # NoneBot 内部路径:V11/V12 的 MessageSegment 都继承自它
|
||||||
|
from nonebot.internal.adapter.message import MessageSegment as BaseMessageSegment
|
||||||
|
except Exception: # pragma: no cover - 内部路径变更时的兜底,见 _as_segment
|
||||||
|
BaseMessageSegment = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
# 消息段里代表「本地/远端媒体文件」的 type(file 字段语义)
|
||||||
|
MEDIA_SEG_TYPES = frozenset({"image", "record", "video"})
|
||||||
|
|
||||||
|
# OneBot V11 发送类 API(与 picstatus misc_statistics 的清单一致,另加合并转发)。
|
||||||
|
_SEND_APIS = frozenset(
|
||||||
|
{
|
||||||
|
"send_private_msg",
|
||||||
|
"send_group_msg",
|
||||||
|
"send_msg",
|
||||||
|
"send_private_forward_msg",
|
||||||
|
"send_group_forward_msg",
|
||||||
|
"send_forward_msg",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 文件上传类 API:file 字段在**顶层**而不是消息段里,所以走单独一小段逻辑,
|
||||||
|
# 且**只做 URL、不做 base64 兜底**——文件动辄几十上百 MB,内联会撑爆内存和
|
||||||
|
# WS 帧,OneBot 的文件上传语义本来也不是为内联设计的。
|
||||||
|
_FILE_UPLOAD_APIS = frozenset({"upload_group_file", "upload_private_file"})
|
||||||
|
|
||||||
|
# 只有 OneBot 的 file 字段认 file:// / base64:// 语义;bot.py 当前只注册 V11
|
||||||
|
# (pyproject.toml 虽列了 V12,但代码里没装适配器)。
|
||||||
|
_ONEBOT_NAMES = frozenset({"OneBot V11"})
|
||||||
|
|
||||||
|
# 通配绑定:值本身不可拨号,需要换成具体网卡地址
|
||||||
|
_WILDCARD_HOSTS = frozenset({"", "0.0.0.0", "::", "*"})
|
||||||
|
|
||||||
|
_MAX_DEPTH = 8
|
||||||
|
_MAX_TOKENS = 256
|
||||||
|
_DEFAULT_TTL = 300.0
|
||||||
|
_DEFAULT_MAX_MB = 4.0
|
||||||
|
|
||||||
|
# token -> (文件路径, 过期时刻 monotonic)
|
||||||
|
_tokens: dict[str, tuple[Path, float]] = {}
|
||||||
|
|
||||||
|
# 媒体服务是否真的挂上了:没挂上就不能发 URL(否则协议端必然 404)
|
||||||
|
_mounted = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 环境开关
|
||||||
|
|
||||||
|
|
||||||
|
def _env_flag(name: str, default: bool) -> bool:
|
||||||
|
raw = os.environ.get(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
return raw.strip().lower() not in {"", "0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled() -> bool:
|
||||||
|
"""总开关(默认开)。关闭的语义是「协议端就在本机」,此时 file:// 是有效的。"""
|
||||||
|
return _env_flag("HEXI_INLINE_MEDIA", True)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_enabled() -> bool:
|
||||||
|
"""URL 优先模式(默认开);关掉则一律走 base64。"""
|
||||||
|
return _env_flag("HEXI_INLINE_MEDIA_URL", True)
|
||||||
|
|
||||||
|
|
||||||
|
def _ttl() -> float:
|
||||||
|
try:
|
||||||
|
return float(os.environ.get("HEXI_MEDIA_URL_TTL", "") or _DEFAULT_TTL)
|
||||||
|
except ValueError:
|
||||||
|
return _DEFAULT_TTL
|
||||||
|
|
||||||
|
|
||||||
|
def _max_inline_mb() -> float:
|
||||||
|
try:
|
||||||
|
return float(os.environ.get("HEXI_INLINE_MEDIA_MAX_MB", "") or _DEFAULT_MAX_MB)
|
||||||
|
except ValueError:
|
||||||
|
return _DEFAULT_MAX_MB
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 路径解析
|
||||||
|
|
||||||
|
|
||||||
|
def local_file_of(value: Any) -> Path | None:
|
||||||
|
"""把 file 字段值解析成本机真实存在的文件路径;不是本地文件则 None。
|
||||||
|
|
||||||
|
- `http(s)://` / `base64://` → None(原样放行)
|
||||||
|
- `file://` → `url2pathname` 还原
|
||||||
|
- 其余按原始路径候选(`f2s()` 对 `str` 是原样透传的)
|
||||||
|
- 不存在 / 非法 → None(收到的图片 id、协议端缓存路径靠这条放行)
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
if value.startswith(("http://", "https://", "base64://")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates: list[str] = []
|
||||||
|
if value.startswith("file://"):
|
||||||
|
# 关键陷阱:不能直接 unquote(urlparse(uri).path)——Windows 上会得到
|
||||||
|
# 前导斜杠的 `/D:/...`,is_file() 恒为 False 而**静默不转换**,
|
||||||
|
# 只在跨机时才暴露。url2pathname 才能正确还原成 `D:\\...`。
|
||||||
|
try:
|
||||||
|
uri_path = urlparse(value).path
|
||||||
|
candidates.append(url2pathname(uri_path))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
# 有人手工拼 file:// 且不做百分号转义(video_analysis._file_uri 即如此:
|
||||||
|
# `"file:///" + path.replace("\\", "/")`)。对已经未转义的串再 unquote,
|
||||||
|
# 文件名含字面 %XX 时会认错,所以把原始路径也列为候选兜底。
|
||||||
|
if len(uri_path) > 2 and uri_path[0] == "/" and uri_path[2] == ":":
|
||||||
|
candidates.append(uri_path[1:]) # /D:/x → D:/x
|
||||||
|
else:
|
||||||
|
# 裸路径:f2s() 对 str 原样透传;alconna 的 $onebot11:file 走 as_posix()
|
||||||
|
candidates.append(value)
|
||||||
|
|
||||||
|
for raw in candidates:
|
||||||
|
try:
|
||||||
|
path = Path(raw)
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 媒体服务
|
||||||
|
|
||||||
|
|
||||||
|
def _is_private(ip: str) -> bool:
|
||||||
|
parts = ip.split(".")
|
||||||
|
if len(parts) != 4:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
a, b = int(parts[0]), int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_ips() -> list[str]:
|
||||||
|
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。
|
||||||
|
|
||||||
|
与 hexi/web_hub/dashboard.py 的 `_local_ips` 同源逻辑(那边供 /hub 首页
|
||||||
|
「协议端配对」展示,即用户已经验证过能用的地址);此处刻意不 import,
|
||||||
|
避免 hexi.core → hexi.web_hub 的反向依赖。
|
||||||
|
"""
|
||||||
|
ips: list[str] = []
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
for addrs in psutil.net_if_addrs().values():
|
||||||
|
for addr in addrs:
|
||||||
|
if addr.family == socket.AF_INET:
|
||||||
|
ips.append(str(addr.address or ""))
|
||||||
|
except Exception: # noqa: BLE001 - psutil 缺失/异常时退回 getaddrinfo
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not ips:
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
|
||||||
|
ips.extend(info[4][0] for info in infos)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
for ip in ips:
|
||||||
|
ip = ip.strip()
|
||||||
|
if not ip or ":" in ip or ip.count(".") < 3:
|
||||||
|
continue
|
||||||
|
# 环回与链路本地(169.254.x)协议端访问不到,排除
|
||||||
|
if ip.startswith(("127.", "169.254.")):
|
||||||
|
continue
|
||||||
|
if ip not in out:
|
||||||
|
out.append(ip)
|
||||||
|
out.sort(key=lambda ip: (not _is_private(ip), ip))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _listen_addr() -> tuple[str, int] | None:
|
||||||
|
"""本机媒体服务的对外可达地址(host, port)。"""
|
||||||
|
try:
|
||||||
|
from nonebot import get_driver
|
||||||
|
|
||||||
|
config = get_driver().config
|
||||||
|
port = int(getattr(config, "port", 0) or 0)
|
||||||
|
host = str(getattr(config, "host", "") or "").strip()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not port:
|
||||||
|
return None
|
||||||
|
if host not in _WILDCARD_HOSTS:
|
||||||
|
# 显式绑定(如 dev 的 127.0.0.1)直接用
|
||||||
|
return host, port
|
||||||
|
for ip in _local_ips():
|
||||||
|
return ip, port
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url() -> str | None:
|
||||||
|
"""媒体服务基地址;未挂载/无可达地址时 None(调用方据此回退 base64)。"""
|
||||||
|
if not _mounted:
|
||||||
|
return None
|
||||||
|
override = os.environ.get("HEXI_MEDIA_BASE_URL", "").strip()
|
||||||
|
if override:
|
||||||
|
return override.rstrip("/")
|
||||||
|
addr = _listen_addr()
|
||||||
|
if addr is None:
|
||||||
|
return None
|
||||||
|
host, port = addr
|
||||||
|
return f"http://{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _gc() -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
for token, (_, expires) in list(_tokens.items()):
|
||||||
|
if expires <= now:
|
||||||
|
del _tokens[token]
|
||||||
|
while len(_tokens) >= _MAX_TOKENS:
|
||||||
|
_tokens.pop(next(iter(_tokens)))
|
||||||
|
|
||||||
|
|
||||||
|
def _publish(path: Path) -> str:
|
||||||
|
"""登记一个一次性 token(值随机不可猜),返回它。"""
|
||||||
|
_gc()
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
_tokens[token] = (path, time.monotonic() + _ttl())
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def mount_media_endpoint() -> None:
|
||||||
|
"""把媒体路由挂到现有 ASGI app 上(同一个端口,不新开服务)。
|
||||||
|
|
||||||
|
免鉴权是**必须**的——协议端登录不了 hub——所以安全模型改为
|
||||||
|
「路径由 bot 在改写那一刻自己登记,请求方只能出示不可猜 token、
|
||||||
|
根本无法表达路径」,天然没有目录穿越面,配合短 TTL。
|
||||||
|
"""
|
||||||
|
global _mounted
|
||||||
|
|
||||||
|
if not _enabled():
|
||||||
|
logger.info("出站媒体内联化已禁用 (HEXI_INLINE_MEDIA=false),按原值发送")
|
||||||
|
return
|
||||||
|
if _mounted:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from nonebot import get_app
|
||||||
|
|
||||||
|
sub = FastAPI(
|
||||||
|
title="HeXi Outbound Media",
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
|
openapi_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@sub.get("/{token}")
|
||||||
|
async def media(token: str):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
entry = _tokens.get(token)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="媒体不存在或已过期")
|
||||||
|
path, expires = entry
|
||||||
|
if expires <= time.monotonic():
|
||||||
|
_tokens.pop(token, None)
|
||||||
|
raise HTTPException(status_code=404, detail="媒体链接已过期")
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="文件已不存在")
|
||||||
|
# no-store:过期媒体不该被中间层缓存住
|
||||||
|
return FileResponse(path, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
get_app().mount("/media", sub)
|
||||||
|
_mounted = True
|
||||||
|
logger.info("出站媒体服务已挂载: /media/<token>")
|
||||||
|
except Exception as e: # noqa: BLE001 - 挂载失败只降级,不影响启动
|
||||||
|
logger.warning(
|
||||||
|
f"出站媒体服务挂载失败,将回退 base64: {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 改写
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_base64(path: Path) -> str:
|
||||||
|
# 同步 IO 挪到线程,别在事件循环里读文件
|
||||||
|
data = await asyncio.to_thread(path.read_bytes)
|
||||||
|
size_mb = len(data) / 1048576
|
||||||
|
limit = _max_inline_mb()
|
||||||
|
if size_mb > limit:
|
||||||
|
# 超阈值仍要发:不转是 100% 必失败,转了才可能成功
|
||||||
|
logger.warning(
|
||||||
|
f"出站媒体内联: {path.name} 体积 {size_mb:.1f}MB 超过 "
|
||||||
|
f"HEXI_INLINE_MEDIA_MAX_MB={limit:g},仍以 base64 发送"
|
||||||
|
)
|
||||||
|
return f"base64://{b64encode(data).decode()}"
|
||||||
|
|
||||||
|
|
||||||
|
async def rewrite_file(value: Any) -> str | None:
|
||||||
|
"""改写单个 file 字段值;无需改写(或改写失败)返回 None。"""
|
||||||
|
path = local_file_of(value)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if _url_enabled():
|
||||||
|
base = _base_url()
|
||||||
|
if base:
|
||||||
|
return f"{base}/media/{_publish(path)}"
|
||||||
|
return await _to_base64(path)
|
||||||
|
except Exception as e: # noqa: BLE001 - 单个段失败只告警,按原值发送
|
||||||
|
logger.warning(f"出站媒体改写失败,按原值发送 {path}: {type(e).__name__}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def rewrite_upload_file(value: Any) -> str | None:
|
||||||
|
"""文件上传类 API 的 file 字段改写;无需改写返回 None。
|
||||||
|
|
||||||
|
只做 URL,**不做 base64 兜底**:拿不到可达地址就原样放行,行为与改动前
|
||||||
|
一致(不保证成功,但绝不会比原来更糟)。`HEXI_INLINE_MEDIA_URL=false`
|
||||||
|
同样会让这里放行——那个开关的语义是「媒体服务这条路不可用」,文件上传
|
||||||
|
依赖同一条路,理应一起关掉。
|
||||||
|
"""
|
||||||
|
if not _url_enabled():
|
||||||
|
return None
|
||||||
|
path = local_file_of(value)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
base = _base_url()
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
return f"{base}/media/{_publish(path)}"
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning(f"群/私聊文件改写失败,按原值发送 {path}: {type(e).__name__}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_segment(node: Any) -> tuple[str, dict] | None:
|
||||||
|
"""识别消息段对象 → (type, data);不是消息段返回 None。
|
||||||
|
|
||||||
|
MessageSegment 不是 dict 子类(但实现了 keys/get),所以单靠 isinstance(dict)
|
||||||
|
认不出来;这里以 NoneBot 的公共基类为准,并留一条结构兜底以防内部路径变更。
|
||||||
|
"""
|
||||||
|
if BaseMessageSegment is not None and isinstance(node, BaseMessageSegment):
|
||||||
|
seg_type, seg_data = node.type, node.data
|
||||||
|
else:
|
||||||
|
seg_type = getattr(node, "type", None)
|
||||||
|
seg_data = getattr(node, "data", None)
|
||||||
|
if isinstance(seg_type, str) and isinstance(seg_data, dict):
|
||||||
|
return seg_type, seg_data
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _walk_segment(seg_type: str, seg_data: dict, depth: int) -> None:
|
||||||
|
if seg_type in MEDIA_SEG_TYPES:
|
||||||
|
rewritten = await rewrite_file(seg_data.get("file"))
|
||||||
|
if rewritten is not None:
|
||||||
|
seg_data["file"] = rewritten
|
||||||
|
# 合并转发节点:content 可能是 str / list[dict] / Message,里面还可能嵌媒体
|
||||||
|
# (MessageSegment.node_custom 把 Message 原样塞进 data["content"])
|
||||||
|
for key in ("content", "messages"):
|
||||||
|
if key in seg_data:
|
||||||
|
await _walk(seg_data[key], depth + 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _walk(node: Any, depth: int = 0) -> None:
|
||||||
|
"""深度受限的通用遍历,原地改写。
|
||||||
|
|
||||||
|
形态是散的(Message / 纯 dict 段 / list[dict] / 转发节点的三种 content),
|
||||||
|
写死形状必漏,所以按结构特征递归而不是枚举。
|
||||||
|
"""
|
||||||
|
if node is None or depth > _MAX_DEPTH:
|
||||||
|
return
|
||||||
|
# str/bytes 是叶子;数字等标量也直接跳过
|
||||||
|
if isinstance(node, (str, bytes, int, float, bool)):
|
||||||
|
return
|
||||||
|
|
||||||
|
seg = _as_segment(node)
|
||||||
|
if seg is not None:
|
||||||
|
await _walk_segment(seg[0], seg[1], depth)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(node, (list, tuple)): # 含 Message(list 子类)
|
||||||
|
for item in node:
|
||||||
|
await _walk(item, depth + 1)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(node, dict):
|
||||||
|
seg_type, seg_data = node.get("type"), node.get("data")
|
||||||
|
if isinstance(seg_type, str) and isinstance(seg_data, dict):
|
||||||
|
# 段字典({"type": "image", "data": {...}} / node 字典)
|
||||||
|
await _walk_segment(seg_type, seg_data, depth)
|
||||||
|
return
|
||||||
|
for value in node.values():
|
||||||
|
await _walk(value, depth + 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 钩子
|
||||||
|
|
||||||
|
|
||||||
|
@BaseBot.on_calling_api
|
||||||
|
async def _inline_outbound_media(bot: BaseBot, api: str, data: dict[str, Any]) -> None:
|
||||||
|
"""call_api 前置钩子:把出站 API 里的本地文件改写成可跨机访问的形式。
|
||||||
|
|
||||||
|
钩子拿到的是 `_call_api` 之前**同一个可变 data dict**,且 hook 的 task group
|
||||||
|
在 `_call_api` 之前 await 完成,所以原地改生效。
|
||||||
|
"""
|
||||||
|
if not _enabled():
|
||||||
|
return
|
||||||
|
is_send = api in _SEND_APIS
|
||||||
|
is_upload = api in _FILE_UPLOAD_APIS
|
||||||
|
if not (is_send or is_upload) or not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if bot.adapter.get_name() not in _ONEBOT_NAMES:
|
||||||
|
return
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_upload:
|
||||||
|
# 文件上传的 file 在顶层(不是消息段);name/folder 等参数一律不碰
|
||||||
|
try:
|
||||||
|
rewritten = await rewrite_upload_file(data.get("file"))
|
||||||
|
except Exception as e: # noqa: BLE001 - 绝不反过来搞挂发送
|
||||||
|
logger.warning(f"出站媒体改写异常(upload.file),按原值发送: {e}")
|
||||||
|
return
|
||||||
|
if rewritten is not None:
|
||||||
|
data["file"] = rewritten
|
||||||
|
return
|
||||||
|
|
||||||
|
for key in ("message", "messages"):
|
||||||
|
if key in data:
|
||||||
|
try:
|
||||||
|
await _walk(data[key])
|
||||||
|
except Exception as e: # noqa: BLE001 - 绝不反过来搞挂发送
|
||||||
|
logger.warning(f"出站媒体改写异常({key}),按原值发送: {e}")
|
||||||
@@ -27,7 +27,7 @@ from nonebot import get_loaded_plugins, logger
|
|||||||
from nonebot.internal.matcher import matchers as matchers_registry
|
from nonebot.internal.matcher import matchers as matchers_registry
|
||||||
from nonebot.rule import Rule
|
from nonebot.rule import Rule
|
||||||
|
|
||||||
_PLUGIN_ROOT = Path(__file__).resolve().parents[2] # hexi/
|
_PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hexi/
|
||||||
DATA_DIR = _PLUGIN_ROOT / "data"
|
DATA_DIR = _PLUGIN_ROOT / "data"
|
||||||
STORE_PATH = DATA_DIR / "plugin_control.json"
|
STORE_PATH = DATA_DIR / "plugin_control.json"
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ def remove_plugin(plugin_id: str) -> None:
|
|||||||
def list_plugins() -> list[dict[str, Any]]:
|
def list_plugins() -> list[dict[str, Any]]:
|
||||||
"""枚举所有 application 插件(或注册了配置 schema 的插件)及其控制面状态。"""
|
"""枚举所有 application 插件(或注册了配置 schema 的插件)及其控制面状态。"""
|
||||||
from nonebot.plugin import get_loaded_plugins
|
from nonebot.plugin import get_loaded_plugins
|
||||||
from hexi.web_config import has_schema
|
from hexi.web_hub.web_config import has_schema
|
||||||
|
|
||||||
result: list[dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -174,6 +174,7 @@ def list_plugins() -> list[dict[str, Any]]:
|
|||||||
"name": meta.name,
|
"name": meta.name,
|
||||||
"description": meta.description or "",
|
"description": meta.description or "",
|
||||||
"usage": meta.usage or "",
|
"usage": meta.usage or "",
|
||||||
|
"type": meta.type,
|
||||||
"enabled": ctl["global"]["enabled"],
|
"enabled": ctl["global"]["enabled"],
|
||||||
"chat": ctl["global"]["chat"],
|
"chat": ctl["global"]["chat"],
|
||||||
"groups": {gid: lvl for gid, lvl in ctl["groups"].items()},
|
"groups": {gid: lvl for gid, lvl in ctl["groups"].items()},
|
||||||
@@ -16,7 +16,6 @@ from nonebot.params import CommandArg
|
|||||||
from nonebot.permission import SUPERUSER
|
from nonebot.permission import SUPERUSER
|
||||||
from nonebot.plugin import (
|
from nonebot.plugin import (
|
||||||
Plugin,
|
Plugin,
|
||||||
PluginMetadata,
|
|
||||||
_plugins,
|
_plugins,
|
||||||
get_loaded_plugins,
|
get_loaded_plugins,
|
||||||
get_plugin_by_module_name,
|
get_plugin_by_module_name,
|
||||||
@@ -26,16 +25,10 @@ from nonebot.rule import Rule, to_me
|
|||||||
require("nonebot_plugin_alconna")
|
require("nonebot_plugin_alconna")
|
||||||
from nonebot_plugin_alconna import UniMessage
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core.message_utils import send_forward_msg
|
from hexi.core.message_utils import send_forward_msg
|
||||||
from . import plugin_control
|
from . import plugin_control
|
||||||
|
|
||||||
__plugin_meta__ = PluginMetadata(
|
# 插件热拔插管理(hexi.core 核心模块,非 NoneBot 插件)
|
||||||
name="插件管理",
|
|
||||||
description="热插拔插件:运行时加载/卸载/重载插件(仅超管)",
|
|
||||||
usage="插件列表 / 加载插件 <模块名> / 卸载插件 <模块名> / 重载插件 <模块名>",
|
|
||||||
type="application",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _forward_sender_name() -> str:
|
def _forward_sender_name() -> str:
|
||||||
"""合并转发节点的发送者昵称(用机器人配置昵称,插件名写进 content)"""
|
"""合并转发节点的发送者昵称(用机器人配置昵称,插件名写进 content)"""
|
||||||
nickname = get_driver().config.nickname
|
nickname = get_driver().config.nickname
|
||||||
@@ -94,8 +87,33 @@ def _unregister_plugin(plugin: Plugin) -> None:
|
|||||||
parent.sub_plugins.discard(plugin)
|
parent.sub_plugins.discard(plugin)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_library(name: str) -> bool:
|
||||||
|
"""判断模块是否归属「非 application」插件(library / 未声明类别)。
|
||||||
|
|
||||||
|
只有 application 插件可热拔插;library(含根包未声明类别但被依赖的核心/框架)
|
||||||
|
都应保护,热拔插会破坏依赖它的插件运行态。
|
||||||
|
|
||||||
|
优先级:NoneBot 插件注册表 > sys.modules 回溯(含父包 __plugin_meta__)。
|
||||||
|
"""
|
||||||
|
plugin = get_plugin_by_module_name(name)
|
||||||
|
if plugin is not None:
|
||||||
|
meta = plugin.metadata
|
||||||
|
return not (meta and meta.type == "application")
|
||||||
|
parts = name.split(".")
|
||||||
|
for i in range(len(parts), 0, -1):
|
||||||
|
module = sys.modules.get(".".join(parts[:i]))
|
||||||
|
if module is not None:
|
||||||
|
meta = getattr(module, "__plugin_meta__", None)
|
||||||
|
if meta is not None:
|
||||||
|
return meta.type != "application"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def hot_load(name: str) -> bool:
|
def hot_load(name: str) -> bool:
|
||||||
"""热加载插件模块(直接 import,matcher 随之注册)"""
|
"""热加载插件模块(直接 import,matcher 随之注册)"""
|
||||||
|
if _is_library(name):
|
||||||
|
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,不支持热加载")
|
||||||
|
return False
|
||||||
if name in _hot_plugins:
|
if name in _hot_plugins:
|
||||||
return False
|
return False
|
||||||
if any(p.module_name == name for p in get_loaded_plugins()):
|
if any(p.module_name == name for p in get_loaded_plugins()):
|
||||||
@@ -115,7 +133,14 @@ def hot_unload(name: str) -> bool:
|
|||||||
|
|
||||||
不限于热加载的插件:启动时加载的插件(hexi/plugins 与 pyproject.toml
|
不限于热加载的插件:启动时加载的插件(hexi/plugins 与 pyproject.toml
|
||||||
声明)同样支持卸载,以 NoneBot 插件注册表为准查找。
|
声明)同样支持卸载,以 NoneBot 插件注册表为准查找。
|
||||||
|
|
||||||
|
安全限制:非 application 插件(library/未声明类别,常为核心库/框架)被其他
|
||||||
|
插件依赖,热卸载会让依赖方引用悬空并导致运行混乱,故拒绝卸载。
|
||||||
"""
|
"""
|
||||||
|
if _is_library(name):
|
||||||
|
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热卸载会使依赖它的插件运行混乱,已拒绝")
|
||||||
|
return False
|
||||||
|
|
||||||
plugin = get_plugin_by_module_name(name)
|
plugin = get_plugin_by_module_name(name)
|
||||||
if plugin is None and name not in _hot_plugins:
|
if plugin is None and name not in _hot_plugins:
|
||||||
return False
|
return False
|
||||||
@@ -148,6 +173,9 @@ def hot_unload(name: str) -> bool:
|
|||||||
|
|
||||||
def hot_reload(name: str) -> bool:
|
def hot_reload(name: str) -> bool:
|
||||||
"""热重载插件:先卸载再加载"""
|
"""热重载插件:先卸载再加载"""
|
||||||
|
if _is_library(name):
|
||||||
|
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热重载会使依赖它的插件运行混乱,已拒绝")
|
||||||
|
return False
|
||||||
hot_unload(name)
|
hot_unload(name)
|
||||||
return hot_load(name)
|
return hot_load(name)
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
纯逻辑实现,不依赖 NoneBot 运行时;同步(requests)与异步(httpx/aiohttp)均可用。
|
纯逻辑实现,不依赖 NoneBot 运行时;同步(requests)与异步(httpx/aiohttp)均可用。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core import rate_limit
|
from hexi.core import rate_limit
|
||||||
|
|
||||||
# 异步请求前取令牌(无令牌时等待,最多等 timeout 秒,超时返回 False)
|
# 异步请求前取令牌(无令牌时等待,最多等 timeout 秒,超时返回 False)
|
||||||
if not await rate_limit.acquire("steam", rate=1, capacity=2):
|
if not await rate_limit.acquire("steam", rate=1, capacity=2):
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# 机器人插件目录
|
||||||
@@ -29,6 +29,7 @@ __plugin_meta__ = PluginMetadata(
|
|||||||
解绑: /解绑 (解除你的QQ与当前绑定的EAID)
|
解绑: /解绑 (解除你的QQ与当前绑定的EAID)
|
||||||
修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID)
|
修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID)
|
||||||
""".strip(),
|
""".strip(),
|
||||||
|
type="application",
|
||||||
extra={
|
extra={
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 199 KiB |
@@ -1,82 +0,0 @@
|
|||||||
# CLAUDE.md — 群聊人设卡插件开发文档
|
|
||||||
|
|
||||||
本文件是**本插件内开发**的唯一入口文档;需要项目全局信息(Poetry 命令、启动方式、测试约定)时再查项目根目录的 CLAUDE.md。详细设计定稿见同目录 `DESIGN.md`。
|
|
||||||
|
|
||||||
## 插件概述
|
|
||||||
|
|
||||||
基于群聊语料蒸馏群成员的形象风格,生成 galgame 风格人物卡(九段画像)。
|
|
||||||
|
|
||||||
- **目标**:生成"人的画像",不是关系网分析;样貌参考为虚构,永远带标注
|
|
||||||
- **隐私硬约束**:只采集 opt-in 成员;敏感数据本地正则替换为占位符,**绝不经过 LLM**
|
|
||||||
- **维度隔离**:每个 `(group_id, user_id)` 是独立人设,跨群不混
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
nonebot_plugin_galgame_card/
|
|
||||||
├── __init__.py 入口层:on_message 采集器(鉴权/采样/调治理落库)
|
|
||||||
├── models.py 数据层:五张表(persona_group/user/chat_log/impression/summary)
|
|
||||||
├── repository.py 数据层:仓储(来源无关,所有读写唯一入口)
|
|
||||||
├── processor.py 治理层:纯函数(五维提取/噪声/脱敏),可单测
|
|
||||||
├── config.py 插件配置(WEB_ENABLE/WEB_USERNAME/WEB_PASSWORD/WEB_SECRET_KEY)
|
|
||||||
├── web.py Web 管理后台后端(/galgame_card/api/*,JWT 登录,仿 learning_chat)
|
|
||||||
├── web/index.html 管理后台前端(单文件,无需构建)
|
|
||||||
├── DESIGN.md 设计定稿(数据模型/流水线/九段协议/证据纪律)
|
|
||||||
└── CLAUDE.md 本文件
|
|
||||||
```
|
|
||||||
|
|
||||||
**Web 管理后台**:`http://<host>:<port>/galgame_card`(默认 admin/galgame,可在 .env 改 WEB_*)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。路由在 `@driver.on_startup` 里注册,改后端需重启 bot。
|
|
||||||
|
|
||||||
## 核心设计(速览,细节见 DESIGN.md)
|
|
||||||
|
|
||||||
1. **两级闸门**:群开关 `persona_group.enabled`(默认关)+ 个人 opt-in `persona_user`,都过才采集
|
|
||||||
2. **两级流水线**:语料 →(攒够 N 条)→ 印象(LLM 自然语言,增量中间层)→(攒够 M 条)→ 画像(九段 markdown,版本化)
|
|
||||||
3. **消息五维**:内容 / 谁发的 / 发给谁(回复/@)/ 几点发的 / 被回复内容快照——只存治理后纯文本
|
|
||||||
4. **脱敏四层**(`desensitize()`):明确模式(最长优先排序防截胡)→ 定位式(同条关键词+值)→ 跨条语境(关键词在附近消息)→ 兜底(保守替换)
|
|
||||||
5. **九段画像协议**:身份印象/性格特征/说话风格/口头禅语录/兴趣话题/相处模式/时间画像/样貌参考(虚构)/不确定信息
|
|
||||||
6. **总结路径不进消息 handler**:LLM 调用只在调度器触发,防延迟/限流
|
|
||||||
|
|
||||||
## 开发命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 跑本插件测试(治理层纯函数,9+ 个用例)
|
|
||||||
poetry run pytest tests/test_persona_processor.py
|
|
||||||
|
|
||||||
# 全量测试
|
|
||||||
poetry run pytest
|
|
||||||
|
|
||||||
# 启动/重启验证:PyCharm 的 "start bot" 运行配置(勿用 bat 脚本)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 数据库
|
|
||||||
|
|
||||||
- orm 默认库:`data/nonebot_plugin_orm/db.sqlite3`(不是 `hexi/data/data.db`)
|
|
||||||
- 建表:bot 启动时 orm 自动 create_all(新表加在 `models.py` 里即可,重启生效)
|
|
||||||
- 配置键是 `SQLALCHEMY_DATABASE_URL`(本插件未设置,走默认库)
|
|
||||||
|
|
||||||
## 开发注意事项(踩过的坑)
|
|
||||||
|
|
||||||
0. **⚠️ orm 启动自动同步会清空表数据**:`.env` 里 `ALEMBIC_STARTUP_CHECK=false` 时,nonebot_plugin_orm 每次启动都 autogenerate 同步数据库模式,**模型一有变更(改 models.py)就会重建表、清空全部数据**(2026-08-11 实测踩坑,全表被清)。已在本插件 `__init__.py` 导入期把 `migrate.sync` 替换为安全空操作。**今后 schema 演进只准通过 `repository.ensure_schema()` 显式 ALTER**,改完 models.py 后要在重启前手动执行对应 ALTER(或加进 ensure_schema)。
|
|
||||||
1. **`on_message` 必须 `block=False`**:否则事件流被拦截,群里其他插件全废
|
|
||||||
2. **只收群消息**:handler 参数注解 `GroupMessageEvent`(类型注解即过滤器)
|
|
||||||
3. **脱敏正则排序**:身份证/银行卡必须在手机号之前,否则手机号截胡身份证数字段
|
|
||||||
4. **定位式替换只替换值、保留关键词**:`密码是 xyz789` → `密码是 [密码]`,关键词不能丢
|
|
||||||
5. **测试不能裸 import 包**:`__init__.py` 触发 NoneBot 初始化,用 importlib 按路径加载 processor(见 `tests/test_persona_processor.py`,与 test_rate_limit 同款)
|
|
||||||
6. **总结/印象生成**:绝不在消息 handler 里调 LLM,走调度层(apscheduler)
|
|
||||||
7. **仓储并发**:`add_summary` 版本自增有并发撞 UNIQUE 风险,调度层加锁保护
|
|
||||||
|
|
||||||
## 当前进度
|
|
||||||
|
|
||||||
- ✅ 数据层:五表 + 仓储(含群开关、滚动淘汰、版本自增)
|
|
||||||
- ✅ 采集层:消息路径(监控→鉴权→治理→脱敏→采样→落库)
|
|
||||||
- ✅ Web 管理后台:/galgame_card(群开关、参与者、数据浏览/清理;Chrome 已实测)
|
|
||||||
- ⏳ QQ 命令集:`开启人设采集` / `加入人设` / `退出人设` / `查看人设`(Web 已覆盖同等功能,QQ 命令可选做)
|
|
||||||
- ⏳ 总结路径:LLM 客户端、印象生成、九段画像生成、调度触发
|
|
||||||
- ⏳ 呈现层:人物卡展示/图片渲染
|
|
||||||
|
|
||||||
## 待决策点
|
|
||||||
|
|
||||||
- 触发阈值(印象 ≥50 条新语料 / 画像 ≥5 条新印象,⏳ 待调)
|
|
||||||
- 脱敏兜底位数(裸数字 ≥6 位默认替换 `[账号]`,保守优先;误杀多可提到 8 位,动 `BARE_DIGITS_RE`)
|
|
||||||
- 密保答案场景(中文值正则误杀率高,方案待定)
|
|
||||||
- 命令名与权限(超管/群主)
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
# 群聊人设卡(Galgame 风格人物卡构建器)设计文档
|
|
||||||
|
|
||||||
> 本文档固化设计决策,作为各层实现的唯一依据。标注 ⏳ 的为草案/待定项。
|
|
||||||
|
|
||||||
## 1. 定位
|
|
||||||
|
|
||||||
**目标**:基于群聊发言语料,蒸馏群成员的形象风格,生成 galgame 风格的人物卡(人设)。
|
|
||||||
|
|
||||||
**非目标**:
|
|
||||||
- 不做关系网分析("相处模式"只是画像的一个段落,不是独立产品)
|
|
||||||
- 不做真实身份推断(年龄/职业/住址等现实信息只能进"不确定信息"段)
|
|
||||||
- 不生成真实样貌(样貌参考为虚构,永远带标注)
|
|
||||||
|
|
||||||
**消费方**:① 展示给人看(人物卡);② 可选:作为扮演 prompt 注入(MaiBot 兼容方向)。
|
|
||||||
|
|
||||||
## 2. 整体架构
|
|
||||||
|
|
||||||
功能分层:采集 → 治理 → 存储 → 调度 → 分析 → 呈现。
|
|
||||||
|
|
||||||
两级流水线(借鉴 MaiBot 印象机制):
|
|
||||||
|
|
||||||
```
|
|
||||||
群聊消息 ──五维落库──> 语料 ──(攒够 N 条)──> 印象(LLM 自然语言) ──(攒够 M 条)──> 画像(九段协议) ──> 版本化快照
|
|
||||||
```
|
|
||||||
|
|
||||||
- **语料 → 印象**:每次对"上次印象之后的新语料"生成一段自然语言印象(话题/氛围/互动),存 `persona_impression`。印象是增量中间产物,画像不重读全部原文。
|
|
||||||
- **印象 → 画像**:从印象集 + 规则统计(@/回复 互动、活跃时段)生成九段人物卡。
|
|
||||||
- 画像每次生成都是新版本(version +1),永久留档可对比。
|
|
||||||
|
|
||||||
## 3. 数据层(已实现 ✅)
|
|
||||||
|
|
||||||
五张表,前缀 `persona_`:
|
|
||||||
|
|
||||||
### persona_group —— 群采集开关(数据来源总闸门)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| group_id | BigInteger PK | 群号 |
|
|
||||||
| enabled | Boolean 默认 False | 该群是否开启采集(默认关,需显式开启) |
|
|
||||||
| updated_at | DateTime | 最后变更时间 |
|
|
||||||
|
|
||||||
群关闭 → 该群所有人一律不采集;群开启后,个人还需 opt-in(两级闸门)。
|
|
||||||
|
|
||||||
### persona_user —— 参与者名单(按群 opt-in)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| user_id | BigInteger PK | 参与人 QQ |
|
|
||||||
| group_id | BigInteger PK | 所在群 |
|
|
||||||
| joined_at | DateTime | 加入时间 |
|
|
||||||
|
|
||||||
### persona_chat_log —— 采集语料(五维 + 发言段链条)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| id | Integer PK 自增 | 全局有序,印象覆盖区间用它表示 |
|
|
||||||
| user_id | BigInteger | 谁发的 |
|
|
||||||
| group_id | BigInteger | |
|
|
||||||
| nickname | String(64) | 群昵称快照 |
|
|
||||||
| content | Text | 内容(纯文本,已脱敏截断) |
|
|
||||||
| target_user_id | BigInteger NULL | 发给谁(ev.reply 的 sender / @ 对象;无则 NULL=群聊漫谈) |
|
|
||||||
| target_inherited | Boolean | target 是否从发言段链条继承(对上一句的解释/补充仍算发给同一对象) |
|
|
||||||
| follows_id | Integer NULL | 发言段链条:同一说话人的上一条语料 id(间隔 ≤ 5 分钟) |
|
|
||||||
| reply_to_content | Text NULL | 被回复内容快照(对方不在语料里也能知道他在回应什么) |
|
|
||||||
| created_at | DateTime | 几点发的 |
|
|
||||||
|
|
||||||
索引:`(user_id, group_id, created_at)`。滚动保留:单用户单群上限 3000 条(⏳ 常量待定)。
|
|
||||||
|
|
||||||
**发言段链条**(解决"不带 @/回复 的后续补充丢目标"):`@B 借我玩玩` → 下一条 `我的意思是借号不是借人`(无显式目标)继承 target=B 并打 `target_inherited` 标记;分析层窗口组装可沿 `follows_id` 回溯整段发言。
|
|
||||||
|
|
||||||
### persona_impression —— 印象(两级流水线中间产物)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| id | Integer PK 自增 | |
|
|
||||||
| user_id / group_id | BigInteger | |
|
|
||||||
| content | Text | LLM 生成的自然语言印象 |
|
|
||||||
| cover_from_id / cover_to_id | Integer | 覆盖的语料 id 区间(增量依据) |
|
|
||||||
| model | String(64) | 生成模型 |
|
|
||||||
| created_at | DateTime | |
|
|
||||||
|
|
||||||
### persona_image —— 图片识别结果缓存(⏳ 多模态预留,未启用)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| hash | String(64) PK | 图片 hash(对应 chat_log.image_hashes) |
|
|
||||||
| description | Text | 多模态识别结果(表情包梗/截图内容) |
|
|
||||||
| model | String(64) | 识别模型 |
|
|
||||||
| recognized_at | DateTime | |
|
|
||||||
|
|
||||||
**预留接口**:`vision.py`(BaseImageRecognizer,当前为 Noop 占位)。将来接入多模态 LLM 后:异步后台识别(绝不在消息路径同步调)、同一 hash 只识别一次(缓存复用)、失败不影响采集。识别描述供分析层窗口组装喂给总结 LLM。
|
|
||||||
|
|
||||||
### persona_summary —— 画像快照(版本化)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| id | Integer PK 自增 | |
|
|
||||||
| user_id / group_id | BigInteger | |
|
|
||||||
| version | Integer | 每次生成 +1,同人同群唯一 |
|
|
||||||
| card_text | Text | 九段 markdown 画像原文 |
|
|
||||||
| corpus_count | Integer | 语料覆盖条数(元信息) |
|
|
||||||
| impression_count | Integer | 使用的印象条数(元信息) |
|
|
||||||
| model | String(64) | 生成模型 |
|
|
||||||
| created_at | DateTime | |
|
|
||||||
|
|
||||||
约束:`UNIQUE(user_id, group_id, version)`(并发写入由调度层锁保护)。
|
|
||||||
|
|
||||||
## 4. 九段画像协议(已定稿 ✅)
|
|
||||||
|
|
||||||
格式:markdown 固定标题 + 有界 bullet,代码可解析、人可编辑、LLM 可生成、可注入 prompt。
|
|
||||||
|
|
||||||
```
|
|
||||||
# 人物卡 · {主称呼}
|
|
||||||
语料 {N} 条 · 时间跨度 {start}-{end} · 版本 v{n} · 生成于 {date}
|
|
||||||
|
|
||||||
## 身份印象 ≤4 条 群内可见的:自称方式、群角色(吐槽役)、常用昵称
|
|
||||||
## 性格特征 ≤6 条 毒舌但心软 / 重度拖延 / 嘴硬
|
|
||||||
## 说话风格 ≤5 条 爱用"草"开头、句尾 wwww、先吐槽再给结论
|
|
||||||
## 口头禅语录 ≤6 条 带原文引用:"有一说一,这个图确实带"
|
|
||||||
## 兴趣话题 ≤5 条 明日方舟(资深);聊工作→抱怨、聊感情→回避
|
|
||||||
## 相处模式 ≤4 条 对小B互怼最多,对新人客气(一句话式,非关系网)
|
|
||||||
## 时间画像 ≤3 条 深夜 22-02 点活跃,白天潜水
|
|
||||||
## 样貌参考 ≤3 条 🎨 虚构标注:基于气质的参考描述
|
|
||||||
## 不确定信息 ≤3 条 疑是学生(语料出现"上课"),未证实
|
|
||||||
```
|
|
||||||
|
|
||||||
- 段内 bullet 为纯文本,可带原文引用(口头禅语录段必须带原文)
|
|
||||||
- 样貌参考段**必须**带 🎨 虚构标注与设计依据("基于 XX 气质")
|
|
||||||
- 现实身份信息只能出现在"不确定信息"段
|
|
||||||
- ⏳ 每段具体生成约束(prompt 细则)属分析层,待细化
|
|
||||||
|
|
||||||
## 5. 证据纪律(已定稿 ✅)
|
|
||||||
|
|
||||||
三层防编造(借鉴 MaiBot):
|
|
||||||
|
|
||||||
1. 印象 prompt 明文约束:"不要添加语料中没有依据的新事实"
|
|
||||||
2. 规则统计(互动对象、活跃时段)优先于 LLM 分类结果
|
|
||||||
3. LLM 分类结果默认降级进"不确定信息"段——**模型说的不算稳定真相**
|
|
||||||
|
|
||||||
指纹缓存(⏳):证据(印象集 + 统计)hash 未变则不重新生成画像。
|
|
||||||
|
|
||||||
## 6. 采集与治理(✅ 消息路径已实现,⏳ 阈值待调)
|
|
||||||
|
|
||||||
实现位置:`__init__.py`(on_message 入口 + 鉴权 + 采样)+ `processor.py`(纯函数治理,可单测)。
|
|
||||||
|
|
||||||
**两级闸门**:群开关 `persona_group.enabled`(群级,默认关)+ 个人 opt-in `persona_user`(个人,群内开启才生效)。两条同时满足才采集。
|
|
||||||
|
|
||||||
- 群开关控制:`开启人设采集 @群` / `关闭人设采集`(⏳ 命令名待定,权限:超管/群主)
|
|
||||||
- 只采集 `persona_user` 名单内成员(opt-in),退出即停
|
|
||||||
|
|
||||||
**脱敏(硬约束:本地正则完成,绝不经过 LLM——LLM 只接触脱敏后文本)**:
|
|
||||||
敏感值**替换为占位符**而非丢弃整条,保留对话语境(如"借号"互动是人格素材,凭证不是)。
|
|
||||||
实现:`processor.desensitize()`,四层,覆盖场景:借号/验证码代收/密码口令/兑换码卡密/密保/收入/联系方式变体/位置。
|
|
||||||
|
|
||||||
| 规则层 | 模式 | 占位符 |
|
|
||||||
|---|---|---|
|
|
||||||
| 明确模式(按最长优先排序,防截胡) | 身份证 → 银行卡 → 邮箱 → 手机号(含分隔变体) → IP → wxid → 坐标 → 车牌 | `[身份证]` `[银行卡]` `[邮箱]` `[手机号]` `[IP]` `[微信号]` `[坐标]` `[车牌]` |
|
|
||||||
| 定位式(同条"关键词+值") | `密码是 xyz789` `账号 abc123` `激活码 ABCDE-1` `验证码是 123456` `VX: xxx` `月薪 25000`(连接词支持 是/为/冒号/空格) | `[密码]` `[账号]` `[兑换码]` `[验证码]` `[微信号]` `[收入]` |
|
|
||||||
| 跨条语境 | 关键词在附近消息(如"收下验证码"→ 下一条 `123456`)→ 本条值按语境类型替换 | `[验证码]` `[密码]` `[兑换码]` |
|
|
||||||
| 兜底 | 裸长数字串 ≥6 位 → `[账号]`;字母+数字混合 ≥6 位 → `[密码]`(保守替换,宁误杀不放过) | `[账号]` `[密码]` |
|
|
||||||
|
|
||||||
语境来源:本群最近 3 条已治理文本(复用复读检测的内存窗口)。
|
|
||||||
待扩展场景(⏳):密保答案("你妈妈的名字")、QQ 号文本、代充代练语境。
|
|
||||||
|
|
||||||
- 噪声过滤:纯表情图(无文字)、复读、命令/签到、长链接轰炸
|
|
||||||
- 长文截断(约 200 字/条)
|
|
||||||
- 采样:连续刷屏 5 秒内只记 1 条
|
|
||||||
|
|
||||||
## 7. 调度与触发(⏳ 草案)
|
|
||||||
|
|
||||||
| 方式 | 条件 |
|
|
||||||
|---|---|
|
|
||||||
| 手动 | 管理员 `生成人物卡 @xxx`(强制,无视阈值) |
|
|
||||||
| 印象 | 新语料 ≥ 50 条(⏳)且距上次印象 ≥ 24h |
|
|
||||||
| 画像 | 新印象 ≥ 5 条(⏳)或语料显著增长;首次需语料 ≥ 200 条 |
|
|
||||||
| 防重入 | 同人同群生成中加锁 |
|
|
||||||
|
|
||||||
## 8. 入口层命令集(⏳ 草案)
|
|
||||||
|
|
||||||
`加入人设` `退出人设`(opt-in 控制)、`查看人设 @xxx`(展示九段卡)、`生成人设 @xxx`(管理员强制)。
|
|
||||||
|
|
||||||
## 9. 借鉴与不借鉴 MaiBot(已定稿 ✅)
|
|
||||||
|
|
||||||
**借鉴**:两级流水线(印象机制)、九段协议格式(段落文本协议)、证据纪律三层、指纹缓存、防串人(证据绑定 user_id)。
|
|
||||||
|
|
||||||
**不借鉴**:向量库 + BM25 双路召回 + PPR(语料量级 SQL 直查即可)、完整 A_memorix 记忆系统、md5 person_id(QQ 号即 id)。
|
|
||||||
|
|
||||||
## 10. 开发阶段
|
|
||||||
|
|
||||||
- **Phase 1**:数据层(四表 + 仓储)✅ 本文档落盘时完成
|
|
||||||
- **Phase 2**:治理层(采集过滤 + opt-in 命令)
|
|
||||||
- **Phase 3**:分析层(印象/画像生成,LLM 客户端)
|
|
||||||
- **Phase 4**:调度层(触发/锁)+ 呈现层(人物卡展示)
|
|
||||||
- ⏳ 后续可选:galgame 风格卡面图片渲染(协议文本为渲染源)
|
|
||||||
@@ -28,11 +28,10 @@ except ImportError:
|
|||||||
|
|
||||||
from . import models # noqa: F401 # 确保表结构注册
|
from . import models # noqa: F401 # 确保表结构注册
|
||||||
from . import repository, processor
|
from . import repository, processor
|
||||||
from . import web # noqa: F401 # Web 管理后台(/galgame_card)
|
|
||||||
from . import handlers as _handlers # noqa: E402
|
from . import handlers as _handlers # noqa: E402
|
||||||
|
|
||||||
from hexi.web_config import register_plugin_config # noqa: E402
|
from hexi.web_hub.web_config import register_plugin_config # noqa: E402
|
||||||
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
|
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
|
||||||
from . import web_hub # noqa: E402
|
from . import web_hub # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ def _get_galgame_config_values() -> dict:
|
|||||||
conf = getattr(conf_mod, "config", None) if conf_mod else None
|
conf = getattr(conf_mod, "config", None) if conf_mod else None
|
||||||
if conf is None:
|
if conf is None:
|
||||||
return {}
|
return {}
|
||||||
keys = ("web_enable", "web_username", "web_password", "web_secret_key", "image_vision_enabled")
|
keys = ("image_vision_enabled",)
|
||||||
return {k: getattr(conf, k, None) for k in keys}
|
return {k: getattr(conf, k, None) for k in keys}
|
||||||
|
|
||||||
|
|
||||||
@@ -65,10 +64,6 @@ register_plugin_config(
|
|||||||
__name__,
|
__name__,
|
||||||
{
|
{
|
||||||
"fields": [
|
"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},
|
{"key": "image_vision_enabled", "label": "图片识别启用", "type": "bool", "default": False},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""插件配置(Web 管理后台)
|
"""插件配置(图片识别等运行时行为)。
|
||||||
|
|
||||||
env 变量名与字段名对应(大写):WEB_ENABLE / WEB_USERNAME / WEB_PASSWORD / WEB_SECRET_KEY
|
access 由统一 Web 管理台 /hub 提供,插件不再自持 Web 鉴权/开关。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from nonebot import get_plugin_config
|
from nonebot import get_plugin_config
|
||||||
@@ -8,10 +8,6 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
|
|
||||||
class Config(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)
|
# 图片多模态识别(预留接口,当前未启用;将来接入后置 True)
|
||||||
image_vision_enabled: bool = False
|
image_vision_enabled: bool = False
|
||||||
|
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
"""Web 管理后台:群开关 / 参与者 / 数据浏览(挂在 bot 端口 /galgame_card 路径)
|
|
||||||
|
|
||||||
仿 nonebot_plugin_learning_chat 的网页模式:JWT 登录 + /galgame_card/api/* 路由 + 静态页。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException
|
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from jose import jwt
|
|
||||||
from nonebot import get_adapter, get_app, get_driver, logger
|
|
||||||
from nonebot.adapters.onebot.v11 import Adapter, Bot
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from . import repository
|
|
||||||
from .config import config
|
|
||||||
|
|
||||||
API_PREFIX = "/galgame_card/api"
|
|
||||||
WEB_DIR = Path(__file__).parent / "web"
|
|
||||||
|
|
||||||
driver = get_driver()
|
|
||||||
|
|
||||||
|
|
||||||
def authentication():
|
|
||||||
def inner(token: Optional[str] = Header(None, alias="Authorization")):
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, config.web_secret_key, algorithms="HS256")
|
|
||||||
if not payload.get("username") or payload["username"] != config.web_username:
|
|
||||||
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
|
|
||||||
except (jwt.JWTError, jwt.ExpiredSignatureError, AttributeError):
|
|
||||||
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
|
|
||||||
|
|
||||||
return Depends(inner)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginModel(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
def _ok(data=None, msg: str = "ok") -> JSONResponse:
|
|
||||||
return JSONResponse({"status": 0, "msg": msg, "data": data})
|
|
||||||
|
|
||||||
|
|
||||||
def _err(code: int, msg: str) -> JSONResponse:
|
|
||||||
return JSONResponse({"status": code, "msg": msg})
|
|
||||||
|
|
||||||
|
|
||||||
async def _first_bot() -> Optional[Bot]:
|
|
||||||
bots = get_adapter(Adapter).bots
|
|
||||||
return next(iter(bots.values()), None)
|
|
||||||
|
|
||||||
|
|
||||||
@driver.on_startup
|
|
||||||
async def init_web():
|
|
||||||
if not config.web_enable:
|
|
||||||
return
|
|
||||||
app = get_app()
|
|
||||||
|
|
||||||
@app.post(f"{API_PREFIX}/login")
|
|
||||||
async def login(user: LoginModel):
|
|
||||||
if user.username != config.web_username or user.password != config.web_password:
|
|
||||||
return _err(-100, "登录失败,请确认账号密码无误")
|
|
||||||
token = jwt.encode(
|
|
||||||
{
|
|
||||||
"username": user.username,
|
|
||||||
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12),
|
|
||||||
},
|
|
||||||
config.web_secret_key,
|
|
||||||
algorithm="HS256",
|
|
||||||
)
|
|
||||||
return _ok({"token": token}, "登录成功")
|
|
||||||
|
|
||||||
@app.get(f"{API_PREFIX}/groups", dependencies=[authentication()])
|
|
||||||
async def groups():
|
|
||||||
"""管理中的群:数据库直读,不依赖 OneBot"""
|
|
||||||
rows = await repository.list_groups()
|
|
||||||
corpus = await repository.corpus_counts_by_group()
|
|
||||||
impressions = await repository.impression_counts_by_group()
|
|
||||||
summaries = await repository.summary_counts_by_group()
|
|
||||||
participants = await repository.participant_counts_by_group()
|
|
||||||
result = []
|
|
||||||
for row in rows:
|
|
||||||
gid = row.group_id
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"group_id": gid,
|
|
||||||
"group_name": row.group_name,
|
|
||||||
"enabled": row.enabled,
|
|
||||||
"participants": participants.get(gid, 0),
|
|
||||||
"corpus_count": corpus.get(gid, 0),
|
|
||||||
"impression_count": impressions.get(gid, 0),
|
|
||||||
"summary_count": summaries.get(gid, 0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return _ok(result)
|
|
||||||
|
|
||||||
@app.get(f"{API_PREFIX}/groups/discover", dependencies=[authentication()])
|
|
||||||
async def discover_groups():
|
|
||||||
"""OneBot 全部群("添加群"选择器用),顺带回填已管理群名称"""
|
|
||||||
bot = await _first_bot()
|
|
||||||
if bot is None:
|
|
||||||
return _err(-100, "未连接 OneBot,无法获取群列表")
|
|
||||||
group_list = await bot.get_group_list()
|
|
||||||
managed_ids = {row.group_id for row in await repository.list_groups()}
|
|
||||||
result = [
|
|
||||||
{
|
|
||||||
"group_id": int(g["group_id"]),
|
|
||||||
"group_name": g.get("group_name", ""),
|
|
||||||
"managed": int(g["group_id"]) in managed_ids,
|
|
||||||
}
|
|
||||||
for g in group_list
|
|
||||||
]
|
|
||||||
await repository.backfill_group_names(
|
|
||||||
{int(g["group_id"]): g.get("group_name", "") for g in group_list}
|
|
||||||
)
|
|
||||||
return _ok(result)
|
|
||||||
|
|
||||||
@app.post(f"{API_PREFIX}/groups", dependencies=[authentication()])
|
|
||||||
async def add_group(data: dict):
|
|
||||||
gid = int(data.get("group_id") or 0)
|
|
||||||
if gid <= 0:
|
|
||||||
return _err(400, "group_id 不合法")
|
|
||||||
await repository.add_group(gid, str(data.get("group_name") or "").strip())
|
|
||||||
return _ok(msg="已加入管理列表")
|
|
||||||
|
|
||||||
@app.post(f"{API_PREFIX}/groups/{{group_id}}/enabled", dependencies=[authentication()])
|
|
||||||
async def set_group_enabled(group_id: int, data: dict):
|
|
||||||
await repository.set_group_enabled(group_id, bool(data.get("enabled")))
|
|
||||||
return _ok(msg="已开启采集" if data.get("enabled") else "已关闭采集")
|
|
||||||
|
|
||||||
@app.get(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
|
|
||||||
async def participants(group_id: int):
|
|
||||||
members = await repository.joined_users(group_id)
|
|
||||||
stats = dict(await repository.chat_log_stats_by_user(group_id))
|
|
||||||
result = [
|
|
||||||
{"user_id": uid, "corpus_count": stats.get(uid, 0)} for uid in members
|
|
||||||
]
|
|
||||||
return _ok(result)
|
|
||||||
|
|
||||||
@app.post(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
|
|
||||||
async def add_participant(group_id: int, data: dict):
|
|
||||||
user_id = int(data.get("user_id") or 0)
|
|
||||||
if user_id <= 0:
|
|
||||||
return _err(400, "user_id 不合法")
|
|
||||||
if await repository.is_joined(user_id, group_id):
|
|
||||||
return _err(400, "已在人设名单中")
|
|
||||||
await repository.join(user_id, group_id)
|
|
||||||
return _ok(msg="已加入")
|
|
||||||
|
|
||||||
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/participants/{{user_id}}", dependencies=[authentication()])
|
|
||||||
async def remove_participant(group_id: int, user_id: int):
|
|
||||||
await repository.leave(user_id, group_id)
|
|
||||||
return _ok(msg="已移除")
|
|
||||||
|
|
||||||
@app.get(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
|
|
||||||
async def logs(group_id: int, user_id: Optional[int] = None, page: int = 1, per_page: int = 50):
|
|
||||||
offset = max(0, (page - 1) * per_page)
|
|
||||||
items = await repository.list_logs(group_id, user_id, offset, per_page)
|
|
||||||
total = await repository.count_logs_filtered(group_id, user_id)
|
|
||||||
result = [
|
|
||||||
{
|
|
||||||
"id": row.id,
|
|
||||||
"user_id": row.user_id,
|
|
||||||
"nickname": row.nickname,
|
|
||||||
"content": row.content,
|
|
||||||
"target_user_id": row.target_user_id,
|
|
||||||
"target_inherited": row.target_inherited,
|
|
||||||
"follows_id": row.follows_id,
|
|
||||||
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
}
|
|
||||||
for row in items
|
|
||||||
]
|
|
||||||
return _ok({"items": result, "total": total})
|
|
||||||
|
|
||||||
@app.get(f"{API_PREFIX}/groups/{{group_id}}/impressions", dependencies=[authentication()])
|
|
||||||
async def impressions(group_id: int, page: int = 1, per_page: int = 50):
|
|
||||||
offset = max(0, (page - 1) * per_page)
|
|
||||||
items = await repository.list_impressions_group(group_id, offset, per_page)
|
|
||||||
result = [
|
|
||||||
{
|
|
||||||
"id": row.id,
|
|
||||||
"user_id": row.user_id,
|
|
||||||
"content": row.content,
|
|
||||||
"cover_from_id": row.cover_from_id,
|
|
||||||
"cover_to_id": row.cover_to_id,
|
|
||||||
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
}
|
|
||||||
for row in items
|
|
||||||
]
|
|
||||||
return _ok({"items": result, "total": await repository.count_impressions_group(group_id)})
|
|
||||||
|
|
||||||
@app.get(f"{API_PREFIX}/groups/{{group_id}}/summaries", dependencies=[authentication()])
|
|
||||||
async def summaries(group_id: int):
|
|
||||||
items = await repository.list_summaries_group(group_id)
|
|
||||||
result = [
|
|
||||||
{
|
|
||||||
"version": row.version,
|
|
||||||
"card_text": row.card_text,
|
|
||||||
"corpus_count": row.corpus_count,
|
|
||||||
"impression_count": row.impression_count,
|
|
||||||
"model": row.model,
|
|
||||||
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
}
|
|
||||||
for row in items
|
|
||||||
]
|
|
||||||
return _ok(result)
|
|
||||||
|
|
||||||
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
|
|
||||||
async def delete_logs(group_id: int, user_id: Optional[int] = None):
|
|
||||||
count = await repository.delete_logs(group_id, user_id)
|
|
||||||
return _ok(msg=f"已删除 {count} 条语料")
|
|
||||||
|
|
||||||
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/data", dependencies=[authentication()])
|
|
||||||
async def clear_group(group_id: int):
|
|
||||||
await repository.clear_group_data(group_id)
|
|
||||||
return _ok(msg="已清空该群全部数据")
|
|
||||||
|
|
||||||
@app.delete(f"{API_PREFIX}/groups/{{group_id}}", dependencies=[authentication()])
|
|
||||||
async def remove_group(group_id: int):
|
|
||||||
await repository.remove_group(group_id)
|
|
||||||
return _ok(msg="已移出管理列表(数据保留,重新加入可续上)")
|
|
||||||
|
|
||||||
@app.get(f"{API_PREFIX}/settings", dependencies=[authentication()])
|
|
||||||
async def settings():
|
|
||||||
from . import processor
|
|
||||||
|
|
||||||
return _ok(
|
|
||||||
{
|
|
||||||
"web_username": config.web_username,
|
|
||||||
"web_enable": config.web_enable,
|
|
||||||
"thresholds": {
|
|
||||||
"max_content_len": processor.MAX_CONTENT_LEN,
|
|
||||||
"repeat_window": processor.REPEAT_WINDOW,
|
|
||||||
"throttle_seconds": processor.THROTTLE_SECONDS,
|
|
||||||
"rolling_window": repository.ROLLING_WINDOW,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# React 管理后台(web/persona-admin 构建产物)
|
|
||||||
dist_dir = WEB_DIR / "persona-admin" / "dist"
|
|
||||||
if (dist_dir / "index.html").exists():
|
|
||||||
app.mount(
|
|
||||||
"/galgame_card/assets",
|
|
||||||
StaticFiles(directory=dist_dir / "assets"),
|
|
||||||
name="galgame_card_assets",
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/galgame_card")
|
|
||||||
@app.get("/galgame_card/")
|
|
||||||
async def admin_page():
|
|
||||||
return FileResponse(dist_dir / "index.html")
|
|
||||||
|
|
||||||
else:
|
|
||||||
# 构建产物缺失时回退到旧版单文件页面
|
|
||||||
@app.get("/galgame_card")
|
|
||||||
@app.get("/galgame_card/")
|
|
||||||
async def admin_page():
|
|
||||||
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
|
|
||||||
return HTMLResponse(html)
|
|
||||||
|
|
||||||
logger.info("群聊人设卡 | Web 管理后台已挂载: /galgame_card")
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>群聊人设卡 · 管理后台</title>
|
|
||||||
<style>
|
|
||||||
:root { --bg:#1a1d24; --panel:#232833; --border:#333a47; --text:#dde2ea; --muted:#8a94a6;
|
|
||||||
--accent:#5b8cff; --danger:#ff5c5c; --ok:#3ecf8e; --sidebar:#1e222b; }
|
|
||||||
* { box-sizing:border-box; margin:0; padding:0; }
|
|
||||||
body { background:var(--bg); color:var(--text); font:14px/1.6 "Microsoft YaHei",sans-serif; }
|
|
||||||
h1 { font-size:20px; }
|
|
||||||
h2 { font-size:16px; margin:16px 0 10px; }
|
|
||||||
.card { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:16px; }
|
|
||||||
table { width:100%; border-collapse:collapse; }
|
|
||||||
th,td { padding:8px 10px; text-align:left; border-bottom:1px solid var(--border); }
|
|
||||||
th { color:var(--muted); font-weight:normal; font-size:12px; }
|
|
||||||
tr:hover td { background:rgba(255,255,255,.03); }
|
|
||||||
button { background:var(--accent); color:#fff; border:none; border-radius:6px; padding:6px 14px; cursor:pointer; font-size:13px; }
|
|
||||||
button:hover { opacity:.85; }
|
|
||||||
button.danger { background:var(--danger); }
|
|
||||||
button.ghost { background:transparent; border:1px solid var(--border); color:var(--muted); }
|
|
||||||
input,select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:6px 10px; font-size:13px; }
|
|
||||||
.switch { position:relative; width:42px; height:22px; display:inline-block; cursor:pointer; }
|
|
||||||
.switch input { display:none; }
|
|
||||||
.switch .track { position:absolute; inset:0; background:var(--border); border-radius:11px; transition:.2s; }
|
|
||||||
.switch .track::after { content:""; position:absolute; width:16px; height:16px; background:#fff; border-radius:50%; top:3px; left:3px; transition:.2s; }
|
|
||||||
.switch input:checked + .track { background:var(--ok); }
|
|
||||||
.switch input:checked + .track::after { left:23px; }
|
|
||||||
.muted { color:var(--muted); font-size:12px; }
|
|
||||||
.tabs { display:flex; gap:6px; margin-bottom:12px; }
|
|
||||||
.tabs button.active { background:var(--accent); }
|
|
||||||
pre { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:12px; white-space:pre-wrap; word-break:break-all; font-size:12px; max-height:400px; overflow:auto; }
|
|
||||||
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
|
|
||||||
.hidden { display:none; }
|
|
||||||
.msg { margin:8px 0; font-size:13px; }
|
|
||||||
.msg.err { color:var(--danger); }
|
|
||||||
.msg.ok { color:var(--ok); }
|
|
||||||
/* 登录 */
|
|
||||||
.login-wrap { max-width:320px; margin:80px auto; }
|
|
||||||
.login-wrap .card { padding:28px; }
|
|
||||||
.login-wrap input { width:100%; margin-bottom:12px; padding:9px; }
|
|
||||||
.login-wrap button { width:100%; padding:9px; }
|
|
||||||
/* 侧栏 + 主体 */
|
|
||||||
#mainView { display:flex; min-height:100vh; }
|
|
||||||
.sidebar { width:200px; flex-shrink:0; background:var(--sidebar); border-right:1px solid var(--border);
|
|
||||||
display:flex; flex-direction:column; position:sticky; top:0; height:100vh; }
|
|
||||||
.brand { padding:20px 18px 14px; font-size:16px; font-weight:bold; border-bottom:1px solid var(--border); }
|
|
||||||
.nav { flex:1; padding:10px 0; }
|
|
||||||
.nav a { display:block; padding:10px 18px; color:var(--muted); text-decoration:none; font-size:14px; border-left:3px solid transparent; }
|
|
||||||
.nav a:hover { color:var(--text); background:rgba(255,255,255,.03); }
|
|
||||||
.nav a.active { color:var(--text); background:rgba(91,140,255,.12); border-left-color:var(--accent); }
|
|
||||||
.sidebar-footer { padding:14px 18px; border-top:1px solid var(--border); }
|
|
||||||
.sidebar-footer button { width:100%; background:transparent; border:1px solid var(--border); color:var(--muted); }
|
|
||||||
.sidebar-footer button:hover { color:var(--danger); border-color:var(--danger); }
|
|
||||||
.content { flex:1; padding:24px; min-width:0; }
|
|
||||||
.page-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
|
|
||||||
.toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div id="loginView" class="login-wrap hidden">
|
|
||||||
<div class="card">
|
|
||||||
<h1 style="text-align:center">群聊人设卡</h1>
|
|
||||||
<input id="username" placeholder="用户名">
|
|
||||||
<input id="password" type="password" placeholder="密码">
|
|
||||||
<button onclick="login()">登 录</button>
|
|
||||||
<div id="loginMsg" class="msg"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="mainView" class="hidden">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="brand">群聊人设卡</div>
|
|
||||||
<nav class="nav">
|
|
||||||
<a href="#/groups">群管理</a>
|
|
||||||
<a href="#/data">数据浏览</a>
|
|
||||||
<a href="#/settings">设置</a>
|
|
||||||
</nav>
|
|
||||||
<div class="sidebar-footer"><button onclick="logout()">退出登录</button></div>
|
|
||||||
</aside>
|
|
||||||
<main class="content" id="content"></main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API = "/galgame_card/api";
|
|
||||||
let TOKEN = localStorage.getItem("gc_token") || "";
|
|
||||||
|
|
||||||
/* ---------- 基础 ---------- */
|
|
||||||
async function api(path, options = {}) {
|
|
||||||
const res = await fetch(API + path, {
|
|
||||||
...options,
|
|
||||||
headers: { "Content-Type": "application/json", "Authorization": TOKEN, ...(options.headers || {}) },
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.status !== 0) {
|
|
||||||
if (data.status === 400) showLogin();
|
|
||||||
throw new Error(data.msg || "请求失败");
|
|
||||||
}
|
|
||||||
return data.data;
|
|
||||||
}
|
|
||||||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c])); }
|
|
||||||
function pageHead(title, extra = "") {
|
|
||||||
return `<div class="page-head"><h1>${esc(title)}</h1><div>${extra}</div></div>`;
|
|
||||||
}
|
|
||||||
function showLogin() {
|
|
||||||
document.getElementById("loginView").classList.remove("hidden");
|
|
||||||
document.getElementById("mainView").classList.add("hidden");
|
|
||||||
}
|
|
||||||
function showMain() {
|
|
||||||
document.getElementById("loginView").classList.add("hidden");
|
|
||||||
document.getElementById("mainView").classList.remove("hidden");
|
|
||||||
}
|
|
||||||
async function login() {
|
|
||||||
const msg = document.getElementById("loginMsg");
|
|
||||||
try {
|
|
||||||
const data = await api("/login", { method: "POST", body: JSON.stringify({
|
|
||||||
username: document.getElementById("username").value.trim(),
|
|
||||||
password: document.getElementById("password").value }) });
|
|
||||||
TOKEN = data.token; localStorage.setItem("gc_token", TOKEN);
|
|
||||||
msg.className = "msg ok"; msg.textContent = "登录成功";
|
|
||||||
showMain(); location.hash = "#/groups"; route();
|
|
||||||
} catch (e) { msg.className = "msg err"; msg.textContent = e.message; }
|
|
||||||
}
|
|
||||||
function logout() { localStorage.removeItem("gc_token"); TOKEN = ""; showLogin(); }
|
|
||||||
|
|
||||||
/* ---------- 路由:侧栏 + 主体 ---------- */
|
|
||||||
const routes = { "#/groups": renderGroupList, "#/data": renderDataBrowser, "#/settings": renderSettings };
|
|
||||||
function route() {
|
|
||||||
const hash = location.hash || "#/groups";
|
|
||||||
document.querySelectorAll(".nav a").forEach(a => a.classList.toggle("active", a.getAttribute("href") === hash));
|
|
||||||
const content = document.getElementById("content");
|
|
||||||
const m = hash.match(/^#\/groups\/(\d+)/);
|
|
||||||
if (m) { renderGroupDetail(Number(m[1])); return; }
|
|
||||||
(routes[hash] || renderGroupList)(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 群管理 ---------- */
|
|
||||||
async function renderGroupList(content) {
|
|
||||||
content.innerHTML = pageHead("群管理", '<button class="ghost" onclick="route()">刷新</button>') +
|
|
||||||
`<div class="card"><h2>群采集开关</h2>
|
|
||||||
<table><thead><tr><th>群</th><th>开关</th><th>参与者</th><th>语料</th><th>印象</th><th>画像</th><th></th></tr></thead>
|
|
||||||
<tbody id="groupRows"></tbody></table><div id="listMsg" class="msg"></div></div>`;
|
|
||||||
const tbody = document.getElementById("groupRows");
|
|
||||||
document.getElementById("listMsg").textContent = "加载中…";
|
|
||||||
try {
|
|
||||||
const groups = await api("/groups");
|
|
||||||
tbody.innerHTML = groups.map(g => `<tr>
|
|
||||||
<td><b>${esc(g.group_name)}</b><br><span class="muted">${g.group_id}</span></td>
|
|
||||||
<td><label class="switch"><input type="checkbox" ${g.enabled ? "checked" : ""} onchange="toggleGroup(${g.group_id}, this.checked)"><span class="track"></span></label></td>
|
|
||||||
<td>${g.participants}</td><td>${g.corpus_count}</td><td>${g.impression_count}</td><td>${g.summary_count}</td>
|
|
||||||
<td><a href="#/groups/${g.group_id}"><button>管理</button></a></td></tr>`).join("");
|
|
||||||
document.getElementById("listMsg").textContent = "";
|
|
||||||
} catch (e) { document.getElementById("listMsg").className = "msg err"; document.getElementById("listMsg").textContent = e.message; }
|
|
||||||
}
|
|
||||||
async function toggleGroup(gid, enabled) {
|
|
||||||
try { await api(`/groups/${gid}/enabled`, { method: "POST", body: JSON.stringify({ enabled }) }); }
|
|
||||||
catch (e) { alert(e.message); route(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 群详情(管理) ---------- */
|
|
||||||
async function renderGroupDetail(gid) {
|
|
||||||
const content = document.getElementById("content");
|
|
||||||
content.innerHTML = pageHead(`群管理 #${gid}`,
|
|
||||||
`<a href="#/groups"><button class="ghost">← 返回</button></a>
|
|
||||||
<button class="danger" onclick="clearGroup(${gid})">清空该群全部数据</button>`) +
|
|
||||||
`<div class="card"><h2>参与者</h2>
|
|
||||||
<div class="row"><input id="addUserId" placeholder="QQ 号" type="number">
|
|
||||||
<button onclick="addParticipant(${gid})">加入人设名单</button>
|
|
||||||
<span class="muted">仅名单内成员会被采集</span></div>
|
|
||||||
<table><thead><tr><th>QQ</th><th>语料条数</th><th></th></tr></thead><tbody id="participantRows"></tbody></table></div>
|
|
||||||
<div class="card"><div class="tabs">
|
|
||||||
<button id="tabLogs" class="active" onclick="switchTab('logs')">语料</button>
|
|
||||||
<button id="tabImpressions" onclick="switchTab('impressions')">印象</button>
|
|
||||||
<button id="tabSummaries" onclick="switchTab('summaries')">画像</button>
|
|
||||||
</div>
|
|
||||||
<div id="tabLogsView">
|
|
||||||
<div class="row"><input id="logUserId" placeholder="按 QQ 过滤" type="number">
|
|
||||||
<button onclick="loadLogs(${gid}, 1)">查询</button>
|
|
||||||
<button class="danger" onclick="clearLogs(${gid})">删除全部语料</button></div>
|
|
||||||
<table><thead><tr><th>ID</th><th>发送者</th><th>发给</th><th>时间</th><th>内容(已脱敏)</th></tr></thead>
|
|
||||||
<tbody id="logRows"></tbody></table>
|
|
||||||
<div class="row" id="logPager" style="margin-top:10px"></div>
|
|
||||||
</div>
|
|
||||||
<div id="tabImpressionsView" class="hidden">
|
|
||||||
<table><thead><tr><th>ID</th><th>用户</th><th>覆盖语料区间</th><th>时间</th><th>印象内容</th></tr></thead>
|
|
||||||
<tbody id="impressionRows"></tbody></table>
|
|
||||||
</div>
|
|
||||||
<div id="tabSummariesView" class="hidden"><div id="summaryList"></div></div>
|
|
||||||
</div>`;
|
|
||||||
window._gid = gid; window._logPage = 1;
|
|
||||||
loadParticipants(gid); switchTab("logs"); loadLogs(gid, 1);
|
|
||||||
}
|
|
||||||
async function loadParticipants(gid) {
|
|
||||||
const tbody = document.getElementById("participantRows");
|
|
||||||
try {
|
|
||||||
const items = await api(`/groups/${gid}/participants`);
|
|
||||||
tbody.innerHTML = items.map(p => `<tr><td>${p.user_id}</td><td>${p.corpus_count}</td>
|
|
||||||
<td><button class="danger" onclick="removeParticipant(${gid}, ${p.user_id})">移除</button></td></tr>`).join("")
|
|
||||||
|| `<tr><td colspan="3" class="muted">暂无参与者</td></tr>`;
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
async function addParticipant(gid) {
|
|
||||||
const uid = document.getElementById("addUserId").value.trim();
|
|
||||||
if (!uid) return;
|
|
||||||
try { await api(`/groups/${gid}/participants`, { method: "POST", body: JSON.stringify({ user_id: Number(uid) }) });
|
|
||||||
document.getElementById("addUserId").value = ""; loadParticipants(gid); }
|
|
||||||
catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
async function removeParticipant(gid, uid) {
|
|
||||||
if (!confirm(`移除 ${uid}?其语料保留在库中`)) return;
|
|
||||||
await api(`/groups/${gid}/participants/${uid}`, { method: "DELETE" });
|
|
||||||
loadParticipants(gid);
|
|
||||||
}
|
|
||||||
function switchTab(tab) {
|
|
||||||
["logs", "impressions", "summaries"].forEach(t => {
|
|
||||||
const name = t[0].toUpperCase() + t.slice(1);
|
|
||||||
document.getElementById(`tab${name}`).classList.toggle("active", t === tab);
|
|
||||||
document.getElementById(`tab${name}View`).classList.toggle("hidden", t !== tab);
|
|
||||||
});
|
|
||||||
const gid = window._gid;
|
|
||||||
if (tab === "logs") loadLogs(gid, 1);
|
|
||||||
if (tab === "impressions") loadImpressions(gid);
|
|
||||||
if (tab === "summaries") loadSummaries(gid);
|
|
||||||
}
|
|
||||||
async function loadLogs(gid, page) {
|
|
||||||
window._logPage = page;
|
|
||||||
const tbody = document.getElementById("logRows");
|
|
||||||
const uid = document.getElementById("logUserId").value.trim();
|
|
||||||
const q = new URLSearchParams({ page, per_page: 50 });
|
|
||||||
if (uid) q.set("user_id", uid);
|
|
||||||
try {
|
|
||||||
const data = await api(`/groups/${gid}/logs?${q}`);
|
|
||||||
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${esc(r.nickname)}<br><span class="muted">${r.user_id}</span></td>
|
|
||||||
<td>${r.target_user_id ? "→ " + r.target_user_id : '<span class="muted">—</span>'}</td><td>${r.created_at}</td>
|
|
||||||
<td>${esc(r.content)}</td></tr>`).join("") || `<tr><td colspan="5" class="muted">暂无语料</td></tr>`;
|
|
||||||
const pages = Math.max(1, Math.ceil(data.total / 50));
|
|
||||||
document.getElementById("logPager").innerHTML =
|
|
||||||
`<button class="ghost" ${page <= 1 ? "disabled" : ""} onclick="loadLogs(${gid}, ${page - 1})">上一页</button>
|
|
||||||
<span class="muted">第 ${page}/${pages} 页 · 共 ${data.total} 条</span>
|
|
||||||
<button class="ghost" ${page >= pages ? "disabled" : ""} onclick="loadLogs(${gid}, ${page + 1})">下一页</button>`;
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
async function clearLogs(gid) {
|
|
||||||
if (!confirm("删除该群全部语料?印象/画像保留")) return;
|
|
||||||
try { await api(`/groups/${gid}/logs`, { method: "DELETE" }); loadLogs(gid, 1); } catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
async function loadImpressions(gid) {
|
|
||||||
const tbody = document.getElementById("impressionRows");
|
|
||||||
try {
|
|
||||||
const data = await api(`/groups/${gid}/impressions?page=1&per_page=100`);
|
|
||||||
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${r.user_id}</td>
|
|
||||||
<td>#${r.cover_from_id} ~ #${r.cover_to_id}</td><td>${r.created_at}</td><td>${esc(r.content)}</td></tr>`).join("")
|
|
||||||
|| `<tr><td colspan="5" class="muted">暂无印象</td></tr>`;
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
async function loadSummaries(gid) {
|
|
||||||
const box = document.getElementById("summaryList");
|
|
||||||
try {
|
|
||||||
const items = await api(`/groups/${gid}/summaries`);
|
|
||||||
box.innerHTML = items.map(s => `<h3 style="margin:14px 0 6px">v${s.version} · ${s.created_at} · 语料 ${s.corpus_count} 条 · 印象 ${s.impression_count} 条</h3><pre>${esc(s.card_text)}</pre>`).join("")
|
|
||||||
|| `<p class="muted">暂无画像(语料积累后由总结路径生成)</p>`;
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
async function clearGroup(gid) {
|
|
||||||
if (!confirm("确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复")) return;
|
|
||||||
try { await api(`/groups/${gid}`, { method: "DELETE" }); location.hash = "#/groups"; route(); } catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 数据浏览(跨群) ---------- */
|
|
||||||
async function renderDataBrowser(content) {
|
|
||||||
content.innerHTML = pageHead("数据浏览") +
|
|
||||||
`<div class="card"><div class="row">
|
|
||||||
<select id="dataGroupSelect" onchange="dataSelectGroup()"><option value="">选择群…</option></select>
|
|
||||||
<span class="muted" id="dataGroupInfo"></span></div>
|
|
||||||
<div class="tabs">
|
|
||||||
<button id="dTabLogs" class="active" onclick="dataTab('logs')">语料</button>
|
|
||||||
<button id="dTabImpressions" onclick="dataTab('impressions')">印象</button>
|
|
||||||
<button id="dTabSummaries" onclick="dataTab('summaries')">画像</button>
|
|
||||||
</div>
|
|
||||||
<div id="dTabLogsView">
|
|
||||||
<table><thead><tr><th>ID</th><th>发送者</th><th>发给</th><th>时间</th><th>内容(已脱敏)</th></tr></thead>
|
|
||||||
<tbody id="dLogRows"><tr><td colspan="5" class="muted">请先选择群</td></tr></tbody></table>
|
|
||||||
</div>
|
|
||||||
<div id="dTabImpressionsView" class="hidden">
|
|
||||||
<table><thead><tr><th>ID</th><th>用户</th><th>覆盖语料区间</th><th>时间</th><th>印象内容</th></tr></thead>
|
|
||||||
<tbody id="dImpressionRows"></tbody></table>
|
|
||||||
</div>
|
|
||||||
<div id="dTabSummariesView" class="hidden"><div id="dSummaryList"></div></div>
|
|
||||||
</div>`;
|
|
||||||
try {
|
|
||||||
const groups = await api("/groups");
|
|
||||||
document.getElementById("dataGroupSelect").innerHTML =
|
|
||||||
`<option value="">选择群…</option>` + groups.map(g => `<option value="${g.group_id}">${esc(g.group_name)}(${g.group_id})</option>`).join("");
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
async function dataSelectGroup() {
|
|
||||||
const gid = Number(document.getElementById("dataGroupSelect").value);
|
|
||||||
window._dataGid = gid;
|
|
||||||
document.getElementById("dataGroupInfo").textContent = gid ? "" : "";
|
|
||||||
if (!gid) return;
|
|
||||||
dataTab("logs");
|
|
||||||
}
|
|
||||||
function dataTab(tab) {
|
|
||||||
["logs", "impressions", "summaries"].forEach(t => {
|
|
||||||
const name = "d" + t[0].toUpperCase() + t.slice(1);
|
|
||||||
document.getElementById(`dTab${t[0].toUpperCase()}${t.slice(1)}`).classList.toggle("active", t === tab);
|
|
||||||
document.getElementById(`dTab${t[0].toUpperCase()}${t.slice(1)}View`).classList.toggle("hidden", t !== tab);
|
|
||||||
});
|
|
||||||
const gid = window._dataGid;
|
|
||||||
if (!gid) return;
|
|
||||||
if (tab === "logs") loadDataLogs(gid);
|
|
||||||
if (tab === "impressions") loadDataImpressions(gid);
|
|
||||||
if (tab === "summaries") loadDataSummaries(gid);
|
|
||||||
}
|
|
||||||
async function loadDataLogs(gid) {
|
|
||||||
const tbody = document.getElementById("dLogRows");
|
|
||||||
try {
|
|
||||||
const data = await api(`/groups/${gid}/logs?page=1&per_page=100`);
|
|
||||||
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${esc(r.nickname)}<br><span class="muted">${r.user_id}</span></td>
|
|
||||||
<td>${r.target_user_id ? "→ " + r.target_user_id : '<span class="muted">—</span>'}</td><td>${r.created_at}</td>
|
|
||||||
<td>${esc(r.content)}</td></tr>`).join("")
|
|
||||||
|| `<tr><td colspan="5" class="muted">暂无数据</td></tr>`;
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
async function loadDataImpressions(gid) {
|
|
||||||
const tbody = document.getElementById("dImpressionRows");
|
|
||||||
try {
|
|
||||||
const data = await api(`/groups/${gid}/impressions?page=1&per_page=100`);
|
|
||||||
tbody.innerHTML = data.items.map(r => `<tr><td>${r.id}</td><td>${r.user_id}</td>
|
|
||||||
<td>#${r.cover_from_id} ~ #${r.cover_to_id}</td><td>${r.created_at}</td><td>${esc(r.content)}</td></tr>`).join("")
|
|
||||||
|| `<tr><td colspan="5" class="muted">暂无数据</td></tr>`;
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
async function loadDataSummaries(gid) {
|
|
||||||
const box = document.getElementById("dSummaryList");
|
|
||||||
try {
|
|
||||||
const items = await api(`/groups/${gid}/summaries`);
|
|
||||||
box.innerHTML = items.map(s => `<h3 style="margin:14px 0 6px">v${s.version} · ${s.created_at} · 语料 ${s.corpus_count} 条 · 印象 ${s.impression_count} 条</h3><pre>${esc(s.card_text)}</pre>`).join("")
|
|
||||||
|| `<p class="muted">暂无数据</p>`;
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 设置 ---------- */
|
|
||||||
async function renderSettings(content) {
|
|
||||||
content.innerHTML = pageHead("设置") + `<div class="card" id="settingsBody"><p class="muted">加载中…</p></div>`;
|
|
||||||
try {
|
|
||||||
const s = await api("/settings");
|
|
||||||
const rows = [
|
|
||||||
["Web 登录账号", s.web_username],
|
|
||||||
["Web 后台启用", s.web_enable ? "是" : "否"],
|
|
||||||
["长文截断上限", `${s.thresholds.max_content_len} 字`],
|
|
||||||
["复读检测窗口", `${s.thresholds.repeat_window} 条`],
|
|
||||||
["刷屏采样间隔", `${s.thresholds.throttle_seconds} 秒`],
|
|
||||||
["单用户语料滚动上限", `${s.thresholds.rolling_window} 条`],
|
|
||||||
];
|
|
||||||
document.getElementById("settingsBody").innerHTML =
|
|
||||||
`<h2>当前配置(只读)</h2>
|
|
||||||
<table>${rows.map(r => `<tr><th style="width:200px">${r[0]}</th><td>${esc(r[1])}</td></tr>`).join("")}</table>
|
|
||||||
<p class="muted" style="margin-top:12px">阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME / WEB_PASSWORD。</p>`;
|
|
||||||
} catch (e) { document.getElementById("settingsBody").innerHTML = `<p class="msg err">${esc(e.message)}</p>`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 启动 ---------- */
|
|
||||||
window.addEventListener("hashchange", route);
|
|
||||||
if (TOKEN) { showMain(); route(); } else { showLogin(); }
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>群聊人设卡 · 管理后台</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "persona-admin",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@heroui/react": "^2.8.0",
|
|
||||||
"@heroui/theme": "^2.4.26",
|
|
||||||
"framer-motion": "^11.11.17",
|
|
||||||
"next-themes": "^0.4.4",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-router-dom": "^6.28.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.3.12",
|
|
||||||
"@types/react-dom": "^18.3.1",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.15",
|
|
||||||
"typescript": "^5.6.3",
|
|
||||||
"vite": "^6.0.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Navigate,
|
|
||||||
NavLink,
|
|
||||||
Outlet,
|
|
||||||
Route,
|
|
||||||
Routes,
|
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { Button, Tooltip } from "@heroui/react";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { clearToken, hasToken } from "./api";
|
|
||||||
import Login from "./pages/Login";
|
|
||||||
import GroupList from "./pages/GroupList";
|
|
||||||
import GroupDetail from "./pages/GroupDetail";
|
|
||||||
import DataBrowser from "./pages/DataBrowser";
|
|
||||||
import Settings from "./pages/Settings";
|
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ to: "/groups", label: "群管理", icon: "◈" },
|
|
||||||
{ to: "/data", label: "数据浏览", icon: "▤" },
|
|
||||||
{ to: "/settings", label: "设置", icon: "⚙" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function Layout() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
|
||||||
const isDark = resolvedTheme === "dark";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasToken()) navigate("/login", { replace: true });
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
clearToken();
|
|
||||||
navigate("/login", { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen overflow-hidden">
|
|
||||||
<aside className="w-56 shrink-0 border-r border-default-100 bg-content1 flex flex-col">
|
|
||||||
<div className="px-5 py-5 text-lg font-bold tracking-wide border-b border-default-100">
|
|
||||||
群聊人设卡
|
|
||||||
</div>
|
|
||||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
|
||||||
{NAV_ITEMS.map((item) => (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-colors ${
|
|
||||||
isActive
|
|
||||||
? "bg-primary/15 text-primary font-medium"
|
|
||||||
: "text-default-500 hover:text-foreground hover:bg-default-100/50"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="w-4 text-center">{item.icon}</span>
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div className="px-3 py-4 border-t border-default-100 flex items-center gap-2">
|
|
||||||
<Tooltip content={isDark ? "切换亮色" : "切换暗色"} placement="right">
|
|
||||||
<Button
|
|
||||||
isIconOnly
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
onPress={() => setTheme(isDark ? "light" : "dark")}
|
|
||||||
aria-label="切换主题"
|
|
||||||
>
|
|
||||||
{isDark ? "☀" : "🌙"}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip content="退出登录" placement="right">
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="light"
|
|
||||||
color="danger"
|
|
||||||
size="sm"
|
|
||||||
onPress={logout}
|
|
||||||
className="justify-start"
|
|
||||||
>
|
|
||||||
⏻ 退出登录
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route element={<Layout />}>
|
|
||||||
<Route path="/" element={<Navigate to="/groups" replace />} />
|
|
||||||
<Route path="/groups" element={<GroupList />} />
|
|
||||||
<Route path="/groups/:gid" element={<GroupDetail />} />
|
|
||||||
<Route path="/data" element={<DataBrowser />} />
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
<Route path="*" element={<Navigate to="/groups" replace />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
const API = "/galgame_card/api";
|
|
||||||
|
|
||||||
let token: string = localStorage.getItem("gc_token") || "";
|
|
||||||
|
|
||||||
export function setToken(t: string) {
|
|
||||||
token = t;
|
|
||||||
localStorage.setItem("gc_token", t);
|
|
||||||
}
|
|
||||||
export function clearToken() {
|
|
||||||
token = "";
|
|
||||||
localStorage.removeItem("gc_token");
|
|
||||||
}
|
|
||||||
export function hasToken() {
|
|
||||||
return !!token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function api<T = any>(path: string, options: RequestInit = {}): Promise<T> {
|
|
||||||
const res = await fetch(API + path, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: token,
|
|
||||||
...(options.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.status !== 0) {
|
|
||||||
if (data.status === 400) {
|
|
||||||
clearToken();
|
|
||||||
window.location.hash = "#/login";
|
|
||||||
}
|
|
||||||
throw new Error(data.msg || "请求失败");
|
|
||||||
}
|
|
||||||
return data.data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 类型 ---------- */
|
|
||||||
|
|
||||||
export interface GroupInfo {
|
|
||||||
group_id: number;
|
|
||||||
group_name: string;
|
|
||||||
enabled: boolean;
|
|
||||||
participants: number;
|
|
||||||
corpus_count: number;
|
|
||||||
impression_count: number;
|
|
||||||
summary_count: number;
|
|
||||||
}
|
|
||||||
export interface DiscoverGroup {
|
|
||||||
group_id: number;
|
|
||||||
group_name: string;
|
|
||||||
managed: boolean;
|
|
||||||
}
|
|
||||||
export interface Participant {
|
|
||||||
user_id: number;
|
|
||||||
corpus_count: number;
|
|
||||||
}
|
|
||||||
export interface LogItem {
|
|
||||||
id: number;
|
|
||||||
user_id: number;
|
|
||||||
nickname: string;
|
|
||||||
content: string;
|
|
||||||
target_user_id: number | null;
|
|
||||||
target_inherited: boolean;
|
|
||||||
follows_id: number | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
export interface ImpressionItem {
|
|
||||||
id: number;
|
|
||||||
user_id: number;
|
|
||||||
content: string;
|
|
||||||
cover_from_id: number;
|
|
||||||
cover_to_id: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
export interface SummaryItem {
|
|
||||||
version: number;
|
|
||||||
card_text: string;
|
|
||||||
corpus_count: number;
|
|
||||||
impression_count: number;
|
|
||||||
model: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
export interface SettingsInfo {
|
|
||||||
web_username: string;
|
|
||||||
web_enable: boolean;
|
|
||||||
thresholds: {
|
|
||||||
max_content_len: number;
|
|
||||||
repeat_window: number;
|
|
||||||
throttle_seconds: number;
|
|
||||||
rolling_window: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
html.dark {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
font-family: "Microsoft YaHei", -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 侧栏滚动条 */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
@apply bg-default-700 rounded-full;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import { HeroUIProvider, ToastProvider } from "@heroui/react";
|
|
||||||
import { ThemeProvider } from "next-themes";
|
|
||||||
import { HashRouter } from "react-router-dom";
|
|
||||||
import App from "./App";
|
|
||||||
import "./index.css";
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
|
||||||
<HeroUIProvider>
|
|
||||||
<ToastProvider toastProps={{ placement: "top-center" }} />
|
|
||||||
<HashRouter>
|
|
||||||
<App />
|
|
||||||
</HashRouter>
|
|
||||||
</HeroUIProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
Select,
|
|
||||||
SelectItem,
|
|
||||||
Spinner,
|
|
||||||
Tab,
|
|
||||||
Tabs,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableColumn,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@heroui/react";
|
|
||||||
import { addToast } from "@heroui/react";
|
|
||||||
import { api, GroupInfo, ImpressionItem, LogItem, SummaryItem } from "../api";
|
|
||||||
|
|
||||||
export default function DataBrowser() {
|
|
||||||
const [groups, setGroups] = useState<GroupInfo[]>([]);
|
|
||||||
const [gid, setGid] = useState<number>(0);
|
|
||||||
const [tab, setTab] = useState("logs");
|
|
||||||
|
|
||||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
|
||||||
const [impressions, setImpressions] = useState<ImpressionItem[]>([]);
|
|
||||||
const [summaries, setSummaries] = useState<SummaryItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<GroupInfo[]>("/groups")
|
|
||||||
.then(setGroups)
|
|
||||||
.catch((e: any) => addToast({ title: e.message, color: "danger" }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!gid) return;
|
|
||||||
setLoading(true);
|
|
||||||
const loaders: Record<string, Promise<any>> = {
|
|
||||||
logs: api(`/groups/${gid}/logs?page=1&per_page=100`).then((d: any) =>
|
|
||||||
setLogs(d.items)
|
|
||||||
),
|
|
||||||
impressions: api(`/groups/${gid}/impressions?page=1&per_page=100`).then((d: any) =>
|
|
||||||
setImpressions(d.items)
|
|
||||||
),
|
|
||||||
summaries: api(`/groups/${gid}/summaries`).then(setSummaries),
|
|
||||||
};
|
|
||||||
Promise.all([loaders.logs, loaders.impressions, loaders.summaries]).finally(() =>
|
|
||||||
setLoading(false)
|
|
||||||
);
|
|
||||||
}, [gid]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold mb-5">数据浏览</h1>
|
|
||||||
<Card className="bg-content1">
|
|
||||||
<CardBody>
|
|
||||||
<Select
|
|
||||||
label="选择群"
|
|
||||||
placeholder="选择要浏览的群"
|
|
||||||
className="max-w-sm mb-4"
|
|
||||||
variant="bordered"
|
|
||||||
selectedKeys={gid ? [String(gid)] : []}
|
|
||||||
onSelectionChange={(keys) => {
|
|
||||||
const k = Array.from(keys)[0];
|
|
||||||
if (k) setGid(Number(k));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{groups.map((g) => (
|
|
||||||
<SelectItem key={String(g.group_id)}>
|
|
||||||
{g.group_name || `群 ${g.group_id}`}({g.group_id})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{!gid && <p className="text-default-500 py-6">请先选择群</p>}
|
|
||||||
|
|
||||||
{loading && gid && (
|
|
||||||
<div className="flex justify-center py-16">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && gid && (
|
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(k) => setTab(String(k))}>
|
|
||||||
<Tab key="logs" title="语料">
|
|
||||||
<Table aria-label="语料" removeWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableColumn>ID</TableColumn>
|
|
||||||
<TableColumn>发送者</TableColumn>
|
|
||||||
<TableColumn>发给</TableColumn>
|
|
||||||
<TableColumn>时间</TableColumn>
|
|
||||||
<TableColumn>内容(已脱敏)</TableColumn>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody items={logs} emptyContent={gid ? "暂无数据" : "请先选择群"}>
|
|
||||||
{(l) => (
|
|
||||||
<TableRow key={l.id}>
|
|
||||||
<TableCell>{l.id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div>{l.nickname || "—"}</div>
|
|
||||||
<div className="text-xs text-default-500">{l.user_id}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{l.target_user_id ? (
|
|
||||||
<span>
|
|
||||||
→ {l.target_user_id}
|
|
||||||
{l.target_inherited && (
|
|
||||||
<span className="text-xs text-warning ml-1">延续</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-default-500">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-default-500">{l.created_at}</TableCell>
|
|
||||||
<TableCell className="max-w-xl break-all">{l.content}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Tab>
|
|
||||||
|
|
||||||
<Tab key="impressions" title="印象">
|
|
||||||
<Table aria-label="印象" removeWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableColumn>ID</TableColumn>
|
|
||||||
<TableColumn>用户</TableColumn>
|
|
||||||
<TableColumn>覆盖语料区间</TableColumn>
|
|
||||||
<TableColumn>时间</TableColumn>
|
|
||||||
<TableColumn>印象内容</TableColumn>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody items={impressions} emptyContent={gid ? "暂无数据" : "请先选择群"}>
|
|
||||||
{(r) => (
|
|
||||||
<TableRow key={r.id}>
|
|
||||||
<TableCell>{r.id}</TableCell>
|
|
||||||
<TableCell>{r.user_id}</TableCell>
|
|
||||||
<TableCell className="text-default-500">
|
|
||||||
#{r.cover_from_id} ~ #{r.cover_to_id}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-default-500">{r.created_at}</TableCell>
|
|
||||||
<TableCell className="max-w-xl break-all">{r.content}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Tab>
|
|
||||||
|
|
||||||
<Tab key="summaries" title="画像">
|
|
||||||
{summaries.length === 0 ? (
|
|
||||||
<p className="text-default-500 py-6">{gid ? "暂无数据" : "请先选择群"}</p>
|
|
||||||
) : (
|
|
||||||
summaries.map((s) => (
|
|
||||||
<div key={s.version} className="mb-4">
|
|
||||||
<div className="text-sm text-default-500 mb-2">
|
|
||||||
v{s.version} · {s.created_at} · 语料 {s.corpus_count} 条 · 印象{" "}
|
|
||||||
{s.impression_count} 条
|
|
||||||
</div>
|
|
||||||
<pre className="bg-content2 border border-default-100 rounded-xl p-4 text-sm whitespace-pre-wrap break-all max-h-96 overflow-auto">
|
|
||||||
{s.card_text}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
Input,
|
|
||||||
Spinner,
|
|
||||||
Tab,
|
|
||||||
Tabs,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableColumn,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@heroui/react";
|
|
||||||
import { addToast } from "@heroui/react";
|
|
||||||
import { api, ImpressionItem, LogItem, Participant, SummaryItem } from "../api";
|
|
||||||
|
|
||||||
export default function GroupDetail() {
|
|
||||||
const { gid } = useParams();
|
|
||||||
const gidNum = Number(gid);
|
|
||||||
|
|
||||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
|
||||||
const [addUid, setAddUid] = useState("");
|
|
||||||
const [tab, setTab] = useState("logs");
|
|
||||||
|
|
||||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
|
||||||
const [logTotal, setLogTotal] = useState(0);
|
|
||||||
const [logPage, setLogPage] = useState(1);
|
|
||||||
const [logUid, setLogUid] = useState("");
|
|
||||||
const [impressions, setImpressions] = useState<ImpressionItem[]>([]);
|
|
||||||
const [summaries, setSummaries] = useState<SummaryItem[]>([]);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const loadParticipants = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setParticipants(await api<Participant[]>(`/groups/${gidNum}/participants`));
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
}, [gidNum]);
|
|
||||||
|
|
||||||
const loadLogs = useCallback(
|
|
||||||
async (page: number) => {
|
|
||||||
const q = new URLSearchParams({ page: String(page), per_page: "50" });
|
|
||||||
if (logUid) q.set("user_id", logUid);
|
|
||||||
try {
|
|
||||||
const data = await api<{ items: LogItem[]; total: number }>(
|
|
||||||
`/groups/${gidNum}/logs?${q}`
|
|
||||||
);
|
|
||||||
setLogs(data.items);
|
|
||||||
setLogTotal(data.total);
|
|
||||||
setLogPage(page);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[gidNum, logUid]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadImpressions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await api<{ items: ImpressionItem[] }>(
|
|
||||||
`/groups/${gidNum}/impressions?page=1&per_page=100`
|
|
||||||
);
|
|
||||||
setImpressions(data.items);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
}, [gidNum]);
|
|
||||||
|
|
||||||
const loadSummaries = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setSummaries(await api<SummaryItem[]>(`/groups/${gidNum}/summaries`));
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
}, [gidNum]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
Promise.all([loadParticipants(), loadLogs(1)]).finally(() => setLoading(false));
|
|
||||||
}, [loadParticipants, loadLogs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tab === "impressions") loadImpressions();
|
|
||||||
if (tab === "summaries") loadSummaries();
|
|
||||||
}, [tab]);
|
|
||||||
|
|
||||||
/* ---------- 操作 ---------- */
|
|
||||||
const addParticipant = async () => {
|
|
||||||
const uid = Number(addUid.trim());
|
|
||||||
if (!uid) return;
|
|
||||||
try {
|
|
||||||
await api(`/groups/${gidNum}/participants`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ user_id: uid }),
|
|
||||||
});
|
|
||||||
setAddUid("");
|
|
||||||
loadParticipants();
|
|
||||||
addToast({ title: `已加入 ${uid}`, color: "success" });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeParticipant = async (uid: number) => {
|
|
||||||
if (!confirm(`移除 ${uid}?其语料保留在库中`)) return;
|
|
||||||
try {
|
|
||||||
await api(`/groups/${gidNum}/participants/${uid}`, { method: "DELETE" });
|
|
||||||
loadParticipants();
|
|
||||||
addToast({ title: "已移除", color: "success" });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearLogs = async () => {
|
|
||||||
if (!confirm("删除该群全部语料?印象/画像保留")) return;
|
|
||||||
try {
|
|
||||||
await api(`/groups/${gidNum}/logs`, { method: "DELETE" });
|
|
||||||
loadLogs(1);
|
|
||||||
addToast({ title: "已删除全部语料", color: "success" });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearGroup = async () => {
|
|
||||||
if (!confirm("确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复")) return;
|
|
||||||
try {
|
|
||||||
await api(`/groups/${gidNum}/data`, { method: "DELETE" });
|
|
||||||
addToast({ title: "已清空", color: "success" });
|
|
||||||
setTimeout(() => (window.location.hash = "#/groups"), 600);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pages = Math.max(1, Math.ceil(logTotal / 50));
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center py-24">
|
|
||||||
<Spinner label="加载中…" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-5">
|
|
||||||
<Link to="/groups">
|
|
||||||
<Button size="sm" variant="flat" isIconOnly aria-label="返回">
|
|
||||||
←
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-xl font-bold flex-1">群管理 #{gidNum}</h1>
|
|
||||||
<Button size="sm" color="danger" variant="flat" onPress={clearGroup}>
|
|
||||||
清空该群全部数据
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 参与者 */}
|
|
||||||
<Card className="bg-content1 mb-5">
|
|
||||||
<CardBody className="gap-3">
|
|
||||||
<h2 className="font-semibold">参与者</h2>
|
|
||||||
<div className="flex gap-2 max-w-md">
|
|
||||||
<Input
|
|
||||||
placeholder="QQ 号"
|
|
||||||
type="number"
|
|
||||||
value={addUid}
|
|
||||||
onValueChange={setAddUid}
|
|
||||||
variant="bordered"
|
|
||||||
size="sm"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && addParticipant()}
|
|
||||||
/>
|
|
||||||
<Button size="sm" color="primary" onPress={addParticipant}>
|
|
||||||
加入人设名单
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Table aria-label="参与者" removeWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableColumn>QQ</TableColumn>
|
|
||||||
<TableColumn>语料条数</TableColumn>
|
|
||||||
<TableColumn>操作</TableColumn>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody items={participants} emptyContent="暂无参与者">
|
|
||||||
{(p) => (
|
|
||||||
<TableRow key={p.user_id}>
|
|
||||||
<TableCell>{p.user_id}</TableCell>
|
|
||||||
<TableCell>{p.corpus_count}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button size="sm" color="danger" variant="light" onPress={() => removeParticipant(p.user_id)}>
|
|
||||||
移除
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 数据页签 */}
|
|
||||||
<Card className="bg-content1">
|
|
||||||
<CardBody>
|
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(k) => setTab(String(k))}>
|
|
||||||
<Tab key="logs" title="语料">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Input
|
|
||||||
placeholder="按 QQ 过滤"
|
|
||||||
type="number"
|
|
||||||
value={logUid}
|
|
||||||
onValueChange={setLogUid}
|
|
||||||
variant="bordered"
|
|
||||||
size="sm"
|
|
||||||
className="max-w-40"
|
|
||||||
/>
|
|
||||||
<Button size="sm" variant="flat" onPress={() => loadLogs(1)}>
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" color="danger" variant="flat" onPress={clearLogs}>
|
|
||||||
删除全部语料
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Table aria-label="语料" removeWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableColumn>ID</TableColumn>
|
|
||||||
<TableColumn>发送者</TableColumn>
|
|
||||||
<TableColumn>发给</TableColumn>
|
|
||||||
<TableColumn>时间</TableColumn>
|
|
||||||
<TableColumn>内容(已脱敏)</TableColumn>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody items={logs} emptyContent="暂无语料">
|
|
||||||
{(l) => (
|
|
||||||
<TableRow key={l.id}>
|
|
||||||
<TableCell>{l.id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div>{l.nickname || "—"}</div>
|
|
||||||
<div className="text-xs text-default-500">{l.user_id}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{l.target_user_id ? (
|
|
||||||
<span>
|
|
||||||
→ {l.target_user_id}
|
|
||||||
{l.target_inherited && (
|
|
||||||
<span className="text-xs text-warning ml-1">延续</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-default-500">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-default-500">{l.created_at}</TableCell>
|
|
||||||
<TableCell className="max-w-xl break-all">{l.content}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<div className="flex items-center gap-3 mt-3">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
isDisabled={logPage <= 1}
|
|
||||||
onPress={() => loadLogs(logPage - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-default-500">
|
|
||||||
第 {logPage}/{pages} 页 · 共 {logTotal} 条
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
isDisabled={logPage >= pages}
|
|
||||||
onPress={() => loadLogs(logPage + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Tab>
|
|
||||||
|
|
||||||
<Tab key="impressions" title="印象">
|
|
||||||
<Table aria-label="印象" removeWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableColumn>ID</TableColumn>
|
|
||||||
<TableColumn>用户</TableColumn>
|
|
||||||
<TableColumn>覆盖语料区间</TableColumn>
|
|
||||||
<TableColumn>时间</TableColumn>
|
|
||||||
<TableColumn>印象内容</TableColumn>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody items={impressions} emptyContent="暂无印象">
|
|
||||||
{(r) => (
|
|
||||||
<TableRow key={r.id}>
|
|
||||||
<TableCell>{r.id}</TableCell>
|
|
||||||
<TableCell>{r.user_id}</TableCell>
|
|
||||||
<TableCell className="text-default-500">
|
|
||||||
#{r.cover_from_id} ~ #{r.cover_to_id}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-default-500">{r.created_at}</TableCell>
|
|
||||||
<TableCell className="max-w-xl break-all">{r.content}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Tab>
|
|
||||||
|
|
||||||
<Tab key="summaries" title="画像">
|
|
||||||
{summaries.length === 0 ? (
|
|
||||||
<p className="text-default-500 py-6">暂无画像(语料积累后由总结路径生成)</p>
|
|
||||||
) : (
|
|
||||||
summaries.map((s) => (
|
|
||||||
<div key={s.version} className="mb-4">
|
|
||||||
<div className="text-sm text-default-500 mb-2">
|
|
||||||
v{s.version} · {s.created_at} · 语料 {s.corpus_count} 条 · 印象{" "}
|
|
||||||
{s.impression_count} 条
|
|
||||||
</div>
|
|
||||||
<pre className="bg-content2 border border-default-100 rounded-xl p-4 text-sm whitespace-pre-wrap break-all max-h-96 overflow-auto">
|
|
||||||
{s.card_text}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
Checkbox,
|
|
||||||
CheckboxGroup,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalContent,
|
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
|
||||||
Spinner,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableColumn,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
useDisclosure,
|
|
||||||
} from "@heroui/react";
|
|
||||||
import { addToast } from "@heroui/react";
|
|
||||||
import { api, DiscoverGroup, GroupInfo } from "../api";
|
|
||||||
|
|
||||||
export default function GroupList() {
|
|
||||||
const [groups, setGroups] = useState<GroupInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
// 添加群弹窗
|
|
||||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
|
||||||
const [discoverList, setDiscoverList] = useState<DiscoverGroup[]>([]);
|
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
|
||||||
const [discoverLoading, setDiscoverLoading] = useState(false);
|
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
setGroups(await api<GroupInfo[]>("/groups"));
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggle = async (g: GroupInfo, enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
await api(`/groups/${g.group_id}/enabled`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ enabled }),
|
|
||||||
});
|
|
||||||
g.enabled = enabled;
|
|
||||||
setGroups([...groups]);
|
|
||||||
addToast({
|
|
||||||
title: enabled ? "已开启采集" : "已关闭采集",
|
|
||||||
description: `${g.group_name || g.group_id}`,
|
|
||||||
color: enabled ? "success" : "warning",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeGroup = async (g: GroupInfo) => {
|
|
||||||
if (!confirm(`将 ${g.group_name || g.group_id} 移出管理列表?其数据保留`)) return;
|
|
||||||
try {
|
|
||||||
await api(`/groups/${g.group_id}`, { method: "DELETE" });
|
|
||||||
load();
|
|
||||||
addToast({ title: "已移出管理列表", color: "success" });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- 添加群 ---------- */
|
|
||||||
const openDiscover = async () => {
|
|
||||||
onOpen();
|
|
||||||
setDiscoverLoading(true);
|
|
||||||
setSelected([]);
|
|
||||||
try {
|
|
||||||
setDiscoverList(await api<DiscoverGroup[]>("/groups/discover"));
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
} finally {
|
|
||||||
setDiscoverLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmAdd = async () => {
|
|
||||||
const picked = discoverList.filter(
|
|
||||||
(g) => selected.includes(String(g.group_id)) && !g.managed
|
|
||||||
);
|
|
||||||
if (picked.length === 0) return;
|
|
||||||
setAdding(true);
|
|
||||||
try {
|
|
||||||
for (const g of picked) {
|
|
||||||
await api("/groups", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ group_id: g.group_id, group_name: g.group_name }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
addToast({ title: `已加入 ${picked.length} 个群`, color: "success" });
|
|
||||||
onOpenChange(false);
|
|
||||||
load();
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ title: e.message, color: "danger" });
|
|
||||||
} finally {
|
|
||||||
setAdding(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<h1 className="text-xl font-bold">群管理</h1>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button size="sm" variant="flat" onPress={load}>
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" color="primary" onPress={openDiscover}>
|
|
||||||
+ 添加群
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Card className="bg-content1">
|
|
||||||
<CardBody>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex justify-center py-16">
|
|
||||||
<Spinner label="加载中…" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<p className="text-danger text-center py-8">{error}</p>
|
|
||||||
) : (
|
|
||||||
<Table aria-label="管理群列表" removeWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableColumn>群</TableColumn>
|
|
||||||
<TableColumn>采集开关</TableColumn>
|
|
||||||
<TableColumn>参与者</TableColumn>
|
|
||||||
<TableColumn>语料</TableColumn>
|
|
||||||
<TableColumn>印象</TableColumn>
|
|
||||||
<TableColumn>画像</TableColumn>
|
|
||||||
<TableColumn>操作</TableColumn>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody items={groups} emptyContent="还没有管理的群,点右上角「添加群」">
|
|
||||||
{(g) => (
|
|
||||||
<TableRow key={g.group_id}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-medium">{g.group_name || `群 ${g.group_id}`}</div>
|
|
||||||
<div className="text-xs text-default-500">{g.group_id}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Switch size="sm" isSelected={g.enabled} onValueChange={(v) => toggle(g, v)} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{g.participants}</TableCell>
|
|
||||||
<TableCell>{g.corpus_count}</TableCell>
|
|
||||||
<TableCell>{g.impression_count}</TableCell>
|
|
||||||
<TableCell>{g.summary_count}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Link to={`/groups/${g.group_id}`}>
|
|
||||||
<Button size="sm" color="primary" variant="flat">
|
|
||||||
管理
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button size="sm" color="danger" variant="light" onPress={() => removeGroup(g)}>
|
|
||||||
移除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 添加群弹窗 */}
|
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="lg" scrollBehavior="inside">
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader>添加群到管理列表</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
{discoverLoading ? (
|
|
||||||
<div className="flex justify-center py-10">
|
|
||||||
<Spinner label="正在从 OneBot 获取群列表…" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CheckboxGroup value={selected} onValueChange={setSelected}>
|
|
||||||
{discoverList.map((g) => (
|
|
||||||
<div key={g.group_id} className="flex items-center justify-between py-1">
|
|
||||||
<Checkbox value={String(g.group_id)} isDisabled={g.managed}>
|
|
||||||
{g.group_name || `群 ${g.group_id}`}
|
|
||||||
<span className="text-xs text-default-500 ml-2">{g.group_id}</span>
|
|
||||||
</Checkbox>
|
|
||||||
{g.managed && <span className="text-xs text-success">已管理</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CheckboxGroup>
|
|
||||||
)}
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant="flat" onPress={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
isDisabled={selected.length === 0 || discoverLoading}
|
|
||||||
isLoading={adding}
|
|
||||||
onPress={confirmAdd}
|
|
||||||
>
|
|
||||||
加入管理({selected.filter((s) => !discoverList.find((g) => String(g.group_id) === s)?.managed).length})
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button, Card, CardBody, Input } from "@heroui/react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { api, setToken } from "../api";
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const data = await api<{ token: string }>("/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
setToken(data.token);
|
|
||||||
navigate("/groups", { replace: true });
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message || "登录失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex items-center justify-center bg-content2">
|
|
||||||
<Card className="w-80 bg-content1" shadow="lg">
|
|
||||||
<CardBody className="gap-4 p-8">
|
|
||||||
<h1 className="text-xl font-bold text-center mb-2">群聊人设卡</h1>
|
|
||||||
<Input
|
|
||||||
label="用户名"
|
|
||||||
value={username}
|
|
||||||
onValueChange={setUsername}
|
|
||||||
variant="bordered"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="密码"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onValueChange={setPassword}
|
|
||||||
variant="bordered"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
|
||||||
/>
|
|
||||||
{error && <p className="text-danger text-sm text-center">{error}</p>}
|
|
||||||
<Button color="primary" isLoading={loading} onPress={submit}>
|
|
||||||
登 录
|
|
||||||
</Button>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Card, CardBody, Spinner, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@heroui/react";
|
|
||||||
import { api, SettingsInfo } from "../api";
|
|
||||||
|
|
||||||
export default function Settings() {
|
|
||||||
const [info, setInfo] = useState<SettingsInfo | null>(null);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<SettingsInfo>("/settings")
|
|
||||||
.then(setInfo)
|
|
||||||
.catch((e: any) => setError(e.message));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold mb-5">设置</h1>
|
|
||||||
<Card className="bg-content1 max-w-2xl">
|
|
||||||
<CardBody>
|
|
||||||
{error ? (
|
|
||||||
<p className="text-danger">{error}</p>
|
|
||||||
) : !info ? (
|
|
||||||
<div className="flex justify-center py-12">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h2 className="font-semibold mb-3">当前配置(只读)</h2>
|
|
||||||
<Table aria-label="配置" removeWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableColumn>项</TableColumn>
|
|
||||||
<TableColumn>值</TableColumn>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow key="user">
|
|
||||||
<TableCell>Web 登录账号</TableCell>
|
|
||||||
<TableCell>{info.web_username}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow key="enable">
|
|
||||||
<TableCell>Web 后台启用</TableCell>
|
|
||||||
<TableCell>{info.web_enable ? "是" : "否"}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow key="len">
|
|
||||||
<TableCell>长文截断上限</TableCell>
|
|
||||||
<TableCell>{info.thresholds.max_content_len} 字</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow key="repeat">
|
|
||||||
<TableCell>复读检测窗口</TableCell>
|
|
||||||
<TableCell>{info.thresholds.repeat_window} 条</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow key="throttle">
|
|
||||||
<TableCell>刷屏采样间隔</TableCell>
|
|
||||||
<TableCell>{info.thresholds.throttle_seconds} 秒</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow key="rolling">
|
|
||||||
<TableCell>单用户语料滚动上限</TableCell>
|
|
||||||
<TableCell>{info.thresholds.rolling_window} 条</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<p className="text-default-500 text-sm mt-4">
|
|
||||||
阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME /
|
|
||||||
WEB_PASSWORD。
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { heroui } from "@heroui/theme";
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
darkMode: "class",
|
|
||||||
plugins: [heroui()],
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
base: "/galgame_card/",
|
|
||||||
plugins: [react()],
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。
|
"""群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。
|
||||||
|
|
||||||
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
|
统一鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
|
||||||
旧版独立后台 /galgame_card(JWT 自鉴权)仍保留可用,前端由 hub 渲染。
|
前端由统一管理台 hexi/web 渲染。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,10 +12,9 @@ from typing import Optional
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from hexi.web_auth import require_admin
|
from hexi.web_hub.web_auth import require_admin
|
||||||
|
|
||||||
from . import repository
|
from . import repository
|
||||||
from .config import config
|
|
||||||
|
|
||||||
API = require_admin
|
API = require_admin
|
||||||
|
|
||||||
@@ -42,11 +41,8 @@ async def _first_bot():
|
|||||||
def build_admin_app() -> FastAPI | None:
|
def build_admin_app() -> FastAPI | None:
|
||||||
"""构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。
|
"""构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。
|
||||||
|
|
||||||
在 hub 统一登录态下可用;未启用 Web 时返回 None。
|
鉴权由 hub 统一登录态提供(hexi.web_hub.web_auth)。
|
||||||
"""
|
"""
|
||||||
if not config.web_enable:
|
|
||||||
return None
|
|
||||||
|
|
||||||
app = FastAPI(title="Galgame Card API")
|
app = FastAPI(title="Galgame Card API")
|
||||||
|
|
||||||
@app.get("/groups", dependencies=[API])
|
@app.get("/groups", dependencies=[API])
|
||||||
@@ -213,8 +209,6 @@ def build_admin_app() -> FastAPI | None:
|
|||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
{
|
{
|
||||||
"web_username": config.web_username,
|
|
||||||
"web_enable": config.web_enable,
|
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"max_content_len": processor.MAX_CONTENT_LEN,
|
"max_content_len": processor.MAX_CONTENT_LEN,
|
||||||
"repeat_window": processor.REPEAT_WINDOW,
|
"repeat_window": processor.REPEAT_WINDOW,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from nonebot.params import CommandArg
|
|||||||
from nonebot.plugin import PluginMetadata
|
from nonebot.plugin import PluginMetadata
|
||||||
from nonebot.rule import to_me
|
from nonebot.rule import to_me
|
||||||
|
|
||||||
|
from ...core import message_utils
|
||||||
|
|
||||||
require("nonebot_plugin_alconna")
|
require("nonebot_plugin_alconna")
|
||||||
from nonebot_plugin_alconna import UniMessage # noqa: E402
|
from nonebot_plugin_alconna import UniMessage # noqa: E402
|
||||||
|
|
||||||
@@ -31,7 +33,6 @@ from .service import get_services, register_bot_adapter # noqa: E402
|
|||||||
from .renderer import html_render # noqa: E402
|
from .renderer import html_render # noqa: E402
|
||||||
from .templates import list_templates, template_exists # noqa: E402
|
from .templates import list_templates, template_exists # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
# ---------- 指令定义(全部要求 @ 机器人) ----------
|
# ---------- 指令定义(全部要求 @ 机器人) ----------
|
||||||
|
|
||||||
analysis_cmd = on_command(
|
analysis_cmd = on_command(
|
||||||
@@ -192,7 +193,7 @@ async def _(bot: Bot, event: GroupMessageEvent):
|
|||||||
|
|
||||||
group_id = str(event.group_id)
|
group_id = str(event.group_id)
|
||||||
days = _parse_days(event)
|
days = _parse_days(event)
|
||||||
await UniMessage.text("正在启动分析引擎,正在拉取最近消息...").send()
|
await message_utils.common_proc_reply(event.message_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
svc = get_services()
|
svc = get_services()
|
||||||
@@ -347,6 +348,7 @@ async def _(bot: Bot, event: GroupMessageEvent):
|
|||||||
f"用户称号: {'开' if cm.get_user_title_analysis_enabled() else '关'}",
|
f"用户称号: {'开' if cm.get_user_title_analysis_enabled() else '关'}",
|
||||||
f"金句分析: {'开' if cm.get_golden_quote_analysis_enabled() else '关'}",
|
f"金句分析: {'开' if cm.get_golden_quote_analysis_enabled() else '关'}",
|
||||||
f"聊天质量: {'开' if cm.get_chat_quality_analysis_enabled() else '关'}",
|
f"聊天质量: {'开' if cm.get_chat_quality_analysis_enabled() else '关'}",
|
||||||
|
f"本地记录: {'开' if cm.get_use_local_history() else '关'}",
|
||||||
"用法:设置分析 [参数] [值],如:设置分析 天数 3",
|
"用法:设置分析 [参数] [值],如:设置分析 天数 3",
|
||||||
]
|
]
|
||||||
await UniMessage.text(chr(10).join(lines)).send()
|
await UniMessage.text(chr(10).join(lines)).send()
|
||||||
@@ -362,8 +364,12 @@ _SETTING_MAP = {
|
|||||||
"称号": ("set_user_title_analysis_enabled", str, "用户称号已{}"),
|
"称号": ("set_user_title_analysis_enabled", str, "用户称号已{}"),
|
||||||
"金句": ("set_golden_quote_analysis_enabled", str, "金句分析已{}"),
|
"金句": ("set_golden_quote_analysis_enabled", str, "金句分析已{}"),
|
||||||
"聊天质量": ("set_chat_quality_analysis_enabled", str, "聊天质量已{}"),
|
"聊天质量": ("set_chat_quality_analysis_enabled", str, "聊天质量已{}"),
|
||||||
|
"本地记录": ("set_use_local_history", str, "本地记录已{}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 走开/关布尔语义的键(值为 _BOOL_WORDS 里的词)
|
||||||
|
_BOOL_KEYS = {"话题", "称号", "金句", "聊天质量", "本地记录"}
|
||||||
|
|
||||||
_BOOL_WORDS = {"开": True, "on": True, "true": True, "关": False, "off": False, "false": False}
|
_BOOL_WORDS = {"开": True, "on": True, "true": True, "关": False, "off": False, "false": False}
|
||||||
|
|
||||||
|
|
||||||
@@ -375,7 +381,9 @@ async def _(bot: Bot, event: GroupMessageEvent, args: tuple = CommandArg()):
|
|||||||
tokens = _cmd_tokens(args)
|
tokens = _cmd_tokens(args)
|
||||||
if len(tokens) < 2:
|
if len(tokens) < 2:
|
||||||
await UniMessage.text(
|
await UniMessage.text(
|
||||||
"用法:设置分析 [参数] [值]" + chr(10) + "参数:天数/窗口/最大消息/最小消息/输出格式/话题/称号/金句/聊天质量"
|
"用法:设置分析 [参数] [值]"
|
||||||
|
+ chr(10)
|
||||||
|
+ "参数:天数/窗口/最大消息/最小消息/输出格式/话题/称号/金句/聊天质量/本地记录"
|
||||||
).send()
|
).send()
|
||||||
return
|
return
|
||||||
key = tokens[0]
|
key = tokens[0]
|
||||||
@@ -396,7 +404,7 @@ async def _(bot: Bot, event: GroupMessageEvent, args: tuple = CommandArg()):
|
|||||||
await UniMessage.text(f"{key} 已设为 {v}").send()
|
await UniMessage.text(f"{key} 已设为 {v}").send()
|
||||||
elif conv is str:
|
elif conv is str:
|
||||||
v = val.lower()
|
v = val.lower()
|
||||||
if key in ("话题", "称号", "金句", "聊天质量"):
|
if key in _BOOL_KEYS:
|
||||||
if v not in _BOOL_WORDS:
|
if v not in _BOOL_WORDS:
|
||||||
await UniMessage.text("布尔值请填:开/关 或 on/off").send()
|
await UniMessage.text("布尔值请填:开/关 或 on/off").send()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from . import history_store
|
||||||
from .core.domain.value_objects.unified_group import UnifiedGroup, UnifiedMember
|
from .core.domain.value_objects.unified_group import UnifiedGroup, UnifiedMember
|
||||||
from .core.domain.value_objects.unified_message import (
|
from .core.domain.value_objects.unified_message import (
|
||||||
MessageContent,
|
MessageContent,
|
||||||
@@ -17,6 +15,29 @@ from .core.domain.value_objects.unified_message import (
|
|||||||
)
|
)
|
||||||
from .core.utils.logger import logger
|
from .core.utils.logger import logger
|
||||||
|
|
||||||
|
# 分页锚点字段链(后端差异实测):NapCat 的 message_seq 参数按 OB11
|
||||||
|
# message_id(shortId 短 ID 映射) 解析,传消息内的 message_seq(内核 msgSeq)
|
||||||
|
# 命中不了映射 → NapCat 抛"消息不存在",翻页断裂;go-cqhttp/LLOneBot 等
|
||||||
|
# 则识别 message_seq。优先 message_id,翻页无进度时依次回退。
|
||||||
|
ANCHOR_FIELDS = ("message_id", "message_seq", "seq", "real_id")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ts(ts: int) -> str:
|
||||||
|
"""时间戳转日志用的可读时间。"""
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
return str(ts)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_anchor(raw: dict[str, Any], fields: tuple[str, ...], start: int):
|
||||||
|
"""从消息中按字段优先级提取分页锚点值"""
|
||||||
|
for field in fields[start:]:
|
||||||
|
val = raw.get(field)
|
||||||
|
if val is not None:
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class OneBotAdapter:
|
class OneBotAdapter:
|
||||||
"""面向 NoneBot OneBot V11 的最小适配器。"""
|
"""面向 NoneBot OneBot V11 的最小适配器。"""
|
||||||
@@ -31,6 +52,8 @@ class OneBotAdapter:
|
|||||||
self.platform_id = str(self.config.get("platform_id") or "onebot")
|
self.platform_id = str(self.config.get("platform_id") or "onebot")
|
||||||
self.bot_self_ids = [str(x) for x in self.config.get("bot_self_ids", [])]
|
self.bot_self_ids = [str(x) for x in self.config.get("bot_self_ids", [])]
|
||||||
self.filter_bot_messages = bool(self.config.get("filter_bot_messages", True))
|
self.filter_bot_messages = bool(self.config.get("filter_bot_messages", True))
|
||||||
|
# 优先读本地消息库(learning_chat 落库),取不到再回退接口分页
|
||||||
|
self.use_local_history = bool(self.config.get("use_local_history", True))
|
||||||
|
|
||||||
# —— 消息拉取 ——
|
# —— 消息拉取 ——
|
||||||
async def fetch_messages(
|
async def fetch_messages(
|
||||||
@@ -52,8 +75,21 @@ class OneBotAdapter:
|
|||||||
start_ts = int(
|
start_ts = int(
|
||||||
(datetime.now() - timedelta(days=days)).timestamp()
|
(datetime.now() - timedelta(days=days)).timestamp()
|
||||||
)
|
)
|
||||||
|
end_ts = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
# 本地消息库优先(完整且毫秒级);before_id 无法映射到时间戳,故跳过
|
||||||
|
if self.use_local_history and not before_id:
|
||||||
|
local = await self._fetch_from_local_store(
|
||||||
|
group_id, start_ts, end_ts, max_count
|
||||||
|
)
|
||||||
|
if local:
|
||||||
|
return local
|
||||||
|
|
||||||
current_anchor = before_id
|
current_anchor = before_id
|
||||||
|
anchor_idx = 0
|
||||||
|
no_progress_pages = 0
|
||||||
|
last_earliest: dict[str, Any] | None = None
|
||||||
|
|
||||||
while len(all_raw) < max_count:
|
while len(all_raw) < max_count:
|
||||||
fetch_count = min(chunk_size, max_count - len(all_raw))
|
fetch_count = min(chunk_size, max_count - len(all_raw))
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
@@ -85,13 +121,34 @@ class OneBotAdapter:
|
|||||||
break
|
break
|
||||||
messages = result.get("messages", [])
|
messages = result.get("messages", [])
|
||||||
if not messages:
|
if not messages:
|
||||||
break
|
# 空页可能意味着当前锚点字段不被后端识别(如 NapCat 外的
|
||||||
|
# 实现收到 message_id),换下一字段重试一次,仍空则结束。
|
||||||
|
if current_anchor is None or last_earliest is None:
|
||||||
|
break
|
||||||
|
if no_progress_pages >= 1 or anchor_idx >= len(ANCHOR_FIELDS) - 1:
|
||||||
|
logger.warning(
|
||||||
|
"OneBot 分页拉取: 返回空页且锚点字段已耗尽,停止回溯"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
no_progress_pages += 1
|
||||||
|
anchor_idx += 1
|
||||||
|
current_anchor = _pick_anchor(
|
||||||
|
last_earliest, ANCHOR_FIELDS, anchor_idx
|
||||||
|
)
|
||||||
|
if current_anchor is None:
|
||||||
|
break
|
||||||
|
logger.warning(
|
||||||
|
f"OneBot 分页拉取: 空页,锚点字段切换为 {ANCHOR_FIELDS[anchor_idx]}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
first = messages[0]
|
first = messages[0]
|
||||||
last = messages[-1]
|
last = messages[-1]
|
||||||
earliest = first if first.get("time", 0) <= last.get("time", 0) else last
|
earliest = first if first.get("time", 0) <= last.get("time", 0) else last
|
||||||
|
last_earliest = earliest
|
||||||
chunk_earliest_ts = earliest.get("time", 0)
|
chunk_earliest_ts = earliest.get("time", 0)
|
||||||
|
|
||||||
|
prev_len = len(all_raw)
|
||||||
for raw in messages:
|
for raw in messages:
|
||||||
msg_time = raw.get("time", 0)
|
msg_time = raw.get("time", 0)
|
||||||
msg_id = str(raw.get("message_id", ""))
|
msg_id = str(raw.get("message_id", ""))
|
||||||
@@ -100,19 +157,42 @@ class OneBotAdapter:
|
|||||||
if start_ts <= msg_time <= int(datetime.now().timestamp()):
|
if start_ts <= msg_time <= int(datetime.now().timestamp()):
|
||||||
all_raw.append(raw)
|
all_raw.append(raw)
|
||||||
seen_raw_ids.add(msg_id)
|
seen_raw_ids.add(msg_id)
|
||||||
|
added = len(all_raw) - prev_len
|
||||||
|
|
||||||
seq_val = (
|
|
||||||
earliest.get("message_seq")
|
|
||||||
or earliest.get("real_id")
|
|
||||||
or earliest.get("seq")
|
|
||||||
)
|
|
||||||
mid_val = earliest.get("message_id")
|
|
||||||
new_anchor = seq_val if seq_val is not None else mid_val
|
|
||||||
if chunk_earliest_ts <= start_ts:
|
if chunk_earliest_ts <= start_ts:
|
||||||
|
logger.info(
|
||||||
|
f"OneBot 分页拉取: 已到达起始时间,共 {len(all_raw)} 条"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if added == 0:
|
||||||
|
# 本页没有新增消息:锚点不生效(后端不支持该字段)或数据已取尽。
|
||||||
|
# 先切换锚点字段再试一次,仍无新增则结束。
|
||||||
|
no_progress_pages += 1
|
||||||
|
if no_progress_pages >= 2:
|
||||||
|
logger.warning(
|
||||||
|
"OneBot 分页拉取: 连续 2 页无新增,停止回溯"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if anchor_idx < len(ANCHOR_FIELDS) - 1:
|
||||||
|
anchor_idx += 1
|
||||||
|
logger.warning(
|
||||||
|
f"OneBot 分页拉取: 锚点字段切换为 {ANCHOR_FIELDS[anchor_idx]}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
no_progress_pages = 0
|
||||||
|
|
||||||
|
new_anchor = _pick_anchor(earliest, ANCHOR_FIELDS, anchor_idx)
|
||||||
|
if new_anchor is None:
|
||||||
break
|
break
|
||||||
if current_anchor and str(new_anchor) == str(current_anchor):
|
if current_anchor and str(new_anchor) == str(current_anchor):
|
||||||
|
logger.info("OneBot 分页拉取: 锚点未位移,历史已取尽")
|
||||||
break
|
break
|
||||||
current_anchor = new_anchor
|
current_anchor = new_anchor
|
||||||
|
logger.info(
|
||||||
|
f"OneBot 分页拉取进度: {len(all_raw)} 条,"
|
||||||
|
f"锚点({ANCHOR_FIELDS[anchor_idx]}): {new_anchor}"
|
||||||
|
)
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
unified: list[UnifiedMessage] = []
|
unified: list[UnifiedMessage] = []
|
||||||
@@ -131,6 +211,54 @@ class OneBotAdapter:
|
|||||||
logger.warning(f"OneBot 分页获取消息失败: {e}")
|
logger.warning(f"OneBot 分页获取消息失败: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def _fetch_from_local_store(
|
||||||
|
self, group_id: str, start_ts: int, end_ts: int, max_count: int
|
||||||
|
) -> list[UnifiedMessage]:
|
||||||
|
"""从本地消息库(learning_chat 落库)取群历史;不可用时返回空以回退分页。"""
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
history_store.fetch_group_messages,
|
||||||
|
group_id,
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
max_count,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"本地历史读取失败,回退接口分页: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
logger.info(
|
||||||
|
f"本地历史无数据({result.error or '窗口内无消息'}),"
|
||||||
|
"回退 OneBot 分页拉取"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"本地历史记录拉取: group={group_id}, source={history_store.MESSAGE_TABLE}, "
|
||||||
|
f"count={len(result.messages)}, "
|
||||||
|
f"窗口=[{_fmt_ts(start_ts)}..{_fmt_ts(end_ts)}], "
|
||||||
|
f"名称覆盖={result.names_resolved}/{len(result.messages)}, "
|
||||||
|
f"去重={result.duplicates}"
|
||||||
|
)
|
||||||
|
if result.truncated:
|
||||||
|
window_total = (
|
||||||
|
result.window_total if result.window_total is not None else "?"
|
||||||
|
)
|
||||||
|
earliest = result.messages[0]["time"] if result.messages else end_ts
|
||||||
|
logger.warning(
|
||||||
|
f"本地历史截断: group={group_id}, max_messages={max_count}, "
|
||||||
|
f"窗口内共 {window_total} 条, 实际取最近 {len(result.messages)} 条, "
|
||||||
|
f"最早={_fmt_ts(earliest)}, 窗口起点={_fmt_ts(start_ts)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
unified: list[UnifiedMessage] = []
|
||||||
|
for raw in result.messages:
|
||||||
|
converted = self._convert_message(raw, group_id)
|
||||||
|
if converted:
|
||||||
|
unified.append(converted)
|
||||||
|
return unified
|
||||||
|
|
||||||
def _convert_message(self, raw: dict, group_id: str) -> UnifiedMessage | None:
|
def _convert_message(self, raw: dict, group_id: str) -> UnifiedMessage | None:
|
||||||
try:
|
try:
|
||||||
sender = raw.get("sender", {})
|
sender = raw.get("sender", {})
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ def _default_config() -> dict:
|
|||||||
cfg.setdefault("basic", {}).setdefault("max_messages", 1000)
|
cfg.setdefault("basic", {}).setdefault("max_messages", 1000)
|
||||||
cfg.setdefault("basic", {}).setdefault("min_messages_threshold", 50)
|
cfg.setdefault("basic", {}).setdefault("min_messages_threshold", 50)
|
||||||
cfg.setdefault("basic", {}).setdefault("filter_bot_messages", True)
|
cfg.setdefault("basic", {}).setdefault("filter_bot_messages", True)
|
||||||
|
# 优先读本地消息库(learning_chat 落库),取不到再回退接口分页
|
||||||
|
cfg.setdefault("basic", {}).setdefault("use_local_history", True)
|
||||||
cfg.setdefault("analysis_features", {}).setdefault("chat_quality_analysis_enabled", False)
|
cfg.setdefault("analysis_features", {}).setdefault("chat_quality_analysis_enabled", False)
|
||||||
cfg.setdefault("incremental", {}).setdefault("incremental_enabled", False)
|
cfg.setdefault("incremental", {}).setdefault("incremental_enabled", False)
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
@@ -770,6 +770,15 @@ class ConfigManager:
|
|||||||
self._ensure_group("basic")["filter_bot_messages"] = enabled
|
self._ensure_group("basic")["filter_bot_messages"] = enabled
|
||||||
self.config.save_config()
|
self.config.save_config()
|
||||||
|
|
||||||
|
def get_use_local_history(self) -> bool:
|
||||||
|
"""获取是否优先从本地消息库读取群历史。"""
|
||||||
|
return self._get_group("basic").get("use_local_history", True)
|
||||||
|
|
||||||
|
def set_use_local_history(self, enabled: bool):
|
||||||
|
"""设置是否优先从本地消息库读取群历史(关闭则始终走接口分页)。"""
|
||||||
|
self._ensure_group("basic")["use_local_history"] = enabled
|
||||||
|
self.config.save_config()
|
||||||
|
|
||||||
def get_html_output_dir(self) -> str:
|
def get_html_output_dir(self) -> str:
|
||||||
"""获取HTML输出目录"""
|
"""获取HTML输出目录"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""本地群历史记录源:只读读取 learning_chat 落库的群消息。
|
||||||
|
|
||||||
|
`nonebot_plugin_learning_chat` 会把机器人收到的每条群消息写入
|
||||||
|
`learning_chat_message`(与该群是否开启学习无关),比 OneBot
|
||||||
|
`get_group_msg_history` 分页更完整——部分后端只能取回一页就被截断。
|
||||||
|
昵称/群名片从同库的 `nonebot_plugin_uninfo_*` 表离线补齐,不额外调接口。
|
||||||
|
|
||||||
|
模块只依赖标准库(logger 做守卫导入),便于单测用 importlib 裸加载;
|
||||||
|
读连接一律 `mode=ro`(绝不建库),任何失败都返回带 error 的空结果。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try: # 插件包内导入:走插件 logger
|
||||||
|
from .core.utils.logger import logger
|
||||||
|
except Exception: # 单测裸加载(无包上下文)时降级为标准库 logger
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MESSAGE_TABLE = "learning_chat_message"
|
||||||
|
# nonebot_plugin_uninfo 的 SceneType.GROUP
|
||||||
|
SCENE_TYPE_GROUP = 1
|
||||||
|
# 显式指定本地消息库位置(测试或非默认部署用)
|
||||||
|
DB_PATH_ENV = "HEXI_GROUP_DAILY_HISTORY_DB"
|
||||||
|
|
||||||
|
_MESSAGE_SQL = (
|
||||||
|
"SELECT id, user_id, message_id, raw_message, message, plain_text, time "
|
||||||
|
f"FROM {MESSAGE_TABLE} "
|
||||||
|
"WHERE group_id = ? AND time >= ? AND time <= ? "
|
||||||
|
"ORDER BY time DESC, id DESC LIMIT ?"
|
||||||
|
)
|
||||||
|
_COUNT_SQL = (
|
||||||
|
f"SELECT COUNT(*) FROM {MESSAGE_TABLE} "
|
||||||
|
"WHERE group_id = ? AND time >= ? AND time <= ?"
|
||||||
|
)
|
||||||
|
# 同库 uninfo 三表:拿本群成员的 QQ 昵称与群名片
|
||||||
|
_NAMES_SQL = (
|
||||||
|
"SELECT u.user_id AS user_id, "
|
||||||
|
"u.user_data AS user_data, "
|
||||||
|
"s.member_data AS member_data "
|
||||||
|
"FROM nonebot_plugin_uninfo_scenemodel AS sc "
|
||||||
|
"JOIN nonebot_plugin_uninfo_sessionmodel AS s "
|
||||||
|
"ON s.scene_persist_id = sc.id "
|
||||||
|
"JOIN nonebot_plugin_uninfo_usermodel AS u "
|
||||||
|
"ON u.id = s.user_persist_id "
|
||||||
|
"WHERE sc.scene_id = ? AND sc.scene_type = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LocalHistoryResult:
|
||||||
|
"""本地历史读取结果;空 messages 表示需要回退到接口分页。"""
|
||||||
|
|
||||||
|
messages: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
truncated: bool = False
|
||||||
|
window_total: int | None = None
|
||||||
|
names_resolved: int = 0
|
||||||
|
duplicates: int = 0
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return bool(self.messages)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sqlite_url(raw: Any) -> Path | None:
|
||||||
|
"""从 SQLAlchemy 数据库配置里解析出 sqlite 文件路径。"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
text = str(raw).strip()
|
||||||
|
if not text.startswith("sqlite") or "memory" in text:
|
||||||
|
return None
|
||||||
|
# sqlite+aiosqlite:///D:/path/db.sqlite3 或 sqlite:///./rel/db.sqlite3
|
||||||
|
_, _, tail = text.partition("///")
|
||||||
|
tail = tail.strip()
|
||||||
|
if not tail:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Path(tail).expanduser()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _driver_db_path() -> Path | None:
|
||||||
|
"""读取 `SQLALCHEMY_DATABASE_URL`(环境变量优先,其次 .env 配置)。"""
|
||||||
|
for key in ("SQLALCHEMY_DATABASE_URL", "sqlalchemy_database_url"):
|
||||||
|
raw = os.environ.get(key)
|
||||||
|
if raw:
|
||||||
|
return _parse_sqlite_url(raw)
|
||||||
|
try:
|
||||||
|
from nonebot import get_driver
|
||||||
|
|
||||||
|
raw = getattr(get_driver().config, "sqlalchemy_database_url", None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return _parse_sqlite_url(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _localstore_db_path() -> Path | None:
|
||||||
|
"""nonebot_plugin_orm 默认库:localstore 数据目录下的 db.sqlite3。"""
|
||||||
|
try:
|
||||||
|
from nonebot_plugin_localstore import get_data_dir
|
||||||
|
|
||||||
|
return get_data_dir("nonebot_plugin_orm") / "db.sqlite3"
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_db_path() -> Path:
|
||||||
|
"""解析本地消息库路径:环境变量 → ORM 配置 → localstore → 仓库默认位置。"""
|
||||||
|
override = os.environ.get(DB_PATH_ENV)
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser()
|
||||||
|
for candidate in (_driver_db_path(), _localstore_db_path()):
|
||||||
|
if candidate is not None:
|
||||||
|
return candidate
|
||||||
|
# hexi/plugins/<plugin>/history_store.py -> hexi/
|
||||||
|
return (
|
||||||
|
Path(__file__).resolve().parents[2]
|
||||||
|
/ "data"
|
||||||
|
/ "nonebot_plugin_orm"
|
||||||
|
/ "db.sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_readonly(path: Path) -> sqlite3.Connection:
|
||||||
|
"""只读打开。路径可能含空格,必须用 as_uri 转义后的 file URI。"""
|
||||||
|
con = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=3.0)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
return con
|
||||||
|
|
||||||
|
|
||||||
|
def _loads(raw: Any) -> dict[str, Any]:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_display_names(con: sqlite3.Connection, group_id: int) -> dict[str, tuple[str, str | None]]:
|
||||||
|
"""取本群成员的 (昵称, 群名片);uninfo 表缺失时返回空表。"""
|
||||||
|
names: dict[str, tuple[str, str | None]] = {}
|
||||||
|
for row in con.execute(_NAMES_SQL, (str(group_id), SCENE_TYPE_GROUP)):
|
||||||
|
user_data = _loads(row["user_data"])
|
||||||
|
member_data = _loads(row["member_data"])
|
||||||
|
nickname = str(user_data.get("name") or "")
|
||||||
|
card = str(member_data.get("nick") or "") or None
|
||||||
|
if nickname or card:
|
||||||
|
names[str(row["user_id"])] = (nickname, card)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cq(cq: str) -> list[dict[str, Any]]:
|
||||||
|
"""CQ 串转 OneBot 消息段(复用适配器自带解析,含反转义)。"""
|
||||||
|
try:
|
||||||
|
from nonebot.adapters.onebot.v11 import Message
|
||||||
|
|
||||||
|
return [{"type": seg.type, "data": dict(seg.data)} for seg in Message(cq)]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"本地历史 CQ 解析失败,降级为纯文本: {exc}")
|
||||||
|
return [{"type": "text", "data": {"text": cq}}]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_raw_message(
|
||||||
|
row: sqlite3.Row, names: dict[str, tuple[str, str | None]]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""组装成 adapter._convert_message 能直接消费的 OneBot 原始结构。"""
|
||||||
|
message_id = row["message_id"]
|
||||||
|
if message_id is None:
|
||||||
|
return None
|
||||||
|
user_id = str(row["user_id"])
|
||||||
|
nickname, card = names.get(user_id, ("", None))
|
||||||
|
cq = row["raw_message"] or row["message"] or row["plain_text"] or ""
|
||||||
|
return {
|
||||||
|
"message_id": message_id,
|
||||||
|
"time": int(row["time"] or 0),
|
||||||
|
"sender": {"user_id": user_id, "nickname": nickname, "card": card or ""},
|
||||||
|
"message": _parse_cq(cq),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_group_messages(
|
||||||
|
group_id: str | int,
|
||||||
|
start_ts: int,
|
||||||
|
end_ts: int,
|
||||||
|
limit: int,
|
||||||
|
db_path: Path | str | None,
|
||||||
|
) -> LocalHistoryResult:
|
||||||
|
try:
|
||||||
|
gid = int(group_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return LocalHistoryResult(error=f"群号非法: {group_id!r}")
|
||||||
|
try:
|
||||||
|
row_limit = max(1, int(limit))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
row_limit = 1000
|
||||||
|
|
||||||
|
path = Path(db_path) if db_path is not None else resolve_db_path()
|
||||||
|
if not path.exists():
|
||||||
|
return LocalHistoryResult(error=f"本地消息库不存在: {path}")
|
||||||
|
|
||||||
|
start, end = int(start_ts), int(end_ts)
|
||||||
|
con = _open_readonly(path)
|
||||||
|
try:
|
||||||
|
# 多取一条用于判定截断(多出来的那条正是窗口内最旧的消息)
|
||||||
|
rows = con.execute(_MESSAGE_SQL, (gid, start, end, row_limit + 1)).fetchall()
|
||||||
|
truncated = len(rows) > row_limit
|
||||||
|
if truncated:
|
||||||
|
rows = rows[:row_limit]
|
||||||
|
window_total = None
|
||||||
|
if truncated:
|
||||||
|
try:
|
||||||
|
window_total = int(con.execute(_COUNT_SQL, (gid, start, end)).fetchone()[0])
|
||||||
|
except (sqlite3.Error, TypeError, ValueError):
|
||||||
|
window_total = None
|
||||||
|
try:
|
||||||
|
names = _load_display_names(con, gid)
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.debug(f"本地历史昵称补齐失败(忽略): {exc}")
|
||||||
|
names = {}
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
messages: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
duplicates = 0
|
||||||
|
names_resolved = 0
|
||||||
|
for row in rows:
|
||||||
|
raw = _build_raw_message(row, names)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
message_id = str(raw["message_id"])
|
||||||
|
if not message_id:
|
||||||
|
continue
|
||||||
|
if message_id in seen:
|
||||||
|
duplicates += 1
|
||||||
|
continue
|
||||||
|
seen.add(message_id)
|
||||||
|
sender = raw["sender"]
|
||||||
|
if sender["nickname"] or sender["card"]:
|
||||||
|
names_resolved += 1
|
||||||
|
messages.append(raw)
|
||||||
|
# 查询按 (time, id) 倒序取最近 N 条,翻回时间升序
|
||||||
|
messages.reverse()
|
||||||
|
return LocalHistoryResult(
|
||||||
|
messages=messages,
|
||||||
|
truncated=truncated,
|
||||||
|
window_total=window_total,
|
||||||
|
names_resolved=names_resolved,
|
||||||
|
duplicates=duplicates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_group_messages(
|
||||||
|
group_id: str | int,
|
||||||
|
start_ts: int,
|
||||||
|
end_ts: int,
|
||||||
|
limit: int = 1000,
|
||||||
|
db_path: Path | str | None = None,
|
||||||
|
) -> LocalHistoryResult:
|
||||||
|
"""读取 [start_ts, end_ts] 闭区间内的群消息(时间升序,最多 limit 条)。
|
||||||
|
|
||||||
|
同步函数,调用方用 `asyncio.to_thread` 包;失败只返回带 error 的空结果。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _fetch_group_messages(group_id, start_ts, end_ts, limit, db_path)
|
||||||
|
except Exception as exc: # 兜底:本地库问题不该影响分析主链路
|
||||||
|
logger.warning(f"本地历史读取异常: {exc}", exc_info=True)
|
||||||
|
return LocalHistoryResult(error=str(exc))
|
||||||
@@ -119,6 +119,7 @@ def register_bot_adapter(bot: Any, platform_id: str | None = None) -> OneBotAdap
|
|||||||
"platform_id": platform_id or "onebot",
|
"platform_id": platform_id or "onebot",
|
||||||
"bot_self_ids": bot_self_ids,
|
"bot_self_ids": bot_self_ids,
|
||||||
"filter_bot_messages": config_manager.get_filter_bot_messages(),
|
"filter_bot_messages": config_manager.get_filter_bot_messages(),
|
||||||
|
"use_local_history": config_manager.get_use_local_history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
bot_manager.register_adapter(adapter, platform_id or "onebot")
|
bot_manager.register_adapter(adapter, platform_id or "onebot")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import sys as _sys
|
import sys as _sys
|
||||||
|
|
||||||
from hexi.config_standard import register_config_items
|
from hexi.web_hub.config_standard import register_config_items
|
||||||
|
|
||||||
from . import constants
|
from . import constants
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
|
|||||||
from nonebot.rule import Rule, is_type
|
from nonebot.rule import Rule, is_type
|
||||||
from nonebot_plugin_alconna import UniMessage
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
|
from hexi.core.custom_utils import check_manage
|
||||||
|
|
||||||
from ...services.moderation import group_silence, member_silence, silence
|
from ...services.moderation import group_silence, member_silence, silence
|
||||||
from ...utils.message import get_at_id, has_at_all
|
from ...utils.message import get_at_id, has_at_all
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from nonebot import on_fullmatch, on_startswith, require
|
|||||||
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
|
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
|
||||||
from nonebot_plugin_alconna import UniMessage
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
|
from hexi.core.custom_utils import check_manage
|
||||||
|
|
||||||
from ...services.profile import card_edit, group_name, title_get
|
from ...services.profile import card_edit, group_name, title_get
|
||||||
from ...utils.message import get_at_id
|
from ...utils.message import get_at_id
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
|
|||||||
|
|
||||||
from nonebot_plugin_alconna import UniMessage
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
|
from hexi.core.custom_utils import check_manage
|
||||||
|
|
||||||
from ..utils.permissions import is_bot_manage, self_member_info
|
from ..utils.permissions import is_bot_manage, self_member_info
|
||||||
from ..utils.text import parse_seconds
|
from ..utils.text import parse_seconds
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
|
|||||||
|
|
||||||
from nonebot_plugin_alconna import UniMessage
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
|
from hexi.core.custom_utils import check_manage
|
||||||
|
|
||||||
from ..utils.permissions import is_bot_manage, self_member_info
|
from ..utils.permissions import is_bot_manage, self_member_info
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
|
|||||||
|
|
||||||
from nonebot_plugin_alconna import UniMessage
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import (
|
from hexi.core.custom_utils import (
|
||||||
check_bot_admin,
|
check_bot_admin,
|
||||||
check_manage,
|
check_manage,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from .utils.war_renderer import render_war_briefing # noqa: F401
|
|||||||
from .utils import gen_ms_img, pic2b64 # noqa: F401
|
from .utils import gen_ms_img, pic2b64 # noqa: F401
|
||||||
from .services.equipment_store import init_db as _init_equipment_db
|
from .services.equipment_store import init_db as _init_equipment_db
|
||||||
from .web import stratagem_admin as _stratagem_admin
|
from .web import stratagem_admin as _stratagem_admin
|
||||||
from hexi.web_plugin_registry import register_web_plugin
|
from hexi.web_hub.web_plugin_registry import register_web_plugin
|
||||||
|
|
||||||
__plugin_meta__ = PluginMetadata(
|
__plugin_meta__ = PluginMetadata(
|
||||||
name="绝地潜兵小助手",
|
name="绝地潜兵小助手",
|
||||||
@@ -47,7 +47,7 @@ register_web_plugin(
|
|||||||
logger.info("战备管理 Web 插件已注册(/api/helldivers)")
|
logger.info("战备管理 Web 插件已注册(/api/helldivers)")
|
||||||
|
|
||||||
# 统一配置标准:把 API 相关配置暴露给 Web 管理台(/hub 插件配置)
|
# 统一配置标准:把 API 相关配置暴露给 Web 管理台(/hub 插件配置)
|
||||||
from hexi.config_standard import register_model_config # noqa: E402
|
from hexi.web_hub.config_standard import register_model_config # noqa: E402
|
||||||
from .config import config as _hd2_config # noqa: E402
|
from .config import config as _hd2_config # noqa: E402
|
||||||
|
|
||||||
register_model_config(
|
register_model_config(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from nonebot.internal.params import ArgPlainText
|
|||||||
from nonebot.matcher import Matcher
|
from nonebot.matcher import Matcher
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from hexi.core import message_utils
|
||||||
from ..services.equipment import get_equipment_by_combination, get_random_equipment
|
from ..services.equipment import get_equipment_by_combination, get_random_equipment
|
||||||
from ..services.hd2_api import get_briefing_data
|
from ..services.hd2_api import get_briefing_data
|
||||||
from ..utils import gen_ms_img, pic2b64
|
from ..utils import gen_ms_img, pic2b64
|
||||||
@@ -24,7 +25,7 @@ _IMG_DIR = _BASE_DIR / "res" / "img"
|
|||||||
|
|
||||||
|
|
||||||
async def _send_war_card(matcher: Matcher, ev: MessageEvent, top_per_race: Optional[int]) -> None:
|
async def _send_war_card(matcher: Matcher, ev: MessageEvent, top_per_race: Optional[int]) -> None:
|
||||||
await matcher.send("正在获取前线战况,请民主的等待!")
|
await message_utils.common_proc_reply(ev.message_id)
|
||||||
try:
|
try:
|
||||||
data = await get_briefing_data()
|
data = await get_briefing_data()
|
||||||
png = await render_war_briefing(data, top_per_race)
|
png = await render_war_briefing(data, top_per_race)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -153,14 +153,34 @@ def count_stratagems() -> int:
|
|||||||
return int(conn.execute("SELECT COUNT(*) AS n FROM stratagems").fetchone()["n"])
|
return int(conn.execute("SELECT COUNT(*) AS n FROM stratagems").fetchone()["n"])
|
||||||
|
|
||||||
|
|
||||||
def list_stratagems_page(page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
|
def list_stratagems_page(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
keyword: str = "",
|
||||||
|
type: str = "",
|
||||||
|
backpack: str = "",
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
init_db()
|
init_db()
|
||||||
total = count_stratagems()
|
where: list[str] = []
|
||||||
offset = (page - 1) * page_size
|
params: list[Any] = []
|
||||||
|
kw = (keyword or "").strip()
|
||||||
|
if kw:
|
||||||
|
like = f"%{kw}%"
|
||||||
|
where.append("(name LIKE ? OR name_en LIKE ? OR alias LIKE ?)")
|
||||||
|
params.extend([like, like, like])
|
||||||
|
if type:
|
||||||
|
where.append("type = ?")
|
||||||
|
params.append(type)
|
||||||
|
if backpack in ("0", "1"):
|
||||||
|
where.append("backpack = ?")
|
||||||
|
params.append(int(backpack))
|
||||||
|
where_sql = (" WHERE " + " AND ".join(where)) if where else ""
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
|
total = int(conn.execute(f"SELECT COUNT(*) FROM stratagems{where_sql}", params).fetchone()[0])
|
||||||
|
offset = (page - 1) * page_size
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM stratagems ORDER BY id LIMIT ? OFFSET ?",
|
f"SELECT * FROM stratagems{where_sql} ORDER BY id LIMIT ? OFFSET ?",
|
||||||
(page_size, offset),
|
(*params, page_size, offset),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_row_to_item(r) for r in rows], total
|
return [_row_to_item(r) for r in rows], total
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,20 @@ def _equip_icon(equip: dict[str, Any]) -> str:
|
|||||||
raw = equip.get("path") or ""
|
raw = equip.get("path") or ""
|
||||||
if not raw:
|
if not raw:
|
||||||
return ""
|
return ""
|
||||||
p = basic_path / raw.replace("\\", "/")
|
rel = raw.replace("\\", "/")
|
||||||
if not p.exists():
|
# 兼容两种路径存法:插件根相对路径 res/img/...(新/播种数据),
|
||||||
return ""
|
# 以及 res/img 目录相对路径 img/...(旧库迁移前遗留),统一找真实文件。
|
||||||
return f"<img class='eq-ic' src='{_rel(p)}' alt='' />"
|
candidates = [rel]
|
||||||
|
if rel.startswith("res/"):
|
||||||
|
candidates.append(rel[len("res/"):])
|
||||||
|
else:
|
||||||
|
candidates.append("res/" + rel)
|
||||||
|
candidates.append("res/img/" + rel)
|
||||||
|
for cand in candidates:
|
||||||
|
p = basic_path / cand
|
||||||
|
if p.exists():
|
||||||
|
return f"<img class='eq-ic' src='{_rel(p)}' alt='' />"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _item_html(equip: dict[str, Any]) -> str:
|
def _item_html(equip: dict[str, Any]) -> str:
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""SVG -> PNG 转换 + 可选 HD2 风格边框,用于战备图标上传。"""
|
"""SVG -> PNG 转换。
|
||||||
|
|
||||||
|
与 docs/HD2/run.py 一致:把上传的单张 SVG 按输出尺寸(默认 550x550)栅格化成
|
||||||
|
透明背景 PNG,不做批量,也不叠加边框。
|
||||||
|
|
||||||
|
优先用 cairosvg(与 run.py 完全一致);若运行环境缺 cairo 原生库,则回退到
|
||||||
|
Chromium canvas 渲染(透明背景、同尺寸),保证 Web 后台上传始终可用。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import io
|
import logging
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cairosvg # type: ignore
|
||||||
|
_HAS_CAIROSVG = True
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
cairosvg = None
|
||||||
|
_HAS_CAIROSVG = False
|
||||||
|
|
||||||
SCHEMA_SIZE = 550 # 与随包 PNG 图标一致
|
SCHEMA_SIZE = 550 # 与随包 PNG 图标一致
|
||||||
TYPE_COLORS = {
|
|
||||||
"blue": "#6bb7ea",
|
|
||||||
"red": "#fe6d6a",
|
|
||||||
"green": "#7ecb3f",
|
|
||||||
}
|
|
||||||
|
|
||||||
basic_path = Path(__file__).resolve().parents[1]
|
basic_path = Path(__file__).resolve().parents[1]
|
||||||
IMG_DIR = basic_path / "res" / "img" / "helldivers"
|
IMG_DIR = basic_path / "res" / "img" / "helldivers"
|
||||||
@@ -23,49 +32,13 @@ IMG_DIR = basic_path / "res" / "img" / "helldivers"
|
|||||||
|
|
||||||
def sanitize_filename(name: str) -> str:
|
def sanitize_filename(name: str) -> str:
|
||||||
"""把战备名转成安全的 Windows 文件名(保留中文/空格,去掉非法字符)。"""
|
"""把战备名转成安全的 Windows 文件名(保留中文/空格,去掉非法字符)。"""
|
||||||
s = re.sub(r'[\\/:*?"<>|]', "_", name).strip()
|
s = re.sub(r'[\/:*?"<>|]', "_", name).strip()
|
||||||
s = re.sub(r"\s+", " ", s)
|
s = re.sub(r"\s+", " ", s)
|
||||||
return s or "strategem"
|
return s or "strategem"
|
||||||
|
|
||||||
|
|
||||||
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
async def _render_svg_canvas(svg_text: str, size: int) -> bytes:
|
||||||
h = hex_color.lstrip("#")
|
"""用 Chromium canvas 把 SVG 光栅化为带透明通道的 PNG(兜底方案)。"""
|
||||||
if len(h) == 3:
|
|
||||||
h = "".join(c * 2 for c in h)
|
|
||||||
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_frame(src_png: bytes, size: int, frame_color: str) -> bytes:
|
|
||||||
"""把透明的 SVG 画面叠到 HD2 风格画布上:彩色边框 + 深色内底 + 居中图形。"""
|
|
||||||
rgb = _hex_to_rgb(frame_color)
|
|
||||||
img = Image.open(io.BytesIO(src_png)).convert("RGBA")
|
|
||||||
canvas = Image.new("RGBA", (size, size), rgb + (255,))
|
|
||||||
draw = ImageDraw.Draw(canvas)
|
|
||||||
border = max(10, size // 20)
|
|
||||||
# 外圈用类型色,内部深色
|
|
||||||
draw.rectangle([0, 0, size - 1, size - 1], fill=rgb + (255,))
|
|
||||||
draw.rectangle([border, border, size - 1 - border, size - 1 - border], fill=(12, 12, 12, 255))
|
|
||||||
# 去掉透明边距再居中
|
|
||||||
alpha = img.getchannel("A")
|
|
||||||
bbox = alpha.getbbox()
|
|
||||||
glyph = img.crop(bbox) if bbox else img
|
|
||||||
inner = size - 2 * border
|
|
||||||
avail = int(inner * 0.82)
|
|
||||||
glyph.thumbnail((avail, avail), Image.LANCZOS)
|
|
||||||
ox = (size - glyph.width) // 2
|
|
||||||
oy = (size - glyph.height) // 2
|
|
||||||
canvas.alpha_composite(glyph, (ox, oy))
|
|
||||||
out = io.BytesIO()
|
|
||||||
canvas.save(out, format="PNG")
|
|
||||||
return out.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
async def _render_svg(svg_text: str, size: int) -> bytes:
|
|
||||||
"""用 Chromium 的 canvas 把 SVG 光栅化为带透明通道的 PNG。
|
|
||||||
|
|
||||||
用 toDataURL 导出而非截图,避免 omit_background 在部分环境把透明背景
|
|
||||||
拍成白色,从而保住透明 alpha。
|
|
||||||
"""
|
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
data_uri = "data:image/svg+xml;base64," + base64.b64encode(svg_text.encode("utf-8")).decode()
|
data_uri = "data:image/svg+xml;base64," + base64.b64encode(svg_text.encode("utf-8")).decode()
|
||||||
@@ -92,17 +65,28 @@ async def _render_svg(svg_text: str, size: int) -> bytes:
|
|||||||
await page.set_content("<!-- svg to png -->")
|
await page.set_content("<!-- svg to png -->")
|
||||||
data_url = await page.evaluate(script)
|
data_url = await page.evaluate(script)
|
||||||
await browser.close()
|
await browser.close()
|
||||||
|
if not data_url:
|
||||||
|
raise ValueError("SVG 转换失败")
|
||||||
return base64.b64decode(data_url.split(",", 1)[1])
|
return base64.b64decode(data_url.split(",", 1)[1])
|
||||||
|
|
||||||
|
|
||||||
async def svg_to_png(svg_text: str, framed: bool = False, frame_color: str | None = None, size: int = SCHEMA_SIZE) -> bytes:
|
async def svg_to_png(svg_text: str, size: int = SCHEMA_SIZE) -> bytes:
|
||||||
"""SVG -> PNG。framed=True 时叠加 HD2 风格边框到类型色。"""
|
"""SVG -> PNG(透明背景,默认 550x550)。
|
||||||
|
|
||||||
|
参考 docs/HD2/run.py:cairosvg.svg2png 按 output_width/output_height 栅格化到目标尺寸。
|
||||||
|
"""
|
||||||
if not svg_text.strip():
|
if not svg_text.strip():
|
||||||
raise ValueError("SVG 内容为空")
|
raise ValueError("SVG 内容为空")
|
||||||
png = await _render_svg(svg_text, size)
|
if _HAS_CAIROSVG:
|
||||||
if framed:
|
try:
|
||||||
png = _apply_frame(png, size, frame_color or TYPE_COLORS["blue"])
|
return cairosvg.svg2png(
|
||||||
return png
|
bytestring=svg_text.encode("utf-8"),
|
||||||
|
output_width=size,
|
||||||
|
output_height=size,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning(f"cairosvg 转换失败,改用 canvas 兜底: {type(e).__name__}: {e}")
|
||||||
|
return await _render_svg_canvas(svg_text, size)
|
||||||
|
|
||||||
|
|
||||||
def save_icon_png(png_bytes: bytes, name: str) -> str:
|
def save_icon_png(png_bytes: bytes, name: str) -> str:
|
||||||
@@ -111,4 +95,4 @@ def save_icon_png(png_bytes: bytes, name: str) -> str:
|
|||||||
filename = sanitize_filename(name) + ".png"
|
filename = sanitize_filename(name) + ".png"
|
||||||
dest = IMG_DIR / filename
|
dest = IMG_DIR / filename
|
||||||
dest.write_bytes(png_bytes)
|
dest.write_bytes(png_bytes)
|
||||||
return f"res/img/helldivers/{filename}"
|
return f"res/img/helldivers/{filename}"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""战备管理 API:作为统一 Web 的一个模块,挂到 /api/helldivers。
|
"""战备管理 API:作为统一 Web 的一个模块,挂到 /api/helldivers。
|
||||||
|
|
||||||
- 不再自己出 HTML,页面由统一前端 (hexi/web) 渲染。
|
- 不再自己出 HTML,页面由统一前端 (hexi/web) 渲染。
|
||||||
- 鉴权统一走 hexi.web_auth(OAuth2 + SQLite)。
|
- 鉴权统一走 hexi.web_hub.web_auth(OAuth2 + SQLite)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
from hexi.web_auth import require_admin
|
from hexi.web_hub.web_auth import require_admin
|
||||||
|
|
||||||
from ..services.equipment_store import (
|
from ..services.equipment_store import (
|
||||||
create_stratagem,
|
create_stratagem,
|
||||||
@@ -24,7 +24,7 @@ from ..services.equipment_store import (
|
|||||||
list_stratagems_page,
|
list_stratagems_page,
|
||||||
update_stratagem,
|
update_stratagem,
|
||||||
)
|
)
|
||||||
from ..utils.icon_utils import TYPE_COLORS, save_icon_png, svg_to_png
|
from ..utils.icon_utils import save_icon_png, svg_to_png
|
||||||
|
|
||||||
basic_path = Path(__file__).resolve().parents[1]
|
basic_path = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
@@ -39,10 +39,10 @@ def build_admin_app() -> FastAPI | None:
|
|||||||
auth = require_admin
|
auth = require_admin
|
||||||
|
|
||||||
@app.get("/stratagems")
|
@app.get("/stratagems")
|
||||||
async def api_list(page: int = 1, page_size: int = 20, _: dict = auth):
|
async def api_list(page: int = 1, page_size: int = 20, q: str = "", type: str = "", backpack: str = "", _: dict = auth):
|
||||||
page = max(1, page)
|
page = max(1, page)
|
||||||
page_size = min(max(1, page_size), 100)
|
page_size = min(max(1, page_size), 100)
|
||||||
items, total = list_stratagems_page(page, page_size)
|
items, total = list_stratagems_page(page, page_size, keyword=q, type=type, backpack=backpack)
|
||||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
@app.post("/stratagems")
|
@app.post("/stratagems")
|
||||||
@@ -86,8 +86,17 @@ def build_admin_app() -> FastAPI | None:
|
|||||||
|
|
||||||
@app.get("/img/{path:path}")
|
@app.get("/img/{path:path}")
|
||||||
async def icon_file(path: str):
|
async def icon_file(path: str):
|
||||||
|
# 兼容多种存量路径:res/img/...(插件根相对,新)、img/...(旧库,res/img 相对)、
|
||||||
|
# 以及直接/相对 res/img 的路径,统一归一化到 res/img 下的相对路径再解析。
|
||||||
|
rel = path.replace("\\", "/").lstrip("/")
|
||||||
|
if rel.startswith("res/img/"):
|
||||||
|
rel = rel[len("res/img/"):]
|
||||||
|
elif rel.startswith("res/"):
|
||||||
|
rel = rel[len("res/"):]
|
||||||
|
elif rel.startswith("img/"):
|
||||||
|
rel = rel[len("img/"):]
|
||||||
base = (basic_path / "res" / "img").resolve()
|
base = (basic_path / "res" / "img").resolve()
|
||||||
target = (basic_path / "res" / "img" / path).resolve()
|
target = (basic_path / "res" / "img" / rel).resolve()
|
||||||
if not target.is_relative_to(base) or not target.exists():
|
if not target.is_relative_to(base) or not target.exists():
|
||||||
raise HTTPException(status_code=404, detail="图片不存在")
|
raise HTTPException(status_code=404, detail="图片不存在")
|
||||||
return FileResponse(target)
|
return FileResponse(target)
|
||||||
@@ -96,13 +105,11 @@ def build_admin_app() -> FastAPI | None:
|
|||||||
async def api_upload_icon(seed: int, request: Request, _: dict = auth):
|
async def api_upload_icon(seed: int, request: Request, _: dict = auth):
|
||||||
body = await _get_body(request)
|
body = await _get_body(request)
|
||||||
svg = (body.get("svg") or "").strip()
|
svg = (body.get("svg") or "").strip()
|
||||||
framed = bool(body.get("framed"))
|
|
||||||
item = get_stratagem(seed)
|
item = get_stratagem(seed)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="未找到该战备")
|
raise HTTPException(status_code=404, detail="未找到该战备")
|
||||||
frame_color = TYPE_COLORS.get(item.get("type"), "#6bb7ea")
|
|
||||||
try:
|
try:
|
||||||
png = await svg_to_png(svg, framed=framed, frame_color=frame_color)
|
png = await svg_to_png(svg)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ from nonebot.plugin import PluginMetadata
|
|||||||
require("nonebot_plugin_orm")
|
require("nonebot_plugin_orm")
|
||||||
|
|
||||||
from .config import NICKNAME, config_manager, register_config as _register_learning_config # noqa: E402
|
from .config import NICKNAME, config_manager, register_config as _register_learning_config # noqa: E402
|
||||||
from . import web_api # noqa: E402
|
|
||||||
from . import web_hub # noqa: E402
|
from . import web_hub # noqa: E402
|
||||||
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
|
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
|
||||||
|
|
||||||
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/learning_chat
|
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/learning_chat
|
||||||
register_web_plugin(
|
register_web_plugin(
|
||||||
|
|||||||
@@ -49,13 +49,6 @@ class ChatGroupConfig(BaseModel):
|
|||||||
|
|
||||||
class ChatConfig(BaseModel):
|
class ChatConfig(BaseModel):
|
||||||
total_enable: bool = Field(default=True, alias="群聊学习总开关")
|
total_enable: bool = Field(default=True, alias="群聊学习总开关")
|
||||||
enable_web: bool = Field(default=True, alias="启用后台管理")
|
|
||||||
web_username: str = Field(default="chat", alias="后台管理用户名")
|
|
||||||
web_password: str = Field(default="admin", alias="后台管理密码")
|
|
||||||
web_secret_key: str = Field(
|
|
||||||
default="49c294d32f69b732ef6447c18379451ce1738922a75cd1d4812ef150318a2ed0",
|
|
||||||
alias="后台管理token密钥",
|
|
||||||
)
|
|
||||||
ban_words: List[str] = Field(default_factory=list, alias="全局屏蔽词")
|
ban_words: List[str] = Field(default_factory=list, alias="全局屏蔽词")
|
||||||
ban_users: List[int] = Field(default_factory=list, alias="全局屏蔽用户")
|
ban_users: List[int] = Field(default_factory=list, alias="全局屏蔽用户")
|
||||||
KEYWORDS_SIZE: int = Field(default=3, alias="单句关键词分词数量")
|
KEYWORDS_SIZE: int = Field(default=3, alias="单句关键词分词数量")
|
||||||
@@ -118,9 +111,9 @@ def register_config() -> None:
|
|||||||
"""把顶层 ChatConfig 顶层字段接入统一 Web 配置标准(/hub 可读可改)。
|
"""把顶层 ChatConfig 顶层字段接入统一 Web 配置标准(/hub 可读可改)。
|
||||||
|
|
||||||
getter/setter 直接读写 config_manager.config,并保存到 learning_chat.yml。
|
getter/setter 直接读写 config_manager.config,并保存到 learning_chat.yml。
|
||||||
权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json(避免与自有后台互相覆盖)。
|
权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json。
|
||||||
"""
|
"""
|
||||||
from hexi.config_standard import register_config_items
|
from hexi.web_hub.config_standard import register_config_items
|
||||||
|
|
||||||
def _get(key):
|
def _get(key):
|
||||||
return getattr(config_manager.config, key)
|
return getattr(config_manager.config, key)
|
||||||
@@ -131,10 +124,6 @@ def register_config() -> None:
|
|||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
{"key": "total_enable", "label": "群聊学习总开关", "type": "bool"},
|
{"key": "total_enable", "label": "群聊学习总开关", "type": "bool"},
|
||||||
{"key": "enable_web", "label": "启用后台管理", "type": "bool"},
|
|
||||||
{"key": "web_username", "label": "后台管理用户名", "type": "string"},
|
|
||||||
{"key": "web_password", "label": "后台管理密码", "type": "password", "secret": True},
|
|
||||||
{"key": "web_secret_key", "label": "后台签名密钥", "type": "password", "secret": True},
|
|
||||||
{"key": "ban_words", "label": "全局屏蔽词", "type": "text", "item_type": "str"},
|
{"key": "ban_words", "label": "全局屏蔽词", "type": "text", "item_type": "str"},
|
||||||
{"key": "ban_users", "label": "全局屏蔽用户", "type": "text", "item_type": "int"},
|
{"key": "ban_users", "label": "全局屏蔽用户", "type": "text", "item_type": "int"},
|
||||||
{"key": "KEYWORDS_SIZE", "label": "单句关键词分词数量", "type": "int"},
|
{"key": "KEYWORDS_SIZE", "label": "单句关键词分词数量", "type": "int"},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import time
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import cached_property, cmp_to_key
|
from functools import cached_property, cmp_to_key
|
||||||
|
|
||||||
# from ..nonebot_plugin_hexi_core.MessageUtils import send_poke
|
# from ..core.MessageUtils import send_poke
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import jieba_fast.analyse as jieba_analyse
|
import jieba_fast.analyse as jieba_analyse
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Vue 3 + TypeScript + Vite
|
|
||||||
|
|
||||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
|
||||||
|
|
||||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description" content="Nonebot-Plugin-Learning-Chat 控制台" />
|
|
||||||
<title>Learning-Chat 控制台</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "learning-chat-admin",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
|
||||||
"axios": "^1.5.0",
|
|
||||||
"element-plus": "^2.14.3",
|
|
||||||
"pinia": "^2.1.6",
|
|
||||||
"vue": "^3.3.4",
|
|
||||||
"vue-router": "^4.2.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^25.6.0",
|
|
||||||
"@vitejs/plugin-vue": "^4.5.0",
|
|
||||||
"typescript": "^5.2.2",
|
|
||||||
"vite": "^4.5.0",
|
|
||||||
"vue-tsc": "^1.8.19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,146 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-config-provider>
|
|
||||||
<!-- 登录页: 无框架布局 -->
|
|
||||||
<RouterView v-if="!requiresAuth" />
|
|
||||||
|
|
||||||
<!-- 主界面: 侧边栏 + 顶栏 + 内容区 -->
|
|
||||||
<el-container v-else class="app-container">
|
|
||||||
<el-aside width="220px" class="app-aside">
|
|
||||||
<div class="aside-title">Learning-Chat 控制台</div>
|
|
||||||
<el-menu :default-active="route.path" router class="aside-menu">
|
|
||||||
<el-menu-item index="/config">
|
|
||||||
<el-icon><Setting /></el-icon>
|
|
||||||
<span>配置</span>
|
|
||||||
</el-menu-item>
|
|
||||||
<el-menu-item index="/messages">
|
|
||||||
<el-icon><ChatDotRound /></el-icon>
|
|
||||||
<span>群聊消息</span>
|
|
||||||
</el-menu-item>
|
|
||||||
<el-menu-item index="/contexts">
|
|
||||||
<el-icon><Collection /></el-icon>
|
|
||||||
<span>学习内容</span>
|
|
||||||
</el-menu-item>
|
|
||||||
<el-menu-item index="/answers">
|
|
||||||
<el-icon><ChatLineRound /></el-icon>
|
|
||||||
<span>回复内容</span>
|
|
||||||
</el-menu-item>
|
|
||||||
<el-menu-item index="/blacklist">
|
|
||||||
<el-icon><CircleClose /></el-icon>
|
|
||||||
<span>禁用列表</span>
|
|
||||||
</el-menu-item>
|
|
||||||
</el-menu>
|
|
||||||
</el-aside>
|
|
||||||
|
|
||||||
<el-container class="app-body">
|
|
||||||
<el-header class="app-header" height="60px">
|
|
||||||
<span class="header-title">Nonebot-Plugin-Learning-Chat</span>
|
|
||||||
<div class="header-right">
|
|
||||||
<el-tag size="small" type="primary" effect="plain">{{ authStore.username }}</el-tag>
|
|
||||||
<el-button size="small" type="danger" plain @click="handleLogout">退出登录</el-button>
|
|
||||||
</div>
|
|
||||||
</el-header>
|
|
||||||
<el-main class="app-main">
|
|
||||||
<RouterView />
|
|
||||||
</el-main>
|
|
||||||
</el-container>
|
|
||||||
</el-container>
|
|
||||||
</el-config-provider>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
// 依据路由而非登录态切换布局, 避免登录瞬间闪现后台框架
|
|
||||||
const requiresAuth = computed(() => route.meta.requiresAuth !== false);
|
|
||||||
|
|
||||||
// token 到期自动登出(守卫已拦截"已过期"情况, 这里覆盖"使用中到期")
|
|
||||||
onMounted(() => {
|
|
||||||
authStore.scheduleAutoLogout(() => {
|
|
||||||
ElMessage.warning('登录已过期,请重新登录');
|
|
||||||
if (router.currentRoute.value.path !== '/login') {
|
|
||||||
router.push('/login');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '退出',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return; // 用户取消
|
|
||||||
}
|
|
||||||
authStore.logout();
|
|
||||||
router.push('/login');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.app-container {
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-aside {
|
|
||||||
background: #fff;
|
|
||||||
border-right: 1px solid #e4e7ed;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-title {
|
|
||||||
height: 60px;
|
|
||||||
line-height: 60px;
|
|
||||||
padding: 0 20px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu {
|
|
||||||
flex: 1;
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-body {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #e4e7ed;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
background: #f5f7fa;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import axios, { AxiosError } from 'axios';
|
|
||||||
import { ElMessage } from 'element-plus';
|
|
||||||
import router from '@/router';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
import type {
|
|
||||||
ApiResponse,
|
|
||||||
BlacklistItem,
|
|
||||||
ChatAnswer,
|
|
||||||
ChatContext,
|
|
||||||
ChatMessage,
|
|
||||||
GlobalConfig,
|
|
||||||
GroupConfig,
|
|
||||||
GroupInfo,
|
|
||||||
LoginResult,
|
|
||||||
PaginatedData,
|
|
||||||
} from '@/types';
|
|
||||||
|
|
||||||
/** 带状态码的请求错误 */
|
|
||||||
export class ApiError extends Error {
|
|
||||||
status: number;
|
|
||||||
|
|
||||||
/** 认证失效类错误: 已由全局拦截器提示并跳转登录, 视图层不再重复弹窗 */
|
|
||||||
authHandled = false;
|
|
||||||
|
|
||||||
constructor(message: string, status = 0) {
|
|
||||||
super(message);
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let redirectingToLogin = false;
|
|
||||||
|
|
||||||
/** 认证失效: 清除登录态并跳转登录页(避免并发失败时重复跳转) */
|
|
||||||
function handleUnauthorized() {
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
authStore.logout();
|
|
||||||
if (router.currentRoute.value.path !== '/login' && !redirectingToLogin) {
|
|
||||||
redirectingToLogin = true;
|
|
||||||
ElMessage.warning('登录已失效,请重新登录');
|
|
||||||
router.push('/login').finally(() => {
|
|
||||||
redirectingToLogin = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ApiClient {
|
|
||||||
private axiosInstance;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.axiosInstance = axios.create({
|
|
||||||
baseURL: '/learning_chat/api',
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 请求: 附加 token 头(后端契约: authentication() 读取 token header)
|
|
||||||
this.axiosInstance.interceptors.request.use((config) => {
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
if (authStore.token) {
|
|
||||||
config.headers.token = authStore.token;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 响应: 三态归一化
|
|
||||||
// 1. {"status": 0, "msg", "data"} → 成功, 返回整个信封(data 由调用方取)
|
|
||||||
// 2. {"status": !=0, "msg"} → 业务失败(HTTP 200 内)
|
|
||||||
// 3. {"detail": ...}(HTTP 400/422)→ 认证失效/参数错误
|
|
||||||
// 4. 裸 dict(全局/分群配置 GET)→ 直接返回
|
|
||||||
this.axiosInstance.interceptors.response.use(
|
|
||||||
(response) => {
|
|
||||||
const data = response.data;
|
|
||||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
||||||
if (typeof data.detail !== 'undefined') {
|
|
||||||
handleUnauthorized();
|
|
||||||
const err = new ApiError(String(data.detail), response.status);
|
|
||||||
err.authHandled = true;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (typeof data.status === 'number') {
|
|
||||||
if (data.status === 0) return data;
|
|
||||||
throw new ApiError(String(data.msg || '操作失败'), data.status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
(error: AxiosError) => {
|
|
||||||
const status = error.response?.status;
|
|
||||||
const data = error.response?.data as { detail?: unknown } | undefined;
|
|
||||||
const isAuthError = status === 400 || status === 422;
|
|
||||||
// 后端认证失效契约为 HTTP 400/422 + {"detail"}
|
|
||||||
if (isAuthError) {
|
|
||||||
handleUnauthorized();
|
|
||||||
}
|
|
||||||
const detail = data?.detail;
|
|
||||||
const message =
|
|
||||||
typeof detail === 'string'
|
|
||||||
? detail
|
|
||||||
: status === 0
|
|
||||||
? '无法连接到服务器'
|
|
||||||
: error.message || '网络请求失败';
|
|
||||||
const apiError = new ApiError(message, status ?? 0);
|
|
||||||
apiError.authHandled = isAuthError;
|
|
||||||
return Promise.reject(apiError);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登录 */
|
|
||||||
async login(username: string, password: string): Promise<ApiResponse<LoginResult>> {
|
|
||||||
return this.axiosInstance.post('/login', { username, password });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取群列表 */
|
|
||||||
async getGroupList(): Promise<ApiResponse<{ group_list: GroupInfo[] }>> {
|
|
||||||
return this.axiosInstance.get('/get_group_list');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取全局配置(裸 dict) */
|
|
||||||
async getGlobalConfig(): Promise<GlobalConfig> {
|
|
||||||
return this.axiosInstance.get('/chat_global_config');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存全局配置 */
|
|
||||||
async saveGlobalConfig(data: GlobalConfig): Promise<ApiResponse> {
|
|
||||||
return this.axiosInstance.post('/chat_global_config', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取分群配置(裸 dict, 概率为百分比) */
|
|
||||||
async getGroupConfig(groupId: number | string): Promise<GroupConfig> {
|
|
||||||
return this.axiosInstance.get(`/chat_group_config?group_id=${groupId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存分群配置(groupId 传 'all' 表示保存至所有群) */
|
|
||||||
async saveGroupConfig(groupId: number | string, data: GroupConfig): Promise<ApiResponse> {
|
|
||||||
return this.axiosInstance.post(`/chat_group_config?group_id=${groupId}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取聊天消息 */
|
|
||||||
async getMessages(params: {
|
|
||||||
page?: number;
|
|
||||||
perPage?: number;
|
|
||||||
orderBy?: string;
|
|
||||||
orderDir?: string;
|
|
||||||
group_id?: string;
|
|
||||||
user_id?: string;
|
|
||||||
message?: string;
|
|
||||||
}): Promise<ApiResponse<PaginatedData<ChatMessage>>> {
|
|
||||||
return this.axiosInstance.get('/get_chat_messages', { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取学习内容 */
|
|
||||||
async getContexts(params: {
|
|
||||||
page?: number;
|
|
||||||
perPage?: number;
|
|
||||||
orderBy?: string;
|
|
||||||
orderDir?: string;
|
|
||||||
keywords?: string;
|
|
||||||
}): Promise<ApiResponse<PaginatedData<ChatContext>>> {
|
|
||||||
return this.axiosInstance.get('/get_chat_contexts', { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取回复 */
|
|
||||||
async getAnswers(params: {
|
|
||||||
page?: number;
|
|
||||||
perPage?: number;
|
|
||||||
orderBy?: string;
|
|
||||||
orderDir?: string;
|
|
||||||
context_id?: number;
|
|
||||||
keywords?: string;
|
|
||||||
}): Promise<ApiResponse<PaginatedData<ChatAnswer>>> {
|
|
||||||
return this.axiosInstance.get('/get_chat_answers', { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取禁用列表 */
|
|
||||||
async getBlacklist(params: {
|
|
||||||
page?: number;
|
|
||||||
perPage?: number;
|
|
||||||
keywords?: string;
|
|
||||||
bans?: string;
|
|
||||||
}): Promise<ApiResponse<PaginatedData<BlacklistItem>>> {
|
|
||||||
return this.axiosInstance.get('/get_chat_blacklist', { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除(type: message | context | answer | blacklist) */
|
|
||||||
async deleteChat(type: string, id: number): Promise<ApiResponse> {
|
|
||||||
return this.axiosInstance.delete(`/delete_chat?type=${type}&id=${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 禁用(type: message | context | answer) */
|
|
||||||
async banChat(type: string, id: number): Promise<ApiResponse> {
|
|
||||||
return this.axiosInstance.put(`/ban_chat?type=${type}&id=${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量删除/取消禁用(type: answer | blacklist | context | message, id 可选) */
|
|
||||||
async deleteAll(type: string, id?: number): Promise<ApiResponse> {
|
|
||||||
let url = `/delete_all?type=${type}`;
|
|
||||||
if (id !== undefined) {
|
|
||||||
url += `&id=${id}`;
|
|
||||||
}
|
|
||||||
return this.axiosInstance.put(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new ApiClient();
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-dialog
|
|
||||||
:model-value="modelValue"
|
|
||||||
:title="title"
|
|
||||||
width="640px"
|
|
||||||
destroy-on-close
|
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
|
||||||
>
|
|
||||||
<div class="full-content">{{ content }}</div>
|
|
||||||
<template #footer>
|
|
||||||
<el-button type="primary" plain @click="copyContent">复制内容</el-button>
|
|
||||||
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ElMessage } from 'element-plus';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean;
|
|
||||||
title?: string;
|
|
||||||
content?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const copyContent = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(props.content || '');
|
|
||||||
ElMessage.success('已复制到剪贴板');
|
|
||||||
} catch {
|
|
||||||
ElMessage.error('复制失败,请手动选择复制');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.full-content {
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #303133;
|
|
||||||
background: #f5f7fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { createApp } from 'vue';
|
|
||||||
import { createPinia } from 'pinia';
|
|
||||||
import ElementPlus from 'element-plus';
|
|
||||||
import 'element-plus/dist/index.css';
|
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
|
|
||||||
import router from '@/router';
|
|
||||||
import App from '@/App.vue';
|
|
||||||
import '@/style.css';
|
|
||||||
|
|
||||||
// 移除旧版「API 地址设置」功能残留(同源架构下该功能已废弃, 写死的地址会导致跨域失败)
|
|
||||||
localStorage.removeItem('API_BASE_URL');
|
|
||||||
|
|
||||||
const app = createApp(App);
|
|
||||||
|
|
||||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|
||||||
app.component(key, component);
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(createPinia());
|
|
||||||
app.use(router);
|
|
||||||
app.use(ElementPlus);
|
|
||||||
app.mount('#app');
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
|
||||||
import { ElMessage } from 'element-plus';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
|
|
||||||
// 懒加载视图
|
|
||||||
const Login = () => import('@/views/Login.vue');
|
|
||||||
const Config = () => import('@/views/Config.vue');
|
|
||||||
const Messages = () => import('@/views/Messages.vue');
|
|
||||||
const Contexts = () => import('@/views/Contexts.vue');
|
|
||||||
const Answers = () => import('@/views/Answers.vue');
|
|
||||||
const Blacklist = () => import('@/views/Blacklist.vue');
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
redirect: () => {
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
return authStore.isAuthenticated ? '/config' : '/login';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'Login',
|
|
||||||
component: Login,
|
|
||||||
meta: { requiresAuth: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/config',
|
|
||||||
name: 'Config',
|
|
||||||
component: Config,
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/messages',
|
|
||||||
name: 'Messages',
|
|
||||||
component: Messages,
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/contexts',
|
|
||||||
name: 'Contexts',
|
|
||||||
component: Contexts,
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/answers',
|
|
||||||
name: 'Answers',
|
|
||||||
component: Answers,
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/blacklist',
|
|
||||||
name: 'Blacklist',
|
|
||||||
component: Blacklist,
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/:pathMatch(.*)*',
|
|
||||||
redirect: '/login',
|
|
||||||
meta: { requiresAuth: false },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
// base 与 vite.config.ts 的 base 保持一致, 否则 SPA 导航会产生不带前缀的根路径 URL, 刷新即 404
|
|
||||||
history: createWebHistory('/learning_chat/'),
|
|
||||||
routes,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 全局路由守卫
|
|
||||||
router.beforeEach((to, _from) => {
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
// token 已过期(按 JWT exp 主动检测) -> 立即登出跳登录页
|
|
||||||
if (authStore.token) {
|
|
||||||
const exp = authStore.getTokenExpiry(authStore.token);
|
|
||||||
if (exp !== null && exp * 1000 <= Date.now()) {
|
|
||||||
authStore.logout();
|
|
||||||
if (to.path !== '/login') {
|
|
||||||
ElMessage.warning('登录已过期,请重新登录');
|
|
||||||
return { path: '/login', query: { redirect: to.fullPath } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requiresAuth = to.meta.requiresAuth;
|
|
||||||
|
|
||||||
// 需要登录但未认证 -> 跳登录页
|
|
||||||
if (requiresAuth && !authStore.isAuthenticated) {
|
|
||||||
return {
|
|
||||||
path: '/login',
|
|
||||||
query: { redirect: to.fullPath },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已登录但访问登录页 -> 跳配置页
|
|
||||||
if (to.path === '/login' && authStore.isAuthenticated) {
|
|
||||||
return '/config';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他情况放行
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { ref, computed } from 'vue';
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
// 初始化时从 localStorage 读取
|
|
||||||
const token = ref<string>(localStorage.getItem('token') || '');
|
|
||||||
const username = ref<string>(localStorage.getItem('username') || '');
|
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value);
|
|
||||||
|
|
||||||
let expireTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
/** 解析 JWT payload 中的 exp(unix 秒); 非 JWT 或解析失败返回 null */
|
|
||||||
const getTokenExpiry = (t: string): number | null => {
|
|
||||||
try {
|
|
||||||
const payload = t.split('.')[1];
|
|
||||||
if (!payload) return null;
|
|
||||||
const json = JSON.parse(
|
|
||||||
atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
|
|
||||||
);
|
|
||||||
return typeof json.exp === 'number' ? json.exp : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册到期自动登出:
|
|
||||||
* - token 已过期 → 立即 logout 并返回 true
|
|
||||||
* - 未过期 → 在 exp 时刻触发 onExpired 回调
|
|
||||||
*/
|
|
||||||
const scheduleAutoLogout = (onExpired?: () => void): boolean => {
|
|
||||||
if (expireTimer) {
|
|
||||||
clearTimeout(expireTimer);
|
|
||||||
expireTimer = null;
|
|
||||||
}
|
|
||||||
if (!token.value) return false;
|
|
||||||
const exp = getTokenExpiry(token.value);
|
|
||||||
if (exp === null) return false;
|
|
||||||
const remainingMs = exp * 1000 - Date.now();
|
|
||||||
if (remainingMs <= 0) {
|
|
||||||
logout();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
expireTimer = setTimeout(() => {
|
|
||||||
logout();
|
|
||||||
onExpired?.();
|
|
||||||
}, remainingMs);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setAuth = (newToken: string, newUsername: string) => {
|
|
||||||
token.value = newToken;
|
|
||||||
username.value = newUsername;
|
|
||||||
localStorage.setItem('token', newToken);
|
|
||||||
localStorage.setItem('username', newUsername);
|
|
||||||
// 登录即武装到期定时器
|
|
||||||
scheduleAutoLogout();
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
if (expireTimer) {
|
|
||||||
clearTimeout(expireTimer);
|
|
||||||
expireTimer = null;
|
|
||||||
}
|
|
||||||
token.value = '';
|
|
||||||
username.value = '';
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('username');
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
username,
|
|
||||||
isAuthenticated,
|
|
||||||
getTokenExpiry,
|
|
||||||
scheduleAutoLogout,
|
|
||||||
setAuth,
|
|
||||||
logout,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/* 全局样式 — Element Plus 自带 reset, 这里只保留主题与少量通用类 */
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
|
||||||
background: #f5f7fa;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 页面级白色卡片容器(列表页) */
|
|
||||||
.page-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 页面顶部说明条 */
|
|
||||||
.page-tip {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索栏与操作行 */
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar .el-input {
|
|
||||||
width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar .toolbar-wide {
|
|
||||||
width: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分页容器 */
|
|
||||||
.pagination-wrap {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格内可点击查看完整内容的长文本单元格 */
|
|
||||||
.clickable-cell {
|
|
||||||
cursor: pointer;
|
|
||||||
color: #606266;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clickable-cell:hover {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 单行省略(配合可点击单元格使用) */
|
|
||||||
.cell-ellipsis {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表单辅助说明文字 */
|
|
||||||
.help-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #909399;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* 与后端 web_api.py 契约一致的类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 后端统一信封(列表/操作类接口); 配置 GET 返回裸 dict, 认证错误返回 {"detail"} */
|
|
||||||
export interface ApiResponse<T = unknown> {
|
|
||||||
status: number;
|
|
||||||
msg: string;
|
|
||||||
data?: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedData<T> {
|
|
||||||
items: T[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResult {
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupInfo {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
id: number;
|
|
||||||
message_id: string | number;
|
|
||||||
group_id: number;
|
|
||||||
user_id: number;
|
|
||||||
raw_message: string;
|
|
||||||
message?: string;
|
|
||||||
/** unix 秒整数 */
|
|
||||||
time: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatContext {
|
|
||||||
id: number;
|
|
||||||
keywords: string;
|
|
||||||
/** unix 秒整数 */
|
|
||||||
time: number;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatAnswer {
|
|
||||||
id: number;
|
|
||||||
group_id: number;
|
|
||||||
keywords: string;
|
|
||||||
/** unix 秒整数 */
|
|
||||||
time: number;
|
|
||||||
count: number;
|
|
||||||
context_id: number;
|
|
||||||
/** 旧数据可能为 null */
|
|
||||||
messages: Array<{ msg: string }> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlacklistItem {
|
|
||||||
id: number;
|
|
||||||
keywords: string;
|
|
||||||
bans: string;
|
|
||||||
global_ban?: boolean;
|
|
||||||
ban_group_id?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GlobalConfig {
|
|
||||||
total_enable: boolean;
|
|
||||||
enable_web: boolean;
|
|
||||||
web_username: string;
|
|
||||||
web_password: string;
|
|
||||||
web_secret_key: string;
|
|
||||||
KEYWORDS_SIZE: number;
|
|
||||||
cross_group_threshold: number;
|
|
||||||
learn_max_count: number;
|
|
||||||
ban_words: string[];
|
|
||||||
ban_users: string[];
|
|
||||||
dictionary: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupConfig {
|
|
||||||
enable: boolean;
|
|
||||||
answer_threshold: number;
|
|
||||||
answer_threshold_weights: number[];
|
|
||||||
repeat_threshold: number;
|
|
||||||
/** 0-100 百分比(后端 GET ×100 / POST ÷100 契约) */
|
|
||||||
break_probability: number;
|
|
||||||
speak_enable: boolean;
|
|
||||||
speak_threshold: number;
|
|
||||||
speak_min_interval: number;
|
|
||||||
/** 0-100 百分比 */
|
|
||||||
speak_continuously_probability: number;
|
|
||||||
speak_continuously_max_len: number;
|
|
||||||
/** 0-100 百分比 */
|
|
||||||
speak_poke_probability: number;
|
|
||||||
ban_words: string[];
|
|
||||||
ban_users: string[];
|
|
||||||
member_list: GroupInfo[];
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/** 后端 time 为 unix 秒整数, 格式化为本地时间(兼容毫秒/字符串) */
|
|
||||||
export function formatTime(time: number | string | null | undefined): string {
|
|
||||||
if (time === null || time === undefined || time === '') return '';
|
|
||||||
let t = Number(time);
|
|
||||||
if (Number.isNaN(t)) return String(time);
|
|
||||||
if (t < 1e12) t *= 1000; // 秒 → 毫秒
|
|
||||||
return new Date(t).toLocaleString('zh-CN');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从错误对象提取可读消息(ApiError/普通 Error/未知值) */
|
|
||||||
export function errorMessage(err: unknown, fallback: string): string {
|
|
||||||
if (err instanceof Error && err.message) return err.message;
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { ElMessage } from 'element-plus';
|
|
||||||
import { ApiError } from '@/api/client';
|
|
||||||
import { errorMessage } from './format';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 展示错误提示。
|
|
||||||
* 认证失效类错误已由全局拦截器统一提示并跳转登录, 这里跳过, 避免重复弹窗。
|
|
||||||
*/
|
|
||||||
export function showError(err: unknown, fallback: string): void {
|
|
||||||
if (err instanceof ApiError && err.authHandled) return;
|
|
||||||
ElMessage.error(errorMessage(err, fallback));
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<el-alert type="info" :closable="false" show-icon class="page-tip">
|
|
||||||
<template #title>
|
|
||||||
<p>此数据库记录了机器人已学习到的所有回复。</p>
|
|
||||||
<p>点击「禁用」可以将该回复进行禁用;点击「删除」可以删除该回复。</p>
|
|
||||||
</template>
|
|
||||||
</el-alert>
|
|
||||||
|
|
||||||
<div class="page-card">
|
|
||||||
<div class="toolbar">
|
|
||||||
<el-input
|
|
||||||
v-model="searchKeywords"
|
|
||||||
placeholder="搜索关键词"
|
|
||||||
clearable
|
|
||||||
class="toolbar-wide"
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
|
||||||
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="answers" stripe border @sort-change="handleSortChange">
|
|
||||||
<el-table-column type="expand">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="answers-detail">
|
|
||||||
<div v-for="(m, i) in (row.messages || [])" :key="i" class="answer-msg">
|
|
||||||
{{ m.msg }}
|
|
||||||
</div>
|
|
||||||
<el-empty
|
|
||||||
v-if="!row.messages || row.messages.length === 0"
|
|
||||||
description="无消息内容"
|
|
||||||
:image-size="50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="内容/关键词" min-width="180" sortable="custom">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span
|
|
||||||
class="clickable-cell cell-ellipsis"
|
|
||||||
:title="row.keywords"
|
|
||||||
@click="showDetail(`回复 #${row.id}`, row.keywords)"
|
|
||||||
>
|
|
||||||
{{ row.keywords }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="group_id" label="群ID" width="110" />
|
|
||||||
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
|
|
||||||
<template #default="{ row }">{{ formatTime(row.time) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="count" label="次数" width="90" sortable="custom" />
|
|
||||||
<el-table-column label="消息预览" min-width="240">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="msg-preview clickable-cell" @click="showDetail(`完整消息 - ${row.keywords}`, (row.messages || []).map((m) => m.msg).join('\n'))">
|
|
||||||
<div v-for="(m, i) in (row.messages || []).slice(0, 2)" :key="i" class="msg-preview-line">
|
|
||||||
{{ m.msg }}
|
|
||||||
</div>
|
|
||||||
<div v-if="(row.messages || []).length > 2" class="msg-preview-more">
|
|
||||||
+{{ row.messages!.length - 2 }} 更多(点击查看全部)
|
|
||||||
</div>
|
|
||||||
<span v-if="!row.messages || row.messages.length === 0" class="msg-preview-empty">无消息内容</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="150" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
|
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<template #empty>
|
|
||||||
<el-empty description="暂无回复数据" :image-size="80" />
|
|
||||||
</template>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="page"
|
|
||||||
:page-size="perPage"
|
|
||||||
:total="total"
|
|
||||||
layout="total, prev, pager, next"
|
|
||||||
background
|
|
||||||
@current-change="fetchAnswers"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import apiClient from '@/api/client';
|
|
||||||
import FullTextDialog from '@/components/FullTextDialog.vue';
|
|
||||||
import { formatTime } from '@/utils/format';
|
|
||||||
import { showError } from '@/utils/notify';
|
|
||||||
import type { ChatAnswer } from '@/types';
|
|
||||||
|
|
||||||
const answers = ref<ChatAnswer[]>([]);
|
|
||||||
const total = ref(0);
|
|
||||||
const page = ref(1);
|
|
||||||
const perPage = 10;
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
const searchKeywords = ref('');
|
|
||||||
const sortColumn = ref<'keywords' | 'time' | 'count'>('count');
|
|
||||||
const sortOrder = ref<'asc' | 'desc'>('desc');
|
|
||||||
|
|
||||||
// 完整内容弹窗
|
|
||||||
const detailVisible = ref(false);
|
|
||||||
const detailTitle = ref('');
|
|
||||||
const detailContent = ref('');
|
|
||||||
|
|
||||||
const showDetail = (title: string, content: string) => {
|
|
||||||
detailTitle.value = title;
|
|
||||||
detailContent.value = content || '(无内容)';
|
|
||||||
detailVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchAnswers = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await apiClient.getAnswers({
|
|
||||||
page: page.value,
|
|
||||||
perPage,
|
|
||||||
orderBy: sortColumn.value,
|
|
||||||
orderDir: sortOrder.value,
|
|
||||||
keywords: searchKeywords.value,
|
|
||||||
});
|
|
||||||
answers.value = res.data!.items;
|
|
||||||
total.value = res.data!.total;
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '获取回复列表失败');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
page.value = 1;
|
|
||||||
fetchAnswers();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = ({
|
|
||||||
prop,
|
|
||||||
order,
|
|
||||||
}: {
|
|
||||||
prop: string;
|
|
||||||
order: 'ascending' | 'descending' | null;
|
|
||||||
}) => {
|
|
||||||
if (!prop) return;
|
|
||||||
sortColumn.value = prop as 'keywords' | 'time' | 'count';
|
|
||||||
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
|
|
||||||
page.value = 1;
|
|
||||||
fetchAnswers();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (row: ChatAnswer) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.deleteChat('answer', row.id);
|
|
||||||
ElMessage.success('已删除');
|
|
||||||
fetchAnswers();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBan = async (row: ChatAnswer) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '禁用',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.banChat('answer', row.id);
|
|
||||||
ElMessage.success('已禁用');
|
|
||||||
fetchAnswers();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '禁用失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAll = async () => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
'确定要删除所有回复吗?这是不可逆的操作!',
|
|
||||||
'删除所有回复',
|
|
||||||
{
|
|
||||||
type: 'error',
|
|
||||||
confirmButtonText: '全部删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.deleteAll('answer');
|
|
||||||
ElMessage.success('已删除所有回复');
|
|
||||||
fetchAnswers();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
fetchAnswers();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.answers-detail {
|
|
||||||
padding: 8px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-msg {
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: #f5f7fa;
|
|
||||||
border-radius: 4px;
|
|
||||||
border-left: 3px solid #67c23a;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #606266;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-msg:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-preview-line {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #909399;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-preview-more {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #409eff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-preview-empty {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #c0c4cc;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<el-alert type="info" :closable="false" show-icon class="page-tip">
|
|
||||||
<template #title>
|
|
||||||
<p>此数据库记录了机器人被禁用的内容/关键词。</p>
|
|
||||||
<p>可以取消禁用,使其能够重新继续学习;不能在此添加禁用,只能在配置中添加屏蔽词来达到禁用效果。</p>
|
|
||||||
</template>
|
|
||||||
</el-alert>
|
|
||||||
|
|
||||||
<div class="page-card">
|
|
||||||
<div class="toolbar">
|
|
||||||
<el-input
|
|
||||||
v-model="searchKeywords"
|
|
||||||
placeholder="搜索关键词"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
|
||||||
<el-input
|
|
||||||
v-model="searchBans"
|
|
||||||
placeholder="搜索禁用群"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
|
||||||
<el-button type="danger" plain @click="handleDeleteAll">取消所有禁用</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="blacklist" stripe border>
|
|
||||||
<el-table-column label="内容/关键词" min-width="240">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span
|
|
||||||
class="clickable-cell cell-ellipsis"
|
|
||||||
:title="row.keywords"
|
|
||||||
@click="showDetail(`禁用项 #${row.id}`, row.keywords)"
|
|
||||||
>
|
|
||||||
{{ row.keywords }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="已禁用的群" min-width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag v-if="row.global_ban" type="danger" effect="plain">全局禁用</el-tag>
|
|
||||||
<el-tag v-else-if="row.bans" type="info" effect="plain">{{ row.bans }}</el-tag>
|
|
||||||
<span v-else class="bans-empty">—</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="130" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" type="success" plain @click="handleUnban(row)">取消禁用</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<template #empty>
|
|
||||||
<el-empty description="暂无禁用项目" :image-size="80" />
|
|
||||||
</template>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="page"
|
|
||||||
:page-size="perPage"
|
|
||||||
:total="total"
|
|
||||||
layout="total, prev, pager, next"
|
|
||||||
background
|
|
||||||
@current-change="fetchBlacklist"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import apiClient from '@/api/client';
|
|
||||||
import FullTextDialog from '@/components/FullTextDialog.vue';
|
|
||||||
import { showError } from '@/utils/notify';
|
|
||||||
import type { BlacklistItem } from '@/types';
|
|
||||||
|
|
||||||
const blacklist = ref<BlacklistItem[]>([]);
|
|
||||||
const total = ref(0);
|
|
||||||
const page = ref(1);
|
|
||||||
const perPage = 10;
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
const searchKeywords = ref('');
|
|
||||||
const searchBans = ref('');
|
|
||||||
|
|
||||||
// 完整内容弹窗
|
|
||||||
const detailVisible = ref(false);
|
|
||||||
const detailTitle = ref('');
|
|
||||||
const detailContent = ref('');
|
|
||||||
|
|
||||||
const showDetail = (title: string, content: string) => {
|
|
||||||
detailTitle.value = title;
|
|
||||||
detailContent.value = content || '(无内容)';
|
|
||||||
detailVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchBlacklist = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await apiClient.getBlacklist({
|
|
||||||
page: page.value,
|
|
||||||
perPage,
|
|
||||||
keywords: searchKeywords.value,
|
|
||||||
bans: searchBans.value,
|
|
||||||
});
|
|
||||||
blacklist.value = res.data!.items;
|
|
||||||
total.value = res.data!.total;
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '获取禁用列表失败');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
page.value = 1;
|
|
||||||
fetchBlacklist();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnban = async (row: BlacklistItem) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定要取消禁用 "${row.keywords}" 吗?`, '取消禁用', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '取消禁用',
|
|
||||||
cancelButtonText: '返回',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.deleteChat('blacklist', row.id);
|
|
||||||
ElMessage.success('已取消禁用');
|
|
||||||
fetchBlacklist();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '取消禁用失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAll = async () => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('确定要取消所有禁用吗?', '取消所有禁用', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '全部取消',
|
|
||||||
cancelButtonText: '返回',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.deleteAll('blacklist');
|
|
||||||
ElMessage.success('已取消所有禁用');
|
|
||||||
fetchBlacklist();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '取消禁用失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
fetchBlacklist();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.bans-empty {
|
|
||||||
color: #c0c4cc;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,586 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="config-container">
|
|
||||||
<el-tabs v-model="activeTab">
|
|
||||||
<!-- ==================== 全局配置 ==================== -->
|
|
||||||
<el-tab-pane label="全局配置" name="global">
|
|
||||||
<div v-loading="loading" class="config-body">
|
|
||||||
<template v-if="globalConfig">
|
|
||||||
<el-card shadow="never" class="config-card">
|
|
||||||
<template #header>基本设置</template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="群聊学习总开关">
|
|
||||||
<el-switch
|
|
||||||
v-model="globalConfig.total_enable"
|
|
||||||
active-text="开启"
|
|
||||||
inactive-text="关闭"
|
|
||||||
/>
|
|
||||||
<div class="help-text">关闭后,全局都将不会再学习和回复</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="后台管理总开关">
|
|
||||||
<el-switch
|
|
||||||
v-model="globalConfig.enable_web"
|
|
||||||
active-text="开启"
|
|
||||||
inactive-text="关闭"
|
|
||||||
/>
|
|
||||||
<div class="help-text">是否开启本后台管理</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="config-card">
|
|
||||||
<template #header>认证设置</template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="后台管理用户名">
|
|
||||||
<el-input v-model="globalConfig.web_username" placeholder="e.g. chat" />
|
|
||||||
<div class="help-text">登录本后台管理所需要的用户名</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="后台管理密码">
|
|
||||||
<el-input
|
|
||||||
v-model="globalConfig.web_password"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
placeholder="输入密码"
|
|
||||||
/>
|
|
||||||
<div class="help-text">登录本后台管理所需要的密码</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="后台管理Token密钥">
|
|
||||||
<el-input v-model="globalConfig.web_secret_key" placeholder="输入密钥" />
|
|
||||||
<div class="help-text">用于加密验证Token的密钥</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
|
|
||||||
<template #header>学习参数</template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="单句关键词数量">
|
|
||||||
<el-input-number v-model="globalConfig.KEYWORDS_SIZE" :min="2" controls-position="right" />
|
|
||||||
<div class="help-text">单句语句标签数量,建议保持默认为3</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="跨群回复阈值">
|
|
||||||
<el-input-number v-model="globalConfig.cross_group_threshold" :min="1" controls-position="right" />
|
|
||||||
<div class="help-text">当学习到的一种回复在N个群都有,就变为全局回复</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="最高学习次数">
|
|
||||||
<el-input-number v-model="globalConfig.learn_max_count" :min="2" controls-position="right" />
|
|
||||||
<div class="help-text">值越高,这个回复就会越容易进行回复</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
|
|
||||||
<template #header>屏蔽设置</template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="全局屏蔽词">
|
|
||||||
<div class="tag-list">
|
|
||||||
<el-tag
|
|
||||||
v-for="(word, i) in globalConfig.ban_words"
|
|
||||||
:key="i"
|
|
||||||
type="primary"
|
|
||||||
effect="plain"
|
|
||||||
closable
|
|
||||||
@close="globalConfig.ban_words.splice(i, 1)"
|
|
||||||
>
|
|
||||||
{{ word }}
|
|
||||||
</el-tag>
|
|
||||||
<el-input
|
|
||||||
v-model="banWordInput"
|
|
||||||
size="small"
|
|
||||||
class="tag-input"
|
|
||||||
placeholder="输入后按回车添加"
|
|
||||||
@keyup.enter="addTag(globalConfig.ban_words, banWordInput)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">含有这些词的消息不会学习和回复</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="全局屏蔽用户">
|
|
||||||
<div class="tag-list">
|
|
||||||
<el-tag
|
|
||||||
v-for="(user, i) in globalConfig.ban_users"
|
|
||||||
:key="i"
|
|
||||||
type="primary"
|
|
||||||
effect="plain"
|
|
||||||
closable
|
|
||||||
@close="globalConfig.ban_users.splice(i, 1)"
|
|
||||||
>
|
|
||||||
{{ user }}
|
|
||||||
</el-tag>
|
|
||||||
<el-input
|
|
||||||
v-model="banUserInput"
|
|
||||||
size="small"
|
|
||||||
class="tag-input"
|
|
||||||
placeholder="输入后按回车添加"
|
|
||||||
@keyup.enter="addTag(globalConfig.ban_users, banUserInput)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">和这些用户有关的消息不会学习和回复</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="自定义词典">
|
|
||||||
<div class="tag-list">
|
|
||||||
<el-tag
|
|
||||||
v-for="(word, i) in globalConfig.dictionary"
|
|
||||||
:key="i"
|
|
||||||
type="primary"
|
|
||||||
effect="plain"
|
|
||||||
closable
|
|
||||||
@close="globalConfig.dictionary.splice(i, 1)"
|
|
||||||
>
|
|
||||||
{{ word }}
|
|
||||||
</el-tag>
|
|
||||||
<el-input
|
|
||||||
v-model="dictionaryInput"
|
|
||||||
size="small"
|
|
||||||
class="tag-input"
|
|
||||||
placeholder="输入后按回车添加"
|
|
||||||
@keyup.enter="addTag(globalConfig.dictionary, dictionaryInput)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">添加自定义词语,提高学习的准确性</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<el-button type="primary" :loading="saving" @click="saveGlobal">保存</el-button>
|
|
||||||
<el-button @click="resetGlobal">重置</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<!-- ==================== 分群配置 ==================== -->
|
|
||||||
<el-tab-pane label="分群配置" name="group">
|
|
||||||
<div class="page-card group-select-row">
|
|
||||||
<span class="group-select-label">选择群:</span>
|
|
||||||
<el-select
|
|
||||||
v-model="selectedGroupId"
|
|
||||||
placeholder="-- 请选择群 --"
|
|
||||||
clearable
|
|
||||||
style="flex: 1"
|
|
||||||
@change="handleGroupChange"
|
|
||||||
>
|
|
||||||
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selectedGroupId" v-loading="groupLoading" class="config-body">
|
|
||||||
<template v-if="groupConfig">
|
|
||||||
<el-card shadow="never" class="config-card">
|
|
||||||
<template #header>基本设置</template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="群聊学习开关">
|
|
||||||
<el-switch v-model="groupConfig.enable" active-text="开启" inactive-text="关闭" />
|
|
||||||
<div class="help-text">针对该群的学习开关</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="groupConfig.enable" label="回复阈值">
|
|
||||||
<el-input-number v-model="groupConfig.answer_threshold" :min="2" controls-position="right" />
|
|
||||||
<div class="help-text">学习成功所需要的次数,值越低学得越快</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
|
|
||||||
<template #header>回复阈值权重</template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="设置权重(%)">
|
|
||||||
<div class="weight-list">
|
|
||||||
<div v-for="(weight, i) in groupConfig.answer_threshold_weights" :key="i" class="weight-row">
|
|
||||||
<el-input-number v-model="groupConfig.answer_threshold_weights[i]" :min="1" :max="100" controls-position="right" />
|
|
||||||
<el-button size="small" type="danger" plain @click="removeWeight(i)">删除</el-button>
|
|
||||||
</div>
|
|
||||||
<el-button size="small" type="success" plain @click="addWeight">+ 添加权重</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">影响回复阈值的计算方式</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
|
|
||||||
<template #header>复读设置</template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="复读阈值">
|
|
||||||
<el-input-number v-model="groupConfig.repeat_threshold" :min="2" controls-position="right" />
|
|
||||||
<div class="help-text">N个人复读后机器人就会跟着复读</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="打断复读概率">
|
|
||||||
<div class="suffix-wrap">
|
|
||||||
<el-input-number v-model="groupConfig.break_probability" :min="0" :max="100" controls-position="right" />
|
|
||||||
<span class="suffix-text">%</span>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">达到复读阈值时,打断复读的概率</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
|
|
||||||
<template #header>主动发言设置</template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="主动发言开关">
|
|
||||||
<el-switch v-model="groupConfig.speak_enable" active-text="开启" inactive-text="关闭" />
|
|
||||||
<div class="help-text">是否允许机器人在该群主动发言</div>
|
|
||||||
</el-form-item>
|
|
||||||
<template v-if="groupConfig.speak_enable">
|
|
||||||
<el-form-item label="主动发言阈值">
|
|
||||||
<el-input-number v-model="groupConfig.speak_threshold" :min="0" controls-position="right" />
|
|
||||||
<div class="help-text">值越低,主动发言的可能性越高</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="主动发言最小间隔">
|
|
||||||
<div class="suffix-wrap">
|
|
||||||
<el-input-number v-model="groupConfig.speak_min_interval" :min="0" controls-position="right" />
|
|
||||||
<span class="suffix-text">秒</span>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">进行主动发言的最小时间间隔</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="连续主动发言概率">
|
|
||||||
<div class="suffix-wrap">
|
|
||||||
<el-input-number v-model="groupConfig.speak_continuously_probability" :min="0" :max="100" controls-position="right" />
|
|
||||||
<span class="suffix-text">%</span>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">触发主动发言时,连续发言的概率</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="最大连续发言句数">
|
|
||||||
<el-input-number v-model="groupConfig.speak_continuously_max_len" :min="1" controls-position="right" />
|
|
||||||
<div class="help-text">连续主动发言的最大句数</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="戳一戳概率">
|
|
||||||
<div class="suffix-wrap">
|
|
||||||
<el-input-number v-model="groupConfig.speak_poke_probability" :min="0" :max="100" controls-position="right" />
|
|
||||||
<span class="suffix-text">%</span>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">主动发言时附带戳一戳的概率</div>
|
|
||||||
</el-form-item>
|
|
||||||
</template>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
|
|
||||||
<template #header>屏蔽设置</template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="屏蔽词">
|
|
||||||
<div class="tag-list">
|
|
||||||
<el-tag
|
|
||||||
v-for="(word, i) in groupConfig.ban_words"
|
|
||||||
:key="i"
|
|
||||||
type="primary"
|
|
||||||
effect="plain"
|
|
||||||
closable
|
|
||||||
@close="groupConfig.ban_words.splice(i, 1)"
|
|
||||||
>
|
|
||||||
{{ word }}
|
|
||||||
</el-tag>
|
|
||||||
<el-input
|
|
||||||
v-model="groupBanWordInput"
|
|
||||||
size="small"
|
|
||||||
class="tag-input"
|
|
||||||
placeholder="输入后按回车添加"
|
|
||||||
@keyup.enter="addTag(groupConfig.ban_words, groupBanWordInput)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">含有这些词的消息不会学习</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="屏蔽用户">
|
|
||||||
<div class="tag-list">
|
|
||||||
<el-tag
|
|
||||||
v-for="(userId, i) in groupConfig.ban_users"
|
|
||||||
:key="i"
|
|
||||||
type="primary"
|
|
||||||
effect="plain"
|
|
||||||
closable
|
|
||||||
@close="groupConfig.ban_users.splice(i, 1)"
|
|
||||||
>
|
|
||||||
{{ formatUserId(userId) }}
|
|
||||||
</el-tag>
|
|
||||||
<el-select
|
|
||||||
v-model="pendingMember"
|
|
||||||
size="small"
|
|
||||||
class="member-select"
|
|
||||||
placeholder="-- 选择用户添加 --"
|
|
||||||
clearable
|
|
||||||
@change="addMember"
|
|
||||||
>
|
|
||||||
<el-option v-for="m in groupConfig.member_list" :key="m.value" :label="m.label" :value="m.value" />
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
<div class="help-text">和这些用户有关的消息不会学习</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<el-button type="primary" :loading="saving" @click="saveGroup">保存</el-button>
|
|
||||||
<el-button type="success" plain :loading="saving" @click="saveGroupAll">保存至所有群</el-button>
|
|
||||||
<el-button @click="resetGroup">重置</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref, type Ref } from 'vue';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import apiClient from '@/api/client';
|
|
||||||
import { showError } from '@/utils/notify';
|
|
||||||
import type { GlobalConfig, GroupConfig, GroupInfo } from '@/types';
|
|
||||||
|
|
||||||
const activeTab = ref<'global' | 'group'>('global');
|
|
||||||
|
|
||||||
// ==================== 全局配置 ====================
|
|
||||||
const loading = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
const globalConfig = ref<GlobalConfig | null>(null);
|
|
||||||
let globalBackup: GlobalConfig | null = null;
|
|
||||||
|
|
||||||
const banWordInput = ref('');
|
|
||||||
const banUserInput = ref('');
|
|
||||||
const dictionaryInput = ref('');
|
|
||||||
|
|
||||||
const fetchGlobalConfig = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
globalConfig.value = await apiClient.getGlobalConfig();
|
|
||||||
// 深拷贝快照, 重置时恢复(修复原浅拷贝 bug)
|
|
||||||
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '获取全局配置失败');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveGlobal = async () => {
|
|
||||||
if (!globalConfig.value) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await apiClient.saveGlobalConfig(globalConfig.value);
|
|
||||||
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
|
|
||||||
ElMessage.success('保存成功');
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '保存失败');
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetGlobal = () => {
|
|
||||||
if (globalBackup) {
|
|
||||||
globalConfig.value = JSON.parse(JSON.stringify(globalBackup));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== 分群配置 ====================
|
|
||||||
const groups = ref<GroupInfo[]>([]);
|
|
||||||
const selectedGroupId = ref<number | null>(null);
|
|
||||||
const groupLoading = ref(false);
|
|
||||||
const groupConfig = ref<GroupConfig | null>(null);
|
|
||||||
let groupBackup: GroupConfig | null = null;
|
|
||||||
|
|
||||||
const groupBanWordInput = ref('');
|
|
||||||
const pendingMember = ref<number | null>(null);
|
|
||||||
|
|
||||||
const fetchGroupList = async () => {
|
|
||||||
try {
|
|
||||||
const res = await apiClient.getGroupList();
|
|
||||||
groups.value = res.data!.group_list;
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '获取群列表失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGroupChange = (value: number | null) => {
|
|
||||||
if (value === null) {
|
|
||||||
groupConfig.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetchGroupConfig(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchGroupConfig = async (groupId: number) => {
|
|
||||||
groupLoading.value = true;
|
|
||||||
try {
|
|
||||||
groupConfig.value = await apiClient.getGroupConfig(groupId);
|
|
||||||
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
|
|
||||||
} catch (err) {
|
|
||||||
groupConfig.value = null;
|
|
||||||
showError(err, '获取分群配置失败');
|
|
||||||
} finally {
|
|
||||||
groupLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveGroup = async () => {
|
|
||||||
if (!groupConfig.value || selectedGroupId.value === null) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await apiClient.saveGroupConfig(selectedGroupId.value, groupConfig.value);
|
|
||||||
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
|
|
||||||
ElMessage.success('保存成功');
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '保存失败');
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveGroupAll = async () => {
|
|
||||||
if (!groupConfig.value) return;
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('确定要将当前配置保存至所有群吗?', '保存至所有群', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '保存',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await apiClient.saveGroupConfig('all', groupConfig.value);
|
|
||||||
ElMessage.success('已保存至所有群');
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '保存失败');
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetGroup = () => {
|
|
||||||
if (groupBackup) {
|
|
||||||
groupConfig.value = JSON.parse(JSON.stringify(groupBackup));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== 通用 ====================
|
|
||||||
/** 动态标签输入: 回车添加, 重复值不添加 */
|
|
||||||
const addTag = (list: string[], input: Ref<string>) => {
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (value && !list.includes(value)) {
|
|
||||||
list.push(value);
|
|
||||||
}
|
|
||||||
input.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const addWeight = () => {
|
|
||||||
if (!groupConfig.value) return;
|
|
||||||
groupConfig.value.answer_threshold_weights.push(25);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeWeight = (index: number) => {
|
|
||||||
if (!groupConfig.value) return;
|
|
||||||
if (groupConfig.value.answer_threshold_weights.length > 1) {
|
|
||||||
groupConfig.value.answer_threshold_weights.splice(index, 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMember = (value: number | null) => {
|
|
||||||
if (!groupConfig.value || value === null) return;
|
|
||||||
const v = String(value);
|
|
||||||
if (!groupConfig.value.ban_users.includes(v)) {
|
|
||||||
groupConfig.value.ban_users.push(v);
|
|
||||||
}
|
|
||||||
pendingMember.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatUserId = (userId: string | number) => {
|
|
||||||
const member = groupConfig.value?.member_list.find(
|
|
||||||
(m) => m.value.toString() === userId.toString()
|
|
||||||
);
|
|
||||||
return member?.label || String(userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchGlobalConfig();
|
|
||||||
fetchGroupList();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.config-container {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-body {
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-card {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-card .el-form {
|
|
||||||
max-width: 520px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-card :deep(.el-input-number) {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suffix-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suffix-text {
|
|
||||||
color: #909399;
|
|
||||||
font-size: 13px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-select {
|
|
||||||
width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weight-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weight-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weight-row .el-input-number {
|
|
||||||
width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-select-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-select-label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #303133;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<el-alert type="info" :closable="false" show-icon class="page-tip">
|
|
||||||
<template #title>
|
|
||||||
<p>此数据库记录了机器人所学习的内容。</p>
|
|
||||||
<p>点击「回复列表」可以查看该条内容已学习到的回复;点击「禁用」可以禁用该学习;点击「删除」可以删除该学习。</p>
|
|
||||||
</template>
|
|
||||||
</el-alert>
|
|
||||||
|
|
||||||
<div class="page-card">
|
|
||||||
<div class="toolbar">
|
|
||||||
<el-input
|
|
||||||
v-model="searchKeywords"
|
|
||||||
placeholder="搜索关键词"
|
|
||||||
clearable
|
|
||||||
class="toolbar-wide"
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
|
||||||
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="contexts" stripe border @sort-change="handleSortChange">
|
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
|
||||||
<el-table-column label="内容/关键词" min-width="240" sortable="custom">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span
|
|
||||||
class="clickable-cell cell-ellipsis"
|
|
||||||
:title="row.keywords"
|
|
||||||
@click="showDetail(`学习内容 #${row.id}`, row.keywords)"
|
|
||||||
>
|
|
||||||
{{ row.keywords }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
|
|
||||||
<template #default="{ row }">{{ formatTime(row.time) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="count" label="已学次数" width="110" sortable="custom" />
|
|
||||||
<el-table-column label="操作" width="220" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" type="primary" plain @click="showAnswers(row)">回复列表</el-button>
|
|
||||||
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
|
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<template #empty>
|
|
||||||
<el-empty description="暂无学习内容" :image-size="80" />
|
|
||||||
</template>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="page"
|
|
||||||
:page-size="perPage"
|
|
||||||
:total="total"
|
|
||||||
layout="total, prev, pager, next"
|
|
||||||
background
|
|
||||||
@current-change="fetchContexts"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 回复列表弹窗 -->
|
|
||||||
<el-dialog
|
|
||||||
v-model="dialogVisible"
|
|
||||||
:title="`回复列表 - ${currentContext?.keywords ?? ''}`"
|
|
||||||
width="640px"
|
|
||||||
destroy-on-close
|
|
||||||
@closed="dialogVisible = false; currentContext = null; contextAnswers = []"
|
|
||||||
>
|
|
||||||
<div v-loading="answersLoading">
|
|
||||||
<el-table v-if="contextAnswers.length" :data="contextAnswers" size="small" stripe border>
|
|
||||||
<el-table-column prop="keywords" label="回复内容" min-width="180" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="count" label="次数" width="80" />
|
|
||||||
<el-table-column label="操作" width="140" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" type="warning" plain @click="handleBanAnswer(row)">禁用</el-button>
|
|
||||||
<el-button size="small" type="danger" plain @click="handleDeleteAnswer(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<el-empty v-else-if="!answersLoading" description="暂无回复" :image-size="80" />
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import apiClient from '@/api/client';
|
|
||||||
import FullTextDialog from '@/components/FullTextDialog.vue';
|
|
||||||
import { formatTime } from '@/utils/format';
|
|
||||||
import { showError } from '@/utils/notify';
|
|
||||||
import type { ChatAnswer, ChatContext } from '@/types';
|
|
||||||
|
|
||||||
const contexts = ref<ChatContext[]>([]);
|
|
||||||
const total = ref(0);
|
|
||||||
const page = ref(1);
|
|
||||||
const perPage = 10;
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
const searchKeywords = ref('');
|
|
||||||
const sortColumn = ref<'keywords' | 'time' | 'count'>('time');
|
|
||||||
const sortOrder = ref<'asc' | 'desc'>('desc');
|
|
||||||
|
|
||||||
const dialogVisible = ref(false);
|
|
||||||
const currentContext = ref<ChatContext | null>(null);
|
|
||||||
const contextAnswers = ref<ChatAnswer[]>([]);
|
|
||||||
const answersLoading = ref(false);
|
|
||||||
|
|
||||||
// 完整内容弹窗
|
|
||||||
const detailVisible = ref(false);
|
|
||||||
const detailTitle = ref('');
|
|
||||||
const detailContent = ref('');
|
|
||||||
|
|
||||||
const showDetail = (title: string, content: string) => {
|
|
||||||
detailTitle.value = title;
|
|
||||||
detailContent.value = content || '(无内容)';
|
|
||||||
detailVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchContexts = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await apiClient.getContexts({
|
|
||||||
page: page.value,
|
|
||||||
perPage,
|
|
||||||
orderBy: sortColumn.value,
|
|
||||||
orderDir: sortOrder.value,
|
|
||||||
keywords: searchKeywords.value,
|
|
||||||
});
|
|
||||||
contexts.value = res.data!.items;
|
|
||||||
total.value = res.data!.total;
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '获取学习内容失败');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
page.value = 1;
|
|
||||||
fetchContexts();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = ({
|
|
||||||
prop,
|
|
||||||
order,
|
|
||||||
}: {
|
|
||||||
prop: string;
|
|
||||||
order: 'ascending' | 'descending' | null;
|
|
||||||
}) => {
|
|
||||||
if (!prop) return;
|
|
||||||
sortColumn.value = prop as 'keywords' | 'time' | 'count';
|
|
||||||
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
|
|
||||||
page.value = 1;
|
|
||||||
fetchContexts();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (row: ChatContext) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定要删除学习内容 "${row.keywords}" 吗?`, '删除学习内容', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.deleteChat('context', row.id);
|
|
||||||
ElMessage.success('已删除');
|
|
||||||
fetchContexts();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBan = async (row: ChatContext) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定要禁用学习内容 "${row.keywords}" 吗?`, '禁用学习内容', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '禁用',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.banChat('context', row.id);
|
|
||||||
ElMessage.success('已禁用');
|
|
||||||
fetchContexts();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '禁用失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAll = async () => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
'确定要删除所有学习内容吗?这是不可逆的操作!',
|
|
||||||
'删除所有学习内容',
|
|
||||||
{
|
|
||||||
type: 'error',
|
|
||||||
confirmButtonText: '全部删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.deleteAll('context');
|
|
||||||
ElMessage.success('已删除所有学习内容');
|
|
||||||
fetchContexts();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showAnswers = async (row: ChatContext) => {
|
|
||||||
currentContext.value = row;
|
|
||||||
contextAnswers.value = [];
|
|
||||||
dialogVisible.value = true;
|
|
||||||
answersLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await apiClient.getAnswers({ context_id: row.id, perPage: 100 });
|
|
||||||
contextAnswers.value = res.data!.items;
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '获取回复列表失败');
|
|
||||||
} finally {
|
|
||||||
answersLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAnswer = async (row: ChatAnswer) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.deleteChat('answer', row.id);
|
|
||||||
ElMessage.success('已删除');
|
|
||||||
if (currentContext.value) showAnswers(currentContext.value);
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBanAnswer = async (row: ChatAnswer) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '禁用',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.banChat('answer', row.id);
|
|
||||||
ElMessage.success('已禁用');
|
|
||||||
if (currentContext.value) showAnswers(currentContext.value);
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '禁用失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
fetchContexts();
|
|
||||||
</script>
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="login-wrapper">
|
|
||||||
<el-card class="login-card" shadow="always">
|
|
||||||
<div class="logo-container">
|
|
||||||
<img
|
|
||||||
:src="LOGO_URL"
|
|
||||||
alt="Nonebot-Plugin-Learning-Chat"
|
|
||||||
class="logo"
|
|
||||||
@error="logoFailed = true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h1 class="app-title">Nonebot-Plugin-Learning-Chat 控制台</h1>
|
|
||||||
|
|
||||||
<el-form
|
|
||||||
ref="formRef"
|
|
||||||
:model="form"
|
|
||||||
:rules="rules"
|
|
||||||
label-position="top"
|
|
||||||
size="large"
|
|
||||||
@submit.prevent
|
|
||||||
>
|
|
||||||
<el-form-item label="用户名" prop="username">
|
|
||||||
<el-input
|
|
||||||
v-model="form.username"
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
:disabled="loading"
|
|
||||||
autofocus
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="密码" prop="password">
|
|
||||||
<el-input
|
|
||||||
v-model="form.password"
|
|
||||||
type="password"
|
|
||||||
placeholder="请输入密码"
|
|
||||||
show-password
|
|
||||||
:disabled="loading"
|
|
||||||
@keyup.enter="handleLogin"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="error"
|
|
||||||
:title="error"
|
|
||||||
type="error"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
class="login-error"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
class="login-button"
|
|
||||||
:loading="loading"
|
|
||||||
@click="handleLogin"
|
|
||||||
>
|
|
||||||
登录
|
|
||||||
</el-button>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { reactive, ref } from 'vue';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
import type { FormInstance, FormRules } from 'element-plus';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
import apiClient from '@/api/client';
|
|
||||||
|
|
||||||
const LOGO_URL =
|
|
||||||
'https://s3.sansenhoshi.top/s-file-trans/%E5%9B%BE%E7%89%87/icon/123.jpg';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const route = useRoute();
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
const formRef = ref<FormInstance>();
|
|
||||||
const loading = ref(false);
|
|
||||||
const error = ref('');
|
|
||||||
const logoFailed = ref(false);
|
|
||||||
|
|
||||||
const form = reactive({ username: '', password: '' });
|
|
||||||
|
|
||||||
const rules: FormRules = {
|
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
|
||||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
error.value = '';
|
|
||||||
if (!formRef.value) return;
|
|
||||||
const valid = await formRef.value.validate().catch(() => false);
|
|
||||||
if (!valid) return;
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await apiClient.login(form.username.trim(), form.password);
|
|
||||||
authStore.setAuth(res.data!.token, form.username.trim());
|
|
||||||
// 登录后跳回被拦截前的页面(如 token 过期时重定向来的路径)
|
|
||||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
|
|
||||||
await router.push(redirect);
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : '登录失败,请重试';
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.login-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-container {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 90px;
|
|
||||||
height: 90px;
|
|
||||||
border-radius: 10px;
|
|
||||||
object-fit: cover;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-title {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
margin: 0 0 28px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-error {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<el-alert type="info" :closable="false" show-icon class="page-tip">
|
|
||||||
<template #title>
|
|
||||||
<p>此数据库记录了机器人收到的聊天记录。</p>
|
|
||||||
<p>点击「禁用」可以将某条聊天记录进行禁用;点击「删除」可以删除某条记录。</p>
|
|
||||||
</template>
|
|
||||||
</el-alert>
|
|
||||||
|
|
||||||
<div class="page-card">
|
|
||||||
<div class="toolbar">
|
|
||||||
<el-input v-model="searchGroupId" placeholder="群ID" clearable />
|
|
||||||
<el-input v-model="searchUserId" placeholder="用户ID" clearable />
|
|
||||||
<el-input
|
|
||||||
v-model="searchMessage"
|
|
||||||
placeholder="消息内容"
|
|
||||||
clearable
|
|
||||||
class="toolbar-wide"
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
|
||||||
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="messages" stripe border>
|
|
||||||
<el-table-column prop="message_id" label="消息ID" width="130" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="group_id" label="群ID" width="110" />
|
|
||||||
<el-table-column prop="user_id" label="用户ID" width="110" />
|
|
||||||
<el-table-column label="消息" min-width="240">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span
|
|
||||||
class="clickable-cell cell-ellipsis"
|
|
||||||
:title="row.raw_message || row.message"
|
|
||||||
@click="showDetail(`消息 #${row.message_id}`, row.raw_message || row.message)"
|
|
||||||
>
|
|
||||||
{{ row.raw_message || row.message }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="时间" width="180">
|
|
||||||
<template #default="{ row }">{{ formatTime(row.time) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="150" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
|
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<template #empty>
|
|
||||||
<el-empty description="暂无消息数据" :image-size="80" />
|
|
||||||
</template>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="page"
|
|
||||||
:page-size="perPage"
|
|
||||||
:total="total"
|
|
||||||
layout="total, prev, pager, next"
|
|
||||||
background
|
|
||||||
@current-change="fetchMessages"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import apiClient from '@/api/client';
|
|
||||||
import FullTextDialog from '@/components/FullTextDialog.vue';
|
|
||||||
import { formatTime } from '@/utils/format';
|
|
||||||
import { showError } from '@/utils/notify';
|
|
||||||
import type { ChatMessage } from '@/types';
|
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([]);
|
|
||||||
const total = ref(0);
|
|
||||||
const page = ref(1);
|
|
||||||
const perPage = 10;
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
const searchGroupId = ref('');
|
|
||||||
const searchUserId = ref('');
|
|
||||||
const searchMessage = ref('');
|
|
||||||
|
|
||||||
// 完整内容弹窗
|
|
||||||
const detailVisible = ref(false);
|
|
||||||
const detailTitle = ref('');
|
|
||||||
const detailContent = ref('');
|
|
||||||
|
|
||||||
const showDetail = (title: string, content: string) => {
|
|
||||||
detailTitle.value = title;
|
|
||||||
detailContent.value = content || '(无内容)';
|
|
||||||
detailVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMessages = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await apiClient.getMessages({
|
|
||||||
page: page.value,
|
|
||||||
perPage,
|
|
||||||
group_id: searchGroupId.value,
|
|
||||||
user_id: searchUserId.value,
|
|
||||||
message: searchMessage.value,
|
|
||||||
});
|
|
||||||
messages.value = res.data!.items;
|
|
||||||
total.value = res.data!.total;
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '获取消息失败');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 搜索必须重置到第 1 页, 否则可能停留在超范围的页码上
|
|
||||||
const handleSearch = () => {
|
|
||||||
page.value = 1;
|
|
||||||
fetchMessages();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (row: ChatMessage) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定要删除消息 "${row.raw_message || row.message}" 吗?`, '删除消息', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.deleteChat('message', row.id);
|
|
||||||
ElMessage.success('已删除');
|
|
||||||
fetchMessages();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBan = async (row: ChatMessage) => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定要禁用这条消息吗?`, '禁用消息', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '禁用',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.banChat('message', row.id);
|
|
||||||
ElMessage.success('已禁用');
|
|
||||||
fetchMessages();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '禁用失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAll = async () => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
'确定要删除所有消息吗?这是不可逆的操作!',
|
|
||||||
'删除所有消息',
|
|
||||||
{
|
|
||||||
type: 'error',
|
|
||||||
confirmButtonText: '全部删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await apiClient.deleteAll('message');
|
|
||||||
ElMessage.success('已删除所有消息');
|
|
||||||
fetchMessages();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, '删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
fetchMessages();
|
|
||||||
</script>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
declare module '*.vue' {
|
|
||||||
import type { DefineComponent } from 'vue'
|
|
||||||
const component: DefineComponent<{}, {}, any>
|
|
||||||
export default component
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
|
||||||
}
|
|
||||||