diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 98d846ac..f95b6c9b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -71,7 +71,6 @@ 1. **GitHub Issues**: 对于公开的违规行为,可以在相关issue中直接指出 2. **私下联系**: 可以通过GitHub私信联系项目维护者 -3. **邮件联系**: [如果有项目邮箱地址,请在此提供] 所有报告都将得到及时和公正的处理。我们承诺保护报告者的隐私和安全。 diff --git a/changelogs/changelog.md b/changelogs/changelog.md index c12e726b..690f797d 100644 --- a/changelogs/changelog.md +++ b/changelogs/changelog.md @@ -1,5 +1,11 @@ # Changelog +## [0.11.7] - 2025-12-2 +- 增加麦麦做梦功能 + +- 添加全局记忆配置项 + + ## [0.11.6] - 2025-12-2 ### 🌟 重大更新 - 大幅提高记忆检索能力,略微提高token消耗 diff --git a/docker-compose.yml b/docker-compose.yml index 217ae187..1161e7d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: # image: infinitycat/maibot:dev environment: - TZ=Asia/Shanghai -# - EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d # 同意EULA +# - EULA_AGREE=99f08e0cab0190de853cb6af7d64d4de # 同意EULA # - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 # 同意EULA ports: - "18001:8001" # webui端口 diff --git a/dummy b/dummy new file mode 100644 index 00000000..5cae3661 --- /dev/null +++ b/dummy @@ -0,0 +1,10 @@ +{ + "cells": [], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fb613e19..00f91cf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "json-repair>=0.47.6", "maim-message", "matplotlib>=3.10.3", + "msgpack>=1.1.2", "numpy>=2.2.6", "openai>=1.95.0", "pandas>=2.3.1", @@ -23,6 +24,7 @@ dependencies = [ "pydantic>=2.11.7", "pypinyin>=0.54.0", "python-dotenv>=1.1.1", + "python-multipart>=0.0.20", "quick-algo>=0.1.3", "rich>=14.0.0", "ruff>=0.12.2", @@ -32,6 +34,7 @@ dependencies = [ "tomlkit>=0.13.3", "urllib3>=2.5.0", "uvicorn>=0.35.0", + "zstandard>=0.25.0", ] diff --git a/src/chat/brain_chat/PFC/action_planner.py b/src/chat/brain_chat/PFC/action_planner.py new file mode 100644 index 00000000..4770c6ce --- /dev/null +++ b/src/chat/brain_chat/PFC/action_planner.py @@ -0,0 +1,491 @@ +import time +from typing import Tuple, Optional # 增加了 Optional +from src.common.logger_manager import get_logger +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from .chat_observer import ChatObserver +from .pfc_utils import get_items_from_json +from src.individuality.individuality import Individuality +from .observation_info import ObservationInfo +from .conversation_info import ConversationInfo +from src.plugins.utils.chat_message_builder import build_readable_messages + + +logger = get_logger("pfc_action_planner") + + +# --- 定义 Prompt 模板 --- + +# Prompt(1): 首次回复或非连续回复时的决策 Prompt +PROMPT_INITIAL_REPLY = """{persona_text}。现在你在参与一场QQ私聊,请根据以下【所有信息】审慎且灵活的决策下一步行动,可以回复,可以倾听,可以调取知识,甚至可以屏蔽对方: + +【当前对话目标】 +{goals_str} +{knowledge_info_str} + +【最近行动历史概要】 +{action_history_summary} +【上一次行动的详细情况和结果】 +{last_action_context} +【时间和超时提示】 +{time_since_last_bot_message_info}{timeout_context} +【最近的对话记录】(包括你已成功发送的消息 和 新收到的消息) +{chat_history_text} + +------ +可选行动类型以及解释: +fetch_knowledge: 需要调取知识或记忆,当需要专业知识或特定信息时选择,对方若提到你不太认识的人名或实体也可以尝试选择 +listening: 倾听对方发言,当你认为对方话才说到一半,发言明显未结束时选择 +direct_reply: 直接回复对方 +rethink_goal: 思考一个对话目标,当你觉得目前对话需要目标,或当前目标不再适用,或话题卡住时选择。注意私聊的环境是灵活的,有可能需要经常选择 +end_conversation: 结束对话,对方长时间没回复或者当你觉得对话告一段落时可以选择 +block_and_ignore: 更加极端的结束对话方式,直接结束对话并在一段时间内无视对方所有发言(屏蔽),当对话让你感到十分不适,或你遭到各类骚扰时选择 + +请以JSON格式输出你的决策: +{{ + "action": "选择的行动类型 (必须是上面列表中的一个)", + "reason": "选择该行动的详细原因 (必须有解释你是如何根据“上一次行动结果”、“对话记录”和自身设定人设做出合理判断的)" +}} + +注意:请严格按照JSON格式输出,不要包含任何其他内容。""" + +# Prompt(2): 上一次成功回复后,决定继续发言时的决策 Prompt +PROMPT_FOLLOW_UP = """{persona_text}。现在你在参与一场QQ私聊,刚刚你已经回复了对方,请根据以下【所有信息】审慎且灵活的决策下一步行动,可以继续发送新消息,可以等待,可以倾听,可以调取知识,甚至可以屏蔽对方: + +【当前对话目标】 +{goals_str} +{knowledge_info_str} + +【最近行动历史概要】 +{action_history_summary} +【上一次行动的详细情况和结果】 +{last_action_context} +【时间和超时提示】 +{time_since_last_bot_message_info}{timeout_context} +【最近的对话记录】(包括你已成功发送的消息 和 新收到的消息) +{chat_history_text} + +------ +可选行动类型以及解释: +fetch_knowledge: 需要调取知识,当需要专业知识或特定信息时选择,对方若提到你不太认识的人名或实体也可以尝试选择 +wait: 暂时不说话,留给对方交互空间,等待对方回复(尤其是在你刚发言后、或上次发言因重复、发言过多被拒时、或不确定做什么时,这是不错的选择) +listening: 倾听对方发言(虽然你刚发过言,但如果对方立刻回复且明显话没说完,可以选择这个) +send_new_message: 发送一条新消息继续对话,允许适当的追问、补充、深入话题,或开启相关新话题。**但是避免在因重复被拒后立即使用,也不要在对方没有回复的情况下过多的“消息轰炸”或重复发言** +rethink_goal: 思考一个对话目标,当你觉得目前对话需要目标,或当前目标不再适用,或话题卡住时选择。注意私聊的环境是灵活的,有可能需要经常选择 +end_conversation: 结束对话,对方长时间没回复或者当你觉得对话告一段落时可以选择 +block_and_ignore: 更加极端的结束对话方式,直接结束对话并在一段时间内无视对方所有发言(屏蔽),当对话让你感到十分不适,或你遭到各类骚扰时选择 + +请以JSON格式输出你的决策: +{{ + "action": "选择的行动类型 (必须是上面列表中的一个)", + "reason": "选择该行动的详细原因 (必须有解释你是如何根据“上一次行动结果”、“对话记录”和自身设定人设做出合理判断的。请说明你为什么选择继续发言而不是等待,以及打算发送什么类型的新消息连续发言,必须记录已经发言了几次)" +}} + +注意:请严格按照JSON格式输出,不要包含任何其他内容。""" + +# 新增:Prompt(3): 决定是否在结束对话前发送告别语 +PROMPT_END_DECISION = """{persona_text}。刚刚你决定结束一场 QQ 私聊。 + +【你们之前的聊天记录】 +{chat_history_text} + +你觉得你们的对话已经完整结束了吗?有时候,在对话自然结束后再说点什么可能会有点奇怪,但有时也可能需要一条简短的消息来圆满结束。 +如果觉得确实有必要再发一条简短、自然、符合你人设的告别消息(比如 "好,下次再聊~" 或 "嗯,先这样吧"),就输出 "yes"。 +如果觉得当前状态下直接结束对话更好,没有必要再发消息,就输出 "no"。 + +请以 JSON 格式输出你的选择: +{{ + "say_bye": "yes/no", + "reason": "选择 yes 或 no 的原因和内心想法 (简要说明)" +}} + +注意:请严格按照 JSON 格式输出,不要包含任何其他内容。""" + + +# ActionPlanner 类定义,顶格 +class ActionPlanner: + """行动规划器""" + + def __init__(self, stream_id: str, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_PFC_action_planner, + temperature=global_config.llm_PFC_action_planner["temp"], + max_tokens=1500, + request_type="action_planning", + ) + self.personality_info = Individuality.get_instance().get_prompt(x_person=2, level=3) + self.name = global_config.BOT_NICKNAME + self.private_name = private_name + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + # self.action_planner_info = ActionPlannerInfo() # 移除未使用的变量 + + # 修改 plan 方法签名,增加 last_successful_reply_action 参数 + async def plan( + self, + observation_info: ObservationInfo, + conversation_info: ConversationInfo, + last_successful_reply_action: Optional[str], + ) -> Tuple[str, str]: + """规划下一步行动 + + Args: + observation_info: 决策信息 + conversation_info: 对话信息 + last_successful_reply_action: 上一次成功的回复动作类型 ('direct_reply' 或 'send_new_message' 或 None) + + Returns: + Tuple[str, str]: (行动类型, 行动原因) + """ + # --- 获取 Bot 上次发言时间信息 --- + # (这部分逻辑不变) + time_since_last_bot_message_info = "" + try: + bot_id = str(global_config.BOT_QQ) + if hasattr(observation_info, "chat_history") and observation_info.chat_history: + for i in range(len(observation_info.chat_history) - 1, -1, -1): + msg = observation_info.chat_history[i] + if not isinstance(msg, dict): + continue + sender_info = msg.get("user_info", {}) + sender_id = str(sender_info.get("user_id")) if isinstance(sender_info, dict) else None + msg_time = msg.get("time") + if sender_id == bot_id and msg_time: + time_diff = time.time() - msg_time + if time_diff < 60.0: + time_since_last_bot_message_info = ( + f"提示:你上一条成功发送的消息是在 {time_diff:.1f} 秒前。\n" + ) + break + else: + logger.debug( + f"[私聊][{self.private_name}]Observation info chat history is empty or not available for bot time check." + ) + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo object might not have chat_history attribute yet for bot time check." + ) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]获取 Bot 上次发言时间时出错: {e}") + + # --- 获取超时提示信息 --- + # (这部分逻辑不变) + timeout_context = "" + try: + if hasattr(conversation_info, "goal_list") and conversation_info.goal_list: + last_goal_dict = conversation_info.goal_list[-1] + if isinstance(last_goal_dict, dict) and "goal" in last_goal_dict: + last_goal_text = last_goal_dict["goal"] + if isinstance(last_goal_text, str) and "分钟,思考接下来要做什么" in last_goal_text: + try: + timeout_minutes_text = last_goal_text.split(",")[0].replace("你等待了", "") + timeout_context = f"重要提示:对方已经长时间({timeout_minutes_text})没有回复你的消息了(这可能代表对方繁忙/不想回复/没注意到你的消息等情况,或在对方看来本次聊天已告一段落),请基于此情况规划下一步。\n" + except Exception: + timeout_context = "重要提示:对方已经长时间没有回复你的消息了(这可能代表对方繁忙/不想回复/没注意到你的消息等情况,或在对方看来本次聊天已告一段落),请基于此情况规划下一步。\n" + else: + logger.debug( + f"[私聊][{self.private_name}]Conversation info goal_list is empty or not available for timeout check." + ) + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ConversationInfo object might not have goal_list attribute yet for timeout check." + ) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]检查超时目标时出错: {e}") + + # --- 构建通用 Prompt 参数 --- + logger.debug( + f"[私聊][{self.private_name}]开始规划行动:当前目标: {getattr(conversation_info, 'goal_list', '不可用')}" + ) + + # 构建对话目标 (goals_str) + goals_str = "" + try: + if hasattr(conversation_info, "goal_list") and conversation_info.goal_list: + for goal_reason in conversation_info.goal_list: + if isinstance(goal_reason, dict): + goal = goal_reason.get("goal", "目标内容缺失") + reasoning = goal_reason.get("reasoning", "没有明确原因") + else: + goal = str(goal_reason) + reasoning = "没有明确原因" + + goal = str(goal) if goal is not None else "目标内容缺失" + reasoning = str(reasoning) if reasoning is not None else "没有明确原因" + goals_str += f"- 目标:{goal}\n 原因:{reasoning}\n" + + if not goals_str: + goals_str = "- 目前没有明确对话目标,请考虑设定一个。\n" + else: + goals_str = "- 目前没有明确对话目标,请考虑设定一个。\n" + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ConversationInfo object might not have goal_list attribute yet." + ) + goals_str = "- 获取对话目标时出错。\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}]构建对话目标字符串时出错: {e}") + goals_str = "- 构建对话目标时出错。\n" + + # --- 知识信息字符串构建开始 --- + knowledge_info_str = "【已获取的相关知识和记忆】\n" + try: + # 检查 conversation_info 是否有 knowledge_list 并且不为空 + if hasattr(conversation_info, "knowledge_list") and conversation_info.knowledge_list: + # 最多只显示最近的 5 条知识,防止 Prompt 过长 + recent_knowledge = conversation_info.knowledge_list[-5:] + for i, knowledge_item in enumerate(recent_knowledge): + if isinstance(knowledge_item, dict): + query = knowledge_item.get("query", "未知查询") + knowledge = knowledge_item.get("knowledge", "无知识内容") + source = knowledge_item.get("source", "未知来源") + # 只取知识内容的前 2000 个字,避免太长 + knowledge_snippet = knowledge[:2000] + "..." if len(knowledge) > 2000 else knowledge + knowledge_info_str += ( + f"{i + 1}. 关于 '{query}' 的知识 (来源: {source}):\n {knowledge_snippet}\n" + ) + else: + # 处理列表里不是字典的异常情况 + knowledge_info_str += f"{i + 1}. 发现一条格式不正确的知识记录。\n" + + if not recent_knowledge: # 如果 knowledge_list 存在但为空 + knowledge_info_str += "- 暂无相关知识和记忆。\n" + + else: + # 如果 conversation_info 没有 knowledge_list 属性,或者列表为空 + knowledge_info_str += "- 暂无相关知识记忆。\n" + except AttributeError: + logger.warning(f"[私聊][{self.private_name}]ConversationInfo 对象可能缺少 knowledge_list 属性。") + knowledge_info_str += "- 获取知识列表时出错。\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}]构建知识信息字符串时出错: {e}") + knowledge_info_str += "- 处理知识列表时出错。\n" + # --- 知识信息字符串构建结束 --- + + # 获取聊天历史记录 (chat_history_text) + try: + if hasattr(observation_info, "chat_history") and observation_info.chat_history: + chat_history_text = observation_info.chat_history_str + if not chat_history_text: + chat_history_text = "还没有聊天记录。\n" + else: + chat_history_text = "还没有聊天记录。\n" + + if hasattr(observation_info, "new_messages_count") and observation_info.new_messages_count > 0: + if hasattr(observation_info, "unprocessed_messages") and observation_info.unprocessed_messages: + new_messages_list = observation_info.unprocessed_messages + new_messages_str = await build_readable_messages( + new_messages_list, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + chat_history_text += ( + f"\n--- 以下是 {observation_info.new_messages_count} 条新消息 ---\n{new_messages_str}" + ) + else: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo has new_messages_count > 0 but unprocessed_messages is empty or missing." + ) + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo object might be missing expected attributes for chat history." + ) + chat_history_text = "获取聊天记录时出错。\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}]处理聊天记录时发生未知错误: {e}") + chat_history_text = "处理聊天记录时出错。\n" + + # 构建 Persona 文本 (persona_text) + persona_text = f"你的名字是{self.name},{self.personality_info}。" + + # 构建行动历史和上一次行动结果 (action_history_summary, last_action_context) + # (这部分逻辑不变) + action_history_summary = "你最近执行的行动历史:\n" + last_action_context = "关于你【上一次尝试】的行动:\n" + action_history_list = [] + try: + if hasattr(conversation_info, "done_action") and conversation_info.done_action: + action_history_list = conversation_info.done_action[-5:] + else: + logger.debug(f"[私聊][{self.private_name}]Conversation info done_action is empty or not available.") + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ConversationInfo object might not have done_action attribute yet." + ) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]访问行动历史时出错: {e}") + + if not action_history_list: + action_history_summary += "- 还没有执行过行动。\n" + last_action_context += "- 这是你规划的第一个行动。\n" + else: + for i, action_data in enumerate(action_history_list): + action_type = "未知" + plan_reason = "未知" + status = "未知" + final_reason = "" + action_time = "" + + if isinstance(action_data, dict): + action_type = action_data.get("action", "未知") + plan_reason = action_data.get("plan_reason", "未知规划原因") + status = action_data.get("status", "未知") + final_reason = action_data.get("final_reason", "") + action_time = action_data.get("time", "") + elif isinstance(action_data, tuple): + # 假设旧格式兼容 + if len(action_data) > 0: + action_type = action_data[0] + if len(action_data) > 1: + plan_reason = action_data[1] # 可能是规划原因或最终原因 + if len(action_data) > 2: + status = action_data[2] + if status == "recall" and len(action_data) > 3: + final_reason = action_data[3] + elif status == "done" and action_type in ["direct_reply", "send_new_message"]: + plan_reason = "成功发送" # 简化显示 + + reason_text = f", 失败/取消原因: {final_reason}" if final_reason else "" + summary_line = f"- 时间:{action_time}, 尝试行动:'{action_type}', 状态:{status}{reason_text}" + action_history_summary += summary_line + "\n" + + if i == len(action_history_list) - 1: + last_action_context += f"- 上次【规划】的行动是: '{action_type}'\n" + last_action_context += f"- 当时规划的【原因】是: {plan_reason}\n" + if status == "done": + last_action_context += "- 该行动已【成功执行】。\n" + # 记录这次成功的行动类型,供下次决策 + # self.last_successful_action_type = action_type # 不在这里记录,由 conversation 控制 + elif status == "recall": + last_action_context += "- 但该行动最终【未能执行/被取消】。\n" + if final_reason: + last_action_context += f"- 【重要】失败/取消的具体原因是: “{final_reason}”\n" + else: + last_action_context += "- 【重要】失败/取消原因未明确记录。\n" + # self.last_successful_action_type = None # 行动失败,清除记录 + else: + last_action_context += f"- 该行动当前状态: {status}\n" + # self.last_successful_action_type = None # 非完成状态,清除记录 + + # --- 选择 Prompt --- + if last_successful_reply_action in ["direct_reply", "send_new_message"]: + prompt_template = PROMPT_FOLLOW_UP + logger.debug(f"[私聊][{self.private_name}]使用 PROMPT_FOLLOW_UP (追问决策)") + else: + prompt_template = PROMPT_INITIAL_REPLY + logger.debug(f"[私聊][{self.private_name}]使用 PROMPT_INITIAL_REPLY (首次/非连续回复决策)") + + # --- 格式化最终的 Prompt --- + prompt = prompt_template.format( + persona_text=persona_text, + goals_str=goals_str if goals_str.strip() else "- 目前没有明确对话目标,请考虑设定一个。", + action_history_summary=action_history_summary, + last_action_context=last_action_context, + time_since_last_bot_message_info=time_since_last_bot_message_info, + timeout_context=timeout_context, + chat_history_text=chat_history_text if chat_history_text.strip() else "还没有聊天记录。", + knowledge_info_str=knowledge_info_str, + ) + + logger.debug(f"[私聊][{self.private_name}]发送到LLM的最终提示词:\n------\n{prompt}\n------") + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]LLM (行动规划) 原始返回内容: {content}") + + # --- 初始行动规划解析 --- + success, initial_result = get_items_from_json( + content, + self.private_name, + "action", + "reason", + default_values={"action": "wait", "reason": "LLM返回格式错误或未提供原因,默认等待"}, + ) + + initial_action = initial_result.get("action", "wait") + initial_reason = initial_result.get("reason", "LLM未提供原因,默认等待") + + # 检查是否需要进行结束对话决策 --- + if initial_action == "end_conversation": + logger.info(f"[私聊][{self.private_name}]初步规划结束对话,进入告别决策...") + + # 使用新的 PROMPT_END_DECISION + end_decision_prompt = PROMPT_END_DECISION.format( + persona_text=persona_text, # 复用之前的 persona_text + chat_history_text=chat_history_text, # 复用之前的 chat_history_text + ) + + logger.debug( + f"[私聊][{self.private_name}]发送到LLM的结束决策提示词:\n------\n{end_decision_prompt}\n------" + ) + try: + end_content, _ = await self.llm.generate_response_async(end_decision_prompt) # 再次调用LLM + logger.debug(f"[私聊][{self.private_name}]LLM (结束决策) 原始返回内容: {end_content}") + + # 解析结束决策的JSON + end_success, end_result = get_items_from_json( + end_content, + self.private_name, + "say_bye", + "reason", + default_values={"say_bye": "no", "reason": "结束决策LLM返回格式错误,默认不告别"}, + required_types={"say_bye": str, "reason": str}, # 明确类型 + ) + + say_bye_decision = end_result.get("say_bye", "no").lower() # 转小写方便比较 + end_decision_reason = end_result.get("reason", "未提供原因") + + if end_success and say_bye_decision == "yes": + # 决定要告别,返回新的 'say_goodbye' 动作 + logger.info( + f"[私聊][{self.private_name}]结束决策: yes, 准备生成告别语. 原因: {end_decision_reason}" + ) + # 注意:这里的 reason 可以考虑拼接初始原因和结束决策原因,或者只用结束决策原因 + final_action = "say_goodbye" + final_reason = f"决定发送告别语。决策原因: {end_decision_reason} (原结束理由: {initial_reason})" + return final_action, final_reason + else: + # 决定不告别 (包括解析失败或明确说no) + logger.info( + f"[私聊][{self.private_name}]结束决策: no, 直接结束对话. 原因: {end_decision_reason}" + ) + # 返回原始的 'end_conversation' 动作 + final_action = "end_conversation" + final_reason = initial_reason # 保持原始的结束理由 + return final_action, final_reason + + except Exception as end_e: + logger.error(f"[私聊][{self.private_name}]调用结束决策LLM或处理结果时出错: {str(end_e)}") + # 出错时,默认执行原始的结束对话 + logger.warning(f"[私聊][{self.private_name}]结束决策出错,将按原计划执行 end_conversation") + return "end_conversation", initial_reason # 返回原始动作和原因 + + else: + action = initial_action + reason = initial_reason + + # 验证action类型 (保持不变) + valid_actions = [ + "direct_reply", + "send_new_message", + "fetch_knowledge", + "wait", + "listening", + "rethink_goal", + "end_conversation", # 仍然需要验证,因为可能从上面决策后返回 + "block_and_ignore", + "say_goodbye", # 也要验证这个新动作 + ] + if action not in valid_actions: + logger.warning(f"[私聊][{self.private_name}]LLM返回了未知的行动类型: '{action}',强制改为 wait") + reason = f"(原始行动'{action}'无效,已强制改为wait) {reason}" + action = "wait" + + logger.info(f"[私聊][{self.private_name}]规划的行动: {action}") + logger.info(f"[私聊][{self.private_name}]行动原因: {reason}") + return action, reason + + except Exception as e: + # 外层异常处理保持不变 + logger.error(f"[私聊][{self.private_name}]规划行动时调用 LLM 或处理结果出错: {str(e)}") + return "wait", f"行动规划处理中发生错误,暂时等待: {str(e)}" diff --git a/src/chat/brain_chat/PFC/chat_observer.py b/src/chat/brain_chat/PFC/chat_observer.py new file mode 100644 index 00000000..22cbf27d --- /dev/null +++ b/src/chat/brain_chat/PFC/chat_observer.py @@ -0,0 +1,379 @@ +import time +import asyncio +import traceback +from typing import Optional, Dict, Any, List +from src.common.logger import get_module_logger +from maim_message import UserInfo +from ...config.config import global_config +from .chat_states import NotificationManager, create_new_message_notification, create_cold_chat_notification +from .message_storage import MongoDBMessageStorage +from rich.traceback import install + +install(extra_lines=3) + +logger = get_module_logger("chat_observer") + + +class ChatObserver: + """聊天状态观察器""" + + # 类级别的实例管理 + _instances: Dict[str, "ChatObserver"] = {} + + @classmethod + def get_instance(cls, stream_id: str, private_name: str) -> "ChatObserver": + """获取或创建观察器实例 + + Args: + stream_id: 聊天流ID + private_name: 私聊名称 + + Returns: + ChatObserver: 观察器实例 + """ + if stream_id not in cls._instances: + cls._instances[stream_id] = cls(stream_id, private_name) + return cls._instances[stream_id] + + def __init__(self, stream_id: str, private_name: str): + """初始化观察器 + + Args: + stream_id: 聊天流ID + """ + self.last_check_time = None + self.last_bot_speak_time = None + self.last_user_speak_time = None + if stream_id in self._instances: + raise RuntimeError(f"ChatObserver for {stream_id} already exists. Use get_instance() instead.") + + self.stream_id = stream_id + self.private_name = private_name + self.message_storage = MongoDBMessageStorage() + + # self.last_user_speak_time: Optional[float] = None # 对方上次发言时间 + # self.last_bot_speak_time: Optional[float] = None # 机器人上次发言时间 + # self.last_check_time: float = time.time() # 上次查看聊天记录时间 + self.last_message_read: Optional[Dict[str, Any]] = None # 最后读取的消息ID + self.last_message_time: float = time.time() + + self.waiting_start_time: float = time.time() # 等待开始时间,初始化为当前时间 + + # 运行状态 + self._running: bool = False + self._task: Optional[asyncio.Task] = None + self._update_event = asyncio.Event() # 触发更新的事件 + self._update_complete = asyncio.Event() # 更新完成的事件 + + # 通知管理器 + self.notification_manager = NotificationManager() + + # 冷场检查配置 + self.cold_chat_threshold: float = 60.0 # 60秒无消息判定为冷场 + self.last_cold_chat_check: float = time.time() + self.is_cold_chat_state: bool = False + + self.update_event = asyncio.Event() + self.update_interval = 2 # 更新间隔(秒) + self.message_cache = [] + self.update_running = False + + async def check(self) -> bool: + """检查距离上一次观察之后是否有了新消息 + + Returns: + bool: 是否有新消息 + """ + logger.debug(f"[私聊][{self.private_name}]检查距离上一次观察之后是否有了新消息: {self.last_check_time}") + + new_message_exists = await self.message_storage.has_new_messages(self.stream_id, self.last_check_time) + + if new_message_exists: + logger.debug(f"[私聊][{self.private_name}]发现新消息") + self.last_check_time = time.time() + + return new_message_exists + + async def _add_message_to_history(self, message: Dict[str, Any]): + """添加消息到历史记录并发送通知 + + Args: + message: 消息数据 + """ + try: + # 发送新消息通知 + notification = create_new_message_notification( + sender="chat_observer", target="observation_info", message=message + ) + # print(self.notification_manager) + await self.notification_manager.send_notification(notification) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]添加消息到历史记录时出错: {e}") + print(traceback.format_exc()) + + # 检查并更新冷场状态 + await self._check_cold_chat() + + async def _check_cold_chat(self): + """检查是否处于冷场状态并发送通知""" + current_time = time.time() + + # 每10秒检查一次冷场状态 + if current_time - self.last_cold_chat_check < 10: + return + + self.last_cold_chat_check = current_time + + # 判断是否冷场 + is_cold = ( + True + if self.last_message_time is None + else (current_time - self.last_message_time) > self.cold_chat_threshold + ) + + # 如果冷场状态发生变化,发送通知 + if is_cold != self.is_cold_chat_state: + self.is_cold_chat_state = is_cold + notification = create_cold_chat_notification(sender="chat_observer", target="pfc", is_cold=is_cold) + await self.notification_manager.send_notification(notification) + + def new_message_after(self, time_point: float) -> bool: + """判断是否在指定时间点后有新消息 + + Args: + time_point: 时间戳 + + Returns: + bool: 是否有新消息 + """ + + if self.last_message_time is None: + logger.debug(f"[私聊][{self.private_name}]没有最后消息时间,返回 False") + return False + + has_new = self.last_message_time > time_point + logger.debug( + f"[私聊][{self.private_name}]判断是否在指定时间点后有新消息: {self.last_message_time} > {time_point} = {has_new}" + ) + return has_new + + def get_message_history( + self, + start_time: Optional[float] = None, + end_time: Optional[float] = None, + limit: Optional[int] = None, + user_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """获取消息历史 + + Args: + start_time: 开始时间戳 + end_time: 结束时间戳 + limit: 限制返回消息数量 + user_id: 指定用户ID + + Returns: + List[Dict[str, Any]]: 消息列表 + """ + filtered_messages = self.message_history + + if start_time is not None: + filtered_messages = [m for m in filtered_messages if m["time"] >= start_time] + + if end_time is not None: + filtered_messages = [m for m in filtered_messages if m["time"] <= end_time] + + if user_id is not None: + filtered_messages = [ + m for m in filtered_messages if UserInfo.from_dict(m.get("user_info", {})).user_id == user_id + ] + + if limit is not None: + filtered_messages = filtered_messages[-limit:] + + return filtered_messages + + async def _fetch_new_messages(self) -> List[Dict[str, Any]]: + """获取新消息 + + Returns: + List[Dict[str, Any]]: 新消息列表 + """ + new_messages = await self.message_storage.get_messages_after(self.stream_id, self.last_message_time) + + if new_messages: + self.last_message_read = new_messages[-1] + self.last_message_time = new_messages[-1]["time"] + + # print(f"获取数据库中找到的新消息: {new_messages}") + + return new_messages + + async def _fetch_new_messages_before(self, time_point: float) -> List[Dict[str, Any]]: + """获取指定时间点之前的消息 + + Args: + time_point: 时间戳 + + Returns: + List[Dict[str, Any]]: 最多5条消息 + """ + new_messages = await self.message_storage.get_messages_before(self.stream_id, time_point) + + if new_messages: + self.last_message_read = new_messages[-1]["message_id"] + + logger.debug(f"[私聊][{self.private_name}]获取指定时间点111之前的消息: {new_messages}") + + return new_messages + + """主要观察循环""" + + async def _update_loop(self): + """更新循环""" + # try: + # start_time = time.time() + # messages = await self._fetch_new_messages_before(start_time) + # for message in messages: + # await self._add_message_to_history(message) + # logger.debug(f"[私聊][{self.private_name}]缓冲消息: {messages}") + # except Exception as e: + # logger.error(f"[私聊][{self.private_name}]缓冲消息出错: {e}") + + while self._running: + try: + # 等待事件或超时(1秒) + try: + # print("等待事件") + await asyncio.wait_for(self._update_event.wait(), timeout=1) + + except asyncio.TimeoutError: + # print("超时") + pass # 超时后也执行一次检查 + + self._update_event.clear() # 重置触发事件 + self._update_complete.clear() # 重置完成事件 + + # 获取新消息 + new_messages = await self._fetch_new_messages() + + if new_messages: + # 处理新消息 + for message in new_messages: + await self._add_message_to_history(message) + + # 设置完成事件 + self._update_complete.set() + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]更新循环出错: {e}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + self._update_complete.set() # 即使出错也要设置完成事件 + + def trigger_update(self): + """触发一次立即更新""" + self._update_event.set() + + async def wait_for_update(self, timeout: float = 5.0) -> bool: + """等待更新完成 + + Args: + timeout: 超时时间(秒) + + Returns: + bool: 是否成功完成更新(False表示超时) + """ + try: + await asyncio.wait_for(self._update_complete.wait(), timeout=timeout) + return True + except asyncio.TimeoutError: + logger.warning(f"[私聊][{self.private_name}]等待更新完成超时({timeout}秒)") + return False + + def start(self): + """启动观察器""" + if self._running: + return + + self._running = True + self._task = asyncio.create_task(self._update_loop()) + logger.debug(f"[私聊][{self.private_name}]ChatObserver for {self.stream_id} started") + + def stop(self): + """停止观察器""" + self._running = False + self._update_event.set() # 设置事件以解除等待 + self._update_complete.set() # 设置完成事件以解除等待 + if self._task: + self._task.cancel() + logger.debug(f"[私聊][{self.private_name}]ChatObserver for {self.stream_id} stopped") + + async def process_chat_history(self, messages: list): + """处理聊天历史 + + Args: + messages: 消息列表 + """ + self.update_check_time() + + for msg in messages: + try: + user_info = UserInfo.from_dict(msg.get("user_info", {})) + if user_info.user_id == global_config.BOT_QQ: + self.update_bot_speak_time(msg["time"]) + else: + self.update_user_speak_time(msg["time"]) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]处理消息时间时出错: {e}") + continue + + def update_check_time(self): + """更新查看时间""" + self.last_check_time = time.time() + + def update_bot_speak_time(self, speak_time: Optional[float] = None): + """更新机器人说话时间""" + self.last_bot_speak_time = speak_time or time.time() + + def update_user_speak_time(self, speak_time: Optional[float] = None): + """更新用户说话时间""" + self.last_user_speak_time = speak_time or time.time() + + def get_time_info(self) -> str: + """获取时间信息文本""" + current_time = time.time() + time_info = "" + + if self.last_bot_speak_time: + bot_speak_ago = current_time - self.last_bot_speak_time + time_info += f"\n距离你上次发言已经过去了{int(bot_speak_ago)}秒" + + if self.last_user_speak_time: + user_speak_ago = current_time - self.last_user_speak_time + time_info += f"\n距离对方上次发言已经过去了{int(user_speak_ago)}秒" + + return time_info + + def get_cached_messages(self, limit: int = 50) -> List[Dict[str, Any]]: + """获取缓存的消息历史 + + Args: + limit: 获取的最大消息数量,默认50 + + Returns: + List[Dict[str, Any]]: 缓存的消息历史列表 + """ + return self.message_cache[-limit:] + + def get_last_message(self) -> Optional[Dict[str, Any]]: + """获取最后一条消息 + + Returns: + Optional[Dict[str, Any]]: 最后一条消息,如果没有则返回None + """ + if not self.message_cache: + return None + return self.message_cache[-1] + + def __str__(self): + return f"ChatObserver for {self.stream_id}" diff --git a/src/chat/brain_chat/PFC/chat_states.py b/src/chat/brain_chat/PFC/chat_states.py new file mode 100644 index 00000000..4b839b7b --- /dev/null +++ b/src/chat/brain_chat/PFC/chat_states.py @@ -0,0 +1,290 @@ +from enum import Enum, auto +from typing import Optional, Dict, Any, List, Set +from dataclasses import dataclass +from datetime import datetime +from abc import ABC, abstractmethod + + +class ChatState(Enum): + """聊天状态枚举""" + + NORMAL = auto() # 正常状态 + NEW_MESSAGE = auto() # 有新消息 + COLD_CHAT = auto() # 冷场状态 + ACTIVE_CHAT = auto() # 活跃状态 + BOT_SPEAKING = auto() # 机器人正在说话 + USER_SPEAKING = auto() # 用户正在说话 + SILENT = auto() # 沉默状态 + ERROR = auto() # 错误状态 + + +class NotificationType(Enum): + """通知类型枚举""" + + NEW_MESSAGE = auto() # 新消息通知 + COLD_CHAT = auto() # 冷场通知 + ACTIVE_CHAT = auto() # 活跃通知 + BOT_SPEAKING = auto() # 机器人说话通知 + USER_SPEAKING = auto() # 用户说话通知 + MESSAGE_DELETED = auto() # 消息删除通知 + USER_JOINED = auto() # 用户加入通知 + USER_LEFT = auto() # 用户离开通知 + ERROR = auto() # 错误通知 + + +@dataclass +class ChatStateInfo: + """聊天状态信息""" + + state: ChatState + last_message_time: Optional[float] = None + last_message_content: Optional[str] = None + last_speaker: Optional[str] = None + message_count: int = 0 + cold_duration: float = 0.0 # 冷场持续时间(秒) + active_duration: float = 0.0 # 活跃持续时间(秒) + + +@dataclass +class Notification: + """通知基类""" + + type: NotificationType + timestamp: float + sender: str # 发送者标识 + target: str # 接收者标识 + data: Dict[str, Any] + + def to_dict(self) -> Dict[str, Any]: + """转换为字典格式""" + return {"type": self.type.name, "timestamp": self.timestamp, "data": self.data} + + +@dataclass +class StateNotification(Notification): + """持续状态通知""" + + is_active: bool = True + + def to_dict(self) -> Dict[str, Any]: + base_dict = super().to_dict() + base_dict["is_active"] = self.is_active + return base_dict + + +class NotificationHandler(ABC): + """通知处理器接口""" + + @abstractmethod + async def handle_notification(self, notification: Notification): + """处理通知""" + pass + + +class NotificationManager: + """通知管理器""" + + def __init__(self): + # 按接收者和通知类型存储处理器 + self._handlers: Dict[str, Dict[NotificationType, List[NotificationHandler]]] = {} + self._active_states: Set[NotificationType] = set() + self._notification_history: List[Notification] = [] + + def register_handler(self, target: str, notification_type: NotificationType, handler: NotificationHandler): + """注册通知处理器 + + Args: + target: 接收者标识(例如:"pfc") + notification_type: 要处理的通知类型 + handler: 处理器实例 + """ + if target not in self._handlers: + self._handlers[target] = {} + if notification_type not in self._handlers[target]: + self._handlers[target][notification_type] = [] + # print(self._handlers[target][notification_type]) + self._handlers[target][notification_type].append(handler) + # print(self._handlers[target][notification_type]) + + def unregister_handler(self, target: str, notification_type: NotificationType, handler: NotificationHandler): + """注销通知处理器 + + Args: + target: 接收者标识 + notification_type: 通知类型 + handler: 要注销的处理器实例 + """ + if target in self._handlers and notification_type in self._handlers[target]: + handlers = self._handlers[target][notification_type] + if handler in handlers: + handlers.remove(handler) + # 如果该类型的处理器列表为空,删除该类型 + if not handlers: + del self._handlers[target][notification_type] + # 如果该目标没有任何处理器,删除该目标 + if not self._handlers[target]: + del self._handlers[target] + + async def send_notification(self, notification: Notification): + """发送通知""" + self._notification_history.append(notification) + + # 如果是状态通知,更新活跃状态 + if isinstance(notification, StateNotification): + if notification.is_active: + self._active_states.add(notification.type) + else: + self._active_states.discard(notification.type) + + # 调用目标接收者的处理器 + target = notification.target + if target in self._handlers: + handlers = self._handlers[target].get(notification.type, []) + # print(handlers) + for handler in handlers: + # print(f"调用处理器: {handler}") + await handler.handle_notification(notification) + + def get_active_states(self) -> Set[NotificationType]: + """获取当前活跃的状态""" + return self._active_states.copy() + + def is_state_active(self, state_type: NotificationType) -> bool: + """检查特定状态是否活跃""" + return state_type in self._active_states + + def get_notification_history( + self, sender: Optional[str] = None, target: Optional[str] = None, limit: Optional[int] = None + ) -> List[Notification]: + """获取通知历史 + + Args: + sender: 过滤特定发送者的通知 + target: 过滤特定接收者的通知 + limit: 限制返回数量 + """ + history = self._notification_history + + if sender: + history = [n for n in history if n.sender == sender] + if target: + history = [n for n in history if n.target == target] + + if limit is not None: + history = history[-limit:] + + return history + + def __str__(self): + str = "" + for target, handlers in self._handlers.items(): + for notification_type, handler_list in handlers.items(): + str += f"NotificationManager for {target} {notification_type} {handler_list}" + return str + + +# 一些常用的通知创建函数 +def create_new_message_notification(sender: str, target: str, message: Dict[str, Any]) -> Notification: + """创建新消息通知""" + return Notification( + type=NotificationType.NEW_MESSAGE, + timestamp=datetime.now().timestamp(), + sender=sender, + target=target, + data={ + "message_id": message.get("message_id"), + "processed_plain_text": message.get("processed_plain_text"), + "detailed_plain_text": message.get("detailed_plain_text"), + "user_info": message.get("user_info"), + "time": message.get("time"), + }, + ) + + +def create_cold_chat_notification(sender: str, target: str, is_cold: bool) -> StateNotification: + """创建冷场状态通知""" + return StateNotification( + type=NotificationType.COLD_CHAT, + timestamp=datetime.now().timestamp(), + sender=sender, + target=target, + data={"is_cold": is_cold}, + is_active=is_cold, + ) + + +def create_active_chat_notification(sender: str, target: str, is_active: bool) -> StateNotification: + """创建活跃状态通知""" + return StateNotification( + type=NotificationType.ACTIVE_CHAT, + timestamp=datetime.now().timestamp(), + sender=sender, + target=target, + data={"is_active": is_active}, + is_active=is_active, + ) + + +class ChatStateManager: + """聊天状态管理器""" + + def __init__(self): + self.current_state = ChatState.NORMAL + self.state_info = ChatStateInfo(state=ChatState.NORMAL) + self.state_history: list[ChatStateInfo] = [] + + def update_state(self, new_state: ChatState, **kwargs): + """更新聊天状态 + + Args: + new_state: 新的状态 + **kwargs: 其他状态信息 + """ + self.current_state = new_state + self.state_info.state = new_state + + # 更新其他状态信息 + for key, value in kwargs.items(): + if hasattr(self.state_info, key): + setattr(self.state_info, key, value) + + # 记录状态历史 + self.state_history.append(self.state_info) + + def get_current_state_info(self) -> ChatStateInfo: + """获取当前状态信息""" + return self.state_info + + def get_state_history(self) -> list[ChatStateInfo]: + """获取状态历史""" + return self.state_history + + def is_cold_chat(self, threshold: float = 60.0) -> bool: + """判断是否处于冷场状态 + + Args: + threshold: 冷场阈值(秒) + + Returns: + bool: 是否冷场 + """ + if not self.state_info.last_message_time: + return True + + current_time = datetime.now().timestamp() + return (current_time - self.state_info.last_message_time) > threshold + + def is_active_chat(self, threshold: float = 5.0) -> bool: + """判断是否处于活跃状态 + + Args: + threshold: 活跃阈值(秒) + + Returns: + bool: 是否活跃 + """ + if not self.state_info.last_message_time: + return False + + current_time = datetime.now().timestamp() + return (current_time - self.state_info.last_message_time) <= threshold diff --git a/src/chat/brain_chat/PFC/conversation.py b/src/chat/brain_chat/PFC/conversation.py new file mode 100644 index 00000000..0bc4cae8 --- /dev/null +++ b/src/chat/brain_chat/PFC/conversation.py @@ -0,0 +1,701 @@ +import time +import asyncio +import datetime + +# from .message_storage import MongoDBMessageStorage +from src.plugins.utils.chat_message_builder import build_readable_messages, get_raw_msg_before_timestamp_with_chat + +# from ...config.config import global_config +from typing import Dict, Any, Optional +from ..chat.message import Message +from .pfc_types import ConversationState +from .pfc import ChatObserver, GoalAnalyzer +from .message_sender import DirectMessageSender +from src.common.logger_manager import get_logger +from .action_planner import ActionPlanner +from .observation_info import ObservationInfo +from .conversation_info import ConversationInfo # 确保导入 ConversationInfo +from .reply_generator import ReplyGenerator +from ..chat.chat_stream import ChatStream +from maim_message import UserInfo +from src.plugins.chat.chat_stream import chat_manager +from .pfc_KnowledgeFetcher import KnowledgeFetcher +from .waiter import Waiter + +import traceback +from rich.traceback import install + +install(extra_lines=3) + +logger = get_logger("pfc") + + +class Conversation: + """对话类,负责管理单个对话的状态和行为""" + + def __init__(self, stream_id: str, private_name: str): + """初始化对话实例 + + Args: + stream_id: 聊天流ID + """ + self.stream_id = stream_id + self.private_name = private_name + self.state = ConversationState.INIT + self.should_continue = False + self.ignore_until_timestamp: Optional[float] = None + + # 回复相关 + self.generated_reply = "" + + async def _initialize(self): + """初始化实例,注册所有组件""" + + try: + self.action_planner = ActionPlanner(self.stream_id, self.private_name) + self.goal_analyzer = GoalAnalyzer(self.stream_id, self.private_name) + self.reply_generator = ReplyGenerator(self.stream_id, self.private_name) + self.knowledge_fetcher = KnowledgeFetcher(self.private_name) + self.waiter = Waiter(self.stream_id, self.private_name) + self.direct_sender = DirectMessageSender(self.private_name) + + # 获取聊天流信息 + self.chat_stream = chat_manager.get_stream(self.stream_id) + + self.stop_action_planner = False + except Exception as e: + logger.error(f"[私聊][{self.private_name}]初始化对话实例:注册运行组件失败: {e}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + raise + + try: + # 决策所需要的信息,包括自身自信和观察信息两部分 + # 注册观察器和观测信息 + self.chat_observer = ChatObserver.get_instance(self.stream_id, self.private_name) + self.chat_observer.start() + self.observation_info = ObservationInfo(self.private_name) + self.observation_info.bind_to_chat_observer(self.chat_observer) + # print(self.chat_observer.get_cached_messages(limit=) + + self.conversation_info = ConversationInfo() + except Exception as e: + logger.error(f"[私聊][{self.private_name}]初始化对话实例:注册信息组件失败: {e}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + raise + try: + logger.info(f"[私聊][{self.private_name}]为 {self.stream_id} 加载初始聊天记录...") + initial_messages = get_raw_msg_before_timestamp_with_chat( # + chat_id=self.stream_id, + timestamp=time.time(), + limit=30, # 加载最近30条作为初始上下文,可以调整 + ) + chat_talking_prompt = await build_readable_messages( + initial_messages, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + if initial_messages: + # 将加载的消息填充到 ObservationInfo 的 chat_history + self.observation_info.chat_history = initial_messages + self.observation_info.chat_history_str = chat_talking_prompt + "\n" + self.observation_info.chat_history_count = len(initial_messages) + + # 更新 ObservationInfo 中的时间戳等信息 + last_msg = initial_messages[-1] + self.observation_info.last_message_time = last_msg.get("time") + last_user_info = UserInfo.from_dict(last_msg.get("user_info", {})) + self.observation_info.last_message_sender = last_user_info.user_id + self.observation_info.last_message_content = last_msg.get("processed_plain_text", "") + + logger.info( + f"[私聊][{self.private_name}]成功加载 {len(initial_messages)} 条初始聊天记录。最后一条消息时间: {self.observation_info.last_message_time}" + ) + + # 让 ChatObserver 从加载的最后一条消息之后开始同步 + self.chat_observer.last_message_time = self.observation_info.last_message_time + self.chat_observer.last_message_read = last_msg # 更新 observer 的最后读取记录 + else: + logger.info(f"[私聊][{self.private_name}]没有找到初始聊天记录。") + + except Exception as load_err: + logger.error(f"[私聊][{self.private_name}]加载初始聊天记录时出错: {load_err}") + # 出错也要继续,只是没有历史记录而已 + # 组件准备完成,启动该论对话 + self.should_continue = True + asyncio.create_task(self.start()) + + async def start(self): + """开始对话流程""" + try: + logger.info(f"[私聊][{self.private_name}]对话系统启动中...") + asyncio.create_task(self._plan_and_action_loop()) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]启动对话系统失败: {e}") + raise + + async def _plan_and_action_loop(self): + """思考步,PFC核心循环模块""" + while self.should_continue: + # 忽略逻辑 + if self.ignore_until_timestamp and time.time() < self.ignore_until_timestamp: + await asyncio.sleep(30) + continue + elif self.ignore_until_timestamp and time.time() >= self.ignore_until_timestamp: + logger.info(f"[私聊][{self.private_name}]忽略时间已到 {self.stream_id},准备结束对话。") + self.ignore_until_timestamp = None + self.should_continue = False + continue + try: + # --- 在规划前记录当前新消息数量 --- + initial_new_message_count = 0 + if hasattr(self.observation_info, "new_messages_count"): + initial_new_message_count = self.observation_info.new_messages_count + 1 # 算上麦麦自己发的那一条 + else: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo missing 'new_messages_count' before planning." + ) + + # --- 调用 Action Planner --- + # 传递 self.conversation_info.last_successful_reply_action + action, reason = await self.action_planner.plan( + self.observation_info, self.conversation_info, self.conversation_info.last_successful_reply_action + ) + + # --- 规划后检查是否有 *更多* 新消息到达 --- + current_new_message_count = 0 + if hasattr(self.observation_info, "new_messages_count"): + current_new_message_count = self.observation_info.new_messages_count + else: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo missing 'new_messages_count' after planning." + ) + + if current_new_message_count > initial_new_message_count + 2: + logger.info( + f"[私聊][{self.private_name}]规划期间发现新增消息 ({initial_new_message_count} -> {current_new_message_count}),跳过本次行动,重新规划" + ) + # 如果规划期间有新消息,也应该重置上次回复状态,因为现在要响应新消息了 + self.conversation_info.last_successful_reply_action = None + await asyncio.sleep(0.1) + continue + + # 包含 send_new_message + if initial_new_message_count > 0 and action in ["direct_reply", "send_new_message"]: + if hasattr(self.observation_info, "clear_unprocessed_messages"): + logger.debug( + f"[私聊][{self.private_name}]准备执行 {action},清理 {initial_new_message_count} 条规划时已知的新消息。" + ) + await self.observation_info.clear_unprocessed_messages() + if hasattr(self.observation_info, "new_messages_count"): + self.observation_info.new_messages_count = 0 + else: + logger.error( + f"[私聊][{self.private_name}]无法清理未处理消息: ObservationInfo 缺少 clear_unprocessed_messages 方法!" + ) + + await self._handle_action(action, reason, self.observation_info, self.conversation_info) + + # 检查是否需要结束对话 (逻辑不变) + goal_ended = False + if hasattr(self.conversation_info, "goal_list") and self.conversation_info.goal_list: + for goal_item in self.conversation_info.goal_list: + if isinstance(goal_item, dict): + current_goal = goal_item.get("goal") + + if current_goal == "结束对话": + goal_ended = True + break + + if goal_ended: + self.should_continue = False + logger.info(f"[私聊][{self.private_name}]检测到'结束对话'目标,停止循环。") + + except Exception as loop_err: + logger.error(f"[私聊][{self.private_name}]PFC主循环出错: {loop_err}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + await asyncio.sleep(1) + + if self.should_continue: + await asyncio.sleep(0.1) + + logger.info(f"[私聊][{self.private_name}]PFC 循环结束 for stream_id: {self.stream_id}") + + def _check_new_messages_after_planning(self): + """检查在规划后是否有新消息""" + # 检查 ObservationInfo 是否已初始化并且有 new_messages_count 属性 + if not hasattr(self, "observation_info") or not hasattr(self.observation_info, "new_messages_count"): + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo 未初始化或缺少 'new_messages_count' 属性,无法检查新消息。" + ) + return False # 或者根据需要抛出错误 + + if self.observation_info.new_messages_count > 2: + logger.info( + f"[私聊][{self.private_name}]生成/执行动作期间收到 {self.observation_info.new_messages_count} 条新消息,取消当前动作并重新规划" + ) + # 如果有新消息,也应该重置上次回复状态 + if hasattr(self, "conversation_info"): # 确保 conversation_info 已初始化 + self.conversation_info.last_successful_reply_action = None + else: + logger.warning( + f"[私聊][{self.private_name}]ConversationInfo 未初始化,无法重置 last_successful_reply_action。" + ) + return True + return False + + def _convert_to_message(self, msg_dict: Dict[str, Any]) -> Message: + """将消息字典转换为Message对象""" + try: + # 尝试从 msg_dict 直接获取 chat_stream,如果失败则从全局 chat_manager 获取 + chat_info = msg_dict.get("chat_info") + if chat_info and isinstance(chat_info, dict): + chat_stream = ChatStream.from_dict(chat_info) + elif self.chat_stream: # 使用实例变量中的 chat_stream + chat_stream = self.chat_stream + else: # Fallback: 尝试从 manager 获取 (可能需要 stream_id) + chat_stream = chat_manager.get_stream(self.stream_id) + if not chat_stream: + raise ValueError(f"无法确定 ChatStream for stream_id {self.stream_id}") + + user_info = UserInfo.from_dict(msg_dict.get("user_info", {})) + + return Message( + message_id=msg_dict.get("message_id", f"gen_{time.time()}"), # 提供默认 ID + chat_stream=chat_stream, # 使用确定的 chat_stream + time=msg_dict.get("time", time.time()), # 提供默认时间 + user_info=user_info, + processed_plain_text=msg_dict.get("processed_plain_text", ""), + detailed_plain_text=msg_dict.get("detailed_plain_text", ""), + ) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]转换消息时出错: {e}") + # 可以选择返回 None 或重新抛出异常,这里选择重新抛出以指示问题 + raise ValueError(f"无法将字典转换为 Message 对象: {e}") from e + + async def _handle_action( + self, action: str, reason: str, observation_info: ObservationInfo, conversation_info: ConversationInfo + ): + """处理规划的行动""" + + logger.debug(f"[私聊][{self.private_name}]执行行动: {action}, 原因: {reason}") + + # 记录action历史 (逻辑不变) + current_action_record = { + "action": action, + "plan_reason": reason, + "status": "start", + "time": datetime.datetime.now().strftime("%H:%M:%S"), + "final_reason": None, + } + # 确保 done_action 列表存在 + if not hasattr(conversation_info, "done_action"): + conversation_info.done_action = [] + conversation_info.done_action.append(current_action_record) + action_index = len(conversation_info.done_action) - 1 + + action_successful = False # 用于标记动作是否成功完成 + + # --- 根据不同的 action 执行 --- + + # send_new_message 失败后执行 wait + if action == "send_new_message": + max_reply_attempts = 3 + reply_attempt_count = 0 + is_suitable = False + need_replan = False + check_reason = "未进行尝试" + final_reply_to_send = "" + + while reply_attempt_count < max_reply_attempts and not is_suitable: + reply_attempt_count += 1 + logger.info( + f"[私聊][{self.private_name}]尝试生成追问回复 (第 {reply_attempt_count}/{max_reply_attempts} 次)..." + ) + self.state = ConversationState.GENERATING + + # 1. 生成回复 (调用 generate 时传入 action_type) + self.generated_reply = await self.reply_generator.generate( + observation_info, conversation_info, action_type="send_new_message" + ) + logger.info( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次生成的追问回复: {self.generated_reply}" + ) + + # 2. 检查回复 (逻辑不变) + self.state = ConversationState.CHECKING + try: + current_goal_str = conversation_info.goal_list[0]["goal"] if conversation_info.goal_list else "" + is_suitable, check_reason, need_replan = await self.reply_generator.check_reply( + reply=self.generated_reply, + goal=current_goal_str, + chat_history=observation_info.chat_history, + chat_history_str=observation_info.chat_history_str, + retry_count=reply_attempt_count - 1, + ) + logger.info( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次追问检查结果: 合适={is_suitable}, 原因='{check_reason}', 需重新规划={need_replan}" + ) + if is_suitable: + final_reply_to_send = self.generated_reply + break + elif need_replan: + logger.warning( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次追问检查建议重新规划,停止尝试。原因: {check_reason}" + ) + break + except Exception as check_err: + logger.error( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次调用 ReplyChecker (追问) 时出错: {check_err}" + ) + check_reason = f"第 {reply_attempt_count} 次检查过程出错: {check_err}" + break + + # 循环结束,处理最终结果 + if is_suitable: + # 检查是否有新消息 + if self._check_new_messages_after_planning(): + logger.info(f"[私聊][{self.private_name}]生成追问回复期间收到新消息,取消发送,重新规划行动") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"有新消息,取消发送追问: {final_reply_to_send}"} + ) + return # 直接返回,重新规划 + + # 发送合适的回复 + self.generated_reply = final_reply_to_send + # --- 在这里调用 _send_reply --- + await self._send_reply() # <--- 调用恢复后的函数 + + # 更新状态: 标记上次成功是 send_new_message + self.conversation_info.last_successful_reply_action = "send_new_message" + action_successful = True # 标记动作成功 + + elif need_replan: + # 打回动作决策 + logger.warning( + f"[私聊][{self.private_name}]经过 {reply_attempt_count} 次尝试,追问回复决定打回动作决策。打回原因: {check_reason}" + ) + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"追问尝试{reply_attempt_count}次后打回: {check_reason}"} + ) + + else: + # 追问失败 + logger.warning( + f"[私聊][{self.private_name}]经过 {reply_attempt_count} 次尝试,未能生成合适的追问回复。最终原因: {check_reason}" + ) + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"追问尝试{reply_attempt_count}次后失败: {check_reason}"} + ) + # 重置状态: 追问失败,下次用初始 prompt + self.conversation_info.last_successful_reply_action = None + + # 执行 Wait 操作 + logger.info(f"[私聊][{self.private_name}]由于无法生成合适追问回复,执行 'wait' 操作...") + self.state = ConversationState.WAITING + await self.waiter.wait(self.conversation_info) + wait_action_record = { + "action": "wait", + "plan_reason": "因 send_new_message 多次尝试失败而执行的后备等待", + "status": "done", + "time": datetime.datetime.now().strftime("%H:%M:%S"), + "final_reason": None, + } + conversation_info.done_action.append(wait_action_record) + + elif action == "direct_reply": + max_reply_attempts = 3 + reply_attempt_count = 0 + is_suitable = False + need_replan = False + check_reason = "未进行尝试" + final_reply_to_send = "" + + while reply_attempt_count < max_reply_attempts and not is_suitable: + reply_attempt_count += 1 + logger.info( + f"[私聊][{self.private_name}]尝试生成首次回复 (第 {reply_attempt_count}/{max_reply_attempts} 次)..." + ) + self.state = ConversationState.GENERATING + + # 1. 生成回复 + self.generated_reply = await self.reply_generator.generate( + observation_info, conversation_info, action_type="direct_reply" + ) + logger.info( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次生成的首次回复: {self.generated_reply}" + ) + + # 2. 检查回复 + self.state = ConversationState.CHECKING + try: + current_goal_str = conversation_info.goal_list[0]["goal"] if conversation_info.goal_list else "" + is_suitable, check_reason, need_replan = await self.reply_generator.check_reply( + reply=self.generated_reply, + goal=current_goal_str, + chat_history=observation_info.chat_history, + chat_history_str=observation_info.chat_history_str, + retry_count=reply_attempt_count - 1, + ) + logger.info( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次首次回复检查结果: 合适={is_suitable}, 原因='{check_reason}', 需重新规划={need_replan}" + ) + if is_suitable: + final_reply_to_send = self.generated_reply + break + elif need_replan: + logger.warning( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次首次回复检查建议重新规划,停止尝试。原因: {check_reason}" + ) + break + except Exception as check_err: + logger.error( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次调用 ReplyChecker (首次回复) 时出错: {check_err}" + ) + check_reason = f"第 {reply_attempt_count} 次检查过程出错: {check_err}" + break + + # 循环结束,处理最终结果 + if is_suitable: + # 检查是否有新消息 + if self._check_new_messages_after_planning(): + logger.info(f"[私聊][{self.private_name}]生成首次回复期间收到新消息,取消发送,重新规划行动") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"有新消息,取消发送首次回复: {final_reply_to_send}"} + ) + return # 直接返回,重新规划 + + # 发送合适的回复 + self.generated_reply = final_reply_to_send + # --- 在这里调用 _send_reply --- + await self._send_reply() # <--- 调用恢复后的函数 + + # 更新状态: 标记上次成功是 direct_reply + self.conversation_info.last_successful_reply_action = "direct_reply" + action_successful = True # 标记动作成功 + + elif need_replan: + # 打回动作决策 + logger.warning( + f"[私聊][{self.private_name}]经过 {reply_attempt_count} 次尝试,首次回复决定打回动作决策。打回原因: {check_reason}" + ) + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"首次回复尝试{reply_attempt_count}次后打回: {check_reason}"} + ) + + else: + # 首次回复失败 + logger.warning( + f"[私聊][{self.private_name}]经过 {reply_attempt_count} 次尝试,未能生成合适的首次回复。最终原因: {check_reason}" + ) + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"首次回复尝试{reply_attempt_count}次后失败: {check_reason}"} + ) + # 重置状态: 首次回复失败,下次还是用初始 prompt + self.conversation_info.last_successful_reply_action = None + + # 执行 Wait 操作 (保持原有逻辑) + logger.info(f"[私聊][{self.private_name}]由于无法生成合适首次回复,执行 'wait' 操作...") + self.state = ConversationState.WAITING + await self.waiter.wait(self.conversation_info) + wait_action_record = { + "action": "wait", + "plan_reason": "因 direct_reply 多次尝试失败而执行的后备等待", + "status": "done", + "time": datetime.datetime.now().strftime("%H:%M:%S"), + "final_reason": None, + } + conversation_info.done_action.append(wait_action_record) + + elif action == "fetch_knowledge": + self.state = ConversationState.FETCHING + knowledge_query = reason + try: + # 检查 knowledge_fetcher 是否存在 + if not hasattr(self, "knowledge_fetcher"): + logger.error(f"[私聊][{self.private_name}]KnowledgeFetcher 未初始化,无法获取知识。") + raise AttributeError("KnowledgeFetcher not initialized") + + knowledge, source = await self.knowledge_fetcher.fetch(knowledge_query, observation_info.chat_history) + logger.info(f"[私聊][{self.private_name}]获取到知识: {knowledge[:100]}..., 来源: {source}") + if knowledge: + # 确保 knowledge_list 存在 + if not hasattr(conversation_info, "knowledge_list"): + conversation_info.knowledge_list = [] + conversation_info.knowledge_list.append( + {"query": knowledge_query, "knowledge": knowledge, "source": source} + ) + action_successful = True + except Exception as fetch_err: + logger.error(f"[私聊][{self.private_name}]获取知识时出错: {str(fetch_err)}") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"获取知识失败: {str(fetch_err)}"} + ) + self.conversation_info.last_successful_reply_action = None # 重置状态 + + elif action == "rethink_goal": + self.state = ConversationState.RETHINKING + try: + # 检查 goal_analyzer 是否存在 + if not hasattr(self, "goal_analyzer"): + logger.error(f"[私聊][{self.private_name}]GoalAnalyzer 未初始化,无法重新思考目标。") + raise AttributeError("GoalAnalyzer not initialized") + await self.goal_analyzer.analyze_goal(conversation_info, observation_info) + action_successful = True + except Exception as rethink_err: + logger.error(f"[私聊][{self.private_name}]重新思考目标时出错: {rethink_err}") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"重新思考目标失败: {rethink_err}"} + ) + self.conversation_info.last_successful_reply_action = None # 重置状态 + + elif action == "listening": + self.state = ConversationState.LISTENING + logger.info(f"[私聊][{self.private_name}]倾听对方发言...") + try: + # 检查 waiter 是否存在 + if not hasattr(self, "waiter"): + logger.error(f"[私聊][{self.private_name}]Waiter 未初始化,无法倾听。") + raise AttributeError("Waiter not initialized") + await self.waiter.wait_listening(conversation_info) + action_successful = True # Listening 完成就算成功 + except Exception as listen_err: + logger.error(f"[私聊][{self.private_name}]倾听时出错: {listen_err}") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"倾听失败: {listen_err}"} + ) + self.conversation_info.last_successful_reply_action = None # 重置状态 + + elif action == "say_goodbye": + self.state = ConversationState.GENERATING # 也可以定义一个新的状态,如 ENDING + logger.info(f"[私聊][{self.private_name}]执行行动: 生成并发送告别语...") + try: + # 1. 生成告别语 (使用 'say_goodbye' action_type) + self.generated_reply = await self.reply_generator.generate( + observation_info, conversation_info, action_type="say_goodbye" + ) + logger.info(f"[私聊][{self.private_name}]生成的告别语: {self.generated_reply}") + + # 2. 直接发送告别语 (不经过检查) + if self.generated_reply: # 确保生成了内容 + await self._send_reply() # 调用发送方法 + # 发送成功后,标记动作成功 + action_successful = True + logger.info(f"[私聊][{self.private_name}]告别语已发送。") + else: + logger.warning(f"[私聊][{self.private_name}]未能生成告别语内容,无法发送。") + action_successful = False # 标记动作失败 + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": "未能生成告别语内容"} + ) + + # 3. 无论是否发送成功,都准备结束对话 + self.should_continue = False + logger.info(f"[私聊][{self.private_name}]发送告别语流程结束,即将停止对话实例。") + + except Exception as goodbye_err: + logger.error(f"[私聊][{self.private_name}]生成或发送告别语时出错: {goodbye_err}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + # 即使出错,也结束对话 + self.should_continue = False + action_successful = False # 标记动作失败 + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"生成或发送告别语时出错: {goodbye_err}"} + ) + + elif action == "end_conversation": + # 这个分支现在只会在 action_planner 最终决定不告别时被调用 + self.should_continue = False + logger.info(f"[私聊][{self.private_name}]收到最终结束指令,停止对话...") + action_successful = True # 标记这个指令本身是成功的 + + elif action == "block_and_ignore": + logger.info(f"[私聊][{self.private_name}]不想再理你了...") + ignore_duration_seconds = 10 * 60 + self.ignore_until_timestamp = time.time() + ignore_duration_seconds + logger.info( + f"[私聊][{self.private_name}]将忽略此对话直到: {datetime.datetime.fromtimestamp(self.ignore_until_timestamp)}" + ) + self.state = ConversationState.IGNORED + action_successful = True # 标记动作成功 + + else: # 对应 'wait' 动作 + self.state = ConversationState.WAITING + logger.info(f"[私聊][{self.private_name}]等待更多信息...") + try: + # 检查 waiter 是否存在 + if not hasattr(self, "waiter"): + logger.error(f"[私聊][{self.private_name}]Waiter 未初始化,无法等待。") + raise AttributeError("Waiter not initialized") + _timeout_occurred = await self.waiter.wait(self.conversation_info) + action_successful = True # Wait 完成就算成功 + except Exception as wait_err: + logger.error(f"[私聊][{self.private_name}]等待时出错: {wait_err}") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"等待失败: {wait_err}"} + ) + self.conversation_info.last_successful_reply_action = None # 重置状态 + + # --- 更新 Action History 状态 --- + # 只有当动作本身成功时,才更新状态为 done + if action_successful: + conversation_info.done_action[action_index].update( + { + "status": "done", + "time": datetime.datetime.now().strftime("%H:%M:%S"), + } + ) + # 重置状态: 对于非回复类动作的成功,清除上次回复状态 + if action not in ["direct_reply", "send_new_message"]: + self.conversation_info.last_successful_reply_action = None + logger.debug(f"[私聊][{self.private_name}]动作 {action} 成功完成,重置 last_successful_reply_action") + # 如果动作是 recall 状态,在各自的处理逻辑中已经更新了 done_action + + async def _send_reply(self): + """发送回复""" + if not self.generated_reply: + logger.warning(f"[私聊][{self.private_name}]没有生成回复内容,无法发送。") + return + + try: + _current_time = time.time() + reply_content = self.generated_reply + + # 发送消息 (确保 direct_sender 和 chat_stream 有效) + if not hasattr(self, "direct_sender") or not self.direct_sender: + logger.error(f"[私聊][{self.private_name}]DirectMessageSender 未初始化,无法发送回复。") + return + if not self.chat_stream: + logger.error(f"[私聊][{self.private_name}]ChatStream 未初始化,无法发送回复。") + return + + await self.direct_sender.send_message(chat_stream=self.chat_stream, content=reply_content) + + # 发送成功后,手动触发 observer 更新可能导致重复处理自己发送的消息 + # 更好的做法是依赖 observer 的自动轮询或数据库触发器(如果支持) + # 暂时注释掉,观察是否影响 ObservationInfo 的更新 + # self.chat_observer.trigger_update() + # if not await self.chat_observer.wait_for_update(): + # logger.warning(f"[私聊][{self.private_name}]等待 ChatObserver 更新完成超时") + + self.state = ConversationState.ANALYZING # 更新状态 + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]发送消息或更新状态时失败: {str(e)}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + self.state = ConversationState.ANALYZING + + async def _send_timeout_message(self): + """发送超时结束消息""" + try: + messages = self.chat_observer.get_cached_messages(limit=1) + if not messages: + return + + latest_message = self._convert_to_message(messages[0]) + await self.direct_sender.send_message( + chat_stream=self.chat_stream, content="TODO:超时消息", reply_to_message=latest_message + ) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]发送超时消息失败: {str(e)}") diff --git a/src/chat/brain_chat/PFC/conversation_info.py b/src/chat/brain_chat/PFC/conversation_info.py new file mode 100644 index 00000000..04524b69 --- /dev/null +++ b/src/chat/brain_chat/PFC/conversation_info.py @@ -0,0 +1,10 @@ +from typing import Optional + + +class ConversationInfo: + def __init__(self): + self.done_action = [] + self.goal_list = [] + self.knowledge_list = [] + self.memory_list = [] + self.last_successful_reply_action: Optional[str] = None diff --git a/src/chat/brain_chat/PFC/message_sender.py b/src/chat/brain_chat/PFC/message_sender.py new file mode 100644 index 00000000..12c2143e --- /dev/null +++ b/src/chat/brain_chat/PFC/message_sender.py @@ -0,0 +1,81 @@ +import time +from typing import Optional +from src.common.logger import get_module_logger +from ..chat.chat_stream import ChatStream +from ..chat.message import Message +from maim_message import UserInfo, Seg +from src.plugins.chat.message import MessageSending, MessageSet +from src.plugins.chat.message_sender import message_manager +from ..storage.storage import MessageStorage +from ...config.config import global_config +from rich.traceback import install + +install(extra_lines=3) + + +logger = get_module_logger("message_sender") + + +class DirectMessageSender: + """直接消息发送器""" + + def __init__(self, private_name: str): + self.private_name = private_name + self.storage = MessageStorage() + + async def send_message( + self, + chat_stream: ChatStream, + content: str, + reply_to_message: Optional[Message] = None, + ) -> None: + """发送消息到聊天流 + + Args: + chat_stream: 聊天流 + content: 消息内容 + reply_to_message: 要回复的消息(可选) + """ + try: + # 创建消息内容 + segments = Seg(type="seglist", data=[Seg(type="text", data=content)]) + + # 获取麦麦的信息 + bot_user_info = UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=chat_stream.platform, + ) + + # 用当前时间作为message_id,和之前那套sender一样 + message_id = f"dm{round(time.time(), 2)}" + + # 构建消息对象 + message = MessageSending( + message_id=message_id, + chat_stream=chat_stream, + bot_user_info=bot_user_info, + sender_info=reply_to_message.message_info.user_info if reply_to_message else None, + message_segment=segments, + reply=reply_to_message, + is_head=True, + is_emoji=False, + thinking_start_time=time.time(), + ) + + # 处理消息 + await message.process() + + # 不知道有什么用,先留下来了,和之前那套sender一样 + _message_json = message.to_dict() + + # 发送消息 + message_set = MessageSet(chat_stream, message_id) + message_set.add_message(message) + await message_manager.add_message(message_set) + await self.storage.store_message(message, chat_stream) + logger.info(f"[私聊][{self.private_name}]PFC消息已发送: {content}") + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]PFC消息发送失败: {str(e)}") + raise diff --git a/src/chat/brain_chat/PFC/message_storage.py b/src/chat/brain_chat/PFC/message_storage.py new file mode 100644 index 00000000..cd6a01e3 --- /dev/null +++ b/src/chat/brain_chat/PFC/message_storage.py @@ -0,0 +1,119 @@ +from abc import ABC, abstractmethod +from typing import List, Dict, Any +from src.common.database import db + + +class MessageStorage(ABC): + """消息存储接口""" + + @abstractmethod + async def get_messages_after(self, chat_id: str, message: Dict[str, Any]) -> List[Dict[str, Any]]: + """获取指定消息ID之后的所有消息 + + Args: + chat_id: 聊天ID + message: 消息 + + Returns: + List[Dict[str, Any]]: 消息列表 + """ + pass + + @abstractmethod + async def get_messages_before(self, chat_id: str, time_point: float, limit: int = 5) -> List[Dict[str, Any]]: + """获取指定时间点之前的消息 + + Args: + chat_id: 聊天ID + time_point: 时间戳 + limit: 最大消息数量 + + Returns: + List[Dict[str, Any]]: 消息列表 + """ + pass + + @abstractmethod + async def has_new_messages(self, chat_id: str, after_time: float) -> bool: + """检查是否有新消息 + + Args: + chat_id: 聊天ID + after_time: 时间戳 + + Returns: + bool: 是否有新消息 + """ + pass + + +class MongoDBMessageStorage(MessageStorage): + """MongoDB消息存储实现""" + + async def get_messages_after(self, chat_id: str, message_time: float) -> List[Dict[str, Any]]: + query = {"chat_id": chat_id, "time": {"$gt": message_time}} + # print(f"storage_check_message: {message_time}") + + return list(db.messages.find(query).sort("time", 1)) + + async def get_messages_before(self, chat_id: str, time_point: float, limit: int = 5) -> List[Dict[str, Any]]: + query = {"chat_id": chat_id, "time": {"$lt": time_point}} + + messages = list(db.messages.find(query).sort("time", -1).limit(limit)) + + # 将消息按时间正序排列 + messages.reverse() + return messages + + async def has_new_messages(self, chat_id: str, after_time: float) -> bool: + query = {"chat_id": chat_id, "time": {"$gt": after_time}} + + return db.messages.find_one(query) is not None + + +# # 创建一个内存消息存储实现,用于测试 +# class InMemoryMessageStorage(MessageStorage): +# """内存消息存储实现,主要用于测试""" + +# def __init__(self): +# self.messages: Dict[str, List[Dict[str, Any]]] = {} + +# async def get_messages_after(self, chat_id: str, message_id: Optional[str] = None) -> List[Dict[str, Any]]: +# if chat_id not in self.messages: +# return [] + +# messages = self.messages[chat_id] +# if not message_id: +# return messages + +# # 找到message_id的索引 +# try: +# index = next(i for i, m in enumerate(messages) if m["message_id"] == message_id) +# return messages[index + 1:] +# except StopIteration: +# return [] + +# async def get_messages_before(self, chat_id: str, time_point: float, limit: int = 5) -> List[Dict[str, Any]]: +# if chat_id not in self.messages: +# return [] + +# messages = [ +# m for m in self.messages[chat_id] +# if m["time"] < time_point +# ] + +# return messages[-limit:] + +# async def has_new_messages(self, chat_id: str, after_time: float) -> bool: +# if chat_id not in self.messages: +# return False + +# return any(m["time"] > after_time for m in self.messages[chat_id]) + +# # 测试辅助方法 +# def add_message(self, chat_id: str, message: Dict[str, Any]): +# """添加测试消息""" +# if chat_id not in self.messages: +# self.messages[chat_id] = [] +# self.messages[chat_id].append(message) +# self.messages[chat_id].sort(key=lambda m: m["time"]) diff --git a/src/chat/brain_chat/PFC/observation_info.py b/src/chat/brain_chat/PFC/observation_info.py new file mode 100644 index 00000000..c7572955 --- /dev/null +++ b/src/chat/brain_chat/PFC/observation_info.py @@ -0,0 +1,389 @@ +from typing import List, Optional, Dict, Any, Set +from maim_message import UserInfo +import time +from src.common.logger import get_module_logger +from .chat_observer import ChatObserver +from .chat_states import NotificationHandler, NotificationType, Notification +from src.plugins.utils.chat_message_builder import build_readable_messages +import traceback # 导入 traceback 用于调试 + +logger = get_module_logger("observation_info") + + +class ObservationInfoHandler(NotificationHandler): + """ObservationInfo的通知处理器""" + + def __init__(self, observation_info: "ObservationInfo", private_name: str): + """初始化处理器 + + Args: + observation_info: 要更新的ObservationInfo实例 + private_name: 私聊对象的名称,用于日志记录 + """ + self.observation_info = observation_info + # 将 private_name 存储在 handler 实例中 + self.private_name = private_name + + async def handle_notification(self, notification: Notification): # 添加类型提示 + # 获取通知类型和数据 + notification_type = notification.type + data = notification.data + + try: # 添加错误处理块 + if notification_type == NotificationType.NEW_MESSAGE: + # 处理新消息通知 + # logger.debug(f"[私聊][{self.private_name}]收到新消息通知data: {data}") # 可以在需要时取消注释 + message_id = data.get("message_id") + processed_plain_text = data.get("processed_plain_text") + detailed_plain_text = data.get("detailed_plain_text") + user_info_dict = data.get("user_info") # 先获取字典 + time_value = data.get("time") + + # 确保 user_info 是字典类型再创建 UserInfo 对象 + user_info = None + if isinstance(user_info_dict, dict): + try: + user_info = UserInfo.from_dict(user_info_dict) + except Exception as e: + logger.error( + f"[私聊][{self.private_name}]从字典创建 UserInfo 时出错: {e}, 字典内容: {user_info_dict}" + ) + # 可以选择在这里返回或记录错误,避免后续代码出错 + return + elif user_info_dict is not None: + logger.warning( + f"[私聊][{self.private_name}]收到的 user_info 不是预期的字典类型: {type(user_info_dict)}" + ) + # 根据需要处理非字典情况,这里暂时返回 + return + + message = { + "message_id": message_id, + "processed_plain_text": processed_plain_text, + "detailed_plain_text": detailed_plain_text, + "user_info": user_info_dict, # 存储原始字典或 UserInfo 对象,取决于你的 update_from_message 如何处理 + "time": time_value, + } + # 传递 UserInfo 对象(如果成功创建)或原始字典 + await self.observation_info.update_from_message(message, user_info) # 修改:传递 user_info 对象 + + elif notification_type == NotificationType.COLD_CHAT: + # 处理冷场通知 + is_cold = data.get("is_cold", False) + await self.observation_info.update_cold_chat_status(is_cold, time.time()) # 修改:改为 await 调用 + + elif notification_type == NotificationType.ACTIVE_CHAT: + # 处理活跃通知 (通常由 COLD_CHAT 的反向状态处理) + is_active = data.get("is_active", False) + self.observation_info.is_cold = not is_active + + elif notification_type == NotificationType.BOT_SPEAKING: + # 处理机器人说话通知 (按需实现) + self.observation_info.is_typing = False + self.observation_info.last_bot_speak_time = time.time() + + elif notification_type == NotificationType.USER_SPEAKING: + # 处理用户说话通知 + self.observation_info.is_typing = False + self.observation_info.last_user_speak_time = time.time() + + elif notification_type == NotificationType.MESSAGE_DELETED: + # 处理消息删除通知 + message_id = data.get("message_id") + # 从 unprocessed_messages 中移除被删除的消息 + original_count = len(self.observation_info.unprocessed_messages) + self.observation_info.unprocessed_messages = [ + msg for msg in self.observation_info.unprocessed_messages if msg.get("message_id") != message_id + ] + if len(self.observation_info.unprocessed_messages) < original_count: + logger.info(f"[私聊][{self.private_name}]移除了未处理的消息 (ID: {message_id})") + + elif notification_type == NotificationType.USER_JOINED: + # 处理用户加入通知 (如果适用私聊场景) + user_id = data.get("user_id") + if user_id: + self.observation_info.active_users.add(str(user_id)) # 确保是字符串 + + elif notification_type == NotificationType.USER_LEFT: + # 处理用户离开通知 (如果适用私聊场景) + user_id = data.get("user_id") + if user_id: + self.observation_info.active_users.discard(str(user_id)) # 确保是字符串 + + elif notification_type == NotificationType.ERROR: + # 处理错误通知 + error_msg = data.get("error", "未提供错误信息") + logger.error(f"[私聊][{self.private_name}]收到错误通知: {error_msg}") + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]处理通知时发生错误: {e}") + logger.error(traceback.format_exc()) # 打印详细堆栈信息 + + +# @dataclass <-- 这个,不需要了(递黄瓜) +class ObservationInfo: + """决策信息类,用于收集和管理来自chat_observer的通知信息 (手动实现 __init__)""" + + # 类型提示保留,可用于文档和静态分析 + private_name: str + chat_history: List[Dict[str, Any]] + chat_history_str: str + unprocessed_messages: List[Dict[str, Any]] + active_users: Set[str] + last_bot_speak_time: Optional[float] + last_user_speak_time: Optional[float] + last_message_time: Optional[float] + last_message_id: Optional[str] + last_message_content: str + last_message_sender: Optional[str] + bot_id: Optional[str] + chat_history_count: int + new_messages_count: int + cold_chat_start_time: Optional[float] + cold_chat_duration: float + is_typing: bool + is_cold_chat: bool + changed: bool + chat_observer: Optional[ChatObserver] + handler: Optional[ObservationInfoHandler] + + def __init__(self, private_name: str): + """ + 手动初始化 ObservationInfo 的所有实例变量。 + """ + + # 接收的参数 + self.private_name: str = private_name + + # data_list + self.chat_history: List[Dict[str, Any]] = [] + self.chat_history_str: str = "" + self.unprocessed_messages: List[Dict[str, Any]] = [] + self.active_users: Set[str] = set() + + # data + self.last_bot_speak_time: Optional[float] = None + self.last_user_speak_time: Optional[float] = None + self.last_message_time: Optional[float] = None + self.last_message_id: Optional[str] = None + self.last_message_content: str = "" + self.last_message_sender: Optional[str] = None + self.bot_id: Optional[str] = None + self.chat_history_count: int = 0 + self.new_messages_count: int = 0 + self.cold_chat_start_time: Optional[float] = None + self.cold_chat_duration: float = 0.0 + + # state + self.is_typing: bool = False + self.is_cold_chat: bool = False + self.changed: bool = False + + # 关联对象 + self.chat_observer: Optional[ChatObserver] = None + + self.handler: ObservationInfoHandler = ObservationInfoHandler(self, self.private_name) + + def bind_to_chat_observer(self, chat_observer: ChatObserver): + """绑定到指定的chat_observer + + Args: + chat_observer: 要绑定的 ChatObserver 实例 + """ + if self.chat_observer: + logger.warning(f"[私聊][{self.private_name}]尝试重复绑定 ChatObserver") + return + + self.chat_observer = chat_observer + try: + if not self.handler: # 确保 handler 已经被创建 + logger.error(f"[私聊][{self.private_name}] 尝试绑定时 handler 未初始化!") + self.chat_observer = None # 重置,防止后续错误 + return + + # 注册关心的通知类型 + self.chat_observer.notification_manager.register_handler( + target="observation_info", notification_type=NotificationType.NEW_MESSAGE, handler=self.handler + ) + self.chat_observer.notification_manager.register_handler( + target="observation_info", notification_type=NotificationType.COLD_CHAT, handler=self.handler + ) + # 可以根据需要注册更多通知类型 + # self.chat_observer.notification_manager.register_handler( + # target="observation_info", notification_type=NotificationType.MESSAGE_DELETED, handler=self.handler + # ) + logger.info(f"[私聊][{self.private_name}]成功绑定到 ChatObserver") + except Exception as e: + logger.error(f"[私聊][{self.private_name}]绑定到 ChatObserver 时出错: {e}") + self.chat_observer = None # 绑定失败,重置 + + def unbind_from_chat_observer(self): + """解除与chat_observer的绑定""" + if ( + self.chat_observer and hasattr(self.chat_observer, "notification_manager") and self.handler + ): # 增加 handler 检查 + try: + self.chat_observer.notification_manager.unregister_handler( + target="observation_info", notification_type=NotificationType.NEW_MESSAGE, handler=self.handler + ) + self.chat_observer.notification_manager.unregister_handler( + target="observation_info", notification_type=NotificationType.COLD_CHAT, handler=self.handler + ) + # 如果注册了其他类型,也要在这里注销 + # self.chat_observer.notification_manager.unregister_handler( + # target="observation_info", notification_type=NotificationType.MESSAGE_DELETED, handler=self.handler + # ) + logger.info(f"[私聊][{self.private_name}]成功从 ChatObserver 解绑") + except Exception as e: + logger.error(f"[私聊][{self.private_name}]从 ChatObserver 解绑时出错: {e}") + finally: # 确保 chat_observer 被重置 + self.chat_observer = None + else: + logger.warning(f"[私聊][{self.private_name}]尝试解绑时 ChatObserver 不存在、无效或 handler 未设置") + + # 修改:update_from_message 接收 UserInfo 对象 + async def update_from_message(self, message: Dict[str, Any], user_info: Optional[UserInfo]): + """从消息更新信息 + + Args: + message: 消息数据字典 + user_info: 解析后的 UserInfo 对象 (可能为 None) + """ + message_time = message.get("time") + message_id = message.get("message_id") + processed_text = message.get("processed_plain_text", "") + + # 只有在新消息到达时才更新 last_message 相关信息 + if message_time and message_time > (self.last_message_time or 0): + self.last_message_time = message_time + self.last_message_id = message_id + self.last_message_content = processed_text + # 重置冷场计时器 + self.is_cold_chat = False + self.cold_chat_start_time = None + self.cold_chat_duration = 0.0 + + if user_info: + sender_id = str(user_info.user_id) # 确保是字符串 + self.last_message_sender = sender_id + # 更新发言时间 + if sender_id == self.bot_id: + self.last_bot_speak_time = message_time + else: + self.last_user_speak_time = message_time + self.active_users.add(sender_id) # 用户发言则认为其活跃 + else: + logger.warning( + f"[私聊][{self.private_name}]处理消息更新时缺少有效的 UserInfo 对象, message_id: {message_id}" + ) + self.last_message_sender = None # 发送者未知 + + # 将原始消息字典添加到未处理列表 + self.unprocessed_messages.append(message) + self.new_messages_count = len(self.unprocessed_messages) # 直接用列表长度 + + # logger.debug(f"[私聊][{self.private_name}]消息更新: last_time={self.last_message_time}, new_count={self.new_messages_count}") + self.update_changed() # 标记状态已改变 + else: + # 如果消息时间戳不是最新的,可能不需要处理,或者记录一个警告 + pass + # logger.warning(f"[私聊][{self.private_name}]收到过时或无效时间戳的消息: ID={message_id}, time={message_time}") + + def update_changed(self): + """标记状态已改变,并重置标记""" + # logger.debug(f"[私聊][{self.private_name}]状态标记为已改变 (changed=True)") + self.changed = True + + async def update_cold_chat_status(self, is_cold: bool, current_time: float): + """更新冷场状态 + + Args: + is_cold: 是否处于冷场状态 + current_time: 当前时间戳 + """ + if is_cold != self.is_cold_chat: # 仅在状态变化时更新 + self.is_cold_chat = is_cold + if is_cold: + # 进入冷场状态 + self.cold_chat_start_time = ( + self.last_message_time or current_time + ) # 从最后消息时间开始算,或从当前时间开始 + logger.info(f"[私聊][{self.private_name}]进入冷场状态,开始时间: {self.cold_chat_start_time}") + else: + # 结束冷场状态 + if self.cold_chat_start_time: + self.cold_chat_duration = current_time - self.cold_chat_start_time + logger.info(f"[私聊][{self.private_name}]结束冷场状态,持续时间: {self.cold_chat_duration:.2f} 秒") + self.cold_chat_start_time = None # 重置开始时间 + self.update_changed() # 状态变化,标记改变 + + # 即使状态没变,如果是冷场状态,也更新持续时间 + if self.is_cold_chat and self.cold_chat_start_time: + self.cold_chat_duration = current_time - self.cold_chat_start_time + + def get_active_duration(self) -> float: + """获取当前活跃时长 (距离最后一条消息的时间) + + Returns: + float: 最后一条消息到现在的时长(秒) + """ + if not self.last_message_time: + return 0.0 + return time.time() - self.last_message_time + + def get_user_response_time(self) -> Optional[float]: + """获取用户最后响应时间 (距离用户最后发言的时间) + + Returns: + Optional[float]: 用户最后发言到现在的时长(秒),如果没有用户发言则返回None + """ + if not self.last_user_speak_time: + return None + return time.time() - self.last_user_speak_time + + def get_bot_response_time(self) -> Optional[float]: + """获取机器人最后响应时间 (距离机器人最后发言的时间) + + Returns: + Optional[float]: 机器人最后发言到现在的时长(秒),如果没有机器人发言则返回None + """ + if not self.last_bot_speak_time: + return None + return time.time() - self.last_bot_speak_time + + async def clear_unprocessed_messages(self): + """将未处理消息移入历史记录,并更新相关状态""" + if not self.unprocessed_messages: + return # 没有未处理消息,直接返回 + + # logger.debug(f"[私聊][{self.private_name}]处理 {len(self.unprocessed_messages)} 条未处理消息...") + # 将未处理消息添加到历史记录中 (确保历史记录有长度限制,避免无限增长) + max_history_len = 100 # 示例:最多保留100条历史记录 + self.chat_history.extend(self.unprocessed_messages) + if len(self.chat_history) > max_history_len: + self.chat_history = self.chat_history[-max_history_len:] + + # 更新历史记录字符串 (只使用最近一部分生成,例如20条) + history_slice_for_str = self.chat_history[-20:] + try: + self.chat_history_str = await build_readable_messages( + history_slice_for_str, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, # read_mark 可能需要根据逻辑调整 + ) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]构建聊天记录字符串时出错: {e}") + self.chat_history_str = "[构建聊天记录出错]" # 提供错误提示 + + # 清空未处理消息列表和计数 + # cleared_count = len(self.unprocessed_messages) + self.unprocessed_messages.clear() + self.new_messages_count = 0 + # self.has_unread_messages = False # 这个状态可以通过 new_messages_count 判断 + + self.chat_history_count = len(self.chat_history) # 更新历史记录总数 + # logger.debug(f"[私聊][{self.private_name}]已处理 {cleared_count} 条消息,当前历史记录 {self.chat_history_count} 条。") + + self.update_changed() # 状态改变 diff --git a/src/chat/brain_chat/PFC/pfc.py b/src/chat/brain_chat/PFC/pfc.py new file mode 100644 index 00000000..b17ee21d --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc.py @@ -0,0 +1,345 @@ +from typing import List, Tuple, TYPE_CHECKING +from src.common.logger import get_module_logger +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from .chat_observer import ChatObserver +from .pfc_utils import get_items_from_json +from src.individuality.individuality import Individuality +from .conversation_info import ConversationInfo +from .observation_info import ObservationInfo +from src.plugins.utils.chat_message_builder import build_readable_messages +from rich.traceback import install + +install(extra_lines=3) + +if TYPE_CHECKING: + pass + +logger = get_module_logger("pfc") + + +def _calculate_similarity(goal1: str, goal2: str) -> float: + """简单计算两个目标之间的相似度 + + 这里使用一个简单的实现,实际可以使用更复杂的文本相似度算法 + + Args: + goal1: 第一个目标 + goal2: 第二个目标 + + Returns: + float: 相似度得分 (0-1) + """ + # 简单实现:检查重叠字数比例 + words1 = set(goal1) + words2 = set(goal2) + overlap = len(words1.intersection(words2)) + total = len(words1.union(words2)) + return overlap / total if total > 0 else 0 + + +class GoalAnalyzer: + """对话目标分析器""" + + def __init__(self, stream_id: str, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_normal, temperature=0.7, max_tokens=1000, request_type="conversation_goal" + ) + + self.personality_info = Individuality.get_instance().get_prompt(x_person=2, level=3) + self.name = global_config.BOT_NICKNAME + self.nick_name = global_config.BOT_ALIAS_NAMES + self.private_name = private_name + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + + # 多目标存储结构 + self.goals = [] # 存储多个目标 + self.max_goals = 3 # 同时保持的最大目标数量 + self.current_goal_and_reason = None + + async def analyze_goal(self, conversation_info: ConversationInfo, observation_info: ObservationInfo): + """分析对话历史并设定目标 + + Args: + conversation_info: 对话信息 + observation_info: 观察信息 + + Returns: + Tuple[str, str, str]: (目标, 方法, 原因) + """ + # 构建对话目标 + goals_str = "" + if conversation_info.goal_list: + for goal_reason in conversation_info.goal_list: + if isinstance(goal_reason, dict): + goal = goal_reason.get("goal", "目标内容缺失") + reasoning = goal_reason.get("reasoning", "没有明确原因") + else: + goal = str(goal_reason) + reasoning = "没有明确原因" + + goal_str = f"目标:{goal},产生该对话目标的原因:{reasoning}\n" + goals_str += goal_str + else: + goal = "目前没有明确对话目标" + reasoning = "目前没有明确对话目标,最好思考一个对话目标" + goals_str = f"目标:{goal},产生该对话目标的原因:{reasoning}\n" + + # 获取聊天历史记录 + chat_history_text = observation_info.chat_history_str + + if observation_info.new_messages_count > 0: + new_messages_list = observation_info.unprocessed_messages + new_messages_str = await build_readable_messages( + new_messages_list, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + chat_history_text += f"\n--- 以下是 {observation_info.new_messages_count} 条新消息 ---\n{new_messages_str}" + + # await observation_info.clear_unprocessed_messages() + + persona_text = f"你的名字是{self.name},{self.personality_info}。" + # 构建action历史文本 + action_history_list = conversation_info.done_action + action_history_text = "你之前做的事情是:" + for action in action_history_list: + action_history_text += f"{action}\n" + + prompt = f"""{persona_text}。现在你在参与一场QQ聊天,请分析以下聊天记录,并根据你的性格特征确定多个明确的对话目标。 +这些目标应该反映出对话的不同方面和意图。 + +{action_history_text} +当前对话目标: +{goals_str} + +聊天记录: +{chat_history_text} + +请分析当前对话并确定最适合的对话目标。你可以: +1. 保持现有目标不变 +2. 修改现有目标 +3. 添加新目标 +4. 删除不再相关的目标 +5. 如果你想结束对话,请设置一个目标,目标goal为"结束对话",原因reasoning为你希望结束对话 + +请以JSON数组格式输出当前的所有对话目标,每个目标包含以下字段: +1. goal: 对话目标(简短的一句话) +2. reasoning: 对话原因,为什么设定这个目标(简要解释) + +输出格式示例: +[ +{{ + "goal": "回答用户关于Python编程的具体问题", + "reasoning": "用户提出了关于Python的技术问题,需要专业且准确的解答" +}}, +{{ + "goal": "回答用户关于python安装的具体问题", + "reasoning": "用户提出了关于Python的技术问题,需要专业且准确的解答" +}} +]""" + + logger.debug(f"[私聊][{self.private_name}]发送到LLM的提示词: {prompt}") + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]LLM原始返回内容: {content}") + except Exception as e: + logger.error(f"[私聊][{self.private_name}]分析对话目标时出错: {str(e)}") + content = "" + + # 使用改进后的get_items_from_json函数处理JSON数组 + success, result = get_items_from_json( + content, + self.private_name, + "goal", + "reasoning", + required_types={"goal": str, "reasoning": str}, + allow_array=True, + ) + + if success: + # 判断结果是单个字典还是字典列表 + if isinstance(result, list): + # 清空现有目标列表并添加新目标 + conversation_info.goal_list = [] + for item in result: + conversation_info.goal_list.append(item) + + # 返回第一个目标作为当前主要目标(如果有) + if result: + first_goal = result[0] + return first_goal.get("goal", ""), "", first_goal.get("reasoning", "") + else: + # 单个目标的情况 + conversation_info.goal_list.append(result) + return goal, "", reasoning + + # 如果解析失败,返回默认值 + return "", "", "" + + async def _update_goals(self, new_goal: str, method: str, reasoning: str): + """更新目标列表 + + Args: + new_goal: 新的目标 + method: 实现目标的方法 + reasoning: 目标的原因 + """ + # 检查新目标是否与现有目标相似 + for i, (existing_goal, _, _) in enumerate(self.goals): + if _calculate_similarity(new_goal, existing_goal) > 0.7: # 相似度阈值 + # 更新现有目标 + self.goals[i] = (new_goal, method, reasoning) + # 将此目标移到列表前面(最主要的位置) + self.goals.insert(0, self.goals.pop(i)) + return + + # 添加新目标到列表前面 + self.goals.insert(0, (new_goal, method, reasoning)) + + # 限制目标数量 + if len(self.goals) > self.max_goals: + self.goals.pop() # 移除最老的目标 + + async def get_all_goals(self) -> List[Tuple[str, str, str]]: + """获取所有当前目标 + + Returns: + List[Tuple[str, str, str]]: 目标列表,每项为(目标, 方法, 原因) + """ + return self.goals.copy() + + async def get_alternative_goals(self) -> List[Tuple[str, str, str]]: + """获取除了当前主要目标外的其他备选目标 + + Returns: + List[Tuple[str, str, str]]: 备选目标列表 + """ + if len(self.goals) <= 1: + return [] + return self.goals[1:].copy() + + async def analyze_conversation(self, goal, reasoning): + messages = self.chat_observer.get_cached_messages() + chat_history_text = await build_readable_messages( + messages, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + + persona_text = f"你的名字是{self.name},{self.personality_info}。" + # ===> Persona 文本构建结束 <=== + + # --- 修改 Prompt 字符串,使用 persona_text --- + prompt = f"""{persona_text}。现在你在参与一场QQ聊天, + 当前对话目标:{goal} + 产生该对话目标的原因:{reasoning} + + 请分析以下聊天记录,并根据你的性格特征评估该目标是否已经达到,或者你是否希望停止该次对话。 + 聊天记录: + {chat_history_text} + 请以JSON格式输出,包含以下字段: + 1. goal_achieved: 对话目标是否已经达到(true/false) + 2. stop_conversation: 是否希望停止该次对话(true/false) + 3. reason: 为什么希望停止该次对话(简要解释) + +输出格式示例: +{{ + "goal_achieved": true, + "stop_conversation": false, + "reason": "虽然目标已达成,但对话仍然有继续的价值" +}}""" + + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]LLM原始返回内容: {content}") + + # 尝试解析JSON + success, result = get_items_from_json( + content, + self.private_name, + "goal_achieved", + "stop_conversation", + "reason", + required_types={"goal_achieved": bool, "stop_conversation": bool, "reason": str}, + ) + + if not success: + logger.error(f"[私聊][{self.private_name}]无法解析对话分析结果JSON") + return False, False, "解析结果失败" + + goal_achieved = result["goal_achieved"] + stop_conversation = result["stop_conversation"] + reason = result["reason"] + + return goal_achieved, stop_conversation, reason + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]分析对话状态时出错: {str(e)}") + return False, False, f"分析出错: {str(e)}" + + +# 先注释掉,万一以后出问题了还能开回来((( +# class DirectMessageSender: +# """直接发送消息到平台的发送器""" + +# def __init__(self, private_name: str): +# self.logger = get_module_logger("direct_sender") +# self.storage = MessageStorage() +# self.private_name = private_name + +# async def send_via_ws(self, message: MessageSending) -> None: +# try: +# await global_api.send_message(message) +# except Exception as e: +# raise ValueError(f"未找到平台:{message.message_info.platform} 的url配置,请检查配置文件") from e + +# async def send_message( +# self, +# chat_stream: ChatStream, +# content: str, +# reply_to_message: Optional[Message] = None, +# ) -> None: +# """直接发送消息到平台 + +# Args: +# chat_stream: 聊天流 +# content: 消息内容 +# reply_to_message: 要回复的消息 +# """ +# # 构建消息对象 +# message_segment = Seg(type="text", data=content) +# bot_user_info = UserInfo( +# user_id=global_config.BOT_QQ, +# user_nickname=global_config.BOT_NICKNAME, +# platform=chat_stream.platform, +# ) + +# message = MessageSending( +# message_id=f"dm{round(time.time(), 2)}", +# chat_stream=chat_stream, +# bot_user_info=bot_user_info, +# sender_info=reply_to_message.message_info.user_info if reply_to_message else None, +# message_segment=message_segment, +# reply=reply_to_message, +# is_head=True, +# is_emoji=False, +# thinking_start_time=time.time(), +# ) + +# # 处理消息 +# await message.process() + +# _message_json = message.to_dict() + +# # 发送消息 +# try: +# await self.send_via_ws(message) +# await self.storage.store_message(message, chat_stream) +# logger.success(f"[私聊][{self.private_name}]PFC消息已发送: {content}") +# except Exception as e: +# logger.error(f"[私聊][{self.private_name}]PFC消息发送失败: {str(e)}") diff --git a/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py b/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py new file mode 100644 index 00000000..0989339d --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py @@ -0,0 +1,85 @@ +from typing import List, Tuple +from src.common.logger import get_module_logger +from src.plugins.memory_system.Hippocampus import HippocampusManager +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from ..chat.message import Message +from ..knowledge.knowledge_lib import qa_manager +from ..utils.chat_message_builder import build_readable_messages + +logger = get_module_logger("knowledge_fetcher") + + +class KnowledgeFetcher: + """知识调取器""" + + def __init__(self, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_normal, + temperature=global_config.llm_normal["temp"], + max_tokens=1000, + request_type="knowledge_fetch", + ) + self.private_name = private_name + + def _lpmm_get_knowledge(self, query: str) -> str: + """获取相关知识 + + Args: + query: 查询内容 + + Returns: + str: 构造好的,带相关度的知识 + """ + + logger.debug(f"[私聊][{self.private_name}]正在从LPMM知识库中获取知识") + try: + knowledge_info = qa_manager.get_knowledge(query) + logger.debug(f"[私聊][{self.private_name}]LPMM知识库查询结果: {knowledge_info:150}") + return knowledge_info + except Exception as e: + logger.error(f"[私聊][{self.private_name}]LPMM知识库搜索工具执行失败: {str(e)}") + return "未找到匹配的知识" + + async def fetch(self, query: str, chat_history: List[Message]) -> Tuple[str, str]: + """获取相关知识 + + Args: + query: 查询内容 + chat_history: 聊天历史 + + Returns: + Tuple[str, str]: (获取的知识, 知识来源) + """ + # 构建查询上下文 + chat_history_text = await build_readable_messages( + chat_history, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + + # 从记忆中获取相关知识 + related_memory = await HippocampusManager.get_instance().get_memory_from_text( + text=f"{query}\n{chat_history_text}", + max_memory_num=3, + max_memory_length=2, + max_depth=3, + fast_retrieval=False, + ) + knowledge_text = "" + sources_text = "无记忆匹配" # 默认值 + if related_memory: + sources = [] + for memory in related_memory: + knowledge_text += memory[1] + "\n" + sources.append(f"记忆片段{memory[0]}") + knowledge_text = knowledge_text.strip() + sources_text = ",".join(sources) + + knowledge_text += "\n现在有以下**知识**可供参考:\n " + knowledge_text += self._lpmm_get_knowledge(query) + knowledge_text += "\n请记住这些**知识**,并根据**知识**回答问题。\n" + + return knowledge_text or "未找到相关知识", sources_text or "无记忆匹配" diff --git a/src/chat/brain_chat/PFC/pfc_manager.py b/src/chat/brain_chat/PFC/pfc_manager.py new file mode 100644 index 00000000..7837606c --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc_manager.py @@ -0,0 +1,115 @@ +import time +from typing import Dict, Optional +from src.common.logger import get_module_logger +from .conversation import Conversation +import traceback + +logger = get_module_logger("pfc_manager") + + +class PFCManager: + """PFC对话管理器,负责管理所有对话实例""" + + # 单例模式 + _instance = None + + # 会话实例管理 + _instances: Dict[str, Conversation] = {} + _initializing: Dict[str, bool] = {} + + @classmethod + def get_instance(cls) -> "PFCManager": + """获取管理器单例 + + Returns: + PFCManager: 管理器实例 + """ + if cls._instance is None: + cls._instance = PFCManager() + return cls._instance + + async def get_or_create_conversation(self, stream_id: str, private_name: str) -> Optional[Conversation]: + """获取或创建对话实例 + + Args: + stream_id: 聊天流ID + private_name: 私聊名称 + + Returns: + Optional[Conversation]: 对话实例,创建失败则返回None + """ + # 检查是否已经有实例 + if stream_id in self._initializing and self._initializing[stream_id]: + logger.debug(f"[私聊][{private_name}]会话实例正在初始化中: {stream_id}") + return None + + if stream_id in self._instances and self._instances[stream_id].should_continue: + logger.debug(f"[私聊][{private_name}]使用现有会话实例: {stream_id}") + return self._instances[stream_id] + if stream_id in self._instances: + instance = self._instances[stream_id] + if ( + hasattr(instance, "ignore_until_timestamp") + and instance.ignore_until_timestamp + and time.time() < instance.ignore_until_timestamp + ): + logger.debug(f"[私聊][{private_name}]会话实例当前处于忽略状态: {stream_id}") + # 返回 None 阻止交互。或者可以返回实例但标记它被忽略了喵? + # 还是返回 None 吧喵。 + return None + + # 检查 should_continue 状态 + if instance.should_continue: + logger.debug(f"[私聊][{private_name}]使用现有会话实例: {stream_id}") + return instance + # else: 实例存在但不应继续 + try: + # 创建新实例 + logger.info(f"[私聊][{private_name}]创建新的对话实例: {stream_id}") + self._initializing[stream_id] = True + # 创建实例 + conversation_instance = Conversation(stream_id, private_name) + self._instances[stream_id] = conversation_instance + + # 启动实例初始化 + await self._initialize_conversation(conversation_instance) + except Exception as e: + logger.error(f"[私聊][{private_name}]创建会话实例失败: {stream_id}, 错误: {e}") + return None + + return conversation_instance + + async def _initialize_conversation(self, conversation: Conversation): + """初始化会话实例 + + Args: + conversation: 要初始化的会话实例 + """ + stream_id = conversation.stream_id + private_name = conversation.private_name + + try: + logger.info(f"[私聊][{private_name}]开始初始化会话实例: {stream_id}") + # 启动初始化流程 + await conversation._initialize() + + # 标记初始化完成 + self._initializing[stream_id] = False + + logger.info(f"[私聊][{private_name}]会话实例 {stream_id} 初始化完成") + + except Exception as e: + logger.error(f"[私聊][{private_name}]管理器初始化会话实例失败: {stream_id}, 错误: {e}") + logger.error(f"[私聊][{private_name}]{traceback.format_exc()}") + # 清理失败的初始化 + + async def get_conversation(self, stream_id: str) -> Optional[Conversation]: + """获取已存在的会话实例 + + Args: + stream_id: 聊天流ID + + Returns: + Optional[Conversation]: 会话实例,不存在则返回None + """ + return self._instances.get(stream_id) diff --git a/src/chat/brain_chat/PFC/pfc_types.py b/src/chat/brain_chat/PFC/pfc_types.py new file mode 100644 index 00000000..0ea5eda6 --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc_types.py @@ -0,0 +1,23 @@ +from enum import Enum +from typing import Literal + + +class ConversationState(Enum): + """对话状态""" + + INIT = "初始化" + RETHINKING = "重新思考" + ANALYZING = "分析历史" + PLANNING = "规划目标" + GENERATING = "生成回复" + CHECKING = "检查回复" + SENDING = "发送消息" + FETCHING = "获取知识" + WAITING = "等待" + LISTENING = "倾听" + ENDED = "结束" + JUDGING = "判断" + IGNORED = "屏蔽" + + +ActionType = Literal["direct_reply", "fetch_knowledge", "wait"] diff --git a/src/chat/brain_chat/PFC/pfc_utils.py b/src/chat/brain_chat/PFC/pfc_utils.py new file mode 100644 index 00000000..2f7bd5e0 --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc_utils.py @@ -0,0 +1,127 @@ +import json +import re +from typing import Dict, Any, Optional, Tuple, List, Union +from src.common.logger import get_module_logger + +logger = get_module_logger("pfc_utils") + + +def get_items_from_json( + content: str, + private_name: str, + *items: str, + default_values: Optional[Dict[str, Any]] = None, + required_types: Optional[Dict[str, type]] = None, + allow_array: bool = True, +) -> Tuple[bool, Union[Dict[str, Any], List[Dict[str, Any]]]]: + """从文本中提取JSON内容并获取指定字段 + + Args: + content: 包含JSON的文本 + private_name: 私聊名称 + *items: 要提取的字段名 + default_values: 字段的默认值,格式为 {字段名: 默认值} + required_types: 字段的必需类型,格式为 {字段名: 类型} + allow_array: 是否允许解析JSON数组 + + Returns: + Tuple[bool, Union[Dict[str, Any], List[Dict[str, Any]]]]: (是否成功, 提取的字段字典或字典列表) + """ + content = content.strip() + result = {} + + # 设置默认值 + if default_values: + result.update(default_values) + + # 首先尝试解析为JSON数组 + if allow_array: + try: + # 尝试找到文本中的JSON数组 + array_pattern = r"\[[\s\S]*\]" + array_match = re.search(array_pattern, content) + if array_match: + array_content = array_match.group() + json_array = json.loads(array_content) + + # 确认是数组类型 + if isinstance(json_array, list): + # 验证数组中的每个项目是否包含所有必需字段 + valid_items = [] + for item in json_array: + if not isinstance(item, dict): + continue + + # 检查是否有所有必需字段 + if all(field in item for field in items): + # 验证字段类型 + if required_types: + type_valid = True + for field, expected_type in required_types.items(): + if field in item and not isinstance(item[field], expected_type): + type_valid = False + break + + if not type_valid: + continue + + # 验证字符串字段不为空 + string_valid = True + for field in items: + if isinstance(item[field], str) and not item[field].strip(): + string_valid = False + break + + if not string_valid: + continue + + valid_items.append(item) + + if valid_items: + return True, valid_items + except json.JSONDecodeError: + logger.debug(f"[私聊][{private_name}]JSON数组解析失败,尝试解析单个JSON对象") + except Exception as e: + logger.debug(f"[私聊][{private_name}]尝试解析JSON数组时出错: {str(e)}") + + # 尝试解析JSON对象 + try: + json_data = json.loads(content) + except json.JSONDecodeError: + # 如果直接解析失败,尝试查找和提取JSON部分 + json_pattern = r"\{[^{}]*\}" + json_match = re.search(json_pattern, content) + if json_match: + try: + json_data = json.loads(json_match.group()) + except json.JSONDecodeError: + logger.error(f"[私聊][{private_name}]提取的JSON内容解析失败") + return False, result + else: + logger.error(f"[私聊][{private_name}]无法在返回内容中找到有效的JSON") + return False, result + + # 提取字段 + for item in items: + if item in json_data: + result[item] = json_data[item] + + # 验证必需字段 + if not all(item in result for item in items): + logger.error(f"[私聊][{private_name}]JSON缺少必要字段,实际内容: {json_data}") + return False, result + + # 验证字段类型 + if required_types: + for field, expected_type in required_types.items(): + if field in result and not isinstance(result[field], expected_type): + logger.error(f"[私聊][{private_name}]{field} 必须是 {expected_type.__name__} 类型") + return False, result + + # 验证字符串字段不为空 + for field in items: + if isinstance(result[field], str) and not result[field].strip(): + logger.error(f"[私聊][{private_name}]{field} 不能为空") + return False, result + + return True, result diff --git a/src/chat/brain_chat/PFC/reply_checker.py b/src/chat/brain_chat/PFC/reply_checker.py new file mode 100644 index 00000000..35e9af50 --- /dev/null +++ b/src/chat/brain_chat/PFC/reply_checker.py @@ -0,0 +1,183 @@ +import json +from typing import Tuple, List, Dict, Any +from src.common.logger import get_module_logger +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from .chat_observer import ChatObserver +from maim_message import UserInfo + +logger = get_module_logger("reply_checker") + + +class ReplyChecker: + """回复检查器""" + + def __init__(self, stream_id: str, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_PFC_reply_checker, temperature=0.50, max_tokens=1000, request_type="reply_check" + ) + self.name = global_config.BOT_NICKNAME + self.private_name = private_name + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + self.max_retries = 3 # 最大重试次数 + + async def check( + self, reply: str, goal: str, chat_history: List[Dict[str, Any]], chat_history_text: str, retry_count: int = 0 + ) -> Tuple[bool, str, bool]: + """检查生成的回复是否合适 + + Args: + reply: 生成的回复 + goal: 对话目标 + chat_history: 对话历史记录 + chat_history_text: 对话历史记录文本 + retry_count: 当前重试次数 + + Returns: + Tuple[bool, str, bool]: (是否合适, 原因, 是否需要重新规划) + """ + # 不再从 observer 获取,直接使用传入的 chat_history + # messages = self.chat_observer.get_cached_messages(limit=20) + try: + # 筛选出最近由 Bot 自己发送的消息 + bot_messages = [] + for msg in reversed(chat_history): + user_info = UserInfo.from_dict(msg.get("user_info", {})) + if str(user_info.user_id) == str(global_config.BOT_QQ): # 确保比较的是字符串 + bot_messages.append(msg.get("processed_plain_text", "")) + if len(bot_messages) >= 2: # 只和最近的两条比较 + break + # 进行比较 + if bot_messages: + # 可以用简单比较,或者更复杂的相似度库 (如 difflib) + # 简单比较:是否完全相同 + if reply == bot_messages[0]: # 和最近一条完全一样 + logger.warning( + f"[私聊][{self.private_name}]ReplyChecker 检测到回复与上一条 Bot 消息完全相同: '{reply}'" + ) + return ( + False, + "被逻辑检查拒绝:回复内容与你上一条发言完全相同,可以选择深入话题或寻找其它话题或等待", + True, + ) # 不合适,需要返回至决策层 + # 2. 相似度检查 (如果精确匹配未通过) + import difflib # 导入 difflib 库 + + # 计算编辑距离相似度,ratio() 返回 0 到 1 之间的浮点数 + similarity_ratio = difflib.SequenceMatcher(None, reply, bot_messages[0]).ratio() + logger.debug(f"[私聊][{self.private_name}]ReplyChecker - 相似度: {similarity_ratio:.2f}") + + # 设置一个相似度阈值 + similarity_threshold = 0.9 + if similarity_ratio > similarity_threshold: + logger.warning( + f"[私聊][{self.private_name}]ReplyChecker 检测到回复与上一条 Bot 消息高度相似 (相似度 {similarity_ratio:.2f}): '{reply}'" + ) + return ( + False, + f"被逻辑检查拒绝:回复内容与你上一条发言高度相似 (相似度 {similarity_ratio:.2f}),可以选择深入话题或寻找其它话题或等待。", + True, + ) + + except Exception as e: + import traceback + + logger.error(f"[私聊][{self.private_name}]检查回复时出错: 类型={type(e)}, 值={e}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") # 打印详细的回溯信息 + + prompt = f"""你是一个聊天逻辑检查器,请检查以下回复或消息是否合适: + +当前对话目标:{goal} +最新的对话记录: +{chat_history_text} + +待检查的消息: +{reply} + +请结合聊天记录检查以下几点: +1. 这条消息是否依然符合当前对话目标和实现方式 +2. 这条消息是否与最新的对话记录保持一致性 +3. 是否存在重复发言,或重复表达同质内容(尤其是只是换一种方式表达了相同的含义) +4. 这条消息是否包含违规内容(例如血腥暴力,政治敏感等) +5. 这条消息是否以发送者的角度发言(不要让发送者自己回复自己的消息) +6. 这条消息是否通俗易懂 +7. 这条消息是否有些多余,例如在对方没有回复的情况下,依然连续多次“消息轰炸”(尤其是已经连续发送3条信息的情况,这很可能不合理,需要着重判断) +8. 这条消息是否使用了完全没必要的修辞 +9. 这条消息是否逻辑通顺 +10. 这条消息是否太过冗长了(通常私聊的每条消息长度在20字以内,除非特殊情况) +11. 在连续多次发送消息的情况下,这条消息是否衔接自然,会不会显得奇怪(例如连续两条消息中部分内容重叠) + +请以JSON格式输出,包含以下字段: +1. suitable: 是否合适 (true/false) +2. reason: 原因说明 +3. need_replan: 是否需要重新决策 (true/false),当你认为此时已经不适合发消息,需要规划其它行动时,设为true + +输出格式示例: +{{ + "suitable": true, + "reason": "回复符合要求,虽然有可能略微偏离目标,但是整体内容流畅得体", + "need_replan": false +}} + +注意:请严格按照JSON格式输出,不要包含任何其他内容。""" + + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]检查回复的原始返回: {content}") + + # 清理内容,尝试提取JSON部分 + content = content.strip() + try: + # 尝试直接解析 + result = json.loads(content) + except json.JSONDecodeError: + # 如果直接解析失败,尝试查找和提取JSON部分 + import re + + json_pattern = r"\{[^{}]*\}" + json_match = re.search(json_pattern, content) + if json_match: + try: + result = json.loads(json_match.group()) + except json.JSONDecodeError: + # 如果JSON解析失败,尝试从文本中提取结果 + is_suitable = "不合适" not in content.lower() and "违规" not in content.lower() + reason = content[:100] if content else "无法解析响应" + need_replan = "重新规划" in content.lower() or "目标不适合" in content.lower() + return is_suitable, reason, need_replan + else: + # 如果找不到JSON,从文本中判断 + is_suitable = "不合适" not in content.lower() and "违规" not in content.lower() + reason = content[:100] if content else "无法解析响应" + need_replan = "重新规划" in content.lower() or "目标不适合" in content.lower() + return is_suitable, reason, need_replan + + # 验证JSON字段 + suitable = result.get("suitable", None) + reason = result.get("reason", "未提供原因") + need_replan = result.get("need_replan", False) + + # 如果suitable字段是字符串,转换为布尔值 + if isinstance(suitable, str): + suitable = suitable.lower() == "true" + + # 如果suitable字段不存在或不是布尔值,从reason中判断 + if suitable is None: + suitable = "不合适" not in reason.lower() and "违规" not in reason.lower() + + # 如果不合适且未达到最大重试次数,返回需要重试 + if not suitable and retry_count < self.max_retries: + return False, reason, False + + # 如果不合适且已达到最大重试次数,返回需要重新规划 + if not suitable and retry_count >= self.max_retries: + return False, f"多次重试后仍不合适: {reason}", True + + return suitable, reason, need_replan + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]检查回复时出错: {e}") + # 如果出错且已达到最大重试次数,建议重新规划 + if retry_count >= self.max_retries: + return False, "多次检查失败,建议重新规划", True + return False, f"检查过程出错,建议重试: {str(e)}", False diff --git a/src/chat/brain_chat/PFC/reply_generator.py b/src/chat/brain_chat/PFC/reply_generator.py new file mode 100644 index 00000000..890f807c --- /dev/null +++ b/src/chat/brain_chat/PFC/reply_generator.py @@ -0,0 +1,228 @@ +from typing import Tuple, List, Dict, Any +from src.common.logger import get_module_logger +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from .chat_observer import ChatObserver +from .reply_checker import ReplyChecker +from src.individuality.individuality import Individuality +from .observation_info import ObservationInfo +from .conversation_info import ConversationInfo +from src.plugins.utils.chat_message_builder import build_readable_messages + +logger = get_module_logger("reply_generator") + +# --- 定义 Prompt 模板 --- + +# Prompt for direct_reply (首次回复) +PROMPT_DIRECT_REPLY = """{persona_text}。现在你在参与一场QQ私聊,请根据以下信息生成一条回复: + +当前对话目标:{goals_str} + +{knowledge_info_str} + +最近的聊天记录: +{chat_history_text} + + +请根据上述信息,结合聊天记录,回复对方。该回复应该: +1. 符合对话目标,以"你"的角度发言(不要自己与自己对话!) +2. 符合你的性格特征和身份细节 +3. 通俗易懂,自然流畅,像正常聊天一样,简短(通常20字以内,除非特殊情况) +4. 可以适当利用相关知识,但不要生硬引用 +5. 自然、得体,结合聊天记录逻辑合理,且没有重复表达同质内容 + +请注意把握聊天内容,不要回复的太有条理,可以有个性。请分清"你"和对方说的话,不要把"你"说的话当做对方说的话,这是你自己说的话。 +可以回复得自然随意自然一些,就像真人一样,注意把握聊天内容,整体风格可以平和、简短,不要刻意突出自身学科背景,不要说你说过的话,可以简短,多简短都可以,但是避免冗长。 +请你注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 +不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 + +请直接输出回复内容,不需要任何额外格式。""" + +# Prompt for send_new_message (追问/补充) +PROMPT_SEND_NEW_MESSAGE = """{persona_text}。现在你在参与一场QQ私聊,**刚刚你已经发送了一条或多条消息**,现在请根据以下信息再发一条新消息: + +当前对话目标:{goals_str} + +{knowledge_info_str} + +最近的聊天记录: +{chat_history_text} + + +请根据上述信息,结合聊天记录,继续发一条新消息(例如对之前消息的补充,深入话题,或追问等等)。该消息应该: +1. 符合对话目标,以"你"的角度发言(不要自己与自己对话!) +2. 符合你的性格特征和身份细节 +3. 通俗易懂,自然流畅,像正常聊天一样,简短(通常20字以内,除非特殊情况) +4. 可以适当利用相关知识,但不要生硬引用 +5. 跟之前你发的消息自然的衔接,逻辑合理,且没有重复表达同质内容或部分重叠内容 + +请注意把握聊天内容,不用太有条理,可以有个性。请分清"你"和对方说的话,不要把"你"说的话当做对方说的话,这是你自己说的话。 +这条消息可以自然随意自然一些,就像真人一样,注意把握聊天内容,整体风格可以平和、简短,不要刻意突出自身学科背景,不要说你说过的话,可以简短,多简短都可以,但是避免冗长。 +请你注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出消息内容。 +不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 + +请直接输出回复内容,不需要任何额外格式。""" + +# Prompt for say_goodbye (告别语生成) +PROMPT_FAREWELL = """{persona_text}。你在参与一场 QQ 私聊,现在对话似乎已经结束,你决定再发一条最后的消息来圆满结束。 + +最近的聊天记录: +{chat_history_text} + +请根据上述信息,结合聊天记录,构思一条**简短、自然、符合你人设**的最后的消息。 +这条消息应该: +1. 从你自己的角度发言。 +2. 符合你的性格特征和身份细节。 +3. 通俗易懂,自然流畅,通常很简短。 +4. 自然地为这场对话画上句号,避免开启新话题或显得冗长、刻意。 + +请像真人一样随意自然,**简洁是关键**。 +不要输出多余内容(包括前后缀、冒号、引号、括号、表情包、at或@等)。 + +请直接输出最终的告别消息内容,不需要任何额外格式。""" + + +class ReplyGenerator: + """回复生成器""" + + def __init__(self, stream_id: str, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_PFC_chat, + temperature=global_config.llm_PFC_chat["temp"], + max_tokens=300, + request_type="reply_generation", + ) + self.personality_info = Individuality.get_instance().get_prompt(x_person=2, level=3) + self.name = global_config.BOT_NICKNAME + self.private_name = private_name + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + self.reply_checker = ReplyChecker(stream_id, private_name) + + # 修改 generate 方法签名,增加 action_type 参数 + async def generate( + self, observation_info: ObservationInfo, conversation_info: ConversationInfo, action_type: str + ) -> str: + """生成回复 + + Args: + observation_info: 观察信息 + conversation_info: 对话信息 + action_type: 当前执行的动作类型 ('direct_reply' 或 'send_new_message') + + Returns: + str: 生成的回复 + """ + # 构建提示词 + logger.debug( + f"[私聊][{self.private_name}]开始生成回复 (动作类型: {action_type}):当前目标: {conversation_info.goal_list}" + ) + + # --- 构建通用 Prompt 参数 --- + # (这部分逻辑基本不变) + + # 构建对话目标 (goals_str) + goals_str = "" + if conversation_info.goal_list: + for goal_reason in conversation_info.goal_list: + if isinstance(goal_reason, dict): + goal = goal_reason.get("goal", "目标内容缺失") + reasoning = goal_reason.get("reasoning", "没有明确原因") + else: + goal = str(goal_reason) + reasoning = "没有明确原因" + + goal = str(goal) if goal is not None else "目标内容缺失" + reasoning = str(reasoning) if reasoning is not None else "没有明确原因" + goals_str += f"- 目标:{goal}\n 原因:{reasoning}\n" + else: + goals_str = "- 目前没有明确对话目标\n" # 简化无目标情况 + + # --- 新增:构建知识信息字符串 --- + knowledge_info_str = "【供参考的相关知识和记忆】\n" # 稍微改下标题,表明是供参考 + try: + # 检查 conversation_info 是否有 knowledge_list 并且不为空 + if hasattr(conversation_info, "knowledge_list") and conversation_info.knowledge_list: + # 最多只显示最近的 5 条知识 + recent_knowledge = conversation_info.knowledge_list[-5:] + for i, knowledge_item in enumerate(recent_knowledge): + if isinstance(knowledge_item, dict): + query = knowledge_item.get("query", "未知查询") + knowledge = knowledge_item.get("knowledge", "无知识内容") + source = knowledge_item.get("source", "未知来源") + # 只取知识内容的前 2000 个字 + knowledge_snippet = knowledge[:2000] + "..." if len(knowledge) > 2000 else knowledge + knowledge_info_str += ( + f"{i + 1}. 关于 '{query}' (来源: {source}): {knowledge_snippet}\n" # 格式微调,更简洁 + ) + else: + knowledge_info_str += f"{i + 1}. 发现一条格式不正确的知识记录。\n" + + if not recent_knowledge: + knowledge_info_str += "- 暂无。\n" # 更简洁的提示 + + else: + knowledge_info_str += "- 暂无。\n" + except AttributeError: + logger.warning(f"[私聊][{self.private_name}]ConversationInfo 对象可能缺少 knowledge_list 属性。") + knowledge_info_str += "- 获取知识列表时出错。\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}]构建知识信息字符串时出错: {e}") + knowledge_info_str += "- 处理知识列表时出错。\n" + + # 获取聊天历史记录 (chat_history_text) + chat_history_text = observation_info.chat_history_str + if observation_info.new_messages_count > 0 and observation_info.unprocessed_messages: + new_messages_list = observation_info.unprocessed_messages + new_messages_str = await build_readable_messages( + new_messages_list, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + chat_history_text += f"\n--- 以下是 {observation_info.new_messages_count} 条新消息 ---\n{new_messages_str}" + elif not chat_history_text: + chat_history_text = "还没有聊天记录。" + + # 构建 Persona 文本 (persona_text) + persona_text = f"你的名字是{self.name},{self.personality_info}。" + + # --- 选择 Prompt --- + if action_type == "send_new_message": + prompt_template = PROMPT_SEND_NEW_MESSAGE + logger.info(f"[私聊][{self.private_name}]使用 PROMPT_SEND_NEW_MESSAGE (追问生成)") + elif action_type == "say_goodbye": # 处理告别动作 + prompt_template = PROMPT_FAREWELL + logger.info(f"[私聊][{self.private_name}]使用 PROMPT_FAREWELL (告别语生成)") + else: # 默认使用 direct_reply 的 prompt (包括 'direct_reply' 或其他未明确处理的类型) + prompt_template = PROMPT_DIRECT_REPLY + logger.info(f"[私聊][{self.private_name}]使用 PROMPT_DIRECT_REPLY (首次/非连续回复生成)") + + # --- 格式化最终的 Prompt --- + prompt = prompt_template.format( + persona_text=persona_text, + goals_str=goals_str, + chat_history_text=chat_history_text, + knowledge_info_str=knowledge_info_str, + ) + + # --- 调用 LLM 生成 --- + logger.debug(f"[私聊][{self.private_name}]发送到LLM的生成提示词:\n------\n{prompt}\n------") + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]生成的回复: {content}") + # 移除旧的检查新消息逻辑,这应该由 conversation 控制流处理 + return content + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]生成回复时出错: {e}") + return "抱歉,我现在有点混乱,让我重新思考一下..." + + # check_reply 方法保持不变 + async def check_reply( + self, reply: str, goal: str, chat_history: List[Dict[str, Any]], chat_history_str: str, retry_count: int = 0 + ) -> Tuple[bool, str, bool]: + """检查回复是否合适 + (此方法逻辑保持不变) + """ + return await self.reply_checker.check(reply, goal, chat_history, chat_history_str, retry_count) diff --git a/src/chat/brain_chat/PFC/waiter.py b/src/chat/brain_chat/PFC/waiter.py new file mode 100644 index 00000000..0f5881fc --- /dev/null +++ b/src/chat/brain_chat/PFC/waiter.py @@ -0,0 +1,79 @@ +from src.common.logger import get_module_logger +from .chat_observer import ChatObserver +from .conversation_info import ConversationInfo + +# from src.individuality.individuality import Individuality # 不再需要 +from ...config.config import global_config +import time +import asyncio + +logger = get_module_logger("waiter") + +# --- 在这里设定你想要的超时时间(秒) --- +# 例如: 120 秒 = 2 分钟 +DESIRED_TIMEOUT_SECONDS = 300 + + +class Waiter: + """等待处理类""" + + def __init__(self, stream_id: str, private_name: str): + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + self.name = global_config.BOT_NICKNAME + self.private_name = private_name + # self.wait_accumulated_time = 0 # 不再需要累加计时 + + async def wait(self, conversation_info: ConversationInfo) -> bool: + """等待用户新消息或超时""" + wait_start_time = time.time() + logger.info(f"[私聊][{self.private_name}]进入常规等待状态 (超时: {DESIRED_TIMEOUT_SECONDS} 秒)...") + + while True: + # 检查是否有新消息 + if self.chat_observer.new_message_after(wait_start_time): + logger.info(f"[私聊][{self.private_name}]等待结束,收到新消息") + return False # 返回 False 表示不是超时 + + # 检查是否超时 + elapsed_time = time.time() - wait_start_time + if elapsed_time > DESIRED_TIMEOUT_SECONDS: + logger.info(f"[私聊][{self.private_name}]等待超过 {DESIRED_TIMEOUT_SECONDS} 秒...添加思考目标。") + wait_goal = { + "goal": f"你等待了{elapsed_time / 60:.1f}分钟,注意可能在对方看来聊天已经结束,思考接下来要做什么", + "reasoning": "对方很久没有回复你的消息了", + } + conversation_info.goal_list.append(wait_goal) + logger.info(f"[私聊][{self.private_name}]添加目标: {wait_goal}") + return True # 返回 True 表示超时 + + await asyncio.sleep(5) # 每 5 秒检查一次 + logger.debug( + f"[私聊][{self.private_name}]等待中..." + ) # 可以考虑把这个频繁日志注释掉,只在超时或收到消息时输出 + + async def wait_listening(self, conversation_info: ConversationInfo) -> bool: + """倾听用户发言或超时""" + wait_start_time = time.time() + logger.info(f"[私聊][{self.private_name}]进入倾听等待状态 (超时: {DESIRED_TIMEOUT_SECONDS} 秒)...") + + while True: + # 检查是否有新消息 + if self.chat_observer.new_message_after(wait_start_time): + logger.info(f"[私聊][{self.private_name}]倾听等待结束,收到新消息") + return False # 返回 False 表示不是超时 + + # 检查是否超时 + elapsed_time = time.time() - wait_start_time + if elapsed_time > DESIRED_TIMEOUT_SECONDS: + logger.info(f"[私聊][{self.private_name}]倾听等待超过 {DESIRED_TIMEOUT_SECONDS} 秒...添加思考目标。") + wait_goal = { + # 保持 goal 文本一致 + "goal": f"你等待了{elapsed_time / 60:.1f}分钟,对方似乎话说一半突然消失了,可能忙去了?也可能忘记了回复?要问问吗?还是结束对话?或继续等待?思考接下来要做什么", + "reasoning": "对方话说一半消失了,很久没有回复", + } + conversation_info.goal_list.append(wait_goal) + logger.info(f"[私聊][{self.private_name}]添加目标: {wait_goal}") + return True # 返回 True 表示超时 + + await asyncio.sleep(5) # 每 5 秒检查一次 + logger.debug(f"[私聊][{self.private_name}]倾听等待中...") # 同上,可以考虑注释掉 diff --git a/src/chat/brain_chat/brain_chat.py b/src/chat/brain_chat/brain_chat.py index 8702248f..9945b496 100644 --- a/src/chat/brain_chat/brain_chat.py +++ b/src/chat/brain_chat/brain_chat.py @@ -96,6 +96,9 @@ class BrainChatting: self.more_plan = False + # 最近一次是否成功进行了 reply,用于选择 BrainPlanner 的 Prompt + self._last_successful_reply: bool = False + async def start(self): """检查是否需要启动主循环,如果未激活则启动。""" @@ -157,6 +160,7 @@ class BrainChatting: ) async def _loopbody(self): # sourcery skip: hoist-if-from-if + # 获取最新消息(用于上下文,但不影响是否调用 observe) recent_messages_list = message_api.get_messages_by_time_in_chat( chat_id=self.stream_id, start_time=self.last_read_time, @@ -165,17 +169,25 @@ class BrainChatting: limit_mode="latest", filter_mai=True, filter_command=False, - filter_no_read_command=True, + filter_intercept_message_level=1, ) + # 如果有新消息,更新 last_read_time if len(recent_messages_list) >= 1: self.last_read_time = time.time() - await self._observe(recent_messages_list=recent_messages_list) - - else: - # Normal模式:消息数量不足,等待 - await asyncio.sleep(0.2) - return True + + # 总是执行一次思考迭代(不管有没有新消息) + # wait 动作会在其内部等待,不需要在这里处理 + should_continue = await self._observe(recent_messages_list=recent_messages_list) + + if not should_continue: + # 选择了 complete_talk,返回 False 表示需要等待新消息 + return False + + # 继续下一次迭代(除非选择了 complete_talk) + # 短暂等待后再继续,避免过于频繁的循环 + await asyncio.sleep(0.1) + return True async def _send_and_store_reply( @@ -272,14 +284,16 @@ class BrainChatting: except Exception as e: logger.error(f"{self.log_prefix} 动作修改失败: {e}") - # 执行planner + # 获取必要信息 is_group_chat, chat_target_info, _ = self.action_planner.get_necessary_info() + # 一次思考迭代:Think - Act - Observe + # 获取聊天上下文 message_list_before_now = get_raw_msg_before_timestamp_with_chat( chat_id=self.stream_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.6), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_content_block, message_id_list = build_readable_messages_with_id( messages=message_list_before_now, @@ -290,12 +304,12 @@ class BrainChatting: ) prompt_info = await self.action_planner.build_planner_prompt( - is_group_chat=is_group_chat, chat_target_info=chat_target_info, current_available_actions=available_actions, chat_content_block=chat_content_block, message_id_list=message_id_list, interest=global_config.personality.interest, + prompt_key="brain_planner_prompt_react", ) continue_flag, modified_message = await events_manager.handle_mai_events( EventType.ON_PLAN, None, prompt_info[0], None, self.chat_stream.stream_id @@ -311,7 +325,12 @@ class BrainChatting: available_actions=available_actions, ) - # 3. 并行执行所有动作 + # 检查是否有 complete_talk 动作(会停止后续迭代) + has_complete_talk = any( + action.action_type == "complete_talk" for action in action_to_use_info + ) + + # 并行执行所有动作 action_tasks = [ asyncio.create_task( self._execute_action(action, action_to_use_info, thinking_id, available_actions, cycle_timers) @@ -343,7 +362,14 @@ class BrainChatting: else: logger.warning(f"{self.log_prefix} 回复动作执行失败") - # 构建最终的循环信息 + # 更新观察时间标记 + self.action_planner.last_obs_time_mark = time.time() + + # 如果选择了 complete_talk,标记为完成,不再继续迭代 + if has_complete_talk: + logger.info(f"{self.log_prefix} 检测到 complete_talk 动作,本次思考完成") + + # 构建循环信息 if reply_loop_info: # 如果有回复信息,使用回复的loop_info作为基础 loop_info = reply_loop_info @@ -369,10 +395,16 @@ class BrainChatting: } _reply_text = action_reply_text + # 如果选择了 complete_talk,返回 False 以停止 _loopbody 的循环 + # 否则返回 True,让 _loopbody 继续下一次迭代 + should_continue = not has_complete_talk + self.end_cycle(loop_info, cycle_timers) self.print_cycle_info(cycle_timers) - return True + # 如果选择了 complete_talk,返回 False 停止循环 + # 否则返回 True,继续下一次思考迭代 + return should_continue async def _main_chat_loop(self): """主循环,持续进行计划并可能回复消息,直到被外部取消。""" @@ -380,9 +412,13 @@ class BrainChatting: while self.running: # 主循环 success = await self._loopbody() - await asyncio.sleep(0.1) if not success: - break + # 选择了 complete,等待新消息 + logger.info(f"{self.log_prefix} 选择了 complete,等待新消息...") + await self._wait_for_new_message() + # 有新消息后继续循环 + continue + await asyncio.sleep(0.1) except asyncio.CancelledError: # 设置了关闭标志位后被取消是正常流程 logger.info(f"{self.log_prefix} 麦麦已关闭聊天") @@ -392,6 +428,33 @@ class BrainChatting: await asyncio.sleep(3) self._loop_task = asyncio.create_task(self._main_chat_loop()) logger.error(f"{self.log_prefix} 结束了当前聊天循环") + + async def _wait_for_new_message(self): + """等待新消息到达""" + last_check_time = self.last_read_time + check_interval = 1.0 # 每秒检查一次 + + while self.running: + # 检查是否有新消息 + recent_messages_list = message_api.get_messages_by_time_in_chat( + chat_id=self.stream_id, + start_time=last_check_time, + end_time=time.time(), + limit=20, + limit_mode="latest", + filter_mai=True, + filter_command=False, + filter_intercept_message_level=1, + ) + + # 如果有新消息,更新 last_read_time 并返回 + if len(recent_messages_list) >= 1: + self.last_read_time = time.time() + logger.info(f"{self.log_prefix} 检测到新消息,恢复循环") + return + + # 等待一段时间后再次检查 + await asyncio.sleep(check_interval) async def _handle_action( self, @@ -506,12 +569,12 @@ class BrainChatting: """执行单个动作的通用函数""" try: with Timer(f"动作{action_planner_info.action_type}", cycle_timers): - if action_planner_info.action_type == "no_reply": - # 直接处理no_reply逻辑,不再通过动作系统 - reason = action_planner_info.reasoning or "选择不回复" - # logger.info(f"{self.log_prefix} 选择不回复,原因: {reason}") + if action_planner_info.action_type == "complete_talk": + # 直接处理complete_talk逻辑,不再通过动作系统 + reason = action_planner_info.reasoning or "选择完成对话" + logger.info(f"{self.log_prefix} 选择完成对话,原因: {reason}") - # 存储no_reply信息到数据库 + # 存储complete_talk信息到数据库 await database_api.store_action_info( chat_stream=self.chat_stream, action_build_into_prompt=False, @@ -519,9 +582,9 @@ class BrainChatting: action_done=True, thinking_id=thinking_id, action_data={"reason": reason}, - action_name="no_reply", + action_name="complete_talk", ) - return {"action_type": "no_reply", "success": True, "reply_text": "", "command": ""} + return {"action_type": "complete_talk", "success": True, "reply_text": "", "command": ""} elif action_planner_info.action_type == "reply": try: @@ -543,11 +606,17 @@ class BrainChatting: ) else: logger.info("回复生成失败") - return {"action_type": "reply", "success": False, "reply_text": "", "loop_info": None} + return { + "action_type": "reply", + "success": False, + "reply_text": "", + "loop_info": None, + } except asyncio.CancelledError: logger.debug(f"{self.log_prefix} 并行执行:回复生成任务已被取消") return {"action_type": "reply", "success": False, "reply_text": "", "loop_info": None} + response_set = llm_response.reply_set selected_expressions = llm_response.selected_expressions loop_info, reply_text, _ = await self._send_and_store_reply( @@ -558,6 +627,8 @@ class BrainChatting: actions=chosen_action_plan_infos, selected_expressions=selected_expressions, ) + # 标记这次循环已经成功进行了回复 + self._last_successful_reply = True return { "action_type": "reply", "success": True, @@ -567,7 +638,88 @@ class BrainChatting: # 其他动作 else: - # 执行普通动作 + # 内建 wait / listening:不通过插件系统,直接在这里处理 + if action_planner_info.action_type in ["wait", "listening"]: + reason = action_planner_info.reasoning or "" + action_data = action_planner_info.action_data or {} + + if action_planner_info.action_type == "wait": + # 获取等待时间(必填) + wait_seconds = action_data.get("wait_seconds") + if wait_seconds is None: + logger.warning(f"{self.log_prefix} wait 动作缺少 wait_seconds 参数,使用默认值 5 秒") + wait_seconds = 5 + else: + try: + wait_seconds = float(wait_seconds) + if wait_seconds < 0: + logger.warning(f"{self.log_prefix} wait_seconds 不能为负数,使用默认值 5 秒") + wait_seconds = 5 + except (ValueError, TypeError): + logger.warning(f"{self.log_prefix} wait_seconds 参数格式错误,使用默认值 5 秒") + wait_seconds = 5 + + logger.info(f"{self.log_prefix} 执行 wait 动作,等待 {wait_seconds} 秒") + + # 记录动作信息 + await database_api.store_action_info( + chat_stream=self.chat_stream, + action_build_into_prompt=False, + action_prompt_display=reason or f"等待 {wait_seconds} 秒", + action_done=True, + thinking_id=thinking_id, + action_data={"reason": reason, "wait_seconds": wait_seconds}, + action_name="wait", + ) + + # 等待指定时间 + await asyncio.sleep(wait_seconds) + + logger.info(f"{self.log_prefix} wait 动作完成,继续下一次思考") + + # 这些动作本身不产生文本回复 + self._last_successful_reply = False + return { + "action_type": "wait", + "success": True, + "reply_text": "", + "command": "", + } + + # listening 已合并到 wait,如果遇到则转换为 wait(向后兼容) + elif action_planner_info.action_type == "listening": + logger.debug(f"{self.log_prefix} 检测到 listening 动作,已合并到 wait,自动转换") + # 使用默认等待时间 + wait_seconds = 3 + + logger.info(f"{self.log_prefix} 执行 listening(转换为 wait)动作,等待 {wait_seconds} 秒") + + # 记录动作信息 + await database_api.store_action_info( + chat_stream=self.chat_stream, + action_build_into_prompt=False, + action_prompt_display=reason or f"倾听并等待 {wait_seconds} 秒", + action_done=True, + thinking_id=thinking_id, + action_data={"reason": reason, "wait_seconds": wait_seconds}, + action_name="listening", + ) + + # 等待指定时间 + await asyncio.sleep(wait_seconds) + + logger.info(f"{self.log_prefix} listening 动作完成,继续下一次思考") + + # 这些动作本身不产生文本回复 + self._last_successful_reply = False + return { + "action_type": "listening", + "success": True, + "reply_text": "", + "command": "", + } + + # 其余动作:走原有插件 Action 体系 with Timer("动作执行", cycle_timers): success, reply_text, command = await self._handle_action( action_planner_info.action_type, @@ -577,6 +729,10 @@ class BrainChatting: thinking_id, action_planner_info.action_message, ) + # 非 reply 类动作执行成功时,清空最近成功回复标记,让下一轮回到 initial Prompt + if success and action_planner_info.action_type != "reply": + self._last_successful_reply = False + return { "action_type": action_planner_info.action_type, "success": success, diff --git a/src/chat/brain_chat/brain_planner.py b/src/chat/brain_chat/brain_planner.py index 739ff106..df143ead 100644 --- a/src/chat/brain_chat/brain_planner.py +++ b/src/chat/brain_chat/brain_planner.py @@ -35,12 +35,14 @@ install(extra_lines=3) def init_prompt(): + # ReAct 形式的 Planner Prompt Prompt( """ {time_block} {name_block} 你的兴趣是:{interest} {chat_context_description},以下是具体的聊天内容 + **聊天内容** {chat_content_block} @@ -57,11 +59,35 @@ reply "reason":"回复的原因" }} -no_reply +wait 动作描述: -等待,保持沉默,等待对方发言 +暂时不再发言,等待指定时间。适用于以下情况: +- 你已经表达清楚一轮,想给对方留出空间 +- 你感觉对方的话还没说完,或者自己刚刚发了好几条连续消息 +- 你想要等待一定时间来让对方把话说完,或者等待对方反应 +- 你想保持安静,专注"听"而不是马上回复 +请你根据上下文来判断要等待多久,请你灵活判断: +- 如果你们交流间隔时间很短,聊的很频繁,不宜等待太久 +- 如果你们交流间隔时间很长,聊的很少,可以等待较长时间 {{ - "action": "no_reply", + "action": "wait", + "target_message_id":"想要作为这次等待依据的消息id(通常是对方的最新消息)", + "wait_seconds": 等待的秒数(必填,例如:5 表示等待5秒), + "reason":"选择等待的原因" +}} + +complete_talk +动作描述: +当前聊天暂时结束了,对方离开,没有更多话题了 +你可以使用该动作来暂时休息,等待对方有新发言再继续: +- 多次wait之后,对方迟迟不回复消息才用 +- 如果对方只是短暂不回复,应该使用wait而不是complete_talk +- 聊天内容显示当前聊天已经结束或者没有新内容时候,选择complete_talk +选择此动作后,将不再继续循环思考,直到收到对方的新消息 +{{ + "action": "complete_talk", + "target_message_id":"触发完成对话的消息id(通常是对方的最新消息)", + "reason":"选择完成对话的原因" }} {action_options_text} @@ -92,7 +118,7 @@ no_reply ``` """, - "brain_planner_prompt", + "brain_planner_prompt_react", ) Prompt( @@ -122,6 +148,9 @@ class BrainPlanner: ) # 用于动作规划 self.last_obs_time_mark = 0.0 + + # 计划日志记录 + self.plan_log: List[Tuple[str, float, List[ActionPlannerInfo]]] = [] def find_message_by_id( self, message_id: str, message_id_list: List[Tuple[str, "DatabaseMessages"]] @@ -152,10 +181,11 @@ class BrainPlanner: action_planner_infos = [] try: - action = action_json.get("action", "no_reply") + action = action_json.get("action", "complete_talk") + logger.debug(f"{self.log_prefix}解析动作JSON: action={action}, json={action_json}") reasoning = action_json.get("reason", "未提供原因") action_data = {key: value for key, value in action_json.items() if key not in ["action", "reason"]} - # 非no_reply动作需要target_message_id + # 非complete_talk动作需要target_message_id target_message = None if target_message_id := action_json.get("target_message_id"): @@ -171,16 +201,26 @@ class BrainPlanner: # 验证action是否可用 available_action_names = [action_name for action_name, _ in current_available_actions] - internal_action_names = ["no_reply", "reply", "wait_time"] + # 内部保留动作(不依赖插件系统) + # 注意:listening 已合并到 wait 中,如果遇到 listening 则转换为 wait + internal_action_names = ["complete_talk", "reply", "wait_time", "wait", "listening"] + + logger.debug(f"{self.log_prefix}动作验证: action={action}, internal={internal_action_names}, available={available_action_names}") + + # 将 listening 转换为 wait(向后兼容) + if action == "listening": + logger.debug(f"{self.log_prefix}检测到 listening 动作,已合并到 wait,自动转换") + action = "wait" if action not in internal_action_names and action not in available_action_names: logger.warning( - f"{self.log_prefix}LLM 返回了当前不可用或无效的动作: '{action}' (可用: {available_action_names}),将强制使用 'no_reply'" + f"{self.log_prefix}LLM 返回了当前不可用或无效的动作: '{action}' (内部动作: {internal_action_names}, 可用插件动作: {available_action_names}),将强制使用 'complete_talk'" ) reasoning = ( f"LLM 返回了当前不可用的动作 '{action}' (可用: {available_action_names})。原始理由: {reasoning}" ) - action = "no_reply" + action = "complete_talk" + logger.warning(f"{self.log_prefix}动作已转换为 complete_talk") # 创建ActionPlannerInfo对象 # 将列表转换为字典格式 @@ -201,7 +241,7 @@ class BrainPlanner: available_actions_dict = dict(current_available_actions) action_planner_infos.append( ActionPlannerInfo( - action_type="no_reply", + action_type="complete_talk", reasoning=f"解析单个action时出错: {e}", action_data={}, action_message=None, @@ -218,7 +258,7 @@ class BrainPlanner: ) -> List[ActionPlannerInfo]: # sourcery skip: use-named-expression """ - 规划器 (Planner): 使用LLM根据上下文决定做出什么动作。 + 规划器 (Planner): 使用LLM根据上下文决定做出什么动作(ReAct模式)。 """ # 获取聊天上下文 @@ -226,7 +266,7 @@ class BrainPlanner: chat_id=self.chat_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.6), - filter_no_read_command=True, + filter_intercept_message_level=1, ) message_id_list: list[Tuple[str, "DatabaseMessages"]] = [] chat_content_block, message_id_list = build_readable_messages_with_id( @@ -257,18 +297,20 @@ class BrainPlanner: logger.debug(f"{self.log_prefix}过滤后有{len(filtered_actions)}个可用动作") - # 构建包含所有动作的提示词 + # 构建包含所有动作的提示词:使用统一的 ReAct Prompt + prompt_key = "brain_planner_prompt_react" + # 这里不记录日志,避免重复打印,由调用方按需控制 log_prompt prompt, message_id_list = await self.build_planner_prompt( - is_group_chat=is_group_chat, chat_target_info=chat_target_info, current_available_actions=filtered_actions, chat_content_block=chat_content_block, message_id_list=message_id_list, interest=global_config.personality.interest, + prompt_key=prompt_key, ) # 调用LLM获取决策 - actions = await self._execute_main_planner( + reasoning, actions = await self._execute_main_planner( prompt=prompt, message_id_list=message_id_list, filtered_actions=filtered_actions, @@ -276,16 +318,22 @@ class BrainPlanner: loop_start_time=loop_start_time, ) + # 记录和展示计划日志 + logger.info( + f"{self.log_prefix}Planner: {reasoning}。选择了{len(actions)}个动作: {' '.join([a.action_type for a in actions])}" + ) + self.add_plan_log(reasoning, actions) + return actions async def build_planner_prompt( self, - is_group_chat: bool, chat_target_info: Optional["TargetPersonInfo"], current_available_actions: Dict[str, ActionInfo], message_id_list: List[Tuple[str, "DatabaseMessages"]], chat_content_block: str = "", interest: str = "", + prompt_key: str = "brain_planner_prompt_react", ) -> tuple[str, List[Tuple[str, "DatabaseMessages"]]]: """构建 Planner LLM 的提示词 (获取模板并填充数据)""" try: @@ -321,7 +369,7 @@ class BrainPlanner: name_block = f"你的名字是{bot_name}{bot_nickname},请注意哪些是你自己的发言。" # 获取主规划器模板并填充 - planner_prompt_template = await global_prompt_manager.get_prompt_async("brain_planner_prompt") + planner_prompt_template = await global_prompt_manager.get_prompt_async(prompt_key) prompt = planner_prompt_template.format( time_block=time_block, chat_context_description=chat_context_description, @@ -431,17 +479,18 @@ class BrainPlanner: filtered_actions: Dict[str, ActionInfo], available_actions: Dict[str, ActionInfo], loop_start_time: float, - ) -> List[ActionPlannerInfo]: + ) -> Tuple[str, List[ActionPlannerInfo]]: """执行主规划器""" llm_content = None actions: List[ActionPlannerInfo] = [] + extracted_reasoning = "" try: # 调用LLM llm_content, (reasoning_content, _, _) = await self.planner_llm.generate_response_async(prompt=prompt) - # logger.info(f"{self.log_prefix}规划器原始提示词: {prompt}") - # logger.info(f"{self.log_prefix}规划器原始响应: {llm_content}") + logger.info(f"{self.log_prefix}规划器原始提示词: {prompt}") + logger.info(f"{self.log_prefix}规划器原始响应: {llm_content}") if global_config.debug.show_planner_prompt: logger.info(f"{self.log_prefix}规划器原始提示词: {prompt}") @@ -456,10 +505,11 @@ class BrainPlanner: except Exception as req_e: logger.error(f"{self.log_prefix}LLM 请求执行失败: {req_e}") - return [ + extracted_reasoning = f"LLM 请求失败,模型出现问题: {req_e}" + return extracted_reasoning, [ ActionPlannerInfo( - action_type="no_reply", - reasoning=f"LLM 请求失败,模型出现问题: {req_e}", + action_type="complete_talk", + reasoning=extracted_reasoning, action_data={}, action_message=None, available_actions=available_actions, @@ -469,24 +519,32 @@ class BrainPlanner: # 解析LLM响应 if llm_content: try: - if json_objects := self._extract_json_from_markdown(llm_content): - logger.debug(f"{self.log_prefix}从响应中提取到{len(json_objects)}个JSON对象") + json_objects, extracted_reasoning = self._extract_json_from_markdown(llm_content) + if json_objects: + logger.info(f"{self.log_prefix}从响应中提取到{len(json_objects)}个JSON对象") + for i, json_obj in enumerate(json_objects): + logger.info(f"{self.log_prefix}解析第{i+1}个JSON对象: {json_obj}") filtered_actions_list = list(filtered_actions.items()) for json_obj in json_objects: - actions.extend(self._parse_single_action(json_obj, message_id_list, filtered_actions_list)) + parsed_actions = self._parse_single_action(json_obj, message_id_list, filtered_actions_list) + logger.info(f"{self.log_prefix}解析后的动作: {[a.action_type for a in parsed_actions]}") + actions.extend(parsed_actions) else: # 尝试解析为直接的JSON logger.warning(f"{self.log_prefix}LLM没有返回可用动作: {llm_content}") - actions = self._create_no_reply("LLM没有返回可用动作", available_actions) + extracted_reasoning = extracted_reasoning or "LLM没有返回可用动作" + actions = self._create_complete_talk(extracted_reasoning, available_actions) except Exception as json_e: logger.warning(f"{self.log_prefix}解析LLM响应JSON失败 {json_e}. LLM原始输出: '{llm_content}'") - actions = self._create_no_reply(f"解析LLM响应JSON失败: {json_e}", available_actions) + extracted_reasoning = f"解析LLM响应JSON失败: {json_e}" + actions = self._create_complete_talk(extracted_reasoning, available_actions) traceback.print_exc() else: - actions = self._create_no_reply("规划器没有获得LLM响应", available_actions) + extracted_reasoning = "规划器没有获得LLM响应" + actions = self._create_complete_talk(extracted_reasoning, available_actions) - # 添加循环开始时间到所有非no_reply动作 + # 添加循环开始时间到所有动作 for action in actions: action.action_data = action.action_data or {} action.action_data["loop_start_time"] = loop_start_time @@ -495,47 +553,136 @@ class BrainPlanner: f"{self.log_prefix}规划器决定执行{len(actions)}个动作: {' '.join([a.action_type for a in actions])}" ) - return actions + return extracted_reasoning, actions - def _create_no_reply(self, reasoning: str, available_actions: Dict[str, ActionInfo]) -> List[ActionPlannerInfo]: - """创建no_reply""" + def _create_complete_talk(self, reasoning: str, available_actions: Dict[str, ActionInfo]) -> List[ActionPlannerInfo]: + """创建complete_talk""" return [ ActionPlannerInfo( - action_type="no_reply", + action_type="complete_talk", reasoning=reasoning, action_data={}, action_message=None, available_actions=available_actions, ) ] + + def add_plan_log(self, reasoning: str, actions: List[ActionPlannerInfo]): + """添加计划日志""" + self.plan_log.append((reasoning, time.time(), actions)) + if len(self.plan_log) > 20: + self.plan_log.pop(0) - def _extract_json_from_markdown(self, content: str) -> List[dict]: + def _extract_json_from_markdown(self, content: str) -> Tuple[List[dict], str]: # sourcery skip: for-append-to-extend - """从Markdown格式的内容中提取JSON对象""" + """从Markdown格式的内容中提取JSON对象和推理内容""" json_objects = [] + reasoning_content = "" # 使用正则表达式查找```json包裹的JSON内容 json_pattern = r"```json\s*(.*?)\s*```" - matches = re.findall(json_pattern, content, re.DOTALL) + markdown_matches = re.findall(json_pattern, content, re.DOTALL) - for match in matches: + # 提取JSON之前的内容作为推理文本 + first_json_pos = len(content) + if markdown_matches: + # 找到第一个```json的位置 + first_json_pos = content.find("```json") + if first_json_pos > 0: + reasoning_content = content[:first_json_pos].strip() + # 清理推理内容中的注释标记 + reasoning_content = re.sub(r"^//\s*", "", reasoning_content, flags=re.MULTILINE) + reasoning_content = reasoning_content.strip() + + # 处理```json包裹的JSON + for match in markdown_matches: try: # 清理可能的注释和格式问题 json_str = re.sub(r"//.*?\n", "\n", match) # 移除单行注释 json_str = re.sub(r"/\*.*?\*/", "", json_str, flags=re.DOTALL) # 移除多行注释 if json_str := json_str.strip(): - json_obj = json.loads(repair_json(json_str)) - if isinstance(json_obj, dict): - json_objects.append(json_obj) - elif isinstance(json_obj, list): - for item in json_obj: - if isinstance(item, dict): - json_objects.append(item) + # 先尝试将整个块作为一个JSON对象或数组(适用于多行JSON) + try: + json_obj = json.loads(repair_json(json_str)) + if isinstance(json_obj, dict): + json_objects.append(json_obj) + elif isinstance(json_obj, list): + for item in json_obj: + if isinstance(item, dict): + json_objects.append(item) + except json.JSONDecodeError: + # 如果整个块解析失败,尝试按行分割(适用于多个单行JSON对象) + lines = [line.strip() for line in json_str.split("\n") if line.strip()] + for line in lines: + try: + # 尝试解析每一行作为独立的JSON对象 + json_obj = json.loads(repair_json(line)) + if isinstance(json_obj, dict): + json_objects.append(json_obj) + elif isinstance(json_obj, list): + for item in json_obj: + if isinstance(item, dict): + json_objects.append(item) + except json.JSONDecodeError: + # 单行解析失败,继续下一行 + continue except Exception as e: - logger.warning(f"解析JSON块失败: {e}, 块内容: {match[:100]}...") + logger.warning(f"{self.log_prefix}解析JSON块失败: {e}, 块内容: {match[:100]}...") continue - return json_objects + # 如果没有找到完整的```json```块,尝试查找不完整的代码块(缺少结尾```) + if not json_objects: + json_start_pos = content.find("```json") + if json_start_pos != -1: + # 找到```json之后的内容 + json_content_start = json_start_pos + 7 # ```json的长度 + # 提取从```json之后到内容结尾的所有内容 + incomplete_json_str = content[json_content_start:].strip() + + # 提取JSON之前的内容作为推理文本 + if json_start_pos > 0: + reasoning_content = content[:json_start_pos].strip() + reasoning_content = re.sub(r"^//\s*", "", reasoning_content, flags=re.MULTILINE) + reasoning_content = reasoning_content.strip() + + if incomplete_json_str: + try: + # 清理可能的注释和格式问题 + json_str = re.sub(r"//.*?\n", "\n", incomplete_json_str) + json_str = re.sub(r"/\*.*?\*/", "", json_str, flags=re.DOTALL) + json_str = json_str.strip() + + if json_str: + # 尝试按行分割,每行可能是一个JSON对象 + lines = [line.strip() for line in json_str.split("\n") if line.strip()] + for line in lines: + try: + json_obj = json.loads(repair_json(line)) + if isinstance(json_obj, dict): + json_objects.append(json_obj) + elif isinstance(json_obj, list): + for item in json_obj: + if isinstance(item, dict): + json_objects.append(item) + except json.JSONDecodeError: + pass + + # 如果按行解析没有成功,尝试将整个块作为一个JSON对象或数组 + if not json_objects: + try: + json_obj = json.loads(repair_json(json_str)) + if isinstance(json_obj, dict): + json_objects.append(json_obj) + elif isinstance(json_obj, list): + for item in json_obj: + if isinstance(item, dict): + json_objects.append(item) + except Exception as e: + logger.debug(f"尝试解析不完整的JSON代码块失败: {e}") + except Exception as e: + logger.debug(f"处理不完整的JSON代码块时出错: {e}") + + return json_objects, reasoning_content init_prompt() diff --git a/src/chat/heart_flow/heartFC_chat.py b/src/chat/heart_flow/heartFC_chat.py index 1af21a4f..eb101741 100644 --- a/src/chat/heart_flow/heartFC_chat.py +++ b/src/chat/heart_flow/heartFC_chat.py @@ -190,7 +190,7 @@ class HeartFChatting: limit_mode="latest", filter_mai=True, filter_command=False, - filter_no_read_command=True, + filter_intercept_message_level=0, ) # 根据连续 no_reply 次数动态调整阈值 @@ -485,7 +485,7 @@ class HeartFChatting: chat_id=self.stream_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.6), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_content_block, message_id_list = build_readable_messages_with_id( messages=message_list_before_now, diff --git a/src/chat/message_receive/bot.py b/src/chat/message_receive/bot.py index 2d798f54..1f5c7012 100644 --- a/src/chat/message_receive/bot.py +++ b/src/chat/message_receive/bot.py @@ -83,7 +83,7 @@ class ChatBot: self._started = True - async def _process_commands_with_new_system(self, message: MessageRecv): + async def _process_commands(self, message: MessageRecv): # sourcery skip: use-named-expression """使用新插件系统处理命令""" try: @@ -115,17 +115,17 @@ class ChatBot: try: # 执行命令 - success, response, intercept_message = await command_instance.execute() - message.is_no_read_command = bool(intercept_message) + success, response, intercept_message_level = await command_instance.execute() + message.intercept_message_level = intercept_message_level # 记录命令执行结果 if success: - logger.info(f"命令执行成功: {command_class.__name__} (拦截: {intercept_message})") + logger.info(f"命令执行成功: {command_class.__name__} (拦截等级: {intercept_message_level})") else: logger.warning(f"命令执行失败: {command_class.__name__} - {response}") # 根据命令的拦截设置决定是否继续处理消息 - return True, response, not intercept_message # 找到命令,根据intercept_message决定是否继续 + return True, response, not bool(intercept_message_level) # 找到命令,根据intercept_message决定是否继续 except Exception as e: logger.error(f"执行命令时出错: {command_class.__name__} - {e}") @@ -295,7 +295,7 @@ class ChatBot: # return # 命令处理 - 使用新插件系统检查并处理命令 - is_command, cmd_result, continue_process = await self._process_commands_with_new_system(message) + is_command, cmd_result, continue_process = await self._process_commands(message) # 如果是命令且不需要继续处理,则直接返回 if is_command and not continue_process: diff --git a/src/chat/message_receive/message.py b/src/chat/message_receive/message.py index 9528785e..727dd3b0 100644 --- a/src/chat/message_receive/message.py +++ b/src/chat/message_receive/message.py @@ -122,7 +122,7 @@ class MessageRecv(Message): self.is_notify = False self.is_command = False - self.is_no_read_command = False + self.intercept_message_level = 0 self.priority_mode = "interest" self.priority_info = None diff --git a/src/chat/message_receive/storage.py b/src/chat/message_receive/storage.py index b70f30ff..b73b04a3 100644 --- a/src/chat/message_receive/storage.py +++ b/src/chat/message_receive/storage.py @@ -72,7 +72,7 @@ class MessageStorage: key_words = "" key_words_lite = "" selected_expressions = message.selected_expressions - is_no_read_command = False + intercept_message_level = 0 else: filtered_display_message = "" interest_value = message.interest_value @@ -86,7 +86,7 @@ class MessageStorage: is_picid = message.is_picid is_notify = message.is_notify is_command = message.is_command - is_no_read_command = getattr(message, "is_no_read_command", False) + intercept_message_level = getattr(message, "intercept_message_level", 0) # 序列化关键词列表为JSON字符串 key_words = MessageStorage._serialize_keywords(message.key_words) key_words_lite = MessageStorage._serialize_keywords(message.key_words_lite) @@ -138,7 +138,7 @@ class MessageStorage: is_picid=is_picid, is_notify=is_notify, is_command=is_command, - is_no_read_command=is_no_read_command, + intercept_message_level=intercept_message_level, key_words=key_words, key_words_lite=key_words_lite, selected_expressions=selected_expressions, diff --git a/src/chat/planner_actions/action_modifier.py b/src/chat/planner_actions/action_modifier.py index 739d9999..baf1e1c5 100644 --- a/src/chat/planner_actions/action_modifier.py +++ b/src/chat/planner_actions/action_modifier.py @@ -69,7 +69,7 @@ class ActionModifier: chat_id=self.chat_stream.stream_id, timestamp=time.time(), limit=min(int(global_config.chat.max_context_size * 0.33), 10), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_content = build_readable_messages( diff --git a/src/chat/planner_actions/planner.py b/src/chat/planner_actions/planner.py index 5c498e30..f8bab0bb 100644 --- a/src/chat/planner_actions/planner.py +++ b/src/chat/planner_actions/planner.py @@ -316,7 +316,7 @@ class ActionPlanner: chat_id=self.chat_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.6), - filter_no_read_command=True, + filter_intercept_message_level=1, ) message_id_list: list[Tuple[str, "DatabaseMessages"]] = [] chat_content_block, message_id_list = build_readable_messages_with_id( diff --git a/src/chat/replyer/group_generator.py b/src/chat/replyer/group_generator.py index 3dd52272..a37925c2 100644 --- a/src/chat/replyer/group_generator.py +++ b/src/chat/replyer/group_generator.py @@ -256,7 +256,7 @@ class DefaultReplyer: logger.debug(f"使用处理器选中的{len(selected_expressions)}个表达方式") for expr in selected_expressions: if isinstance(expr, dict) and "situation" in expr and "style" in expr: - style_habits.append(f"当{expr['situation']}时,使用 {expr['style']}") + style_habits.append(f"当{expr['situation']}时:{expr['style']}") else: logger.debug("没有从处理器获得表达方式,将使用空的表达方式") # 不再在replyer中进行随机选择,全部交给处理器处理 @@ -751,14 +751,14 @@ class DefaultReplyer: chat_id=chat_id, timestamp=reply_time_point, limit=global_config.chat.max_context_size * 1, - filter_no_read_command=True, + filter_intercept_message_level=1, ) message_list_before_short = get_raw_msg_before_timestamp_with_chat( chat_id=chat_id, timestamp=reply_time_point, limit=int(global_config.chat.max_context_size * 0.33), - filter_no_read_command=True, + filter_intercept_message_level=1, ) person_list_short: List[Person] = [] @@ -941,7 +941,7 @@ class DefaultReplyer: chat_id=chat_id, timestamp=time.time(), limit=min(int(global_config.chat.max_context_size * 0.33), 15), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_talking_prompt_half = build_readable_messages( message_list_before_now_half, diff --git a/src/chat/replyer/private_generator.py b/src/chat/replyer/private_generator.py index 396e806f..726ce4b2 100644 --- a/src/chat/replyer/private_generator.py +++ b/src/chat/replyer/private_generator.py @@ -271,7 +271,7 @@ class PrivateReplyer: logger.debug(f"使用处理器选中的{len(selected_expressions)}个表达方式") for expr in selected_expressions: if isinstance(expr, dict) and "situation" in expr and "style" in expr: - style_habits.append(f"当{expr['situation']}时,使用 {expr['style']}") + style_habits.append(f"当{expr['situation']}时:{expr['style']}") else: logger.debug("没有从处理器获得表达方式,将使用空的表达方式") # 不再在replyer中进行随机选择,全部交给处理器处理 @@ -663,7 +663,7 @@ class PrivateReplyer: chat_id=chat_id, timestamp=time.time(), limit=global_config.chat.max_context_size, - filter_no_read_command=True, + filter_intercept_message_level=1, ) dialogue_prompt = build_readable_messages( @@ -678,7 +678,7 @@ class PrivateReplyer: chat_id=chat_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.33), - filter_no_read_command=True, + filter_intercept_message_level=1, ) person_list_short: List[Person] = [] @@ -878,7 +878,7 @@ class PrivateReplyer: chat_id=chat_id, timestamp=time.time(), limit=min(int(global_config.chat.max_context_size * 0.33), 15), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_talking_prompt_half = build_readable_messages( message_list_before_now_half, diff --git a/src/chat/replyer/prompt/replyer_prompt.py b/src/chat/replyer/prompt/replyer_prompt.py index f9ee25a4..c1c89124 100644 --- a/src/chat/replyer/prompt/replyer_prompt.py +++ b/src/chat/replyer/prompt/replyer_prompt.py @@ -19,7 +19,7 @@ def init_replyer_prompt(): {planner_reasoning} {identity} {chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些,{mood_state} -尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理,可以有个性。 +尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。 {reply_style} 请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出一句回复内容就好。 不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 @@ -39,7 +39,7 @@ def init_replyer_prompt(): {planner_reasoning} {identity} {chat_prompt}你正在和{sender_name}聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些,{mood_state} -尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理,可以有个性。 +尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。 {reply_style} 请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 {moderation_prompt}不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。""", diff --git a/src/chat/utils/chat_message_builder.py b/src/chat/utils/chat_message_builder.py index 5592e6cf..6a634fb4 100644 --- a/src/chat/utils/chat_message_builder.py +++ b/src/chat/utils/chat_message_builder.py @@ -120,7 +120,7 @@ def get_raw_msg_by_timestamp_with_chat( limit_mode: str = "latest", filter_bot=False, filter_command=False, - filter_no_read_command=False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """获取在特定聊天从指定时间戳到指定时间戳的消息,按时间升序排序,返回消息列表 limit: 限制返回的消息数量,0为不限制 @@ -138,7 +138,7 @@ def get_raw_msg_by_timestamp_with_chat( limit_mode=limit_mode, filter_bot=filter_bot, filter_command=filter_command, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) @@ -150,7 +150,7 @@ def get_raw_msg_by_timestamp_with_chat_inclusive( limit_mode: str = "latest", filter_bot=False, filter_command=False, - filter_no_read_command=False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """获取在特定聊天从指定时间戳到指定时间戳的消息(包含边界),按时间升序排序,返回消息列表 limit: 限制返回的消息数量,0为不限制 @@ -167,7 +167,7 @@ def get_raw_msg_by_timestamp_with_chat_inclusive( limit_mode=limit_mode, filter_bot=filter_bot, filter_command=filter_command, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) @@ -303,7 +303,7 @@ def get_raw_msg_before_timestamp(timestamp: float, limit: int = 0) -> List[Datab def get_raw_msg_before_timestamp_with_chat( - chat_id: str, timestamp: float, limit: int = 0, filter_no_read_command: bool = False + chat_id: str, timestamp: float, limit: int = 0, filter_intercept_message_level: Optional[int] = None ) -> List[DatabaseMessages]: """获取指定时间戳之前的消息,按时间升序排序,返回消息列表 limit: 限制返回的消息数量,0为不限制 @@ -311,7 +311,7 @@ def get_raw_msg_before_timestamp_with_chat( filter_query = {"chat_id": chat_id, "time": {"$lt": timestamp}} sort_order = [("time", 1)] return find_messages( - message_filter=filter_query, sort=sort_order, limit=limit, filter_no_read_command=filter_no_read_command + message_filter=filter_query, sort=sort_order, limit=limit, filter_intercept_message_level=filter_intercept_message_level ) diff --git a/src/chat/utils/statistic.py b/src/chat/utils/statistic.py index fa865d48..4a115e06 100644 --- a/src/chat/utils/statistic.py +++ b/src/chat/utils/statistic.py @@ -8,7 +8,7 @@ from typing import Any, Dict, Tuple, List from src.common.logger import get_logger from src.common.database.database import db -from src.common.database.database_model import OnlineTime, LLMUsage, Messages +from src.common.database.database_model import OnlineTime, LLMUsage, Messages, ActionRecords from src.manager.async_task_manager import AsyncTask from src.manager.local_store_manager import local_storage from src.config.config import global_config @@ -505,13 +505,6 @@ class StatisticOutputTask(AsyncTask): for period_key, _ in collect_period } - # 获取bot的QQ账号 - bot_qq_account = ( - str(global_config.bot.qq_account) - if hasattr(global_config, "bot") and hasattr(global_config.bot, "qq_account") - else "" - ) - query_start_timestamp = collect_period[-1][1].timestamp() # Messages.time is a DoubleField (timestamp) for message in Messages.select().where(Messages.time >= query_start_timestamp): # type: ignore message_time_ts = message.time # This is a float timestamp @@ -537,7 +530,7 @@ class StatisticOutputTask(AsyncTask): if not chat_id: # Should not happen if above logic is correct continue - # Update name_mapping + # Update name_mapping(仅用于展示聊天名称) try: if chat_id in self.name_mapping: if chat_name != self.name_mapping[chat_id][0] and message_time_ts > self.name_mapping[chat_id][1]: @@ -549,19 +542,30 @@ class StatisticOutputTask(AsyncTask): # 重置为正确的格式 self.name_mapping[chat_id] = (chat_name, message_time_ts) - # 检查是否是bot发送的消息(回复) - is_bot_reply = False - if bot_qq_account and message.user_id == bot_qq_account: - is_bot_reply = True - for idx, (_, period_start_dt) in enumerate(collect_period): if message_time_ts >= period_start_dt.timestamp(): for period_key, _ in collect_period[idx:]: stats[period_key][TOTAL_MSG_CNT] += 1 stats[period_key][MSG_CNT_BY_CHAT][chat_id] += 1 - if is_bot_reply: - stats[period_key][TOTAL_REPLY_CNT] += 1 break + + # 使用 ActionRecords 中的 reply 动作次数作为回复数基准 + try: + action_query_start_timestamp = collect_period[-1][1].timestamp() + for action in ActionRecords.select().where(ActionRecords.time >= action_query_start_timestamp): # type: ignore + # 仅统计已完成的 reply 动作 + if action.action_name != "reply" or not action.action_done: + continue + + action_time_ts = action.time + for idx, (_, period_start_dt) in enumerate(collect_period): + if action_time_ts >= period_start_dt.timestamp(): + for period_key, _ in collect_period[idx:]: + stats[period_key][TOTAL_REPLY_CNT] += 1 + break + except Exception as e: + logger.warning(f"统计 reply 动作次数失败,将回复数视为 0,错误信息:{e}") + return stats def _collect_all_statistics(self, now: datetime) -> Dict[str, Dict[str, Any]]: diff --git a/src/common/data_models/database_data_model.py b/src/common/data_models/database_data_model.py index 9707add8..5bd35873 100644 --- a/src/common/data_models/database_data_model.py +++ b/src/common/data_models/database_data_model.py @@ -77,7 +77,7 @@ class DatabaseMessages(BaseDataModel): is_emoji: bool = False, is_picid: bool = False, is_command: bool = False, - is_no_read_command: bool = False, + intercept_message_level: int = 0, is_notify: bool = False, selected_expressions: Optional[str] = None, user_id: str = "", @@ -120,7 +120,7 @@ class DatabaseMessages(BaseDataModel): self.is_emoji = is_emoji self.is_picid = is_picid self.is_command = is_command - self.is_no_read_command = is_no_read_command + self.intercept_message_level = intercept_message_level self.is_notify = is_notify self.selected_expressions = selected_expressions @@ -188,7 +188,7 @@ class DatabaseMessages(BaseDataModel): "is_emoji": self.is_emoji, "is_picid": self.is_picid, "is_command": self.is_command, - "is_no_read_command": self.is_no_read_command, + "intercept_message_level": self.intercept_message_level, "is_notify": self.is_notify, "selected_expressions": self.selected_expressions, "user_id": self.user_info.user_id, diff --git a/src/common/data_models/message_data_model.py b/src/common/data_models/message_data_model.py index 3c4c5505..dc4ad951 100644 --- a/src/common/data_models/message_data_model.py +++ b/src/common/data_models/message_data_model.py @@ -22,7 +22,7 @@ class MessageAndActionModel(BaseDataModel): is_action_record: bool = field(default=False) action_name: Optional[str] = None is_command: bool = field(default=False) - is_no_read_command: bool = field(default=False) + intercept_message_level: int = field(default=0) @classmethod def from_DatabaseMessages(cls, message: "DatabaseMessages"): @@ -37,7 +37,7 @@ class MessageAndActionModel(BaseDataModel): display_message=message.display_message, chat_info_platform=message.chat_info.platform, is_command=message.is_command, - is_no_read_command=getattr(message, "is_no_read_command", False), + intercept_message_level=getattr(message, "intercept_message_level", 0), ) diff --git a/src/common/database/database_model.py b/src/common/database/database_model.py index 9b1f18df..5e916317 100644 --- a/src/common/database/database_model.py +++ b/src/common/database/database_model.py @@ -170,7 +170,7 @@ class Messages(BaseModel): is_emoji = BooleanField(default=False) is_picid = BooleanField(default=False) is_command = BooleanField(default=False) - is_no_read_command = BooleanField(default=False) + intercept_message_level = IntegerField(default=0) is_notify = BooleanField(default=False) selected_expressions = TextField(null=True) @@ -324,7 +324,6 @@ class Expression(BaseModel): # new mode fields context = TextField(null=True) - up_content = TextField(null=True) content_list = TextField(null=True) count = IntegerField(default=1) diff --git a/src/common/message_repository.py b/src/common/message_repository.py index 4b9c6b35..fa0126d4 100644 --- a/src/common/message_repository.py +++ b/src/common/message_repository.py @@ -25,7 +25,7 @@ def find_messages( limit_mode: str = "latest", filter_bot=False, filter_command=False, - filter_no_read_command=False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """ 根据提供的过滤器、排序和限制条件查找消息。 @@ -85,8 +85,9 @@ def find_messages( # 使用按位取反构造 Peewee 的 NOT 条件,避免直接与 False 比较 query = query.where(~Messages.is_command) - if filter_no_read_command: - query = query.where(~Messages.is_no_read_command) + if filter_intercept_message_level is not None: + # 过滤掉所有 intercept_message_level > filter_intercept_message_level 的消息 + query = query.where(Messages.intercept_message_level <= filter_intercept_message_level) if limit > 0: if limit_mode == "earliest": diff --git a/src/common/toml_utils.py b/src/common/toml_utils.py index 0c34f6ab..0a88c458 100644 --- a/src/common/toml_utils.py +++ b/src/common/toml_utils.py @@ -4,6 +4,7 @@ TOML 工具函数 提供 TOML 文件的格式化保存功能,确保数组等元素以美观的多行格式输出。 """ +import re from typing import Any import tomlkit from tomlkit.items import AoT, Table, Array @@ -54,14 +55,71 @@ def _format_toml_value(obj: Any, threshold: int, depth: int = 0) -> Any: return obj -def save_toml_with_format(data: Any, file_path: str, multiline_threshold: int = 1) -> None: - """格式化 TOML 数据并保存到文件""" +def _update_toml_doc(target: Any, source: Any) -> None: + """ + 递归合并字典,将 source 的值更新到 target 中,保留 target 的注释和格式。 + - 已存在的键:更新值(递归处理嵌套字典) + - 新增的键:添加到 target + - 跳过 version 字段 + """ + if isinstance(source, list) or not isinstance(source, dict) or not isinstance(target, dict): + return + + for key, value in source.items(): + if key == "version": + continue + if key in target: + # 已存在的键:递归更新或直接赋值 + target_value = target[key] + if isinstance(value, dict) and isinstance(target_value, dict): + _update_toml_doc(target_value, value) + else: + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + else: + # 新增的键:添加到 target + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + + +def save_toml_with_format( + data: Any, file_path: str, multiline_threshold: int = 1, preserve_comments: bool = True +) -> None: + """ + 格式化 TOML 数据并保存到文件。 + + Args: + data: 要保存的数据(dict 或 tomlkit 文档) + file_path: 保存路径 + multiline_threshold: 数组多行格式化阈值,-1 表示不格式化 + preserve_comments: 是否保留原文件的注释和格式(默认 True) + 若为 True 且文件已存在且 data 不是 tomlkit 文档,会先读取原文件,再将 data 合并进去 + """ + import os + from tomlkit import TOMLDocument + + # 如果需要保留注释、文件存在、且 data 不是已有的 tomlkit 文档,先读取原文件再合并 + if preserve_comments and os.path.exists(file_path) and not isinstance(data, TOMLDocument): + with open(file_path, "r", encoding="utf-8") as f: + doc = tomlkit.load(f) + _update_toml_doc(doc, data) + data = doc + formatted = _format_toml_value(data, multiline_threshold) if multiline_threshold >= 0 else data + output = tomlkit.dumps(formatted) + # 规范化:将 3+ 连续空行压缩为 1 个空行,防止空行累积 + output = re.sub(r'\n{3,}', '\n\n', output) with open(file_path, "w", encoding="utf-8") as f: - tomlkit.dump(formatted, f) + f.write(output) def format_toml_string(data: Any, multiline_threshold: int = 1) -> str: """格式化 TOML 数据并返回字符串""" formatted = _format_toml_value(data, multiline_threshold) if multiline_threshold >= 0 else data - return tomlkit.dumps(formatted) \ No newline at end of file + output = tomlkit.dumps(formatted) + # 规范化:将 3+ 连续空行压缩为 1 个空行,防止空行累积 + return re.sub(r'\n{3,}', '\n\n', output) \ No newline at end of file diff --git a/src/config/api_ada_configs.py b/src/config/api_ada_configs.py index 897e1f87..a5d5f059 100644 --- a/src/config/api_ada_configs.py +++ b/src/config/api_ada_configs.py @@ -60,6 +60,12 @@ class ModelInfo(ConfigBase): price_out: float = field(default=0.0) """每M token输出价格""" + temperature: float | None = field(default=None) + """模型级别温度(可选),会覆盖任务配置中的温度""" + + max_tokens: int | None = field(default=None) + """模型级别最大token数(可选),会覆盖任务配置中的max_tokens""" + force_stream_mode: bool = field(default=False) """是否强制使用流式输出模式""" diff --git a/src/config/config.py b/src/config/config.py index 9342cbb4..061fa07d 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -35,6 +35,7 @@ from src.config.official_configs import ( MemoryConfig, DebugConfig, JargonConfig, + DreamConfig, ) from .api_ada_configs import ( @@ -57,7 +58,7 @@ TEMPLATE_DIR = os.path.join(PROJECT_ROOT, "template") # 考虑到,实际上配置文件中的mai_version是不会自动更新的,所以采用硬编码 # 对该字段的更新,请严格参照语义化版本规范:https://semver.org/lang/zh-CN/ -MMC_VERSION = "0.11.6" +MMC_VERSION = "0.11.7-snapshot.1" def get_key_comment(toml_table, key): @@ -357,6 +358,7 @@ class Config(ConfigBase): mood: MoodConfig voice: VoiceConfig jargon: JargonConfig + dream: DreamConfig @dataclass diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 5e88f8cd..3e93b56f 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -173,7 +173,11 @@ class ChatConfig(ConfigBase): def get_talk_value(self, chat_id: Optional[str]) -> float: """根据规则返回当前 chat 的动态 talk_value,未匹配则回退到基础值。""" if not self.enable_talk_value_rules or not self.talk_value_rules: - return self.talk_value + result = self.talk_value + # 防止返回0值,自动转换为0.0001 + if result == 0: + return 0.0000001 + return result now_min = self._now_minutes() @@ -199,7 +203,11 @@ class ChatConfig(ConfigBase): start_min, end_min = parsed if self._in_range(now_min, start_min, end_min): try: - return float(value) + result = float(value) + # 防止返回0值,自动转换为0.0001 + if result == 0: + return 0.0000001 + return result except Exception: continue @@ -218,12 +226,20 @@ class ChatConfig(ConfigBase): start_min, end_min = parsed if self._in_range(now_min, start_min, end_min): try: - return float(value) + result = float(value) + # 防止返回0值,自动转换为0.0001 + if result == 0: + return 0.0000001 + return result except Exception: continue # 3) 未命中规则返回基础值 - return self.talk_value + result = self.talk_value + # 防止返回0值,自动转换为0.0001 + if result == 0: + return 0.0000001 + return result @dataclass @@ -247,6 +263,9 @@ class MemoryConfig(ConfigBase): enable_jargon_detection: bool = True """记忆检索过程中是否启用黑话识别""" + global_memory: bool = False + """是否允许记忆检索在聊天记录中进行全局查询(忽略当前chat_id,仅对 search_chat_history 等工具生效)""" + def __post_init__(self): """验证配置值""" if self.max_agent_iterations < 1: @@ -342,22 +361,30 @@ class ExpressionConfig(ConfigBase): tuple: (是否使用表达, 是否学习表达, 学习间隔) """ if not self.learning_list: - # 如果没有配置,使用默认值:启用表达,启用学习,300秒间隔 - return True, True, 300 + # 如果没有配置,使用默认值:启用表达,启用学习,学习强度1.0(对应300秒间隔) + return True, True, 1.0 # 优先检查聊天流特定的配置 if chat_stream_id: specific_expression_config = self._get_stream_specific_config(chat_stream_id) if specific_expression_config is not None: - return specific_expression_config + use_expression, enable_learning, learning_intensity = specific_expression_config + # 防止学习强度为0,自动转换为0.0001 + if learning_intensity == 0: + learning_intensity = 0.0000001 + return use_expression, enable_learning, learning_intensity # 检查全局配置(第一个元素为空字符串的配置) global_expression_config = self._get_global_config() if global_expression_config is not None: - return global_expression_config + use_expression, enable_learning, learning_intensity = global_expression_config + # 防止学习强度为0,自动转换为0.0001 + if learning_intensity == 0: + learning_intensity = 0.0000001 + return use_expression, enable_learning, learning_intensity - # 如果都没有匹配,返回默认值 - return True, True, 300 + # 如果都没有匹配,返回默认值:启用表达,启用学习,学习强度1.0(对应300秒间隔) + return True, True, 1.0 def _get_stream_specific_config(self, chat_stream_id: str) -> Optional[tuple[bool, bool, int]]: """ @@ -393,6 +420,9 @@ class ExpressionConfig(ConfigBase): use_expression: bool = config_item[1].lower() == "enable" enable_learning: bool = config_item[2].lower() == "enable" learning_intensity: float = float(config_item[3]) + # 防止学习强度为0,自动转换为0.0001 + if learning_intensity == 0: + learning_intensity = 0.0000001 return use_expression, enable_learning, learning_intensity # type: ignore except (ValueError, IndexError): continue @@ -416,6 +446,9 @@ class ExpressionConfig(ConfigBase): use_expression: bool = config_item[1].lower() == "enable" enable_learning: bool = config_item[2].lower() == "enable" learning_intensity = float(config_item[3]) + # 防止学习强度为0,自动转换为0.0001 + if learning_intensity == 0: + learning_intensity = 0.0000001 return use_expression, enable_learning, learning_intensity # type: ignore except (ValueError, IndexError): continue @@ -714,3 +747,89 @@ class JargonConfig(ConfigBase): all_global: bool = False """是否将所有新增的jargon项目默认为全局(is_global=True),chat_id记录第一次存储时的id""" + + +@dataclass +class DreamConfig(ConfigBase): + """Dream配置类""" + + interval_minutes: int = 30 + """做梦时间间隔(分钟),默认30分钟""" + + max_iterations: int = 20 + """做梦最大轮次,默认20轮""" + + first_delay_seconds: int = 60 + """程序启动后首次做梦前的延迟时间(秒),默认60秒""" + + dream_time_ranges: list[str] = field(default_factory=lambda: []) + """ + 做梦时间段配置列表,格式:["HH:MM-HH:MM", ...] + 如果列表为空,则表示全天允许做梦。 + 如果配置了时间段,则只有在这些时间段内才会实际执行做梦流程。 + 时间段外,调度器仍会按间隔检查,但不会进入做梦流程。 + + 示例: + [ + "09:00-22:00", # 白天允许做梦 + "23:00-02:00", # 跨夜时间段(23:00到次日02:00) + ] + + 支持跨夜区间,例如 "23:00-02:00" 表示从23:00到次日02:00。 + """ + + def _now_minutes(self) -> int: + """返回本地时间的分钟数(0-1439)。""" + lt = time.localtime() + return lt.tm_hour * 60 + lt.tm_min + + def _parse_range(self, range_str: str) -> Optional[tuple[int, int]]: + """解析 "HH:MM-HH:MM" 到 (start_min, end_min)。""" + try: + start_str, end_str = [s.strip() for s in range_str.split("-")] + sh, sm = [int(x) for x in start_str.split(":")] + eh, em = [int(x) for x in end_str.split(":")] + return sh * 60 + sm, eh * 60 + em + except Exception: + return None + + def _in_range(self, now_min: int, start_min: int, end_min: int) -> bool: + """ + 判断 now_min 是否在 [start_min, end_min] 区间内。 + 支持跨夜:如果 start > end,则表示跨越午夜。 + """ + if start_min <= end_min: + return start_min <= now_min <= end_min + # 跨夜:例如 23:00-02:00 + return now_min >= start_min or now_min <= end_min + + def is_in_dream_time(self) -> bool: + """ + 检查当前时间是否在允许做梦的时间段内。 + 如果 dream_time_ranges 为空,则返回 True(全天允许)。 + """ + if not self.dream_time_ranges: + return True + + now_min = self._now_minutes() + + for time_range in self.dream_time_ranges: + if not isinstance(time_range, str): + continue + parsed = self._parse_range(time_range) + if not parsed: + continue + start_min, end_min = parsed + if self._in_range(now_min, start_min, end_min): + return True + + return False + + def __post_init__(self): + """验证配置值""" + if self.interval_minutes < 1: + raise ValueError(f"interval_minutes 必须至少为1,当前值: {self.interval_minutes}") + if self.max_iterations < 1: + raise ValueError(f"max_iterations 必须至少为1,当前值: {self.max_iterations}") + if self.first_delay_seconds < 0: + raise ValueError(f"first_delay_seconds 不能为负数,当前值: {self.first_delay_seconds}") \ No newline at end of file diff --git a/src/dream/dream_agent.py b/src/dream/dream_agent.py new file mode 100644 index 00000000..c14f8061 --- /dev/null +++ b/src/dream/dream_agent.py @@ -0,0 +1,558 @@ +import asyncio +import random +import time +import json +from typing import Any, Dict, List, Optional, Tuple + +from peewee import fn + +from src.common.logger import get_logger +from src.config.config import global_config, model_config +from src.common.database.database_model import ChatHistory, Jargon +from src.chat.utils.prompt_builder import Prompt, global_prompt_manager +from src.llm_models.payload_content.message import MessageBuilder, RoleType, Message +from src.plugin_system.apis import llm_api +from src.dream.dream_generator import generate_dream_summary + +# dream 工具工厂函数 +from src.dream.tools.search_chat_history_tool import make_search_chat_history +from src.dream.tools.get_chat_history_detail_tool import make_get_chat_history_detail +from src.dream.tools.delete_chat_history_tool import make_delete_chat_history +from src.dream.tools.create_chat_history_tool import make_create_chat_history +from src.dream.tools.update_chat_history_tool import make_update_chat_history +from src.dream.tools.finish_maintenance_tool import make_finish_maintenance +from src.dream.tools.search_jargon_tool import make_search_jargon +from src.dream.tools.delete_jargon_tool import make_delete_jargon +from src.dream.tools.update_jargon_tool import make_update_jargon + +logger = get_logger("dream_agent") + + +def init_dream_prompts() -> None: + """初始化 dream agent 的提示词""" + Prompt( + """ +你的名字是{bot_name},你现在处于"梦境维护模式(dream agent)"。 +你可以自由地在 ChatHistory 库中探索、整理、创建和删改记录,以帮助自己在未来更好地回忆和理解对话历史。 + +本轮要维护的聊天ID:{chat_id} +本轮随机选中的起始记忆 ID:{start_memory_id} +请优先以这条起始记忆为切入点,先理解它的内容与上下文,再决定如何在其附近进行创建新概括、重写或删除等整理操作;如果起始记忆为空,则由你自行选择合适的切入点。 + +你可以使用的工具包括: +**ChatHistory 维护工具:** +- search_chat_history:根据关键词或参与人搜索该 chat_id 下的历史记忆概括列表 +- get_chat_history_detail:查看某条概括的详细内容 +- create_chat_history:根据整理后的理解创建一条新的 ChatHistory 概括记录(主题、概括、关键词、关键信息等) +- update_chat_history:在不改变事实的前提下重写或精炼主题、概括、关键词、关键信息 +- delete_chat_history:删除明显冗余、噪声、错误或无意义的记录,或者非常有时效性的信息,或者无太多有用信息的日常互动。 +你也可以先用 create_chat_history 创建一条新的综合概括,再对旧的冗余记录执行多次 delete_chat_history 来完成“合并”效果。 + +**Jargon(黑话)维护工具(只读,禁止修改):** +- search_jargon:根据一个或多个关键词搜索Jargon 记录,通常是含义不明确的词条或者特殊的缩写 + +**通用工具:** +- finish_maintenance:当你认为当前维护工作已经完成,没有更多需要整理的内容时,调用此工具来结束本次运行 + +**工作目标**: +- 发现冗余、重复或高度相似的记录,并进行合并或删除; +- 发现主题/概括过于含糊、啰嗦或缺少关键信息的记录,进行重写和精简; +- summary要尽可能保持有用的信息; +- 尽量保持信息的真实与可用性,不要凭空捏造事实。 + +**合并准则** +- 你可以新建一个记录,然后删除旧记录来实现合并。 +- 如果两个或多个记录的主题相似,内容是对主题不同方面的信息或讨论,且信息量较少,则可以合并为一条记录。 +- 如果两个记录冲突,可以根据逻辑保留一个或者进行整合,也可以采取更新的记录,删除旧的记录 + +**轮次信息**: +- 本次维护最多执行 {max_iterations} 轮 +- 每轮开始时,系统会告知你当前是第几轮,还剩多少轮 +- 如果提前完成维护工作,可以调用 finish_maintenance 工具主动结束 + +**每一轮的执行方式(必须遵守):** +- 第一步:先用一小段中文自然语言,写出你的「思考」和本轮计划(例如要查什么、准备怎么合并/修改); +- 第二步:在这段思考之后,再通过工具调用来执行你的计划(可以调用 0~N 个工具); +- 第三步:收到工具结果后,在下一轮继续先写出新的思考,再视情况继续调用工具。 + +请不要在没有先写出思考的情况下直接调用工具。 +只输出你的思考内容或工具调用结果,由系统负责真正执行工具调用。 +""", + name="dream_react_head_prompt", + ) + + + +class DreamTool: + """dream 模块内部使用的简易工具封装""" + + def __init__(self, name: str, description: str, parameters: List[Tuple], execute_func): + self.name = name + self.description = description + self.parameters = parameters + self.execute_func = execute_func + + def get_tool_definition(self) -> Dict[str, Any]: + return { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + } + + async def execute(self, **kwargs) -> str: + return await self.execute_func(**kwargs) + + +class DreamToolRegistry: + def __init__(self) -> None: + self.tools: Dict[str, DreamTool] = {} + + def register_tool(self, tool: DreamTool) -> None: + """ + 注册或更新 dream 工具。 + 注意:dream agent 每个 chat_id 会重新初始化工具,这里允许覆盖已有同名工具。 + """ + self.tools[tool.name] = tool + logger.info(f"注册/更新 dream 工具: {tool.name}") + + def get_tool(self, name: str) -> Optional[DreamTool]: + return self.tools.get(name) + + def get_tool_definitions(self) -> List[Dict[str, Any]]: + return [tool.get_tool_definition() for tool in self.tools.values()] + + +_dream_tool_registry = DreamToolRegistry() + + +def get_dream_tool_registry() -> DreamToolRegistry: + return _dream_tool_registry + + +def init_dream_tools(chat_id: str) -> None: + """注册 dream agent 可用的 ChatHistory / Jargon 相关工具(限定在当前 chat_id 作用域内)""" + from src.llm_models.payload_content.tool_option import ToolParamType + + # 通过工厂函数生成绑定当前 chat_id 的工具实现 + search_chat_history = make_search_chat_history(chat_id) + get_chat_history_detail = make_get_chat_history_detail(chat_id) + delete_chat_history = make_delete_chat_history(chat_id) + create_chat_history = make_create_chat_history(chat_id) + update_chat_history = make_update_chat_history(chat_id) + finish_maintenance = make_finish_maintenance(chat_id) + + search_jargon = make_search_jargon(chat_id) + delete_jargon = make_delete_jargon(chat_id) + update_jargon = make_update_jargon(chat_id) + + _dream_tool_registry.register_tool( + DreamTool( + "search_chat_history", + "根据关键词或参与人查询当前 chat_id 下的 ChatHistory 概览,便于快速定位相关记忆。", + [ + ("keyword", ToolParamType.STRING, "关键词(可选,支持多个关键词,可用空格、逗号等分隔)。", False, None), + ("participant", ToolParamType.STRING, "参与人昵称(可选)。", False, None), + ], + search_chat_history, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "get_chat_history_detail", + "根据 memory_id 获取单条 ChatHistory 的详细内容,包含主题、概括、关键词、关键信息等字段(不包含原文)。", + [ + ("memory_id", ToolParamType.INTEGER, "ChatHistory 主键 ID。", True, None), + ], + get_chat_history_detail, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "delete_chat_history", + "根据 memory_id 删除一条 ChatHistory 记录(请谨慎使用)。", + [ + ("memory_id", ToolParamType.INTEGER, "需要删除的 ChatHistory 主键 ID。", True, None), + ], + delete_chat_history, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "update_chat_history", + "按字段更新 ChatHistory 记录,可用于清理、重写或补充信息。", + [ + ("memory_id", ToolParamType.INTEGER, "需要更新的 ChatHistory 主键 ID。", True, None), + ("theme", ToolParamType.STRING, "新的主题标题,如果不需要修改可不填。", False, None), + ("summary", ToolParamType.STRING, "新的概括内容,如果不需要修改可不填。", False, None), + ("keywords", ToolParamType.STRING, "新的关键词 JSON 字符串,如 ['关键词1','关键词2']。", False, None), + ("key_point", ToolParamType.STRING, "新的关键信息 JSON 字符串,如 ['要点1','要点2']。", False, None), + ], + update_chat_history, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "create_chat_history", + "根据整理后的理解创建一条新的 ChatHistory 概括记录(主题、概括、关键词、关键信息等)。", + [ + ("theme", ToolParamType.STRING, "新的主题标题(必填)。", True, None), + ("summary", ToolParamType.STRING, "新的概括内容(必填)。", True, None), + ("keywords", ToolParamType.STRING, "新的关键词 JSON 字符串,如 ['关键词1','关键词2'](必填)。", True, None), + ("key_point", ToolParamType.STRING, "新的关键信息 JSON 字符串,如 ['要点1','要点2'](必填)。", True, None), + ("start_time", ToolParamType.STRING, "起始时间戳(秒,Unix 时间,必填)。", True, None), + ("end_time", ToolParamType.STRING, "结束时间戳(秒,Unix 时间,必填)。", True, None), + ], + create_chat_history, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "finish_maintenance", + "结束本次 dream 维护任务。当你认为当前 chat_id 下的维护工作已经完成,没有更多需要整理、合并或修改的内容时,调用此工具来主动结束本次运行。", + [ + ("reason", ToolParamType.STRING, "结束维护的原因说明(可选),例如 '已完成所有记录的整理' 或 '当前记录质量良好,无需进一步维护'。", False, None), + ], + finish_maintenance, + ) + ) + + # ==================== Jargon 维护工具 ==================== + # 注册 Jargon 工具 + _dream_tool_registry.register_tool( + DreamTool( + "search_jargon", + "根据一个或多个关键词搜索当前 chat_id 相关的 Jargon 记录概览(只包含 is_jargon=True,含全局 Jargon),便于快速理解黑话库。", + [ + ("keyword", ToolParamType.STRING, "按一个或多个关键词搜索内容/含义/推断结果(必填)。", True, None), + ], + search_jargon, + ) + ) + + +async def run_dream_agent_once( + chat_id: str, + max_iterations: Optional[int] = None, + start_memory_id: Optional[int] = None, +) -> None: + """ + 运行一次 dream agent,对指定 chat_id 的 ChatHistory 进行最多 max_iterations 轮的整理。 + 如果 max_iterations 为 None,则使用配置文件中的默认值。 + """ + if max_iterations is None: + max_iterations = global_config.dream.max_iterations + + start_ts = time.time() + logger.info(f"[dream] 开始对 chat_id={chat_id} 进行 dream 维护,最多迭代 {max_iterations} 轮") + + # 初始化工具(作用域限定在当前 chat_id) + init_dream_tools(chat_id) + + tool_registry = get_dream_tool_registry() + tool_defs = tool_registry.get_tool_definitions() + + bot_name = global_config.bot.nickname + time_now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + head_prompt = await global_prompt_manager.format_prompt( + "dream_react_head_prompt", + bot_name=bot_name, + time_now=time_now, + chat_id=chat_id, + start_memory_id=start_memory_id if start_memory_id is not None else "无(本轮由你自由选择切入点)", + max_iterations=max_iterations, + ) + + conversation_messages: List[Message] = [] + + # 如果提供了起始记忆 ID,则在对话正式开始前,先把这条记忆的详细信息放入上下文, + # 避免 LLM 还需要额外调用一次 get_chat_history_detail 才能看到起始记忆内容。 + if start_memory_id is not None: + try: + record = ChatHistory.get_or_none(ChatHistory.id == start_memory_id) + if record: + start_time_str = ( + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.start_time)) + if record.start_time + else "未知" + ) + end_time_str = ( + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.end_time)) + if record.end_time + else "未知" + ) + detail_text = ( + f"ID={record.id}\n" + f"chat_id={record.chat_id}\n" + f"时间范围={start_time_str} 至 {end_time_str}\n" + f"主题={record.theme or '无'}\n" + f"关键词={record.keywords or '无'}\n" + f"参与者={record.participants or '无'}\n" + f"概括={record.summary or '无'}\n" + f"关键信息={record.key_point or '无'}" + ) + + logger.debug( + f"[dream] 预加载起始记忆详情 memory_id={start_memory_id}," + f"预览: {detail_text[:200].replace(chr(10), ' ')}" + ) + + start_detail_builder = MessageBuilder() + start_detail_builder.set_role(RoleType.User) + start_detail_builder.add_text_content( + "【起始记忆详情】以下是本轮随机/指定的起始记忆的详细信息,供你在整理时优先参考:\n\n" + + detail_text + ) + conversation_messages.append(start_detail_builder.build()) + else: + logger.warning( + f"[dream] 提供的 start_memory_id={start_memory_id} 未找到对应 ChatHistory 记录," + "将不预加载起始记忆详情。" + ) + except Exception as e: + logger.error(f"[dream] 预加载起始记忆详情失败 start_memory_id={start_memory_id}: {e}") + + # 注意:message_factory 必须是同步函数,返回消息列表(不能是 async/coroutine) + def message_factory( + _client, + *, + _head_prompt: str = head_prompt, + _conversation_messages: List[Message] = conversation_messages, + ) -> List[Message]: + messages: List[Message] = [] + system_builder = MessageBuilder() + system_builder.set_role(RoleType.System) + system_builder.add_text_content(_head_prompt) + messages.append(system_builder.build()) + messages.extend(_conversation_messages) + return messages + + for iteration in range(1, max_iterations + 1): + # 在每轮开始时,添加轮次信息到对话中 + remaining_rounds = max_iterations - iteration + 1 + round_info_builder = MessageBuilder() + round_info_builder.set_role(RoleType.User) + round_info_builder.add_text_content( + f"【轮次信息】当前是第 {iteration}/{max_iterations} 轮,还剩 {remaining_rounds} 轮。" + ) + conversation_messages.append(round_info_builder.build()) + + # 调用 LLM 让其决定是否要使用工具 + success, response, reasoning_content, model_name, tool_calls = ( + await llm_api.generate_with_model_with_tools_by_message_factory( + message_factory, + model_config=model_config.model_task_config.tool_use, + tool_options=tool_defs, + request_type="dream.react", + ) + ) + + if not success: + logger.error(f"[dream] 第 {iteration} 轮 LLM 调用失败: {response}") + break + + # 先输出「思考」内容,再输出工具调用信息(思考文本较长,仅在 debug 下输出) + thought_log = reasoning_content or (response[:300] if response else "") + if thought_log: + logger.debug(f"[dream] 第 {iteration} 轮思考内容: {thought_log}") + + logger.info( + f"[dream] 第 {iteration} 轮响应,模型={model_name},工具调用数={len(tool_calls) if tool_calls else 0}" + ) + + assistant_msg: Optional[Message] = None + if tool_calls: + builder = MessageBuilder() + builder.set_role(RoleType.Assistant) + if response and response.strip(): + builder.add_text_content(response) + builder.set_tool_calls(tool_calls) + assistant_msg = builder.build() + elif response and response.strip(): + builder = MessageBuilder() + builder.set_role(RoleType.Assistant) + builder.add_text_content(response) + assistant_msg = builder.build() + + if assistant_msg: + conversation_messages.append(assistant_msg) + + # 如果本轮没有工具调用,仅作为思考记录,继续下一轮 + if not tool_calls: + logger.debug(f"[dream] 第 {iteration} 轮未调用任何工具,仅记录思考。") + continue + + # 执行所有工具调用 + tasks = [] + finish_maintenance_called = False + for tc in tool_calls: + tool = tool_registry.get_tool(tc.func_name) + if not tool: + logger.warning(f"[dream] 未知工具:{tc.func_name}") + continue + + # 检测是否调用了 finish_maintenance 工具 + if tc.func_name == "finish_maintenance": + finish_maintenance_called = True + + params = tc.args or {} + + async def _run_single(t: DreamTool, p: Dict[str, Any], call_id: str, it: int): + try: + result = await t.execute(**p) + logger.debug(f"[dream] 第 {it} 轮 工具 {t.name} 执行完成") + return call_id, result + except Exception as e: + logger.error(f"[dream] 工具 {t.name} 执行失败: {e}") + return call_id, f"工具 {t.name} 执行失败: {e}" + + tasks.append(_run_single(tool, params, tc.call_id, iteration)) + + if not tasks: + continue + + tool_results = await asyncio.gather(*tasks, return_exceptions=False) + + # 将工具结果作为 Tool 消息追加 + for call_id, obs in tool_results: + tool_builder = MessageBuilder() + tool_builder.set_role(RoleType.Tool) + tool_builder.add_text_content(str(obs)) + tool_builder.add_tool_call(call_id) + conversation_messages.append(tool_builder.build()) + + # 如果调用了 finish_maintenance 工具,提前结束本次运行 + if finish_maintenance_called: + logger.info(f"[dream] 第 {iteration} 轮检测到 finish_maintenance 工具调用,提前结束本次维护。") + break + + cost = time.time() - start_ts + logger.info(f"[dream] 对 chat_id={chat_id} 的 dream 维护结束,共迭代 {iteration} 轮,耗时 {cost:.1f} 秒") + + # 生成梦境总结 + await generate_dream_summary(chat_id, conversation_messages, iteration, cost) + + +def _pick_random_chat_id() -> Optional[str]: + """从 ChatHistory 中随机选择一个 chat_id,用于 dream agent 本次维护 + + 规则: + - 只在 chat_id 所属的 ChatHistory 记录数 >= 10 时才会参与随机选择; + - 记录数不足 10 的 chat_id 将被跳过,不会触发做梦 react。 + """ + try: + # 统计每个 chat_id 的记录数,只保留记录数 >= 10 的 chat_id + rows = ( + ChatHistory.select(ChatHistory.chat_id, fn.COUNT(ChatHistory.id).alias("cnt")) + .group_by(ChatHistory.chat_id) + .having(fn.COUNT(ChatHistory.id) >= 10) + .order_by(ChatHistory.chat_id) + .limit(200) + ) + eligible_ids = [r.chat_id for r in rows] + if not eligible_ids: + logger.warning("[dream] ChatHistory 中暂无满足条件(记录数 >= 10)的 chat_id,本轮 dream 任务跳过。") + return None + chosen = random.choice(eligible_ids) + logger.info(f"[dream] 从 {len(eligible_ids)} 个满足条件的 chat_id 中随机选择:{chosen}") + return chosen + except Exception as e: + logger.error(f"[dream] 随机选择 chat_id 失败: {e}") + return None + + +def _pick_random_memory_for_chat(chat_id: str) -> Optional[int]: + """ + 在给定 chat_id 下随机选择一条 ChatHistory 记录,作为本轮整理的起始记忆。 + """ + try: + rows = ( + ChatHistory.select(ChatHistory.id) + .where(ChatHistory.chat_id == chat_id) + .order_by(ChatHistory.start_time.asc()) + .limit(200) + ) + ids = [r.id for r in rows] + if not ids: + logger.warning(f"[dream] chat_id={chat_id} 下暂无 ChatHistory 记录,无法选择起始记忆。") + return None + return random.choice(ids) + except Exception as e: + logger.error(f"[dream] 在 chat_id={chat_id} 下随机选择起始记忆失败: {e}") + return None + + +async def run_dream_cycle_once() -> None: + """ + 单次 dream 周期: + - 随机选择一个 chat_id + - 在该 chat_id 下随机选择一条 ChatHistory 作为起始记忆 + - 以这条起始记忆为切入点,对该 chat_id 运行一次 dream agent(最多 15 轮) + """ + chat_id = _pick_random_chat_id() + if not chat_id: + return + + start_memory_id = _pick_random_memory_for_chat(chat_id) + await run_dream_agent_once( + chat_id=chat_id, + max_iterations=None, # 使用配置文件中的默认值 + start_memory_id=start_memory_id, + ) + + +async def start_dream_scheduler( + first_delay_seconds: Optional[int] = None, + interval_seconds: Optional[int] = None, + stop_event: Optional[asyncio.Event] = None, +) -> None: + """ + dream 调度器: + - 程序启动后先等待 first_delay_seconds(如果为 None,则使用配置文件中的值,默认 60s) + - 然后每隔 interval_seconds(如果为 None,则使用配置文件中的值,默认 30 分钟)运行一次 dream agent 周期 + - 如果提供 stop_event,则在 stop_event 被 set() 后优雅退出循环 + """ + if first_delay_seconds is None: + first_delay_seconds = global_config.dream.first_delay_seconds + + if interval_seconds is None: + interval_seconds = global_config.dream.interval_minutes * 60 + + logger.info( + f"[dream] dream 调度器启动:首次延迟 {first_delay_seconds}s,之后每隔 {interval_seconds}s ({interval_seconds // 60} 分钟) 运行一次 dream agent" + ) + + try: + await asyncio.sleep(first_delay_seconds) + while True: + if stop_event is not None and stop_event.is_set(): + logger.info("[dream] 收到停止事件,结束 dream 调度器循环。") + break + + start_ts = time.time() + # 检查当前时间是否在允许做梦的时间段内 + if not global_config.dream.is_in_dream_time(): + logger.debug("[dream] 当前时间不在允许做梦的时间段内,跳过本次执行") + else: + try: + await run_dream_cycle_once() + except Exception as e: + logger.error(f"[dream] 单次 dream 周期执行异常: {e}") + + elapsed = time.time() - start_ts + # 保证两次执行之间至少间隔 interval_seconds + to_sleep = max(0.0, interval_seconds - elapsed) + await asyncio.sleep(to_sleep) + except asyncio.CancelledError: + logger.info("[dream] dream 调度器任务被取消,准备退出。") + raise + + +# 初始化提示词 +init_dream_prompts() + diff --git a/src/dream/dream_generator.py b/src/dream/dream_generator.py new file mode 100644 index 00000000..174d1b69 --- /dev/null +++ b/src/dream/dream_generator.py @@ -0,0 +1,198 @@ +import random +from typing import List, Optional + +from src.common.logger import get_logger +from src.config.config import model_config +from src.chat.utils.prompt_builder import Prompt +from src.llm_models.payload_content.message import RoleType, Message +from src.llm_models.utils_model import LLMRequest + +logger = get_logger("dream_generator") + +# 初始化 utils 模型用于生成梦境总结 +_dream_summary_model: Optional[LLMRequest] = None + +# 梦境风格列表(21种) +DREAM_STYLES = [ + "保持诗意和想象力,自由编写", + "诗意朦胧,如薄雾笼罩的清晨", + "奇幻冒险,充满未知与探索", + "温暖怀旧,带着时光的痕迹", + "神秘悬疑,暗藏深意", + "浪漫唯美,如诗如画", + "科幻未来,科技与想象交织", + "自然清新,如山林间的微风", + "深沉哲思,引人深思", + "轻松幽默,充满趣味", + "悲伤忧郁,带着淡淡哀愁", + "激昂热烈,充满活力", + "宁静平和,如湖面般平静", + "荒诞离奇,打破常规", + "细腻温柔,如春风拂面", + "壮阔宏大,气势磅礴", + "简约纯粹,返璞归真", + "复杂多变,层次丰富", + "梦幻迷离,虚实难辨", + "现实写意,贴近生活", + "抽象概念,超越具象", +] + + +def get_random_dream_styles(count: int = 2) -> List[str]: + """从梦境风格列表中随机选择指定数量的风格""" + return random.sample(DREAM_STYLES, min(count, len(DREAM_STYLES))) + + +def get_dream_summary_model() -> LLMRequest: + """获取用于生成梦境总结的 utils 模型实例""" + global _dream_summary_model + if _dream_summary_model is None: + _dream_summary_model = LLMRequest( + model_set=model_config.model_task_config.utils, + request_type="dream.summary", + ) + return _dream_summary_model + + +def init_dream_summary_prompt() -> None: + """初始化梦境总结的提示词""" + Prompt( + """ +你刚刚完成了一次对聊天记录的记忆整理工作。以下是整理过程的摘要: +整理过程: +{conversation_text} + +请将这次整理涉及的相关信息改写为一个富有诗意和想象力的"梦境",请你仅使用具体的记忆的内容,而不是整理过程编写。 +要求: +1. 使用第一人称视角 +2. 叙述直白,不要复杂修辞,口语化 +3. 长度控制在200-800字 +4. 用中文输出 +梦境风格: +{dream_styles} +请直接输出梦境内容,不要添加其他说明: +""", + name="dream_summary_prompt", + ) + + +async def generate_dream_summary( + chat_id: str, + conversation_messages: List[Message], + total_iterations: int, + time_cost: float, +) -> None: + """生成梦境总结并输出到日志""" + try: + import json + from src.chat.utils.prompt_builder import global_prompt_manager + + # 第一步:建立工具调用结果映射 (call_id -> result) + tool_results_map: dict[str, str] = {} + for msg in conversation_messages: + if msg.role == RoleType.Tool and msg.tool_call_id: + content = "" + if msg.content: + if isinstance(msg.content, list) and msg.content: + content = msg.content[0].text if hasattr(msg.content[0], "text") else str(msg.content[0]) + else: + content = str(msg.content) + tool_results_map[msg.tool_call_id] = content + + # 第二步:详细记录所有工具调用操作和结果到日志 + tool_call_count = 0 + logger.info(f"[dream][工具调用详情] 开始记录 chat_id={chat_id} 的所有工具调用操作:") + + for msg in conversation_messages: + if msg.role == RoleType.Assistant and msg.tool_calls: + tool_call_count += 1 + # 提取思考内容 + thought_content = "" + if msg.content: + if isinstance(msg.content, list) and msg.content: + thought_content = msg.content[0].text if hasattr(msg.content[0], "text") else str(msg.content[0]) + else: + thought_content = str(msg.content) + + logger.info(f"[dream][工具调用详情] === 第 {tool_call_count} 组工具调用 ===") + if thought_content: + logger.info(f"[dream][工具调用详情] 思考内容:{thought_content[:500]}{'...' if len(thought_content) > 500 else ''}") + + # 记录每个工具调用的详细信息 + for idx, tool_call in enumerate(msg.tool_calls, 1): + tool_name = tool_call.func_name + tool_args = tool_call.args or {} + tool_call_id = tool_call.call_id + tool_result = tool_results_map.get(tool_call_id, "未找到执行结果") + + # 格式化参数 + try: + args_str = json.dumps(tool_args, ensure_ascii=False, indent=2) if tool_args else "无参数" + except Exception: + args_str = str(tool_args) + + logger.info(f"[dream][工具调用详情] --- 工具 {idx}: {tool_name} ---") + logger.info(f"[dream][工具调用详情] 调用参数:\n{args_str}") + logger.info(f"[dream][工具调用详情] 执行结果:\n{tool_result}") + logger.info(f"[dream][工具调用详情] {'-' * 60}") + + logger.info(f"[dream][工具调用详情] 共记录了 {tool_call_count} 组工具调用操作") + + # 第三步:构建对话历史摘要(用于生成梦境) + conversation_summary = [] + for msg in conversation_messages: + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + content = "" + if msg.content: + content = msg.content[0].text if isinstance(msg.content, list) and msg.content else str(msg.content) + + if role == "user" and "轮次信息" in content: + # 跳过轮次信息消息 + continue + + if role == "assistant": + # 只保留思考内容,简化工具调用信息 + if content: + # 截取前500字符,避免过长 + content_preview = content[:500] + ("..." if len(content) > 500 else "") + conversation_summary.append(f"[{role}] {content_preview}") + elif role == "tool": + # 工具结果,只保留关键信息 + if content: + # 截取前300字符 + content_preview = content[:300] + ("..." if len(content) > 300 else "") + conversation_summary.append(f"[工具执行] {content_preview}") + + conversation_text = "\n".join(conversation_summary[-20:]) # 只保留最后20条消息 + + # 随机选择2个梦境风格 + selected_styles = get_random_dream_styles(2) + dream_styles_text = "\n".join([f"{i+1}. {style}" for i, style in enumerate(selected_styles)]) + + # 使用 Prompt 管理器格式化梦境生成 prompt + dream_prompt = await global_prompt_manager.format_prompt( + "dream_summary_prompt", + chat_id=chat_id, + total_iterations=total_iterations, + time_cost=time_cost, + conversation_text=conversation_text, + dream_styles=dream_styles_text, + ) + + # 调用 utils 模型生成梦境 + summary_model = get_dream_summary_model() + dream_content, (reasoning, model_name, _) = await summary_model.generate_response_async( + dream_prompt, + max_tokens=512, + temperature=0.8, + ) + + if dream_content: + logger.info(f"[dream][梦境总结] 对 chat_id={chat_id} 的整理过程梦境:\n{dream_content}") + else: + logger.warning("[dream][梦境总结] 未能生成梦境总结") + + except Exception as e: + logger.error(f"[dream][梦境总结] 生成梦境总结失败: {e}", exc_info=True) + +init_dream_summary_prompt() \ No newline at end of file diff --git a/src/dream/tools/__init__.py b/src/dream/tools/__init__.py new file mode 100644 index 00000000..488675a6 --- /dev/null +++ b/src/dream/tools/__init__.py @@ -0,0 +1,10 @@ +""" +dream agent 工具实现模块。 + +每个工具的具体实现放在独立文件中,通过 make_xxx(chat_id) 工厂函数 +生成绑定到特定 chat_id 的协程函数,由 dream_agent.init_dream_tools 统一注册。 +""" + + + + diff --git a/src/dream/tools/create_chat_history_tool.py b/src/dream/tools/create_chat_history_tool.py new file mode 100644 index 00000000..43535c29 --- /dev/null +++ b/src/dream/tools/create_chat_history_tool.py @@ -0,0 +1,66 @@ +import time + +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory + +logger = get_logger("dream_agent") + + +def make_create_chat_history(chat_id: str): + async def create_chat_history( + theme: str, + summary: str, + keywords: str, + key_point: str, + start_time: float, + end_time: float, + ) -> str: + """创建一条新的 ChatHistory 概括记录(用于整理/合并后的新记忆)""" + try: + logger.info( + f"[dream][tool] 调用 create_chat_history(" + f"theme={bool(theme)}, summary={bool(summary)}, " + f"keywords={bool(keywords)}, key_point={bool(key_point)}, " + f"start_time={start_time}, end_time={end_time}) (chat_id={chat_id})" + ) + + now_ts = time.time() + + # 将传入的 start_time/end_time(如果有)解析为时间戳;否则回退为当前时间 + def _parse_ts(value, default): + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + start_ts = _parse_ts(start_time, now_ts) + end_ts = _parse_ts(end_time, now_ts) + + record = ChatHistory.create( + chat_id=chat_id, + theme=theme, + summary=summary, + keywords=keywords, + key_point=key_point, + # 对于由 dream 整理产生的新概括,时间范围优先使用工具提供的时间,否则使用当前时间占位 + start_time=start_ts, + end_time=end_ts, + ) + + msg = ( + f"已创建新的 ChatHistory 记录,ID={record.id}," + f"theme={record.theme or '无'},summary={'有' if record.summary else '无'}。" + ) + logger.info(f"[dream][tool] create_chat_history 完成: {msg}") + return msg + except Exception as e: + logger.error(f"create_chat_history 失败: {e}") + return f"create_chat_history 执行失败: {e}" + + return create_chat_history + + + + diff --git a/src/dream/tools/delete_chat_history_tool.py b/src/dream/tools/delete_chat_history_tool.py new file mode 100644 index 00000000..02cedf94 --- /dev/null +++ b/src/dream/tools/delete_chat_history_tool.py @@ -0,0 +1,29 @@ +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory + +logger = get_logger("dream_agent") + + +def make_delete_chat_history(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def delete_chat_history(memory_id: int) -> str: + """删除一条 chat_history 记录""" + try: + logger.info(f"[dream][tool] 调用 delete_chat_history(memory_id={memory_id})") + record = ChatHistory.get_or_none(ChatHistory.id == memory_id) + if not record: + msg = f"未找到 ID={memory_id} 的 ChatHistory 记录,无法删除。" + logger.info(f"[dream][tool] delete_chat_history 未找到记录: {msg}") + return msg + rows = ChatHistory.delete().where(ChatHistory.id == memory_id).execute() + msg = f"已删除 ID={memory_id} 的 ChatHistory 记录,受影响行数={rows}。" + logger.info(f"[dream][tool] delete_chat_history 完成: {msg}") + return msg + except Exception as e: + logger.error(f"delete_chat_history 失败: {e}") + return f"delete_chat_history 执行失败: {e}" + + return delete_chat_history + + + + diff --git a/src/dream/tools/delete_jargon_tool.py b/src/dream/tools/delete_jargon_tool.py new file mode 100644 index 00000000..2cf38e56 --- /dev/null +++ b/src/dream/tools/delete_jargon_tool.py @@ -0,0 +1,29 @@ +from src.common.logger import get_logger +from src.common.database.database_model import Jargon + +logger = get_logger("dream_agent") + + +def make_delete_jargon(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def delete_jargon(jargon_id: int) -> str: + """删除一条 Jargon 记录""" + try: + logger.info(f"[dream][tool] 调用 delete_jargon(jargon_id={jargon_id})") + record = Jargon.get_or_none(Jargon.id == jargon_id) + if not record: + msg = f"未找到 ID={jargon_id} 的 Jargon 记录,无法删除。" + logger.info(f"[dream][tool] delete_jargon 未找到记录: {msg}") + return msg + rows = Jargon.delete().where(Jargon.id == jargon_id).execute() + msg = f"已删除 ID={jargon_id} 的 Jargon 记录(内容:{record.content}),受影响行数={rows}。" + logger.info(f"[dream][tool] delete_jargon 完成: {msg}") + return msg + except Exception as e: + logger.error(f"delete_jargon 失败: {e}") + return f"delete_jargon 执行失败: {e}" + + return delete_jargon + + + + diff --git a/src/dream/tools/finish_maintenance_tool.py b/src/dream/tools/finish_maintenance_tool.py new file mode 100644 index 00000000..dbb9f15f --- /dev/null +++ b/src/dream/tools/finish_maintenance_tool.py @@ -0,0 +1,20 @@ +from typing import Optional + +from src.common.logger import get_logger + +logger = get_logger("dream_agent") + + +def make_finish_maintenance(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def finish_maintenance(reason: Optional[str] = None) -> str: + """结束本次 dream 维护任务。当你认为当前 chat_id 下的维护工作已经完成,没有更多需要整理的内容时,调用此工具来结束本次运行。""" + reason_text = f",原因:{reason}" if reason else "" + msg = f"DREAM_MAINTENANCE_COMPLETE{reason_text}" + logger.info(f"[dream][tool] 调用 finish_maintenance,结束本次维护{reason_text}") + return msg + + return finish_maintenance + + + + diff --git a/src/dream/tools/get_chat_history_detail_tool.py b/src/dream/tools/get_chat_history_detail_tool.py new file mode 100644 index 00000000..b8b0da54 --- /dev/null +++ b/src/dream/tools/get_chat_history_detail_tool.py @@ -0,0 +1,55 @@ +import time +from typing import Optional + +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory + +logger = get_logger("dream_agent") + + +def make_get_chat_history_detail(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def get_chat_history_detail(memory_id: int) -> str: + """获取单条 chat_history 的完整内容""" + try: + logger.info(f"[dream][tool] 调用 get_chat_history_detail(memory_id={memory_id})") + record = ChatHistory.get_or_none(ChatHistory.id == memory_id) + if not record: + msg = f"未找到 ID={memory_id} 的 ChatHistory 记录。" + logger.info(f"[dream][tool] get_chat_history_detail 未找到记录: {msg}") + return msg + + # 将时间戳转换为可读时间格式 + start_time_str = ( + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.start_time)) + if record.start_time + else "未知" + ) + end_time_str = ( + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.end_time)) + if record.end_time + else "未知" + ) + + result = ( + f"ID={record.id}\n" + # f"chat_id={record.chat_id}\n" + f"时间范围={start_time_str} 至 {end_time_str}\n" + f"主题={record.theme or '无'}\n" + f"关键词={record.keywords or '无'}\n" + f"参与者={record.participants or '无'}\n" + f"概括={record.summary or '无'}\n" + f"关键信息={record.key_point or '无'}" + ) + logger.debug( + f"[dream][tool] get_chat_history_detail 成功,预览: {result[:200].replace(chr(10), ' ')}" + ) + return result + except Exception as e: + logger.error(f"get_chat_history_detail 失败: {e}") + return f"get_chat_history_detail 执行失败: {e}" + + return get_chat_history_detail + + + + diff --git a/src/dream/tools/search_chat_history_tool.py b/src/dream/tools/search_chat_history_tool.py new file mode 100644 index 00000000..0c7d1da6 --- /dev/null +++ b/src/dream/tools/search_chat_history_tool.py @@ -0,0 +1,226 @@ +import json +from typing import List, Optional + +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory +from src.chat.utils.utils import parse_keywords_string + +logger = get_logger("dream_agent") + + +def make_search_chat_history(chat_id: str): + async def search_chat_history( + keyword: Optional[str] = None, + participant: Optional[str] = None, + ) -> str: + """根据关键词或参与人查询记忆,返回匹配的记忆id、记忆标题theme和关键词keywords(dream 维护专用版本)""" + try: + # 检查参数 + if not keyword and not participant: + return "未指定查询参数(需要提供keyword或participant之一)" + + logger.info( + f"[dream][tool] 调用 search_chat_history(keyword={keyword}, participant={participant}) " + f"(作用域 chat_id={chat_id})" + ) + + # 构建查询条件 + query = ChatHistory.select().where(ChatHistory.chat_id == chat_id) + + # 执行查询(按时间倒序,最近的在前) + records = list(query.order_by(ChatHistory.start_time.desc()).limit(50)) + + filtered_records: List[ChatHistory] = [] + + for record in records: + participant_matched = True # 如果没有participant条件,默认为True + keyword_matched = True # 如果没有keyword条件,默认为True + + # 检查参与人匹配 + if participant: + participant_matched = False + participants_list: List[str] = [] + if record.participants: + try: + participants_data = ( + json.loads(record.participants) + if isinstance(record.participants, str) + else record.participants + ) + if isinstance(participants_data, list): + participants_list = [str(p).lower() for p in participants_data] + except (json.JSONDecodeError, TypeError, ValueError): + pass + + participant_lower = participant.lower().strip() + if participant_lower and any(participant_lower in p for p in participants_list): + participant_matched = True + + # 检查关键词匹配 + if keyword: + keyword_matched = False + # 解析多个关键词(支持空格、逗号等分隔符) + keywords_list = parse_keywords_string(keyword) + if not keywords_list: + keywords_list = [keyword.strip()] if keyword.strip() else [] + + # 转换为小写以便匹配 + keywords_lower = [kw.lower() for kw in keywords_list if kw.strip()] + + if keywords_lower: + # 在theme、keywords、summary、original_text中搜索 + theme = (record.theme or "").lower() + summary = (record.summary or "").lower() + original_text = (record.original_text or "").lower() + + # 解析record中的keywords JSON + record_keywords_list: List[str] = [] + if record.keywords: + try: + keywords_data = ( + json.loads(record.keywords) + if isinstance(record.keywords, str) + else record.keywords + ) + if isinstance(keywords_data, list): + record_keywords_list = [str(k).lower() for k in keywords_data] + except (json.JSONDecodeError, TypeError, ValueError): + pass + + # 有容错的全匹配:如果关键词数量>2,允许n-1个关键词匹配;否则必须全部匹配 + matched_count = 0 + for kw in keywords_lower: + kw_matched = ( + kw in theme + or kw in summary + or kw in original_text + or any(kw in k for k in record_keywords_list) + ) + if kw_matched: + matched_count += 1 + + # 计算需要匹配的关键词数量 + total_keywords = len(keywords_lower) + if total_keywords > 2: + # 关键词数量>2,允许n-1个关键词匹配 + required_matches = total_keywords - 1 + else: + # 关键词数量<=2,必须全部匹配 + required_matches = total_keywords + + keyword_matched = matched_count >= required_matches + + # 两者都匹配(如果同时有participant和keyword,需要两者都匹配;如果只有一个条件,只需要该条件匹配) + matched = participant_matched and keyword_matched + + if matched: + filtered_records.append(record) + + if not filtered_records: + if keyword and participant: + keywords_str = "、".join(parse_keywords_string(keyword) if keyword else []) + return f"未找到包含关键词'{keywords_str}'且参与人包含'{participant}'的聊天记录" + elif keyword: + keywords_list = parse_keywords_string(keyword) + keywords_str = "、".join(keywords_list) + if len(keywords_list) > 2: + required_count = len(keywords_list) - 1 + return ( + f"未找到包含至少{required_count}个关键词(共{len(keywords_list)}个)'{keywords_str}'的聊天记录" + ) + else: + return f"未找到包含所有关键词'{keywords_str}'的聊天记录" + elif participant: + return f"未找到参与人包含'{participant}'的聊天记录" + else: + return "未找到相关聊天记录" + + # 如果匹配结果超过20条,不返回具体记录,只返回提示和所有相关关键词 + if len(filtered_records) > 20: + all_keywords_set = set() + for record in filtered_records: + if record.keywords: + try: + keywords_data = ( + json.loads(record.keywords) + if isinstance(record.keywords, str) + else record.keywords + ) + if isinstance(keywords_data, list): + for k in keywords_data: + k_str = str(k).strip() + if k_str: + all_keywords_set.add(k_str) + except (json.JSONDecodeError, TypeError, ValueError): + continue + + search_label = keyword or participant or "当前条件" + + if all_keywords_set: + keywords_str = "、".join(sorted(all_keywords_set)) + response_text = ( + f"包含“{search_label}”的结果过多,请尝试更多关键词精确查找\n\n" + f"有关\"{search_label}\"的关键词:\n" + f"{keywords_str}" + ) + else: + response_text = ( + f"包含“{search_label}”的结果过多,请尝试更多关键词精确查找\n\n" + f"有关\"{search_label}\"的关键词信息为空" + ) + + logger.info( + f"[dream][tool] search_chat_history 匹配结果超过20条,返回关键词汇总提示,总数={len(filtered_records)}" + ) + return response_text + + # 构建结果文本,返回id、theme和keywords(最多20条) + results: List[str] = [] + for record in filtered_records[:20]: + result_parts: List[str] = [] + + # 记忆ID + result_parts.append(f"记忆ID:{record.id}") + + # 主题 + if record.theme: + result_parts.append(f"主题:{record.theme}") + else: + result_parts.append("主题:(无)") + + # 关键词 + if record.keywords: + try: + keywords_data = ( + json.loads(record.keywords) + if isinstance(record.keywords, str) + else record.keywords + ) + if isinstance(keywords_data, list) and keywords_data: + keywords_str = "、".join([str(k) for k in keywords_data]) + result_parts.append(f"关键词:{keywords_str}") + else: + result_parts.append("关键词:(无)") + except (json.JSONDecodeError, TypeError, ValueError): + result_parts.append("关键词:(无)") + else: + result_parts.append("关键词:(无)") + + results.append("\n".join(result_parts)) + + if not results: + return "未找到相关聊天记录" + + response_text = "\n\n---\n\n".join(results) + + logger.info(f"[dream][tool] search_chat_history 返回 {len(filtered_records)} 条匹配记录") + return response_text + except Exception as e: + logger.error(f"search_chat_history 失败: {e}") + return f"search_chat_history 执行失败: {e}" + + return search_chat_history + + + + diff --git a/src/dream/tools/search_jargon_tool.py b/src/dream/tools/search_jargon_tool.py new file mode 100644 index 00000000..d20f170b --- /dev/null +++ b/src/dream/tools/search_jargon_tool.py @@ -0,0 +1,106 @@ +from typing import List + +from src.common.logger import get_logger +from src.common.database.database_model import Jargon +from src.config.config import global_config +from src.chat.utils.utils import parse_keywords_string +from src.jargon.jargon_utils import parse_chat_id_list, chat_id_list_contains + +logger = get_logger("dream_agent") + + +def make_search_jargon(chat_id: str): + async def search_jargon(keyword: str) -> str: + """根据一个或多个关键词搜索当前 chat_id 相关的 Jargon 记录概览(只包含 is_jargon=True,是否跨 chat_id 由 all_global 决定)""" + try: + if not keyword or not keyword.strip(): + return "未指定查询关键词(参数 keyword 为必填,且不能为空)" + + logger.info( + f"[dream][tool] 调用 search_jargon(keyword={keyword}) (作用域 chat_id={chat_id})" + ) + + # 基础条件:只查 is_jargon=True 的记录 + query = Jargon.select().where(Jargon.is_jargon) + + # 根据 all_global 配置决定 chat_id 作用域 + if global_config.jargon.all_global: + # 开启全局黑话:只看 is_global=True 的记录,不区分 chat_id + query = query.where(Jargon.is_global) + else: + # 关闭全局黑话:后续在 Python 层按 chat_id 列表过滤(包含 is_global=True) + pass + + # 先按使用次数排序取一批候选,做一个安全上限 + query = query.order_by(Jargon.count.desc()).limit(200) + candidates = list(query) + + if not candidates: + msg = "未找到符合条件的 Jargon 记录。" + logger.info(f"[dream][tool] search_jargon 无记录: {msg}") + return msg + + # 关键词为必填,因此此处必然执行关键词过滤(支持多个关键词,大小写不敏感) + keywords_list = parse_keywords_string(keyword) or [] + if not keywords_list and keyword.strip(): + keywords_list = [keyword.strip()] + keywords_lower = [kw.lower() for kw in keywords_list if kw.strip()] + + # 先按关键词过滤(仅对 content 字段进行匹配) + filtered_keyword: List[Jargon] = [] + for r in candidates: + content = (r.content or "").lower() + + # 只要命中任意一个关键词即可视为匹配(OR 逻辑) + any_matched = False + for kw in keywords_lower: + if not kw: + continue + if kw in content: + any_matched = True + break + + if any_matched: + filtered_keyword.append(r) + + if global_config.jargon.all_global: + # 全局黑话模式:不再做 chat_id 过滤,直接使用关键词过滤结果 + records = filtered_keyword + else: + # 非全局模式:仅保留全局黑话或 chat_id 列表中包含当前 chat_id 的记录 + records = [] + for r in filtered_keyword: + if r.is_global: + records.append(r) + continue + chat_id_list = parse_chat_id_list(r.chat_id) + if chat_id_list_contains(chat_id_list, chat_id): + records.append(r) + + if not records: + scope_note = ( + "(当前为全局黑话模式,仅统计 is_global=True 的条目)" + if global_config.jargon.all_global + else "(当前为按 chat_id 作用域模式,仅统计全局黑话或与当前 chat_id 相关的条目)" + ) + return f"未找到包含关键词'{keyword}'的 Jargon 记录{scope_note}" + + lines: List[str] = [] + for r in records: + is_jargon_str = "是" if r.is_jargon else "否" if r.is_jargon is False else "未判定" + is_global_str = "全局" if r.is_global else "非全局" + lines.append( + f"ID={r.id} | 内容={r.content} | 含义={r.meaning or '无'} | " + f"chat_id={r.chat_id} | {is_global_str} | 是否黑话={is_jargon_str}" + ) + + result = "\n".join(lines) + logger.info(f"[dream][tool] search_jargon 返回 {len(records)} 条记录") + return result + except Exception as e: + logger.error(f"search_jargon 失败: {e}") + return f"search_jargon 执行失败: {e}" + + return search_jargon + + diff --git a/src/dream/tools/update_chat_history_tool.py b/src/dream/tools/update_chat_history_tool.py new file mode 100644 index 00000000..2070facf --- /dev/null +++ b/src/dream/tools/update_chat_history_tool.py @@ -0,0 +1,55 @@ +from typing import Any, Dict, Optional + +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory +from src.plugin_system.apis import database_api + +logger = get_logger("dream_agent") + + +def make_update_chat_history(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def update_chat_history( + memory_id: int, + theme: Optional[str] = None, + summary: Optional[str] = None, + keywords: Optional[str] = None, + key_point: Optional[str] = None, + ) -> str: + """按字段更新 chat_history(字符串字段要求 JSON 的字段须传入已序列化的字符串)""" + try: + logger.info( + f"[dream][tool] 调用 update_chat_history(memory_id={memory_id}, " + f"theme={bool(theme)}, summary={bool(summary)}, keywords={bool(keywords)}, key_point={bool(key_point)})" + ) + record = ChatHistory.get_or_none(ChatHistory.id == memory_id) + if not record: + msg = f"未找到 ID={memory_id} 的 ChatHistory 记录,无法更新。" + logger.info(f"[dream][tool] update_chat_history 未找到记录: {msg}") + return msg + + data: Dict[str, Any] = {} + if theme is not None: + data["theme"] = theme + if summary is not None: + data["summary"] = summary + if keywords is not None: + data["keywords"] = keywords + if key_point is not None: + data["key_point"] = key_point + + if not data: + return "未提供任何需要更新的字段。" + + await database_api.db_save(ChatHistory, data=data, key_field="id", key_value=memory_id) + msg = f"已更新 ChatHistory 记录 ID={memory_id},更新字段={list(data.keys())}。" + logger.info(f"[dream][tool] update_chat_history 完成: {msg}") + return msg + except Exception as e: + logger.error(f"update_chat_history 失败: {e}") + return f"update_chat_history 执行失败: {e}" + + return update_chat_history + + + + diff --git a/src/dream/tools/update_jargon_tool.py b/src/dream/tools/update_jargon_tool.py new file mode 100644 index 00000000..9ebfe7b9 --- /dev/null +++ b/src/dream/tools/update_jargon_tool.py @@ -0,0 +1,55 @@ +from typing import Any, Dict, Optional + +from src.common.logger import get_logger +from src.common.database.database_model import Jargon +from src.plugin_system.apis import database_api + +logger = get_logger("dream_agent") + + +def make_update_jargon(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def update_jargon( + jargon_id: int, + meaning: Optional[str] = None, + is_global: Optional[bool] = None, + is_jargon: Optional[bool] = None, + content: Optional[str] = None, + ) -> str: + """按字段更新 Jargon 记录,可用于修正含义、调整全局性、标记是否为黑话等""" + try: + logger.info( + f"[dream][tool] 调用 update_jargon(jargon_id={jargon_id}, " + f"meaning={bool(meaning)}, is_global={is_global}, is_jargon={is_jargon}, content={bool(content)})" + ) + record = Jargon.get_or_none(Jargon.id == jargon_id) + if not record: + msg = f"未找到 ID={jargon_id} 的 Jargon 记录,无法更新。" + logger.info(f"[dream][tool] update_jargon 未找到记录: {msg}") + return msg + + data: Dict[str, Any] = {} + if meaning is not None: + data["meaning"] = meaning + if is_global is not None: + data["is_global"] = is_global + if is_jargon is not None: + data["is_jargon"] = is_jargon + if content is not None: + data["content"] = content + + if not data: + return "未提供任何需要更新的字段。" + + await database_api.db_save(Jargon, data=data, key_field="id", key_value=jargon_id) + msg = f"已更新 Jargon 记录 ID={jargon_id},更新字段={list(data.keys())}。" + logger.info(f"[dream][tool] update_jargon 完成: {msg}") + return msg + except Exception as e: + logger.error(f"update_jargon 失败: {e}") + return f"update_jargon 执行失败: {e}" + + return update_jargon + + + + diff --git a/src/express/expression_learner.py b/src/express/expression_learner.py index af69cad6..e39677a9 100644 --- a/src/express/expression_learner.py +++ b/src/express/expression_learner.py @@ -12,11 +12,10 @@ from src.config.config import model_config, global_config from src.chat.utils.chat_message_builder import ( get_raw_msg_by_timestamp_with_chat_inclusive, build_anonymous_messages, - build_bare_messages, ) from src.chat.utils.prompt_builder import Prompt, global_prompt_manager from src.chat.message_receive.chat_stream import get_chat_manager -from src.express.express_utils import filter_message_content, calculate_similarity +from src.express.express_utils import filter_message_content from json_repair import repair_json @@ -26,10 +25,10 @@ logger = get_logger("expressor") def init_prompt() -> None: - learn_style_prompt = """ -{chat_str} + learn_style_prompt = """{chat_str} -请从上面这段群聊中概括除了人名为"SELF"之外的人的语言风格 +请从上面这段群聊中概括除了人名为"SELF"之外的人的语言风格。 +每一行消息前面的方括号中的数字(如 [1]、[2])是该行消息的唯一编号,请在输出中引用这些编号来标注“表达方式的来源行”。 1. 只考虑文字,不要考虑表情包和图片 2. 不要涉及具体的人名,但是可以涉及具体名词 3. 思考有没有特殊的梗,一并总结成语言风格 @@ -37,41 +36,29 @@ def init_prompt() -> None: 注意:总结成如下格式的规律,总结的内容要详细,但具有概括性: 例如:当"AAAAA"时,可以"BBBBB", AAAAA代表某个具体的场景,不超过20个字。BBBBB代表对应的语言风格,特定句式或表达方式,不超过20个字。 -例如: -当"对某件事表示十分惊叹"时,使用"我嘞个xxxx" -当"表示讽刺的赞同,不讲道理"时,使用"对对对" -当"想说明某个具体的事实观点,但懒得明说,使用"懂的都懂" -当"当涉及游戏相关时,夸赞,略带戏谑意味"时,使用"这么强!" +请严格以 JSON 数组的形式输出结果,每个元素为一个对象,结构如下(注意字段名): +[ + {{"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}}, + {{"situation": "CCCC", "style": "DDDD", "source_id": "7"}} + {{"situation": "对某件事表示十分惊叹", "style": "使用 我嘞个xxxx", "source_id": "[消息编号]"}}, + {{"situation": "表示讽刺的赞同,不讲道理", "style": "对对对", "source_id": "[消息编号]"}}, + {{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "使用 这么强!", "source_id": "[消息编号]"}}, +] -请注意:不要总结你自己(SELF)的发言,尽量保证总结内容的逻辑性 -现在请你概括 +请注意: +- 不要总结你自己(SELF)的发言,尽量保证总结内容的逻辑性 +- 请只针对最重要的若干条表达方式进行总结,避免输出太多重复或相似的条目 + +其中: +- situation:表示“在什么情境下”的简短概括(不超过20个字) +- style:表示对应的语言风格或常用表达(不超过20个字) +- source_id:该表达方式对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 + +现在请你输出 JSON: """ Prompt(learn_style_prompt, "learn_style_prompt") - match_expression_context_prompt = """ -**聊天内容** -{chat_str} -**从聊天内容总结的表达方式pairs** -{expression_pairs} - -请你为上面的每一条表达方式,找到该表达方式的原文句子,并输出匹配结果,expression_pair不能有重复,每个expression_pair仅输出一个最合适的context。 -如果找不到原句,就不输出该句的匹配结果。 -以json格式输出: -格式如下: -{{ - "expression_pair": "表达方式pair的序号(数字)", - "context": "与表达方式对应的原文句子的原始内容,不要修改原文句子的内容", -}}, -{{ - "expression_pair": "表达方式pair的序号(数字)", - "context": "与表达方式对应的原文句子的原始内容,不要修改原文句子的内容", -}}, -... - -现在请你输出匹配结果: -""" - Prompt(match_expression_context_prompt, "match_expression_context_prompt") class ExpressionLearner: @@ -99,6 +86,10 @@ class ExpressionLearner: _, self.enable_learning, self.learning_intensity = global_config.expression.get_expression_config_for_chat( self.chat_id ) + # 防止除以零:如果学习强度为0或负数,使用最小值0.0001 + if self.learning_intensity <= 0: + logger.warning(f"学习强度为 {self.learning_intensity},已自动调整为 0.0001 以避免除以零错误") + self.learning_intensity = 0.0000001 self.min_messages_for_learning = 15 / self.learning_intensity # 触发学习所需的最少消息数 self.min_learning_interval = 120 / self.learning_intensity @@ -193,7 +184,6 @@ class ExpressionLearner: situation, style, _context, - _up_content, ) in learnt_expressions: learnt_expressions_str += f"{situation}->{style}\n" logger.info(f"在 {self.chat_name} 学习到表达风格:\n{learnt_expressions_str}") @@ -205,193 +195,17 @@ class ExpressionLearner: situation, style, context, - up_content, ) in learnt_expressions: await self._upsert_expression_record( situation=situation, style=style, context=context, - up_content=up_content, current_time=current_time, ) return learnt_expressions - async def match_expression_context( - self, expression_pairs: List[Tuple[str, str]], random_msg_match_str: str - ) -> List[Tuple[str, str, str]]: - # 为expression_pairs逐个条目赋予编号,并构建成字符串 - numbered_pairs = [] - for i, (situation, style) in enumerate(expression_pairs, 1): - numbered_pairs.append(f'{i}. 当"{situation}"时,使用"{style}"') - - expression_pairs_str = "\n".join(numbered_pairs) - - prompt = "match_expression_context_prompt" - prompt = await global_prompt_manager.format_prompt( - prompt, - expression_pairs=expression_pairs_str, - chat_str=random_msg_match_str, - ) - - response, _ = await self.express_learn_model.generate_response_async(prompt, temperature=0.3) - - # print(f"match_expression_context_prompt: {prompt}") - # print(f"{response}") - - # 解析JSON响应 - match_responses = [] - try: - response = response.strip() - - # 尝试提取JSON代码块(如果存在) - json_pattern = r"```json\s*(.*?)\s*```" - matches = re.findall(json_pattern, response, re.DOTALL) - if matches: - response = matches[0].strip() - - # 移除可能的markdown代码块标记(如果没有找到```json,但可能有```) - if not matches: - response = re.sub(r"^```\s*", "", response, flags=re.MULTILINE) - response = re.sub(r"```\s*$", "", response, flags=re.MULTILINE) - response = response.strip() - - # 检查是否已经是标准JSON数组格式 - if response.startswith("[") and response.endswith("]"): - match_responses = json.loads(response) - else: - # 尝试直接解析多个JSON对象 - try: - # 如果是多个JSON对象用逗号分隔,包装成数组 - if response.startswith("{") and not response.startswith("["): - response = "[" + response + "]" - match_responses = json.loads(response) - else: - # 使用repair_json处理响应 - repaired_content = repair_json(response) - - # 确保repaired_content是列表格式 - if isinstance(repaired_content, str): - try: - parsed_data = json.loads(repaired_content) - if isinstance(parsed_data, dict): - # 如果是字典,包装成列表 - match_responses = [parsed_data] - elif isinstance(parsed_data, list): - match_responses = parsed_data - else: - match_responses = [] - except json.JSONDecodeError: - match_responses = [] - elif isinstance(repaired_content, dict): - # 如果是字典,包装成列表 - match_responses = [repaired_content] - elif isinstance(repaired_content, list): - match_responses = repaired_content - else: - match_responses = [] - except json.JSONDecodeError: - # 如果还是失败,尝试repair_json - repaired_content = repair_json(response) - if isinstance(repaired_content, str): - parsed_data = json.loads(repaired_content) - match_responses = parsed_data if isinstance(parsed_data, list) else [parsed_data] - else: - match_responses = repaired_content if isinstance(repaired_content, list) else [repaired_content] - - except (json.JSONDecodeError, Exception) as e: - logger.error(f"解析匹配响应JSON失败: {e}, 响应内容: \n{response}") - return [] - - # 确保 match_responses 是一个列表 - if not isinstance(match_responses, list): - if isinstance(match_responses, dict): - match_responses = [match_responses] - else: - logger.error(f"match_responses 不是列表或字典类型: {type(match_responses)}, 内容: {match_responses}") - return [] - - # 清理和规范化 match_responses 中的元素 - normalized_responses = [] - for item in match_responses: - if isinstance(item, dict): - # 已经是字典,直接添加 - normalized_responses.append(item) - elif isinstance(item, str): - # 如果是字符串,尝试解析为 JSON - try: - parsed = json.loads(item) - if isinstance(parsed, dict): - normalized_responses.append(parsed) - elif isinstance(parsed, list): - # 如果是列表,递归处理 - for sub_item in parsed: - if isinstance(sub_item, dict): - normalized_responses.append(sub_item) - else: - logger.debug(f"跳过非字典类型的子元素: {type(sub_item)}, 内容: {sub_item}") - else: - logger.debug(f"跳过无法转换为字典的字符串元素: {item}") - except (json.JSONDecodeError, TypeError): - logger.debug(f"跳过无法解析为JSON的字符串元素: {item}") - elif isinstance(item, list): - # 如果是列表,展开并处理其中的字典 - for sub_item in item: - if isinstance(sub_item, dict): - normalized_responses.append(sub_item) - elif isinstance(sub_item, str): - # 尝试解析字符串 - try: - parsed = json.loads(sub_item) - if isinstance(parsed, dict): - normalized_responses.append(parsed) - else: - logger.debug(f"跳过非字典类型的解析结果: {type(parsed)}, 内容: {parsed}") - except (json.JSONDecodeError, TypeError): - logger.debug(f"跳过无法解析为JSON的字符串子元素: {sub_item}") - else: - logger.debug(f"跳过非字典类型的列表元素: {type(sub_item)}, 内容: {sub_item}") - else: - logger.debug(f"跳过无法处理的元素类型: {type(item)}, 内容: {item}") - - match_responses = normalized_responses - - matched_expressions = [] - used_pair_indices = set() # 用于跟踪已经使用的expression_pair索引 - - logger.debug(f"规范化后的 match_responses 类型: {type(match_responses)}, 长度: {len(match_responses)}") - logger.debug(f"规范化后的 match_responses 内容: {match_responses}") - - for match_response in match_responses: - try: - # 检查 match_response 的类型(此时应该都是字典) - if not isinstance(match_response, dict): - logger.error(f"match_response 不是字典类型: {type(match_response)}, 内容: {match_response}") - continue - - # 获取表达方式序号 - if "expression_pair" not in match_response: - logger.error(f"match_response 缺少 'expression_pair' 字段: {match_response}") - continue - - pair_index = int(match_response["expression_pair"]) - 1 # 转换为0-based索引 - - # 检查索引是否有效且未被使用过 - if 0 <= pair_index < len(expression_pairs) and pair_index not in used_pair_indices: - situation, style = expression_pairs[pair_index] - context = match_response.get("context", "") - matched_expressions.append((situation, style, context)) - used_pair_indices.add(pair_index) # 标记该索引已使用 - logger.debug(f"成功匹配表达方式 {pair_index + 1}: {situation} -> {style}") - elif pair_index in used_pair_indices: - logger.debug(f"跳过重复的表达方式 {pair_index + 1}") - except (ValueError, KeyError, IndexError, TypeError) as e: - logger.error(f"解析匹配条目失败: {e}, 条目: {match_response}") - continue - - return matched_expressions - - async def learn_expression(self, num: int = 10, timestamp_start: Optional[float] = None) -> Optional[List[Tuple[str, str, str, str]]]: + async def learn_expression(self, num: int = 10, timestamp_start: Optional[float] = None) -> Optional[List[Tuple[str, str, str]]]: """从指定聊天流学习表达方式 Args: @@ -414,10 +228,8 @@ class ExpressionLearner: if not random_msg or random_msg == []: return None - # 学习用 - random_msg_str: str = await build_anonymous_messages(random_msg) - # 溯源用 - random_msg_match_str: str = await build_bare_messages(random_msg) + # 学习用(开启行编号,便于溯源) + random_msg_str: str = await build_anonymous_messages(random_msg, show_ids=True) prompt: str = await global_prompt_manager.format_prompt( "learn_style_prompt", @@ -432,83 +244,107 @@ class ExpressionLearner: except Exception as e: logger.error(f"学习表达方式失败,模型生成出错: {e}") return None - expressions: List[Tuple[str, str]] = self.parse_expression_response(response) + + # 解析 LLM 返回的表达方式列表(包含来源行编号) + expressions: List[Tuple[str, str, str]] = self.parse_expression_response(response) expressions = self._filter_self_reference_styles(expressions) if not expressions: logger.info("过滤后没有可用的表达方式(style 与机器人名称重复)") return None # logger.debug(f"学习{type_str}的response: {response}") - # 对表达方式溯源 - matched_expressions: List[Tuple[str, str, str]] = await self.match_expression_context( - expressions, random_msg_match_str - ) - # 为每条消息构建精简文本列表,保留到原消息索引的映射 - bare_lines: List[Tuple[int, str]] = self._build_bare_lines(random_msg) - # 将 matched_expressions 结合上一句 up_content(若不存在上一句则跳过) - filtered_with_up: List[Tuple[str, str, str, str]] = [] # (situation, style, context, up_content) - for situation, style, context in matched_expressions: - # 在 bare_lines 中找到第一处相似度达到85%的行 - pos = None - for i, (_, c) in enumerate(bare_lines): - similarity = calculate_similarity(c, context) - if similarity >= 0.85: # 85%相似度阈值 - pos = i - break + # 直接根据 source_id 在 random_msg 中溯源,获取 context + filtered_expressions: List[Tuple[str, str, str]] = [] # (situation, style, context) - if pos is None or pos == 0: - # 没有匹配到目标句或没有上一句,跳过该表达 + for situation, style, source_id in expressions: + source_id_str = (source_id or "").strip() + if not source_id_str.isdigit(): + # 无效的来源行编号,跳过 continue - # 检查目标句是否为空 - target_content = bare_lines[pos][1] - if not target_content: - # 目标句为空,跳过该表达 + line_index = int(source_id_str) - 1 # build_anonymous_messages 的编号从 1 开始 + if line_index < 0 or line_index >= len(random_msg): + # 超出范围,跳过 continue - prev_original_idx = bare_lines[pos - 1][0] - up_content = filter_message_content(random_msg[prev_original_idx].processed_plain_text or "") - if not up_content: - # 上一句为空,跳过该表达 + # 当前行的原始内容 + current_msg = random_msg[line_index] + context = filter_message_content(current_msg.processed_plain_text or "") + if not context: continue - filtered_with_up.append((situation, style, context, up_content)) - if not filtered_with_up: + filtered_expressions.append((situation, style, context)) + + if not filtered_expressions: return None - return filtered_with_up + return filtered_expressions def parse_expression_response(self, response: str) -> List[Tuple[str, str, str]]: """ - 解析LLM返回的表达风格总结,每一行提取"当"和"使用"之间的内容,存储为(situation, style)元组 + 解析 LLM 返回的表达风格总结 JSON,提取 (situation, style, source_id) 元组列表。 + + 期望的 JSON 结构: + [ + {"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}, + ... + ] """ + if not response: + return [] + + raw = response.strip() + + # 尝试提取 ```json 代码块 + json_block_pattern = r"```json\s*(.*?)\s*```" + match = re.search(json_block_pattern, raw, re.DOTALL) + if match: + raw = match.group(1).strip() + else: + # 去掉可能存在的通用 ``` 包裹 + raw = re.sub(r"^```\s*", "", raw, flags=re.MULTILINE) + raw = re.sub(r"```\s*$", "", raw, flags=re.MULTILINE) + raw = raw.strip() + + parsed = None expressions: List[Tuple[str, str, str]] = [] - for line in response.splitlines(): - line = line.strip() - if not line: + + try: + # 优先尝试直接解析 + if raw.startswith("[") and raw.endswith("]"): + parsed = json.loads(raw) + else: + repaired = repair_json(raw) + if isinstance(repaired, str): + parsed = json.loads(repaired) + else: + parsed = repaired + except Exception: + logger.error(f"解析表达风格 JSON 失败,原始响应:{response}") + return [] + + if isinstance(parsed, dict): + parsed_list = [parsed] + elif isinstance(parsed, list): + parsed_list = parsed + else: + logger.error(f"表达风格解析结果类型异常: {type(parsed)}, 内容: {parsed}") + return [] + + for item in parsed_list: + if not isinstance(item, dict): continue - # 查找"当"和下一个引号 - idx_when = line.find('当"') - if idx_when == -1: + situation = str(item.get("situation", "")).strip() + style = str(item.get("style", "")).strip() + source_id = str(item.get("source_id", "")).strip() + if not situation or not style or not source_id: + # 三个字段必须同时存在 continue - idx_quote1 = idx_when + 1 - idx_quote2 = line.find('"', idx_quote1 + 1) - if idx_quote2 == -1: - continue - situation = line[idx_quote1 + 1 : idx_quote2] - # 查找"使用" - idx_use = line.find('使用"', idx_quote2) - if idx_use == -1: - continue - idx_quote3 = idx_use + 2 - idx_quote4 = line.find('"', idx_quote3 + 1) - if idx_quote4 == -1: - continue - style = line[idx_quote3 + 1 : idx_quote4] - expressions.append((situation, style)) + expressions.append((situation, style, source_id)) + return expressions - def _filter_self_reference_styles(self, expressions: List[Tuple[str, str]]) -> List[Tuple[str, str]]: + def _filter_self_reference_styles(self, expressions: List[Tuple[str, str, str]]) -> List[Tuple[str, str, str]]: """ 过滤掉style与机器人名称/昵称重复的表达 """ @@ -525,12 +361,12 @@ class ExpressionLearner: banned_casefold = {name.casefold() for name in banned_names if name} - filtered: List[Tuple[str, str]] = [] + filtered: List[Tuple[str, str, str]] = [] removed_count = 0 - for situation, style in expressions: + for situation, style, source_id in expressions: normalized_style = (style or "").strip() if normalized_style and normalized_style.casefold() not in banned_casefold: - filtered.append((situation, style)) + filtered.append((situation, style, source_id)) else: removed_count += 1 @@ -544,7 +380,6 @@ class ExpressionLearner: situation: str, style: str, context: str, - up_content: str, current_time: float, ) -> None: expr_obj = Expression.select().where((Expression.chat_id == self.chat_id) & (Expression.style == style)).first() @@ -554,7 +389,6 @@ class ExpressionLearner: expr_obj=expr_obj, situation=situation, context=context, - up_content=up_content, current_time=current_time, ) return @@ -563,7 +397,6 @@ class ExpressionLearner: situation=situation, style=style, context=context, - up_content=up_content, current_time=current_time, ) @@ -572,7 +405,6 @@ class ExpressionLearner: situation: str, style: str, context: str, - up_content: str, current_time: float, ) -> None: content_list = [situation] @@ -587,7 +419,6 @@ class ExpressionLearner: chat_id=self.chat_id, create_date=current_time, context=context, - up_content=up_content, ) async def _update_existing_expression( @@ -595,7 +426,6 @@ class ExpressionLearner: expr_obj: Expression, situation: str, context: str, - up_content: str, current_time: float, ) -> None: content_list = self._parse_content_list(expr_obj.content_list) @@ -605,7 +435,6 @@ class ExpressionLearner: expr_obj.count = (expr_obj.count or 0) + 1 expr_obj.last_active_time = current_time expr_obj.context = context - expr_obj.up_content = up_content new_situation = await self._compose_situation_text( content_list=content_list, @@ -651,27 +480,6 @@ class ExpressionLearner: logger.error(f"概括表达情境失败: {e}") return None - def _build_bare_lines(self, messages: List) -> List[Tuple[int, str]]: - """ - 为每条消息构建精简文本列表,保留到原消息索引的映射 - - Args: - messages: 消息列表 - - Returns: - List[Tuple[int, str]]: (original_index, bare_content) 元组列表 - """ - bare_lines: List[Tuple[int, str]] = [] - - for idx, msg in enumerate(messages): - content = msg.processed_plain_text or "" - content = filter_message_content(content) - # 即使content为空也要记录,防止错位 - bare_lines.append((idx, content)) - - return bare_lines - - init_prompt() diff --git a/src/hippo_memorizer/chat_history_summarizer.py b/src/hippo_memorizer/chat_history_summarizer.py index 70847de0..456a0f2e 100644 --- a/src/hippo_memorizer/chat_history_summarizer.py +++ b/src/hippo_memorizer/chat_history_summarizer.py @@ -429,15 +429,36 @@ class ChatHistorySummarizer: # 2. 构造编号后的消息字符串和参与者信息 numbered_lines, index_to_msg_str, index_to_msg_text, index_to_participants = self._build_numbered_messages_for_llm(messages) - # 3. 调用 LLM 识别话题,并得到 topic -> indices + # 3. 调用 LLM 识别话题,并得到 topic -> indices(失败时最多重试 3 次) existing_topics = list(self.topic_cache.keys()) - success, topic_to_indices = await self._analyze_topics_with_llm( - numbered_lines=numbered_lines, - existing_topics=existing_topics, - ) + max_retries = 3 + attempt = 0 + success = False + topic_to_indices: Dict[str, List[int]] = {} + + while attempt < max_retries: + attempt += 1 + success, topic_to_indices = await self._analyze_topics_with_llm( + numbered_lines=numbered_lines, + existing_topics=existing_topics, + ) + + if success and topic_to_indices: + if attempt > 1: + logger.info( + f"{self.log_prefix} 话题识别在第 {attempt} 次重试后成功 | 话题数: {len(topic_to_indices)}" + ) + break + + logger.warning( + f"{self.log_prefix} 话题识别失败或无有效话题,第 {attempt} 次尝试失败" + + ("" if attempt >= max_retries else ",准备重试") + ) if not success or not topic_to_indices: - logger.warning(f"{self.log_prefix} 话题识别失败或无有效话题,本次检查忽略") + logger.error( + f"{self.log_prefix} 话题识别连续 {max_retries} 次失败或始终无有效话题,本次检查放弃" + ) # 即使识别失败,也认为是一次“检查”,但不更新 no_update_checks(保持原状) return diff --git a/src/chat/utils/memory_forget_task.py b/src/hippo_memorizer/memory_forget_task.py similarity index 98% rename from src/chat/utils/memory_forget_task.py rename to src/hippo_memorizer/memory_forget_task.py index cd9144a1..ce2a9b2a 100644 --- a/src/chat/utils/memory_forget_task.py +++ b/src/hippo_memorizer/memory_forget_task.py @@ -28,10 +28,10 @@ class MemoryForgetTask(AsyncTask): # logger.info("[记忆遗忘] 开始遗忘检查...") # 执行4个阶段的遗忘检查 - await self._forget_stage_1(current_time) - await self._forget_stage_2(current_time) - await self._forget_stage_3(current_time) - await self._forget_stage_4(current_time) + # await self._forget_stage_1(current_time) + # await self._forget_stage_2(current_time) + # await self._forget_stage_3(current_time) + # await self._forget_stage_4(current_time) # logger.info("[记忆遗忘] 遗忘检查完成") except Exception as e: diff --git a/src/llm_models/utils_model.py b/src/llm_models/utils_model.py index 44ff2de3..7fb4cfd0 100644 --- a/src/llm_models/utils_model.py +++ b/src/llm_models/utils_model.py @@ -315,12 +315,30 @@ class LLMRequest: while retry_remain > 0: try: if request_type == RequestType.RESPONSE: + # 温度优先级:参数传入 > 模型级别配置 > extra_params > 任务配置 + effective_temperature = temperature + if effective_temperature is None: + effective_temperature = model_info.temperature + if effective_temperature is None: + effective_temperature = (model_info.extra_params or {}).get("temperature") + if effective_temperature is None: + effective_temperature = self.model_for_task.temperature + + # max_tokens 优先级:参数传入 > 模型级别配置 > extra_params > 任务配置 + effective_max_tokens = max_tokens + if effective_max_tokens is None: + effective_max_tokens = model_info.max_tokens + if effective_max_tokens is None: + effective_max_tokens = (model_info.extra_params or {}).get("max_tokens") + if effective_max_tokens is None: + effective_max_tokens = self.model_for_task.max_tokens + return await client.get_response( model_info=model_info, message_list=(compressed_messages or message_list), tool_options=tool_options, - max_tokens=self.model_for_task.max_tokens if max_tokens is None else max_tokens, - temperature=temperature if temperature is not None else (model_info.extra_params or {}).get("temperature", self.model_for_task.temperature), + max_tokens=effective_max_tokens, + temperature=effective_temperature, response_format=response_format, stream_response_handler=stream_response_handler, async_response_parser=async_response_parser, diff --git a/src/main.py b/src/main.py index 3bcc9695..d2ca213b 100644 --- a/src/main.py +++ b/src/main.py @@ -16,6 +16,7 @@ from src.common.server import get_global_server, Server from src.mood.mood_manager import mood_manager from src.chat.knowledge import lpmm_start_up from rich.traceback import install + # from src.api.main import start_api_server # 导入新的插件管理器 @@ -23,6 +24,7 @@ from src.plugin_system.core.plugin_manager import plugin_manager # 导入消息API和traceback模块 from src.common.message import get_global_api +from src.dream.dream_agent import start_dream_scheduler # 插件系统现在使用统一的插件加载器 @@ -106,7 +108,7 @@ class MainSystem: await async_task_manager.add_task(TelemetryHeartBeatTask()) # 添加记忆遗忘任务 - from src.chat.utils.memory_forget_task import MemoryForgetTask + from src.hippo_memorizer.memory_forget_task import MemoryForgetTask await async_task_manager.add_task(MemoryForgetTask()) @@ -159,6 +161,7 @@ class MainSystem: try: tasks = [ get_emoji_manager().start_periodic_check_register(), + start_dream_scheduler(), self.app.run(), self.server.run(), ] diff --git a/src/memory_system/retrieval_tools/query_chat_history.py b/src/memory_system/retrieval_tools/query_chat_history.py index 097632c4..0d2d2cc7 100644 --- a/src/memory_system/retrieval_tools/query_chat_history.py +++ b/src/memory_system/retrieval_tools/query_chat_history.py @@ -5,11 +5,13 @@ import json from typing import Optional +from datetime import datetime + from src.common.logger import get_logger from src.common.database.database_model import ChatHistory from src.chat.utils.utils import parse_keywords_string +from src.config.config import global_config from .tool_registry import register_memory_retrieval_tool -from datetime import datetime logger = get_logger("memory_retrieval_tools") @@ -33,7 +35,18 @@ async def search_chat_history( return "未指定查询参数(需要提供keyword或participant之一)" # 构建查询条件 - query = ChatHistory.select().where(ChatHistory.chat_id == chat_id) + # 根据配置决定是否限制在当前 chat_id 内查询 + use_global_search = global_config.memory.global_memory + + if use_global_search: + # 全局查询所有聊天记录 + query = ChatHistory.select() + logger.debug( + f"search_chat_history 启用全局查询模式,忽略 chat_id 过滤,keyword={keyword}, participant={participant}" + ) + else: + # 仅在当前聊天流内查询 + query = ChatHistory.select().where(ChatHistory.chat_id == chat_id) # 执行查询 records = list(query.order_by(ChatHistory.start_time.desc()).limit(50)) @@ -139,9 +152,45 @@ async def search_chat_history( else: return "未找到相关聊天记录" - # 构建结果文本,返回id、theme和keywords + # 如果匹配结果超过20条,不返回具体记录,只返回提示和所有相关关键词 + if len(filtered_records) > 20: + # 统计所有记录上的关键词并去重 + all_keywords_set = set() + for record in filtered_records: + if record.keywords: + try: + keywords_data = ( + json.loads(record.keywords) + if isinstance(record.keywords, str) + else record.keywords + ) + if isinstance(keywords_data, list): + for k in keywords_data: + k_str = str(k).strip() + if k_str: + all_keywords_set.add(k_str) + except (json.JSONDecodeError, TypeError, ValueError): + continue + + # xxx 使用用户原始查询词,优先 keyword,其次 participant,最后退化成“当前条件” + search_label = keyword or participant or "当前条件" + + if all_keywords_set: + keywords_str = "、".join(sorted(all_keywords_set)) + return ( + f"包含“{search_label}”的结果过多,请尝试更多关键词精确查找\n\n" + f"有关\"{search_label}\"的关键词:\n" + f"{keywords_str}" + ) + else: + return ( + f"包含“{search_label}”的结果过多,请尝试更多关键词精确查找\n\n" + f"有关\"{search_label}\"的关键词信息为空" + ) + + # 构建结果文本,返回id、theme和keywords(最多20条) results = [] - for record in filtered_records[:20]: # 最多返回20条记录 + for record in filtered_records[:20]: result_parts = [] # 添加记忆ID @@ -173,9 +222,6 @@ async def search_chat_history( return "未找到相关聊天记录" response_text = "\n\n---\n\n".join(results) - if len(filtered_records) > 20: - omitted_count = len(filtered_records) - 20 - response_text += f"\n\n(还有{omitted_count}条记录已省略,可使用记忆ID查询详细信息)" return response_text except Exception as e: diff --git a/src/plugin_system/apis/message_api.py b/src/plugin_system/apis/message_api.py index 6a4805b2..94ee9cbc 100644 --- a/src/plugin_system/apis/message_api.py +++ b/src/plugin_system/apis/message_api.py @@ -72,7 +72,7 @@ def get_messages_by_time_in_chat( limit_mode: str = "latest", filter_mai: bool = False, filter_command: bool = False, - filter_no_read_command: bool = False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """ 获取指定聊天中指定时间范围内的消息 @@ -111,7 +111,7 @@ def get_messages_by_time_in_chat( limit_mode=limit_mode, filter_bot=filter_mai, filter_command=filter_command, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) @@ -123,7 +123,7 @@ def get_messages_by_time_in_chat_inclusive( limit_mode: str = "latest", filter_mai: bool = False, filter_command: bool = False, - filter_no_read_command: bool = False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """ 获取指定聊天中指定时间范围内的消息(包含边界) @@ -158,7 +158,7 @@ def get_messages_by_time_in_chat_inclusive( limit_mode=limit_mode, filter_bot=filter_mai, filter_command=filter_command, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) if filter_mai: return filter_mai_messages(messages) @@ -284,7 +284,7 @@ def get_messages_before_time_in_chat( timestamp: float, limit: int = 0, filter_mai: bool = False, - filter_no_read_command: bool = False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """ 获取指定聊天中指定时间戳之前的消息 @@ -313,7 +313,7 @@ def get_messages_before_time_in_chat( chat_id=chat_id, timestamp=timestamp, limit=limit, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) if filter_mai: return filter_mai_messages(messages) diff --git a/src/plugin_system/base/base_command.py b/src/plugin_system/base/base_command.py index 4b098869..0cfdb5d0 100644 --- a/src/plugin_system/base/base_command.py +++ b/src/plugin_system/base/base_command.py @@ -55,11 +55,11 @@ class BaseCommand(ABC): self.matched_groups = groups @abstractmethod - async def execute(self) -> Tuple[bool, Optional[str], bool]: + async def execute(self) -> Tuple[bool, Optional[str], int]: """执行Command的抽象方法,子类必须实现 Returns: - Tuple[bool, Optional[str], bool]: (是否执行成功, 可选的回复消息, 是否拦截消息 不进行 后续处理) + Tuple[bool, Optional[str], int]: (是否执行成功, 可选的回复消息, 拦截消息力度,0代表不拦截,1代表仅不触发回复,replyer可见,2代表不触发回复,replyer不可见) """ pass diff --git a/src/webui/config_routes.py b/src/webui/config_routes.py index 5e54c8f7..63803d5e 100644 --- a/src/webui/config_routes.py +++ b/src/webui/config_routes.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Body from typing import Any, Annotated from src.common.logger import get_logger -from src.common.toml_utils import save_toml_with_format +from src.common.toml_utils import save_toml_with_format, _update_toml_doc from src.config.config import Config, APIAdapterConfig, CONFIG_DIR, PROJECT_ROOT from src.config.official_configs import ( BotConfig, @@ -51,40 +51,6 @@ PathBody = Annotated[dict[str, str], Body()] router = APIRouter(prefix="/config", tags=["config"]) -# ===== 辅助函数 ===== - - -def _update_dict_preserve_comments(target: Any, source: Any) -> None: - """ - 递归合并字典,保留 target 中的注释和格式 - 将 source 的值更新到 target 中(仅更新已存在的键) - - Args: - target: 目标字典(tomlkit 对象,包含注释) - source: 源字典(普通 dict 或 list) - """ - # 如果 source 是列表,直接替换(数组表没有注释保留的意义) - if isinstance(source, list): - return # 调用者需要直接赋值 - - # 如果都是字典,递归合并 - if isinstance(source, dict) and isinstance(target, dict): - for key, value in source.items(): - if key == "version": - continue # 跳过版本号 - if key in target: - target_value = target[key] - # 递归处理嵌套字典 - if isinstance(value, dict) and isinstance(target_value, dict): - _update_dict_preserve_comments(target_value, value) - else: - # 使用 tomlkit.item 保持类型 - try: - target[key] = tomlkit.item(value) - except (TypeError, ValueError): - target[key] = value - - # ===== 架构获取接口 ===== @@ -238,7 +204,7 @@ async def update_bot_config(config_data: ConfigBody): except Exception as e: raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e - # 保存配置文件(格式化数组为多行) + # 保存配置文件(自动保留注释和格式) config_path = os.path.join(CONFIG_DIR, "bot_config.toml") save_toml_with_format(config_data, config_path) @@ -261,7 +227,7 @@ async def update_model_config(config_data: ConfigBody): except Exception as e: raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e - # 保存配置文件(格式化数组为多行) + # 保存配置文件(自动保留注释和格式) config_path = os.path.join(CONFIG_DIR, "model_config.toml") save_toml_with_format(config_data, config_path) @@ -300,7 +266,7 @@ async def update_bot_config_section(section_name: str, section_data: SectionBody config_data[section_name] = section_data elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): # 字典递归合并 - _update_dict_preserve_comments(config_data[section_name], section_data) + _update_toml_doc(config_data[section_name], section_data) else: # 其他类型直接替换 config_data[section_name] = section_data @@ -398,7 +364,7 @@ async def update_model_config_section(section_name: str, section_data: SectionBo config_data[section_name] = section_data elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): # 字典递归合并 - _update_dict_preserve_comments(config_data[section_name], section_data) + _update_toml_doc(config_data[section_name], section_data) else: # 其他类型直接替换 config_data[section_name] = section_data diff --git a/src/webui/expression_routes.py b/src/webui/expression_routes.py index bef93b30..1fa0ce7d 100644 --- a/src/webui/expression_routes.py +++ b/src/webui/expression_routes.py @@ -1,7 +1,7 @@ """表达方式管理 API 路由""" from fastapi import APIRouter, HTTPException, Header, Query, Cookie -from pydantic import BaseModel +from pydantic import BaseModel, NonNegativeFloat from typing import Optional, List, Dict from src.common.logger import get_logger from src.common.database.database_model import Expression, ChatStreams @@ -21,7 +21,6 @@ class ExpressionResponse(BaseModel): situation: str style: str context: Optional[str] - up_content: Optional[str] last_active_time: float chat_id: str create_date: Optional[float] @@ -49,8 +48,7 @@ class ExpressionCreateRequest(BaseModel): situation: str style: str - context: Optional[str] = None - up_content: Optional[str] = None + context: Optional[str] = NonNegativeFloat chat_id: str @@ -60,7 +58,6 @@ class ExpressionUpdateRequest(BaseModel): situation: Optional[str] = None style: Optional[str] = None context: Optional[str] = None - up_content: Optional[str] = None chat_id: Optional[str] = None @@ -102,7 +99,6 @@ def expression_to_response(expression: Expression) -> ExpressionResponse: situation=expression.situation, style=expression.style, context=expression.context, - up_content=expression.up_content, last_active_time=expression.last_active_time, chat_id=expression.chat_id, create_date=expression.create_date, @@ -310,7 +306,6 @@ async def create_expression(request: ExpressionCreateRequest, maibot_session: Op situation=request.situation, style=request.style, context=request.context, - up_content=request.up_content, chat_id=request.chat_id, last_active_time=current_time, create_date=current_time, diff --git a/src/webui/plugin_routes.py b/src/webui/plugin_routes.py index 1f2d85da..9eedc9d7 100644 --- a/src/webui/plugin_routes.py +++ b/src/webui/plugin_routes.py @@ -1420,18 +1420,8 @@ async def update_plugin_config( shutil.copy(config_path, backup_path) logger.info(f"已备份配置文件: {backup_path}") - # 写入新配置(使用 tomlkit 保留注释) - import tomlkit - - # 先读取原配置以保留注释和格式 - existing_doc = tomlkit.document() - if config_path.exists(): - with open(config_path, "r", encoding="utf-8") as f: - existing_doc = tomlkit.load(f) - # 更新值 - for key, value in request.config.items(): - existing_doc[key] = value - save_toml_with_format(existing_doc, str(config_path)) + # 写入新配置(自动保留注释和格式) + save_toml_with_format(request.config, str(config_path)) logger.info(f"已更新插件配置: {plugin_id}") diff --git a/src/webui/routes.py b/src/webui/routes.py index 2c0fc9f7..96942f1d 100644 --- a/src/webui/routes.py +++ b/src/webui/routes.py @@ -223,9 +223,9 @@ async def update_token( # 更新 token success, message = token_manager.update_token(request.new_token) - # 如果更新成功,更新 Cookie + # 如果更新成功,清除 Cookie,要求用户重新登录 if success: - set_auth_cookie(response, request.new_token) + clear_auth_cookie(response) return TokenUpdateResponse(success=success, message=message) except HTTPException: @@ -272,8 +272,8 @@ async def regenerate_token( # 重新生成 token new_token = token_manager.regenerate_token() - # 更新 Cookie - set_auth_cookie(response, new_token) + # 清除 Cookie,要求用户重新登录 + clear_auth_cookie(response) return TokenRegenerateResponse(success=True, token=new_token, message="Token 已重新生成") except HTTPException: diff --git a/src/webui/token_manager.py b/src/webui/token_manager.py index 69abf1d8..17e3f068 100644 --- a/src/webui/token_manager.py +++ b/src/webui/token_manager.py @@ -160,13 +160,29 @@ class TokenManager: def regenerate_token(self) -> str: """ - 重新生成 token + 重新生成 token(保留 first_setup_completed 状态) Returns: str: 新生成的 token """ logger.info("正在重新生成 WebUI Token...") - return self._create_new_token() + + # 生成新的 64 位十六进制字符串 + new_token = secrets.token_hex(32) + + # 加载现有配置,保留 first_setup_completed 状态 + config = self._load_config() + old_token = config.get("access_token", "")[:8] if config.get("access_token") else "无" + first_setup_completed = config.get("first_setup_completed", True) # 默认为 True,表示已完成配置 + + config["access_token"] = new_token + config["updated_at"] = self._get_current_timestamp() + config["first_setup_completed"] = first_setup_completed # 保留原来的状态 + + self._save_config(config) + logger.info(f"WebUI Token 已重新生成: {old_token}... -> {new_token[:8]}...") + + return new_token def _validate_token_format(self, token: str) -> bool: """ diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 9daf8739..a978c93a 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "6.23.5" +version = "7.0.2" #----以下是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- # 如果你想要修改配置文件,请递增version的值 @@ -69,6 +69,7 @@ learning_list = [ # 表达学习配置列表,支持按聊天流配置 # 第三位: 是否学习表达 ("enable"/"disable") # 第四位: 学习强度(浮点数),影响学习频率,最短学习时间间隔 = 300/学习强度(秒) # 学习强度越高,学习越频繁;学习强度越低,学习越少 + # 如果学习强度设置为0会自动转换为0.0001以避免除以零错误 ] expression_groups = [ @@ -86,7 +87,7 @@ allow_reflect = [] # 允许进行表达反思的聊天流ID列表,格式:["q [chat] # 麦麦的聊天设置 -talk_value = 1 # 聊天频率,越小越沉默,范围0-1 +talk_value = 1 # 聊天频率,越小越沉默,范围0-1,如果设置为0会自动转换为0.0001以避免除以零错误 mentioned_bot_reply = true # 是否启用提及必回复 max_context_size = 30 # 上下文长度 planner_smooth = 2 # 规划器平滑,增大数值会减小planner负荷,略微降低反应速度,推荐1-5,0为关闭,必须大于等于0 @@ -97,7 +98,7 @@ enable_talk_value_rules = true # 是否启用动态发言频率规则 # 推荐格式(对象数组):{ target="platform:id:type" 或 "", time="HH:MM-HH:MM", value=0.5 } # 说明: # - target 为空字符串表示全局;type 为 group/private,例如:"qq:1919810:group" 或 "qq:114514:private"; -# - 支持跨夜区间,例如 "23:00-02:00";数值范围建议 0-1。 +# - 支持跨夜区间,例如 "23:00-02:00";数值范围建议 0-1,如果 value 设置为0会自动转换为0.0001以避免除以零错误。 talk_value_rules = [ { target = "", time = "00:00-08:59", value = 0.8 }, { target = "", time = "09:00-22:59", value = 1.0 }, @@ -110,6 +111,24 @@ include_planner_reasoning = false # 是否将planner推理加入replyer,默认 [memory] max_agent_iterations = 3 # 记忆思考深度(最低为1(不深入思考)) enable_jargon_detection = true # 记忆检索过程中是否启用黑话识别 +global_memory = false # 是否允许记忆检索进行全局查询 + +[dream] +interval_minutes = 45 # 做梦时间间隔(分钟),默认30分钟 +max_iterations = 20 # 做梦最大轮次,默认20轮 +first_delay_seconds = 1200 # 程序启动后首次做梦前的延迟时间(秒),默认60秒 + +# 做梦时间段配置,格式:["HH:MM-HH:MM", ...] +# 如果列表为空,则表示全天允许做梦。 +# 如果配置了时间段,则只有在这些时间段内才会实际执行做梦流程。 +# 时间段外,调度器仍会按间隔检查,但不会进入做梦流程。 +# 支持跨夜区间,例如 "23:00-02:00" 表示从23:00到次日02:00。 +# 示例: +dream_time_ranges = [ + # "09:00-22:00", # 白天允许做梦 + "23:00-10:00", # 跨夜时间段(23:00到次日10:00) +] +# dream_time_ranges = [] [jargon] all_global = true # 是否开启全局黑话模式,注意,此功能关闭后,已经记录的全局黑话不会改变,需要手动删除 diff --git a/template/model_config_template.toml b/template/model_config_template.toml index 1e072d13..225b94f1 100644 --- a/template/model_config_template.toml +++ b/template/model_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "1.8.2" +version = "1.9.0" # 配置文件版本号迭代规则同bot_config.toml @@ -54,9 +54,11 @@ name = "siliconflow-deepseek-v3.2" api_provider = "SiliconFlow" price_in = 2.0 price_out = 3.0 +# temperature = 0.5 # 可选:为该模型单独指定温度,会覆盖任务配置中的温度 +# max_tokens = 4096 # 可选:为该模型单独指定最大token数,会覆盖任务配置中的max_tokens [models.extra_params] # 可选的额外参数配置 enable_thinking = false # 不启用思考 -# temperature = 0.5 # 可选:为该模型单独指定温度,会覆盖任务配置中的温度 + [[models]] model_identifier = "deepseek-ai/DeepSeek-V3.2-Exp" @@ -64,9 +66,11 @@ name = "siliconflow-deepseek-v3.2-think" api_provider = "SiliconFlow" price_in = 2.0 price_out = 3.0 +# temperature = 0.7 # 可选:为该模型单独指定温度,会覆盖任务配置中的温度 +# max_tokens = 4096 # 可选:为该模型单独指定最大token数,会覆盖任务配置中的max_tokens [models.extra_params] # 可选的额外参数配置 enable_thinking = true # 启用思考 -# temperature = 0.7 # 可选:为该模型单独指定温度,会覆盖任务配置中的温度 + [[models]] model_identifier = "Qwen/Qwen3-Next-80B-A3B-Instruct" diff --git a/webui/dist/assets/index-DM1UfLap.css b/webui/dist/assets/index-DM1UfLap.css new file mode 100644 index 00000000..997524d6 Binary files /dev/null and b/webui/dist/assets/index-DM1UfLap.css differ diff --git a/webui/dist/assets/index-QJDQd8Xo.css b/webui/dist/assets/index-QJDQd8Xo.css deleted file mode 100644 index 06b5dafa..00000000 Binary files a/webui/dist/assets/index-QJDQd8Xo.css and /dev/null differ diff --git a/webui/dist/assets/index-DJb_iiTR.js b/webui/dist/assets/index-siV9e-l5.js similarity index 51% rename from webui/dist/assets/index-DJb_iiTR.js rename to webui/dist/assets/index-siV9e-l5.js index 886bc66f..40b8c501 100644 Binary files a/webui/dist/assets/index-DJb_iiTR.js and b/webui/dist/assets/index-siV9e-l5.js differ diff --git a/webui/dist/index.html b/webui/dist/index.html index 66ce72c0..30c422b4 100644 Binary files a/webui/dist/index.html and b/webui/dist/index.html differ