diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8b83ff7..1854d3c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+## v3.6.2 合并转发 UID、表情包跟进与协调器清理
+
+本版本继续收敛消息附件链路和协调器结构:合并转发可作为会话内 `forward_` UID 被 AI 按层读取和复用,表情包跟进策略更稳定,同时移除旧版协调器兼容模块,并完成 3.6.2 版本号同步。
+
+- 新增合并转发 UID 语义。消息入口会把合并转发登记为 ``,保留原始 OneBot forward id,避免在上下文中直接暴露裸 ID。
+- 增强 `get_forward_msg`。工具支持传入 `forward_` UID,按分页读取节点;节点内图片、文件会注册为附件 UID,内层合并转发会返回新的 `` 供继续读取。
+- 完善附件与 XML 处理。OneBot forward 段、合并转发内附件注册、合法 `` / `` 标签保留和复读图片发送链路同步修复。
+- 调整表情包回复策略。主提示词、NagaAgent 提示词和协调器提示统一要求“先文字、后表情包”,减少首条回复被表情包检索拖慢或覆盖正文的情况。
+- 清理旧版协调器入口。删除 `services.ai_coordinator` 兼容模块,文档和测试改为引用 `services.coordinator.AICoordinator`,并补齐合并转发、附件、复读和 XML 回归测试。
+
+---
+
## v3.6.1 Agent 路由收敛、文件分析增强与发版脚本修复
本版本围绕 Agent 职责边界和文件分析链路做小版本收敛:把 arXiv 论文分析从独立 Agent 合并到通用文件分析 Agent,让论文、PDF 页面视觉分析和视频附件获取都走统一的附件 UID 语义;同时修复版本迭代脚本重写 Tauri 配置格式导致 pre-commit 失败的问题,保证后续版本号同步、lock 文件刷新和自动提交流程更稳定。
diff --git a/README.md b/README.md
index 24dc6ad4..b7d19cc3 100644
--- a/README.md
+++ b/README.md
@@ -176,7 +176,7 @@ async def main() -> None:
asyncio.run(main())
```
-- [Python 库 API 参考](docs/python-api.md) — 根包符号表、shim 路径、`AIClient` / `CognitiveService` 等嵌入示例
+- [Python 库 API 参考](docs/python-api.md) — 根包符号表、稳定子包路径、`AIClient` / `CognitiveService` 等嵌入示例
- [配置详解 — 库嵌入配置](docs/configuration.md#2-库嵌入配置) — `from_mapping` / `Config.builder`
- [开发者与拓展中心](docs/development.md) — 模块结构与自检命令
diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json
index 077c6509..0f669ad6 100644
--- a/apps/undefined-chat/package-lock.json
+++ b/apps/undefined-chat/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "undefined-chat",
- "version": "3.6.1",
+ "version": "3.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "undefined-chat",
- "version": "3.6.1",
+ "version": "3.6.2",
"dependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json
index 71aaf1ae..9bc32cae 100644
--- a/apps/undefined-chat/package.json
+++ b/apps/undefined-chat/package.json
@@ -1,7 +1,7 @@
{
"name": "undefined-chat",
"private": true,
- "version": "3.6.1",
+ "version": "3.6.2",
"type": "module",
"scripts": {
"tauri": "tauri",
diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock
index 57704049..c3fbf761 100644
--- a/apps/undefined-chat/src-tauri/Cargo.lock
+++ b/apps/undefined-chat/src-tauri/Cargo.lock
@@ -5431,7 +5431,7 @@ dependencies = [
[[package]]
name = "undefined_chat"
-version = "3.6.1"
+version = "3.6.2"
dependencies = [
"futures-util",
"keyring",
diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml
index e75855b0..c452e146 100644
--- a/apps/undefined-chat/src-tauri/Cargo.toml
+++ b/apps/undefined-chat/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "undefined_chat"
-version = "3.6.1"
+version = "3.6.2"
description = "Undefined native chat client"
authors = ["Undefined contributors"]
license = "MIT"
diff --git a/apps/undefined-chat/src-tauri/tauri.conf.json b/apps/undefined-chat/src-tauri/tauri.conf.json
index 215e05cb..0b25919a 100644
--- a/apps/undefined-chat/src-tauri/tauri.conf.json
+++ b/apps/undefined-chat/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Undefined Chat",
- "version": "3.6.1",
+ "version": "3.6.2",
"identifier": "com.undefined.chat",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json
index 313382f2..60f3e9a7 100644
--- a/apps/undefined-console/package-lock.json
+++ b/apps/undefined-console/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "undefined-console",
- "version": "3.6.1",
+ "version": "3.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "undefined-console",
- "version": "3.6.1",
+ "version": "3.6.2",
"dependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-http": "^2.3.0"
diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json
index 5b227cd3..b025051f 100644
--- a/apps/undefined-console/package.json
+++ b/apps/undefined-console/package.json
@@ -1,7 +1,7 @@
{
"name": "undefined-console",
"private": true,
- "version": "3.6.1",
+ "version": "3.6.2",
"type": "module",
"scripts": {
"tauri": "tauri",
diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock
index 96b29fcd..125ed043 100644
--- a/apps/undefined-console/src-tauri/Cargo.lock
+++ b/apps/undefined-console/src-tauri/Cargo.lock
@@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "undefined_console"
-version = "3.6.1"
+version = "3.6.2"
dependencies = [
"serde",
"serde_json",
diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml
index 14001baf..b0787ca2 100644
--- a/apps/undefined-console/src-tauri/Cargo.toml
+++ b/apps/undefined-console/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "undefined_console"
-version = "3.6.1"
+version = "3.6.2"
description = "Undefined cross-platform management console"
authors = ["Undefined contributors"]
license = "MIT"
diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json
index 3a2874f1..227c744b 100644
--- a/apps/undefined-console/src-tauri/tauri.conf.json
+++ b/apps/undefined-console/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Undefined Console",
- "version": "3.6.1",
+ "version": "3.6.2",
"identifier": "com.undefined.console",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/docs/configuration.md b/docs/configuration.md
index dd7f441c..bef89406 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -580,6 +580,8 @@ Prompt caching 补充:
外部接收的远程图片或文件默认会先下载到附件缓存再生成 UID,避免后续 URL 失效;大文件超过阈值时,UID 仍会生成,但绑定的是 URL 引用而不是缓存文件,AI 可在上下文中看到原始 `source_ref`。如果本地缓存因总容量或时间清理被删除,但记录仍保留 URL,后续需要文件内容时会优先按 URL 回源下载。
+合并转发会复用同一注册表登记为 `forward_...` UID,并在实时 AI 输入中显示为 ``。历史记录仍保留递归展开后的文本;需要查看实时上下文里的转发内容时,AI 会调用 `messages.get_forward_msg` 按层读取,内层合并转发会继续分配新的 `forward_...` UID。
+
### 4.10.2 `[message_batcher]` 同 sender 短时消息合并
| 字段 | 默认值 | 说明 |
@@ -764,7 +766,7 @@ Prompt caching 补充:
- 命中 B 站链接、BV 号或 AV 号后,自动提取会发送一次外层合并转发,固定包含三个节点:视频信息、视频文件或视频状态、弹幕列表。
- 弹幕通过 Bilibili protobuf 接口分段拉取;项目内置了解码逻辑,无需安装 `protoc` 或额外生成 protobuf 代码。
- 弹幕列表节点会按每 100 条弹幕生成一个内层合并转发;每条弹幕对应内层合并转发中的一个节点,便于在客户端逐条查看。
-- 视频文件下载、清晰度、时长和体积限制仍由本节配置控制;自动提取的转发消息也会通过统一发送层写入历史,供后续 AI 回复读取。
+- 视频文件下载、清晰度、时长和体积限制仍由本节配置控制;自动提取的转发消息会通过统一发送层写入历史。后续实时 AI 上下文遇到合并转发时只看到 `forward_...` UID,需要内容时按层调用 `messages.get_forward_msg` 读取。
---
diff --git a/docs/development.md b/docs/development.md
index 305e4d02..3d911c6c 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -37,7 +37,7 @@ src/Undefined/
│ └── _openapi.py # OpenAPI 文档生成
├── memes/ # 表情包库 (service + ingest/ + search/ + store + vector_store)
├── services/ # 核心运行服务
-│ ├── coordinator/ # AICoordinator mixins(ai_coordinator.py 门面)
+│ ├── coordinator/ # AICoordinator 唯一实现(群聊 / 私聊 / 批处理 / 后台任务 mixins)
│ ├── commands/ # CommandDispatcher mixins(stats / bugfix)
│ ├── message_batcher/ # 同 sender 短时合并
│ ├── command.py # 命令分发门面 + shim 组合
diff --git a/docs/memes.md b/docs/memes.md
index 15dcbd07..7b98f913 100644
--- a/docs/memes.md
+++ b/docs/memes.md
@@ -28,7 +28,7 @@ Undefined 平台自 3.3.0 版本起内置了强大的**全局表情包库**功
存储与索引完成后,AI Agent 会通过内置的 `memes.*` 系列工具使用表情包:
- **`memes.search_memes`**:支持关键词检索(基于 SQLite)、语义检索(基于 ChromaDB 向量相似度)与混合检索(Hybrid)。AI 可借此根据当前对话的语境快速寻找最有梗的静态图或 GIF。
- **发送机制**:使用统一的图片 `uid` 进行索引。系统不仅提供了 `memes.send_meme_by_uid` 让 AI 一键发送表情包,还支持 AI 输出 `` 统一资源标签指令进行图文混排;用户发来的图片在 AI 上下文中也使用同一格式,旧 `` / `[图片 uid=...]` 仅作为兼容格式保留。
-- **回复顺序**:只有当本轮明确是纯表情包 / 纯反应图回复时,AI 才应先搜索并发送表情包。凡是需要文字承接、解释、答疑、推进任务或确认操作的场景,都必须先发送必要文字;如果仍想补表情包,再把 `memes.search_memes` / `memes.send_meme_by_uid` 放到后续轮次,避免表情包检索拖慢首条回复体验。
+- **回复顺序**:只有当本轮明确是纯表情包 / 纯反应图回复时,AI 才应先搜索并发送表情包。凡是需要文字承接、解释、答疑、推进任务或确认操作的场景,都必须先发送必要文字;轻松聊天、吐槽、附和、接梗、表达情绪、被拍一拍或被 @ 后的短回应等场景,文字发送成功后会更积极地在后续轮次补发一张独立表情包。严肃答疑、代码排查、长任务推进、隐私/安全拒绝或信息不足追问默认不补表情包,避免影响信息传递。
## 目录结构与配置
diff --git a/docs/message-batching.md b/docs/message-batching.md
index ad410ecb..e84c97e7 100644
--- a/docs/message-batching.md
+++ b/docs/message-batching.md
@@ -87,7 +87,7 @@ allow_cancel_after_send = false
## 相关文件
- 实现:[src/Undefined/services/message_batcher/](src/Undefined/services/message_batcher/)
-- 接入:[src/Undefined/services/ai_coordinator.py](src/Undefined/services/ai_coordinator.py) 中 `handle_auto_reply` / `handle_private_reply` / `_dispatch_grouped_request`
+- 接入:[src/Undefined/services/coordinator/](src/Undefined/services/coordinator/) 中 `handle_auto_reply` / `handle_private_reply` / `_dispatch_grouped_request`
- 创建/注入:[src/Undefined/handlers/message_flow.py](src/Undefined/handlers/message_flow.py)
- 关停 flush:[src/Undefined/main.py](src/Undefined/main.py)
- 热更新:[src/Undefined/config/hot_reload.py](src/Undefined/config/hot_reload.py)
diff --git a/docs/multi-model.md b/docs/multi-model.md
index c8dec583..393f5471 100644
--- a/docs/multi-model.md
+++ b/docs/multi-model.md
@@ -129,7 +129,7 @@ features.pool_enabled ← 全局总开关(false 时完全不生效)
| `config/loader.py` | 解析 pool 配置,字段缺省继承主模型 |
| `ai/model_selector.py` | 纯选择逻辑:策略、偏好存储、compare 状态 |
| `services/model_pool.py` | 私聊交互服务:`/compare`、「选X」、`select_chat_config` |
-| `services/ai_coordinator.py` | 持有 `ModelPoolService`,私聊队列投递时选模型 |
+| `services/coordinator/` | 持有 `ModelPoolService`,私聊队列投递时选模型 |
| `handlers.py` | 私聊消息委托给 `model_pool.handle_private_message()` |
| `skills/agents/runner.py` | Agent 执行时调用 `model_selector.select_agent_config()` |
| `utils/queue_intervals.py` | 注册 pool 模型的队列间隔 |
diff --git a/docs/pipelines.md b/docs/pipelines.md
index 07e89eb1..386f3272 100644
--- a/docs/pipelines.md
+++ b/docs/pipelines.md
@@ -6,8 +6,8 @@
## 运行顺序
-1. `MessageHandler` 先并行执行消息预处理:附件收集、历史文本解析、昵称或群信息读取等。图片、文件等媒体会登记为附件 UID,并在 AI 可见正文中统一写作 ``。
-2. 用户消息先写入历史。
+1. `MessageHandler` 先并行执行消息预处理:附件收集、历史文本解析、昵称或群信息读取等。图片、文件等媒体会登记为附件 UID,并在 AI 可见正文中统一写作 ``;合并转发会登记为 ``,不在实时 AI 输入中自动展开。
+2. 用户消息先写入历史。历史记录仍会递归展开合并转发文本,保持历史检索和旧行为兼容;实时 AI 输入只保留 forward UID,AI 需要查看时调用 `messages.get_forward_msg` 按层读取。
3. 若消息命中斜杠命令,立即分发命令并结束本轮后续流程;命令输入和命令输出会写入历史,供后续 AI 轮次读取。
4. 未命中命令时,`PipelineRegistry` 并行调用所有已注册管线的 `detect(context)`。
5. 对所有命中的管线,并行调用对应的 `process(detection, context)`。
@@ -16,6 +16,8 @@
命中自动处理管线的消息会继续进入 AI 自动回复,让 AI 基于用户消息和刚写入的自动处理结果判断后续行为。
+合并转发里的图片仍会在后台递归扫描并进入表情包入库队列;该扫描不把转发文本或图片列表追加到实时 AI 上下文,避免大型转发树撑爆上下文。
+
## 内置 Bilibili 管线
Bilibili 自动提取管线命中 B 站链接、BV 号或 AV 号后,会发送一次外层合并转发,外层固定包含三个节点:视频信息、视频文件或视频状态、弹幕列表。
diff --git a/docs/python-api.md b/docs/python-api.md
index 08949b09..c015f87a 100644
--- a/docs/python-api.md
+++ b/docs/python-api.md
@@ -89,6 +89,7 @@ from Undefined.api.app import RuntimeAPIServer
以下模块**不会**进入根包 re-export,也不保证跨版本兼容:
- `Undefined.main`, `Undefined.webui`, `Undefined.handlers`, `Undefined.onebot`
+- `Undefined.services` 下的运行时编排模块;`AICoordinator` 的唯一内部导入路径是 `Undefined.services.coordinator`,旧路径 `Undefined.services.ai_coordinator` 已移除
- `Undefined.config.coercers`, `Undefined.config.model_parsers`
- `Undefined.utils.io`, `Undefined.utils.paths`
diff --git a/docs/usage.md b/docs/usage.md
index 791bc947..5fc87cea 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -184,7 +184,7 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需
| `messages.send_text_file` | 将文本内容生成文件后发送 |
| `messages.send_url_file` | 下载指定 URL 的文件后发送 |
| `messages.send_group_sign` | 执行群签到操作 |
-| `messages.get_forward_msg` | 获取合并转发消息的内容 |
+| `messages.get_forward_msg` | 按层读取合并转发内容;支持 `` 和旧合并转发 ID,可用 `offset`/`limit` 分页查看更多 |
---
diff --git a/pyproject.toml b/pyproject.toml
index e36a5b7f..3b8062f9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "Undefined-bot"
-version = "3.6.1"
+version = "3.6.2"
description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11."
readme = "README.md"
authors = [
diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml
index b6902745..b0a9fcee 100644
--- a/res/prompts/undefined.xml
+++ b/res/prompts/undefined.xml
@@ -189,17 +189,17 @@
-
+
**图文混排规则:**
- 只有当本轮回复目标明确是“纯表情包/纯反应图”(用户直接要求发表情包,或只需要一个无文字反应且不承担信息传递)时,才允许第一轮先调用 `memes.search_memes`
- 其他任何需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 `send_message`
- - 如果本轮既需要文字发言又想配表情包,先调用 `send_message` 发出必要文字;`memes.search_memes` 和 `memes.send_meme_by_uid` 只能放到后续响应轮次再做,因为表情包检索可能拖慢首条回复体验
+ - 如果本轮既需要文字发言又适合配表情包,先调用 `send_message` 发出必要文字;轻松聊天、吐槽、附和、接梗、表达情绪、被拍一拍、被 @ 后的短回应等场景,文字发送成功后优先考虑在后续响应轮次调用 `memes.search_memes` 和 `memes.send_meme_by_uid` 补一张独立表情包;不要阻塞首条文字回复
- 当不确定是不是纯表情包场景,按非纯表情包处理:先文字,后检索或不检索;不要为了“增强语气”在首轮抢先调用 `memes.search_memes`
- 如果要发送独立表情包,先用 `memes.search_memes` 找到合适的图片 `uid`,再用 `memes.send_meme_by_uid` 单独发送一条图片消息
- - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,只有在它们确实属于纯表情包回复时才先发表情包;否则先用文字自然回应,表情包最多作为后置补充
- - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,如果文字本身有用,先发文字;表情包只作为后续可选增强,不能阻塞首条文字回复
+ - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,若已经满足回复触发且不需要承担严肃信息传递,优先选择纯表情包回复或文字后补一张表情包;如果必须表达具体信息,先用文字自然回应,再把表情包作为后置补充
+ - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,如果文字本身有用,先发文字;轻松、情绪化、玩梗语境中优先把表情包作为后续增强,但严肃答疑、代码排查、长任务推进、隐私/安全拒绝或信息不足追问时默认不补表情包
- 表情包相关规则只决定“怎么回复”,不单独构成“该不该回复”的参与许可;是否回复仍以前面的回复触发逻辑为准
- - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送;如果文字本身是必要回复,先发文字,再延后检索和发送表情包
+ - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送;如果文字本身是必要回复,先发文字,再在适合轻松表达时延后检索和发送表情包
- 推荐使用统一标签 `` 引用任何附件(图片或文件),系统根据 UID 前缀自动处理:
- `pic_*` UID → 内嵌为图片(等效于旧 `` 语法)
- `file_*` UID → 作为独立文件消息在文字之后发出
@@ -719,11 +719,11 @@
图片处理
先判断是否需要参与:只有当图片与当前对话强相关、且回答必须依赖图片内容时才分析
- 表情包先理解意思;它不只适用于轻松闲聊。但只有明确纯表情包回复才先检索表情包;凡是需要文字承接、答疑、解释或推进任务的场景,都先发送必要文字,表情包最多放到后续轮次作为可选补充
+ 表情包先理解意思;它不只适用于轻松闲聊。但只有明确纯表情包回复才先检索表情包;凡是需要文字承接、答疑、解释或推进任务的场景,都先发送必要文字。轻松互动、吐槽、接梗、情绪回应中,已经决定回复且文字后仍适合表达时,优先放到后续轮次补一张独立表情包;严肃、任务型、高信息密度场景不补
只有在需要分析图片内容时才调用 file_analysis_agent(如报错截图/界面/文档/图片问题)
当消息中出现“[图片: xxx]”占位符时,xxx 即为 file_id 或 URL,可直接作为 file_source 调用 file_analysis_agent
未调用 file_analysis_agent 时,不要猜测图片内容;可以说明“我看不到图片内容,需要先分析”
- 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;只有明确纯表情包回复时才可先尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,再把表情包检索和发送放到后续轮次
+ 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;只有明确纯表情包回复时才可先尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,轻松场景优先考虑再把表情包检索和发送放到后续轮次
回复时不要描述图片内容,像正常人一样直接回应重点
不要分析每条图片。图片分析有很大延迟,只有需要时才分析
@@ -808,7 +808,7 @@
不是每条消息都要回
大部分时候你应该保持沉默
不符合触发条件时,直接调用 end
- optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,除非明确是纯表情包回复,否则先把必要文字回复做好,表情包最后再搜或不搜。
+ optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,除非明确是纯表情包回复,否则先把必要文字回复做好。轻松、接梗、情绪回应可以优先后补表情包;严肃或任务型场景不补。
diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml
index 85f5471d..af8b90cf 100644
--- a/res/prompts/undefined_nagaagent.xml
+++ b/res/prompts/undefined_nagaagent.xml
@@ -2,6 +2,7 @@
+
@@ -188,17 +189,17 @@
-
+
**图文混排规则:**
- 只有当本轮回复目标明确是“纯表情包/纯反应图”(用户直接要求发表情包,或只需要一个无文字反应且不承担信息传递)时,才允许第一轮先调用 `memes.search_memes`
- 其他任何需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 `send_message`
- - 如果本轮既需要文字发言又想配表情包,先调用 `send_message` 发出必要文字;`memes.search_memes` 和 `memes.send_meme_by_uid` 只能放到后续响应轮次再做,因为表情包检索可能拖慢首条回复体验
+ - 如果本轮既需要文字发言又适合配表情包,先调用 `send_message` 发出必要文字;轻松聊天、吐槽、附和、接梗、表达情绪、被拍一拍、被 @ 后的短回应等场景,文字发送成功后优先考虑在后续响应轮次调用 `memes.search_memes` 和 `memes.send_meme_by_uid` 补一张独立表情包;不要阻塞首条文字回复
- 当不确定是不是纯表情包场景,按非纯表情包处理:先文字,后检索或不检索;不要为了“增强语气”在首轮抢先调用 `memes.search_memes`
- 如果要发送独立表情包,先用 `memes.search_memes` 找到合适的图片 `uid`,再用 `memes.send_meme_by_uid` 单独发送一条图片消息
- - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,只有在它们确实属于纯表情包回复时才先发表情包;否则先用文字自然回应,表情包最多作为后置补充
- - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,如果文字本身有用,先发文字;表情包只作为后续可选增强,不能阻塞首条文字回复
+ - 对于吐槽、附和、接梗、表达态度、表达情绪这类回复,若已经满足回复触发且不需要承担严肃信息传递,优先选择纯表情包回复或文字后补一张表情包;如果必须表达具体信息,先用文字自然回应,再把表情包作为后置补充
+ - 对于私聊对话、被拍一拍、被@、轻量答疑这类本来就会回复的场景,如果文字本身有用,先发文字;轻松、情绪化、玩梗语境中优先把表情包作为后续增强,但严肃答疑、代码排查、长任务推进、隐私/安全拒绝或信息不足追问时默认不补表情包
- 表情包相关规则只决定“怎么回复”,不单独构成“该不该回复”的参与许可;是否回复仍以前面的回复触发逻辑为准
- - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送;如果文字本身是必要回复,先发文字,再延后检索和发送表情包
+ - 默认不要把表情包和正文写进同一条消息;需要补一句解释时,优先分成两条消息发送;如果文字本身是必要回复,先发文字,再在适合轻松表达时延后检索和发送表情包
- 推荐使用统一标签 `` 引用任何附件(图片或文件),系统根据 UID 前缀自动处理:
- `pic_*` UID → 内嵌为图片(等效于旧 `` 语法)
- `file_*` UID → 作为独立文件消息在文字之后发出
@@ -238,6 +239,7 @@
- 决定不回复且没有任何值得记录的信息
- 只是简单回复了一句话,没有后续价值
- 纯粹的闲聊或表情回应,没有特殊上下文
+ - 旁观了他人的普通闲聊,没有参与且内容无实质价值(如日常碎碎念、无意义的玩笑等)
调用 end 时提供:
@@ -251,7 +253,11 @@
不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。
历史消息、认知记忆、侧写、最近消息参考只能用于实体/时间/地点消歧,不能作为 observations 的新事实来源。
**群聊场景下的当前批次观察**:即使你决定不回复,也只观察【当前输入批次】。如果当前批次直接出现有价值的群聊动态(话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等),可写入 observations;不要从历史或参考上下文里补写旧动态。
- 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。
+ 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。写入 observations / end.observations 时必须按实体类型使用稳定标识:
+ - 用户中心观察(sender_id 是 QQ 用户,或事实明确属于某个 QQ 用户):格式为 "QQ号12345678(昵称张三)做了某事";昵称会变但 QQ 号不变。
+ - 群聊实体观察(事实属于群整体、群规、群氛围、群事件,而不是某个用户):格式为 "group:群号123456(群名技术群)发生了某事";没有群名时只写群号。
+ - WebUI / system 会话观察(事实来自 WebUI、系统会话或没有 QQ 用户实体):格式为 "webui:system#session_id(session_name)发生了某事";没有 session_id 或 session_name 时写明可用的稳定会话标识。
+ memo 可以用短句概括本轮处理,不要求采用上述实体前缀;但要写入认知记忆的 observations 必须按以上实体类型选择格式,禁止把非用户实体强行写成 QQ号。
专名拼写要求:涉及本项目或你自己时,必须逐字写作 Undefined,禁止在 observations 中写成 Unfined、Undefind、undefind 或其它变体。
若当前消息在转述第三方人物/群成员的信息,必须按原文实体记录(昵称/QQ号);禁止默认改写成当前 sender。
如果同一条内容已写入 observations,不要重复写入 memory.add。
@@ -447,8 +453,8 @@
把整批 <message> 视作本轮的全部输入:
1) 区分每条意图:【独立请求】各自回应不要遗漏(与平时一样,可多次 send_message 自然分发);【修正/否定/补充/打断】则以最后一次明确意图为准,旧的不再执行
- 2) 拿不准时偏向"独立请求",宁多勿漏
- 3) 整批在本轮一次性处理完,不要为同一意图重复输出,也不要"中间一波、结尾一波"重复相同回复
+ 2) 拿不准时偏向“独立请求”,宁多勿漏
+ 3) 整批在本轮一次性处理完,不要为同一意图重复输出,也不要“中间一波、结尾一波”重复相同回复
@@ -581,7 +587,7 @@
**启动前信息充足度闸门:**
在决定启动任何业务工具或 Agent 前,只围绕当前输入批次判断四件事(没有【连续消息说明】时当前输入批次就是最后一条消息):
- 1. 当前任务对象是否明确(优先从上下文推断,推断不了或不确定时再追问)
+ 1. 当前任务对象是否明确
2. 目标产物 / 目标动作是否明确
3. 会显著影响结果的关键参数是否已给出
4. 是否仍存在会导致明显误做或重做的关键歧义
@@ -612,7 +618,7 @@
**意图增量审计(决策前必须执行):**
在决定调用任何业务工具或 Agent 前,先在内部推理中完成以下步骤:
- 1. **回溯**:读取用户最近 1-3 条消息及你的回复历史
+ 1. **回溯**:读取用户最近消息及你的回复历史
2. **对比**:分析当前输入批次是否只是对上一条请求的情绪宣泄、催促或无信息量的补充
3. **定性**:将当前意图归类为 [新任务]、[参数修正] 或 [非实质性延伸]
4. **充足度检查**:如果是 [新任务] 或 [参数修正],检查当前输入批次是否已具备开工所需关键参数
@@ -769,11 +775,11 @@
图片处理
先判断是否需要参与:只有当图片与当前对话强相关、且回答必须依赖图片内容时才分析
- 表情包先理解意思;它不只适用于轻松闲聊。但只有明确纯表情包回复才先检索表情包;凡是需要文字承接、答疑、解释或推进任务的场景,都先发送必要文字,表情包最多放到后续轮次作为可选补充
+ 表情包先理解意思;它不只适用于轻松闲聊。但只有明确纯表情包回复才先检索表情包;凡是需要文字承接、答疑、解释或推进任务的场景,都先发送必要文字。轻松互动、吐槽、接梗、情绪回应中,已经决定回复且文字后仍适合表达时,优先放到后续轮次补一张独立表情包;严肃、任务型、高信息密度场景不补
只有在需要分析图片内容时才调用 file_analysis_agent(如报错截图/界面/文档/图片问题)
当消息中出现“[图片: xxx]”占位符时,xxx 即为 file_id 或 URL,可直接作为 file_source 调用 file_analysis_agent
未调用 file_analysis_agent 时,不要猜测图片内容;可以说明“我看不到图片内容,需要先分析”
- 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;只有明确纯表情包回复时才可先尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,再把表情包检索和发送放到后续轮次
+ 即使已分析图片,也要再次判断是否需要发言;如果明显在和别人说或你拿不准是不是在对你说,默认不回复;只有明确纯表情包回复时才可先尝试 memes.search_memes 并使用独立表情包回应;若还需要文字发言,先 send_message 发送必要文字,轻松场景优先考虑再把表情包检索和发送放到后续轮次
回复时不要描述图片内容,像正常人一样直接回应重点
不要分析每条图片。图片分析有很大延迟,只有需要时才分析
@@ -860,7 +866,7 @@
不是每条消息都要回
大部分时候你应该保持沉默
不符合触发条件时,直接调用 end
- optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,除非明确是纯表情包回复,否则先把必要文字回复做好,表情包最后再搜或不搜。
+ optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,除非明确是纯表情包回复,否则先把必要文字回复做好。轻松、接梗、情绪回应可以优先后补表情包;严肃或任务型场景不补。
diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py
index 5ed45c18..6472d018 100644
--- a/src/Undefined/__init__.py
+++ b/src/Undefined/__init__.py
@@ -24,7 +24,7 @@
from .skills.registry import BaseRegistry as BaseRegistry
from .skills.tools import ToolRegistry as ToolRegistry
-__version__ = "3.6.1"
+__version__ = "3.6.2"
# symbol -> (module_path, attribute_name);首次访问时才 importlib 加载
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
diff --git a/src/Undefined/attachments/models.py b/src/Undefined/attachments/models.py
index fd7c58d3..82758b0a 100644
--- a/src/Undefined/attachments/models.py
+++ b/src/Undefined/attachments/models.py
@@ -6,7 +6,7 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from pathlib import Path
@@ -70,6 +70,7 @@ class RegisteredMessageAttachments:
attachments: list[dict[str, str]]
normalized_text: str
+ forward_refs: list[dict[str, str]] = field(default_factory=list)
@dataclass(frozen=True)
diff --git a/src/Undefined/attachments/registry.py b/src/Undefined/attachments/registry.py
index 1e958f90..bcd4356d 100644
--- a/src/Undefined/attachments/registry.py
+++ b/src/Undefined/attachments/registry.py
@@ -548,6 +548,13 @@ async def get_uid_by_url(self, url: str) -> str | None:
return record.uid
return None
+ def _uid_prefix_for_media_type(self, media_type: str) -> str:
+ if media_type == "image":
+ return "pic"
+ if media_type == "forward":
+ return "forward"
+ return "file"
+
def _build_uid(self, prefix: str) -> str:
while True:
uid = f"{prefix}_{uuid4().hex[:8]}"
@@ -593,7 +600,7 @@ async def register_bytes(
)
normalized_mime = mime_type or _guess_mime_type(display_name, content)
suffix = _guess_suffix(display_name, content, normalized_mime)
- prefix = "pic" if normalized_media_type == "image" else "file"
+ prefix = self._uid_prefix_for_media_type(normalized_media_type)
async with self._lock:
digest = await asyncio.to_thread(hashlib.sha256, content)
@@ -735,7 +742,7 @@ async def register_remote_reference(
normalized_media_type = (
"image" if normalized_kind == "image" else normalized_kind
)
- prefix = "pic" if normalized_media_type == "image" else "file"
+ prefix = self._uid_prefix_for_media_type(normalized_media_type)
ref = url
normalized_segment_data = dict(segment_data or {})
if source_ref and source_ref != url:
@@ -778,6 +785,62 @@ async def register_remote_reference(
await self._persist()
return self._records.get(uid, record)
+ async def register_forward_reference(
+ self,
+ scope_key: str,
+ forward_id: str,
+ *,
+ display_name: str | None = None,
+ source_kind: str = "onebot_forward",
+ segment_data: Mapping[str, str] | None = None,
+ description: str = "",
+ ) -> AttachmentRecord:
+ """登记合并转发引用,仅保存 OneBot forward id,不展开内容。"""
+ await self.load()
+ normalized_forward_id = str(forward_id or "").strip()
+ if not normalized_forward_id:
+ raise ValueError("合并转发 ID 不能为空")
+ normalized_segment_data = {
+ str(k): str(v)
+ for k, v in dict(segment_data or {}).items()
+ if str(k).strip() and str(v).strip()
+ }
+ normalized_segment_data.setdefault("forward_id", normalized_forward_id)
+ digest_hex = hashlib.sha256(
+ f"{scope_key}\n{normalized_forward_id}".encode("utf-8")
+ ).hexdigest()
+
+ async with self._lock:
+ for existing in self._records.values():
+ if (
+ existing.scope_key == scope_key
+ and existing.kind == "forward"
+ and existing.source_ref == normalized_forward_id
+ ):
+ return existing
+
+ uid = self._build_uid("forward")
+ record = AttachmentRecord(
+ uid=uid,
+ scope_key=scope_key,
+ kind="forward",
+ media_type="forward",
+ display_name=display_name or f"合并转发 {normalized_forward_id}",
+ source_kind=source_kind,
+ source_ref=normalized_forward_id,
+ local_path=None,
+ mime_type="application/vnd.undefined.forward",
+ sha256=digest_hex,
+ created_at=_now_iso(),
+ segment_data=normalized_segment_data,
+ semantic_kind="forward",
+ description=description,
+ )
+ self._records[uid] = record
+ self._prune_records()
+ await self._persist()
+ return self._records.get(uid, record)
+
async def _register_remote_url_or_reference(
self,
scope_key: str,
diff --git a/src/Undefined/attachments/segments.py b/src/Undefined/attachments/segments.py
index 6c064cdd..f469fc33 100644
--- a/src/Undefined/attachments/segments.py
+++ b/src/Undefined/attachments/segments.py
@@ -32,6 +32,7 @@
"video": "视频",
"record": "语音",
"pic": "图片",
+ "forward": "合并转发",
}
_WINDOWS_ABS_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]")
_FORWARD_ATTACHMENT_MAX_DEPTH = 3
@@ -128,6 +129,14 @@ def attachment_ref_to_tag(attachment: Mapping[str, str]) -> str:
return f''
+def forward_ref_to_tag(ref: Mapping[str, str]) -> str:
+ """将合并转发引用序列化为 AI 可按需读取的内联标签。"""
+ uid = str(ref.get("uid", "") or "").strip()
+ if not uid:
+ return ""
+ return f''
+
+
def attachment_refs_to_tags(
attachments: Sequence[Mapping[str, str]],
*,
@@ -153,6 +162,8 @@ def attachment_refs_to_xml(
continue
kind = str(item.get("kind", "") or item.get("media_type", "") or "file").strip()
media_type = str(item.get("media_type", "") or kind or "file").strip()
+ if media_type == "forward" or kind == "forward":
+ continue
name = str(item.get("display_name", "") or "").strip()
attrs = [
f'uid="{escape_xml_attr(uid)}"',
@@ -174,6 +185,8 @@ def attachment_refs_to_xml(
if description:
attrs.append(f'description="{escape_xml_attr(description)}"')
lines.append(f"{indent} ")
+ if len(lines) == 1:
+ return ""
lines.append(f"{indent}")
return "\n".join(lines)
@@ -227,7 +240,7 @@ def display_name_from_source(raw_source: str, fallback: str) -> str:
def media_kind_from_value(value: str) -> str:
"""将任意媒体类型字符串规范为 registry 支持的 kind。"""
text = str(value or "").strip().lower()
- if text in {"image", "file", "audio", "video", "record"}:
+ if text in {"image", "file", "audio", "video", "record", "forward"}:
return text
return "file"
@@ -252,6 +265,10 @@ def segment_text(
reply_id = str(data.get("id") or data.get("message_id") or "").strip()
return f"[引用: {reply_id}]" if reply_id else "[引用]"
if type_ == "forward":
+ if ref is not None:
+ tag = forward_ref_to_tag(ref)
+ if tag:
+ return tag
forward_id = str(data.get("id") or data.get("resid") or "").strip()
return f"[合并转发: {forward_id}]" if forward_id else "[合并转发]"
if ref is not None:
@@ -343,6 +360,8 @@ async def register_message_attachments(
resolve_image_url: Callable[[str], Awaitable[str | None]] | None = None,
get_forward_messages: Callable[[str], Awaitable[list[dict[str, Any]]]]
| None = None,
+ register_forward_refs: bool = False,
+ expand_forward_attachments: bool = True,
) -> RegisteredMessageAttachments:
"""扫描消息段并将图片/文件注册到 ``AttachmentRegistry``。
@@ -352,11 +371,14 @@ async def register_message_attachments(
scope_key: 会话作用域键。
resolve_image_url: 可选,将 ``file`` 字段解析为可下载 URL。
get_forward_messages: 可选,拉取合并转发子消息。
+ register_forward_refs: 是否将顶层合并转发注册为 ``forward_`` 引用。
+ expand_forward_attachments: 是否递归扫描合并转发内的附件。
Returns:
已注册附件引用与归一化纯文本。
"""
attachments: list[dict[str, str]] = []
+ forward_refs: list[dict[str, str]] = []
normalized_parts: list[str] = []
if registry is None or not scope_key:
for segment in segments:
@@ -518,14 +540,32 @@ async def _collect_from_segments(
)
ref = record.prompt_ref()
- elif (
- type_ == "forward"
- and get_forward_messages is not None
- and depth < _FORWARD_ATTACHMENT_MAX_DEPTH
- ):
+ elif type_ == "forward":
# 合并转发递归展开,深度上限防止无限嵌套
forward_id = _extract_forward_id(data)
- if forward_id and forward_id not in visited_forward_ids:
+ if register_forward_refs and depth == 0 and forward_id:
+ register_forward = getattr(
+ registry,
+ "register_forward_reference",
+ None,
+ )
+ if callable(register_forward):
+ record = await register_forward(
+ scope_key,
+ forward_id,
+ display_name=f"合并转发 {forward_id}",
+ source_kind="onebot_forward",
+ segment_data=segment_data_from_onebot_data(data),
+ )
+ ref = record.prompt_ref()
+
+ if (
+ expand_forward_attachments
+ and get_forward_messages is not None
+ and depth < _FORWARD_ATTACHMENT_MAX_DEPTH
+ and forward_id
+ and forward_id not in visited_forward_ids
+ ):
visited_forward_ids.add(forward_id)
try:
nodes = _normalize_forward_nodes(
@@ -573,7 +613,10 @@ async def _collect_from_segments(
)
if ref is not None:
- attachments.append(ref)
+ if str(ref.get("media_type") or "") == "forward":
+ forward_refs.append(ref)
+ else:
+ attachments.append(ref)
if depth == 0:
normalized_parts.append(segment_text(type_, data, ref))
@@ -582,4 +625,5 @@ async def _collect_from_segments(
return RegisteredMessageAttachments(
attachments=attachments,
normalized_text="".join(normalized_parts).strip(),
+ forward_refs=forward_refs,
)
diff --git a/src/Undefined/handlers/message_flow.py b/src/Undefined/handlers/message_flow.py
index ad9bc175..7cc0961d 100644
--- a/src/Undefined/handlers/message_flow.py
+++ b/src/Undefined/handlers/message_flow.py
@@ -19,6 +19,7 @@
register_message_attachments,
)
from Undefined.attachments.models import RegisteredMessageAttachments
+from Undefined.attachments.segments import normalize_message_segments
from Undefined.ai import AIClient
from Undefined.config import Config
from Undefined.faq import FAQStorage
@@ -32,7 +33,7 @@
)
from Undefined.rate_limit import RateLimiter
from Undefined.scheduled_task_storage import ScheduledTaskStorage
-from Undefined.services.ai_coordinator import AICoordinator
+from Undefined.services.coordinator import AICoordinator
from Undefined.services.command import CommandDispatcher
from Undefined.services.message_batcher import MessageBatcher, make_scope
from Undefined.services.model_pool import ModelPoolService
@@ -52,6 +53,8 @@
logger = logging.getLogger(__name__)
KEYWORD_REPLY_HISTORY_PREFIX = "[系统关键词自动回复] "
+FORWARD_MEME_SCAN_MAX_DEPTH = 3
+FORWARD_MEME_SCAN_MAX_NODES = 50
def _is_private_model_pool_control_text(text: str) -> bool:
@@ -66,8 +69,20 @@ def _coerce_registered_attachments(value: Any) -> RegisteredMessageAttachments:
return RegisteredMessageAttachments(
attachments=[item for item in value if isinstance(item, dict)],
normalized_text="",
+ forward_refs=[],
)
- return RegisteredMessageAttachments(attachments=[], normalized_text="")
+ return RegisteredMessageAttachments(
+ attachments=[],
+ normalized_text="",
+ forward_refs=[],
+ )
+
+
+def _extract_forward_id_from_segment(segment: dict[str, Any]) -> str:
+ raw_data = segment.get("data", {})
+ data = raw_data if isinstance(raw_data, dict) else {}
+ forward_id = data.get("id") or data.get("resid") or data.get("message_id")
+ return str(forward_id).strip() if forward_id is not None else ""
class MessageHandler(PokeMixin, RepeatMixin, AutoExtractMixin):
@@ -257,13 +272,21 @@ async def _collect_message_attachments(
request_type=request_type,
)
if not scope_key:
- return RegisteredMessageAttachments(attachments=[], normalized_text="")
+ return RegisteredMessageAttachments(
+ attachments=[],
+ normalized_text="",
+ forward_refs=[],
+ )
ai_client = getattr(self, "ai", None)
attachment_registry = (
getattr(ai_client, "attachment_registry", None) if ai_client else None
)
if attachment_registry is None:
- return RegisteredMessageAttachments(attachments=[], normalized_text="")
+ return RegisteredMessageAttachments(
+ attachments=[],
+ normalized_text="",
+ forward_refs=[],
+ )
onebot = getattr(self, "onebot", None)
resolve_image_url = getattr(onebot, "get_image", None) if onebot else None
result = await register_message_attachments(
@@ -274,6 +297,8 @@ async def _collect_message_attachments(
get_forward_messages=getattr(onebot, "get_forward_msg", None)
if onebot
else None,
+ register_forward_refs=True,
+ expand_forward_attachments=False,
)
attachments = result.attachments
# 命中表情库时为 AI 上下文补充 [表情包] 描述
@@ -281,6 +306,7 @@ async def _collect_message_attachments(
return RegisteredMessageAttachments(
attachments=attachments,
normalized_text=result.normalized_text,
+ forward_refs=result.forward_refs,
)
def _schedule_meme_ingest(
@@ -311,6 +337,146 @@ def _schedule_meme_ingest(
),
)
+ def _schedule_forward_meme_scan(
+ self,
+ *,
+ message_content: list[dict[str, Any]],
+ chat_type: str,
+ chat_id: int,
+ sender_id: int,
+ message_id: int | None,
+ scope_key: str | None,
+ ) -> None:
+ if not scope_key:
+ return
+ if not any(
+ str(seg.get("type", "")).lower() == "forward" for seg in message_content
+ ):
+ return
+ meme_service = getattr(self.ai, "_meme_service", None)
+ if meme_service is None or not getattr(meme_service, "enabled", False):
+ return
+ self._spawn_background_task(
+ f"forward_meme_scan:{chat_type}:{chat_id}:{sender_id}:{message_id or 0}",
+ self._scan_forward_memes_for_ingest(
+ message_content=message_content,
+ chat_type=chat_type,
+ chat_id=chat_id,
+ sender_id=sender_id,
+ message_id=message_id,
+ scope_key=scope_key,
+ ),
+ )
+
+ async def _scan_forward_memes_for_ingest(
+ self,
+ *,
+ message_content: list[dict[str, Any]],
+ chat_type: str,
+ chat_id: int,
+ sender_id: int,
+ message_id: int | None,
+ scope_key: str,
+ ) -> None:
+ ai_client = getattr(self, "ai", None)
+ attachment_registry = (
+ getattr(ai_client, "attachment_registry", None) if ai_client else None
+ )
+ if attachment_registry is None:
+ return
+ onebot = getattr(self, "onebot", None)
+ if onebot is None:
+ return
+ get_forward_messages = getattr(onebot, "get_forward_msg", None)
+ if not callable(get_forward_messages):
+ return
+
+ collected: list[dict[str, str]] = []
+ visited: set[str] = set()
+ node_count = 0
+
+ async def _walk(segments: list[dict[str, Any]], depth: int) -> None:
+ nonlocal node_count
+ if depth > FORWARD_MEME_SCAN_MAX_DEPTH:
+ return
+ if depth > 0:
+ direct = await register_message_attachments(
+ registry=attachment_registry,
+ segments=segments,
+ scope_key=scope_key,
+ resolve_image_url=getattr(onebot, "get_image", None),
+ get_forward_messages=None,
+ register_forward_refs=False,
+ expand_forward_attachments=False,
+ )
+ collected.extend(direct.attachments)
+ if depth >= FORWARD_MEME_SCAN_MAX_DEPTH:
+ return
+ for segment in segments:
+ if str(segment.get("type", "")).strip().lower() != "forward":
+ continue
+ forward_id = _extract_forward_id_from_segment(segment)
+ if not forward_id or forward_id in visited:
+ continue
+ visited.add(forward_id)
+ try:
+ raw_nodes = await get_forward_messages(forward_id)
+ except Exception:
+ logger.debug(
+ "[memes] 合并转发表情包扫描拉取失败: id=%s",
+ forward_id,
+ exc_info=True,
+ )
+ continue
+ if isinstance(raw_nodes, dict):
+ nodes = raw_nodes.get("messages")
+ else:
+ nodes = raw_nodes
+ if not isinstance(nodes, list):
+ continue
+ for node in nodes:
+ if node_count >= FORWARD_MEME_SCAN_MAX_NODES:
+ return
+ if not isinstance(node, dict):
+ continue
+ node_count += 1
+ raw_message = (
+ node.get("content")
+ or node.get("message")
+ or node.get("raw_message")
+ )
+ nested_segments = [
+ dict(item)
+ for item in normalize_message_segments(raw_message)
+ if isinstance(item, dict)
+ ]
+ if nested_segments:
+ await _walk(nested_segments, depth + 1)
+
+ try:
+ await _walk(message_content, 0)
+ image_attachments = [
+ item
+ for item in collected
+ if str(item.get("media_type") or item.get("kind") or "") == "image"
+ ]
+ if not image_attachments:
+ return
+ annotated = await self._annotate_meme_descriptions(
+ image_attachments,
+ scope_key,
+ )
+ self._schedule_meme_ingest(
+ attachments=annotated,
+ chat_type=chat_type,
+ chat_id=chat_id,
+ sender_id=sender_id,
+ message_id=message_id,
+ scope_key=scope_key,
+ )
+ except Exception:
+ logger.warning("[memes] 合并转发表情包扫描失败", exc_info=True)
+
async def _refresh_profile_display_names(
self,
*,
@@ -479,10 +645,9 @@ async def _handle_private_message(self, event: dict[str, Any]) -> None:
sender_name=resolved_private_name,
)
- parsed_content_base = private_registered.normalized_text or parsed_content_raw
- parsed_content = append_attachment_text(
- parsed_content_base, private_attachments
- )
+ prompt_refs = private_attachments + private_registered.forward_refs
+ ai_content_base = private_registered.normalized_text or parsed_content_raw
+ parsed_content = append_attachment_text(parsed_content_raw, private_attachments)
safe_parsed = redact_string(parsed_content)
logger.debug(
"[历史记录] 保存私聊: user=%s content=%s...",
@@ -513,6 +678,17 @@ async def _handle_private_message(self, event: dict[str, Any]) -> None:
request_type="private",
),
)
+ self._schedule_forward_meme_scan(
+ message_content=private_message_content,
+ chat_type="private",
+ chat_id=private_sender_id,
+ sender_id=private_sender_id,
+ message_id=safe_int(trigger_message_id),
+ scope_key=build_attachment_scope(
+ user_id=private_sender_id,
+ request_type="private",
+ ),
+ )
if not self.config.should_process_private_message():
logger.debug(
@@ -553,9 +729,9 @@ async def _handle_private_message(self, event: dict[str, Any]) -> None:
await self.ai_coordinator.handle_private_reply(
private_sender_id,
- parsed_content_base,
+ ai_content_base,
private_message_content,
- attachments=private_attachments,
+ attachments=prompt_refs,
sender_name=user_name,
trigger_message_id=trigger_message_id,
)
@@ -627,8 +803,9 @@ async def _fetch_group_name() -> str:
group_name=str(group_name or "").strip(),
)
- parsed_content_base = group_registered.normalized_text or parsed_content_raw
- parsed_content = append_attachment_text(parsed_content_base, group_attachments)
+ prompt_refs = group_attachments + group_registered.forward_refs
+ ai_content_base = group_registered.normalized_text or parsed_content_raw
+ parsed_content = append_attachment_text(parsed_content_raw, group_attachments)
safe_parsed = redact_string(parsed_content)
logger.debug(
f"[历史记录] 保存群聊: group={group_id}, sender={sender_id}, content={safe_parsed[:50]}..."
@@ -649,7 +826,7 @@ async def _fetch_group_name() -> str:
# 机器人发言计入复读计数,防止 bot 复读自身
if sender_id == self.config.bot_qq:
- await self._append_bot_repeat_counter(group_id, parsed_content_base)
+ await self._append_bot_repeat_counter(group_id, parsed_content_raw)
return
self._schedule_meme_ingest(
@@ -660,6 +837,14 @@ async def _fetch_group_name() -> str:
message_id=safe_int(trigger_message_id),
scope_key=build_attachment_scope(group_id=group_id, request_type="group"),
)
+ self._schedule_forward_meme_scan(
+ message_content=message_content,
+ chat_type="group",
+ chat_id=group_id,
+ sender_id=sender_id,
+ message_id=safe_int(trigger_message_id),
+ scope_key=build_attachment_scope(group_id=group_id, request_type="group"),
+ )
is_at_bot = self.ai_coordinator._is_at_bot(message_content)
@@ -709,8 +894,8 @@ async def _fetch_group_name() -> str:
if await self._maybe_trigger_repeat(
group_id,
sender_id,
- parsed_content_base,
- attachments=group_attachments,
+ ai_content_base,
+ attachments=prompt_refs,
):
return
@@ -725,9 +910,9 @@ async def _fetch_group_name() -> str:
await self.ai_coordinator.handle_auto_reply(
group_id,
sender_id,
- normalized_text if not group_attachments else parsed_content_base,
+ normalized_text if not prompt_refs else ai_content_base,
message_content,
- attachments=group_attachments,
+ attachments=prompt_refs,
sender_name=display_name,
group_name=group_name,
sender_role=sender_role,
diff --git a/src/Undefined/handlers/poke.py b/src/Undefined/handlers/poke.py
index 06ba2738..81e609d9 100644
--- a/src/Undefined/handlers/poke.py
+++ b/src/Undefined/handlers/poke.py
@@ -12,7 +12,7 @@
if TYPE_CHECKING:
from Undefined.config import Config
from Undefined.onebot import OneBotClient
- from Undefined.services.ai_coordinator import AICoordinator
+ from Undefined.services.coordinator import AICoordinator
from Undefined.utils.history import MessageHistoryManager
logger = logging.getLogger(__name__)
diff --git a/src/Undefined/services/ai_coordinator.py b/src/Undefined/services/ai_coordinator.py
deleted file mode 100644
index dc4fec71..00000000
--- a/src/Undefined/services/ai_coordinator.py
+++ /dev/null
@@ -1,1092 +0,0 @@
-import asyncio
-import logging
-import time
-from datetime import datetime
-from pathlib import Path
-from typing import Any, Optional
-
-from Undefined.attachments import (
- attachment_refs_to_xml,
- build_attachment_scope,
- dispatch_pending_file_sends,
- render_message_with_pic_placeholders,
-)
-from Undefined.config import Config
-from Undefined.context import RequestContext
-from Undefined.context_resource_registry import collect_context_resources
-from Undefined.render import render_html_to_image, render_markdown_to_html
-from Undefined.services.model_pool import ModelPoolService
-from Undefined.services.queue_manager import QueueManager, QUEUE_LANE_BACKGROUND
-from Undefined.services.message_batcher import (
- BufferedMessage,
- MessageBatcher,
- make_scope,
-)
-from Undefined.services.coordinator.message_ids import collect_message_ids
-from Undefined.utils.history import MessageHistoryManager
-from Undefined.utils.sender import MessageSender
-from Undefined.utils.scheduler import TaskScheduler
-from Undefined.services.security import SecurityService
-from Undefined.utils.recent_messages import get_recent_messages_prefer_local
-from Undefined.utils.resources import read_text_resource
-from Undefined.utils.xml import (
- escape_xml_attr,
- escape_xml_text_preserving_attachment_tags,
-)
-
-logger = logging.getLogger(__name__)
-
-
-_STATS_ANALYSIS_PROMPT_PATH = "res/prompts/stats_analysis.txt"
-_STATS_ANALYSIS_FALLBACK_PROMPT = (
- "你是一位专业的数据分析师。请根据以下 Token 使用统计数据提供分析:\n\n"
- "{data_summary}\n\n"
- "请从整体概况、趋势、模型效率、成本结构、异常点和优化建议进行总结,"
- "语言简洁,建议可执行。"
-)
-
-
-_GROUP_STRATEGY_FOOTER = """
-
- 【回复策略 - 更克制,纯表情包才前置检索】
- 1. 如果用户 @ 了你或拍了拍你 → 【必须回复】
- 2. 如果消息中明确提到了你(根据上下文判断用户是否在叫你或维持对话流) → 【必须回复】
- 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】
- 4. 其他技术问题 → 【酌情回复,直接按用户提到的对象回答,不要引入无关的项目名/工具名作背景】
- 5. 先判断当前输入批次(无连续消息说明时就是最后一条消息)是不是在对你说:
- - 先看 sender_id、@/reply、前后文对话对象和当前群聊环境;不要先入为主把"你"、"AI"、"bot"、"机器人"当作在叫 Undefined
- - 泛称或讨论其他 AI/bot/机器人时不算叫你;无法确认指向 Undefined 时默认不回复
- - 如果明显是在和别人说话 → 【不要回复】
- - 如果你不能确定是不是在和你说话 → 【默认不回复】
- - 只有明确在和你说,或多人公开讨论且对话明显开放时,才进入下一步
- 6. 群聊里的主动参与只保留给公开、开放的技术或项目讨论:
- - 只在多人公开讨论代码、AI、开发工具、项目进展、技术 bug 等,且不是别人之间定向交流时,才可以【极低频参与】
- - 默认更倾向不参与;不要长篇大论,一两句点到为止;如果别人已经在深入讨论且不需要你,保持沉默
- - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复,且本轮明确是纯表情包/纯反应图时,才优先考虑表情包表达
- 7. 对于已经决定要回复的场景(包括被@、被拍一拍、轻量答疑,以及少量符合条件的主动参与):
- - 只有明确纯表情包回复才先检索表情包,再用 memes.send_meme_by_uid 单独发一条图片消息
- - 其他需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 send_message
- - 如果确实还想补表情包,把 memes.search_memes 和 memes.send_meme_by_uid 放到文字发送后的后续响应轮次,不要阻塞首条文字回复
- - 不要发送任何敷衍消息(如'懒得掺和'、'哦'等);不想回复就直接调用 end
- - 严肃、任务型、高信息密度场景少发表情包,避免打断信息传递
- - 绝不要刷屏、绝不要每条都回
- 8. 对于本来就会回复的场景(私聊、被拍一拍、被@、轻量答疑):
- - 如果表情包能自然增强语气、缓和语气或让表达更像真人,也只能作为后续可选补充
- - 但不要为了发表情包而牺牲信息传递;信息密度优先时仍以文字为主
-
- 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,除非明确是纯表情包回复,否则先把文字回复做好,表情包最后再搜。"""
-
-
-_PRIVATE_STRATEGY_FOOTER = """
-
-【私聊消息】
-这是私聊消息,用户专门来找你说话。你可以自由选择是否回复:
-- 如果想回复,先调用 send_message 工具发送回复内容,然后调用 end 结束对话
-- 只有明确纯表情包回复时,才先用 memes.search_memes 查表情包,再用 memes.send_meme_by_uid 单独发图;其他场景先把文字回复做好,表情包最后再搜或不搜
-- 如果不想回复,直接调用 end 结束对话即可"""
-
-
-class AICoordinator:
- """AI 协调器,处理 AI 回复逻辑、Prompt 构建和队列管理"""
-
- def __init__(
- self,
- config: Config,
- ai: Any, # AIClient
- queue_manager: QueueManager,
- history_manager: MessageHistoryManager,
- sender: MessageSender,
- onebot: Any, # OneBotClient
- scheduler: TaskScheduler,
- security: SecurityService,
- command_dispatcher: Any = None,
- ) -> None:
- self.config = config
- self.ai = ai
- self.queue_manager = queue_manager
- self.history_manager = history_manager
- self.sender = sender
- self.onebot = onebot
- self.scheduler = scheduler
- self.security = security
- self.command_dispatcher = command_dispatcher
- self.model_pool = ModelPoolService(ai, config, sender)
- # batcher 由外部(handlers/message_flow)创建并通过 set_batcher 注入;未注入时所有消息按单条流程直送。
- self._batcher: MessageBatcher | None = None
-
- def set_batcher(self, batcher: MessageBatcher | None) -> None:
- """注入消息合并器;传 None 等同于禁用合并。"""
- self._batcher = batcher
-
- @property
- def batcher(self) -> MessageBatcher | None:
- return self._batcher
-
- async def handle_batched_dispatch(self, items: list[BufferedMessage]) -> None:
- """:class:`MessageBatcher` 的 flush_callback:把一批消息组装为单次请求并入队。"""
- if not items:
- return
- await self._dispatch_grouped_request(items)
-
- async def handle_auto_reply(
- self,
- group_id: int,
- sender_id: int,
- text: str,
- message_content: list[dict[str, Any]],
- attachments: list[dict[str, str]] | None = None,
- is_poke: bool = False,
- sender_name: str = "未知用户",
- group_name: str = "未知群聊",
- sender_role: str = "member",
- sender_title: str = "",
- sender_level: str = "",
- trigger_message_id: int | None = None,
- is_fake_at: bool = False,
- ) -> None:
- """群聊自动回复入口:根据消息内容、命中情况和安全检测决定是否回复
-
- 参数:
- group_id: 群号
- sender_id: 发送者 QQ
- text: 消息纯文本
- message_content: 结构化原始消息内容
- is_poke: 是否为拍一拍触发
- sender_name: 发送者昵称
- group_name: 群名称
- sender_role: 发送者角色 (owner/admin/member)
- sender_title: 发送者群头衔
- is_fake_at: 是否为假@(纯文本 @昵称)触发
- """
- is_at_bot = is_poke or is_fake_at or self._is_at_bot(message_content)
- logger.debug(
- "[自动回复] group=%s sender=%s at_bot=%s fake_at=%s text_len=%s",
- group_id,
- sender_id,
- is_at_bot,
- is_fake_at,
- len(text),
- )
-
- if sender_id != self.config.superadmin_qq:
- logger.debug(f"[Security] 注入检测: group={group_id}, user={sender_id}")
- if await self.security.detect_injection(text, message_content):
- logger.warning(
- f"[Security] 检测到注入攻击: group={group_id}, user={sender_id}"
- )
- await self.history_manager.modify_last_group_message(
- group_id, sender_id, "<这句话检测到用户进行注入,已删除>"
- )
- if is_at_bot:
- await self._handle_injection_response(
- group_id, text, sender_id=sender_id
- )
- return
-
- scope = make_scope(group_id=group_id)
- item = BufferedMessage(
- scope=scope,
- sender_id=sender_id,
- text=text,
- message_content=list(message_content),
- attachments=list(attachments or []),
- sender_name=sender_name,
- arrival_time=time.time(),
- is_private=False,
- trigger_message_id=trigger_message_id,
- is_poke=is_poke,
- is_at_bot=is_at_bot,
- is_fake_at=is_fake_at,
- group_id=group_id,
- group_name=group_name,
- sender_role=sender_role,
- sender_title=sender_title,
- sender_level=sender_level,
- )
-
- # 路由:拍一拍 → 永远旁路;否则按 batcher 启用情况与 @bot 处理规则决定
- if is_poke:
- await self._dispatch_grouped_request([item])
- return
-
- batcher = getattr(self, "_batcher", None)
- if batcher is not None and batcher.is_enabled_for(is_group=True):
- if is_at_bot and batcher.has_buffer(scope, sender_id):
- # 已有 buffer 时再来一条 @bot:单独立即处理,不打断现有 buffer
- logger.info(
- "[自动回复] batch 内 @bot 旁路立即处理: group=%s sender=%s",
- group_id,
- sender_id,
- )
- await self._dispatch_grouped_request([item])
- return
- await batcher.submit(item)
- return
-
- await self._dispatch_grouped_request([item])
-
- async def handle_private_reply(
- self,
- user_id: int,
- text: str,
- message_content: list[dict[str, Any]],
- attachments: list[dict[str, str]] | None = None,
- is_poke: bool = False,
- sender_name: str = "未知用户",
- trigger_message_id: int | None = None,
- ) -> None:
- """处理私聊消息入口,决定回复策略并进行安全检测"""
- logger.debug("[私聊回复] user=%s text_len=%s", user_id, len(text))
- if user_id != self.config.superadmin_qq:
- if await self.security.detect_injection(text, message_content):
- logger.warning(f"[Security] 私聊注入攻击: user_id={user_id}")
- await self.history_manager.modify_last_private_message(
- user_id, "<这句话检测到用户进行注入,已删除>"
- )
- await self._handle_injection_response(user_id, text, is_private=True)
- return
-
- scope = make_scope(user_id=user_id)
- item = BufferedMessage(
- scope=scope,
- sender_id=user_id,
- text=text,
- message_content=list(message_content),
- attachments=list(attachments or []),
- sender_name=sender_name,
- arrival_time=time.time(),
- is_private=True,
- trigger_message_id=trigger_message_id,
- is_poke=is_poke,
- )
-
- if is_poke:
- await self._dispatch_grouped_request([item])
- return
-
- batcher = getattr(self, "_batcher", None)
- if batcher is not None and batcher.is_enabled_for(is_group=False):
- await batcher.submit(item)
- return
-
- await self._dispatch_grouped_request([item])
-
- async def execute_reply(self, request: dict[str, Any]) -> None:
- """执行排队中的回复请求(由 QueueManager 分发调用)
-
- 参数:
- request: 包含请求类型和必要元数据的请求字典
- """
- """执行回复请求(由 QueueManager 调用)"""
- req_type = request.get("type", "unknown")
- logger.debug("[执行请求] type=%s keys=%s", req_type, list(request.keys()))
- batch_token = request.get("_message_batcher_token")
- if bool(getattr(batch_token, "cancelled", False)):
- logger.info(
- "[MessageBatcher] 跳过已取消的投机请求: type=%s scope=%s sender=%s batch=%s",
- req_type,
- getattr(batch_token, "scope", ""),
- getattr(batch_token, "sender_id", ""),
- getattr(batch_token, "batch_id", ""),
- )
- return
- if req_type == "auto_reply":
- await self._execute_auto_reply(request)
- elif req_type == "private_reply":
- await self._execute_private_reply(request)
- elif req_type == "stats_analysis":
- await self._execute_stats_analysis(request)
- elif req_type == "agent_intro_generation":
- await self._execute_agent_intro_generation(request)
- elif req_type in {"queued_llm_call", "background_llm_call"}:
- await self._execute_queued_llm_call(request)
-
- async def _execute_auto_reply(self, request: dict[str, Any]) -> None:
- group_id = request["group_id"]
- sender_id = request["sender_id"]
- sender_name = str(request.get("sender_name") or "未知用户")
- group_name = str(request.get("group_name") or "未知群聊")
- full_question = request["full_question"]
- trigger_message_id = request.get("trigger_message_id")
- message_ids = [
- str(item).strip()
- for item in request.get("message_ids", [])
- if str(item).strip()
- ]
- # 用于向 batcher 注册 inflight 任务(仅当本请求源自合并桶时生效)
- batcher_scope: str | None = make_scope(group_id=group_id) if group_id else None
-
- async with RequestContext(
- request_type="group",
- group_id=group_id,
- sender_id=sender_id,
- user_id=sender_id,
- ) as ctx:
-
- async def send_msg_cb(message: str, reply_to: int | None = None) -> None:
- await self.sender.send_group_message(
- group_id,
- message,
- reply_to=reply_to,
- history_message=message,
- )
-
- async def get_recent_cb(
- chat_id: str, msg_type: str, start: int, end: int
- ) -> list[dict[str, Any]]:
- return await get_recent_messages_prefer_local(
- chat_id=chat_id,
- msg_type=msg_type,
- start=start,
- end=end,
- onebot_client=self.onebot,
- history_manager=self.history_manager,
- bot_qq=self.config.bot_qq,
- attachment_registry=getattr(self.ai, "attachment_registry", None),
- group_name_hint=group_name,
- )
-
- async def send_private_cb(
- uid: int, msg: str, reply_to: int | None = None
- ) -> None:
- await self.sender.send_private_message(uid, msg, reply_to=reply_to)
-
- async def send_img_cb(tid: int, mtype: str, path: str) -> None:
- await self._send_image(tid, mtype, path)
-
- async def send_like_cb(uid: int, times: int = 1) -> None:
- await self.onebot.send_like(uid, times)
-
- ai_client = self.ai
- memory_storage = self.ai.memory_storage
- runtime_config = self.ai.runtime_config
- sender = self.sender
- history_manager = self.history_manager
- onebot_client = self.onebot
- scheduler = self.scheduler
- send_message_callback = send_msg_cb
- get_recent_messages_callback = get_recent_cb
- get_image_url_callback = self.onebot.get_image
- get_forward_msg_callback = self.onebot.get_forward_msg
- send_like_callback = send_like_cb
- send_private_message_callback = send_private_cb
- send_image_callback = send_img_cb
- resource_vars = dict(globals())
- resource_vars.update(locals())
- resources = collect_context_resources(resource_vars)
- for key, value in resources.items():
- if value is not None:
- ctx.set_resource(key, value)
- if trigger_message_id is not None:
- ctx.set_resource("trigger_message_id", trigger_message_id)
- if message_ids:
- ctx.set_resource("message_ids", list(message_ids))
- if request.get("_queue_lane"):
- ctx.set_resource("queue_lane", request.get("_queue_lane"))
- logger.debug(
- "[上下文资源] group=%s keys=%s",
- group_id,
- ", ".join(sorted(resources.keys())),
- )
-
- try:
- # 把当前 task 注册到 batcher,使其有能力在新消息到达时取消本次 LLM 调用
- batcher = getattr(self, "_batcher", None)
- current_task = asyncio.current_task()
- registered_task: asyncio.Task[Any] | None = None
- if (
- batcher is not None
- and batcher_scope is not None
- and current_task is not None
- ):
- batcher.register_inflight(
- batcher_scope, sender_id, current_task, ctx
- )
- registered_task = current_task
- try:
- await self.ai.ask(
- full_question,
- send_message_callback=send_msg_cb,
- get_recent_messages_callback=get_recent_cb,
- get_image_url_callback=self.onebot.get_image,
- get_forward_msg_callback=self.onebot.get_forward_msg,
- send_like_callback=send_like_cb,
- sender=self.sender,
- history_manager=self.history_manager,
- onebot_client=self.onebot,
- scheduler=self.scheduler,
- extra_context={
- "render_html_to_image": render_html_to_image,
- "render_markdown_to_html": render_markdown_to_html,
- "group_id": group_id,
- "user_id": sender_id,
- "is_at_bot": bool(request.get("is_at_bot", False)),
- "sender_name": sender_name,
- "group_name": group_name,
- "message_ids": list(message_ids),
- "batched_count": int(request.get("batched_count", 1) or 1),
- "current_input_is_batched": int(
- request.get("batched_count", 1) or 1
- )
- > 1,
- },
- )
- finally:
- if (
- batcher is not None
- and batcher_scope is not None
- and registered_task is not None
- ):
- batcher.unregister_inflight(
- batcher_scope, sender_id, registered_task
- )
- except asyncio.CancelledError:
- # 投机预发送被新消息抢占取消:不写错误日志、不重试
- logger.info(
- "[自动回复] 任务被取消(投机抢占): group=%s sender=%s",
- group_id,
- sender_id,
- )
- raise
- except Exception:
- logger.exception("自动回复执行出错")
- raise
-
- async def _execute_private_reply(self, request: dict[str, Any]) -> None:
- user_id = request["user_id"]
- sender_name = str(request.get("sender_name") or "未知用户")
- full_question = request["full_question"]
- trigger_message_id = request.get("trigger_message_id")
- message_ids = [
- str(item).strip()
- for item in request.get("message_ids", [])
- if str(item).strip()
- ]
- batcher_scope: str | None = make_scope(user_id=user_id)
-
- async with RequestContext(
- request_type="private",
- user_id=user_id,
- sender_id=user_id,
- ) as ctx:
-
- async def send_msg_cb(message: str, reply_to: int | None = None) -> None:
- await self.sender.send_private_message(
- user_id, message, reply_to=reply_to
- )
-
- async def get_recent_cb(
- chat_id: str, msg_type: str, start: int, end: int
- ) -> list[dict[str, Any]]:
- return await get_recent_messages_prefer_local(
- chat_id=chat_id,
- msg_type=msg_type,
- start=start,
- end=end,
- onebot_client=self.onebot,
- history_manager=self.history_manager,
- bot_qq=self.config.bot_qq,
- attachment_registry=getattr(self.ai, "attachment_registry", None),
- )
-
- async def send_img_cb(tid: int, mtype: str, path: str) -> None:
- await self._send_image(tid, mtype, path)
-
- async def send_like_cb(uid: int, times: int = 1) -> None:
- await self.onebot.send_like(uid, times)
-
- async def send_private_cb(
- uid: int, msg: str, reply_to: int | None = None
- ) -> None:
- await self.sender.send_private_message(uid, msg, reply_to=reply_to)
-
- ai_client = self.ai
- memory_storage = self.ai.memory_storage
- runtime_config = self.ai.runtime_config
- sender = self.sender
- history_manager = self.history_manager
- onebot_client = self.onebot
- scheduler = self.scheduler
- send_message_callback = send_msg_cb
- get_recent_messages_callback = get_recent_cb
- get_image_url_callback = self.onebot.get_image
- get_forward_msg_callback = self.onebot.get_forward_msg
- send_like_callback = send_like_cb
- send_private_message_callback = send_private_cb
- send_image_callback = send_img_cb
- resource_vars = dict(globals())
- resource_vars.update(locals())
- resources = collect_context_resources(resource_vars)
- for key, value in resources.items():
- if value is not None:
- ctx.set_resource(key, value)
- if trigger_message_id is not None:
- ctx.set_resource("trigger_message_id", trigger_message_id)
- if message_ids:
- ctx.set_resource("message_ids", list(message_ids))
- if request.get("_queue_lane"):
- ctx.set_resource("queue_lane", request.get("_queue_lane"))
- logger.debug(
- "[上下文资源] private user=%s keys=%s",
- user_id,
- ", ".join(sorted(resources.keys())),
- )
-
- try:
- batcher = getattr(self, "_batcher", None)
- current_task = asyncio.current_task()
- registered_task: asyncio.Task[Any] | None = None
- if (
- batcher is not None
- and batcher_scope is not None
- and current_task is not None
- ):
- batcher.register_inflight(batcher_scope, user_id, current_task, ctx)
- registered_task = current_task
- try:
- result = await self.ai.ask(
- full_question,
- send_message_callback=send_msg_cb,
- get_recent_messages_callback=get_recent_cb,
- get_image_url_callback=self.onebot.get_image,
- get_forward_msg_callback=self.onebot.get_forward_msg,
- send_like_callback=send_like_cb,
- sender=self.sender,
- history_manager=self.history_manager,
- onebot_client=self.onebot,
- scheduler=self.scheduler,
- extra_context={
- "render_html_to_image": render_html_to_image,
- "render_markdown_to_html": render_markdown_to_html,
- "user_id": user_id,
- "is_private_chat": True,
- "sender_name": sender_name,
- "selected_model_name": request.get("selected_model_name"),
- "message_ids": list(message_ids),
- "batched_count": int(request.get("batched_count", 1) or 1),
- "current_input_is_batched": int(
- request.get("batched_count", 1) or 1
- )
- > 1,
- },
- )
- finally:
- if (
- batcher is not None
- and batcher_scope is not None
- and registered_task is not None
- ):
- batcher.unregister_inflight(
- batcher_scope, user_id, registered_task
- )
- if result:
- scope_key = build_attachment_scope(
- user_id=user_id,
- request_type="private",
- )
- rendered = await render_message_with_pic_placeholders(
- str(result),
- registry=self.ai.attachment_registry,
- scope_key=scope_key,
- strict=False,
- )
- await self.sender.send_private_message(
- user_id,
- rendered.delivery_text,
- history_message=rendered.history_text,
- )
- await dispatch_pending_file_sends(
- rendered,
- sender=self.sender,
- target_type="private",
- target_id=user_id,
- registry=self.ai.attachment_registry,
- )
- except asyncio.CancelledError:
- logger.info("[私聊回复] 任务被取消(投机抢占): user=%s", user_id)
- raise
- except Exception:
- logger.exception("私聊回复执行出错")
- raise
-
- async def _execute_stats_analysis(self, request: dict[str, Any]) -> None:
- """执行 stats 命令的 AI 分析"""
- group_id = request["group_id"]
- request_id = request.get("request_id")
- data_summary = request.get("data_summary", "")
-
- if not request_id:
- logger.warning("[统计分析] 缺少 request_id,群=%s", group_id)
- return
- try:
- prompt_template = _STATS_ANALYSIS_FALLBACK_PROMPT
- try:
- loaded_prompt = read_text_resource(_STATS_ANALYSIS_PROMPT_PATH).strip()
- if loaded_prompt:
- prompt_template = loaded_prompt
- except Exception as exc:
- logger.warning("[统计分析] 读取提示词失败,使用内置模板: %s", exc)
-
- if "{data_summary}" not in prompt_template:
- logger.warning(
- "[统计分析] 提示词缺少 {data_summary} 占位符,自动追加",
- )
- prompt_template = f"{prompt_template}\n\n{{data_summary}}"
-
- safe_data_summary = str(data_summary).strip() or "暂无统计数据摘要"
- try:
- full_prompt = prompt_template.format(data_summary=safe_data_summary)
- except Exception as exc:
- logger.warning("[统计分析] 提示词渲染失败,使用回退模板: %s", exc)
- full_prompt = _STATS_ANALYSIS_FALLBACK_PROMPT.format(
- data_summary=safe_data_summary
- )
-
- messages = [
- {"role": "system", "content": "你是一位专业的数据分析师。"},
- {"role": "user", "content": full_prompt},
- ]
-
- result = await self.ai.submit_queued_llm_call(
- model_config=self.config.chat_model,
- messages=messages,
- max_tokens=2048,
- call_type="stats_analysis",
- queue_lane=request.get("_queue_lane"),
- )
-
- choices = result.get("choices", [{}])
- if choices:
- content = choices[0].get("message", {}).get("content", "")
- analysis = content.strip()
- else:
- analysis = "AI 分析未能生成结果"
-
- if not analysis:
- analysis = "AI 分析结果为空,建议稍后重试。"
-
- logger.info(
- "[统计分析] 分析完成: group=%s length=%s request_id=%s",
- group_id,
- len(analysis),
- request_id,
- )
-
- if self.command_dispatcher:
- self.command_dispatcher.set_stats_analysis_result(
- group_id, request_id, analysis
- )
-
- except Exception as exc:
- logger.exception("[统计分析] AI 分析失败: %s", exc)
- if self.command_dispatcher:
- self.command_dispatcher.set_stats_analysis_result(
- group_id, request_id, ""
- )
-
- async def _execute_queued_llm_call(self, request: dict[str, Any]) -> None:
- """执行队列中的 LLM 子请求。"""
- request_id = request.get("request_id", "")
- retry_count = int(request.get("_retry_count", 0) or 0)
- queue_lane = str(request.get("_queue_lane") or QUEUE_LANE_BACKGROUND)
- call_type = str(request.get("call_type", "background") or "background")
- try:
- result = await self.ai.request_model(
- model_config=request["model_config"],
- messages=request["messages"],
- tools=request.get("tools"),
- tool_choice=request.get("tool_choice", "auto"),
- call_type=call_type,
- max_tokens=request.get("max_tokens")
- or getattr(request["model_config"], "max_tokens", 4096),
- transport_state=request.get("transport_state"),
- )
- self.ai.set_llm_call_result(request_id, result)
- if retry_count > 0:
- logger.info(
- "[queued_llm_retry_success] request_id=%s call_type=%s model=%s lane=%s retry=%s",
- request_id,
- call_type,
- getattr(request["model_config"], "model_name", "default"),
- queue_lane,
- retry_count,
- )
- except Exception as exc:
- retry_count = request.get("_retry_count", 0)
- if retry_count >= self.config.ai_request_max_retries:
- self.ai.set_llm_call_result(request_id, exc)
- raise
-
- async def _execute_agent_intro_generation(self, request: dict[str, Any]) -> None:
- """执行 Agent 自我介绍生成请求"""
- request_id = request.get("request_id")
- agent_name = request.get("agent_name")
-
- if not request_id or not agent_name:
- logger.warning(
- "[Agent介绍生成] 缺少必要参数: request_id=%s agent_name=%s",
- request_id,
- agent_name,
- )
- return
-
- try:
- from Undefined.skills.agents.intro_generator import AgentIntroGenerator
-
- agent_intro_generator = self.ai._agent_intro_generator
- if not isinstance(agent_intro_generator, AgentIntroGenerator):
- logger.error("[Agent介绍生成] 无法获取 AgentIntroGenerator 实例")
- return
-
- (
- system_prompt,
- user_prompt,
- ) = await agent_intro_generator.get_intro_prompt_and_context(agent_name)
-
- messages = [
- {"role": "system", "content": system_prompt or "你是一位智能助手。"},
- {"role": "user", "content": user_prompt},
- ]
-
- result = await self.ai.submit_queued_llm_call(
- model_config=self.ai.agent_config,
- messages=messages,
- max_tokens=agent_intro_generator.config.max_tokens,
- call_type=f"agent:{agent_name}",
- queue_lane=request.get("_queue_lane"),
- )
-
- choices = result.get("choices", [{}])
- if choices:
- content = choices[0].get("message", {}).get("content", "")
- generated_content = content.strip()
- else:
- generated_content = ""
-
- logger.info(
- "[Agent介绍生成] 生成完成: agent=%s length=%s request_id=%s",
- agent_name,
- len(generated_content),
- request_id,
- )
-
- agent_intro_generator.set_intro_generation_result(
- request_id, generated_content if generated_content else None
- )
-
- except Exception as exc:
- logger.exception(
- "[Agent介绍生成] 生成失败: agent=%s error=%s",
- agent_name,
- exc,
- )
- try:
- agent_intro_generator = self.ai._agent_intro_generator
- agent_intro_generator.set_intro_generation_result(request_id, None)
- except Exception:
- pass
-
- def _is_at_bot(self, content: list[dict[str, Any]]) -> bool:
- """检查消息内容中是否包含对机器人的 @ 提问"""
- for seg in content:
- if seg.get("type") == "at" and str(
- seg.get("data", {}).get("qq", "")
- ) == str(self.config.bot_qq):
- return True
- return False
-
- async def _handle_injection_response(
- self,
- tid: int,
- text: str,
- is_private: bool = False,
- sender_id: Optional[int] = None,
- ) -> None:
- """当检测到注入攻击时,生成并发送特定的防御性回复"""
- reply = await self.security.generate_injection_response(text)
- if not reply.strip():
- return
- if is_private:
- await self.sender.send_private_message(tid, reply, auto_history=False)
- await self.history_manager.add_private_message(
- tid, "<对注入消息的回复>", "Bot", "Bot"
- )
- else:
- msg = f"[@{sender_id}] {reply}" if sender_id else reply
- await self.sender.send_group_message(tid, msg, auto_history=False)
- await self.history_manager.add_group_message(
- tid, self.config.bot_qq, "<对注入消息的回复>", "Bot", ""
- )
-
- def _format_group_message_segment(self, item: BufferedMessage) -> str:
- """格式化群聊单条 ```` 块。"""
- time_str = datetime.fromtimestamp(item.arrival_time).strftime(
- "%Y-%m-%d %H:%M:%S"
- )
- group_name = item.group_name or "未知群聊"
- location = group_name if group_name.endswith("群") else f"{group_name}群"
- safe_name = escape_xml_attr(item.sender_name or "未知用户")
- safe_uid = escape_xml_attr(item.sender_id)
- safe_gid = escape_xml_attr(item.group_id or 0)
- safe_gname = escape_xml_attr(group_name)
- safe_loc = escape_xml_attr(location)
- safe_role = escape_xml_attr(item.sender_role or "member")
- safe_title = escape_xml_attr(item.sender_title or "")
- safe_time = escape_xml_attr(time_str)
- safe_text = escape_xml_text_preserving_attachment_tags(
- item.text,
- item.attachments,
- )
- message_id_attr = ""
- if item.trigger_message_id is not None:
- message_id_attr = (
- f' message_id="{escape_xml_attr(item.trigger_message_id)}"'
- )
- level_attr = (
- f' level="{escape_xml_attr(item.sender_level)}"'
- if item.sender_level
- else ""
- )
- attachment_xml = (
- f"\n{attachment_refs_to_xml(item.attachments)}" if item.attachments else ""
- )
- return (
- f'\n'
- f" {safe_text}{attachment_xml}\n"
- f" "
- )
-
- def _format_private_message_segment(self, item: BufferedMessage) -> str:
- """格式化私聊单条 ```` 块。"""
- time_str = datetime.fromtimestamp(item.arrival_time).strftime(
- "%Y-%m-%d %H:%M:%S"
- )
- safe_name = escape_xml_attr(item.sender_name or "未知用户")
- safe_uid = escape_xml_attr(item.sender_id)
- safe_time = escape_xml_attr(time_str)
- safe_text = escape_xml_text_preserving_attachment_tags(
- item.text,
- item.attachments,
- )
- message_id_attr = ""
- if item.trigger_message_id is not None:
- message_id_attr = (
- f' message_id="{escape_xml_attr(item.trigger_message_id)}"'
- )
- attachment_xml = (
- f"\n{attachment_refs_to_xml(item.attachments)}" if item.attachments else ""
- )
- return (
- f'\n'
- f" {safe_text}{attachment_xml}\n"
- f" "
- )
-
- @staticmethod
- def _build_continuous_messages_note(items: list[BufferedMessage]) -> str:
- """生成"连续消息说明"段。仅在 ``len(items) >= 2`` 时使用。"""
- count = len(items)
- first_t = items[0].arrival_time
- last_t = items[-1].arrival_time
- span = max(0.0, last_t - first_t)
- return (
- f"\n\n 【连续消息说明】以上 {count} 条 是同一用户在约 "
- f"{span:.1f} 秒内连续发送的消息(按时间先后排列),代表本轮要回应的全部输入:\n"
- f" - 这些 共同构成【当前输入批次】,不要把同批前几条误判为历史旧任务;"
- f"批次之外的历史消息仍只作为背景,不能回溯拾荒\n"
- f" - 先识别每条的意图,分清是【独立请求】还是【对前一条的修正/否定/补充/打断】\n"
- f' · 若是【多个独立的不同意图/问题】(如"先帮我查 A,再翻译 B")'
- f" → 每个都要回应,不要遗漏;与平时一样,可以多次 send_message 自然分发\n"
- f' · 若后发是【对前发的修正/否定/补充/打断】(如"画猫" → "改成狗")'
- f" → 以最后一次明确意图为准,旧的不再执行,可简短说明已采纳更新\n"
- f' · 拿不准时偏向"独立请求",宁多勿漏\n'
- f" - 整批在本轮一次性处理完即可,不要为同一意图重复输出(不要"
- f'"中间一波、结尾再来一波"重复相同回复)\n'
- f" - history 中若出现与当前轮 相同的条目,视为同一来源,不要重复处理"
- )
-
- def _build_grouped_prompt(self, items: list[BufferedMessage]) -> str:
- """根据 BufferedMessage 列表构造合并后的完整 prompt。"""
- if not items:
- return ""
- is_private = items[0].is_private
- # prefix:拍一拍优先;否则任一 @bot
- any_poke = any(it.is_poke for it in items)
- any_at_bot = any(it.is_at_bot for it in items)
- if any_poke:
- prefix = "(用户拍了拍你) "
- elif any_at_bot:
- prefix = "(用户 @ 了你) "
- else:
- prefix = ""
-
- if is_private:
- segments = [self._format_private_message_segment(it) for it in items]
- else:
- segments = [self._format_group_message_segment(it) for it in items]
- body = prefix + "\n".join(segments)
- if len(items) >= 2:
- body += self._build_continuous_messages_note(items)
- body += _GROUP_STRATEGY_FOOTER if not is_private else _PRIVATE_STRATEGY_FOOTER
- return body
-
- @staticmethod
- def _collect_message_ids(items: list[BufferedMessage]) -> list[str]:
- return collect_message_ids(items)
-
- async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None:
- """根据一组 BufferedMessage 决定优先级、构造 prompt 并入队。
-
- 既是单条直送路径的统一出口,也是 :class:`MessageBatcher` 的 flush_callback。
- """
- if not items:
- return
- first = items[0]
- last = items[-1]
- full_question = self._build_grouped_prompt(items)
- message_ids = self._collect_message_ids(items)
- any_poke = any(it.is_poke for it in items)
- any_at_bot = any(it.is_at_bot for it in items)
-
- if first.is_private:
- user_id = first.sender_id
- request_data: dict[str, Any] = {
- "type": "private_reply",
- "user_id": user_id,
- "sender_name": first.sender_name,
- "text": last.text,
- "full_question": full_question,
- "trigger_message_id": last.trigger_message_id,
- "message_ids": message_ids,
- "batched_count": len(items),
- }
- if first.batch_token is not None:
- request_data["_message_batcher_token"] = first.batch_token
- effective_config = self.model_pool.select_chat_config(
- self.config.chat_model, user_id=user_id
- )
- request_data["selected_model_name"] = effective_config.model_name
- logger.debug(
- "[私聊回复] full_question_len=%s user=%s batched=%s",
- len(full_question),
- user_id,
- len(items),
- )
- if user_id == self.config.superadmin_qq:
- await self.queue_manager.add_superadmin_request(
- request_data, model_name=effective_config.model_name
- )
- else:
- await self.queue_manager.add_private_request(
- request_data, model_name=effective_config.model_name
- )
- return
-
- # 群聊
- group_id = first.group_id or 0
- sender_id = first.sender_id
- request_data = {
- "type": "auto_reply",
- "group_id": group_id,
- "sender_id": sender_id,
- "sender_name": first.sender_name,
- "group_name": first.group_name,
- "text": last.text,
- "full_question": full_question,
- "is_at_bot": any_at_bot,
- "trigger_message_id": last.trigger_message_id,
- "message_ids": message_ids,
- "batched_count": len(items),
- }
- if first.batch_token is not None:
- request_data["_message_batcher_token"] = first.batch_token
- logger.debug(
- "[自动回复] full_question_len=%s group=%s sender=%s batched=%s",
- len(full_question),
- group_id,
- sender_id,
- len(items),
- )
- if sender_id == self.config.superadmin_qq:
- logger.info("[AI] 投递至群聊超级管理员队列 (batched=%s)", len(items))
- await self.queue_manager.add_group_superadmin_request(
- request_data, model_name=self.config.chat_model.model_name
- )
- elif any_at_bot:
- trigger = "拍一拍" if any_poke else "@机器人"
- logger.info("[AI] 触发原因: %s (batched=%s)", trigger, len(items))
- await self.queue_manager.add_group_mention_request(
- request_data, model_name=self.config.chat_model.model_name
- )
- else:
- logger.info("[AI] 投递至普通请求队列 (batched=%s)", len(items))
- await self.queue_manager.add_group_normal_request(
- request_data, model_name=self.config.chat_model.model_name
- )
-
- def _build_prompt(
- self,
- prefix: str,
- name: str,
- uid: int,
- gid: int,
- gname: str,
- loc: str,
- role: str,
- title: str,
- time_str: str,
- text: str,
- attachments: list[dict[str, str]] | None = None,
- message_id: int | None = None,
- level: str = "",
- ) -> str:
- """构建最终发送给 AI 的结构化 XML 消息 Prompt
-
- 包含回复策略提示、用户信息和原始文本内容。
- """
- safe_name = escape_xml_attr(name)
- safe_uid = escape_xml_attr(uid)
- safe_gid = escape_xml_attr(gid)
- safe_gname = escape_xml_attr(gname)
- safe_loc = escape_xml_attr(loc)
- safe_role = escape_xml_attr(role)
- safe_title = escape_xml_attr(title)
- safe_time = escape_xml_attr(time_str)
- safe_text = escape_xml_text_preserving_attachment_tags(text, attachments)
- message_id_attr = ""
- if message_id is not None:
- message_id_attr = f' message_id="{escape_xml_attr(message_id)}"'
- level_attr = f' level="{escape_xml_attr(level)}"' if level else ""
- attachment_xml = (
- f"\n{attachment_refs_to_xml(attachments)}" if attachments else ""
- )
- return f"""{prefix}
- {safe_text}{attachment_xml}
-
-{_GROUP_STRATEGY_FOOTER}"""
-
- async def _send_image(self, tid: int, mtype: str, path: str) -> None:
- """发送图片或语音消息到群聊或私聊"""
- import os
-
- if not os.path.exists(path):
- return
- file_uri = Path(path).resolve().as_uri()
- ext = os.path.splitext(path)[1].lower()
- if ext in [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"]:
- msg = f"[CQ:image,file={file_uri}]"
- elif ext in [".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"]:
- msg = f"[CQ:record,file={file_uri}]"
- else:
- return
-
- try:
- if mtype == "group":
- await self.sender.send_group_message(tid, msg, auto_history=False)
- elif mtype == "private":
- await self.sender.send_private_message(tid, msg, auto_history=False)
- except Exception:
- logger.exception("发送媒体文件失败")
diff --git a/src/Undefined/services/coordinator/group.py b/src/Undefined/services/coordinator/group.py
index 20ca134c..e34488f9 100644
--- a/src/Undefined/services/coordinator/group.py
+++ b/src/Undefined/services/coordinator/group.py
@@ -33,7 +33,7 @@
_GROUP_STRATEGY_FOOTER = """
- 【回复策略 - 更克制,纯表情包才前置检索】
+ 【回复策略 - 克制参与,轻松场景可后补表情包】
1. 如果用户 @ 了你或拍了拍你 → 【必须回复】
2. 如果消息中明确提到了你(根据上下文判断用户是否在叫你或维持对话流) → 【必须回复】
3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】
@@ -47,19 +47,19 @@
6. 群聊里的主动参与只保留给公开、开放的技术或项目讨论:
- 只在多人公开讨论代码、AI、开发工具、项目进展、技术 bug 等,且不是别人之间定向交流时,才可以【极低频参与】
- 默认更倾向不参与;不要长篇大论,一两句点到为止;如果别人已经在深入讨论且不需要你,保持沉默
- - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复,且本轮明确是纯表情包/纯反应图时,才优先考虑表情包表达
+ - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复时,才考虑用表情包增强表达
7. 对于已经决定要回复的场景(包括被@、被拍一拍、轻量答疑,以及少量符合条件的主动参与):
- 只有明确纯表情包回复才先检索表情包,再用 memes.send_meme_by_uid 单独发一条图片消息
- 其他需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 send_message
- - 如果确实还想补表情包,把 memes.search_memes 和 memes.send_meme_by_uid 放到文字发送后的后续响应轮次,不要阻塞首条文字回复
+ - 轻松聊天、吐槽、附和、接梗、表达情绪、被拍一拍、被@后的短回应等场景,文字发送成功后优先考虑在后续响应轮次补一张独立表情包,不要阻塞首条文字回复
- 不要发送任何敷衍消息(如'懒得掺和'、'哦'等);不想回复就直接调用 end
- - 严肃、任务型、高信息密度场景少发表情包,避免打断信息传递
+ - 严肃答疑、代码排查、长任务推进、隐私/安全拒绝、信息不足追问这类场景默认不补表情包,避免打断信息传递
- 绝不要刷屏、绝不要每条都回
8. 对于本来就会回复的场景(私聊、被拍一拍、被@、轻量答疑):
- - 如果表情包能自然增强语气、缓和语气或让表达更像真人,也只能作为后续可选补充
+ - 如果表情包能自然增强语气、缓和语气或让表达更像真人,优先作为后续补充
- 但不要为了发表情包而牺牲信息传递;信息密度优先时仍以文字为主
- 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,除非明确是纯表情包回复,否则先把文字回复做好,表情包最后再搜。"""
+ 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,除非明确是纯表情包回复,否则先把文字回复做好;轻松、接梗、情绪回应可以优先后补表情包。"""
class GroupReplyMixin:
diff --git a/src/Undefined/services/coordinator/private.py b/src/Undefined/services/coordinator/private.py
index b5ecbe48..bc65d9ab 100644
--- a/src/Undefined/services/coordinator/private.py
+++ b/src/Undefined/services/coordinator/private.py
@@ -41,7 +41,7 @@
【私聊消息】
这是私聊消息,用户专门来找你说话。你可以自由选择是否回复:
- 如果想回复,先调用 send_message 工具发送回复内容,然后调用 end 结束对话
-- 只有明确纯表情包回复时,才先用 memes.search_memes 查表情包,再用 memes.send_meme_by_uid 单独发图;其他场景先把文字回复做好,表情包最后再搜或不搜
+- 只有明确纯表情包回复时,才先用 memes.search_memes 查表情包,再用 memes.send_meme_by_uid 单独发图;其他场景先把文字回复做好,轻松、接梗、情绪回应可以优先在后续轮次补一张独立表情包;严肃答疑、任务推进、隐私/安全拒绝或信息不足追问默认不补
- 如果不想回复,直接调用 end 结束对话即可"""
diff --git a/src/Undefined/skills/toolsets/messages/get_forward_msg/config.json b/src/Undefined/skills/toolsets/messages/get_forward_msg/config.json
index 1d301c5a..d2b0f254 100644
--- a/src/Undefined/skills/toolsets/messages/get_forward_msg/config.json
+++ b/src/Undefined/skills/toolsets/messages/get_forward_msg/config.json
@@ -2,16 +2,28 @@
"type": "function",
"function": {
"name": "get_forward_msg",
- "description": "获取合并转发消息的详情内容。当你在聊天记录中看到 [合并转发: ID] 时,可以使用此工具获取其包含的具体消息列表。",
+ "description": "获取合并转发消息的详情内容。当你在聊天记录中看到 或旧的 [合并转发: ID] 时,可以使用此工具按层读取其包含的消息列表。默认只返回当前层节点;节点内若仍有合并转发,会返回新的 ,需要继续传入该 uid 读取。",
"parameters": {
"type": "object",
"properties": {
"message_id": {
"type": "string",
- "description": "合并转发消息的 ID"
+ "description": "合并转发消息 UID 或旧 OneBot 合并转发 ID。例如 forward_xxx 或 raw forward id。"
+ },
+ "offset": {
+ "type": "integer",
+ "description": "分页起始节点下标,默认 0。"
+ },
+ "limit": {
+ "type": "integer",
+ "description": "最多返回多少个当前层节点,默认 50,最大 100。"
+ },
+ "max_depth": {
+ "type": "integer",
+ "description": "兼容参数。当前默认按层读取,遇到内层合并转发会返回新的 forward uid,需再次调用本工具。"
}
},
"required": ["message_id"]
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py b/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py
index da7676e5..29ad4a69 100644
--- a/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py
+++ b/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py
@@ -1,131 +1,253 @@
-from typing import Any, Dict
+from __future__ import annotations
+
from datetime import datetime
import logging
-import json
+from typing import Any, Mapping
+
+from Undefined.attachments import build_attachment_scope, register_message_attachments
+from Undefined.attachments.segments import (
+ forward_ref_to_tag,
+ normalize_message_segments,
+)
+from Undefined.utils.xml import (
+ escape_xml_attr,
+ escape_xml_text_preserving_attachment_tags,
+)
logger = logging.getLogger(__name__)
+_DEFAULT_LIMIT = 50
+_MAX_LIMIT = 100
+
+
+def _safe_int(value: Any, default: int, *, minimum: int = 0, maximum: int = 100) -> int:
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ return default
+ return max(minimum, min(maximum, parsed))
+
-async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
- message_id = args.get("message_id")
+def _format_time(raw_time: Any) -> str:
+ if raw_time is None or raw_time == "":
+ return "未知时间"
+ try:
+ timestamp = float(raw_time)
+ if timestamp > 1_000_000_000_000:
+ timestamp /= 1000.0
+ if timestamp <= 0:
+ return str(raw_time)
+ return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
+ except (TypeError, ValueError, OSError, OverflowError):
+ return str(raw_time)
+
+
+def _normalize_nodes(raw_nodes: Any) -> list[Mapping[str, Any]]:
+ if isinstance(raw_nodes, list):
+ return [node for node in raw_nodes if isinstance(node, Mapping)]
+ if isinstance(raw_nodes, Mapping):
+ messages = raw_nodes.get("messages")
+ if isinstance(messages, list):
+ return [node for node in messages if isinstance(node, Mapping)]
+ return []
+
+
+def _resolve_scope_key(context: Mapping[str, Any]) -> str | None:
+ get_scope = context.get("get_scope_from_context")
+ if callable(get_scope):
+ try:
+ resolved = get_scope(context)
+ if resolved is None:
+ return None
+ return str(resolved)
+ except Exception:
+ logger.debug("从工具上下文推断 scope 失败", exc_info=True)
+ return build_attachment_scope(
+ group_id=context.get("group_id"),
+ user_id=context.get("user_id"),
+ request_type=str(context.get("request_type", "") or ""),
+ webui_session=bool(context.get("webui_session", False)),
+ )
+
+
+def _raw_forward_id_from_record(uid_or_id: str, context: Mapping[str, Any]) -> str:
+ if not uid_or_id.startswith("forward_"):
+ return uid_or_id
+ registry = context.get("attachment_registry")
+ scope_key = _resolve_scope_key(context)
+ if registry is None or not scope_key:
+ return ""
+ resolve = getattr(registry, "resolve", None)
+ if not callable(resolve):
+ return ""
+ record = resolve(uid_or_id, scope_key)
+ if record is None or getattr(record, "media_type", "") != "forward":
+ return ""
+ return str(getattr(record, "source_ref", "") or "").strip()
+
+
+async def _register_node_segments(
+ *,
+ segments: list[Mapping[str, Any]],
+ context: Mapping[str, Any],
+) -> tuple[str, list[dict[str, str]]]:
+ registry = context.get("attachment_registry")
+ scope_key = _resolve_scope_key(context)
+ onebot = context.get("onebot_client")
+ resolve_image_url = (
+ getattr(onebot, "get_image", None) if onebot is not None else None
+ )
+ if registry is None or not scope_key:
+ text_parts: list[str] = []
+ for segment in segments:
+ type_ = str(segment.get("type", "") or "")
+ raw_data = segment.get("data", {})
+ data = raw_data if isinstance(raw_data, Mapping) else {}
+ if type_ == "text":
+ text_parts.append(str(data.get("text", "") or ""))
+ elif type_ == "forward":
+ forward_id = str(data.get("id") or data.get("resid") or "").strip()
+ text_parts.append(
+ f"[合并转发: {forward_id}]" if forward_id else "[合并转发]"
+ )
+ elif type_ == "image":
+ text_parts.append("[图片]")
+ elif type_ == "file":
+ text_parts.append("[文件]")
+ elif type_ == "at":
+ qq = str(data.get("qq", "") or "").strip()
+ text_parts.append(f"[@{qq}]" if qq else "[@]")
+ elif type_ == "face":
+ text_parts.append("[表情]")
+ elif type_ == "reply":
+ text_parts.append("[引用]")
+ elif type_:
+ text_parts.append(f"[{type_}]")
+ return "".join(text_parts).strip(), []
+
+ result = await register_message_attachments(
+ registry=registry,
+ segments=segments,
+ scope_key=scope_key,
+ resolve_image_url=resolve_image_url,
+ get_forward_messages=None,
+ register_forward_refs=True,
+ expand_forward_attachments=False,
+ )
+ refs = list(result.attachments) + list(result.forward_refs)
+ return result.normalized_text, refs
+
+
+async def execute(args: dict[str, Any], context: dict[str, Any]) -> str:
+ message_id = str(args.get("message_id") or args.get("uid") or "").strip()
if not message_id:
return "错误:message_id 不能为空"
get_forward_msg_callback = context.get("get_forward_msg_callback")
- if not get_forward_msg_callback:
+ if not callable(get_forward_msg_callback):
return "错误:获取合并转发消息的回调未设置"
- try:
- nodes = await get_forward_msg_callback(message_id)
- if not nodes:
- return "未能获取到合并转发消息的内容或内容为空"
-
- logger.info(f"成功获取合并转发内容,节点数: {len(nodes)}")
-
- formatted_messages = []
- for i, node in enumerate(nodes):
- # 记录第一个节点的结构用于调试
- if i == 0:
- logger.debug(
- f"合并转发节点示例结构: {json.dumps(node, ensure_ascii=False)[:500]}"
- )
+ raw_forward_id = _raw_forward_id_from_record(message_id, context)
+ if not raw_forward_id:
+ return f"错误:合并转发 UID 不可用或不属于当前会话:{message_id}"
- sender = node.get("sender") or {}
- # 兼容有些实现直接把发送者信息放在节点根部
- sender_name = (
- sender.get("nickname")
- or node.get("nickname")
- or sender.get("card")
- or node.get("card")
- or "未知用户"
- )
- sender_id = sender.get("user_id") or node.get("user_id") or "未知ID"
+ offset = _safe_int(args.get("offset"), 0, minimum=0, maximum=1_000_000)
+ limit = _safe_int(args.get("limit"), _DEFAULT_LIMIT, minimum=1, maximum=_MAX_LIMIT)
+ # 保留参数用于向后兼容和未来扩展;当前实现默认首层,不递归展开。
+ _ = _safe_int(args.get("max_depth"), 1, minimum=1, maximum=5)
- node_time = node.get("time")
- if node_time:
- try:
- timestamp = datetime.fromtimestamp(float(node_time)).strftime(
- "%Y-%m-%d %H:%M:%S"
+ try:
+ nodes = _normalize_nodes(await get_forward_msg_callback(raw_forward_id))
+ except Exception as exc:
+ logger.exception("获取合并转发消息失败: id=%s", raw_forward_id)
+ return f"获取合并转发消息失败:{exc}"
+
+ if not nodes:
+ return "未能获取到合并转发消息的内容或内容为空"
+
+ window = nodes[offset : offset + limit]
+ if not window:
+ return (
+ f"合并转发 {message_id} 共 {len(nodes)} 个节点,"
+ f"offset={offset} 已超出范围。"
+ )
+
+ formatted_messages: list[str] = []
+ for node in window:
+ sender = node.get("sender")
+ sender_data = sender if isinstance(sender, Mapping) else {}
+ sender_name = (
+ sender_data.get("nickname")
+ or sender_data.get("card")
+ or node.get("nickname")
+ or node.get("card")
+ or "未知用户"
+ )
+ sender_id = sender_data.get("user_id") or node.get("user_id") or "未知ID"
+ timestamp = _format_time(node.get("time"))
+ raw_content = (
+ node.get("content") or node.get("message") or node.get("raw_message")
+ )
+ segments = normalize_message_segments(raw_content)
+ text, refs = await _register_node_segments(
+ segments=segments,
+ context=context,
+ )
+ if not text:
+ text = "(空消息)"
+ safe_text = escape_xml_text_preserving_attachment_tags(text, refs)
+ attachment_lines: list[str] = []
+ forward_lines: list[str] = []
+ for ref in refs:
+ media_type = str(ref.get("media_type") or ref.get("kind") or "").strip()
+ if media_type == "forward":
+ tag = forward_ref_to_tag(ref)
+ if tag:
+ forward_uid = str(ref.get("uid", "") or "").strip()
+ forward_lines.append(
+ f' '
+ f"{tag}"
)
- except (ValueError, TypeError):
- timestamp = str(node_time)
- else:
- timestamp = "未知时间"
-
- # 激进的内容提取:尝试所有可能的字段
- raw_content = (
- node.get("content") or node.get("message") or node.get("raw_message")
+ continue
+ uid = str(ref.get("uid", "") or "").strip()
+ if not uid:
+ continue
+ name = str(ref.get("display_name", "") or "").strip()
+ name_attr = f' name="{escape_xml_attr(name)}"' if name else ""
+ escaped_type = escape_xml_attr(media_type or "file")
+ attachment_lines.append(
+ f' '
)
-
- text_parts = []
- if raw_content is None:
- text_parts.append("(消息内容字段缺失)")
- elif isinstance(raw_content, str):
- text_parts.append(raw_content)
- elif isinstance(raw_content, dict):
- # 单个消息段
- raw_content = [raw_content]
-
- if isinstance(raw_content, list):
- for segment in raw_content:
- if isinstance(segment, str):
- text_parts.append(segment)
- continue
- if not isinstance(segment, dict):
- continue
-
- seg_type = segment.get("type")
- seg_data = segment.get("data", {})
-
- if seg_type == "text":
- text_parts.append(seg_data.get("text", ""))
- elif seg_type == "at":
- qq = seg_data.get("qq", "")
- text_parts.append(f"[@ {qq}]")
- elif seg_type == "image":
- file = seg_data.get("file", "") or seg_data.get("url", "")
- text_parts.append(f"[图片: {file}]")
- elif seg_type == "forward":
- inner_id = seg_data.get("id")
- text_parts.append(f"[合并转发: {inner_id}]")
- elif seg_type == "reply":
- text_parts.append("[引用]")
- elif seg_type == "face":
- text_parts.append("[表情]")
- elif seg_type == "json":
- # 尝试从 JSON 中提取描述
- try:
- j_data = json.loads(seg_data.get("data", "{}"))
- desc = (
- j_data.get("meta", {})
- .get("detail", {})
- .get("desc", "JSON消息")
- )
- text_parts.append(f"[{desc}]")
- except Exception:
- text_parts.append("[JSON消息]")
- elif seg_type == "xml":
- text_parts.append("[XML消息]")
- elif seg_type:
- text_parts.append(f"[{seg_type}]")
-
- text = "".join(text_parts).strip()
- if not text:
- # 如果还是空,把整个节点键名返回给 AI 辅助判断
- keys = list(node.keys())
- text = f"(无法解析内容,节点键名: {keys})"
-
- # 格式:XML 标准化
- formatted_messages.append(f"""
-{text}
-""")
-
- result = "\n---\n".join(formatted_messages)
- logger.info(
- f"get_forward_msg 处理完成,返回数据样例 (前500字符): {result[:500]}..."
+ extra = ""
+ if attachment_lines or forward_lines:
+ extra = "\n" + "\n".join(attachment_lines + forward_lines)
+ formatted_messages.append(
+ f'\n'
+ f"{safe_text}{extra}\n"
+ f""
)
- return result
- except Exception as e:
- logger.exception(f"解析合并转发消息时出错: {e}")
- return "解析合并转发消息时出错,请重试"
+ next_offset = offset + len(window)
+ page_note = (
+ f"合并转发 {message_id}(源 ID: {raw_forward_id})节点 "
+ f"{offset + 1}-{next_offset}/{len(nodes)}"
+ )
+ if next_offset < len(nodes):
+ page_note += (
+ "\n继续读取:调用 "
+ f'get_forward_msg(message_id="{message_id}", '
+ f"offset={next_offset}, limit={limit})"
+ )
+ result = page_note + "\n" + "\n---\n".join(formatted_messages)
+ logger.info(
+ "get_forward_msg 完成: id=%s raw=%s offset=%s limit=%s total=%s",
+ message_id,
+ raw_forward_id,
+ offset,
+ limit,
+ len(nodes),
+ )
+ return result
diff --git a/src/Undefined/utils/xml.py b/src/Undefined/utils/xml.py
index 908cc34e..05a4e618 100644
--- a/src/Undefined/utils/xml.py
+++ b/src/Undefined/utils/xml.py
@@ -9,8 +9,8 @@
from xml.sax.saxutils import escape
-_INLINE_ATTACHMENT_TAG_RE = re.compile(
- r"[\"'])(?P[^\"']+)(?P=quote)\s*/?>",
+_INLINE_PRESERVED_TAG_RE = re.compile(
+ r"<(?Pattachment|forward)\s+uid=(?P[\"'])(?P[^\"']+)(?P=quote)\s*/?>",
re.IGNORECASE,
)
@@ -28,24 +28,36 @@ def escape_xml_text_preserving_attachment_tags(
value: str,
attachments: Sequence[Mapping[str, str]] | None = None,
) -> str:
- """Escape XML text while preserving known ```` tags."""
- allowed_uids = {
+ """Escape XML text while preserving known inline resource tags."""
+ allowed_attachment_uids = {
str(item.get("uid", "") or "").strip()
for item in (attachments or [])
- if isinstance(item, Mapping) and str(item.get("uid", "") or "").strip()
+ if isinstance(item, Mapping)
+ and str(item.get("uid", "") or "").strip()
+ and (str(item.get("media_type") or item.get("kind") or "").strip() != "forward")
}
- if not allowed_uids:
+ allowed_forward_uids = {
+ str(item.get("uid", "") or "").strip()
+ for item in (attachments or [])
+ if isinstance(item, Mapping)
+ and str(item.get("uid", "") or "").strip()
+ and (str(item.get("media_type") or item.get("kind") or "").strip() == "forward")
+ }
+ if not allowed_attachment_uids and not allowed_forward_uids:
return escape_xml_text(value)
text = str(value or "")
parts: list[str] = []
last_index = 0
- for match in _INLINE_ATTACHMENT_TAG_RE.finditer(text):
+ for match in _INLINE_PRESERVED_TAG_RE.finditer(text):
+ tag = str(match.group("tag") or "").lower()
uid = html.unescape(str(match.group("uid") or "").strip())
- if uid not in allowed_uids:
+ if tag == "attachment" and uid not in allowed_attachment_uids:
+ continue
+ if tag == "forward" and uid not in allowed_forward_uids:
continue
parts.append(escape_xml_text(text[last_index : match.start()]))
- parts.append(f'')
+ parts.append(f'<{tag} uid="{escape_xml_attr(uid)}"/>')
last_index = match.end()
parts.append(escape_xml_text(text[last_index:]))
return "".join(parts)
diff --git a/tests/test_ai_coordinator_queue_routing.py b/tests/test_ai_coordinator_queue_routing.py
index 327f834a..6ec1e2ff 100644
--- a/tests/test_ai_coordinator_queue_routing.py
+++ b/tests/test_ai_coordinator_queue_routing.py
@@ -1,18 +1,25 @@
from __future__ import annotations
+import importlib
from collections.abc import Awaitable, Callable
from types import SimpleNamespace
from typing import Any, cast
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, Mock
import pytest
from Undefined.context import RequestContext
-from Undefined.services.ai_coordinator import AICoordinator
+from Undefined.services.coordinator import AICoordinator
+from Undefined.services.coordinator.background import BackgroundMixin
from Undefined.services.message_batcher import BufferedMessage
from Undefined.services.coordinator import group as coordinator_group_module
+def test_legacy_ai_coordinator_module_is_removed() -> None:
+ with pytest.raises(ModuleNotFoundError):
+ importlib.import_module("Undefined.services.ai_coordinator")
+
+
@pytest.mark.asyncio
async def test_handle_auto_reply_routes_group_superadmin_to_dedicated_queue() -> None:
coordinator: Any = object.__new__(AICoordinator)
@@ -217,6 +224,8 @@ def test_build_prompt_limits_proactive_participation_to_technical_contexts() ->
assert "轻松互动、玩梗、吐槽本身不构成参与许可" in prompt
assert "只有明确纯表情包回复才先检索表情包" in prompt
assert "第一轮必须优先把必要文字回复做好并调用 send_message" in prompt
+ assert "文字发送成功后优先考虑在后续响应轮次补一张独立表情包" in prompt
+ assert "严肃答疑、代码排查、长任务推进、隐私/安全拒绝、信息不足追问" in prompt
assert "默认先尝试 memes.search_memes" not in prompt
assert "普通闲聊、玩梗、吐槽、轻松互动:" not in prompt
@@ -315,3 +324,33 @@ async def _fake_ask(*_args: Any, **kwargs: Any) -> str:
assert captured_extra_context["batched_count"] == 2
assert captured_extra_context["current_input_is_batched"] is True
assert captured_resources["message_ids"] == ["101", "102"]
+
+
+@pytest.mark.asyncio
+async def test_execute_queued_llm_call_coerces_max_tokens_to_int() -> None:
+ coordinator: Any = object.__new__(AICoordinator)
+ model_config = SimpleNamespace(model_name="chat-model", max_tokens="4096")
+ result = {"choices": [{"message": {"content": "ok"}}]}
+ request_model = AsyncMock(return_value=result)
+ set_llm_call_result = Mock()
+ coordinator.ai = SimpleNamespace(
+ request_model=request_model,
+ set_llm_call_result=set_llm_call_result,
+ )
+ coordinator.config = SimpleNamespace(ai_request_max_retries=0)
+
+ await BackgroundMixin._execute_queued_llm_call(
+ coordinator,
+ {
+ "request_id": "req-1",
+ "model_config": model_config,
+ "messages": [{"role": "user", "content": "hello"}],
+ "max_tokens": "123",
+ "call_type": "test_call",
+ },
+ )
+
+ request_model.assert_awaited_once()
+ assert request_model.await_args is not None
+ assert request_model.await_args.kwargs["max_tokens"] == 123
+ set_llm_call_result.assert_called_once_with("req-1", result)
diff --git a/tests/test_attachments.py b/tests/test_attachments.py
index df97c52e..a025cd4a 100644
--- a/tests/test_attachments.py
+++ b/tests/test_attachments.py
@@ -638,6 +638,57 @@ async def _fake_get_forward(_forward_id: str) -> list[dict[str, object]]:
assert result.attachments[0]["uid"].startswith("pic_")
+@pytest.mark.asyncio
+async def test_register_message_attachments_registers_forward_uid_without_expansion(
+ tmp_path: Path,
+) -> None:
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ calls = 0
+
+ async def _fake_get_forward(_forward_id: str) -> list[dict[str, object]]:
+ nonlocal calls
+ calls += 1
+ return [{"message": [{"type": "image", "data": {"file": "unused"}}]}]
+
+ result = await register_message_attachments(
+ registry=registry,
+ segments=[{"type": "forward", "data": {"id": "forward-1"}}],
+ scope_key="group:10001",
+ get_forward_messages=_fake_get_forward,
+ register_forward_refs=True,
+ expand_forward_attachments=False,
+ )
+
+ assert calls == 0
+ assert result.attachments == []
+ assert len(result.forward_refs) == 1
+ forward_ref = result.forward_refs[0]
+ assert forward_ref["uid"].startswith("forward_")
+ assert result.normalized_text == f''
+ record = registry.resolve(forward_ref["uid"], "group:10001")
+ assert record is not None
+ assert record.media_type == "forward"
+ assert record.source_ref == "forward-1"
+
+
+def test_attachment_refs_to_xml_skips_forward_refs() -> None:
+ xml = attachment_refs_to_xml(
+ [
+ {
+ "uid": "forward_demo",
+ "kind": "forward",
+ "media_type": "forward",
+ "display_name": "合并转发",
+ }
+ ]
+ )
+
+ assert xml == ""
+
+
@pytest.mark.asyncio
async def test_register_message_attachments_preserves_segment_data_for_images(
tmp_path: Path,
diff --git a/tests/test_coordinator_level.py b/tests/test_coordinator_level.py
index 38ecaa68..ab15493c 100644
--- a/tests/test_coordinator_level.py
+++ b/tests/test_coordinator_level.py
@@ -3,7 +3,7 @@
from unittest.mock import MagicMock
-from Undefined.services.ai_coordinator import AICoordinator
+from Undefined.services.coordinator import AICoordinator
def _make_coordinator() -> AICoordinator:
diff --git a/tests/test_get_forward_msg_tool.py b/tests/test_get_forward_msg_tool.py
new file mode 100644
index 00000000..91c1b5ff
--- /dev/null
+++ b/tests/test_get_forward_msg_tool.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+import base64
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from Undefined.attachments import AttachmentRegistry
+from Undefined.skills.toolsets.messages.get_forward_msg.handler import execute
+
+_PNG_BYTES = (
+ b"\x89PNG\r\n\x1a\n"
+ b"\x00\x00\x00\rIHDR"
+ b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
+ b"\x90wS\xde"
+ b"\x00\x00\x00\x0cIDATx\x9cc``\x00\x00\x00\x02\x00\x01"
+ b"\x0b\xe7\x02\x9d"
+ b"\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+@pytest.mark.asyncio
+async def test_get_forward_msg_accepts_forward_uid_and_registers_nested_refs(
+ tmp_path: Path,
+) -> None:
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ forward_record = await registry.register_forward_reference(
+ "group:10001",
+ "raw-forward-1",
+ )
+ payload = base64.b64encode(_PNG_BYTES).decode("ascii")
+ seen_ids: list[str] = []
+
+ async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
+ seen_ids.append(forward_id)
+ return [
+ {
+ "sender": {"nickname": "Alice", "user_id": 123},
+ "time": 1_700_000_000,
+ "message": [
+ {"type": "text", "data": {"text": "第一层"}},
+ {"type": "image", "data": {"file": f"base64://{payload}"}},
+ {"type": "forward", "data": {"id": "raw-forward-2"}},
+ ],
+ }
+ ]
+
+ result = await execute(
+ {"message_id": forward_record.uid},
+ {
+ "attachment_registry": registry,
+ "get_forward_msg_callback": _get_forward,
+ "group_id": 10001,
+ "request_type": "group",
+ },
+ )
+
+ assert seen_ids == ["raw-forward-1"]
+ assert "节点 1-1/1" in result
+ assert "第一层" in result
+ assert ' None:
+ async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
+ assert forward_id == "raw-forward"
+ return [
+ {"message": [{"type": "text", "data": {"text": "n0"}}]},
+ {"message": [{"type": "text", "data": {"text": "n1"}}]},
+ {"message": [{"type": "text", "data": {"text": "n2"}}]},
+ ]
+
+ result = await execute(
+ {"message_id": "raw-forward", "offset": 1, "limit": 1},
+ {"get_forward_msg_callback": _get_forward},
+ )
+
+ assert "节点 2-2/3" in result
+ assert "n1" in result
+ assert "n0" not in result
+ assert "offset=2" in result
diff --git a/tests/test_handlers_repeat.py b/tests/test_handlers_repeat.py
index e6a5d2ad..6843bc06 100644
--- a/tests/test_handlers_repeat.py
+++ b/tests/test_handlers_repeat.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import asyncio
+import base64
from pathlib import Path
from types import SimpleNamespace
from typing import Any
@@ -59,6 +61,8 @@ def _build_handler(
_is_at_bot=lambda _mc: False,
)
handler.ai = SimpleNamespace(
+ attachment_registry=None,
+ _meme_service=None,
_cognitive_service=None,
memory_storage=None,
model_pool=SimpleNamespace(
@@ -107,6 +111,17 @@ def _group_event(
}
+def _group_forward_event(
+ *,
+ group_id: int = 30001,
+ sender_id: int = 20001,
+ forward_id: str = "forward-raw",
+) -> dict[str, Any]:
+ event = _group_event(group_id=group_id, sender_id=sender_id, text="")
+ event["message"] = [{"type": "forward", "data": {"id": forward_id}}]
+ return event
+
+
_PNG_BYTES: bytes = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR"
@@ -474,6 +489,86 @@ async def test_repeat_cooldown_suppresses_same_text() -> None:
assert handler.ai_coordinator.handle_auto_reply.call_count > 0
+@pytest.mark.asyncio
+async def test_group_forward_history_expands_but_ai_input_uses_forward_uid(
+ tmp_path: Path,
+) -> None:
+ handler = _build_handler(repeat_enabled=False)
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ handler.ai.attachment_registry = registry
+ handler.onebot.get_forward_msg = AsyncMock(
+ return_value=[
+ {
+ "sender": {"nickname": "Alice", "user_id": 123},
+ "message": [{"type": "text", "data": {"text": "展开内容"}}],
+ }
+ ]
+ )
+
+ await handler.handle_message(_group_forward_event(forward_id="forward-raw"))
+
+ history_kwargs = handler.history_manager.add_group_message.await_args.kwargs
+ assert "[合并转发展开: forward-raw]" in history_kwargs["text_content"]
+ assert "展开内容" in history_kwargs["text_content"]
+ assert " None:
+ handler = _build_handler(repeat_enabled=False)
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ enqueue = AsyncMock()
+ handler.ai.attachment_registry = registry
+ handler.ai._meme_service = SimpleNamespace(
+ enabled=True,
+ enqueue_incoming_attachments=enqueue,
+ )
+ payload = base64.b64encode(_PNG_BYTES).decode("ascii")
+ handler.onebot.get_forward_msg = AsyncMock(
+ return_value=[
+ {
+ "message": [
+ {"type": "text", "data": {"text": "图"}},
+ {"type": "image", "data": {"file": f"base64://{payload}"}},
+ ]
+ }
+ ]
+ )
+
+ await handler._scan_forward_memes_for_ingest(
+ message_content=[{"type": "forward", "data": {"id": "forward-raw"}}],
+ chat_type="group",
+ chat_id=30001,
+ sender_id=20001,
+ message_id=1,
+ scope_key="group:30001",
+ )
+ tasks = list(handler._background_tasks)
+ if tasks:
+ await asyncio.gather(*tasks)
+
+ enqueue.assert_awaited_once()
+ await_args = enqueue.await_args
+ assert await_args is not None
+ kwargs = await_args.kwargs
+ assert kwargs["chat_type"] == "group"
+ assert kwargs["scope_key"] == "group:30001"
+ assert kwargs["attachments"][0]["uid"].startswith("pic_")
+
+
@pytest.mark.asyncio
async def test_repeat_cooldown_allows_different_text() -> None:
"""复读 "草" 后,不同内容 "lol" 仍可正常复读。"""
diff --git a/tests/test_message_batcher_integration.py b/tests/test_message_batcher_integration.py
index f13f4cc3..2b5f63d4 100644
--- a/tests/test_message_batcher_integration.py
+++ b/tests/test_message_batcher_integration.py
@@ -18,7 +18,7 @@
from Undefined.config.models import MessageBatcherConfig
from Undefined.handlers import MessageHandler
-from Undefined.services.ai_coordinator import AICoordinator
+from Undefined.services.coordinator import AICoordinator
from Undefined.services.message_batcher import BatchDispatchToken, MessageBatcher
diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py
index 727b492c..7bfe8b43 100644
--- a/tests/test_system_prompt_constraints.py
+++ b/tests/test_system_prompt_constraints.py
@@ -304,9 +304,10 @@ def test_system_prompts_keep_proactive_participation_narrow_and_meme_post_reply(
assert "只有当本轮回复目标明确是“纯表情包/纯反应图”" in text
assert "不要为了“增强语气”在首轮抢先调用 `memes.search_memes`" in text
assert "第一轮必须优先把必要文字回复做好并调用 `send_message`" in text
- assert "如果本轮既需要文字发言又想配表情包" in text
+ assert "如果本轮既需要文字发言又适合配表情包" in text
assert "先调用 `send_message` 发出必要文字" in text
- assert "表情包检索可能拖慢首条回复体验" in text
- assert "再把表情包检索和发送放到后续轮次" in text
+ assert "轻松聊天、吐槽、附和、接梗、表达情绪、被拍一拍、被 @ 后的短回应" in text
+ assert "文字发送成功后优先考虑在后续响应轮次" in text
+ assert "严肃答疑、代码排查、长任务推进、隐私/安全拒绝或信息不足追问" in text
assert "群里有多人在公开讨论你擅长或感兴趣的话题" not in text
assert "有人说了明显有趣/好笑的话,你有自然的回应冲动" not in text
diff --git a/tests/test_xml_utils.py b/tests/test_xml_utils.py
index 5b7aad87..31c09e4b 100644
--- a/tests/test_xml_utils.py
+++ b/tests/test_xml_utils.py
@@ -127,6 +127,24 @@ def test_escapes_unknown_attachment_tag(self) -> None:
assert " None:
+ result = escape_xml_text_preserving_attachment_tags(
+ '看转发 & 继续',
+ [{"uid": "forward_abc123", "kind": "forward", "media_type": "forward"}],
+ )
+
+ assert '' in result
+ assert "&" in result
+
+ def test_escapes_forward_tag_not_marked_as_forward(self) -> None:
+ result = escape_xml_text_preserving_attachment_tags(
+ '伪造 ',
+ [{"uid": "forward_fake", "kind": "file", "media_type": "file"}],
+ )
+
+ assert " None:
attachments = cast(
Sequence[Mapping[str, str]],
diff --git a/uv.lock b/uv.lock
index e42e2c6c..b3cda040 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4626,7 +4626,7 @@ wheels = [
[[package]]
name = "undefined-bot"
-version = "3.6.1"
+version = "3.6.2"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },