diff --git a/src/heart_flow/mai_state_manager.py b/src/heart_flow/mai_state_manager.py index 0888ae1fd..4d92d7fd8 100644 --- a/src/heart_flow/mai_state_manager.py +++ b/src/heart_flow/mai_state_manager.py @@ -8,9 +8,22 @@ from src.plugins.moods.moods import MoodManager logger = get_logger("mai_state") -# enable_unlimited_hfc_chat = True -enable_unlimited_hfc_chat = False +# -- 状态相关的可配置参数 (可以从 glocal_config 加载) -- +enable_unlimited_hfc_chat = True # 调试用:无限专注聊天 +# enable_unlimited_hfc_chat = False +prevent_offline_state = True # 调试用:防止进入离线状态 +# 不同状态下普通聊天的最大消息数 +MAX_NORMAL_CHAT_NUM_PEEKING = 30 +MAX_NORMAL_CHAT_NUM_NORMAL = 40 +MAX_NORMAL_CHAT_NUM_FOCUSED = 30 + +# 不同状态下专注聊天的最大消息数 +MAX_FOCUSED_CHAT_NUM_PEEKING = 20 +MAX_FOCUSED_CHAT_NUM_NORMAL = 30 +MAX_FOCUSED_CHAT_NUM_FOCUSED = 40 + +# -- 状态定义 -- class MaiState(enum.Enum): """ @@ -34,11 +47,11 @@ class MaiState(enum.Enum): if self == MaiState.OFFLINE: return 0 elif self == MaiState.PEEKING: - return 30 + return MAX_NORMAL_CHAT_NUM_PEEKING elif self == MaiState.NORMAL_CHAT: - return 40 + return MAX_NORMAL_CHAT_NUM_NORMAL elif self == MaiState.FOCUSED_CHAT: - return 30 + return MAX_NORMAL_CHAT_NUM_FOCUSED def get_focused_chat_max_num(self): # 调试用 @@ -48,11 +61,11 @@ class MaiState(enum.Enum): if self == MaiState.OFFLINE: return 0 elif self == MaiState.PEEKING: - return 20 + return MAX_FOCUSED_CHAT_NUM_PEEKING elif self == MaiState.NORMAL_CHAT: - return 30 + return MAX_FOCUSED_CHAT_NUM_NORMAL elif self == MaiState.FOCUSED_CHAT: - return 40 + return MAX_FOCUSED_CHAT_NUM_FOCUSED class MaiStateInfo: @@ -110,7 +123,6 @@ class MaiStateManager: """管理 Mai 的整体状态转换逻辑""" def __init__(self): - # MaiStateManager doesn't hold the state itself, it operates on a MaiStateInfo instance. pass def check_and_decide_next_state(self, current_state_info: MaiStateInfo) -> Optional[MaiState]: @@ -129,6 +141,13 @@ class MaiStateManager: 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(f"阻止进入 OFFLINE,改为 PEEKING") + return MaiState.PEEKING + return candidate_state + if current_status == MaiState.OFFLINE: logger.info("当前[离线],没看手机,思考要不要上线看看......") elif current_status == MaiState.PEEKING: @@ -141,61 +160,71 @@ class MaiStateManager: # 1. 麦麦每分钟都有概率离线 if time_since_last_min_check >= 60: if current_status != MaiState.OFFLINE: - if random.random() < 0.03: # 3% 概率切换到 OFFLINE,20分钟有50%的概率还在线 - logger.debug(f"突然不想聊了,从 {current_status.value} 切换到 离线") - next_state = MaiState.OFFLINE + if random.random() < 0.03: # 3% 概率切换到 OFFLINE + potential_next = MaiState.OFFLINE + resolved_next = _resolve_offline(potential_next) + logger.debug(f"规则1:概率触发下线,resolve 为 {resolved_next.value}") + # 只有当解析后的状态与当前状态不同时才设置 next_state + if resolved_next != current_status: + next_state = resolved_next - # 2. 状态持续时间规则 (如果没有自行下线) + # 2. 状态持续时间规则 (只有在规则1没有触发状态改变时才检查) if next_state is None: + time_limit_exceeded = False + choices_list = [] + weights = [] + rule_id = "" + if current_status == MaiState.OFFLINE: - # 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] - next_state_candidate = random.choices(choices_list, weights=weights, k=1)[0] - if next_state_candidate != MaiState.OFFLINE: - next_state = next_state_candidate - logger.debug(f"上线!开始 {next_state.value}") - else: - # 继续离线状态 - next_state = 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] - next_state = random.choices(choices_list, weights=weights, k=1)[0] - logger.debug(f"手机看完了,接下来 {next_state.value}") - 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] - next_state = random.choices(choices_list, weights=weights, k=1)[0] - if next_state == MaiState.FOCUSED_CHAT: - logger.debug(f"继续深入聊天, {next_state.value}") - else: - logger.debug(f"聊完了,接下来 {next_state.value}") - 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] - next_state = random.choices(choices_list, weights=weights, k=1)[0] - logger.debug(f"深入聊天结束,接下来 {next_state.value}") + 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 以便调用者可以重置计时器 + # 并且是由于持续时间规则触发的,返回 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/plugins/heartFC_chat/heartFC_chat.py b/src/plugins/heartFC_chat/heartFC_chat.py index 00cc27cd9..25cf854af 100644 --- a/src/plugins/heartFC_chat/heartFC_chat.py +++ b/src/plugins/heartFC_chat/heartFC_chat.py @@ -1,6 +1,7 @@ import asyncio import time import traceback +import random # <--- 添加导入 from typing import List, Optional, Dict, Any, Deque, Callable, Coroutine from collections import deque from src.plugins.chat.message import MessageRecv, BaseMessageInfo, MessageThinking, MessageSending @@ -31,6 +32,8 @@ from src.individuality.individuality import Individuality INITIAL_DURATION = 60.0 +WAITING_TIME_THRESHOLD = 300 # 等待新消息时间阈值,单位秒 + logger = get_logger("interest") # Logger Name Changed @@ -45,10 +48,11 @@ 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 + return self._available_actions.copy() # 返回副本以防外部修改 def add_action(self, action_name: str, description: str) -> bool: """ @@ -81,6 +85,30 @@ class ActionManager: 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() @@ -151,7 +179,7 @@ class HeartFChatting: 其生命周期现在由其关联的 SubHeartflow 的 FOCUSED 状态控制。 """ - CONSECUTIVE_NO_REPLY_THRESHOLD = 5 # 连续不回复的阈值 + CONSECUTIVE_NO_REPLY_THRESHOLD = 3 # 连续不回复的阈值 def __init__( self, @@ -214,6 +242,7 @@ class HeartFChatting: 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 # <--- 新增:累计等待时间 async def _initialize(self) -> bool: """ @@ -489,6 +518,7 @@ class HeartFChatting: 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]: @@ -511,6 +541,7 @@ class HeartFChatting: """ # 重置连续不回复计数器 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() @@ -566,6 +597,7 @@ class HeartFChatting: 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() @@ -601,23 +633,41 @@ class HeartFChatting: observation = self.observations[0] if self.observations else None try: + dang_qian_deng_dai = 0.0 # 初始化本次等待时间 with Timer("等待新消息", cycle_timers): # 等待新消息、超时或关闭信号,并获取结果 await self._wait_for_new_message(observation, planner_start_db_time, self.log_prefix) + # 从计时器获取实际等待时间 + dang_qian_deng_dai = 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 += dang_qian_deng_dai # 累加等待时间 logger.debug( - f"{self.log_prefix} 连续不回复计数增加: {self._lian_xu_bu_hui_fu_ci_shu}/{self.CONSECUTIVE_NO_REPLY_THRESHOLD}" + f"{self.log_prefix} 连续不回复计数增加: {self._lian_xu_bu_hui_fu_ci_shu}/{self.CONSECUTIVE_NO_REPLY_THRESHOLD}, " + f"本次等待: {dang_qian_deng_dai:.2f}秒, 累计等待: {self._lian_xu_deng_dai_shi_jian:.2f}秒" ) - # 检查是否达到阈值 - if self._lian_xu_bu_hui_fu_ci_shu >= self.CONSECUTIVE_NO_REPLY_THRESHOLD: + # 检查是否同时达到次数和时间阈值 + time_threshold = 0.66 * WAITING_TIME_THRESHOLD * self.CONSECUTIVE_NO_REPLY_THRESHOLD + if (self._lian_xu_bu_hui_fu_ci_shu >= self.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.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 >= self.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 @@ -658,8 +708,8 @@ class HeartFChatting: return True # 检查超时 (放在检查新消息和关闭之后) - if time.monotonic() - wait_start_time > 120: - logger.warning(f"{log_prefix} 等待新消息超时(20秒)") + if time.monotonic() - wait_start_time > WAITING_TIME_THRESHOLD: + logger.warning(f"{log_prefix} 等待新消息超时({WAITING_TIME_THRESHOLD}秒)") return False try: @@ -737,9 +787,49 @@ class HeartFChatting: 参数: current_mind: 子思维的当前思考结果 + cycle_timers: 计时器字典 + is_re_planned: 是否为重新规划 """ logger.info(f"{self.log_prefix}[Planner] 开始{'重新' if is_re_planned else ''}执行规划器") + # --- 新增:检查历史动作并调整可用动作 --- + lian_xu_wen_ben_hui_fu = 0 # 连续文本回复次数 + actions_to_remove_temporarily = [] + 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 # 遇到非文本回复,中断计数 + # 检查最近的3个循环即可,避免检查过多历史 (如果历史很长) + 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: # 80% 概率 + 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: # 40% 概率 + 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 (未触发)") + # 如果 lian_xu_wen_ben_hui_fu == 0,则不移除任何动作 + # --- 结束:检查历史动作 --- + # 获取观察信息 observation = self.observations[0] if is_re_planned: @@ -754,6 +844,11 @@ class HeartFChatting: emoji_query = "" # <--- 在这里初始化 emoji_query try: + # --- 新增:应用临时动作移除 --- + if actions_to_remove_temporarily: + self.action_manager.temporarily_remove_actions(actions_to_remove_temporarily) + logger.debug(f"{self.log_prefix}[Planner] 临时移除的动作: {actions_to_remove_temporarily}, 当前可用: {list(self.action_manager.get_available_actions().keys())}") + # --- 构建提示词 --- replan_prompt_str = "" if is_re_planned: @@ -767,6 +862,7 @@ class HeartFChatting: # --- 调用 LLM --- try: planner_tools = self.action_manager.get_planner_tool_definition() + logger.debug(f"{self.log_prefix}[Planner] 本次使用的工具定义: {planner_tools}") # 记录本次使用的工具 _response_text, _reasoning_content, tool_calls = await self.planner_llm.generate_response_tool_async( prompt=prompt, tools=planner_tools, @@ -810,15 +906,23 @@ class HeartFChatting: extracted_action = arguments.get("action", "no_reply") # 验证动作 if extracted_action not in self.action_manager.get_available_actions(): + # 如果LLM返回了一个此时不该用的动作(因为被临时移除了) + # 或者完全无效的动作 logger.warning( - f"{self.log_prefix}[Planner] LLM返回了未授权的动作: {extracted_action},使用默认动作no_reply" + f"{self.log_prefix}[Planner] LLM返回了当前不可用或无效的动作: {extracted_action},将强制使用 'no_reply'" ) action = "no_reply" - reasoning = f"LLM返回了未授权的动作: {extracted_action}" + reasoning = f"LLM返回了当前不可用的动作: {extracted_action}" emoji_query = "" - llm_error = False # 视为非LLM错误,只是逻辑修正 + llm_error = False # 视为逻辑修正而非 LLM 错误 + # --- 检查 'no_reply' 是否也恰好被移除了 (极端情况) --- + if "no_reply" not in self.action_manager.get_available_actions(): + logger.error(f"{self.log_prefix}[Planner] 严重错误:'no_reply' 动作也不可用!无法执行任何动作。") + action = "error" # 回退到错误状态 + reasoning = "无法执行任何有效动作,包括 no_reply" + llm_error = True else: - # 动作有效,使用提取的值 + # 动作有效且可用,使用提取的值 action = extracted_action reasoning = arguments.get("reasoning", "未提供理由") emoji_query = arguments.get("emoji_query", "") @@ -837,8 +941,18 @@ class HeartFChatting: reasoning = f"验证工具调用失败: {error_msg}" logger.warning(f"{self.log_prefix}[Planner] {reasoning}") else: # not valid_tool_calls - reasoning = "LLM未返回有效的工具调用" - logger.warning(f"{self.log_prefix}[Planner] {reasoning}") + # 如果没有有效的工具调用,我们需要检查 'no_reply' 是否是当前唯一可用的动作 + available_actions = list(self.action_manager.get_available_actions().keys()) + if available_actions == ["no_reply"]: + logger.info(f"{self.log_prefix}[Planner] LLM未返回工具调用,但当前唯一可用动作是 'no_reply',将执行 'no_reply'") + action = "no_reply" + reasoning = "LLM未返回工具调用,且当前仅 'no_reply' 可用" + emoji_query = "" + llm_error = False # 视为逻辑选择而非错误 + else: + reasoning = "LLM未返回有效的工具调用" + logger.warning(f"{self.log_prefix}[Planner] {reasoning}") + # llm_error 保持为 True # 如果 llm_error 仍然是 True,说明在处理过程中有错误发生 except Exception as llm_e: @@ -847,6 +961,12 @@ class HeartFChatting: action = "error" reasoning = f"Planner内部处理错误: {llm_e}" llm_error = True + # --- 新增:确保动作恢复 --- + finally: + if actions_to_remove_temporarily: # 只有当确实移除了动作时才需要恢复 + self.action_manager.restore_actions() + logger.debug(f"{self.log_prefix}[Planner] 恢复了原始动作集, 当前可用: {list(self.action_manager.get_available_actions().keys())}") + # --- 结束:确保动作恢复 --- # --- 结束 LLM 决策 --- # return { diff --git a/src/plugins/heartFC_chat/heartflow_prompt_builder.py b/src/plugins/heartFC_chat/heartflow_prompt_builder.py index 32c78a106..a0f266d66 100644 --- a/src/plugins/heartFC_chat/heartflow_prompt_builder.py +++ b/src/plugins/heartFC_chat/heartflow_prompt_builder.py @@ -85,6 +85,7 @@ def init_prompt(): - 遵守回复原则 - 必须调用工具并包含action和reasoning - 你可以选择文字回复(text_reply),纯表情回复(emoji_reply),不回复(no_reply) +- 并不是所有选择都可用 - 选择text_reply或emoji_reply时必须提供emoji_query - 保持回复自然,符合日常聊天习惯""", "planner_prompt",