diff --git a/src/common/logger.py b/src/common/logger.py index b5317d58b..6c95935ea 100644 --- a/src/common/logger.py +++ b/src/common/logger.py @@ -633,13 +633,12 @@ HFC_STYLE_CONFIG = { "file_format": "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {extra[module]: <15} | 专注聊天 | {message}", }, "simple": { - "console_format": ( - "{time:MM-DD HH:mm} | 专注聊天 | {message}" - ), + "console_format": ("{time:MM-DD HH:mm} | 专注聊天 | {message}"), "file_format": "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {extra[module]: <15} | 专注聊天 | {message}", }, } + CONFIRM_STYLE_CONFIG = { "console_format": "{message}", # noqa: E501 "file_format": "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {extra[module]: <15} | EULA与PRIVACY确认 | {message}", diff --git a/src/config/config.py b/src/config/config.py index e6cf16d4d..fbf558a37 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -22,7 +22,7 @@ logger = get_logger("config") # 考虑到,实际上配置文件中的mai_version是不会自动更新的,所以采用硬编码 is_test = False mai_version_main = "0.6.3" -mai_version_fix = "" +mai_version_fix = "fix-1" if mai_version_fix: if is_test: diff --git a/src/heart_flow/mai_state_manager.py b/src/heart_flow/mai_state_manager.py index cd7393445..29277820e 100644 --- a/src/heart_flow/mai_state_manager.py +++ b/src/heart_flow/mai_state_manager.py @@ -10,6 +10,9 @@ 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 diff --git a/src/heart_flow/sub_heartflow.py b/src/heart_flow/sub_heartflow.py index 4a48e977c..8d07e6b52 100644 --- a/src/heart_flow/sub_heartflow.py +++ b/src/heart_flow/sub_heartflow.py @@ -18,7 +18,7 @@ from src.heart_flow.sub_mind import SubMind # 定义常量 (从 interest.py 移动过来) MAX_INTEREST = 15.0 -logger = get_logger("subheartflow") +logger = get_logger("sub_heartflow") PROBABILITY_INCREASE_RATE_PER_SECOND = 0.1 PROBABILITY_DECREASE_RATE_PER_SECOND = 0.1 @@ -346,7 +346,7 @@ class SubHeartflow: return True # 已经在运行 # 如果实例不存在,则创建并启动 - logger.info(f"{log_prefix} 麦麦准备开始专注聊天 (创建新实例)...") + logger.info(f"{log_prefix} 麦麦准备开始专注聊天...") try: # 创建 HeartFChatting 实例,并传递 从构造函数传入的 回调函数 self.heart_fc_instance = HeartFChatting( @@ -359,7 +359,7 @@ class SubHeartflow: # 初始化并启动 HeartFChatting if await self.heart_fc_instance._initialize(): await self.heart_fc_instance.start() - logger.info(f"{log_prefix} 麦麦已成功进入专注聊天模式 (新实例已启动)。") + logger.debug(f"{log_prefix} 麦麦已成功进入专注聊天模式 (新实例已启动)。") return True else: logger.error(f"{log_prefix} HeartFChatting 初始化失败,无法进入专注模式。") @@ -397,7 +397,7 @@ class SubHeartflow: # 移除限额检查逻辑 logger.debug(f"{log_prefix} 准备进入或保持 专注聊天 状态") if await self._start_heart_fc_chat(): - logger.info(f"{log_prefix} 成功进入或保持 HeartFChatting 状态。") + logger.debug(f"{log_prefix} 成功进入或保持 HeartFChatting 状态。") state_changed = True else: logger.error(f"{log_prefix} 启动 HeartFChatting 失败,无法进入 FOCUSED 状态。") @@ -511,12 +511,12 @@ class SubHeartflow: # 取消可能存在的旧后台任务 (self.task) if self.task and not self.task.done(): - logger.info(f"{self.log_prefix} 取消子心流主任务 (Shutdown)...") + logger.debug(f"{self.log_prefix} 取消子心流主任务 (Shutdown)...") self.task.cancel() try: await asyncio.wait_for(self.task, timeout=1.0) # 给点时间响应取消 except asyncio.CancelledError: - logger.info(f"{self.log_prefix} 子心流主任务已取消 (Shutdown)。") + logger.debug(f"{self.log_prefix} 子心流主任务已取消 (Shutdown)。") except asyncio.TimeoutError: logger.warning(f"{self.log_prefix} 等待子心流主任务取消超时 (Shutdown)。") except Exception as e: diff --git a/src/plugins/emoji_system/emoji_manager.py b/src/plugins/emoji_system/emoji_manager.py index 72169bc44..d6da4ce3f 100644 --- a/src/plugins/emoji_system/emoji_manager.py +++ b/src/plugins/emoji_system/emoji_manager.py @@ -34,9 +34,12 @@ MAX_EMOJI_FOR_PROMPT = 20 # 最大允许的表情包描述数量于图片替换 class MaiEmoji: """定义一个表情包""" - def __init__(self, filename: str, path: str): - self.path = path # 存储目录路径 - self.filename = filename + def __init__(self, full_path: str): + if not full_path: + raise ValueError("full_path cannot be empty") + self.full_path = full_path # 文件的完整路径 (包括文件名) + self.path = os.path.dirname(full_path) # 文件所在的目录路径 + self.filename = os.path.basename(full_path) # 文件名 self.embedding = [] self.hash = "" # 初始为空,在创建实例时会计算 self.description = "" @@ -48,35 +51,58 @@ class MaiEmoji: self.format = "" async def initialize_hash_format(self): - """从文件创建表情包实例 - - 参数: - file_path: 文件的完整路径 - - 返回: - MaiEmoji: 创建的表情包实例,如果失败则返回None - """ + """从文件创建表情包实例, 计算哈希值和格式""" + image_base64 = None + image_bytes = None try: - file_path = os.path.join(self.path, self.filename) - if not os.path.exists(file_path): - logger.error(f"[错误] 表情包文件不存在: {file_path}") + # 使用 full_path 检查文件是否存在 + if not os.path.exists(self.full_path): + logger.error(f"[初始化错误] 表情包文件不存在: {self.full_path}") + self.is_deleted = True return None - image_base64 = image_path_to_base64(file_path) + # 使用 full_path 读取文件 + logger.debug(f"[初始化] 正在读取文件: {self.full_path}") + image_base64 = image_path_to_base64(self.full_path) if image_base64 is None: - logger.error(f"[错误] 无法读取图片: {file_path}") + logger.error(f"[初始化错误] 无法读取或转换Base64: {self.full_path}") + self.is_deleted = True return None + logger.debug(f"[初始化] 文件读取成功 (Base64预览: {image_base64[:50]}...)") # 计算哈希值 + logger.debug(f"[初始化] 正在解码Base64并计算哈希: {self.filename}") image_bytes = base64.b64decode(image_base64) self.hash = hashlib.md5(image_bytes).hexdigest() + logger.debug(f"[初始化] 哈希计算成功: {self.hash}") # 获取图片格式 - self.format = Image.open(io.BytesIO(image_bytes)).format.lower() + logger.debug(f"[初始化] 正在使用Pillow获取格式: {self.filename}") + try: + with Image.open(io.BytesIO(image_bytes)) as img: + self.format = img.format.lower() + logger.debug(f"[初始化] 格式获取成功: {self.format}") + except Exception as pil_error: + logger.error(f"[初始化错误] Pillow无法处理图片 ({self.filename}): {pil_error}") + logger.error(traceback.format_exc()) + self.is_deleted = True + return None + # 如果所有步骤成功,返回 True + return True + + except FileNotFoundError: + logger.error(f"[初始化错误] 文件在处理过程中丢失: {self.full_path}") + self.is_deleted = True + return None + except base64.binascii.Error as b64_error: + logger.error(f"[初始化错误] Base64解码失败 ({self.filename}): {b64_error}") + self.is_deleted = True + return None except Exception as e: - logger.error(f"[错误] 初始化表情包失败: {str(e)}") + logger.error(f"[初始化错误] 初始化表情包时发生未预期错误 ({self.filename}): {str(e)}") logger.error(traceback.format_exc()) + self.is_deleted = True return None async def register_to_db(self): @@ -87,44 +113,47 @@ class MaiEmoji: """ try: # 确保目标目录存在 - os.makedirs(EMOJI_REGISTED_DIR, exist_ok=True) - # 源路径是当前实例的完整路径 - source_path = os.path.join(self.path, self.filename) - # 目标路径 - destination_path = os.path.join(EMOJI_REGISTED_DIR, self.filename) + # 源路径是当前实例的完整路径 self.full_path + source_full_path = self.full_path + # 目标完整路径 + destination_full_path = os.path.join(EMOJI_REGISTED_DIR, self.filename) # 检查源文件是否存在 - if not os.path.exists(source_path): - logger.error(f"[错误] 源文件不存在: {source_path}") + if not os.path.exists(source_full_path): + logger.error(f"[错误] 源文件不存在: {source_full_path}") return False # --- 文件移动 --- try: # 如果目标文件已存在,先删除 (确保移动成功) - if os.path.exists(destination_path): - os.remove(destination_path) + if os.path.exists(destination_full_path): + os.remove(destination_full_path) - os.rename(source_path, destination_path) - logger.debug(f"[移动] 文件从 {source_path} 移动到 {destination_path}") - # 更新实例的路径属性为新目录 + os.rename(source_full_path, destination_full_path) + logger.debug(f"[移动] 文件从 {source_full_path} 移动到 {destination_full_path}") + # 更新实例的路径属性为新路径 + self.full_path = destination_full_path self.path = EMOJI_REGISTED_DIR + # self.filename 保持不变 except Exception as move_error: logger.error(f"[错误] 移动文件失败: {str(move_error)}") - return False # 文件移动失败,不继续 + # 如果移动失败,尝试将实例状态恢复?暂时不处理,仅返回失败 + return False # --- 数据库操作 --- try: # 准备数据库记录 for emoji collection emoji_record = { "filename": self.filename, - "path": os.path.join(self.path, self.filename), # 使用更新后的路径 + "path": self.path, # 存储目录路径 + "full_path": self.full_path, # 存储完整文件路径 "embedding": self.embedding, "description": self.description, - "emotion": self.emotion, # 添加情感标签字段 + "emotion": self.emotion, "hash": self.hash, "format": self.format, - "timestamp": int(self.register_time), # 使用实例的注册时间 + "timestamp": int(self.register_time), "usage_count": self.usage_count, "last_used_time": self.last_used_time, } @@ -132,17 +161,24 @@ class MaiEmoji: # 使用upsert确保记录存在或被更新 db["emoji"].update_one({"hash": self.hash}, {"$set": emoji_record}, upsert=True) - logger.success(f"[注册] 表情包信息保存到数据库: {self.emotion}") + logger.success(f"[注册] 表情包信息保存到数据库: {self.filename} ({self.emotion})") return True except Exception as db_error: - logger.error(f"[错误] 保存数据库失败: {str(db_error)}") - # 考虑是否需要将文件移回?为了简化,暂时只记录错误 + logger.error(f"[错误] 保存数据库失败 ({self.filename}): {str(db_error)}") + # 数据库保存失败,是否需要将文件移回?为了简化,暂时只记录错误 + # 可以考虑在这里尝试删除已移动的文件,避免残留 + try: + if os.path.exists(self.full_path): # full_path 此时是目标路径 + os.remove(self.full_path) + logger.warning(f"[回滚] 已删除移动失败后残留的文件: {self.full_path}") + except Exception as remove_error: + logger.error(f"[错误] 回滚删除文件失败: {remove_error}") return False except Exception as e: - logger.error(f"[错误] 注册表情包失败: {str(e)}") + logger.error(f"[错误] 注册表情包失败 ({self.filename}): {str(e)}") logger.error(traceback.format_exc()) return False @@ -156,30 +192,36 @@ class MaiEmoji: """ try: # 1. 删除文件 - if os.path.exists(os.path.join(self.path, self.filename)): + file_to_delete = self.full_path + if os.path.exists(file_to_delete): try: - os.remove(os.path.join(self.path, self.filename)) - logger.debug(f"[删除] 文件: {os.path.join(self.path, self.filename)}") + os.remove(file_to_delete) + logger.debug(f"[删除] 文件: {file_to_delete}") except Exception as e: - logger.error(f"[错误] 删除文件失败 {os.path.join(self.path, self.filename)}: {str(e)}") - # 继续执行,即使文件删除失败也尝试删除数据库记录 + logger.error(f"[错误] 删除文件失败 {file_to_delete}: {str(e)}") + # 文件删除失败,但仍然尝试删除数据库记录 # 2. 删除数据库记录 result = db.emoji.delete_one({"hash": self.hash}) deleted_in_db = result.deleted_count > 0 if deleted_in_db: - logger.info(f"[删除] 表情包 {self.filename} 无对应文件,已删除") - + logger.info(f"[删除] 表情包数据库记录 {self.filename} (Hash: {self.hash})") # 3. 标记对象已被删除 self.is_deleted = True return True else: - logger.error(f"[错误] 删除表情包记录失败: {self.hash}") + # 如果数据库记录删除失败,但文件可能已删除,记录一个警告 + if not os.path.exists(file_to_delete): + logger.warning( + f"[警告] 表情包文件 {file_to_delete} 已删除,但数据库记录删除失败 (Hash: {self.hash})" + ) + else: + logger.error(f"[错误] 删除表情包数据库记录失败: {self.hash}") return False except Exception as e: - logger.error(f"[错误] 删除表情包失败: {str(e)}") + logger.error(f"[错误] 删除表情包失败 ({self.filename}): {str(e)}") return False @@ -209,6 +251,7 @@ class EmojiManager: def _ensure_emoji_dir(self): """确保表情存储目录存在""" os.makedirs(EMOJI_DIR, exist_ok=True) + os.makedirs(EMOJI_REGISTED_DIR, exist_ok=True) def initialize(self): """初始化数据库连接和表情目录""" @@ -265,22 +308,27 @@ class EmojiManager: Args: text_emotion: 输入的情感描述文本 Returns: - Optional[Tuple[str, str]]: (表情包文件路径, 表情包描述),如果没有找到则返回None + Optional[Tuple[str, str]]: (表情包完整文件路径, 表情包描述),如果没有找到则返回None """ try: self._ensure_db() _time_start = time.time() - # 获取所有表情包 + # 获取所有表情包 (从内存缓存中获取) all_emojis = self.emoji_objects if not all_emojis: - logger.warning("数据库中没有任何表情包") + logger.warning("内存中没有任何表情包对象") + # 可以考虑再查一次数据库?或者依赖定期任务更新 return None # 计算每个表情包与输入文本的最大情感相似度 emoji_similarities = [] for emoji in all_emojis: + # 跳过已标记为删除的对象 + if emoji.is_deleted: + continue + emotions = emoji.emotion if not emotions: continue @@ -321,9 +369,10 @@ class EmojiManager: _time_end = time.time() logger.info( # 使用匹配到的 emotion 记录日志喵~ - f"为[{text_emotion}]找到表情包: {matched_emotion},({similarity:.4f})" + f"为[{text_emotion}]找到表情包: {matched_emotion} ({selected_emoji.filename}), Similarity: {similarity:.4f}" ) - return selected_emoji.path, f"[ {selected_emoji.description} ]" + # 返回完整文件路径和描述 + return selected_emoji.full_path, f"[ {selected_emoji.description} ]" except Exception as e: logger.error(f"[错误] 获取表情包失败: {str(e)}") @@ -371,40 +420,50 @@ class EmojiManager: self.emoji_num = total_count removed_count = 0 # 使用列表复制进行遍历,因为我们会在遍历过程中修改列表 - for emoji in self.emoji_objects[:]: + objects_to_remove = [] + for emoji in self.emoji_objects: try: + # 跳过已经标记为删除的,避免重复处理 + if emoji.is_deleted: + objects_to_remove.append(emoji) # 收集起来一次性移除 + continue + # 检查文件是否存在 - if not os.path.exists(emoji.path): - logger.warning(f"[检查] 表情包文件已被删除: {emoji.path}") + if not os.path.exists(emoji.full_path): + logger.warning(f"[检查] 表情包文件丢失: {emoji.full_path}") # 执行表情包对象的删除方法 - await emoji.delete() - # 从列表中移除该对象 - self.emoji_objects.remove(emoji) + await emoji.delete() # delete 方法现在会标记 is_deleted + objects_to_remove.append(emoji) # 标记删除后,也收集起来移除 # 更新计数 self.emoji_num -= 1 removed_count += 1 continue - if emoji.description == None: - logger.warning(f"[检查] 表情包文件已被删除: {emoji.path}") - # 执行表情包对象的删除方法 + # 检查描述是否为空 (如果为空也视为无效) + if not emoji.description: + logger.warning(f"[检查] 表情包描述为空,视为无效: {emoji.filename}") await emoji.delete() - # 从列表中移除该对象 - self.emoji_objects.remove(emoji) - # 更新计数 + objects_to_remove.append(emoji) self.emoji_num -= 1 removed_count += 1 continue except Exception as item_error: - logger.error(f"[错误] 处理表情包记录时出错: {str(item_error)}") + logger.error(f"[错误] 处理表情包记录时出错 ({emoji.filename}): {str(item_error)}") + # 即使出错,也尝试继续检查下一个 continue + # 从 self.emoji_objects 中移除标记的对象 + if objects_to_remove: + self.emoji_objects = [e for e in self.emoji_objects if e not in objects_to_remove] + + # 清理 EMOJI_REGISTED_DIR 目录中未被追踪的文件 await self.clean_unused_emojis(EMOJI_REGISTED_DIR, self.emoji_objects) + # 输出清理结果 if removed_count > 0: - logger.success(f"[清理] 已清理 {removed_count} 个失效的表情包记录") - logger.info(f"[统计] 清理前: {total_count} | 清理后: {len(self.emoji_objects)}") + logger.success(f"[清理] 已清理 {removed_count} 个失效/文件丢失的表情包记录") + logger.info(f"[统计] 清理前记录数: {total_count} | 清理后有效记录数: {len(self.emoji_objects)}") else: logger.info(f"[检查] 已检查 {total_count} 个表情包记录,全部完好") @@ -467,45 +526,72 @@ class EmojiManager: await asyncio.sleep(global_config.EMOJI_CHECK_INTERVAL * 60) async def get_all_emoji_from_db(self): - """获取所有表情包并初始化为MaiEmoji类对象 - - 参数: - hash: 可选,如果提供则只返回指定哈希值的表情包 - - 返回: - list[MaiEmoji]: 表情包对象列表 - """ + """获取所有表情包并初始化为MaiEmoji类对象,更新 self.emoji_objects""" try: self._ensure_db() + logger.info("[数据库] 开始加载所有表情包记录...") - # 获取所有表情包 all_emoji_data = list(db.emoji.find()) - - # 将数据库记录转换为MaiEmoji对象 emoji_objects = [] + load_errors = 0 + for emoji_data in all_emoji_data: - emoji = MaiEmoji( - filename=emoji_data.get("filename", ""), - path=emoji_data.get("path", ""), - ) + full_path = emoji_data.get("full_path") + if not full_path: + logger.warning(f"[加载错误] 数据库记录缺少 'full_path' 字段: {emoji_data.get('_id')}") + load_errors += 1 + continue # 跳过缺少 full_path 的记录 - # 设置额外属性 - emoji.hash = emoji_data.get("hash", "") - emoji.usage_count = emoji_data.get("usage_count", 0) - emoji.last_used_time = emoji_data.get("last_used_time", emoji_data.get("timestamp", time.time())) - emoji.register_time = emoji_data.get("timestamp", time.time()) - emoji.description = emoji_data.get("description", "") - emoji.emotion = emoji_data.get("emotion", []) # 添加情感标签的加载 - emoji_objects.append(emoji) + try: + # 使用 full_path 初始化 MaiEmoji 对象 + emoji = MaiEmoji(full_path=full_path) - # 存储到EmojiManager中 + # 设置从数据库加载的属性 + emoji.hash = emoji_data.get("hash", "") + # 如果 hash 为空,也跳过?取决于业务逻辑 + if not emoji.hash: + logger.warning(f"[加载错误] 数据库记录缺少 'hash' 字段: {full_path}") + load_errors += 1 + continue + + emoji.description = emoji_data.get("description", "") + emoji.emotion = emoji_data.get("emotion", []) + emoji.usage_count = emoji_data.get("usage_count", 0) + # 优先使用 last_used_time,否则用 timestamp,最后用当前时间 + last_used = emoji_data.get("last_used_time") + timestamp = emoji_data.get("timestamp") + emoji.last_used_time = ( + last_used if last_used is not None else (timestamp if timestamp is not None else time.time()) + ) + emoji.register_time = timestamp if timestamp is not None else time.time() + emoji.format = emoji_data.get("format", "") # 加载格式 + + # 不需要再手动设置 path 和 filename,__init__ 会自动处理 + + emoji_objects.append(emoji) + + except ValueError as ve: # 捕获 __init__ 可能的错误 + logger.error(f"[加载错误] 初始化 MaiEmoji 失败 ({full_path}): {ve}") + load_errors += 1 + except Exception as e: + logger.error(f"[加载错误] 处理数据库记录时出错 ({full_path}): {str(e)}") + load_errors += 1 + + # 更新内存中的列表和数量 self.emoji_objects = emoji_objects + self.emoji_num = len(emoji_objects) + + logger.success(f"[数据库] 加载完成: 共加载 {self.emoji_num} 个表情包记录。") + if load_errors > 0: + logger.warning(f"[数据库] 加载过程中出现 {load_errors} 个错误。") except Exception as e: - logger.error(f"[错误] 获取所有表情包对象失败: {str(e)}") + logger.error(f"[错误] 从数据库加载所有表情包对象失败: {str(e)}") + self.emoji_objects = [] # 加载失败则清空列表 + self.emoji_num = 0 async def get_emoji_from_db(self, hash=None): - """获取所有表情包并初始化为MaiEmoji类对象 + """获取指定哈希值的表情包并初始化为MaiEmoji类对象列表 (主要用于调试或特定查找) 参数: hash: 可选,如果提供则只返回指定哈希值的表情包 @@ -516,50 +602,73 @@ class EmojiManager: try: self._ensure_db() - # 准备查询条件 query = {} if hash: query = {"hash": hash} - - # 获取所有表情包 - all_emoji_data = list(db.emoji.find(query)) - - # 将数据库记录转换为MaiEmoji对象 - emoji_objects = [] - for emoji_data in all_emoji_data: - emoji = MaiEmoji( - filename=emoji_data.get("filename", ""), - path=emoji_data.get("path", ""), + else: + logger.warning( + "[查询] 未提供 hash,将尝试加载所有表情包,建议使用 get_all_emoji_from_db 更新管理器状态。" ) - # 设置额外属性 - emoji.usage_count = emoji_data.get("usage_count", 0) - emoji.last_used_time = emoji_data.get("last_used_time", emoji_data.get("timestamp", time.time())) - emoji.register_time = emoji_data.get("timestamp", time.time()) - emoji.description = emoji_data.get("description", "") - emoji.emotion = emoji_data.get("emotion", []) # 添加情感标签的加载 + emoji_data_list = list(db.emoji.find(query)) + emoji_objects = [] + load_errors = 0 - emoji_objects.append(emoji) + for emoji_data in emoji_data_list: + full_path = emoji_data.get("full_path") + if not full_path: + logger.warning(f"[加载错误] 数据库记录缺少 'full_path' 字段: {emoji_data.get('_id')}") + load_errors += 1 + continue - # 存储到EmojiManager中 - self.emoji_objects = emoji_objects + try: + emoji = MaiEmoji(full_path=full_path) + emoji.hash = emoji_data.get("hash", "") + if not emoji.hash: + logger.warning(f"[加载错误] 数据库记录缺少 'hash' 字段: {full_path}") + load_errors += 1 + continue + + emoji.description = emoji_data.get("description", "") + emoji.emotion = emoji_data.get("emotion", []) + emoji.usage_count = emoji_data.get("usage_count", 0) + last_used = emoji_data.get("last_used_time") + timestamp = emoji_data.get("timestamp") + emoji.last_used_time = ( + last_used if last_used is not None else (timestamp if timestamp is not None else time.time()) + ) + emoji.register_time = timestamp if timestamp is not None else time.time() + emoji.format = emoji_data.get("format", "") + emoji_objects.append(emoji) + except ValueError as ve: + logger.error(f"[加载错误] 初始化 MaiEmoji 失败 ({full_path}): {ve}") + load_errors += 1 + except Exception as e: + logger.error(f"[加载错误] 处理数据库记录时出错 ({full_path}): {str(e)}") + load_errors += 1 + + if load_errors > 0: + logger.warning(f"[查询] 加载过程中出现 {load_errors} 个错误。") return emoji_objects except Exception as e: - logger.error(f"[错误] 获取所有表情包对象失败: {str(e)}") + logger.error(f"[错误] 从数据库获取表情包对象失败: {str(e)}") return [] - async def get_emoji_from_manager(self, hash) -> MaiEmoji: - """从EmojiManager中获取表情包 + async def get_emoji_from_manager(self, hash) -> Optional[MaiEmoji]: + """从内存中的 emoji_objects 列表获取表情包 参数: - hash:如果提供则只返回指定哈希值的表情包 + hash: 要查找的表情包哈希值 + 返回: + MaiEmoji 或 None: 如果找到则返回 MaiEmoji 对象,否则返回 None """ for emoji in self.emoji_objects: - if emoji.hash == hash: + # 确保对象未被标记为删除且哈希值匹配 + if not emoji.is_deleted and emoji.hash == hash: return emoji - return None + return None # 如果循环结束还没找到,则返回 None async def delete_emoji(self, emoji_hash: str) -> bool: """根据哈希值删除表情包 @@ -779,51 +888,111 @@ class EmojiManager: Returns: bool: 注册是否成功 """ + file_full_path = os.path.join(EMOJI_DIR, filename) + if not os.path.exists(file_full_path): + logger.error(f"[注册失败] 文件不存在: {file_full_path}") + return False + try: - # 使用MaiEmoji类创建表情包实例 - new_emoji = MaiEmoji(filename, EMOJI_DIR) - await new_emoji.initialize_hash_format() - emoji_base64 = image_path_to_base64(os.path.join(EMOJI_DIR, filename)) - description, emotions = await self.build_emoji_description(emoji_base64) - if description == "" or description == None: + # 1. 创建 MaiEmoji 实例并初始化哈希和格式 + new_emoji = MaiEmoji(full_path=file_full_path) + init_result = await new_emoji.initialize_hash_format() + if init_result is None or new_emoji.is_deleted: # 初始化失败或文件读取错误 + logger.error(f"[注册失败] 初始化哈希和格式失败: {filename}") + # 是否需要删除源文件?看业务需求,暂时不删 return False - new_emoji.description = description - new_emoji.emotion = emotions - # 检查是否已经注册过 - # 对比内存中是否存在相同哈希值的表情包 + # 2. 检查哈希是否已存在 (在内存中检查) if await self.get_emoji_from_manager(new_emoji.hash): - logger.warning(f"[警告] 表情包已存在: {filename}") + logger.warning(f"[注册跳过] 表情包已存在 (Hash: {new_emoji.hash}): {filename}") + # 删除重复的源文件 + try: + os.remove(file_full_path) + logger.info(f"[清理] 删除重复的待注册文件: {filename}") + except Exception as e: + logger.error(f"[错误] 删除重复文件失败: {str(e)}") + return False # 返回 False 表示未注册新表情 + + # 3. 构建描述和情感 + try: + emoji_base64 = image_path_to_base64(file_full_path) + if emoji_base64 is None: # 再次检查读取 + logger.error(f"[注册失败] 无法读取图片以生成描述: {filename}") + return False + description, emotions = await self.build_emoji_description(emoji_base64) + if not description: # 检查描述是否成功生成或审核通过 + logger.warning(f"[注册失败] 未能生成有效描述或审核未通过: {filename}") + # 删除未能生成描述的文件 + try: + os.remove(file_full_path) + logger.info(f"[清理] 删除描述生成失败的文件: {filename}") + except Exception as e: + logger.error(f"[错误] 删除描述生成失败文件时出错: {str(e)}") + return False + new_emoji.description = description + new_emoji.emotion = emotions + except Exception as build_desc_error: + logger.error(f"[注册失败] 生成描述/情感时出错 ({filename}): {build_desc_error}") + # 同样考虑删除文件 + try: + os.remove(file_full_path) + logger.info(f"[清理] 删除描述生成异常的文件: {filename}") + except Exception as e: + logger.error(f"[错误] 删除描述生成异常文件时出错: {str(e)}") return False + # 4. 检查容量并决定是否替换或直接注册 if self.emoji_num >= self.emoji_num_max: - logger.warning(f"表情包数量已达到上限({self.emoji_num}/{self.emoji_num_max})") + logger.warning(f"表情包数量已达到上限({self.emoji_num}/{self.emoji_num_max}),尝试替换...") replaced = await self.replace_a_emoji(new_emoji) if not replaced: - logger.error("[错误] 替换表情包失败,无法完成注册") + logger.error("[注册失败] 替换表情包失败,无法完成注册") + # 替换失败,删除新表情包文件 + try: + os.remove(file_full_path) # new_emoji 的 full_path 此时还是源路径 + logger.info(f"[清理] 删除替换失败的新表情文件: {filename}") + except Exception as e: + logger.error(f"[错误] 删除替换失败文件时出错: {str(e)}") return False + # 替换成功时,replace_a_emoji 内部已处理 new_emoji 的注册和添加到列表 return True else: - # 修复:等待异步注册完成 - register_success = await new_emoji.register_to_db() + # 直接注册 + register_success = await new_emoji.register_to_db() # 此方法会移动文件并更新 DB if register_success: + # 注册成功后,添加到内存列表 self.emoji_objects.append(new_emoji) self.emoji_num += 1 - logger.success(f"[成功] 注册: {filename}") + logger.success(f"[成功] 注册新表情包: {filename} (当前: {self.emoji_num}/{self.emoji_num_max})") return True else: - logger.error(f"[错误] 注册表情包到数据库失败: {filename}") + logger.error(f"[注册失败] 保存表情包到数据库/移动文件失败: {filename}") + # register_to_db 失败时,内部会尝试清理移动后的文件,源文件可能还在 + # 是否需要删除源文件? + if os.path.exists(file_full_path): + try: + os.remove(file_full_path) + logger.info(f"[清理] 删除注册失败的源文件: {filename}") + except Exception as e: + logger.error(f"[错误] 删除注册失败源文件时出错: {str(e)}") return False except Exception as e: - logger.error(f"[错误] 注册表情包失败: {str(e)}") + logger.error(f"[错误] 注册表情包时发生未预期错误 ({filename}): {str(e)}") logger.error(traceback.format_exc()) + # 尝试删除源文件以避免循环处理 + if os.path.exists(file_full_path): + try: + os.remove(file_full_path) + logger.info(f"[清理] 删除处理异常的源文件: {filename}") + except Exception as remove_error: + logger.error(f"[错误] 删除异常处理文件时出错: {remove_error}") return False async def clear_temp_emoji(self): - """每天清理临时表情包 + """清理临时表情包 清理/data/emoji和/data/image目录下的所有文件 - 当目录中文件数超过50时,会全部删除 + 当目录中文件数超过100时,会全部删除 """ logger.info("[清理] 开始清理缓存...") @@ -833,7 +1002,7 @@ class EmojiManager: if os.path.exists(emoji_dir): files = os.listdir(emoji_dir) # 如果文件数超过50就全部删除 - if len(files) > 50: + if len(files) > 100: for filename in files: file_path = os.path.join(emoji_dir, filename) if os.path.isfile(file_path): @@ -845,7 +1014,7 @@ class EmojiManager: if os.path.exists(image_dir): files = os.listdir(image_dir) # 如果文件数超过50就全部删除 - if len(files) > 50: + if len(files) > 100: for filename in files: file_path = os.path.join(image_dir, filename) if os.path.isfile(file_path): @@ -855,29 +1024,40 @@ class EmojiManager: logger.success("[清理] 完成") async def clean_unused_emojis(self, emoji_dir, emoji_objects): - """清理未使用的表情包文件 - 遍历指定文件夹中的所有文件,删除未在emoji_objects列表中的文件 - """ - # 首先检查目录是否存在喵~ + """清理指定目录中未被 emoji_objects 追踪的表情包文件""" if not os.path.exists(emoji_dir): - logger.warning(f"[清理] 表情包目录不存在,跳过清理: {emoji_dir}") + logger.warning(f"[清理] 目标目录不存在,跳过清理: {emoji_dir}") return - # 获取所有表情包路径 - emoji_paths = {emoji.path for emoji in emoji_objects} + try: + # 获取内存中所有有效表情包的完整路径集合 + tracked_full_paths = {emoji.full_path for emoji in emoji_objects if not emoji.is_deleted} + cleaned_count = 0 - # 遍历文件夹中的所有文件 - for file_name in os.listdir(emoji_dir): - file_path = os.path.join(emoji_dir, file_name) + # 遍历指定目录中的所有文件 + for file_name in os.listdir(emoji_dir): + file_full_path = os.path.join(emoji_dir, file_name) - # 检查文件是否在表情包路径列表中 - if file_path not in emoji_paths: - try: - # 删除未在表情包列表中的文件 - os.remove(file_path) - logger.info(f"[清理] 删除未使用的表情包文件: {file_path}") - except Exception as e: - logger.error(f"[错误] 删除文件时出错: {str(e)}") + # 确保处理的是文件而不是子目录 + if not os.path.isfile(file_full_path): + continue + + # 如果文件不在被追踪的集合中,则删除 + if file_full_path not in tracked_full_paths: + try: + os.remove(file_full_path) + logger.info(f"[清理] 删除未追踪的表情包文件: {file_full_path}") + cleaned_count += 1 + except Exception as e: + logger.error(f"[错误] 删除文件时出错 ({file_full_path}): {str(e)}") + + if cleaned_count > 0: + logger.success(f"[清理] 在目录 {emoji_dir} 中清理了 {cleaned_count} 个破损表情包。") + else: + logger.info(f"[清理] 目录 {emoji_dir} 中没有需要清理的。") + + except Exception as e: + logger.error(f"[错误] 清理未使用表情包文件时出错 ({emoji_dir}): {str(e)}") # 创建全局单例 diff --git a/src/plugins/heartFC_chat/heartFC_chat.py b/src/plugins/heartFC_chat/heartFC_chat.py index bfdf2d6ae..73d679e4e 100644 --- a/src/plugins/heartFC_chat/heartFC_chat.py +++ b/src/plugins/heartFC_chat/heartFC_chat.py @@ -2,6 +2,7 @@ import asyncio import time import traceback import random # <--- 添加导入 +import json # <--- 确保导入 json from typing import List, Optional, Dict, Any, Deque, Callable, Coroutine from collections import deque from src.plugins.chat.message import MessageRecv, BaseMessageInfo, MessageThinking, MessageSending @@ -14,9 +15,7 @@ from src.plugins.models.utils_model import LLMRequest from src.config.config import global_config from src.plugins.chat.utils_image import image_path_to_base64 # Local import needed after move from src.plugins.utils.timer_calculator import Timer # <--- Import Timer -from src.do_tool.tool_use import ToolUser from src.plugins.emoji_system.emoji_manager import emoji_manager -from src.plugins.utils.json_utils import process_llm_tool_calls, extract_tool_call_arguments from src.heart_flow.sub_mind import SubMind from src.heart_flow.observation import Observation from src.plugins.heartFC_chat.heartflow_prompt_builder import global_prompt_manager, prompt_builder @@ -37,7 +36,7 @@ EMOJI_SEND_PRO = 0.3 # 设置一个概率,比如 30% 才真的发 CONSECUTIVE_NO_REPLY_THRESHOLD = 3 # 连续不回复的阈值 -logger = get_logger("HFC") # Logger Name Changed +logger = get_logger("hfc") # Logger Name Changed # 默认动作定义 @@ -119,35 +118,6 @@ class ActionManager: """重置为默认动作集""" self._available_actions = DEFAULT_ACTIONS.copy() - def get_planner_tool_definition(self) -> List[Dict[str, Any]]: - """获取当前动作集对应的规划器工具定义""" - return [ - { - "type": "function", - "function": { - "name": "decide_reply_action", - "description": "根据当前聊天内容和上下文,决定机器人是否应该回复以及如何回复。", - "parameters": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": list(self._available_actions.keys()), - "description": "决定采取的行动:" - + ", ".join([f"'{k}'({v})" for k, v in self._available_actions.items()]), - }, - "reasoning": {"type": "string", "description": "做出此决定的简要理由。"}, - "emoji_query": { - "type": "string", - "description": "如果行动是'emoji_reply',指定表情的主题或概念。如果行动是'text_reply'且希望在文本后追加表情,也在此指定表情主题。", - }, - }, - "required": ["action", "reasoning"], - }, - }, - } - ] - # 在文件开头添加自定义异常类 class HeartFCError(Exception): @@ -222,7 +192,6 @@ class HeartFChatting: max_tokens=256, request_type="response_heartflow", ) - self.tool_user = ToolUser() self.heart_fc_sender = HeartFCSender() # LLM规划器配置 @@ -261,7 +230,7 @@ class HeartFChatting: self.log_prefix = f"[{chat_manager.get_stream_name(self.stream_id) or self.stream_id}]" self._initialized = True - logger.info(f"麦麦感觉到了,可以开始认真水群{self.log_prefix} ") + logger.debug(f"{self.log_prefix}麦麦感觉到了,可以开始认真水群 ") return True async def start(self): @@ -292,7 +261,7 @@ class HeartFChatting: pass # 忽略取消或超时错误 self._loop_task = None # 清理旧任务引用 - logger.info(f"{self.log_prefix} 启动认真水群(HFC)主循环...") + logger.debug(f"{self.log_prefix} 启动认真水群(HFC)主循环...") # 创建新的循环任务 self._loop_task = asyncio.create_task(self._hfc_loop()) # 添加完成回调 @@ -470,6 +439,16 @@ class HeartFChatting: # execute:执行 + # 在此处添加日志记录 + if action == "text_reply": + action_str = "回复" + elif action == "emoji_reply": + action_str = "回复表情" + else: + action_str = "不回复" + + logger.info(f"{self.log_prefix} 麦麦决定'{action_str}', 原因'{reasoning}'") + return await self._handle_action( action, reasoning, planner_result.get("emoji_query", ""), cycle_timers, planner_start_db_time ) @@ -784,41 +763,36 @@ class HeartFChatting: async def _planner(self, current_mind: str, cycle_timers: dict, is_re_planned: bool = False) -> Dict[str, Any]: """ 规划器 (Planner): 使用LLM根据上下文决定是否和如何回复。 + 重构为:让LLM返回结构化JSON文本,然后在代码中解析。 参数: current_mind: 子思维的当前思考结果 cycle_timers: 计时器字典 - is_re_planned: 是否为重新规划 + is_re_planned: 是否为重新规划 (此重构中暂时简化,不处理 is_re_planned 的特殊逻辑) """ - logger.info(f"{self.log_prefix}[Planner] 开始{'重新' if is_re_planned else ''}执行规划器") + logger.info(f"{self.log_prefix}开始想要做什么") - # --- 新增:检查历史动作并调整可用动作 --- - lian_xu_wen_ben_hui_fu = 0 # 连续文本回复次数 actions_to_remove_temporarily = [] - probability_roll = random.random() # 在循环外掷骰子一次,用于概率判断 - - # 反向遍历最近的循环历史 + # --- 检查历史动作并决定临时移除动作 (逻辑保持不变) --- + 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 # 遇到非文本回复,中断计数 - # 检查最近的3个循环即可,避免检查过多历史 (如果历史很长) + 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: # 80% 概率 + 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: @@ -826,183 +800,179 @@ class HeartFChatting: 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% 概率 + 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 (未触发)") - # 如果 lian_xu_wen_ben_hui_fu == 0,则不移除任何动作 - # --- 结束:检查历史动作 --- + # --- 结束检查历史动作 --- # 获取观察信息 observation = self.observations[0] - if is_re_planned: - await observation.observe() + # if is_re_planned: # 暂时简化,不处理重新规划 + # await observation.observe() observed_messages = observation.talking_message observed_messages_str = observation.talking_message_str_truncate - # --- 使用 LLM 进行决策 --- # - reasoning = "默认决策或获取决策失败" - llm_error = False # LLM错误标志 - arguments = None # 初始化参数变量 - emoji_query = "" # <--- 在这里初始化 emoji_query + # --- 使用 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(self.action_manager.get_available_actions().keys())}" + f"{self.log_prefix}[Planner] 临时移除的动作: {actions_to_remove_temporarily}, 当前可用: {list(current_available_actions.keys())}" ) - # --- 构建提示词 --- - replan_prompt_str = "" - if is_re_planned: - replan_prompt_str = await self._build_replan_prompt( - self._current_cycle.action_type, self._current_cycle.reasoning - ) + # --- 构建提示词 (调用修改后的 _build_planner_prompt) --- + # replan_prompt_str = "" # 暂时简化 + # if is_re_planned: + # replan_prompt_str = await self._build_replan_prompt( + # self._current_cycle.action_type, self._current_cycle.reasoning + # ) prompt = await self._build_planner_prompt( - observed_messages_str, current_mind, self.sub_mind.structured_info, replan_prompt_str + observed_messages_str, + current_mind, + self.sub_mind.structured_info, + "", # replan_prompt_str, + current_available_actions, # <--- 传入当前可用动作 ) - # --- 调用 LLM --- + # --- 调用 LLM (普通文本生成) --- + llm_content = None 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, - ) - logger.debug(f"{self.log_prefix}[Planner] 原始人 LLM响应: {_response_text}") + # 假设 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}") - action = "error" - reasoning = f"LLM请求失败: {req_e}" + logger.error(f"{self.log_prefix}[Planner] LLM 请求执行失败: {req_e}") + reasoning = f"LLM 请求失败: {req_e}" llm_error = True - # 直接返回错误结果 - return { - "action": action, - "reasoning": reasoning, - "emoji_query": "", - "current_mind": current_mind, - "observed_messages": observed_messages, - "llm_error": llm_error, - } + # 直接使用默认动作返回错误结果 + action = "no_reply" # 明确设置为默认值 + emoji_query = "" # 明确设置为空 + # 不再立即返回,而是继续执行 finally 块以恢复动作 + # return { ... } - # 默认错误状态 - action = "error" - reasoning = "处理工具调用时出错" - llm_error = True + # --- 解析 LLM 返回的 JSON (仅当 LLM 请求未出错时进行) --- + if not llm_error and llm_content: + try: + # 尝试去除可能的 markdown 代码块标记 + cleaned_content = ( + llm_content.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + ) + if not cleaned_content: + raise json.JSONDecodeError("Cleaned content is empty", cleaned_content, 0) + parsed_json = json.loads(cleaned_content) - # 1. 验证工具调用 - success, valid_tool_calls, error_msg = process_llm_tool_calls( - tool_calls, log_prefix=f"{self.log_prefix}[Planner] " - ) + # 提取决策,提供默认值 + extracted_action = parsed_json.get("action", "no_reply") + extracted_reasoning = parsed_json.get("reasoning", "LLM未提供理由") + extracted_emoji_query = parsed_json.get("emoji_query", "") - if success and valid_tool_calls: - # 2. 提取第一个调用并获取参数 - first_tool_call = valid_tool_calls[0] - tool_name = first_tool_call.get("function", {}).get("name") - arguments = extract_tool_call_arguments(first_tool_call, None) - - # 3. 检查名称和参数 - expected_tool_name = "decide_reply_action" - if tool_name == expected_tool_name and arguments is not None: - # 4. 成功,提取决策 - extracted_action = arguments.get("action", "no_reply") - # 验证动作 - if extracted_action not in self.action_manager.get_available_actions(): - # 如果LLM返回了一个此时不该用的动作(因为被临时移除了) - # 或者完全无效的动作 + # 验证动作是否在当前可用列表中 + # !! 使用调用 prompt 时实际可用的动作列表进行验证 + if extracted_action not in current_available_actions: logger.warning( - f"{self.log_prefix}[Planner] LLM返回了当前不可用或无效的动作: {extracted_action},将强制使用 'no_reply'" + f"{self.log_prefix}[Planner] LLM 返回了当前不可用或无效的动作: '{extracted_action}' (可用: {list(current_available_actions.keys())}),将强制使用 'no_reply'" ) action = "no_reply" - reasoning = f"LLM返回了当前不可用的动作: {extracted_action}" + reasoning = f"LLM 返回了当前不可用的动作 '{extracted_action}' (可用: {list(current_available_actions.keys())})。原始理由: {extracted_reasoning}" emoji_query = "" - llm_error = False # 视为逻辑修正而非 LLM 错误 - # --- 检查 'no_reply' 是否也恰好被移除了 (极端情况) --- - if "no_reply" not in self.action_manager.get_available_actions(): + # 检查 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 + llm_error = True # 标记为严重错误 + else: + llm_error = False # 视为逻辑修正而非 LLM 错误 else: - # 动作有效且可用,使用提取的值 + # 动作有效且可用 action = extracted_action - reasoning = arguments.get("reasoning", "未提供理由") - emoji_query = arguments.get("emoji_query", "") - llm_error = False # 成功处理 - # 记录决策结果 + reasoning = extracted_reasoning + emoji_query = extracted_emoji_query + llm_error = False # 解析成功 logger.debug( - f"{self.log_prefix}[要做什么]\nPrompt:\n{prompt}\n\n决策结果: {action}, 理由: {reasoning}, 表情查询: '{emoji_query}'" + f"{self.log_prefix}[要做什么]\nPrompt:\n{prompt}\n\n决策结果 (来自JSON): {action}, 理由: {reasoning}, 表情查询: '{emoji_query}'" ) - elif tool_name != expected_tool_name: - reasoning = f"LLM返回了非预期的工具: {tool_name}" - logger.warning(f"{self.log_prefix}[Planner] {reasoning}") - else: # arguments is None - reasoning = f"无法提取工具 {tool_name} 的参数" - logger.warning(f"{self.log_prefix}[Planner] {reasoning}") - elif not success: - reasoning = f"验证工具调用失败: {error_msg}" - logger.warning(f"{self.log_prefix}[Planner] {reasoning}") - else: # not valid_tool_calls - # 如果没有有效的工具调用,我们需要检查 '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: - logger.error(f"{self.log_prefix}[Planner] Planner LLM处理过程中发生意外错误: {llm_e}") + 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" - reasoning = f"Planner内部处理错误: {llm_e}" + action = "error" # 发生未知错误,标记为 error 动作 + reasoning = f"Planner 内部处理错误: {outer_e}" + emoji_query = "" llm_error = True - # --- 新增:确保动作恢复 --- finally: - if actions_to_remove_temporarily: # 只有当确实移除了动作时才需要恢复 + # --- 确保动作恢复 --- + # 检查 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}'") - # 掷骰子看看要不要听它的 + logger.debug(f"{self.log_prefix}[Planner] 大模型建议文字回复带表情: '{emoji_query}'") if random.random() > EMOJI_SEND_PRO: logger.info( - f"{self.log_prefix}[Planner] 但是麦麦这次不想加表情 ({1 - EMOJI_SEND_PRO:.0%}),忽略表情 '{emoji_query}'" + f"{self.log_prefix}但是麦麦这次不想加表情 ({1 - EMOJI_SEND_PRO:.0%}),忽略表情 '{emoji_query}'" ) - emoji_query = "" # 把表情请求清空,就不发了 + emoji_query = "" # 清空表情请求 else: - logger.info(f"{self.log_prefix}[Planner] 好吧,加上表情 '{emoji_query}'") - # --- 结束:概率性忽略 --- - - # --- 结束 LLM 决策 --- # + 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, + "llm_error": llm_error, # 返回错误状态 } async def _get_anchor_message(self) -> Optional[MessageRecv]: @@ -1031,9 +1001,7 @@ class HeartFChatting: } anchor_message = MessageRecv(placeholder_msg_dict) anchor_message.update_chat_stream(self.chat_stream) - logger.info( - f"{self.log_prefix} Created placeholder anchor message: ID={anchor_message.message_info.message_id}" - ) + logger.debug(f"{self.log_prefix} 创建占位符锚点消息: ID={anchor_message.message_info.message_id}") return anchor_message except Exception as e: @@ -1146,8 +1114,9 @@ class HeartFChatting: current_mind: Optional[str], structured_info: Dict[str, Any], replan_prompt: str, + current_available_actions: Dict[str, str], ) -> str: - """构建 Planner LLM 的提示词""" + """构建 Planner LLM 的提示词 (获取模板并填充数据)""" try: # 准备结构化信息块 structured_info_block = "" @@ -1163,12 +1132,13 @@ class HeartFChatting: else: chat_content_block = "当前没有观察到新的聊天内容。\n" - # 准备当前思维块 + # 准备当前思维块 (修改以匹配模板) current_mind_block = "" if current_mind: - current_mind_block = f"{current_mind}" + # 模板中占位符是 {current_mind_block},它期望包含"你的内心想法:"的前缀 + current_mind_block = f"你的内心想法:\n{current_mind}" else: - current_mind_block = "[没有特别的想法]" + current_mind_block = "你的内心想法:\n[没有特别的想法]" # 准备循环信息块 (分析最近的活动循环) recent_active_cycles = [] @@ -1208,23 +1178,40 @@ class HeartFChatting: # 包装提示块,增加可读性,即使没有连续回复也给个标记 if cycle_info_block: + # 模板中占位符是 {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} prompt_personality = individuality.get_prompt(x_person=2, level=2) - # 获取提示词模板并填充数据 - prompt = (await global_prompt_manager.get_prompt_async("planner_prompt")).format( + # --- 构建可用动作描述 (用于填充模板中的 {action_options_text}) --- + 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}) --- + 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, prompt_personality=prompt_personality, structured_info_block=structured_info_block, chat_content_block=chat_content_block, current_mind_block=current_mind_block, - replan=replan_prompt, + replan="", # 暂时留空 replan 信息 cycle_info_block=cycle_info_block, + action_options_text=action_options_text, # 传入可用动作描述 + example_action=example_action_key, # 传入示例动作键 ) return prompt @@ -1232,7 +1219,7 @@ class HeartFChatting: except Exception as e: logger.error(f"{self.log_prefix}[Planner] 构建提示词时出错: {e}") logger.error(traceback.format_exc()) - return "" + return "[构建 Planner Prompt 时出错]" # 返回错误提示,避免空字符串 # --- 回复器 (Replier) 的定义 --- # async def _replier_work( @@ -1273,7 +1260,7 @@ class HeartFChatting: 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") + # 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 diff --git a/src/plugins/heartFC_chat/heartflow_prompt_builder.py b/src/plugins/heartFC_chat/heartflow_prompt_builder.py index 69bae0411..40819f013 100644 --- a/src/plugins/heartFC_chat/heartflow_prompt_builder.py +++ b/src/plugins/heartFC_chat/heartflow_prompt_builder.py @@ -47,17 +47,15 @@ def init_prompt(): "info_from_tools", ) - # Planner提示词 - 优化版 + # Planner提示词 - 修改为要求 JSON 输出 Prompt( """你的名字是{bot_name},{prompt_personality},你现在正在一个群聊中。需要基于以下信息决定如何参与对话: {structured_info_block} {chat_content_block} -你的内心想法: {current_mind_block} -{replan} {cycle_info_block} -请综合分析聊天内容和你看到的新消息,参考内心想法,使用'decide_reply_action'工具做出决策。决策时请注意: +请综合分析聊天内容和你看到的新消息,参考内心想法,并根据以下原则和可用动作做出决策。 【回复原则】 1. 不回复(no_reply)适用: @@ -81,14 +79,34 @@ def init_prompt(): - 避免重复或评价自己的发言 - 不要和自己聊天 -【必须遵守】 -- 遵守回复原则 -- 必须调用工具并包含action和reasoning -- 你可以选择文字回复(text_reply),纯表情回复(emoji_reply),不回复(no_reply) -- 并不是所有选择都可用 -- 选择text_reply或emoji_reply时必须提供emoji_query -- 保持回复自然,符合日常聊天习惯""", - "planner_prompt", +【决策任务】 +{action_options_text} + +你必须从上面列出的可用行动中选择一个,并说明原因。 +你的决策必须以严格的 JSON 格式输出,且仅包含 JSON 内容,不要有任何其他文字或解释。 +JSON 结构如下,包含三个字段 "action", "reasoning", "emoji_query": +{{ + "action": "string", // 必须是上面提供的可用行动之一 (例如: '{example_action}') + "reasoning": "string", // 做出此决定的详细理由和思考过程,说明你如何应用了回复原则 + "emoji_query": "string" // 可选。如果行动是 'emoji_reply',必须提供表情主题(填写表情包的适用场合);如果行动是 'text_reply' 且你想附带表情,也在此提供表情主题,否则留空字符串 ""。遵循回复原则,不要滥用。 +}} + +例如: +{{ + "action": "text_reply", + "reasoning": "用户提到了我,且问题比较具体,适合用文本回复。考虑到内容,可以带上一个微笑表情。", + "emoji_query": "微笑" +}} +或 +{{ + "action": "no_reply", + "reasoning": "我已经连续回复了两次,而且这个话题我不太感兴趣,根据回复原则,选择不回复,等待其他人发言。", + "emoji_query": "" +}} + +请输出你的决策 JSON: +""", # 使用三引号避免内部引号问题 + "planner_prompt", # 保持名称不变,替换内容 ) Prompt( @@ -157,10 +175,13 @@ class PromptBuilder: current_mind_info, structured_info, chat_stream, + sender_name, ) return None - async def _build_prompt_focus(self, reason, current_mind_info, structured_info, chat_stream) -> tuple[str, str]: + async def _build_prompt_focus( + self, reason, current_mind_info, structured_info, chat_stream, sender_name + ) -> tuple[str, str]: individuality = Individuality.get_instance() prompt_personality = individuality.get_prompt(x_person=0, level=2) # 日程构建 @@ -240,6 +261,7 @@ class PromptBuilder: reason=reason, prompt_ger=prompt_ger, moderation_prompt=await global_prompt_manager.get_prompt_async("moderation_prompt"), + sender_name=sender_name, ) logger.debug(f"focus_chat_prompt: \n{prompt}") diff --git a/src/plugins/heartFC_chat/normal_chat.py b/src/plugins/heartFC_chat/normal_chat.py index c159a329e..9ed63c2df 100644 --- a/src/plugins/heartFC_chat/normal_chat.py +++ b/src/plugins/heartFC_chat/normal_chat.py @@ -358,7 +358,9 @@ class NormalChat: 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,防止竞争 --- @@ -443,7 +445,7 @@ class NormalChat: logger.error(f"[{self.stream_name}] 任务异常: {exc}") logger.error(traceback.format_exc()) except asyncio.CancelledError: - logger.info(f"[{self.stream_name}] 任务已取消") + logger.debug(f"[{self.stream_name}] 任务已取消") except Exception as e: logger.error(f"[{self.stream_name}] 回调处理错误: {e}") finally: @@ -456,12 +458,12 @@ class NormalChat: """停止当前实例的兴趣监控任务。""" if self._chat_task and not self._chat_task.done(): task = self._chat_task - logger.info(f"[{self.stream_name}] 尝试取消聊天任务。") + logger.debug(f"[{self.stream_name}] 尝试取消normal聊天任务。") task.cancel() try: await task # 等待任务响应取消 except asyncio.CancelledError: - logger.info(f"[{self.stream_name}] 聊天任务已成功取消。") + logger.info(f"[{self.stream_name}] 结束一般聊天模式。") except Exception as e: # 回调函数 _handle_task_completion 会处理异常日志 logger.warning(f"[{self.stream_name}] 等待监控任务取消时捕获到异常 (可能已在回调中记录): {e}") diff --git a/src/plugins/heartFC_chat/normal_chat_generator.py b/src/plugins/heartFC_chat/normal_chat_generator.py index 02baf94d4..ea698bf2c 100644 --- a/src/plugins/heartFC_chat/normal_chat_generator.py +++ b/src/plugins/heartFC_chat/normal_chat_generator.py @@ -82,12 +82,14 @@ class NormalChatGenerator: sender_name=sender_name, chat_stream=message.chat_stream, ) - logger.info(f"构建prompt时间: {t_build_prompt.human_readable}") + logger.debug(f"构建prompt时间: {t_build_prompt.human_readable}") try: content, reasoning_content, self.current_model_name = await model.generate_response(prompt) - logger.info(f"prompt:{prompt}\n生成回复:{content}") + 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 diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 23368ccaf..c924d35ab 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -104,8 +104,8 @@ mentioned_bot_inevitable_reply = false # 提及 bot 必然回复 at_bot_inevitable_reply = false # @bot 必然回复 [focus_chat] #专注聊天 -reply_trigger_threshold = 3.5 # 专注聊天触发阈值,越低越容易进入专注聊天 -default_decay_rate_per_second = 0.98 # 默认衰减率,越大衰减越快,越高越难进入专注聊天 +reply_trigger_threshold = 3.6 # 专注聊天触发阈值,越低越容易进入专注聊天 +default_decay_rate_per_second = 0.95 # 默认衰减率,越大衰减越快,越高越难进入专注聊天 consecutive_no_reply_threshold = 3 # 连续不回复的阈值,越低越容易结束专注聊天 # 以下选项暂时无效