feat(reply): 引入 reply 和 respond 动作,优化消息回复机制

- 增加 reply 动作,针对单条消息进行深度回复,使用 s4u 模板。
- 增加 respond 动作,统一回应未读消息,使用 normal 模板。
- 更新核心动作插件以支持新动作,确保配置选项可用。
- 优化动作执行逻辑,提升对话流畅性和响应准确性。
This commit is contained in:
Windpicker-owo
2025-11-10 13:24:45 +08:00
parent b27b2e4811
commit 90ed6964b3
9 changed files with 341 additions and 129 deletions

View File

@@ -75,16 +75,29 @@ class ChatterPlanFilter:
if "reply" in plan.available_actions and reply_not_available:
# 如果reply动作不可用但llm返回的仍然有reply则改为no_reply
if (
isinstance(parsed_json, dict)
and parsed_json.get("actions", {}).get("action_type", "") == "reply"
):
parsed_json["actions"]["action_type"] = "no_reply"
if isinstance(parsed_json, dict):
actions_obj = parsed_json.get("actions", {})
# actions 可能是字典或列表
if isinstance(actions_obj, dict) and actions_obj.get("action_type", "") == "reply":
parsed_json["actions"]["action_type"] = "no_reply"
elif isinstance(actions_obj, list):
for action_item in actions_obj:
if isinstance(action_item, dict) and action_item.get("action_type", "") == "reply":
action_item["action_type"] = "no_reply"
if "reason" in action_item:
action_item["reason"] += " (但由于兴趣度不足reply动作不可用已改为no_reply)"
elif isinstance(parsed_json, list):
for item in parsed_json:
if isinstance(item, dict) and item.get("actions", {}).get("action_type", "") == "reply":
item["actions"]["action_type"] = "no_reply"
item["actions"]["reason"] += " (但由于兴趣度不足reply动作不可用已改为no_reply)"
if isinstance(item, dict):
actions_obj = item.get("actions", {})
if isinstance(actions_obj, dict) and actions_obj.get("action_type", "") == "reply":
item["actions"]["action_type"] = "no_reply"
elif isinstance(actions_obj, list):
for action_item in actions_obj:
if isinstance(action_item, dict) and action_item.get("action_type", "") == "reply":
action_item["action_type"] = "no_reply"
if "reason" in action_item:
action_item["reason"] += " (但由于兴趣度不足reply动作不可用已改为no_reply)"
if isinstance(parsed_json, dict):
parsed_json = [parsed_json]

View File

@@ -333,16 +333,17 @@ class ChatterActionPlanner:
context.processing_message_id = target_message_id
logger.debug(f"Normal模式 - 开始处理目标消息: {target_message_id}")
# 4. 构建回复动作Normal模式的简化流程
# 4. 构建回复动作Normal模式使用respond动作
from src.common.data_models.info_data_model import ActionPlannerInfo, Plan
from src.plugin_system.base.component_types import ChatType
# 构建目标消息字典 - 使用 flatten() 方法获取扁平化的字典
target_message_dict = target_message.flatten()
reply_action = ActionPlannerInfo(
action_type="reply",
reasoning="Normal模式 - 兴趣度达到阈值,直接回复(简化流程)",
# Normal模式使用respond动作表示统一回应未读消息
respond_action = ActionPlannerInfo(
action_type="respond",
reasoning="Normal模式 - 兴趣度达到阈值使用respond动作统一回应未读消息",
action_data={"target_message_id": target_message.message_id},
action_message=target_message,
should_quote_reply=False, # Normal模式默认不引用回复保持对话流畅
@@ -354,14 +355,14 @@ class ChatterActionPlanner:
chat_id=self.chat_id,
chat_type=ChatType.PRIVATE if not context else context.chat_type,
mode=ChatMode.NORMAL,
decided_actions=[reply_action],
decided_actions=[respond_action],
)
# 5. 执行reply动作
# 5. 执行respond动作
execution_result = await self.executor.execute(minimal_plan)
self._update_stats_from_execution_result(execution_result)
logger.info("Normal模式 - 执行reply动作完成")
logger.info("Normal模式 - 执行respond动作完成")
# 6. 更新兴趣计算器状态(回复成功,重置不回复计数)
await self._update_interest_calculator_state(replied=True)
@@ -374,7 +375,7 @@ class ChatterActionPlanner:
# 8. 检查是否需要退出Normal模式
await self._check_exit_normal_mode(context)
return [asdict(reply_action)], target_message_dict
return [asdict(respond_action)], target_message_dict
else:
# 未达到reply阈值
logger.debug("Normal模式 - 未达到reply阈值不执行回复")

View File

@@ -43,29 +43,30 @@ def init_prompts():
# 目标
你的任务是根据当前对话,给出一个或多个动作,构成一次完整的响应组合。
- 主要动作:通常是 reply如需回复
- 主要动作:通常是 reply或respond(如需回复)。
- 辅助动作(可选):如 emoji、poke_user 等,用于增强表达。
# 决策流程
1. 已读仅供参考,不能对已读执行任何动作。
2. 目标消息必须来自未读历史,并使用其前缀 <m...> 作为 target_message_id。
3. **【重要】兴趣度优先原则**:每条未读消息后都标注了 [兴趣度: X.XXX],数值越高表示该消息越值得你关注和回复。在选择回复目标时,**应优先选择兴趣度高的消息**(通常 ≥0.5 表示较高兴趣),除非有特殊情况(如被直接@或提问)。
3. 兴趣度优先原则:每条未读消息后都标注了 [兴趣度: X.XXX],数值越高表示该消息越值得你关注和回复。在选择回复目标时,**应优先选择兴趣度高的消息**(通常 ≥0.5 表示较高兴趣),除非有特殊情况(如被直接@或提问)。
4. 优先级:
- 直接针对你:@你、回复你、点名提问、引用你的消息。
- **兴趣度高的消息**:兴趣度 ≥0.5 的消息应优先考虑回复。
- 与你强相关的话题或你熟悉的问题。
- 其他与上下文弱相关的内容最后考虑。
{mentioned_bonus}
4. 多目标:若多人同时需要回应,请在 actions 中并行生成多个 reply每个都指向各自的 target_message_id。
5. **【核心规则】处理无上下文的纯表情包**: 对不含任何实质文本、且无紧密上下文互动的纯**表情包**消息(如消息内容仅为“[表情包xxxxx]”),应默认选择 `no_action`。
6. **【!!!绝对禁止!!!】处理失败消息**: 绝不能回复任何指示媒体内容(图片、表情包等)处理失败的消息。如果消息中出现如“[表情包(描述生成失败)]”或“[图片(描述生成失败)]”等文字,必须将其视为系统错误提示,并立即选择`no_action`。
6. 风格:保持人设一致;避免重复你说过的话;避免冗余和口头禅
5. 多目标:若多人同时需要回应,请在 actions 中并行生成多个 reply每个都指向各自的 target_message_id。
6. 处理无上下文的纯表情包: 对不含任何实质文本、且无紧密上下文互动的纯**表情包**消息(如消息内容仅为“[表情包xxxxx]”),应默认选择 `no_action`。
7. 处理失败消息: 绝不能回复任何指示媒体内容(图片、表情包等)处理失败的消息。如果消息中出现如“[表情包(描述生成失败)]”或“[图片(描述生成失败)]”等文字,必须将其视为系统错误提示,并立即选择`no_action`。
8. 正确决定回复时机: 在决定reply或respond前务必评估当前对话氛围和上下文连贯性。避免在不合适的时机如对方情绪低落、话题不相关等,对方并没有和你对话,贸然插入会很令人讨厌等)进行回复,以免打断对话流或引起误解。如判断当前不适合回复,请选择`no_action`
9. 认清自己的身份和角色: 在规划回复时,务必确定对方是不是真的在叫自己。聊天时往往有数百甚至数千个用户,请务必认清自己的身份和角色,避免误以为对方在和自己对话而贸然插入回复,导致尴尬局面。
# 思绪流规范thinking
- 真实、自然、非结论化,像给自己看的随笔。
- 描述你看到/想到/感觉到的过程,不要出现"因此/我决定"等总结词。
- 直接使用对方昵称,而不是 <m1>/<m2> 这样的标签。
- **禁止出现"兴趣度、分数"等技术术语或内部实现细节**。兴趣度仅用于你内部的决策权重不要在thinking中提及而应该用自然语言描述你对消息的感受"这个话题挺有意思的""我对这个很感兴趣"等)。
- 禁止出现"兴趣度、分数"等技术术语或内部实现细节。兴趣度仅用于你内部的决策权重不要在thinking中提及而应该用自然语言描述你对消息的感受"这个话题挺有意思的""我对这个很感兴趣"等)。
## 可用动作列表
{action_options_text}
@@ -77,12 +78,10 @@ def init_prompts():
"thinking": "在这里写下你的思绪流...",
"actions": [
{{
"action_type": "reply",
"action_type": "respond",
"reasoning": "选择该动作的理由",
"action_data": {{
"target_message_id": "m123",
"content": "你的回复内容",
"should_quote_reply": false
}}
}}
]
@@ -116,21 +115,6 @@ def init_prompts():
}}
```
# 引用回复控制should_quote_reply
在群聊中回复消息时,你可以通过 `should_quote_reply` 参数控制是否引用原消息:
- **true**: 明确引用原消息(适用于需要明确指向特定消息时,如回答问题、回应多人之一、回复较早的消息)
- **false**: 不引用原消息(适用于自然对话流、接续最新话题、轻松闲聊等场景)
- **不填写**: 系统将自动决定(默认不引用,让对话更流畅)
**【重要】默认策略:大多数情况下应该使用 `false` 以保持对话自然流畅**
**使用建议**
- 当对话自然流畅、你的回复是接续最新话题时,**建议明确设为 `false`** 以避免打断对话节奏
- 当需要明确回复某个特定用户或特定问题时,设为 `true` 以帮助定位
- 当群聊中多人同时发言,你要回复其中一个较早的消息(非最新消息)时,设为 `true`
- 当有人直接@你或明确向你提问时,可以考虑设为 `true` 表明你在回复他
- 私聊场景**必须**设为 `false` 或不填(因为只有两个人,引用是多余的)
# 强制规则
- 需要目标消息的动作reply/poke_user/set_emoji_like 等),必须提供准确的 target_message_id来自未读历史里的 <m...> 标签)。
- 当动作需要额外参数时,必须在 action_data 中补全。

View File

@@ -0,0 +1,108 @@
"""
回复动作模块
定义了两种回复动作:
- reply: 针对单条消息的深度回复(使用 s4u 模板)
- respond: 对未读消息的统一回应(使用 normal 模板)
"""
from typing import ClassVar
from src.common.logger import get_logger
from src.plugin_system import ActionActivationType, BaseAction, ChatMode
logger = get_logger("reply_actions")
class ReplyAction(BaseAction):
"""Reply动作 - 针对单条消息的深度回复
特点:
- 使用 s4u (Speak for You) 模板
- 专注于理解和回应单条消息的具体内容
- 适合 Focus 模式下的精准回复
"""
# 动作基本信息
action_name = "reply"
action_description = "针对特定消息进行精准回复。深度理解并回应单条消息的具体内容。需要指定目标消息ID。"
# 激活设置
activation_type = ActionActivationType.ALWAYS # 回复动作总是可用
mode_enable = ChatMode.ALL # 在所有模式下都可用
parallel_action = False # 回复动作不能与其他动作并行
# 动作参数定义
action_parameters: ClassVar = {
"target_message_id": "要回复的目标消息ID必需来自未读消息的 <m...> 标签)",
"content": "回复的具体内容可选由LLM生成",
"should_quote_reply": "是否引用原消息可选true/false默认false。群聊中回复较早消息或需要明确指向时使用true",
}
# 动作使用场景
action_require: ClassVar = [
"需要针对特定消息进行精准回复时使用",
"适合单条消息的深度理解和回应",
"必须提供准确的 target_message_id来自未读历史的 <m...> 标签)",
"私聊场景必须使用此动作(不支持 respond",
"群聊中需要明确回应某个特定用户或问题时使用",
"关注单条消息的具体内容和上下文细节",
]
# 关联类型
associated_types: ClassVar[list[str]] = ["text"]
async def execute(self) -> tuple[bool, str]:
"""执行reply动作
注意:实际的回复生成由 action_manager 统一处理
这里只是标记使用 reply 动作s4u 模板)
"""
logger.info(f"{self.log_prefix} 使用 reply 动作s4u 模板)")
return True, ""
class RespondAction(BaseAction):
"""Respond动作 - 对未读消息的统一回应
特点:
- 关注整体对话动态和未读消息的统一回应
- 适合对于群聊消息下的宏观回应
- 避免与单一用户深度对话而忽略其他用户的消息
"""
# 动作基本信息
action_name = "respond"
action_description = "统一回应所有未读消息。理解整体对话动态和话题走向,生成连贯的回复。无需指定目标消息。"
# 激活设置
activation_type = ActionActivationType.ALWAYS # 回应动作总是可用
mode_enable = ChatMode.ALL # 在所有模式下都可用
parallel_action = False # 回应动作不能与其他动作并行
# 动作参数定义
action_parameters: ClassVar = {
"content": "回复的具体内容可选由LLM生成",
}
# 动作使用场景
action_require: ClassVar = [
"需要统一回应多条未读消息时使用Normal 模式专用)",
"适合理解整体对话动态而非单条消息",
"不需要指定 target_message_id会自动处理所有未读消息",
"关注对话流程、话题走向和整体氛围",
"适合群聊中的自然对话流,无需精确指向特定消息",
"可以同时回应多个话题或参与者",
]
# 关联类型
associated_types: ClassVar[list[str]] = ["text"]
async def execute(self) -> tuple[bool, str]:
"""执行respond动作
注意:实际的回复生成由 action_manager 统一处理
这里只是标记使用 respond 动作normal 模板)
"""
logger.info(f"{self.log_prefix} 使用 respond 动作normal 模板)")
return True, ""

View File

@@ -16,6 +16,7 @@ from src.plugin_system.base.config_types import ConfigField
# 导入API模块 - 标准Python包方式
from src.plugins.built_in.core_actions.emoji import EmojiAction
from src.plugins.built_in.core_actions.reply import ReplyAction, RespondAction
logger = get_logger("core_actions")
@@ -52,7 +53,8 @@ class CoreActionsPlugin(BasePlugin):
"config_version": ConfigField(type=str, default="0.6.0", description="配置文件版本"),
},
"components": {
"enable_reply": ConfigField(type=bool, default=True, description="是否启用基本回复动作"),
"enable_reply": ConfigField(type=bool, default=True, description="是否启用 reply 动作s4u模板"),
"enable_respond": ConfigField(type=bool, default=True, description="是否启用 respond 动作normal模板"),
"enable_emoji": ConfigField(type=bool, default=True, description="是否启用发送表情/图片动作"),
},
}
@@ -62,6 +64,16 @@ class CoreActionsPlugin(BasePlugin):
# --- 根据配置注册组件 ---
components: ClassVar = []
# 注册 reply 动作
if self.get_config("components.enable_reply", True):
components.append((ReplyAction.get_action_info(), ReplyAction))
# 注册 respond 动作
if self.get_config("components.enable_respond", True):
components.append((RespondAction.get_action_info(), RespondAction))
# 注册 emoji 动作
if self.get_config("components.enable_emoji", True):
components.append((EmojiAction.get_action_info(), EmojiAction))