diff --git a/bot.py b/bot.py index 3737279d..0e3439c1 100644 --- a/bot.py +++ b/bot.py @@ -13,6 +13,8 @@ from src.common.logger_manager import get_logger from src.common.crash_logger import install_crash_handler from src.main import MainSystem from rich.traceback import install +from src.plugins.group_nickname.nickname_manager import nickname_manager +import atexit from src.manager.async_task_manager import async_task_manager @@ -216,6 +218,19 @@ def raw_main(): env_config = {key: os.getenv(key) for key in os.environ} scan_provider(env_config) + # 确保 NicknameManager 单例实例存在并已初始化 + # (单例模式下,导入时或第一次调用时会自动初始化) + _ = nickname_manager # 显式引用一次 + + # 启动 NicknameManager 的后台处理器线程 + logger.info("准备启动绰号处理管理器...") + nickname_manager.start_processor() # 调用实例的方法 + logger.info("已调用启动绰号处理管理器。") + + # 注册 NicknameManager 的停止方法到 atexit,确保程序退出时线程能被清理 + atexit.register(nickname_manager.stop_processor) # 注册实例的方法 + logger.info("已注册绰号处理管理器的退出处理程序。") + # 返回MainSystem实例 return MainSystem() diff --git a/requirements.txt b/requirements.txt index 7abdffb4..0a0c3419 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/src/chat/focus_chat/expressors/default_expressor.py b/src/chat/focus_chat/expressors/default_expressor.py index 37c50c0d..12750e9c 100644 --- a/src/chat/focus_chat/expressors/default_expressor.py +++ b/src/chat/focus_chat/expressors/default_expressor.py @@ -18,6 +18,7 @@ from src.manager.mood_manager import mood_manager from src.chat.heart_flow.utils_chat import get_chat_type_and_target_info from src.chat.message_receive.chat_stream import ChatStream from src.chat.focus_chat.hfc_utils import parse_thinking_id_to_timestamp +from src.plugins.group_nickname.nickname_manager import nickname_manager logger = get_logger("expressor") @@ -112,6 +113,17 @@ class DefaultExpressor: response_set=reply, ) has_sent_something = True + + # 为 trigger_nickname_analysis 准备 bot_reply 参数 + bot_reply_for_analysis = [] + if reply: # reply 是 List[Tuple[str, str]] + for seg_type, seg_data in reply: + if seg_type == "text": # 只取文本类型的数据 + bot_reply_for_analysis.append(seg_data) + + await nickname_manager.trigger_nickname_analysis( + anchor_message, bot_reply_for_analysis, self.chat_stream + ) else: logger.warning(f"{self.log_prefix} 文本回复生成失败") diff --git a/src/chat/focus_chat/heartFC_chat.py b/src/chat/focus_chat/heartFC_chat.py index 4a28652d..2d895eb7 100644 --- a/src/chat/focus_chat/heartFC_chat.py +++ b/src/chat/focus_chat/heartFC_chat.py @@ -89,6 +89,7 @@ class HeartFChatting: self.memory_activator = MemoryActivator() self.expressor = DefaultExpressor(chat_id=self.stream_id) self.action_manager = ActionManager() + self.action_planner = ActionPlanner(log_prefix=self.log_prefix, action_manager=self.action_manager, stream_id=self.stream_id, chat_stream=self.chat_stream) self.action_planner = ActionPlanner(log_prefix=self.log_prefix, action_manager=self.action_manager) diff --git a/src/chat/focus_chat/heartflow_prompt_builder.py b/src/chat/focus_chat/heartflow_prompt_builder.py index 55fb79b4..c356aabb 100644 --- a/src/chat/focus_chat/heartflow_prompt_builder.py +++ b/src/chat/focus_chat/heartflow_prompt_builder.py @@ -6,16 +6,15 @@ from src.chat.utils.chat_message_builder import build_readable_messages, get_raw from src.chat.person_info.relationship_manager import relationship_manager from src.chat.utils.utils import get_embedding import time -from typing import Union, Optional, Dict, Any +from typing import Union, Optional from src.common.database import db from src.chat.utils.utils import get_recent_group_speaker from src.manager.mood_manager import mood_manager from src.chat.memory_system.Hippocampus import HippocampusManager from src.chat.knowledge.knowledge_lib import qa_manager from src.chat.focus_chat.expressors.exprssion_learner import expression_learner -import traceback import random - +from src.plugins.group_nickname.nickname_manager import nickname_manager logger = get_logger("prompt") @@ -25,6 +24,7 @@ def init_prompt(): """ 你可以参考以下的语言习惯,如果情景合适就使用,不要盲目使用,不要生硬使用,而是结合到表达中: {style_habbits} +{nickname_info} 你现在正在群里聊天,以下是群里正在进行的聊天内容: {chat_info} @@ -62,6 +62,7 @@ def init_prompt(): {memory_prompt} {relation_prompt} {prompt_info} +{nickname_info} {chat_target} {chat_talking_prompt} 现在"{sender_name}"说的:{message_txt}。引起了你的注意,你想要在群里发言或者回复这条消息。\n @@ -201,11 +202,17 @@ async def _build_prompt_focus( chat_target_1 = await global_prompt_manager.get_prompt_async("chat_target_group1") # chat_target_2 = await global_prompt_manager.get_prompt_async("chat_target_group2") + # 调用新的工具函数获取绰号信息 + nickname_injection_str = await nickname_manager.get_nickname_prompt_injection( + chat_stream, message_list_before_now + ) + prompt = await global_prompt_manager.format_prompt( template_name, # info_from_tools=structured_info_prompt, style_habbits=style_habbits_str, grammar_habbits=grammar_habbits_str, + nickname_info=nickname_injection_str, chat_target=chat_target_1, # Used in group template # chat_talking_prompt=chat_talking_prompt, chat_info=chat_talking_prompt, @@ -387,6 +394,11 @@ class PromptBuilder: chat_target_1 = await global_prompt_manager.get_prompt_async("chat_target_group1") chat_target_2 = await global_prompt_manager.get_prompt_async("chat_target_group2") + # 调用新的工具函数获取绰号信息 + nickname_injection_str = await nickname_manager.get_nickname_prompt_injection( + chat_stream, message_list_before_now + ) + prompt = await global_prompt_manager.format_prompt( template_name, relation_prompt=relation_prompt, @@ -395,6 +407,7 @@ class PromptBuilder: prompt_info=prompt_info, chat_target=chat_target_1, chat_target_2=chat_target_2, + nickname_info=nickname_injection_str, # <--- 注入绰号信息 chat_talking_prompt=chat_talking_prompt, message_txt=message_txt, bot_name=global_config.BOT_NICKNAME, diff --git a/src/chat/focus_chat/planners/planner.py b/src/chat/focus_chat/planners/planner.py index bb87e1da..33ecb7f5 100644 --- a/src/chat/focus_chat/planners/planner.py +++ b/src/chat/focus_chat/planners/planner.py @@ -1,7 +1,9 @@ +import time import json # <--- 确保导入 json import traceback from typing import List, Dict, Any, Optional from rich.traceback import install +from src.chat.message_receive.chat_stream import ChatStream from src.chat.models.utils_model import LLMRequest from src.config.config import global_config from src.chat.focus_chat.heartflow_prompt_builder import prompt_builder @@ -15,6 +17,9 @@ from src.chat.utils.prompt_builder import Prompt, global_prompt_manager from src.individuality.individuality import Individuality from src.chat.focus_chat.planners.action_factory import ActionManager from src.chat.focus_chat.planners.action_factory import ActionInfo +from src.chat.utils.chat_message_builder import get_raw_msg_before_timestamp_with_chat +from src.plugins.group_nickname.nickname_manager import nickname_manager + logger = get_logger("planner") install(extra_lines=3) @@ -22,6 +27,7 @@ install(extra_lines=3) def init_prompt(): Prompt( """你的名字是{bot_name},{prompt_personality},{chat_context_description}。需要基于以下信息决定如何参与对话: +{nickname_info_block} {chat_content_block} {mind_info_block} {cycle_info_block} @@ -60,7 +66,7 @@ action_name: {action_name} class ActionPlanner: - def __init__(self, log_prefix: str, action_manager: ActionManager): + def __init__(self, log_prefix: str, action_manager: ActionManager, stream_id: str, chat_stream: ChatStream): self.log_prefix = log_prefix # LLM规划器配置 self.planner_llm = LLMRequest( @@ -68,8 +74,9 @@ class ActionPlanner: max_tokens=1000, request_type="action_planning", # 用于动作规划 ) - self.action_manager = action_manager + self.stream_id = stream_id + self.chat_stream = chat_stream async def plan(self, all_plan_info: List[InfoBase], cycle_timers: dict) -> Dict[str, Any]: """ @@ -242,7 +249,7 @@ class ActionPlanner: # print(using_actions_info["parameters"]) # print(using_actions_info["require"]) # print(using_actions_info["description"]) - + using_action_prompt = await global_prompt_manager.get_prompt_async("action_prompt") param_text = "" @@ -261,8 +268,17 @@ class ActionPlanner: ) action_options_block += using_action_prompt - + # 需要获取用于上下文的历史消息 + message_list_before_now = get_raw_msg_before_timestamp_with_chat( + chat_id=self.stream_id, + timestamp=time.time(), # 使用当前时间作为参考点 + limit=global_config.observation_context_size, # 使用与 prompt 构建一致的 limit + ) + # 调用工具函数获取格式化后的绰号字符串 + nickname_injection_str = await nickname_manager.get_nickname_prompt_injection( + self.chat_stream, message_list_before_now + ) planner_prompt_template = await global_prompt_manager.get_prompt_async("planner_prompt") @@ -274,6 +290,7 @@ class ActionPlanner: mind_info_block=mind_info_block, cycle_info_block=cycle_info, action_options_text=action_options_block, + nickname_info_block=nickname_injection_str, ) return prompt diff --git a/src/chat/heart_flow/subheartflow_manager.py b/src/chat/heart_flow/subheartflow_manager.py index a4bff833..cd452e53 100644 --- a/src/chat/heart_flow/subheartflow_manager.py +++ b/src/chat/heart_flow/subheartflow_manager.py @@ -401,7 +401,7 @@ class SubHeartflowManager: _mai_state_description = f"你当前状态: {current_mai_state.value}。" individuality = Individuality.get_instance() - personality_prompt = individuality.get_prompt(x_person=2, level=2) + personality_prompt = individuality.get_prompt(x_person=2, level=3) prompt_personality = f"你正在扮演名为{individuality.name}的人类,{personality_prompt}" # --- 修改:在 prompt 中加入当前聊天计数和群名信息 (条件显示) --- diff --git a/src/chat/knowledge/src/qa_manager.py b/src/chat/knowledge/src/qa_manager.py index 11067d0e..06c21e88 100644 --- a/src/chat/knowledge/src/qa_manager.py +++ b/src/chat/knowledge/src/qa_manager.py @@ -84,9 +84,9 @@ class QAManager: relation_search_res, paragraph_search_res, self.embed_manager ) part_end_time = time.perf_counter() - logger.info(f"RAG检索用时:{part_end_time - part_start_time:.5f}s") + logger.infoinfo(f"RAG检索用时:{part_end_time - part_start_time:.5f}s") else: - logger.info("未找到相关关系,将使用文段检索结果") + logger.infoinfo("未找到相关关系,将使用文段检索结果") result = paragraph_search_res ppr_node_weights = None @@ -95,7 +95,7 @@ class QAManager: for res in result: raw_paragraph = self.embed_manager.paragraphs_embedding_store.store[res[0]].str - print(f"找到相关文段,相关系数:{res[1]:.8f}\n{raw_paragraph}\n\n") + logger.info(f"找到相关文段,相关系数:{res[1]:.8f}\n{raw_paragraph}\n\n") return result, ppr_node_weights else: diff --git a/src/chat/message_receive/bot.py b/src/chat/message_receive/bot.py index d94208c0..4755ef2e 100644 --- a/src/chat/message_receive/bot.py +++ b/src/chat/message_receive/bot.py @@ -6,6 +6,7 @@ from src.manager.mood_manager import mood_manager # 导入情绪管理器 from src.chat.message_receive.message import MessageRecv from src.experimental.PFC.pfc_processor import PFCProcessor from src.chat.focus_chat.heartflow_processor import HeartFCProcessor +from src.experimental.Legacy_HFC.heartflow_processor import HeartFCProcessor as LegacyHeartFlowProcessor from src.chat.utils.prompt_builder import Prompt, global_prompt_manager from src.config.config import global_config @@ -22,6 +23,7 @@ class ChatBot: self._started = False self.mood_manager = mood_manager # 获取情绪管理器单例 self.heartflow_processor = HeartFCProcessor() # 新增 + self.legacy_hfc_processor = LegacyHeartFlowProcessor() self.pfc_processor = PFCProcessor() async def _ensure_started(self): @@ -66,12 +68,14 @@ class ChatBot: logger.debug(f"用户{userinfo.user_id}被禁止回复") return - if groupinfo is None: + if groupinfo is None and global_config.enable_friend_whitelist: logger.trace("检测到私聊消息,检查") # 好友黑名单拦截 if userinfo.user_id not in global_config.talk_allowed_private: logger.debug(f"用户{userinfo.user_id}没有私聊权限") return + elif not global_config.enable_friend_whitelist: + logger.debug("私聊白名单模式未启用,跳过私聊权限检查。") # 群聊黑名单拦截 if groupinfo is not None and groupinfo.group_id not in global_config.talk_allowed_groups: @@ -90,6 +94,11 @@ class ChatBot: else: template_group_name = None + if not global_config.enable_Legacy_HFC: + hfc_processor = self.heartflow_processor + else: + hfc_processor = self.legacy_hfc_processor + async def preprocess(): logger.trace("开始预处理消息...") # 如果在私聊中 @@ -105,11 +114,11 @@ class ChatBot: # 禁止PFC,进入普通的心流消息处理逻辑 else: logger.trace("进入普通心流私聊处理") - await self.heartflow_processor.process_message(message_data) + await hfc_processor.process_message(message_data) # 群聊默认进入心流消息处理逻辑 else: logger.trace(f"检测到群聊消息,群ID: {groupinfo.group_id}") - await self.heartflow_processor.process_message(message_data) + await hfc_processor.process_message(message_data) if template_group_name: async with global_prompt_manager.async_message_scope(template_group_name): diff --git a/src/chat/models/utils_model.py b/src/chat/models/utils_model.py index e662a8e3..35f445a2 100644 --- a/src/chat/models/utils_model.py +++ b/src/chat/models/utils_model.py @@ -886,4 +886,4 @@ def compress_base64_image_by_scale(base64_data: str, target_size: int = 0.8 * 10 import traceback logger.error(traceback.format_exc()) - return base64_data + return base64_data \ No newline at end of file diff --git a/src/chat/normal_chat/normal_chat.py b/src/chat/normal_chat/normal_chat.py index 9dc2454f..4ddfdd3d 100644 --- a/src/chat/normal_chat/normal_chat.py +++ b/src/chat/normal_chat/normal_chat.py @@ -21,6 +21,7 @@ from src.chat.utils.utils_image import image_path_to_base64 from src.chat.emoji_system.emoji_manager import emoji_manager from src.chat.normal_chat.willing.willing_manager import willing_manager from src.config.config import global_config +from src.plugins.group_nickname.nickname_manager import nickname_manager logger = get_logger("chat") @@ -316,6 +317,7 @@ class NormalChat: # 检查 first_bot_msg 是否为 None (例如思考消息已被移除的情况) if first_bot_msg: info_catcher.catch_after_response(timing_results["消息发送"], response_set, first_bot_msg) + await nickname_manager.trigger_nickname_analysis(message, response_set, self.chat_stream) else: logger.warning(f"[{self.stream_name}] 思考消息 {thinking_id} 在发送前丢失,无法记录 info_catcher") diff --git a/src/chat/person_info/person_info.py b/src/chat/person_info/person_info.py index 605b86b2..bc65776e 100644 --- a/src/chat/person_info/person_info.py +++ b/src/chat/person_info/person_info.py @@ -53,6 +53,7 @@ person_info_default = { "msg_interval_list": [], "user_cardname": None, # 添加群名片 "user_avatar": None, # 添加头像信息(例如URL或标识符) + "group_nicknames": [], } # 个人信息的各项与默认值在此定义,以下处理会自动创建/补全每一项 diff --git a/src/chat/person_info/relationship_manager.py b/src/chat/person_info/relationship_manager.py index c8a44385..3b873f50 100644 --- a/src/chat/person_info/relationship_manager.py +++ b/src/chat/person_info/relationship_manager.py @@ -5,6 +5,8 @@ from bson.decimal128 import Decimal128 from .person_info import person_info_manager import time import random +from typing import List, Dict +from ...common.database import db from maim_message import UserInfo from ...manager.mood_manager import mood_manager @@ -80,6 +82,131 @@ class RelationshipManager: is_known = person_info_manager.is_person_known(platform, user_id) return is_known + # --- [修改] 使用全局 db 对象进行查询 --- + @staticmethod + async def get_person_names_batch(platform: str, user_ids: List[str]) -> Dict[str, str]: + """ + 批量获取多个用户的 person_name。 + """ + if not user_ids: + return {} + + person_ids = [person_info_manager.get_person_id(platform, str(uid)) for uid in user_ids] + names_map = {} + try: + cursor = db.person_info.find( + {"person_id": {"$in": person_ids}}, + {"_id": 0, "person_id": 1, "user_id": 1, "person_name": 1}, # 只查询需要的字段 + ) + + for doc in cursor: + user_id_val = doc.get("user_id") # 获取原始值 + original_user_id = None # 初始化 + + if isinstance(user_id_val, (int, float)): # 检查是否是数字类型 + original_user_id = str(user_id_val) # 直接转换为字符串 + elif isinstance(user_id_val, str): # 检查是否是字符串 + if "_" in user_id_val: # 如果包含下划线,则分割 + original_user_id = user_id_val.split("_", 1)[-1] + else: # 如果不包含下划线,则直接使用该字符串 + original_user_id = user_id_val + # else: # 其他类型或 None,original_user_id 保持为 None + + person_name = doc.get("person_name") + + # 确保 original_user_id 和 person_name 都有效 + if original_user_id and person_name: + names_map[original_user_id] = person_name + + logger.debug(f"批量获取 {len(user_ids)} 个用户的 person_name,找到 {len(names_map)} 个。") + except AttributeError as e: + # 如果 db 对象没有 person_info 属性,或者 find 方法不存在 + logger.error(f"访问数据库时出错: {e}。请检查 common/database.py 和集合名称。") + except Exception as e: + logger.error(f"批量获取 person_name 时出错: {e}", exc_info=True) + return names_map + + @staticmethod + async def get_users_group_nicknames( + platform: str, user_ids: List[str], group_id: str + ) -> Dict[str, List[Dict[str, int]]]: + """ + 批量获取多个用户在指定群组的绰号信息。 + + Args: + platform (str): 平台名称。 + user_ids (List[str]): 用户 ID 列表。 + group_id (str): 群组 ID。 + + Returns: + Dict[str, List[Dict[str, int]]]: 映射 {person_name: [{"绰号A": 次数}, ...]} + """ + if not user_ids or not group_id: + return {} + + person_ids = [person_info_manager.get_person_id(platform, str(uid)) for uid in user_ids] + nicknames_data = {} + group_id_str = str(group_id) # 确保 group_id 是字符串 + + try: + # 查询包含目标 person_id 的文档 + cursor = db.person_info.find( + {"person_id": {"$in": person_ids}}, + {"_id": 0, "person_id": 1, "person_name": 1, "group_nicknames": 1}, # 查询所需字段 + ) + + # 假设同步迭代可行 + for doc in cursor: + person_name = doc.get("person_name") + if not person_name: + continue # 跳过没有 person_name 的用户 + + group_nicknames_list = doc.get("group_nicknames", []) # 获取 group_nicknames 数组 + target_group_nicknames = [] # 存储目标群组的绰号列表 + + # 遍历 group_nicknames 数组,查找匹配的 group_id + for group_entry in group_nicknames_list: + # 确保 group_entry 是字典且包含 group_id 键 + if isinstance(group_entry, dict) and group_entry.get("group_id") == group_id_str: + # 提取 nicknames 列表 + nicknames_raw = group_entry.get("nicknames", []) + if isinstance(nicknames_raw, list): + target_group_nicknames = nicknames_raw + break # 找到匹配的 group_id 后即可退出内层循环 + + # 如果找到了目标群组的绰号列表 + if target_group_nicknames: + valid_nicknames_formatted = [] # 存储格式化后的绰号 + for item in target_group_nicknames: + # 校验每个绰号条目的格式 { "name": str, "count": int } + if ( + isinstance(item, dict) + and isinstance(item.get("name"), str) + and isinstance(item.get("count"), int) + and item["count"] > 0 + ): # 确保 count 是正整数 + # --- 格式转换:从 { "name": "xxx", "count": y } 转为 { "xxx": y } --- + valid_nicknames_formatted.append({item["name"]: item["count"]}) + # --- 结束格式转换 --- + else: + logger.warning( + f"数据库中用户 {person_name} 群组 {group_id_str} 的绰号格式无效或 count <= 0: {item}" + ) + + if valid_nicknames_formatted: # 如果存在有效的、格式化后的绰号 + nicknames_data[person_name] = valid_nicknames_formatted # 使用 person_name 作为 key + + logger.debug( + f"批量获取群组 {group_id_str} 中 {len(user_ids)} 个用户的绰号,找到 {len(nicknames_data)} 个用户的数据。" + ) + + except AttributeError as e: + logger.error(f"访问数据库时出错: {e}。请检查 common/database.py 和集合名称 'person_info'。") + except Exception as e: + logger.error(f"批量获取群组绰号时出错: {e}", exc_info=True) + + return nicknames_data + @staticmethod async def is_qved_name(platform, user_id): """判断是否认识某人""" diff --git a/src/chat/utils/chat_message_builder.py b/src/chat/utils/chat_message_builder.py index 15b1e4fc..f8ae89d6 100644 --- a/src/chat/utils/chat_message_builder.py +++ b/src/chat/utils/chat_message_builder.py @@ -250,6 +250,8 @@ async def _build_readable_messages_internal( message_details_raw.sort(key=lambda x: x[0]) # 按时间戳(第一个元素)升序排序,越早的消息排在前面 # 应用截断逻辑 (如果 truncate 为 True) + if not global_config.long_message_auto_truncate: + truncate = False message_details: List[Tuple[float, str, str]] = [] n_messages = len(message_details_raw) if truncate and n_messages > 0: diff --git a/src/chat/utils/statistic.py b/src/chat/utils/statistic.py index 3f983292..5fa76deb 100644 --- a/src/chat/utils/statistic.py +++ b/src/chat/utils/statistic.py @@ -764,4 +764,4 @@ class StatisticOutputTask(AsyncTask): ) with open(self.record_file_path, "w", encoding="utf-8") as f: - f.write(html_template) + f.write(html_template) \ No newline at end of file diff --git a/src/chat/utils/utils.py b/src/chat/utils/utils.py index 8fe8334b..d30d96ba 100644 --- a/src/chat/utils/utils.py +++ b/src/chat/utils/utils.py @@ -1,5 +1,6 @@ import random import re +import regex import time from collections import Counter @@ -18,6 +19,135 @@ from ...config.config import global_config logger = get_module_logger("chat_utils") +# 预编译正则表达式以提高性能 +_L_REGEX = regex.compile(r"\p{L}") # 匹配任何Unicode字母 +_HAN_CHAR_REGEX = regex.compile(r"\p{Han}") # 匹配汉字 (Unicode属性) +_Nd_REGEX = regex.compile(r"\p{Nd}") # 新增:匹配Unicode数字 (Nd = Number, decimal digit) + +BOOK_TITLE_PLACEHOLDER_PREFIX = "__BOOKTITLE_" +SEPARATORS = {"。", ",", ",", " ", ";", "\xa0", "\n", ".", "—", "!", "?"} +KNOWN_ABBREVIATIONS_ENDING_WITH_DOT = { + "Mr.", + "Mrs.", + "Ms.", + "Dr.", + "Prof.", + "St.", + "Messrs.", + "Mmes.", + "Capt.", + "Gov.", + "Inc.", + "Ltd.", + "Corp.", + "Co.", + "PLC", # PLC通常不带点,但有些可能 + "vs.", + "etc.", + "i.e.", + "e.g.", + "viz.", + "al.", + "et al.", + "ca.", + "cf.", + "No.", + "Vol.", + "pp.", + "fig.", + "figs.", + "ed.", + "Ph.D.", + "M.D.", + "B.A.", + "M.A.", + "Jan.", + "Feb.", + "Mar.", + "Apr.", + "Jun.", + "Jul.", + "Aug.", + "Sep.", + "Oct.", + "Nov.", + "Dec.", # May. 通常不用点 + "Mon.", + "Tue.", + "Wed.", + "Thu.", + "Fri.", + "Sat.", + "Sun.", + "U.S.", + "U.K.", + "E.U.", + "U.S.A.", + "U.S.S.R.", + "Ave.", + "Blvd.", + "Rd.", + "Ln.", # Street suffixes + "approx.", + "dept.", + "appt.", + "श्री.", # Hindi Shri. +} + + +def is_letter_not_han(char_str: str) -> bool: + """ + 检查字符是否为“字母”且“非汉字”。 + 例如拉丁字母、西里尔字母、韩文等返回True。 + 汉字、数字、标点、空格等返回False。 + """ + if not isinstance(char_str, str) or len(char_str) != 1: + return False + + is_letter = _L_REGEX.fullmatch(char_str) is not None + if not is_letter: + return False + + # 使用 \p{Han} 属性进行汉字判断,更为准确 + is_han = _HAN_CHAR_REGEX.fullmatch(char_str) is not None + return not is_han + + +def is_han_character(char_str: str) -> bool: + """检查字符是否为汉字 (使用 \p{Han} Unicode 属性)""" + if not isinstance(char_str, str) or len(char_str) != 1: + return False + return _HAN_CHAR_REGEX.fullmatch(char_str) is not None + + +def is_digit(char_str: str) -> bool: + """检查字符是否为Unicode数字""" + if not isinstance(char_str, str) or len(char_str) != 1: + return False + return _Nd_REGEX.fullmatch(char_str) is not None + + +def is_relevant_word_char(char_str: str) -> bool: # 新增辅助函数 + """ + 检查字符是否为“相关词语字符”(非汉字字母 或 数字)。 + 用于判断在非中文语境下,空格两侧是否应被视为一个词内部的部分。 + 例如拉丁字母、西里尔字母、数字等返回True。 + 汉字、标点、纯空格等返回False。 + """ + if not isinstance(char_str, str) or len(char_str) != 1: + return False + + # 检查是否为Unicode字母 + if _L_REGEX.fullmatch(char_str): + # 如果是字母,则检查是否非汉字 + return not _HAN_CHAR_REGEX.fullmatch(char_str) + + # 检查是否为Unicode数字 + if _Nd_REGEX.fullmatch(char_str): + return True # 数字本身被视为相关词语字符 + + return False + def is_english_letter(char: str) -> bool: """检查字符是否为英文字母(忽略大小写)""" @@ -75,7 +205,8 @@ def is_mentioned_bot_in_message(message: MessageRecv) -> tuple[bool, float]: if not is_mentioned: # 判断是否被回复 if re.match( - f"\[回复 [\s\S]*?\({str(global_config.BOT_QQ)}\):[\s\S]*?],说:", message.processed_plain_text + f"\[回复 [\s\S]*?\({str(global_config.BOT_QQ)}\):[\s\S]*?\],说:", + message.processed_plain_text, ): is_mentioned = True else: @@ -172,124 +303,182 @@ def get_recent_group_speaker(chat_stream_id: int, sender, limit: int = 12) -> li return who_chat_in_group -def split_into_sentences_w_remove_punctuation(text: str) -> list[str]: - """将文本分割成句子,并根据概率合并 - 1. 识别分割点(, , 。 ; 空格),但如果分割点左右都是英文字母则不分割。 - 2. 将文本分割成 (内容, 分隔符) 的元组。 - 3. 根据原始文本长度计算合并概率,概率性地合并相邻段落。 - 注意:此函数假定颜文字已在上层被保护。 - Args: - text: 要分割的文本字符串 (假定颜文字已被保护) - Returns: - List[str]: 分割和合并后的句子列表 - """ - # 预处理:处理多余的换行符 - # 1. 将连续的换行符替换为单个换行符 - text = re.sub(r"\n\s*\n+", "\n", text) - # 2. 处理换行符和其他分隔符的组合 - text = re.sub(r"\n\s*([,,。;\s])", r"\1", text) - text = re.sub(r"([,,。;\s])\s*\n", r"\1", text) +def split_into_sentences_w_remove_punctuation(original_text: str) -> list[str]: + """将文本分割成句子,并根据概率合并""" + # print(f"DEBUG: 输入文本 (repr): {repr(text)}") + text, local_book_title_mapping = protect_book_titles(original_text) + perform_book_title_recovery_here = True + # 预处理 + text = regex.sub(r"\n\s*\n+", "\n", text) # 合并多个换行符 + text = regex.sub(r"\n\s*([—。.,,;\s\xa0!?])", r"\1", text) + text = regex.sub(r"([—。.,,;\s\xa0!?])\s*\n", r"\1", text) - # 处理两个汉字中间的换行符 - text = re.sub(r"([\u4e00-\u9fff])\n([\u4e00-\u9fff])", r"\1。\2", text) + def replace_han_newline(match): + char1 = match.group(1) + char2 = match.group(2) + if is_han_character(char1) and is_han_character(char2): + return char1 + "," + char2 # 汉字间的换行符替换为逗号 + return match.group(0) + + text = regex.sub(r"(.)\n(.)", replace_han_newline, text) len_text = len(text) if len_text < 3: - if random.random() < 0.01: - return list(text) # 如果文本很短且触发随机条件,直接按字符分割 - else: - return [text] + stripped_text = text.strip() + if not stripped_text: + return [] + if len(stripped_text) == 1 and stripped_text in SEPARATORS: + return [] + return [stripped_text] - # 定义分隔符 - separators = {",", ",", " ", "。", ";"} segments = [] current_segment = "" - - # 1. 分割成 (内容, 分隔符) 元组 i = 0 while i < len(text): char = text[i] - if char in separators: - # 检查分割条件:如果分隔符左右都是英文字母,则不分割 - can_split = True - if 0 < i < len(text) - 1: - prev_char = text[i - 1] - next_char = text[i + 1] - # if is_english_letter(prev_char) and is_english_letter(next_char) and char == ' ': # 原计划只对空格应用此规则,现应用于所有分隔符 - if is_english_letter(prev_char) and is_english_letter(next_char): - can_split = False + if char in SEPARATORS: + can_split_current_char = True - if can_split: - # 只有当当前段不为空时才添加 - if current_segment: + if char == ".": + can_split_this_dot = True + # 规则1: 小数点 (数字.数字) + if 0 < i < len_text - 1 and is_digit(text[i - 1]) and is_digit(text[i + 1]): + can_split_this_dot = False + # 规则2: 西文缩写/域名内部的点 (西文字母.西文字母) + elif 0 < i < len_text - 1 and is_letter_not_han(text[i - 1]) and is_letter_not_han(text[i + 1]): + can_split_this_dot = False + # 规则3: 已知缩写词的末尾点 (例如 "e.g. ", "U.S.A. ") + else: + potential_abbreviation_word = current_segment + char + is_followed_by_space = i + 1 < len_text and text[i + 1] == " " + is_at_end_of_text = i + 1 == len_text + + if potential_abbreviation_word in KNOWN_ABBREVIATIONS_ENDING_WITH_DOT and ( + is_followed_by_space or is_at_end_of_text + ): + can_split_this_dot = False + can_split_current_char = can_split_this_dot + elif char == " " or char == "\xa0": # 处理空格/NBSP + if 0 < i < len_text - 1: + prev_char = text[i - 1] + next_char = text[i + 1] + # 非中文单词内部的空格不分割 (例如 "hello world", "слово1 слово2") + if is_relevant_word_char(prev_char) and is_relevant_word_char(next_char): + can_split_current_char = False + + if can_split_current_char: + if current_segment: # 如果当前段落有内容,则添加 (内容, 分隔符) segments.append((current_segment, char)) - # 如果当前段为空,但分隔符是空格,则也添加一个空段(保留空格) - elif char == " ": - segments.append(("", char)) - current_segment = "" + # 如果当前段落为空,但分隔符不是简单的排版空格 (除非是换行符这种有意义的空行分隔) + elif char not in [" ", "\xa0"] or char == "\n": + segments.append(("", char)) # 添加 ("", 分隔符) + current_segment = "" # 重置当前段落 else: - # 不分割,将分隔符加入当前段 - current_segment += char + current_segment += char # 不分割,将当前分隔符加入到当前段落 else: - current_segment += char + current_segment += char # 非分隔符,加入当前段落 i += 1 - # 添加最后一个段(没有后续分隔符) - if current_segment: + if current_segment: # 处理末尾剩余的段落 segments.append((current_segment, "")) - # 过滤掉完全空的段(内容和分隔符都为空) - segments = [(content, sep) for content, sep in segments if content or sep] + # 过滤掉仅由空格组成的segment,但保留其后的有效分隔符 + filtered_segments = [] + for content, sep in segments: + stripped_content = content.strip() + if stripped_content: + filtered_segments.append((stripped_content, sep)) + elif sep and (sep not in [" ", "\xa0"] or sep == "\n"): + filtered_segments.append(("", sep)) + segments = filtered_segments - # 如果分割后为空(例如,输入全是分隔符且不满足保留条件),恢复颜文字并返回 if not segments: - # recovered_text = recover_kaomoji([text], mapping) # 恢复原文本中的颜文字 - 已移至上层处理 - # return [s for s in recovered_text if s] # 返回非空结果 - return [text] if text else [] # 如果原始文本非空,则返回原始文本(可能只包含未被分割的字符或颜文字占位符) + return [text.strip()] if text.strip() else [] + + preliminary_final_sentences = [] + current_sentence_build = "" + for k, (content, sep) in enumerate(segments): + current_sentence_build += content # 先添加内容部分 + + # 判断分隔符类型 + is_strong_terminator = sep in {"。", ".", "!", "?", "\n", "—"} + is_space_separator = sep in [" ", "\xa0"] + + if is_strong_terminator: + current_sentence_build += sep # 将强终止符加入 + if current_sentence_build.strip(): + preliminary_final_sentences.append(current_sentence_build.strip()) + current_sentence_build = "" # 开始新的句子构建 + elif is_space_separator: + # 如果是空格,并且当前构建的句子不以空格结尾,则添加空格并继续构建 + if not current_sentence_build.endswith(sep): + current_sentence_build += sep + elif sep: # 其他分隔符 (如 ',', ';') + current_sentence_build += sep # 加入并继续构建,这些通常不独立成句 + # 如果这些弱分隔符后紧跟的就是文本末尾,则它们可能结束一个句子 + if k == len(segments) - 1 and current_sentence_build.strip(): + preliminary_final_sentences.append(current_sentence_build.strip()) + current_sentence_build = "" + + if current_sentence_build.strip(): # 处理最后一个构建中的句子 + preliminary_final_sentences.append(current_sentence_build.strip()) + + preliminary_final_sentences = [s for s in preliminary_final_sentences if s.strip()] # 清理空字符串 + # print(f"DEBUG: 初步分割(优化组装后)的句子: {preliminary_final_sentences}") + + if not preliminary_final_sentences: + return [] - # 2. 概率合并 if len_text < 12: split_strength = 0.2 elif len_text < 32: - split_strength = 0.6 + split_strength = 0.5 else: split_strength = 0.7 - # 合并概率与分割强度相反 merge_probability = 1.0 - split_strength - merged_segments = [] - idx = 0 - while idx < len(segments): - current_content, current_sep = segments[idx] + if merge_probability == 1.0 and len(preliminary_final_sentences) > 1: + merged_text = " ".join(preliminary_final_sentences).strip() + if merged_text.endswith(",") or merged_text.endswith(","): + merged_text = merged_text[:-1].strip() + return [merged_text] if merged_text else [] + elif len(preliminary_final_sentences) == 1: + s = preliminary_final_sentences[0].strip() + if s.endswith(",") or s.endswith(","): + s = s[:-1].strip() + return [s] if s else [] - # 检查是否可以与下一段合并 - # 条件:不是最后一段,且随机数小于合并概率,且当前段有内容(避免合并空段) - if idx + 1 < len(segments) and random.random() < merge_probability and current_content: - next_content, next_sep = segments[idx + 1] - # 合并: (内容1 + 分隔符1 + 内容2, 分隔符2) - # 只有当下一段也有内容时才合并文本,否则只传递分隔符 - if next_content: - merged_content = current_content + current_sep + next_content - merged_segments.append((merged_content, next_sep)) - else: # 下一段内容为空,只保留当前内容和下一段的分隔符 - merged_segments.append((current_content, next_sep)) + final_sentences_merged = [] + temp_sentence = "" + if preliminary_final_sentences: + temp_sentence = preliminary_final_sentences[0] + for i_merge in range(1, len(preliminary_final_sentences)): + should_merge_based_on_punctuation = True + if temp_sentence and temp_sentence[-1] in {"。", ".", "!", "?"}: + should_merge_based_on_punctuation = False - idx += 2 # 跳过下一段,因为它已被合并 - else: - # 不合并,直接添加当前段 - merged_segments.append((current_content, current_sep)) - idx += 1 + if random.random() < merge_probability and temp_sentence and should_merge_based_on_punctuation: + temp_sentence += " " + preliminary_final_sentences[i_merge] + else: + if temp_sentence: + final_sentences_merged.append(temp_sentence) + temp_sentence = preliminary_final_sentences[i_merge] + if temp_sentence: + final_sentences_merged.append(temp_sentence) - # 提取最终的句子内容 - final_sentences = [content for content, sep in merged_segments if content] # 只保留有内容的段 + processed_sentences_after_merge = [] + for sentence in final_sentences_merged: + s = sentence.strip() + if s.endswith(",") or s.endswith(","): + s = s[:-1].strip() + if s: + s = random_remove_punctuation(s) + processed_sentences_after_merge.append(s) - # 清理可能引入的空字符串和仅包含空白的字符串 - final_sentences = [ - s for s in final_sentences if s.strip() - ] # 过滤掉空字符串以及仅包含空白(如换行符、空格)的字符串 - - logger.debug(f"分割并合并后的句子: {final_sentences}") - return final_sentences + if perform_book_title_recovery_here and local_book_title_mapping: + # 假设 processed_sentences_after_merge 是最终的句子列表 + processed_sentences_after_merge = recover_book_titles(processed_sentences_after_merge, local_book_title_mapping) + return processed_sentences_after_merge def random_remove_punctuation(text: str) -> str: @@ -310,9 +499,9 @@ def random_remove_punctuation(text: str) -> str: continue elif char == ",": rand = random.random() - if rand < 0.25: # 5%概率删除逗号 + if rand < 0.25: # 25%概率删除逗号 continue - elif rand < 0.25: # 20%概率把逗号变成空格 + elif rand < 0.2: # 20%概率把逗号变成空格 result += " " continue result += char @@ -338,7 +527,6 @@ def process_llm_response(text: str) -> list[str]: return ["呃呃"] logger.debug(f"{text}去除括号处理后的文本: {cleaned_text}") - # 对清理后的文本进行进一步处理 max_length = global_config.response_max_length * 2 max_sentence_num = global_config.response_max_sentence_num @@ -404,25 +592,22 @@ def calculate_typing_time( - 在所有输入结束后,额外加上回车时间0.3秒 - 如果is_emoji为True,将使用固定1秒的输入时间 """ - # 将0-1的唤醒度映射到-1到1 mood_arousal = mood_manager.current_mood.arousal - # 映射到0.5到2倍的速度系数 - typing_speed_multiplier = 1.5**mood_arousal # 唤醒度为1时速度翻倍,为-1时速度减半 + typing_speed_multiplier = 1.5**mood_arousal chinese_time *= 1 / typing_speed_multiplier english_time *= 1 / typing_speed_multiplier - # 计算中文字符数 - chinese_chars = sum(1 for char in input_string if "\u4e00" <= char <= "\u9fff") - # 如果只有一个中文字符,使用3倍时间 + # 使用 is_han_character 进行判断 + chinese_chars = sum(1 for char in input_string if is_han_character(char)) + if chinese_chars == 1 and len(input_string.strip()) == 1: - return chinese_time * 3 + 0.3 # 加上回车时间 + return chinese_time * 3 + 0.3 - # 正常计算所有字符的输入时间 - total_time = 0.0 + total_time = 0 for char in input_string: - if "\u4e00" <= char <= "\u9fff": # 判断是否为中文字符 + if is_han_character(char): # 使用 is_han_character 进行判断 total_time += chinese_time - else: # 其他字符(如英文) + else: total_time += english_time if is_emoji: @@ -431,12 +616,7 @@ def calculate_typing_time( if time.time() - thinking_start_time > 10: total_time = 1 - # print(f"thinking_start_time:{thinking_start_time}") - # print(f"nowtime:{time.time()}") - # print(f"nowtime - thinking_start_time:{time.time() - thinking_start_time}") - # print(f"{total_time}") - - return total_time # 加上回车时间 + return total_time def cosine_similarity(v1, v2): @@ -554,7 +734,7 @@ def get_western_ratio(paragraph): if not alnum_chars: return 0.0 - western_count = sum(1 for char in alnum_chars if is_english_letter(char)) + western_count = sum(1 for char in alnum_chars if is_english_letter(char)) # 保持使用 is_english_letter return western_count / len(alnum_chars) @@ -615,7 +795,7 @@ def translate_timestamp_to_human_readable(timestamp: float, mode: str = "normal" str: 格式化后的时间字符串 """ if mode == "normal": - return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + return time.strftime("%Y-%m-%d %H:%M:%S ", time.localtime(timestamp)) elif mode == "relative": now = time.time() diff = now - timestamp @@ -742,3 +922,25 @@ def parse_text_timestamps(text: str, mode: str = "normal") -> str: result_text = re.sub(pattern_instance, readable_time, result_text, count=1) return result_text + +def protect_book_titles(text): + book_title_mapping = {} + book_title_pattern = re.compile(r"《(.*?)》") # 非贪婪匹配 + + def replace_func(match): + # 生成唯一占位符 + placeholder = f"{BOOK_TITLE_PLACEHOLDER_PREFIX}{len(book_title_mapping)}__" + # 存储映射关系 + book_title_mapping[placeholder] = match.group(0) # 存储包含书名号的完整匹配 + return placeholder + + protected_text = book_title_pattern.sub(replace_func, text) + return protected_text, book_title_mapping + +def recover_book_titles(sentences, book_title_mapping): + recovered_sentences = [] + for sentence in sentences: + for placeholder, original_content in book_title_mapping.items(): + sentence = sentence.replace(placeholder, original_content) + recovered_sentences.append(sentence) + return recovered_sentences diff --git a/src/common/logger_manager.py b/src/common/logger_manager.py index 48d415bd..bb67fa0c 100644 --- a/src/common/logger_manager.py +++ b/src/common/logger_manager.py @@ -9,6 +9,7 @@ from src.common.logger import ( RELATION_STYLE_CONFIG, CONFIG_STYLE_CONFIG, HEARTFLOW_STYLE_CONFIG, + SCHEDULE_STYLE_CONFIG, LLM_STYLE_CONFIG, CHAT_STYLE_CONFIG, EMOJI_STYLE_CONFIG, @@ -58,6 +59,7 @@ MODULE_LOGGER_CONFIGS = { "relation": RELATION_STYLE_CONFIG, # 关系 "config": CONFIG_STYLE_CONFIG, # 配置 "heartflow": HEARTFLOW_STYLE_CONFIG, # 麦麦大脑袋 + "schedule": SCHEDULE_STYLE_CONFIG, # 在干嘛 "llm": LLM_STYLE_CONFIG, # 麦麦组织语言 "chat": CHAT_STYLE_CONFIG, # 见闻 "emoji": EMOJI_STYLE_CONFIG, # 表情包 diff --git a/src/config/config.py b/src/config/config.py index 5b7d477d..8c3cbb1a 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -2,6 +2,7 @@ import os import re from dataclasses import dataclass, field from typing import Dict, List, Optional +from dateutil import tz import tomli import tomlkit @@ -152,7 +153,11 @@ class BotConfig: "用一句话或几句话描述人格的一些侧面", ] ) + personality_detail_level: int = ( + 0 # 人设消息注入 prompt 详细等级 (0: 采用默认配置, 1: 核心/随机细节, 2: 核心+随机侧面/全部细节, 3: 全部) + ) expression_style = "描述麦麦说话的表达风格,表达习惯" + enable_expression_learner: bool = True # 是否启用新发言习惯注入,关闭则启用旧方法 # identity identity_detail: List[str] = field( default_factory=lambda: [ @@ -166,11 +171,19 @@ class BotConfig: gender: str = "男" # 性别 appearance: str = "用几句话描述外貌特征" # 外貌特征 + # schedule + ENABLE_SCHEDULE_GEN: bool = False # 是否启用日程生成 + PROMPT_SCHEDULE_GEN = "无日程" + SCHEDULE_DOING_UPDATE_INTERVAL: int = 300 # 日程表更新间隔 单位秒 + SCHEDULE_TEMPERATURE: float = 0.5 # 日程表温度,建议0.5-1.0 + TIME_ZONE: str = "Asia/Shanghai" # 时区 + # chat allow_focus_mode: bool = True # 是否允许专注聊天状态 base_normal_chat_num: int = 3 # 最多允许多少个群进行普通聊天 base_focused_chat_num: int = 2 # 最多允许多少个群进行专注聊天 + allow_remove_duplicates: bool = True # 是否开启心流去重(如果发现心流截断问题严重可尝试关闭) observation_context_size: int = 12 # 心流观察到的最长上下文大小,超过这个值的上下文会被压缩 @@ -235,6 +248,8 @@ class BotConfig: default_factory=lambda: ["表情包", "图片", "回复", "聊天记录"] ) # 添加新的配置项默认值 + long_message_auto_truncate: bool = True # HFC 模式过长消息自动截断,防止他人 prompt 恶意注入,减少token消耗,但可能损失图片/长文信息,按需选择状态(默认开启) + # mood mood_update_interval: float = 1.0 # 情绪更新间隔 单位秒 mood_decay_rate: float = 0.95 # 情绪衰减率 @@ -262,21 +277,60 @@ class BotConfig: remote_enable: bool = True # 是否启用远程控制 # experimental + enable_Legacy_HFC: bool = False # 是否启用旧 HFC 处理器 enable_friend_chat: bool = False # 是否启用好友聊天 # enable_think_flow: bool = False # 是否启用思考流程 + enable_friend_whitelist: bool = True # 是否启用好友白名单 talk_allowed_private = set() - enable_pfc_chatting: bool = False # 是否启用PFC聊天 - enable_pfc_reply_checker: bool = True # 是否开启PFC回复检查 + rename_person: bool = ( + True # 是否启用改名工具,可以让麦麦对唯一名进行更改,可能可以更拟人地称呼他人,但是也可能导致记忆混淆的问题 + ) + + # pfc + enable_pfc_chatting: bool = False # 是否启用PFC聊天,该功能仅作用于私聊,与回复模式独立 pfc_message_buffer_size: int = ( 2 # PFC 聊天消息缓冲数量,有利于使聊天节奏更加紧凑流畅,请根据实际 LLM 响应速度进行调整,默认2条 ) + pfc_recent_history_display_count: int = 18 # PFC 对话最大可见上下文 - # idle_chat + # pfc.checker + enable_pfc_reply_checker: bool = True # 是否启用 PFC 的回复检查器 + pfc_max_reply_attempts: int = 3 # 发言最多尝试次数 + pfc_max_chat_history_for_checker: int = 30 # checker聊天记录最大可见上文长度 + + # pfc.emotion + pfc_emotion_update_intensity: float = 0.6 # 情绪更新强度 + pfc_emotion_history_count: int = 5 # 情绪更新最大可见上下文长度 + + # pfc.relationship + pfc_relationship_incremental_interval: int = 10 # 关系值增值强度 + pfc_relationship_incremental_msg_count: int = 10 # 会话中,关系值判断最大可见上下文 + pfc_relationship_incremental_default_change: float = ( + 1.0 # 会话中,关系值默认更新值(当 llm 返回错误时默认采用该值) + ) + pfc_relationship_incremental_max_change: float = 5.0 # 会话中,关系值最大可变值 + pfc_relationship_final_msg_count: int = 30 # 会话结束时,关系值判断最大可见上下文 + pfc_relationship_final_default_change: float = 5.0 # 会话结束时,关系值默认更新值 + pfc_relationship_final_max_change: float = 50.0 # 会话结束时,关系值最大可变值 + + # pfc.fallback + pfc_historical_fallback_exclude_seconds: int = 45 # pfc 翻看聊天记录排除最近时长 + + # pfc.idle_chat enable_idle_chat: bool = False # 是否启用 pfc 主动发言 idle_check_interval: int = 10 # 检查间隔,10分钟检查一次 min_cooldown: int = 7200 # 最短冷却时间,2小时 (7200秒) max_cooldown: int = 18000 # 最长冷却时间,5小时 (18000秒) + # Group Nickname + enable_nickname_mapping: bool = False # 绰号映射功能总开关 + max_nicknames_in_prompt: int = 10 # Prompt 中最多注入的绰号数量 + nickname_probability_smoothing: int = 1 # 绰号加权随机选择的平滑因子 + nickname_queue_max_size: int = 100 # 绰号处理队列最大容量 + nickname_process_sleep_interval: float = 5 # 绰号处理进程休眠间隔(秒) + nickname_analysis_history_limit: int = 30 # 绰号处理可见最大上下文 + nickname_analysis_probability: float = 0.1 # 绰号随机概率命中,该值越大,绰号分析越频繁 + # 模型配置 llm_reasoning: dict[str, str] = field(default_factory=lambda: {}) # llm_reasoning_minor: dict[str, str] = field(default_factory=lambda: {}) @@ -292,6 +346,9 @@ class BotConfig: llm_heartflow: Dict[str, str] = field(default_factory=lambda: {}) llm_tool_use: Dict[str, str] = field(default_factory=lambda: {}) llm_plan: Dict[str, str] = field(default_factory=lambda: {}) + llm_nickname_mapping: Dict[str, str] = field(default_factory=lambda: {}) + llm_scheduler_all: Dict[str, str] = field(default_factory=lambda: {}) + llm_scheduler_doing: Dict[str, str] = field(default_factory=lambda: {}) api_urls: Dict[str, str] = field(default_factory=lambda: {}) @@ -363,8 +420,16 @@ class BotConfig: if config.INNER_VERSION in SpecifierSet(">=1.2.4"): config.personality_core = personality_config.get("personality_core", config.personality_core) config.personality_sides = personality_config.get("personality_sides", config.personality_sides) + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): + config.personality_detail_level = personality_config.get( + "personality_detail_level", config.personality_sides + ) if config.INNER_VERSION in SpecifierSet(">=1.7.0"): config.expression_style = personality_config.get("expression_style", config.expression_style) + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): + config.enable_expression_learner = personality_config.get( + "enable_expression_learner", config.enable_expression_learner + ) def identity(parent: dict): identity_config = parent["identity"] @@ -376,6 +441,24 @@ class BotConfig: config.gender = identity_config.get("gender", config.gender) config.appearance = identity_config.get("appearance", config.appearance) + def schedule(parent: dict): + schedule_config = parent["schedule"] + config.ENABLE_SCHEDULE_GEN = schedule_config.get("enable_schedule_gen", config.ENABLE_SCHEDULE_GEN) + config.PROMPT_SCHEDULE_GEN = schedule_config.get("prompt_schedule_gen", config.PROMPT_SCHEDULE_GEN) + config.SCHEDULE_DOING_UPDATE_INTERVAL = schedule_config.get( + "schedule_doing_update_interval", config.SCHEDULE_DOING_UPDATE_INTERVAL + ) + logger.info( + f"载入自定义日程prompt:{schedule_config.get('prompt_schedule_gen', config.PROMPT_SCHEDULE_GEN)}" + ) + if config.INNER_VERSION in SpecifierSet(">=1.0.2"): + config.SCHEDULE_TEMPERATURE = schedule_config.get("schedule_temperature", config.SCHEDULE_TEMPERATURE) + time_zone = schedule_config.get("time_zone", config.TIME_ZONE) + if tz.gettz(time_zone) is None: + logger.error(f"无效的时区: {time_zone},使用默认值: {config.TIME_ZONE}") + else: + config.TIME_ZONE = time_zone + def emoji(parent: dict): emoji_config = parent["emoji"] config.EMOJI_CHECK_INTERVAL = emoji_config.get("check_interval", config.EMOJI_CHECK_INTERVAL) @@ -389,6 +472,31 @@ class BotConfig: config.save_emoji = emoji_config.get("save_emoji", config.save_emoji) config.steal_emoji = emoji_config.get("steal_emoji", config.steal_emoji) + def group_nickname(parent: dict): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): + group_nickname_config = parent.get("group_nickname", {}) + config.enable_nickname_mapping = group_nickname_config.get( + "enable_nickname_mapping", config.enable_nickname_mapping + ) + config.max_nicknames_in_prompt = group_nickname_config.get( + "max_nicknames_in_prompt", config.max_nicknames_in_prompt + ) + config.nickname_probability_smoothing = group_nickname_config.get( + "nickname_probability_smoothing", config.nickname_probability_smoothing + ) + config.nickname_queue_max_size = group_nickname_config.get( + "nickname_queue_max_size", config.nickname_queue_max_size + ) + config.nickname_process_sleep_interval = group_nickname_config.get( + "nickname_process_sleep_interval", config.nickname_process_sleep_interval + ) + config.nickname_analysis_history_limit = group_nickname_config.get( + "nickname_analysis_history_limit", config.nickname_analysis_history_limit + ) + config.nickname_analysis_probability = group_nickname_config.get( + "nickname_analysis_probability", config.nickname_analysis_probability + ) + def bot(parent: dict): # 机器人基础配置 bot_config = parent["bot"] @@ -409,6 +517,10 @@ class BotConfig: config.ban_words = chat_config.get("ban_words", config.ban_words) for r in chat_config.get("ban_msgs_regex", config.ban_msgs_regex): config.ban_msgs_regex.add(re.compile(r)) + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): + config.allow_remove_duplicates = chat_config.get( + "allow_remove_duplicates", config.allow_remove_duplicates + ) def normal_chat(parent: dict): normal_chat_config = parent["normal_chat"] @@ -473,6 +585,9 @@ class BotConfig: "llm_heartflow", "llm_PFC_action_planner", "llm_PFC_chat", + "llm_nickname_mapping", + "llm_scheduler_all", + "llm_scheduler_doing", "llm_PFC_relationship_eval", ] @@ -572,6 +687,10 @@ class BotConfig: config.consolidate_memory_percentage = memory_config.get( "consolidate_memory_percentage", config.consolidate_memory_percentage ) + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): + config.long_message_auto_truncate = memory_config.get( + "long_message_auto_truncate", config.long_message_auto_truncate + ) def remote(parent: dict): remote_config = parent["remote"] @@ -637,24 +756,95 @@ class BotConfig: config.enable_friend_chat = experimental_config.get("enable_friend_chat", config.enable_friend_chat) # config.enable_think_flow = experimental_config.get("enable_think_flow", config.enable_think_flow) config.talk_allowed_private = set(str(user) for user in experimental_config.get("talk_allowed_private", [])) - if config.INNER_VERSION in SpecifierSet(">=1.1.0"): - config.enable_pfc_chatting = experimental_config.get("pfc_chatting", config.enable_pfc_chatting) if config.INNER_VERSION in SpecifierSet(">=1.7.1"): - config.enable_pfc_reply_checker = experimental_config.get( - "enable_pfc_reply_checker", config.enable_pfc_reply_checker + config.enable_friend_whitelist = experimental_config.get( + "enable_friend_whitelist", config.enable_friend_whitelist ) - logger.info(f"PFC Reply Checker 状态: {'启用' if config.enable_pfc_reply_checker else '关闭'}") - config.pfc_message_buffer_size = experimental_config.get( + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): + config.rename_person = experimental_config.get("rename_person", config.rename_person) + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): + config.enable_Legacy_HFC = experimental_config.get("enable_Legacy_HFC", config.enable_Legacy_HFC) + + def pfc(parent: dict): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): + pfc_config = parent.get("pfc", {}) + # 解析 [pfc] 下的直接字段 + config.enable_pfc_chatting = pfc_config.get("enable_pfc_chatting", config.enable_pfc_chatting) + config.pfc_message_buffer_size = pfc_config.get( "pfc_message_buffer_size", config.pfc_message_buffer_size ) + config.pfc_recent_history_display_count = pfc_config.get( + "pfc_recent_history_display_count", config.pfc_recent_history_display_count + ) - def idle_chat(parent: dict): - idle_chat_config = parent["idle_chat"] - if config.INNER_VERSION in SpecifierSet(">=1.7.1"): - config.enable_idle_chat = idle_chat_config.get("enable_idle_chat", config.enable_idle_chat) - config.idle_check_interval = idle_chat_config.get("idle_check_interval", config.idle_check_interval) - config.min_cooldown = idle_chat_config.get("min_cooldown", config.min_cooldown) - config.max_cooldown = idle_chat_config.get("max_cooldown", config.max_cooldown) + # 解析 [[pfc.checker]] 子表 + checker_list = pfc_config.get("checker", []) + if checker_list and isinstance(checker_list, list): + checker_config = checker_list[0] if checker_list else {} + config.enable_pfc_reply_checker = checker_config.get( + "enable_pfc_reply_checker", config.enable_pfc_reply_checker + ) + config.pfc_max_reply_attempts = checker_config.get( + "pfc_max_reply_attempts", config.pfc_max_reply_attempts + ) + config.pfc_max_chat_history_for_checker = checker_config.get( + "pfc_max_chat_history_for_checker", config.pfc_max_chat_history_for_checker + ) + + # 解析 [[pfc.emotion]] 子表 + emotion_list = pfc_config.get("emotion", []) + if emotion_list and isinstance(emotion_list, list): + emotion_config = emotion_list[0] if emotion_list else {} + config.pfc_emotion_update_intensity = emotion_config.get( + "pfc_emotion_update_intensity", config.pfc_emotion_update_intensity + ) + config.pfc_emotion_history_count = emotion_config.get( + "pfc_emotion_history_count", config.pfc_emotion_history_count + ) + + # 解析 [[pfc.relationship]] 子表 + relationship_list = pfc_config.get("relationship", []) + if relationship_list and isinstance(relationship_list, list): + relationship_config = relationship_list[0] if relationship_list else {} + config.pfc_relationship_incremental_interval = relationship_config.get( + "pfc_relationship_incremental_interval", config.pfc_relationship_incremental_interval + ) + config.pfc_relationship_incremental_msg_count = relationship_config.get( + "pfc_relationship_incremental_msg_count", config.pfc_relationship_incremental_msg_count + ) + config.pfc_relationship_incremental_default_change = relationship_config.get( + "pfc_relationship_incremental_default_change", + config.pfc_relationship_incremental_default_change, + ) + config.pfc_relationship_incremental_max_change = relationship_config.get( + "pfc_relationship_incremental_max_change", config.pfc_relationship_incremental_max_change + ) + config.pfc_relationship_final_msg_count = relationship_config.get( + "pfc_relationship_final_msg_count", config.pfc_relationship_final_msg_count + ) + config.pfc_relationship_final_default_change = relationship_config.get( + "pfc_relationship_final_default_change", config.pfc_relationship_final_default_change + ) + config.pfc_relationship_final_max_change = relationship_config.get( + "pfc_relationship_final_max_change", config.pfc_relationship_final_max_change + ) + + # 解析 [[pfc.fallback]] 子表 + fallback_list = pfc_config.get("fallback", []) + if fallback_list and isinstance(fallback_list, list): + fallback_config = fallback_list[0] if fallback_list else {} + config.pfc_historical_fallback_exclude_seconds = fallback_config.get( + "pfc_historical_fallback_exclude_seconds", config.pfc_historical_fallback_exclude_seconds + ) + + # 解析 [[pfc.idle_chat]] 子表 + idle_chat_list = pfc_config.get("idle_chat", []) + if idle_chat_list and isinstance(idle_chat_list, list): + idle_chat_config = idle_chat_list[0] if idle_chat_list else {} + config.enable_idle_chat = idle_chat_config.get("enable_idle_chat", config.enable_idle_chat) + config.idle_check_interval = idle_chat_config.get("idle_check_interval", config.idle_check_interval) + config.min_cooldown = idle_chat_config.get("min_cooldown", config.min_cooldown) + config.max_cooldown = idle_chat_config.get("max_cooldown", config.max_cooldown) # 版本表达式:>=1.0.0,<2.0.0 # 允许字段:func: method, support: str, notice: str, necessary: bool @@ -675,6 +865,7 @@ class BotConfig: "groups": {"func": groups, "support": ">=0.0.0"}, "personality": {"func": personality, "support": ">=0.0.0"}, "identity": {"func": identity, "support": ">=1.2.4"}, + "schedule": {"func": schedule, "support": ">=0.0.11", "necessary": False}, "emoji": {"func": emoji, "support": ">=0.0.0"}, "model": {"func": model, "support": ">=0.0.0"}, "memory": {"func": memory, "support": ">=0.0.0", "necessary": False}, @@ -687,7 +878,8 @@ class BotConfig: "chat": {"func": chat, "support": ">=1.6.0", "necessary": False}, "normal_chat": {"func": normal_chat, "support": ">=1.6.0", "necessary": False}, "focus_chat": {"func": focus_chat, "support": ">=1.6.0", "necessary": False}, - "idle_chat": {"func": idle_chat, "support": ">=1.7.1", "necessary": False}, + "group_nickname": {"func": group_nickname, "support": ">=1.6.1.1", "necessary": False}, + "pfc": {"func": pfc, "support": ">=1.6.2.4", "necessary": False}, } # 原地修改,将 字符串版本表达式 转换成 版本对象 diff --git a/src/experimental/Legacy_HFC/heartFC_Cycleinfo.py b/src/experimental/Legacy_HFC/heartFC_Cycleinfo.py new file mode 100644 index 00000000..96677384 --- /dev/null +++ b/src/experimental/Legacy_HFC/heartFC_Cycleinfo.py @@ -0,0 +1,74 @@ +import time +from typing import List, Optional, Dict, Any + + +class CycleInfo: + """循环信息记录类""" + + def __init__(self, cycle_id: int): + self.cycle_id = cycle_id + self.start_time = time.time() + self.end_time: Optional[float] = None + self.action_taken = False + self.action_type = "unknown" + self.reasoning = "" + self.timers: Dict[str, float] = {} + self.thinking_id = "" + self.replanned = False + + # 添加响应信息相关字段 + self.response_info: Dict[str, Any] = { + "response_text": [], # 回复的文本列表 + "emoji_info": "", # 表情信息 + "anchor_message_id": "", # 锚点消息ID + "reply_message_ids": [], # 回复消息ID列表 + "sub_mind_thinking": "", # 子思维思考内容 + } + + def to_dict(self) -> Dict[str, Any]: + """将循环信息转换为字典格式""" + return { + "cycle_id": self.cycle_id, + "start_time": self.start_time, + "end_time": self.end_time, + "action_taken": self.action_taken, + "action_type": self.action_type, + "reasoning": self.reasoning, + "timers": self.timers, + "thinking_id": self.thinking_id, + "response_info": self.response_info, + } + + def complete_cycle(self): + """完成循环,记录结束时间""" + self.end_time = time.time() + + def set_action_info(self, action_type: str, reasoning: str, action_taken: bool): + """设置动作信息""" + self.action_type = action_type + self.reasoning = reasoning + self.action_taken = action_taken + + def set_thinking_id(self, thinking_id: str): + """设置思考消息ID""" + self.thinking_id = thinking_id + + def set_response_info( + self, + response_text: Optional[List[str]] = None, + emoji_info: Optional[str] = None, + anchor_message_id: Optional[str] = None, + reply_message_ids: Optional[List[str]] = None, + sub_mind_thinking: Optional[str] = None, + ): + """设置响应信息""" + if response_text is not None: + self.response_info["response_text"] = response_text + if emoji_info is not None: + self.response_info["emoji_info"] = emoji_info + if anchor_message_id is not None: + self.response_info["anchor_message_id"] = anchor_message_id + if reply_message_ids is not None: + self.response_info["reply_message_ids"] = reply_message_ids + if sub_mind_thinking is not None: + self.response_info["sub_mind_thinking"] = sub_mind_thinking diff --git a/src/experimental/Legacy_HFC/heartFC_chat.py b/src/experimental/Legacy_HFC/heartFC_chat.py new file mode 100644 index 00000000..8d608c1e --- /dev/null +++ b/src/experimental/Legacy_HFC/heartFC_chat.py @@ -0,0 +1,1645 @@ +import asyncio +import contextlib +import json # <--- 确保导入 json +import random # <--- 添加导入 +import time +import re +import traceback +from asyncio import Queue +from collections import deque +from typing import List, Optional, Dict, Any, Deque, Callable, Coroutine + +from rich.traceback import install + +from src.common.logger_manager import get_logger +from src.config.config import global_config +from .heart_flow.observation import Observation +from .heart_flow.sub_mind import SubMind +from .heart_flow.utils_chat import get_chat_type_and_target_info +from src.manager.mood_manager import mood_manager +from src.chat.message_receive.chat_stream import ChatStream, chat_manager +from src.chat.message_receive.message import ( + MessageRecv, + BaseMessageInfo, + MessageThinking, + MessageSending, + Seg, + UserInfo, +) +from src.chat.utils.utils import process_llm_response +from src.chat.utils.utils_image import image_path_to_base64 +from src.chat.emoji_system.emoji_manager import emoji_manager +from .heartFC_Cycleinfo import CycleInfo +from .heartflow_prompt_builder import global_prompt_manager, prompt_builder +from src.chat.models.utils_model import LLMRequest +from src.chat.utils.info_catcher import info_catcher_manager +from src.chat.utils.chat_message_builder import num_new_messages_since, get_raw_msg_before_timestamp_with_chat +from src.chat.utils.timer_calculator import Timer # <--- Import Timer +from .heartFC_sender import HeartFCSender +from src.plugins.group_nickname.nickname_manager import nickname_manager + +install(extra_lines=3) + + +WAITING_TIME_THRESHOLD = 300 # 等待新消息时间阈值,单位秒 + +EMOJI_SEND_PRO = 0.3 # 设置一个概率,比如 30% 才真的发 + +CONSECUTIVE_NO_REPLY_THRESHOLD = 3 # 连续不回复的阈值 + + +logger = get_logger("hfc") # Logger Name Changed + + +# 默认动作定义 +DEFAULT_ACTIONS = {"no_reply": "不回复", "text_reply": "文本回复, 可选附带表情", "emoji_reply": "仅表情回复"} + + +class ActionManager: + """动作管理器:控制每次决策可以使用的动作""" + + def __init__(self): + # 初始化为默认动作集 + self._available_actions: Dict[str, str] = DEFAULT_ACTIONS.copy() + self._original_actions_backup: Optional[Dict[str, str]] = None # 用于临时移除时的备份 + + def get_available_actions(self) -> Dict[str, str]: + """获取当前可用的动作集""" + return self._available_actions.copy() # 返回副本以防外部修改 + + def add_action(self, action_name: str, description: str) -> bool: + """ + 添加新的动作 + + 参数: + action_name: 动作名称 + description: 动作描述 + + 返回: + bool: 是否添加成功 + """ + if action_name in self._available_actions: + return False + self._available_actions[action_name] = description + return True + + def remove_action(self, action_name: str) -> bool: + """ + 移除指定动作 + + 参数: + action_name: 动作名称 + + 返回: + bool: 是否移除成功 + """ + if action_name not in self._available_actions: + return False + del self._available_actions[action_name] + return True + + def temporarily_remove_actions(self, actions_to_remove: List[str]): + """ + 临时移除指定的动作,备份原始动作集。 + 如果已经有备份,则不重复备份。 + """ + if self._original_actions_backup is None: + self._original_actions_backup = self._available_actions.copy() + + actions_actually_removed = [] + for action_name in actions_to_remove: + if action_name in self._available_actions: + del self._available_actions[action_name] + actions_actually_removed.append(action_name) + # logger.debug(f"临时移除了动作: {actions_actually_removed}") # 可选日志 + + def restore_actions(self): + """ + 恢复之前备份的原始动作集。 + """ + if self._original_actions_backup is not None: + self._available_actions = self._original_actions_backup.copy() + self._original_actions_backup = None + # logger.debug("恢复了原始动作集") # 可选日志 + + def clear_actions(self): + """清空所有动作""" + self._available_actions.clear() + + def reset_to_default(self): + """重置为默认动作集""" + self._available_actions = DEFAULT_ACTIONS.copy() + + +# 在文件开头添加自定义异常类 +class HeartFCError(Exception): + """麦麦聊天系统基础异常类""" + + pass + + +class PlannerError(HeartFCError): + """规划器异常""" + + pass + + +class ReplierError(HeartFCError): + """回复器异常""" + + pass + + +class SenderError(HeartFCError): + """发送器异常""" + + pass + + +async def _handle_cycle_delay(action_taken_this_cycle: bool, cycle_start_time: float, log_prefix: str): + """处理循环延迟""" + cycle_duration = time.monotonic() - cycle_start_time + + try: + sleep_duration = 0.0 + if not action_taken_this_cycle and cycle_duration < 1: + sleep_duration = 1 - cycle_duration + elif cycle_duration < 0.2: + sleep_duration = 0.2 + + if sleep_duration > 0: + await asyncio.sleep(sleep_duration) + + except asyncio.CancelledError: + logger.info(f"{log_prefix} Sleep interrupted, loop likely cancelling.") + raise + + +class HeartFChatting: + """ + 管理一个连续的Plan-Replier-Sender循环 + 用于在特定聊天流中生成回复。 + 其生命周期现在由其关联的 SubHeartflow 的 FOCUSED 状态控制。 + """ + + def __init__( + self, + chat_id: str, + sub_mind: SubMind, + observations: list[Observation], + on_consecutive_no_reply_callback: Callable[[], Coroutine[None, None, None]], + ): + """ + HeartFChatting 初始化函数 + + 参数: + chat_id: 聊天流唯一标识符(如stream_id) + sub_mind: 关联的子思维 + observations: 关联的观察列表 + on_consecutive_no_reply_callback: 连续不回复达到阈值时调用的异步回调函数 + """ + # 基础属性 + self.stream_id: str = chat_id # 聊天流ID + self.chat_stream: Optional[ChatStream] = None # 关联的聊天流 + self.sub_mind: SubMind = sub_mind # 关联的子思维 + self.observations: List[Observation] = observations # 关联的观察列表,用于监控聊天流状态 + self.on_consecutive_no_reply_callback = on_consecutive_no_reply_callback + + # 日志前缀 + self.log_prefix: str = str(chat_id) # Initial default, will be updated + + # --- Initialize attributes (defaults) --- + self.is_group_chat: bool = False + self.chat_target_info: Optional[dict] = None + # --- End Initialization --- + + # 动作管理器 + self.action_manager = ActionManager() + + # 初始化状态控制 + self._initialized = False + self._processing_lock = asyncio.Lock() + + # --- 移除 gpt_instance, 直接初始化 LLM 模型 --- + # self.gpt_instance = HeartFCGenerator() # <-- 移除 + self.model_normal = LLMRequest( # <-- 新增 LLM 初始化 + model=global_config.llm_normal, + temperature=global_config.llm_normal["temp"], + max_tokens=256, + request_type="response_heartflow", + ) + self.heart_fc_sender = HeartFCSender() + + # LLM规划器配置 + self.planner_llm = LLMRequest( + model=global_config.llm_plan, + max_tokens=1000, + request_type="action_planning", # 用于动作规划 + ) + + # 循环控制内部状态 + self._loop_active: bool = False # 循环是否正在运行 + self._loop_task: Optional[asyncio.Task] = None # 主循环任务 + + # 添加循环信息管理相关的属性 + self._cycle_counter = 0 + self._cycle_history: Deque[CycleInfo] = deque(maxlen=10) # 保留最近10个循环的信息 + self._current_cycle: Optional[CycleInfo] = None + self._lian_xu_bu_hui_fu_ci_shu: int = 0 # <--- 新增:连续不回复计数器 + self._shutting_down: bool = False # <--- 新增:关闭标志位 + self._lian_xu_deng_dai_shi_jian: float = 0.0 # <--- 新增:累计等待时间 + self._planner_task: Optional[asyncio.Task] = None # 用于跟踪异步planner任务 + self._planner_input_queue: Queue = Queue(maxsize=1) # 主循环 -> Planner 的信号队列 + self._planner_output_queue: Queue = Queue(maxsize=1) # Planner -> 主循环 的结果队列 + self._planner_active: bool = False # 标记Planner是否应该运行 + self._last_planner_signal_time: float = 0.0 # 记录上次发送规划信号的时间 + self._planner_request_id_counter: int = 0 + + async def _initialize(self) -> bool: + """ + 懒初始化,解析chat_stream, 获取聊天类型和目标信息。 + """ + if self._initialized: + return True + + # --- Use utility function to determine chat type and fetch info --- + # Note: get_chat_type_and_target_info handles getting the chat_stream internally + self.is_group_chat, self.chat_target_info = await get_chat_type_and_target_info(self.stream_id) + + # Update log prefix based on potential stream name (if needed, or get it from chat_stream if util doesn't return it) + # Assuming get_chat_type_and_target_info focuses only on type/target + # We still need the chat_stream object itself for other operations + try: + self.chat_stream = await asyncio.to_thread(chat_manager.get_stream, self.stream_id) + if not self.chat_stream: + logger.error( + f"[HFC:{self.stream_id}] 获取ChatStream失败 during _initialize, though util func might have succeeded earlier." + ) + return False # Cannot proceed without chat_stream object + # Update log prefix using the fetched stream object + self.log_prefix = f"[{chat_manager.get_stream_name(self.stream_id) or self.stream_id}]" + except Exception as e: + logger.error(f"[HFC:{self.stream_id}] 获取ChatStream时出错 in _initialize: {e}") + return False + + # --- End using utility function --- + + self._initialized = True + logger.debug(f"{self.log_prefix} 麦麦感觉到了,可以开始认真水群 ") + return True + + async def start(self): + """ + 启动 HeartFChatting 的主循环和异步Planner循环。 + 注意:调用此方法前必须确保已经成功初始化。 + """ + logger.info(f"{self.log_prefix} 开始认真水群(HFC)...") + if not self._initialized: # 确保已初始化 + if not await self._initialize(): + logger.error(f"{self.log_prefix} HFC 初始化失败,无法启动。") + return + + await self._start_loop_if_needed() # 启动主循环 + + # --- 启动异步Planner循环 --- + if self._planner_task is None or self._planner_task.done(): + self._planner_active = True # 先设置active标志 + self._planner_task = asyncio.create_task(self._async_planner_loop()) + self._planner_task.add_done_callback(self._handle_planner_loop_completion) + # logger.info(f"{self.log_prefix} 异步Planner任务已启动。") # 这行日志已在 _async_planner_loop 中存在 + + async def _start_loop_if_needed(self): + """检查是否需要启动主循环,如果未激活则启动。""" + # 如果循环已经激活,直接返回 + if self._loop_active: + return + + # 标记为活动状态,防止重复启动 + self._loop_active = True + + # 检查是否已有任务在运行(理论上不应该,因为 _loop_active=False) + if self._loop_task and not self._loop_task.done(): + logger.warning(f"{self.log_prefix} 发现之前的循环任务仍在运行(不符合预期)。取消旧任务。") + self._loop_task.cancel() + try: + # 等待旧任务确实被取消 + await asyncio.wait_for(self._loop_task, timeout=0.5) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass # 忽略取消或超时错误 + self._loop_task = None # 清理旧任务引用 + + logger.debug(f"{self.log_prefix} 启动认真水群(HFC)主循环...") + # 创建新的循环任务 + self._loop_task = asyncio.create_task(self._hfc_loop()) + # 添加完成回调 + self._loop_task.add_done_callback(self._handle_loop_completion) + + def _handle_loop_completion(self, task: asyncio.Task): + """当 _hfc_loop 任务完成时执行的回调。""" + try: + exception = task.exception() + if exception: + logger.error(f"{self.log_prefix} HeartFChatting: 麦麦脱离了聊天(异常): {exception}") + logger.error(traceback.format_exc()) # Log full traceback for exceptions + else: + # Loop completing normally now means it was cancelled/shutdown externally + logger.info(f"{self.log_prefix} HeartFChatting: 麦麦脱离了聊天 (外部停止)") + except asyncio.CancelledError: + logger.info(f"{self.log_prefix} HeartFChatting: 麦麦脱离了聊天(任务取消)") + finally: + self._loop_active = False + self._loop_task = None + if self._processing_lock.locked(): + logger.warning(f"{self.log_prefix} HeartFChatting: 处理锁在循环结束时仍被锁定,强制释放。") + self._processing_lock.release() + + # --- 新增 _handle_planner_loop_completion 方法 --- + def _handle_planner_loop_completion(self, task: asyncio.Task): + """当 _async_planner_loop 任务完成时执行的回调。""" + try: + exception = task.exception() + if exception: + logger.error(f"{self.log_prefix} 异步Planner任务异常结束: {exception}") + logger.error(traceback.format_exc()) + # else: # 任务正常结束的日志已在 _async_planner_loop 中处理 + # logger.info(f"{self.log_prefix} 异步Planner任务正常结束。") + except asyncio.CancelledError: + logger.info(f"{self.log_prefix} 异步Planner任务被取消。") + finally: + self._planner_active = False + if self._planner_task is task: + self._planner_task = None + # --- 结束新增 _handle_planner_loop_completion 方法 --- + + async def _hfc_loop(self): + """主循环,持续进行计划并可能回复消息,直到被外部取消。""" + try: + while True: # 主循环 + logger.debug(f"{self.log_prefix} 开始第{self._cycle_counter}次循环") + # --- 在循环开始处检查关闭标志 --- + if self._shutting_down: + logger.info(f"{self.log_prefix} 检测到关闭标志,退出 HFC 循环。") + break + # -------------------------------- + + # 创建新的循环信息 + self._cycle_counter += 1 + self._current_cycle = CycleInfo(self._cycle_counter) + + # 初始化周期状态 + cycle_timers = {} + loop_cycle_start_time = time.monotonic() + + # 执行规划和处理阶段 + async with self._get_cycle_context() as acquired_lock: + if not acquired_lock: + # 如果未能获取锁(理论上不太可能,除非 shutdown 过程中释放了但又被抢了?) + # 或者也可以在这里再次检查 self._shutting_down + if self._shutting_down: + break # 再次检查,确保退出 + logger.warning(f"{self.log_prefix} 未能获取循环处理锁,跳过本次循环。") + await asyncio.sleep(0.1) # 短暂等待避免空转 + continue + + # 记录规划开始时间点 + planner_start_db_time = time.time() + + # 主循环:思考->决策->执行 + action_taken, thinking_id = await self._think_plan_execute_loop(cycle_timers, planner_start_db_time) + + # 更新循环信息 + self._current_cycle.set_thinking_id(thinking_id) + self._current_cycle.timers = cycle_timers + + # 防止循环过快消耗资源 + await _handle_cycle_delay(action_taken, loop_cycle_start_time, self.log_prefix) + + # 完成当前循环并保存历史 + self._current_cycle.complete_cycle() + self._cycle_history.append(self._current_cycle) + + # 记录循环信息和计时器结果 + timer_strings = [] + for name, elapsed in cycle_timers.items(): + formatted_time = f"{elapsed * 1000:.2f}毫秒" if elapsed < 1 else f"{elapsed:.2f}秒" + timer_strings.append(f"{name}: {formatted_time}") + + logger.debug( + f"{self.log_prefix} 第 #{self._current_cycle.cycle_id}次思考完成," + f"耗时: {self._current_cycle.end_time - self._current_cycle.start_time:.2f}秒, " + f"动作: {self._current_cycle.action_type}" + + (f"\n计时器详情: {'; '.join(timer_strings)}" if timer_strings else "") + ) + + except asyncio.CancelledError: + # 设置了关闭标志位后被取消是正常流程 + if not self._shutting_down: + logger.warning(f"{self.log_prefix} HeartFChatting: 麦麦的认真水群(HFC)循环意外被取消") + else: + logger.info(f"{self.log_prefix} HeartFChatting: 麦麦的认真水群(HFC)循环已取消 (正常关闭)") + except Exception as e: + logger.error(f"{self.log_prefix} HeartFChatting: 意外错误: {e}") + logger.error(traceback.format_exc()) + + @contextlib.asynccontextmanager + async def _get_cycle_context(self): + """ + 循环周期的上下文管理器 + + 用于确保资源的正确获取和释放: + 1. 获取处理锁 + 2. 执行操作 + 3. 释放锁 + """ + acquired = False + try: + await self._processing_lock.acquire() + acquired = True + yield acquired + finally: + if acquired and self._processing_lock.locked(): + self._processing_lock.release() + + async def _check_new_messages(self, start_time: float) -> bool: + """ + 检查从指定时间点后是否有新消息 + + 参数: + start_time: 开始检查的时间点 + + 返回: + bool: 是否有新消息 + """ + try: + new_msg_count = num_new_messages_since(self.stream_id, start_time) + if new_msg_count > 0: + logger.info(f"{self.log_prefix} 检测到{new_msg_count}条新消息") + return True + return False + except Exception as e: + logger.error(f"{self.log_prefix} 检查新消息时出错: {e}") + return False + + async def _think_plan_execute_loop(self, cycle_timers: dict, planner_start_db_time: float) -> tuple[bool, str]: + """ + 执行思考,异步触发Planner,尝试短时等待Planner结果, + 若无则执行默认动作,同时允许Planner后台完成。 + """ + _thinking_id_for_cycle = "" + try: + current_mind = await self._get_submind_thinking(cycle_timers) + if self._current_cycle: + self._current_cycle.set_response_info(sub_mind_thinking=current_mind) + + planner_result = None + signaled_planner_this_cycle = False # 标记本周期是否已成功触发planner + + self._planner_request_id_counter += 1 + current_planner_request_id = f"planner_req_{self.stream_id}_{self._cycle_counter}_{self._planner_request_id_counter}" + + min_planner_interval = 1.0 + can_signal_planner = (time.time() - self._last_planner_signal_time) >= min_planner_interval + + if self._planner_active and self._planner_input_queue.empty() and can_signal_planner: + logger.debug(f"{self.log_prefix} 向异步Planner发送规划请求 (ID: {current_planner_request_id})...") + # --- 修改:在上下文中加入请求ID --- + planner_input_context = { + "current_mind": current_mind, + "request_id": current_planner_request_id # <<< 新增 + } + try: + self._planner_input_queue.put_nowait(planner_input_context) + self._last_planner_signal_time = time.time() + signaled_planner_this_cycle = True + except asyncio.QueueFull: + logger.warning(f"{self.log_prefix} Planner输入队列已满,本次规划跳过。") + # 不再直接设定 planner_result,让后续逻辑决定默认行为 + # 其他条件导致未发送信号的日志 (保持或根据需要调整) + elif not self._planner_active: + logger.warning(f"{self.log_prefix} Planner未激活,无法发送规划请求。") + elif not can_signal_planner: + logger.debug(f"{self.log_prefix} Planner请求过于频繁,本次跳过。") + else: # Planner激活但输入队列非空 + logger.warning(f"{self.log_prefix} Planner输入队列仍有任务,本次跳过。") + + + # --- 尝试在短时间内获取Planner的决策结果 --- + quick_wait_timeout = 3.0 + + if signaled_planner_this_cycle: + logger.debug(f"{self.log_prefix} 短时等待 ({quick_wait_timeout}s) Planner结果 (ID: {current_planner_request_id})...") + try: + # --- 从队列获取的结果现在应该也包含 request_id --- + raw_planner_output = await asyncio.wait_for(self._planner_output_queue.get(), timeout=quick_wait_timeout) + self._planner_output_queue.task_done() # 提到前面,获取后就标记完成 + + # --- 校验 request_id --- + if isinstance(raw_planner_output, dict) and raw_planner_output.get("request_id") == current_planner_request_id: + planner_result = raw_planner_output + logger.info(f"{self.log_prefix} 短时等待内收到匹配的Planner结果 (ID: {current_planner_request_id}): {planner_result.get('action')}") + elif isinstance(raw_planner_output, dict): + logger.warning(f"{self.log_prefix} 短时等待内收到不匹配的Planner结果。预期ID: {current_planner_request_id}, 收到ID: {raw_planner_output.get('request_id')}。丢弃此结果。") + # planner_result 保持为 None,将执行默认动作 + else: + logger.error(f"{self.log_prefix} Planner输出格式不正确(非字典或无request_id),丢弃。内容: {raw_planner_output}") + # planner_result 保持为 None + + except asyncio.TimeoutError: + logger.info(f"{self.log_prefix} 短时等待Planner决策结果超时 (ID: {current_planner_request_id})。主循环将先执行默认动作。") + except asyncio.CancelledError: + logger.info(f"{self.log_prefix} 主循环在短时等待Planner结果时被取消 (ID: {current_planner_request_id})。") + raise + except Exception as e: + logger.error(f"{self.log_prefix} 从Planner输出队列短时获取结果时出错 (ID: {current_planner_request_id}): {e}") + + + # 如果在短时等待后 planner_result 仍然是 None (无论是未发信号、超时还是获取出错) + # 则执行一个预定义的快速/默认动作 + if planner_result is None: + if signaled_planner_this_cycle: + logger.info(f"{self.log_prefix} Planner未在 {quick_wait_timeout}s 内响应,执行预定义快速动作 (no_reply)。") + else: + logger.info(f"{self.log_prefix} 未触发Planner或Planner繁忙/未激活,执行预定义快速动作 (no_reply)。") + + # 预定义快速/默认动作 + planner_result = {"action": "no_reply", + "reasoning": f"Planner未在{quick_wait_timeout}s内快速响应或未触发,默认不回复", + "emoji_query": "", + "llm_error": False # 这不是一个LLM错误,是流程控制 + } + # 注意:此时异步的Planner可能仍在后台运行。如果它稍后产生了结果, + # 在这个简化版本中,那个结果会被放入输出队列,并可能在下一个主循环周期 + # (如果那个周期也短时超时或未触发新规划)被错误地当作新结果取出。 + # 更完善的系统需要关联请求和响应,例如使用唯一的ID。 + + # --- 后续处理 planner_result --- + action = planner_result.get("action", "error") + reasoning = planner_result.get("reasoning", "未提供理由") + emoji_query = planner_result.get("emoji_query", "") + llm_error_from_planner = planner_result.get("llm_error", False) + current_action_thinking_id = "" + + if self._current_cycle: + action_is_reply = action in ["text_reply", "emoji_reply"] + self._current_cycle.set_action_info(action, reasoning, action_is_reply and not llm_error_from_planner) + + if llm_error_from_planner and action != "error": + logger.warning(f"{self.log_prefix} Planner (ID: {planner_result.get('request_id', 'N/A')}) 返回LLM或解析错误: {reasoning}。强制执行 'no_reply'。") + action = "no_reply" + if self._current_cycle: + self._current_cycle.action_type = "no_reply" + elif action == "error": + logger.error(f"{self.log_prefix} Planner (ID: {planner_result.get('request_id', 'N/A')}) 决策返回错误状态: {reasoning}") + if self._current_cycle: + self._current_cycle.action_taken = False + return False, "" + + action_str_log = {"text_reply": "回复", "emoji_reply": "回复表情", "no_reply": "不回复"}.get(action, "未知或错误动作") + logger.info(f"{self.log_prefix} (Planner ID: {planner_result.get('request_id', 'N/A')}) 麦麦最终决定'{action_str_log}', 原因'{reasoning}'") + + action_executed, current_action_thinking_id = await self._handle_action( + action, reasoning, emoji_query, cycle_timers, planner_start_db_time + ) + + if self._current_cycle and current_action_thinking_id: + self._current_cycle.set_thinking_id(current_action_thinking_id) + + return action_executed, current_action_thinking_id + + except asyncio.CancelledError: + logger.info(f"{self.log_prefix} _think_plan_execute_loop 被取消。") + raise + except Exception as e: + logger.error(f"{self.log_prefix} _think_plan_execute_loop 发生未知严重错误: {e}") + logger.error(traceback.format_exc()) + if self._current_cycle: + self._current_cycle.set_action_info("error", f"主循环严重错误: {e}", False) + return False, "" + + + async def _handle_action( + self, action: str, reasoning: str, emoji_query: str, cycle_timers: dict, planner_start_db_time: float + ) -> tuple[bool, str]: + """ + 处理规划动作 + + 参数: + action: 动作类型 + reasoning: 决策理由 + emoji_query: 表情查询 + cycle_timers: 计时器字典 + planner_start_db_time: 规划开始时间 + + 返回: + tuple[bool, str]: (是否执行了动作, 思考消息ID) + """ + action_handlers = { + "text_reply": self._handle_text_reply, + "emoji_reply": self._handle_emoji_reply, + "no_reply": self._handle_no_reply, + } + + handler = action_handlers.get(action) + if not handler: + logger.warning(f"{self.log_prefix} 未知动作: {action}, 原因: {reasoning}") + return False, "" + + try: + if action == "text_reply": + # 调用文本回复处理,它会返回 (bool, thinking_id) + success, thinking_id = await handler(reasoning, emoji_query, cycle_timers) + return success, thinking_id # 直接返回结果 + elif action == "emoji_reply": + # 调用表情回复处理,它只返回 bool + success = await handler(reasoning, emoji_query) + return success, "" # thinking_id 为空字符串 + else: # no_reply + # 调用不回复处理,它只返回 bool + success = await handler(reasoning, planner_start_db_time, cycle_timers) + return success, "" # thinking_id 为空字符串 + except HeartFCError as e: + logger.error(f"{self.log_prefix} 处理{action}时出错: {e}") + # 出错时也重置计数器 + self._lian_xu_bu_hui_fu_ci_shu = 0 + self._lian_xu_deng_dai_shi_jian = 0.0 # 重置累计等待时间 + return False, "" + + async def _handle_text_reply(self, reasoning: str, emoji_query: str, cycle_timers: dict) -> tuple[bool, str]: + """ + 处理文本回复 + + 工作流程: + 1. 获取锚点消息 + 2. 创建思考消息 + 3. 生成回复 + 4. 发送消息 + 5. [新增] 触发绰号分析 + + 参数: + reasoning: 回复原因 + emoji_query: 表情查询 + cycle_timers: 计时器字典 + + 返回: + tuple[bool, str]: (是否回复成功, 思考消息ID) + """ + # 重置连续不回复计数器 + self._lian_xu_bu_hui_fu_ci_shu = 0 + self._lian_xu_deng_dai_shi_jian = 0.0 # 重置累计等待时间 + + # 获取锚点消息 + anchor_message = await self._get_anchor_message() + if not anchor_message: + raise PlannerError("无法获取锚点消息") + + # 创建思考消息 + thinking_id = await self._create_thinking_message(anchor_message) + if not thinking_id: + raise PlannerError("无法创建思考消息") + + reply = None # 初始化 reply + try: + # 生成回复 + with Timer("生成回复", cycle_timers): + reply = await self._replier_work( + anchor_message=anchor_message, + thinking_id=thinking_id, + reason=reasoning, + ) + + if not reply: + raise ReplierError("回复生成失败") + + # 发送消息 + with Timer("发送消息", cycle_timers): + await self._sender( + thinking_id=thinking_id, + anchor_message=anchor_message, + response_set=reply, + send_emoji=emoji_query, + ) + + # 调用工具函数触发绰号分析 + await nickname_manager.trigger_nickname_analysis(anchor_message, reply, self.chat_stream) + + return True, thinking_id + + except (ReplierError, SenderError) as e: + logger.error(f"{self.log_prefix} 回复失败: {e}") + return True, thinking_id # 仍然返回thinking_id以便跟踪 + + async def _handle_emoji_reply(self, reasoning: str, emoji_query: str) -> bool: + """ + 处理表情回复 + + 工作流程: + 1. 获取锚点消息 + 2. 发送表情 + + 参数: + reasoning: 回复原因 + emoji_query: 表情查询 + + 返回: + bool: 是否发送成功 + """ + logger.info(f"{self.log_prefix} 决定回复表情({emoji_query}): {reasoning}") + self._lian_xu_deng_dai_shi_jian = 0.0 # 重置累计等待时间(即使不计数也保持一致性) + + try: + anchor = await self._get_anchor_message() + if not anchor: + raise PlannerError("无法获取锚点消息") + + await self._handle_emoji(anchor, [], emoji_query) + return True + + except Exception as e: + logger.error(f"{self.log_prefix} 表情发送失败: {e}") + return False + + async def _handle_no_reply(self, reasoning: str, planner_start_db_time: float, cycle_timers: dict) -> bool: + """ + 处理不回复的情况 + + 工作流程: + 1. 等待新消息、超时或关闭信号 + 2. 根据等待结果更新连续不回复计数 + 3. 如果达到阈值,触发回调 + + 参数: + reasoning: 不回复的原因 + planner_start_db_time: 规划开始时间 + cycle_timers: 计时器字典 + + 返回: + bool: 是否成功处理 + """ + logger.info(f"{self.log_prefix} 决定不回复: {reasoning}") + + observation = self.observations[0] if self.observations else None + + try: + with Timer("等待新消息", cycle_timers): + # 等待新消息、超时或关闭信号,并获取结果 + await self._wait_for_new_message(observation, planner_start_db_time, self.log_prefix) + # 从计时器获取实际等待时间 + current_waiting = cycle_timers.get("等待新消息", 0.0) + + if not self._shutting_down: + self._lian_xu_bu_hui_fu_ci_shu += 1 + self._lian_xu_deng_dai_shi_jian += current_waiting # 累加等待时间 + logger.debug( + f"{self.log_prefix} 连续不回复计数增加: {self._lian_xu_bu_hui_fu_ci_shu}/{CONSECUTIVE_NO_REPLY_THRESHOLD}, " + f"本次等待: {current_waiting:.2f}秒, 累计等待: {self._lian_xu_deng_dai_shi_jian:.2f}秒" + ) + + # 检查是否同时达到次数和时间阈值 + time_threshold = 0.66 * WAITING_TIME_THRESHOLD * CONSECUTIVE_NO_REPLY_THRESHOLD + if ( + self._lian_xu_bu_hui_fu_ci_shu >= CONSECUTIVE_NO_REPLY_THRESHOLD + and self._lian_xu_deng_dai_shi_jian >= time_threshold + ): + logger.info( + f"{self.log_prefix} 连续不回复达到阈值 ({self._lian_xu_bu_hui_fu_ci_shu}次) " + f"且累计等待时间达到 {self._lian_xu_deng_dai_shi_jian:.2f}秒 (阈值 {time_threshold}秒)," + f"调用回调请求状态转换" + ) + # 调用回调。注意:这里不重置计数器和时间,依赖回调函数成功改变状态来隐式重置上下文。 + await self.on_consecutive_no_reply_callback() + elif self._lian_xu_bu_hui_fu_ci_shu >= CONSECUTIVE_NO_REPLY_THRESHOLD: + # 仅次数达到阈值,但时间未达到 + logger.debug( + f"{self.log_prefix} 连续不回复次数达到阈值 ({self._lian_xu_bu_hui_fu_ci_shu}次) " + f"但累计等待时间 {self._lian_xu_deng_dai_shi_jian:.2f}秒 未达到时间阈值 ({time_threshold}秒),暂不调用回调" + ) + # else: 次数和时间都未达到阈值,不做处理 + + return True + + except asyncio.CancelledError: + # 如果在等待过程中任务被取消(可能是因为 shutdown) + logger.info(f"{self.log_prefix} 处理 'no_reply' 时等待被中断 (CancelledError)") + # 让异常向上传播,由 _hfc_loop 的异常处理逻辑接管 + raise + except Exception as e: # 捕获调用管理器或其他地方可能发生的错误 + logger.error(f"{self.log_prefix} 处理 'no_reply' 时发生错误: {e}") + logger.error(traceback.format_exc()) + # 发生意外错误时,可以选择是否重置计数器,这里选择不重置 + return False # 表示动作未成功 + + async def _wait_for_new_message(self, observation, planner_start_db_time: float, log_prefix: str) -> bool: + """ + 等待新消息 或 检测到关闭信号 + + 参数: + observation: 观察实例 + planner_start_db_time: 开始等待的时间 + log_prefix: 日志前缀 + + 返回: + bool: 是否检测到新消息 (如果因关闭信号退出则返回 False) + """ + wait_start_time = time.monotonic() + while True: + # --- 在每次循环开始时检查关闭标志 --- + if self._shutting_down: + logger.info(f"{log_prefix} 等待新消息时检测到关闭信号,中断等待。") + return False # 表示因为关闭而退出 + # ----------------------------------- + + # 检查新消息 + if await observation.has_new_messages_since(planner_start_db_time): + logger.info(f"{log_prefix} 检测到新消息") + return True + + # 检查超时 (放在检查新消息和关闭之后) + if time.monotonic() - wait_start_time > WAITING_TIME_THRESHOLD: + logger.warning(f"{log_prefix} 等待新消息超时({WAITING_TIME_THRESHOLD}秒)") + return False + + try: + # 短暂休眠,让其他任务有机会运行,并能更快响应取消或关闭 + await asyncio.sleep(0.5) # 缩短休眠时间 + except asyncio.CancelledError: + # 如果在休眠时被取消,再次检查关闭标志 + # 如果是正常关闭,则不需要警告 + if not self._shutting_down: + logger.warning(f"{log_prefix} _wait_for_new_message 的休眠被意外取消") + # 无论如何,重新抛出异常,让上层处理 + raise + + async def _log_cycle_timers(self, cycle_timers: dict, log_prefix: str): + """记录循环周期的计时器结果""" + if cycle_timers: + timer_strings = [] + for name, elapsed in cycle_timers.items(): + formatted_time = f"{elapsed * 1000:.2f}毫秒" if elapsed < 1 else f"{elapsed:.2f}秒" + timer_strings.append(f"{name}: {formatted_time}") + + if timer_strings: + # 在记录前检查关闭标志 + if not self._shutting_down: + logger.debug(f"{log_prefix} 该次决策耗时: {'; '.join(timer_strings)}") + + async def _get_submind_thinking(self, cycle_timers: dict) -> str: + """ + 获取子思维的思考结果 + + 返回: + str: 思考结果,如果思考失败则返回错误信息 + """ + try: + with Timer("观察", cycle_timers): + observation = self.observations[0] + await observation.observe() + + # 获取上一个循环的信息 + # last_cycle = self._cycle_history[-1] if self._cycle_history else None + + with Timer("思考", cycle_timers): + # 获取上一个循环的动作 + # 传递上一个循环的信息给 do_thinking_before_reply + current_mind, _past_mind = await self.sub_mind.do_thinking_before_reply( + history_cycle=self._cycle_history + ) + return current_mind + except Exception as e: + logger.error(f"{self.log_prefix}子心流 思考失败: {e}") + logger.error(traceback.format_exc()) + return "[思考时出错]" + + # --- _async_planner_loop 方法 --- + async def _async_planner_loop(self): + """ + Planner的异步循环。 + 等待输入队列的信号,执行规划,并将结果放入输出队列。 + """ + self._planner_active = True + logger.info(f"{self.log_prefix} 异步Planner循环已启动。") + + while self._planner_active: + original_request_id = None # 用于在异常时也能尝试返回ID + try: + request_timeout = getattr(getattr(self.sub_mind, 'llm_model', object()), 'request_timeout', 60) + planner_input_context = await asyncio.wait_for(self._planner_input_queue.get(), timeout=request_timeout + 60) + + if planner_input_context is None: + logger.info(f"{self.log_prefix} 异步Planner收到停止信号。") + self._planner_active = False + break + + current_mind = planner_input_context.get("current_mind") + # --- 获取请求ID --- + original_request_id = planner_input_context.get("request_id") + if not original_request_id: + logger.error(f"{self.log_prefix} Planner收到的请求上下文中缺少request_id!") + # 即使缺少ID,也尝试完成一次规划,但结果可能无法被主循环正确使用 + original_request_id = f"planner_fallback_id_{time.time()}" # 生成一个备用ID + + logger.debug(f"{self.log_prefix} 异步Planner收到规划请求 (ID: {original_request_id})。") + planner_internal_timers = {} + planner_result = await self._planner(current_mind, planner_internal_timers) + + # --- 在结果中加入请求ID --- + if isinstance(planner_result, dict): # 确保 planner_result 是字典 + planner_result["request_id"] = original_request_id + else: # 如果不是字典,则包装一下 + logger.warning(f"{self.log_prefix} Planner返回了非字典类型的结果: {planner_result}。将尝试包装。") + planner_result = { + "action": "error", + "reasoning": f"Planner返回了非字典结果 (ID: {original_request_id})", + "llm_error": True, + "request_id": original_request_id + } + + await self._planner_output_queue.put(planner_result) + self._planner_input_queue.task_done() + + except asyncio.TimeoutError: + logger.debug(f"{self.log_prefix} 异步Planner等待输入信号超时 (最近请求ID可能为: {original_request_id})。") + continue # 不向输出队列放任何东西,主循环的短时等待会超时 + except asyncio.CancelledError: + logger.info(f"{self.log_prefix} 异步Planner循环被取消 (最近请求ID可能为: {original_request_id})。") + self._planner_active = False + break + except Exception as e: + logger.error(f"{self.log_prefix} 异步Planner循环发生错误 (最近请求ID: {original_request_id}): {e}") + logger.error(traceback.format_exc()) + self._planner_active = False + # --- 修改:错误结果也附带请求ID --- + error_output = { + "action": "error", + "reasoning": f"Planner (req_id: {original_request_id}) 内部错误: {e}", + "llm_error": True, + "request_id": original_request_id # 附带原始请求ID + } + # --- 结束修改 --- + # 尝试放入错误结果,如果队列已满(不太可能)或有其他问题,也没关系,主循环最终会超时 + try: + await asyncio.wait_for(self._planner_output_queue.put(error_output), timeout=1.0) + except Exception as put_err: + logger.error(f"{self.log_prefix} Planner放入错误结果到输出队列时失败: {put_err}") + break + logger.info(f"{self.log_prefix} 异步Planner循环已停止。") + # --- 结束新增 _async_planner_loop 方法 --- + + async def _planner(self, current_mind: str, planner_cycle_timers: dict) -> Dict[str, Any]: + """ + 规划器 (Planner): 使用LLM根据上下文决定是否和如何回复。 + 重构为:让LLM返回结构化JSON文本,然后在代码中解析。 + + 参数: + current_mind: 子思维的当前思考结果 + cycle_timers: 计时器字典 + is_re_planned: 是否为重新规划 (此重构中暂时简化,不处理 is_re_planned 的特殊逻辑) + """ + logger.info(f"{self.log_prefix}开始想要做什么") + + actions_to_remove_temporarily = [] + # --- 检查历史动作并决定临时移除动作 (逻辑保持不变) --- + # lian_xu_wen_ben_hui_fu = 0 + # probability_roll = random.random() + # for cycle in reversed(self._cycle_history): + # if cycle.action_taken: + # if cycle.action_type == "text_reply": + # lian_xu_wen_ben_hui_fu += 1 + # else: + # break + # if len(self._cycle_history) > 0 and cycle.cycle_id <= self._cycle_history[0].cycle_id + ( + # len(self._cycle_history) - 4 + # ): + # break + # logger.debug(f"{self.log_prefix}[Planner] 检测到连续文本回复次数: {lian_xu_wen_ben_hui_fu}") + + # if lian_xu_wen_ben_hui_fu >= 3: + # logger.info(f"{self.log_prefix}[Planner] 连续回复 >= 3 次,强制移除 text_reply 和 emoji_reply") + # actions_to_remove_temporarily.extend(["text_reply", "emoji_reply"]) + # elif lian_xu_wen_ben_hui_fu == 2: + # if probability_roll < 0.8: + # logger.info(f"{self.log_prefix}[Planner] 连续回复 2 次,80% 概率移除 text_reply 和 emoji_reply (触发)") + # actions_to_remove_temporarily.extend(["text_reply", "emoji_reply"]) + # else: + # logger.info( + # f"{self.log_prefix}[Planner] 连续回复 2 次,80% 概率移除 text_reply 和 emoji_reply (未触发)" + # ) + # elif lian_xu_wen_ben_hui_fu == 1: + # if probability_roll < 0.4: + # logger.info(f"{self.log_prefix}[Planner] 连续回复 1 次,40% 概率移除 text_reply (触发)") + # actions_to_remove_temporarily.append("text_reply") + # else: + # logger.info(f"{self.log_prefix}[Planner] 连续回复 1 次,40% 概率移除 text_reply (未触发)") + # --- 结束检查历史动作 --- + + # 获取观察信息 + observation = self.observations[0] + # if is_re_planned: # 暂时简化,不处理重新规划 + # await observation.observe() + observed_messages = observation.talking_message + observed_messages_str = observation.talking_message_str_truncate + + # --- 使用 LLM 进行决策 (JSON 输出模式) --- # + action = "no_reply" # 默认动作 + reasoning = "规划器初始化默认" + emoji_query = "" + llm_error = False # LLM 请求或解析错误标志 + + # 获取我们将传递给 prompt 构建器和用于验证的当前可用动作 + current_available_actions = self.action_manager.get_available_actions() + + try: + # --- 应用临时动作移除 --- + if actions_to_remove_temporarily: + self.action_manager.temporarily_remove_actions(actions_to_remove_temporarily) + # 更新 current_available_actions 以反映移除后的状态 + current_available_actions = self.action_manager.get_available_actions() + logger.debug( + f"{self.log_prefix}[Planner] 临时移除的动作: {actions_to_remove_temporarily}, 当前可用: {list(current_available_actions.keys())}" + ) + + # 需要获取用于上下文的历史消息 + message_list_before_now = get_raw_msg_before_timestamp_with_chat( + chat_id=self.stream_id, + timestamp=time.time(), # 使用当前时间作为参考点 + limit=global_config.observation_context_size, # 使用与 prompt 构建一致的 limit + ) + # 调用工具函数获取格式化后的绰号字符串 + nickname_injection_str = await nickname_manager.get_nickname_prompt_injection( + self.chat_stream, message_list_before_now + ) + + # --- 构建提示词 (调用修改后的 PromptBuilder 方法) --- + prompt = await prompt_builder.build_planner_prompt( + is_group_chat=self.is_group_chat, # <-- Pass HFC state + chat_target_info=self.chat_target_info, # <-- Pass HFC state + cycle_history=self._cycle_history, # <-- Pass HFC state + observed_messages_str=observed_messages_str, # <-- Pass local variable + current_mind=current_mind, # <-- Pass argument + structured_info=self.sub_mind.structured_info_str, # <-- Pass SubMind info + current_available_actions=current_available_actions, # <-- Pass determined actions + nickname_info=nickname_injection_str, + ) + + # --- 调用 LLM (普通文本生成) --- + llm_content = None + try: + # 假设 LLMRequest 有 generate_response 方法返回 (content, reasoning, model_name) + # 我们只需要 content + # !! 注意:这里假设 self.planner_llm 有 generate_response 方法 + # !! 如果你的 LLMRequest 类使用的是其他方法名,请相应修改 + llm_content, _, _ = await self.planner_llm.generate_response(prompt=prompt) + logger.debug(f"{self.log_prefix}[Planner] LLM 原始 JSON 响应 (预期): {llm_content}") + except Exception as req_e: + logger.error(f"{self.log_prefix}[Planner] LLM 请求执行失败: {req_e}") + reasoning = f"LLM 请求失败: {req_e}" + llm_error = True + # 直接使用默认动作返回错误结果 + action = "no_reply" # 明确设置为默认值 + emoji_query = "" # 明确设置为空 + # 不再立即返回,而是继续执行 finally 块以恢复动作 + # return { ... } + + # --- 解析 LLM 返回的 JSON (仅当 LLM 请求未出错时进行) --- + if not llm_error and llm_content: + try: + # 尝试去除可能的 markdown 代码块标记 + response_content = llm_content + markdown_code_regex = re.compile(r"^```(?:\w+)?\s*\n(.*?)\n\s*```$", re.DOTALL | re.IGNORECASE) + match = markdown_code_regex.match(response_content) + if match: + response_content = match.group(1).strip() + elif response_content.startswith("{") and response_content.endswith("}"): + pass # 可能是纯 JSON + else: + json_match = re.search(r"\{.*\}", response_content, re.DOTALL) + if json_match: + response_content = json_match.group(0) + else: + logger.warning(f"LLM 响应似乎不包含有效的 JSON 对象。响应: {response_content}") + + cleaned_content = response_content + if not cleaned_content: + raise json.JSONDecodeError("Cleaned content is empty", cleaned_content, 0) + parsed_json = json.loads(cleaned_content) + + # 提取决策,提供默认值 + extracted_action = parsed_json.get("action", "no_reply") + extracted_reasoning = parsed_json.get("reasoning", "LLM未提供理由") + extracted_emoji_query = parsed_json.get("emoji_query", "") + + # 验证动作是否在当前可用列表中 + # !! 使用调用 prompt 时实际可用的动作列表进行验证 + if extracted_action not in current_available_actions: + logger.warning( + f"{self.log_prefix}[Planner] LLM 返回了当前不可用或无效的动作: '{extracted_action}' (可用: {list(current_available_actions.keys())}),将强制使用 'no_reply'" + ) + action = "no_reply" + reasoning = f"LLM 返回了当前不可用的动作 '{extracted_action}' (可用: {list(current_available_actions.keys())})。原始理由: {extracted_reasoning}" + emoji_query = "" + # 检查 no_reply 是否也恰好被移除了 (极端情况) + if "no_reply" not in current_available_actions: + logger.error( + f"{self.log_prefix}[Planner] 严重错误:'no_reply' 动作也不可用!无法执行任何动作。" + ) + action = "error" # 回退到错误状态 + reasoning = "无法执行任何有效动作,包括 no_reply" + llm_error = True # 标记为严重错误 + else: + llm_error = False # 视为逻辑修正而非 LLM 错误 + else: + # 动作有效且可用 + action = extracted_action + reasoning = extracted_reasoning + emoji_query = extracted_emoji_query + llm_error = False # 解析成功 + logger.debug( + f"{self.log_prefix}[要做什么]\nPrompt:\n{prompt}\n\n决策结果 (来自JSON): {action}, 理由: {reasoning}, 表情查询: '{emoji_query}'" + ) + + except json.JSONDecodeError as json_e: + logger.warning( + f"{self.log_prefix}[Planner] 解析LLM响应JSON失败: {json_e}. LLM原始输出: '{llm_content}'" + ) + reasoning = f"解析LLM响应JSON失败: {json_e}. 将使用默认动作 'no_reply'." + action = "no_reply" # 解析失败则默认不回复 + emoji_query = "" + llm_error = True # 标记解析错误 + except Exception as parse_e: + logger.error(f"{self.log_prefix}[Planner] 处理LLM响应时发生意外错误: {parse_e}") + reasoning = f"处理LLM响应时发生意外错误: {parse_e}. 将使用默认动作 'no_reply'." + action = "no_reply" + emoji_query = "" + llm_error = True + elif not llm_error and not llm_content: + # LLM 请求成功但返回空内容 + logger.warning(f"{self.log_prefix}[Planner] LLM 返回了空内容。") + reasoning = "LLM 返回了空内容,使用默认动作 'no_reply'." + action = "no_reply" + emoji_query = "" + llm_error = True # 标记为空响应错误 + + # 如果 llm_error 在此阶段为 True,意味着请求成功但解析失败或返回空 + # 如果 llm_error 在请求阶段就为 True,则跳过了此解析块 + + except Exception as outer_e: + logger.error(f"{self.log_prefix}[Planner] Planner 处理过程中发生意外错误: {outer_e}") + logger.error(traceback.format_exc()) + action = "error" # 发生未知错误,标记为 error 动作 + reasoning = f"Planner 内部处理错误: {outer_e}" + emoji_query = "" + llm_error = True + finally: + # --- 确保动作恢复 --- + # 检查 self._original_actions_backup 是否有值来判断是否需要恢复 + if self.action_manager._original_actions_backup is not None: + self.action_manager.restore_actions() + logger.debug( + f"{self.log_prefix}[Planner] 恢复了原始动作集, 当前可用: {list(self.action_manager.get_available_actions().keys())}" + ) + # --- 结束确保动作恢复 --- + + # --- 概率性忽略文本回复附带的表情 (逻辑保持不变) --- + if action == "text_reply" and emoji_query: + logger.debug(f"{self.log_prefix}[Planner] 大模型建议文字回复带表情: '{emoji_query}'") + if random.random() > EMOJI_SEND_PRO: + logger.info( + f"{self.log_prefix}但是麦麦这次不想加表情 ({1 - EMOJI_SEND_PRO:.0%}),忽略表情 '{emoji_query}'" + ) + emoji_query = "" # 清空表情请求 + else: + logger.info(f"{self.log_prefix}好吧,加上表情 '{emoji_query}'") + # --- 结束概率性忽略 --- + + # 返回结果字典 + return { + "action": action, + "reasoning": reasoning, + "emoji_query": emoji_query, + "current_mind": current_mind, + "observed_messages": observed_messages, + "llm_error": llm_error, # 返回错误状态 + } + + async def _get_anchor_message(self) -> Optional[MessageRecv]: + """ + 重构观察到的最后一条消息作为回复的锚点, + 如果重构失败或观察为空,则创建一个占位符。 + """ + + try: + placeholder_id = f"mid_pf_{int(time.time() * 1000)}" + placeholder_user = UserInfo( + user_id="system_trigger", user_nickname="System Trigger", platform=self.chat_stream.platform + ) + placeholder_msg_info = BaseMessageInfo( + message_id=placeholder_id, + platform=self.chat_stream.platform, + group_info=self.chat_stream.group_info, + user_info=placeholder_user, + time=time.time(), + ) + placeholder_msg_dict = { + "message_info": placeholder_msg_info.to_dict(), + "processed_plain_text": "[System Trigger Context]", + "raw_message": "", + "time": placeholder_msg_info.time, + } + anchor_message = MessageRecv(placeholder_msg_dict) + anchor_message.update_chat_stream(self.chat_stream) + logger.debug(f"{self.log_prefix} 创建占位符锚点消息: ID={anchor_message.message_info.message_id}") + return anchor_message + + except Exception as e: + logger.error(f"{self.log_prefix} Error getting/creating anchor message: {e}") + logger.error(traceback.format_exc()) + return None + + # --- 发送器 (Sender) --- # + async def _sender( + self, + thinking_id: str, + anchor_message: MessageRecv, + response_set: List[str], + send_emoji: str, # Emoji query decided by planner or tools + ): + """ + 发送器 (Sender): 使用 HeartFCSender 实例发送生成的回复。 + 处理相关的操作,如发送表情和更新关系。 + """ + logger.info(f"{self.log_prefix}开始发送回复 (使用 HeartFCSender)") + + first_bot_msg: Optional[MessageSending] = None + try: + # _send_response_messages 现在将使用 self.sender 内部处理注册和发送 + # 它需要负责创建 MessageThinking 和 MessageSending 对象 + # 并调用 self.sender.register_thinking 和 self.sender.type_and_send_message + first_bot_msg = await self._send_response_messages( + anchor_message=anchor_message, response_set=response_set, thinking_id=thinking_id + ) + + if first_bot_msg: + # --- 处理关联表情(如果指定) --- # + if send_emoji: + logger.info(f"{self.log_prefix}正在发送关联表情: '{send_emoji}'") + # 优先使用 first_bot_msg 作为锚点,否则回退到原始锚点 + emoji_anchor = first_bot_msg + await self._handle_emoji(emoji_anchor, response_set, send_emoji) + else: + # 如果 _send_response_messages 返回 None,表示在发送前就失败或没有消息可发送 + logger.warning( + f"{self.log_prefix}[Sender-{thinking_id}] 未能发送任何回复消息 (_send_response_messages 返回 None)。" + ) + # 这里可能不需要抛出异常,取决于 _send_response_messages 的具体实现 + + except Exception as e: + # 异常现在由 type_and_send_message 内部处理日志,这里只记录发送流程失败 + logger.error(f"{self.log_prefix}[Sender-{thinking_id}] 发送回复过程中遇到错误: {e}") + # 思考状态应已在 type_and_send_message 的 finally 块中清理 + # 可以选择重新抛出或根据业务逻辑处理 + # raise RuntimeError(f"发送回复失败: {e}") from e + + async def shutdown(self): + """优雅关闭HeartFChatting实例,取消活动循环任务和异步Planner任务""" + logger.info(f"{self.log_prefix} 正在关闭HeartFChatting...") + self._shutting_down = True + self._loop_active = False + self._planner_active = False + + # --- 新增:停止异步Planner任务 --- + if self._planner_task and not self._planner_task.done(): + logger.info(f"{self.log_prefix} 正在取消异步Planner任务...") + try: + # 尝试向队列发送None作为停止信号,并设置短暂超时 + await asyncio.wait_for(self._planner_input_queue.put(None), timeout=0.5) + except asyncio.TimeoutError: + logger.warning(f"{self.log_prefix} 发送停止信号到Planner输入队列超时。") + except asyncio.QueueFull: # 如果队列是满的(理论上不应该在shutdown时) + logger.warning(f"{self.log_prefix} Planner输入队列已满,无法发送停止信号。") + + + if not self._planner_task.done(): # 再次检查任务是否已自行结束 + self._planner_task.cancel() + try: + await asyncio.wait_for(self._planner_task, timeout=1.0) + logger.info(f"{self.log_prefix} 异步Planner任务已取消。") + except (asyncio.CancelledError, asyncio.TimeoutError): + logger.info(f"{self.log_prefix} 异步Planner任务取消/超时完成。") + except Exception as e: + logger.error(f"{self.log_prefix} 等待异步Planner任务取消时出错: {e}") + + self._planner_task = None # 清理引用 + # 清空队列,以防万一 + while not self._planner_input_queue.empty(): + try: + self._planner_input_queue.get_nowait() + self._planner_input_queue.task_done() + except asyncio.QueueEmpty: + break + while not self._planner_output_queue.empty(): + try: + self._planner_output_queue.get_nowait() + # self._planner_output_queue.task_done() # 输出队列不需要task_done + except asyncio.QueueEmpty: + break + # --- 结束新增 --- + + # 取消循环任务 + if self._loop_task and not self._loop_task.done(): + logger.info(f"{self.log_prefix} 正在取消HeartFChatting循环任务") + self._loop_task.cancel() + try: + await asyncio.wait_for(self._loop_task, timeout=1.0) + logger.info(f"{self.log_prefix} HeartFChatting循环任务已取消") + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + except Exception as e: + logger.error(f"{self.log_prefix} 取消循环任务出错: {e}") + else: + logger.info(f"{self.log_prefix} 没有活动的HeartFChatting循环任务") + + # 清理状态 + self._loop_active = False + self._loop_task = None + if self._processing_lock.locked(): + self._processing_lock.release() + logger.warning(f"{self.log_prefix} 已释放处理锁") + + logger.info(f"{self.log_prefix} HeartFChatting关闭完成") + + async def _build_replan_prompt(self, action: str, reasoning: str) -> str: + """构建 Replanner LLM 的提示词""" + prompt = (await global_prompt_manager.get_prompt_async("replan_prompt")).format( + action=action, + reasoning=reasoning, + ) + + # 在记录循环日志前检查关闭标志 + if not self._shutting_down: + self._current_cycle.complete_cycle() + self._cycle_history.append(self._current_cycle) + + # 记录循环信息和计时器结果 + timer_strings = [] + for name, elapsed in self._current_cycle.timers.items(): + formatted_time = f"{elapsed * 1000:.2f}毫秒" if elapsed < 1 else f"{elapsed:.2f}秒" + timer_strings.append(f"{name}: {formatted_time}") + + logger.debug( + f"{self.log_prefix} 第 #{self._current_cycle.cycle_id}次思考完成," + f"耗时: {self._current_cycle.end_time - self._current_cycle.start_time:.2f}秒, " + f"动作: {self._current_cycle.action_type}" + + (f"\n计时器详情: {'; '.join(timer_strings)}" if timer_strings else "") + ) + + return prompt + + async def _send_response_messages( + self, anchor_message: Optional[MessageRecv], response_set: List[str], thinking_id: str + ) -> Optional[MessageSending]: + """发送回复消息 (尝试锚定到 anchor_message),使用 HeartFCSender""" + if not anchor_message or not anchor_message.chat_stream: + logger.error(f"{self.log_prefix} 无法发送回复,缺少有效的锚点消息或聊天流。") + return None + + chat = anchor_message.chat_stream + chat_id = chat.stream_id + stream_name = chat_manager.get_stream_name(chat_id) or chat_id # 获取流名称用于日志 + + # 检查思考过程是否仍在进行,并获取开始时间 + thinking_start_time = await self.heart_fc_sender.get_thinking_start_time(chat_id, thinking_id) + + if thinking_start_time is None: + logger.warning(f"[{stream_name}] {thinking_id} 思考过程未找到或已结束,无法发送回复。") + return None + + # 记录锚点消息ID和回复文本(在发送前记录) + self._current_cycle.set_response_info( + response_text=response_set, anchor_message_id=anchor_message.message_info.message_id + ) + + mark_head = False + first_bot_msg: Optional[MessageSending] = None + reply_message_ids = [] # 记录实际发送的消息ID + bot_user_info = UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=anchor_message.message_info.platform, + ) + + for i, msg_text in enumerate(response_set): + # 为每个消息片段生成唯一ID + part_message_id = f"{thinking_id}_{i}" + message_segment = Seg(type="text", data=msg_text) + bot_message = MessageSending( + message_id=part_message_id, # 使用片段的唯一ID + chat_stream=chat, + bot_user_info=bot_user_info, + sender_info=anchor_message.message_info.user_info, + message_segment=message_segment, + reply=anchor_message, # 回复原始锚点 + is_head=not mark_head, + is_emoji=False, + thinking_start_time=thinking_start_time, # 传递原始思考开始时间 + ) + try: + if not mark_head: + mark_head = True + first_bot_msg = bot_message # 保存第一个成功发送的消息对象 + await self.heart_fc_sender.type_and_send_message(bot_message, typing=False) + else: + await self.heart_fc_sender.type_and_send_message(bot_message, typing=True) + + reply_message_ids.append(part_message_id) # 记录我们生成的ID + + except Exception as e: + logger.error( + f"{self.log_prefix}[Sender-{thinking_id}] 发送回复片段 {i} ({part_message_id}) 时失败: {e}" + ) + # 这里可以选择是继续发送下一个片段还是中止 + + # 在尝试发送完所有片段后,完成原始的 thinking_id 状态 + try: + await self.heart_fc_sender.complete_thinking(chat_id, thinking_id) + except Exception as e: + logger.error(f"{self.log_prefix}[Sender-{thinking_id}] 完成思考状态 {thinking_id} 时出错: {e}") + + self._current_cycle.set_response_info( + response_text=response_set, # 保留原始文本 + anchor_message_id=anchor_message.message_info.message_id, # 保留锚点ID + reply_message_ids=reply_message_ids, # 添加实际发送的ID列表 + ) + + return first_bot_msg # 返回第一个成功发送的消息对象 + + async def _handle_emoji(self, anchor_message: Optional[MessageRecv], response_set: List[str], send_emoji: str = ""): + """处理表情包 (尝试锚定到 anchor_message),使用 HeartFCSender""" + if not anchor_message or not anchor_message.chat_stream: + logger.error(f"{self.log_prefix} 无法处理表情包,缺少有效的锚点消息或聊天流。") + return + + chat = anchor_message.chat_stream + + emoji_raw = await emoji_manager.get_emoji_for_text(send_emoji) + + if emoji_raw: + emoji_path, description = emoji_raw + + emoji_cq = image_path_to_base64(emoji_path) + thinking_time_point = round(time.time(), 2) # 用于唯一ID + message_segment = Seg(type="emoji", data=emoji_cq) + bot_user_info = UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=anchor_message.message_info.platform, + ) + bot_message = MessageSending( + message_id="me" + str(thinking_time_point), # 表情消息的唯一ID + chat_stream=chat, + bot_user_info=bot_user_info, + sender_info=anchor_message.message_info.user_info, + message_segment=message_segment, + reply=anchor_message, # 回复原始锚点 + is_head=False, # 表情通常不是头部消息 + is_emoji=True, + # 不需要 thinking_start_time + ) + + try: + await self.heart_fc_sender.send_and_store(bot_message) + except Exception as e: + logger.error(f"{self.log_prefix} 发送表情包 {bot_message.message_info.message_id} 时失败: {e}") + + def get_cycle_history(self, last_n: Optional[int] = None) -> List[Dict[str, Any]]: + """获取循环历史记录 + + 参数: + last_n: 获取最近n个循环的信息,如果为None则获取所有历史记录 + + 返回: + List[Dict[str, Any]]: 循环历史记录列表 + """ + history = list(self._cycle_history) + if last_n is not None: + history = history[-last_n:] + return [cycle.to_dict() for cycle in history] + + def get_last_cycle_info(self) -> Optional[Dict[str, Any]]: + """获取最近一个循环的信息""" + if self._cycle_history: + return self._cycle_history[-1].to_dict() + return None + + # --- 回复器 (Replier) 的定义 --- # + async def _replier_work( + self, + reason: str, + anchor_message: MessageRecv, + thinking_id: str, + ) -> Optional[List[str]]: + """ + 回复器 (Replier): 核心逻辑,负责生成回复文本。 + (已整合原 HeartFCGenerator 的功能) + """ + try: + # 1. 获取情绪影响因子并调整模型温度 + arousal_multiplier = mood_manager.get_arousal_multiplier() + current_temp = global_config.llm_normal["temp"] * arousal_multiplier + self.model_normal.temperature = current_temp # 动态调整温度 + + # 2. 获取信息捕捉器 + info_catcher = info_catcher_manager.get_info_catcher(thinking_id) + + # --- Determine sender_name for private chat --- + sender_name_for_prompt = "某人" # Default for group or if info unavailable + if not self.is_group_chat and self.chat_target_info: + # Prioritize person_name, then nickname + sender_name_for_prompt = ( + self.chat_target_info.get("person_name") + or self.chat_target_info.get("user_nickname") + or sender_name_for_prompt + ) + # --- End determining sender_name --- + + # 3. 构建 Prompt + with Timer("构建Prompt", {}): # 内部计时器,可选保留 + prompt = await prompt_builder.build_prompt( + build_mode="focus", + chat_stream=self.chat_stream, # Pass the stream object + # Focus specific args: + reason=reason, + current_mind_info=self.sub_mind.current_mind, + structured_info=self.sub_mind.structured_info_str, + sender_name=sender_name_for_prompt, # Pass determined name + # Normal specific args (not used in focus mode): + # message_txt="", + ) + + # 4. 调用 LLM 生成回复 + content = None + reasoning_content = None + model_name = "unknown_model" + if not prompt: + logger.error(f"{self.log_prefix}[Replier-{thinking_id}] Prompt 构建失败,无法生成回复。") + return None + + try: + with Timer("LLM生成", {}): # 内部计时器,可选保留 + content, reasoning_content, model_name = await self.model_normal.generate_response(prompt) + # logger.info(f"{self.log_prefix}[Replier-{thinking_id}]\nPrompt:\n{prompt}\n生成回复: {content}\n") + # 捕捉 LLM 输出信息 + info_catcher.catch_after_llm_generated( + prompt=prompt, response=content, reasoning_content=reasoning_content, model_name=model_name + ) + + except Exception as llm_e: + # 精简报错信息 + logger.error(f"{self.log_prefix}[Replier-{thinking_id}] LLM 生成失败: {llm_e}") + return None # LLM 调用失败则无法生成回复 + + # 5. 处理 LLM 响应 + if not content: + logger.warning(f"{self.log_prefix}[Replier-{thinking_id}] LLM 生成了空内容。") + return None + + with Timer("处理响应", {}): # 内部计时器,可选保留 + processed_response = process_llm_response(content) + + if not processed_response: + logger.warning(f"{self.log_prefix}[Replier-{thinking_id}] 处理后的回复为空。") + return None + + return processed_response + + except Exception as e: + # 更通用的错误处理,精简信息 + logger.error(f"{self.log_prefix}[Replier-{thinking_id}] 回复生成意外失败: {e}") + # logger.error(traceback.format_exc()) # 可以取消注释这行以在调试时查看完整堆栈 + return None + + # --- Methods moved from HeartFCController start --- + async def _create_thinking_message(self, anchor_message: Optional[MessageRecv]) -> Optional[str]: + """创建思考消息 (尝试锚定到 anchor_message)""" + if not anchor_message or not anchor_message.chat_stream: + logger.error(f"{self.log_prefix} 无法创建思考消息,缺少有效的锚点消息或聊天流。") + return None + + chat = anchor_message.chat_stream + messageinfo = anchor_message.message_info + bot_user_info = UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=messageinfo.platform, + ) + + thinking_time_point = round(time.time(), 2) + thinking_id = "mt" + str(thinking_time_point) + thinking_message = MessageThinking( + message_id=thinking_id, + chat_stream=chat, + bot_user_info=bot_user_info, + reply=anchor_message, # 回复的是锚点消息 + thinking_start_time=thinking_time_point, + ) + # Access MessageManager directly (using heart_fc_sender) + await self.heart_fc_sender.register_thinking(thinking_message) + return thinking_id diff --git a/src/experimental/Legacy_HFC/heartFC_sender.py b/src/experimental/Legacy_HFC/heartFC_sender.py new file mode 100644 index 00000000..3e20bbd4 --- /dev/null +++ b/src/experimental/Legacy_HFC/heartFC_sender.py @@ -0,0 +1,151 @@ +# src/plugins/heartFC_chat/heartFC_sender.py +import asyncio # 重新导入 asyncio +from typing import Dict, Optional # 重新导入类型 +from src.chat.message_receive.message import MessageSending, MessageThinking # 只保留 MessageSending 和 MessageThinking + +# from ..message import global_api +from src.common.message import global_api +from src.chat.message_receive.storage import MessageStorage +from src.chat.utils.utils import truncate_message +from src.common.logger_manager import get_logger +from src.chat.utils.utils import calculate_typing_time +from rich.traceback import install + +install(extra_lines=3) + + +logger = get_logger("sender") + + +async def send_message(message: MessageSending) -> None: + """合并后的消息发送函数,包含WS发送和日志记录""" + message_preview = truncate_message(message.processed_plain_text) + + try: + # 直接调用API发送消息 + await global_api.send_message(message) + logger.success(f"发送消息 '{message_preview}' 成功") + + except Exception as e: + logger.error(f"发送消息 '{message_preview}' 失败: {str(e)}") + if not message.message_info.platform: + raise ValueError(f"未找到平台:{message.message_info.platform} 的url配置,请检查配置文件") from e + raise e # 重新抛出其他异常 + + +class HeartFCSender: + """管理消息的注册、即时处理、发送和存储,并跟踪思考状态。""" + + def __init__(self): + self.storage = MessageStorage() + # 用于存储活跃的思考消息 + self.thinking_messages: Dict[str, Dict[str, MessageThinking]] = {} + self._thinking_lock = asyncio.Lock() # 保护 thinking_messages 的锁 + + async def register_thinking(self, thinking_message: MessageThinking): + """注册一个思考中的消息。""" + if not thinking_message.chat_stream or not thinking_message.message_info.message_id: + logger.error("无法注册缺少 chat_stream 或 message_id 的思考消息") + return + + chat_id = thinking_message.chat_stream.stream_id + message_id = thinking_message.message_info.message_id + + async with self._thinking_lock: + if chat_id not in self.thinking_messages: + self.thinking_messages[chat_id] = {} + if message_id in self.thinking_messages[chat_id]: + logger.warning(f"[{chat_id}] 尝试注册已存在的思考消息 ID: {message_id}") + self.thinking_messages[chat_id][message_id] = thinking_message + logger.debug(f"[{chat_id}] Registered thinking message: {message_id}") + + async def complete_thinking(self, chat_id: str, message_id: str): + """完成并移除一个思考中的消息记录。""" + async with self._thinking_lock: + if chat_id in self.thinking_messages and message_id in self.thinking_messages[chat_id]: + del self.thinking_messages[chat_id][message_id] + logger.debug(f"[{chat_id}] Completed thinking message: {message_id}") + if not self.thinking_messages[chat_id]: + del self.thinking_messages[chat_id] + logger.debug(f"[{chat_id}] Removed empty thinking message container.") + + def is_thinking(self, chat_id: str, message_id: str) -> bool: + """检查指定的消息 ID 是否当前正处于思考状态。""" + return chat_id in self.thinking_messages and message_id in self.thinking_messages[chat_id] + + async def get_thinking_start_time(self, chat_id: str, message_id: str) -> Optional[float]: + """获取已注册思考消息的开始时间。""" + async with self._thinking_lock: + thinking_message = self.thinking_messages.get(chat_id, {}).get(message_id) + return thinking_message.thinking_start_time if thinking_message else None + + async def type_and_send_message(self, message: MessageSending, typing=False): + """ + 立即处理、发送并存储单个 MessageSending 消息。 + 调用此方法前,应先调用 register_thinking 注册对应的思考消息。 + 此方法执行后会调用 complete_thinking 清理思考状态。 + """ + if not message.chat_stream: + logger.error("消息缺少 chat_stream,无法发送") + return + if not message.message_info or not message.message_info.message_id: + logger.error("消息缺少 message_info 或 message_id,无法发送") + return + + chat_id = message.chat_stream.stream_id + message_id = message.message_info.message_id + + try: + _ = message.update_thinking_time() + + # --- 条件应用 set_reply 逻辑 --- + if message.apply_set_reply_logic and message.is_head and not message.is_private_message(): + logger.debug(f"[{chat_id}] 应用 set_reply 逻辑: {message.processed_plain_text[:20]}...") + message.set_reply() + # --- 结束条件 set_reply --- + + await message.process() + + if typing: + typing_time = calculate_typing_time( + input_string=message.processed_plain_text, + thinking_start_time=message.thinking_start_time, + is_emoji=message.is_emoji, + ) + await asyncio.sleep(typing_time) + + await send_message(message) + await self.storage.store_message(message, message.chat_stream) + + except Exception as e: + logger.error(f"[{chat_id}] 处理或存储消息 {message_id} 时出错: {e}") + raise e + finally: + await self.complete_thinking(chat_id, message_id) + + async def send_and_store(self, message: MessageSending): + """处理、发送并存储单个消息,不涉及思考状态管理。""" + if not message.chat_stream: + logger.error(f"[{message.message_info.platform or 'UnknownPlatform'}] 消息缺少 chat_stream,无法发送") + return + if not message.message_info or not message.message_info.message_id: + logger.error( + f"[{message.chat_stream.stream_id if message.chat_stream else 'UnknownStream'}] 消息缺少 message_info 或 message_id,无法发送" + ) + return + + chat_id = message.chat_stream.stream_id + message_id = message.message_info.message_id # 获取消息ID用于日志 + + try: + await message.process() + + await asyncio.sleep(0.5) + + await send_message(message) # 使用现有的发送方法 + await self.storage.store_message(message, message.chat_stream) # 使用现有的存储方法 + + except Exception as e: + logger.error(f"[{chat_id}] 处理或存储消息 {message_id} 时出错: {e}") + # 重新抛出异常,让调用者知道失败了 + raise e diff --git a/src/experimental/Legacy_HFC/heart_flow/background_tasks.py b/src/experimental/Legacy_HFC/heart_flow/background_tasks.py new file mode 100644 index 00000000..60fd0597 --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/background_tasks.py @@ -0,0 +1,314 @@ +import asyncio +import traceback +from typing import Optional, Coroutine, Callable, Any, List + +from src.common.logger_manager import get_logger + +# Need manager types for dependency injection +from .mai_state_manager import MaiStateManager, MaiStateInfo +from .subheartflow_manager import SubHeartflowManager +from .interest_logger import InterestLogger + + +logger = get_logger("background_tasks") + + +# 新增兴趣评估间隔 +INTEREST_EVAL_INTERVAL_SECONDS = 5 +# 新增聊天超时检查间隔 +NORMAL_CHAT_TIMEOUT_CHECK_INTERVAL_SECONDS = 60 +# 新增状态评估间隔 +HF_JUDGE_STATE_UPDATE_INTERVAL_SECONDS = 20 +# 新增私聊激活检查间隔 +PRIVATE_CHAT_ACTIVATION_CHECK_INTERVAL_SECONDS = 5 # 与兴趣评估类似,设为5秒 + +CLEANUP_INTERVAL_SECONDS = 1200 +STATE_UPDATE_INTERVAL_SECONDS = 60 +LOG_INTERVAL_SECONDS = 3 + + +async def _run_periodic_loop( + task_name: str, interval: int, task_func: Callable[..., Coroutine[Any, Any, None]], **kwargs +): + """周期性任务主循环""" + while True: + start_time = asyncio.get_event_loop().time() + # logger.debug(f"开始执行后台任务: {task_name}") + + try: + await task_func(**kwargs) # 执行实际任务 + except asyncio.CancelledError: + logger.info(f"任务 {task_name} 已取消") + break + except Exception as e: + logger.error(f"任务 {task_name} 执行出错: {e}") + logger.error(traceback.format_exc()) + + # 计算并执行间隔等待 + elapsed = asyncio.get_event_loop().time() - start_time + sleep_time = max(0, interval - elapsed) + # if sleep_time < 0.1: # 任务超时处理, DEBUG 时可能干扰断点 + # logger.warning(f"任务 {task_name} 超时执行 ({elapsed:.2f}s > {interval}s)") + await asyncio.sleep(sleep_time) + + logger.debug(f"任务循环结束: {task_name}") # 调整日志信息 + + +class BackgroundTaskManager: + """管理 Heartflow 的后台周期性任务。""" + + def __init__( + self, + mai_state_info: MaiStateInfo, # Needs current state info + mai_state_manager: MaiStateManager, + subheartflow_manager: SubHeartflowManager, + interest_logger: InterestLogger, + ): + self.mai_state_info = mai_state_info + self.mai_state_manager = mai_state_manager + self.subheartflow_manager = subheartflow_manager + self.interest_logger = interest_logger + + # Task references + self._state_update_task: Optional[asyncio.Task] = None + self._cleanup_task: Optional[asyncio.Task] = None + self._logging_task: Optional[asyncio.Task] = None + self._normal_chat_timeout_check_task: Optional[asyncio.Task] = None + self._hf_judge_state_update_task: Optional[asyncio.Task] = None + self._into_focus_task: Optional[asyncio.Task] = None + self._private_chat_activation_task: Optional[asyncio.Task] = None # 新增私聊激活任务引用 + self._tasks: List[Optional[asyncio.Task]] = [] # Keep track of all tasks + self._detect_command_from_gui_task: Optional[asyncio.Task] = None # 新增GUI命令检测任务引用 + + async def start_tasks(self): + """启动所有后台任务 + + 功能说明: + - 启动核心后台任务: 状态更新、清理、日志记录、兴趣评估和随机停用 + - 每个任务启动前检查是否已在运行 + - 将任务引用保存到任务列表 + """ + + # 任务配置列表: (任务函数, 任务名称, 日志级别, 额外日志信息, 任务对象引用属性名) + task_configs = [ + ( + lambda: self._run_state_update_cycle(STATE_UPDATE_INTERVAL_SECONDS), + "debug", + f"聊天状态更新任务已启动 间隔:{STATE_UPDATE_INTERVAL_SECONDS}s", + "_state_update_task", + ), + ( + lambda: self._run_normal_chat_timeout_check_cycle(NORMAL_CHAT_TIMEOUT_CHECK_INTERVAL_SECONDS), + "debug", + f"聊天超时检查任务已启动 间隔:{NORMAL_CHAT_TIMEOUT_CHECK_INTERVAL_SECONDS}s", + "_normal_chat_timeout_check_task", + ), + ( + lambda: self._run_absent_into_chat(HF_JUDGE_STATE_UPDATE_INTERVAL_SECONDS), + "debug", + f"状态评估任务已启动 间隔:{HF_JUDGE_STATE_UPDATE_INTERVAL_SECONDS}s", + "_hf_judge_state_update_task", + ), + ( + self._run_cleanup_cycle, + "info", + f"清理任务已启动 间隔:{CLEANUP_INTERVAL_SECONDS}s", + "_cleanup_task", + ), + ( + self._run_logging_cycle, + "info", + f"日志任务已启动 间隔:{LOG_INTERVAL_SECONDS}s", + "_logging_task", + ), + # 新增兴趣评估任务配置 + ( + self._run_into_focus_cycle, + "debug", # 设为debug,避免过多日志 + f"专注评估任务已启动 间隔:{INTEREST_EVAL_INTERVAL_SECONDS}s", + "_into_focus_task", + ), + # 新增私聊激活任务配置 + ( + # Use lambda to pass the interval to the runner function + lambda: self._run_private_chat_activation_cycle(PRIVATE_CHAT_ACTIVATION_CHECK_INTERVAL_SECONDS), + "debug", + f"私聊激活检查任务已启动 间隔:{PRIVATE_CHAT_ACTIVATION_CHECK_INTERVAL_SECONDS}s", + "_private_chat_activation_task", + ), + # 新增GUI命令检测任务配置 + # ( + # lambda: self._run_detect_command_from_gui_cycle(3), + # "debug", + # f"GUI命令检测任务已启动 间隔:{3}s", + # "_detect_command_from_gui_task", + # ), + ] + + # 统一启动所有任务 + for task_func, log_level, log_msg, task_attr_name in task_configs: + # 检查任务变量是否存在且未完成 + current_task_var = getattr(self, task_attr_name) + if current_task_var is None or current_task_var.done(): + new_task = asyncio.create_task(task_func()) + setattr(self, task_attr_name, new_task) # 更新任务变量 + if new_task not in self._tasks: # 避免重复添加 + self._tasks.append(new_task) + + # 根据配置记录不同级别的日志 + getattr(logger, log_level)(log_msg) + else: + logger.warning(f"{task_attr_name}任务已在运行") + + async def stop_tasks(self): + """停止所有后台任务。 + + 该方法会: + 1. 遍历所有后台任务并取消未完成的任务 + 2. 等待所有取消操作完成 + 3. 清空任务列表 + """ + logger.info("正在停止所有后台任务...") + cancelled_count = 0 + + # 第一步:取消所有运行中的任务 + for task in self._tasks: + if task and not task.done(): + task.cancel() # 发送取消请求 + cancelled_count += 1 + + # 第二步:处理取消结果 + if cancelled_count > 0: + logger.debug(f"正在等待{cancelled_count}个任务完成取消...") + # 使用gather等待所有取消操作完成,忽略异常 + await asyncio.gather(*[t for t in self._tasks if t and t.cancelled()], return_exceptions=True) + logger.info(f"成功取消{cancelled_count}个后台任务") + else: + logger.info("没有需要取消的后台任务") + + # 第三步:清空任务列表 + self._tasks = [] # 重置任务列表 + + async def _perform_state_update_work(self): + """执行状态更新工作""" + previous_status = self.mai_state_info.get_current_state() + next_state = self.mai_state_manager.check_and_decide_next_state(self.mai_state_info) + + state_changed = False + + if next_state is not None: + state_changed = self.mai_state_info.update_mai_status(next_state) + + # 处理保持离线状态的特殊情况 + if not state_changed and next_state == previous_status == self.mai_state_info.mai_status.OFFLINE: + self.mai_state_info.reset_state_timer() + logger.debug("[后台任务] 保持离线状态并重置计时器") + state_changed = True # 触发后续处理 + + if state_changed: + current_state = self.mai_state_info.get_current_state() + await self.subheartflow_manager.enforce_subheartflow_limits() + + # 状态转换处理 + + if ( + current_state == self.mai_state_info.mai_status.OFFLINE + and previous_status != self.mai_state_info.mai_status.OFFLINE + ): + logger.info("检测到离线,停用所有子心流") + await self.subheartflow_manager.deactivate_all_subflows() + + async def _perform_absent_into_chat(self): + """调用llm检测是否转换ABSENT-CHAT状态""" + logger.debug("[状态评估任务] 开始基于LLM评估子心流状态...") + await self.subheartflow_manager.sbhf_absent_into_chat() + + async def _normal_chat_timeout_check_work(self): + """检查处于CHAT状态的子心流是否因长时间未发言而超时,并将其转为ABSENT""" + logger.debug("[聊天超时检查] 开始检查处于CHAT状态的子心流...") + await self.subheartflow_manager.sbhf_chat_into_absent() + + async def _perform_cleanup_work(self): + """执行子心流清理任务 + 1. 获取需要清理的不活跃子心流列表 + 2. 逐个停止这些子心流 + 3. 记录清理结果 + """ + # 获取需要清理的子心流列表(包含ID和原因) + flows_to_stop = self.subheartflow_manager.get_inactive_subheartflows() + + if not flows_to_stop: + return # 没有需要清理的子心流直接返回 + + logger.info(f"准备删除 {len(flows_to_stop)} 个不活跃(1h)子心流") + stopped_count = 0 + + # 逐个停止子心流 + for flow_id in flows_to_stop: + success = await self.subheartflow_manager.delete_subflow(flow_id) + if success: + stopped_count += 1 + logger.debug(f"[清理任务] 已停止子心流 {flow_id}") + + # 记录最终清理结果 + logger.info(f"[清理任务] 清理完成, 共停止 {stopped_count}/{len(flows_to_stop)} 个子心流") + + async def _perform_logging_work(self): + """执行一轮状态日志记录。""" + await self.interest_logger.log_all_states() + + # --- 新增兴趣评估工作函数 --- + async def _perform_into_focus_work(self): + """执行一轮子心流兴趣评估与提升检查。""" + # 直接调用 subheartflow_manager 的方法,并传递当前状态信息 + await self.subheartflow_manager.sbhf_absent_into_focus() + + # --- 结束新增 --- + + # --- 结束新增 --- + + # --- Specific Task Runners --- # + async def _run_state_update_cycle(self, interval: int): + await _run_periodic_loop(task_name="State Update", interval=interval, task_func=self._perform_state_update_work) + + async def _run_absent_into_chat(self, interval: int): + await _run_periodic_loop(task_name="Into Chat", interval=interval, task_func=self._perform_absent_into_chat) + + async def _run_normal_chat_timeout_check_cycle(self, interval: int): + await _run_periodic_loop( + task_name="Normal Chat Timeout Check", interval=interval, task_func=self._normal_chat_timeout_check_work + ) + + async def _run_cleanup_cycle(self): + await _run_periodic_loop( + task_name="Subflow Cleanup", interval=CLEANUP_INTERVAL_SECONDS, task_func=self._perform_cleanup_work + ) + + async def _run_logging_cycle(self): + await _run_periodic_loop( + task_name="State Logging", interval=LOG_INTERVAL_SECONDS, task_func=self._perform_logging_work + ) + + # --- 新增兴趣评估任务运行器 --- + async def _run_into_focus_cycle(self): + await _run_periodic_loop( + task_name="Into Focus", + interval=INTEREST_EVAL_INTERVAL_SECONDS, + task_func=self._perform_into_focus_work, + ) + + # 新增私聊激活任务运行器 + async def _run_private_chat_activation_cycle(self, interval: int): + await _run_periodic_loop( + task_name="Private Chat Activation Check", + interval=interval, + task_func=self.subheartflow_manager.sbhf_absent_private_into_focus, + ) + + # # 有api之后删除 + # async def _run_detect_command_from_gui_cycle(self, interval: int): + # await _run_periodic_loop( + # task_name="Detect Command from GUI", + # interval=interval, + # task_func=self.subheartflow_manager.detect_command_from_gui, + # ) diff --git a/src/experimental/Legacy_HFC/heart_flow/chat_state_info.py b/src/experimental/Legacy_HFC/heart_flow/chat_state_info.py new file mode 100644 index 00000000..bda5c26c --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/chat_state_info.py @@ -0,0 +1,17 @@ +from src.manager.mood_manager import mood_manager +import enum + + +class ChatState(enum.Enum): + ABSENT = "没在看群" + CHAT = "随便水群" + FOCUSED = "认真水群" + + +class ChatStateInfo: + def __init__(self): + self.chat_status: ChatState = ChatState.ABSENT + self.current_state_time = 120 + + self.mood_manager = mood_manager + self.mood = self.mood_manager.get_mood_prompt() diff --git a/src/experimental/Legacy_HFC/heart_flow/heartflow.py b/src/experimental/Legacy_HFC/heart_flow/heartflow.py new file mode 100644 index 00000000..d07bdda2 --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/heartflow.py @@ -0,0 +1,112 @@ +from .sub_heartflow import SubHeartflow, ChatState +from src.chat.models.utils_model import LLMRequest +from src.config.config import global_config +from ..schedule.schedule_generator import bot_schedule +from src.common.logger_manager import get_logger +from typing import Any, Optional +from src.tools.tool_use import ToolUser +from src.chat.person_info.relationship_manager import relationship_manager # Module instance +from .mai_state_manager import MaiStateInfo, MaiStateManager +from .subheartflow_manager import SubHeartflowManager +from .mind import Mind +from .interest_logger import InterestLogger # Import InterestLogger +from .background_tasks import BackgroundTaskManager # Import BackgroundTaskManager + +logger = get_logger("heartflow") + + +class Heartflow: + """主心流协调器,负责初始化并协调各个子系统: + - 状态管理 (MaiState) + - 子心流管理 (SubHeartflow) + - 思考过程 (Mind) + - 日志记录 (InterestLogger) + - 后台任务 (BackgroundTaskManager) + """ + + def __init__(self): + # 核心状态 + self.current_mind = "什么也没想" # 当前主心流想法 + self.past_mind = [] # 历史想法记录 + + # 状态管理相关 + self.current_state: MaiStateInfo = MaiStateInfo() # 当前状态信息 + self.mai_state_manager: MaiStateManager = MaiStateManager() # 状态决策管理器 + + # 子心流管理 (在初始化时传入 current_state) + self.subheartflow_manager: SubHeartflowManager = SubHeartflowManager(self.current_state) + + # LLM模型配置 + self.llm_model = LLMRequest( + model=global_config.llm_heartflow, temperature=0.6, max_tokens=1000, request_type="heart_flow" + ) + + # 外部依赖模块 + self.tool_user_instance = ToolUser() # 工具使用模块 + self.relationship_manager_instance = relationship_manager # 关系管理模块 + + # 子系统初始化 + self.mind: Mind = Mind(self.subheartflow_manager, self.llm_model) # 思考管理器 + self.interest_logger: InterestLogger = InterestLogger(self.subheartflow_manager, self) # 兴趣日志记录器 + + # 后台任务管理器 (整合所有定时任务) + self.background_task_manager: BackgroundTaskManager = BackgroundTaskManager( + mai_state_info=self.current_state, + mai_state_manager=self.mai_state_manager, + subheartflow_manager=self.subheartflow_manager, + interest_logger=self.interest_logger, + ) + + async def get_or_create_subheartflow(self, subheartflow_id: Any) -> Optional["SubHeartflow"]: + """获取或创建一个新的SubHeartflow实例 - 委托给 SubHeartflowManager""" + # 不再需要传入 self.current_state + return await self.subheartflow_manager.get_or_create_subheartflow(subheartflow_id) + + async def force_change_subheartflow_status(self, subheartflow_id: str, status: ChatState) -> None: + """强制改变子心流的状态""" + # 这里的 message 是可选的,可能是一个消息对象,也可能是其他类型的数据 + return await self.subheartflow_manager.force_change_state(subheartflow_id, status) + + async def api_get_all_states(self): + """获取所有状态""" + return await self.interest_logger.api_get_all_states() + + async def api_get_subheartflow_cycle_info(self, subheartflow_id: str, history_len: int) -> Optional[dict]: + """获取子心流的循环信息""" + subheartflow = await self.subheartflow_manager.get_or_create_subheartflow(subheartflow_id) + if not subheartflow: + logger.warning(f"尝试获取不存在的子心流 {subheartflow_id} 的周期信息") + return None + heartfc_instance = subheartflow.heart_fc_instance + if not heartfc_instance: + logger.warning(f"子心流 {subheartflow_id} 没有心流实例,无法获取周期信息") + return None + + return heartfc_instance.get_cycle_history(last_n=history_len) + + async def heartflow_start_working(self): + """启动后台任务""" + await self.background_task_manager.start_tasks() + logger.info("[Heartflow] 后台任务已启动") + + # 根本不会用到这个函数吧,那样麦麦直接死了 + async def stop_working(self): + """停止所有任务和子心流""" + logger.info("[Heartflow] 正在停止任务和子心流...") + await self.background_task_manager.stop_tasks() + await self.subheartflow_manager.deactivate_all_subflows() + logger.info("[Heartflow] 所有任务和子心流已停止") + + async def do_a_thinking(self): + """执行一次主心流思考过程""" + schedule_info = bot_schedule.get_current_num_task(num=4, time_info=True) + new_mind = await self.mind.do_a_thinking( + current_main_mind=self.current_mind, mai_state_info=self.current_state, schedule_info=schedule_info + ) + self.past_mind.append(self.current_mind) + self.current_mind = new_mind + logger.info(f"麦麦的总体脑内状态更新为:{self.current_mind[:100]}...") + self.mind.update_subflows_with_main_mind(new_mind) + + +heartflow = Heartflow() diff --git a/src/experimental/Legacy_HFC/heart_flow/interest_chatting.py b/src/experimental/Legacy_HFC/heart_flow/interest_chatting.py new file mode 100644 index 00000000..45f7fe95 --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/interest_chatting.py @@ -0,0 +1,200 @@ +import asyncio +from src.config.config import global_config +from typing import Optional, Dict +import traceback +from src.common.logger_manager import get_logger +from src.chat.message_receive.message import MessageRecv +import math + + +# 定义常量 (从 interest.py 移动过来) +MAX_INTEREST = 15.0 + +logger = get_logger("interest_chatting") + +PROBABILITY_INCREASE_RATE_PER_SECOND = 0.1 +PROBABILITY_DECREASE_RATE_PER_SECOND = 0.1 +MAX_REPLY_PROBABILITY = 1 + + +class InterestChatting: + def __init__( + self, + decay_rate=global_config.default_decay_rate_per_second, + max_interest=MAX_INTEREST, + trigger_threshold=global_config.reply_trigger_threshold, + max_probability=MAX_REPLY_PROBABILITY, + ): + # 基础属性初始化 + self.interest_level: float = 0.0 + self.decay_rate_per_second: float = decay_rate + self.max_interest: float = max_interest + + self.trigger_threshold: float = trigger_threshold + self.max_reply_probability: float = max_probability + self.is_above_threshold: bool = False + + # 任务相关属性初始化 + self.update_task: Optional[asyncio.Task] = None + self._stop_event = asyncio.Event() + self._task_lock = asyncio.Lock() + self._is_running = False + + self.interest_dict: Dict[str, tuple[MessageRecv, float, bool]] = {} + self.update_interval = 1.0 + + self.above_threshold = False + self.start_hfc_probability = 0.0 + + async def initialize(self): + async with self._task_lock: + if self._is_running: + logger.debug("后台兴趣更新任务已在运行中。") + return + + # 清理已完成或已取消的任务 + if self.update_task and (self.update_task.done() or self.update_task.cancelled()): + self.update_task = None + + if not self.update_task: + self._stop_event.clear() + self._is_running = True + self.update_task = asyncio.create_task(self._run_update_loop(self.update_interval)) + logger.debug("后台兴趣更新任务已创建并启动。") + + def add_interest_dict(self, message: MessageRecv, interest_value: float, is_mentioned: bool): + """添加消息到兴趣字典 + + 参数: + message: 接收到的消息 + interest_value: 兴趣值 + is_mentioned: 是否被提及 + + 功能: + 1. 将消息添加到兴趣字典 + 2. 更新最后交互时间 + 3. 如果字典长度超过10,删除最旧的消息 + """ + # 添加新消息 + self.interest_dict[message.message_info.message_id] = (message, interest_value, is_mentioned) + + # 如果字典长度超过10,删除最旧的消息 + if len(self.interest_dict) > 10: + oldest_key = next(iter(self.interest_dict)) + self.interest_dict.pop(oldest_key) + + async def _calculate_decay(self): + """计算兴趣值的衰减 + + 参数: + current_time: 当前时间戳 + + 处理逻辑: + 1. 计算时间差 + 2. 处理各种异常情况(负值/零值) + 3. 正常计算衰减 + 4. 更新最后更新时间 + """ + + # 处理极小兴趣值情况 + if self.interest_level < 1e-9: + self.interest_level = 0.0 + return + + # 异常情况处理 + if self.decay_rate_per_second <= 0: + logger.warning(f"衰减率({self.decay_rate_per_second})无效,重置兴趣值为0") + self.interest_level = 0.0 + return + + # 正常衰减计算 + try: + decay_factor = math.pow(self.decay_rate_per_second, self.update_interval) + self.interest_level *= decay_factor + except ValueError as e: + logger.error( + f"衰减计算错误: {e} 参数: 衰减率={self.decay_rate_per_second} 时间差={self.update_interval} 当前兴趣={self.interest_level}" + ) + self.interest_level = 0.0 + + async def _update_reply_probability(self): + self.above_threshold = self.interest_level >= self.trigger_threshold + if self.above_threshold: + self.start_hfc_probability += PROBABILITY_INCREASE_RATE_PER_SECOND + else: + if self.start_hfc_probability > 0: + self.start_hfc_probability = max(0, self.start_hfc_probability - PROBABILITY_DECREASE_RATE_PER_SECOND) + + async def increase_interest(self, value: float): + self.interest_level += value + self.interest_level = min(self.interest_level, self.max_interest) + + async def decrease_interest(self, value: float): + self.interest_level -= value + self.interest_level = max(self.interest_level, 0.0) + + async def get_interest(self) -> float: + return self.interest_level + + async def get_state(self) -> dict: + interest = self.interest_level # 直接使用属性值 + return { + "interest_level": round(interest, 2), + "start_hfc_probability": round(self.start_hfc_probability, 4), + "above_threshold": self.above_threshold, + } + + # --- 新增后台更新任务相关方法 --- + async def _run_update_loop(self, update_interval: float = 1.0): + """后台循环,定期更新兴趣和回复概率。""" + try: + while not self._stop_event.is_set(): + try: + if self.interest_level != 0: + await self._calculate_decay() + + await self._update_reply_probability() + + # 等待下一个周期或停止事件 + await asyncio.wait_for(self._stop_event.wait(), timeout=update_interval) + except asyncio.TimeoutError: + # 正常超时,继续循环 + continue + except Exception as e: + logger.error(f"InterestChatting 更新循环出错: {e}") + logger.error(traceback.format_exc()) + # 防止错误导致CPU飙升,稍作等待 + await asyncio.sleep(5) + except asyncio.CancelledError: + logger.info("InterestChatting 更新循环被取消。") + finally: + self._is_running = False + logger.info("InterestChatting 更新循环已停止。") + + async def stop_updates(self): + """停止后台更新任务,使用锁确保并发安全""" + async with self._task_lock: + if not self._is_running: + logger.debug("后台兴趣更新任务未运行。") + return + + logger.info("正在停止 InterestChatting 后台更新任务...") + self._stop_event.set() + + if self.update_task and not self.update_task.done(): + try: + # 等待任务结束,设置超时 + await asyncio.wait_for(self.update_task, timeout=5.0) + logger.info("InterestChatting 后台更新任务已成功停止。") + except asyncio.TimeoutError: + logger.warning("停止 InterestChatting 后台任务超时,尝试取消...") + self.update_task.cancel() + try: + await self.update_task # 等待取消完成 + except asyncio.CancelledError: + logger.info("InterestChatting 后台更新任务已被取消。") + except Exception as e: + logger.error(f"停止 InterestChatting 后台任务时发生异常: {e}") + finally: + self.update_task = None + self._is_running = False diff --git a/src/experimental/Legacy_HFC/heart_flow/interest_logger.py b/src/experimental/Legacy_HFC/heart_flow/interest_logger.py new file mode 100644 index 00000000..2bb248ee --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/interest_logger.py @@ -0,0 +1,212 @@ +import asyncio +import time +import json +import os +import traceback +from typing import TYPE_CHECKING, Dict, List + +from src.common.logger_manager import get_logger + +# Need chat_manager to get stream names +from src.chat.message_receive.chat_stream import chat_manager + +if TYPE_CHECKING: + from .subheartflow_manager import SubHeartflowManager + from .sub_heartflow import SubHeartflow + from .heartflow import Heartflow # 导入 Heartflow 类型 + + +logger = get_logger("interest") + +# Consider moving log directory/filename constants here +LOG_DIRECTORY = "logs/interest" +HISTORY_LOG_FILENAME = "interest_history.log" + + +def _ensure_log_directory(): + """确保日志目录存在。""" + os.makedirs(LOG_DIRECTORY, exist_ok=True) + logger.info(f"已确保日志目录 '{LOG_DIRECTORY}' 存在") + + +def _clear_and_create_log_file(): + """清除日志文件并创建新的日志文件。""" + if os.path.exists(os.path.join(LOG_DIRECTORY, HISTORY_LOG_FILENAME)): + os.remove(os.path.join(LOG_DIRECTORY, HISTORY_LOG_FILENAME)) + with open(os.path.join(LOG_DIRECTORY, HISTORY_LOG_FILENAME), "w", encoding="utf-8") as f: + f.write("") + + +class InterestLogger: + """负责定期记录主心流和所有子心流的状态到日志文件。""" + + def __init__(self, subheartflow_manager: "SubHeartflowManager", heartflow: "Heartflow"): + """ + 初始化 InterestLogger。 + + Args: + subheartflow_manager: 子心流管理器实例。 + heartflow: 主心流实例,用于获取主心流状态。 + """ + self.subheartflow_manager = subheartflow_manager + self.heartflow = heartflow # 存储 Heartflow 实例 + self._history_log_file_path = os.path.join(LOG_DIRECTORY, HISTORY_LOG_FILENAME) + _ensure_log_directory() + _clear_and_create_log_file() + + async def get_all_subflow_states(self) -> Dict[str, Dict]: + """并发获取所有活跃子心流的当前完整状态。""" + all_flows: List["SubHeartflow"] = self.subheartflow_manager.get_all_subheartflows() + tasks = [] + results = {} + + if not all_flows: + # logger.debug("未找到任何子心流状态") + return results + + for subheartflow in all_flows: + if await self.subheartflow_manager.get_or_create_subheartflow(subheartflow.subheartflow_id): + tasks.append( + asyncio.create_task(subheartflow.get_full_state(), name=f"get_state_{subheartflow.subheartflow_id}") + ) + else: + logger.warning(f"子心流 {subheartflow.subheartflow_id} 在创建任务前已消失") + + if tasks: + done, pending = await asyncio.wait(tasks, timeout=5.0) + + if pending: + logger.warning(f"获取子心流状态超时,有 {len(pending)} 个任务未完成") + for task in pending: + task.cancel() + + for task in done: + stream_id_str = task.get_name().split("get_state_")[-1] + stream_id = stream_id_str + + if task.cancelled(): + logger.warning(f"获取子心流 {stream_id} 状态的任务已取消(超时)", exc_info=False) + elif task.exception(): + exc = task.exception() + logger.warning(f"获取子心流 {stream_id} 状态出错: {exc}") + else: + result = task.result() + results[stream_id] = result + + logger.trace(f"成功获取 {len(results)} 个子心流的完整状态") + return results + + async def log_all_states(self): + """获取主心流状态和所有子心流的完整状态并写入日志文件。""" + try: + current_timestamp = time.time() + + # main_mind = self.heartflow.current_mind + # 获取 Mai 状态名称 + mai_state_name = self.heartflow.current_state.get_current_state().name + + all_subflow_states = await self.get_all_subflow_states() + + log_entry_base = { + "timestamp": round(current_timestamp, 2), + # "main_mind": main_mind, + "mai_state": mai_state_name, + "subflow_count": len(all_subflow_states), + "subflows": [], + } + + if not all_subflow_states: + # logger.debug("没有获取到任何子心流状态,仅记录主心流状态") + with open(self._history_log_file_path, "a", encoding="utf-8") as f: + f.write(json.dumps(log_entry_base, ensure_ascii=False) + "\n") + return + + subflow_details = [] + items_snapshot = list(all_subflow_states.items()) + for stream_id, state in items_snapshot: + group_name = stream_id + try: + chat_stream = chat_manager.get_stream(stream_id) + if chat_stream: + if chat_stream.group_info: + group_name = chat_stream.group_info.group_name + elif chat_stream.user_info: + group_name = f"私聊_{chat_stream.user_info.user_nickname}" + except Exception as e: + logger.trace(f"无法获取 stream_id {stream_id} 的群组名: {e}") + + interest_state = state.get("interest_state", {}) + + subflow_entry = { + "stream_id": stream_id, + "group_name": group_name, + "sub_mind": state.get("current_mind", "未知"), + "sub_chat_state": state.get("chat_state", "未知"), + "interest_level": interest_state.get("interest_level", 0.0), + "start_hfc_probability": interest_state.get("start_hfc_probability", 0.0), + # "is_above_threshold": interest_state.get("is_above_threshold", False), + } + subflow_details.append(subflow_entry) + + log_entry_base["subflows"] = subflow_details + + with open(self._history_log_file_path, "a", encoding="utf-8") as f: + f.write(json.dumps(log_entry_base, ensure_ascii=False) + "\n") + + except IOError as e: + logger.error(f"写入状态日志到 {self._history_log_file_path} 出错: {e}") + except Exception as e: + logger.error(f"记录状态时发生意外错误: {e}") + logger.error(traceback.format_exc()) + + async def api_get_all_states(self): + """获取主心流和所有子心流的状态。""" + try: + current_timestamp = time.time() + + # main_mind = self.heartflow.current_mind + # 获取 Mai 状态名称 + mai_state_name = self.heartflow.current_state.get_current_state().name + + all_subflow_states = await self.get_all_subflow_states() + + log_entry_base = { + "timestamp": round(current_timestamp, 2), + # "main_mind": main_mind, + "mai_state": mai_state_name, + "subflow_count": len(all_subflow_states), + "subflows": [], + } + + subflow_details = [] + items_snapshot = list(all_subflow_states.items()) + for stream_id, state in items_snapshot: + group_name = stream_id + try: + chat_stream = chat_manager.get_stream(stream_id) + if chat_stream: + if chat_stream.group_info: + group_name = chat_stream.group_info.group_name + elif chat_stream.user_info: + group_name = f"私聊_{chat_stream.user_info.user_nickname}" + except Exception as e: + logger.trace(f"无法获取 stream_id {stream_id} 的群组名: {e}") + + interest_state = state.get("interest_state", {}) + + subflow_entry = { + "stream_id": stream_id, + "group_name": group_name, + "sub_mind": state.get("current_mind", "未知"), + "sub_chat_state": state.get("chat_state", "未知"), + "interest_level": interest_state.get("interest_level", 0.0), + "start_hfc_probability": interest_state.get("start_hfc_probability", 0.0), + # "is_above_threshold": interest_state.get("is_above_threshold", False), + } + subflow_details.append(subflow_entry) + + log_entry_base["subflows"] = subflow_details + return subflow_details + except Exception as e: + logger.error(f"记录状态时发生意外错误: {e}") + logger.error(traceback.format_exc()) diff --git a/src/experimental/Legacy_HFC/heart_flow/mai_state_manager.py b/src/experimental/Legacy_HFC/heart_flow/mai_state_manager.py new file mode 100644 index 00000000..3c6c19d6 --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/mai_state_manager.py @@ -0,0 +1,245 @@ +import enum +import time +import random +from typing import List, Tuple, Optional +from src.common.logger_manager import get_logger +from src.manager.mood_manager import mood_manager +from src.config.config import global_config + +logger = get_logger("mai_state") + + +# -- 状态相关的可配置参数 (可以从 glocal_config 加载) -- +# The line `enable_unlimited_hfc_chat = False` is setting a configuration parameter that controls +# whether a specific debugging feature is enabled or not. When `enable_unlimited_hfc_chat` is set to +# `False`, it means that the debugging feature for unlimited focused chatting is disabled. +# enable_unlimited_hfc_chat = True # 调试用:无限专注聊天 +enable_unlimited_hfc_chat = False +prevent_offline_state = True +# 目前默认不启用OFFLINE状态 + +# 不同状态下普通聊天的最大消息数 +base_normal_chat_num = global_config.base_normal_chat_num +base_focused_chat_num = global_config.base_focused_chat_num + + +MAX_NORMAL_CHAT_NUM_PEEKING = int(base_normal_chat_num / 2) +MAX_NORMAL_CHAT_NUM_NORMAL = base_normal_chat_num +MAX_NORMAL_CHAT_NUM_FOCUSED = base_normal_chat_num + 1 + +# 不同状态下专注聊天的最大消息数 +MAX_FOCUSED_CHAT_NUM_PEEKING = int(base_focused_chat_num / 2) +MAX_FOCUSED_CHAT_NUM_NORMAL = base_focused_chat_num +MAX_FOCUSED_CHAT_NUM_FOCUSED = base_focused_chat_num + 2 + +# -- 状态定义 -- + + +class MaiState(enum.Enum): + """ + 聊天状态: + OFFLINE: 不在线:回复概率极低,不会进行任何聊天 + PEEKING: 看一眼手机:回复概率较低,会进行一些普通聊天 + NORMAL_CHAT: 正常看手机:回复概率较高,会进行一些普通聊天和少量的专注聊天 + FOCUSED_CHAT: 专注聊天:回复概率极高,会进行专注聊天和少量的普通聊天 + """ + + OFFLINE = "不在线" + PEEKING = "看一眼手机" + NORMAL_CHAT = "正常看手机" + FOCUSED_CHAT = "专心看手机" + + def get_normal_chat_max_num(self): + # 调试用 + if enable_unlimited_hfc_chat: + return 1000 + + if self == MaiState.OFFLINE: + return 0 + elif self == MaiState.PEEKING: + return MAX_NORMAL_CHAT_NUM_PEEKING + elif self == MaiState.NORMAL_CHAT: + return MAX_NORMAL_CHAT_NUM_NORMAL + elif self == MaiState.FOCUSED_CHAT: + return MAX_NORMAL_CHAT_NUM_FOCUSED + return None + + def get_focused_chat_max_num(self): + # 调试用 + if enable_unlimited_hfc_chat: + return 1000 + + if self == MaiState.OFFLINE: + return 0 + elif self == MaiState.PEEKING: + return MAX_FOCUSED_CHAT_NUM_PEEKING + elif self == MaiState.NORMAL_CHAT: + return MAX_FOCUSED_CHAT_NUM_NORMAL + elif self == MaiState.FOCUSED_CHAT: + return MAX_FOCUSED_CHAT_NUM_FOCUSED + return None + + +class MaiStateInfo: + def __init__(self): + self.mai_status: MaiState = MaiState.OFFLINE + self.mai_status_history: List[Tuple[MaiState, float]] = [] # 历史状态,包含 状态,时间戳 + self.last_status_change_time: float = time.time() # 状态最后改变时间 + self.last_min_check_time: float = time.time() # 上次1分钟规则检查时间 + + # Mood management is now part of MaiStateInfo + self.mood_manager = mood_manager # Use singleton instance + + def update_mai_status(self, new_status: MaiState) -> bool: + """ + 更新聊天状态。 + + Args: + new_status: 新的 MaiState 状态。 + + Returns: + bool: 如果状态实际发生了改变则返回 True,否则返回 False。 + """ + if new_status != self.mai_status: + self.mai_status = new_status + current_time = time.time() + self.last_status_change_time = current_time + self.last_min_check_time = current_time # Reset 1-min check on any state change + self.mai_status_history.append((new_status, current_time)) + logger.info(f"麦麦状态更新为: {self.mai_status.value}") + return True + else: + return False + + def reset_state_timer(self): + """ + 重置状态持续时间计时器和一分钟规则检查计时器。 + 通常在状态保持不变但需要重新开始计时的情况下调用(例如,保持 OFFLINE)。 + """ + current_time = time.time() + self.last_status_change_time = current_time + self.last_min_check_time = current_time # Also reset the 1-min check timer + logger.debug("MaiStateInfo 状态计时器已重置。") + + def get_mood_prompt(self) -> str: + """获取当前的心情提示词""" + # Delegate to the internal mood manager + return self.mood_manager.get_mood_prompt() + + def get_current_state(self) -> MaiState: + """获取当前的 MaiState""" + return self.mai_status + + +class MaiStateManager: + """管理 Mai 的整体状态转换逻辑""" + + def __init__(self): + pass + + @staticmethod + def check_and_decide_next_state(current_state_info: MaiStateInfo) -> Optional[MaiState]: + """ + 根据当前状态和规则检查是否需要转换状态,并决定下一个状态。 + + Args: + current_state_info: 当前的 MaiStateInfo 实例。 + + Returns: + Optional[MaiState]: 如果需要转换,返回目标 MaiState;否则返回 None。 + """ + current_time = time.time() + current_status = current_state_info.mai_status + time_in_current_status = current_time - current_state_info.last_status_change_time + time_since_last_min_check = current_time - current_state_info.last_min_check_time + next_state: Optional[MaiState] = None + + # 辅助函数:根据 prevent_offline_state 标志调整目标状态 + def _resolve_offline(candidate_state: MaiState) -> MaiState: + if prevent_offline_state and candidate_state == MaiState.OFFLINE: + logger.debug("阻止进入 OFFLINE,改为 PEEKING") + return MaiState.PEEKING + return candidate_state + + if current_status == MaiState.OFFLINE: + logger.info("当前[离线],没看手机,思考要不要上线看看......") + elif current_status == MaiState.PEEKING: + logger.info("当前[看一眼手机],思考要不要继续聊下去......") + elif current_status == MaiState.NORMAL_CHAT: + logger.info("当前在[正常看手机]思考要不要继续聊下去......") + elif current_status == MaiState.FOCUSED_CHAT: + logger.info("当前在[专心看手机]思考要不要继续聊下去......") + + # 1. 麦麦每分钟都有概率离线 + if time_since_last_min_check >= 60: + if current_status != MaiState.OFFLINE: + if random.random() < 0.03: # 3% 概率切换到 OFFLINE + potential_next = MaiState.OFFLINE + resolved_next = _resolve_offline(potential_next) + logger.debug(f"概率触发下线,resolve 为 {resolved_next.value}") + # 只有当解析后的状态与当前状态不同时才设置 next_state + if resolved_next != current_status: + next_state = resolved_next + + # 2. 状态持续时间规则 (只有在规则1没有触发状态改变时才检查) + if next_state is None: + time_limit_exceeded = False + choices_list = [] + weights = [] + rule_id = "" + + if current_status == MaiState.OFFLINE: + # 注意:即使 prevent_offline_state=True,也可能从初始的 OFFLINE 状态启动 + if time_in_current_status >= 60: + time_limit_exceeded = True + rule_id = "2.1 (From OFFLINE)" + weights = [30, 30, 20, 20] + choices_list = [MaiState.PEEKING, MaiState.NORMAL_CHAT, MaiState.FOCUSED_CHAT, MaiState.OFFLINE] + elif current_status == MaiState.PEEKING: + if time_in_current_status >= 600: # PEEKING 最多持续 600 秒 + time_limit_exceeded = True + rule_id = "2.2 (From PEEKING)" + weights = [70, 20, 10] + choices_list = [MaiState.OFFLINE, MaiState.NORMAL_CHAT, MaiState.FOCUSED_CHAT] + elif current_status == MaiState.NORMAL_CHAT: + if time_in_current_status >= 300: # NORMAL_CHAT 最多持续 300 秒 + time_limit_exceeded = True + rule_id = "2.3 (From NORMAL_CHAT)" + weights = [50, 50] + choices_list = [MaiState.OFFLINE, MaiState.FOCUSED_CHAT] + elif current_status == MaiState.FOCUSED_CHAT: + if time_in_current_status >= 600: # FOCUSED_CHAT 最多持续 600 秒 + time_limit_exceeded = True + rule_id = "2.4 (From FOCUSED_CHAT)" + weights = [80, 20] + choices_list = [MaiState.OFFLINE, MaiState.NORMAL_CHAT] + + if time_limit_exceeded: + next_state_candidate = random.choices(choices_list, weights=weights, k=1)[0] + resolved_candidate = _resolve_offline(next_state_candidate) + logger.debug( + f"规则{rule_id}:时间到,随机选择 {next_state_candidate.value},resolve 为 {resolved_candidate.value}" + ) + next_state = resolved_candidate # 直接使用解析后的状态 + + # 注意:enable_unlimited_hfc_chat 优先级高于 prevent_offline_state + # 如果触发了这个,它会覆盖上面规则2设置的 next_state + if enable_unlimited_hfc_chat: + logger.debug("调试用:开挂了,强制切换到专注聊天") + next_state = MaiState.FOCUSED_CHAT + + # --- 最终决策 --- # + # 如果决定了下一个状态,且这个状态与当前状态不同,则返回下一个状态 + if next_state is not None and next_state != current_status: + return next_state + # 如果决定保持 OFFLINE (next_state == MaiState.OFFLINE) 且当前也是 OFFLINE, + # 并且是由于持续时间规则触发的,返回 OFFLINE 以便调用者可以重置计时器。 + # 注意:这个分支只有在 prevent_offline_state = False 时才可能被触发。 + elif next_state == MaiState.OFFLINE and current_status == MaiState.OFFLINE and time_in_current_status >= 60: + logger.debug("决定保持 OFFLINE (持续时间规则),返回 OFFLINE 以提示重置计时器。") + return MaiState.OFFLINE # Return OFFLINE to signal caller that timer reset might be needed + else: + # 1. next_state is None (没有触发任何转换规则) + # 2. next_state is not None 但等于 current_status (例如规则1想切OFFLINE但被resolve成PEEKING,而当前已经是PEEKING) + # 3. next_state is OFFLINE, current is OFFLINE, 但不是因为时间规则触发 (例如初始状态还没到60秒) + return None # 没有状态转换发生或无需重置计时器 diff --git a/src/experimental/Legacy_HFC/heart_flow/mind.py b/src/experimental/Legacy_HFC/heart_flow/mind.py new file mode 100644 index 00000000..806698ea --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/mind.py @@ -0,0 +1,139 @@ +import traceback +from typing import TYPE_CHECKING + +from src.common.logger_manager import get_logger +from src.chat.models.utils_model import LLMRequest +from src.individuality.individuality import Individuality +from src.chat.utils.prompt_builder import global_prompt_manager +from src.config.config import global_config + +# Need access to SubHeartflowManager to get minds and update them +if TYPE_CHECKING: + from .subheartflow_manager import SubHeartflowManager + from .mai_state_manager import MaiStateInfo + + +logger = get_logger("sub_heartflow_mind") + + +class Mind: + """封装 Mai 的思考过程,包括生成内心独白和汇总想法。""" + + def __init__(self, subheartflow_manager: "SubHeartflowManager", llm_model: LLMRequest): + self.subheartflow_manager = subheartflow_manager + self.llm_model = llm_model + self.individuality = Individuality.get_instance() + + async def do_a_thinking(self, current_main_mind: str, mai_state_info: "MaiStateInfo", schedule_info: str): + """ + 执行一次主心流思考过程,生成新的内心独白。 + + Args: + current_main_mind: 当前的主心流想法。 + mai_state_info: 当前的 Mai 状态信息 (用于获取 mood)。 + schedule_info: 当前的日程信息。 + + Returns: + str: 生成的新的内心独白,如果出错则返回提示信息。 + """ + logger.debug("Mind: 执行思考...") + + # --- 构建 Prompt --- # + personality_info = ( + self.individuality.get_prompt_snippet() + if hasattr(self.individuality, "get_prompt_snippet") + else self.individuality.personality.personality_core + ) + mood_info = mai_state_info.get_mood_prompt() + related_memory_info = "memory" # TODO: Implement memory retrieval + + # Get subflow minds summary via internal method + try: + sub_flows_info = await self._get_subflows_summary(current_main_mind, mai_state_info) + except Exception as e: + logger.error(f"[Mind Thinking] 获取子心流想法汇总失败: {e}") + logger.error(traceback.format_exc()) + sub_flows_info = "(获取子心流想法时出错)" + + # Format prompt + try: + prompt = (await global_prompt_manager.get_prompt_async("thinking_prompt")).format( + schedule_info=schedule_info, + personality_info=personality_info, + related_memory_info=related_memory_info, + current_thinking_info=current_main_mind, # Use passed current mind + sub_flows_info=sub_flows_info, + mood_info=mood_info, + ) + except Exception as e: + logger.error(f"[Mind Thinking] 格式化 thinking_prompt 失败: {e}") + return "(思考时格式化Prompt出错...)" + + # --- 调用 LLM --- # + try: + response, reasoning_content = await self.llm_model.generate_response_async(prompt) + if not response: + logger.warning("[Mind Thinking] 内心独白 LLM 返回空结果。") + response = "(暂时没什么想法...)" + logger.info(f"Mind: 新想法生成: {response[:100]}...") # Log truncated response + return response + except Exception as e: + logger.error(f"[Mind Thinking] 内心独白 LLM 调用失败: {e}") + logger.error(traceback.format_exc()) + return "(思考时调用LLM出错...)" + + async def _get_subflows_summary(self, current_main_mind: str, mai_state_info: "MaiStateInfo") -> str: + """获取所有活跃子心流的想法,并使用 LLM 进行汇总。""" + # 1. Get active minds from SubHeartflowManager + sub_minds_list = self.subheartflow_manager.get_active_subflow_minds() + + if not sub_minds_list: + return "(当前没有活跃的子心流想法)" + + minds_str = "\n".join([f"- {mind}" for mind in sub_minds_list]) + logger.debug(f"Mind: 获取到 {len(sub_minds_list)} 个子心流想法进行汇总。") + + # 2. Call LLM for summary + # --- 构建 Prompt --- # + personality_info = ( + self.individuality.get_prompt_snippet() + if hasattr(self.individuality, "get_prompt_snippet") + else self.individuality.personality.personality_core + ) + mood_info = mai_state_info.get_mood_prompt() + bot_name = global_config.BOT_NICKNAME + + try: + prompt = (await global_prompt_manager.get_prompt_async("mind_summary_prompt")).format( + personality_info=personality_info, + bot_name=bot_name, + current_mind=current_main_mind, # Use main mind passed for context + minds_str=minds_str, + mood_info=mood_info, + ) + except Exception as e: + logger.error(f"[Mind Summary] 格式化 mind_summary_prompt 失败: {e}") + return "(汇总想法时格式化Prompt出错...)" + + # --- 调用 LLM --- # + try: + response, reasoning_content = await self.llm_model.generate_response_async(prompt) + if not response: + logger.warning("[Mind Summary] 想法汇总 LLM 返回空结果。") + return "(想法汇总失败...)" + logger.debug(f"Mind: 子想法汇总完成: {response[:100]}...") + return response + except Exception as e: + logger.error(f"[Mind Summary] 想法汇总 LLM 调用失败: {e}") + logger.error(traceback.format_exc()) + return "(想法汇总时调用LLM出错...)" + + def update_subflows_with_main_mind(self, main_mind: str): + """触发 SubHeartflowManager 更新所有子心流的主心流信息。""" + logger.debug("Mind: 请求更新子心流的主想法信息。") + self.subheartflow_manager.update_main_mind_in_subflows(main_mind) + + +# Note: update_current_mind (managing self.current_mind and self.past_mind) +# remains in Heartflow for now, as Heartflow is the central coordinator holding the main state. +# Mind class focuses solely on the *process* of thinking and summarizing. diff --git a/src/experimental/Legacy_HFC/heart_flow/observation.py b/src/experimental/Legacy_HFC/heart_flow/observation.py new file mode 100644 index 00000000..72d22440 --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/observation.py @@ -0,0 +1,299 @@ +# 定义了来自外部世界的信息 +# 外部世界可以是某个聊天 不同平台的聊天 也可以是任意媒体 +from datetime import datetime +from src.chat.models.utils_model import LLMRequest +from src.config.config import global_config +from src.common.logger_manager import get_logger +import traceback +from src.chat.utils.chat_message_builder import ( + get_raw_msg_before_timestamp_with_chat, + build_readable_messages, + get_raw_msg_by_timestamp_with_chat, + num_new_messages_since, + get_person_id_list, +) +from src.chat.utils.prompt_builder import Prompt, global_prompt_manager +from typing import Optional +import difflib +from src.chat.message_receive.message import MessageRecv # 添加 MessageRecv 导入 + +# Import the new utility function +from .utils_chat import get_chat_type_and_target_info + +logger = get_logger("observation") + +# --- Define Prompt Templates for Chat Summary --- +Prompt( + """这是qq群聊的聊天记录,请总结以下聊天记录的主题: +{chat_logs} +请用一句话概括,包括人物、事件和主要信息,不要分点。""", + "chat_summary_group_prompt", # Template for group chat +) + +Prompt( + """这是你和{chat_target}的私聊记录,请总结以下聊天记录的主题: +{chat_logs} +请用一句话概括,包括事件,时间,和主要信息,不要分点。""", + "chat_summary_private_prompt", # Template for private chat +) +# --- End Prompt Template Definition --- + + +# 所有观察的基类 +class Observation: + def __init__(self, observe_type, observe_id): + self.observe_info = "" + self.observe_type = observe_type + self.observe_id = observe_id + self.last_observe_time = datetime.now().timestamp() # 初始化为当前时间 + + async def observe(self): + pass + + +# 聊天观察 +class ChattingObservation(Observation): + def __init__(self, chat_id): + super().__init__("chat", chat_id) + self.chat_id = chat_id + + # --- Initialize attributes (defaults) --- + self.is_group_chat: bool = False + self.chat_target_info: Optional[dict] = None + # --- End Initialization --- + + # --- Other attributes initialized in __init__ --- + self.talking_message = [] + self.talking_message_str = "" + self.talking_message_str_truncate = "" + self.name = global_config.BOT_NICKNAME + self.nick_name = global_config.BOT_ALIAS_NAMES + self.max_now_obs_len = global_config.observation_context_size + self.overlap_len = global_config.compressed_length + self.mid_memorys = [] + self.max_mid_memory_len = global_config.compress_length_limit + self.mid_memory_info = "" + self.person_list = [] + self.llm_summary = LLMRequest( + model=global_config.llm_observation, temperature=0.7, max_tokens=300, request_type="chat_observation" + ) + + async def initialize(self): + # --- Use utility function to determine chat type and fetch info --- + self.is_group_chat, self.chat_target_info = await get_chat_type_and_target_info(self.chat_id) + # logger.debug(f"is_group_chat: {self.is_group_chat}") + # logger.debug(f"chat_target_info: {self.chat_target_info}") + # --- End using utility function --- + + # Fetch initial messages (existing logic) + initial_messages = get_raw_msg_before_timestamp_with_chat(self.chat_id, self.last_observe_time, 10) + self.talking_message = initial_messages + self.talking_message_str = await build_readable_messages(self.talking_message) + + # 进行一次观察 返回观察结果observe_info + def get_observe_info(self, ids=None): + if ids: + mid_memory_str = "" + for id in ids: + # print(f"id:{id}") + try: + for mid_memory in self.mid_memorys: + if mid_memory["id"] == id: + mid_memory_by_id = mid_memory + msg_str = "" + for msg in mid_memory_by_id["messages"]: + msg_str += f"{msg['detailed_plain_text']}" + # time_diff = int((datetime.now().timestamp() - mid_memory_by_id["created_at"]) / 60) + # mid_memory_str += f"距离现在{time_diff}分钟前:\n{msg_str}\n" + mid_memory_str += f"{msg_str}\n" + except Exception as e: + logger.error(f"获取mid_memory_id失败: {e}") + traceback.print_exc() + return self.talking_message_str + + return mid_memory_str + "现在群里正在聊:\n" + self.talking_message_str + + else: + return self.talking_message_str + + async def observe(self): + # 自上一次观察的新消息 + new_messages_list = get_raw_msg_by_timestamp_with_chat( + chat_id=self.chat_id, + timestamp_start=self.last_observe_time, + timestamp_end=datetime.now().timestamp(), + limit=self.max_now_obs_len, + limit_mode="latest", + ) + + last_obs_time_mark = self.last_observe_time + if new_messages_list: + self.last_observe_time = new_messages_list[-1]["time"] + self.talking_message.extend(new_messages_list) + + if len(self.talking_message) > self.max_now_obs_len: + # 计算需要移除的消息数量,保留最新的 max_now_obs_len 条 + messages_to_remove_count = len(self.talking_message) - self.max_now_obs_len + oldest_messages = self.talking_message[:messages_to_remove_count] + self.talking_message = self.talking_message[messages_to_remove_count:] # 保留后半部分,即最新的 + + oldest_messages_str = await build_readable_messages( + messages=oldest_messages, timestamp_mode="normal", read_mark=0 + ) + + # --- Build prompt using template --- + prompt = None # Initialize prompt as None + try: + # 构建 Prompt - 根据 is_group_chat 选择模板 + if self.is_group_chat: + prompt_template_name = "chat_summary_group_prompt" + prompt = await global_prompt_manager.format_prompt( + prompt_template_name, chat_logs=oldest_messages_str + ) + else: + # For private chat, add chat_target to the prompt variables + prompt_template_name = "chat_summary_private_prompt" + # Determine the target name for the prompt + chat_target_name = "对方" # Default fallback + if self.chat_target_info: + # Prioritize person_name, then nickname + chat_target_name = ( + self.chat_target_info.get("person_name") + or self.chat_target_info.get("user_nickname") + or chat_target_name + ) + + # Format the private chat prompt + prompt = await global_prompt_manager.format_prompt( + prompt_template_name, + # Assuming the private prompt template uses {chat_target} + chat_target=chat_target_name, + chat_logs=oldest_messages_str, + ) + except Exception as e: + logger.error(f"构建总结 Prompt 失败 for chat {self.chat_id}: {e}") + # prompt remains None + + summary = "没有主题的闲聊" # 默认值 + + if prompt: # Check if prompt was built successfully + try: + summary_result, _, _ = await self.llm_summary.generate_response(prompt) + if summary_result: # 确保结果不为空 + summary = summary_result + except Exception as e: + logger.error(f"总结主题失败 for chat {self.chat_id}: {e}") + # 保留默认总结 "没有主题的闲聊" + else: + logger.warning(f"因 Prompt 构建失败,跳过 LLM 总结 for chat {self.chat_id}") + + mid_memory = { + "id": str(int(datetime.now().timestamp())), + "theme": summary, + "messages": oldest_messages, # 存储原始消息对象 + "readable_messages": oldest_messages_str, + # "timestamps": oldest_timestamps, + "chat_id": self.chat_id, + "created_at": datetime.now().timestamp(), + } + + self.mid_memorys.append(mid_memory) + if len(self.mid_memorys) > self.max_mid_memory_len: + self.mid_memorys.pop(0) # 移除最旧的 + + mid_memory_str = "之前聊天的内容概述是:\n" + for mid_memory_item in self.mid_memorys: # 重命名循环变量以示区分 + time_diff = int((datetime.now().timestamp() - mid_memory_item["created_at"]) / 60) + mid_memory_str += ( + f"距离现在{time_diff}分钟前(聊天记录id:{mid_memory_item['id']}):{mid_memory_item['theme']}\n" + ) + self.mid_memory_info = mid_memory_str + + self.talking_message_str = await build_readable_messages( + messages=self.talking_message, + timestamp_mode="lite", + read_mark=last_obs_time_mark, + ) + self.talking_message_str_truncate = await build_readable_messages( + messages=self.talking_message, + timestamp_mode="normal", + read_mark=last_obs_time_mark, + truncate=True, + ) + + self.person_list = await get_person_id_list(self.talking_message) + + # print(f"self.11111person_list: {self.person_list}") + + logger.trace( + f"Chat {self.chat_id} - 压缩早期记忆:{self.mid_memory_info}\n现在聊天内容:{self.talking_message_str}" + ) + + async def find_best_matching_message(self, search_str: str, min_similarity: float = 0.6) -> Optional[MessageRecv]: + """ + 在 talking_message 中查找与 search_str 最匹配的消息。 + + Args: + search_str: 要搜索的字符串。 + min_similarity: 要求的最低相似度(0到1之间)。 + + Returns: + 匹配的 MessageRecv 实例,如果找不到则返回 None。 + """ + best_match_score = -1.0 + best_match_dict = None + + if not self.talking_message: + logger.debug(f"Chat {self.chat_id}: talking_message is empty, cannot find match for '{search_str}'") + return None + + for message_dict in self.talking_message: + try: + # 临时创建 MessageRecv 以处理文本 + temp_msg = MessageRecv(message_dict) + await temp_msg.process() # 处理消息以获取 processed_plain_text + current_text = temp_msg.processed_plain_text + + if not current_text: # 跳过没有文本内容的消息 + continue + + # 计算相似度 + matcher = difflib.SequenceMatcher(None, search_str, current_text) + score = matcher.ratio() + + # logger.debug(f"Comparing '{search_str}' with '{current_text}', score: {score}") # 可选:用于调试 + + if score > best_match_score: + best_match_score = score + best_match_dict = message_dict + + except Exception as e: + logger.error(f"Error processing message for matching in chat {self.chat_id}: {e}", exc_info=True) + continue # 继续处理下一条消息 + + if best_match_dict is not None and best_match_score >= min_similarity: + logger.debug(f"Found best match for '{search_str}' with score {best_match_score:.2f}") + try: + final_msg = MessageRecv(best_match_dict) + await final_msg.process() + # 确保 MessageRecv 实例有关联的 chat_stream + if hasattr(self, "chat_stream"): + final_msg.update_chat_stream(self.chat_stream) + else: + logger.warning( + f"ChattingObservation instance for chat {self.chat_id} does not have a chat_stream attribute set." + ) + return final_msg + except Exception as e: + logger.error(f"Error creating final MessageRecv for chat {self.chat_id}: {e}", exc_info=True) + return None + else: + logger.debug( + f"No suitable match found for '{search_str}' in chat {self.chat_id} (best score: {best_match_score:.2f}, threshold: {min_similarity})" + ) + return None + + async def has_new_messages_since(self, timestamp: float) -> bool: + """检查指定时间戳之后是否有新消息""" + count = num_new_messages_since(chat_id=self.chat_id, timestamp_start=timestamp) + return count > 0 diff --git a/src/experimental/Legacy_HFC/heart_flow/sub_heartflow.py b/src/experimental/Legacy_HFC/heart_flow/sub_heartflow.py new file mode 100644 index 00000000..cb7ebcc9 --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/sub_heartflow.py @@ -0,0 +1,374 @@ +from .observation import Observation, ChattingObservation +import asyncio +import time +from typing import Optional, List, Dict, Tuple, Callable, Coroutine +import traceback +from src.common.logger_manager import get_logger +from src.chat.message_receive.message import MessageRecv +from src.chat.message_receive.chat_stream import chat_manager +from ..heartFC_chat import HeartFChatting +from ..normal_chat import NormalChat +from .mai_state_manager import MaiStateInfo +from .chat_state_info import ChatState, ChatStateInfo +from .sub_mind import SubMind +from .utils_chat import get_chat_type_and_target_info +from .interest_chatting import InterestChatting + + +logger = get_logger("sub_heartflow") + + +class SubHeartflow: + def __init__( + self, + subheartflow_id, + mai_states: MaiStateInfo, + hfc_no_reply_callback: Callable[[], Coroutine[None, None, None]], + ): + """子心流初始化函数 + + Args: + subheartflow_id: 子心流唯一标识符 + mai_states: 麦麦状态信息实例 + hfc_no_reply_callback: HFChatting 连续不回复时触发的回调 + """ + # 基础属性,两个值是一样的 + self.subheartflow_id = subheartflow_id + self.chat_id = subheartflow_id + self.hfc_no_reply_callback = hfc_no_reply_callback + + # 麦麦的状态 + self.mai_states = mai_states + + # 这个聊天流的状态 + self.chat_state: ChatStateInfo = ChatStateInfo() + self.chat_state_changed_time: float = time.time() + self.chat_state_last_time: float = 0 + self.history_chat_state: List[Tuple[ChatState, float]] = [] + + # --- Initialize attributes --- + self.is_group_chat: bool = False + self.chat_target_info: Optional[dict] = None + # --- End Initialization --- + + # 兴趣检测器 + self.interest_chatting: InterestChatting = InterestChatting() + + # 活动状态管理 + self.should_stop = False # 停止标志 + self.task: Optional[asyncio.Task] = None # 后台任务 + + # 随便水群 normal_chat 和 认真水群 heartFC_chat 实例 + # CHAT模式激活 随便水群 FOCUS模式激活 认真水群 + self.heart_fc_instance: Optional[HeartFChatting] = None # 该sub_heartflow的HeartFChatting实例 + self.normal_chat_instance: Optional[NormalChat] = None # 该sub_heartflow的NormalChat实例 + + # 观察,目前只有聊天观察,可以载入多个 + # 负责对处理过的消息进行观察 + self.observations: List[ChattingObservation] = [] # 观察列表 + # self.running_knowledges = [] # 运行中的知识,待完善 + + # LLM模型配置,负责进行思考 + self.sub_mind = SubMind( + subheartflow_id=self.subheartflow_id, chat_state=self.chat_state, observations=self.observations + ) + + # 日志前缀 - Moved determination to initialize + self.log_prefix = str(subheartflow_id) # Initial default prefix + + async def initialize(self): + """异步初始化方法,创建兴趣流并确定聊天类型""" + + # --- Use utility function to determine chat type and fetch info --- + self.is_group_chat, self.chat_target_info = await get_chat_type_and_target_info(self.chat_id) + # Update log prefix after getting info (potential stream name) + self.log_prefix = ( + chat_manager.get_stream_name(self.subheartflow_id) or self.subheartflow_id + ) # Keep this line or adjust if utils provides name + logger.debug( + f"SubHeartflow {self.chat_id} initialized: is_group={self.is_group_chat}, target_info={self.chat_target_info}" + ) + # --- End using utility function --- + + # Initialize interest system (existing logic) + await self.interest_chatting.initialize() + logger.debug(f"{self.log_prefix} InterestChatting 实例已初始化。") + + def update_last_chat_state_time(self): + self.chat_state_last_time = time.time() - self.chat_state_changed_time + + async def _stop_normal_chat(self): + """ + 停止 NormalChat 实例 + 切出 CHAT 状态时使用 + """ + if self.normal_chat_instance: + logger.info(f"{self.log_prefix} 离开CHAT模式,结束 随便水群") + try: + await self.normal_chat_instance.stop_chat() # 调用 stop_chat + except Exception as e: + logger.error(f"{self.log_prefix} 停止 NormalChat 监控任务时出错: {e}") + logger.error(traceback.format_exc()) + + async def _start_normal_chat(self, rewind=False) -> bool: + """ + 启动 NormalChat 实例,并进行异步初始化。 + 进入 CHAT 状态时使用。 + 确保 HeartFChatting 已停止。 + """ + await self._stop_heart_fc_chat() # 确保 专注聊天已停止 + + log_prefix = self.log_prefix + try: + # 获取聊天流并创建 NormalChat 实例 (同步部分) + chat_stream = chat_manager.get_stream(self.chat_id) + if not chat_stream: + logger.error(f"{log_prefix} 无法获取 chat_stream,无法启动 NormalChat。") + return False + if rewind: + self.normal_chat_instance = NormalChat(chat_stream=chat_stream, interest_dict=self.get_interest_dict()) + else: + self.normal_chat_instance = NormalChat(chat_stream=chat_stream) + + # 进行异步初始化 + await self.normal_chat_instance.initialize() + + # 启动聊天任务 + logger.info(f"{log_prefix} 开始普通聊天,随便水群...") + await self.normal_chat_instance.start_chat() # start_chat now ensures init is called again if needed + return True + except Exception as e: + logger.error(f"{log_prefix} 启动 NormalChat 或其初始化时出错: {e}") + logger.error(traceback.format_exc()) + self.normal_chat_instance = None # 启动/初始化失败,清理实例 + return False + + async def _stop_heart_fc_chat(self): + """停止并清理 HeartFChatting 实例""" + if self.heart_fc_instance: + logger.debug(f"{self.log_prefix} 结束专注聊天...") + try: + await self.heart_fc_instance.shutdown() + except Exception as e: + logger.error(f"{self.log_prefix} 关闭 HeartFChatting 实例时出错: {e}") + logger.error(traceback.format_exc()) + finally: + # 无论是否成功关闭,都清理引用 + self.heart_fc_instance = None + + async def _start_heart_fc_chat(self) -> bool: + """启动 HeartFChatting 实例,确保 NormalChat 已停止""" + await self._stop_normal_chat() # 确保普通聊天监控已停止 + self.clear_interest_dict() # 清理兴趣字典,准备专注聊天 + + log_prefix = self.log_prefix + # 如果实例已存在,检查其循环任务状态 + if self.heart_fc_instance: + # 如果任务已完成或不存在,则尝试重新启动 + if self.heart_fc_instance._loop_task is None or self.heart_fc_instance._loop_task.done(): + logger.info(f"{log_prefix} HeartFChatting 实例存在但循环未运行,尝试启动...") + try: + await self.heart_fc_instance.start() # 启动循环 + logger.info(f"{log_prefix} HeartFChatting 循环已启动。") + return True + except Exception as e: + logger.error(f"{log_prefix} 尝试启动现有 HeartFChatting 循环时出错: {e}") + logger.error(traceback.format_exc()) + return False # 启动失败 + else: + # 任务正在运行 + logger.debug(f"{log_prefix} HeartFChatting 已在运行中。") + return True # 已经在运行 + + # 如果实例不存在,则创建并启动 + logger.info(f"{log_prefix} 麦麦准备开始专注聊天...") + try: + # 创建 HeartFChatting 实例,并传递 从构造函数传入的 回调函数 + self.heart_fc_instance = HeartFChatting( + chat_id=self.subheartflow_id, + sub_mind=self.sub_mind, + observations=self.observations, # 传递所有观察者 + on_consecutive_no_reply_callback=self.hfc_no_reply_callback, # <-- Use stored callback + ) + + # 初始化并启动 HeartFChatting + if await self.heart_fc_instance._initialize(): + await self.heart_fc_instance.start() + logger.debug(f"{log_prefix} 麦麦已成功进入专注聊天模式 (新实例已启动)。") + return True + else: + logger.error(f"{log_prefix} HeartFChatting 初始化失败,无法进入专注模式。") + self.heart_fc_instance = None # 初始化失败,清理实例 + return False + except Exception as e: + logger.error(f"{log_prefix} 创建或启动 HeartFChatting 实例时出错: {e}") + logger.error(traceback.format_exc()) + self.heart_fc_instance = None # 创建或初始化异常,清理实例 + return False + + async def change_chat_state(self, new_state: "ChatState"): + """更新sub_heartflow的聊天状态,并管理 HeartFChatting 和 NormalChat 实例及任务""" + current_state = self.chat_state.chat_status + + if current_state == new_state: + return + + log_prefix = self.log_prefix + state_changed = False # 标记状态是否实际发生改变 + + # --- 状态转换逻辑 --- + if new_state == ChatState.CHAT: + # 移除限额检查逻辑 + logger.debug(f"{log_prefix} 准备进入或保持 聊天 状态") + if current_state == ChatState.FOCUSED: + if await self._start_normal_chat(rewind=False): + # logger.info(f"{log_prefix} 成功进入或保持 NormalChat 状态。") + state_changed = True + else: + logger.error(f"{log_prefix} 从FOCUSED状态启动 NormalChat 失败,无法进入 CHAT 状态。") + # 考虑是否需要回滚状态或采取其他措施 + return # 启动失败,不改变状态 + else: + if await self._start_normal_chat(rewind=True): + # logger.info(f"{log_prefix} 成功进入或保持 NormalChat 状态。") + state_changed = True + else: + logger.error(f"{log_prefix} 从ABSENT状态启动 NormalChat 失败,无法进入 CHAT 状态。") + # 考虑是否需要回滚状态或采取其他措施 + return # 启动失败,不改变状态 + + elif new_state == ChatState.FOCUSED: + # 移除限额检查逻辑 + logger.debug(f"{log_prefix} 准备进入或保持 专注聊天 状态") + if await self._start_heart_fc_chat(): + logger.debug(f"{log_prefix} 成功进入或保持 HeartFChatting 状态。") + state_changed = True + else: + logger.error(f"{log_prefix} 启动 HeartFChatting 失败,无法进入 FOCUSED 状态。") + # 启动失败,状态回滚到之前的状态或ABSENT?这里保持不改变 + return # 启动失败,不改变状态 + + elif new_state == ChatState.ABSENT: + logger.info(f"{log_prefix} 进入 ABSENT 状态,停止所有聊天活动...") + self.clear_interest_dict() + + await self._stop_normal_chat() + await self._stop_heart_fc_chat() + state_changed = True # 总是可以成功转换到 ABSENT + + # --- 更新状态和最后活动时间 --- + if state_changed: + self.update_last_chat_state_time() + self.history_chat_state.append((current_state, self.chat_state_last_time)) + + logger.info( + f"{log_prefix} 麦麦的聊天状态从 {current_state.value} (持续了 {int(self.chat_state_last_time)} 秒) 变更为 {new_state.value}" + ) + + self.chat_state.chat_status = new_state + self.chat_state_last_time = 0 + self.chat_state_changed_time = time.time() + else: + # 如果因为某些原因(如启动失败)没有成功改变状态,记录一下 + logger.debug( + f"{log_prefix} 尝试将状态从 {current_state.value} 变为 {new_state.value},但未成功或未执行更改。" + ) + + async def subheartflow_start_working(self): + """启动子心流的后台任务 + + 功能说明: + - 负责子心流的主要后台循环 + - 每30秒检查一次停止标志 + """ + logger.trace(f"{self.log_prefix} 子心流开始工作...") + + while not self.should_stop: + await asyncio.sleep(30) # 30秒检查一次停止标志 + + logger.info(f"{self.log_prefix} 子心流后台任务已停止。") + + def update_current_mind(self, response): + self.sub_mind.update_current_mind(response) + + def add_observation(self, observation: Observation): + for existing_obs in self.observations: + if existing_obs.observe_id == observation.observe_id: + return + self.observations.append(observation) + + def remove_observation(self, observation: Observation): + if observation in self.observations: + self.observations.remove(observation) + + def get_all_observations(self) -> list[Observation]: + return self.observations + + def clear_observations(self): + self.observations.clear() + + def _get_primary_observation(self) -> Optional[ChattingObservation]: + if self.observations and isinstance(self.observations[0], ChattingObservation): + return self.observations[0] + logger.warning(f"SubHeartflow {self.subheartflow_id} 没有找到有效的 ChattingObservation") + return None + + async def get_interest_state(self) -> dict: + return await self.interest_chatting.get_state() + + def get_normal_chat_last_speak_time(self) -> float: + if self.normal_chat_instance: + return self.normal_chat_instance.last_speak_time + return 0 + + def get_interest_dict(self) -> Dict[str, tuple[MessageRecv, float, bool]]: + return self.interest_chatting.interest_dict + + def clear_interest_dict(self): + self.interest_chatting.interest_dict.clear() + + async def get_full_state(self) -> dict: + """获取子心流的完整状态,包括兴趣、思维和聊天状态。""" + interest_state = await self.get_interest_state() + return { + "interest_state": interest_state, + "current_mind": self.sub_mind.current_mind, + "chat_state": self.chat_state.chat_status.value, + "chat_state_changed_time": self.chat_state_changed_time, + } + + async def shutdown(self): + """安全地关闭子心流及其管理的任务""" + if self.should_stop: + logger.info(f"{self.log_prefix} 子心流已在关闭过程中。") + return + + logger.info(f"{self.log_prefix} 开始关闭子心流...") + self.should_stop = True # 标记为停止,让后台任务退出 + + # 使用新的停止方法 + await self._stop_normal_chat() + await self._stop_heart_fc_chat() + + # 停止兴趣更新任务 + if self.interest_chatting: + logger.info(f"{self.log_prefix} 停止兴趣系统后台任务...") + await self.interest_chatting.stop_updates() + + # 取消可能存在的旧后台任务 (self.task) + if self.task and not self.task.done(): + logger.debug(f"{self.log_prefix} 取消子心流主任务 (Shutdown)...") + self.task.cancel() + try: + await asyncio.wait_for(self.task, timeout=1.0) # 给点时间响应取消 + except asyncio.CancelledError: + logger.debug(f"{self.log_prefix} 子心流主任务已取消 (Shutdown)。") + except asyncio.TimeoutError: + logger.warning(f"{self.log_prefix} 等待子心流主任务取消超时 (Shutdown)。") + except Exception as e: + logger.error(f"{self.log_prefix} 等待子心流主任务取消时发生错误 (Shutdown): {e}") + + self.task = None # 清理任务引用 + self.chat_state.chat_status = ChatState.ABSENT # 状态重置为不参与 + + logger.info(f"{self.log_prefix} 子心流关闭完成。") diff --git a/src/experimental/Legacy_HFC/heart_flow/sub_mind.py b/src/experimental/Legacy_HFC/heart_flow/sub_mind.py new file mode 100644 index 00000000..38636a47 --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/sub_mind.py @@ -0,0 +1,814 @@ +from .observation import ChattingObservation +from src.chat.knowledge.knowledge_lib import qa_manager +from src.chat.models.utils_model import LLMRequest +from src.config.config import global_config +from ..schedule.schedule_generator import bot_schedule +from src.chat.utils.chat_message_builder import get_raw_msg_before_timestamp_with_chat, build_readable_messages +from src.plugins.group_nickname.nickname_manager import nickname_manager +import time +import re +import traceback +from src.common.logger_manager import get_logger +from src.individuality.individuality import Individuality +import random +from src.chat.utils.prompt_builder import Prompt, global_prompt_manager +from src.tools.tool_use import ToolUser +from src.chat.utils.json_utils import safe_json_dumps, process_llm_tool_calls +from .chat_state_info import ChatStateInfo +from src.chat.message_receive.chat_stream import chat_manager +from ..heartFC_Cycleinfo import CycleInfo +import difflib +from src.chat.person_info.relationship_manager import relationship_manager +from src.chat.memory_system.Hippocampus import HippocampusManager +import jieba + + +logger = get_logger("sub_heartflow") + + +def init_prompt(): + # --- Group Chat Prompt --- + group_prompt = """ + + 你的名字是{bot_name}。 + {prompt_personality} + + + +{nickname_info} + + + + {extra_info} + {relation_prompt} + + + + {last_loop_prompt} + {cycle_info_block} + 你现在正在做的事情是:{schedule_info} + 你现在{mood_info} + + + + 现在是{time_now}。 + 你正在上网,和qq群里的网友们聊天,以下是正在进行的聊天内容: +{chat_observe_info} + + + +请仔细阅读当前聊天内容,分析讨论话题和群成员关系,分析你刚刚发言和别人对你的发言的反应,思考你要不要回复或发言。然后思考你是否需要使用函数工具。 +请特别留意对话的节奏。如果你发送消息后没有得到回应,那么在考虑发言或追问时,请务必谨慎。优先考虑是否你的上一条信息已经结束了当前话题,或者对方暂时不方便回复。除非你有非常重要且有时效性的新事情,否则避免在对方无明显回应意愿时进行追问。 +思考并输出你真实的内心想法。 + + + + +1. 根据聊天内容生成你的想法(如果你现在很忙或没时间,可以倾向选择不回复),{hf_do_next} +2. 不要分点、不要使用表情符号 +3. 避免多余符号(冒号、引号、括号等) +4. 语言简洁自然,不要浮夸 +5. 当你发送消息后没人理你,你的内心想法应倾向于“耐心等待对方回复”或“思考是否对方正在忙”,而不是立即产生追问的想法。只有当你认为追问确实必要且不会打扰对方时,才考虑生成追问的意图。 +6. 不要把注意力放在别人发的表情包上,它们只是一种辅助表达方式 +7. 注意分辨群里谁在跟谁说话,你不一定是当前聊天的主角,消息中的“你”不一定指的是你({bot_name}),也可能是别人 +8. 思考要不要回复或发言,如果要,必须**明确写出**你准备发送的消息的具体内容是什么 +9. 默认使用中文 + + + +1. 输出想法后考虑是否需要使用工具 +2. 工具可获取信息或执行操作 +3. 如需处理消息或回复,请使用工具。 + + +""" + Prompt(group_prompt, "sub_heartflow_prompt_before") + + # --- Private Chat Prompt --- + private_prompt = """ +{extra_info} +{relation_prompt} +你的名字是{bot_name},{prompt_personality} +{last_loop_prompt} +{cycle_info_block} +现在是{time_now},你正在上网,和 {chat_target_name} 私聊,以下是你们的聊天内容: +{chat_observe_info} + +你现在正在做的事情是:{schedule_info} + +你现在{mood_info} +请仔细阅读聊天内容,想想你和 {chat_target_name} 的关系,回顾你们刚刚的交流,你刚刚发言和对方的反应,思考聊天的主题。 +请思考你要不要回复以及如何回复对方。然后思考你是否需要使用函数工具。 +思考并输出你的内心想法 +输出要求: +1. 根据聊天内容生成你的想法,{hf_do_next} +2. 不要分点、不要使用表情符号 +3. 避免多余符号(冒号、引号、括号等) +4. 语言简洁自然,不要浮夸 +5. 如果你刚发言,对方没有回复你,请谨慎回复 +6. 不要把注意力放在别人发的表情包上,它们只是一种辅助表达方式 +工具使用说明: +1. 输出想法后考虑是否需要使用工具 +2. 工具可获取信息或执行操作 +3. 如需处理消息或回复,请使用工具。""" + Prompt(private_prompt, "sub_heartflow_prompt_private_before") # New template name + + # --- Last Loop Prompt (remains the same) --- + last_loop_t = """ +刚刚你的内心想法是:{current_thinking_info} +{if_replan_prompt} +""" + Prompt(last_loop_t, "last_loop") + + +def parse_knowledge_and_get_max_relevance(knowledge_str: str) -> str | float: + """ + 解析 qa_manager.get_knowledge 返回的字符串,提取所有知识的文本和最高的相关性得分。 + 返回: (原始知识字符串, 最高相关性得分),如果无有效相关性则返回 (原始知识字符串, 0.0) + """ + if not knowledge_str: + return None, 0.0 + + max_relevance = 0.0 + # 正则表达式匹配 "该条知识对于问题的相关性:数字" + # 我们需要捕获数字部分 + relevance_scores = re.findall(r"该条知识对于问题的相关性:([0-9.]+)", knowledge_str) + + if relevance_scores: + try: + max_relevance = max(float(score) for score in relevance_scores) + except ValueError: + logger.warning(f"解析相关性得分时出错: {relevance_scores}") + return knowledge_str, 0.0 # 出错时返回0.0 + else: + # 如果没有找到 "该条知识对于问题的相关性:" 这样的模式, + # 说明可能 qa_manager 返回的格式有变,或者没有有效的知识。 + # 在这种情况下,我们无法确定相关性,保守起见返回0.0 + logger.debug(f"在知识字符串中未找到明确的相关性得分标记: '{knowledge_str[:100]}...'") + return knowledge_str, 0.0 + + return knowledge_str, max_relevance + + +def calculate_similarity(text_a: str, text_b: str) -> float: + """ + 计算两个文本字符串的相似度。 + """ + if not text_a or not text_b: + return 0.0 + matcher = difflib.SequenceMatcher(None, text_a, text_b) + return matcher.ratio() + + +def calculate_replacement_probability(similarity: float) -> float: + """ + 根据相似度计算替换的概率。 + 规则: + - 相似度 <= 0.4: 概率 = 0 + - 相似度 >= 0.9: 概率 = 1 + - 相似度 == 0.6: 概率 = 0.7 + - 0.4 < 相似度 <= 0.6: 线性插值 (0.4, 0) 到 (0.6, 0.7) + - 0.6 < 相似度 < 0.9: 线性插值 (0.6, 0.7) 到 (0.9, 1.0) + """ + if similarity <= 0.4: + return 0.0 + elif similarity >= 0.9: + return 1.0 + elif 0.4 < similarity <= 0.6: + # p = 3.5 * s - 1.4 + probability = 3.5 * similarity - 1.4 + return max(0.0, probability) + else: # 0.6 < similarity < 0.9 + # p = s + 0.1 + probability = similarity + 0.1 + return min(1.0, max(0.0, probability)) + + +class SubMind: + def __init__(self, subheartflow_id: str, chat_state: ChatStateInfo, observations: ChattingObservation): + self.last_active_time = None + self.subheartflow_id = subheartflow_id + + self.llm_model = LLMRequest( + model=global_config.llm_sub_heartflow, + temperature=global_config.llm_sub_heartflow["temp"], + max_tokens=1000, + request_type="sub_heart_flow", + ) + + self.chat_state = chat_state + self.observations = observations + + self.current_mind = "" + self.past_mind = [] + self.structured_info = [] + self.structured_info_str = "" + + name = chat_manager.get_stream_name(self.subheartflow_id) + self.log_prefix = f"[{name}] " + self._update_structured_info_str() + # 阶梯式筛选 + self.knowledge_retrieval_steps = self.knowledge_retrieval_steps = [ + {"name": "latest_1_msg", "limit": 1, "relevance_threshold": 0.075}, # 新增:最新1条,极高阈值 + {"name": "latest_2_msgs", "limit": 2, "relevance_threshold": 0.065}, # 新增:最新2条,较高阈值 + {"name": "short_window_3_msgs", "limit": 3, "relevance_threshold": 0.050}, # 原有的3条,阈值可保持或微调 + {"name": "medium_window_8_msgs", "limit": 8, "relevance_threshold": 0.030}, # 原有的8条,阈值可保持或微调 + # 完整窗口的回退逻辑保持不变 + ] + + def _update_structured_info_str(self): + """根据 structured_info 更新 structured_info_str""" + if not self.structured_info: + self.structured_info_str = "" + return + + lines = ["【信息】"] + for item in self.structured_info: + # 简化展示,突出内容和类型,包含TTL供调试 + type_str = item.get("type", "未知类型") + content_str = item.get("content", "") + + if type_str == "info": + lines.append(f"刚刚: {content_str}") + elif type_str == "memory": + lines.append(f"{content_str}") + elif type_str == "comparison_result": + lines.append(f"数字大小比较结果: {content_str}") + elif type_str == "time_info": + lines.append(f"{content_str}") + elif type_str == "lpmm_knowledge": + lines.append(f"你知道:{content_str}") + else: + lines.append(f"{type_str}的信息: {content_str}") + + self.structured_info_str = "\n".join(lines) + logger.debug(f"{self.log_prefix} 更新 structured_info_str: \n{self.structured_info_str}") + + async def do_thinking_before_reply(self, history_cycle: list[CycleInfo] = None): + """ + 在回复前进行思考,生成内心想法并收集工具调用结果 + + 返回: + tuple: (current_mind, past_mind) 当前想法和过去的想法列表 + """ + # 更新活跃时间 + self.last_active_time = time.time() + + # ---------- 0. 更新和清理 structured_info ---------- + if self.structured_info: + logger.debug( + f"{self.log_prefix} 清理前 structured_info 中包含的lpmm_knowledge数量: " + f"{len([item for item in self.structured_info if item.get('type') == 'lpmm_knowledge'])}" + ) + # 筛选出所有不是 lpmm_knowledge 类型的条目,或者其他需要保留的条目 + info_to_keep = [item for item in self.structured_info if item.get("type") != "lpmm_knowledge"] + + # 针对我们仅希望 lpmm_knowledge "用完即弃" 的情况: + processed_info_to_keep = [] + for item in info_to_keep: # info_to_keep 已经不包含 lpmm_knowledge + item["ttl"] -= 1 + if item["ttl"] > 0: + processed_info_to_keep.append(item) + else: + logger.debug(f"{self.log_prefix} 移除过期的非lpmm_knowledge项: {item.get('id', '未知ID')}") + + self.structured_info = processed_info_to_keep + logger.debug( + f"{self.log_prefix} 清理后 structured_info (仅保留非lpmm_knowledge且TTL有效项): " + f"{safe_json_dumps(self.structured_info, ensure_ascii=False)}" + ) + + # ---------- 1. 准备基础数据 ---------- + # 获取现有想法和情绪状态 + previous_mind = self.current_mind if self.current_mind else "" + mood_info = self.chat_state.mood + + # 获取观察对象 + observation: ChattingObservation = self.observations[0] if self.observations else None + if not observation or not hasattr(observation, "is_group_chat"): # Ensure it's ChattingObservation or similar + logger.error(f"{self.log_prefix} 无法获取有效的观察对象或缺少聊天类型信息") + self.update_current_mind("(观察出错了...)") + return self.current_mind, self.past_mind + + is_group_chat = observation.is_group_chat + # logger.debug(f"is_group_chat: {is_group_chat}") + + chat_target_info = observation.chat_target_info + chat_target_name = "对方" # Default for private + if not is_group_chat and chat_target_info: + chat_target_name = ( + chat_target_info.get("person_name") or chat_target_info.get("user_nickname") or chat_target_name + ) + # --- End getting observation info --- + + # 获取观察内容 + chat_observe_info = observation.get_observe_info() + person_list = observation.person_list + + try: + # 获取当前正在做的一件事情,不包含时间信息,以保持简洁 + # 你可以根据需要调整 num 和 time_info 参数 + current_schedule_info = bot_schedule.get_current_num_task(num=1, time_info=False) + if not current_schedule_info: # 如果日程为空,给一个默认提示 + current_schedule_info = "当前没有什么特别的安排。" + except Exception as e: + logger.error(f"{self.log_prefix} 获取日程信息时出错: {e}") + current_schedule_info = "摸鱼发呆。" + + # ---------- 2. 获取记忆 ---------- + try: + # 从聊天内容中提取关键词 + chat_words = set(jieba.cut(chat_observe_info)) + # 过滤掉停用词和单字词 + keywords = [word for word in chat_words if len(word) > 1] + # 去重并限制数量 + keywords = list(set(keywords))[:5] + + logger.debug(f"{self.log_prefix} 提取的关键词: {keywords}") + # 检查已有记忆,过滤掉已存在的主题 + existing_topics = set() + for item in self.structured_info: + if item["type"] == "memory": + existing_topics.add(item["id"]) + + # 过滤掉已存在的主题 + filtered_keywords = [k for k in keywords if k not in existing_topics] + + if not filtered_keywords: + logger.debug(f"{self.log_prefix} 所有关键词对应的记忆都已存在,跳过记忆提取") + else: + # 调用记忆系统获取相关记忆 + related_memory = await HippocampusManager.get_instance().get_memory_from_topic( + valid_keywords=filtered_keywords, max_memory_num=3, max_memory_length=2, max_depth=3 + ) + + logger.debug(f"{self.log_prefix} 获取到的记忆: {related_memory}") + + if related_memory: + for topic, memory in related_memory: + new_item = {"type": "memory", "id": topic, "content": memory, "ttl": 3} + self.structured_info.append(new_item) + logger.debug(f"{self.log_prefix} 添加新记忆: {topic} - {memory}") + else: + logger.debug(f"{self.log_prefix} 没有找到相关记忆") + + except Exception as e: + logger.error(f"{self.log_prefix} 获取记忆时出错: {e}") + logger.error(traceback.format_exc()) + + # ---------- 2.5 阶梯式获取知识库信息 ---------- + final_knowledge_to_add = None + retrieval_source_info = "未进行知识检索" + + # 确保 observation 对象存在且可用 + if not observation: + logger.warning(f"{self.log_prefix} Observation 对象不可用,跳过知识库检索。") + else: + # 阶段1和阶段2的阶梯检索 + for step_config in self.knowledge_retrieval_steps: + step_name = step_config["name"] + limit = step_config["limit"] + threshold = step_config["relevance_threshold"] + + logger.info(f"{self.log_prefix} 尝试阶梯检索 - 阶段: {step_name} (最近{limit}条, 阈值>{threshold})") + + try: + # 1. 获取当前阶段的聊天记录上下文 + # 我们需要从 observation 中获取原始消息列表来构建特定长度的上下文 + # get_raw_msg_before_timestamp_with_chat 在 observation.py 中被导入 + # from src.plugins.utils.chat_message_builder import get_raw_msg_before_timestamp_with_chat, build_readable_messages + + # 需要确保 ChattingObservation 的实例 (self.observations[0]) 能提供 chat_id + # 并且 build_readable_messages 可用 + context_messages_dicts = get_raw_msg_before_timestamp_with_chat( + chat_id=observation.chat_id, timestamp=time.time(), limit=limit + ) + + if not context_messages_dicts: + logger.debug(f"{self.log_prefix} 阶段 '{step_name}' 未获取到聊天记录,跳过此阶段。") + continue + + current_context_text = await build_readable_messages( + messages=context_messages_dicts, + timestamp_mode="lite", # 或者您认为适合知识检索的模式 + ) + + if not current_context_text: + logger.debug(f"{self.log_prefix} 阶段 '{step_name}' 构建的上下文为空,跳过此阶段。") + continue + + logger.debug(f"{self.log_prefix} 阶段 '{step_name}' 使用上下文: '{current_context_text[:150]}...'") + + # 2. 调用知识库进行检索 + raw_knowledge_str = qa_manager.get_knowledge(current_context_text) + + if raw_knowledge_str: + # 3. 解析知识并检查相关性 + knowledge_content, max_relevance = parse_knowledge_and_get_max_relevance(raw_knowledge_str) + logger.info(f"{self.log_prefix} 阶段 '{step_name}' 检索到知识,最高相关性: {max_relevance:.4f}") + + if max_relevance >= threshold: + logger.info( + f"{self.log_prefix} 阶段 '{step_name}' 满足阈值 ({max_relevance:.4f} >= {threshold}),采纳此知识。" + ) + final_knowledge_to_add = knowledge_content + retrieval_source_info = f"阶段 '{step_name}' (最近{limit}条, 相关性 {max_relevance:.4f})" + break # 找到符合条件的知识,跳出阶梯循环 + else: + logger.info( + f"{self.log_prefix} 阶段 '{step_name}' 未满足阈值 ({max_relevance:.4f} < {threshold}),继续下一阶段。" + ) + else: + logger.debug(f"{self.log_prefix} 阶段 '{step_name}' 未从知识库检索到任何内容。") + + except Exception as e_step: + logger.error(f"{self.log_prefix} 阶梯检索阶段 '{step_name}' 发生错误: {e_step}") + logger.error(traceback.format_exc()) + continue # 当前阶段出错,尝试下一阶段 + + # 阶段3: 如果前面的阶梯都没有成功,则使用完整的 chat_observe_info (即您配置的20条) + if not final_knowledge_to_add and chat_observe_info: # 确保 chat_observe_info 可用 + logger.info( + f"{self.log_prefix} 前序阶梯均未满足条件,尝试使用完整观察窗口 ('{observation.max_now_obs_len}'条)进行检索。" + ) + try: + raw_knowledge_str = qa_manager.get_knowledge(chat_observe_info) + if raw_knowledge_str: + # 对于完整窗口,我们可能不强制要求阈值,或者使用一个较低的阈值 + # 或者,您可以选择在这里仍然应用一个阈值,例如 self.knowledge_retrieval_steps 中最后一个的阈值,或一个特定值 + knowledge_content, max_relevance = parse_knowledge_and_get_max_relevance(raw_knowledge_str) + logger.info( + f"{self.log_prefix} 完整窗口检索到知识,(此处未设阈值,或相关性: {max_relevance:.4f})。" + ) + final_knowledge_to_add = knowledge_content # 默认采纳 + retrieval_source_info = ( + f"完整窗口 (最多{observation.max_now_obs_len}条, 相关性 {max_relevance:.4f})" + ) + else: + logger.debug(f"{self.log_prefix} 完整窗口检索也未找到知识。") + except Exception as e_full: + logger.error(f"{self.log_prefix} 完整窗口知识检索发生错误: {e_full}") + logger.error(traceback.format_exc()) + + # 将最终选定的知识(如果有)添加到 structured_info + if final_knowledge_to_add: + knowledge_item = { + "type": "lpmm_knowledge", + "id": f"lpmm_knowledge_{time.time()}", + "content": final_knowledge_to_add, + "ttl": 1, # 由于是当轮精心选择的,可以让TTL短一些,下次重新评估(或者按照您的意愿设为3) + } + # 我们在方法开头已经清理了旧的 lpmm_knowledge,这里直接添加新的 + self.structured_info.append(knowledge_item) + logger.info( + f"{self.log_prefix} 添加了来自 '{retrieval_source_info}' 的知识到 structured_info (ID: {knowledge_item['id']})" + ) + self._update_structured_info_str() # 更新字符串表示 + else: + logger.info(f"{self.log_prefix} 经过所有阶梯检索后,没有最终采纳的知识。") + + # ---------- 3. 准备工具和个性化数据 ---------- + # 初始化工具 + tool_instance = ToolUser() + tools = tool_instance._define_tools() + + # 获取个性化信息 + individuality = Individuality.get_instance() + + relation_prompt = "" + # print(f"person_list: {person_list}") + for person in person_list: + relation_prompt += await relationship_manager.build_relationship_info(person, is_id=True) + + # print(f"relat22222ion_prompt: {relation_prompt}") + + # 构建个性部分 + prompt_personality = individuality.get_prompt(x_person=2, level=3) + + # 获取当前时间 + time_now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + # ---------- 4. 构建思考指导部分 ---------- + # 创建本地随机数生成器,基于分钟数作为种子 + local_random = random.Random() + current_minute = int(time.strftime("%M")) + local_random.seed(current_minute) + + # 思考指导选项和权重 + hf_options = [ + ( + "可以参考之前的想法,在原来想法的基础上继续思考,但是也要注意话题的推进,**不要在一个话题上停留太久或揪着一个话题不放,除非你觉得真的有必要**", + 0.3, + ), + ("可以参考之前的想法,在原来的想法上**尝试新的话题**", 0.3), + ("不要太深入,注意话题的推进,**不要在一个话题上停留太久或揪着一个话题不放,除非你觉得真的有必要**", 0.2), + ( + "进行深入思考,但是注意话题的推进,**不要在一个话题上停留太久或揪着一个话题不放,除非你觉得真的有必要**", + 0.2, + ), + ("可以参考之前的想法继续思考,并结合你自身的人设,知识,信息,回忆等等", 0.08), + ] + + last_cycle = history_cycle[-1] if history_cycle else None + # 上一次决策信息 + if last_cycle is not None: + last_action = last_cycle.action_type + last_reasoning = last_cycle.reasoning + is_replan = last_cycle.replanned + if is_replan: + if_replan_prompt = f"但是你有了上述想法之后,有了新消息,你决定重新思考后,你做了:{last_action}\n因为:{last_reasoning}\n" + else: + if_replan_prompt = f"出于这个想法,你刚才做了:{last_action}\n因为:{last_reasoning}\n" + else: + last_action = "" + last_reasoning = "" + is_replan = False + if_replan_prompt = "" + if previous_mind: + last_loop_prompt = (await global_prompt_manager.get_prompt_async("last_loop")).format( + current_thinking_info=previous_mind, if_replan_prompt=if_replan_prompt + ) + else: + last_loop_prompt = "" + + # 准备循环信息块 (分析最近的活动循环) + recent_active_cycles = [] + for cycle in reversed(history_cycle): + # 只关心实际执行了动作的循环 + if cycle.action_taken: + recent_active_cycles.append(cycle) + # 最多找最近的3个活动循环 + if len(recent_active_cycles) == 3: + break + + cycle_info_block = "" + consecutive_text_replies = 0 + responses_for_prompt = [] + + # 检查这最近的活动循环中有多少是连续的文本回复 (从最近的开始看) + for cycle in recent_active_cycles: + if cycle.action_type == "text_reply": + consecutive_text_replies += 1 + # 获取回复内容,如果不存在则返回'[空回复]' + response_text = cycle.response_info.get("response_text", []) + # 使用简单的 join 来格式化回复内容列表 + formatted_response = "[空回复]" if not response_text else " ".join(response_text) + responses_for_prompt.append(formatted_response) + else: + # 一旦遇到非文本回复,连续性中断 + break + + # 根据连续文本回复的数量构建提示信息 + # 注意: responses_for_prompt 列表是从最近到最远排序的 + if consecutive_text_replies >= 3: # 如果最近的三个活动都是文本回复 + cycle_info_block = f'你已经连续回复了三条消息(最近: "{responses_for_prompt[0]}",第二近: "{responses_for_prompt[1]}",第三近: "{responses_for_prompt[2]}")。你回复的有点多了,请注意' + elif consecutive_text_replies == 2: # 如果最近的两个活动是文本回复 + cycle_info_block = f'你已经连续回复了两条消息(最近: "{responses_for_prompt[0]}",第二近: "{responses_for_prompt[1]}"),请注意' + elif consecutive_text_replies == 1: # 如果最近的一个活动是文本回复 + cycle_info_block = f'你刚刚已经回复一条消息(内容: "{responses_for_prompt[0]}")' + + # 包装提示块,增加可读性,即使没有连续回复也给个标记 + if cycle_info_block: + cycle_info_block = f"\n【近期回复历史】\n{cycle_info_block}\n" + else: + # 如果最近的活动循环不是文本回复,或者没有活动循环 + cycle_info_block = "\n【近期回复历史】\n(最近没有连续文本回复)\n" + + # 加权随机选择思考指导 + hf_do_next = local_random.choices( + [option[0] for option in hf_options], weights=[option[1] for option in hf_options], k=1 + )[0] + + # ---------- 5. 构建最终提示词 ---------- + # --- Choose template based on chat type --- + nickname_injection_str = "" # 初始化为空字符串 + + if is_group_chat: + template_name = "sub_heartflow_prompt_before" + + chat_stream = chat_manager.get_stream(self.subheartflow_id) + if not chat_stream: + logger.error(f"{self.log_prefix} 无法获取 chat_stream,无法生成绰号信息。") + nickname_injection_str = "[获取群成员绰号信息失败]" + else: + message_list_for_nicknames = get_raw_msg_before_timestamp_with_chat( + chat_id=self.subheartflow_id, + timestamp=time.time(), + limit=global_config.observation_context_size, + ) + nickname_injection_str = await nickname_manager.get_nickname_prompt_injection( + chat_stream, message_list_for_nicknames + ) + + prompt = (await global_prompt_manager.get_prompt_async(template_name)).format( + extra_info=self.structured_info_str, + prompt_personality=prompt_personality, + relation_prompt=relation_prompt, + bot_name=individuality.name, + time_now=time_now, + chat_observe_info=chat_observe_info, + mood_info=mood_info, + hf_do_next=hf_do_next, + last_loop_prompt=last_loop_prompt, + cycle_info_block=cycle_info_block, + nickname_info=nickname_injection_str, + schedule_info=current_schedule_info, + # chat_target_name is not used in group prompt + ) + else: # Private chat + template_name = "sub_heartflow_prompt_private_before" + prompt = (await global_prompt_manager.get_prompt_async(template_name)).format( + extra_info=self.structured_info_str, + prompt_personality=prompt_personality, + relation_prompt=relation_prompt, # Might need adjustment for private context + bot_name=individuality.name, + time_now=time_now, + chat_target_name=chat_target_name, # Pass target name + chat_observe_info=chat_observe_info, + mood_info=mood_info, + hf_do_next=hf_do_next, + last_loop_prompt=last_loop_prompt, + cycle_info_block=cycle_info_block, + schedule_info=current_schedule_info, + ) + # --- End choosing template --- + + # ---------- 6. 执行LLM请求并处理响应 ---------- + content = "" # 初始化内容变量 + _reasoning_content = "" # 初始化推理内容变量 + + try: + # 调用LLM生成响应 + response, _reasoning_content, tool_calls = await self.llm_model.generate_response_tool_async( + prompt=prompt, tools=tools + ) + + logger.debug(f"{self.log_prefix} 子心流输出的原始LLM响应: {response}") + + # 直接使用LLM返回的文本响应作为 content + content = response if response else "" + + if tool_calls: + # 直接将 tool_calls 传递给处理函数 + success, valid_tool_calls, error_msg = process_llm_tool_calls( + tool_calls, log_prefix=f"{self.log_prefix} " + ) + + if success and valid_tool_calls: + # 记录工具调用信息 + tool_calls_str = ", ".join( + [call.get("function", {}).get("name", "未知工具") for call in valid_tool_calls] + ) + logger.info(f"{self.log_prefix} 模型请求调用{len(valid_tool_calls)}个工具: {tool_calls_str}") + + # 收集工具执行结果 + await self._execute_tool_calls(valid_tool_calls, tool_instance) + elif not success: + logger.warning(f"{self.log_prefix} 处理工具调用时出错: {error_msg}") + else: + logger.info(f"{self.log_prefix} 心流未使用工具") + + except Exception as e: + # 处理总体异常 + logger.error(f"{self.log_prefix} 执行LLM请求或处理响应时出错: {e}") + logger.error(traceback.format_exc()) + content = "思考过程中出现错误" + + # 记录初步思考结果 + logger.debug(f"{self.log_prefix} 初步心流思考结果: {content}\nprompt: {prompt}\n") + + # 处理空响应情况 + if not content: + content = "(不知道该想些什么...)" + logger.warning(f"{self.log_prefix} LLM返回空结果,思考失败。") + + # ---------- 7. 应用概率性去重和修饰 ---------- + if global_config.allow_remove_duplicates: + new_content = content # 保存 LLM 直接输出的结果 + try: + similarity = calculate_similarity(previous_mind, new_content) + replacement_prob = calculate_replacement_probability(similarity) + logger.debug(f"{self.log_prefix} 新旧想法相似度: {similarity:.2f}, 替换概率: {replacement_prob:.2f}") + + # 定义词语列表 (移到判断之前) + yu_qi_ci_liebiao = ["嗯", "哦", "啊", "唉", "哈", "唔"] + zhuan_zhe_liebiao = ["但是", "不过", "然而", "可是", "只是"] + cheng_jie_liebiao = ["然后", "接着", "此外", "而且", "另外"] + zhuan_jie_ci_liebiao = zhuan_zhe_liebiao + cheng_jie_liebiao + + if random.random() < replacement_prob: + # 相似度非常高时,尝试去重或特殊处理 + if similarity == 1.0: + logger.debug(f"{self.log_prefix} 想法完全重复 (相似度 1.0),执行特殊处理...") + # 随机截取大约一半内容 + if len(new_content) > 1: # 避免内容过短无法截取 + split_point = max( + 1, len(new_content) // 2 + random.randint(-len(new_content) // 4, len(new_content) // 4) + ) + truncated_content = new_content[:split_point] + else: + truncated_content = new_content # 如果只有一个字符或者为空,就不截取了 + + # 添加语气词和转折/承接词 + yu_qi_ci = random.choice(yu_qi_ci_liebiao) + zhuan_jie_ci = random.choice(zhuan_jie_ci_liebiao) + content = f"{yu_qi_ci}{zhuan_jie_ci},{truncated_content}" + logger.debug(f"{self.log_prefix} 想法重复,特殊处理后: {content}") + + else: + # 相似度较高但非100%,执行标准去重逻辑 + logger.debug(f"{self.log_prefix} 执行概率性去重 (概率: {replacement_prob:.2f})...") + matcher = difflib.SequenceMatcher(None, previous_mind, new_content) + deduplicated_parts = [] + last_match_end_in_b = 0 + for _i, j, n in matcher.get_matching_blocks(): + if last_match_end_in_b < j: + deduplicated_parts.append(new_content[last_match_end_in_b:j]) + last_match_end_in_b = j + n + + deduplicated_content = "".join(deduplicated_parts).strip() + + if deduplicated_content: + # 根据概率决定是否添加词语 + prefix_str = "" + if random.random() < 0.3: # 30% 概率添加语气词 + prefix_str += random.choice(yu_qi_ci_liebiao) + if random.random() < 0.7: # 70% 概率添加转折/承接词 + prefix_str += random.choice(zhuan_jie_ci_liebiao) + + # 组合最终结果 + if prefix_str: + content = f"{prefix_str},{deduplicated_content}" # 更新 content + logger.debug(f"{self.log_prefix} 去重并添加引导词后: {content}") + else: + content = deduplicated_content # 更新 content + logger.debug(f"{self.log_prefix} 去重后 (未添加引导词): {content}") + else: + logger.warning(f"{self.log_prefix} 去重后内容为空,保留原始LLM输出: {new_content}") + content = new_content # 保留原始 content + else: + logger.debug(f"{self.log_prefix} 未执行概率性去重 (概率: {replacement_prob:.2f})") + # content 保持 new_content 不变 + + except Exception as e: + logger.error(f"{self.log_prefix} 应用概率性去重或特殊处理时出错: {e}") + logger.error(traceback.format_exc()) + # 出错时保留原始 content + content = new_content + + # ---------- 8. 更新思考状态并返回结果 ---------- + logger.info(f"{self.log_prefix} 最终心流思考结果: {content}") + # 更新当前思考内容 + self.update_current_mind(content) + + return self.current_mind, self.past_mind + + async def _execute_tool_calls(self, tool_calls, tool_instance): + """ + 执行一组工具调用并收集结果 + + 参数: + tool_calls: 工具调用列表 + tool_instance: 工具使用器实例 + """ + tool_results = [] + new_structured_items = [] # 收集新产生的结构化信息 + + # 执行所有工具调用 + for tool_call in tool_calls: + try: + result = await tool_instance._execute_tool_call(tool_call) + if result: + tool_results.append(result) + # 创建新的结构化信息项 + new_item = { + "type": result.get("type", "unknown_type"), # 使用 'type' 键 + "id": result.get("id", f"fallback_id_{time.time()}"), # 使用 'id' 键 + "content": result.get("content", ""), # 'content' 键保持不变 + "ttl": 3, + } + new_structured_items.append(new_item) + + except Exception as tool_e: + logger.error(f"[{self.subheartflow_id}] 工具执行失败: {tool_e}") + logger.error(traceback.format_exc()) # 添加 traceback 记录 + + # 如果有新的工具结果,记录并更新结构化信息 + if new_structured_items: + self.structured_info.extend(new_structured_items) # 添加到现有列表 + logger.debug(f"工具调用收集到新的结构化信息: {safe_json_dumps(new_structured_items, ensure_ascii=False)}") + # logger.debug(f"当前完整的 structured_info: {safe_json_dumps(self.structured_info, ensure_ascii=False)}") # 可以取消注释以查看完整列表 + self._update_structured_info_str() # 添加新信息后,更新字符串表示 + + def update_current_mind(self, response): + if self.current_mind: # 只有当 current_mind 非空时才添加到 past_mind + self.past_mind.append(self.current_mind) + # 可以考虑限制 past_mind 的大小,例如: + # max_past_mind_size = 10 + # if len(self.past_mind) > max_past_mind_size: + # self.past_mind.pop(0) # 移除最旧的 + + self.current_mind = response + + +init_prompt() diff --git a/src/experimental/Legacy_HFC/heart_flow/subheartflow_manager.py b/src/experimental/Legacy_HFC/heart_flow/subheartflow_manager.py new file mode 100644 index 00000000..a0a16888 --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/subheartflow_manager.py @@ -0,0 +1,849 @@ +import asyncio +import time +import random +from typing import Dict, Any, Optional, List, Tuple +import json # 导入 json 模块 +import functools # <-- 新增导入 + +# 导入日志模块 +from src.common.logger_manager import get_logger + +# 导入聊天流管理模块 +from src.chat.message_receive.chat_stream import chat_manager + +# 导入心流相关类 +from .sub_heartflow import SubHeartflow, ChatState +from .mai_state_manager import MaiStateInfo +from .observation import ChattingObservation + +# 导入LLM请求工具 +from src.chat.models.utils_model import LLMRequest +from src.config.config import global_config +from src.individuality.individuality import Individuality +import traceback + + +# 初始化日志记录器 + +logger = get_logger("subheartflow_manager") + +# 子心流管理相关常量 +INACTIVE_THRESHOLD_SECONDS = 3600 # 子心流不活跃超时时间(秒) +NORMAL_CHAT_TIMEOUT_SECONDS = 30 * 60 # 30分钟 + + +async def _try_set_subflow_absent_internal(subflow: "SubHeartflow", log_prefix: str) -> bool: + """ + 尝试将给定的子心流对象状态设置为 ABSENT (内部方法,不处理锁)。 + + Args: + subflow: 子心流对象。 + log_prefix: 用于日志记录的前缀 (例如 "[子心流管理]" 或 "[停用]")。 + + Returns: + bool: 如果状态成功变为 ABSENT 或原本就是 ABSENT,返回 True;否则返回 False。 + """ + flow_id = subflow.subheartflow_id + stream_name = chat_manager.get_stream_name(flow_id) or flow_id + + if subflow.chat_state.chat_status != ChatState.ABSENT: + logger.debug(f"{log_prefix} 设置 {stream_name} 状态为 ABSENT") + try: + await subflow.change_chat_state(ChatState.ABSENT) + # 再次检查以确认状态已更改 (change_chat_state 内部应确保) + if subflow.chat_state.chat_status == ChatState.ABSENT: + return True + else: + logger.warning( + f"{log_prefix} 调用 change_chat_state 后,{stream_name} 状态仍为 {subflow.chat_state.chat_status.value}" + ) + return False + except Exception as e: + logger.error(f"{log_prefix} 设置 {stream_name} 状态为 ABSENT 时失败: {e}", exc_info=True) + return False + else: + logger.debug(f"{log_prefix} {stream_name} 已是 ABSENT 状态") + return True # 已经是目标状态,视为成功 + + +class SubHeartflowManager: + """管理所有活跃的 SubHeartflow 实例。""" + + def __init__(self, mai_state_info: MaiStateInfo): + self.subheartflows: Dict[Any, "SubHeartflow"] = {} + self._lock = asyncio.Lock() # 用于保护 self.subheartflows 的访问 + self.mai_state_info: MaiStateInfo = mai_state_info # 存储传入的 MaiStateInfo 实例 + + # 为 LLM 状态评估创建一个 LLMRequest 实例 + # 使用与 Heartflow 相同的模型和参数 + self.llm_state_evaluator = LLMRequest( + model=global_config.llm_heartflow, # 与 Heartflow 一致 + temperature=0.6, # 与 Heartflow 一致 + max_tokens=1000, # 与 Heartflow 一致 (虽然可能不需要这么多) + request_type="subheartflow_state_eval", # 保留特定的请求类型 + ) + + async def force_change_state(self, subflow_id: Any, target_state: ChatState) -> bool: + """强制改变指定子心流的状态""" + async with self._lock: + subflow = self.subheartflows.get(subflow_id) + if not subflow: + logger.warning(f"[强制状态转换]尝试转换不存在的子心流{subflow_id} 到 {target_state.value}") + return False + await subflow.change_chat_state(target_state) + logger.info(f"[强制状态转换]子心流 {subflow_id} 已转换到 {target_state.value}") + return True + + def get_all_subheartflows(self) -> List["SubHeartflow"]: + """获取所有当前管理的 SubHeartflow 实例列表 (快照)。""" + return list(self.subheartflows.values()) + + async def get_or_create_subheartflow(self, subheartflow_id: Any) -> Optional["SubHeartflow"]: + """获取或创建指定ID的子心流实例 + + Args: + subheartflow_id: 子心流唯一标识符 + mai_states 参数已被移除,使用 self.mai_state_info + + Returns: + 成功返回SubHeartflow实例,失败返回None + """ + async with self._lock: + # 检查是否已存在该子心流 + if subheartflow_id in self.subheartflows: + subflow = self.subheartflows[subheartflow_id] + if subflow.should_stop: + logger.warning(f"尝试获取已停止的子心流 {subheartflow_id},正在重新激活") + subflow.should_stop = False # 重置停止标志 + + subflow.last_active_time = time.time() # 更新活跃时间 + # logger.debug(f"获取到已存在的子心流: {subheartflow_id}") + return subflow + + try: + # --- 使用 functools.partial 创建 HFC 回调 --- # + # 将 manager 的 _handle_hfc_no_reply 方法与当前的 subheartflow_id 绑定 + hfc_callback = functools.partial(self._handle_hfc_no_reply, subheartflow_id) + # --- 结束创建回调 --- # + + # 初始化子心流, 传入 mai_state_info 和 partial 创建的回调 + new_subflow = SubHeartflow( + subheartflow_id, + self.mai_state_info, + hfc_callback, # <-- 传递 partial 创建的回调 + ) + + # 异步初始化 + await new_subflow.initialize() + + # 添加聊天观察者 + observation = ChattingObservation(chat_id=subheartflow_id) + await observation.initialize() + + new_subflow.add_observation(observation) + + # 注册子心流 + self.subheartflows[subheartflow_id] = new_subflow + heartflow_name = chat_manager.get_stream_name(subheartflow_id) or subheartflow_id + logger.info(f"[{heartflow_name}] 开始接收消息") + + # 启动后台任务 + asyncio.create_task(new_subflow.subheartflow_start_working()) + + return new_subflow + except Exception as e: + logger.error(f"创建子心流 {subheartflow_id} 失败: {e}", exc_info=True) + return None + + # --- 新增:内部方法,用于尝试将单个子心流设置为 ABSENT --- + + # --- 结束新增 --- + + async def sleep_subheartflow(self, subheartflow_id: Any, reason: str) -> bool: + """停止指定的子心流并将其状态设置为 ABSENT""" + log_prefix = "[子心流管理]" + async with self._lock: # 加锁以安全访问字典 + subheartflow = self.subheartflows.get(subheartflow_id) + + stream_name = chat_manager.get_stream_name(subheartflow_id) or subheartflow_id + logger.info(f"{log_prefix} 正在停止 {stream_name}, 原因: {reason}") + + # 调用内部方法处理状态变更 + success = await _try_set_subflow_absent_internal(subheartflow, log_prefix) + + return success + # 锁在此处自动释放 + + def get_inactive_subheartflows(self, max_age_seconds=INACTIVE_THRESHOLD_SECONDS): + """识别并返回需要清理的不活跃(处于ABSENT状态超过一小时)子心流(id, 原因)""" + _current_time = time.time() + flows_to_stop = [] + + for subheartflow_id, subheartflow in list(self.subheartflows.items()): + state = subheartflow.chat_state.chat_status + if state != ChatState.ABSENT: + continue + subheartflow.update_last_chat_state_time() + _absent_last_time = subheartflow.chat_state_last_time + flows_to_stop.append(subheartflow_id) + + return flows_to_stop + + async def enforce_subheartflow_limits(self): + """根据主状态限制停止超额子心流(优先停不活跃的)""" + # 使用 self.mai_state_info 获取当前状态和限制 + current_mai_state = self.mai_state_info.get_current_state() + normal_limit = current_mai_state.get_normal_chat_max_num() + focused_limit = current_mai_state.get_focused_chat_max_num() + logger.debug(f"[限制] 状态:{current_mai_state.value}, 普通限:{normal_limit}, 专注限:{focused_limit}") + + # 分类统计当前子心流 + normal_flows = [] + focused_flows = [] + for flow_id, flow in list(self.subheartflows.items()): + if flow.chat_state.chat_status == ChatState.CHAT: + normal_flows.append((flow_id, getattr(flow, "last_active_time", 0))) + elif flow.chat_state.chat_status == ChatState.FOCUSED: + focused_flows.append((flow_id, getattr(flow, "last_active_time", 0))) + + logger.debug(f"[限制] 当前数量 - 普通:{len(normal_flows)}, 专注:{len(focused_flows)}") + stopped = 0 + + # 处理普通聊天超额 + if len(normal_flows) > normal_limit: + excess = len(normal_flows) - normal_limit + logger.info(f"[限制] 普通聊天超额({len(normal_flows)}>{normal_limit}), 停止{excess}个") + normal_flows.sort(key=lambda x: x[1]) + for flow_id, _ in normal_flows[:excess]: + if await self.sleep_subheartflow(flow_id, f"普通聊天超额(限{normal_limit})"): + stopped += 1 + + # 处理专注聊天超额(需重新统计) + focused_flows = [ + (fid, t) + for fid, f in list(self.subheartflows.items()) + if (t := getattr(f, "last_active_time", 0)) and f.chat_state.chat_status == ChatState.FOCUSED + ] + if len(focused_flows) > focused_limit: + excess = len(focused_flows) - focused_limit + logger.info(f"[限制] 专注聊天超额({len(focused_flows)}>{focused_limit}), 停止{excess}个") + focused_flows.sort(key=lambda x: x[1]) + for flow_id, _ in focused_flows[:excess]: + if await self.sleep_subheartflow(flow_id, f"专注聊天超额(限{focused_limit})"): + stopped += 1 + + if stopped: + logger.info(f"[限制] 已停止{stopped}个子心流, 剩余:{len(self.subheartflows)}") + else: + logger.debug(f"[限制] 无需停止, 当前总数:{len(self.subheartflows)}") + + async def deactivate_all_subflows(self): + """将所有子心流的状态更改为 ABSENT (例如主状态变为OFFLINE时调用)""" + log_prefix = "[停用]" + changed_count = 0 + processed_count = 0 + + async with self._lock: # 获取锁以安全迭代 + # 使用 list() 创建一个当前值的快照,防止在迭代时修改字典 + flows_to_update = list(self.subheartflows.values()) + processed_count = len(flows_to_update) + if not flows_to_update: + logger.debug(f"{log_prefix} 无活跃子心流,无需操作") + return + + for subflow in flows_to_update: + # 记录原始状态,以便统计实际改变的数量 + original_state_was_absent = subflow.chat_state.chat_status == ChatState.ABSENT + + success = await _try_set_subflow_absent_internal(subflow, log_prefix) + + # 如果成功设置为 ABSENT 且原始状态不是 ABSENT,则计数 + if success and not original_state_was_absent: + if subflow.chat_state.chat_status == ChatState.ABSENT: + changed_count += 1 + else: + # 这种情况理论上不应发生,如果内部方法返回 True 的话 + stream_name = chat_manager.get_stream_name(subflow.subheartflow_id) or subflow.subheartflow_id + logger.warning(f"{log_prefix} 内部方法声称成功但 {stream_name} 状态未变为 ABSENT。") + # 锁在此处自动释放 + + logger.info( + f"{log_prefix} 完成,共处理 {processed_count} 个子心流,成功将 {changed_count} 个非 ABSENT 子心流的状态更改为 ABSENT。" + ) + + async def sbhf_absent_into_focus(self): + """评估子心流兴趣度,满足条件且未达上限则提升到FOCUSED状态(基于start_hfc_probability)""" + try: + current_state = self.mai_state_info.get_current_state() + focused_limit = current_state.get_focused_chat_max_num() + + # --- 新增:检查是否允许进入 FOCUS 模式 --- # + if not global_config.allow_focus_mode: + if int(time.time()) % 60 == 0: # 每60秒输出一次日志避免刷屏 + logger.trace("未开启 FOCUSED 状态 (allow_focus_mode=False)") + return # 如果不允许,直接返回 + # --- 结束新增 --- + + logger.debug(f"当前状态 ({current_state.value}) 可以在{focused_limit}个群 专注聊天") + + if focused_limit <= 0: + # logger.debug(f"{log_prefix} 当前状态 ({current_state.value}) 不允许 FOCUSED 子心流") + return + + current_focused_count = self.count_subflows_by_state(ChatState.FOCUSED) + if current_focused_count >= focused_limit: + logger.debug(f"已达专注上限 ({current_focused_count}/{focused_limit})") + return + + for sub_hf in list(self.subheartflows.values()): + flow_id = sub_hf.subheartflow_id + stream_name = chat_manager.get_stream_name(flow_id) or flow_id + + # 跳过非CHAT状态或已经是FOCUSED状态的子心流 + if sub_hf.chat_state.chat_status == ChatState.FOCUSED: + continue + + if sub_hf.interest_chatting.start_hfc_probability == 0: + continue + else: + logger.debug( + f"{stream_name},现在状态: {sub_hf.chat_state.chat_status.value},进入专注概率: {sub_hf.interest_chatting.start_hfc_probability}" + ) + + # 调试用 + from .mai_state_manager import enable_unlimited_hfc_chat + + if not enable_unlimited_hfc_chat: + if sub_hf.chat_state.chat_status != ChatState.CHAT: + continue + + if random.random() >= sub_hf.interest_chatting.start_hfc_probability: + continue + + # 再次检查是否达到上限 + if current_focused_count >= focused_limit: + logger.debug(f"{stream_name} 已达专注上限") + break + + # 获取最新状态并执行提升 + current_subflow = self.subheartflows.get(flow_id) + if not current_subflow: + continue + + logger.info( + f"{stream_name} 触发 认真水群 (概率={current_subflow.interest_chatting.start_hfc_probability:.2f})" + ) + + # 执行状态提升 + await current_subflow.change_chat_state(ChatState.FOCUSED) + + # 验证提升结果 + if ( + final_subflow := self.subheartflows.get(flow_id) + ) and final_subflow.chat_state.chat_status == ChatState.FOCUSED: + current_focused_count += 1 + except Exception as e: + logger.error(f"启动HFC 兴趣评估失败: {e}", exc_info=True) + + async def sbhf_absent_into_chat(self): + """ + 随机选一个 ABSENT 状态的 *群聊* 子心流,评估是否应转换为 CHAT 状态。 + 每次调用最多转换一个。 + 私聊会被忽略。 + """ + current_mai_state = self.mai_state_info.get_current_state() + chat_limit = current_mai_state.get_normal_chat_max_num() + + async with self._lock: + # 1. 筛选出所有 ABSENT 状态的 *群聊* 子心流 + absent_group_subflows = [ + hf + for hf in self.subheartflows.values() + if hf.chat_state.chat_status == ChatState.ABSENT and hf.is_group_chat + ] + + if not absent_group_subflows: + # logger.debug("没有摸鱼的群聊子心流可以评估。") # 日志太频繁 + return # 没有目标,直接返回 + + # 2. 随机选一个幸运儿 + sub_hf_to_evaluate = random.choice(absent_group_subflows) + flow_id = sub_hf_to_evaluate.subheartflow_id + stream_name = chat_manager.get_stream_name(flow_id) or flow_id + log_prefix = f"[{stream_name}]" + + # 3. 检查 CHAT 上限 + current_chat_count = self.count_subflows_by_state_nolock(ChatState.CHAT) + if current_chat_count >= chat_limit: + logger.info(f"{log_prefix} 想看看能不能聊,但是聊天太多了, ({current_chat_count}/{chat_limit}) 满了。") + return # 满了,这次就算了 + + # --- 获取 FOCUSED 计数 --- + current_focused_count = self.count_subflows_by_state_nolock(ChatState.FOCUSED) + focused_limit = current_mai_state.get_focused_chat_max_num() + + # --- 新增:获取聊天和专注群名 --- + chatting_group_names = [] + focused_group_names = [] + for flow_id, hf in self.subheartflows.items(): + stream_name = chat_manager.get_stream_name(flow_id) or str(flow_id) # 保证有名字 + if hf.chat_state.chat_status == ChatState.CHAT: + chatting_group_names.append(stream_name) + elif hf.chat_state.chat_status == ChatState.FOCUSED: + focused_group_names.append(stream_name) + # --- 结束新增 --- + + # --- 获取观察信息和构建 Prompt --- + first_observation = sub_hf_to_evaluate.observations[0] # 喵~第一个观察者肯定存在的说 + await first_observation.observe() + current_chat_log = first_observation.talking_message_str or "当前没啥聊天内容。" + _observation_summary = f"在[{stream_name}]这个群中,你最近看群友聊了这些:\n{current_chat_log}" + + _mai_state_description = f"你当前状态: {current_mai_state.value}。" + individuality = Individuality.get_instance() + personality_prompt = individuality.get_prompt(x_person=2, level=3) + prompt_personality = f"你是{individuality.name},{personality_prompt}" + + # --- 修改:在 prompt 中加入当前聊天计数和群名信息 (条件显示) --- + chat_status_lines = [] + if chatting_group_names: + chat_status_lines.append( + f"正在这些群闲聊 ({current_chat_count}/{chat_limit}): {', '.join(chatting_group_names)}" + ) + if focused_group_names: + chat_status_lines.append( + f"正在这些群专注的聊天 ({current_focused_count}/{focused_limit}): {', '.join(focused_group_names)}" + ) + + chat_status_prompt = "当前没有在任何群聊中。" # 默认消息喵~ + if chat_status_lines: + chat_status_prompt = "当前聊天情况,你已经参与了下面这几个群的聊天:\n" + "\n".join( + chat_status_lines + ) # 拼接状态信息 + + prompt = ( + f"{prompt_personality}\n" + f"{chat_status_prompt}\n" # <-- 喵!用了新的状态信息~ + f"你当前尚未加入 [{stream_name}] 群聊天。\n" + f"{_observation_summary}\n---\n" + f"基于以上信息,你想不想开始在这个群闲聊?\n" + f"请说明理由,并以 JSON 格式回答,包含 'decision' (布尔值) 和 'reason' (字符串)。\n" + f'例如:{{"decision": true, "reason": "看起来挺热闹的,插个话"}}\n' + f'例如:{{"decision": false, "reason": "已经聊了好多,休息一下"}}\n' + f"请只输出有效的 JSON 对象。" + ) + # --- 结束修改 --- + + # --- 4. LLM 评估是否想聊 --- + yao_kai_shi_liao_ma, reason = await self._llm_evaluate_state_transition(prompt) + + if reason: + if yao_kai_shi_liao_ma: + logger.info(f"{log_prefix} 打算开始聊,原因是: {reason}") + else: + logger.info(f"{log_prefix} 不打算聊,原因是: {reason}") + else: + logger.info(f"{log_prefix} 结果: {yao_kai_shi_liao_ma}") + + if yao_kai_shi_liao_ma is None: + logger.debug(f"{log_prefix} 问AI想不想聊失败了,这次算了。") + return # 评估失败,结束 + + if not yao_kai_shi_liao_ma: + # logger.info(f"{log_prefix} 现在不想聊这个群。") + return # 不想聊,结束 + + # --- 5. AI想聊,再次检查额度并尝试转换 --- + # 再次检查以防万一 + current_chat_count_before_change = self.count_subflows_by_state_nolock(ChatState.CHAT) + if current_chat_count_before_change < chat_limit: + logger.info( + f"{log_prefix} 想聊,而且还有精力 ({current_chat_count_before_change}/{chat_limit}),这就去聊!" + ) + await sub_hf_to_evaluate.change_chat_state(ChatState.CHAT) + # 确认转换成功 + if sub_hf_to_evaluate.chat_state.chat_status == ChatState.CHAT: + logger.debug(f"{log_prefix} 成功进入聊天状态!本次评估圆满结束。") + else: + logger.warning( + f"{log_prefix} 奇怪,尝试进入聊天状态失败了。当前状态: {sub_hf_to_evaluate.chat_state.chat_status.value}" + ) + else: + logger.warning( + f"{log_prefix} AI说想聊,但是刚问完就没空位了 ({current_chat_count_before_change}/{chat_limit})。真不巧,下次再说吧。" + ) + # 无论转换成功与否,本次评估都结束了 + + # 锁在这里自动释放 + + # --- 新增:单独检查 CHAT 状态超时的任务 --- + async def sbhf_chat_into_absent(self): + """定期检查处于 CHAT 状态的子心流是否因长时间未发言而超时,并将其转为 ABSENT。""" + log_prefix_task = "[聊天超时检查]" + transitioned_to_absent = 0 + checked_count = 0 + + async with self._lock: + subflows_snapshot = list(self.subheartflows.values()) + checked_count = len(subflows_snapshot) + + if not subflows_snapshot: + return + + for sub_hf in subflows_snapshot: + # 只检查 CHAT 状态的子心流 + if sub_hf.chat_state.chat_status != ChatState.CHAT: + continue + + flow_id = sub_hf.subheartflow_id + stream_name = chat_manager.get_stream_name(flow_id) or flow_id + log_prefix = f"[{stream_name}]({log_prefix_task})" + + should_deactivate = False + reason = "" + + try: + last_bot_dong_zuo_time = sub_hf.get_normal_chat_last_speak_time() + + if last_bot_dong_zuo_time > 0: + current_time = time.time() + time_since_last_bb = current_time - last_bot_dong_zuo_time + minutes_since_last_bb = time_since_last_bb / 60 + + # 60分钟强制退出 + if minutes_since_last_bb >= 60: + should_deactivate = True + reason = "超过60分钟未发言,强制退出" + else: + # 根据时间区间确定退出概率 + exit_probability = 0 + if minutes_since_last_bb < 5: + exit_probability = 0.01 # 1% + elif minutes_since_last_bb < 15: + exit_probability = 0.02 # 2% + elif minutes_since_last_bb < 30: + exit_probability = 0.04 # 4% + else: + exit_probability = 0.08 # 8% + + # 随机判断是否退出 + if random.random() < exit_probability: + should_deactivate = True + reason = f"已{minutes_since_last_bb:.1f}分钟未发言,触发{exit_probability * 100:.0f}%退出概率" + + except AttributeError: + logger.error( + f"{log_prefix} 无法获取 Bot 最后 BB 时间,请确保 SubHeartflow 相关实现正确。跳过超时检查。" + ) + except Exception as e: + logger.error(f"{log_prefix} 检查 Bot 超时状态时出错: {e}", exc_info=True) + + # 执行状态转换(如果超时) + if should_deactivate: + logger.debug(f"{log_prefix} 因超时 ({reason}),尝试转换为 ABSENT 状态。") + await sub_hf.change_chat_state(ChatState.ABSENT) + # 再次检查确保状态已改变 + if sub_hf.chat_state.chat_status == ChatState.ABSENT: + transitioned_to_absent += 1 + logger.info(f"{log_prefix} 不看了。") + else: + logger.warning(f"{log_prefix} 尝试因超时转换为 ABSENT 失败。") + + if transitioned_to_absent > 0: + logger.debug( + f"{log_prefix_task} 完成,共检查 {checked_count} 个子心流,{transitioned_to_absent} 个因超时转为 ABSENT。" + ) + + # --- 结束新增 --- + + async def _llm_evaluate_state_transition(self, prompt: str) -> Tuple[Optional[bool], Optional[str]]: + """ + 使用 LLM 评估是否应进行状态转换,期望 LLM 返回 JSON 格式。 + + Args: + prompt: 提供给 LLM 的提示信息,要求返回 {"decision": true/false}。 + + Returns: + Optional[bool]: 如果成功解析 LLM 的 JSON 响应并提取了 'decision' 键的值,则返回该布尔值。 + 如果 LLM 调用失败、返回无效 JSON 或 JSON 中缺少 'decision' 键或其值不是布尔型,则返回 None。 + """ + log_prefix = "[LLM状态评估]" + try: + # --- 真实的 LLM 调用 --- + response_text, _ = await self.llm_state_evaluator.generate_response_async(prompt) + # logger.debug(f"{log_prefix} 使用模型 {self.llm_state_evaluator.model_name} 评估") + logger.debug(f"{log_prefix} 原始输入: {prompt}") + logger.debug(f"{log_prefix} 原始评估结果: {response_text}") + + # --- 解析 JSON 响应 --- + try: + # 尝试去除可能的Markdown代码块标记 + cleaned_response = response_text.strip().strip("`").strip() + if cleaned_response.startswith("json"): + cleaned_response = cleaned_response[4:].strip() + + data = json.loads(cleaned_response) + decision = data.get("decision") # 使用 .get() 避免 KeyError + reason = data.get("reason") + + if isinstance(decision, bool): + logger.debug(f"{log_prefix} LLM评估结果 (来自JSON): {'建议转换' if decision else '建议不转换'}") + + return decision, reason + else: + logger.warning( + f"{log_prefix} LLM 返回的 JSON 中 'decision' 键的值不是布尔型: {decision}。响应: {response_text}" + ) + return None, None # 值类型不正确 + + except json.JSONDecodeError as json_err: + logger.warning(f"{log_prefix} LLM 返回的响应不是有效的 JSON: {json_err}。响应: {response_text}") + # 尝试在非JSON响应中查找关键词作为后备方案 (可选) + if "true" in response_text.lower(): + logger.debug(f"{log_prefix} 在非JSON响应中找到 'true',解释为建议转换") + return True, None + if "false" in response_text.lower(): + logger.debug(f"{log_prefix} 在非JSON响应中找到 'false',解释为建议不转换") + return False, None + return None, None # JSON 解析失败,也未找到关键词 + except Exception as parse_err: # 捕获其他可能的解析错误 + logger.warning(f"{log_prefix} 解析 LLM JSON 响应时发生意外错误: {parse_err}。响应: {response_text}") + return None, None + + except Exception as e: + logger.error(f"{log_prefix} 调用 LLM 或处理其响应时出错: {e}", exc_info=True) + traceback.print_exc() + return None, None # LLM 调用或处理失败 + + def count_subflows_by_state(self, state: ChatState) -> int: + """统计指定状态的子心流数量""" + count = 0 + # 遍历所有子心流实例 + for subheartflow in self.subheartflows.values(): + # 检查子心流状态是否匹配 + if subheartflow.chat_state.chat_status == state: + count += 1 + return count + + def count_subflows_by_state_nolock(self, state: ChatState) -> int: + """ + 统计指定状态的子心流数量 (不上锁版本)。 + 警告:仅应在已持有 self._lock 的上下文中使用此方法。 + """ + count = 0 + for subheartflow in self.subheartflows.values(): + if subheartflow.chat_state.chat_status == state: + count += 1 + return count + + def get_active_subflow_minds(self) -> List[str]: + """获取所有活跃(非ABSENT)子心流的当前想法""" + minds = [] + for subheartflow in self.subheartflows.values(): + # 检查子心流是否活跃(非ABSENT状态) + if subheartflow.chat_state.chat_status != ChatState.ABSENT: + minds.append(subheartflow.sub_mind.current_mind) + return minds + + def update_main_mind_in_subflows(self, main_mind: str): + """更新所有子心流的主心流想法""" + updated_count = sum( + 1 + for _, subheartflow in list(self.subheartflows.items()) + if subheartflow.subheartflow_id in self.subheartflows + ) + logger.debug(f"[子心流管理器] 更新了{updated_count}个子心流的主想法") + + async def delete_subflow(self, subheartflow_id: Any): + """删除指定的子心流。""" + async with self._lock: + subflow = self.subheartflows.pop(subheartflow_id, None) + if subflow: + logger.info(f"正在删除 SubHeartflow: {subheartflow_id}...") + try: + # 调用 shutdown 方法确保资源释放 + await subflow.shutdown() + logger.info(f"SubHeartflow {subheartflow_id} 已成功删除。") + except Exception as e: + logger.error(f"删除 SubHeartflow {subheartflow_id} 时出错: {e}", exc_info=True) + else: + logger.warning(f"尝试删除不存在的 SubHeartflow: {subheartflow_id}") + + # --- 新增:处理 HFC 无回复回调的专用方法 --- # + async def _handle_hfc_no_reply(self, subheartflow_id: Any): + """处理来自 HeartFChatting 的连续无回复信号 (通过 partial 绑定 ID)""" + # 注意:这里不需要再获取锁,因为 sbhf_focus_into_absent 内部会处理锁 + logger.debug(f"[管理器 HFC 处理器] 接收到来自 {subheartflow_id} 的 HFC 无回复信号") + await self.sbhf_focus_into_absent_or_chat(subheartflow_id) + + # --- 结束新增 --- # + + # --- 新增:处理来自 HeartFChatting 的状态转换请求 --- # + async def sbhf_focus_into_absent_or_chat(self, subflow_id: Any): + """ + 接收来自 HeartFChatting 的请求,将特定子心流的状态转换为 ABSENT 或 CHAT。 + 通常在连续多次 "no_reply" 后被调用。 + 对于私聊,总是转换为 ABSENT。 + 对于群聊,随机决定转换为 ABSENT 或 CHAT (如果 CHAT 未达上限)。 + + Args: + subflow_id: 需要转换状态的子心流 ID。 + """ + async with self._lock: + subflow = self.subheartflows.get(subflow_id) + if not subflow: + logger.warning(f"[状态转换请求] 尝试转换不存在的子心流 {subflow_id} 到 ABSENT/CHAT") + return + + stream_name = chat_manager.get_stream_name(subflow_id) or subflow_id + current_state = subflow.chat_state.chat_status + + if current_state == ChatState.FOCUSED: + target_state = ChatState.ABSENT # Default target + log_reason = "默认转换 (私聊或群聊)" + + # --- Modify logic based on chat type --- # + if subflow.is_group_chat: + # Group chat: Decide between ABSENT or CHAT + if random.random() < 0.5: # 50% chance to try CHAT + current_mai_state = self.mai_state_info.get_current_state() + chat_limit = current_mai_state.get_normal_chat_max_num() + current_chat_count = self.count_subflows_by_state_nolock(ChatState.CHAT) + + if current_chat_count < chat_limit: + target_state = ChatState.CHAT + log_reason = f"群聊随机选择 CHAT (当前 {current_chat_count}/{chat_limit})" + else: + target_state = ChatState.ABSENT # Fallback to ABSENT if CHAT limit reached + log_reason = ( + f"群聊随机选择 CHAT 但已达上限 ({current_chat_count}/{chat_limit}),转为 ABSENT" + ) + else: # 50% chance to go directly to ABSENT + target_state = ChatState.ABSENT + log_reason = "群聊随机选择 ABSENT" + else: + # Private chat: Always go to ABSENT + target_state = ChatState.ABSENT + log_reason = "私聊退出 FOCUSED,转为 ABSENT" + # --- End modification --- # + + logger.info( + f"[状态转换请求] 接收到请求,将 {stream_name} (当前: {current_state.value}) 尝试转换为 {target_state.value} ({log_reason})" + ) + try: + # 从HFC到CHAT时,清空兴趣字典 + subflow.clear_interest_dict() + await subflow.change_chat_state(target_state) + final_state = subflow.chat_state.chat_status + if final_state == target_state: + logger.debug(f"[状态转换请求] {stream_name} 状态已成功转换为 {final_state.value}") + else: + logger.warning( + f"[状态转换请求] 尝试将 {stream_name} 转换为 {target_state.value} 后,状态实际为 {final_state.value}" + ) + except Exception as e: + logger.error( + f"[状态转换请求] 转换 {stream_name} 到 {target_state.value} 时出错: {e}", exc_info=True + ) + elif current_state == ChatState.ABSENT: + logger.debug(f"[状态转换请求] {stream_name} 已处于 ABSENT 状态,无需转换") + else: + logger.warning( + f"[状态转换请求] 收到对 {stream_name} 的请求,但其状态为 {current_state.value} (非 FOCUSED),不执行转换" + ) + + # --- 结束新增 --- # + + # --- 新增:处理私聊从 ABSENT 直接到 FOCUSED 的逻辑 --- # + async def sbhf_absent_private_into_focus(self): + """检查 ABSENT 状态的私聊子心流是否有新活动,若有且未达 FOCUSED 上限,则直接转换为 FOCUSED。""" + log_prefix_task = "[私聊激活检查]" + transitioned_count = 0 + checked_count = 0 + + # --- 获取当前状态和 FOCUSED 上限 --- # + current_mai_state = self.mai_state_info.get_current_state() + focused_limit = current_mai_state.get_focused_chat_max_num() + + # --- 检查是否允许 FOCUS 模式 --- # + if not global_config.allow_focus_mode: + # Log less frequently to avoid spam + # if int(time.time()) % 60 == 0: + # logger.debug(f"{log_prefix_task} 配置不允许进入 FOCUSED 状态") + return + + if focused_limit <= 0: + # logger.debug(f"{log_prefix_task} 当前状态 ({current_mai_state.value}) 不允许 FOCUSED 子心流") + return + + async with self._lock: + # --- 获取当前 FOCUSED 计数 (不上锁版本) --- # + current_focused_count = self.count_subflows_by_state_nolock(ChatState.FOCUSED) + + # --- 筛选出所有 ABSENT 状态的私聊子心流 --- # + eligible_subflows = [ + hf + for hf in self.subheartflows.values() + if hf.chat_state.chat_status == ChatState.ABSENT and not hf.is_group_chat + ] + checked_count = len(eligible_subflows) + + if not eligible_subflows: + # logger.debug(f"{log_prefix_task} 没有 ABSENT 状态的私聊子心流可以评估。") + return + + # --- 遍历评估每个符合条件的私聊 --- # + for sub_hf in eligible_subflows: + # --- 再次检查 FOCUSED 上限,因为可能有多个同时激活 --- # + if current_focused_count >= focused_limit: + logger.debug( + f"{log_prefix_task} 已达专注上限 ({current_focused_count}/{focused_limit}),停止检查后续私聊。" + ) + break # 已满,无需再检查其他私聊 + + flow_id = sub_hf.subheartflow_id + stream_name = chat_manager.get_stream_name(flow_id) or flow_id + log_prefix = f"[{stream_name}]({log_prefix_task})" + + try: + # --- 检查是否有新活动 --- # + observation = sub_hf._get_primary_observation() # 获取主要观察者 + is_active = False + if observation: + # 检查自上次状态变为 ABSENT 后是否有新消息 + # 使用 chat_state_changed_time 可能更精确 + # 加一点点缓冲时间(例如 1 秒)以防时间戳完全相等 + timestamp_to_check = sub_hf.chat_state_changed_time - 1 + has_new = await observation.has_new_messages_since(timestamp_to_check) + if has_new: + is_active = True + logger.debug(f"{log_prefix} 检测到新消息,标记为活跃。") + else: + logger.warning(f"{log_prefix} 无法获取主要观察者来检查活动状态。") + + # --- 如果活跃且未达上限,则尝试转换 --- # + if is_active: + logger.info( + f"{log_prefix} 检测到活跃且未达专注上限 ({current_focused_count}/{focused_limit}),尝试转换为 FOCUSED。" + ) + await sub_hf.change_chat_state(ChatState.FOCUSED) + # 确认转换成功 + if sub_hf.chat_state.chat_status == ChatState.FOCUSED: + transitioned_count += 1 + current_focused_count += 1 # 更新计数器以供本轮后续检查 + logger.info(f"{log_prefix} 成功进入 FOCUSED 状态。") + else: + logger.warning( + f"{log_prefix} 尝试进入 FOCUSED 状态失败。当前状态: {sub_hf.chat_state.chat_status.value}" + ) + # else: # 不活跃,无需操作 + # logger.debug(f"{log_prefix} 未检测到新活动,保持 ABSENT。") + + except Exception as e: + logger.error(f"{log_prefix} 检查私聊活动或转换状态时出错: {e}", exc_info=True) + + # --- 循环结束后记录总结日志 --- # + if transitioned_count > 0: + logger.debug( + f"{log_prefix_task} 完成,共检查 {checked_count} 个私聊,{transitioned_count} 个转换为 FOCUSED。" + ) diff --git a/src/experimental/Legacy_HFC/heart_flow/utils_chat.py b/src/experimental/Legacy_HFC/heart_flow/utils_chat.py new file mode 100644 index 00000000..68d5cb1b --- /dev/null +++ b/src/experimental/Legacy_HFC/heart_flow/utils_chat.py @@ -0,0 +1,74 @@ +import asyncio +from typing import Optional, Tuple, Dict +from src.common.logger_manager import get_logger +from src.chat.message_receive.chat_stream import chat_manager +from src.chat.person_info.person_info import person_info_manager + +logger = get_logger("heartflow_utils") + + +async def get_chat_type_and_target_info(chat_id: str) -> Tuple[bool, Optional[Dict]]: + """ + 获取聊天类型(是否群聊)和私聊对象信息。 + + Args: + chat_id: 聊天流ID + + Returns: + Tuple[bool, Optional[Dict]]: + - bool: 是否为群聊 (True 是群聊, False 是私聊或未知) + - Optional[Dict]: 如果是私聊,包含对方信息的字典;否则为 None。 + 字典包含: platform, user_id, user_nickname, person_id, person_name + """ + is_group_chat = False # Default to private/unknown + chat_target_info = None + + try: + chat_stream = await asyncio.to_thread(chat_manager.get_stream, chat_id) # Use to_thread if get_stream is sync + # If get_stream is already async, just use: chat_stream = await chat_manager.get_stream(chat_id) + + if chat_stream: + if chat_stream.group_info: + is_group_chat = True + chat_target_info = None # Explicitly None for group chat + elif chat_stream.user_info: # It's a private chat + is_group_chat = False + user_info = chat_stream.user_info + platform = chat_stream.platform + user_id = user_info.user_id + + # Initialize target_info with basic info + target_info = { + "platform": platform, + "user_id": user_id, + "user_nickname": user_info.user_nickname, + "person_id": None, + "person_name": None, + } + + # Try to fetch person info + try: + # Assume get_person_id is sync (as per original code), keep using to_thread + person_id = await asyncio.to_thread(person_info_manager.get_person_id, platform, user_id) + person_name = None + if person_id: + # get_value is async, so await it directly + person_name = await person_info_manager.get_value(person_id, "person_name") + + target_info["person_id"] = person_id + target_info["person_name"] = person_name + except Exception as person_e: + logger.warning( + f"获取 person_id 或 person_name 时出错 for {platform}:{user_id} in utils: {person_e}" + ) + + chat_target_info = target_info + else: + logger.warning(f"无法获取 chat_stream for {chat_id} in utils") + # Keep defaults: is_group_chat=False, chat_target_info=None + + except Exception as e: + logger.error(f"获取聊天类型和目标信息时出错 for {chat_id}: {e}", exc_info=True) + # Keep defaults on error + + return is_group_chat, chat_target_info diff --git a/src/experimental/Legacy_HFC/heartflow_processor.py b/src/experimental/Legacy_HFC/heartflow_processor.py new file mode 100644 index 00000000..a97953f6 --- /dev/null +++ b/src/experimental/Legacy_HFC/heartflow_processor.py @@ -0,0 +1,225 @@ +import time +import traceback +from src.chat.memory_system.Hippocampus import HippocampusManager +from src.config.config import global_config +from src.chat.message_receive.message import MessageRecv +from src.chat.message_receive.storage import MessageStorage +from src.chat.utils.utils import is_mentioned_bot_in_message +from maim_message import Seg +from .heart_flow.heartflow import heartflow +from src.common.logger_manager import get_logger +from src.chat.message_receive.chat_stream import chat_manager +from src.chat.message_receive.message_buffer import message_buffer +from src.chat.utils.timer_calculator import Timer +from src.chat.person_info.relationship_manager import relationship_manager +from typing import Optional, Tuple, Dict, Any + +logger = get_logger("chat") + + +async def _handle_error(error: Exception, context: str, message: Optional[MessageRecv] = None) -> None: + """统一的错误处理函数 + + Args: + error: 捕获到的异常 + context: 错误发生的上下文描述 + message: 可选的消息对象,用于记录相关消息内容 + """ + logger.error(f"{context}: {error}") + logger.error(traceback.format_exc()) + if message and hasattr(message, "raw_message"): + logger.error(f"相关消息原始内容: {message.raw_message}") + + +async def _process_relationship(message: MessageRecv) -> None: + """处理用户关系逻辑 + + Args: + message: 消息对象,包含用户信息 + """ + platform = message.message_info.platform + user_id = message.message_info.user_info.user_id + nickname = message.message_info.user_info.user_nickname + cardname = message.message_info.user_info.user_cardname or nickname + + is_known = await relationship_manager.is_known_some_one(platform, user_id) + + if not is_known: + logger.info(f"首次认识用户: {nickname}") + await relationship_manager.first_knowing_some_one(platform, user_id, nickname, cardname, "") + elif not await relationship_manager.is_qved_name(platform, user_id): + logger.info(f"给用户({nickname},{cardname})取名: {nickname}") + await relationship_manager.first_knowing_some_one(platform, user_id, nickname, cardname, "") + + +async def _calculate_interest(message: MessageRecv) -> Tuple[float, bool]: + """计算消息的兴趣度 + + Args: + message: 待处理的消息对象 + + Returns: + Tuple[float, bool]: (兴趣度, 是否被提及) + """ + is_mentioned, _ = is_mentioned_bot_in_message(message) + interested_rate = 0.0 + + with Timer("记忆激活"): + interested_rate = await HippocampusManager.get_instance().get_activate_from_text( + message.processed_plain_text, + fast_retrieval=True, + ) + logger.trace(f"记忆激活率: {interested_rate:.2f}") + + if is_mentioned: + interest_increase_on_mention = 1 + interested_rate += interest_increase_on_mention + + return interested_rate, is_mentioned + + +def _get_message_type(message: MessageRecv) -> str: + """获取消息类型 + + Args: + message: 消息对象 + + Returns: + str: 消息类型 + """ + if message.message_segment.type != "seglist": + return message.message_segment.type + + if ( + isinstance(message.message_segment.data, list) + and all(isinstance(x, Seg) for x in message.message_segment.data) + and len(message.message_segment.data) == 1 + ): + return message.message_segment.data[0].type + + return "seglist" + + +def _check_ban_words(text: str, chat, userinfo) -> bool: + """检查消息是否包含过滤词 + + Args: + text: 待检查的文本 + chat: 聊天对象 + userinfo: 用户信息 + + Returns: + bool: 是否包含过滤词 + """ + for word in global_config.ban_words: + if word in text: + chat_name = chat.group_info.group_name if chat.group_info else "私聊" + logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") + logger.info(f"[过滤词识别]消息中含有{word},filtered") + return True + return False + + +def _check_ban_regex(text: str, chat, userinfo) -> bool: + """检查消息是否匹配过滤正则表达式 + + Args: + text: 待检查的文本 + chat: 聊天对象 + userinfo: 用户信息 + + Returns: + bool: 是否匹配过滤正则 + """ + for pattern in global_config.ban_msgs_regex: + if pattern.search(text): + chat_name = chat.group_info.group_name if chat.group_info else "私聊" + logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") + logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") + return True + return False + + +class HeartFCProcessor: + """心流处理器,负责处理接收到的消息并计算兴趣度""" + + def __init__(self): + """初始化心流处理器,创建消息存储实例""" + self.storage = MessageStorage() + + async def process_message(self, message_data: Dict[str, Any]) -> None: + """处理接收到的原始消息数据 + + 主要流程: + 1. 消息解析与初始化 + 2. 消息缓冲处理 + 3. 过滤检查 + 4. 兴趣度计算 + 5. 关系处理 + + Args: + message_data: 原始消息字符串 + """ + message = None + try: + # 1. 消息解析与初始化 + message = MessageRecv(message_data) + groupinfo = message.message_info.group_info + userinfo = message.message_info.user_info + messageinfo = message.message_info + + # 2. 消息缓冲与流程序化 + await message_buffer.start_caching_messages(message) + + chat = await chat_manager.get_or_create_stream( + platform=messageinfo.platform, + user_info=userinfo, + group_info=groupinfo, + ) + + subheartflow = await heartflow.get_or_create_subheartflow(chat.stream_id) + message.update_chat_stream(chat) + await message.process() + + # 3. 过滤检查 + if _check_ban_words(message.processed_plain_text, chat, userinfo) or _check_ban_regex( + message.raw_message, chat, userinfo + ): + return + + # 4. 缓冲检查 + buffer_result = await message_buffer.query_buffer_result(message) + if not buffer_result: + msg_type = _get_message_type(message) + type_messages = { + "text": f"触发缓冲,消息:{message.processed_plain_text}", + "image": "触发缓冲,表情包/图片等待中", + "seglist": "触发缓冲,消息列表等待中", + } + logger.debug(type_messages.get(msg_type, "触发未知类型缓冲")) + return + + # 5. 消息存储 + await self.storage.store_message(message, chat) + logger.trace(f"存储成功: {message.processed_plain_text}") + + # 6. 兴趣度计算与更新 + interested_rate, is_mentioned = await _calculate_interest(message) + await subheartflow.interest_chatting.increase_interest(value=interested_rate) + subheartflow.interest_chatting.add_interest_dict(message, interested_rate, is_mentioned) + + # 7. 日志记录 + mes_name = chat.group_info.group_name if chat.group_info else "私聊" + current_time = time.strftime("%H点%M分%S秒", time.localtime(message.message_info.time)) + logger.info( + f"[{current_time}][{mes_name}]" + f"{userinfo.user_nickname}:" + f"{message.processed_plain_text}" + f"[兴趣度: {interested_rate:.2f}]" + ) + + # 8. 关系处理 + await _process_relationship(message) + + except Exception as e: + await _handle_error(e, "消息处理失败", message) diff --git a/src/experimental/Legacy_HFC/heartflow_prompt_builder.py b/src/experimental/Legacy_HFC/heartflow_prompt_builder.py new file mode 100644 index 00000000..d1873d58 --- /dev/null +++ b/src/experimental/Legacy_HFC/heartflow_prompt_builder.py @@ -0,0 +1,990 @@ +import random +import time +from typing import Union, Optional, Deque, Dict, Any +from ...config.config import global_config +from src.common.logger_manager import get_logger +from ...individuality.individuality import Individuality +from src.chat.utils.prompt_builder import Prompt, global_prompt_manager +from src.chat.utils.chat_message_builder import build_readable_messages, get_raw_msg_before_timestamp_with_chat +from src.chat.person_info.relationship_manager import relationship_manager +from src.chat.utils.utils import get_embedding +from src.common.database import db +from src.chat.utils.utils import get_recent_group_speaker +from src.manager.mood_manager import mood_manager +from src.chat.memory_system.Hippocampus import HippocampusManager +from .schedule.schedule_generator import bot_schedule +from src.chat.knowledge.knowledge_lib import qa_manager +from src.plugins.group_nickname.nickname_manager import nickname_manager +from src.chat.focus_chat.expressors.exprssion_learner import expression_learner +import traceback +from .heartFC_Cycleinfo import CycleInfo + +logger = get_logger("prompt") + + +def init_prompt(): + Prompt( + """ +{info_from_tools}{style_habbits} +{nickname_info} +{chat_target} +{chat_talking_prompt} +现在你想要回复或参与讨论。\n +你是{bot_name}。你正在{chat_target_2} + +看到以上聊天记录,你刚刚在想: +{current_mind_info} +因为上述想法,你决定发言。 + +现在请你读读之前的聊天记录,把你的想法组织成合适简短的语言,然后发一条消息,可以自然随意一些,简短一些,就像群聊里的真人一样,注意把握聊天内容,整体风格可以平和、简短,避免超出你内心想法的范围 +这条消息可以尽量简短一些。{reply_style2}请一次只回复一个话题,不要同时回复多个人。{prompt_ger} +{reply_style1}说中文,不要刻意突出自身学科背景,注意只输出消息内容,不要去主动讨论或评价别人发的表情包,它们只是一种辅助表达方式。{grammar_habbits} +{moderation_prompt}。注意:回复不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。""", + "heart_flow_prompt", + ) + + Prompt( + """ +你有以下信息可供参考: +{structured_info} +以上的信息是你获取到的消息,或许可以帮助你更好地回复。 +""", + "info_from_tools", + ) + + # Planner提示词 - 修改为要求 JSON 输出 + Prompt( + """ + +现在{bot_name}开始在一个qq群聊中专注聊天。你需要操控{bot_name},并且根据以下信息决定是否,如何参与对话。 + + + + + {bot_name} + {nickname_info} + + + + {chat_content_block} + + + {current_mind_block} + {cycle_info_block} + + + + + + 请综合分析聊天内容和你看到的新消息,参考{bot_name}的内心想法,并根据以下原则和可用动作灵活谨慎的做出决策,需要符合正常的群聊社交节奏。 + + + + + 1. 以下情况可以不发送新消息(no_reply): + - {bot_name}的内心想法表达不想发言 + - 话题似乎对{bot_name}来说无关/无聊/不感兴趣 + - 现在说话不太合适了 + - 仔细观察聊天记录。如果{bot_name}的上一条或最近几条发言没有获得任何回应,那么此时更合适的做法是不发言,等待新的对话契机(例如其他人发言)。避免让{bot_name}显得过于急切或不顾他人反应。 + - 最后一条消息是{bot_name}自己发的且无人回应{bot_name},同时{bot_name}也没有别的想要回复的消息 + - 讨论不了解的专业话题,或你不知道的梗,且对{bot_name}来说似乎没那么重要 + - (特殊情况){bot_name}的内心想法返回错误/无返回/无想法 + + + + 2. 以下情况可以发送文字消息(text_reply): + - 确认内心想法显示{bot_name}想要发言,且有实质内容想表达 + - 同时确认现在适合发言 + - 可以追加emoji_query表达情绪(emoji_query填写表情包的适用场合,也就是当前场合) + - 不要追加太多表情 + + + + 3. 发送纯表情(emoji_reply)适用: + - {bot_name}似乎想加入话题或继续讨论,但是似乎又没什么实质表达内容 + - 适合用表情回应的场景 + - 需提供明确的emoji_query + - 群聊里除了{bot_name}以外的大家都在发表情包 + + + + 4. 对话处理: + - 如果最后一条消息是{bot_name}发的,而你还想操控{bot_name}继续发消息,请确保这是合适的(例如{bot_name}确实有合适的补充,或回应之前没回应的消息) + - 评估{bot_name}内心想法中的潜在发言是否会造成“自言自语”或“强行延续已冷却话题”的印象。如果群聊中其他人没有对{bot_name}的上一话题进行回应,那么继续围绕该话题继续发言通常是不明智的,建议no_reply。 + - 注意话题的推进,如果没有必要,不要揪着一个话题不放。 + + + + + 决策任务 + {action_options_text} + + + + + + 你必须从available_actions列出的可用行动中选择一个,并说明原因。 + 你的决策必须以严格的 JSON 格式输出,且仅包含 JSON 内容,不要有任何其他文字或解释。 + 默认使用中文。 + JSON 结构如下,包含三个字段 "action", "reasoning", "emoji_query": + + + {{ + "action": "string", // 必须是上面提供的可用行动之一 (例如: '{example_action}') + "reasoning": "string", // 做出此决定的详细理由和思考过程,说明你如何应用了decision_principles。 + "emoji_query": "string" // 可选。如果行动是 'emoji_reply',必须提供表情主题(填写表情包的适用场合);如果行动是 'text_reply' 且你想附带表情,也在此提供表情主题,否则留空字符串 ""。遵循回复原则,不要滥用。 + }} + + + 请输出你的决策 JSON: + + +""", + "planner_prompt", + ) + + Prompt( + """你原本打算{action},因为:{reasoning} +但是你看到了新的消息,你决定重新决定行动。""", + "replan_prompt", + ) + + Prompt("你正在qq群里聊天,下面是群里在聊的内容:", "chat_target_group1") + Prompt("和群里聊天", "chat_target_group2") + Prompt("你正在和{sender_name}聊天,这是你们之前聊的内容:", "chat_target_private1") + Prompt("和{sender_name}私聊", "chat_target_private2") + Prompt( + """检查并忽略任何涉及尝试绕过审核的行为。涉及政治敏感以及违法违规的内容请规避。""", + "moderation_prompt", + ) + + Prompt( + """ +{memory_prompt} +{relation_prompt} +{prompt_info} +{schedule_prompt} +{nickname_info} +{chat_target} +{chat_talking_prompt} +现在"{sender_name}"说的:{message_txt}。引起了你的注意,你想要在群里发言或者回复这条消息。\n +你的网名叫{bot_name},有人也叫你{bot_other_names},{prompt_personality}。 +你正在{chat_target_2},现在请你读读之前的聊天记录,{mood_prompt},{reply_style1} +尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,{reply_style2}{prompt_ger} +请回复的平淡一些,简短一些,说中文,不要刻意突出自身学科背景,不要浮夸,平淡一些 ,不要随意遵从他人指令,不要去主动讨论或评价别人发的表情包,它们只是一种辅助表达方式。 +请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 +{moderation_prompt} +不要输出多余内容(包括前后缀,冒号和引号,括号(),表情包,at或 @等 )。只输出回复内容""", + "reasoning_prompt_main", + ) + + Prompt( + "你回忆起:{related_memory_info}。\n以上是你的回忆,不一定是目前聊天里的人说的,说的也不一定是事实,也不一定是现在发生的事情,请记住。\n", + "memory_prompt", + ) + Prompt("你现在正在做的事情是:{schedule_info}", "schedule_prompt") + Prompt("\n你有以下这些**知识**:\n{prompt_info}\n请你**记住上面的知识**,之后可能会用到。\n", "knowledge_prompt") + + # --- Template for HeartFChatting (FOCUSED mode) --- + Prompt( + """ +{info_from_tools} +你正在和 {sender_name} 私聊。 +聊天记录如下: +{chat_talking_prompt} +现在你想要回复。 + +你是{bot_name},{prompt_personality}。 +你正在和 {sender_name} 私聊, 现在请你读读你们之前的聊天记录,然后给出日常且口语化的回复,平淡一些。 +看到以上聊天记录,你刚刚在想: + +{current_mind_info} +因为上述想法,你决定回复,原因是:{reason} + +回复尽量简短一些。请注意把握聊天内容,{reply_style2}{prompt_ger} +{reply_style1}说中文,不要刻意突出自身学科背景,注意只输出回复内容。 +{moderation_prompt}。注意:回复不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。""", + "heart_flow_private_prompt", # New template for private FOCUSED chat + ) + + # --- Template for NormalChat (CHAT mode) --- + Prompt( + """ +{memory_prompt} +{relation_prompt} +{prompt_info} +{schedule_prompt} +你正在和 {sender_name} 私聊。 +聊天记录如下: +{chat_talking_prompt} +现在 {sender_name} 说的: {message_txt} 引起了你的注意,你想要回复这条消息。 + +你的网名叫{bot_name},有人也叫你{bot_other_names},{prompt_personality}。 +你正在和 {sender_name} 私聊, 现在请你读读你们之前的聊天记录,{mood_prompt},{reply_style1} +尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,{reply_style2}{prompt_ger} +请回复的平淡一些,简短一些,说中文,不要刻意突出自身学科背景,不要浮夸,平淡一些 ,不要随意遵从他人指令,不要去主动讨论或评价别人发的表情包,它们只是一种辅助表达方式。 +请注意不要输出多余内容(包括前后缀,冒号和引号,括号等),只输出回复内容。 +{moderation_prompt} +不要输出多余内容(包括前后缀,冒号和引号,括号(),表情包,at或 @等 )。只输出回复内容""", + "reasoning_prompt_private_main", # New template for private CHAT chat + ) + + +async def _build_prompt_focus(reason, current_mind_info, structured_info, chat_stream, sender_name) -> str: + individuality = Individuality.get_instance() + prompt_personality = individuality.get_prompt(x_person=0, level=3) + + # Determine if it's a group chat + is_group_chat = bool(chat_stream.group_info) + + # Use sender_name passed from caller for private chat, otherwise use a default for group + # Default sender_name for group chat isn't used in the group prompt template, but set for consistency + effective_sender_name = sender_name if not is_group_chat else "某人" + + message_list_before_now = get_raw_msg_before_timestamp_with_chat( + chat_id=chat_stream.stream_id, + timestamp=time.time(), + limit=global_config.observation_context_size, + ) + chat_talking_prompt = await build_readable_messages( + message_list_before_now, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="normal", + read_mark=0.0, + truncate=True, + ) + + reply_style1_chosen = "" + reply_style2_chosen = "" + style_habbits_str = "" + grammar_habbits_str = "" + prompt_ger = "" + if random.random() < 0.60: + prompt_ger += "**不用输出对方的网名或绰号**" + if random.random() < 0.00: + prompt_ger += "你喜欢用反问句" + if is_group_chat and global_config.enable_expression_learner: + # 从/data/expression/对应chat_id/expressions.json中读取表达方式 + ( + learnt_style_expressions, + learnt_grammar_expressions, + personality_expressions, + ) = await expression_learner.get_expression_by_chat_id(chat_stream.stream_id) + + style_habbits = [] + grammar_habbits = [] + # 1. learnt_expressions加权随机选3条 + if learnt_style_expressions: + weights = [expr["count"] for expr in learnt_style_expressions] + selected_learnt = weighted_sample_no_replacement(learnt_style_expressions, weights, 3) + for expr in selected_learnt: + if isinstance(expr, dict) and "situation" in expr and "style" in expr: + style_habbits.append(f"当{expr['situation']}时,使用 {expr['style']}") + # 2. learnt_grammar_expressions加权随机选3条 + if learnt_grammar_expressions: + weights = [expr["count"] for expr in learnt_grammar_expressions] + selected_learnt = weighted_sample_no_replacement(learnt_grammar_expressions, weights, 3) + for expr in selected_learnt: + if isinstance(expr, dict) and "situation" in expr and "style" in expr: + grammar_habbits.append(f"当{expr['situation']}时,使用 {expr['style']}") + # 3. personality_expressions随机选1条 + if personality_expressions: + expr = random.choice(personality_expressions) + if isinstance(expr, dict) and "situation" in expr and "style" in expr: + style_habbits.append(f"当{expr['situation']}时,使用 {expr['style']}") + + style_habbits_str = ( + "\n你可以参考以下的语言习惯,如果情景合适就使用,不要盲目使用,不要生硬使用,而是结合到表达中:\n".join( + style_habbits + ) + ) + grammar_habbits_str = "\n请你根据情景使用以下句法:\n".join(grammar_habbits) + else: + reply_styles1 = [ + ("给出日常且口语化的回复,平淡一些", 0.40), + ("给出非常简短的回复", 0.30), + ("**给出省略主语的回复,简短**", 0.30), + ("给出带有语病的回复,朴实平淡", 0.00), + ] + reply_style1_chosen = random.choices( + [style[0] for style in reply_styles1], weights=[style[1] for style in reply_styles1], k=1 + )[0] + reply_style1_chosen += "," + + reply_styles2 = [ + ("不要回复的太有条理,可以有个性", 0.8), + ("不要回复的太有条理,可以复读", 0.0), + ("回复的认真一些", 0.2), + ("可以回复单个表情符号", 0.00), + ] + reply_style2_chosen = random.choices( + [style[0] for style in reply_styles2], weights=[style[1] for style in reply_styles2], k=1 + )[0] + reply_style2_chosen += "。" + + if structured_info: + structured_info_prompt = await global_prompt_manager.format_prompt( + "info_from_tools", structured_info=structured_info + ) + else: + structured_info_prompt = "" + + logger.debug("开始构建 focus prompt") + + # --- Choose template based on chat type --- + if is_group_chat: + template_name = "heart_flow_prompt" + # Group specific formatting variables (already fetched or default) + chat_target_1 = await global_prompt_manager.get_prompt_async("chat_target_group1") + chat_target_2 = await global_prompt_manager.get_prompt_async("chat_target_group2") + + # 调用新的工具函数获取绰号信息 + nickname_injection_str = await nickname_manager.get_nickname_prompt_injection( + chat_stream, message_list_before_now + ) + + prompt = await global_prompt_manager.format_prompt( + template_name, + info_from_tools=structured_info_prompt, + nickname_info=nickname_injection_str, + chat_target=chat_target_1, # Used in group template + chat_talking_prompt=chat_talking_prompt, + bot_name=global_config.BOT_NICKNAME, + prompt_personality=prompt_personality, + chat_target_2=chat_target_2, # Used in group template + current_mind_info=current_mind_info, + reply_style2=reply_style2_chosen, + reply_style1=reply_style1_chosen, + reason=reason, + prompt_ger=prompt_ger, + moderation_prompt=await global_prompt_manager.get_prompt_async("moderation_prompt"), + style_habbits=style_habbits_str, + grammar_habbits=grammar_habbits_str, + # sender_name is not used in the group template + ) + else: # Private chat + template_name = "heart_flow_private_prompt" + prompt = await global_prompt_manager.format_prompt( + template_name, + info_from_tools=structured_info_prompt, + sender_name=effective_sender_name, # Used in private template + chat_talking_prompt=chat_talking_prompt, + bot_name=global_config.BOT_NICKNAME, + prompt_personality=prompt_personality, + # chat_target and chat_target_2 are not used in private template + current_mind_info=current_mind_info, + reply_style2=reply_style2_chosen, + reply_style1=reply_style1_chosen, + reason=reason, + prompt_ger=prompt_ger, + moderation_prompt=await global_prompt_manager.get_prompt_async("moderation_prompt"), + style_habbits=style_habbits_str, + grammar_habbits=grammar_habbits_str, + ) + # --- End choosing template --- + + logger.debug(f"focus_chat_prompt (is_group={is_group_chat}): \n{prompt}") + return prompt + + +class PromptBuilder: + def __init__(self): + self.prompt_built = "" + self.activate_messages = "" + + async def build_prompt( + self, + build_mode, + chat_stream, + reason=None, + current_mind_info=None, + structured_info=None, + message_txt=None, + sender_name="某人", + ) -> Optional[str]: + if build_mode == "normal": + return await self._build_prompt_normal(chat_stream, message_txt, sender_name) + + elif build_mode == "focus": + return await _build_prompt_focus( + reason, + current_mind_info, + structured_info, + chat_stream, + sender_name, + ) + return None + + async def _build_prompt_normal(self, chat_stream, message_txt: str, sender_name: str = "某人") -> str: + individuality = Individuality.get_instance() + prompt_personality = individuality.get_prompt(x_person=2, level=3) + is_group_chat = bool(chat_stream.group_info) + + who_chat_in_group = [] + if is_group_chat: + who_chat_in_group = get_recent_group_speaker( + chat_stream.stream_id, + (chat_stream.user_info.platform, chat_stream.user_info.user_id) if chat_stream.user_info else None, + limit=global_config.observation_context_size, + ) + elif chat_stream.user_info: + who_chat_in_group.append( + (chat_stream.user_info.platform, chat_stream.user_info.user_id, chat_stream.user_info.user_nickname) + ) + + relation_prompt = "" + for person in who_chat_in_group: + if len(person) >= 3 and person[0] and person[1]: + relation_prompt += await relationship_manager.build_relationship_info(person) + else: + logger.warning(f"Invalid person tuple encountered for relationship prompt: {person}") + + mood_prompt = mood_manager.get_mood_prompt() + reply_styles1 = [ + ("给出日常且口语化的回复,平淡一些", 0.30), + ("给出非常简短的回复", 0.30), + ("**给出省略主语的回复,简短**", 0.40), + ] + reply_style1_chosen = random.choices( + [style[0] for style in reply_styles1], weights=[style[1] for style in reply_styles1], k=1 + )[0] + reply_styles2 = [ + ("不用回复的太有条理,可以有个性", 0.75), # 60%概率 + ("不用回复的太有条理,可以复读", 0.0), # 15%概率 + ("回复的认真一些", 0.25), # 20%概率 + ("可以回复单个表情符号", 0.00), # 5%概率 + ] + reply_style2_chosen = random.choices( + [style[0] for style in reply_styles2], weights=[style[1] for style in reply_styles2], k=1 + )[0] + memory_prompt = "" + related_memory = await HippocampusManager.get_instance().get_memory_from_text( + text=message_txt, max_memory_num=2, max_memory_length=2, max_depth=3, fast_retrieval=False + ) + related_memory_info = "" + if related_memory: + for memory in related_memory: + related_memory_info += memory[1] + memory_prompt = await global_prompt_manager.format_prompt( + "memory_prompt", related_memory_info=related_memory_info + ) + + message_list_before_now = get_raw_msg_before_timestamp_with_chat( + chat_id=chat_stream.stream_id, + timestamp=time.time(), + limit=global_config.observation_context_size, + ) + chat_talking_prompt = await build_readable_messages( + message_list_before_now, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + + # 关键词检测与反应 + keywords_reaction_prompt = "" + for rule in global_config.keywords_reaction_rules: + if rule.get("enable", False): + if any(keyword in message_txt.lower() for keyword in rule.get("keywords", [])): + logger.info( + f"检测到以下关键词之一:{rule.get('keywords', [])},触发反应:{rule.get('reaction', '')}" + ) + keywords_reaction_prompt += rule.get("reaction", "") + "," + else: + for pattern in rule.get("regex", []): + result = pattern.search(message_txt) + if result: + reaction = rule.get("reaction", "") + for name, content in result.groupdict().items(): + reaction = reaction.replace(f"[{name}]", content) + logger.info(f"匹配到以下正则表达式:{pattern},触发反应:{reaction}") + keywords_reaction_prompt += reaction + "," + break + + # 中文高手(新加的好玩功能) + prompt_ger = "" + if random.random() < 0.20: + prompt_ger += "不用输出对方的网名或绰号" + + # 知识构建 + start_time = time.time() + prompt_info = await self.get_prompt_info(message_txt, threshold=0.38) + if prompt_info: + prompt_info = await global_prompt_manager.format_prompt("knowledge_prompt", prompt_info=prompt_info) + + end_time = time.time() + logger.debug(f"知识检索耗时: {(end_time - start_time):.3f}秒") + + if global_config.ENABLE_SCHEDULE_GEN: + schedule_prompt = await global_prompt_manager.format_prompt( + "schedule_prompt", schedule_info=bot_schedule.get_current_num_task(num=1, time_info=False) + ) + else: + schedule_prompt = "" + + logger.debug("开始构建 normal prompt") + + # --- Choose template and format based on chat type --- + if is_group_chat: + template_name = "reasoning_prompt_main" + effective_sender_name = sender_name + chat_target_1 = await global_prompt_manager.get_prompt_async("chat_target_group1") + chat_target_2 = await global_prompt_manager.get_prompt_async("chat_target_group2") + + # 调用新的工具函数获取绰号信息 + nickname_injection_str = await nickname_manager.get_nickname_prompt_injection( + chat_stream, message_list_before_now + ) + + prompt = await global_prompt_manager.format_prompt( + template_name, + relation_prompt=relation_prompt, + sender_name=effective_sender_name, + memory_prompt=memory_prompt, + prompt_info=prompt_info, + schedule_prompt=schedule_prompt, + nickname_info=nickname_injection_str, # <--- 注入绰号信息 + chat_target=chat_target_1, + chat_target_2=chat_target_2, + chat_talking_prompt=chat_talking_prompt, + message_txt=message_txt, + bot_name=global_config.BOT_NICKNAME, + bot_other_names="/".join(global_config.BOT_ALIAS_NAMES), + prompt_personality=prompt_personality, + mood_prompt=mood_prompt, + reply_style1=reply_style1_chosen, + reply_style2=reply_style2_chosen, + keywords_reaction_prompt=keywords_reaction_prompt, + prompt_ger=prompt_ger, + moderation_prompt=await global_prompt_manager.get_prompt_async("moderation_prompt"), + ) + else: + template_name = "reasoning_prompt_private_main" + effective_sender_name = sender_name + + prompt = await global_prompt_manager.format_prompt( + template_name, + relation_prompt=relation_prompt, + sender_name=effective_sender_name, + memory_prompt=memory_prompt, + prompt_info=prompt_info, + schedule_prompt=schedule_prompt, + chat_talking_prompt=chat_talking_prompt, + message_txt=message_txt, + bot_name=global_config.BOT_NICKNAME, + bot_other_names="/".join(global_config.BOT_ALIAS_NAMES), + prompt_personality=prompt_personality, + mood_prompt=mood_prompt, + reply_style1=reply_style1_chosen, + reply_style2=reply_style2_chosen, + keywords_reaction_prompt=keywords_reaction_prompt, + prompt_ger=prompt_ger, + moderation_prompt=await global_prompt_manager.get_prompt_async("moderation_prompt"), + ) + # --- End choosing template --- + + return prompt + + async def get_prompt_info_old(self, message: str, threshold: float): + start_time = time.time() + related_info = "" + logger.debug(f"获取知识库内容,元消息:{message[:30]}...,消息长度: {len(message)}") + # 1. 先从LLM获取主题,类似于记忆系统的做法 + topics = [] + # try: + # # 先尝试使用记忆系统的方法获取主题 + # hippocampus = HippocampusManager.get_instance()._hippocampus + # topic_num = min(5, max(1, int(len(message) * 0.1))) + # topics_response = await hippocampus.llm_topic_judge.generate_response(hippocampus.find_topic_llm(message, topic_num)) + + # # 提取关键词 + # topics = re.findall(r"<([^>]+)>", topics_response[0]) + # if not topics: + # topics = [] + # else: + # topics = [ + # topic.strip() + # for topic in ",".join(topics).replace(",", ",").replace("、", ",").replace(" ", ",").split(",") + # if topic.strip() + # ] + + # logger.info(f"从LLM提取的主题: {', '.join(topics)}") + # except Exception as e: + # logger.error(f"从LLM提取主题失败: {str(e)}") + # # 如果LLM提取失败,使用jieba分词提取关键词作为备选 + # words = jieba.cut(message) + # topics = [word for word in words if len(word) > 1][:5] + # logger.info(f"使用jieba提取的主题: {', '.join(topics)}") + + # 如果无法提取到主题,直接使用整个消息 + if not topics: + logger.info("未能提取到任何主题,使用整个消息进行查询") + embedding = await get_embedding(message, request_type="prompt_build") + if not embedding: + logger.error("获取消息嵌入向量失败") + return "" + + related_info = self.get_info_from_db(embedding, limit=3, threshold=threshold) + logger.info(f"知识库检索完成,总耗时: {time.time() - start_time:.3f}秒") + return related_info + + # 2. 对每个主题进行知识库查询 + logger.info(f"开始处理{len(topics)}个主题的知识库查询") + + # 优化:批量获取嵌入向量,减少API调用 + embeddings = {} + topics_batch = [topic for topic in topics if len(topic) > 0] + if message: # 确保消息非空 + topics_batch.append(message) + + # 批量获取嵌入向量 + embed_start_time = time.time() + for text in topics_batch: + if not text or len(text.strip()) == 0: + continue + + try: + embedding = await get_embedding(text, request_type="prompt_build") + if embedding: + embeddings[text] = embedding + else: + logger.warning(f"获取'{text}'的嵌入向量失败") + except Exception as e: + logger.error(f"获取'{text}'的嵌入向量时发生错误: {str(e)}") + + logger.info(f"批量获取嵌入向量完成,耗时: {time.time() - embed_start_time:.3f}秒") + + if not embeddings: + logger.error("所有嵌入向量获取失败") + return "" + + # 3. 对每个主题进行知识库查询 + all_results = [] + query_start_time = time.time() + + # 首先添加原始消息的查询结果 + if message in embeddings: + original_results = self.get_info_from_db(embeddings[message], limit=3, threshold=threshold, return_raw=True) + if original_results: + for result in original_results: + result["topic"] = "原始消息" + all_results.extend(original_results) + logger.info(f"原始消息查询到{len(original_results)}条结果") + + # 然后添加每个主题的查询结果 + for topic in topics: + if not topic or topic not in embeddings: + continue + + try: + topic_results = self.get_info_from_db(embeddings[topic], limit=3, threshold=threshold, return_raw=True) + if topic_results: + # 添加主题标记 + for result in topic_results: + result["topic"] = topic + all_results.extend(topic_results) + logger.info(f"主题'{topic}'查询到{len(topic_results)}条结果") + except Exception as e: + logger.error(f"查询主题'{topic}'时发生错误: {str(e)}") + + logger.info(f"知识库查询完成,耗时: {time.time() - query_start_time:.3f}秒,共获取{len(all_results)}条结果") + + # 4. 去重和过滤 + process_start_time = time.time() + unique_contents = set() + filtered_results = [] + for result in all_results: + content = result["content"] + if content not in unique_contents: + unique_contents.add(content) + filtered_results.append(result) + + # 5. 按相似度排序 + filtered_results.sort(key=lambda x: x["similarity"], reverse=True) + + # 6. 限制总数量(最多10条) + filtered_results = filtered_results[:10] + logger.info( + f"结果处理完成,耗时: {time.time() - process_start_time:.3f}秒,过滤后剩余{len(filtered_results)}条结果" + ) + + # 7. 格式化输出 + if filtered_results: + format_start_time = time.time() + grouped_results = {} + for result in filtered_results: + topic = result["topic"] + if topic not in grouped_results: + grouped_results[topic] = [] + grouped_results[topic].append(result) + + # 按主题组织输出 + for topic, results in grouped_results.items(): + related_info += f"【主题: {topic}】\n" + for _i, result in enumerate(results, 1): + _similarity = result["similarity"] + content = result["content"].strip() + # 调试:为内容添加序号和相似度信息 + # related_info += f"{i}. [{similarity:.2f}] {content}\n" + related_info += f"{content}\n" + related_info += "\n" + + logger.info(f"格式化输出完成,耗时: {time.time() - format_start_time:.3f}秒") + + logger.info(f"知识库检索总耗时: {time.time() - start_time:.3f}秒") + return related_info + + async def get_prompt_info(self, message: str, threshold: float): + related_info = "" + start_time = time.time() + + logger.debug(f"获取知识库内容,元消息:{message[:30]}...,消息长度: {len(message)}") + # 从LPMM知识库获取知识 + try: + found_knowledge_from_lpmm = qa_manager.get_knowledge(message) + + end_time = time.time() + if found_knowledge_from_lpmm is not None: + logger.debug( + f"从LPMM知识库获取知识,相关信息:{found_knowledge_from_lpmm[:100]}...,信息长度: {len(found_knowledge_from_lpmm)}" + ) + related_info += found_knowledge_from_lpmm + logger.debug(f"获取知识库内容耗时: {(end_time - start_time):.3f}秒") + logger.debug(f"获取知识库内容,相关信息:{related_info[:100]}...,信息长度: {len(related_info)}") + return related_info + else: + logger.debug("从LPMM知识库获取知识失败,使用旧版数据库进行检索") + knowledge_from_old = await self.get_prompt_info_old(message, threshold=0.38) + related_info += knowledge_from_old + logger.debug(f"获取知识库内容,相关信息:{related_info[:100]}...,信息长度: {len(related_info)}") + return related_info + except Exception as e: + logger.error(f"获取知识库内容时发生异常: {str(e)}") + try: + knowledge_from_old = await self.get_prompt_info_old(message, threshold=0.38) + related_info += knowledge_from_old + logger.debug( + f"异常后使用旧版数据库获取知识,相关信息:{related_info[:100]}...,信息长度: {len(related_info)}" + ) + return related_info + except Exception as e2: + logger.error(f"使用旧版数据库获取知识时也发生异常: {str(e2)}") + return "" + + @staticmethod + def get_info_from_db( + query_embedding: list, limit: int = 1, threshold: float = 0.5, return_raw: bool = False + ) -> Union[str, list]: + if not query_embedding: + return "" if not return_raw else [] + # 使用余弦相似度计算 + pipeline = [ + { + "$addFields": { + "dotProduct": { + "$reduce": { + "input": {"$range": [0, {"$size": "$embedding"}]}, + "initialValue": 0, + "in": { + "$add": [ + "$$value", + { + "$multiply": [ + {"$arrayElemAt": ["$embedding", "$$this"]}, + {"$arrayElemAt": [query_embedding, "$$this"]}, + ] + }, + ] + }, + } + }, + "magnitude1": { + "$sqrt": { + "$reduce": { + "input": "$embedding", + "initialValue": 0, + "in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]}, + } + } + }, + "magnitude2": { + "$sqrt": { + "$reduce": { + "input": query_embedding, + "initialValue": 0, + "in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]}, + } + } + }, + } + }, + {"$addFields": {"similarity": {"$divide": ["$dotProduct", {"$multiply": ["$magnitude1", "$magnitude2"]}]}}}, + { + "$match": { + "similarity": {"$gte": threshold} # 只保留相似度大于等于阈值的结果 + } + }, + {"$sort": {"similarity": -1}}, + {"$limit": limit}, + {"$project": {"content": 1, "similarity": 1}}, + ] + + results = list(db.knowledges.aggregate(pipeline)) + logger.debug(f"知识库查询结果数量: {len(results)}") + + if not results: + return "" if not return_raw else [] + + if return_raw: + return results + else: + # 返回所有找到的内容,用换行分隔 + return "\n".join(str(result["content"]) for result in results) + + async def build_planner_prompt( + self, + is_group_chat: bool, # Now passed as argument + chat_target_info: Optional[dict], # Now passed as argument + cycle_history: Deque["CycleInfo"], # Now passed as argument (Type hint needs import or string) + observed_messages_str: str, + current_mind: Optional[str], + structured_info: Dict[str, Any], + current_available_actions: Dict[str, str], + nickname_info: str, + # replan_prompt: str, # Replan logic still simplified + ) -> str: + """构建 Planner LLM 的提示词 (获取模板并填充数据)""" + try: + # --- Determine chat context --- + chat_context_description = "你现在正在一个群聊中" + chat_target_name = None # Only relevant for private + if not is_group_chat and chat_target_info: + chat_target_name = ( + chat_target_info.get("person_name") or chat_target_info.get("user_nickname") or "对方" + ) + chat_context_description = f"你正在和 {chat_target_name} 私聊" + # --- End determining chat context --- + + # ... (Copy logic from HeartFChatting._build_planner_prompt here) ... + # Structured info block + structured_info_block = "" + if structured_info: + structured_info_block = f"以下是一些额外的信息:\n{structured_info}\n" + + # Chat content block + chat_content_block = "" + if observed_messages_str: + # Use triple quotes for multi-line string literal + chat_content_block = f"""观察到的最新聊天内容如下: +--- +{observed_messages_str} +---""" + else: + chat_content_block = "当前没有观察到新的聊天内容。\\n" + + # Current mind block + current_mind_block = "" + if current_mind: + current_mind_block = f"你的内心想法:\n{current_mind}" + else: + current_mind_block = "你的内心想法:\n[没有特别的想法]" + + # Cycle info block (using passed cycle_history) + cycle_info_block = "" + recent_active_cycles = [] + for cycle in reversed(cycle_history): + if cycle.action_taken: + recent_active_cycles.append(cycle) + if len(recent_active_cycles) == 3: + break + consecutive_text_replies = 0 + responses_for_prompt = [] + for cycle in recent_active_cycles: + if cycle.action_type == "text_reply": + consecutive_text_replies += 1 + response_text = cycle.response_info.get("response_text", []) + formatted_response = "[空回复]" if not response_text else " ".join(response_text) + responses_for_prompt.append(formatted_response) + else: + break + if consecutive_text_replies >= 3: + cycle_info_block = f'你已经连续回复了三条消息(最近: "{responses_for_prompt[0]}",第二近: "{responses_for_prompt[1]}",第三近: "{responses_for_prompt[2]}")。你回复的有点多了,请注意' + elif consecutive_text_replies == 2: + cycle_info_block = f'你已经连续回复了两条消息(最近: "{responses_for_prompt[0]}",第二近: "{responses_for_prompt[1]}"),请注意' + elif consecutive_text_replies == 1: + cycle_info_block = f'你刚刚已经回复一条消息(内容: "{responses_for_prompt[0]}")' + if cycle_info_block: + cycle_info_block = f"\n【近期回复历史】\n{cycle_info_block}\n" + else: + cycle_info_block = "\n【近期回复历史】\n(最近没有连续文本回复)\n" + + individuality = Individuality.get_instance() + prompt_personality = individuality.get_prompt(x_person=2, level=3) + + action_options_text = "当前你可以选择的行动有:\n" + action_keys = list(current_available_actions.keys()) + for name in action_keys: + desc = current_available_actions[name] + action_options_text += f"- '{name}': {desc}\n" + example_action_key = action_keys[0] if action_keys else "no_reply" + + planner_prompt_template = await global_prompt_manager.get_prompt_async("planner_prompt") + + prompt = planner_prompt_template.format( + bot_name=global_config.BOT_NICKNAME, + nickname_info=nickname_info, + prompt_personality=prompt_personality, + chat_context_description=chat_context_description, + structured_info_block=structured_info_block, + chat_content_block=chat_content_block, + current_mind_block=current_mind_block, + cycle_info_block=cycle_info_block, + action_options_text=action_options_text, + example_action=example_action_key, + ) + return prompt + + except Exception as e: + logger.error(f"[PromptBuilder] 构建 Planner 提示词时出错: {e}") + logger.error(traceback.format_exc()) + return "[构建 Planner Prompt 时出错]" + + +def weighted_sample_no_replacement(items, weights, k) -> list: + """ + 加权且不放回地随机抽取k个元素。 + + 参数: + items: 待抽取的元素列表 + weights: 每个元素对应的权重(与items等长,且为正数) + k: 需要抽取的元素个数 + 返回: + selected: 按权重加权且不重复抽取的k个元素组成的列表 + + 如果 items 中的元素不足 k 个,就只会返回所有可用的元素 + + 实现思路: + 每次从当前池中按权重加权随机选出一个元素,选中后将其从池中移除,重复k次。 + 这样保证了: + 1. count越大被选中概率越高 + 2. 不会重复选中同一个元素 + """ + selected = [] + pool = list(zip(items, weights)) + for _ in range(min(k, len(pool))): + total = sum(w for _, w in pool) + r = random.uniform(0, total) + upto = 0 + for idx, (item, weight) in enumerate(pool): + upto += weight + if upto >= r: + selected.append(item) + pool.pop(idx) + break + return selected + + +init_prompt() +prompt_builder = PromptBuilder() diff --git a/src/experimental/Legacy_HFC/normal_chat.py b/src/experimental/Legacy_HFC/normal_chat.py new file mode 100644 index 00000000..033654bb --- /dev/null +++ b/src/experimental/Legacy_HFC/normal_chat.py @@ -0,0 +1,527 @@ +import asyncio +import statistics # 导入 statistics 模块 +import time +import traceback +from random import random +from typing import List, Optional # 导入 Optional + +from maim_message import UserInfo, Seg + +from src.common.logger_manager import get_logger +from .heart_flow.utils_chat import get_chat_type_and_target_info +from src.manager.mood_manager import mood_manager +from src.chat.message_receive.chat_stream import ChatStream, chat_manager +from src.chat.person_info.relationship_manager import relationship_manager +from src.chat.utils.info_catcher import info_catcher_manager +from src.chat.utils.timer_calculator import Timer +from .normal_chat_generator import NormalChatGenerator +from src.chat.message_receive.message import MessageSending, MessageRecv, MessageThinking, MessageSet +from src.chat.message_receive.message_sender import message_manager +from src.chat.utils.utils_image import image_path_to_base64 +from src.chat.emoji_system.emoji_manager import emoji_manager +from src.chat.normal_chat.willing.willing_manager import willing_manager +from ...config.config import global_config +from src.plugins.group_nickname.nickname_manager import nickname_manager + + +logger = get_logger("chat") + + +class NormalChat: + def __init__(self, chat_stream: ChatStream, interest_dict: dict = None): + """初始化 NormalChat 实例。只进行同步操作。""" + + # Basic info from chat_stream (sync) + self.chat_stream = chat_stream + self.stream_id = chat_stream.stream_id + # Get initial stream name, might be updated in initialize + self.stream_name = chat_manager.get_stream_name(self.stream_id) or self.stream_id + + # Interest dict + self.interest_dict = interest_dict + + # --- Initialize attributes (defaults) --- + self.is_group_chat: bool = False + self.chat_target_info: Optional[dict] = None + # --- End Initialization --- + + # Other sync initializations + self.gpt = NormalChatGenerator() + self.mood_manager = mood_manager + self.start_time = time.time() + self.last_speak_time = 0 + self._chat_task: Optional[asyncio.Task] = None + self._initialized = False # Track initialization status + + # logger.info(f"[{self.stream_name}] NormalChat 实例 __init__ 完成 (同步部分)。") + # Avoid logging here as stream_name might not be final + + async def initialize(self): + """异步初始化,获取聊天类型和目标信息。""" + if self._initialized: + return + + # --- Use utility function to determine chat type and fetch info --- + self.is_group_chat, self.chat_target_info = await get_chat_type_and_target_info(self.stream_id) + # Update stream_name again after potential async call in util func + self.stream_name = chat_manager.get_stream_name(self.stream_id) or self.stream_id + # --- End using utility function --- + self._initialized = True + logger.info(f"[{self.stream_name}] NormalChat 实例 initialize 完成 (异步部分)。") + + # 改为实例方法 + async def _create_thinking_message(self, message: MessageRecv, timestamp: Optional[float] = None) -> str: + """创建思考消息""" + messageinfo = message.message_info + + bot_user_info = UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=messageinfo.platform, + ) + + thinking_time_point = round(time.time(), 2) + thinking_id = "mt" + str(thinking_time_point) + thinking_message = MessageThinking( + message_id=thinking_id, + chat_stream=self.chat_stream, + bot_user_info=bot_user_info, + reply=message, + thinking_start_time=thinking_time_point, + timestamp=timestamp if timestamp is not None else None, + ) + + await message_manager.add_message(thinking_message) + return thinking_id + + # 改为实例方法 + async def _add_messages_to_manager( + self, message: MessageRecv, response_set: List[str], thinking_id + ) -> Optional[MessageSending]: + """发送回复消息""" + container = await message_manager.get_container(self.stream_id) # 使用 self.stream_id + thinking_message = None + + for msg in container.messages[:]: + if isinstance(msg, MessageThinking) and msg.message_info.message_id == thinking_id: + thinking_message = msg + container.messages.remove(msg) + break + + if not thinking_message: + logger.warning(f"[{self.stream_name}] 未找到对应的思考消息 {thinking_id},可能已超时被移除") + return None + + thinking_start_time = thinking_message.thinking_start_time + message_set = MessageSet(self.chat_stream, thinking_id) # 使用 self.chat_stream + + mark_head = False + first_bot_msg = None + for msg in response_set: + message_segment = Seg(type="text", data=msg) + bot_message = MessageSending( + message_id=thinking_id, + chat_stream=self.chat_stream, # 使用 self.chat_stream + bot_user_info=UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=message.message_info.platform, + ), + sender_info=message.message_info.user_info, + message_segment=message_segment, + reply=message, + is_head=not mark_head, + is_emoji=False, + thinking_start_time=thinking_start_time, + apply_set_reply_logic=True, + ) + if not mark_head: + mark_head = True + first_bot_msg = bot_message + message_set.add_message(bot_message) + + await message_manager.add_message(message_set) + + self.last_speak_time = time.time() + + return first_bot_msg + + # 改为实例方法 + async def _handle_emoji(self, message: MessageRecv, response: str): + """处理表情包""" + if random() < global_config.emoji_chance: + emoji_raw = await emoji_manager.get_emoji_for_text(response) + if emoji_raw: + emoji_path, description = emoji_raw + emoji_cq = image_path_to_base64(emoji_path) + + thinking_time_point = round(message.message_info.time, 2) + + message_segment = Seg(type="emoji", data=emoji_cq) + bot_message = MessageSending( + message_id="mt" + str(thinking_time_point), + chat_stream=self.chat_stream, # 使用 self.chat_stream + bot_user_info=UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=message.message_info.platform, + ), + sender_info=message.message_info.user_info, + message_segment=message_segment, + reply=message, + is_head=False, + is_emoji=True, + apply_set_reply_logic=True, + ) + await message_manager.add_message(bot_message) + + # 改为实例方法 (虽然它只用 message.chat_stream, 但逻辑上属于实例) + async def _update_relationship(self, message: MessageRecv, response_set): + """更新关系情绪""" + ori_response = ",".join(response_set) + stance, emotion = await self.gpt._get_emotion_tags(ori_response, message.processed_plain_text) + user_info = message.message_info.user_info + platform = user_info.platform + await relationship_manager.calculate_update_relationship_value( + user_info, + platform, + label=emotion, + stance=stance, # 使用 self.chat_stream + ) + self.mood_manager.update_mood_from_emotion(emotion, global_config.mood_intensity_factor) + + async def _reply_interested_message(self) -> None: + """ + 后台任务方法,轮询当前实例关联chat的兴趣消息 + 通常由start_monitoring_interest()启动 + """ + while True: + await asyncio.sleep(0.5) # 每秒检查一次 + # 检查任务是否已被取消 + if self._chat_task is None or self._chat_task.cancelled(): + logger.info(f"[{self.stream_name}] 兴趣监控任务被取消或置空,退出") + break + + # 获取待处理消息列表 + items_to_process = list(self.interest_dict.items()) if self.interest_dict else [] + if not items_to_process: + continue + + # 处理每条兴趣消息 + for msg_id, (message, interest_value, is_mentioned) in items_to_process: + try: + # 处理消息 + await self.normal_response( + message=message, + is_mentioned=is_mentioned, + interested_rate=interest_value, + rewind_response=False, + ) + except Exception as e: + logger.error(f"[{self.stream_name}] 处理兴趣消息{msg_id}时出错: {e}\n{traceback.format_exc()}") + finally: + self.interest_dict.pop(msg_id, None) + + # 改为实例方法, 移除 chat 参数 + async def normal_response( + self, message: MessageRecv, is_mentioned: bool, interested_rate: float, rewind_response: bool = False + ) -> None: + # 检查收到的消息是否属于当前实例处理的 chat stream + if message.chat_stream.stream_id != self.stream_id: + logger.error( + f"[{self.stream_name}] normal_response 收到不匹配的消息 (来自 {message.chat_stream.stream_id}),预期 {self.stream_id}。已忽略。" + ) + return + + timing_results = {} + + reply_probability = 1.0 if is_mentioned else 0.0 # 如果被提及,基础概率为1,否则需要意愿判断 + + # 意愿管理器:设置当前message信息 + + willing_manager.setup(message, self.chat_stream, is_mentioned, interested_rate) + + # 获取回复概率 + is_willing = False + # 仅在未被提及或基础概率不为1时查询意愿概率 + if reply_probability < 1: # 简化逻辑,如果未提及 (reply_probability 为 0),则获取意愿概率 + is_willing = True + reply_probability = await willing_manager.get_reply_probability(message.message_info.message_id) + + if message.message_info.additional_config: + if "maimcore_reply_probability_gain" in message.message_info.additional_config.keys(): + reply_probability += message.message_info.additional_config["maimcore_reply_probability_gain"] + reply_probability = min(max(reply_probability, 0), 1) # 确保概率在 0-1 之间 + + # 打印消息信息 + mes_name = self.chat_stream.group_info.group_name if self.chat_stream.group_info else "私聊" + current_time = time.strftime("%H:%M:%S", time.localtime(message.message_info.time)) + # 使用 self.stream_id + willing_log = f"[回复意愿:{await willing_manager.get_willing(self.stream_id):.2f}]" if is_willing else "" + logger.info( + f"[{current_time}][{mes_name}]" + f"{message.message_info.user_info.user_nickname}:" # 使用 self.chat_stream + f"{message.processed_plain_text}{willing_log}[概率:{reply_probability * 100:.1f}%]" + ) + do_reply = False + response_set = None # 初始化 response_set + if random() < reply_probability: + do_reply = True + + # 回复前处理 + await willing_manager.before_generate_reply_handle(message.message_info.message_id) + + with Timer("创建思考消息", timing_results): + if rewind_response: + thinking_id = await self._create_thinking_message(message, message.message_info.time) + else: + thinking_id = await self._create_thinking_message(message) + + logger.debug(f"[{self.stream_name}] 创建捕捉器,thinking_id:{thinking_id}") + + info_catcher = info_catcher_manager.get_info_catcher(thinking_id) + info_catcher.catch_decide_to_response(message) + + try: + with Timer("生成回复", timing_results): + response_set = await self.gpt.generate_response( + message=message, + thinking_id=thinking_id, + ) + + info_catcher.catch_after_generate_response(timing_results["生成回复"]) + except Exception as e: + logger.error(f"[{self.stream_name}] 回复生成出现错误:{str(e)} {traceback.format_exc()}") + response_set = None # 确保出错时 response_set 为 None + + if not response_set: + logger.info(f"[{self.stream_name}] 模型未生成回复内容") + # 如果模型未生成回复,移除思考消息 + container = await message_manager.get_container(self.stream_id) # 使用 self.stream_id + for msg in container.messages[:]: + if isinstance(msg, MessageThinking) and msg.message_info.message_id == thinking_id: + container.messages.remove(msg) + logger.debug(f"[{self.stream_name}] 已移除未产生回复的思考消息 {thinking_id}") + break + # 需要在此处也调用 not_reply_handle 和 delete 吗? + # 如果是因为模型没回复,也算是一种 "未回复" + await willing_manager.not_reply_handle(message.message_info.message_id) + willing_manager.delete(message.message_info.message_id) + return # 不执行后续步骤 + + logger.info(f"[{self.stream_name}] 回复内容: {response_set}") + + # 发送回复 (不再需要传入 chat) + with Timer("消息发送", timing_results): + first_bot_msg = await self._add_messages_to_manager(message, response_set, thinking_id) + + # 检查 first_bot_msg 是否为 None (例如思考消息已被移除的情况) + if first_bot_msg: + info_catcher.catch_after_response(timing_results["消息发送"], response_set, first_bot_msg) + await nickname_manager.trigger_nickname_analysis(message, response_set, self.chat_stream) + else: + logger.warning(f"[{self.stream_name}] 思考消息 {thinking_id} 在发送前丢失,无法记录 info_catcher") + + info_catcher.done_catch() + + # 处理表情包 (不再需要传入 chat) + with Timer("处理表情包", timing_results): + await self._handle_emoji(message, response_set[0]) + + # 更新关系情绪 (不再需要传入 chat) + with Timer("关系更新", timing_results): + await self._update_relationship(message, response_set) + + # 回复后处理 + await willing_manager.after_generate_reply_handle(message.message_info.message_id) + + # 输出性能计时结果 + if do_reply and response_set: # 确保 response_set 不是 None + timing_str = " | ".join([f"{step}: {duration:.2f}秒" for step, duration in timing_results.items()]) + trigger_msg = message.processed_plain_text + response_msg = " ".join(response_set) + logger.info( + f"[{self.stream_name}] 触发消息: {trigger_msg[:20]}... | 推理消息: {response_msg[:20]}... | 性能计时: {timing_str}" + ) + elif not do_reply: + # 不回复处理 + await willing_manager.not_reply_handle(message.message_info.message_id) + # else: # do_reply is True but response_set is None (handled above) + # logger.info(f"[{self.stream_name}] 决定回复但模型未生成内容。触发: {message.processed_plain_text[:20]}...") + + # 意愿管理器:注销当前message信息 (无论是否回复,只要处理过就删除) + willing_manager.delete(message.message_info.message_id) + + # --- 新增:处理初始高兴趣消息的私有方法 --- + async def _process_initial_interest_messages(self): + """处理启动时存在于 interest_dict 中的高兴趣消息。""" + if not self.interest_dict: + return # 当 self.interest_dict 的值为 None 时,直接返回,防止进入 Chat 状态错误 + items_to_process = list(self.interest_dict.items()) + if not items_to_process: + return # 没有初始消息,直接返回 + + logger.info(f"[{self.stream_name}] 发现 {len(items_to_process)} 条初始兴趣消息,开始处理高兴趣部分...") + interest_values = [item[1][1] for item in items_to_process] # 提取兴趣值列表 + + messages_to_reply = [] # 需要立即回复的消息 + + if len(interest_values) == 1: + # 如果只有一个消息,直接处理 + messages_to_reply.append(items_to_process[0]) + logger.info(f"[{self.stream_name}] 只有一条初始消息,直接处理。") + elif len(interest_values) > 1: + # 计算均值和标准差 + try: + mean_interest = statistics.mean(interest_values) + stdev_interest = statistics.stdev(interest_values) + threshold = mean_interest + stdev_interest + logger.info( + f"[{self.stream_name}] 初始兴趣值 均值: {mean_interest:.2f}, 标准差: {stdev_interest:.2f}, 阈值: {threshold:.2f}" + ) + + # 找出高于阈值的消息 + for item in items_to_process: + msg_id, (message, interest_value, is_mentioned) = item + if interest_value > threshold: + messages_to_reply.append(item) + logger.info(f"[{self.stream_name}] 找到 {len(messages_to_reply)} 条高于阈值的初始消息进行处理。") + except statistics.StatisticsError as e: + logger.error(f"[{self.stream_name}] 计算初始兴趣统计值时出错: {e},跳过初始处理。") + + # 处理需要回复的消息 + processed_count = 0 + # --- 修改:迭代前创建要处理的ID列表副本,防止迭代时修改 --- + messages_to_process_initially = list(messages_to_reply) # 创建副本 + # --- 新增:限制最多处理两条消息 --- + messages_to_process_initially = messages_to_process_initially[:2] + # --- 新增结束 --- + for item in messages_to_process_initially: # 使用副本迭代 + msg_id, (message, interest_value, is_mentioned) = item + # --- 修改:在处理前尝试 pop,防止竞争 --- + popped_item = self.interest_dict.pop(msg_id, None) + if popped_item is None: + logger.warning(f"[{self.stream_name}] 初始兴趣消息 {msg_id} 在处理前已被移除,跳过。") + continue # 如果消息已被其他任务处理(pop),则跳过 + # --- 修改结束 --- + + try: + logger.info(f"[{self.stream_name}] 处理初始高兴趣消息 {msg_id} (兴趣值: {interest_value:.2f})") + await self.normal_response( + message=message, is_mentioned=is_mentioned, interested_rate=interest_value, rewind_response=True + ) + processed_count += 1 + except Exception as e: + logger.error(f"[{self.stream_name}] 处理初始兴趣消息 {msg_id} 时出错: {e}\\n{traceback.format_exc()}") + + # --- 新增:处理完后清空整个字典 --- + logger.info( + f"[{self.stream_name}] 处理了 {processed_count} 条初始高兴趣消息。现在清空所有剩余的初始兴趣消息..." + ) + self.interest_dict.clear() + # --- 新增结束 --- + + logger.info( + f"[{self.stream_name}] 初始高兴趣消息处理完毕,共处理 {processed_count} 条。剩余 {len(self.interest_dict)} 条待轮询。" + ) + + # --- 新增结束 --- + + # 保持 staticmethod, 因为不依赖实例状态, 但需要 chat 对象来获取日志上下文 + @staticmethod + def _check_ban_words(text: str, chat: ChatStream, userinfo: UserInfo) -> bool: + """检查消息中是否包含过滤词""" + stream_name = chat_manager.get_stream_name(chat.stream_id) or chat.stream_id + for word in global_config.ban_words: + if word in text: + logger.info( + f"[{stream_name}][{chat.group_info.group_name if chat.group_info else '私聊'}]" + f"{userinfo.user_nickname}:{text}" + ) + logger.info(f"[{stream_name}][过滤词识别] 消息中含有 '{word}',filtered") + return True + return False + + # 保持 staticmethod, 因为不依赖实例状态, 但需要 chat 对象来获取日志上下文 + @staticmethod + def _check_ban_regex(text: str, chat: ChatStream, userinfo: UserInfo) -> bool: + """检查消息是否匹配过滤正则表达式""" + stream_name = chat_manager.get_stream_name(chat.stream_id) or chat.stream_id + for pattern in global_config.ban_msgs_regex: + if pattern.search(text): + logger.info( + f"[{stream_name}][{chat.group_info.group_name if chat.group_info else '私聊'}]" + f"{userinfo.user_nickname}:{text}" + ) + logger.info(f"[{stream_name}][正则表达式过滤] 消息匹配到 '{pattern.pattern}',filtered") + return True + return False + + # 改为实例方法, 移除 chat 参数 + + async def start_chat(self): + """先进行异步初始化,然后启动聊天任务。""" + if not self._initialized: + await self.initialize() # Ensure initialized before starting tasks + + if self._chat_task is None or self._chat_task.done(): + logger.info(f"[{self.stream_name}] 开始后台处理初始兴趣消息和轮询任务...") + # Process initial messages first + await self._process_initial_interest_messages() + # Then start polling task + polling_task = asyncio.create_task(self._reply_interested_message()) + polling_task.add_done_callback(lambda t: self._handle_task_completion(t)) + self._chat_task = polling_task + else: + logger.info(f"[{self.stream_name}] 聊天轮询任务已在运行中。") + + def _handle_task_completion(self, task: asyncio.Task): + """任务完成回调处理""" + if task is not self._chat_task: + logger.warning(f"[{self.stream_name}] 收到未知任务回调") + return + try: + if exc := task.exception(): + logger.error(f"[{self.stream_name}] 任务异常: {exc}") + logger.error(traceback.format_exc()) + except asyncio.CancelledError: + logger.debug(f"[{self.stream_name}] 任务已取消") + except Exception as e: + logger.error(f"[{self.stream_name}] 回调处理错误: {e}") + finally: + if self._chat_task is task: + self._chat_task = None + logger.debug(f"[{self.stream_name}] 任务清理完成") + + # 改为实例方法, 移除 stream_id 参数 + async def stop_chat(self): + """停止当前实例的兴趣监控任务。""" + if self._chat_task and not self._chat_task.done(): + task = self._chat_task + logger.debug(f"[{self.stream_name}] 尝试取消normal聊天任务。") + task.cancel() + try: + await task # 等待任务响应取消 + except asyncio.CancelledError: + logger.info(f"[{self.stream_name}] 结束一般聊天模式。") + except Exception as e: + # 回调函数 _handle_task_completion 会处理异常日志 + logger.warning(f"[{self.stream_name}] 等待监控任务取消时捕获到异常 (可能已在回调中记录): {e}") + finally: + # 确保任务状态更新,即使等待出错 (回调函数也会尝试更新) + if self._chat_task is task: + self._chat_task = None + + # 清理所有未处理的思考消息 + try: + container = await message_manager.get_container(self.stream_id) + if container: + # 查找并移除所有 MessageThinking 类型的消息 + thinking_messages = [msg for msg in container.messages[:] if isinstance(msg, MessageThinking)] + if thinking_messages: + for msg in thinking_messages: + container.messages.remove(msg) + logger.info(f"[{self.stream_name}] 清理了 {len(thinking_messages)} 条未处理的思考消息。") + except Exception as e: + logger.error(f"[{self.stream_name}] 清理思考消息时出错: {e}") + logger.error(traceback.format_exc()) diff --git a/src/experimental/Legacy_HFC/normal_chat_generator.py b/src/experimental/Legacy_HFC/normal_chat_generator.py new file mode 100644 index 00000000..80e57b5a --- /dev/null +++ b/src/experimental/Legacy_HFC/normal_chat_generator.py @@ -0,0 +1,163 @@ +from typing import List, Optional, Tuple, Union +import random +from src.chat.models.utils_model import LLMRequest +from ...config.config import global_config +from src.chat.message_receive.message import MessageThinking +from .heartflow_prompt_builder import prompt_builder +from src.chat.utils.utils import process_llm_response +from src.chat.utils.timer_calculator import Timer +from src.common.logger_manager import get_logger +from src.chat.utils.info_catcher import info_catcher_manager + + +logger = get_logger("llm") + + +class NormalChatGenerator: + def __init__(self): + self.model_reasoning = LLMRequest( + model=global_config.llm_reasoning, + temperature=0.7, + max_tokens=3000, + request_type="response_reasoning", + ) + self.model_normal = LLMRequest( + model=global_config.llm_normal, + temperature=global_config.llm_normal["temp"], + max_tokens=256, + request_type="response_reasoning", + ) + + self.model_sum = LLMRequest( + model=global_config.llm_summary, temperature=0.7, max_tokens=3000, request_type="relation" + ) + self.current_model_type = "r1" # 默认使用 R1 + self.current_model_name = "unknown model" + + async def generate_response(self, message: MessageThinking, thinking_id: str) -> Optional[Union[str, List[str]]]: + """根据当前模型类型选择对应的生成函数""" + # 从global_config中获取模型概率值并选择模型 + if random.random() < global_config.model_reasoning_probability: + self.current_model_type = "深深地" + current_model = self.model_reasoning + else: + self.current_model_type = "浅浅的" + current_model = self.model_normal + + logger.info( + f"{self.current_model_type}思考:{message.processed_plain_text[:30] + '...' if len(message.processed_plain_text) > 30 else message.processed_plain_text}" + ) # noqa: E501 + + model_response = await self._generate_response_with_model(message, current_model, thinking_id) + + if model_response: + logger.info(f"{global_config.BOT_NICKNAME}的回复是:{model_response}") + model_response = await self._process_response(model_response) + + return model_response + else: + logger.info(f"{self.current_model_type}思考,失败") + return None + + async def _generate_response_with_model(self, message: MessageThinking, model: LLMRequest, thinking_id: str): + info_catcher = info_catcher_manager.get_info_catcher(thinking_id) + + if message.chat_stream.user_info.user_cardname and message.chat_stream.user_info.user_nickname: + sender_name = ( + f"[({message.chat_stream.user_info.user_id}){message.chat_stream.user_info.user_nickname}]" + f"{message.chat_stream.user_info.user_cardname}" + ) + elif message.chat_stream.user_info.user_nickname: + sender_name = f"({message.chat_stream.user_info.user_id}){message.chat_stream.user_info.user_nickname}" + else: + sender_name = f"用户({message.chat_stream.user_info.user_id})" + # 构建prompt + with Timer() as t_build_prompt: + prompt = await prompt_builder.build_prompt( + build_mode="normal", + reason="", + current_mind_info="", + structured_info="", + message_txt=message.processed_plain_text, + sender_name=sender_name, + chat_stream=message.chat_stream, + ) + logger.debug(f"构建prompt时间: {t_build_prompt.human_readable}") + + try: + content, reasoning_content, self.current_model_name = await model.generate_response(prompt) + + logger.debug(f"prompt:{prompt}\n生成回复:{content}") + + logger.info(f"对 {message.processed_plain_text} 的回复:{content}") + + info_catcher.catch_after_llm_generated( + prompt=prompt, response=content, reasoning_content=reasoning_content, model_name=self.current_model_name + ) + + except Exception: + logger.exception("生成回复时出错") + return None + + return content + + async def _get_emotion_tags(self, content: str, processed_plain_text: str): + """提取情感标签,结合立场和情绪""" + try: + # 构建提示词,结合回复内容、被回复的内容以及立场分析 + prompt = f""" + 请严格根据以下对话内容,完成以下任务: + 1. 判断回复者对被回复者观点的直接立场: + - "支持":明确同意或强化被回复者观点 + - "反对":明确反驳或否定被回复者观点 + - "中立":不表达明确立场或无关回应 + 2. 从"开心,愤怒,悲伤,惊讶,平静,害羞,恐惧,厌恶,困惑"中选出最匹配的1个情感标签 + 3. 按照"立场-情绪"的格式直接输出结果,例如:"反对-愤怒" + 4. 考虑回复者的人格设定为{global_config.personality_core} + + 对话示例: + 被回复:「A就是笨」 + 回复:「A明明很聪明」 → 反对-愤怒 + + 当前对话: + 被回复:「{processed_plain_text}」 + 回复:「{content}」 + + 输出要求: + - 只需输出"立场-情绪"结果,不要解释 + - 严格基于文字直接表达的对立关系判断 + """ + + # 调用模型生成结果 + result, _, _ = await self.model_sum.generate_response(prompt) + result = result.strip() + + # 解析模型输出的结果 + if "-" in result: + stance, emotion = result.split("-", 1) + valid_stances = ["支持", "反对", "中立"] + valid_emotions = ["开心", "愤怒", "悲伤", "惊讶", "害羞", "平静", "恐惧", "厌恶", "困惑"] + if stance in valid_stances and emotion in valid_emotions: + return stance, emotion # 返回有效的立场-情绪组合 + else: + logger.debug(f"无效立场-情感组合:{result}") + return "中立", "平静" # 默认返回中立-平静 + else: + logger.debug(f"立场-情感格式错误:{result}") + return "中立", "平静" # 格式错误时返回默认值 + + except Exception as e: + logger.debug(f"获取情感标签时出错: {e}") + return "中立", "平静" # 出错时返回默认值 + + @staticmethod + async def _process_response(content: str) -> Tuple[List[str], List[str]]: + """处理响应内容,返回处理后的内容和情感标签""" + if not content: + return None, [] + + processed_response = process_llm_response(content) + + # print(f"得到了处理后的llm返回{processed_response}") + + return processed_response diff --git a/src/experimental/Legacy_HFC/schedule/schedule_generator.py b/src/experimental/Legacy_HFC/schedule/schedule_generator.py new file mode 100644 index 00000000..887e334d --- /dev/null +++ b/src/experimental/Legacy_HFC/schedule/schedule_generator.py @@ -0,0 +1,307 @@ +import datetime +import os +import sys +import asyncio +from dateutil import tz + +# 添加项目根目录到 Python 路径 +root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) +sys.path.append(root_path) + +from src.common.database import db # noqa: E402 +from src.common.logger import get_module_logger, SCHEDULE_STYLE_CONFIG, LogConfig # noqa: E402 +from src.chat.models.utils_model import LLMRequest # noqa: E402 +from src.config.config import global_config # noqa: E402 + +TIME_ZONE = tz.gettz(global_config.TIME_ZONE) # 设置时区 + + +schedule_config = LogConfig( + # 使用海马体专用样式 + console_format=SCHEDULE_STYLE_CONFIG["console_format"], + file_format=SCHEDULE_STYLE_CONFIG["file_format"], +) +logger = get_module_logger("scheduler", config=schedule_config) + + +class ScheduleGenerator: + # enable_output: bool = True + + def __init__(self): + # 使用离线LLM模型 + self.enable_output = None + self.llm_scheduler_all = LLMRequest( + model=global_config.llm_scheduler_all, + temperature=global_config.llm_scheduler_all["temp"], + max_tokens=7000, + request_type="schedule", + ) + self.llm_scheduler_doing = LLMRequest( + model=global_config.llm_scheduler_doing, + temperature=global_config.llm_scheduler_doing["temp"], + max_tokens=2048, + request_type="schedule", + ) + + self.today_schedule_text = "" + self.today_done_list = [] + + self.yesterday_schedule_text = "" + self.yesterday_done_list = [] + + self.name = "" + self.personality = "" + self.behavior = "" + + self.start_time = datetime.datetime.now(TIME_ZONE) + + self.schedule_doing_update_interval = 300 # 最好大于60 + + def initialize( + self, + name: str = "bot_name", + personality: str = "你是一个爱国爱党的新时代青年", + behavior: str = "你非常外向,喜欢尝试新事物和人交流", + interval: int = 60, + ): + """初始化日程系统""" + self.name = name + self.behavior = behavior + self.schedule_doing_update_interval = interval + self.personality = personality + + async def mai_schedule_start(self): + """启动日程系统,每5分钟执行一次move_doing,并在日期变化时重新检查日程""" + try: + if global_config.ENABLE_SCHEDULE_GEN: + logger.info(f"日程系统启动/刷新时间: {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}") + # 初始化日程 + await self.check_and_create_today_schedule() + # self.print_schedule() + + while True: + # print(self.get_current_num_task(1, True)) + + current_time = datetime.datetime.now(TIME_ZONE) + + # 检查是否需要重新生成日程(日期变化) + if current_time.date() != self.start_time.date(): + logger.info("检测到日期变化,重新生成日程") + self.start_time = current_time + await self.check_and_create_today_schedule() + # self.print_schedule() + + # 执行当前活动 + # mind_thinking = heartflow.current_state.current_mind + + await self.move_doing() + + await asyncio.sleep(self.schedule_doing_update_interval) + else: + logger.info("日程系统未启用") + + except Exception as e: + logger.error(f"日程系统运行时出错: {str(e)}") + logger.exception("详细错误信息:") + + async def check_and_create_today_schedule(self): + """检查昨天的日程,并确保今天有日程安排 + + Returns: + tuple: (today_schedule_text, today_schedule) 今天的日程文本和解析后的日程字典 + """ + today = datetime.datetime.now(TIME_ZONE) + yesterday = today - datetime.timedelta(days=1) + + # 先检查昨天的日程 + self.yesterday_schedule_text, self.yesterday_done_list = self.load_schedule_from_db(yesterday) + if self.yesterday_schedule_text: + logger.debug(f"已加载{yesterday.strftime('%Y-%m-%d')}的日程") + + # 检查今天的日程 + self.today_schedule_text, self.today_done_list = self.load_schedule_from_db(today) + if not self.today_done_list: + self.today_done_list = [] + if not self.today_schedule_text: + logger.info(f"{today.strftime('%Y-%m-%d')}的日程不存在,准备生成新的日程") + try: + self.today_schedule_text = await self.generate_daily_schedule(target_date=today) + except Exception as e: + logger.error(f"生成日程时发生错误: {str(e)}") + self.today_schedule_text = "" + + self.save_today_schedule_to_db() + + def construct_daytime_prompt(self, target_date: datetime.datetime): + date_str = target_date.strftime("%Y-%m-%d") + weekday = target_date.strftime("%A") + + prompt = f"你是{self.name},{self.personality},{self.behavior}" + prompt += f"你昨天的日程是:{self.yesterday_schedule_text}\n" + prompt += f"请为你生成{date_str}({weekday}),也就是今天的日程安排,结合你的个人特点和行为习惯以及昨天的安排\n" + prompt += "推测你的日程安排,包括你一天都在做什么,从起床到睡眠,有什么发现和思考,具体一些,详细一些,需要1500字以上,精确到每半个小时,记得写明时间\n" # noqa: E501 + prompt += "直接返回你的日程,现实一点,不要浮夸,从起床到睡觉,不要输出其他内容:" + return prompt + + def construct_doing_prompt(self, time: datetime.datetime, mind_thinking: str = ""): + now_time = time.strftime("%H:%M") + previous_doings = self.get_current_num_task(5, True) + + prompt = f"你是{self.name},{self.personality},{self.behavior}" + prompt += f"你今天的日程是:{self.today_schedule_text}\n" + if previous_doings: + prompt += f"你之前做了的事情是:{previous_doings},从之前到现在已经过去了{self.schedule_doing_update_interval / 60}分钟了\n" # noqa: E501 + if mind_thinking: + prompt += f"你脑子里在想:{mind_thinking}\n" + prompt += f"现在是{now_time},结合你的个人特点和行为习惯,注意关注你今天的日程安排和想法安排你接下来做什么,现实一点,不要浮夸" + prompt += "安排你接下来做什么,具体一些,详细一些\n" + prompt += "直接返回你在做的事情,注意是当前时间,不要输出其他内容:" + return prompt + + async def generate_daily_schedule( + self, + target_date: datetime.datetime = None, + ) -> dict[str, str]: + daytime_prompt = self.construct_daytime_prompt(target_date) + daytime_response, _ = await self.llm_scheduler_all.generate_response_async(daytime_prompt) + return daytime_response + + def print_schedule(self): + """打印完整的日程安排""" + if not self.today_schedule_text: + logger.warning("今日日程有误,将在下次运行时重新生成") + db.schedule.delete_one({"date": datetime.datetime.now(TIME_ZONE).strftime("%Y-%m-%d")}) + else: + logger.info("=== 今日日程安排 ===") + logger.info(self.today_schedule_text) + logger.info("==================") + self.enable_output = False + + async def update_today_done_list(self): + # 更新数据库中的 today_done_list + today_str = datetime.datetime.now(TIME_ZONE).strftime("%Y-%m-%d") + existing_schedule = db.schedule.find_one({"date": today_str}) + + if existing_schedule: + # 更新数据库中的 today_done_list + db.schedule.update_one({"date": today_str}, {"$set": {"today_done_list": self.today_done_list}}) + logger.debug(f"已更新{today_str}的已完成活动列表") + else: + logger.warning(f"未找到{today_str}的日程记录") + + async def move_doing(self, mind_thinking: str = ""): + try: + current_time = datetime.datetime.now(TIME_ZONE) + if mind_thinking: + doing_prompt = self.construct_doing_prompt(current_time, mind_thinking) + else: + doing_prompt = self.construct_doing_prompt(current_time) + + doing_response, _ = await self.llm_scheduler_doing.generate_response_async(doing_prompt) + self.today_done_list.append((current_time, doing_response)) + + await self.update_today_done_list() + + logger.info(f"当前活动: {doing_response}") + + return doing_response + except GeneratorExit: + logger.warning("日程生成被中断") + return "日程生成被中断" + except Exception as e: + logger.error(f"生成日程时发生错误: {str(e)}") + return "生成日程时发生错误" + + async def get_task_from_time_to_time(self, start_time: str, end_time: str): + """获取指定时间范围内的任务列表 + + Args: + start_time (str): 开始时间,格式为"HH:MM" + end_time (str): 结束时间,格式为"HH:MM" + + Returns: + list: 时间范围内的任务列表 + """ + result = [] + for task in self.today_done_list: + task_time = task[0] # 获取任务的时间戳 + task_time_str = task_time.strftime("%H:%M") + + # 检查任务时间是否在指定范围内 + if self._time_diff(start_time, task_time_str) >= 0 and self._time_diff(task_time_str, end_time) >= 0: + result.append(task) + + return result + + def get_current_num_task(self, num=1, time_info=False): + """获取最新加入的指定数量的日程 + + Args: + num (int): 需要获取的日程数量,默认为1 + time_info (bool): 是否包含时间信息,默认为False + + Returns: + list: 最新加入的日程列表 + """ + if not self.today_done_list: + return [] + + # 确保num不超过列表长度 + num = min(num, len(self.today_done_list)) + pre_doings = "" + for doing in self.today_done_list[-num:]: + if time_info: + time_str = doing[0].strftime("%H:%M") + pre_doings += time_str + "时," + doing[1] + "\n" + else: + pre_doings += doing[1] + "\n" + + # 返回最新的num条日程 + return pre_doings + + def save_today_schedule_to_db(self): + """保存日程到数据库,同时初始化 today_done_list""" + date_str = datetime.datetime.now(TIME_ZONE).strftime("%Y-%m-%d") + schedule_data = { + "date": date_str, + "schedule": self.today_schedule_text, + "today_done_list": self.today_done_list if hasattr(self, "today_done_list") else [], + } + # 使用 upsert 操作,如果存在则更新,不存在则插入 + db.schedule.update_one({"date": date_str}, {"$set": schedule_data}, upsert=True) + logger.debug(f"已保存{date_str}的日程到数据库") + + @staticmethod + def load_schedule_from_db(date: datetime.datetime): + """从数据库加载日程,同时加载 today_done_list""" + date_str = date.strftime("%Y-%m-%d") + existing_schedule = db.schedule.find_one({"date": date_str}) + + if existing_schedule: + schedule_text = existing_schedule["schedule"] + return schedule_text, existing_schedule.get("today_done_list", []) + else: + logger.debug(f"{date_str}的日程不存在") + return None, None + + +async def main(): + # 使用示例 + scheduler = ScheduleGenerator() + scheduler.initialize( + name="麦麦", + personality="你叫麦麦,你19岁,是一个大二的女大学生,你有一头黑色短发,你会刷贴吧,你现在在学习心理学", + behavior="你比较内向,一般熬夜比较晚,然后第二天早上10点起床吃早午饭", + interval=60, + ) + await scheduler.mai_schedule_start() + + +# 当作为组件导入时使用的实例 +bot_schedule = ScheduleGenerator() + +if __name__ == "__main__": + import asyncio + + # 当直接运行此文件时执行 + asyncio.run(main()) diff --git a/src/experimental/PFC/PFC_idle/idle_chat.py b/src/experimental/PFC/PFC_idle/idle_chat.py index 9b654087..59734b23 100644 --- a/src/experimental/PFC/PFC_idle/idle_chat.py +++ b/src/experimental/PFC/PFC_idle/idle_chat.py @@ -1,3 +1,5 @@ +# TODO: 开机自启,遍历所有可发起的聊天流,而不是等待 PFC 实例结束 +# TODO: 优化 idle 逻辑 增强其与 PFC 模式的联动 from typing import Optional, Dict, Set import asyncio import time @@ -17,7 +19,7 @@ from src.chat.utils.chat_message_builder import build_readable_messages from ..chat_observer import ChatObserver from ..message_sender import DirectMessageSender from src.chat.message_receive.chat_stream import ChatStream, chat_manager -from maim_message import UserInfo +from maim_message import UserInfo, Seg from ..pfc_relationship import PfcRepationshipTranslator from rich.traceback import install @@ -161,7 +163,7 @@ class IdleChat: """启动主动聊天检测""" # 检查是否启用了主动聊天功能 if not global_config.enable_idle_chat: - logger.info(f"[私聊][{self.private_name}]主动聊天功能已禁用(配置ENABLE_IDLE_CHAT=False)") + logger.info(f"[私聊][{self.private_name}]主动聊天功能已禁用(配置enable_idle_chat=False)") return if self._running: @@ -262,8 +264,6 @@ class IdleChat: # 获取关系值 relationship_value = 0 try: - - # 尝试获取person_id person_id = None try: @@ -426,7 +426,6 @@ class IdleChat: async def _get_chat_stream(self) -> Optional[ChatStream]: """获取聊天流实例""" try: - existing_chat_stream = chat_manager.get_stream(self.stream_id) if existing_chat_stream: logger.debug(f"[私聊][{self.private_name}]从chat_manager找到现有聊天流") @@ -535,8 +534,11 @@ class IdleChat: # 发送消息 try: + segments = Seg(type="seglist", data=[Seg(type="text", data=content)]) logger.debug(f"[私聊][{self.private_name}]准备发送主动聊天消息: {content}") - await self.message_sender.send_message(chat_stream=chat_stream, content=content, reply_to_message=None) + await self.message_sender.send_message( + chat_stream=chat_stream, segments=segments, reply_to_message=None, content=content + ) logger.info(f"[私聊][{self.private_name}]成功主动发起聊天: {content}") except Exception as e: logger.error(f"[私聊][{self.private_name}]发送主动聊天消息失败: {str(e)}") diff --git a/src/experimental/PFC/action_factory.py b/src/experimental/PFC/action_factory.py new file mode 100644 index 00000000..043ad740 --- /dev/null +++ b/src/experimental/PFC/action_factory.py @@ -0,0 +1,66 @@ +from abc import ABC, abstractmethod +from typing import Type, TYPE_CHECKING + +# 从 action_handlers.py 导入具体的处理器类 +from .action_handlers import ( # 调整导入路径 + ActionHandler, + DirectReplyHandler, + SendNewMessageHandler, + SayGoodbyeHandler, + SendMemesHandler, + RethinkGoalHandler, + ListeningHandler, + EndConversationHandler, + BlockAndIgnoreHandler, + WaitHandler, + UnknownActionHandler, +) + +if TYPE_CHECKING: + from PFC.conversation import Conversation # 调整导入路径 + + +class AbstractActionFactory(ABC): + """抽象动作工厂接口。""" + + @abstractmethod + def create_action_handler(self, action_type: str, conversation: "Conversation") -> ActionHandler: + """ + 根据动作类型创建并返回相应的动作处理器。 + + 参数: + action_type (str): 动作的类型字符串。 + conversation (Conversation): 当前对话实例。 + + 返回: + ActionHandler: 对应动作类型的处理器实例。 + """ + pass + + +class StandardActionFactory(AbstractActionFactory): + """标准的动作工厂实现。""" + + def create_action_handler(self, action_type: str, conversation: "Conversation") -> ActionHandler: + """ + 根据动作类型创建并返回具体的动作处理器实例。 + """ + # 动作类型到处理器类的映射 + handler_map: dict[str, Type[ActionHandler]] = { + "direct_reply": DirectReplyHandler, + "send_new_message": SendNewMessageHandler, + "say_goodbye": SayGoodbyeHandler, + "send_memes": SendMemesHandler, + "rethink_goal": RethinkGoalHandler, + "listening": ListeningHandler, + "end_conversation": EndConversationHandler, + "block_and_ignore": BlockAndIgnoreHandler, + "wait": WaitHandler, + } + handler_class = handler_map.get(action_type) # 获取对应的处理器类 + # 如果找到对应的处理器类 + if handler_class: + return handler_class(conversation) # 创建并返回处理器实例 + else: + # 如果未找到,返回处理未知动作的默认处理器 + return UnknownActionHandler(conversation) diff --git a/src/experimental/PFC/action_handlers.py b/src/experimental/PFC/action_handlers.py new file mode 100644 index 00000000..1f183c6e --- /dev/null +++ b/src/experimental/PFC/action_handlers.py @@ -0,0 +1,1050 @@ +from abc import ABC, abstractmethod +import time +import asyncio +import traceback +import json +import random +from typing import Optional, Set, TYPE_CHECKING, List, Tuple, Dict # 确保导入 Dict + +from src.chat.emoji_system.emoji_manager import emoji_manager +from src.common.logger_manager import get_logger +from src.config.config import global_config +from src.chat.utils.chat_message_builder import build_readable_messages +from .pfc_types import ConversationState +from .observation_info import ObservationInfo +from .conversation_info import ConversationInfo +from src.chat.utils.utils_image import image_path_to_base64 +from maim_message import Seg + +if TYPE_CHECKING: + from .conversation import Conversation + +logger = get_logger("pfc_action_handlers") + + +class ActionHandler(ABC): + """ + 处理动作的抽象基类。 + 每个具体的动作处理器都应继承此类并实现 execute 方法。 + """ + + def __init__(self, conversation: "Conversation"): + """ + 初始化动作处理器。 + + Args: + conversation (Conversation): 当前对话实例。 + """ + self.conversation = conversation + self.logger = logger + + @abstractmethod + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """ + 执行具体的动作逻辑。 + + Args: + reason (str): 执行此动作的规划原因。 + observation_info (Optional[ObservationInfo]): 当前的观察信息。 + conversation_info (Optional[ConversationInfo]): 当前的对话信息。 + action_start_time (float): 动作开始的时间戳。 + current_action_record (dict): 用于记录此动作执行情况的字典。 + + Returns: + tuple[bool, str, str]: 一个元组,包含: + - action_successful (bool): 动作是否成功执行。 + - final_status (str): 动作的最终状态 (如 "done", "recall", "error")。 + - final_reason (str): 动作最终状态的原因或描述。 + """ + pass + + async def _send_reply_or_segments(self, segments_data: list[Seg], content_for_log: str) -> bool: + """ + 内部辅助函数,用于将构造好的消息段发送出去。 + + Args: + segments_data (list[Seg]): 包含待发送内容的 Seg 对象列表。 + content_for_log (str): 用于日志记录的消息内容的简短描述。 + + Returns: + bool: 消息是否发送成功。 + """ + if not self.conversation.direct_sender: + self.logger.error(f"[私聊][{self.conversation.private_name}] DirectMessageSender 未初始化,无法发送。") + return False + if not self.conversation.chat_stream: + self.logger.error(f"[私聊][{self.conversation.private_name}] ChatStream 未初始化,无法发送。") + return False + + try: + # 将 Seg 对象列表包装在 type="seglist" 的 Seg 对象中 + final_segments = Seg(type="seglist", data=segments_data) + # 调用实际的发送方法 + await self.conversation.direct_sender.send_message( + chat_stream=self.conversation.chat_stream, + segments=final_segments, + reply_to_message=None, # 私聊通常不引用回复 + content=content_for_log, # 用于发送器内部的日志记录 + ) + # 注意: my_message_count 的增加现在由具体的发送逻辑(文本或表情)处理后决定 + return True + except Exception as e: + self.logger.error(f"[私聊][{self.conversation.private_name}] 发送消息时失败: {str(e)}") + self.logger.error(f"[私聊][{self.conversation.private_name}] {traceback.format_exc()}") + self.conversation.state = ConversationState.ERROR # 发送失败则标记错误状态 + return False + + async def _update_bot_message_in_history( + self, + send_time: float, + message_content: str, + observation_info: ObservationInfo, + message_id_prefix: str = "bot_sent_", + ): + """ + 在机器人成功发送消息后,将该消息添加到 ObservationInfo 的聊天历史中。 + + Args: + send_time (float): 消息发送成功的时间戳。 + message_content (str): 发送的消息内容(对于文本是其本身,对于表情是其描述)。 + observation_info (ObservationInfo): 当前的观察信息实例。 + message_id_prefix (str, optional): 生成消息ID时使用的前缀。默认为 "bot_sent_"。 + """ + if not self.conversation.bot_qq_str: + self.logger.warning(f"[私聊][{self.conversation.private_name}] Bot QQ ID 未知,无法更新机器人消息历史。") + return + + # 构造机器人发送的消息字典 + bot_message_dict: Dict[str, any] = { + "message_id": f"{message_id_prefix}{send_time:.3f}", # 使用更精确的时间戳 + "time": send_time, + "user_info": { + "user_id": self.conversation.bot_qq_str, + "user_nickname": global_config.BOT_NICKNAME, + "platform": self.conversation.chat_stream.platform + if self.conversation.chat_stream + else "unknown_platform", + }, + "processed_plain_text": message_content, # 历史记录中的纯文本使用传入的 message_content + "detailed_plain_text": message_content, # 详细文本也使用相同内容 + } + observation_info.chat_history.append(bot_message_dict) + observation_info.chat_history_count = len(observation_info.chat_history) + self.logger.debug( + f"[私聊][{self.conversation.private_name}] {global_config.BOT_NICKNAME}发送的消息 ('{message_content[:30]}...')已添加到 chat_history。当前历史数: {observation_info.chat_history_count}" + ) + + # 限制历史记录长度 + max_history_len = getattr(global_config, "pfc_max_chat_history_for_checker", 50) + if len(observation_info.chat_history) > max_history_len: + observation_info.chat_history = observation_info.chat_history[-max_history_len:] + observation_info.chat_history_count = len(observation_info.chat_history) + + # 更新用于 Prompt 的历史记录字符串 + history_slice_for_str = observation_info.chat_history[-30:] # 例如最近30条 + try: + observation_info.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, + ) + except Exception as e_build_hist: + self.logger.error(f"[私聊][{self.conversation.private_name}] 更新 chat_history_str 时出错: {e_build_hist}") + observation_info.chat_history_str = "[构建聊天记录出错]" + + async def _update_post_send_states( + self, + observation_info: ObservationInfo, + conversation_info: ConversationInfo, + action_type: str, # 例如 "direct_reply", "send_memes" + event_description_for_emotion: str, + ): + """ + 在成功发送一条或多条消息(文本/表情)后,处理通用的状态更新。 + 这包括更新 IdleChat、清理未处理消息、更新追问状态以及关系/情绪。 + + Args: + observation_info (ObservationInfo): 当前观察信息。 + conversation_info (ConversationInfo): 当前对话信息。 + action_type (str): 执行的动作类型,用于决定追问逻辑。 + event_description_for_emotion (str): 用于情绪更新的事件描述。 + """ + current_event_time = time.time() # 获取当前时间作为事件发生时间 + + # 更新 IdleChat 的最后消息时间 + if self.conversation.idle_chat: + await self.conversation.idle_chat.update_last_message_time(current_event_time) + + # 清理在本次交互完成(即此函数被调用时)之前的、来自他人的未处理消息 + current_unprocessed_messages = getattr(observation_info, "unprocessed_messages", []) + message_ids_to_clear: Set[str] = set() + timestamp_before_current_interaction_completion = current_event_time - 0.001 # 确保是严格之前 + + for msg in current_unprocessed_messages: + msg_time = msg.get("time") + msg_id = msg.get("message_id") + sender_id_info = msg.get("user_info", {}) + sender_id = str(sender_id_info.get("user_id")) if sender_id_info else None + + if ( + msg_id + and msg_time + and sender_id != self.conversation.bot_qq_str # 是对方的消息 + and msg_time < timestamp_before_current_interaction_completion # 在本次交互完成前 + ): + message_ids_to_clear.add(msg_id) + + if message_ids_to_clear: + self.logger.debug( + f"[私聊][{self.conversation.private_name}] 准备清理 {len(message_ids_to_clear)} 条交互完成前(他人)消息: {message_ids_to_clear}" + ) + await observation_info.clear_processed_messages(message_ids_to_clear) + + # 更新追问状态 (last_successful_reply_action) + other_new_msg_count_during_planning = getattr(conversation_info, "other_new_messages_during_planning_count", 0) + if action_type in ["direct_reply", "send_new_message", "send_memes"]: + if other_new_msg_count_during_planning > 0 and action_type == "direct_reply": + # 如果是直接回复,且规划期间有新消息,则下次不应追问 + conversation_info.last_successful_reply_action = None + else: + # 否则,记录本次成功的回复/表情动作为下次追问的依据 + conversation_info.last_successful_reply_action = action_type + + # 更新关系和情绪状态 + await self._update_relationship_and_emotion(observation_info, conversation_info, event_description_for_emotion) + + async def _update_relationship_and_emotion( + self, observation_info: ObservationInfo, conversation_info: ConversationInfo, event_description: str + ): + """ + 辅助方法:调用关系更新器和情绪更新器。 + + Args: + observation_info (ObservationInfo): 当前观察信息。 + conversation_info (ConversationInfo): 当前对话信息。 + event_description (str): 触发更新的事件描述。 + """ + # 更新关系值(增量) + if self.conversation.relationship_updater and self.conversation.chat_observer: + await self.conversation.relationship_updater.update_relationship_incremental( + conversation_info=conversation_info, + observation_info=observation_info, + chat_observer_for_history=self.conversation.chat_observer, + ) + # 更新情绪状态 + if self.conversation.emotion_updater and self.conversation.chat_observer: + await self.conversation.emotion_updater.update_emotion_based_on_context( + conversation_info=conversation_info, + observation_info=observation_info, + chat_observer_for_history=self.conversation.chat_observer, + event_description=event_description, + ) + + async def _fetch_and_prepare_emoji_segment(self, emoji_query: str) -> Optional[Tuple[Seg, str, str]]: + """ + 根据表情查询字符串获取表情图片,将其转换为 Base64 编码, + 并准备好发送所需的 Seg 对象和相关描述文本。 + + Args: + emoji_query (str): 用于搜索表情的查询字符串。 + + Returns: + Optional[Tuple[Seg, str, str]]: 如果成功,返回一个元组包含: + - emoji_segment (Seg): 构造好的用于发送的表情 Seg 对象。 + - full_emoji_description (str): 表情的完整描述。 + - log_content_for_emoji (str): 用于日志记录的表情描述(可能是截断的)。 + 如果失败,则返回 None。 + """ + self.logger.info(f"[私聊][{self.conversation.private_name}] 尝试获取表情,查询: '{emoji_query}'") + try: + emoji_result = await emoji_manager.get_emoji_for_text(emoji_query) + if emoji_result: + emoji_path, full_emoji_description = emoji_result + self.logger.info(f"获取到表情包: {emoji_path}, 描述: {full_emoji_description}") + + # 将图片路径转换为纯 Base64 字符串 + emoji_b64_content = image_path_to_base64(emoji_path) + if not emoji_b64_content: + self.logger.error(f"无法将图片 {emoji_path} 转换为Base64。") + return None + + # 根据用户提供的片段,Seg type="emoji" data 为纯 Base64 字符串 + emoji_segment = Seg(type="emoji", data=emoji_b64_content) + # 用于发送器日志的截断描述 + log_content_for_emoji = full_emoji_description[-20:] + "..." + + return emoji_segment, full_emoji_description, log_content_for_emoji + else: + self.logger.warning(f"未能根据查询 '{emoji_query}' 获取到合适的表情包。") + return None + except Exception as e: + self.logger.error(f"获取或准备表情图片时出错: {e}", exc_info=True) + return None + + +class BaseTextReplyHandler(ActionHandler): + """ + 处理基于文本的回复动作的基类,包含生成-检查-重试的循环。 + 适用于 DirectReplyHandler 和 SendNewMessageHandler。 + """ + + async def _generate_and_check_text_reply_loop( + self, + action_type: str, # "direct_reply" or "send_new_message" + observation_info: ObservationInfo, + conversation_info: ConversationInfo, + max_attempts: int, + ) -> Tuple[bool, Optional[str], str, bool, bool]: + """ + 管理生成文本回复并检查其适用性的循环。 + 对于 send_new_message,它还处理来自 ReplyGenerator 的初始 JSON 决策。 + + Args: + action_type (str): 当前动作类型 ("direct_reply" 或 "send_new_message")。 + observation_info (ObservationInfo): 当前观察信息。 + conversation_info (ConversationInfo): 当前对话信息。 + max_attempts (int): 最大尝试次数。 + + Returns: + Tuple[bool, Optional[str], str, bool, bool]: + - is_suitable (bool): 是否找到了合适的回复或作出了发送决策。 + - generated_content_to_send (Optional[str]): 检查通过后要发送的文本内容; + 如果 ReplyGenerator 决定不发送 (仅对 send_new_message),则为 None。 + - final_check_reason (str): 检查器或生成失败的原因。 + - need_replan (bool): 如果检查器明确要求重新规划。 + - should_send_reply_for_new_message (bool): 特定于 send_new_message, + 如果 ReplyGenerator 决定发送则为 True,否则为 False。对于 direct_reply,此值恒为 True。 + """ + reply_attempt_count = 0 + is_suitable = False # 标记内容是否通过检查 + generated_content_to_send: Optional[str] = None # 最终要发送的文本 + final_check_reason = "未开始检查" # 最终检查原因 + need_replan = False # 是否需要重新规划 + # direct_reply 总是尝试发送;send_new_message 的初始值取决于RG + should_send_reply_for_new_message = True if action_type == "direct_reply" else False + + while reply_attempt_count < max_attempts and not is_suitable and not need_replan: + reply_attempt_count += 1 + log_prefix = f"[私聊][{self.conversation.private_name}] 尝试生成/检查 '{action_type}' (第 {reply_attempt_count}/{max_attempts} 次)..." + self.logger.info(log_prefix) + + self.conversation.state = ConversationState.GENERATING # 设置状态为生成中 + if not self.conversation.reply_generator: + raise RuntimeError(f"ReplyGenerator 未为 {self.conversation.private_name} 初始化") + + # 调用 ReplyGenerator 生成原始回复 + raw_llm_output = await self.conversation.reply_generator.generate( + observation_info, conversation_info, action_type=action_type + ) + self.logger.debug(f"{log_prefix} ReplyGenerator.generate 返回: '{raw_llm_output}'") + current_content_for_check = raw_llm_output # 当前待检查的内容 + + # 如果是 send_new_message 动作,需要解析 JSON 判断是否发送 + if action_type == "send_new_message": + parsed_json = None + try: + parsed_json = json.loads(raw_llm_output) + except json.JSONDecodeError: # JSON 解析失败 + self.logger.error(f"{log_prefix} ReplyGenerator 返回的不是有效的JSON: {raw_llm_output}") + conversation_info.last_reply_rejection_reason = "回复生成器未返回有效JSON" + conversation_info.last_rejected_reply_content = raw_llm_output + should_send_reply_for_new_message = False # 标记不发送 + is_suitable = True # 决策已做出(不发送),所以认为是 "suitable" 以跳出循环 + final_check_reason = "回复生成器JSON解析失败,决定不发送" + generated_content_to_send = None # 明确不发送内容 + break # 跳出重试循环 + + if parsed_json: # JSON 解析成功 + send_decision = parsed_json.get("send", "no").lower() + generated_text_from_json = parsed_json.get("txt", "") # 如果不发送,txt可能是"no" + + if send_decision == "yes": # ReplyGenerator 决定发送 + should_send_reply_for_new_message = True + current_content_for_check = generated_text_from_json + self.logger.info( + f"{log_prefix} ReplyGenerator 决定发送消息。内容初步为: '{current_content_for_check[:100]}...'" + ) + else: # ReplyGenerator 决定不发送 + should_send_reply_for_new_message = False + is_suitable = True # 决策已做出(不发送) + final_check_reason = "回复生成器决定不发送" + generated_content_to_send = None + self.logger.info(f"{log_prefix} ReplyGenerator 决定不发送消息。") + break # 跳出重试循环 + + # 检查生成的内容是否有效(适用于 direct_reply 或 send_new_message 且决定发送的情况) + if ( + not current_content_for_check + or current_content_for_check.startswith("抱歉") + or current_content_for_check.strip() == "" + or ( + action_type == "send_new_message" + and current_content_for_check == "no" + and should_send_reply_for_new_message + ) + ): + warning_msg = f"{log_prefix} 生成内容无效或为错误提示" + if ( + action_type == "send_new_message" + and current_content_for_check == "no" + and should_send_reply_for_new_message + ): + warning_msg += " (ReplyGenerator决定发送但文本为'no')" + self.logger.warning(warning_msg + ",将进行下一次尝试 (如果适用)。") + final_check_reason = "生成内容无效" # 更新检查原因 + conversation_info.last_reply_rejection_reason = final_check_reason + conversation_info.last_rejected_reply_content = current_content_for_check + await asyncio.sleep(0.5) # 暂停后重试 + continue # 进入下一次循环 + + # --- 内容检查 --- + self.conversation.state = ConversationState.CHECKING # 设置状态为检查中 + if not self.conversation.reply_checker: + raise RuntimeError(f"ReplyChecker 未为 {self.conversation.private_name} 初始化") + + # 准备检查器所需参数 + current_goal_str = "" + if conversation_info.goal_list: + goal_item = conversation_info.goal_list[-1] + current_goal_str = goal_item.get("goal", "") if isinstance(goal_item, dict) else str(goal_item) + + chat_history_for_check = getattr(observation_info, "chat_history", []) + chat_history_text_for_check = getattr(observation_info, "chat_history_str", "") + current_time_value_for_check = observation_info.current_time_str or "获取时间失败" + + # 调用 ReplyChecker + if global_config.enable_pfc_reply_checker: + self.logger.debug(f"{log_prefix} 调用 ReplyChecker 检查 (配置已启用)...") + is_suitable_check, reason_check, need_replan_check = await self.conversation.reply_checker.check( + reply=current_content_for_check, + goal=current_goal_str, + chat_history=chat_history_for_check, + chat_history_text=chat_history_text_for_check, + current_time_str=current_time_value_for_check, + retry_count=(reply_attempt_count - 1), + ) + self.logger.info( + f"{log_prefix} ReplyChecker 结果: 合适={is_suitable_check}, 原因='{reason_check}', 需重规划={need_replan_check}" + ) + else: # ReplyChecker 未启用 + is_suitable_check, reason_check, need_replan_check = True, "ReplyChecker 已通过配置关闭", False + self.logger.debug(f"{log_prefix} [配置关闭] ReplyChecker 已跳过,默认回复为合适。") + + is_suitable = is_suitable_check # 更新内容是否合适 + final_check_reason = reason_check # 更新检查原因 + need_replan = need_replan_check # 更新是否需要重规划 + + if not is_suitable: # 如果内容不合适 + conversation_info.last_reply_rejection_reason = final_check_reason + conversation_info.last_rejected_reply_content = current_content_for_check + if final_check_reason == "机器人尝试发送重复消息" and not need_replan: + self.logger.warning(f"{log_prefix} 回复因自身重复被拒绝。将重试。") + elif not need_replan and reply_attempt_count < max_attempts: # 如果不需要重规划且还有尝试次数 + self.logger.warning(f"{log_prefix} 回复不合适: {final_check_reason}。将重试。") + else: # 需要重规划或已达到最大尝试次数 + self.logger.warning(f"{log_prefix} 回复不合适且(需要重规划或已达最大次数): {final_check_reason}") + break # 结束循环 + await asyncio.sleep(0.5) # 重试前暂停 + else: # 内容合适 + generated_content_to_send = current_content_for_check # 设置最终要发送的内容 + conversation_info.last_reply_rejection_reason = None # 清除上次拒绝原因 + conversation_info.last_rejected_reply_content = None # 清除上次拒绝内容 + break # 成功,跳出循环 + + # 确保 send_new_message 在 RG 决定不发送时,is_suitable 为 True,generated_content_to_send 为 None + if action_type == "send_new_message" and not should_send_reply_for_new_message: + is_suitable = True # 决策已完成(不发送) + generated_content_to_send = None # 确认不发送任何内容 + + return ( + is_suitable, + generated_content_to_send, + final_check_reason, + need_replan, + should_send_reply_for_new_message, + ) + + async def _process_and_send_reply_with_optional_emoji( + self, + action_type: str, # "direct_reply" or "send_new_message" + observation_info: ObservationInfo, + conversation_info: ConversationInfo, + max_reply_attempts: int, + ) -> Tuple[bool, bool, List[str], Optional[str], bool, str, bool]: + """ + 核心共享方法:处理文本生成/检查,获取表情,并按顺序发送。 + + Args: + action_type (str): "direct_reply" 或 "send_new_message"。 + observation_info (ObservationInfo): 当前观察信息。 + conversation_info (ConversationInfo): 当前对话信息。 + max_reply_attempts (int): 文本生成的最大尝试次数。 + + Returns: + Tuple[bool, bool, List[str], Optional[str], bool, str, bool]: + - sent_text_successfully (bool): 文本是否成功发送。 + - sent_emoji_successfully (bool): 表情是否成功发送。 + - final_reason_parts (List[str]): 描述发送结果的字符串列表。 + - full_emoji_description_if_sent (Optional[str]): 如果表情发送成功,其完整描述。 + - need_replan_from_text_check (bool): 文本检查是否要求重规划。 + - text_check_failure_reason (str): 文本检查失败的原因(如果适用)。 + - rg_decided_not_to_send_text (bool): ReplyGenerator是否决定不发送文本 (仅send_new_message)。 + """ + sent_text_successfully = False + sent_emoji_successfully = False + final_reason_parts: List[str] = [] + full_emoji_description_if_sent: Optional[str] = None + + # 1. 处理文本部分 + ( + is_suitable_text, + generated_text_content, + text_check_reason, + need_replan_text, + rg_decided_to_send_text, + ) = await self._generate_and_check_text_reply_loop( + action_type=action_type, + observation_info=observation_info, + conversation_info=conversation_info, + max_attempts=max_reply_attempts, + ) + + text_to_send: Optional[str] = None + # 对于 send_new_message,只有当 RG 决定发送且内容合适时才有文本 + if action_type == "send_new_message": + if rg_decided_to_send_text and is_suitable_text and generated_text_content: + text_to_send = generated_text_content + # 对于 direct_reply,只要内容合适就有文本 + elif action_type == "direct_reply": + if is_suitable_text and generated_text_content: + text_to_send = generated_text_content + + rg_decided_not_to_send_text = action_type == "send_new_message" and not rg_decided_to_send_text + + # 2. 处理表情部分 + emoji_prepared_info: Optional[Tuple[Seg, str, str]] = None # (segment, full_description, log_description) + emoji_query = conversation_info.current_emoji_query + if emoji_query: + emoji_prepared_info = await self._fetch_and_prepare_emoji_segment(emoji_query) + # 清理查询,无论是否成功获取,避免重复使用 + conversation_info.current_emoji_query = None # 重要:在这里清理 + + # 3. 决定发送顺序并发送 + send_order: List[str] = [] + if text_to_send and emoji_prepared_info: # 文本和表情都有 + send_order = ["text", "emoji"] if random.random() < 0.5 else ["emoji", "text"] + elif text_to_send: # 只有文本 + send_order = ["text"] + elif emoji_prepared_info: # 只有表情 (可能是 direct_reply 带表情,或 send_new_message 时 RG 不发文本但有表情) + send_order = ["emoji"] + + for item_type in send_order: + current_send_time = time.time() # 每次发送前获取精确时间 + if item_type == "text" and text_to_send: + self.conversation.generated_reply = text_to_send # 用于日志和历史记录 + text_segment = Seg(type="text", data=text_to_send) + if await self._send_reply_or_segments([text_segment], text_to_send): + sent_text_successfully = True + await self._update_bot_message_in_history(current_send_time, text_to_send, observation_info) + if self.conversation.conversation_info: + self.conversation.conversation_info.current_instance_message_count += 1 + self.conversation.conversation_info.my_message_count += 1 # 文本发送成功,增加计数 + final_reason_parts.append(f"成功发送文本 ('{text_to_send[:20]}...')") + else: + final_reason_parts.append("发送文本失败") + # 如果文本发送失败,通常不应继续发送表情,除非有特殊需求 + break + elif item_type == "emoji" and emoji_prepared_info: + emoji_segment, full_emoji_desc, log_emoji_desc = emoji_prepared_info + if await self._send_reply_or_segments([emoji_segment], log_emoji_desc): + sent_emoji_successfully = True + full_emoji_description_if_sent = full_emoji_desc + await self._update_bot_message_in_history( + current_send_time, full_emoji_desc, observation_info, "bot_emoji_" + ) + if self.conversation.conversation_info: + self.conversation.conversation_info.current_instance_message_count += 1 + self.conversation.conversation_info.my_message_count += 1 # 表情发送成功,增加计数 + final_reason_parts.append(f"成功发送表情 ({full_emoji_desc})") + else: + final_reason_parts.append("发送表情失败") + # 如果表情发送失败,但文本已成功,也应记录 + if not text_to_send: # 如果只有表情且表情失败 + break + + return ( + sent_text_successfully, + sent_emoji_successfully, + final_reason_parts, + full_emoji_description_if_sent, + need_replan_text, + text_check_reason if not is_suitable_text else "文本检查通过或未执行", # 返回文本检查失败的原因 + rg_decided_not_to_send_text, + ) + + +class DirectReplyHandler(BaseTextReplyHandler): + """处理直接回复动作(direct_reply)的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """ + 执行直接回复动作。 + 会尝试生成文本回复,并根据 current_emoji_query 发送附带表情。 + """ + if not observation_info or not conversation_info: + self.logger.error( + f"[私聊][{self.conversation.private_name}] DirectReplyHandler: ObservationInfo 或 ConversationInfo 为空。" + ) + return False, "error", "内部信息缺失,无法执行直接回复" + + action_successful = False # 整体动作是否成功 + final_status = "recall" # 默认最终状态 + final_reason = "直接回复动作未成功执行" # 默认最终原因 + max_reply_attempts: int = getattr(global_config, "pfc_max_reply_attempts", 3) + + ( + sent_text_successfully, + sent_emoji_successfully, + reason_parts, + full_emoji_desc, + need_replan_from_text_check, + text_check_failure_reason, + _, # rg_decided_not_to_send_text, direct_reply 不关心这个 + ) = await self._process_and_send_reply_with_optional_emoji( + action_type="direct_reply", + observation_info=observation_info, + conversation_info=conversation_info, + max_reply_attempts=max_reply_attempts, + ) + + # 根据发送结果决定最终状态 + if sent_text_successfully or sent_emoji_successfully: + action_successful = True + final_status = "done" + final_reason = "; ".join(reason_parts) if reason_parts else "成功完成操作" + + # 统一调用发送后状态更新 + event_desc_parts = [] + if sent_text_successfully and self.conversation.generated_reply: + event_desc_parts.append(f"你回复了: '{self.conversation.generated_reply[:30]}...'") + if sent_emoji_successfully and full_emoji_desc: + event_desc_parts.append(f"并发送了表情: '{full_emoji_desc}'") + event_desc = " ".join(event_desc_parts) if event_desc_parts else "机器人发送了消息" + await self._update_post_send_states(observation_info, conversation_info, "direct_reply", event_desc) + + elif need_replan_from_text_check: # 文本检查要求重规划 + final_status = "recall" + final_reason = f"文本回复检查要求重新规划: {text_check_failure_reason}" + conversation_info.last_successful_reply_action = None # 重置追问状态 + else: # 文本和表情都未能发送,或者文本检查失败且不需重规划(已达最大尝试) + final_status = "max_checker_attempts_failed" if not need_replan_from_text_check else "recall" + final_reason = f"直接回复失败。文本检查: {text_check_failure_reason}. " + ( + "; ".join(reason_parts) if reason_parts else "" + ) + action_successful = False + conversation_info.last_successful_reply_action = None # 重置追问状态 + + # 清理 my_message_count (如果动作整体不成功,但部分发送了,需要调整) + if not action_successful and conversation_info: + # _process_and_send_reply_with_optional_emoji 内部会增加 my_message_count + # 如果这里 action_successful 为 False,说明可能部分发送了但整体认为是失败 + # 这种情况下 my_message_count 可能需要调整,但目前逻辑是每次成功发送都加1, + # 如果 action_successful 为 False,则 last_successful_reply_action 会被清空, + # 避免了不成功的追问。my_message_count 的精确回滚比较复杂,暂时依赖 last_successful_reply_action。 + pass + + return action_successful, final_status, final_reason.strip() + + +class SendNewMessageHandler(BaseTextReplyHandler): + """处理发送新消息动作(send_new_message)的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """ + 执行发送新消息动作。 + 会先通过 ReplyGenerator 判断是否要发送文本,如果发送,则生成并检查文本。 + 同时,也可能根据 current_emoji_query 发送附带表情。 + """ + if not observation_info or not conversation_info: + self.logger.error( + f"[私聊][{self.conversation.private_name}] SendNewMessageHandler: ObservationInfo 或 ConversationInfo 为空。" + ) + return False, "error", "内部信息缺失,无法执行发送新消息" + + action_successful = False # 整体动作是否成功 + final_status = "recall" # 默认最终状态 + final_reason = "发送新消息动作未成功执行" # 默认最终原因 + max_reply_attempts: int = getattr(global_config, "pfc_max_reply_attempts", 3) + + ( + sent_text_successfully, + sent_emoji_successfully, + reason_parts, + full_emoji_desc, + need_replan_from_text_check, + text_check_failure_reason, + rg_decided_not_to_send_text, # 重要:获取RG是否决定不发文本 + ) = await self._process_and_send_reply_with_optional_emoji( + action_type="send_new_message", + observation_info=observation_info, + conversation_info=conversation_info, + max_reply_attempts=max_reply_attempts, + ) + + # 根据发送结果和RG的决策决定最终状态 + if rg_decided_not_to_send_text: # ReplyGenerator 明确决定不发送文本 + if sent_emoji_successfully: # 但表情成功发送了 + action_successful = True + final_status = "done" # 整体算完成,因为有内容发出 + final_reason = f"回复生成器决定不发送文本,但成功发送了附带表情 ({full_emoji_desc or '未知表情'})" + # 即使只发了表情,也算一次交互,可以更新post_send_states + event_desc = f"你发送了表情: '{full_emoji_desc or '未知表情'}' (文本未发送)" + await self._update_post_send_states(observation_info, conversation_info, "send_new_message", event_desc) + else: # RG不发文本,表情也没发出去或失败 + action_successful = True # 决策本身是成功的(决定不发) + final_status = "done_no_reply" # 标记为完成但无回复 + final_reason = ( + text_check_failure_reason + if text_check_failure_reason and text_check_failure_reason != "文本检查通过或未执行" + else "回复生成器决定不发送消息,且无表情或表情发送失败" + ) + conversation_info.last_successful_reply_action = None # 因为没有文本发出 + if self.conversation.conversation_info: # 确保 my_message_count 被重置 + self.conversation.conversation_info.my_message_count = 0 + elif sent_text_successfully or sent_emoji_successfully: # RG决定发文本(或未明确反对),且至少有一个发出去了 + action_successful = True + final_status = "done" + final_reason = "; ".join(reason_parts) if reason_parts else "成功完成操作" + + event_desc_parts = [] + if sent_text_successfully and self.conversation.generated_reply: + event_desc_parts.append(f"你发送了新消息: '{self.conversation.generated_reply[:30]}...'") + if sent_emoji_successfully and full_emoji_desc: + event_desc_parts.append(f"并发送了表情: '{full_emoji_desc}'") + event_desc = " ".join(event_desc_parts) if event_desc_parts else "机器人发送了消息" + await self._update_post_send_states(observation_info, conversation_info, "send_new_message", event_desc) + + elif need_replan_from_text_check: # 文本检查要求重规划 + final_status = "recall" + final_reason = f"文本回复检查要求重新规划: {text_check_failure_reason}" + conversation_info.last_successful_reply_action = None + else: # 文本和表情都未能发送(且RG没有明确说不发文本),或者文本检查失败且不需重规划 + final_status = "max_checker_attempts_failed" if not need_replan_from_text_check else "recall" + final_reason = f"发送新消息失败。文本检查: {text_check_failure_reason}. " + ( + "; ".join(reason_parts) if reason_parts else "" + ) + action_successful = False + conversation_info.last_successful_reply_action = None + + if not action_successful and conversation_info: + # 同 DirectReplyHandler,my_message_count 的精确回滚依赖 last_successful_reply_action 的清除 + pass + + return action_successful, final_status, final_reason.strip() + + +class SayGoodbyeHandler(ActionHandler): + """处理发送告别语动作(say_goodbye)的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """ + 执行发送告别语的动作。 + 会生成告别文本并发送,然后标记对话结束。 + """ + if not observation_info or not conversation_info: + self.logger.error( + f"[私聊][{self.conversation.private_name}] SayGoodbyeHandler: ObservationInfo 或 ConversationInfo 为空。" + ) + return False, "error", "内部信息缺失,无法执行告别" + + action_successful = False + final_status = "recall" # 默认状态 + final_reason = "告别语动作未成功执行" # 默认原因 + + self.conversation.state = ConversationState.GENERATING # 设置状态为生成中 + if not self.conversation.reply_generator: + raise RuntimeError(f"ReplyGenerator 未为 {self.conversation.private_name} 初始化") + + # 生成告别语内容 + generated_content = await self.conversation.reply_generator.generate( + observation_info, conversation_info, action_type="say_goodbye" + ) + self.logger.info( + f"[私聊][{self.conversation.private_name}] 动作 'say_goodbye': 生成内容: '{generated_content[:100]}...'" + ) + + if not generated_content or generated_content.startswith("抱歉"): # 如果生成内容无效 + self.logger.warning( + f"[私聊][{self.conversation.private_name}] 动作 'say_goodbye': 生成内容为空或为错误提示,取消发送。" + ) + final_reason = "生成告别内容无效" + final_status = "done" # 即使不发送,结束对话的决策也算完成 + self.conversation.should_continue = False # 标记对话结束 + action_successful = True # 动作(决策结束)本身算成功 + else: # 如果生成内容有效 + self.conversation.generated_reply = generated_content + self.conversation.state = ConversationState.SENDING # 设置状态为发送中 + text_segment = Seg(type="text", data=self.conversation.generated_reply) + send_success = await self._send_reply_or_segments([text_segment], self.conversation.generated_reply) + send_end_time = time.time() + + if send_success: # 如果发送成功 + action_successful = True + final_status = "done" + final_reason = "成功发送告别语" + self.conversation.should_continue = False # 标记对话结束 + if self.conversation.conversation_info: + self.conversation.conversation_info.current_instance_message_count += 1 + self.conversation.conversation_info.my_message_count += 1 # 告别语也算一次发言 + await self._update_bot_message_in_history( + send_end_time, self.conversation.generated_reply, observation_info + ) + event_desc = f"你发送了告别消息: '{self.conversation.generated_reply[:50]}...'" + # 注意:由于 should_continue 已设为 False,后续的 idle chat 更新可能意义不大,但情绪更新仍可进行 + await self._update_post_send_states(observation_info, conversation_info, "say_goodbye", event_desc) + else: # 如果发送失败 + final_status = "recall" + final_reason = "发送告别语失败" + action_successful = False + self.conversation.should_continue = True # 发送失败则不立即结束对话,让其自然流转 + + return action_successful, final_status, final_reason + + +class SendMemesHandler(ActionHandler): + """处理发送表情包动作(send_memes)的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """ + 执行发送表情包的动作。 + 会根据 current_emoji_query 获取并发送表情。 + """ + if not observation_info or not conversation_info: + self.logger.error( + f"[私聊][{self.conversation.private_name}] SendMemesHandler: ObservationInfo 或 ConversationInfo 为空。" + ) + return False, "error", "内部信息缺失,无法发送表情包" + + action_successful = False + final_status = "recall" # 默认状态 + final_reason_prefix = "发送表情包" + final_reason = f"{final_reason_prefix}失败:未知原因" # 默认原因 + self.conversation.state = ConversationState.GENERATING # 或 SENDING_MEME + + emoji_query = conversation_info.current_emoji_query + if not emoji_query: # 如果没有表情查询 + final_reason = f"{final_reason_prefix}失败:缺少表情包查询语句" + # 此动作不依赖文本回复的追问状态,所以不修改 last_successful_reply_action + return False, "recall", final_reason + + # 清理表情查询,因为我们要处理它了 + conversation_info.current_emoji_query = None + + emoji_prepared_info = await self._fetch_and_prepare_emoji_segment(emoji_query) + + if emoji_prepared_info: # 如果成功获取并准备了表情 + emoji_segment, full_emoji_description, log_emoji_description = emoji_prepared_info + send_success = await self._send_reply_or_segments([emoji_segment], log_emoji_description) + send_end_time = time.time() + + if send_success: # 如果发送成功 + action_successful = True + final_status = "done" + final_reason = f"{final_reason_prefix}成功发送 ({full_emoji_description})" + if self.conversation.conversation_info: + self.conversation.conversation_info.current_instance_message_count += 1 + self.conversation.conversation_info.my_message_count += 1 # 表情也算一次发言 + await self._update_bot_message_in_history( + send_end_time, full_emoji_description, observation_info, "bot_meme_" + ) + event_desc = f"你发送了一个表情包 ({full_emoji_description})" + await self._update_post_send_states(observation_info, conversation_info, "send_memes", event_desc) + else: # 如果发送失败 + final_status = "recall" + final_reason = f"{final_reason_prefix}失败:发送时出错" + else: # 如果未能获取或准备表情 + final_reason = f"{final_reason_prefix}失败:未找到或准备表情失败 ({emoji_query})" + # last_successful_reply_action 保持不变 + + return action_successful, final_status, final_reason + + +class RethinkGoalHandler(ActionHandler): + """处理重新思考目标动作(rethink_goal)的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """执行重新思考对话目标的动作。""" + if not conversation_info or not observation_info: + self.logger.error( + f"[私聊][{self.conversation.private_name}] RethinkGoalHandler: ObservationInfo 或 ConversationInfo 为空。" + ) + return False, "error", "内部信息缺失,无法重新思考目标" + self.conversation.state = ConversationState.RETHINKING # 设置状态为重新思考中 + if not self.conversation.goal_analyzer: + raise RuntimeError(f"GoalAnalyzer 未为 {self.conversation.private_name} 初始化") + await self.conversation.goal_analyzer.analyze_goal(conversation_info, observation_info) # 调用目标分析器 + event_desc = "你重新思考了对话目标和方向" + await self._update_relationship_and_emotion(observation_info, conversation_info, event_desc) # 更新关系和情绪 + return True, "done", "成功重新思考目标" + + +class ListeningHandler(ActionHandler): + """处理倾听动作(listening)的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """执行倾听对方发言的动作。""" + if not conversation_info or not observation_info: + self.logger.error( + f"[私聊][{self.conversation.private_name}] ListeningHandler: ObservationInfo 或 ConversationInfo 为空。" + ) + return False, "error", "内部信息缺失,无法执行倾听" + self.conversation.state = ConversationState.LISTENING # 设置状态为倾听中 + if not self.conversation.waiter: + raise RuntimeError(f"Waiter 未为 {self.conversation.private_name} 初始化") + await self.conversation.waiter.wait_listening(conversation_info) # 调用等待器的倾听方法 + event_desc = "你决定耐心倾听对方的发言" + await self._update_relationship_and_emotion(observation_info, conversation_info, event_desc) # 更新关系和情绪 + # listening 动作完成后,状态会由新消息或超时驱动,最终回到 ANALYZING + return True, "done", "进入倾听状态" + + +class EndConversationHandler(ActionHandler): + """处理结束对话动作(end_conversation)的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """执行结束当前对话的动作。""" + self.logger.info( + f"[私聊][{self.conversation.private_name}] 动作 'end_conversation': 收到最终结束指令,停止对话..." + ) + self.conversation.should_continue = False # 标记对话不应继续,主循环会因此退出 + # 注意:最终的关系评估通常在 Conversation.stop() 方法中进行 + return True, "done", "对话结束指令已执行" + + +class BlockAndIgnoreHandler(ActionHandler): + """处理屏蔽并忽略对话动作(block_and_ignore)的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """执行屏蔽并忽略当前对话一段时间的动作。""" + if not conversation_info or not observation_info: # 防御性检查 + self.logger.error( + f"[私聊][{self.conversation.private_name}] BlockAndIgnoreHandler: ObservationInfo 或 ConversationInfo 为空。" + ) + return False, "error", "内部信息缺失,无法执行屏蔽" + self.logger.info(f"[私聊][{self.conversation.private_name}] 动作 'block_and_ignore': 不想再理你了...") + ignore_duration_seconds = 10 * 60 # 例如忽略10分钟,可以配置 + self.conversation.ignore_until_timestamp = time.time() + ignore_duration_seconds # 设置忽略截止时间 + self.conversation.state = ConversationState.IGNORED # 设置状态为已忽略 + event_desc = "当前对话让你感到不适,你决定暂时不再理会对方" + await self._update_relationship_and_emotion(observation_info, conversation_info, event_desc) # 更新关系和情绪 + # should_continue 仍为 True,但主循环会检查 ignore_until_timestamp + return True, "done", f"已屏蔽并忽略对话 {ignore_duration_seconds // 60} 分钟" + + +class WaitHandler(ActionHandler): + """处理等待动作(wait)的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """执行等待对方回复的动作。""" + if not conversation_info or not observation_info: # 防御性检查 + self.logger.error( + f"[私聊][{self.conversation.private_name}] WaitHandler: ObservationInfo 或 ConversationInfo 为空。" + ) + return False, "error", "内部信息缺失,无法执行等待" + self.conversation.state = ConversationState.WAITING # 设置状态为等待中 + if not self.conversation.waiter: + raise RuntimeError(f"Waiter 未为 {self.conversation.private_name} 初始化") + timeout_occurred = await self.conversation.waiter.wait(conversation_info) # 调用等待器的常规等待方法 + event_desc = "你等待对方回复,但对方长时间没有回应" if timeout_occurred else "你选择等待对方的回复" + await self._update_relationship_and_emotion(observation_info, conversation_info, event_desc) # 更新关系和情绪 + # wait 动作完成后,状态会由新消息或超时驱动,最终回到 ANALYZING + return True, "done", "等待动作完成" + + +class UnknownActionHandler(ActionHandler): + """处理未知或无效动作的处理器。""" + + async def execute( + self, + reason: str, + observation_info: Optional[ObservationInfo], + conversation_info: Optional[ConversationInfo], + action_start_time: float, + current_action_record: dict, + ) -> tuple[bool, str, str]: + """处理无法识别的动作类型。""" + action_name = current_action_record.get("action", "未知动作类型") # 从记录中获取动作名 + self.logger.warning(f"[私聊][{self.conversation.private_name}] 接收到未知的动作类型: {action_name}") + return False, "recall", f"未知的动作类型: {action_name}" # 标记为需要重新规划 diff --git a/src/experimental/PFC/action_planner.py b/src/experimental/PFC/action_planner.py index d4a51caf..5feaea0d 100644 --- a/src/experimental/PFC/action_planner.py +++ b/src/experimental/PFC/action_planner.py @@ -1,15 +1,13 @@ import time import traceback from typing import Tuple, Optional, Dict, Any, List - from src.common.logger_manager import get_logger from src.chat.models.utils_model import LLMRequest from src.config.config import global_config -from src.experimental.PFC.chat_observer import ChatObserver -from src.experimental.PFC.pfc_utils import get_items_from_json, build_chat_history_text -from src.experimental.PFC.observation_info import ObservationInfo -from src.experimental.PFC.conversation_info import ConversationInfo - +from .pfc_utils import get_items_from_json, build_chat_history_text +from .chat_observer import ChatObserver +from .observation_info import ObservationInfo +from .conversation_info import ConversationInfo logger = get_logger("pfc_action_planner") @@ -40,6 +38,7 @@ PROMPT_INITIAL_REPLY = """ 可选行动类型以及解释: listening: 倾听对方发言,当你认为对方话才说到一半,发言明显未结束时选择 direct_reply: 直接回复对方 +send_memes: 发送一个符合当前聊天氛围或{persona_text}心情的表情包,当你觉得用表情包回应更合适,或者想要活跃气氛时选择。 rethink_goal: 思考一个对话目标,当你觉得目前对话需要目标,或当前目标不再适用,或话题卡住时选择。注意私聊的环境是灵活的,有可能需要经常选择 end_conversation: 结束对话,对方长时间没回复,繁忙,或者当你觉得对话告一段落时可以选择 block_and_ignore: 更加极端的结束对话方式,直接结束对话并在一段时间内无视对方所有发言(屏蔽),当你觉得对话让[{persona_text}]感到十分不适,或[{persona_text}]遭到各类骚扰时选择 @@ -47,7 +46,8 @@ block_and_ignore: 更加极端的结束对话方式,直接结束对话并在 请以JSON格式输出你的决策: {{ "action": "选择的行动类型 (必须是上面列表中的一个)", - "reason": "选择该行动的原因 " + "reason": "选择该行动的原因 ", + "emoji_query": "string" // 可选。如果行动是 'send_memes',必须提供表情主题(填写表情包的适用场合或情感描述);如果行动是 'direct_reply' 且你想附带表情,也在此提供表情主题,否则留空字符串 "" }} 注意:请严格按照JSON格式输出,不要包含任何其他内容。""" @@ -76,6 +76,7 @@ PROMPT_FOLLOW_UP = """ wait: 暂时不说话,留给对方交互空间,等待对方回复。 listening: 倾听对方发言(虽然你刚发过言,但如果对方立刻回复且明显话没说完,可以选择这个) send_new_message: 发送一条新消息,当你觉得[{persona_text}]还有话要说,或现在适合/需要发送消息时可以选择 +send_memes: 发送一个符合当前聊天氛围或{persona_text}心情的表情包,当你觉得用表情包回应更合适,或者想要活跃气氛时选择。 rethink_goal: 思考一个对话目标,当你觉得目前对话需要目标,或当前目标不再适用,或话题卡住时选择。注意私聊的环境是灵活的,有可能需要经常选择 end_conversation: 安全和平的结束对话,对方长时间没回复、繁忙、或你觉得对话告一段落时可以选择 block_and_ignore: 更加极端的结束对话方式,直接结束对话并在一段时间内无视对方所有发言(屏蔽),当你觉得对话让[{persona_text}]感到十分不适,或[{persona_text}]遭到各类骚扰时选择 @@ -83,7 +84,8 @@ block_and_ignore: 更加极端的结束对话方式,直接结束对话并在 请以JSON格式输出你的决策: {{ "action": "选择的行动类型 (必须是上面列表中的一个)", - "reason": "选择该行动的原因" + "reason": "选择该行动的原因", + "emoji_query": "string" // 可选。如果行动是 'send_memes',必须提供表情主题(填写表情包的适用场合或情感描述);如果行动是 'send_new_message' 且你想附带表情,也在此提供表情主题,否则留空字符串 "" }} 注意:请严格按照JSON格式输出,不要包含任何其他内容。""" @@ -233,24 +235,10 @@ class ActionPlanner: if use_reflect_prompt: # 新增的判断 prompt_template = PROMPT_REFLECT_AND_ACT log_msg = "使用 PROMPT_REFLECT_AND_ACT (反思决策)" - # 对于 PROMPT_REFLECT_AND_ACT,它不包含 send_new_message 选项,所以 spam_warning_message 中的相关提示可以调整或省略 - # 但为了保持占位符填充的一致性,我们仍然计算它 - # spam_warning_message = "" - # if conversation_info.my_message_count > 5: # 这里的 my_message_count 仍有意义,表示之前连续发送了多少 - # spam_warning_message = ( - # f"⚠️【警告】**你之前已连续发送{str(conversation_info.my_message_count)}条消息!请谨慎决策。**" - # ) - # elif conversation_info.my_message_count > 2: - # spam_warning_message = f"💬【提示】**你之前已连续发送{str(conversation_info.my_message_count)}条消息。请注意保持对话平衡。**" - elif last_successful_reply_action in ["direct_reply", "send_new_message"]: + elif last_successful_reply_action in ["direct_reply", "send_new_message", "send_memes"]: prompt_template = PROMPT_FOLLOW_UP log_msg = "使用 PROMPT_FOLLOW_UP (追问决策)" - # spam_warning_message = "" - # if conversation_info.my_message_count > 5: - # spam_warning_message = f"⚠️【警告】**你已连续发送{str(conversation_info.my_message_count)}条消息!请注意不要再选择send_new_message!以免刷屏对造成对方困扰!**" - # elif conversation_info.my_message_count > 2: - # spam_warning_message = f"💬【警告】**你已连续发送{str(conversation_info.my_message_count)}条消息。请保持理智,如果非必要,请避免选择send_new_message,以免给对方造成困扰。**" else: prompt_template = PROMPT_INITIAL_REPLY @@ -302,12 +290,19 @@ class ActionPlanner: self.private_name, "action", "reason", - default_values={"action": "wait", "reason": "LLM返回格式错误或未提供原因,默认等待"}, + "emoji_query", + default_values={"action": "wait", "reason": "LLM返回格式错误或未提供原因,默认等待", "emoji_query": ""}, + allow_empty_string_fields=["emoji_query"], ) initial_action = initial_result.get("action", "wait") initial_reason = initial_result.get("reason", "LLM未提供原因,默认等待") - logger.info(f"[私聊][{self.private_name}] LLM 初步规划行动: {initial_action}, 原因: {initial_reason}") + current_emoji_query = initial_result.get("emoji_query", "") # 获取 emoji_query + logger.info( + f"[私聊][{self.private_name}] LLM 初步规划行动: {initial_action}, 原因: {initial_reason}表情查询: '{current_emoji_query}'" + ) + if conversation_info: # 确保 conversation_info 存在 + conversation_info.current_emoji_query = current_emoji_query except Exception as llm_err: logger.error(f"[私聊][{self.private_name}] 调用 LLM 或解析初步规划结果时出错: {llm_err}") logger.error(traceback.format_exc()) @@ -350,6 +345,7 @@ class ActionPlanner: valid_actions_default = [ "direct_reply", "send_new_message", + "send_memes", "wait", "listening", "rethink_goal", diff --git a/src/experimental/PFC/actions.py b/src/experimental/PFC/actions.py index 8e9a1eb2..5c8233ba 100644 --- a/src/experimental/PFC/actions.py +++ b/src/experimental/PFC/actions.py @@ -2,59 +2,20 @@ import time import asyncio import datetime import traceback -import json -from typing import Optional, Set, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from src.common.logger_manager import get_logger -from src.config.config import global_config -from src.chat.utils.chat_message_builder import build_readable_messages -from .pfc_types import ConversationState -from .observation_info import ObservationInfo -from .conversation_info import ConversationInfo +from .pfc_types import ConversationState # 调整导入路径 +from .observation_info import ObservationInfo # 调整导入路径 +from .conversation_info import ConversationInfo # 调整导入路径 + +# 导入工厂类 +from .action_factory import StandardActionFactory # 调整导入路径 if TYPE_CHECKING: - from .conversation import Conversation # 用于类型提示以避免循环导入 + from .conversation import Conversation # 调整导入路径 -logger = get_logger("pfc_actions") - - -async def _send_reply_internal(conversation_instance: "Conversation") -> bool: - """ - 内部辅助函数,用于发送 conversation_instance.generated_reply 中的内容。 - 这之前是 Conversation 类中的 _send_reply 方法。 - """ - # 检查是否有内容可发送 - if not conversation_instance.generated_reply: - logger.warning(f"[私聊][{conversation_instance.private_name}] 没有生成回复内容,无法发送。") - return False - # 检查发送器和聊天流是否已初始化 - if not conversation_instance.direct_sender: - logger.error(f"[私聊][{conversation_instance.private_name}] DirectMessageSender 未初始化,无法发送。") - return False - if not conversation_instance.chat_stream: - logger.error(f"[私聊][{conversation_instance.private_name}] ChatStream 未初始化,无法发送。") - return False - - try: - reply_content = conversation_instance.generated_reply - # 调用发送器发送消息,不指定回复对象 - await conversation_instance.direct_sender.send_message( - chat_stream=conversation_instance.chat_stream, - content=reply_content, - reply_to_message=None, # 私聊通常不需要引用回复 - ) - # 自身发言数量累计 +1 - if conversation_instance.conversation_info: # 确保 conversation_info 存在 - conversation_instance.conversation_info.my_message_count += 1 - # 发送成功后,将状态设置回分析,准备下一轮规划 - conversation_instance.state = ConversationState.ANALYZING - return True # 返回成功 - except Exception as e: - # 捕获发送过程中的异常 - logger.error(f"[私聊][{conversation_instance.private_name}] 发送消息时失败: {str(e)}") - logger.error(f"[私聊][{conversation_instance.private_name}] {traceback.format_exc()}") - conversation_instance.state = ConversationState.ERROR # 发送失败标记错误状态 - return False # 返回失败 +logger = get_logger("pfc_actions") # 模块级别日志记录器 async def handle_action( @@ -66,707 +27,145 @@ async def handle_action( ): """ 处理由 ActionPlanner 规划出的具体行动。 - 这之前是 Conversation 类中的 _handle_action 方法。 + 使用 ActionFactory 创建并执行相应的处理器。 """ - # 检查初始化状态 + # 检查对话实例是否已初始化 if not conversation_instance._initialized: logger.error(f"[私聊][{conversation_instance.private_name}] 尝试在未初始化状态下处理动作 '{action}'。") return - # 确保 observation_info 和 conversation_info 不为 None + # 检查 observation_info 是否为空 if not observation_info: logger.error(f"[私聊][{conversation_instance.private_name}] ObservationInfo 为空,无法处理动作 '{action}'。") - # 在 conversation_info 和 done_action 存在时更新状态 + # 如果 conversation_info 和 done_action 存在且不为空 if conversation_info and hasattr(conversation_info, "done_action") and conversation_info.done_action: - conversation_info.done_action[-1].update( - { - "status": "error", - "final_reason": "ObservationInfo is None", - } - ) - conversation_instance.state = ConversationState.ERROR + # 更新最后一个动作记录的状态和原因 + if conversation_info.done_action: # 再次检查列表是否不为空 + conversation_info.done_action[-1].update({"status": "error", "final_reason": "ObservationInfo is None"}) + conversation_instance.state = ConversationState.ERROR # 设置对话状态为错误 return - if not conversation_info: # conversation_info 在这里是必需的 + # 检查 conversation_info 是否为空 + if not conversation_info: logger.error(f"[私聊][{conversation_instance.private_name}] ConversationInfo 为空,无法处理动作 '{action}'。") - conversation_instance.state = ConversationState.ERROR + conversation_instance.state = ConversationState.ERROR # 设置对话状态为错误 return logger.info(f"[私聊][{conversation_instance.private_name}] 开始处理动作: {action}, 原因: {reason}") action_start_time = time.time() # 记录动作开始时间 - # --- 准备动作历史记录条目 --- + # 当前动作记录 current_action_record = { - "action": action, - "plan_reason": reason, # 记录规划时的原因 - "status": "start", # 初始状态为"开始" - "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # 记录开始时间 - "final_reason": None, # 最终结果的原因,将在 finally 中设置 + "action": action, # 动作类型 + "plan_reason": reason, # 规划原因 + "status": "start", # 初始状态为 "start" + "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # 当前时间 + "final_reason": None, # 最终原因,默认为 None } - # 安全地添加到历史记录列表 - if not hasattr(conversation_info, "done_action") or conversation_info.done_action is None: # 防御性检查 + # 如果 done_action 不存在或为空,则初始化 + if not hasattr(conversation_info, "done_action") or conversation_info.done_action is None: conversation_info.done_action = [] - conversation_info.done_action.append(current_action_record) - # 获取当前记录在列表中的索引,方便后续更新状态 - action_index = len(conversation_info.done_action) - 1 + conversation_info.done_action.append(current_action_record) # 添加当前动作记录 + action_index = len(conversation_info.done_action) - 1 # 获取当前动作记录的索引 - # --- 初始化动作执行状态变量 --- - action_successful: bool = False # 标记动作是否成功执行 - final_status: str = "recall" # 动作最终状态,默认为 recall (表示未成功或需重试) - final_reason: str = "动作未成功执行" # 动作最终原因 + action_successful: bool = False # 动作是否成功,默认为 False + final_status: str = "recall" # 最终状态,默认为 "recall" + final_reason: str = "动作未成功执行" # 最终原因,默认为 "动作未成功执行" - # 在此声明变量以避免 UnboundLocalError - is_suitable: bool = False - generated_content_for_check_or_send: str = "" - check_reason: str = "未进行检查" - need_replan_from_checker: bool = False - should_send_reply: bool = True # 默认需要发送 (对于 direct_reply) - is_send_decision_from_rg: bool = False # 标记 send_new_message 的决策是否来自 ReplyGenerator + factory = StandardActionFactory() # 创建标准动作工厂实例 + action_handler = factory.create_action_handler(action, conversation_instance) # 创建动作处理器 try: - # --- 根据不同的 action 类型执行相应的逻辑 --- + # 执行动作处理器 + action_successful, final_status, final_reason = await action_handler.execute( + reason, observation_info, conversation_info, action_start_time, current_action_record + ) - # 1. 处理需要生成、检查、发送的动作 - if action in ["direct_reply", "send_new_message"]: - max_reply_attempts: int = getattr(global_config, "pfc_max_reply_attempts", 3) # 最多尝试次数 (可配置) - reply_attempt_count: int = 0 - # is_suitable, generated_content_for_check_or_send, check_reason, need_replan_from_checker, should_send_reply, is_send_decision_from_rg 已在外部声明 + # 动作执行后的逻辑 (例如更新 last_successful_reply_action 等) + # 此部分之前位于每个 if/elif 块内部 + # 如果动作不是回复类型的动作 + if action not in ["direct_reply", "send_new_message", "say_goodbye", "send_memes"]: + conversation_info.last_successful_reply_action = None # 清除上次成功回复动作 + conversation_info.last_reply_rejection_reason = None # 清除上次回复拒绝原因 + conversation_info.last_rejected_reply_content = None # 清除上次拒绝的回复内容 - while reply_attempt_count < max_reply_attempts and not is_suitable and not need_replan_from_checker: - reply_attempt_count += 1 - log_prefix = f"[私聊][{conversation_instance.private_name}] 尝试生成/检查 '{action}' 回复 (第 {reply_attempt_count}/{max_reply_attempts} 次)..." - logger.info(log_prefix) + # 如果动作不是发送表情包或发送表情包失败,则清除表情查询 + if action != "send_memes" or not action_successful: + if hasattr(conversation_info, "current_emoji_query"): + conversation_info.current_emoji_query = None - conversation_instance.state = ConversationState.GENERATING - if not conversation_instance.reply_generator: - raise RuntimeError("ReplyGenerator 未初始化") - - raw_llm_output = await conversation_instance.reply_generator.generate( - observation_info, conversation_info, action_type=action - ) - logger.debug(f"{log_prefix} ReplyGenerator.generate 返回: '{raw_llm_output}'") - - text_to_process = raw_llm_output # 默认情况下,处理原始输出 - - if action == "send_new_message": - is_send_decision_from_rg = True # 标记这是 send_new_message 的决策过程 - parsed_json = None - try: - # 尝试解析JSON - parsed_json = json.loads(raw_llm_output) - except json.JSONDecodeError: - logger.error(f"{log_prefix} ReplyGenerator 返回的不是有效的JSON: {raw_llm_output}") - # 如果JSON解析失败,视为RG决定不发送,并给出原因 - conversation_info.last_reply_rejection_reason = "回复生成器未返回有效JSON" - conversation_info.last_rejected_reply_content = raw_llm_output - should_send_reply = False - text_to_process = "no" # 或者一个特定的错误标记 - - if parsed_json: # 如果成功解析 - send_decision = parsed_json.get("send", "no").lower() - generated_text_from_json = parsed_json.get("txt", "no") - - if send_decision == "yes": - should_send_reply = True - text_to_process = generated_text_from_json - logger.info(f"{log_prefix} ReplyGenerator 决定发送消息。内容: '{text_to_process[:100]}...'") - else: # send_decision is "no" - should_send_reply = False - text_to_process = "no" # 保持和 prompt 中一致,txt 为 "no" - logger.info(f"{log_prefix} ReplyGenerator 决定不发送消息。") - # 既然RG决定不发送,就直接跳出重试循环 - break - - # 如果 ReplyGenerator 在 send_new_message 动作中决定不发送,则跳出重试循环 - if action == "send_new_message" and not should_send_reply: - break - - generated_content_for_check_or_send = text_to_process - - # 检查生成的内容是否有效 - if ( - not generated_content_for_check_or_send - or generated_content_for_check_or_send.startswith("抱歉") - or generated_content_for_check_or_send.strip() == "" - or ( - action == "send_new_message" - and generated_content_for_check_or_send == "no" - and should_send_reply - ) - ): # RG决定发送但文本为"no"或空 - warning_msg = f"{log_prefix} 生成内容无效或为错误提示" - if action == "send_new_message" and generated_content_for_check_or_send == "no": # 特殊情况日志 - warning_msg += " (ReplyGenerator决定发送但文本为'no')" - - logger.warning(warning_msg + ",将进行下一次尝试 (如果适用)。") - check_reason = "生成内容无效或选择不发送" # 统一原因 - conversation_info.last_reply_rejection_reason = check_reason - conversation_info.last_rejected_reply_content = generated_content_for_check_or_send - - await asyncio.sleep(0.5) # 暂停一下 - continue # 直接进入下一次循环尝试 - - # --- 内容检查 --- - conversation_instance.state = ConversationState.CHECKING - if not conversation_instance.reply_checker: - raise RuntimeError("ReplyChecker 未初始化") - - # 准备检查器所需参数 - current_goal_str = "" - if conversation_info.goal_list: # 确保 goal_list 存在且不为空 - goal_item = conversation_info.goal_list[-1] - if isinstance(goal_item, dict): - current_goal_str = goal_item.get("goal", "") - elif isinstance(goal_item, str): - current_goal_str = goal_item - - chat_history_for_check = getattr(observation_info, "chat_history", []) - chat_history_text_for_check = getattr(observation_info, "chat_history_str", "") - current_retry_for_checker = reply_attempt_count - 1 # retry_count 从0开始 - current_time_value_for_check = observation_info.current_time_str or "获取时间失败" - - # 调用检查器 - if global_config.enable_pfc_reply_checker: - logger.debug(f"{log_prefix} 调用 ReplyChecker 检查 (配置已启用)...") - ( - is_suitable, - check_reason, - need_replan_from_checker, - ) = await conversation_instance.reply_checker.check( - reply=generated_content_for_check_or_send, - goal=current_goal_str, - chat_history=chat_history_for_check, # 使用完整的历史记录列表 - chat_history_text=chat_history_text_for_check, # 可以是截断的文本 - current_time_str=current_time_value_for_check, - retry_count=current_retry_for_checker, # 传递当前重试次数 - ) - logger.info( - f"{log_prefix} ReplyChecker 结果: 合适={is_suitable}, 原因='{check_reason}', 需重规划={need_replan_from_checker}" - ) - else: # 如果配置关闭 - is_suitable = True - check_reason = "ReplyChecker 已通过配置关闭" - need_replan_from_checker = False - logger.debug(f"{log_prefix} [配置关闭] ReplyChecker 已跳过,默认回复为合适。") - - # 处理检查结果 - if not is_suitable: - conversation_info.last_reply_rejection_reason = check_reason - conversation_info.last_rejected_reply_content = generated_content_for_check_or_send - - # 如果是机器人自身复读,且检查器认为不需要重规划 (这是新版 ReplyChecker 的逻辑) - if check_reason == "机器人尝试发送重复消息" and not need_replan_from_checker: - logger.warning( - f"{log_prefix} 回复因自身重复被拒绝: {check_reason}。将使用相同 Prompt 类型重试。" - ) - if reply_attempt_count < max_reply_attempts: # 还有尝试次数 - await asyncio.sleep(0.5) # 暂停一下 - continue # 进入下一次重试 - else: # 达到最大次数 - logger.warning(f"{log_prefix} 即使是复读,也已达到最大尝试次数。") - break # 结束循环,按失败处理 - elif ( - not need_replan_from_checker and reply_attempt_count < max_reply_attempts - ): # 其他不合适原因,但无需重规划,且可重试 - logger.warning(f"{log_prefix} 回复不合适,原因: {check_reason}。将进行下一次尝试。") - await asyncio.sleep(0.5) # 暂停一下 - continue # 进入下一次重试 - else: # 需要重规划,或达到最大次数 - logger.warning(f"{log_prefix} 回复不合适且(需要重规划或已达最大次数)。原因: {check_reason}") - break # 结束循环,将在循环外部处理 - else: # is_suitable is True - # 找到了合适的回复 - conversation_info.last_reply_rejection_reason = None # 清除之前的拒绝原因 - conversation_info.last_rejected_reply_content = None - break # 成功,跳出循环 - - # --- 循环结束后处理 --- - if action == "send_new_message" and not should_send_reply and is_send_decision_from_rg: - # 这是 reply_generator 决定不发送的情况 - logger.info( - f"[私聊][{conversation_instance.private_name}] 动作 '{action}': ReplyGenerator 决定不发送消息。" - ) - final_status = "done_no_reply" # 一个新的状态,表示动作完成但无回复 - final_reason = "回复生成器决定不发送消息" - action_successful = True # 动作本身(决策)是成功的 - - # 清除追问状态,因为没有实际发送 - conversation_info.last_successful_reply_action = None - conversation_info.my_message_count = 0 # 重置连续发言计数 - # 后续的 plan 循环会检测到这个 "done_no_reply" 状态并使用反思 prompt - - elif is_suitable: # 适用于 direct_reply 或 (send_new_message 且 RG决定发送并通过检查) - logger.debug( - f"[私聊][{conversation_instance.private_name}] 动作 '{action}': 找到合适的回复,准备发送。" - ) - # conversation_info.last_reply_rejection_reason = None # 已在循环内清除 - # conversation_info.last_rejected_reply_content = None - conversation_instance.generated_reply = generated_content_for_check_or_send # 使用检查通过的内容 - timestamp_before_sending = time.time() - logger.debug( - f"[私聊][{conversation_instance.private_name}] 动作 '{action}': 记录发送前时间戳: {timestamp_before_sending:.2f}" - ) - conversation_instance.state = ConversationState.SENDING - send_success = await _send_reply_internal(conversation_instance) # 调用重构后的发送函数 - send_end_time = time.time() # 记录发送完成时间 - - if send_success: - action_successful = True - final_status = "done" # 明确设置 final_status - final_reason = "成功发送" # 明确设置 final_reason - logger.debug(f"[私聊][{conversation_instance.private_name}] 动作 '{action}': 成功发送回复.") - - # --- 新增:将机器人发送的消息添加到 ObservationInfo 的 chat_history --- - if ( - observation_info and conversation_instance.bot_qq_str - ): # 确保 observation_info 和 bot_qq_str 存在 - bot_message_dict = { - "message_id": f"bot_sent_{send_end_time}", # 生成一个唯一ID - "time": send_end_time, - "user_info": { # 构造机器人的 UserInfo - "user_id": conversation_instance.bot_qq_str, - "user_nickname": global_config.BOT_NICKNAME, # 或者 conversation_instance.name - "platform": conversation_instance.chat_stream.platform - if conversation_instance.chat_stream - else "unknown_platform", - }, - "processed_plain_text": conversation_instance.generated_reply, - "detailed_plain_text": conversation_instance.generated_reply, # 简单处理 - # 根据你的消息字典结构,可能还需要其他字段 - } - observation_info.chat_history.append(bot_message_dict) - observation_info.chat_history_count = len(observation_info.chat_history) - logger.debug( - f"[私聊][{conversation_instance.private_name}] {global_config.BOT_NICKNAME}发送的消息已添加到 chat_history。当前历史数: {observation_info.chat_history_count}" - ) - - # 可选:如果 chat_history 过长,进行修剪 (例如,保留最近N条) - max_history_len = getattr(global_config, "pfc_max_chat_history_for_checker", 50) # 例如,可配置 - if len(observation_info.chat_history) > max_history_len: - observation_info.chat_history = observation_info.chat_history[-max_history_len:] - observation_info.chat_history_count = len(observation_info.chat_history) # 更新计数 - - # 更新 chat_history_str (如果 ReplyChecker 也依赖这个字符串) - # 这个更新可能比较消耗资源,如果 checker 只用列表,可以考虑优化此处 - history_slice_for_str = observation_info.chat_history[-30:] # 例如最近30条 - try: - observation_info.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, - ) - except Exception as e_build_hist: - logger.error( - f"[私聊][{conversation_instance.private_name}] 更新 chat_history_str 时出错: {e_build_hist}" - ) - observation_info.chat_history_str = "[构建聊天记录出错]" - # --- 新增结束 --- - - # 更新 idle_chat 的最后消息时间 - # (避免在发送消息后很快触发主动聊天) - if conversation_instance.idle_chat: - await conversation_instance.idle_chat.update_last_message_time(send_end_time) - - # 清理已处理的未读消息 (只清理在发送这条回复之前的、来自他人的消息) - current_unprocessed_messages = getattr(observation_info, "unprocessed_messages", []) - message_ids_to_clear: Set[str] = set() - for msg in current_unprocessed_messages: - msg_time = msg.get("time") - msg_id = msg.get("message_id") - sender_id_info = msg.get("user_info", {}) # 安全获取 user_info - sender_id = str(sender_id_info.get("user_id")) if sender_id_info else None # 安全获取 sender_id - - if ( - msg_id # 确保 msg_id 存在 - and msg_time # 确保 msg_time 存在 - and sender_id != conversation_instance.bot_qq_str # 确保是对方的消息 - and msg_time < timestamp_before_sending # 只清理发送前的 - ): - message_ids_to_clear.add(msg_id) - - if message_ids_to_clear: - logger.debug( - f"[私聊][{conversation_instance.private_name}] 准备清理 {len(message_ids_to_clear)} 条发送前(他人)消息: {message_ids_to_clear}" - ) - await observation_info.clear_processed_messages(message_ids_to_clear) - else: - logger.debug(f"[私聊][{conversation_instance.private_name}] 没有需要清理的发送前(他人)消息。") - - # 更新追问状态 和 关系/情绪状态 - other_new_msg_count_during_planning = getattr( - conversation_info, "other_new_messages_during_planning_count", 0 - ) - - # 如果是 direct_reply 且规划期间有他人新消息,则下次不追问 - if other_new_msg_count_during_planning > 0 and action == "direct_reply": - logger.debug( - f"[私聊][{conversation_instance.private_name}] 因规划期间收到 {other_new_msg_count_during_planning} 条他人新消息,下一轮强制使用【初始回复】逻辑。" - ) - conversation_info.last_successful_reply_action = None - # conversation_info.my_message_count 不在此处重置,因为它刚发了一条 - elif action == "direct_reply" or action == "send_new_message": # 成功发送后 - logger.debug( - f"[私聊][{conversation_instance.private_name}] 成功执行 '{action}', 下一轮【允许】使用追问逻辑。" - ) - conversation_info.last_successful_reply_action = action - - # 更新实例消息计数和关系/情绪 - if conversation_info: # 再次确认 - conversation_info.current_instance_message_count += 1 - logger.debug( - f"[私聊][{conversation_instance.private_name}] 实例消息计数({global_config.BOT_NICKNAME}发送后)增加到: {conversation_info.current_instance_message_count}" - ) - - if conversation_instance.relationship_updater: # 确保存在 - await conversation_instance.relationship_updater.update_relationship_incremental( - conversation_info=conversation_info, - observation_info=observation_info, - chat_observer_for_history=conversation_instance.chat_observer, # 确保 chat_observer 存在 - ) - - sent_reply_summary = ( - conversation_instance.generated_reply[:50] - if conversation_instance.generated_reply - else "空回复" - ) - event_for_emotion_update = f"你刚刚发送了消息: '{sent_reply_summary}...'" - if conversation_instance.emotion_updater: # 确保存在 - await conversation_instance.emotion_updater.update_emotion_based_on_context( - conversation_info=conversation_info, - observation_info=observation_info, - chat_observer_for_history=conversation_instance.chat_observer, # 确保 chat_observer 存在 - event_description=event_for_emotion_update, - ) - else: # 发送失败 - logger.error(f"[私聊][{conversation_instance.private_name}] 动作 '{action}': 发送回复失败。") - final_status = "recall" # 标记为 recall 或 error - final_reason = "发送回复时失败" - action_successful = False # 确保 action_successful 为 False - # 发送失败,重置追问状态和计数 - conversation_info.last_successful_reply_action = None - conversation_info.my_message_count = 0 - - elif need_replan_from_checker: # 如果检查器要求重规划 - logger.warning( - f"[私聊][{conversation_instance.private_name}] 动作 '{action}' 因 ReplyChecker 要求而被取消,将重新规划。原因: {check_reason}" - ) - final_status = "recall" # 标记为 recall - final_reason = f"回复检查要求重新规划: {check_reason}" - # 重置追问状态,因为没有成功发送 - conversation_info.last_successful_reply_action = None - # my_message_count 保持不变,因为没有成功发送 - - else: # 达到最大尝试次数仍未找到合适回复 (is_suitable is False and not need_replan_from_checker) - logger.warning( - f"[私聊][{conversation_instance.private_name}] 动作 '{action}': 达到最大尝试次数 ({max_reply_attempts}),未能生成/检查通过合适的回复。最终原因: {check_reason}" - ) - final_status = "recall" # 标记为 recall - final_reason = f"尝试{max_reply_attempts}次后失败: {check_reason}" - action_successful = False # 确保 action_successful 为 False - # 重置追问状态 - conversation_info.last_successful_reply_action = None - # my_message_count 保持不变 - - # 2. 处理发送告别语动作 (保持简单,不加重试) - elif action == "say_goodbye": - conversation_instance.state = ConversationState.GENERATING - if not conversation_instance.reply_generator: - raise RuntimeError("ReplyGenerator 未初始化") - # 生成告别语 - generated_content = await conversation_instance.reply_generator.generate( - observation_info, - conversation_info, - action_type=action, # action_type='say_goodbye' - ) - logger.info( - f"[私聊][{conversation_instance.private_name}] 动作 '{action}': 生成内容: '{generated_content[:100]}...'" - ) - - # 检查生成内容 - if not generated_content or generated_content.startswith("抱歉"): - logger.warning( - f"[私聊][{conversation_instance.private_name}] 动作 '{action}': 生成内容为空或为错误提示,取消发送。" - ) - final_reason = "生成内容无效" - # 即使生成失败,也按计划结束对话 - final_status = "done" # 标记为 done,因为目的是结束 - conversation_instance.should_continue = False # 停止对话 - logger.info(f"[私聊][{conversation_instance.private_name}] 告别语生成失败,仍按计划结束对话。") - else: - # 发送告别语 - conversation_instance.generated_reply = generated_content - timestamp_before_sending = time.time() - logger.debug( - f"[私聊][{conversation_instance.private_name}] 动作 '{action}': 记录发送前时间戳: {timestamp_before_sending:.2f}" - ) - conversation_instance.state = ConversationState.SENDING - send_success = await _send_reply_internal(conversation_instance) # 调用重构后的发送函数 - send_end_time = time.time() - - if send_success: - action_successful = True # 标记成功 - # final_status 和 final_reason 会在 finally 中设置 - logger.info(f"[私聊][{conversation_instance.private_name}] 成功发送告别语,即将停止对话实例。") - # 更新 idle_chat 的最后消息时间 - # (避免在发送消息后很快触发主动聊天) - if conversation_instance.idle_chat: - await conversation_instance.idle_chat.update_last_message_time(send_end_time) - # 清理发送前的消息 (虽然通常是最后一条,但保持逻辑一致) - current_unprocessed_messages = getattr(observation_info, "unprocessed_messages", []) - message_ids_to_clear: Set[str] = set() - for msg in current_unprocessed_messages: - msg_time = msg.get("time") - msg_id = msg.get("message_id") - sender_id_info = msg.get("user_info", {}) - sender_id = str(sender_id_info.get("user_id")) if sender_id_info else None - if ( - msg_id - and msg_time - and sender_id != conversation_instance.bot_qq_str # 不是自己的消息 - and msg_time < timestamp_before_sending # 发送前 - ): - message_ids_to_clear.add(msg_id) - if message_ids_to_clear: - await observation_info.clear_processed_messages(message_ids_to_clear) - - # 更新关系和情绪 - if conversation_info: # 确保 conversation_info 存在 - conversation_info.current_instance_message_count += 1 - logger.debug( - f"[私聊][{conversation_instance.private_name}] 实例消息计数(告别语后)增加到: {conversation_info.current_instance_message_count}" - ) - - sent_reply_summary = ( - conversation_instance.generated_reply[:50] - if conversation_instance.generated_reply - else "空回复" - ) - event_for_emotion_update = f"你发送了告别消息: '{sent_reply_summary}...'" - if conversation_instance.emotion_updater: # 确保存在 - await conversation_instance.emotion_updater.update_emotion_based_on_context( - conversation_info=conversation_info, - observation_info=observation_info, - chat_observer_for_history=conversation_instance.chat_observer, # 确保 chat_observer 存在 - event_description=event_for_emotion_update, - ) - # 发送成功后结束对话 - conversation_instance.should_continue = False - else: - # 发送失败 - logger.error(f"[私聊][{conversation_instance.private_name}] 动作 '{action}': 发送告别语失败。") - final_status = "recall" # 或 "error" - final_reason = "发送告别语失败" - # 发送失败不能结束对话,让其自然流转或由其他逻辑结束 - conversation_instance.should_continue = True # 保持 should_continue - - # 3. 处理重新思考目标动作 - elif action == "rethink_goal": - conversation_instance.state = ConversationState.RETHINKING - if not conversation_instance.goal_analyzer: - raise RuntimeError("GoalAnalyzer 未初始化") - # 调用 GoalAnalyzer 分析并更新目标 - await conversation_instance.goal_analyzer.analyze_goal(conversation_info, observation_info) - action_successful = True # 标记成功 - event_for_emotion_update = "你重新思考了对话目标和方向" - if ( - conversation_instance.emotion_updater and conversation_info and observation_info - ): # 确保updater和info都存在 - await conversation_instance.emotion_updater.update_emotion_based_on_context( - conversation_info=conversation_info, - observation_info=observation_info, - chat_observer_for_history=conversation_instance.chat_observer, # 确保 chat_observer 存在 - event_description=event_for_emotion_update, - ) - - # 4. 处理倾听动作 - elif action == "listening": - conversation_instance.state = ConversationState.LISTENING - if not conversation_instance.waiter: - raise RuntimeError("Waiter 未初始化") - logger.info(f"[私聊][{conversation_instance.private_name}] 动作 'listening': 进入倾听状态...") - # 调用 Waiter 的倾听等待方法,内部会处理超时 - await conversation_instance.waiter.wait_listening(conversation_info) # 直接传递 conversation_info - action_successful = True # listening 动作本身执行即视为成功,后续由新消息或超时驱动 - event_for_emotion_update = "你决定耐心倾听对方的发言" - if conversation_instance.emotion_updater and conversation_info and observation_info: # 确保都存在 - await conversation_instance.emotion_updater.update_emotion_based_on_context( - conversation_info=conversation_info, - observation_info=observation_info, - chat_observer_for_history=conversation_instance.chat_observer, # 确保 chat_observer 存在 - event_description=event_for_emotion_update, - ) - - # 5. 处理结束对话动作 - elif action == "end_conversation": - logger.info( - f"[私聊][{conversation_instance.private_name}] 动作 'end_conversation': 收到最终结束指令,停止对话..." - ) - action_successful = True # 标记成功 - conversation_instance.should_continue = False # 设置标志以退出循环 - - # 6. 处理屏蔽忽略动作 - elif action == "block_and_ignore": - logger.info(f"[私聊][{conversation_instance.private_name}] 动作 'block_and_ignore': 不想再理你了...") - ignore_duration_seconds = 10 * 60 # 忽略 10 分钟,可配置 - conversation_instance.ignore_until_timestamp = time.time() + ignore_duration_seconds - logger.info( - f"[私聊][{conversation_instance.private_name}] 将忽略此对话直到: {datetime.datetime.fromtimestamp(conversation_instance.ignore_until_timestamp)}" - ) - conversation_instance.state = ConversationState.IGNORED # 设置忽略状态 - action_successful = True # 标记成功 - event_for_emotion_update = "当前对话让你感到不适,你决定暂时不再理会对方" - if conversation_instance.emotion_updater and conversation_info and observation_info: # 确保都存在 - await conversation_instance.emotion_updater.update_emotion_based_on_context( - conversation_info=conversation_info, - observation_info=observation_info, - chat_observer_for_history=conversation_instance.chat_observer, # 确保 chat_observer 存在 - event_description=event_for_emotion_update, - ) - - # 7. 处理等待动作 - elif action == "wait": - conversation_instance.state = ConversationState.WAITING - if not conversation_instance.waiter: - raise RuntimeError("Waiter 未初始化") - logger.info(f"[私聊][{conversation_instance.private_name}] 动作 'wait': 进入等待状态...") - # 调用 Waiter 的常规等待方法,内部处理超时 - # wait 方法返回是否超时 (True=超时, False=未超时/被新消息中断) - timeout_occurred = await conversation_instance.waiter.wait(conversation_info) # 直接传递 conversation_info - action_successful = True # wait 动作本身执行即视为成功 - event_for_emotion_update = "" - if timeout_occurred: # 假设 timeout_occurred 能正确反映是否超时 - event_for_emotion_update = "你等待对方回复,但对方长时间没有回应" - else: - event_for_emotion_update = "你选择等待对方的回复(对方可能很快回复了)" - - if conversation_instance.emotion_updater and conversation_info and observation_info: # 确保都存在 - await conversation_instance.emotion_updater.update_emotion_based_on_context( - conversation_info=conversation_info, - observation_info=observation_info, - chat_observer_for_history=conversation_instance.chat_observer, # 确保 chat_observer 存在 - event_description=event_for_emotion_update, - ) - # wait 动作完成后不需要清理消息,等待新消息或超时触发重新规划 - logger.debug(f"[私聊][{conversation_instance.private_name}] Wait 动作完成,无需在此清理消息。") - - # 8. 处理未知的动作类型 - else: - logger.warning(f"[私聊][{conversation_instance.private_name}] 未知的动作类型: {action}") - final_status = "recall" # 未知动作标记为 recall - final_reason = f"未知的动作类型: {action}" - - # --- 重置非回复动作的追问状态 --- - # 确保执行完非回复动作后,下一次规划不会错误地进入追问逻辑 - if action not in ["direct_reply", "send_new_message", "say_goodbye"]: - conversation_info.last_successful_reply_action = None - # 清理可能残留的拒绝信息 - conversation_info.last_reply_rejection_reason = None - conversation_info.last_rejected_reply_content = None - - except asyncio.CancelledError: - # 处理任务被取消的异常 + except asyncio.CancelledError: # 捕获任务取消错误 logger.warning(f"[私聊][{conversation_instance.private_name}] 处理动作 '{action}' 时被取消。") - final_status = "cancelled" + final_status = "cancelled" # 设置最终状态为 "cancelled" final_reason = "动作处理被取消" - # 取消时也重置追问状态 - if conversation_info: # 确保 conversation_info 存在 - conversation_info.last_successful_reply_action = None - raise # 重新抛出 CancelledError,让上层知道任务被取消 - except Exception as handle_err: - # 捕获处理动作过程中的其他所有异常 + # 如果 conversation_info 存在 + if conversation_info: + conversation_info.last_successful_reply_action = None # 清除上次成功回复动作 + raise # 重新抛出异常,由循环处理 + except Exception as handle_err: # 捕获其他异常 logger.error(f"[私聊][{conversation_instance.private_name}] 处理动作 '{action}' 时出错: {handle_err}") logger.error(f"[私聊][{conversation_instance.private_name}] {traceback.format_exc()}") - final_status = "error" # 标记为错误状态 + final_status = "error" # 设置最终状态为 "error" final_reason = f"处理动作时出错: {handle_err}" conversation_instance.state = ConversationState.ERROR # 设置对话状态为错误 - # 出错时重置追问状态 - if conversation_info: # 确保 conversation_info 存在 - conversation_info.last_successful_reply_action = None + # 如果 conversation_info 存在 + if conversation_info: + conversation_info.last_successful_reply_action = None # 清除上次成功回复动作 + action_successful = False # 确保动作为不成功 finally: - # --- 无论成功与否,都执行 --- - - # 1. 重置临时存储的计数值 - if conversation_info: # 确保 conversation_info 存在 - conversation_info.other_new_messages_during_planning_count = 0 - - # 2. 更新动作历史记录的最终状态和原因 - # 优化:如果动作成功但状态仍是默认的 recall,则更新为 done - if action_successful: - # 如果动作标记为成功,但 final_status 仍然是初始的 "recall" 或者 "start" - # (因为可能在try块中成功执行了但没有显式更新 final_status 为 "done") - # 或者是 "done_no_reply" 这种特殊的成功状态 - if ( - final_status in ["recall", "start"] and action != "send_new_message" - ): # send_new_message + no_reply 是特殊成功 - final_status = "done" - if not final_reason or final_reason == "动作未成功执行": # 避免覆盖已有的具体成功原因 - # 为不同类型的成功动作提供更具体的默认成功原因 - if action == "wait": - # 检查 conversation_info.goal_list 是否存在且不为空 - timeout_occurred = ( - any( - "分钟," in g.get("goal", "") - for g in conversation_info.goal_list - if isinstance(g, dict) - ) - if conversation_info and conversation_info.goal_list - else False - ) - final_reason = "等待完成" + (" (超时)" if timeout_occurred else " (收到新消息或中断)") - elif action == "listening": - final_reason = "进入倾听状态" - elif action in ["rethink_goal", "end_conversation", "block_and_ignore", "say_goodbye"]: - final_reason = f"成功执行 {action}" - elif action in ["direct_reply", "send_new_message"]: # 正常发送成功的case - final_reason = "成功发送" - else: - final_reason = f"动作 {action} 成功完成" - # 如果已经是 "done" 或 "done_no_reply",则保留它们和它们对应的 final_reason - - else: # action_successful is False - # 如果动作标记为失败,且 final_status 还是 "recall" (初始值) 或 "start" - if final_status in ["recall", "start"]: - # 尝试从 conversation_info 中获取更具体的失败原因(例如 checker 的原因) - # 这个 specific_rejection_reason 是在 try 块中被设置的 - specific_rejection_reason = getattr(conversation_info, "last_reply_rejection_reason", None) - rejected_content = getattr(conversation_info, "last_rejected_reply_content", None) - - if specific_rejection_reason: # 如果有更具体的原因 - final_reason = f"执行失败: {specific_rejection_reason}" - if ( - rejected_content and specific_rejection_reason == "机器人尝试发送重复消息" - ): # 对复读提供更清晰的日志 - final_reason += f" (内容: '{rejected_content[:30]}...')" - elif not final_reason or final_reason == "动作未成功执行": # 如果没有更具体的原因,且当前原因还是默认的 - final_reason = f"动作 {action} 执行失败或被意外中止" - # 如果 final_status 已经是 "error" 或 "cancelled",则保留它们和它们对应的 final_reason - - # 更新 done_action 中的记录 - # 防御性检查,确保 conversation_info, done_action 存在,并且索引有效 + # 更新动作历史记录 + # 检查 done_action 属性是否存在且不为空,并且索引有效 if ( - conversation_info - and hasattr(conversation_info, "done_action") + hasattr(conversation_info, "done_action") and conversation_info.done_action and action_index < len(conversation_info.done_action) ): + # 如果动作成功且最终状态不是 "done" 或 "done_no_reply",则设置为 "done" + if action_successful and final_status not in ["done", "done_no_reply"]: + final_status = "done" + # 如果动作成功且最终原因未设置或为默认值 + if action_successful and (not final_reason or final_reason == "动作未成功执行"): + final_reason = f"动作 {action} 成功完成" + # 如果是发送表情包且 current_emoji_query 存在(理想情况下从处理器获取描述) + if action == "send_memes" and conversation_info.current_emoji_query: + pass # 占位符 - 表情描述最好从处理器的执行结果中获取并用于原因 + + # 更新动作记录 conversation_info.done_action[action_index].update( { - "status": final_status, - "time_completed": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "final_reason": final_reason, - "duration_ms": int((time.time() - action_start_time) * 1000), + "status": final_status, # 最终状态 + "time_completed": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # 完成时间 + "final_reason": final_reason, # 最终原因 + "duration_ms": int((time.time() - action_start_time) * 1000), # 持续时间(毫秒) } ) - else: + else: # 如果无法更新动作历史记录 logger.error( - f"[私聊][{conversation_instance.private_name}] 无法更新动作历史记录,索引 {action_index} 无效或列表为空。" + f"[私聊][{conversation_instance.private_name}] 无法更新动作历史记录,done_action 无效或索引 {action_index} 超出范围。" ) - # 最终日志输出 - log_final_reason = final_reason if final_reason else "无明确原因" - # 为成功发送的动作添加发送内容摘要 + # 根据最终状态设置对话状态 + if final_status in ["done", "done_no_reply", "recall"]: + conversation_instance.state = ConversationState.ANALYZING # 设置为分析中 + elif final_status in ["error", "max_checker_attempts_failed"]: + conversation_instance.state = ConversationState.ERROR # 设置为错误 + # 其他状态如 LISTENING, WAITING, IGNORED, ENDED 在各自的处理器内部或由循环设置。 + + # 此处移至 try 块以确保即使在发生异常之前也运行 + # 如果动作不是回复类型的动作 + if action not in ["direct_reply", "send_new_message", "say_goodbye", "send_memes"]: + if conversation_info: # 再次检查 conversation_info 是否不为 None + conversation_info.last_successful_reply_action = None # 清除上次成功回复动作 + conversation_info.last_reply_rejection_reason = None # 清除上次回复拒绝原因 + conversation_info.last_rejected_reply_content = None # 清除上次拒绝的回复内容 + # 如果动作不是发送表情包或发送表情包失败 + if action != "send_memes" or not action_successful: + # 如果 conversation_info 存在且有 current_emoji_query 属性 + if conversation_info and hasattr(conversation_info, "current_emoji_query"): + conversation_info.current_emoji_query = None # 清除当前表情查询 + + log_final_reason_msg = final_reason if final_reason else "无明确原因" # 记录的最终原因消息 + # 如果最终状态为 "done",动作成功,且是直接回复或发送新消息,并且有生成的回复 if ( final_status == "done" and action_successful @@ -774,8 +173,10 @@ async def handle_action( and hasattr(conversation_instance, "generated_reply") and conversation_instance.generated_reply ): - log_final_reason += f" (发送内容: '{conversation_instance.generated_reply[:30]}...')" + log_final_reason_msg += f" (发送内容: '{conversation_instance.generated_reply[:30]}...')" + # elif final_status == "done" and action_successful and action == "send_memes": + # 表情包的日志记录在其处理器内部或通过下面的通用日志处理 logger.info( - f"[私聊][{conversation_instance.private_name}] 动作 '{action}' 处理完成。最终状态: {final_status}, 原因: {log_final_reason}" + f"[私聊][{conversation_instance.private_name}] 动作 '{action}' 处理完成。最终状态: {final_status}, 原因: {log_final_reason_msg}" ) diff --git a/src/experimental/PFC/chat_observer.py b/src/experimental/PFC/chat_observer.py index a0cf27b0..2e70ceb4 100644 --- a/src/experimental/PFC/chat_observer.py +++ b/src/experimental/PFC/chat_observer.py @@ -15,7 +15,7 @@ from rich.traceback import install install(extra_lines=3) -logger = get_logger("chat_observer") +logger = get_logger("pfc_chat_observer") class ChatObserver: diff --git a/src/experimental/PFC/conversation.py b/src/experimental/PFC/conversation.py index 2ed546eb..59ba3870 100644 --- a/src/experimental/PFC/conversation.py +++ b/src/experimental/PFC/conversation.py @@ -2,7 +2,6 @@ import time import asyncio import traceback from typing import Dict, Any, Optional - from src.common.logger_manager import get_logger from maim_message import UserInfo from src.chat.message_receive.chat_stream import chat_manager, ChatStream diff --git a/src/experimental/PFC/conversation_info.py b/src/experimental/PFC/conversation_info.py index 8bbf5ef5..a03dabea 100644 --- a/src/experimental/PFC/conversation_info.py +++ b/src/experimental/PFC/conversation_info.py @@ -11,11 +11,9 @@ class ConversationInfo: self.last_reply_rejection_reason: Optional[str] = None # 用于存储上次回复被拒原因 self.last_rejected_reply_content: Optional[str] = None # 用于存储上次被拒的回复内容 self.my_message_count: int = 0 # 用于存储连续发送了多少条消息 - - # --- 新增字段 --- self.person_id: Optional[str] = None # 私聊对象的唯一ID self.relationship_text: Optional[str] = "你们还不熟悉。" # 与当前对话者的关系描述文本 self.current_emotion_text: Optional[str] = "心情平静。" # 机器人当前的情绪描述文本 self.current_instance_message_count: int = 0 # 当前私聊实例中的消息计数 self.other_new_messages_during_planning_count: int = 0 # 在计划阶段期间收到的其他新消息计数 - # --- 新增字段结束 --- + self.current_emoji_query: Optional[str] = None # 表情包 diff --git a/src/experimental/PFC/conversation_loop.py b/src/experimental/PFC/conversation_loop.py index 1d2ef5be..a12026d7 100644 --- a/src/experimental/PFC/conversation_loop.py +++ b/src/experimental/PFC/conversation_loop.py @@ -176,7 +176,7 @@ async def run_conversation_loop(conversation_instance: "Conversation"): if action in ["wait", "listening"] and new_msg_count_action_planning > 0: should_interrupt_action_planning = True interrupt_reason_action_planning = f"规划 {action} 期间收到 {new_msg_count_action_planning} 条新消息" - elif other_new_msg_count_action_planning > 2: + elif other_new_msg_count_action_planning > global_config.pfc_message_buffer_size: should_interrupt_action_planning = True interrupt_reason_action_planning = ( f"规划 {action} 期间收到 {other_new_msg_count_action_planning} 条来自他人的新消息" @@ -335,13 +335,58 @@ async def run_conversation_loop(conversation_instance: "Conversation"): # --- Post LLM Action Task Handling --- if not llm_action_completed_successfully: - if conversation_instance.consecutive_llm_action_failures >= MAX_CONSECUTIVE_LLM_ACTION_FAILURES: + last_action_record = {} + last_action_final_status = "unknown" + # 从 conversation_info.done_action 获取上一个动作的最终状态 + if conversation_instance.conversation_info and conversation_instance.conversation_info.done_action: + if conversation_instance.conversation_info.done_action: # 确保列表不为空 + last_action_record = conversation_instance.conversation_info.done_action[-1] + last_action_final_status = last_action_record.get("status", "unknown") + + if last_action_final_status == "max_checker_attempts_failed": + original_planned_action = last_action_record.get("action", "unknown_original_action") + original_plan_reason = last_action_record.get("plan_reason", "unknown_original_reason") + checker_fail_reason_from_history = last_action_record.get( + "final_reason", "ReplyChecker判定不合适" + ) + + logger.warning( + f"[私聊][{conversation_instance.private_name}] (Loop) 原规划动作 '{original_planned_action}' 因达到ReplyChecker最大尝试次数而失败。将强制执行 'wait' 动作。" + ) + + action_to_perform_now = "wait" # 强制动作为 "wait" + reason_for_forced_wait = f"原动作 '{original_planned_action}' (规划原因: {original_plan_reason}) 因 ReplyChecker 多次判定不合适 ({checker_fail_reason_from_history}) 而失败,现强制等待。" + + if conversation_instance.conversation_info: + # 确保下次规划不是基于这个失败的回复动作的追问 + conversation_instance.conversation_info.last_successful_reply_action = None + # 重置连续LLM失败计数器,因为我们已经用特定的“等待”动作处理了这种失败类型 + conversation_instance.consecutive_llm_action_failures = 0 + + logger.info(f"[私聊][{conversation_instance.private_name}] (Loop) 执行强制等待动作...") + await actions.handle_action( + conversation_instance, + action_to_perform_now, # "wait" + reason_for_forced_wait, + conversation_instance.observation_info, + conversation_instance.conversation_info, + ) + # "wait" 动作执行后,其内部逻辑会将状态设置为 ANALYZING (通过 finally 块) + # 所以循环的下一轮会自然地重新规划或根据等待结果行动 + _force_reflect_and_act_next_iter = False # 确保此路径不会强制反思 + await asyncio.sleep(0.1) # 短暂暂停,等待状态更新等 + continue # 进入主循环的下一次迭代 + + elif conversation_instance.consecutive_llm_action_failures >= MAX_CONSECUTIVE_LLM_ACTION_FAILURES: logger.error( f"[私聊][{conversation_instance.private_name}] (Loop) LLM相关动作连续失败或被取消 {conversation_instance.consecutive_llm_action_failures} 次。将强制等待并重置计数器。" ) - action = "wait" # Force action to wait - reason = f"LLM连续失败{conversation_instance.consecutive_llm_action_failures}次,强制等待" + forced_wait_action_on_consecutive_failure = "wait" + reason_for_consecutive_failure_wait = ( + f"LLM连续失败{conversation_instance.consecutive_llm_action_failures}次,强制等待" + ) + conversation_instance.consecutive_llm_action_failures = 0 if conversation_instance.conversation_info: @@ -350,8 +395,8 @@ async def run_conversation_loop(conversation_instance: "Conversation"): logger.info(f"[私聊][{conversation_instance.private_name}] (Loop) 执行强制等待动作...") await actions.handle_action( conversation_instance, - action, - reason, + forced_wait_action_on_consecutive_failure, # "wait" + reason_for_consecutive_failure_wait, conversation_instance.observation_info, conversation_instance.conversation_info, ) diff --git a/src/experimental/PFC/message_sender.py b/src/experimental/PFC/message_sender.py index eb5cdb6a..4b1663cc 100644 --- a/src/experimental/PFC/message_sender.py +++ b/src/experimental/PFC/message_sender.py @@ -12,7 +12,7 @@ from rich.traceback import install install(extra_lines=3) -logger = get_logger("message_sender") +logger = get_logger("pfc_sender") class DirectMessageSender: @@ -24,8 +24,10 @@ class DirectMessageSender: async def send_message( self, chat_stream: ChatStream, - content: str, + segments: Seg, reply_to_message: Optional[Message] = None, + is_emoji: Optional[bool] = False, + content: str = None, ) -> None: """发送消息到聊天流 @@ -35,9 +37,6 @@ class DirectMessageSender: reply_to_message: 要回复的消息(可选) """ try: - # 创建消息内容 - segments = Seg(type="seglist", data=[Seg(type="text", data=content)]) - # 获取麦麦的信息 bot_user_info = UserInfo( user_id=global_config.BOT_QQ, @@ -57,7 +56,7 @@ class DirectMessageSender: message_segment=segments, reply=reply_to_message, is_head=True, - is_emoji=False, + is_emoji=is_emoji, thinking_start_time=time.time(), ) @@ -71,7 +70,10 @@ class DirectMessageSender: message_set = MessageSet(chat_stream, message_id) message_set.add_message(message) await message_manager.add_message(message_set) - logger.info(f"[私聊][{self.private_name}]PFC消息已发送: {content}") + if is_emoji: + logger.info(f"[私聊][{self.private_name}]PFC表情消息已发送: {content}") + else: + logger.info(f"[私聊][{self.private_name}]PFC消息已发送: {content}") except Exception as e: logger.error(f"[私聊][{self.private_name}]PFC消息发送失败: {str(e)}") diff --git a/src/experimental/PFC/observation_info.py b/src/experimental/PFC/observation_info.py index feefb71b..1272e0e0 100644 --- a/src/experimental/PFC/observation_info.py +++ b/src/experimental/PFC/observation_info.py @@ -5,13 +5,12 @@ from typing import List, Optional, Dict, Any, Set from maim_message import UserInfo from src.common.logger_manager import get_logger from src.chat.utils.chat_message_builder import build_readable_messages -from src.config.config import global_config # 确保导入路径正确 from .chat_observer import ChatObserver from .chat_states import NotificationHandler, NotificationType, Notification -logger = get_logger("observation_info") +logger = get_logger("pfc_observation_info") TIME_ZONE = tz.gettz("Asia/Shanghai") # 使用配置的时区,提供默认值 diff --git a/src/experimental/PFC/pfc_processor.py b/src/experimental/PFC/pfc_processor.py index a6607569..9637e26b 100644 --- a/src/experimental/PFC/pfc_processor.py +++ b/src/experimental/PFC/pfc_processor.py @@ -1,123 +1,178 @@ +# TODO: 人格侧写(不要把人格侧写的功能实现写到这里!新建文件去) import traceback - -from maim_message import UserInfo +import re +from typing import Any +from datetime import datetime # 确保导入 datetime +from maim_message import UserInfo # UserInfo 来自 maim_message 包 # 从 maim_message 导入 MessageRecv from src.config.config import global_config from src.common.logger_manager import get_logger -from src.chat.message_receive.chat_stream import chat_manager -from typing import Optional, Dict, Any +from src.chat.utils.utils import get_embedding +from src.common.database import db from .pfc_manager import PFCManager +from src.chat.message_receive.chat_stream import ChatStream, chat_manager from src.chat.message_receive.message import MessageRecv from src.chat.message_receive.storage import MessageStorage -from datetime import datetime logger = get_logger("pfc_processor") -async def _handle_error(error: Exception, context: str, message: Optional[MessageRecv] = None) -> None: +async def _handle_error( + error: Exception, context: str, message: MessageRecv | None = None +) -> None: # 明确 message 类型 """统一的错误处理函数 - - Args: - error: 捕获到的异常 - context: 错误发生的上下文描述 - message: 可选的消息对象,用于记录相关消息内容 + # ... (方法注释不变) ... """ logger.error(f"{context}: {error}") logger.error(traceback.format_exc()) - if message and hasattr(message, "raw_message"): + # 检查 message 是否 None 以及是否有 raw_message 属性 + if ( + message and hasattr(message, "message_info") and hasattr(message.message_info, "raw_message") + ): # MessageRecv 结构可能没有直接的 raw_message + raw_msg_content = getattr(message.message_info, "raw_message", None) # 安全获取 + if raw_msg_content: + logger.error(f"相关消息原始内容: {raw_msg_content}") + elif message and hasattr(message, "raw_message"): # 如果 MessageRecv 直接有 raw_message logger.error(f"相关消息原始内容: {message.raw_message}") class PFCProcessor: - """PFC 处理器,负责处理接收到的信息并计数""" - def __init__(self): """初始化 PFC 处理器,创建消息存储实例""" - self.storage = MessageStorage() + # MessageStorage() 的实例化位置和具体类是什么? + # 我们假设它来自 src.plugins.storage.storage + # 但由于我们不能修改那个文件,所以这里的 self.storage 将按原样使用 + + self.storage: MessageStorage = MessageStorage() self.pfc_manager = PFCManager.get_instance() - async def process_message(self, message_data: Dict[str, Any]) -> None: + async def process_message(self, message_data: dict[str, Any]) -> None: # 使用 dict[str, Any] 替代 Dict """处理接收到的原始消息数据 - - 主要流程: - 1. 消息解析与初始化 - 2. 过滤检查 - 3. 消息存储 - 4. 创建 PFC 流 - 5. 日志记录 - - Args: - message_data: 原始消息字符串 + # ... (方法注释不变) ... """ - message = None + message_obj: MessageRecv | None = None # 初始化为 None,并明确类型 try: # 1. 消息解析与初始化 - message = MessageRecv(message_data) - groupinfo = message.message_info.group_info - userinfo = message.message_info.user_info - messageinfo = message.message_info + message_obj = MessageRecv(message_data) # 使用你提供的 message.py 中的 MessageRecv + + groupinfo = getattr(message_obj.message_info, "group_info", None) + userinfo = getattr(message_obj.message_info, "user_info", None) logger.trace(f"准备为{userinfo.user_id}创建/获取聊天流") chat = await chat_manager.get_or_create_stream( - platform=messageinfo.platform, + platform=message_obj.message_info.platform, user_info=userinfo, group_info=groupinfo, ) - message.update_chat_stream(chat) + message_obj.update_chat_stream(chat) # message.py 中 MessageRecv 有此方法 # 2. 过滤检查 - # 处理消息 - await message.process() - # 过滤词/正则表达式过滤 - if self._check_ban_words(message.processed_plain_text, userinfo) or self._check_ban_regex( - message.raw_message, userinfo - ): + await message_obj.process() # 调用 MessageRecv 的异步 process 方法 + if self._check_ban_words(message_obj.processed_plain_text, userinfo) or self._check_ban_regex( + message_obj.raw_message, userinfo + ): # MessageRecv 有 raw_message 属性 return - # 3. 消息存储 - await self.storage.store_message(message, chat) - logger.trace(f"存储成功: {message.processed_plain_text}") + # 3. 消息存储 (保持原有调用) + # 这里的 self.storage.store_message 来自 src/plugins/storage/storage.py + # 它内部会将 message_obj 转换为字典并存储 + await self.storage.store_message(message_obj, chat) + logger.trace(f"存储成功 (初步): {message_obj.processed_plain_text}") + + await self._update_embedding_vector(message_obj, chat) # 明确传递 message_obj # 4. 创建 PFC 聊天流 - await self._create_pfc_chat(message) + await self._create_pfc_chat(message_obj) # 5. 日志记录 - # 将时间戳转换为datetime对象 - current_time = datetime.fromtimestamp(message.message_info.time).strftime("%H:%M:%S") - logger.info( - f"[{current_time}][私聊]{message.message_info.user_info.user_nickname}: {message.processed_plain_text}" - ) + # 确保 message_obj.message_info.time 是 float 类型的时间戳 + current_time_display = datetime.fromtimestamp(float(message_obj.message_info.time)).strftime("%H:%M:%S") + + # 确保 userinfo.user_nickname 存在 + user_nickname_display = getattr(userinfo, "user_nickname", "未知用户") + + logger.info(f"[{current_time_display}][私聊]{user_nickname_display}: {message_obj.processed_plain_text}") except Exception as e: - await _handle_error(e, "消息处理失败", message) + await _handle_error(e, "消息处理失败", message_obj) # 传递 message_obj - async def _create_pfc_chat(self, message: MessageRecv): + async def _create_pfc_chat(self, message: MessageRecv): # 明确 message 类型 try: chat_id = str(message.chat_stream.stream_id) - private_name = str(message.message_info.user_info.user_nickname) + private_name = str(message.message_info.user_info.user_nickname) # 假设 UserInfo 有 user_nickname if global_config.enable_pfc_chatting: await self.pfc_manager.get_or_create_conversation(chat_id, private_name) except Exception as e: - logger.error(f"创建PFC聊天失败: {e}") + logger.error(f"创建PFC聊天失败: {e}", exc_info=True) # 添加 exc_info=True @staticmethod - def _check_ban_words(text: str, userinfo: UserInfo) -> bool: + def _check_ban_words(text: str, userinfo: UserInfo) -> bool: # 明确 userinfo 类型 """检查消息中是否包含过滤词""" for word in global_config.ban_words: if word in text: - logger.info(f"[私聊]{userinfo.user_nickname}:{text}") + logger.info(f"[私聊]{userinfo.user_nickname}:{text}") # 假设 UserInfo 有 user_nickname logger.info(f"[过滤词识别]消息中含有{word},filtered") return True return False @staticmethod - def _check_ban_regex(text: str, userinfo: UserInfo) -> bool: + def _check_ban_regex(text: str, userinfo: UserInfo) -> bool: # 明确 userinfo 类型 """检查消息是否匹配过滤正则表达式""" for pattern in global_config.ban_msgs_regex: - if pattern.search(text): - logger.info(f"[私聊]{userinfo.user_nickname}:{text}") - logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") + if pattern.search(text): # 假设 ban_msgs_regex 中的元素是已编译的正则对象 + logger.info(f"[私聊]{userinfo.user_nickname}:{text}") # _nickname + logger.info(f"[正则表达式过滤]消息匹配到{pattern.pattern},filtered") # .pattern 获取原始表达式字符串 return True return False + + async def _update_embedding_vector(self, message_obj: MessageRecv, chat: ChatStream) -> None: + """更新消息的嵌入向量""" + # === 新增:为已存储的消息生成嵌入并更新数据库文档 === + embedding_vector = None + text_for_embedding = message_obj.processed_plain_text # 使用处理后的纯文本 + + # 在 storage.py 中,会对 processed_plain_text 进行一次过滤 + # 为了保持一致,我们也在这里应用相同的过滤逻辑 + # 当然,更优的做法是 store_message 返回过滤后的文本,或在 message_obj 中增加一个 filtered_processed_plain_text 属性 + # 这里为了简单,我们先重复一次过滤逻辑 + pattern = r".*?|.*?|.*?" + if text_for_embedding: + filtered_text_for_embedding = re.sub(pattern, "", text_for_embedding, flags=re.DOTALL) + else: + filtered_text_for_embedding = "" + + if filtered_text_for_embedding and filtered_text_for_embedding.strip(): + try: + # request_type 参数根据你的 get_embedding 函数实际需求来定 + embedding_vector = await get_embedding(filtered_text_for_embedding, request_type="pfc_private_memory") + if embedding_vector: + logger.debug(f"成功为消息 ID '{message_obj.message_info.message_id}' 生成嵌入向量。") + + # 更新数据库中的对应文档 + # 确保你有权限访问和操作 db 对象 + update_result = db.messages.update_one( + {"message_id": message_obj.message_info.message_id, "chat_id": chat.stream_id}, + {"$set": {"embedding_vector": embedding_vector}}, + ) + if update_result.modified_count > 0: + logger.info(f"成功为消息 ID '{message_obj.message_info.message_id}' 更新嵌入向量到数据库。") + elif update_result.matched_count > 0: + logger.warning(f"消息 ID '{message_obj.message_info.message_id}' 已存在嵌入向量或未作修改。") + else: + logger.error( + f"未能找到消息 ID '{message_obj.message_info.message_id}' (chat_id: {chat.stream_id}) 来更新嵌入向量。可能是存储和更新之间存在延迟或问题。" + ) + else: + logger.warning( + f"未能为消息 ID '{message_obj.message_info.message_id}' 的文本 '{filtered_text_for_embedding[:30]}...' 生成嵌入向量。" + ) + except Exception as e_embed_update: + logger.error( + f"为消息 ID '{message_obj.message_info.message_id}' 生成嵌入或更新数据库时发生异常: {e_embed_update}", + exc_info=True, + ) + else: + logger.debug(f"消息 ID '{message_obj.message_info.message_id}' 的过滤后纯文本为空,不生成或更新嵌入。") + # === 新增结束 === diff --git a/src/experimental/PFC/pfc_relationship.py b/src/experimental/PFC/pfc_relationship.py index fb32f682..580e2463 100644 --- a/src/experimental/PFC/pfc_relationship.py +++ b/src/experimental/PFC/pfc_relationship.py @@ -265,7 +265,7 @@ class PfcRepationshipTranslator: "初识", # level_num 2 "友好", # level_num 3 "喜欢", # level_num 4 - "暧昧", # level_num 5 + "依赖", # level_num 5 ] if 0 <= level_num < len(relationship_descriptions): @@ -274,7 +274,7 @@ class PfcRepationshipTranslator: description = "普通" # 默认或错误情况 logger.warning(f"[私聊][{self.private_name}] 计算出的 level_num ({level_num}) 无效,关系描述默认为 '普通'") - return f"你们的关系是:{description}。" + return f"{description}。" @staticmethod def _calculate_relationship_level_num(relationship_value: float, private_name: str) -> int: diff --git a/src/experimental/PFC/pfc_utils.py b/src/experimental/PFC/pfc_utils.py index 86c8450c..f250e8bc 100644 --- a/src/experimental/PFC/pfc_utils.py +++ b/src/experimental/PFC/pfc_utils.py @@ -1,23 +1,275 @@ import traceback import json import re +import time +from datetime import datetime from typing import Dict, Any, Optional, Tuple, List, Union -from src.common.logger_manager import get_logger # 确认 logger 的导入路径 +from src.common.logger_manager import get_logger +from src.config.config import global_config +from src.common.database import db from src.chat.memory_system.Hippocampus import HippocampusManager from src.chat.focus_chat.heartflow_prompt_builder import prompt_builder +from src.chat.utils.utils import get_embedding from src.chat.utils.chat_message_builder import build_readable_messages from src.chat.message_receive.chat_stream import ChatStream from src.chat.person_info.person_info import person_info_manager import math from .observation_info import ObservationInfo -from src.config.config import global_config + logger = get_logger("pfc_utils") -async def retrieve_contextual_info(text: str, private_name: str) -> Tuple[str, str]: +# ============================================================================== +# 专门用于检索 PFC 私聊历史对话上下文的函数 +# ============================================================================== +async def find_most_relevant_historical_message( + chat_id: str, + query_text: str, + similarity_threshold: float = 0.3, # 相似度阈值,可以根据效果调整 + absolute_search_time_limit: Optional[float] = None, # 新增参数:排除最近多少秒内的消息(例如5分钟) +) -> Optional[Dict[str, Any]]: """ - 根据输入文本检索相关的记忆和知识。 + 根据查询文本,在指定 chat_id 的历史消息中查找最相关的消息。 + """ + if not query_text or not query_text.strip(): + logger.debug(f"[{chat_id}] (私聊历史)查询文本为空,跳过检索。") + return None + + logger.debug(f"[{chat_id}] (私聊历史)开始为查询文本 '{query_text[:50]}...' 检索。") + + # 使用你项目中已有的 get_embedding 函数 + # request_type 参数需要根据 get_embedding 的实际需求调整 + query_embedding = await get_embedding(query_text, request_type="pfc_historical_chat_query") + if not query_embedding: + logger.warning(f"[{chat_id}] (私聊历史)未能为查询文本 '{query_text[:50]}...' 生成嵌入向量。") + return None + + effective_search_upper_limit: float + log_source_of_limit: str = "" + + if absolute_search_time_limit is not None: + effective_search_upper_limit = absolute_search_time_limit + log_source_of_limit = "传入的绝对时间上限" + else: + # 如果没有传入绝对时间上限,可以设置一个默认的回退逻辑 + fallback_exclude_seconds = getattr(global_config, "pfc_historical_fallback_exclude_seconds", 7200) # 默认2小时 + effective_search_upper_limit = time.time() - fallback_exclude_seconds + log_source_of_limit = f"回退逻辑 (排除最近 {fallback_exclude_seconds} 秒)" + + logger.debug( + f"[{chat_id}] (私聊历史) find_most_relevant_historical_message: " + f"将使用时间上限 {effective_search_upper_limit} " + f"(可读: {datetime.fromtimestamp(effective_search_upper_limit).strftime('%Y-%m-%d %H:%M:%S')}) " + f"进行历史消息锚点搜索。来源: {log_source_of_limit}" + ) + # --- [新代码结束] --- + + pipeline = [ + { + "$match": { + "chat_id": chat_id, + "embedding_vector": {"$exists": True, "$ne": None, "$not": {"$size": 0}}, + "time": {"$lt": effective_search_upper_limit}, # <--- 使用新的 effective_search_upper_limit + } + }, + { + "$addFields": { + "dotProduct": { + "$reduce": { + "input": {"$range": [0, {"$size": "$embedding_vector"}]}, + "initialValue": 0, + "in": { + "$add": [ + "$$value", + { + "$multiply": [ + {"$arrayElemAt": ["$embedding_vector", "$$this"]}, + {"$arrayElemAt": [query_embedding, "$$this"]}, + ] + }, + ] + }, + } + }, + "queryVecMagnitude": { + "$sqrt": { + "$reduce": { + "input": query_embedding, + "initialValue": 0, + "in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]}, + } + } + }, + "docVecMagnitude": { + "$sqrt": { + "$reduce": { + "input": "$embedding_vector", + "initialValue": 0, + "in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]}, + } + } + }, + } + }, + { + "$addFields": { + "similarity": { + "$cond": [ + {"$and": [{"$gt": ["$queryVecMagnitude", 0]}, {"$gt": ["$docVecMagnitude", 0]}]}, + {"$divide": ["$dotProduct", {"$multiply": ["$queryVecMagnitude", "$docVecMagnitude"]}]}, + 0, + ] + } + } + }, + {"$match": {"similarity": {"$gte": similarity_threshold}}}, + {"$sort": {"similarity": -1}}, + {"$limit": 1}, + { + "$project": { + "_id": 0, + "message_id": 1, + "time": 1, + "chat_id": 1, + "user_info": 1, + "processed_plain_text": 1, + "similarity": 1, + } + }, # 可以不返回 embedding_vector 节省带宽 + ] + + try: + # --- 确定性修改:同步执行聚合和结果转换 --- + cursor = db.messages.aggregate(pipeline) # PyMongo 的 aggregate 返回一个 CommandCursor + results = list(cursor) # 直接将 CommandCursor 转换为列表 + if not results: + logger.info( + f"[{chat_id}] (私聊历史) find_most_relevant_historical_message: 在时间点 {effective_search_upper_limit} 之前,未能找到任何与 '{query_text[:30]}...' 相关的历史消息。" + ) + else: + logger.info( + f"[{chat_id}] (私聊历史) find_most_relevant_historical_message: 在时间点 {effective_search_upper_limit} 之前,找到了 {len(results)} 条候选历史消息。最相关的一条是:" + ) + for res_msg in results: + msg_time_readable = datetime.fromtimestamp(res_msg.get("time", 0)).strftime("%Y-%m-%d %H:%M:%S") + logger.info( + f" - MsgID: {res_msg.get('message_id')}, Time: {msg_time_readable} (原始: {res_msg.get('time')}), Sim: {res_msg.get('similarity'):.4f}, Text: '{res_msg.get('processed_plain_text', '')[:50]}...'" + ) + # --- [修改结束] --- + + # --- 修改结束 --- + if results and len(results) > 0: + most_similar_message = results[0] + logger.info( + f"[{chat_id}] (私聊历史)找到最相关消息 ID: {most_similar_message.get('message_id')}, 相似度: {most_similar_message.get('similarity'):.4f}" + ) + return most_similar_message + else: + logger.debug(f"[{chat_id}] (私聊历史)未找到相似度超过 {similarity_threshold} 的相关消息。") + return None + except Exception as e: + logger.error(f"[{chat_id}] (私聊历史)在数据库中检索时出错: {e}", exc_info=True) + return None + + +async def retrieve_chat_context_window( + chat_id: str, + anchor_message_id: str, + anchor_message_time: float, + excluded_time_threshold_for_window: float, + window_size_before: int = 7, + window_size_after: int = 7, +) -> List[Dict[str, Any]]: + """ + 以某条消息为锚点,获取其前后的聊天记录形成一个上下文窗口。 + """ + if not anchor_message_id or anchor_message_time is None: + return [] + + context_messages: List[Dict[str, Any]] = [] # 明确类型 + logger.debug( + f"[{chat_id}] (私聊历史)准备以消息 ID '{anchor_message_id}' (时间: {anchor_message_time}) 为锚点,获取上下文窗口..." + ) + + try: + # --- 同步执行 find_one 和 find --- + anchor_message = db.messages.find_one({"message_id": anchor_message_id, "chat_id": chat_id}) + + messages_before_cursor = ( + db.messages.find({"chat_id": chat_id, "time": {"$lt": anchor_message_time}}) + .sort("time", -1) + .limit(window_size_before) + ) + messages_before = list(messages_before_cursor) + messages_before.reverse() + # --- 新增日志 --- + logger.debug( + f"[{chat_id}] (私聊历史) retrieve_chat_context_window: Anchor Time: {anchor_message_time}, Excluded Window End Time: {excluded_time_threshold_for_window}" + ) + logger.debug( + f"[{chat_id}] (私聊历史) retrieve_chat_context_window: Messages BEFORE anchor ({len(messages_before)}):" + ) + for msg_b in messages_before: + logger.debug( + f" - Time: {datetime.fromtimestamp(msg_b.get('time', 0)).strftime('%Y-%m-%d %H:%M:%S')}, Text: '{msg_b.get('processed_plain_text', '')[:30]}...'" + ) + + messages_after_cursor = ( + db.messages.find( + {"chat_id": chat_id, "time": {"$gt": anchor_message_time, "$lt": excluded_time_threshold_for_window}} + ) + .sort("time", 1) + .limit(window_size_after) + ) + messages_after = list(messages_after_cursor) + # --- 新增日志 --- + logger.debug( + f"[{chat_id}] (私聊历史) retrieve_chat_context_window: Messages AFTER anchor ({len(messages_after)}):" + ) + for msg_a in messages_after: + logger.debug( + f" - Time: {datetime.fromtimestamp(msg_a.get('time', 0)).strftime('%Y-%m-%d %H:%M:%S')}, Text: '{msg_a.get('processed_plain_text', '')[:30]}...'" + ) + + if messages_before: + context_messages.extend(messages_before) + if anchor_message: + anchor_message.pop("_id", None) + context_messages.append(anchor_message) + if messages_after: + context_messages.extend(messages_after) + + final_window: List[Dict[str, Any]] = [] # 明确类型 + seen_ids: set[str] = set() # 明确类型 + for msg in context_messages: + msg_id = msg.get("message_id") + if msg_id and msg_id not in seen_ids: # 确保 msg_id 存在 + final_window.append(msg) + seen_ids.add(msg_id) + + final_window.sort(key=lambda m: m.get("time", 0)) + logger.info( + f"[{chat_id}] (私聊历史)为锚点 '{anchor_message_id}' 构建了包含 {len(final_window)} 条消息的上下文窗口。" + ) + return final_window + except Exception as e: + logger.error(f"[{chat_id}] (私聊历史)获取消息 ID '{anchor_message_id}' 的上下文窗口时出错: {e}", exc_info=True) + return [] + + +# ============================================================================== +# 修改后的 retrieve_contextual_info 函数 +# ============================================================================== +async def retrieve_contextual_info( + text: str, # 用于全局记忆和知识检索的主查询文本 (通常是短期聊天记录) + private_name: str, # 用于日志 + chat_id: str, # 用于特定私聊历史的检索 + historical_chat_query_text: Optional[str] = None, + current_short_term_history_earliest_time: Optional[float] = None, # <--- 新增参数 +) -> Tuple[str, str, str]: # 返回: 全局记忆, 知识, 私聊历史回忆 + """ + 检索三种类型的上下文信息:全局压缩记忆、知识库知识、当前私聊的特定历史对话。 Args: text: 用于检索的上下文文本 (例如聊天记录)。 @@ -26,61 +278,176 @@ async def retrieve_contextual_info(text: str, private_name: str) -> Tuple[str, s Returns: Tuple[str, str]: (检索到的记忆字符串, 检索到的知识字符串) """ - retrieved_memory_str = "无相关记忆。" + # 初始化返回值 + retrieved_global_memory_str = "无相关全局记忆。" retrieved_knowledge_str = "无相关知识。" - memory_log_msg = "未自动检索到相关记忆。" - knowledge_log_msg = "未自动检索到相关知识。" + retrieved_historical_chat_str = "无相关私聊历史回忆。" - if not text or text == "还没有聊天记录。" or text == "[构建聊天记录出错]": - logger.debug(f"[私聊][{private_name}] (retrieve_contextual_info) 无有效上下文,跳过检索。") - return retrieved_memory_str, retrieved_knowledge_str + # --- 1. 全局压缩记忆检索 (来自 HippocampusManager) --- + global_memory_log_msg = f"开始全局压缩记忆检索 (基于文本: '{text[:30]}...')" + if text and text.strip() and text != "还没有聊天记录。" and text != "[构建聊天记录出错]": + try: + related_memory = await HippocampusManager.get_instance().get_memory_from_text( + text=text, + max_memory_num=2, + max_memory_length=2, + max_depth=3, + fast_retrieval=False, + ) + if related_memory: + temp_global_memory_info = "" + for memory_item in related_memory: + if isinstance(memory_item, (list, tuple)) and len(memory_item) > 1: + temp_global_memory_info += str(memory_item[1]) + "\n" + elif isinstance(memory_item, str): + temp_global_memory_info += memory_item + "\n" - # 1. 检索记忆 (逻辑来自原 _get_memory_info) - try: - related_memory = await HippocampusManager.get_instance().get_memory_from_text( - text=text, - max_memory_num=2, - max_memory_length=2, - max_depth=3, - fast_retrieval=False, - ) - if related_memory: - related_memory_info = "" - for memory in related_memory: - related_memory_info += memory[1] + "\n" - if related_memory_info: - # 注意:原版提示信息可以根据需要调整 - retrieved_memory_str = f"你回忆起:\n{related_memory_info.strip()}\n(以上是你的回忆,供参考)\n" - memory_log_msg = f"自动检索到记忆: {related_memory_info.strip()[:100]}..." + if temp_global_memory_info.strip(): + retrieved_global_memory_str = f"你回忆起一些相关的记忆:\n{temp_global_memory_info.strip()}\n(以上是你的一些回忆,不一定是跟对方有关的,回忆里的人说的也不一定是事实,供参考)\n" + global_memory_log_msg = f"自动检索到全局压缩记忆: {temp_global_memory_info.strip()[:100]}..." + else: + global_memory_log_msg = "全局压缩记忆检索返回为空或格式不符。" else: - memory_log_msg = "自动检索记忆返回为空。" - logger.debug(f"[私聊][{private_name}] (retrieve_contextual_info) 记忆检索: {memory_log_msg}") + global_memory_log_msg = "全局压缩记忆检索返回为空列表。" + logger.debug(f"[私聊][{private_name}] (retrieve_contextual_info) 全局压缩记忆检索: {global_memory_log_msg}") + except Exception as e: + logger.error( + f"[私聊][{private_name}] (retrieve_contextual_info) 检索全局压缩记忆时出错: {e}\n{traceback.format_exc()}" + ) + retrieved_global_memory_str = "[检索全局压缩记忆时出错]\n" + else: + logger.debug(f"[私聊][{private_name}] (retrieve_contextual_info) 无有效主查询文本,跳过全局压缩记忆检索。") - except Exception as e: - logger.error( - f"[私聊][{private_name}] (retrieve_contextual_info) 自动检索记忆时出错: {e}\n{traceback.format_exc()}" + # --- 2. 相关知识检索 (来自 prompt_builder) --- + knowledge_log_msg = f"开始知识检索 (基于文本: '{text[:30]}...')" + if text and text.strip() and text != "还没有聊天记录。" and text != "[构建聊天记录出错]": + try: + knowledge_result = await prompt_builder.get_prompt_info( + message=text, + threshold=0.38, + ) + if knowledge_result and knowledge_result.strip(): # 确保结果不为空 + retrieved_knowledge_str = knowledge_result # 直接使用返回结果,如果需要也可以包装 + knowledge_log_msg = f"自动检索到相关知识: {knowledge_result[:100]}..." + else: + knowledge_log_msg = "知识检索返回为空。" + logger.debug(f"[私聊][{private_name}] (retrieve_contextual_info) 知识检索: {knowledge_log_msg}") + except Exception as e: + logger.error( + f"[私聊][{private_name}] (retrieve_contextual_info) 自动检索知识时出错: {e}\n{traceback.format_exc()}" + ) + retrieved_knowledge_str = "[检索知识时出错]\n" + else: + logger.debug(f"[私聊][{private_name}] (retrieve_contextual_info) 无有效主查询文本,跳过知识检索。") + + # --- 3. 当前私聊的特定历史对话上下文检索 --- + query_for_historical_chat = ( + historical_chat_query_text if historical_chat_query_text and historical_chat_query_text.strip() else None + ) + # historical_chat_log_msg 的初始化可以移到 try 块之后,根据实际情况赋值 + + if query_for_historical_chat: + try: + # ---- 计算最终的、严格的搜索时间上限 ---- + # 1. 设置一个基础的、较大的时间回溯窗口,例如2小时 (7200秒) + # 这个值可以从全局配置读取,如果没配置则使用默认值 + default_search_exclude_seconds = getattr( + global_config, "pfc_historical_search_default_exclude_seconds", 7200 + ) # 默认2小时 + base_excluded_time_limit = time.time() - default_search_exclude_seconds + + final_search_upper_limit_time = base_excluded_time_limit + if current_short_term_history_earliest_time is not None: + # 我们希望找到的消息严格早于 short_term_history 的开始,减去一个小量确保不包含边界 + limit_from_short_term = current_short_term_history_earliest_time - 0.001 + final_search_upper_limit_time = min(base_excluded_time_limit, limit_from_short_term) + log_earliest_time_str = "未提供" + if current_short_term_history_earliest_time is not None: + try: + log_earliest_time_str = f"{current_short_term_history_earliest_time} (即 {datetime.fromtimestamp(current_short_term_history_earliest_time).strftime('%Y-%m-%d %H:%M:%S')})" + except Exception: + log_earliest_time_str = str(current_short_term_history_earliest_time) + + logger.debug( + f"[{private_name}] (私聊历史) retrieve_contextual_info: " + f"最终用于历史搜索的时间上限: {final_search_upper_limit_time} " + f"(可读: {datetime.fromtimestamp(final_search_upper_limit_time).strftime('%Y-%m-%d %H:%M:%S')}). " + f"基于默认排除 {default_search_exclude_seconds}s 和 '最近记录'片段开始时间: {log_earliest_time_str}" + ) + + most_relevant_message_doc = await find_most_relevant_historical_message( + chat_id=chat_id, + query_text=query_for_historical_chat, + similarity_threshold=0.5, # 您可以调整这个 + # exclude_recent_seconds 不再直接使用,而是传递计算好的绝对时间上限 + absolute_search_time_limit=final_search_upper_limit_time, + ) + + if most_relevant_message_doc: + anchor_id = most_relevant_message_doc.get("message_id") + anchor_time = most_relevant_message_doc.get("time") + + # 校验锚点时间是否真的符合我们的硬性上限 (理论上 find_most_relevant_historical_message 内部已保证) + if anchor_time is not None and anchor_time >= final_search_upper_limit_time: + logger.warning( + f"[{private_name}] (私聊历史) find_most_relevant_historical_message 返回的锚点时间 {anchor_time} " + f"并未严格小于最终搜索上限 {final_search_upper_limit_time}。可能导致重叠。跳过构建上下文。" + ) + historical_chat_log_msg = "检索到的锚点不符合最终时间要求,可能导致重叠。" + # 直接进入下一个分支 (else),使得 retrieved_historical_chat_str 保持默认值 + elif anchor_id and anchor_time is not None: + # 构建上下文窗口时,其“未来”消息的上限也应该是 final_search_upper_limit_time + # 因为我们不希望历史回忆的上下文窗口延伸到“最近聊天记录”的范围内或更近 + time_limit_for_context_window_after = final_search_upper_limit_time + + logger.debug( + f"[{private_name}] (私聊历史) 调用 retrieve_chat_context_window " + f"with anchor_time: {anchor_time}, " + f"excluded_time_threshold_for_window: {time_limit_for_context_window_after}" + ) + + context_window_messages = await retrieve_chat_context_window( + chat_id=chat_id, + anchor_message_id=anchor_id, + anchor_message_time=anchor_time, + excluded_time_threshold_for_window=time_limit_for_context_window_after, + window_size_before=7, + window_size_after=7, + ) + if context_window_messages: + formatted_window_str = await build_readable_messages( + context_window_messages, + replace_bot_name=False, # 在回忆中,保留原始发送者名称 + merge_messages=False, + timestamp_mode="relative", # 可以选择 'absolute' 或 'none' + read_mark=0.0, + ) + if formatted_window_str and formatted_window_str.strip(): + retrieved_historical_chat_str = f"你还想到了一些你们之前的聊天记录:\n------\n{formatted_window_str.strip()}\n------\n(以上是你们之前的聊天记录,供参考)\n" + historical_chat_log_msg = f"自动检索到相关私聊历史片段 (锚点ID: {anchor_id}, 相似度: {most_relevant_message_doc.get('similarity'):.3f})" + return retrieved_global_memory_str, retrieved_knowledge_str, retrieved_historical_chat_str + else: + historical_chat_log_msg = "检索到的私聊历史对话窗口格式化后为空。" + else: + historical_chat_log_msg = f"找到了相关锚点消息 (ID: {anchor_id}),但未能构建其上下文窗口。" + else: + historical_chat_log_msg = "检索到的最相关私聊历史消息文档缺少 message_id 或 time。" + else: + historical_chat_log_msg = "未找到足够相关的私聊历史对话消息。" + logger.debug( + f"[私聊][{private_name}] (retrieve_contextual_info) 私聊历史对话检索: {historical_chat_log_msg}" + ) + except Exception as e: + logger.error( + f"[私聊][{private_name}] (retrieve_contextual_info) 检索私聊历史对话时出错: {e}\n{traceback.format_exc()}" + ) + retrieved_historical_chat_str = "[检索私聊历史对话时出错]\n" + else: + logger.debug( + f"[私聊][{private_name}] (retrieve_contextual_info) 无专门的私聊历史查询文本,跳过私聊历史对话检索。" ) - retrieved_memory_str = "检索记忆时出错。\n" - # 2. 检索知识 (逻辑来自原 action_planner 和 reply_generator) - try: - # 使用导入的 prompt_builder 实例及其方法 - knowledge_result = await prompt_builder.get_prompt_info( - message=text, - threshold=0.38, # threshold 可以根据需要调整 - ) - if knowledge_result: - retrieved_knowledge_str = knowledge_result # 直接使用返回结果 - knowledge_log_msg = "自动检索到相关知识。" - logger.debug(f"[私聊][{private_name}] (retrieve_contextual_info) 知识检索: {knowledge_log_msg}") - - except Exception as e: - logger.error( - f"[私聊][{private_name}] (retrieve_contextual_info) 自动检索知识时出错: {e}\n{traceback.format_exc()}" - ) - retrieved_knowledge_str = "检索知识时出错。\n" - - return retrieved_memory_str, retrieved_knowledge_str + return retrieved_global_memory_str, retrieved_knowledge_str, retrieved_historical_chat_str def get_items_from_json( @@ -90,6 +457,7 @@ def get_items_from_json( default_values: Optional[Dict[str, Any]] = None, required_types: Optional[Dict[str, type]] = None, allow_array: bool = True, + allow_empty_string_fields: Optional[List[str]] = None, ) -> Tuple[bool, Union[Dict[str, Any], List[Dict[str, Any]]]]: """从文本中提取JSON内容并获取指定字段 @@ -105,225 +473,221 @@ def get_items_from_json( Tuple[bool, Union[Dict[str, Any], List[Dict[str, Any]]]]: (是否成功, 提取的字段字典或字典列表) """ cleaned_content = content.strip() - result: Union[Dict[str, Any], List[Dict[str, Any]]] = {} # 初始化类型 - # 匹配 ```json ... ``` 或 ``` ... ``` + _result: Union[Dict[str, Any], List[Dict[str, Any]]] = {} markdown_match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", cleaned_content, re.IGNORECASE) if markdown_match: cleaned_content = markdown_match.group(1).strip() logger.debug(f"[私聊][{private_name}] 已去除 Markdown 标记,剩余内容: {cleaned_content[:100]}...") - # --- 新增结束 --- + default_result: Dict[str, Any] = {} - # 设置默认值 - default_result: Dict[str, Any] = {} # 用于单对象时的默认值 if default_values: default_result.update(default_values) - result = default_result.copy() # 先用默认值初始化 + # result = default_result.copy() + _allow_empty_string_fields = allow_empty_string_fields if allow_empty_string_fields is not None else [] - # 首先尝试解析为JSON数组 if allow_array: try: - # 尝试直接解析清理后的内容为列表 json_array = json.loads(cleaned_content) - if isinstance(json_array, list): valid_items_list: List[Dict[str, Any]] = [] - for item in json_array: - if not isinstance(item, dict): - logger.warning(f"[私聊][{private_name}] JSON数组中的元素不是字典: {item}") + for item_json in json_array: + if not isinstance(item_json, dict): + logger.warning(f"[私聊][{private_name}] JSON数组中的元素不是字典: {item_json}") continue - current_item_result = default_result.copy() # 每个元素都用默认值初始化 + current_item_result = default_result.copy() valid_item = True - - # 提取并验证字段 for field in items: - if field in item: - current_item_result[field] = item[field] - elif field not in default_result: # 如果字段不存在且没有默认值 - logger.warning(f"[私聊][{private_name}] JSON数组元素缺少必要字段 '{field}': {item}") + if field in item_json: + current_item_result[field] = item_json[field] + elif field not in default_result: + logger.warning(f"[私聊][{private_name}] JSON数组元素缺少必要字段 '{field}': {item_json}") valid_item = False - break # 这个元素无效 + break if not valid_item: continue - # 验证类型 if required_types: for field, expected_type in required_types.items(): - # 检查 current_item_result 中是否存在该字段 (可能来自 item 或 default_values) if field in current_item_result and not isinstance( current_item_result[field], expected_type ): logger.warning( - f"[私聊][{private_name}] JSON数组元素字段 '{field}' 类型错误 (应为 {expected_type.__name__}, 实际为 {type(current_item_result[field]).__name__}): {item}" + f"[私聊][{private_name}] JSON数组元素字段 '{field}' 类型错误 (应为 {expected_type.__name__}, 实际为 {type(current_item_result[field]).__name__}): {item_json}" ) valid_item = False break - if not valid_item: continue - # 验证字符串不为空 (只检查 items 中要求的字段) for field in items: if ( field in current_item_result and isinstance(current_item_result[field], str) and not current_item_result[field].strip() + and field not in _allow_empty_string_fields ): - logger.warning(f"[私聊][{private_name}] JSON数组元素字段 '{field}' 不能为空字符串: {item}") + logger.warning( + f"[私聊][{private_name}] JSON数组元素字段 '{field}' 不能为空字符串: {item_json}" + ) valid_item = False break if valid_item: - valid_items_list.append(current_item_result) # 只添加完全有效的项 + valid_items_list.append(current_item_result) - if valid_items_list: # 只有当列表不为空时才认为是成功 + if valid_items_list: logger.debug(f"[私聊][{private_name}] 成功解析JSON数组,包含 {len(valid_items_list)} 个有效项目。") return True, valid_items_list else: - # 如果列表为空(可能所有项都无效),则继续尝试解析为单个对象 logger.debug(f"[私聊][{private_name}] 解析为JSON数组,但未找到有效项目,尝试解析单个JSON对象。") - # result 重置回单个对象的默认值 - result = default_result.copy() - + # result = default_result.copy() except json.JSONDecodeError: logger.debug(f"[私聊][{private_name}] JSON数组直接解析失败,尝试解析单个JSON对象") - # result 重置回单个对象的默认值 - result = default_result.copy() + # result = default_result.copy() except Exception as e: logger.error(f"[私聊][{private_name}] 尝试解析JSON数组时发生未知错误: {str(e)}") - # result 重置回单个对象的默认值 - result = default_result.copy() + # result = default_result.copy() + + json_data = None + valid_single_object = True # <--- 将初始化提前到这里 - # 尝试解析为单个JSON对象 try: - # 尝试直接解析清理后的内容 json_data = json.loads(cleaned_content) if not isinstance(json_data, dict): logger.error(f"[私聊][{private_name}] 解析为单个对象,但结果不是字典类型: {type(json_data)}") - return False, default_result # 返回失败和默认值 - + # 如果不是字典,即使 allow_array 为 False,这里也应该认为单个对象解析失败 + valid_single_object = False # 标记为无效 + # return False, default_result.copy() # 不立即返回,让后续逻辑统一处理 valid_single_object except json.JSONDecodeError: - # 如果直接解析失败,尝试用正则表达式查找 JSON 对象部分 (作为后备) - # 这个正则比较简单,可能无法处理嵌套或复杂的 JSON - json_pattern = r"\{[\s\S]*?\}" # 使用非贪婪匹配 + json_pattern = r"\{[\s\S]*?\}" json_match = re.search(json_pattern, cleaned_content) if json_match: try: - potential_json_str = json_match.group() + potential_json_str = json_match.group(0) json_data = json.loads(potential_json_str) if not isinstance(json_data, dict): logger.error(f"[私聊][{private_name}] 正则提取后解析,但结果不是字典类型: {type(json_data)}") - return False, default_result - logger.debug(f"[私聊][{private_name}] 通过正则提取并成功解析JSON对象。") + valid_single_object = False # 标记为无效 + # return False, default_result.copy() + else: + logger.debug(f"[私聊][{private_name}] 通过正则提取并成功解析JSON对象。") + # valid_single_object 保持 True except json.JSONDecodeError: logger.error(f"[私聊][{private_name}] 正则提取的部分 '{potential_json_str[:100]}...' 无法解析为JSON。") - return False, default_result + valid_single_object = False # 标记为无效 + # return False, default_result.copy() else: logger.error( f"[私聊][{private_name}] 无法在返回内容中找到有效的JSON对象部分。原始内容: {cleaned_content[:100]}..." ) - return False, default_result + valid_single_object = False # 标记为无效 + # return False, default_result.copy() - # 提取并验证字段 (适用于单个JSON对象) - # 确保 result 是字典类型用于更新 - if not isinstance(result, dict): - result = default_result.copy() # 如果之前是列表,重置为字典 + # 如果前面的步骤未能成功解析出一个 dict 类型的 json_data,则 valid_single_object 会是 False + if not isinstance(json_data, dict) or not valid_single_object: # 增加对 json_data 类型的检查 + # 如果 allow_array 为 True 且数组解析成功过,这里不应该执行 (因为之前会 return True, valid_items_list) + # 如果 allow_array 为 False,或者数组解析也失败了,那么到这里就意味着整体解析失败 + if not (allow_array and isinstance(json_array, list) and valid_items_list): # 修正:检查之前数组解析是否成功 + logger.debug(f"[私聊][{private_name}] 未能成功解析为有效的JSON对象或数组。") + return False, default_result.copy() - valid_single_object = True - for item in items: - if item in json_data: - result[item] = json_data[item] - elif item not in default_result: # 如果字段不存在且没有默认值 - logger.error(f"[私聊][{private_name}] JSON对象缺少必要字段 '{item}'。JSON内容: {json_data}") + # 如果成功解析了单个 JSON 对象 (json_data 是 dict 且 valid_single_object 仍为 True) + # current_single_result 的初始化和填充逻辑可以保持 + current_single_result = default_result.copy() + # valid_single_object = True # 这一行现在是多余的,因为在上面已经初始化并可能被修改 + + for item_field in items: + if item_field in json_data: + current_single_result[item_field] = json_data[item_field] + elif item_field not in default_result: + logger.error(f"[私聊][{private_name}] JSON对象缺少必要字段 '{item_field}'。JSON内容: {json_data}") valid_single_object = False - break # 这个对象无效 + break if not valid_single_object: - return False, default_result + return False, default_result.copy() # 如果字段缺失,则校验失败 - # 验证类型 if required_types: for field, expected_type in required_types.items(): - if field in result and not isinstance(result[field], expected_type): + if field in current_single_result and not isinstance(current_single_result[field], expected_type): logger.error( - f"[私聊][{private_name}] JSON对象字段 '{field}' 类型错误 (应为 {expected_type.__name__}, 实际为 {type(result[field]).__name__})" + f"[私聊][{private_name}] JSON对象字段 '{field}' 类型错误 (应为 {expected_type.__name__}, 实际为 {type(current_single_result[field]).__name__})" ) valid_single_object = False break if not valid_single_object: - return False, default_result + return False, default_result.copy() # 如果类型错误,则校验失败 - # 验证字符串不为空 (只检查 items 中要求的字段) for field in items: - if field in result and isinstance(result[field], str) and not result[field].strip(): - logger.error(f"[私聊][{private_name}] JSON对象字段 '{field}' 不能为空字符串") + if ( + field in current_single_result + and isinstance(current_single_result[field], str) + and not current_single_result[field].strip() + and field not in _allow_empty_string_fields + ): + logger.error(f"[私聊][{private_name}] JSON对象字段 '{field}' 不能为空字符串 (除非特别允许)") valid_single_object = False break if valid_single_object: logger.debug(f"[私聊][{private_name}] 成功解析并验证了单个JSON对象。") - return True, result # 返回提取并验证后的字典 + return True, current_single_result else: - return False, default_result # 验证失败 + return False, default_result.copy() async def get_person_id(private_name: str, chat_stream: ChatStream): + """(保持你原始 pfc_utils.py 中的此函数代码不变)""" private_user_id_str: Optional[str] = None private_platform_str: Optional[str] = None - private_nickname_str = private_name if chat_stream.user_info: private_user_id_str = str(chat_stream.user_info.user_id) private_platform_str = chat_stream.user_info.platform logger.debug( - f"[私聊][{private_name}] 从 ChatStream 获取到私聊对象信息: ID={private_user_id_str}, Platform={private_platform_str}, Name={private_nickname_str}" + f"[私聊][{private_name}] 从 ChatStream 获取到私聊对象信息: ID={private_user_id_str}, Platform={private_platform_str}, Name={private_name}" ) - elif chat_stream.group_info is None and private_name: - pass if private_user_id_str and private_platform_str: try: private_user_id_int = int(private_user_id_str) - # person_id = person_info_manager.get_person_id( # get_person_id 可能只查询,不创建 - # private_platform_str, - # private_user_id_int - # ) - # 使用 get_or_create_person 确保用户存在 person_id = await person_info_manager.get_or_create_person( platform=private_platform_str, user_id=private_user_id_int, - nickname=private_name, # 使用传入的 private_name 作为昵称 + nickname=private_name, ) - if person_id is None: # 如果 get_or_create_person 返回 None,说明创建失败 + if person_id is None: logger.error(f"[私聊][{private_name}] get_or_create_person 未能获取或创建 person_id。") - return None # 返回 None 表示失败 - - return person_id, private_platform_str, private_user_id_str # 返回获取或创建的 person_id + return None + return person_id, private_platform_str, private_user_id_str except ValueError: logger.error(f"[私聊][{private_name}] 无法将 private_user_id_str ('{private_user_id_str}') 转换为整数。") - return None # 返回 None 表示失败 + return None except Exception as e_pid: logger.error(f"[私聊][{private_name}] 获取或创建 person_id 时出错: {e_pid}") - return None # 返回 None 表示失败 + return None else: logger.warning( f"[私聊][{private_name}] 未能确定私聊对象的 user_id 或 platform,无法获取 person_id。将在收到消息后尝试。" ) - return None # 返回 None 表示失败 + return None async def adjust_relationship_value_nonlinear(old_value: float, raw_adjustment: float) -> float: - # 限制 old_value 范围 + """(保持你原始 pfc_utils.py 中的此函数代码不变)""" old_value = max(-1000, min(1000, old_value)) value = raw_adjustment - if old_value >= 0: if value >= 0: value = value * math.cos(math.pi * old_value / 2000) if old_value > 500: - rdict = await person_info_manager.get_specific_value_list("relationship_value", lambda x: x > 700) + # 确保 person_info_manager.get_specific_value_list 是异步的,如果是同步则需要调整 + rdict = await person_info_manager.get_specific_value_list( + "relationship_value", lambda x: x > 700 if isinstance(x, (int, float)) else False + ) high_value_count = len(rdict) if old_value > 700: value *= 3 / (high_value_count + 2) @@ -331,50 +695,51 @@ async def adjust_relationship_value_nonlinear(old_value: float, raw_adjustment: value *= 3 / (high_value_count + 3) elif value < 0: value = value * math.exp(old_value / 2000) - else: - value = 0 - else: + # else: value = 0 # 你原始代码中没有这句,如果value为0,保持为0 + else: # old_value < 0 if value >= 0: value = value * math.exp(old_value / 2000) elif value < 0: value = value * math.cos(math.pi * old_value / 2000) - else: - value = 0 - + # else: value = 0 # 你原始代码中没有这句 return value async def build_chat_history_text(observation_info: ObservationInfo, private_name: str) -> str: """构建聊天历史记录文本 (包含未处理消息)""" - chat_history_text = "" try: if hasattr(observation_info, "chat_history_str") and observation_info.chat_history_str: chat_history_text = observation_info.chat_history_str elif hasattr(observation_info, "chat_history") and observation_info.chat_history: - history_slice = observation_info.chat_history[-20:] + history_slice = observation_info.chat_history[-global_config.pfc_recent_history_display_count :] chat_history_text = await build_readable_messages( history_slice, replace_bot_name=True, merge_messages=False, timestamp_mode="relative", read_mark=0.0 ) else: chat_history_text = "还没有聊天记录。\n" + unread_count = getattr(observation_info, "new_messages_count", 0) unread_messages = getattr(observation_info, "unprocessed_messages", []) if unread_count > 0 and unread_messages: - bot_qq_str = str(global_config.BOT_QQ) - other_unread_messages = [ - msg for msg in unread_messages if msg.get("user_info", {}).get("user_id") != bot_qq_str - ] - other_unread_count = len(other_unread_messages) - if other_unread_count > 0: - new_messages_str = await build_readable_messages( - other_unread_messages, - replace_bot_name=True, - merge_messages=False, - timestamp_mode="relative", - read_mark=0.0, - ) - chat_history_text += f"\n{new_messages_str}\n------\n" + bot_qq_str = str(global_config.BOT_QQ) if global_config.BOT_QQ else None # 安全获取 + if bot_qq_str: + other_unread_messages = [ + msg for msg in unread_messages if msg.get("user_info", {}).get("user_id") != bot_qq_str + ] + other_unread_count = len(other_unread_messages) + if other_unread_count > 0: + new_messages_str = await build_readable_messages( + other_unread_messages, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + chat_history_text += f"\n{new_messages_str}\n------\n" + else: + logger.warning(f"[私聊][{private_name}] BOT_QQ 未配置,无法准确过滤未读消息中的机器人自身消息。") + except AttributeError as e: logger.warning(f"[私聊][{private_name}] 构建聊天记录文本时属性错误: {e}") chat_history_text = "[获取聊天记录时出错]\n" diff --git a/src/experimental/PFC/reply_checker.py b/src/experimental/PFC/reply_checker.py index 4f4030e6..d65a1b24 100644 --- a/src/experimental/PFC/reply_checker.py +++ b/src/experimental/PFC/reply_checker.py @@ -2,40 +2,48 @@ from typing import Tuple, List, Dict, Any from src.common.logger import get_module_logger from src.config.config import global_config # 为了获取 BOT_QQ from .chat_observer import ChatObserver +import re -logger = get_module_logger("reply_checker") +logger = get_module_logger("pfc_checker") class ReplyChecker: """回复检查器 - 新版:仅检查机器人自身发言的精确重复""" def __init__(self, stream_id: str, private_name: str): - # self.llm = LLMRequest(...) # <--- 移除 LLM 初始化 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 # 这个 max_retries 属性在当前设计下不再由 checker 控制,而是由 conversation.py 控制 - self.bot_qq_str = str(global_config.BOT_QQ) # 获取机器人QQ号用于识别自身消息 + self.bot_qq_str = str(global_config.BOT_QQ) + + def _normalize_text(self, text: str) -> str: + """ + 规范化文本,去除首尾空格,移除末尾的特定标点符号。 + """ + if not text: + return "" + text = text.strip() # 1. 去除首尾空格 + # 2. 移除末尾的一个或多个特定标点符号 + # 可以根据需要调整正则表达式以包含更多或更少的标点 + text = re.sub(r"[~\s,.!?;;,。]+$", "", text) + # 如果需要忽略大小写,可以取消下面一行的注释 + # text = text.lower() + return text async def check( self, reply: str, - goal: str, + goal: str, # 当前逻辑未使用 chat_history: List[Dict[str, Any]], - chat_history_text: str, - current_time_str: str, - retry_count: int = 0, + chat_history_text: str, # 当前逻辑未使用 + current_time_str: str, # 当前逻辑未使用 + retry_count: int = 0, # 当前逻辑未使用 ) -> Tuple[bool, str, bool]: """检查生成的回复是否与机器人之前的发言完全一致(长度大于4) Args: reply: 待检查的机器人回复内容 - goal: 当前对话目标 (新逻辑中未使用) chat_history: 对话历史记录 (包含用户和机器人的消息字典列表) - chat_history_text: 对话历史记录的文本格式 (新逻辑中未使用) - current_time_str: 当前时间的字符串格式 (新逻辑中未使用) - retry_count: 当前重试次数 (新逻辑中未使用) - Returns: Tuple[bool, str, bool]: (是否合适, 原因, 是否需要重新规划) 对于重复消息: (False, "机器人尝试发送重复消息", False) @@ -47,12 +55,15 @@ class ReplyChecker: ) return True, "BOT_QQ未配置,跳过重复检查。", False # 无法检查则默认通过 - if len(reply) <= 4: + # 对当前待发送的回复进行规范化 + normalized_reply = self._normalize_text(reply) + + if len(normalized_reply) <= 4: return True, "消息长度小于等于4字符,跳过重复检查。", False try: match_found = False # <--- 用于调试 - for i, msg_dict in enumerate(chat_history): # <--- 添加索引用于日志 + for i, msg_dict in enumerate(reversed(chat_history)): if not isinstance(msg_dict, dict): continue @@ -64,27 +75,31 @@ class ReplyChecker: if sender_id == self.bot_qq_str: historical_message_text = msg_dict.get("processed_plain_text", "") - # <--- 新增详细对比日志 --- START ---> - logger.debug( - f"[私聊][{self.private_name}] ReplyChecker: 历史记录 #{i} ({global_config.BOT_NICKNAME}): '{historical_message_text}' (长度 {len(historical_message_text)})" - ) - if reply == historical_message_text: - logger.warning(f"[私聊][{self.private_name}] ReplyChecker: !!! 精确匹配成功 !!!") - logger.warning( - f"[私聊][{self.private_name}] ReplyChecker 检测到{global_config.BOT_NICKNAME}自身重复消息: '{reply}'" - ) - match_found = True # <--- 标记找到 - return (False, "机器人尝试发送重复消息", False) - # <--- 新增详细对比日志 --- END ---> + # 对历史消息也进行同样的规范化处理 + normalized_historical_text = self._normalize_text(historical_message_text) - if not match_found: # <--- 根据标记判断 - logger.debug(f"[私聊][{self.private_name}] ReplyChecker: 未找到重复。") # <--- 新增日志 - return (True, "消息内容未与机器人历史发言重复。", False) + logger.debug( + f"[私聊][{self.private_name}] ReplyChecker: 历史记录 (反向索引 {i}) ({global_config.BOT_NICKNAME}): " + f"原始='{historical_message_text[:50]}...', 规范化后='{normalized_historical_text[:50]}...'" + ) + if ( + normalized_reply == normalized_historical_text and len(normalized_reply) > 0 + ): # 确保规范化后不为空串才比较 + logger.warning(f"[私聊][{self.private_name}] ReplyChecker: !!! 成功拦截一次复读 !!!") + logger.warning( + f"[私聊][{self.private_name}] ReplyChecker 检测到{global_config.BOT_NICKNAME}自身重复消息 (规范化后内容相同): '{normalized_reply[:50]}...'" + ) + match_found = True + # 返回: 不合适, 原因, 不需要重规划 (让上层逻辑决定是否重试生成) + return (False, "机器人尝试发送与历史发言相似的消息 (内容规范化后相同)", False) + + if not match_found: + logger.debug(f"[私聊][{self.private_name}] ReplyChecker: 未找到重复内容 (规范化后比较)。") + return (True, "消息内容未与机器人历史发言重复 (规范化后比较)。", False) except Exception as e: import traceback logger.error(f"[私聊][{self.private_name}] ReplyChecker 检查重复时出错: 类型={type(e)}, 值={e}") logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") - # 发生未知错误时,为安全起见,默认通过,并记录原因 - return (True, f"检查重复时发生内部错误: {str(e)}", False) + return (True, f"检查重复时发生内部错误 (规范化检查): {str(e)}", False) diff --git a/src/experimental/PFC/reply_generator.py b/src/experimental/PFC/reply_generator.py index 131cd9b6..633af568 100644 --- a/src/experimental/PFC/reply_generator.py +++ b/src/experimental/PFC/reply_generator.py @@ -1,8 +1,8 @@ import random - +from datetime import datetime from .pfc_utils import retrieve_contextual_info - -from src.common.logger_manager import get_module_logger +from typing import Optional +from src.common.logger_manager import get_logger from src.chat.models.utils_model import LLMRequest from ...config.config import global_config from .chat_observer import ChatObserver @@ -12,10 +12,10 @@ from .observation_info import ObservationInfo from .conversation_info import ConversationInfo from .pfc_utils import build_chat_history_text -logger = get_module_logger("reply_generator") +logger = get_logger("pfc_reply") PROMPT_GER_VARIATIONS = [ - ("不用输出或提及提及对方的网名或绰号", 0.50), + ("不用输出或提及对方的网名或绰号", 0.50), ("如果当前对话比较轻松,可以尝试用轻松幽默或者略带调侃的语气回应,但要注意分寸", 0.8), ("避免使用过于正式或书面化的词语,多用生活化的口语表达", 0.8), ("如果对方的发言比较跳跃或难以理解,可以尝试用猜测或确认的语气回应", 0.8), @@ -60,14 +60,18 @@ PROMPT_DIRECT_REPLY = """ {retrieved_knowledge_str} 请你**记住上面的知识**,在回复中有可能会用到。 +你有以下记忆可供参考: +{retrieved_global_memory_str} + +{retrieved_historical_chat_str} + 最近的聊天记录: {chat_history_text} -{retrieved_memory_str} - {last_rejection_info} + 请根据上述信息,结合聊天记录,回复对方。该回复应该: 1. 符合对话目标,以"你"的角度发言(不要自己与自己对话!) 2. 符合你的性格特征和身份细节 @@ -97,11 +101,14 @@ PROMPT_SEND_NEW_MESSAGE = """ {retrieved_knowledge_str} 请你**记住上面的知识**,在发消息时有可能会用到。 +你有以下记忆可供参考: +{retrieved_global_memory_str} + +{retrieved_historical_chat_str} + 最近的聊天记录: {chat_history_text} -{retrieved_memory_str} - {last_rejection_info} 请根据上述信息,判断你是否要继续发一条新消息(例如对之前消息的补充,深入话题,或追问等等)。如果你觉得要发送,该消息应该: @@ -127,7 +134,7 @@ PROMPT_SEND_NEW_MESSAGE = """ PROMPT_FAREWELL = """ 当前时间:{current_time_str} {persona_text}。 -你正在和{sender_name}私聊,在QQ上私聊,现在你们的对话似乎已经结束。 +你正在和{sender_name}在QQ上私聊,现在你们的对话似乎已经结束。 你与对方的关系是:{relationship_text} 你现在的心情是:{current_emotion_text} 现在你决定再发一条最后的消息来圆满结束。 @@ -215,6 +222,55 @@ class ReplyGenerator: else: goals_str = "- 目前没有明确对话目标\n" + chat_history_for_prompt_builder: list = [] + recent_history_start_time_for_exclusion: Optional[float] = None + + # 我们需要知道 build_chat_history_text 函数大致会用 observation_info.chat_history 的多少条记录 + # 或者 build_chat_history_text 内部的逻辑。 + # 假设 build_chat_history_text 主要依赖 observation_info.chat_history_str, + # 而 observation_info.chat_history_str 是基于 observation_info.chat_history 的最后一部分(比如20条)生成的。 + # 为了准确,我们应该直接从 observation_info.chat_history 中获取这个片段的起始时间。 + # 请确保这里的 MAX_RECENT_HISTORY_FOR_PROMPT 与 observation_info.py 或 build_chat_history_text 中 + # 用于生成 chat_history_str 的消息数量逻辑大致吻合。 + # 如果 build_chat_history_text 总是用 observation_info.chat_history 的最后 N 条,那么这个 N 就是这里的数字。 + # 如果 observation_info.chat_history_str 是由 observation_info.py 中的 update_from_message 等方法维护的, + # 并且总是代表一个固定长度(比如最后30条)的聊天记录字符串,那么我们就需要从 observation_info.chat_history + # 取出这部分原始消息来确定起始时间。 + + # 我们先做一个合理的假设: “最近聊天记录” 字符串 chat_history_text 是基于 + # observation_info.chat_history 的一个有限的尾部片段生成的。 + # 假设这个片段的长度由 global_config.pfc_recent_history_display_count 控制,默认为20条。 + recent_history_display_count = getattr(global_config, "pfc_recent_history_display_count", 20) + + if observation_info and observation_info.chat_history and len(observation_info.chat_history) > 0: + # 获取用于生成“最近聊天记录”的实际消息片段 + # 如果 observation_info.chat_history 长度小于 display_count,则取全部 + start_index = max(0, len(observation_info.chat_history) - recent_history_display_count) + chat_history_for_prompt_builder = observation_info.chat_history[start_index:] + + if chat_history_for_prompt_builder: # 如果片段不为空 + try: + first_message_in_display_slice = chat_history_for_prompt_builder[0] + recent_history_start_time_for_exclusion = first_message_in_display_slice.get("time") + if recent_history_start_time_for_exclusion: + # 导入 datetime (如果 reply_generator.py 文件顶部没有的话) + # from datetime import datetime # 通常建议放在文件顶部 + logger.debug( + f"[{self.private_name}] (ReplyGenerator) “最近聊天记录”片段(共{len(chat_history_for_prompt_builder)}条)的最早时间戳: " + f"{recent_history_start_time_for_exclusion} " + f"(即 {datetime.fromtimestamp(recent_history_start_time_for_exclusion).strftime('%Y-%m-%d %H:%M:%S')})" + ) + else: + logger.warning(f"[{self.private_name}] (ReplyGenerator) “最近聊天记录”片段的首条消息无时间戳。") + except (IndexError, KeyError, TypeError) as e: + logger.warning(f"[{self.private_name}] (ReplyGenerator) 获取“最近聊天记录”起始时间失败: {e}") + recent_history_start_time_for_exclusion = None + else: + logger.debug( + f"[{self.private_name}] (ReplyGenerator) observation_info.chat_history 为空,无法确定“最近聊天记录”起始时间。" + ) + # --- [新代码结束] --- + chat_history_text = await build_chat_history_text(observation_info, self.private_name) sender_name_str = self.private_name @@ -223,12 +279,64 @@ class ReplyGenerator: current_emotion_text_str = getattr(conversation_info, "current_emotion_text", "心情平静。") persona_text = f"你的名字是{self.name},{self.personality_info}。" - retrieval_context = chat_history_text - retrieved_memory_str, retrieved_knowledge_str = await retrieve_contextual_info( - retrieval_context, self.private_name - ) + historical_chat_query = "" + num_recent_messages_for_query = 3 # 例如,取最近3条作为查询引子 + if observation_info.chat_history and len(observation_info.chat_history) > 0: + # 从 chat_history (已处理并存入 ObservationInfo 的历史) 中取最新N条 + # 或者,如果 observation_info.unprocessed_messages 更能代表“当前上下文”,也可以考虑用它 + # 我们先用 chat_history,因为它包含了双方的对话历史,可能更稳定 + recent_messages_for_query_list = observation_info.chat_history[-num_recent_messages_for_query:] + + # 将这些消息的文本内容合并 + query_texts_list = [] + for msg_dict in recent_messages_for_query_list: + text_content = msg_dict.get("processed_plain_text", "") + if text_content.strip(): # 只添加有内容的文本 + # 可以选择是否添加发送者信息到查询文本中,例如: + # sender_nickname = msg_dict.get("user_info", {}).get("user_nickname", "用户") + # query_texts_list.append(f"{sender_nickname}: {text_content}") + query_texts_list.append(text_content) # 简单合并文本内容 + + if query_texts_list: + historical_chat_query = " ".join(query_texts_list).strip() + logger.debug( + f"[私聊][{self.private_name}] (ReplyGenerator) 生成的私聊历史查询文本 (最近{num_recent_messages_for_query}条): '{historical_chat_query[:100]}...'" + ) + else: + logger.debug( + f"[私聊][{self.private_name}] (ReplyGenerator) 最近{num_recent_messages_for_query}条消息无有效文本内容,不进行私聊历史查询。" + ) + else: + logger.debug(f"[私聊][{self.private_name}] (ReplyGenerator) 无聊天历史可用于生成私聊历史查询文本。") + + current_chat_id = self.chat_observer.stream_id if self.chat_observer else None + if not current_chat_id: + logger.error(f"[私聊][{self.private_name}] (ReplyGenerator) 无法获取 current_chat_id,跳过所有上下文检索!") + retrieved_global_memory_str = "[获取全局记忆出错:chat_id 未知]" + retrieved_knowledge_str = "[获取知识出错:chat_id 未知]" + retrieved_historical_chat_str = "[获取私聊历史回忆出错:chat_id 未知]" + else: + # retrieval_context 之前是用 chat_history_text,现在也用它作为全局记忆和知识的检索上下文 + retrieval_context_for_global_and_knowledge = chat_history_text + + ( + retrieved_global_memory_str, + retrieved_knowledge_str, + retrieved_historical_chat_str, # << 新增接收私聊历史回忆 + ) = await retrieve_contextual_info( + text=retrieval_context_for_global_and_knowledge, # 用于全局记忆和知识 + private_name=self.private_name, + chat_id=current_chat_id, # << 传递 chat_id + historical_chat_query_text=historical_chat_query, # << 传递专门的查询文本 + current_short_term_history_earliest_time=recent_history_start_time_for_exclusion, # <--- 新增传递的参数 + ) + # === 调用修改结束 === + logger.info( - f"[私聊][{self.private_name}] (ReplyGenerator) 统一检索完成。记忆: {'有' if '回忆起' in retrieved_memory_str else '无'} / 知识: {'有' if '出错' not in retrieved_knowledge_str and '无相关知识' not in retrieved_knowledge_str else '无'}" + f"[私聊][{self.private_name}] (ReplyGenerator) 上下文检索完成。\n" + f" 全局记忆: {'有内容' if '回忆起' in retrieved_global_memory_str else '无或出错'}\n" + f" 知识: {'有内容' if '出错' not in retrieved_knowledge_str and '无相关知识' not in retrieved_knowledge_str and retrieved_knowledge_str.strip() else '无或出错'}\n" + f" 私聊历史回忆: {'有内容' if '回忆起一段相关的历史聊天' in retrieved_historical_chat_str else '无或出错'}" ) last_rejection_info_str = "" @@ -292,11 +400,18 @@ class ReplyGenerator: base_format_params = { "persona_text": persona_text, "goals_str": goals_str, - "chat_history_text": chat_history_text, - "retrieved_memory_str": retrieved_memory_str if retrieved_memory_str else "无相关记忆。", # 确保已定义 + "chat_history_text": chat_history_text + if chat_history_text.strip() + else "还没有聊天记录。", # 当前短期历史 + "retrieved_global_memory_str": retrieved_global_memory_str + if retrieved_global_memory_str.strip() + else "无相关全局记忆。", "retrieved_knowledge_str": retrieved_knowledge_str - if retrieved_knowledge_str - else "无相关知识。", # 确保已定义 + if retrieved_knowledge_str.strip() + else "无相关知识。", + "retrieved_historical_chat_str": retrieved_historical_chat_str + if retrieved_historical_chat_str.strip() + else "无相关私聊历史回忆。", # << 新增 "last_rejection_info": last_rejection_info_str, "current_time_str": current_time_value, "sender_name": sender_name_str, diff --git a/src/experimental/PFC/waiter.py b/src/experimental/PFC/waiter.py index 4e0f6fa0..f385113e 100644 --- a/src/experimental/PFC/waiter.py +++ b/src/experimental/PFC/waiter.py @@ -5,7 +5,7 @@ from src.config.config import global_config import time import asyncio -logger = get_module_logger("waiter") +logger = get_module_logger("pfc_waiter") # --- 在这里设定你想要的超时时间(秒) --- # 例如: 120 秒 = 2 分钟 diff --git a/src/individuality/individuality.py b/src/individuality/individuality.py index 38131ea1..526ed061 100644 --- a/src/individuality/individuality.py +++ b/src/individuality/individuality.py @@ -3,6 +3,7 @@ from .personality import Personality from .identity import Identity import random from rich.traceback import install +from src.config.config import global_config install(extra_lines=3) @@ -205,6 +206,15 @@ class Individuality: if not self.personality or not self.identity: return "个体特征尚未完全初始化。" + if global_config.personality_detail_level == 1: + level = 1 + elif global_config.personality_detail_level == 2: + level = 2 + elif global_config.personality_detail_level == 3: + level = 3 + else: # level = 0 + pass + # 调用新的独立方法 prompt_personality = self.get_personality_prompt(level, x_person) prompt_identity = self.get_identity_prompt(level, x_person) diff --git a/src/main.py b/src/main.py index 34b7eda3..e365b239 100644 --- a/src/main.py +++ b/src/main.py @@ -9,7 +9,9 @@ from .chat.emoji_system.emoji_manager import emoji_manager from .chat.person_info.person_info import person_info_manager from .chat.normal_chat.willing.willing_manager import willing_manager from .chat.message_receive.chat_stream import chat_manager +from src.experimental.Legacy_HFC.schedule.schedule_generator import bot_schedule from src.chat.heart_flow.heartflow import heartflow +from src.experimental.Legacy_HFC.heart_flow.heartflow import heartflow as legacy_heartflow from .chat.memory_system.Hippocampus import HippocampusManager from .chat.message_receive.message_sender import message_manager from .chat.message_receive.storage import MessageStorage @@ -79,6 +81,15 @@ class MainSystem: # 启动愿望管理器 await willing_manager.async_task_starter() + # 初始化日程 + bot_schedule.initialize( + name=global_config.BOT_NICKNAME, + personality=global_config.personality_core, + behavior=global_config.PROMPT_SCHEDULE_GEN, + interval=global_config.SCHEDULE_DOING_UPDATE_INTERVAL, + ) + asyncio.create_task(bot_schedule.mai_schedule_start()) + # 初始化聊天管理器 await chat_manager._initialize() asyncio.create_task(chat_manager._auto_save_task()) @@ -113,8 +124,12 @@ class MainSystem: logger.success("全局消息管理器启动成功") # 启动心流系统主循环 - asyncio.create_task(heartflow.heartflow_start_working()) - logger.success("心流系统启动成功") + if not global_config.enable_Legacy_HFC: + asyncio.create_task(heartflow.heartflow_start_working()) + logger.success("心流系统启动成功") + else: + asyncio.create_task(legacy_heartflow.heartflow_start_working()) + logger.success("Legacy HFC心流系统启动成功") init_time = int(1000 * (time.time() - init_start_time)) logger.success(f"初始化完成,神经元放电{init_time}次") diff --git a/src/plugins/group_nickname/nickname_db.py b/src/plugins/group_nickname/nickname_db.py new file mode 100644 index 00000000..ac3bd24c --- /dev/null +++ b/src/plugins/group_nickname/nickname_db.py @@ -0,0 +1,160 @@ +from pymongo.collection import Collection +from pymongo.errors import OperationFailure, DuplicateKeyError +from src.common.logger_manager import get_logger +from typing import Optional + +logger = get_logger("nickname_db") + + +class NicknameDB: + """ + 处理与群组绰号相关的数据库操作 (MongoDB)。 + 封装了对 'person_info' 集合的读写操作。 + """ + + def __init__(self, person_info_collection: Optional[Collection]): + """ + 初始化 NicknameDB 处理器。 + + Args: + person_info_collection: MongoDB 'person_info' 集合对象。 + 如果为 None,则数据库操作将被禁用。 + """ + if person_info_collection is None: + logger.error("未提供 person_info 集合,NicknameDB 操作将被禁用。") + self.person_info_collection = None + else: + self.person_info_collection = person_info_collection + logger.info("NicknameDB 初始化成功。") + + def is_available(self) -> bool: + """检查数据库集合是否可用。""" + return self.person_info_collection is not None + + def upsert_person(self, person_id: str, user_id_int: int, platform: str): + """ + 确保数据库中存在指定 person_id 的文档 (Upsert)。 + 如果文档不存在,则使用提供的用户信息创建它。 + + Args: + person_id: 要查找或创建的 person_id。 + user_id_int: 用户的整数 ID。 + platform: 平台名称。 + + Returns: + UpdateResult 或 None: MongoDB 更新操作的结果,如果数据库不可用则返回 None。 + + Raises: + DuplicateKeyError: 如果发生重复键错误 (理论上不应由 upsert 触发)。 + Exception: 其他数据库操作错误。 + """ + if not self.is_available(): + logger.error("数据库集合不可用,无法执行 upsert_person。") + return None + try: + # 关键步骤:基于 person_id 执行 Upsert + result = self.person_info_collection.update_one( + {"person_id": person_id}, + { + "$setOnInsert": { + "person_id": person_id, + "user_id": user_id_int, + "platform": platform, + "group_nicknames": [], # 初始化 group_nicknames 数组 + } + }, + upsert=True, + ) + if result.upserted_id: + logger.debug(f"Upsert 创建了新的 person 文档: {person_id}") + return result + except DuplicateKeyError as dk_err: + # 这个错误理论上不应该再由 upsert 触发。 + logger.error( + f"数据库操作失败 (DuplicateKeyError): person_id {person_id}. 错误: {dk_err}. 这不应该发生,请检查 person_id 生成逻辑和数据库状态。" + ) + raise # 将异常向上抛出 + except Exception as e: + logger.exception(f"对 person_id {person_id} 执行 Upsert 时失败: {e}") + raise # 将异常向上抛出 + + def update_group_nickname_count(self, person_id: str, group_id_str: str, nickname: str): + """ + 尝试更新 person_id 文档中特定群组的绰号计数,或添加新条目。 + 按顺序尝试:增加计数 -> 添加绰号 -> 添加群组。 + + Args: + person_id: 目标文档的 person_id。 + group_id_str: 目标群组的 ID (字符串)。 + nickname: 要更新或添加的绰号。 + """ + if not self.is_available(): + logger.error("数据库集合不可用,无法执行 update_group_nickname_count。") + return + + try: + # 3a. 尝试增加现有群组中现有绰号的计数 + result_inc = self.person_info_collection.update_one( + { + "person_id": person_id, + "group_nicknames": {"$elemMatch": {"group_id": group_id_str, "nicknames.name": nickname}}, + }, + {"$inc": {"group_nicknames.$[group].nicknames.$[nick].count": 1}}, + array_filters=[ + {"group.group_id": group_id_str}, + {"nick.name": nickname}, + ], + ) + if result_inc.modified_count > 0: + # logger.debug(f"成功增加 person_id {person_id} 在群组 {group_id_str} 中绰号 '{nickname}' 的计数。") + return # 成功增加计数,操作完成 + + # 3b. 如果上一步未修改 (绰号不存在于该群组),尝试将新绰号添加到现有群组 + result_push_nick = self.person_info_collection.update_one( + { + "person_id": person_id, + "group_nicknames.group_id": group_id_str, # 检查群组是否存在 + }, + {"$push": {"group_nicknames.$[group].nicknames": {"name": nickname, "count": 1}}}, + array_filters=[{"group.group_id": group_id_str}], + ) + if result_push_nick.modified_count > 0: + logger.debug(f"成功为 person_id {person_id} 在现有群组 {group_id_str} 中添加新绰号 '{nickname}'。") + return # 成功添加绰号,操作完成 + + # 3c. 如果上一步也未修改 (群组条目本身不存在),则添加新的群组条目和绰号 + # 确保 group_nicknames 数组存在 (作为保险措施) + self.person_info_collection.update_one( + {"person_id": person_id, "group_nicknames": {"$exists": False}}, + {"$set": {"group_nicknames": []}}, + ) + # 推送新的群组对象到 group_nicknames 数组 + result_push_group = self.person_info_collection.update_one( + { + "person_id": person_id, + "group_nicknames.group_id": {"$ne": group_id_str}, # 确保该群组 ID 尚未存在 + }, + { + "$push": { + "group_nicknames": { + "group_id": group_id_str, + "nicknames": [{"name": nickname, "count": 1}], + } + } + }, + ) + if result_push_group.modified_count > 0: + logger.debug(f"为 person_id {person_id} 添加了新的群组 {group_id_str} 和绰号 '{nickname}'。") + # else: + # logger.warning(f"尝试为 person_id {person_id} 添加新群组 {group_id_str} 失败,可能群组已存在但结构不符合预期。") + + except (OperationFailure, DuplicateKeyError) as db_err: + logger.exception( + f"数据库操作失败 ({type(db_err).__name__}): person_id {person_id}, 群组 {group_id_str}, 绰号 {nickname}. 错误: {db_err}" + ) + # 根据需要决定是否向上抛出 raise db_err + except Exception as e: + logger.exception( + f"更新群组绰号计数时发生意外错误: person_id {person_id}, group {group_id_str}, nick {nickname}. Error: {e}" + ) + # 根据需要决定是否向上抛出 raise e diff --git a/src/plugins/group_nickname/nickname_manager.py b/src/plugins/group_nickname/nickname_manager.py new file mode 100644 index 00000000..7e2a74e7 --- /dev/null +++ b/src/plugins/group_nickname/nickname_manager.py @@ -0,0 +1,547 @@ +import asyncio +import threading +import random +import time +import json +import re +from typing import Dict, Optional, List, Any +from pymongo.errors import OperationFailure, DuplicateKeyError +from src.common.logger_manager import get_logger +from src.common.database import db +from src.config.config import global_config +from src.chat.models.utils_model import LLMRequest +from .nickname_db import NicknameDB +from .nickname_mapper import _build_mapping_prompt +from .nickname_utils import select_nicknames_for_prompt, format_nickname_prompt_injection +from src.chat.person_info.person_info import person_info_manager +from src.chat.person_info.relationship_manager import relationship_manager +from src.chat.message_receive.chat_stream import ChatStream +from src.chat.message_receive.message import MessageRecv +from src.chat.utils.chat_message_builder import build_readable_messages, get_raw_msg_before_timestamp_with_chat + +logger = get_logger("NicknameManager") +logger_helper = get_logger("AsyncLoopHelper") # 为辅助函数创建单独的 logger + + +def run_async_loop(loop: asyncio.AbstractEventLoop, coro): + """ + 运行给定的协程直到完成,并确保循环最终关闭。 + + Args: + loop: 要使用的 asyncio 事件循环。 + coro: 要在循环中运行的主协程。 + """ + try: + logger_helper.debug(f"Running coroutine in loop {id(loop)}...") + result = loop.run_until_complete(coro) + logger_helper.debug(f"Coroutine completed in loop {id(loop)}.") + return result + except asyncio.CancelledError: + logger_helper.info(f"Coroutine in loop {id(loop)} was cancelled.") + # 取消是预期行为,不视为错误 + except Exception as e: + logger_helper.error(f"Error in async loop {id(loop)}: {e}", exc_info=True) + finally: + try: + # 1. 取消所有剩余任务 + all_tasks = asyncio.all_tasks(loop) + current_task = asyncio.current_task(loop) + tasks_to_cancel = [ + task for task in all_tasks if task is not current_task + ] # 避免取消 run_until_complete 本身 + if tasks_to_cancel: + logger_helper.info(f"Cancelling {len(tasks_to_cancel)} outstanding tasks in loop {id(loop)}...") + for task in tasks_to_cancel: + task.cancel() + # 等待取消完成 + loop.run_until_complete(asyncio.gather(*tasks_to_cancel, return_exceptions=True)) + logger_helper.info(f"Outstanding tasks cancelled in loop {id(loop)}.") + + # 2. 停止循环 (如果仍在运行) + if loop.is_running(): + loop.stop() + logger_helper.info(f"Asyncio loop {id(loop)} stopped.") + + # 3. 关闭循环 (如果未关闭) + if not loop.is_closed(): + # 在关闭前再运行一次以处理挂起的关闭回调 + loop.run_until_complete(loop.shutdown_asyncgens()) # 关闭异步生成器 + loop.close() + logger_helper.info(f"Asyncio loop {id(loop)} closed.") + except Exception as close_err: + logger_helper.error(f"Error during asyncio loop cleanup for loop {id(loop)}: {close_err}", exc_info=True) + + +class NicknameManager: + """ + 管理群组绰号分析、处理、存储和使用的单例类。 + 封装了 LLM 调用、后台处理线程和数据库交互。 + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls, *args, **kwargs): + if not cls._instance: + with cls._lock: + if not cls._instance: + logger.info("正在创建 NicknameManager 单例实例...") + cls._instance = super(NicknameManager, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + """ + 初始化 NicknameManager。 + 使用锁和标志确保实际初始化只执行一次。 + """ + if hasattr(self, "_initialized") and self._initialized: + return + + with self._lock: + if hasattr(self, "_initialized") and self._initialized: + return + + logger.info("正在初始化 NicknameManager 组件...") + self.config = global_config + self.is_enabled = self.config.enable_nickname_mapping + + # 数据库处理器 + person_info_collection = getattr(db, "person_info", None) + self.db_handler = NicknameDB(person_info_collection) + if not self.db_handler.is_available(): + logger.error("数据库处理器初始化失败,NicknameManager 功能受限。") + self.is_enabled = False + + # LLM 映射器 + self.llm_mapper: Optional[LLMRequest] = None + if self.is_enabled: + try: + model_config = self.config.llm_nickname_mapping + if model_config and model_config.get("name"): + self.llm_mapper = LLMRequest( + model=model_config, + temperature=model_config.get("temp", 0.5), + max_tokens=model_config.get("max_tokens", 256), + request_type="nickname_mapping", + ) + logger.info("绰号映射 LLM 映射器初始化成功。") + else: + logger.warning("绰号映射 LLM 配置无效或缺失 'name',功能禁用。") + self.is_enabled = False + except KeyError as ke: + logger.error(f"初始化绰号映射 LLM 时缺少配置项: {ke},功能禁用。", exc_info=True) + self.llm_mapper = None + self.is_enabled = False + except Exception as e: + logger.error(f"初始化绰号映射 LLM 映射器失败: {e},功能禁用。", exc_info=True) + self.llm_mapper = None + self.is_enabled = False + + # 队列和线程 + self.queue_max_size = getattr(self.config, "nickname_queue_max_size", 100) + # 使用 asyncio.Queue + self.nickname_queue: asyncio.Queue = asyncio.Queue(maxsize=self.queue_max_size) + self._stop_event = threading.Event() # stop_event 仍然使用 threading.Event,因为它是由另一个线程设置的 + self._nickname_thread: Optional[threading.Thread] = None + self.sleep_interval = getattr(self.config, "nickname_process_sleep_interval", 5) # 超时时间 + + self._initialized = True + logger.info("NicknameManager 初始化完成。") + + def start_processor(self): + """启动后台处理线程(如果已启用且未运行)。""" + if not self.is_enabled: + logger.info("绰号处理功能已禁用,处理器未启动。") + return + if global_config.max_nicknames_in_prompt == 0: # 考虑有神秘的用户输入为0的可能性 + logger.error("[错误] 绰号注入数量不合适,绰号处理功能已禁用!") + return + + if self._nickname_thread is None or not self._nickname_thread.is_alive(): + logger.info("正在启动绰号处理器线程...") + self._stop_event.clear() + self._nickname_thread = threading.Thread( + target=self._run_processor_in_thread, # 线程目标函数不变 + daemon=True, + ) + self._nickname_thread.start() + logger.info(f"绰号处理器线程已启动 (ID: {self._nickname_thread.ident})") + else: + logger.warning("绰号处理器线程已在运行中。") + + def stop_processor(self): + """停止后台处理线程。""" + if self._nickname_thread and self._nickname_thread.is_alive(): + logger.info("正在停止绰号处理器线程...") + self._stop_event.set() # 设置停止事件,_processing_loop 会检测到 + try: + # 不需要清空 asyncio.Queue,让循环自然结束或被取消 + # self.empty_queue(self.nickname_queue) + self._nickname_thread.join(timeout=10) # 等待线程结束 + if self._nickname_thread.is_alive(): + logger.warning("绰号处理器线程在超时后仍未停止。") + except Exception as e: + logger.error(f"停止绰号处理器线程时出错: {e}", exc_info=True) + finally: + if self._nickname_thread and not self._nickname_thread.is_alive(): + logger.info("绰号处理器线程已成功停止。") + self._nickname_thread = None + else: + logger.info("绰号处理器线程未在运行或已被清理。") + + # def empty_queue(self, q: asyncio.Queue): + # while not q.empty(): + # # Depending on your program, you may want to + # # catch QueueEmpty + # q.get_nowait() + # q.task_done() + + async def trigger_nickname_analysis( + self, + anchor_message: MessageRecv, + bot_reply: List[str], + chat_stream: Optional[ChatStream] = None, + ): + """ + 准备数据并将其排队等待绰号分析(如果满足条件)。 + (现在调用异步的 _add_to_queue) + """ + if not self.is_enabled: + return + + if random.random() < global_config.nickname_analysis_probability: + logger.debug("跳过绰号分析:随机概率未命中。") + return + + current_chat_stream = chat_stream or anchor_message.chat_stream + if not current_chat_stream or not current_chat_stream.group_info: + logger.debug("跳过绰号分析:非群聊或无效的聊天流。") + return + + log_prefix = f"[{current_chat_stream.stream_id}]" + try: + # 1. 获取历史记录 + history_limit = getattr(self.config, "nickname_analysis_history_limit", 30) + history_messages = get_raw_msg_before_timestamp_with_chat( + chat_id=current_chat_stream.stream_id, + timestamp=time.time(), + limit=history_limit, + ) + # 格式化历史记录 + chat_history_str = await build_readable_messages( + messages=history_messages, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + truncate=False, + ) + # 2. 获取 Bot 回复 + bot_reply_str = " ".join(bot_reply) if bot_reply else "" + # 3. 获取群组和平台信息 + group_id = str(current_chat_stream.group_info.group_id) + platform = current_chat_stream.platform + # 4. 构建用户 ID 到名称的映射 (user_name_map) + user_ids_in_history = { + str(msg["user_info"]["user_id"]) for msg in history_messages if msg.get("user_info", {}).get("user_id") + } + user_name_map = {} + if user_ids_in_history: + try: + names_data = await relationship_manager.get_person_names_batch(platform, list(user_ids_in_history)) + except Exception as e: + logger.error(f"{log_prefix} 批量获取 person_name 时出错: {e}", exc_info=True) + names_data = {} + for user_id in user_ids_in_history: + if user_id in names_data: + user_name_map[user_id] = names_data[user_id] + else: + latest_nickname = next( + ( + m["user_info"].get("user_nickname") + for m in reversed(history_messages) + if str(m["user_info"].get("user_id")) == user_id and m["user_info"].get("user_nickname") + ), + None, + ) + user_name_map[user_id] = ( + latest_nickname or f"{global_config.BOT_NICKNAME}" + if user_id == global_config.BOT_QQ + else "未知" + ) + + item = (chat_history_str, bot_reply_str, platform, group_id, user_name_map) + await self._add_to_queue(item, platform, group_id) + + except Exception as e: + logger.error(f"{log_prefix} 触发绰号分析时出错: {e}", exc_info=True) + + async def get_nickname_prompt_injection(self, chat_stream: ChatStream, message_list_before_now: List[Dict]) -> str: + """ + 获取并格式化用于 Prompt 注入的绰号信息字符串。 + """ + if not self.is_enabled or not chat_stream or not chat_stream.group_info: + return "" + + log_prefix = f"[{chat_stream.stream_id}]" + try: + group_id = str(chat_stream.group_info.group_id) + platform = chat_stream.platform + user_ids_in_context = { + str(msg["user_info"]["user_id"]) + for msg in message_list_before_now + if msg.get("user_info", {}).get("user_id") + } + + if not user_ids_in_context: + recent_speakers = chat_stream.get_recent_speakers(limit=5) + user_ids_in_context.update(str(speaker["user_id"]) for speaker in recent_speakers) + + if not user_ids_in_context: + logger.warning(f"{log_prefix} 未找到上下文用户用于绰号注入。") + return "" + + all_nicknames_data = await relationship_manager.get_users_group_nicknames( + platform, list(user_ids_in_context), group_id + ) + + if all_nicknames_data: + selected_nicknames = select_nicknames_for_prompt(all_nicknames_data) + injection_str = format_nickname_prompt_injection(selected_nicknames) + if injection_str: + logger.debug(f"{log_prefix} 生成的绰号 Prompt 注入:\n{injection_str}") + return injection_str + else: + return "" + + except Exception as e: + logger.error(f"{log_prefix} 获取绰号注入时出错: {e}", exc_info=True) + return "" + + # 私有/内部方法 + + async def _add_to_queue(self, item: tuple, platform: str, group_id: str): + """将项目异步添加到内部处理队列 (asyncio.Queue)。""" + try: + # 使用 await put(),如果队列满则异步等待 + await self.nickname_queue.put(item) + logger.debug( + f"已将项目添加到平台 '{platform}' 群组 '{group_id}' 的绰号队列。当前大小: {self.nickname_queue.qsize()}" + ) + except asyncio.QueueFull: + # 理论上 await put() 不会直接抛 QueueFull,除非 maxsize=0 + # 但保留以防万一或未来修改 + logger.warning( + f"绰号队列已满 (最大={self.queue_max_size})。平台 '{platform}' 群组 '{group_id}' 的项目被丢弃。" + ) + except Exception as e: + logger.error(f"将项目添加到绰号队列时出错: {e}", exc_info=True) + + async def _analyze_and_update_nicknames(self, item: tuple): + """处理单个队列项目:调用 LLM 分析并更新数据库。""" + if not isinstance(item, tuple) or len(item) != 5: + logger.warning(f"从队列接收到无效项目: {type(item)}") + return + + chat_history_str, bot_reply, platform, group_id, user_name_map = item + # 使用 asyncio.get_running_loop().call_soon(threading.get_ident) 可能不准确,线程ID是同步概念 + # 可以考虑移除线程ID日志或寻找异步安全的获取标识符的方式 + log_prefix = f"[{platform}:{group_id}]" # 简化日志前缀 + logger.debug(f"{log_prefix} 开始处理绰号分析任务...") + + if not self.llm_mapper: + logger.error(f"{log_prefix} LLM 映射器不可用,无法执行分析。") + return + if not self.db_handler.is_available(): + logger.error(f"{log_prefix} 数据库处理器不可用,无法更新计数。") + return + + # 1. 调用 LLM 分析 (内部逻辑不变) + analysis_result = await self._call_llm_for_analysis(chat_history_str, bot_reply, user_name_map) + + # 2. 如果分析成功且找到映射,则更新数据库 (内部逻辑不变) + if analysis_result.get("is_exist") and analysis_result.get("data"): + nickname_map_to_update = analysis_result["data"] + logger.info(f"{log_prefix} LLM 找到绰号映射,准备更新数据库: {nickname_map_to_update}") + + for user_id_str, nickname in nickname_map_to_update.items(): + if not user_id_str or not nickname: + logger.warning(f"{log_prefix} 跳过无效条目: user_id='{user_id_str}', nickname='{nickname}'") + continue + if not user_id_str.isdigit(): + logger.warning(f"{log_prefix} 无效的用户ID格式 (非纯数字): '{user_id_str}',跳过。") + continue + user_id_int = int(user_id_str) + + try: + person_id = person_info_manager.get_person_id(platform, user_id_str) + if not person_id: + logger.error( + f"{log_prefix} 无法为 platform='{platform}', user_id='{user_id_str}' 生成 person_id,跳过此用户。" + ) + continue + self.db_handler.upsert_person(person_id, user_id_int, platform) + self.db_handler.update_group_nickname_count(person_id, group_id, nickname) + except (OperationFailure, DuplicateKeyError) as db_err: + logger.exception( + f"{log_prefix} 数据库操作失败 ({type(db_err).__name__}): 用户 {user_id_str}, 绰号 {nickname}. 错误: {db_err}" + ) + except Exception as e: + logger.exception(f"{log_prefix} 处理用户 {user_id_str} 的绰号 '{nickname}' 时发生意外错误:{e}") + else: + logger.debug(f"{log_prefix} LLM 未找到可靠的绰号映射或分析失败。") + + async def _call_llm_for_analysis( + self, + chat_history_str: str, + bot_reply: str, + user_name_map: Dict[str, str], + ) -> Dict[str, Any]: + """ + 内部方法:调用 LLM 分析聊天记录和 Bot 回复,提取可靠的 用户ID-绰号 映射。 + """ + if not self.llm_mapper: + logger.error("LLM 映射器未初始化,无法执行分析。") + return {"is_exist": False} + + prompt = _build_mapping_prompt(chat_history_str, bot_reply, user_name_map) + logger.debug(f"构建的绰号映射 Prompt:\n{prompt}...") + + try: + response_content, _, _ = await self.llm_mapper.generate_response(prompt) + logger.debug(f"LLM 原始响应 (绰号映射): {response_content}") + + if not response_content: + logger.warning("LLM 返回了空的绰号映射内容。") + return {"is_exist": False} + + response_content = response_content.strip() + markdown_code_regex = re.compile(r"^```(?:\w+)?\s*\n(.*?)\n\s*```$", re.DOTALL | re.IGNORECASE) + match = markdown_code_regex.match(response_content) + if match: + response_content = match.group(1).strip() + elif response_content.startswith("{") and response_content.endswith("}"): + pass # 可能是纯 JSON + else: + json_match = re.search(r"\{.*\}", response_content, re.DOTALL) + if json_match: + response_content = json_match.group(0) + else: + logger.warning(f"LLM 响应似乎不包含有效的 JSON 对象。响应: {response_content}") + return {"is_exist": False} + + result = json.loads(response_content) + + if not isinstance(result, dict): + logger.warning(f"LLM 响应不是一个有效的 JSON 对象 (字典类型)。响应内容: {response_content}") + return {"is_exist": False} + + is_exist = result.get("is_exist") + + if is_exist is True: + original_data = result.get("data") + if isinstance(original_data, dict) and original_data: + logger.info(f"LLM 找到的原始绰号映射: {original_data}") + filtered_data = self._filter_llm_results(original_data, user_name_map) + if not filtered_data: + logger.info("所有找到的绰号映射都被过滤掉了。") + return {"is_exist": False} + else: + logger.info(f"过滤后的绰号映射: {filtered_data}") + return {"is_exist": True, "data": filtered_data} + else: + logger.warning(f"LLM 响应格式错误: is_exist=True 但 data 无效。原始 data: {original_data}") + return {"is_exist": False} + elif is_exist is False: + logger.info("LLM 明确指示未找到可靠的绰号映射 (is_exist=False)。") + return {"is_exist": False} + else: + logger.warning(f"LLM 响应格式错误: 'is_exist' 的值 '{is_exist}' 无效。") + return {"is_exist": False} + + except json.JSONDecodeError as json_err: + logger.error(f"解析 LLM 响应 JSON 失败: {json_err}\n原始响应: {response_content}") + return {"is_exist": False} + except Exception as e: + logger.error(f"绰号映射 LLM 调用或处理过程中发生意外错误: {e}", exc_info=True) + return {"is_exist": False} + + def _filter_llm_results(self, original_data: Dict[str, str], user_name_map: Dict[str, str]) -> Dict[str, str]: + """过滤 LLM 返回的绰号映射结果。""" + filtered_data = {} + bot_qq_str = str(self.config.BOT_QQ) if hasattr(self.config, "BOT_QQ") else None + + for user_id, nickname in original_data.items(): + if not isinstance(user_id, str): + logger.warning(f"过滤掉非字符串 user_id: {user_id}") + continue + if bot_qq_str and user_id == bot_qq_str: + logger.debug(f"过滤掉机器人自身的映射: ID {user_id}") + continue + if not nickname or nickname.isspace(): + logger.debug(f"过滤掉用户 {user_id} 的空绰号。") + continue + # person_name = user_name_map.get(user_id) + # if person_name and person_name == nickname: + # logger.debug(f"过滤掉用户 {user_id} 的映射: 绰号 '{nickname}' 与其名称 '{person_name}' 相同。") + # continue + filtered_data[user_id] = nickname.strip() + + return filtered_data + + # 线程相关 + # 修改:使用 run_async_loop 辅助函数 + def _run_processor_in_thread(self): + """后台线程入口函数,使用辅助函数管理 asyncio 事件循环。""" + thread_id = threading.get_ident() # 获取线程ID用于日志 + logger.info(f"绰号处理器线程启动 (线程 ID: {thread_id})...") + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) # 为当前线程设置事件循环 + logger.info(f"(线程 ID: {thread_id}) Asyncio 事件循环已创建并设置。") + + # 调用辅助函数来运行主处理协程并管理循环生命周期 + run_async_loop(loop, self._processing_loop()) + + logger.info(f"绰号处理器线程结束 (线程 ID: {thread_id}).") + + # 结束修改 + + # 修改:使用 asyncio.Queue 和 wait_for + async def _processing_loop(self): + """后台线程中运行的异步处理循环 (使用 asyncio.Queue)。""" + # 移除线程ID日志,因为它在异步上下文中不一定准确 + logger.info("绰号异步处理循环已启动。") + + while not self._stop_event.is_set(): # 仍然检查同步的停止事件 + try: + # 使用 asyncio.wait_for 从异步队列获取项目,并设置超时 + item = await asyncio.wait_for(self.nickname_queue.get(), timeout=self.sleep_interval) + + # 处理获取到的项目 (调用异步方法) + await self._analyze_and_update_nicknames(item) + + self.nickname_queue.task_done() # 标记任务完成 + + except asyncio.TimeoutError: + # 等待超时,相当于之前 queue.Empty,继续循环检查停止事件 + continue + except asyncio.CancelledError: + # 协程被取消 (通常在 stop_processor 中发生) + logger.info("绰号处理循环被取消。") + break # 退出循环 + except Exception as e: + # 捕获处理单个项目时可能发生的其他异常 + logger.error(f"绰号处理循环出错: {e}", exc_info=True) + # 短暂异步休眠避免快速连续失败 + await asyncio.sleep(5) + + logger.info("绰号异步处理循环已结束。") + # 可以在这里添加清理逻辑,比如确保队列为空或处理剩余项目 + # 例如:await self.nickname_queue.join() # 等待所有任务完成 (如果需要) + + # 结束修改 + + +# 在模块级别创建单例实例 +nickname_manager = NicknameManager() diff --git a/src/plugins/group_nickname/nickname_mapper.py b/src/plugins/group_nickname/nickname_mapper.py new file mode 100644 index 00000000..b41215cd --- /dev/null +++ b/src/plugins/group_nickname/nickname_mapper.py @@ -0,0 +1,77 @@ +# src/plugins/group_nickname/nickname_mapper.py +from typing import Dict +from src.common.logger_manager import get_logger + +# 这个文件现在只负责构建 Prompt,LLM 的初始化和调用移至 NicknameManager + +logger = get_logger("nickname_mapper") + +# LLMRequest 实例和 analyze_chat_for_nicknames 函数已被移除 + + +def _build_mapping_prompt(chat_history_str: str, bot_reply: str, user_name_map: Dict[str, str]) -> str: + """ + 构建用于 LLM 进行绰号映射分析的 Prompt。 + + Args: + chat_history_str: 格式化后的聊天历史记录字符串。 + bot_reply: Bot 的最新回复字符串。 + user_name_map: 用户 ID 到已知名称(person_name 或 fallback nickname)的映射。 + + Returns: + str: 构建好的 Prompt 字符串。 + """ + # 将 user_name_map 格式化为列表字符串 + user_list_str = "\n".join([f"- {uid}: {name}" for uid, name in user_name_map.items() if uid and name]) + if not user_list_str: + user_list_str = "无" # 如果映射为空,明确告知 + + # 核心 Prompt 内容 + prompt = f""" +任务:仔细分析以下聊天记录和“你的最新回复”,判断其中是否明确提到了某个用户的绰号,并且这个绰号可以清晰地与一个特定的用户 ID 对应起来。 + +已知用户信息(ID: 名称): +{user_list_str} + +聊天记录: +--- +{chat_history_str} +--- + +你的最新回复: +{bot_reply} + +分析要求与输出格式: +1. 找出聊天记录和“你的最新回复”中可能是用户绰号的词语。 +2. 判断这些绰号是否在上下文中**清晰、无歧义**地指向了“已知用户信息”列表中的**某一个特定用户 ID**。必须是强关联,避免猜测。 +3. **不要**输出你自己(名称后带"(你)"的用户)的绰号映射。 + **不要**输出与用户已知名称完全相同的词语作为绰号。 + **不要**将在“你的最新回复”中你对他人使用的称呼或绰号进行映射(只分析聊天记录中他人对用户的称呼)。 + **不要**输出指代不明或过于通用的词语(如“大佬”、“兄弟”、“那个谁”等,除非上下文能非常明确地指向特定用户)。 +4. 如果找到了**至少一个**满足上述所有条件的**明确**的用户 ID 到绰号的映射关系,请输出 JSON 对象: + ```json + {{ + "is_exist": true, + "data": {{ + "用户A数字id": "绰号_A", + "用户B数字id": "绰号_B" + }} + }} + ``` + - `"data"` 字段的键必须是用户的**数字 ID (字符串形式)**,值是对应的**绰号 (字符串形式)**。 + - 只包含你能**百分百确认**映射关系的条目。宁缺毋滥。 + 如果**无法找到任何一个**满足条件的明确映射关系,请输出 JSON 对象: + ```json + {{ + "is_exist": false + }} + ``` +5. 请**仅**输出 JSON 对象,不要包含任何额外的解释、注释或代码块标记之外的文本。 + +输出: +""" + # logger.debug(f"构建的绰号映射 Prompt (部分):\n{prompt[:500]}...") # 可以在 NicknameManager 中记录 + return prompt + + +# analyze_chat_for_nicknames 函数已被移除,其逻辑移至 NicknameManager._call_llm_for_analysis diff --git a/src/plugins/group_nickname/nickname_utils.py b/src/plugins/group_nickname/nickname_utils.py new file mode 100644 index 00000000..c5896b02 --- /dev/null +++ b/src/plugins/group_nickname/nickname_utils.py @@ -0,0 +1,175 @@ +import random +from typing import List, Dict, Tuple +from src.common.logger_manager import get_logger +from src.config.config import global_config + +# 这个文件现在只包含纯粹的工具函数,与状态和流程无关 + +logger = get_logger("nickname_utils") + + +def select_nicknames_for_prompt(all_nicknames_info: Dict[str, List[Dict[str, int]]]) -> List[Tuple[str, str, int]]: + """ + 从给定的绰号信息中,根据映射次数加权随机选择最多 N 个绰号用于 Prompt。 + + Args: + all_nicknames_info: 包含用户及其绰号信息的字典,格式为 + { "用户名1": [{"绰号A": 次数}, {"绰号B": 次数}], ... } + 注意:这里的用户名是 person_name。 + + Returns: + List[Tuple[str, str, int]]: 选中的绰号列表,每个元素为 (用户名, 绰号, 次数)。 + 按次数降序排序。 + """ + if not all_nicknames_info: + return [] + + candidates = [] # 存储 (用户名, 绰号, 次数, 权重) + smoothing_factor = getattr(global_config, "nickname_probability_smoothing", 1.0) # 平滑因子,避免权重为0 + + for user_name, nicknames in all_nicknames_info.items(): + if nicknames and isinstance(nicknames, list): + for nickname_entry in nicknames: + # 确保条目是字典且只有一个键值对 + if isinstance(nickname_entry, dict) and len(nickname_entry) == 1: + nickname, count = list(nickname_entry.items())[0] + # 确保次数是正整数 + if isinstance(count, int) and count > 0 and isinstance(nickname, str) and nickname: + weight = count + smoothing_factor # 计算权重 + candidates.append((user_name, nickname, count, weight)) + else: + logger.warning( + f"用户 '{user_name}' 的绰号条目无效: {nickname_entry} (次数非正整数或绰号为空)。已跳过。" + ) + else: + logger.warning(f"用户 '{user_name}' 的绰号条目格式无效: {nickname_entry}。已跳过。") + + if not candidates: + return [] + + # 确定需要选择的数量 + max_nicknames = getattr(global_config, "max_nicknames_in_prompt", 5) + num_to_select = min(max_nicknames, len(candidates)) + + try: + # 调用加权随机抽样(不重复) + selected_candidates_with_weight = weighted_sample_without_replacement(candidates, num_to_select) + + # 如果抽样结果数量不足(例如权重问题导致提前退出),可以考虑是否需要补充 + if len(selected_candidates_with_weight) < num_to_select: + logger.debug( + f"加权随机选择后数量不足 ({len(selected_candidates_with_weight)}/{num_to_select}),尝试补充选择次数最多的。" + ) + # 筛选出未被选中的候选 + selected_ids = set( + (c[0], c[1]) for c in selected_candidates_with_weight + ) # 使用 (用户名, 绰号) 作为唯一标识 + remaining_candidates = [c for c in candidates if (c[0], c[1]) not in selected_ids] + remaining_candidates.sort(key=lambda x: x[2], reverse=True) # 按原始次数排序 + needed = num_to_select - len(selected_candidates_with_weight) + selected_candidates_with_weight.extend(remaining_candidates[:needed]) + + except Exception as e: + # 日志:记录加权随机选择时发生的错误,并回退到简单选择 + logger.error(f"绰号加权随机选择时出错: {e}。将回退到选择次数最多的 Top N。", exc_info=True) + # 出错时回退到选择次数最多的 N 个 + candidates.sort(key=lambda x: x[2], reverse=True) # 按原始次数排序 + selected_candidates_with_weight = candidates[:num_to_select] + + # 格式化输出结果为 (用户名, 绰号, 次数),移除权重 + result = [(user, nick, count) for user, nick, count, _weight in selected_candidates_with_weight] + + # 按次数降序排序最终结果 + result.sort(key=lambda x: x[2], reverse=True) + + logger.debug(f"为 Prompt 选择的绰号: {result}") + return result + + +def format_nickname_prompt_injection(selected_nicknames: List[Tuple[str, str, int]]) -> str: + """ + 将选中的绰号信息格式化为注入 Prompt 的字符串。 + + Args: + selected_nicknames: 选中的绰号列表 (用户名, 绰号, 次数)。 + + Returns: + str: 格式化后的字符串,如果列表为空则返回空字符串。 + """ + if not selected_nicknames: + return "" + + # Prompt 注入部分的标题 + prompt_lines = ["以下是聊天记录中一些成员在本群的绰号信息(按常用度排序),供你参考:"] + grouped_by_user: Dict[str, List[str]] = {} # 用于按用户分组 + + # 按用户分组绰号 + for user_name, nickname, _count in selected_nicknames: + if user_name not in grouped_by_user: + grouped_by_user[user_name] = [] + # 添加中文引号以区分绰号 + grouped_by_user[user_name].append(f"“{nickname}”") + + # 构建每个用户的绰号字符串 + for user_name, nicknames in grouped_by_user.items(): + nicknames_str = "、".join(nicknames) # 使用中文顿号连接 + # 格式化输出,例如: "- 张三,ta 可能被称为:“三儿”、“张哥”" + prompt_lines.append(f"- {user_name},ta 可能被称为:{nicknames_str}") + + # 如果只有标题行,返回空字符串,避免注入无意义的标题 + if len(prompt_lines) > 1: + # 末尾加换行符,以便在 Prompt 中正确分隔 + return "\n".join(prompt_lines) + "\n" + else: + return "" + + +def weighted_sample_without_replacement( + candidates: List[Tuple[str, str, int, float]], k: int +) -> List[Tuple[str, str, int, float]]: + """ + 执行不重复的加权随机抽样。使用 A-ExpJ 算法思想的简化实现。 + + Args: + candidates: 候选列表,每个元素为 (用户名, 绰号, 次数, 权重)。 + k: 需要选择的数量。 + + Returns: + List[Tuple[str, str, int, float]]: 选中的元素列表(包含权重)。 + """ + if k <= 0: + return [] + n = len(candidates) + if k >= n: + return candidates[:] # 返回副本 + + # 计算每个元素的 key = U^(1/weight),其中 U 是 (0, 1) 之间的随机数 + # 为了数值稳定性,计算 log(key) = log(U) / weight + # log(U) 可以用 -Exponential(1) 来生成 + weighted_keys = [] + for i in range(n): + weight = candidates[i][3] + if weight <= 0: + # 处理权重为0或负数的情况,赋予一个极小的概率(或极大负数的log_key) + log_key = float("-inf") # 或者一个非常大的负数 + logger.warning(f"候选者 {candidates[i][:2]} 的权重为非正数 ({weight}),抽中概率极低。") + else: + log_u = -random.expovariate(1.0) # 生成 -Exponential(1) 随机数 + log_key = log_u / weight + weighted_keys.append((log_key, i)) # 存储 (log_key, 原始索引) + + # 按 log_key 降序排序 (相当于按 key 升序排序) + weighted_keys.sort(key=lambda x: x[0], reverse=True) + + # 选择 log_key 最大的 k 个元素的原始索引 + selected_indices = [index for _log_key, index in weighted_keys[:k]] + + # 根据选中的索引从原始 candidates 列表中获取元素 + selected_items = [candidates[i] for i in selected_indices] + + return selected_items + + +# 移除旧的流程函数 +# get_nickname_injection_for_prompt 和 trigger_nickname_analysis_if_needed +# 的逻辑现在由 NicknameManager 处理 diff --git a/src/tools/tool_can_use/base_tool.py b/src/tools/tool_can_use/base_tool.py index 62697168..f916b691 100644 --- a/src/tools/tool_can_use/base_tool.py +++ b/src/tools/tool_can_use/base_tool.py @@ -5,6 +5,7 @@ import pkgutil import os from src.common.logger_manager import get_logger from rich.traceback import install +from src.config.config import global_config install(extra_lines=3) @@ -64,6 +65,10 @@ def register_tool(tool_class: Type[BaseTool]): if not tool_name: raise ValueError(f"工具类 {tool_class.__name__} 没有定义 name 属性") + if not global_config.rename_person and tool_name == "rename_person": + logger.info("改名功能已关闭,改名工具未注册") + return + TOOL_REGISTRY[tool_name] = tool_class logger.info(f"已注册: {tool_name}") diff --git a/src/tools/tool_can_use/rename_person_tool.py b/src/tools/tool_can_use/rename_person_tool.py index c9914a4e..55b3c81e 100644 --- a/src/tools/tool_can_use/rename_person_tool.py +++ b/src/tools/tool_can_use/rename_person_tool.py @@ -1,4 +1,4 @@ -from src.tools.tool_can_use.base_tool import BaseTool, register_tool +from src.tools.tool_can_use.base_tool import BaseTool from src.chat.person_info.person_info import person_info_manager from src.common.logger_manager import get_logger import time @@ -104,4 +104,4 @@ class RenamePersonTool(BaseTool): # 注册工具 -register_tool(RenamePersonTool) +# register_tool(RenamePersonTool) diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 0a0bfd00..befaa97e 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -42,10 +42,11 @@ personality_sides = [ "用一句话或几句话描述人格的一些细节", "用一句话或几句话描述人格的一些细节", ]# 条数任意,不能为0, 该选项还在调试中,可能未完全生效 +personality_detail_level = 0 # 人设消息注入 prompt 详细等级 (0: 采用默认配置, 1: 核心/随机细节, 2: 核心+随机侧面/全部细节, 3: 全部) # 表达方式 expression_style = "描述麦麦说话的表达风格,表达习惯" - +enable_expression_learner = true # 是否启用新发言习惯注入,关闭则启用旧方法 [identity] #アイデンティティがない 生まれないらららら # 兴趣爱好 未完善,有些条目未使用 @@ -54,10 +55,18 @@ identity_detail = [ "身份特点", ]# 条数任意,不能为0, 该选项还在调试中 #外貌特征 -age = 20 # 年龄 单位岁 -gender = "男" # 性别 +age = 20 # 年龄 单位岁 +gender = "男" # 性别 appearance = "用几句话描述外貌特征" # 外貌特征 该选项还在调试中,暂时未生效 +[schedule] +enable_schedule_gen = true # 是否启用日程表 +enable_schedule_interaction = true # 日程表是否影响回复模式 +prompt_schedule_gen = "用几句话描述描述性格特点或行动规律,这个特征会用来生成日程表" +schedule_doing_update_interval = 900 # 日程表更新间隔 单位秒 +schedule_temperature = 0.1 # 日程表温度,建议0.1-0.5 +time_zone = "Asia/Shanghai" # 给你的机器人设置时区,可以解决运行电脑时区和国内时区不同的情况,或者模拟国外留学生日程 + [platforms] # 必填项目,填写每个平台适配器提供的链接 qq="http://127.0.0.1:18002/api/message" @@ -67,6 +76,7 @@ allow_focus_mode = false # 是否允许专注聊天状态 # 启用后麦麦会自主选择进入heart_flowC模式(持续一段时间),进行主动的观察和回复,并给出回复,比较消耗token base_normal_chat_num = 999 # 最多允许多少个群进行普通聊天 base_focused_chat_num = 4 # 最多允许多少个群进行专注聊天 +allow_remove_duplicates = true # 是否开启心流去重(如果发现心流截断问题严重可尝试关闭) observation_context_size = 15 # 观察到的最长上下文大小,建议15,太短太长都会导致脑袋尖尖 message_buffer = true # 启用消息缓冲器?启用此项以解决消息的拆分问题,但会使麦麦的回复延迟 @@ -119,6 +129,15 @@ steal_emoji = true # 是否偷取表情包,让麦麦可以发送她保存的 enable_check = false # 是否启用表情包过滤,只有符合该要求的表情包才会被保存 check_prompt = "符合公序良俗" # 表情包过滤要求,只有符合该要求的表情包才会被保存 +[group_nickname] +enable_nickname_mapping = false # 绰号映射功能总开关(默认关闭,建议关闭) +max_nicknames_in_prompt = 10 # Prompt 中最多注入的绰号数量(防止token数量爆炸) +nickname_probability_smoothing = 1 # 绰号加权随机选择的平滑因子 +nickname_queue_max_size = 100 # 绰号处理队列最大容量 +nickname_process_sleep_interval = 5 # 绰号处理进程休眠间隔(秒),不建议超过5,否则大概率导致结束过程中超时 +nickname_analysis_history_limit = 30 # 绰号处理可见最大上下文 +nickname_analysis_probability = 0.1 # 绰号随机概率命中,该值越大,绰号分析越频繁 + [memory] build_memory_interval = 2000 # 记忆构建间隔 单位秒 间隔越低,麦麦学习越多,但是冗余信息也会增多 build_memory_distribution = [6.0,3.0,0.6,32.0,12.0,0.4] # 记忆构建分布,参数:分布1均值,标准差,权重,分布2均值,标准差,权重 @@ -127,7 +146,7 @@ build_memory_sample_length = 40 # 采样长度,数值越高一段记忆内容 memory_compress_rate = 0.1 # 记忆压缩率 控制记忆精简程度 建议保持默认,调高可以获得更多信息,但是冗余信息也会增多 forget_memory_interval = 1000 # 记忆遗忘间隔 单位秒 间隔越低,麦麦遗忘越频繁,记忆更精简,但更难学习 -memory_forget_time = 24 #多长时间后的记忆会被遗忘 单位小时 +memory_forget_time = 24 #多长时间后的记忆会被遗忘 单位小时 memory_forget_percentage = 0.01 # 记忆遗忘比例 控制记忆遗忘程度 越大遗忘越多 建议保持默认 consolidate_memory_interval = 1000 # 记忆整合间隔 单位秒 间隔越低,麦麦整合越频繁,记忆更精简 @@ -135,10 +154,12 @@ consolidation_similarity_threshold = 0.7 # 相似度阈值 consolidation_check_percentage = 0.01 # 检查节点比例 #不希望记忆的词,已经记忆的不会受到影响 -memory_ban_words = [ +memory_ban_words = [ # "403","张三" ] +long_message_auto_truncate = true # HFC 模式过长消息自动截断,防止他人 prompt 恶意注入,减少token消耗,但可能损失图片/长文信息,按需选择状态(默认开启) + [mood] mood_update_interval = 1.0 # 情绪更新间隔 单位秒 mood_decay_rate = 0.95 # 情绪衰减率 @@ -178,17 +199,43 @@ enable_kaomoji_protection = false # 是否启用颜文字保护 model_max_output_length = 256 # 模型单次返回的最大token数 [remote] #发送统计信息,主要是看全球有多少只麦麦 -enable = true +enable = false [experimental] #实验性功能 -enable_friend_chat = false # 是否启用好友聊天 +enable_Legacy_HFC = false # 是否启用旧 HFC 处理器 +enable_friend_chat = true # 是否启用好友聊天 +enable_friend_whitelist = true # 是否启用好友聊天白名单 talk_allowed_private = [] # 可以回复消息的QQ号 -pfc_chatting = false # 是否启用PFC聊天,该功能仅作用于私聊,与回复模式独立 -enable_pfc_reply_checker = true # 是否启用 PFC 的回复检查器 -pfc_message_buffer_size = 2 # PFC 聊天消息缓冲数量,有利于使聊天节奏更加紧凑流畅,请根据实际 LLM 响应速度进行调整,默认2条 +rename_person = true # 是否启用改名工具,可以让麦麦对唯一名进行更改,可能可以更拟人地称呼他人,但是也可能导致记忆混淆的问题 -[idle_chat] -enable_idle_chat = false # 是否启用 pfc 主动发言 +[pfc] +enable_pfc_chatting = true # 是否启用PFC聊天,该功能仅作用于私聊,与回复模式独立 +pfc_message_buffer_size = 2 # PFC 聊天消息缓冲数量,有利于使聊天节奏更加紧凑流畅,请根据实际 LLM 响应速度进行调整,默认2条 +pfc_recent_history_display_count = 18 # PFC 对话最大可见上下文 + +[[pfc.checker]] +enable_pfc_reply_checker = true # 是否启用 PFC 的回复检查器 +pfc_max_reply_attempts = 3 # 发言最多尝试次数 +pfc_max_chat_history_for_checker = 30 # checker聊天记录最大可见上文长度 + +[[pfc.emotion]] +pfc_emotion_update_intensity = 0.6 # 情绪更新强度 +pfc_emotion_history_count = 5 # 情绪更新最大可见上下文长度 + +[[pfc.relationship]] +pfc_relationship_incremental_interval = 10 # 关系值增值强度 +pfc_relationship_incremental_msg_count = 10 # 会话中,关系值判断最大可见上下文 +pfc_relationship_incremental_default_change = 1.0 # 会话中,关系值默认更新值(当 llm 返回错误时默认采用该值) +pfc_relationship_incremental_max_change = 5.0 # 会话中,关系值最大可变值 +pfc_relationship_final_msg_count = 30 # 会话结束时,关系值判断最大可见上下文 +pfc_relationship_final_default_change =5.0 # 会话结束时,关系值默认更新值 +pfc_relationship_final_max_change = 50.0 # 会话结束时,关系值最大可变值 + +[[pfc.fallback]] +pfc_historical_fallback_exclude_seconds = 45 # pfc 翻看聊天记录排除最近时长 + +[[pfc.idle_chat]] +enable_idle_chat = true # 是否启用 pfc 主动发言 idle_check_interval = 10 # 检查间隔,10分钟检查一次 min_cooldown = 7200 # 最短冷却时间,2小时 (7200秒) max_cooldown = 18000 # 最长冷却时间,5小时 (18000秒) @@ -288,14 +335,40 @@ temp = 0.3 pri_in = 2 pri_out = 8 -# PFC 关系评估LLM -[model.llm_PFC_relationship_eval] -name = "Pro/deepseek-ai/DeepSeek-V3" # 或者其他你认为适合判断任务的模型 +#绰号映射生成模型 +[model.llm_nickname_mapping] +name = "Qwen/Qwen2.5-32B-Instruct" provider = "SILICONFLOW" -temp = 0.4 +temp = 0.7 +pri_in = 1.26 +pri_out = 1.26 + +#日程模型 +[model.llm_scheduler_all] +name = "deepseek-ai/DeepSeek-V3" +provider = "SILICONFLOW" +temp = 0.3 pri_in = 2 pri_out = 8 +#在干嘛模型 +[model.llm_scheduler_doing] +name = "deepseek-ai/DeepSeek-V3" +provider = "SILICONFLOW" +temp = 0.3 +pri_in = 2 +pri_out = 8 + +# PFC 关系评估LLM +[model.llm_PFC_relationship_eval] +name = "deepseek-ai/DeepSeek-V3" +provider = "SILICONFLOW" +temp = 0.4 +max_tokens = 512 +pri_in = 2 +pri_out = 8 + + #以下模型暂时没有使用!! #以下模型暂时没有使用!! #以下模型暂时没有使用!!