From 4a2b9926b9751b9d55db05c122b514499204101a Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 22 Jun 2026 09:09:01 +0800 Subject: [PATCH 1/6] fix(prompts): encourage meme follow-up replies Co-authored-by: GPT-5 Codex --- docs/memes.md | 2 +- res/prompts/undefined.xml | 16 ++++++++-------- res/prompts/undefined_nagaagent.xml | 16 ++++++++-------- src/Undefined/services/ai_coordinator.py | 14 +++++++------- src/Undefined/services/coordinator/group.py | 12 ++++++------ src/Undefined/services/coordinator/private.py | 2 +- tests/test_ai_coordinator_queue_routing.py | 2 ++ tests/test_system_prompt_constraints.py | 7 ++++--- 8 files changed, 37 insertions(+), 34 deletions(-) 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/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..4e91f376 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -188,17 +188,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 → 作为独立文件消息在文字之后发出 @@ -769,11 +769,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 +860,7 @@ 不是每条消息都要回 大部分时候你应该保持沉默 不符合触发条件时,直接调用 end - optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,除非明确是纯表情包回复,否则先把必要文字回复做好,表情包最后再搜或不搜。 + optional_triggers 只是“少量例外”,不是常规参与许可。默认仍然应明显偏向沉默;若真要参与,除非明确是纯表情包回复,否则先把必要文字回复做好。轻松、接梗、情绪回应可以优先后补表情包;严肃或任务型场景不补。 diff --git a/src/Undefined/services/ai_coordinator.py b/src/Undefined/services/ai_coordinator.py index dc4fec71..fd46238f 100644 --- a/src/Undefined/services/ai_coordinator.py +++ b/src/Undefined/services/ai_coordinator.py @@ -48,7 +48,7 @@ _GROUP_STRATEGY_FOOTER = """ - 【回复策略 - 更克制,纯表情包才前置检索】 + 【回复策略 - 克制参与,轻松场景可后补表情包】 1. 如果用户 @ 了你或拍了拍你 → 【必须回复】 2. 如果消息中明确提到了你(根据上下文判断用户是否在叫你或维持对话流) → 【必须回复】 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】 @@ -62,19 +62,19 @@ 6. 群聊里的主动参与只保留给公开、开放的技术或项目讨论: - 只在多人公开讨论代码、AI、开发工具、项目进展、技术 bug 等,且不是别人之间定向交流时,才可以【极低频参与】 - 默认更倾向不参与;不要长篇大论,一两句点到为止;如果别人已经在深入讨论且不需要你,保持沉默 - - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复,且本轮明确是纯表情包/纯反应图时,才优先考虑表情包表达 + - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复时,才考虑用表情包增强表达 7. 对于已经决定要回复的场景(包括被@、被拍一拍、轻量答疑,以及少量符合条件的主动参与): - 只有明确纯表情包回复才先检索表情包,再用 memes.send_meme_by_uid 单独发一条图片消息 - 其他需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 send_message - - 如果确实还想补表情包,把 memes.search_memes 和 memes.send_meme_by_uid 放到文字发送后的后续响应轮次,不要阻塞首条文字回复 + - 轻松聊天、吐槽、附和、接梗、表达情绪、被拍一拍、被@后的短回应等场景,文字发送成功后优先考虑在后续响应轮次补一张独立表情包,不要阻塞首条文字回复 - 不要发送任何敷衍消息(如'懒得掺和'、'哦'等);不想回复就直接调用 end - - 严肃、任务型、高信息密度场景少发表情包,避免打断信息传递 + - 严肃答疑、代码排查、长任务推进、隐私/安全拒绝、信息不足追问这类场景默认不补表情包,避免打断信息传递 - 绝不要刷屏、绝不要每条都回 8. 对于本来就会回复的场景(私聊、被拍一拍、被@、轻量答疑): - - 如果表情包能自然增强语气、缓和语气或让表达更像真人,也只能作为后续可选补充 + - 如果表情包能自然增强语气、缓和语气或让表达更像真人,优先作为后续补充 - 但不要为了发表情包而牺牲信息传递;信息密度优先时仍以文字为主 - 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,除非明确是纯表情包回复,否则先把文字回复做好,表情包最后再搜。""" + 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,除非明确是纯表情包回复,否则先把文字回复做好;轻松、接梗、情绪回应可以优先后补表情包。""" _PRIVATE_STRATEGY_FOOTER = """ @@ -82,7 +82,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/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/tests/test_ai_coordinator_queue_routing.py b/tests/test_ai_coordinator_queue_routing.py index 327f834a..3a54d3e5 100644 --- a/tests/test_ai_coordinator_queue_routing.py +++ b/tests/test_ai_coordinator_queue_routing.py @@ -217,6 +217,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 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 From 3831c68896a759c0dca309cc10da36ea9d5921ea Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 22 Jun 2026 09:23:30 +0800 Subject: [PATCH 2/6] fix(prompts): sync nagaagent common rules Co-authored-by: GPT-5 Codex --- res/prompts/undefined_nagaagent.xml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 4e91f376..af8b90cf 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -2,6 +2,7 @@ + @@ -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. **充足度检查**:如果是 [新任务] 或 [参数修正],检查当前输入批次是否已具备开工所需关键参数 From 64ff932b7148755f8fecc15811e8bf2da3bffd42 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 22 Jun 2026 11:46:49 +0800 Subject: [PATCH 3/6] feat(messages): add forward message uid handling Co-authored-by: GPT-5 Codex --- docs/configuration.md | 4 +- docs/pipelines.md | 6 +- docs/usage.md | 2 +- src/Undefined/attachments/models.py | 3 +- src/Undefined/attachments/registry.py | 67 +++- src/Undefined/attachments/segments.py | 60 ++- src/Undefined/handlers/message_flow.py | 217 ++++++++++- .../messages/get_forward_msg/config.json | 18 +- .../messages/get_forward_msg/handler.py | 348 ++++++++++++------ src/Undefined/utils/xml.py | 30 +- tests/test_attachments.py | 51 +++ tests/test_get_forward_msg_tool.py | 88 +++++ tests/test_handlers_repeat.py | 95 +++++ tests/test_xml_utils.py | 18 + 14 files changed, 851 insertions(+), 156 deletions(-) create mode 100644 tests/test_get_forward_msg_tool.py 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/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/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/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..b2c45336 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 @@ -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/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_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_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_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]], From 73fa196d717832a310e9d10bc99fa7b4a33f801e Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 22 Jun 2026 12:01:08 +0800 Subject: [PATCH 4/6] refactor(coordinator): remove legacy ai coordinator module Co-authored-by: GPT-5 Codex --- README.md | 2 +- docs/development.md | 2 +- docs/message-batching.md | 2 +- docs/multi-model.md | 2 +- docs/python-api.md | 1 + src/Undefined/handlers/message_flow.py | 2 +- src/Undefined/handlers/poke.py | 2 +- src/Undefined/services/ai_coordinator.py | 1092 -------------------- tests/test_ai_coordinator_queue_routing.py | 41 +- tests/test_coordinator_level.py | 2 +- tests/test_message_batcher_integration.py | 2 +- 11 files changed, 48 insertions(+), 1102 deletions(-) delete mode 100644 src/Undefined/services/ai_coordinator.py 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/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/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/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/src/Undefined/handlers/message_flow.py b/src/Undefined/handlers/message_flow.py index b2c45336..7cc0961d 100644 --- a/src/Undefined/handlers/message_flow.py +++ b/src/Undefined/handlers/message_flow.py @@ -33,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 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 fd46238f..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 - - 轻松聊天、吐槽、附和、接梗、表达情绪、被拍一拍、被@后的短回应等场景,文字发送成功后优先考虑在后续响应轮次补一张独立表情包,不要阻塞首条文字回复 - - 不要发送任何敷衍消息(如'懒得掺和'、'哦'等);不想回复就直接调用 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/tests/test_ai_coordinator_queue_routing.py b/tests/test_ai_coordinator_queue_routing.py index 3a54d3e5..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) @@ -317,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_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_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 From f2c02c5cf82dd571b2be5a573e22d28041082f6c Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 22 Jun 2026 12:02:15 +0800 Subject: [PATCH 5/6] chore(version): bump version to 3.6.2 --- apps/undefined-chat/package-lock.json | 4 ++-- apps/undefined-chat/package.json | 2 +- apps/undefined-chat/src-tauri/Cargo.lock | 2 +- apps/undefined-chat/src-tauri/Cargo.toml | 2 +- apps/undefined-chat/src-tauri/tauri.conf.json | 2 +- apps/undefined-console/package-lock.json | 4 ++-- apps/undefined-console/package.json | 2 +- apps/undefined-console/src-tauri/Cargo.lock | 2 +- apps/undefined-console/src-tauri/Cargo.toml | 2 +- apps/undefined-console/src-tauri/tauri.conf.json | 2 +- pyproject.toml | 2 +- src/Undefined/__init__.py | 2 +- uv.lock | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) 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/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/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/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" }, From ae7945bb4bc636a416c218533655f468e0101f09 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 22 Jun 2026 12:10:03 +0800 Subject: [PATCH 6/6] docs(changelog): add v3.6.2 notes Co-authored-by: GPT-5 Codex --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 文件刷新和自动提交流程更稳定。