diff --git a/.gitignore b/.gitignore index 5744424a..9e1b9681 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ mongodb/ NapCat.Framework.Windows.Once/ log/ logs/ +out/ tool_call_benchmark.py run_maibot_core.bat run_napcat_adapter.bat diff --git a/bot.py b/bot.py index 4197556e..ed4c2d19 100644 --- a/bot.py +++ b/bot.py @@ -19,6 +19,11 @@ import atexit install(extra_lines=3) +# 设置工作目录为脚本所在目录 +script_dir = os.path.dirname(os.path.abspath(__file__)) +os.chdir(script_dir) +print(f"已设置工作目录为: {script_dir}") + logger = get_logger("main") confirm_logger = get_logger("confirm") @@ -241,6 +246,7 @@ def raw_main(): if __name__ == "__main__": + exit_code = 0 # 用于记录程序最终的退出状态 try: # 获取MainSystem实例 main_system = raw_main() @@ -256,13 +262,29 @@ if __name__ == "__main__": except KeyboardInterrupt: # loop.run_until_complete(global_api.stop()) logger.warning("收到中断信号,正在优雅关闭...") - loop.run_until_complete(graceful_shutdown()) - finally: - loop.close() + if loop and not loop.is_closed(): + try: + loop.run_until_complete(graceful_shutdown()) + except Exception as ge: # 捕捉优雅关闭时可能发生的错误 + logger.error(f"优雅关闭时发生错误: {ge}") + # except Exception as e: # 将主异常捕获移到外层 try...except + # logger.error(f"事件循环内发生错误: {str(e)} {str(traceback.format_exc())}") + # exit_code = 1 + # finally: # finally 块移到最外层,确保 loop 关闭和暂停总是执行 + # if loop and not loop.is_closed(): + # loop.close() + # # 在这里添加 input() 来暂停 + # input("按 Enter 键退出...") # <--- 添加这行 + # sys.exit(exit_code) # <--- 使用记录的退出码 except Exception as e: - logger.error(f"主程序异常: {str(e)} {str(traceback.format_exc())}") - if loop and not loop.is_closed(): - loop.run_until_complete(graceful_shutdown()) + logger.error(f"主程序发生异常: {str(e)} {str(traceback.format_exc())}") + exit_code = 1 # 标记发生错误 + finally: + # 确保 loop 在任何情况下都尝试关闭(如果存在且未关闭) + if "loop" in locals() and loop and not loop.is_closed(): loop.close() - sys.exit(1) + logger.info("事件循环已关闭") + # 在程序退出前暂停,让你有机会看到输出 + input("按 Enter 键退出...") # <--- 添加这行 + sys.exit(exit_code) # <--- 使用记录的退出码 diff --git a/requirements.txt b/requirements.txt index 91ae096c..8779b40e 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/scripts/import_openie.py b/scripts/import_openie.py index 2a6e09b7..472667c1 100644 --- a/scripts/import_openie.py +++ b/scripts/import_openie.py @@ -85,6 +85,7 @@ def handle_import_openie(openie_data: OpenIE, embed_manager: EmbeddingManager, k logger.error("系统将于2秒后开始检查数据完整性") sleep(2) found_missing = False + missing_idxs = [] for doc in getattr(openie_data, "docs", []): idx = doc.get("idx", "<无idx>") passage = doc.get("passage", "<无passage>") @@ -104,14 +105,38 @@ def handle_import_openie(openie_data: OpenIE, embed_manager: EmbeddingManager, k # print(f"检查: idx={idx}") if missing: found_missing = True + missing_idxs.append(idx) logger.error("\n") logger.error("数据缺失:") logger.error(f"对应哈希值:{idx}") logger.error(f"对应文段内容内容:{passage}") logger.error(f"非法原因:{', '.join(missing)}") + # 确保提示在所有非法数据输出后再输出 if not found_missing: - print("所有数据均完整,没有发现缺失字段。") - return False + logger.info("所有数据均完整,没有发现缺失字段。") + return False + # 新增:提示用户是否删除非法文段继续导入 + # 将print移到所有logger.error之后,确保不会被冲掉 + logger.info("\n检测到非法文段,共{}条。".format(len(missing_idxs))) + logger.info("\n是否删除所有非法文段后继续导入?(y/n): ", end="") + user_choice = input().strip().lower() + if user_choice != "y": + logger.info("用户选择不删除非法文段,程序终止。") + sys.exit(1) + # 删除非法文段 + logger.info("正在删除非法文段并继续导入...") + # 过滤掉非法文段 + openie_data.docs = [ + doc for doc in getattr(openie_data, "docs", []) if doc.get("idx", "<无idx>") not in missing_idxs + ] + # 重新提取数据 + raw_paragraphs = openie_data.extract_raw_paragraph_dict() + entity_list_data = openie_data.extract_entity_dict() + triple_list_data = openie_data.extract_triple_dict() + # 再次校验 + if len(raw_paragraphs) != len(entity_list_data) or len(raw_paragraphs) != len(triple_list_data): + logger.error("删除非法文段后,数据仍不一致,程序终止。") + sys.exit(1) # 将索引换为对应段落的hash值 logger.info("正在进行段落去重与重索引") raw_paragraphs, triple_list_data = hash_deduplicate( @@ -174,7 +199,13 @@ def main(): embed_manager.load_from_file() except Exception as e: logger.error("从文件加载Embedding库时发生错误:{}".format(e)) - logger.error("如果你是第一次导入知识,请忽略此错误") + if "嵌入模型与本地存储不一致" in str(e): + logger.error("检测到嵌入模型与本地存储不一致,已终止导入。请检查模型设置或清空嵌入库后重试。") + logger.error("请保证你的嵌入模型从未更改,并且在导入时使用相同的模型") + # print("检测到嵌入模型与本地存储不一致,已终止导入。请检查模型设置或清空嵌入库后重试。") + sys.exit(1) + if "不存在" in str(e): + logger.error("如果你是第一次导入知识,请忽略此错误") logger.info("Embedding库加载完成") # 初始化KG kg_manager = KGManager() diff --git a/scripts/interest_monitor_gui.py b/scripts/interest_monitor_gui.py deleted file mode 100644 index 0c44507c..00000000 --- a/scripts/interest_monitor_gui.py +++ /dev/null @@ -1,670 +0,0 @@ -import tkinter as tk -from tkinter import ttk -import time -import os -from datetime import datetime, timedelta -import random -from collections import deque -import json # 引入 json - -# --- 引入 Matplotlib --- -from matplotlib.figure import Figure -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg -import matplotlib.dates as mdates # 用于处理日期格式 -import matplotlib # 导入 matplotlib - -# --- 配置 --- -LOG_FILE_PATH = os.path.join("logs", "interest", "interest_history.log") # 指向历史日志文件 -REFRESH_INTERVAL_MS = 200 # 刷新间隔 (毫秒) - 可以适当调长,因为读取文件可能耗时 -WINDOW_TITLE = "Interest Monitor (Live History)" -MAX_HISTORY_POINTS = 1000 # 图表上显示的最大历史点数 (可以增加) -MAX_STREAMS_TO_DISPLAY = 15 # 最多显示多少个聊天流的折线图 (可以增加) -MAX_QUEUE_SIZE = 30 # 新增:历史想法队列最大长度 - -# *** 添加 Matplotlib 中文字体配置 *** -# 尝试使用 'SimHei' 或 'Microsoft YaHei',如果找不到,matplotlib 会回退到默认字体 -# 确保你的系统上安装了这些字体 -matplotlib.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"] -matplotlib.rcParams["axes.unicode_minus"] = False # 解决负号'-'显示为方块的问题 - - -def get_random_color(): - """生成随机颜色用于区分线条""" - return "#{:06x}".format(random.randint(0, 0xFFFFFF)) - - -def format_timestamp(ts): - """辅助函数:格式化时间戳,处理 None 或无效值""" - if ts is None: - return "N/A" - try: - # 假设 ts 是 float 类型的时间戳 - dt_object = datetime.fromtimestamp(float(ts)) - return dt_object.strftime("%Y-%m-%d %H:%M:%S") - except (ValueError, TypeError): - return "Invalid Time" - - -class InterestMonitorApp: - def __init__(self, root): - self._main_mind_loaded = None - self.root = root - self.root.title(WINDOW_TITLE) - self.root.geometry("1800x800") # 调整窗口大小以适应图表 - - # --- 数据存储 --- - # 使用 deque 来存储有限的历史数据点 - # key: stream_id, value: deque([(timestamp, interest_level), ...]) - self.stream_history = {} - # key: stream_id, value: deque([(timestamp, reply_probability), ...]) - self.probability_history = {} - self.stream_colors = {} # 为每个 stream 分配颜色 - self.stream_display_names = {} # 存储显示名称 (group_name) - self.selected_stream_id = tk.StringVar() # 用于 Combobox 绑定 - - # --- 新增:存储其他参数 --- - # 顶层信息 - self.latest_main_mind = tk.StringVar(value="N/A") - self.latest_mai_state = tk.StringVar(value="N/A") - self.latest_subflow_count = tk.IntVar(value=0) - # 子流最新状态 (key: stream_id) - self.stream_sub_minds = {} - self.stream_chat_states = {} - self.stream_threshold_status = {} - self.stream_last_active = {} - self.stream_last_interaction = {} - # 用于显示单个流详情的 StringVar - self.single_stream_sub_mind = tk.StringVar(value="想法: N/A") - self.single_stream_chat_state = tk.StringVar(value="状态: N/A") - self.single_stream_threshold = tk.StringVar(value="阈值: N/A") - self.single_stream_last_active = tk.StringVar(value="活跃: N/A") - self.single_stream_last_interaction = tk.StringVar(value="交互: N/A") - - # 新增:历史想法队列 - self.main_mind_history = deque(maxlen=MAX_QUEUE_SIZE) - self.last_main_mind_timestamp = 0 # 记录最后一条main_mind的时间戳 - - # --- UI 元素 --- - - # --- 新增:顶部全局信息框架 --- - self.global_info_frame = ttk.Frame(root, padding="5 0 5 5") # 顶部内边距调整 - self.global_info_frame.pack(side=tk.TOP, fill=tk.X, pady=(5, 0)) # 底部外边距为0 - - ttk.Label(self.global_info_frame, text="全局状态:").pack(side=tk.LEFT, padx=(0, 10)) - ttk.Label(self.global_info_frame, textvariable=self.latest_mai_state).pack(side=tk.LEFT, padx=5) - ttk.Label(self.global_info_frame, text="想法:").pack(side=tk.LEFT, padx=(10, 0)) - ttk.Label(self.global_info_frame, textvariable=self.latest_main_mind).pack(side=tk.LEFT, padx=5) - ttk.Label(self.global_info_frame, text="子流数:").pack(side=tk.LEFT, padx=(10, 0)) - ttk.Label(self.global_info_frame, textvariable=self.latest_subflow_count).pack(side=tk.LEFT, padx=5) - - # 创建 Notebook (选项卡控件) - self.notebook = ttk.Notebook(root) - # 修改:fill 和 expand,让 notebook 填充剩余空间 - self.notebook.pack(pady=(5, 0), padx=10, fill=tk.BOTH, expand=1) # 顶部外边距改小 - - # --- 第一个选项卡:所有流 --- - self.frame_all = ttk.Frame(self.notebook, padding="5 5 5 5") - self.notebook.add(self.frame_all, text="所有聊天流") - - # 状态标签 (移动到最底部) - self.status_label = tk.Label(root, text="Initializing...", anchor="w", fg="grey") - self.status_label.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=(0, 5)) # 调整边距 - - # Matplotlib 图表设置 (用于第一个选项卡) - self.fig = Figure(figsize=(5, 4), dpi=100) - self.ax = self.fig.add_subplot(111) - # 配置在 update_plot 中进行,避免重复 - - # 创建 Tkinter 画布嵌入 Matplotlib 图表 (用于第一个选项卡) - self.canvas = FigureCanvasTkAgg(self.fig, master=self.frame_all) # <--- 放入 frame_all - self.canvas_widget = self.canvas.get_tk_widget() - self.canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=1) - - # --- 第二个选项卡:单个流 --- - self.frame_single = ttk.Frame(self.notebook, padding="5 5 5 5") - self.notebook.add(self.frame_single, text="单个聊天流详情") - - # 单个流选项卡的上部控制区域 - self.control_frame_single = ttk.Frame(self.frame_single) - self.control_frame_single.pack(side=tk.TOP, fill=tk.X, pady=5) - - ttk.Label(self.control_frame_single, text="选择聊天流:").pack(side=tk.LEFT, padx=(0, 5)) - self.stream_selector = ttk.Combobox( - self.control_frame_single, textvariable=self.selected_stream_id, state="readonly", width=50 - ) - self.stream_selector.pack(side=tk.LEFT, fill=tk.X, expand=True) - self.stream_selector.bind("<>", self.on_stream_selected) - - # --- 新增:单个流详情显示区域 --- - self.single_stream_details_frame = ttk.Frame(self.frame_single, padding="5 5 5 0") - self.single_stream_details_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) - - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_sub_mind).pack(side=tk.LEFT, padx=5) - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_chat_state).pack( - side=tk.LEFT, padx=5 - ) - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_threshold).pack( - side=tk.LEFT, padx=5 - ) - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_last_active).pack( - side=tk.LEFT, padx=5 - ) - ttk.Label(self.single_stream_details_frame, textvariable=self.single_stream_last_interaction).pack( - side=tk.LEFT, padx=5 - ) - - # Matplotlib 图表设置 (用于第二个选项卡) - self.fig_single = Figure(figsize=(5, 4), dpi=100) - # 修改:创建两个子图,一个显示兴趣度,一个显示概率 - self.ax_single_interest = self.fig_single.add_subplot(211) # 2行1列的第1个 - self.ax_single_probability = self.fig_single.add_subplot( - 212, sharex=self.ax_single_interest - ) # 2行1列的第2个,共享X轴 - - # 创建 Tkinter 画布嵌入 Matplotlib 图表 (用于第二个选项卡) - self.canvas_single = FigureCanvasTkAgg(self.fig_single, master=self.frame_single) # <--- 放入 frame_single - self.canvas_widget_single = self.canvas_single.get_tk_widget() - self.canvas_widget_single.pack(side=tk.TOP, fill=tk.BOTH, expand=1) - - # --- 新增第三个选项卡:麦麦历史想法 --- - self.frame_mind_history = ttk.Frame(self.notebook, padding="5 5 5 5") - self.notebook.add(self.frame_mind_history, text="麦麦历史想法") - - # 聊天框样式的文本框(只读)+ 滚动条 - self.mind_text_scroll = tk.Scrollbar(self.frame_mind_history) - self.mind_text_scroll.pack(side=tk.RIGHT, fill=tk.Y) - self.mind_text = tk.Text( - self.frame_mind_history, - height=25, - state="disabled", - wrap="word", - font=("微软雅黑", 12), - yscrollcommand=self.mind_text_scroll.set, - ) - self.mind_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=1, padx=5, pady=5) - self.mind_text_scroll.config(command=self.mind_text.yview) - - # --- 初始化和启动刷新 --- - self.update_display() # 首次加载并开始刷新循环 - - def on_stream_selected(self, event=None): - """当 Combobox 选择改变时调用,更新单个流的图表""" - self.update_single_stream_plot() - - def load_main_mind_history(self): - """只读取包含main_mind的日志行,维护历史想法队列""" - if not os.path.exists(LOG_FILE_PATH): - return - - main_mind_entries = [] - try: - with open(LOG_FILE_PATH, "r", encoding="utf-8") as f: - for line in f: - try: - log_entry = json.loads(line.strip()) - if "main_mind" in log_entry: - ts = log_entry.get("timestamp", 0) - main_mind_entries.append((ts, log_entry)) - except Exception: - continue - main_mind_entries.sort(key=lambda x: x[0]) - recent_entries = main_mind_entries[-MAX_QUEUE_SIZE:] - self.main_mind_history.clear() - for _ts, entry in recent_entries: - self.main_mind_history.append(entry) - if recent_entries: - self.last_main_mind_timestamp = recent_entries[-1][0] - # 首次加载时刷新 - self.refresh_mind_text() - except Exception: - pass - - def update_main_mind_history(self): - """实时监控log文件,发现新main_mind数据则更新队列和展示(仅有新数据时刷新)""" - if not os.path.exists(LOG_FILE_PATH): - return - - new_entries = [] - try: - with open(LOG_FILE_PATH, "r", encoding="utf-8") as f: - for line in reversed(list(f)): - try: - log_entry = json.loads(line.strip()) - if "main_mind" in log_entry: - ts = log_entry.get("timestamp", 0) - if ts > self.last_main_mind_timestamp: - new_entries.append((ts, log_entry)) - else: - break - except Exception: - continue - if new_entries: - for ts, entry in sorted(new_entries): - if len(self.main_mind_history) >= MAX_QUEUE_SIZE: - self.main_mind_history.popleft() - self.main_mind_history.append(entry) - self.last_main_mind_timestamp = ts - self.refresh_mind_text() # 只有有新数据时才刷新 - except Exception: - pass - - def refresh_mind_text(self): - """刷新聊天框样式的历史想法展示""" - self.mind_text.config(state="normal") - self.mind_text.delete(1.0, tk.END) - for entry in self.main_mind_history: - ts = entry.get("timestamp", 0) - dt_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else "" - main_mind = entry.get("main_mind", "") - mai_state = entry.get("mai_state", "") - subflow_count = entry.get("subflow_count", "") - msg = f"[{dt_str}] 状态:{mai_state} 子流:{subflow_count}\n{main_mind}\n\n" - self.mind_text.insert(tk.END, msg) - self.mind_text.see(tk.END) - self.mind_text.config(state="disabled") - - def load_and_update_history(self): - """从 history log 文件加载数据并更新历史记录""" - if not os.path.exists(LOG_FILE_PATH): - self.set_status(f"Error: Log file not found at {LOG_FILE_PATH}", "red") - # 如果文件不存在,不清空现有数据,以便显示最后一次成功读取的状态 - return - - # *** Reset display names each time we reload *** - new_stream_history = {} - new_stream_display_names = {} - new_probability_history = {} # <--- 重置概率历史 - # --- 新增:重置其他子流状态 --- (如果需要的话,但通常覆盖即可) - # self.stream_sub_minds = {} - # self.stream_chat_states = {} - # ... 等等 ... - - read_count = 0 - error_count = 0 - # *** Calculate the timestamp threshold for the last 30 minutes *** - current_time = time.time() - time_threshold = current_time - (15 * 60) # 30 minutes in seconds - - try: - with open(LOG_FILE_PATH, "r", encoding="utf-8") as f: - for line in f: - read_count += 1 - try: - log_entry = json.loads(line.strip()) - timestamp = log_entry.get("timestamp") # 获取顶层时间戳 - - # *** 时间过滤 *** - if timestamp is None: - error_count += 1 - continue # 跳过没有时间戳的行 - try: - entry_timestamp = float(timestamp) - if entry_timestamp < time_threshold: - continue # 跳过时间过早的条目 - except (ValueError, TypeError): - error_count += 1 - continue # 跳过时间戳格式错误的行 - - # --- 新增:更新顶层信息 (使用最后一个有效行的数据) --- - self.latest_main_mind.set( - log_entry.get("main_mind", self.latest_main_mind.get()) - ) # 保留旧值如果缺失 - self.latest_mai_state.set(log_entry.get("mai_state", self.latest_mai_state.get())) - self.latest_subflow_count.set(log_entry.get("subflow_count", self.latest_subflow_count.get())) - - # --- 修改开始:迭代 subflows --- - subflows = log_entry.get("subflows") - if not isinstance(subflows, list): # 检查 subflows 是否存在且为列表 - error_count += 1 - continue # 跳过没有 subflows 或格式无效的行 - - for subflow_entry in subflows: - stream_id = subflow_entry.get("stream_id") - interest_level = subflow_entry.get("interest_level") - # 获取 group_name,如果不存在则回退到 stream_id - group_name = subflow_entry.get("group_name", stream_id) - # reply_probability = subflow_entry.get("reply_probability") # 获取概率值 # <-- 注释掉旧行 - start_hfc_probability = subflow_entry.get( - "start_hfc_probability" - ) # <-- 添加新行,读取新字段 - - # *** 检查必要的字段 *** - # 注意:时间戳已在顶层检查过 - if stream_id is None or interest_level is None: - # 这里可以选择记录子流错误,但暂时跳过 - continue # 跳过无效的 subflow 条目 - - # 确保 interest_level 可以转换为浮点数 - try: - interest_level_float = float(interest_level) - except (ValueError, TypeError): - continue # 跳过 interest_level 无效的 subflow - - # 如果是第一次读到这个 stream_id,则创建 deque - if stream_id not in new_stream_history: - new_stream_history[stream_id] = deque(maxlen=MAX_HISTORY_POINTS) - new_probability_history[stream_id] = deque(maxlen=MAX_HISTORY_POINTS) # 创建概率 deque - # 检查是否已有颜色,没有则分配 - if stream_id not in self.stream_colors: - self.stream_colors[stream_id] = get_random_color() - - # *** 存储此 stream_id 最新的显示名称 *** - new_stream_display_names[stream_id] = group_name - - # --- 新增:存储其他子流信息 --- - self.stream_sub_minds[stream_id] = subflow_entry.get("sub_mind", "N/A") - self.stream_chat_states[stream_id] = subflow_entry.get("sub_chat_state", "N/A") - self.stream_threshold_status[stream_id] = subflow_entry.get("is_above_threshold", False) - self.stream_last_active[stream_id] = subflow_entry.get( - "chat_state_changed_time" - ) # 存储原始时间戳 - - # 添加数据点 (使用顶层时间戳) - new_stream_history[stream_id].append((entry_timestamp, interest_level_float)) - - # 添加概率数据点 (如果存在且有效) - # if reply_probability is not None: # <-- 注释掉旧判断 - if start_hfc_probability is not None: # <-- 修改判断条件 - try: - # 尝试将概率转换为浮点数 - # probability_float = float(reply_probability) # <-- 注释掉旧转换 - probability_float = float(start_hfc_probability) # <-- 使用新变量 - new_probability_history[stream_id].append((entry_timestamp, probability_float)) - except (TypeError, ValueError): - # 如果概率值无效,可以跳过或记录一个默认值,这里跳过 - pass - # --- 修改结束 --- - - except json.JSONDecodeError: - error_count += 1 - # logger.warning(f"Skipping invalid JSON line: {line.strip()}") - continue # 跳过无法解析的行 - # except (TypeError, ValueError) as e: # 这个外层 catch 可能不再需要,因为类型错误在内部处理了 - # error_count += 1 - # # logger.warning(f"Skipping line due to data type error ({e}): {line.strip()}") - # continue # 跳过数据类型错误的行 - - # 读取完成后,用新数据替换旧数据 - self.stream_history = new_stream_history - self.stream_display_names = new_stream_display_names # *** Update display names *** - self.probability_history = new_probability_history # <--- 更新概率历史 - # 清理不再存在的 stream_id 的附加信息 (可选,但保持一致性) - streams_to_remove = set(self.stream_sub_minds.keys()) - set(new_stream_history.keys()) - for sid in streams_to_remove: - self.stream_sub_minds.pop(sid, None) - self.stream_chat_states.pop(sid, None) - self.stream_threshold_status.pop(sid, None) - self.stream_last_active.pop(sid, None) - self.stream_last_interaction.pop(sid, None) - # 颜色和显示名称也应该清理,但当前逻辑是保留旧颜色 - # self.stream_colors.pop(sid, None) - status_msg = f"Data loaded at {datetime.now().strftime('%H:%M:%S')}. Lines read: {read_count}." - if error_count > 0: - status_msg += f" Skipped {error_count} invalid lines." - self.set_status(status_msg, "orange") - else: - self.set_status(status_msg, "green") - - except IOError as e: - self.set_status(f"Error reading file {LOG_FILE_PATH}: {e}", "red") - except Exception as e: - self.set_status(f"An unexpected error occurred during loading: {e}", "red") - - # --- 更新 Combobox --- - self.update_stream_selector() - - def update_stream_selector(self): - """更新单个流选项卡中的 Combobox 列表""" - # 创建 (display_name, stream_id) 对的列表,按 display_name 排序 - available_streams = sorted( - [ - (name, sid) - for sid, name in self.stream_display_names.items() - if sid in self.stream_history and self.stream_history[sid] - ], - key=lambda item: item[0], # 按显示名称排序 - ) - - # 更新 Combobox 的值 (仅显示 display_name) - self.stream_selector["values"] = [name for name, sid in available_streams] - - # 检查当前选中的 stream_id 是否仍然有效 - current_selection_name = self.selected_stream_id.get() - current_selection_valid = any(name == current_selection_name for name, sid in available_streams) - - if not current_selection_valid and available_streams: - # 如果当前选择无效,并且有可选流,则默认选中第一个 - self.selected_stream_id.set(available_streams[0][0]) - # 手动触发一次更新,因为 set 不会触发 <> - self.update_single_stream_plot() - elif not available_streams: - # 如果没有可选流,清空选择 - self.selected_stream_id.set("") - self.update_single_stream_plot() # 清空图表 - - def update_all_streams_plot(self): - """更新第一个选项卡的 Matplotlib 图表 (显示所有流)""" - self.ax.clear() # 清除旧图 - # *** 设置中文标题和标签 *** - self.ax.set_title("兴趣度随时间变化图 (所有活跃流)") - self.ax.set_xlabel("时间") - self.ax.set_ylabel("兴趣度") - self.ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) - self.ax.grid(True) - self.ax.set_ylim(0, 10) # 固定 Y 轴范围 0-10 - - # 只绘制最新的 N 个 stream (按最后记录的兴趣度排序) - # 注意:现在是基于文件读取的快照排序,可能不是实时最新 - active_streams = sorted( - self.stream_history.items(), - key=lambda item: item[1][-1][1] if item[1] else 0, # 按最后兴趣度排序 - reverse=True, - )[:MAX_STREAMS_TO_DISPLAY] - - all_times = [] # 用于确定 X 轴范围 - - for stream_id, history in active_streams: - if not history: - continue - - timestamps, interests = zip(*history) - # 将 time.time() 时间戳转换为 matplotlib 可识别的日期格式 - try: - mpl_dates = [datetime.fromtimestamp(ts) for ts in timestamps] - all_times.extend(mpl_dates) # 收集所有时间点 - - # *** Use display name for label *** - display_label = self.stream_display_names.get(stream_id, stream_id) - - self.ax.plot( - mpl_dates, - interests, - label=display_label, # *** Use display_label *** - color=self.stream_colors.get(stream_id, "grey"), - marker=".", - markersize=3, - linestyle="-", - linewidth=1, - ) - except ValueError as e: - print(f"Skipping plot for {stream_id} due to invalid timestamp: {e}") - continue - - if all_times: - # 根据数据动态调整 X 轴范围,留一点边距 - min_time = min(all_times) - max_time = max(all_times) - # delta = max_time - min_time - # self.ax.set_xlim(min_time - delta * 0.05, max_time + delta * 0.05) - self.ax.set_xlim(min_time, max_time) - - # 自动格式化X轴标签 - self.fig.autofmt_xdate() - else: - # 如果没有数据,设置一个默认的时间范围,例如最近一小时 - now = datetime.now() - one_hour_ago = now - timedelta(hours=1) - self.ax.set_xlim(one_hour_ago, now) - - # 添加图例 - if active_streams: - # 调整图例位置和大小 - # 字体已通过全局 matplotlib.rcParams 设置 - self.ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1), borderaxespad=0.0, fontsize="x-small") - # 调整布局,确保图例不被裁剪 - self.fig.tight_layout(rect=[0, 0, 0.85, 1]) # 右侧留出空间给图例 - - self.canvas.draw() # 重绘画布 - - def update_single_stream_plot(self): - """更新第二个选项卡的 Matplotlib 图表 (显示单个选定的流)""" - self.ax_single_interest.clear() - self.ax_single_probability.clear() - - # 设置子图标题和标签 - self.ax_single_interest.set_title("兴趣度") - self.ax_single_interest.set_ylim(0, 10) # 固定 Y 轴范围 0-10 - - # self.ax_single_probability.set_title("回复评估概率") # <-- 注释掉旧标题 - self.ax_single_probability.set_title("HFC 启动概率") # <-- 修改标题 - self.ax_single_probability.set_xlabel("时间") - # self.ax_single_probability.set_ylabel("概率") # <-- 注释掉旧标签 - self.ax_single_probability.set_ylabel("HFC 概率") # <-- 修改 Y 轴标签 - self.ax_single_probability.grid(True) - self.ax_single_probability.set_ylim(0, 1.05) # 固定 Y 轴范围 0-1 - self.ax_single_probability.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) - - selected_name = self.selected_stream_id.get() - selected_sid = None - - # --- 新增:根据选中的名称找到 stream_id --- - if selected_name: - for sid, name in self.stream_display_names.items(): - if name == selected_name: - selected_sid = sid - break - - all_times = [] # 用于确定 X 轴范围 - - # --- 新增:绘制兴趣度图 --- - if selected_sid and selected_sid in self.stream_history and self.stream_history[selected_sid]: - history = self.stream_history[selected_sid] - timestamps, interests = zip(*history) - try: - mpl_dates = [datetime.fromtimestamp(ts) for ts in timestamps] - all_times.extend(mpl_dates) - self.ax_single_interest.plot( - mpl_dates, - interests, - color=self.stream_colors.get(selected_sid, "blue"), - marker=".", - markersize=3, - linestyle="-", - linewidth=1, - ) - except ValueError as e: - print(f"Skipping interest plot for {selected_sid} due to invalid timestamp: {e}") - - # --- 新增:绘制概率图 --- - if selected_sid and selected_sid in self.probability_history and self.probability_history[selected_sid]: - prob_history = self.probability_history[selected_sid] - prob_timestamps, probabilities = zip(*prob_history) - try: - prob_mpl_dates = [datetime.fromtimestamp(ts) for ts in prob_timestamps] - # 注意:概率图的时间点可能与兴趣度不同,也需要加入 all_times - all_times.extend(prob_mpl_dates) - self.ax_single_probability.plot( - prob_mpl_dates, - probabilities, - color=self.stream_colors.get(selected_sid, "green"), # 可以用不同颜色 - marker=".", - markersize=3, - linestyle="-", - linewidth=1, - ) - except ValueError as e: - print(f"Skipping probability plot for {selected_sid} due to invalid timestamp: {e}") - - # --- 新增:调整 X 轴范围和格式 --- - if all_times: - min_time = min(all_times) - max_time = max(all_times) - # 设置共享的 X 轴范围 - self.ax_single_interest.set_xlim(min_time, max_time) - # self.ax_single_probability.set_xlim(min_time, max_time) # sharex 会自动同步 - # 自动格式化X轴标签 (应用到共享轴的最后一个子图上通常即可) - self.fig_single.autofmt_xdate() - else: - # 如果没有数据,设置一个默认的时间范围 - now = datetime.now() - one_hour_ago = now - timedelta(hours=1) - self.ax_single_interest.set_xlim(one_hour_ago, now) - # self.ax_single_probability.set_xlim(one_hour_ago, now) # sharex 会自动同步 - - # --- 新增:更新单个流的详细信息标签 --- - self.update_single_stream_details(selected_sid) - - # --- 新增:重新绘制画布 --- - self.canvas_single.draw() - - def update_single_stream_details(self, stream_id): - """更新单个流详情区域的标签内容""" - if stream_id: - sub_mind = self.stream_sub_minds.get(stream_id, "N/A") - chat_state = self.stream_chat_states.get(stream_id, "N/A") - threshold = self.stream_threshold_status.get(stream_id, False) - last_active_ts = self.stream_last_active.get(stream_id) - last_interaction_ts = self.stream_last_interaction.get(stream_id) - - self.single_stream_sub_mind.set(f"想法: {sub_mind}") - self.single_stream_chat_state.set(f"状态: {chat_state}") - self.single_stream_threshold.set(f"阈值以上: {'是' if threshold else '否'}") - self.single_stream_last_active.set(f"最后活跃: {format_timestamp(last_active_ts)}") - self.single_stream_last_interaction.set(f"最后交互: {format_timestamp(last_interaction_ts)}") - else: - # 如果没有选择流,则清空详情 - self.single_stream_sub_mind.set("想法: N/A") - self.single_stream_chat_state.set("状态: N/A") - self.single_stream_threshold.set("阈值: N/A") - self.single_stream_last_active.set("活跃: N/A") - self.single_stream_last_interaction.set("交互: N/A") - - def update_display(self): - """主更新循环""" - try: - # --- 新增:首次加载历史想法 --- - if not hasattr(self, "_main_mind_loaded"): - self.load_main_mind_history() - self._main_mind_loaded = True - else: - self.update_main_mind_history() # 只有有新main_mind数据时才刷新界面 - # *** 修改:分别调用两个图表的更新方法 *** - self.load_and_update_history() # 从文件加载数据并更新内部状态 - self.update_all_streams_plot() # 更新所有流的图表 - self.update_single_stream_plot() # 更新单个流的图表 - except Exception as e: - # 提供更详细的错误信息 - import traceback - - error_msg = f"Error during update: {e}\n{traceback.format_exc()}" - self.set_status(error_msg, "red") - print(error_msg) # 打印详细错误到控制台 - - # 安排下一次刷新 - self.root.after(REFRESH_INTERVAL_MS, self.update_display) - - def set_status(self, message: str, color: str = "grey"): - """更新状态栏标签""" - # 限制状态栏消息长度 - max_len = 150 - display_message = (message[:max_len] + "...") if len(message) > max_len else message - self.status_label.config(text=display_message, fg=color) - - -if __name__ == "__main__": - # 导入 timedelta 用于默认时间范围 - from datetime import timedelta - - root = tk.Tk() - app = InterestMonitorApp(root) - root.mainloop() diff --git a/scripts/raw_data_preprocessor.py b/scripts/raw_data_preprocessor.py index c87c30ca..33fdede9 100644 --- a/scripts/raw_data_preprocessor.py +++ b/scripts/raw_data_preprocessor.py @@ -5,12 +5,21 @@ import sys # 新增系统模块导入 import datetime # 新增导入 sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from src.common.logger import get_module_logger +from src.common.logger_manager import get_logger +from src.plugins.knowledge.src.lpmmconfig import global_config -logger = get_module_logger("LPMM数据库-原始数据处理") +logger = get_logger("lpmm") ROOT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) RAW_DATA_PATH = os.path.join(ROOT_PATH, "data/lpmm_raw_data") -IMPORTED_DATA_PATH = os.path.join(ROOT_PATH, "data/imported_lpmm_data") +# 新增:确保 RAW_DATA_PATH 存在 +if not os.path.exists(RAW_DATA_PATH): + os.makedirs(RAW_DATA_PATH, exist_ok=True) + logger.info(f"已创建目录: {RAW_DATA_PATH}") + +if global_config.get("persistence", {}).get("raw_data_path") is not None: + IMPORTED_DATA_PATH = os.path.join(ROOT_PATH, global_config["persistence"]["raw_data_path"]) +else: + IMPORTED_DATA_PATH = os.path.join(ROOT_PATH, "data/imported_lpmm_data") # 添加项目根目录到 sys.path @@ -54,7 +63,7 @@ def main(): print("请确保原始数据已放置在正确的目录中。") confirm = input("确认继续执行?(y/n): ").strip().lower() if confirm != "y": - logger.error("操作已取消") + logger.info("操作已取消") sys.exit(1) print("\n" + "=" * 40 + "\n") @@ -94,6 +103,6 @@ def main(): if __name__ == "__main__": - print(f"Raw Data Path: {RAW_DATA_PATH}") - print(f"Imported Data Path: {IMPORTED_DATA_PATH}") + logger.info(f"原始数据路径: {RAW_DATA_PATH}") + logger.info(f"处理后的数据路径: {IMPORTED_DATA_PATH}") main() diff --git a/src/api/__init__.py b/src/api/__init__.py index f5bc08a6..e69de29b 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -1,8 +0,0 @@ -from fastapi import FastAPI -from strawberry.fastapi import GraphQLRouter - -app = FastAPI() - -graphql_router = GraphQLRouter(schema=None, path="/") # Replace `None` with your actual schema - -app.include_router(graphql_router, prefix="/graphql", tags=["GraphQL"]) diff --git a/src/api/apiforgui.py b/src/api/apiforgui.py new file mode 100644 index 00000000..75ef2f8d --- /dev/null +++ b/src/api/apiforgui.py @@ -0,0 +1,16 @@ +from src.heart_flow.heartflow import heartflow +from src.heart_flow.sub_heartflow import ChatState + + +async def get_all_subheartflow_ids() -> list: + """获取所有子心流的ID列表""" + all_subheartflows = heartflow.subheartflow_manager.get_all_subheartflows() + return [subheartflow.subheartflow_id for subheartflow in all_subheartflows] + + +async def forced_change_subheartflow_status(subheartflow_id: str, status: ChatState) -> bool: + """强制改变子心流的状态""" + subheartflow = await heartflow.get_or_create_subheartflow(subheartflow_id) + if subheartflow: + return await heartflow.force_change_subheartflow_status(subheartflow_id, status) + return False diff --git a/src/api/config_api.py b/src/api/config_api.py index 6ecd4e6d..581c05a0 100644 --- a/src/api/config_api.py +++ b/src/api/config_api.py @@ -1,155 +1,187 @@ -from typing import List, Optional +from typing import List, Optional, Dict, Any import strawberry - -# from packaging.version import Version, InvalidVersion -# from packaging.specifiers import SpecifierSet, InvalidSpecifier -# from ..config.config import global_config -# import os from packaging.version import Version +import os + +ROOT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) @strawberry.type -class BotConfig: +class APIBotConfig: """机器人配置类""" - INNER_VERSION: Version + INNER_VERSION: Version # 配置文件内部版本号 MAI_VERSION: str # 硬编码的版本信息 # bot - BOT_QQ: Optional[int] - BOT_NICKNAME: Optional[str] - BOT_ALIAS_NAMES: List[str] # 别名,可以通过这个叫它 + BOT_QQ: Optional[int] # 机器人QQ号 + BOT_NICKNAME: Optional[str] # 机器人昵称 + BOT_ALIAS_NAMES: List[str] # 机器人别名列表 # group - talk_allowed_groups: set - talk_frequency_down_groups: set - ban_user_id: set + talk_allowed_groups: List[int] # 允许回复消息的群号列表 + talk_frequency_down_groups: List[int] # 降低回复频率的群号列表 + ban_user_id: List[int] # 禁止回复和读取消息的QQ号列表 # personality - personality_core: str # 建议20字以内,谁再写3000字小作文敲谁脑袋 - personality_sides: List[str] + personality_core: str # 人格核心特点描述 + personality_sides: List[str] # 人格细节描述列表 + # identity - identity_detail: List[str] - height: int # 身高 单位厘米 - weight: int # 体重 单位千克 - age: int # 年龄 单位岁 + identity_detail: List[str] # 身份特点列表 + age: int # 年龄(岁) gender: str # 性别 - appearance: str # 外貌特征 + appearance: str # 外貌特征描述 # schedule ENABLE_SCHEDULE_GEN: bool # 是否启用日程生成 - PROMPT_SCHEDULE_GEN: str - SCHEDULE_DOING_UPDATE_INTERVAL: int # 日程表更新间隔 单位秒 - SCHEDULE_TEMPERATURE: float # 日程表温度,建议0.5-1.0 + ENABLE_SCHEDULE_INTERACTION: bool # 是否启用日程交互 + PROMPT_SCHEDULE_GEN: str # 日程生成提示词 + SCHEDULE_DOING_UPDATE_INTERVAL: int # 日程进行中更新间隔 + SCHEDULE_TEMPERATURE: float # 日程生成温度 TIME_ZONE: str # 时区 - # message - MAX_CONTEXT_SIZE: int # 上下文最大消息数 - emoji_chance: float # 发送表情包的基础概率 - thinking_timeout: int # 思考时间 - model_max_output_length: int # 最大回复长度 - message_buffer: bool # 消息缓冲器 + # platforms + platforms: Dict[str, str] # 平台信息 - ban_words: set - ban_msgs_regex: set - # heartflow - # enable_heartflow: bool = False # 是否启用心流 - sub_heart_flow_update_interval: int # 子心流更新频率,间隔 单位秒 - sub_heart_flow_freeze_time: int # 子心流冻结时间,超过这个时间没有回复,子心流会冻结,间隔 单位秒 - sub_heart_flow_stop_time: int # 子心流停止时间,超过这个时间没有回复,子心流会停止,间隔 单位秒 - heart_flow_update_interval: int # 心流更新频率,间隔 单位秒 - observation_context_size: int # 心流观察到的最长上下文大小,超过这个值的上下文会被压缩 - compressed_length: int # 不能大于observation_context_size,心流上下文压缩的最短压缩长度,超过心流观察到的上下文长度,会压缩,最短压缩长度为5 - compress_length_limit: int # 最多压缩份数,超过该数值的压缩上下文会被删除 + # chat + allow_focus_mode: bool # 是否允许专注模式 + base_normal_chat_num: int # 基础普通聊天次数 + base_focused_chat_num: int # 基础专注聊天次数 + observation_context_size: int # 观察上下文大小 + message_buffer: bool # 是否启用消息缓冲 + ban_words: List[str] # 禁止词列表 + ban_msgs_regex: List[str] # 禁止消息的正则表达式列表 - # willing + # normal_chat + MODEL_R1_PROBABILITY: float # 模型推理概率 + MODEL_V3_PROBABILITY: float # 模型普通概率 + emoji_chance: float # 表情符号出现概率 + thinking_timeout: int # 思考超时时间 willing_mode: str # 意愿模式 - response_willing_amplifier: float # 回复意愿放大系数 - response_interested_rate_amplifier: float # 回复兴趣度放大系数 - down_frequency_rate: float # 降低回复频率的群组回复意愿降低系数 - emoji_response_penalty: float # 表情包回复惩罚 - mentioned_bot_inevitable_reply: bool # 提及 bot 必然回复 - at_bot_inevitable_reply: bool # @bot 必然回复 + response_willing_amplifier: float # 回复意愿放大器 + response_interested_rate_amplifier: float # 回复兴趣率放大器 + down_frequency_rate: float # 降低频率率 + emoji_response_penalty: float # 表情回复惩罚 + mentioned_bot_inevitable_reply: bool # 提到机器人时是否必定回复 + at_bot_inevitable_reply: bool # @机器人时是否必定回复 - # response - response_mode: str # 回复策略 - MODEL_R1_PROBABILITY: float # R1模型概率 - MODEL_V3_PROBABILITY: float # V3模型概率 - # MODEL_R1_DISTILL_PROBABILITY: float # R1蒸馏模型概率 + # focus_chat + reply_trigger_threshold: float # 回复触发阈值 + default_decay_rate_per_second: float # 默认每秒衰减率 + consecutive_no_reply_threshold: int # 连续不回复阈值 + + # compressed + compressed_length: int # 压缩长度 + compress_length_limit: int # 压缩长度限制 # emoji - max_emoji_num: int # 表情包最大数量 - max_reach_deletion: bool # 开启则在达到最大数量时删除表情包,关闭则不会继续收集表情包 - EMOJI_CHECK_INTERVAL: int # 表情包检查间隔(分钟) - EMOJI_REGISTER_INTERVAL: int # 表情包注册间隔(分钟) - EMOJI_SAVE: bool # 偷表情包 - EMOJI_CHECK: bool # 是否开启过滤 - EMOJI_CHECK_PROMPT: str # 表情包过滤要求 + max_emoji_num: int # 最大表情符号数量 + max_reach_deletion: bool # 达到最大数量时是否删除 + EMOJI_CHECK_INTERVAL: int # 表情检查间隔 + EMOJI_REGISTER_INTERVAL: Optional[int] # 表情注册间隔(兼容性保留) + EMOJI_SAVE: bool # 是否保存表情 + EMOJI_CHECK: bool # 是否检查表情 + EMOJI_CHECK_PROMPT: str # 表情检查提示词 # memory - build_memory_interval: int # 记忆构建间隔(秒) - memory_build_distribution: list # 记忆构建分布,参数:分布1均值,标准差,权重,分布2均值,标准差,权重 - build_memory_sample_num: int # 记忆构建采样数量 - build_memory_sample_length: int # 记忆构建采样长度 + build_memory_interval: int # 构建记忆间隔 + memory_build_distribution: List[float] # 记忆构建分布 + build_memory_sample_num: int # 构建记忆样本数量 + build_memory_sample_length: int # 构建记忆样本长度 memory_compress_rate: float # 记忆压缩率 - - forget_memory_interval: int # 记忆遗忘间隔(秒) - memory_forget_time: int # 记忆遗忘时间(小时) - memory_forget_percentage: float # 记忆遗忘比例 - - memory_ban_words: list # 添加新的配置项默认值 + forget_memory_interval: int # 忘记记忆间隔 + memory_forget_time: int # 记忆忘记时间 + memory_forget_percentage: float # 记忆忘记百分比 + consolidate_memory_interval: int # 巩固记忆间隔 + consolidation_similarity_threshold: float # 巩固相似度阈值 + consolidation_check_percentage: float # 巩固检查百分比 + memory_ban_words: List[str] # 记忆禁止词列表 # mood - mood_update_interval: float # 情绪更新间隔 单位秒 + mood_update_interval: float # 情绪更新间隔 mood_decay_rate: float # 情绪衰减率 mood_intensity_factor: float # 情绪强度因子 - # keywords - keywords_reaction_rules: list # 关键词回复规则 + # keywords_reaction + keywords_reaction_enable: bool # 是否启用关键词反应 + keywords_reaction_rules: List[Dict[str, Any]] # 关键词反应规则 # chinese_typo - chinese_typo_enable: bool # 是否启用中文错别字生成器 - chinese_typo_error_rate: float # 单字替换概率 - chinese_typo_min_freq: int # 最小字频阈值 - chinese_typo_tone_error_rate: float # 声调错误概率 - chinese_typo_word_replace_rate: float # 整词替换概率 + chinese_typo_enable: bool # 是否启用中文错别字 + chinese_typo_error_rate: float # 中文错别字错误率 + chinese_typo_min_freq: int # 中文错别字最小频率 + chinese_typo_tone_error_rate: float # 中文错别字声调错误率 + chinese_typo_word_replace_rate: float # 中文错别字单词替换率 # response_splitter enable_response_splitter: bool # 是否启用回复分割器 - response_max_length: int # 回复允许的最大长度 - response_max_sentence_num: int # 回复允许的最大句子数 + response_max_length: int # 回复最大长度 + response_max_sentence_num: int # 回复最大句子数 + enable_kaomoji_protection: bool # 是否启用颜文字保护 + + model_max_output_length: int # 模型最大输出长度 # remote - remote_enable: bool # 是否启用远程控制 + remote_enable: bool # 是否启用远程功能 # experimental enable_friend_chat: bool # 是否启用好友聊天 - # enable_think_flow: bool # 是否启用思考流程 + talk_allowed_private: List[int] # 允许私聊的QQ号列表 enable_pfc_chatting: bool # 是否启用PFC聊天 # 模型配置 - llm_reasoning: dict[str, str] # LLM推理 - # llm_reasoning_minor: dict[str, str] - llm_normal: dict[str, str] # LLM普通 - llm_topic_judge: dict[str, str] # LLM话题判断 - llm_summary: dict[str, str] # LLM话题总结 - llm_emotion_judge: dict[str, str] # LLM情感判断 - embedding: dict[str, str] # 嵌入 - vlm: dict[str, str] # VLM - moderation: dict[str, str] # 审核 + llm_reasoning: Dict[str, Any] # 推理模型配置 + llm_normal: Dict[str, Any] # 普通模型配置 + llm_topic_judge: Dict[str, Any] # 主题判断模型配置 + llm_summary: Dict[str, Any] # 总结模型配置 + llm_emotion_judge: Optional[Dict[str, Any]] # 情绪判断模型配置(兼容性保留) + embedding: Dict[str, Any] # 嵌入模型配置 + vlm: Dict[str, Any] # VLM模型配置 + moderation: Optional[Dict[str, Any]] # 审核模型配置(兼容性保留) + llm_observation: Dict[str, Any] # 观察模型配置 + llm_sub_heartflow: Dict[str, Any] # 子心流模型配置 + llm_heartflow: Dict[str, Any] # 心流模型配置 + llm_plan: Optional[Dict[str, Any]] # 计划模型配置 + llm_PFC_action_planner: Optional[Dict[str, Any]] # PFC行动计划模型配置 + llm_PFC_chat: Optional[Dict[str, Any]] # PFC聊天模型配置 + llm_PFC_reply_checker: Optional[Dict[str, Any]] # PFC回复检查模型配置 + llm_tool_use: Optional[Dict[str, Any]] # 工具使用模型配置 - # 实验性 - llm_observation: dict[str, str] # LLM观察 - llm_sub_heartflow: dict[str, str] # LLM子心流 - llm_heartflow: dict[str, str] # LLM心流 - - api_urls: dict[str, str] # API URLs + api_urls: Optional[Dict[str, str]] # API地址配置 @strawberry.type -class EnvConfig: - pass +class APIEnvConfig: + """环境变量配置""" + + HOST: str # 服务主机地址 + PORT: int # 服务端口 + + PLUGINS: List[str] # 插件列表 + + MONGODB_HOST: str # MongoDB 主机地址 + MONGODB_PORT: int # MongoDB 端口 + DATABASE_NAME: str # 数据库名称 + + CHAT_ANY_WHERE_BASE_URL: str # ChatAnywhere 基础URL + SILICONFLOW_BASE_URL: str # SiliconFlow 基础URL + DEEP_SEEK_BASE_URL: str # DeepSeek 基础URL + + DEEP_SEEK_KEY: Optional[str] # DeepSeek API Key + CHAT_ANY_WHERE_KEY: Optional[str] # ChatAnywhere API Key + SILICONFLOW_KEY: Optional[str] # SiliconFlow API Key + + SIMPLE_OUTPUT: Optional[bool] # 是否简化输出 + CONSOLE_LOG_LEVEL: Optional[str] # 控制台日志等级 + FILE_LOG_LEVEL: Optional[str] # 文件日志等级 + DEFAULT_CONSOLE_LOG_LEVEL: Optional[str] # 默认控制台日志等级 + DEFAULT_FILE_LOG_LEVEL: Optional[str] # 默认文件日志等级 @strawberry.field def get_env(self) -> str: return "env" + + +print("当前路径:") +print(ROOT_PATH) diff --git a/src/api/graphql/__init__.py b/src/api/maigraphql/__init__.py similarity index 100% rename from src/api/graphql/__init__.py rename to src/api/maigraphql/__init__.py diff --git a/src/api/graphql/schema.py b/src/api/maigraphql/schema.py similarity index 100% rename from src/api/graphql/schema.py rename to src/api/maigraphql/schema.py diff --git a/src/api/main.py b/src/api/main.py new file mode 100644 index 00000000..6d7e3c1e --- /dev/null +++ b/src/api/main.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter +from strawberry.fastapi import GraphQLRouter + +# from src.config.config import BotConfig +from src.common.logger_manager import get_logger +from src.api.reload_config import reload_config as reload_config_func +from src.common.server import global_server +from .apiforgui import get_all_subheartflow_ids, forced_change_subheartflow_status +from src.heart_flow.sub_heartflow import ChatState +# import uvicorn +# import os + + +router = APIRouter() + + +logger = get_logger("api") + +# maiapi = FastAPI() +logger.info("麦麦API服务器已启动") +graphql_router = GraphQLRouter(schema=None, path="/") # Replace `None` with your actual schema + +router.include_router(graphql_router, prefix="/graphql", tags=["GraphQL"]) + + +@router.post("/config/reload") +async def reload_config(): + return await reload_config_func() + + +@router.get("/gui/subheartflow/get/all") +async def get_subheartflow_ids(): + """获取所有子心流的ID列表""" + return await get_all_subheartflow_ids() + + +@router.post("/gui/subheartflow/forced_change_status") +async def forced_change_subheartflow_status_api(subheartflow_id: str, status: ChatState): # noqa + """强制改变子心流的状态""" + # 参数检查 + if not isinstance(status, ChatState): + logger.warning(f"无效的状态参数: {status}") + return {"status": "failed", "reason": "invalid status"} + logger.info(f"尝试将子心流 {subheartflow_id} 状态更改为 {status.value}") + success = await forced_change_subheartflow_status(subheartflow_id, status) + if success: + logger.info(f"子心流 {subheartflow_id} 状态更改为 {status.value} 成功") + return {"status": "success"} + else: + logger.error(f"子心流 {subheartflow_id} 状态更改为 {status.value} 失败") + return {"status": "failed"} + + +def start_api_server(): + """启动API服务器""" + global_server.register_router(router, prefix="/api/v1") diff --git a/src/api/reload_config.py b/src/api/reload_config.py new file mode 100644 index 00000000..a5f36e3d --- /dev/null +++ b/src/api/reload_config.py @@ -0,0 +1,24 @@ +from fastapi import HTTPException +from rich.traceback import install +from src.config.config import BotConfig +from src.common.logger_manager import get_logger +import os + +install(extra_lines=3) + +logger = get_logger("api") + + +async def reload_config(): + try: + from src.config import config as config_module + + logger.debug("正在重载配置文件...") + bot_config_path = os.path.join(BotConfig.get_config_dir(), "bot_config.toml") + config_module.global_config = BotConfig.load_config(config_path=bot_config_path) + logger.debug("配置文件重载成功") + return {"status": "reloaded"} + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"重载配置时发生错误: {str(e)}") from e diff --git a/src/common/logger.py b/src/common/logger.py index a82c6d88..bf82cffa 100644 --- a/src/common/logger.py +++ b/src/common/logger.py @@ -358,6 +358,23 @@ SUB_HEARTFLOW_STYLE_CONFIG = { }, } +INTEREST_CHAT_STYLE_CONFIG = { + "advanced": { + "console_format": ( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <8} | " + "兴趣 | " + "{message}" + ), + "file_format": "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {extra[module]: <15} | 兴趣 | {message}", + }, + "simple": { + "console_format": "{time:MM-DD HH:mm} | 兴趣 | {message}", # noqa: E501 + "file_format": "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {extra[module]: <15} | 兴趣 | {message}", + }, +} + + SUB_HEARTFLOW_MIND_STYLE_CONFIG = { "advanced": { "console_format": ( @@ -808,6 +825,22 @@ INIT_STYLE_CONFIG = { }, } +API_SERVER_STYLE_CONFIG = { + "advanced": { + "console_format": ( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <8} | " + "API服务 | " + "{message}" + ), + "file_format": "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {extra[module]: <15} | API服务 | {message}", + }, + "simple": { + "console_format": "{time:MM-DD HH:mm} | API服务 | {message}", + "file_format": "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {extra[module]: <15} | API服务 | {message}", + }, +} + # 根据SIMPLE_OUTPUT选择配置 MAIN_STYLE_CONFIG = MAIN_STYLE_CONFIG["simple"] if SIMPLE_OUTPUT else MAIN_STYLE_CONFIG["advanced"] @@ -878,6 +911,10 @@ CHAT_MESSAGE_STYLE_CONFIG = ( ) CHAT_IMAGE_STYLE_CONFIG = CHAT_IMAGE_STYLE_CONFIG["simple"] if SIMPLE_OUTPUT else CHAT_IMAGE_STYLE_CONFIG["advanced"] INIT_STYLE_CONFIG = INIT_STYLE_CONFIG["simple"] if SIMPLE_OUTPUT else INIT_STYLE_CONFIG["advanced"] +API_SERVER_STYLE_CONFIG = API_SERVER_STYLE_CONFIG["simple"] if SIMPLE_OUTPUT else API_SERVER_STYLE_CONFIG["advanced"] +INTEREST_CHAT_STYLE_CONFIG = ( + INTEREST_CHAT_STYLE_CONFIG["simple"] if SIMPLE_OUTPUT else INTEREST_CHAT_STYLE_CONFIG["advanced"] +) def is_registered_module(record: dict) -> bool: diff --git a/src/common/logger_manager.py b/src/common/logger_manager.py index 5c553838..8aae71e1 100644 --- a/src/common/logger_manager.py +++ b/src/common/logger_manager.py @@ -41,6 +41,8 @@ from src.common.logger import ( CHAT_MESSAGE_STYLE_CONFIG, CHAT_IMAGE_STYLE_CONFIG, INIT_STYLE_CONFIG, + INTEREST_CHAT_STYLE_CONFIG, + API_SERVER_STYLE_CONFIG, ) # 可根据实际需要补充更多模块配置 @@ -86,6 +88,8 @@ MODULE_LOGGER_CONFIGS = { "chat_message": CHAT_MESSAGE_STYLE_CONFIG, # 聊天消息 "chat_image": CHAT_IMAGE_STYLE_CONFIG, # 聊天图片 "init": INIT_STYLE_CONFIG, # 初始化 + "interest_chat": INTEREST_CHAT_STYLE_CONFIG, # 兴趣 + "api": API_SERVER_STYLE_CONFIG, # API服务器 # ...如有更多模块,继续添加... } diff --git a/src/config/config.py b/src/config/config.py index bbeefdaf..28c447bd 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -710,6 +710,18 @@ class BotConfig: if config.INNER_VERSION in SpecifierSet(">=1.6.1.5"): config.api_polling_max_retries = experimental_config.get("api_polling_max_retries", config.api_polling_max_retries) + def idle_conversation(parent: dict): + idle_conversation_config = parent["idle_conversation"] + if config.INNER_VERSION in SpecifierSet(">=1.6.1.6"): + config.enable_idle_conversation = idle_conversation_config.get( + "enable_idle_conversation", config.enable_idle_conversation + ) + config.idle_check_interval = idle_conversation_config.get( + "idle_check_interval", config.idle_check_interval + ) + config.min_idle_time = idle_conversation_config.get("min_idle_time", config.min_idle_time) + config.max_idle_time = idle_conversation_config.get("max_idle_time", config.max_idle_time) + # 版本表达式:>=1.0.0,<2.0.0 # 允许字段:func: method, support: str, notice: str, necessary: bool # 如果使用 notice 字段,在该组配置加载时,会展示该字段对用户的警示 @@ -744,6 +756,7 @@ class BotConfig: "normal_chat": {"func": normal_chat, "support": ">=1.6.0", "necessary": False}, "focus_chat": {"func": focus_chat, "support": ">=1.6.0", "necessary": False}, "group_nickname": {"func": group_nickname, "support": ">=1.6.1.1", "necessary": False}, + "idle_conversation": {"func": idle_conversation, "support": ">=1.6.1.6", "necessary": False}, } # 原地修改,将 字符串版本表达式 转换成 版本对象 diff --git a/src/do_tool/tool_can_use/rename_person_tool.py b/src/do_tool/tool_can_use/rename_person_tool.py index d9f23cf4..3b95bc43 100644 --- a/src/do_tool/tool_can_use/rename_person_tool.py +++ b/src/do_tool/tool_can_use/rename_person_tool.py @@ -8,14 +8,16 @@ logger = get_logger("rename_person_tool") class RenamePersonTool(BaseTool): name = "rename_person" - description = "这个工具可以改变用户的昵称。你可以选择改变对他人的称呼。" + description = ( + "这个工具可以改变用户的昵称。你可以选择改变对他人的称呼。你想给人改名,叫别人别的称呼,需要调用这个工具。" + ) parameters = { "type": "object", "properties": { "person_name": {"type": "string", "description": "需要重新取名的用户的当前昵称"}, "message_content": { "type": "string", - "description": "可选的。当前的聊天内容或特定要求,用于提供取名建议的上下文。", + "description": "当前的聊天内容或特定要求,用于提供取名建议的上下文,尽可能详细。", }, }, "required": ["person_name"], diff --git a/src/heart_flow/background_tasks.py b/src/heart_flow/background_tasks.py index 301c2984..5ed664e0 100644 --- a/src/heart_flow/background_tasks.py +++ b/src/heart_flow/background_tasks.py @@ -78,6 +78,7 @@ class BackgroundTaskManager: 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): """启动所有后台任务 @@ -135,6 +136,13 @@ class BackgroundTaskManager: 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", + # ), ] # 统一启动所有任务 @@ -296,3 +304,11 @@ class BackgroundTaskManager: 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/heart_flow/heartflow.py b/src/heart_flow/heartflow.py index bd8bc6ff..894247ce 100644 --- a/src/heart_flow/heartflow.py +++ b/src/heart_flow/heartflow.py @@ -1,4 +1,4 @@ -from src.heart_flow.sub_heartflow import SubHeartflow +from src.heart_flow.sub_heartflow import SubHeartflow, ChatState from src.plugins.models.utils_model import LLMRequest from src.config.config import global_config from src.plugins.schedule.schedule_generator import bot_schedule @@ -62,6 +62,11 @@ class Heartflow: # 不再需要传入 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 heartflow_start_working(self): """启动后台任务""" await self.background_task_manager.start_tasks() diff --git a/src/heart_flow/interest_chatting.py b/src/heart_flow/interest_chatting.py new file mode 100644 index 00000000..4525d09d --- /dev/null +++ b/src/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.plugins.chat.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/heart_flow/interest_logger.py b/src/heart_flow/interest_logger.py index 06d3f1cb..1fe289b8 100644 --- a/src/heart_flow/interest_logger.py +++ b/src/heart_flow/interest_logger.py @@ -29,6 +29,14 @@ def _ensure_log_directory(): 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: """负责定期记录主心流和所有子心流的状态到日志文件。""" @@ -44,6 +52,7 @@ class InterestLogger: 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]: """并发获取所有活跃子心流的当前完整状态。""" @@ -92,7 +101,7 @@ class InterestLogger: try: current_timestamp = time.time() - main_mind = self.heartflow.current_mind + # main_mind = self.heartflow.current_mind # 获取 Mai 状态名称 mai_state_name = self.heartflow.current_state.get_current_state().name @@ -100,7 +109,7 @@ class InterestLogger: log_entry_base = { "timestamp": round(current_timestamp, 2), - "main_mind": main_mind, + # "main_mind": main_mind, "mai_state": mai_state_name, "subflow_count": len(all_subflow_states), "subflows": [], @@ -135,7 +144,7 @@ class InterestLogger: "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), + # "is_above_threshold": interest_state.get("is_above_threshold", False), } subflow_details.append(subflow_entry) diff --git a/src/heart_flow/sub_heartflow.py b/src/heart_flow/sub_heartflow.py index eb8bbabd..66d50762 100644 --- a/src/heart_flow/sub_heartflow.py +++ b/src/heart_flow/sub_heartflow.py @@ -1,215 +1,22 @@ from .observation import Observation, ChattingObservation import asyncio -from src.config.config import global_config import time from typing import Optional, List, Dict, Tuple, Callable, Coroutine import traceback from src.common.logger_manager import get_logger from src.plugins.chat.message import MessageRecv from src.plugins.chat.chat_stream import chat_manager -import math from src.plugins.heartFC_chat.heartFC_chat import HeartFChatting from src.plugins.heartFC_chat.normal_chat import NormalChat from src.heart_flow.mai_state_manager import MaiStateInfo from src.heart_flow.chat_state_info import ChatState, ChatStateInfo from src.heart_flow.sub_mind import SubMind from .utils_chat import get_chat_type_and_target_info +from .interest_chatting import InterestChatting -# 定义常量 (从 interest.py 移动过来) -MAX_INTEREST = 15.0 - logger = get_logger("sub_heartflow") -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 - - # --- 结束 新增方法 --- - class SubHeartflow: def __init__( diff --git a/src/heart_flow/subheartflow_manager.py b/src/heart_flow/subheartflow_manager.py index a62450e9..14296677 100644 --- a/src/heart_flow/subheartflow_manager.py +++ b/src/heart_flow/subheartflow_manager.py @@ -83,6 +83,17 @@ class SubHeartflowManager: 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()) @@ -92,7 +103,7 @@ class SubHeartflowManager: Args: subheartflow_id: 子心流唯一标识符 - # mai_states 参数已被移除,使用 self.mai_state_info + mai_states 参数已被移除,使用 self.mai_state_info Returns: 成功返回SubHeartflow实例,失败返回None @@ -165,7 +176,7 @@ class SubHeartflowManager: def get_inactive_subheartflows(self, max_age_seconds=INACTIVE_THRESHOLD_SECONDS): """识别并返回需要清理的不活跃(处于ABSENT状态超过一小时)子心流(id, 原因)""" - current_time = time.time() + _current_time = time.time() flows_to_stop = [] for subheartflow_id, subheartflow in list(self.subheartflows.items()): @@ -173,9 +184,8 @@ class SubHeartflowManager: if state != ChatState.ABSENT: continue subheartflow.update_last_chat_state_time() - absent_last_time = subheartflow.chat_state_last_time - if max_age_seconds and (current_time - absent_last_time) > max_age_seconds: - flows_to_stop.append(subheartflow_id) + _absent_last_time = subheartflow.chat_state_last_time + flows_to_stop.append(subheartflow_id) return flows_to_stop @@ -662,12 +672,12 @@ class SubHeartflowManager: """处理来自 HeartFChatting 的连续无回复信号 (通过 partial 绑定 ID)""" # 注意:这里不需要再获取锁,因为 sbhf_focus_into_absent 内部会处理锁 logger.debug(f"[管理器 HFC 处理器] 接收到来自 {subheartflow_id} 的 HFC 无回复信号") - await self.sbhf_focus_into_absent(subheartflow_id) + await self.sbhf_focus_into_absent_or_chat(subheartflow_id) # --- 结束新增 --- # # --- 新增:处理来自 HeartFChatting 的状态转换请求 --- # - async def sbhf_focus_into_absent(self, subflow_id: Any): + async def sbhf_focus_into_absent_or_chat(self, subflow_id: Any): """ 接收来自 HeartFChatting 的请求,将特定子心流的状态转换为 ABSENT 或 CHAT。 通常在连续多次 "no_reply" 后被调用。 @@ -719,6 +729,8 @@ class SubHeartflowManager: 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: @@ -842,3 +854,52 @@ class SubHeartflowManager: # --- 结束新增 --- # # --- 结束新增:处理来自 HeartFChatting 的状态转换请求 --- # + + # 临时函数,用于GUI切换,有api后删除 + # async def detect_command_from_gui(self): + # """检测来自GUI的命令""" + # command_file = Path("temp_command/gui_command.json") + # if not command_file.exists(): + # return + + # try: + # # 读取并解析命令文件 + # command_data = json.loads(command_file.read_text()) + # subflow_id = command_data.get("subflow_id") + # target_state = command_data.get("target_state") + + # if not subflow_id or not target_state: + # logger.warning("GUI命令文件格式不正确,缺少必要字段") + # return + + # # 尝试转换为ChatState枚举 + # try: + # target_state_enum = ChatState[target_state.upper()] + # except KeyError: + # logger.warning(f"无效的目标状态: {target_state}") + # command_file.unlink() + # return + + # # 执行状态转换 + # await self.force_change_by_gui(subflow_id, target_state_enum) + + # # 转换成功后删除文件 + # command_file.unlink() + # logger.debug(f"已处理GUI命令并删除命令文件: {command_file}") + + # except json.JSONDecodeError: + # logger.warning("GUI命令文件不是有效的JSON格式") + # except Exception as e: + # logger.error(f"处理GUI命令时发生错误: {e}", exc_info=True) + + # async def force_change_by_gui(self, subflow_id: Any, target_state: ChatState): + # """强制改变指定子心流的状态""" + # async with self._lock: + # subflow = self.subheartflows.get(subflow_id) + # if not subflow: + # logger.warning(f"[强制状态转换] 尝试转换不存在的子心流 {subflow_id} 到 {target_state.value}") + # return + # await subflow.change_chat_state(target_state) + # logger.info(f"[强制状态转换] 成功将 {subflow_id} 的状态转换为 {target_state.value}") + + # --- 结束新增 --- # diff --git a/src/main.py b/src/main.py index 26a56ca2..be71524e 100644 --- a/src/main.py +++ b/src/main.py @@ -18,6 +18,7 @@ from .plugins.remote import heartbeat_thread # noqa: F401 from .individuality.individuality import Individuality from .common.server import global_server from rich.traceback import install +from .api.main import start_api_server install(extra_lines=3) @@ -54,6 +55,9 @@ class MainSystem: self.llm_stats.start() logger.success("LLM统计功能启动成功") + # 启动API服务器 + start_api_server() + logger.success("API服务器启动成功") # 初始化表情管理器 emoji_manager.initialize() logger.success("表情包管理器初始化成功") diff --git a/src/plugins/PFC/action_planner.py b/src/plugins/PFC/action_planner.py index faf13bd0..270ec451 100644 --- a/src/plugins/PFC/action_planner.py +++ b/src/plugins/PFC/action_planner.py @@ -1,24 +1,23 @@ import time -from typing import Tuple, Optional -from .pfc_utils import retrieve_contextual_info +import traceback +from typing import Tuple, Optional, Dict, Any, List -# import jieba # 如果需要旧版知识库的回退,可能需要 -# import re # 如果需要旧版知识库的回退,可能需要 from src.common.logger_manager import get_logger +from src.individuality.individuality import Individuality +from src.plugins.utils.chat_message_builder import build_readable_messages from ..models.utils_model import LLMRequest from ...config.config import global_config + +# 确保导入路径正确 +from .pfc_utils import get_items_from_json, retrieve_contextual_info from .chat_observer import ChatObserver -from .pfc_utils import get_items_from_json -from src.individuality.individuality import Individuality from .observation_info import ObservationInfo from .conversation_info import ConversationInfo -from src.plugins.utils.chat_message_builder import build_readable_messages - logger = get_logger("pfc_action_planner") -# --- 定义 Prompt 模板 --- +# --- 定义 Prompt 模板 --- # Prompt(1): 首次回复或非连续回复时的决策 Prompt PROMPT_INITIAL_REPLY = """{persona_text}。现在你在参与一场QQ私聊,请根据以下【所有信息】审慎且灵活的决策下一步行动,可以回复,可以倾听,可以调取知识,甚至可以屏蔽对方: @@ -41,7 +40,7 @@ PROMPT_INITIAL_REPLY = """{persona_text}。现在你在参与一场QQ私聊, ------ 可选行动类型以及解释: listening: 倾听对方发言,当你认为对方话才说到一半,发言明显未结束时选择 -direct_reply: 直接回复对方 +direct_reply: 直接回复对方 (当有新消息需要处理时,通常应选择此项) rethink_goal: 思考一个对话目标,当你觉得目前对话需要目标,或当前目标不再适用,或话题卡住时选择。注意私聊的环境是灵活的,有可能需要经常选择 end_conversation: 结束对话,对方长时间没回复或者当你觉得对话告一段落时可以选择 block_and_ignore: 更加极端的结束对话方式,直接结束对话并在一段时间内无视对方所有发言(屏蔽),当对话让你感到十分不适,或你遭到各类骚扰时选择 @@ -73,7 +72,7 @@ PROMPT_FOLLOW_UP = """{persona_text}。现在你在参与一场QQ私聊,刚刚 {retrieved_memory_str} ------ 可选行动类型以及解释: -wait: 暂时不说话,留给对方交互空间,等待对方回复(尤其是在你刚发言后、或上次发言因重复、发言过多被拒时、或不确定做什么时,这是不错的选择) +wait: 暂时不说话,留给对方交互空间,等待对方回复(尤其是在你刚发言后、或上次发言因重复、发言过多被拒时、或不确定做什么时,这是不错的选择)。**重要:仅当没有未读消息时才能选择此项。** listening: 倾听对方发言(虽然你刚发过言,但如果对方立刻回复且明显话没说完,可以选择这个) send_new_message: 发送一条新消息继续对话,允许适当的追问、补充、深入话题,或开启相关新话题。**但是避免在因重复被拒后立即使用,也不要在对方没有回复的情况下过多的“消息轰炸”或重复发言** rethink_goal: 思考一个对话目标,当你觉得目前对话需要目标,或当前目标不再适用,或话题卡住时选择。注意私聊的环境是灵活的,有可能需要经常选择 @@ -107,274 +106,113 @@ PROMPT_END_DECISION = """{persona_text}。刚刚你决定结束一场 QQ 私聊 注意:请严格按照 JSON 格式输出,不要包含任何其他内容。""" -# ActionPlanner 类定义,顶格 class ActionPlanner: """行动规划器""" def __init__(self, stream_id: str, private_name: str): - self.llm = LLMRequest( - model=global_config.llm_PFC_action_planner, - temperature=global_config.llm_PFC_action_planner["temp"], - max_tokens=1500, - request_type="action_planning", - ) + """初始化行动规划器""" + self.stream_id = stream_id + self.private_name = private_name + # 初始化 LLM 请求对象 + try: + llm_config = global_config.llm_PFC_action_planner + if not isinstance(llm_config, dict): + raise TypeError(f"LLM config 'llm_PFC_action_planner' is not a dictionary: {llm_config}") + + self.llm = LLMRequest( + model=llm_config, + temperature=llm_config.get("temp", 0.7), + max_tokens=1500, + request_type="action_planning", + ) + except TypeError as e: + logger.error(f"[私聊][{self.private_name}] 初始化 LLMRequest 时配置错误: {e}") + raise + except Exception as e: + logger.error(f"[私聊][{self.private_name}] 初始化 LLMRequest 时发生未知错误: {e}") + raise + + # 获取个性化信息和机器人名称 self.personality_info = Individuality.get_instance().get_prompt(x_person=2, level=3) self.name = global_config.BOT_NICKNAME - self.private_name = private_name + # 获取 ChatObserver 实例 (单例模式) self.chat_observer = ChatObserver.get_instance(stream_id, private_name) - # 修改 plan 方法签名,增加 last_successful_reply_action 参数 async def plan( self, observation_info: ObservationInfo, conversation_info: ConversationInfo, last_successful_reply_action: Optional[str], ) -> Tuple[str, str]: - """规划下一步行动 + """ + 规划下一步行动。 Args: - observation_info: 决策信息 - conversation_info: 对话信息 - last_successful_reply_action: 上一次成功的回复动作类型 ('direct_reply' 或 'send_new_message' 或 None) + observation_info: 观察信息,包含聊天记录、未读消息等。 + conversation_info: 对话信息,包含目标、历史动作等。 + last_successful_reply_action: 上一次成功的回复动作类型 ('direct_reply' 或 'send_new_message' 或 None)。 Returns: - Tuple[str, str]: (行动类型, 行动原因) + Tuple[str, str]: (规划的行动类型, 行动原因)。 """ - # --- 获取 Bot 上次发言时间信息 --- - # (这部分逻辑不变) - time_since_last_bot_message_info = "" + logger.info(f"[私聊][{self.private_name}] 开始规划行动...") + plan_start_time = time.time() + + # --- 1. 准备 Prompt 输入信息 --- try: - bot_id = str(global_config.BOT_QQ) - if hasattr(observation_info, "chat_history") and observation_info.chat_history: - for i in range(len(observation_info.chat_history) - 1, -1, -1): - msg = observation_info.chat_history[i] - if not isinstance(msg, dict): - continue - sender_info = msg.get("user_info", {}) - sender_id = str(sender_info.get("user_id")) if isinstance(sender_info, dict) else None - msg_time = msg.get("time") - if sender_id == bot_id and msg_time: - time_diff = time.time() - msg_time - if time_diff < 60.0: - time_since_last_bot_message_info = ( - f"提示:你上一条成功发送的消息是在 {time_diff:.1f} 秒前。\n" - ) - break - else: - logger.debug( - f"[私聊][{self.private_name}]Observation info chat history is empty or not available for bot time check." - ) - except AttributeError: - logger.warning( - f"[私聊][{self.private_name}]ObservationInfo object might not have chat_history attribute yet for bot time check." + time_since_last_bot_message_info = self._get_bot_last_speak_time_info(observation_info) + timeout_context = self._get_timeout_context(conversation_info) + goals_str = self._build_goals_string(conversation_info) + chat_history_text = await self._build_chat_history_text(observation_info) + persona_text = f"你的名字是{self.name},{self.personality_info}。" + action_history_summary, last_action_context = self._build_action_history_context(conversation_info) + retrieved_memory_str, retrieved_knowledge_str = await retrieve_contextual_info( + chat_history_text, self.private_name ) - except Exception as e: - logger.warning(f"[私聊][{self.private_name}]获取 Bot 上次发言时间时出错: {e}") - - # --- 获取超时提示信息 --- - # (这部分逻辑不变) - timeout_context = "" - try: - if hasattr(conversation_info, "goal_list") and conversation_info.goal_list: - last_goal_dict = conversation_info.goal_list[-1] - if isinstance(last_goal_dict, dict) and "goal" in last_goal_dict: - last_goal_text = last_goal_dict["goal"] - if isinstance(last_goal_text, str) and "分钟,思考接下来要做什么" in last_goal_text: - try: - timeout_minutes_text = last_goal_text.split(",")[0].replace("你等待了", "") - timeout_context = f"重要提示:对方已经长时间({timeout_minutes_text})没有回复你的消息了(这可能代表对方繁忙/不想回复/没注意到你的消息等情况,或在对方看来本次聊天已告一段落),请基于此情况规划下一步。\n" - except Exception: - timeout_context = "重要提示:对方已经长时间没有回复你的消息了(这可能代表对方繁忙/不想回复/没注意到你的消息等情况,或在对方看来本次聊天已告一段落),请基于此情况规划下一步。\n" - else: - logger.debug( - f"[私聊][{self.private_name}]Conversation info goal_list is empty or not available for timeout check." - ) - except AttributeError: - logger.warning( - f"[私聊][{self.private_name}]ConversationInfo object might not have goal_list attribute yet for timeout check." + logger.info( + f"[私聊][{self.private_name}] (ActionPlanner) 检索完成。记忆: {'有' if '回忆起' in retrieved_memory_str else '无'} / 知识: {'有' if retrieved_knowledge_str and '无相关知识' not in retrieved_knowledge_str and '出错' not in retrieved_knowledge_str else '无'}" ) - except Exception as e: - logger.warning(f"[私聊][{self.private_name}]检查超时目标时出错: {e}") + except Exception as prep_err: + logger.error(f"[私聊][{self.private_name}] 准备 Prompt 输入时出错: {prep_err}") + logger.error(traceback.format_exc()) + return "wait", f"准备行动规划输入时出错: {prep_err}" - # --- 构建通用 Prompt 参数 --- - logger.debug( - f"[私聊][{self.private_name}]开始规划行动:当前目标: {getattr(conversation_info, 'goal_list', '不可用')}" - ) - - # 构建对话目标 (goals_str) - goals_str = "" + # --- 2. 选择并格式化 Prompt --- try: - if hasattr(conversation_info, "goal_list") and conversation_info.goal_list: - for goal_reason in conversation_info.goal_list: - if isinstance(goal_reason, dict): - goal = goal_reason.get("goal", "目标内容缺失") - reasoning = goal_reason.get("reasoning", "没有明确原因") - else: - goal = str(goal_reason) - reasoning = "没有明确原因" - - goal = str(goal) if goal is not None else "目标内容缺失" - reasoning = str(reasoning) if reasoning is not None else "没有明确原因" - goals_str += f"- 目标:{goal}\n 原因:{reasoning}\n" - - if not goals_str: - goals_str = "- 目前没有明确对话目标,请考虑设定一个。\n" + if last_successful_reply_action in ["direct_reply", "send_new_message"]: + prompt_template = PROMPT_FOLLOW_UP + log_msg = "使用 PROMPT_FOLLOW_UP (追问决策)" else: - goals_str = "- 目前没有明确对话目标,请考虑设定一个。\n" - except AttributeError: - logger.warning( - f"[私聊][{self.private_name}]ConversationInfo object might not have goal_list attribute yet." + prompt_template = PROMPT_INITIAL_REPLY + log_msg = "使用 PROMPT_INITIAL_REPLY (首次/非连续回复决策)" + logger.debug(f"[私聊][{self.private_name}] {log_msg}") + + prompt = prompt_template.format( + persona_text=persona_text, + goals_str=goals_str if goals_str.strip() else "- 目前没有明确对话目标,请考虑设定一个。", + action_history_summary=action_history_summary, + last_action_context=last_action_context, + time_since_last_bot_message_info=time_since_last_bot_message_info, + timeout_context=timeout_context, + chat_history_text=chat_history_text if chat_history_text.strip() else "还没有聊天记录。", + retrieved_memory_str=retrieved_memory_str if retrieved_memory_str else "无相关记忆。", + retrieved_knowledge_str=retrieved_knowledge_str if retrieved_knowledge_str else "无相关知识。", ) - goals_str = "- 获取对话目标时出错。\n" - except Exception as e: - logger.error(f"[私聊][{self.private_name}]构建对话目标字符串时出错: {e}") - goals_str = "- 构建对话目标时出错。\n" + logger.debug(f"[私聊][{self.private_name}] 发送到LLM的最终提示词:\n------\n{prompt}\n------") + except KeyError as fmt_key_err: + logger.error(f"[私聊][{self.private_name}] 格式化 Prompt 时缺少键: {fmt_key_err}") + return "wait", f"格式化 Prompt 时出错 (缺少键: {fmt_key_err})" + except Exception as fmt_err: + logger.error(f"[私聊][{self.private_name}] 格式化 Prompt 时发生未知错误: {fmt_err}") + return "wait", f"格式化 Prompt 时出错: {fmt_err}" - # 获取聊天历史记录 (chat_history_text) - try: - if hasattr(observation_info, "chat_history") and observation_info.chat_history: - chat_history_text = observation_info.chat_history_str - if not chat_history_text: - chat_history_text = "还没有聊天记录。\n" - else: - chat_history_text = "还没有聊天记录。\n" - - if hasattr(observation_info, "new_messages_count") and observation_info.new_messages_count > 0: - if hasattr(observation_info, "unprocessed_messages") and observation_info.unprocessed_messages: - new_messages_list = observation_info.unprocessed_messages - new_messages_str = await build_readable_messages( - new_messages_list, - replace_bot_name=True, - merge_messages=False, - timestamp_mode="relative", - read_mark=0.0, - ) - chat_history_text += ( - f"\n--- 以下是 {observation_info.new_messages_count} 条新消息 ---\n{new_messages_str}" - ) - else: - logger.warning( - f"[私聊][{self.private_name}]ObservationInfo has new_messages_count > 0 but unprocessed_messages is empty or missing." - ) - except AttributeError: - logger.warning( - f"[私聊][{self.private_name}]ObservationInfo object might be missing expected attributes for chat history." - ) - chat_history_text = "获取聊天记录时出错。\n" - except Exception as e: - logger.error(f"[私聊][{self.private_name}]处理聊天记录时发生未知错误: {e}") - chat_history_text = "处理聊天记录时出错。\n" - - # 构建 Persona 文本 (persona_text) - persona_text = f"你的名字是{self.name},{self.personality_info}。" - - # 构建行动历史和上一次行动结果 (action_history_summary, last_action_context) - # (这部分逻辑不变) - action_history_summary = "你最近执行的行动历史:\n" - last_action_context = "关于你【上一次尝试】的行动:\n" - action_history_list = [] - try: - if hasattr(conversation_info, "done_action") and conversation_info.done_action: - action_history_list = conversation_info.done_action[-5:] - else: - logger.debug(f"[私聊][{self.private_name}]Conversation info done_action is empty or not available.") - except AttributeError: - logger.warning( - f"[私聊][{self.private_name}]ConversationInfo object might not have done_action attribute yet." - ) - except Exception as e: - logger.error(f"[私聊][{self.private_name}]访问行动历史时出错: {e}") - - if not action_history_list: - action_history_summary += "- 还没有执行过行动。\n" - last_action_context += "- 这是你规划的第一个行动。\n" - else: - for i, action_data in enumerate(action_history_list): - action_type = "未知" - plan_reason = "未知" - status = "未知" - final_reason = "" - action_time = "" - - if isinstance(action_data, dict): - action_type = action_data.get("action", "未知") - plan_reason = action_data.get("plan_reason", "未知规划原因") - status = action_data.get("status", "未知") - final_reason = action_data.get("final_reason", "") - action_time = action_data.get("time", "") - elif isinstance(action_data, tuple): - # 假设旧格式兼容 - if len(action_data) > 0: - action_type = action_data[0] - if len(action_data) > 1: - plan_reason = action_data[1] # 可能是规划原因或最终原因 - if len(action_data) > 2: - status = action_data[2] - if status == "recall" and len(action_data) > 3: - final_reason = action_data[3] - elif status == "done" and action_type in ["direct_reply", "send_new_message"]: - plan_reason = "成功发送" # 简化显示 - - reason_text = f", 失败/取消原因: {final_reason}" if final_reason else "" - summary_line = f"- 时间:{action_time}, 尝试行动:'{action_type}', 状态:{status}{reason_text}" - action_history_summary += summary_line + "\n" - - if i == len(action_history_list) - 1: - last_action_context += f"- 上次【规划】的行动是: '{action_type}'\n" - last_action_context += f"- 当时规划的【原因】是: {plan_reason}\n" - if status == "done": - last_action_context += "- 该行动已【成功执行】。\n" - # 记录这次成功的行动类型,供下次决策 - # self.last_successful_action_type = action_type # 不在这里记录,由 conversation 控制 - elif status == "recall": - last_action_context += "- 但该行动最终【未能执行/被取消】。\n" - if final_reason: - last_action_context += f"- 【重要】失败/取消的具体原因是: “{final_reason}”\n" - else: - last_action_context += "- 【重要】失败/取消原因未明确记录。\n" - # self.last_successful_action_type = None # 行动失败,清除记录 - else: - last_action_context += f"- 该行动当前状态: {status}\n" - # self.last_successful_action_type = None # 非完成状态,清除记录 - - retrieved_memory_str_planner, retrieved_knowledge_str_planner = await retrieve_contextual_info( - chat_history_text, self.private_name - ) - # Optional: 可以加一行日志确认结果,方便调试 - logger.info( - f"[私聊][{self.private_name}] (ActionPlanner) 统一检索完成。记忆: {'有' if '回忆起' in retrieved_memory_str_planner else '无'} / 知识: {'有' if '出错' not in retrieved_knowledge_str_planner and '无相关知识' not in retrieved_knowledge_str_planner else '无'}" - ) - - # --- 选择 Prompt --- - if last_successful_reply_action in ["direct_reply", "send_new_message"]: - prompt_template = PROMPT_FOLLOW_UP - logger.debug(f"[私聊][{self.private_name}]使用 PROMPT_FOLLOW_UP (追问决策)") - else: - prompt_template = PROMPT_INITIAL_REPLY - logger.debug(f"[私聊][{self.private_name}]使用 PROMPT_INITIAL_REPLY (首次/非连续回复决策)") - - # --- 格式化最终的 Prompt --- - prompt = prompt_template.format( - persona_text=persona_text, - goals_str=goals_str if goals_str.strip() else "- 目前没有明确对话目标,请考虑设定一个。", - action_history_summary=action_history_summary, - last_action_context=last_action_context, - time_since_last_bot_message_info=time_since_last_bot_message_info, - timeout_context=timeout_context, - chat_history_text=chat_history_text if chat_history_text.strip() else "还没有聊天记录。", - # knowledge_info_str=knowledge_info_str, # 移除了旧知识展示方式 - retrieved_memory_str=retrieved_memory_str_planner if retrieved_memory_str_planner else "无相关记忆。", - retrieved_knowledge_str=retrieved_knowledge_str_planner - if retrieved_knowledge_str_planner - else "无相关知识。", - ) - - logger.debug(f"[私聊][{self.private_name}]发送到LLM的最终提示词:\n------\n{prompt}\n------") + # --- 3. 调用 LLM 进行初步规划 --- try: + llm_start_time = time.time() content, _ = await self.llm.generate_response_async(prompt) - logger.debug(f"[私聊][{self.private_name}]LLM (行动规划) 原始返回内容: {content}") + llm_duration = time.time() - llm_start_time + logger.debug(f"[私聊][{self.private_name}] LLM (行动规划) 耗时: {llm_duration:.3f} 秒, 原始返回: {content}") - # --- 初始行动规划解析 --- success, initial_result = get_items_from_json( content, self.private_name, @@ -385,87 +223,254 @@ class ActionPlanner: 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}") + except Exception as llm_err: + logger.error(f"[私聊][{self.private_name}] 调用 LLM 或解析初步规划结果时出错: {llm_err}") + logger.error(traceback.format_exc()) + return "wait", f"行动规划 LLM 调用或解析出错: {llm_err}" - # 检查是否需要进行结束对话决策 --- - if initial_action == "end_conversation": - logger.info(f"[私聊][{self.private_name}]初步规划结束对话,进入告别决策...") + # --- 4. 处理特殊动作 (end_conversation) --- + final_action = initial_action + final_reason = initial_reason - # 使用新的 PROMPT_END_DECISION - end_decision_prompt = PROMPT_END_DECISION.format( - persona_text=persona_text, # 复用之前的 persona_text - chat_history_text=chat_history_text, # 复用之前的 chat_history_text + if initial_action == "end_conversation": + try: + final_action, final_reason = await self._handle_end_conversation_decision( + persona_text, chat_history_text, initial_reason ) + except Exception as end_dec_err: + logger.error(f"[私聊][{self.private_name}] 处理结束对话决策时出错: {end_dec_err}") + logger.warning(f"[私聊][{self.private_name}] 结束决策出错,将按原计划执行 end_conversation") + final_action = "end_conversation" # 保持原计划 + final_reason = initial_reason - logger.debug( - f"[私聊][{self.private_name}]发送到LLM的结束决策提示词:\n------\n{end_decision_prompt}\n------" - ) - try: - end_content, _ = await self.llm.generate_response_async(end_decision_prompt) # 再次调用LLM - logger.debug(f"[私聊][{self.private_name}]LLM (结束决策) 原始返回内容: {end_content}") + # --- [移除] 不再需要在这里检查 wait 动作的约束 --- + # elif initial_action == "wait": + # # ... (移除之前的检查逻辑) ... + # final_action = "wait" + # final_reason = initial_reason - # 解析结束决策的JSON - end_success, end_result = get_items_from_json( - end_content, - self.private_name, - "say_bye", - "reason", - default_values={"say_bye": "no", "reason": "结束决策LLM返回格式错误,默认不告别"}, - required_types={"say_bye": str, "reason": str}, # 明确类型 - ) + # --- 5. 验证最终行动类型 --- + valid_actions = [ + "direct_reply", + "send_new_message", + "wait", + "listening", + "rethink_goal", + "end_conversation", + "block_and_ignore", + "say_goodbye", + ] + if final_action not in valid_actions: + logger.warning(f"[私聊][{self.private_name}] LLM 返回了未知的行动类型: '{final_action}',强制改为 wait") + final_reason = f"(原始行动'{final_action}'无效,已强制改为wait) {final_reason}" + final_action = "wait" # 遇到无效动作,默认等待 - say_bye_decision = end_result.get("say_bye", "no").lower() # 转小写方便比较 - end_decision_reason = end_result.get("reason", "未提供原因") + plan_duration = time.time() - plan_start_time + logger.success(f"[私聊][{self.private_name}] 最终规划行动: {final_action} (总耗时: {plan_duration:.3f} 秒)") + logger.info(f"[私聊][{self.private_name}] 行动原因: {final_reason}") + return final_action, final_reason - if end_success and say_bye_decision == "yes": - # 决定要告别,返回新的 'say_goodbye' 动作 - logger.info( - f"[私聊][{self.private_name}]结束决策: yes, 准备生成告别语. 原因: {end_decision_reason}" - ) - # 注意:这里的 reason 可以考虑拼接初始原因和结束决策原因,或者只用结束决策原因 - final_action = "say_goodbye" - final_reason = f"决定发送告别语。决策原因: {end_decision_reason} (原结束理由: {initial_reason})" - return final_action, final_reason - else: - # 决定不告别 (包括解析失败或明确说no) - logger.info( - f"[私聊][{self.private_name}]结束决策: no, 直接结束对话. 原因: {end_decision_reason}" - ) - # 返回原始的 'end_conversation' 动作 - final_action = "end_conversation" - final_reason = initial_reason # 保持原始的结束理由 - return final_action, final_reason + # --- Helper methods for preparing prompt inputs --- - except Exception as end_e: - logger.error(f"[私聊][{self.private_name}]调用结束决策LLM或处理结果时出错: {str(end_e)}") - # 出错时,默认执行原始的结束对话 - logger.warning(f"[私聊][{self.private_name}]结束决策出错,将按原计划执行 end_conversation") - return "end_conversation", initial_reason # 返回原始动作和原因 - - else: - action = initial_action - reason = initial_reason - - # 验证action类型 (保持不变) - valid_actions = [ - "direct_reply", - "send_new_message", - "wait", - "listening", - "rethink_goal", - "end_conversation", # 仍然需要验证,因为可能从上面决策后返回 - "block_and_ignore", - "say_goodbye", # 也要验证这个新动作 - ] - if action not in valid_actions: - logger.warning(f"[私聊][{self.private_name}]LLM返回了未知的行动类型: '{action}',强制改为 wait") - reason = f"(原始行动'{action}'无效,已强制改为wait) {reason}" - action = "wait" - - logger.info(f"[私聊][{self.private_name}]规划的行动: {action}") - logger.info(f"[私聊][{self.private_name}]行动原因: {reason}") - return action, reason + def _get_bot_last_speak_time_info(self, observation_info: ObservationInfo) -> str: + """获取机器人上次发言时间提示""" + time_info = "" + try: + if not observation_info or not observation_info.bot_id: + return "" + bot_id_str = str(observation_info.bot_id) + if hasattr(observation_info, "chat_history") and observation_info.chat_history: + for msg in reversed(observation_info.chat_history): + if not isinstance(msg, dict): + continue + sender_info = msg.get("user_info", {}) + sender_id = str(sender_info.get("user_id")) if isinstance(sender_info, dict) else None + msg_time = msg.get("time") + if sender_id == bot_id_str and msg_time: + time_diff = time.time() - msg_time + if time_diff < 60.0: + time_info = f"提示:你上一条成功发送的消息是在 {time_diff:.1f} 秒前。\n" + break + except AttributeError as e: + logger.warning(f"[私聊][{self.private_name}] 获取 Bot 上次发言时间时属性错误: {e}") except Exception as e: - # 外层异常处理保持不变 - logger.error(f"[私聊][{self.private_name}]规划行动时调用 LLM 或处理结果出错: {str(e)}") - return "wait", f"行动规划处理中发生错误,暂时等待: {str(e)}" + logger.warning(f"[私聊][{self.private_name}] 获取 Bot 上次发言时间时出错: {e}") + return time_info + + def _get_timeout_context(self, conversation_info: ConversationInfo) -> str: + """获取超时提示信息""" + + timeout_context = "" + try: + if hasattr(conversation_info, "goal_list") and conversation_info.goal_list: + last_goal_item = conversation_info.goal_list[-1] + last_goal_text = "" + if isinstance(last_goal_item, dict): + last_goal_text = last_goal_item.get("goal", "") + elif isinstance(last_goal_item, str): + last_goal_text = last_goal_item + if ( + isinstance(last_goal_text, str) + and "分钟," in last_goal_text + and "思考接下来要做什么" in last_goal_text + ): + wait_time_str = last_goal_text.split("分钟,")[0].replace("你等待了", "").strip() + timeout_context = f"重要提示:对方已经长时间(约 {wait_time_str} 分钟)没有回复你的消息了,请基于此情况规划下一步。\n" + logger.debug(f"[私聊][{self.private_name}] 检测到超时目标: {last_goal_text}") + except AttributeError as e: + logger.warning(f"[私聊][{self.private_name}] 检查超时目标时属性错误: {e}") + except Exception as e: + logger.warning(f"[私聊][{self.private_name}] 检查超时目标时出错: {e}") + return timeout_context + + def _build_goals_string(self, conversation_info: ConversationInfo) -> str: + """构建对话目标字符串""" + + goals_str = "" + try: + if hasattr(conversation_info, "goal_list") and conversation_info.goal_list: + recent_goals = conversation_info.goal_list[-3:] + for goal_item in recent_goals: + goal = "目标内容缺失" + reasoning = "没有明确原因" + if isinstance(goal_item, dict): + goal = goal_item.get("goal", goal) + reasoning = goal_item.get("reasoning", reasoning) + elif isinstance(goal_item, str): + goal = goal_item + goal = str(goal) if goal is not None else "目标内容缺失" + reasoning = str(reasoning) if reasoning is not None else "没有明确原因" + goals_str += f"- 目标:{goal}\n 原因:{reasoning}\n" + if not goals_str: + goals_str = "- 目前没有明确对话目标,请考虑设定一个。\n" + else: + goals_str = "- 目前没有明确对话目标,请考虑设定一个。\n" + except AttributeError as e: + logger.warning(f"[私聊][{self.private_name}] 构建对话目标字符串时属性错误: {e}") + goals_str = "- 获取对话目标时出错。\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}] 构建对话目标字符串时出错: {e}") + goals_str = "- 构建对话目标时出错。\n" + return goals_str + + async def _build_chat_history_text(self, observation_info: ObservationInfo) -> 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:] + 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--- 以下是 {other_unread_count} 条你需要处理的新消息 ---\n{new_messages_str}\n------\n" + ) + logger.debug(f"[私聊][{self.private_name}] 向 LLM 追加了 {other_unread_count} 条未读消息。") + except AttributeError as e: + logger.warning(f"[私聊][{self.private_name}] 构建聊天记录文本时属性错误: {e}") + chat_history_text = "[获取聊天记录时出错]\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}] 处理聊天记录时发生未知错误: {e}") + chat_history_text = "[处理聊天记录时出错]\n" + return chat_history_text + + def _build_action_history_context(self, conversation_info: ConversationInfo) -> Tuple[str, str]: + """构建行动历史概要和上一次行动详细情况""" + + action_history_summary = "你最近执行的行动历史:\n" + last_action_context = "关于你【上一次尝试】的行动:\n" + action_history_list: List[Dict[str, Any]] = [] + try: + if hasattr(conversation_info, "done_action") and conversation_info.done_action: + action_history_list = conversation_info.done_action[-5:] + except AttributeError as e: + logger.warning(f"[私聊][{self.private_name}] 获取行动历史时属性错误: {e}") + except Exception as e: + logger.error(f"[私聊][{self.private_name}] 访问行动历史时出错: {e}") + if not action_history_list: + action_history_summary += "- 还没有执行过行动。\n" + last_action_context += "- 这是你规划的第一个行动。\n" + else: + for i, action_data in enumerate(action_history_list): + if not isinstance(action_data, dict): + logger.warning(f"[私聊][{self.private_name}] 行动历史记录格式错误,跳过: {action_data}") + continue + action_type = action_data.get("action", "未知动作") + plan_reason = action_data.get("plan_reason", "未知规划原因") + status = action_data.get("status", "未知状态") + final_reason = action_data.get("final_reason", "") + action_time = action_data.get("time", "未知时间") + reason_text = f", 最终原因: “{final_reason}”" if final_reason else "" + summary_line = f"- 时间:{action_time}, 尝试:'{action_type}', 状态:{status}{reason_text}" + action_history_summary += summary_line + "\n" + if i == len(action_history_list) - 1: + last_action_context += f"- 上次【规划】的行动是: '{action_type}'\n" + last_action_context += f"- 当时规划的【原因】是: {plan_reason}\n" + if status == "done": + last_action_context += "- 该行动已【成功执行】。\n" + elif status == "recall" or status == "error" or status.startswith("cancelled"): + last_action_context += "- 但该行动最终【未能成功执行/被取消/出错】。\n" + if final_reason: + last_action_context += f"- 【重要】失败/取消/错误原因是: “{final_reason}”\n" + else: + last_action_context += "- 【重要】失败/取消/错误原因未明确记录。\n" + elif status == "start": + last_action_context += "- 该行动【正在执行中】或【未完成】。\n" + else: + last_action_context += f"- 该行动当前状态未知: {status}\n" + return action_history_summary, last_action_context + + # --- Helper method for handling end_conversation decision --- + + async def _handle_end_conversation_decision( + self, persona_text: str, chat_history_text: str, initial_reason: str + ) -> Tuple[str, str]: + """处理结束对话前的告别决策""" + logger.info(f"[私聊][{self.private_name}] 初步规划结束对话,进入告别决策...") + end_decision_prompt = PROMPT_END_DECISION.format(persona_text=persona_text, chat_history_text=chat_history_text) + logger.debug(f"[私聊][{self.private_name}] 发送到LLM的结束决策提示词:\n------\n{end_decision_prompt}\n------") + llm_start_time = time.time() + end_content, _ = await self.llm.generate_response_async(end_decision_prompt) + llm_duration = time.time() - llm_start_time + logger.debug(f"[私聊][{self.private_name}] LLM (结束决策) 耗时: {llm_duration:.3f} 秒, 原始返回: {end_content}") + end_success, end_result = get_items_from_json( + end_content, + self.private_name, + "say_bye", + "reason", + default_values={"say_bye": "no", "reason": "结束决策LLM返回格式错误,默认不告别"}, + required_types={"say_bye": str, "reason": str}, + ) + say_bye_decision = end_result.get("say_bye", "no").lower() + end_decision_reason = end_result.get("reason", "未提供原因") + if end_success and say_bye_decision == "yes": + logger.info(f"[私聊][{self.private_name}] 结束决策: yes, 准备生成告别语. 原因: {end_decision_reason}") + final_action = "say_goodbye" + final_reason = f"决定发送告别语 (原因: {end_decision_reason})。原结束理由: {initial_reason}" + return final_action, final_reason + else: + logger.info(f"[私聊][{self.private_name}] 结束决策: no, 直接结束对话. 原因: {end_decision_reason}") + return "end_conversation", initial_reason diff --git a/src/plugins/PFC/conversation.py b/src/plugins/PFC/conversation.py index 5bb50706..f3abea51 100644 --- a/src/plugins/PFC/conversation.py +++ b/src/plugins/PFC/conversation.py @@ -1,607 +1,1045 @@ -# -*- coding: utf-8 -*- -# File: conversation.py import time import asyncio import datetime +import traceback +from typing import Dict, Any, Optional, Set, List -from src.plugins.utils.chat_message_builder import build_readable_messages, get_raw_msg_before_timestamp_with_chat -from typing import Dict, Any, Optional, Set # <-- 添加 Set 类型提示 -from ..chat.message import Message -from .pfc_types import ConversationState -from .pfc import ChatObserver, GoalAnalyzer -from .message_sender import DirectMessageSender +# 导入日志记录器 from src.common.logger_manager import get_logger + +# 导入聊天消息构建和获取工具 +from src.plugins.utils.chat_message_builder import build_readable_messages, get_raw_msg_before_timestamp_with_chat + +# 导入消息相关的数据结构 +from maim_message import UserInfo + +# 导入聊天流管理器和聊天流类 +from src.plugins.chat.chat_stream import chat_manager, ChatStream + +# 导入聊天消息类 +from ..chat.message import Message # 假设 Message 类在这里 + +# 导入全局配置 +from ...config.config import global_config + +# 导入 PFC 内部组件和类型 +from .pfc_types import ConversationState # 导入更新后的 pfc_types +from .pfc import GoalAnalyzer # 假设 GoalAnalyzer 在 pfc.py +from .chat_observer import ChatObserver +from .message_sender import DirectMessageSender from .action_planner import ActionPlanner from .observation_info import ObservationInfo from .conversation_info import ConversationInfo from .reply_generator import ReplyGenerator -from ..chat.chat_stream import ChatStream -from maim_message import UserInfo -from src.plugins.chat.chat_stream import chat_manager -from .pfc_KnowledgeFetcher import KnowledgeFetcher +from .idle_conversation_starter import IdleConversationStarter +from .pfc_KnowledgeFetcher import KnowledgeFetcher # 假设 KnowledgeFetcher 在这里 from .waiter import Waiter +from .reply_checker import ReplyChecker # 确保 ReplyChecker 被导入 -import traceback +# 导入富文本回溯,用于更好的错误展示 from rich.traceback import install install(extra_lines=3) -logger = get_logger("pfc") +# 获取当前模块的日志记录器 +logger = get_logger("pfc_conversation") class Conversation: - """对话类,负责管理单个对话的状态和行为""" + """ + 对话类,负责管理单个私聊对话的状态和核心逻辑流程。 + 包含对话的初始化、启动、停止、规划循环以及动作处理。 + """ def __init__(self, stream_id: str, private_name: str): - """初始化对话实例""" - self.stream_id = stream_id - self.private_name = private_name - self.state = ConversationState.INIT - self.should_continue = False - self.ignore_until_timestamp: Optional[float] = None - self.generated_reply = "" + """ + 初始化对话实例。 + + Args: + stream_id (str): 唯一的聊天流 ID。 + private_name (str): 私聊对象的名称,用于日志和区分。 + """ + self.stream_id: str = stream_id + self.private_name: str = private_name + self.state: ConversationState = ConversationState.INIT # 对话的初始状态 + self.should_continue: bool = False # 标记对话循环是否应该继续运行 + self.ignore_until_timestamp: Optional[float] = None # 如果设置了,忽略此时间戳之前的活动 + self.generated_reply: str = "" # 存储最近生成的回复内容 + self.chat_stream: Optional[ChatStream] = None # 关联的聊天流对象 + + # 初始化所有核心组件为 None,将在 _initialize 中创建 + self.action_planner: Optional[ActionPlanner] = None + self.goal_analyzer: Optional[GoalAnalyzer] = None + self.reply_generator: Optional[ReplyGenerator] = None + self.knowledge_fetcher: Optional[KnowledgeFetcher] = None + self.waiter: Optional[Waiter] = None + self.direct_sender: Optional[DirectMessageSender] = None + self.idle_conversation_starter: Optional[IdleConversationStarter] = None + self.chat_observer: Optional[ChatObserver] = None + self.observation_info: Optional[ObservationInfo] = None + self.conversation_info: Optional[ConversationInfo] = None + self.reply_checker: Optional[ReplyChecker] = None # 回复检查器 + + # 内部状态标志 + self._initializing: bool = False # 标记是否正在初始化,防止并发问题 + self._initialized: bool = False # 标记是否已成功初始化 + + # 缓存机器人自己的 QQ 号字符串,避免重复转换 + self.bot_qq_str: Optional[str] = str(global_config.BOT_QQ) if global_config.BOT_QQ else None + if not self.bot_qq_str: + # 这是一个严重问题,记录错误 + logger.error(f"[私聊][{self.private_name}] 严重错误:未能从配置中获取 BOT_QQ ID!PFC 可能无法正常工作。") async def _initialize(self): - """初始化实例,注册所有组件 (保持不变)""" - try: - self.action_planner = ActionPlanner(self.stream_id, self.private_name) - self.goal_analyzer = GoalAnalyzer(self.stream_id, self.private_name) - self.reply_generator = ReplyGenerator(self.stream_id, self.private_name) - self.knowledge_fetcher = KnowledgeFetcher(self.private_name) - self.waiter = Waiter(self.stream_id, self.private_name) - self.direct_sender = DirectMessageSender(self.private_name) - self.chat_stream = chat_manager.get_stream(self.stream_id) - self.stop_action_planner = False - except Exception as e: - logger.error(f"[私聊][{self.private_name}]初始化对话实例:注册运行组件失败: {e}") - logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") - raise + """ + 异步初始化对话实例及其所有依赖的核心组件。 + 这是一个关键步骤,确保所有部分都准备就绪才能开始对话循环。 + """ + # 防止重复初始化 + if self._initialized or self._initializing: + logger.warning(f"[私聊][{self.private_name}] 尝试重复初始化或正在初始化中。") + return + + self._initializing = True # 标记开始初始化 + logger.info(f"[私聊][{self.private_name}] 开始初始化对话实例: {self.stream_id}") try: + # 1. 初始化核心功能组件 + logger.debug(f"[私聊][{self.private_name}] 初始化 ActionPlanner...") + self.action_planner = ActionPlanner(self.stream_id, self.private_name) + + logger.debug(f"[私聊][{self.private_name}] 初始化 GoalAnalyzer...") + self.goal_analyzer = GoalAnalyzer(self.stream_id, self.private_name) + + logger.debug(f"[私聊][{self.private_name}] 初始化 ReplyGenerator...") + self.reply_generator = ReplyGenerator(self.stream_id, self.private_name) + + logger.debug(f"[私聊][{self.private_name}] 初始化 KnowledgeFetcher...") + self.knowledge_fetcher = KnowledgeFetcher(self.private_name) + + logger.debug(f"[私聊][{self.private_name}] 初始化 Waiter...") + self.waiter = Waiter(self.stream_id, self.private_name) + + logger.debug(f"[私聊][{self.private_name}] 初始化 DirectMessageSender...") + self.direct_sender = DirectMessageSender(self.private_name) + + logger.debug(f"[私聊][{self.private_name}] 初始化 ReplyChecker...") + self.reply_checker = ReplyChecker(self.stream_id, self.private_name) + + # 获取关联的 ChatStream + logger.debug(f"[私聊][{self.private_name}] 获取 ChatStream...") + self.chat_stream = chat_manager.get_stream(self.stream_id) + if not self.chat_stream: + # 获取不到 ChatStream 是一个严重问题,因为无法发送消息 + logger.error( + f"[私聊][{self.private_name}] 初始化错误:无法从 chat_manager 获取 stream_id {self.stream_id} 的 ChatStream。" + ) + raise ValueError(f"无法获取 stream_id {self.stream_id} 的 ChatStream") + + # 初始化空闲对话启动器 + logger.debug(f"[私聊][{self.private_name}] 初始化 IdleConversationStarter...") + self.idle_conversation_starter = IdleConversationStarter(self.stream_id, self.private_name) + + # 2. 初始化信息存储和观察组件 + logger.debug(f"[私聊][{self.private_name}] 获取 ChatObserver 实例...") self.chat_observer = ChatObserver.get_instance(self.stream_id, self.private_name) - self.chat_observer.start() + + logger.debug(f"[私聊][{self.private_name}] 初始化 ObservationInfo...") self.observation_info = ObservationInfo(self.private_name) - self.observation_info.bind_to_chat_observer(self.chat_observer) + # 确保 ObservationInfo 知道机器人的 ID + if not self.observation_info.bot_id: + logger.warning(f"[私聊][{self.private_name}] ObservationInfo 未能自动获取 bot_id,尝试手动设置。") + self.observation_info.bot_id = self.bot_qq_str + + logger.debug(f"[私聊][{self.private_name}] 初始化 ConversationInfo...") self.conversation_info = ConversationInfo() + + # 3. 绑定观察者和信息处理器 + logger.debug(f"[私聊][{self.private_name}] 绑定 ObservationInfo 到 ChatObserver...") + self.observation_info.bind_to_chat_observer(self.chat_observer) + + # 4. 加载初始聊天记录 + await self._load_initial_history() + + # 5. 启动需要后台运行的组件 + logger.debug(f"[私聊][{self.private_name}] 启动 ChatObserver...") + self.chat_observer.start() + if self.idle_conversation_starter: + logger.debug(f"[私聊][{self.private_name}] 启动 IdleConversationStarter...") + self.idle_conversation_starter.start() + logger.info(f"[私聊][{self.private_name}] 空闲对话检测器已启动") + + # 6. 标记初始化成功并设置运行状态 + self._initialized = True + self.should_continue = True # 初始化成功,标记可以继续运行循环 + self.state = ConversationState.ANALYZING # 设置初始状态为分析 + + logger.info(f"[私聊][{self.private_name}] 对话实例 {self.stream_id} 初始化完成。") + except Exception as e: - logger.error(f"[私聊][{self.private_name}]初始化对话实例:注册信息组件失败: {e}") - logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") - raise + # 捕获初始化过程中的任何异常 + logger.error(f"[私聊][{self.private_name}] 初始化对话实例失败: {e}") + logger.error(f"[私聊][{self.private_name}] {traceback.format_exc()}") + self.should_continue = False # 初始化失败,标记不能继续 + self._initialized = False # 确保标记为未初始化 + # 尝试停止可能部分启动的组件 + await self.stop() + raise # 将异常重新抛出,通知调用者初始化失败 + finally: + # 无论成功与否,都要清除正在初始化的标记 + self._initializing = False + + async def _load_initial_history(self): + """加载并处理初始的聊天记录""" + if not self.observation_info: + logger.warning(f"[私聊][{self.private_name}] ObservationInfo 未初始化,无法加载历史记录。") + return + try: - logger.info(f"[私聊][{self.private_name}]为 {self.stream_id} 加载初始聊天记录...") + logger.info(f"[私聊][{self.private_name}] 为 {self.stream_id} 加载初始聊天记录...") + # 从聊天核心获取原始消息列表 initial_messages = get_raw_msg_before_timestamp_with_chat( chat_id=self.stream_id, timestamp=time.time(), - limit=30, - ) - chat_talking_prompt = await build_readable_messages( - initial_messages, - replace_bot_name=True, - merge_messages=False, - timestamp_mode="relative", - read_mark=0.0, + limit=30, # limit 可以根据需要调整或配置 ) + if initial_messages: + # 更新 ObservationInfo 中的历史记录列表和计数 self.observation_info.chat_history = initial_messages - self.observation_info.chat_history_str = chat_talking_prompt + "\n" self.observation_info.chat_history_count = len(initial_messages) + + # 获取最后一条消息的信息 last_msg = initial_messages[-1] self.observation_info.last_message_time = last_msg.get("time") - last_user_info = UserInfo.from_dict(last_msg.get("user_info", {})) - self.observation_info.last_message_sender = last_user_info.user_id + self.observation_info.last_message_id = last_msg.get("message_id") + + # 安全地解析最后一条消息的发送者信息 + last_user_info_dict = last_msg.get("user_info", {}) + if isinstance(last_user_info_dict, dict): + try: + last_user_info = UserInfo.from_dict(last_user_info_dict) + # 存储发送者的 user_id 字符串 + self.observation_info.last_message_sender = ( + str(last_user_info.user_id) if last_user_info else None + ) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}] 解析最后一条消息的用户信息时出错: {e}") + self.observation_info.last_message_sender = None + else: + # 如果 user_info 不是字典,也标记为未知 + self.observation_info.last_message_sender = None + + # 存储最后一条消息的文本内容 self.observation_info.last_message_content = last_msg.get("processed_plain_text", "") - logger.info( - f"[私聊][{self.private_name}]成功加载 {len(initial_messages)} 条初始聊天记录。最后一条消息时间: {self.observation_info.last_message_time}" + + # 构建用于 Prompt 的历史记录字符串 (只使用最近的一部分) + history_slice_for_str = initial_messages[-20:] # 可配置 + self.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, # read_mark 可能需要根据实际情况调整 + ) + + # 更新 ChatObserver 和 IdleStarter 的时间戳 + if self.chat_observer: + # 更新观察者的最后消息时间,避免重复处理这些初始消息 + self.chat_observer.last_message_time = self.observation_info.last_message_time + if self.idle_conversation_starter and self.observation_info.last_message_time: + # 更新空闲计时器的起始时间 + await self.idle_conversation_starter.update_last_message_time( + self.observation_info.last_message_time + ) + + logger.info( + f"[私聊][{self.private_name}] 成功加载 {len(initial_messages)} 条初始聊天记录。最后一条消息时间: {self.observation_info.last_message_time}" ) - # --- 注意: 下面两行保持不变,但其健壮性依赖于 ChatObserver 的实现 --- - self.chat_observer.last_message_time = self.observation_info.last_message_time - self.chat_observer.last_message_read = last_msg else: - logger.info(f"[私聊][{self.private_name}]没有找到初始聊天记录。") + # 如果没有历史记录 + logger.info(f"[私聊][{self.private_name}] 没有找到初始聊天记录。") + self.observation_info.chat_history_str = "还没有聊天记录。" # 设置默认提示 except Exception as load_err: - logger.error(f"[私聊][{self.private_name}]加载初始聊天记录时出错: {load_err}") - - self.should_continue = True - asyncio.create_task(self.start()) + # 捕获加载过程中的异常 + logger.error(f"[私聊][{self.private_name}] 加载初始聊天记录时出错: {load_err}") + # 即使出错,也设置一个提示,避免后续使用 None 值 + if self.observation_info: + self.observation_info.chat_history_str = "[加载聊天记录出错]" async def start(self): - """开始对话流程 (保持不变)""" + """ + 启动对话流程。 + 会检查实例是否已初始化,如果未初始化会尝试初始化。 + 成功后,创建并启动核心的规划与行动循环 (`_plan_and_action_loop`)。 + """ + # 检查是否已初始化,如果未初始化则尝试进行初始化 + if not self._initialized: + logger.warning(f"[私聊][{self.private_name}] 对话实例未初始化,尝试初始化...") + try: + await self._initialize() + # 在尝试初始化后,再次检查状态 + if not self._initialized: + logger.error(f"[私聊][{self.private_name}] 初始化失败,无法启动规划循环。") + return # 初始化失败,明确停止 + except Exception as init_err: + logger.error(f"[私聊][{self.private_name}] 初始化过程中发生未捕获错误: {init_err},无法启动。") + return # 初始化异常,明确停止 + + # 再次检查 should_continue 标志,确保初始化成功且未被外部停止 + if not self.should_continue: + logger.warning( + f"[私聊][{self.private_name}] 对话实例已被标记为不应继续 (可能由于初始化失败或已被停止),无法启动规划循环。" + ) + return + + logger.info(f"[私聊][{self.private_name}] 对话系统启动,准备创建规划循环任务...") + # 使用 asyncio.create_task 在后台启动主循环 try: - logger.info(f"[私聊][{self.private_name}]对话系统启动中...") - asyncio.create_task(self._plan_and_action_loop()) - except Exception as e: - logger.error(f"[私聊][{self.private_name}]启动对话系统失败: {e}") - raise + logger.debug(f"[私聊][{self.private_name}] 正在创建 _plan_and_action_loop 任务...") + # 创建任务,但不等待其完成,让它在后台运行 + _loop_task = asyncio.create_task(self._plan_and_action_loop()) + # 可以选择性地添加完成回调来处理任务结束或异常 + # loop_task.add_done_callback(self._handle_loop_completion) + logger.info(f"[私聊][{self.private_name}] 规划循环任务已创建。") + except Exception as task_err: + logger.error(f"[私聊][{self.private_name}] 创建规划循环任务时出错: {task_err}") + # 如果创建任务失败,可能需要停止实例 + await self.stop() + + async def stop(self): + """ + 停止对话实例并清理相关资源。 + 会停止后台任务、解绑观察者等。 + """ + logger.info(f"[私聊][{self.private_name}] 正在停止对话实例: {self.stream_id}") + self.should_continue = False # 设置标志,让主循环退出 + + # 停止空闲对话检测器 + if self.idle_conversation_starter: + self.idle_conversation_starter.stop() + + # 解绑 ObservationInfo 与 ChatObserver + if self.observation_info and self.chat_observer: + self.observation_info.unbind_from_chat_observer() + + # ChatObserver 是单例,通常不由单个 Conversation 停止 + # 如果需要,可以在管理器层面处理 ChatObserver 的生命周期 + + # 标记为未初始化 + self._initialized = False + logger.info(f"[私聊][{self.private_name}] 对话实例 {self.stream_id} 已停止。") async def _plan_and_action_loop(self): - """思考步,PFC核心循环模块""" + """ + 核心的规划与行动循环 (PFC Loop)。 + 持续运行,根据当前状态规划下一步行动,处理新消息中断,执行动作,直到被停止。 + """ + logger.info(f"[私聊][{self.private_name}] 进入 _plan_and_action_loop 循环。") + + # 循环前再次确认初始化状态 + if not self._initialized: + logger.error(f"[私聊][{self.private_name}] 尝试在未初始化状态下运行规划循环,退出。") + return # 明确退出 + + # 主循环,只要 should_continue 为 True 就一直运行 while self.should_continue: - # 忽略逻辑 (保持不变) - if self.ignore_until_timestamp and time.time() < self.ignore_until_timestamp: - await asyncio.sleep(30) - continue - elif self.ignore_until_timestamp and time.time() >= self.ignore_until_timestamp: - logger.info(f"[私聊][{self.private_name}]忽略时间已到 {self.stream_id},准备结束对话。") - self.ignore_until_timestamp = None - self.should_continue = False - continue + loop_iter_start_time = time.time() # 记录本次循环开始时间 + logger.debug(f"[私聊][{self.private_name}] 开始新一轮循环迭代 ({loop_iter_start_time:.2f})") + # --- 处理忽略状态 --- + if self.ignore_until_timestamp and loop_iter_start_time < self.ignore_until_timestamp: + # 如果当前处于忽略状态 + if self.idle_conversation_starter and self.idle_conversation_starter._running: + # 暂停空闲检测器 + self.idle_conversation_starter.stop() + logger.debug(f"[私聊][{self.private_name}] 对话被暂时忽略,暂停空闲对话检测") + # 计算需要睡眠的时间,最多30秒或直到忽略结束 + sleep_duration = min(30, self.ignore_until_timestamp - loop_iter_start_time) + await asyncio.sleep(sleep_duration) + continue # 跳过本次循环的后续步骤,直接进入下一次迭代检查 + elif self.ignore_until_timestamp and loop_iter_start_time >= self.ignore_until_timestamp: + # 如果忽略时间已到 + logger.info(f"[私聊][{self.private_name}] 忽略时间已到 {self.stream_id},准备结束对话。") + self.ignore_until_timestamp = None # 清除忽略时间戳 + await self.stop() # 调用 stop 方法来结束整个对话实例 + continue # 跳过本次循环的后续步骤 + else: + # 如果不在忽略状态,确保空闲检测器在运行 + if self.idle_conversation_starter and not self.idle_conversation_starter._running: + self.idle_conversation_starter.start() + logger.debug(f"[私聊][{self.private_name}] 恢复空闲对话检测") + + # --- 核心规划与行动逻辑 --- try: - # --- [修改点 1] 在规划前记录未处理消息的 ID 集合 --- - message_ids_before_planning = set() - initial_unprocessed_message_count = 0 - if hasattr(self.observation_info, "unprocessed_messages"): - message_ids_before_planning = {msg.get("message_id") for msg in self.observation_info.unprocessed_messages if msg.get("message_id")} - initial_unprocessed_message_count = len(self.observation_info.unprocessed_messages) - logger.debug(f"[私聊][{self.private_name}]规划开始,当前未处理消息数: {initial_unprocessed_message_count}, IDs: {message_ids_before_planning}") - else: - logger.warning(f"[私聊][{self.private_name}]ObservationInfo missing 'unprocessed_messages' before planning.") + # 1. 检查核心组件是否都已初始化 + if not all([self.action_planner, self.observation_info, self.conversation_info]): + logger.error(f"[私聊][{self.private_name}] 核心组件未初始化,无法继续规划循环。将等待5秒后重试...") + await asyncio.sleep(5) + continue # 跳过本次迭代 - # --- 调用 Action Planner (保持不变) --- + # 2. 记录规划开始时间并重置临时状态 + planning_start_time = time.time() + logger.debug(f"[私聊][{self.private_name}] --- 开始规划 ({planning_start_time:.2f}) ---") + # 重置上一轮存储的“规划期间他人新消息数” + self.conversation_info.other_new_messages_during_planning_count = 0 + + # 3. 调用 ActionPlanner 进行规划 + logger.debug(f"[私聊][{self.private_name}] 调用 ActionPlanner.plan...") + # 传入当前观察信息、对话信息和上次成功回复的动作类型 action, reason = await self.action_planner.plan( self.observation_info, self.conversation_info, self.conversation_info.last_successful_reply_action ) + planning_duration = time.time() - planning_start_time + logger.debug( + f"[私聊][{self.private_name}] ActionPlanner.plan 完成 (耗时: {planning_duration:.3f} 秒),初步规划动作: {action}" + ) - # --- [修改点 2] 规划后检查是否有 *过多* 新消息到达 --- - current_unprocessed_messages = [] - current_unprocessed_message_count = 0 - if hasattr(self.observation_info, "unprocessed_messages"): - current_unprocessed_messages = self.observation_info.unprocessed_messages - current_unprocessed_message_count = len(current_unprocessed_messages) - else: - logger.warning(f"[私聊][{self.private_name}]ObservationInfo missing 'unprocessed_messages' after planning.") + # 4. 检查规划期间是否有新消息到达 + current_unprocessed_messages = getattr(self.observation_info, "unprocessed_messages", []) + new_messages_during_planning: List[Dict[str, Any]] = [] + other_new_messages_during_planning: List[Dict[str, Any]] = [] - # 计算规划期间实际新增的消息数量 - new_messages_during_planning_count = 0 + # 遍历当前所有未处理的消息 for msg in current_unprocessed_messages: - msg_id = msg.get("message_id") - if msg_id and msg_id not in message_ids_before_planning: - new_messages_during_planning_count += 1 + msg_time = msg.get("time") + sender_id = msg.get("user_info", {}).get("user_id") + # 检查消息时间是否在本次规划开始之后 + if msg_time and msg_time >= planning_start_time: + new_messages_during_planning.append(msg) + # 同时检查是否是来自他人的消息 + if sender_id != self.bot_qq_str: + other_new_messages_during_planning.append(msg) - logger.debug(f"[私聊][{self.private_name}]规划结束,当前未处理消息数: {current_unprocessed_message_count}, 规划期间新增: {new_messages_during_planning_count}") + new_msg_count = len(new_messages_during_planning) # 规划期间所有新消息数 + other_new_msg_count = len(other_new_messages_during_planning) # 规划期间他人新消息数 + logger.debug( + f"[私聊][{self.private_name}] 规划期间收到新消息总数: {new_msg_count}, 来自他人: {other_new_msg_count}" + ) - # **核心逻辑:判断是否中断** (保持不变) - if new_messages_during_planning_count > 2: - logger.info( - f"[私聊][{self.private_name}]规划期间新增消息数 ({new_messages_during_planning_count}) 超过阈值(2),取消本次行动 '{action}',重新规划" - ) + # 5. 根据动作类型和新消息数量,判断是否需要中断当前规划 + should_interrupt: bool = False + interrupt_reason: str = "" + + if action in ["wait", "listening"]: + # 规则:对于 wait/listen,任何新消息(无论来自谁)都应该中断 + if new_msg_count > 0: + should_interrupt = True + interrupt_reason = f"规划 {action} 期间收到 {new_msg_count} 条新消息" + logger.info(f"[私聊][{self.private_name}] 中断 '{action}',原因: {interrupt_reason}。") + else: + # 规则:对于其他动作,检查来自他人的新消息是否超过阈值 2 + interrupt_threshold: int = 2 + if other_new_msg_count > interrupt_threshold: + should_interrupt = True + interrupt_reason = f"规划 {action} 期间收到 {other_new_msg_count} 条来自他人的新消息 (阈值 >{interrupt_threshold})" + logger.info(f"[私聊][{self.private_name}] 中断 '{action}',原因: {interrupt_reason}。") + + # 6. 如果需要中断,则记录取消信息,重置状态,并进入下一次循环 + if should_interrupt: + logger.info(f"[私聊][{self.private_name}] 执行中断,重新规划...") + # 记录被取消的动作到历史记录 + cancel_record = { + "action": action, + "plan_reason": reason, + "status": "cancelled_due_to_new_messages", # 标记取消原因 + "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "final_reason": interrupt_reason, + } + # 安全地添加到 done_action 列表 + if not hasattr(self.conversation_info, "done_action"): + self.conversation_info.done_action = [] + self.conversation_info.done_action.append(cancel_record) + + # 重置追问状态,因为当前动作被中断了 self.conversation_info.last_successful_reply_action = None - await asyncio.sleep(0.1) - continue # 跳过本轮后续处理,直接进入下一轮循环重新规划 + # 将状态设置回分析,准备处理新消息并重新规划 + self.state = ConversationState.ANALYZING + await asyncio.sleep(0.1) # 短暂等待,避免CPU空转 + continue # 直接进入下一次循环迭代 - # --- 执行动作 (移除 message_ids_before_planning 参数传递) --- + # 7. 如果未中断,存储规划期间的他人新消息数,并执行动作 + logger.debug(f"[私聊][{self.private_name}] 未中断,调用 _handle_action 执行动作 '{action}'...") + # 将计算出的“规划期间他人新消息数”存入 conversation_info,供 _handle_action 使用 + self.conversation_info.other_new_messages_during_planning_count = other_new_msg_count + # 调用动作处理函数 await self._handle_action(action, reason, self.observation_info, self.conversation_info) + logger.debug(f"[私聊][{self.private_name}] _handle_action 完成。") - # --- 检查是否需要结束对话 (逻辑保持不变) --- - goal_ended = False + # 8. 检查是否需要结束整个对话(例如目标达成或执行了结束动作) + goal_ended: bool = False + # 检查最新的目标是否是“结束对话” if hasattr(self.conversation_info, "goal_list") and self.conversation_info.goal_list: - for goal_item in self.conversation_info.goal_list: - current_goal = None - if isinstance(goal_item, dict): - current_goal = goal_item.get("goal") - elif isinstance(goal_item, str): - current_goal = goal_item - if isinstance(current_goal, str) and current_goal == "结束对话": - goal_ended = True - break - if goal_ended: - self.should_continue = False - logger.info(f"[私聊][{self.private_name}]检测到'结束对话'目标,停止循环。") + last_goal_item = self.conversation_info.goal_list[-1] + current_goal: Optional[str] = None + if isinstance(last_goal_item, dict): + current_goal = last_goal_item.get("goal") + elif isinstance(last_goal_item, str): + current_goal = last_goal_item + if isinstance(current_goal, str) and current_goal == "结束对话": + goal_ended = True + # 检查最后执行的动作是否是结束类型且成功完成 + last_action_record = ( + self.conversation_info.done_action[-1] if self.conversation_info.done_action else {} + ) + action_ended: bool = ( + last_action_record.get("action") in ["end_conversation", "say_goodbye"] + and last_action_record.get("status") == "done" + ) + + # 如果满足任一结束条件,则停止循环 + if goal_ended or action_ended: + logger.info( + f"[私聊][{self.private_name}] 检测到结束条件 (目标结束: {goal_ended}, 动作结束: {action_ended}),停止循环。" + ) + await self.stop() # 调用 stop 来停止实例 + continue # 跳过后续,虽然 stop 会设置 should_continue=False + + except asyncio.CancelledError: + # 处理任务被取消的情况 + logger.info(f"[私聊][{self.private_name}] PFC 主循环任务被取消。") + await self.stop() # 确保资源被清理 + break # 明确退出循环 except Exception as loop_err: - logger.error(f"[私聊][{self.private_name}]PFC主循环出错: {loop_err}") - logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") - await asyncio.sleep(1) + # 捕获循环中的其他未预期错误 + logger.error(f"[私聊][{self.private_name}] PFC 主循环出错: {loop_err}") + logger.error(f"[私聊][{self.private_name}] {traceback.format_exc()}") + self.state = ConversationState.ERROR # 设置错误状态 + # 可以在这里添加更复杂的错误恢复逻辑,或者简单等待后重试 + await asyncio.sleep(5) # 等待一段时间,避免错误状态下快速空转 - if self.should_continue: - await asyncio.sleep(0.1) + # --- 控制循环频率 --- + loop_duration = time.time() - loop_iter_start_time # 计算本次循环耗时 + min_loop_interval = 0.1 # 设置最小循环间隔(秒),防止CPU占用过高 + logger.debug(f"[私聊][{self.private_name}] 循环迭代耗时: {loop_duration:.3f} 秒。") + if loop_duration < min_loop_interval: + # 如果循环太快,则睡眠一段时间 + await asyncio.sleep(min_loop_interval - loop_duration) - logger.info(f"[私聊][{self.private_name}]PFC 循环结束 for stream_id: {self.stream_id}") + # 循环结束后的日志 + logger.info(f"[私聊][{self.private_name}] PFC 循环已退出 for stream_id: {self.stream_id}") - - # --- 移除 _check_interrupt_before_sending 方法 --- - # def _check_interrupt_before_sending(self, message_ids_before_planning: set) -> bool: - # ... (旧代码移除) - - def _convert_to_message(self, msg_dict: Dict[str, Any]) -> Message: - """将消息字典转换为Message对象 (保持不变)""" + def _convert_to_message(self, msg_dict: Dict[str, Any]) -> Optional[Message]: + """将从数据库或其他来源获取的消息字典转换为内部使用的 Message 对象""" try: - chat_info = msg_dict.get("chat_info") - if chat_info and isinstance(chat_info, dict): - chat_stream = ChatStream.from_dict(chat_info) - elif self.chat_stream: - chat_stream = self.chat_stream - else: - chat_stream = chat_manager.get_stream(self.stream_id) - if not chat_stream: - raise ValueError(f"无法确定 ChatStream for stream_id {self.stream_id}") + # 优先使用实例自身的 chat_stream,如果不存在则尝试从管理器获取 + chat_stream_to_use = self.chat_stream or chat_manager.get_stream(self.stream_id) + if not chat_stream_to_use: + logger.error( + f"[私聊][{self.private_name}] 无法确定 ChatStream for stream_id {self.stream_id},无法转换消息。" + ) + return None # 无法确定聊天流,返回 None - user_info = UserInfo.from_dict(msg_dict.get("user_info", {})) + # 解析用户信息字典 + user_info_dict = msg_dict.get("user_info", {}) + user_info: Optional[UserInfo] = None + if isinstance(user_info_dict, dict): + try: + # 使用 UserInfo 类的方法从字典创建对象 + user_info = UserInfo.from_dict(user_info_dict) + except Exception as e: + # 解析失败记录警告 + logger.warning( + f"[私聊][{self.private_name}] 从字典创建 UserInfo 时出错: {e}, dict: {user_info_dict}" + ) + if not user_info: + # 如果没有有效的 UserInfo,记录警告并返回 None + logger.warning( + f"[私聊][{self.private_name}] 消息缺少有效的 UserInfo,无法转换。 msg_id: {msg_dict.get('message_id')}" + ) + return None + # 创建并返回 Message 对象 return Message( - message_id=msg_dict.get("message_id", f"gen_{time.time()}"), - chat_stream=chat_stream, - time=msg_dict.get("time", time.time()), - user_info=user_info, - processed_plain_text=msg_dict.get("processed_plain_text", ""), - detailed_plain_text=msg_dict.get("detailed_plain_text", ""), + message_id=msg_dict.get("message_id", f"gen_{time.time()}"), # 如果没有ID,生成一个临时的 + chat_stream=chat_stream_to_use, + time=msg_dict.get("time", time.time()), # 如果没有时间戳,使用当前时间 + user_info=user_info, # 使用解析出的 UserInfo 对象 + processed_plain_text=msg_dict.get("processed_plain_text", ""), # 获取处理后的纯文本 + detailed_plain_text=msg_dict.get("detailed_plain_text", ""), # 获取详细纯文本 ) except Exception as e: - logger.warning(f"[私聊][{self.private_name}]转换消息时出错: {e}") - raise ValueError(f"无法将字典转换为 Message 对象: {e}") from e + # 捕获转换过程中的任何异常 + logger.error(f"[私聊][{self.private_name}] 转换消息时出错: {e}") + logger.error(f"[私聊][{self.private_name}] {traceback.format_exc()}") + return None # 转换失败返回 None - # --- [修改点 3] 修改 _handle_action 签名并调整内部逻辑 (移除 message_ids_before_planning 参数) --- async def _handle_action( - self, - action: str, - reason: str, - observation_info: ObservationInfo, - conversation_info: ConversationInfo + self, action: str, reason: str, observation_info: ObservationInfo, conversation_info: ConversationInfo ): - """处理规划的行动""" - logger.debug(f"[私聊][{self.private_name}]执行行动: {action}, 原因: {reason}") + """ + 处理由 ActionPlanner 规划出的具体行动。 + 包括生成回复、调用检查器、发送消息、等待、思考目标等,并包含重试逻辑。 + 根据执行结果和规则更新对话状态。 + """ + # 检查初始化状态 + if not self._initialized: + logger.error(f"[私聊][{self.private_name}] 尝试在未初始化状态下处理动作 '{action}'。") + return - # 记录action历史 (逻辑不变) + logger.info(f"[私聊][{self.private_name}] 开始处理动作: {action}, 原因: {reason}") + action_start_time = time.time() # 记录动作开始时间 + + # --- 准备动作历史记录条目 --- current_action_record = { "action": action, - "plan_reason": reason, - "status": "start", - "time": datetime.datetime.now().strftime("%H:%M:%S"), - "final_reason": None, + "plan_reason": reason, # 记录规划时的原因 + "status": "start", # 初始状态为“开始” + "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # 记录开始时间 + "final_reason": None, # 最终结果的原因,将在 finally 中设置 } + # 安全地添加到历史记录列表 if not hasattr(conversation_info, "done_action"): conversation_info.done_action = [] conversation_info.done_action.append(current_action_record) + # 获取当前记录在列表中的索引,方便后续更新状态 action_index = len(conversation_info.done_action) - 1 - action_successful = False - reply_sent = False + # --- 初始化动作执行状态变量 --- + action_successful: bool = False # 标记动作是否成功执行 + final_status: str = "recall" # 动作最终状态,默认为 recall (表示未成功或需重试) + final_reason: str = "动作未成功执行" # 动作最终原因 + need_replan_from_checker: bool = False # 标记是否由 ReplyChecker 要求重新规划 - # --- 根据不同的 action 执行 --- - if action == "direct_reply" or action == "send_new_message": - # 合并 reply 和 follow-up 的生成/检查逻辑 (保持不变) - max_reply_attempts = 3 - reply_attempt_count = 0 - is_suitable = False - need_replan = False - check_reason = "未进行尝试" - final_reply_to_send = "" + try: + # --- 根据不同的 action 类型执行相应的逻辑 --- - while reply_attempt_count < max_reply_attempts and not is_suitable: - reply_attempt_count += 1 - log_prefix = f"[私聊][{self.private_name}]尝试生成 '{action}' 回复 (第 {reply_attempt_count}/{max_reply_attempts} 次)..." - logger.info(log_prefix) - self.state = ConversationState.GENERATING + # 1. 处理需要生成、检查、发送的动作 + if action in ["direct_reply", "send_new_message"]: + max_reply_attempts: int = 3 # 最多尝试次数 (可配置) + reply_attempt_count: int = 0 + is_suitable: bool = False # 标记回复是否合适 + generated_content: str = "" # 存储生成的回复 + check_reason: str = "未进行检查" # 存储检查结果原因 - self.generated_reply = await self.reply_generator.generate( - observation_info, conversation_info, action_type=action - ) - logger.info(f"{log_prefix} 生成内容: {self.generated_reply}") + # --- [核心修复] 引入重试循环 --- + while reply_attempt_count < max_reply_attempts and not is_suitable and not need_replan_from_checker: + reply_attempt_count += 1 + log_prefix = f"[私聊][{self.private_name}] 尝试生成/检查 '{action}' 回复 (第 {reply_attempt_count}/{max_reply_attempts} 次)..." + logger.info(log_prefix) - self.state = ConversationState.CHECKING - try: - current_goal_str = "" - if hasattr(conversation_info, 'goal_list') and conversation_info.goal_list: - goal_item = conversation_info.goal_list[0] - if isinstance(goal_item, dict): - current_goal_str = goal_item.get('goal', '') - elif isinstance(goal_item, str): - current_goal_str = goal_item + # --- a. 生成回复 --- + self.state = ConversationState.GENERATING # 更新对话状态 + if not self.reply_generator: + # 检查依赖组件是否存在 + raise RuntimeError("ReplyGenerator 未初始化") + # 调用 ReplyGenerator 生成回复内容 + generated_content = await self.reply_generator.generate( + observation_info, conversation_info, action_type=action + ) + logger.info(f"{log_prefix} 生成内容: '{generated_content}'") # 日志中截断长内容 - chat_history_for_check = getattr(observation_info, 'chat_history', []) - chat_history_str_for_check = getattr(observation_info, 'chat_history_str', '') + # 检查生成内容是否有效 + if not generated_content or generated_content.startswith("抱歉"): + # 如果生成失败或返回错误提示 + logger.warning(f"{log_prefix} 生成内容为空或为错误提示,将进行下一次尝试。") + check_reason = "生成内容无效" # 记录原因 + # 记录拒绝信息供下次生成参考 + conversation_info.last_reply_rejection_reason = check_reason + conversation_info.last_rejected_reply_content = generated_content + await asyncio.sleep(0.5) # 短暂等待后重试 + continue # 进入下一次循环尝试 - is_suitable, check_reason, need_replan = await self.reply_generator.check_reply( - reply=self.generated_reply, + # --- b. 检查回复 --- + self.state = ConversationState.CHECKING # 更新状态为检查中 + if not self.reply_checker: + raise RuntimeError("ReplyChecker 未初始化") + + # 准备检查所需的上下文信息 + current_goal_str: str = "" # 当前对话目标字符串 + if conversation_info.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: List[Dict[str, Any]] = getattr(observation_info, "chat_history", []) + chat_history_text_for_check: str = getattr(observation_info, "chat_history_str", "") + # 当前重试次数 (传递给 checker,可能有用) + # retry_count for checker starts from 0 + current_retry_for_checker = reply_attempt_count - 1 + + logger.debug(f"{log_prefix} 调用 ReplyChecker 检查...") + # 调用 ReplyChecker 的 check 方法 + is_suitable, check_reason, need_replan_from_checker = await self.reply_checker.check( + reply=generated_content, goal=current_goal_str, - chat_history=chat_history_for_check, - chat_history_str=chat_history_str_for_check, - retry_count=reply_attempt_count - 1, + chat_history=chat_history_for_check, # 传递列表形式的历史记录 + chat_history_text=chat_history_text_for_check, # 传递文本形式的历史记录 + retry_count=current_retry_for_checker, ) logger.info( - f"{log_prefix} 检查结果: 合适={is_suitable}, 原因='{check_reason}', 需重新规划={need_replan}" + f"{log_prefix} ReplyChecker 结果: 合适={is_suitable}, 原因='{check_reason}', 需重规划={need_replan_from_checker}" ) - if not is_suitable or need_replan: + # 如果不合适,记录原因并准备下一次尝试(如果还有次数) + if not is_suitable: + # 记录拒绝原因和内容,供下次生成时参考 conversation_info.last_reply_rejection_reason = check_reason - conversation_info.last_rejected_reply_content = self.generated_reply - else: - conversation_info.last_reply_rejection_reason = None - conversation_info.last_rejected_reply_content = None + conversation_info.last_rejected_reply_content = generated_content + # 如果不需要重规划且还有尝试次数 + if not need_replan_from_checker and reply_attempt_count < max_reply_attempts: + logger.warning(f"{log_prefix} 回复不合适,原因: {check_reason}。将进行下一次尝试。") + await asyncio.sleep(0.5) # 等待后重试 + # 如果需要重规划或达到最大次数,循环会在下次判断时自动结束 - if is_suitable: - final_reply_to_send = self.generated_reply - break - elif need_replan: - logger.warning( - f"{log_prefix} 检查建议重新规划,停止尝试。原因: {check_reason}" - ) - break - except Exception as check_err: - logger.error( - f"{log_prefix} 调用 ReplyChecker 时出错: {check_err}" - ) - check_reason = f"第 {reply_attempt_count} 次检查过程出错: {check_err}" - conversation_info.last_reply_rejection_reason = f"检查过程出错: {check_err}" - conversation_info.last_rejected_reply_content = self.generated_reply - break - - # --- 处理生成和检查的结果 --- - if is_suitable: - # --- [修改点 4] 记录发送前时间戳 --- - timestamp_before_sending = time.time() - logger.debug(f"[私聊][{self.private_name}]准备发送回复,记录发送前时间戳: {timestamp_before_sending}") - - # 确认发送 - self.generated_reply = final_reply_to_send - send_success = await self._send_reply() # 调用发送函数 - - if send_success: - action_successful = True - reply_sent = True - logger.info(f"[私聊][{self.private_name}]成功发送 '{action}' 回复.") + # --- 循环结束后,处理最终结果 --- + if is_suitable: + # 如果找到了合适的回复 + logger.info(f"[私聊][{self.private_name}] 动作 '{action}': 找到合适的回复,准备发送。") + # 清除上次的拒绝信息 (因为本次成功了) conversation_info.last_reply_rejection_reason = None conversation_info.last_rejected_reply_content = None - # --- [修改点 5] 基于时间戳处理消息和决定下一轮 prompt 类型 --- - current_unprocessed_messages = getattr(observation_info, 'unprocessed_messages', []) - message_ids_to_clear: Set[str] = set() # 使用 Set 类型 - new_messages_during_sending_count = 0 - - for msg in current_unprocessed_messages: - msg_time = msg.get('time') - msg_id = msg.get('message_id') - if msg_id and msg_time: # 确保时间和 ID 存在 - if msg_time < timestamp_before_sending: - message_ids_to_clear.add(msg_id) - else: - # 时间戳大于等于发送前时间戳,视为新消息 - new_messages_during_sending_count += 1 - - logger.debug(f"[私聊][{self.private_name}]回复发送后,检测到 {len(message_ids_to_clear)} 条发送前消息待清理,{new_messages_during_sending_count} 条发送期间/之后的新消息。") - - # 清理发送前到达的消息 - if message_ids_to_clear: - await observation_info.clear_processed_messages(message_ids_to_clear) - else: - logger.debug(f"[私聊][{self.private_name}]没有需要清理的发送前消息。") - - - # 根据发送期间是否有新消息,决定下次规划用哪个 prompt - if new_messages_during_sending_count > 0: - logger.info(f"[私聊][{self.private_name}]检测到 {new_messages_during_sending_count} 条在发送期间/之后到达的新消息,下一轮将使用首次回复逻辑处理。") - self.conversation_info.last_successful_reply_action = None # 强制下一轮用 PROMPT_INITIAL_REPLY - else: - logger.info(f"[私聊][{self.private_name}]发送期间/之后无新消息,下一轮将根据 '{action}' 使用追问逻辑。") - self.conversation_info.last_successful_reply_action = action # 保持状态,下一轮可能用 PROMPT_FOLLOW_UP - - else: # 发送失败 - logger.error(f"[私聊][{self.private_name}]发送 '{action}' 回复失败。") - action_successful = False - self.conversation_info.last_successful_reply_action = None - conversation_info.done_action[action_index].update( - {"status": "recall", "final_reason": "发送回复时失败"} - ) - - elif need_replan: - # 检查后打回动作决策 (保持不变) - logger.warning( - f"[私聊][{self.private_name}]'{action}' 回复检查后决定打回动作决策 (尝试 {reply_attempt_count} 次)。打回原因: {check_reason}" - ) - conversation_info.done_action[action_index].update( - {"status": "recall", "final_reason": f"'{action}' 尝试{reply_attempt_count}次后打回: {check_reason}"} - ) - self.conversation_info.last_successful_reply_action = None - - else: # 多次尝试后仍然不合适 (保持不变) - logger.warning( - f"[私聊][{self.private_name}]经过 {reply_attempt_count} 次尝试,未能生成合适的 '{action}' 回复。最终原因: {check_reason}" - ) - conversation_info.done_action[action_index].update( - {"status": "recall", "final_reason": f"'{action}' 尝试{reply_attempt_count}次后失败: {check_reason}"} - ) - self.conversation_info.last_successful_reply_action = None - - if action == "send_new_message": - logger.info(f"[私聊][{self.private_name}]由于无法生成合适追问回复,执行 'wait' 操作...") - self.state = ConversationState.WAITING - await self.waiter.wait(self.conversation_info) - wait_action_record = { - "action": "wait", - "plan_reason": "因 send_new_message 多次尝试失败而执行的后备等待", - "status": "done", - "time": datetime.datetime.now().strftime("%H:%M:%S"), - "final_reason": None, - } - conversation_info.done_action.append(wait_action_record) - action_successful = True - self.conversation_info.last_successful_reply_action = None - - # --- 处理其他动作 (保持不变,确保状态重置) --- - elif action == "rethink_goal": - self.state = ConversationState.RETHINKING - try: - if not hasattr(self, "goal_analyzer"): - raise AttributeError("GoalAnalyzer not initialized") - await self.goal_analyzer.analyze_goal(conversation_info, observation_info) - action_successful = True - except Exception as rethink_err: - logger.error(f"[私聊][{self.private_name}]重新思考目标时出错: {rethink_err}") - conversation_info.done_action[action_index].update( - {"status": "recall", "final_reason": f"重新思考目标失败: {rethink_err}"} - ) - self.conversation_info.last_successful_reply_action = None - conversation_info.last_reply_rejection_reason = None - conversation_info.last_rejected_reply_content = None - - - elif action == "listening": - self.state = ConversationState.LISTENING - logger.info(f"[私聊][{self.private_name}]倾听对方发言...") - try: - if not hasattr(self, "waiter"): - raise AttributeError("Waiter not initialized") - await self.waiter.wait_listening(conversation_info) - action_successful = True - except Exception as listen_err: - logger.error(f"[私聊][{self.private_name}]倾听时出错: {listen_err}") - conversation_info.done_action[action_index].update( - {"status": "recall", "final_reason": f"倾听失败: {listen_err}"} - ) - self.conversation_info.last_successful_reply_action = None - conversation_info.last_reply_rejection_reason = None - conversation_info.last_rejected_reply_content = None - - elif action == "say_goodbye": - self.state = ConversationState.GENERATING - logger.info(f"[私聊][{self.private_name}]执行行动: 生成并发送告别语...") - try: - self.generated_reply = await self.reply_generator.generate( - observation_info, conversation_info, action_type="say_goodbye" - ) - logger.info(f"[私聊][{self.private_name}]生成的告别语: {self.generated_reply}") - - if self.generated_reply: - # --- [修改点 6] 告别语发送前记录时间戳 --- - timestamp_before_sending_goodbye = time.time() + # --- c. 发送回复 --- + self.generated_reply = generated_content # 使用最后一次检查通过的内容 + timestamp_before_sending = time.time() # 记录发送前时间戳 + logger.debug( + f"[私聊][{self.private_name}] 动作 '{action}': 记录发送前时间戳: {timestamp_before_sending:.2f}" + ) + self.state = ConversationState.SENDING # 更新状态为发送中 + # 调用内部发送方法 send_success = await self._send_reply() + send_end_time = time.time() # 记录发送结束时间 + if send_success: - action_successful = True - reply_sent = True - logger.info(f"[私聊][{self.private_name}]告别语已发送。") + # 如果发送成功 + action_successful = True # 标记动作成功 + # final_status 和 final_reason 会在 finally 中设置 + logger.info(f"[私聊][{self.private_name}] 动作 '{action}': 成功发送回复.") + # 更新空闲计时器 + if self.idle_conversation_starter: + await self.idle_conversation_starter.update_last_message_time(send_end_time) - # --- [修改点 7] 告别语发送后也处理未读消息 --- - # (虽然通常之后就结束了,但以防万一) - current_unprocessed_messages_goodbye = getattr(observation_info, 'unprocessed_messages', []) - message_ids_to_clear_goodbye: Set[str] = set() - for msg in current_unprocessed_messages_goodbye: - msg_time = msg.get('time') - msg_id = msg.get('message_id') - if msg_id and msg_time and msg_time < timestamp_before_sending_goodbye: - message_ids_to_clear_goodbye.add(msg_id) - if message_ids_to_clear_goodbye: - await observation_info.clear_processed_messages(message_ids_to_clear_goodbye) + # --- d. 清理已处理消息 --- + 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 = msg.get("user_info", {}).get("user_id") + # 规则:只清理【发送前】收到的、【来自他人】的消息 + if ( + msg_id + and msg_time + and sender_id != self.bot_qq_str + and msg_time < timestamp_before_sending + ): + message_ids_to_clear.add(msg_id) + # 如果有需要清理的消息,调用清理方法 + if message_ids_to_clear: + logger.debug( + f"[私聊][{self.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"[私聊][{self.private_name}] 没有需要清理的发送前(他人)消息。") + + # --- e. 决定下一轮规划类型 --- + # 从 conversation_info 获取【规划期间】收到的【他人】新消息数量 + other_new_msg_count_during_planning = getattr( + conversation_info, "other_new_messages_during_planning_count", 0 + ) + + # 规则:如果规划期间收到他人新消息 (0 < count <= 2),则下一轮强制初始回复 + if other_new_msg_count_during_planning > 0: + logger.info( + f"[私聊][{self.private_name}] 因规划期间收到 {other_new_msg_count_during_planning} 条他人新消息,下一轮强制使用【初始回复】逻辑。" + ) + conversation_info.last_successful_reply_action = None # 强制初始回复 + else: + # 规则:如果规划期间【没有】收到他人新消息,则允许追问 + logger.info( + f"[私聊][{self.private_name}] 规划期间无他人新消息,下一轮【允许】使用追问逻辑 (基于 '{action}')。" + ) + conversation_info.last_successful_reply_action = action # 允许追问 - self.should_continue = False # 正常结束 - logger.info(f"[私聊][{self.private_name}]发送告别语流程结束,即将停止对话实例。") else: - logger.warning(f"[私聊][{self.private_name}]发送告别语失败。") - action_successful = False - self.should_continue = True # 发送失败不能结束 - conversation_info.done_action[action_index].update( - {"status": "recall", "final_reason": "发送告别语失败"} - ) - self.conversation_info.last_successful_reply_action = None + # 如果发送失败 + logger.error(f"[私聊][{self.private_name}] 动作 '{action}': 发送回复失败。") + final_status = "recall" # 发送失败,标记为 recall + final_reason = "发送回复时失败" + # 重置追问状态 + conversation_info.last_successful_reply_action = None + + elif need_replan_from_checker: + # 如果 Checker 要求重新规划 + logger.warning( + f"[私聊][{self.private_name}] 动作 '{action}' 因 ReplyChecker 要求而被取消,将重新规划。原因: {check_reason}" + ) + final_status = "recall" # 标记为 recall + final_reason = f"回复检查要求重新规划: {check_reason}" + # # 重置追问状态 + # conversation_info.last_successful_reply_action = None else: - logger.warning(f"[私聊][{self.private_name}]未能生成告别语内容,无法发送。") - action_successful = False - self.should_continue = True - conversation_info.done_action[action_index].update( - {"status": "recall", "final_reason": "未能生成告别语内容"} + # 达到最大尝试次数仍未找到合适回复 + logger.warning( + f"[私聊][{self.private_name}] 动作 '{action}': 达到最大尝试次数 ({max_reply_attempts}),未能生成/检查通过合适的回复。最终原因: {check_reason}" ) - self.conversation_info.last_successful_reply_action = None + final_status = "recall" # 标记为 recall + final_reason = f"尝试{max_reply_attempts}次后失败: {check_reason}" + # # 重置追问状态 + # conversation_info.last_successful_reply_action = None - except Exception as goodbye_err: - logger.error(f"[私聊][{self.private_name}]生成或发送告别语时出错: {goodbye_err}") - logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") - action_successful = False - self.should_continue = True - conversation_info.done_action[action_index].update( - {"status": "recall", "final_reason": f"生成或发送告别语时出错: {goodbye_err}"} + # 2. 处理发送告别语动作 (保持简单,不加重试) + elif action == "say_goodbye": + self.state = ConversationState.GENERATING + if not self.reply_generator: + raise RuntimeError("ReplyGenerator 未初始化") + # 生成告别语 + generated_content = await self.reply_generator.generate( + observation_info, conversation_info, action_type=action ) - self.conversation_info.last_successful_reply_action = None + logger.info(f"[私聊][{self.private_name}] 动作 '{action}': 生成内容: '{generated_content[:100]}...'") - elif action == "end_conversation": - self.should_continue = False - logger.info(f"[私聊][{self.private_name}]收到最终结束指令,停止对话...") - action_successful = True - self.conversation_info.last_successful_reply_action = None - conversation_info.last_reply_rejection_reason = None - conversation_info.last_rejected_reply_content = None + # 检查生成内容 + if not generated_content or generated_content.startswith("抱歉"): + logger.warning(f"[私聊][{self.private_name}] 动作 '{action}': 生成内容为空或为错误提示,取消发送。") + final_reason = "生成内容无效" + # 即使生成失败,也按计划结束对话 + final_status = "done" # 标记为 done,因为目的是结束 + self.should_continue = False + logger.info(f"[私聊][{self.private_name}] 告别语生成失败,仍按计划结束对话。") + else: + # 发送告别语 + self.generated_reply = generated_content + timestamp_before_sending = time.time() + logger.debug( + f"[私聊][{self.private_name}] 动作 '{action}': 记录发送前时间戳: {timestamp_before_sending:.2f}" + ) + self.state = ConversationState.SENDING + send_success = await self._send_reply() + send_end_time = time.time() + if send_success: + action_successful = True # 标记成功 + # final_status 和 final_reason 会在 finally 中设置 + logger.info(f"[私聊][{self.private_name}] 成功发送告别语,即将停止对话实例。") + # 更新空闲计时器 + if self.idle_conversation_starter: + await self.idle_conversation_starter.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 = msg.get("user_info", {}).get("user_id") + if ( + msg_id + and msg_time + and sender_id != self.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) + # 发送成功后结束对话 + self.should_continue = False + else: + # 发送失败 + logger.error(f"[私聊][{self.private_name}] 动作 '{action}': 发送告别语失败。") + final_status = "recall" + final_reason = "发送告别语失败" + # 发送失败不能结束对话 + self.should_continue = True - elif action == "block_and_ignore": - logger.info(f"[私聊][{self.private_name}]不想再理你了...") - ignore_duration_seconds = 10 * 60 - self.ignore_until_timestamp = time.time() + ignore_duration_seconds - logger.info( - f"[私聊][{self.private_name}]将忽略此对话直到: {datetime.datetime.fromtimestamp(self.ignore_until_timestamp)}" - ) - self.state = ConversationState.IGNORED - action_successful = True - self.conversation_info.last_successful_reply_action = None - conversation_info.last_reply_rejection_reason = None - conversation_info.last_rejected_reply_content = None + # 3. 处理重新思考目标动作 + elif action == "rethink_goal": + self.state = ConversationState.RETHINKING + if not self.goal_analyzer: + raise RuntimeError("GoalAnalyzer 未初始化") + # 调用 GoalAnalyzer 分析并更新目标 + await self.goal_analyzer.analyze_goal(conversation_info, observation_info) + action_successful = True # 标记成功 + # 4. 处理倾听动作 + elif action == "listening": + self.state = ConversationState.LISTENING + if not self.waiter: + raise RuntimeError("Waiter 未初始化") + logger.info(f"[私聊][{self.private_name}] 动作 'listening': 进入倾听状态...") + # 调用 Waiter 的倾听等待方法,内部会处理超时 + await self.waiter.wait_listening(conversation_info) + action_successful = True # listening 动作本身执行即视为成功,后续由新消息或超时驱动 - else: # 对应 'wait' 动作 - self.state = ConversationState.WAITING - logger.info(f"[私聊][{self.private_name}]等待更多信息...") - try: - if not hasattr(self, "waiter"): - raise AttributeError("Waiter not initialized") - _timeout_occurred = await self.waiter.wait(self.conversation_info) - action_successful = True - except Exception as wait_err: - logger.error(f"[私聊][{self.private_name}]等待时出错: {wait_err}") - conversation_info.done_action[action_index].update( - {"status": "recall", "final_reason": f"等待失败: {wait_err}"} + # 5. 处理结束对话动作 + elif action == "end_conversation": + logger.info(f"[私聊][{self.private_name}] 动作 'end_conversation': 收到最终结束指令,停止对话...") + action_successful = True # 标记成功 + self.should_continue = False # 设置标志以退出循环 + + # 6. 处理屏蔽忽略动作 + elif action == "block_and_ignore": + logger.info(f"[私聊][{self.private_name}] 动作 'block_and_ignore': 不想再理你了...") + ignore_duration_seconds = 10 * 60 # 忽略 10 分钟,可配置 + self.ignore_until_timestamp = time.time() + ignore_duration_seconds + logger.info( + f"[私聊][{self.private_name}] 将忽略此对话直到: {datetime.datetime.fromtimestamp(self.ignore_until_timestamp)}" ) - self.conversation_info.last_successful_reply_action = None - conversation_info.last_reply_rejection_reason = None - conversation_info.last_rejected_reply_content = None + self.state = ConversationState.IGNORED # 设置忽略状态 + action_successful = True # 标记成功 + # 7. 处理等待动作 + elif action == "wait": + self.state = ConversationState.WAITING + if not self.waiter: + raise RuntimeError("Waiter 未初始化") + logger.info(f"[私聊][{self.private_name}] 动作 'wait': 进入等待状态...") + # 调用 Waiter 的常规等待方法,内部处理超时 + # wait 方法返回是否超时 (True=超时, False=未超时/被新消息中断) + timeout_occurred = await self.waiter.wait(self.conversation_info) + action_successful = True # wait 动作本身执行即视为成功 + # wait 动作完成后不需要清理消息,等待新消息或超时触发重新规划 + logger.debug(f"[私聊][{self.private_name}] Wait 动作完成,无需在此清理消息。") - # --- 更新 Action History 状态 (保持不变) --- - if action_successful: - conversation_info.done_action[action_index].update( - { - "status": "done", - "time": datetime.datetime.now().strftime("%H:%M:%S"), - } - ) - logger.debug(f"[私聊][{self.private_name}]动作 '{action}' 标记为 'done'") - else: - logger.debug(f"[私聊][{self.private_name}]动作 '{action}' 标记为 'recall' 或失败") + # 8. 处理未知的动作类型 + else: + logger.warning(f"[私聊][{self.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: + # 处理任务被取消的异常 + logger.warning(f"[私聊][{self.private_name}] 处理动作 '{action}' 时被取消。") + final_status = "cancelled" + final_reason = "动作处理被取消" + # 取消时也重置追问状态 + conversation_info.last_successful_reply_action = None + raise # 重新抛出 CancelledError,让上层知道任务被取消 + except Exception as handle_err: + # 捕获处理动作过程中的其他所有异常 + logger.error(f"[私聊][{self.private_name}] 处理动作 '{action}' 时出错: {handle_err}") + logger.error(f"[私聊][{self.private_name}] {traceback.format_exc()}") + final_status = "error" # 标记为错误状态 + final_reason = f"处理动作时出错: {handle_err}" + self.state = ConversationState.ERROR # 设置对话状态为错误 + # 出错时重置追问状态 + conversation_info.last_successful_reply_action = None + + finally: + # --- 无论成功与否,都执行 --- + + # 1. 重置临时存储的计数值 + # 确保这个值只在当前规划周期内有效 + conversation_info.other_new_messages_during_planning_count = 0 + + # 2. 更新动作历史记录的最终状态和原因 + # 优化:如果动作成功但状态仍是默认的 recall,则更新为 done + if final_status == "recall" and action_successful: + final_status = "done" + # 根据动作类型设置更具体的成功原因 + if action == "wait": + # 检查是否是因为超时结束的(需要 waiter 返回值,或者检查 goal_list) + # 这里简化处理,直接使用通用成功原因 + timeout_occurred = ( + any("分钟," in g.get("goal", "") for g in conversation_info.goal_list if isinstance(g, dict)) + if 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"]: + final_reason = f"成功执行 {action}" + elif action in ["direct_reply", "send_new_message", "say_goodbye"]: + # 如果是因为发送成功,设置原因 + final_reason = "成功发送" + else: + # 其他未知但标记成功的动作 + final_reason = "动作成功完成" + + elif final_status == "recall" and not action_successful: + # 如果最终是 recall 且未成功,且不是因为检查不通过(比如生成失败),确保原因合理 + # 保留之前的逻辑,检查是否已有更具体的失败原因 + if not final_reason or final_reason == "动作未成功执行": + # 检查是否有 checker 的原因 + checker_reason = conversation_info.last_reply_rejection_reason + if checker_reason: + final_reason = f"回复检查不通过: {checker_reason}" + else: + final_reason = "动作执行失败或被取消" # 通用失败原因 + + # 更新历史记录字典 + if conversation_info.done_action and action_index < len(conversation_info.done_action): + # 使用 update 方法更新字典,更安全 + 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), # 记录耗时(毫秒) + } + ) + logger.debug( + f"[私聊][{self.private_name}] 动作 '{action}' 最终状态: {final_status}, 原因: {final_reason}" + ) + else: + # 如果索引无效或列表为空,记录错误 + logger.error(f"[私聊][{self.private_name}] 无法更新动作历史记录,索引 {action_index} 无效或列表为空。") async def _send_reply(self) -> bool: - """发送回复,并返回是否发送成功 (保持不变)""" + """发送 `self.generated_reply` 中的内容到聊天流""" + # 检查是否有内容可发送 if not self.generated_reply: - logger.warning(f"[私聊][{self.private_name}]没有生成回复内容,无法发送。") + logger.warning(f"[私聊][{self.private_name}] 没有生成回复内容,无法发送。") + return False + # 检查发送器和聊天流是否已初始化 + if not self.direct_sender: + logger.error(f"[私聊][{self.private_name}] DirectMessageSender 未初始化,无法发送。") + return False + if not self.chat_stream: + logger.error(f"[私聊][{self.private_name}] ChatStream 未初始化,无法发送。") return False try: reply_content = self.generated_reply - if not hasattr(self, "direct_sender") or not self.direct_sender: - logger.error(f"[私聊][{self.private_name}]DirectMessageSender 未初始化,无法发送回复。") - return False - if not self.chat_stream: - logger.error(f"[私聊][{self.private_name}]ChatStream 未初始化,无法发送回复。") - return False - - await self.direct_sender.send_message(chat_stream=self.chat_stream, content=reply_content) + # 调用发送器发送消息,不指定回复对象 + await self.direct_sender.send_message( + chat_stream=self.chat_stream, + content=reply_content, + reply_to_message=None, # 私聊通常不需要引用回复 + ) + # 发送成功后,将状态设置回分析,准备下一轮规划 self.state = ConversationState.ANALYZING - return True - + return True # 返回成功 except Exception as e: - logger.error(f"[私聊][{self.private_name}]发送消息时失败: {str(e)}") - logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") - self.state = ConversationState.ANALYZING - return False - + # 捕获发送过程中的异常 + logger.error(f"[私聊][{self.private_name}] 发送消息时失败: {str(e)}") + logger.error(f"[私聊][{self.private_name}] {traceback.format_exc()}") + self.state = ConversationState.ERROR # 发送失败标记错误状态 + return False # 返回失败 async def _send_timeout_message(self): - """发送超时结束消息 (保持不变)""" + """在等待超时后发送一条结束消息""" + # 检查发送器和聊天流 + if not self.direct_sender or not self.chat_stream: + logger.warning(f"[私聊][{self.private_name}] 发送器或聊天流未初始化,无法发送超时消息。") + return try: - messages = self.chat_observer.get_cached_messages(limit=1) - if not messages: - return - latest_message = self._convert_to_message(messages[0]) + # 定义超时消息内容,可以考虑配置化或由 LLM 生成 + timeout_content = "我们好像很久没说话了,先这样吧~" + # 发送超时消息 await self.direct_sender.send_message( - chat_stream=self.chat_stream, content="TODO:超时消息", reply_to_message=latest_message + chat_stream=self.chat_stream, content=timeout_content, reply_to_message=None ) + logger.info(f"[私聊][{self.private_name}] 已发送超时结束消息。") + # 发送超时消息后,通常意味着对话结束,调用 stop + await self.stop() except Exception as e: - logger.error(f"[私聊][{self.private_name}]发送超时消息失败: {str(e)}") \ No newline at end of file + # 捕获发送超时消息的异常 + logger.error(f"[私聊][{self.private_name}] 发送超时消息失败: {str(e)}") diff --git a/src/plugins/PFC/idle_conversation_starter.py b/src/plugins/PFC/idle_conversation_starter.py new file mode 100644 index 00000000..b161edd0 --- /dev/null +++ b/src/plugins/PFC/idle_conversation_starter.py @@ -0,0 +1,335 @@ +from typing import TYPE_CHECKING, Optional +import asyncio +import time +import random +from src.common.logger import get_module_logger +from ..models.utils_model import LLMRequest +from src.config.config import global_config +from .chat_observer import ChatObserver +from .message_sender import DirectMessageSender +from ..chat.chat_stream import ChatStream +from maim_message import UserInfo +from src.individuality.individuality import Individuality +from src.plugins.utils.chat_message_builder import build_readable_messages + +if TYPE_CHECKING: + from .conversation import Conversation + +logger = get_module_logger("pfc_idle") + + +class IdleConversationStarter: + """长时间无对话主动发起对话的组件 + + 该组件会在一段时间没有对话后,自动生成一条消息发送给用户,以保持对话的活跃度。 + 时间阈值会在配置的最小和最大值之间随机选择,每次发送消息后都会重置。 + """ + + def __init__(self, stream_id: str, private_name: str): + """初始化空闲对话启动器 + + Args: + stream_id: 聊天流ID + private_name: 私聊用户名称 + """ + self.stream_id: str = stream_id + self.private_name: str = private_name + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + self.message_sender = DirectMessageSender(private_name) + + # 添加异步锁,保护对共享变量的访问 + self._lock: asyncio.Lock = asyncio.Lock() + + # LLM请求对象,用于生成主动对话内容 + self.llm = LLMRequest( + model=global_config.llm_normal, temperature=0.8, max_tokens=500, request_type="idle_conversation_starter" + ) + + # 个性化信息 + self.personality_info: str = Individuality.get_instance().get_prompt(x_person=2, level=3) + + # 计算实际触发阈值(在min和max之间随机) + self.actual_idle_threshold: int = random.randint(global_config.min_idle_time, global_config.max_idle_time) + + # 工作状态 + self.last_message_time: float = time.time() + self._running: bool = False + self._task: Optional[asyncio.Task] = None + + def start(self) -> None: + """启动空闲对话检测 + + 如果功能被禁用或已经在运行,则不会启动。 + """ + # 如果功能被禁用,则不启动 + if not global_config.enable_idle_conversation: + logger.info(f"[私聊][{self.private_name}]主动发起对话功能已禁用") + return + + if self._running: + logger.debug(f"[私聊][{self.private_name}]主动发起对话功能已在运行中") + return + + self._running = True + self._task = asyncio.create_task(self._check_idle_loop()) + logger.info(f"[私聊][{self.private_name}]启动空闲对话检测,阈值设置为{self.actual_idle_threshold}秒") + + def stop(self) -> None: + """停止空闲对话检测 + + 取消当前运行的任务并重置状态。 + """ + if not self._running: + return + + self._running = False + if self._task: + self._task.cancel() + self._task = None + logger.info(f"[私聊][{self.private_name}]停止空闲对话检测") + + async def update_last_message_time(self, message_time: Optional[float] = None) -> None: + """更新最后一条消息的时间 + + Args: + message_time: 消息时间戳,如果为None则使用当前时间 + """ + async with self._lock: + self.last_message_time = message_time or time.time() + # 重新随机化下一次触发的时间阈值 + self.actual_idle_threshold = random.randint(global_config.min_idle_time, global_config.max_idle_time) + logger.debug( + f"[私聊][{self.private_name}]更新最后消息时间: {self.last_message_time},新阈值: {self.actual_idle_threshold}秒" + ) + + def reload_config(self) -> None: + """重新加载配置 + + 记录当前配置参数,用于日志输出 + """ + try: + logger.debug( + f"[私聊][{self.private_name}]重新加载主动对话配置: 启用={global_config.enable_idle_conversation}, 检查间隔={global_config.idle_check_interval}秒, 最短间隔={global_config.min_idle_time}秒, 最长间隔={global_config.max_idle_time}秒" + ) + + # 重新计算实际阈值 + async def update_threshold(): + async with self._lock: + self.actual_idle_threshold = random.randint( + global_config.min_idle_time, global_config.max_idle_time + ) + logger.debug(f"[私聊][{self.private_name}]更新空闲检测阈值为: {self.actual_idle_threshold}秒") + + # 创建一个任务来异步更新阈值 + asyncio.create_task(update_threshold()) + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]重新加载配置时出错: {str(e)}") + + async def _check_idle_loop(self) -> None: + """检查空闲状态的循环 + + 定期检查是否长时间无对话,如果达到阈值则尝试主动发起对话。 + """ + try: + config_reload_counter = 0 + config_reload_interval = 100 # 每100次检查重新加载一次配置 + + while self._running: + # 定期重新加载配置 + config_reload_counter += 1 + if config_reload_counter >= config_reload_interval: + self.reload_config() + config_reload_counter = 0 + + # 检查是否启用了主动对话功能 + if not global_config.enable_idle_conversation: + # 如果禁用了功能,就等待一段时间后再次检查配置 + await asyncio.sleep(global_config.idle_check_interval) + continue + + # 使用锁保护对共享变量的读取 + current_time = time.time() + async with self._lock: + idle_time = current_time - self.last_message_time + threshold = self.actual_idle_threshold + + if idle_time >= threshold: + logger.info(f"[私聊][{self.private_name}]检测到长时间({idle_time:.0f}秒)无对话,尝试主动发起聊天") + await self._initiate_conversation() + # 更新时间,避免连续触发 + await self.update_last_message_time() + + # 等待下一次检查 + await asyncio.sleep(global_config.idle_check_interval) + + except asyncio.CancelledError: + logger.debug(f"[私聊][{self.private_name}]空闲对话检测任务被取消") + except Exception as e: + logger.error(f"[私聊][{self.private_name}]空闲对话检测出错: {str(e)}") + # 尝试重新启动检测循环 + if self._running: + logger.info(f"[私聊][{self.private_name}]尝试重新启动空闲对话检测") + self._task = asyncio.create_task(self._check_idle_loop()) + + async def _initiate_conversation(self) -> None: + """生成并发送主动对话内容 + + 获取聊天历史记录,使用LLM生成合适的开场白,然后发送消息。 + """ + try: + # 获取聊天历史记录,用于生成更合适的开场白 + messages = self.chat_observer.get_cached_messages(limit=12) # 获取最近12条消息 + chat_history_text = await build_readable_messages( + messages, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + + # 构建提示词 + prompt = f"""{self.personality_info}。你的名字是{global_config.BOT_NICKNAME}。 + 你正在与用户{self.private_name}进行QQ私聊, + 但已经有一段时间没有对话了。 + 你想要主动发起一个友好的对话,可以说说自己在做的事情或者询问对方在做什么。 + 请基于以下之前的对话历史,生成一条自然、友好、符合你个性的主动对话消息。 + 这条消息应该能够引起用户的兴趣,重新开始对话。 + 最近的对话历史(可能已经过去了很久): + {chat_history_text} + 请直接输出一条消息,不要有任何额外的解释或引导文字。消息要简短自然,就像是在日常聊天中的开场白。 + 消息内容尽量简短,不要超过20个字,不要添加任何表情符号。 + """ + + # 尝试生成回复,添加超时处理 + try: + content, _ = await asyncio.wait_for( + self.llm.generate_response_async(prompt), + timeout=30, # 30秒超时 + ) + except asyncio.TimeoutError: + logger.error(f"[私聊][{self.private_name}]生成主动对话内容超时") + return + except Exception as llm_err: + logger.error(f"[私聊][{self.private_name}]生成主动对话内容失败: {str(llm_err)}") + return + + # 清理结果 + content = content.strip() + content = content.strip("\"'") + + if not content: + logger.error(f"[私聊][{self.private_name}]生成的主动对话内容为空") + return + + # 统一错误处理,从这里开始所有操作都在同一个try-except块中 + logger.debug(f"[私聊][{self.private_name}]成功生成主动对话内容: {content},准备发送") + + from .pfc_manager import PFCManager + + # 获取当前实例 + pfc_manager = PFCManager.get_instance() + + # 结束当前对话实例(如果存在) + current_conversation = await pfc_manager.get_conversation(self.stream_id) + if current_conversation: + logger.info(f"[私聊][{self.private_name}]结束当前对话实例,准备创建新实例") + try: + await current_conversation.stop() + await pfc_manager.remove_conversation(self.stream_id) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]结束当前对话实例时出错: {str(e)},继续创建新实例") + + # 创建新的对话实例 + logger.info(f"[私聊][{self.private_name}]创建新的对话实例以发送主动消息") + new_conversation = None + try: + new_conversation = await pfc_manager.get_or_create_conversation(self.stream_id, self.private_name) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]创建新对话实例失败: {str(e)}") + return + + # 确保新对话实例已初始化完成 + chat_stream = await self._get_chat_stream(new_conversation) + if not chat_stream: + logger.error(f"[私聊][{self.private_name}]无法获取有效的聊天流,取消发送主动消息") + return + + # 发送消息 + try: + await self.message_sender.send_message(chat_stream=chat_stream, content=content, reply_to_message=None) + + # 更新空闲会话启动器的最后消息时间 + await self.update_last_message_time() + + # 如果新对话实例有一个聊天观察者,请触发更新 + if new_conversation and hasattr(new_conversation, "chat_observer"): + logger.info(f"[私聊][{self.private_name}]触发聊天观察者更新") + try: + new_conversation.chat_observer.trigger_update() + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]触发聊天观察者更新失败: {str(e)}") + + logger.success(f"[私聊][{self.private_name}]成功主动发起对话: {content}") + except Exception as e: + logger.error(f"[私聊][{self.private_name}]发送主动对话消息失败: {str(e)}") + + except Exception as e: + # 顶级异常处理,确保任何未捕获的异常都不会导致整个进程崩溃 + logger.error(f"[私聊][{self.private_name}]主动发起对话过程中发生未预期的错误: {str(e)}") + + async def _get_chat_stream(self, conversation: Optional["Conversation"] = None) -> Optional[ChatStream]: + """获取可用的聊天流 + + 尝试多种方式获取聊天流: + 1. 从传入的对话实例中获取 + 2. 从全局聊天管理器中获取 + 3. 创建一个新的聊天流 + + Args: + conversation: 对话实例,可以为None + + Returns: + Optional[ChatStream]: 如果成功获取则返回聊天流,否则返回None + """ + chat_stream = None + + # 1. 尝试从对话实例获取 + if conversation and hasattr(conversation, "should_continue"): + # 等待一小段时间,确保初始化完成 + retry_count = 0 + max_retries = 10 + while not conversation.should_continue and retry_count < max_retries: + await asyncio.sleep(0.5) + retry_count += 1 + logger.debug(f"[私聊][{self.private_name}]等待新对话实例初始化完成: 尝试 {retry_count}/{max_retries}") + + if not conversation.should_continue: + logger.warning(f"[私聊][{self.private_name}]新对话实例初始化可能未完成,但仍将尝试获取聊天流") + + # 尝试使用对话实例的聊天流 + if hasattr(conversation, "chat_stream") and conversation.chat_stream: + logger.info(f"[私聊][{self.private_name}]使用新对话实例的聊天流") + return conversation.chat_stream + + # 2. 尝试从聊天管理器获取 + from src.plugins.chat.chat_stream import chat_manager + + try: + logger.info(f"[私聊][{self.private_name}]尝试从chat_manager获取聊天流") + chat_stream = chat_manager.get_stream(self.stream_id) + if chat_stream: + return chat_stream + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]从chat_manager获取聊天流失败: {str(e)}") + + # 3. 创建新的聊天流 + try: + logger.warning(f"[私聊][{self.private_name}]无法获取现有聊天流,创建新的聊天流") + # 创建用户信息对象 + user_info = UserInfo(user_id=global_config.BOT_QQ, user_nickname=global_config.BOT_NICKNAME, platform="qq") + # 创建聊天流 + return ChatStream(self.stream_id, "qq", user_info) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]创建新聊天流失败: {str(e)}") + return None diff --git a/src/plugins/PFC/observation_info.py b/src/plugins/PFC/observation_info.py index 35c63741..73ff4103 100644 --- a/src/plugins/PFC/observation_info.py +++ b/src/plugins/PFC/observation_info.py @@ -1,13 +1,14 @@ -# -*- coding: utf-8 -*- -# File: observation_info.py -from typing import List, Optional, Dict, Any, Set -from maim_message import UserInfo import time +import traceback +from typing import List, Optional, Dict, Any, Set + +from maim_message import UserInfo from src.common.logger import get_module_logger +from src.plugins.utils.chat_message_builder import build_readable_messages + +# 确保导入路径正确 from .chat_observer import ChatObserver from .chat_states import NotificationHandler, NotificationType, Notification -from src.plugins.utils.chat_message_builder import build_readable_messages -import traceback # 导入 traceback 用于调试 logger = get_module_logger("observation_info") @@ -16,324 +17,286 @@ class ObservationInfoHandler(NotificationHandler): """ObservationInfo的通知处理器""" def __init__(self, observation_info: "ObservationInfo", private_name: str): - """初始化处理器 - - Args: - observation_info: 要更新的ObservationInfo实例 - private_name: 私聊对象的名称,用于日志记录 - """ + """初始化处理器""" self.observation_info = observation_info - # 将 private_name 存储在 handler 实例中 self.private_name = private_name - async def handle_notification(self, notification: Notification): # 添加类型提示 - # 获取通知类型和数据 + async def handle_notification(self, notification: Notification): + """处理来自 ChatObserver 的通知""" notification_type = notification.type data = notification.data + timestamp = notification.timestamp # 获取通知时间戳 - try: # 添加错误处理块 + try: if notification_type == NotificationType.NEW_MESSAGE: # 处理新消息通知 - # logger.debug(f"[私聊][{self.private_name}]收到新消息通知data: {data}") # 可以在需要时取消注释 - message_id = data.get("message_id") - processed_plain_text = data.get("processed_plain_text") - detailed_plain_text = data.get("detailed_plain_text") - user_info_dict = data.get("user_info") # 先获取字典 - time_value = data.get("time") + message_dict = data # data 本身就是消息字典 + if not isinstance(message_dict, dict): + logger.warning(f"[私聊][{self.private_name}] 收到的 NEW_MESSAGE 数据不是字典: {data}") + return - # 确保 user_info 是字典类型再创建 UserInfo 对象 - user_info = None + # 解析 UserInfo + user_info_dict = message_dict.get("user_info") + user_info: Optional[UserInfo] = None if isinstance(user_info_dict, dict): try: user_info = UserInfo.from_dict(user_info_dict) except Exception as e: logger.error( - f"[私聊][{self.private_name}]从字典创建 UserInfo 时出错: {e}, 字典内容: {user_info_dict}" + f"[私聊][{self.private_name}] 从字典创建 UserInfo 时出错: {e}, dict: {user_info_dict}" ) - # 可以选择在这里返回或记录错误,避免后续代码出错 - return elif user_info_dict is not None: logger.warning( - f"[私聊][{self.private_name}]收到的 user_info 不是预期的字典类型: {type(user_info_dict)}" + f"[私聊][{self.private_name}] 收到的 user_info 不是预期的字典类型: {type(user_info_dict)}" ) - # 根据需要处理非字典情况,这里暂时返回 - return - message = { - "message_id": message_id, - "processed_plain_text": processed_plain_text, - "detailed_plain_text": detailed_plain_text, - "user_info": user_info_dict, # 存储原始字典或 UserInfo 对象,取决于你的 update_from_message 如何处理 - "time": time_value, - } - # 传递 UserInfo 对象(如果成功创建)或原始字典 - await self.observation_info.update_from_message(message, user_info) # 修改:传递 user_info 对象 + # 更新 ObservationInfo + await self.observation_info.update_from_message(message_dict, user_info) elif notification_type == NotificationType.COLD_CHAT: # 处理冷场通知 is_cold = data.get("is_cold", False) - await self.observation_info.update_cold_chat_status(is_cold, time.time()) # 修改:改为 await 调用 - - elif notification_type == NotificationType.ACTIVE_CHAT: - # 处理活跃通知 (通常由 COLD_CHAT 的反向状态处理) - is_active = data.get("is_active", False) - self.observation_info.is_cold = not is_active - - elif notification_type == NotificationType.BOT_SPEAKING: - # 处理机器人说话通知 (按需实现) - self.observation_info.is_typing = False - self.observation_info.last_bot_speak_time = time.time() - - elif notification_type == NotificationType.USER_SPEAKING: - # 处理用户说话通知 - self.observation_info.is_typing = False - self.observation_info.last_user_speak_time = time.time() + await self.observation_info.update_cold_chat_status(is_cold, timestamp) # 使用通知时间戳 elif notification_type == NotificationType.MESSAGE_DELETED: # 处理消息删除通知 - message_id = data.get("message_id") - # 从 unprocessed_messages 中移除被删除的消息 - original_count = len(self.observation_info.unprocessed_messages) - self.observation_info.unprocessed_messages = [ - msg for msg in self.observation_info.unprocessed_messages if msg.get("message_id") != message_id - ] - # --- [修改点 11] 更新 new_messages_count --- - self.observation_info.new_messages_count = len(self.observation_info.unprocessed_messages) - if self.observation_info.new_messages_count < original_count: - logger.info(f"[私聊][{self.private_name}]移除了未处理的消息 (ID: {message_id}), 当前未处理数: {self.observation_info.new_messages_count}") + message_id_to_delete = data.get("message_id") + if message_id_to_delete: + await self.observation_info.remove_unprocessed_message(message_id_to_delete) + else: + logger.warning(f"[私聊][{self.private_name}] 收到无效的消息删除通知,缺少 message_id: {data}") + # --- 可以根据需要处理其他通知类型 --- + elif notification_type == NotificationType.ACTIVE_CHAT: + is_active = data.get("is_active", False) + # 通常由 COLD_CHAT 的反向状态处理,但也可以在这里显式处理 + await self.observation_info.update_cold_chat_status(not is_active, timestamp) + + elif notification_type == NotificationType.BOT_SPEAKING: + # 机器人开始说话 (例如,如果需要显示"正在输入...") + # self.observation_info.is_typing = True + pass # 暂时不处理 + + elif notification_type == NotificationType.USER_SPEAKING: + # 用户开始说话 + # self.observation_info.is_typing = True + pass # 暂时不处理 elif notification_type == NotificationType.USER_JOINED: - # 处理用户加入通知 (如果适用私聊场景) user_id = data.get("user_id") if user_id: - self.observation_info.active_users.add(str(user_id)) # 确保是字符串 + self.observation_info.active_users.add(str(user_id)) + self.observation_info.update_changed() elif notification_type == NotificationType.USER_LEFT: - # 处理用户离开通知 (如果适用私聊场景) user_id = data.get("user_id") if user_id: - self.observation_info.active_users.discard(str(user_id)) # 确保是字符串 + self.observation_info.active_users.discard(str(user_id)) + self.observation_info.update_changed() elif notification_type == NotificationType.ERROR: - # 处理错误通知 error_msg = data.get("error", "未提供错误信息") - logger.error(f"[私聊][{self.private_name}]收到错误通知: {error_msg}") + logger.error(f"[私聊][{self.private_name}] 收到错误通知: {error_msg}") + # 可以在这里触发一些错误处理逻辑 except Exception as e: - logger.error(f"[私聊][{self.private_name}]处理通知时发生错误: {e}") - logger.error(traceback.format_exc()) # 打印详细堆栈信息 + logger.error(f"[私聊][{self.private_name}] 处理通知时发生错误 (类型: {notification_type.name}): {e}") + logger.error(traceback.format_exc()) -# @dataclass <-- 这个,不需要了(递黄瓜) class ObservationInfo: - """决策信息类,用于收集和管理来自chat_observer的通知信息 (手动实现 __init__)""" - - # 类型提示保留,可用于文档和静态分析 - private_name: str - chat_history: List[Dict[str, Any]] - chat_history_str: str - unprocessed_messages: List[Dict[str, Any]] - active_users: Set[str] - last_bot_speak_time: Optional[float] - last_user_speak_time: Optional[float] - last_message_time: Optional[float] - last_message_id: Optional[str] - last_message_content: str - last_message_sender: Optional[str] - bot_id: Optional[str] - chat_history_count: int - new_messages_count: int - cold_chat_start_time: Optional[float] - cold_chat_duration: float - is_typing: bool - is_cold_chat: bool - changed: bool - chat_observer: Optional[ChatObserver] - handler: Optional[ObservationInfoHandler] + """决策信息类,用于收集和管理来自chat_observer的通知信息""" def __init__(self, private_name: str): - """ - 手动初始化 ObservationInfo 的所有实例变量。 - """ - - # 接收的参数 + """初始化 ObservationInfo""" self.private_name: str = private_name - # data_list - self.chat_history: List[Dict[str, Any]] = [] - self.chat_history_str: str = "" - self.unprocessed_messages: List[Dict[str, Any]] = [] - self.active_users: Set[str] = set() + # 聊天记录相关 + self.chat_history: List[Dict[str, Any]] = [] # 存储已处理的消息历史 + self.chat_history_str: str = "还没有聊天记录。" # 用于生成 Prompt 的历史记录字符串 + self.chat_history_count: int = 0 - # data + # 未处理消息相关 (核心修改点) + self.unprocessed_messages: List[Dict[str, Any]] = [] # 存储尚未被机器人回复的消息 + self.new_messages_count: int = 0 # unprocessed_messages 的数量 + + # 状态信息 + self.active_users: Set[str] = set() # 当前活跃用户 (私聊场景可能只有对方) self.last_bot_speak_time: Optional[float] = None - self.last_user_speak_time: Optional[float] = None - self.last_message_time: Optional[float] = None + self.last_user_speak_time: Optional[float] = None # 指对方用户的发言时间 + self.last_message_time: Optional[float] = None # 指所有消息(包括自己)的最新时间 self.last_message_id: Optional[str] = None self.last_message_content: str = "" - self.last_message_sender: Optional[str] = None - self.bot_id: Optional[str] = None # 需要在某个地方设置 bot_id,例如从 global_config 获取 - self.chat_history_count: int = 0 - self.new_messages_count: int = 0 + self.last_message_sender: Optional[str] = None # user_id of the last message sender + self.bot_id: Optional[str] = None # 机器人自己的 ID + + # 冷场状态 self.cold_chat_start_time: Optional[float] = None self.cold_chat_duration: float = 0.0 + self.is_cold_chat: bool = False # 当前是否处于冷场状态 - # state - self.is_typing: bool = False - self.is_cold_chat: bool = False - self.changed: bool = False + # 其他状态 + self.is_typing: bool = False # 是否正在输入 (未来可能用到) + self.changed: bool = False # 状态是否有变化 (用于优化) # 关联对象 self.chat_observer: Optional[ChatObserver] = None + self.handler: Optional[ObservationInfoHandler] = ObservationInfoHandler(self, self.private_name) - self.handler: ObservationInfoHandler = ObservationInfoHandler(self, self.private_name) + # 初始化 bot_id + try: + from ...config.config import global_config - # --- 初始化 bot_id --- - from ...config.config import global_config # 移动到 __init__ 内部以避免循环导入问题 - self.bot_id = str(global_config.BOT_QQ) if global_config.BOT_QQ else None + self.bot_id = str(global_config.BOT_QQ) if global_config.BOT_QQ else None + if not self.bot_id: + logger.error(f"[私聊][{self.private_name}] 未能从配置中获取 BOT_QQ ID!") + except ImportError: + logger.error(f"[私聊][{self.private_name}] 无法导入 global_config 获取 BOT_QQ ID!") + except Exception as e: + logger.error(f"[私聊][{self.private_name}] 获取 BOT_QQ ID 时出错: {e}") def bind_to_chat_observer(self, chat_observer: ChatObserver): - """绑定到指定的chat_observer (保持不变)""" + """绑定到指定的 ChatObserver 并注册通知处理器""" if self.chat_observer: - logger.warning(f"[私聊][{self.private_name}]尝试重复绑定 ChatObserver") + logger.warning(f"[私聊][{self.private_name}] 尝试重复绑定 ChatObserver") + return + if not self.handler: + logger.error(f"[私聊][{self.private_name}] ObservationInfoHandler 未初始化,无法绑定!") return self.chat_observer = chat_observer try: - if not self.handler: - logger.error(f"[私聊][{self.private_name}] 尝试绑定时 handler 未初始化!") - self.chat_observer = None - return + # 注册需要处理的通知类型 + notification_manager = self.chat_observer.notification_manager + notification_manager.register_handler("observation_info", NotificationType.NEW_MESSAGE, self.handler) + notification_manager.register_handler("observation_info", NotificationType.COLD_CHAT, self.handler) + notification_manager.register_handler("observation_info", NotificationType.MESSAGE_DELETED, self.handler) + # 根据需要注册更多类型... + # notification_manager.register_handler("observation_info", NotificationType.ACTIVE_CHAT, self.handler) + # notification_manager.register_handler("observation_info", NotificationType.USER_JOINED, self.handler) + # notification_manager.register_handler("observation_info", NotificationType.USER_LEFT, self.handler) + # notification_manager.register_handler("observation_info", NotificationType.ERROR, self.handler) - self.chat_observer.notification_manager.register_handler( - target="observation_info", notification_type=NotificationType.NEW_MESSAGE, handler=self.handler - ) - self.chat_observer.notification_manager.register_handler( - target="observation_info", notification_type=NotificationType.COLD_CHAT, handler=self.handler - ) - # --- [修改点 12] 注册 MESSAGE_DELETED --- - self.chat_observer.notification_manager.register_handler( - target="observation_info", notification_type=NotificationType.MESSAGE_DELETED, handler=self.handler - ) - logger.info(f"[私聊][{self.private_name}]成功绑定到 ChatObserver") + logger.info(f"[私聊][{self.private_name}] ObservationInfo 成功绑定到 ChatObserver") + except AttributeError: + logger.error(f"[私聊][{self.private_name}] 绑定的 ChatObserver 对象缺少 notification_manager 属性!") + self.chat_observer = None # 绑定失败 except Exception as e: - logger.error(f"[私聊][{self.private_name}]绑定到 ChatObserver 时出错: {e}") - self.chat_observer = None + logger.error(f"[私聊][{self.private_name}] 绑定到 ChatObserver 时出错: {e}") + self.chat_observer = None # 绑定失败 def unbind_from_chat_observer(self): - """解除与chat_observer的绑定 (保持不变)""" - if ( - self.chat_observer and hasattr(self.chat_observer, "notification_manager") and self.handler - ): + """解除与 ChatObserver 的绑定""" + if self.chat_observer and hasattr(self.chat_observer, "notification_manager") and self.handler: try: - self.chat_observer.notification_manager.unregister_handler( - target="observation_info", notification_type=NotificationType.NEW_MESSAGE, handler=self.handler + notification_manager = self.chat_observer.notification_manager + notification_manager.unregister_handler("observation_info", NotificationType.NEW_MESSAGE, self.handler) + notification_manager.unregister_handler("observation_info", NotificationType.COLD_CHAT, self.handler) + notification_manager.unregister_handler( + "observation_info", NotificationType.MESSAGE_DELETED, self.handler ) - self.chat_observer.notification_manager.unregister_handler( - target="observation_info", notification_type=NotificationType.COLD_CHAT, handler=self.handler - ) - # --- [修改点 13] 注销 MESSAGE_DELETED --- - self.chat_observer.notification_manager.unregister_handler( - target="observation_info", notification_type=NotificationType.MESSAGE_DELETED, handler=self.handler - ) - logger.info(f"[私聊][{self.private_name}]成功从 ChatObserver 解绑") + # ... 注销其他已注册的类型 ... + + logger.info(f"[私聊][{self.private_name}] ObservationInfo 成功从 ChatObserver 解绑") except Exception as e: - logger.error(f"[私聊][{self.private_name}]从 ChatObserver 解绑时出错: {e}") + logger.error(f"[私聊][{self.private_name}] 从 ChatObserver 解绑时出错: {e}") finally: - self.chat_observer = None + self.chat_observer = None # 无论成功与否都清除引用 else: - logger.warning(f"[私聊][{self.private_name}]尝试解绑时 ChatObserver 不存在、无效或 handler 未设置") + logger.warning(f"[私聊][{self.private_name}] 尝试解绑时 ChatObserver 无效或 handler 未设置") async def update_from_message(self, message: Dict[str, Any], user_info: Optional[UserInfo]): - """从消息更新信息 (保持不变)""" + """根据收到的新消息更新 ObservationInfo 的状态""" message_time = message.get("time") message_id = message.get("message_id") processed_text = message.get("processed_plain_text", "") + sender_id_str: Optional[str] = str(user_info.user_id) if user_info else None - if message_time and message_time > (self.last_message_time or 0): + if not message_time or not message_id: + logger.warning(f"[私聊][{self.private_name}] 收到的消息缺少 time 或 message_id: {message}") + return + + # 更新最后消息时间(所有消息) + if message_time > (self.last_message_time or 0): self.last_message_time = message_time self.last_message_id = message_id self.last_message_content = processed_text - self.is_cold_chat = False - self.cold_chat_start_time = None - self.cold_chat_duration = 0.0 + self.last_message_sender = sender_id_str - if user_info: - sender_id = str(user_info.user_id) - self.last_message_sender = sender_id - if sender_id == self.bot_id: - self.last_bot_speak_time = message_time - else: - self.last_user_speak_time = message_time - self.active_users.add(sender_id) + # 更新说话者特定时间 + if sender_id_str: + if sender_id_str == self.bot_id: + self.last_bot_speak_time = message_time else: - logger.warning( - f"[私聊][{self.private_name}]处理消息更新时缺少有效的 UserInfo 对象, message_id: {message_id}" - ) - self.last_message_sender = None - - # --- [修改点 14] 添加到未处理列表,并更新计数 --- - # 检查消息是否已存在于未处理列表中,避免重复添加 - if not any(msg.get("message_id") == message_id for msg in self.unprocessed_messages): - self.unprocessed_messages.append(message) - self.new_messages_count = len(self.unprocessed_messages) - logger.debug(f"[私聊][{self.private_name}]添加新未处理消息 ID: {message_id}, 当前未处理数: {self.new_messages_count}") - self.update_changed() - else: - logger.warning(f"[私聊][{self.private_name}]尝试重复添加未处理消息 ID: {message_id}") - + self.last_user_speak_time = message_time + self.active_users.add(sender_id_str) # 添加到活跃用户 else: - pass + logger.warning(f"[私聊][{self.private_name}] 处理消息更新时缺少有效的 UserInfo, message_id: {message_id}") + # 更新冷场状态 + self.is_cold_chat = False + self.cold_chat_start_time = None + self.cold_chat_duration = 0.0 - def update_changed(self): - """标记状态已改变,并重置标记 (保持不变)""" - self.changed = True + # --- [核心修改] 将新消息添加到未处理列表 --- + # 检查消息是否已存在于未处理列表中,避免重复添加 + if not any(msg.get("message_id") == message_id for msg in self.unprocessed_messages): + # 创建消息的副本以避免修改原始数据(如果需要) + self.unprocessed_messages.append(message.copy()) + self.new_messages_count = len(self.unprocessed_messages) + logger.debug( + f"[私聊][{self.private_name}] 添加新未处理消息 ID: {message_id}, 发送者: {sender_id_str}, 当前未处理数: {self.new_messages_count}" + ) + self.update_changed() + else: + logger.warning(f"[私聊][{self.private_name}] 尝试重复添加未处理消息 ID: {message_id}") + + async def remove_unprocessed_message(self, message_id_to_delete: str): + """从 unprocessed_messages 列表中移除指定 ID 的消息""" + original_count = len(self.unprocessed_messages) + self.unprocessed_messages = [ + msg for msg in self.unprocessed_messages if msg.get("message_id") != message_id_to_delete + ] + new_count = len(self.unprocessed_messages) + + if new_count < original_count: + self.new_messages_count = new_count + logger.info( + f"[私聊][{self.private_name}] 移除了未处理的消息 (ID: {message_id_to_delete}), 当前未处理数: {self.new_messages_count}" + ) + self.update_changed() + else: + logger.warning(f"[私聊][{self.private_name}] 尝试移除不存在的未处理消息 ID: {message_id_to_delete}") async def update_cold_chat_status(self, is_cold: bool, current_time: float): - """更新冷场状态 (保持不变)""" + """更新冷场状态""" if is_cold != self.is_cold_chat: self.is_cold_chat = is_cold if is_cold: - self.cold_chat_start_time = ( - self.last_message_time or current_time - ) - logger.info(f"[私聊][{self.private_name}]进入冷场状态,开始时间: {self.cold_chat_start_time}") + # 冷场开始时间应基于最后一条消息的时间 + self.cold_chat_start_time = self.last_message_time or current_time + logger.info(f"[私聊][{self.private_name}] 进入冷场状态,开始时间: {self.cold_chat_start_time:.2f}") else: if self.cold_chat_start_time: self.cold_chat_duration = current_time - self.cold_chat_start_time - logger.info(f"[私聊][{self.private_name}]结束冷场状态,持续时间: {self.cold_chat_duration:.2f} 秒") - self.cold_chat_start_time = None + logger.info(f"[私聊][{self.private_name}] 结束冷场状态,持续时间: {self.cold_chat_duration:.2f} 秒") + self.cold_chat_start_time = None # 结束冷场,重置开始时间 self.update_changed() + # 持续更新冷场时长 if self.is_cold_chat and self.cold_chat_start_time: self.cold_chat_duration = current_time - self.cold_chat_start_time - def get_active_duration(self) -> float: - """获取当前活跃时长 (保持不变)""" - if not self.last_message_time: - return 0.0 - return time.time() - self.last_message_time - - def get_user_response_time(self) -> Optional[float]: - """获取用户最后响应时间 (保持不变)""" - if not self.last_user_speak_time: - return None - return time.time() - self.last_user_speak_time - - def get_bot_response_time(self) -> Optional[float]: - """获取机器人最后响应时间 (保持不变)""" - if not self.last_bot_speak_time: - return None - return time.time() - self.last_bot_speak_time + def update_changed(self): + """标记状态已改变""" + self.changed = True + # 这个标记通常在处理完改变后由外部逻辑重置为 False # --- [修改点 15] 重命名并修改 clear_unprocessed_messages --- - # async def clear_unprocessed_messages(self): <-- 旧方法注释掉或删除 async def clear_processed_messages(self, message_ids_to_clear: Set[str]): - """将指定ID的未处理消息移入历史记录,并更新相关状态""" + """将指定 ID 的未处理消息移入历史记录,并更新相关状态""" if not message_ids_to_clear: - logger.debug(f"[私聊][{self.private_name}]没有需要清理的消息 ID。") + logger.debug(f"[私聊][{self.private_name}] 没有需要清理的消息 ID。") return messages_to_move = [] @@ -342,36 +305,42 @@ class ObservationInfo: # 分离要清理和要保留的消息 for msg in self.unprocessed_messages: - if msg.get("message_id") in message_ids_to_clear: + msg_id = msg.get("message_id") + if msg_id in message_ids_to_clear: messages_to_move.append(msg) cleared_count += 1 else: remaining_messages.append(msg) if not messages_to_move: - logger.debug(f"[私聊][{self.private_name}]未找到与 ID 列表匹配的未处理消息进行清理。") + logger.debug( + f"[私聊][{self.private_name}] 未找到与 ID 列表 {message_ids_to_clear} 匹配的未处理消息进行清理。" + ) return - logger.debug(f"[私聊][{self.private_name}]准备清理 {cleared_count} 条已处理消息...") + logger.debug(f"[私聊][{self.private_name}] 准备清理 {cleared_count} 条已处理消息...") - # 将要移动的消息添加到历史记录 - max_history_len = 100 + # 将要移动的消息添加到历史记录 (按时间排序) + messages_to_move.sort(key=lambda m: m.get("time", 0)) self.chat_history.extend(messages_to_move) + + # 限制历史记录长度 (可选) + max_history_len = 100 # 例如保留最近 100 条 if len(self.chat_history) > max_history_len: self.chat_history = self.chat_history[-max_history_len:] - # 更新历史记录字符串 (仅使用最近一部分生成) - history_slice_for_str = self.chat_history[-20:] # 例如最近20条 + # 更新历史记录字符串 (仅使用最近一部分生成,提高效率) + history_slice_for_str = self.chat_history[-20:] # 例如最近 20 条 try: self.chat_history_str = await build_readable_messages( history_slice_for_str, replace_bot_name=True, merge_messages=False, timestamp_mode="relative", - read_mark=0.0, + read_mark=0.0, # read_mark 可能需要调整或移除 ) except Exception as e: - logger.error(f"[私聊][{self.private_name}]构建聊天记录字符串时出错: {e}") + logger.error(f"[私聊][{self.private_name}] 构建聊天记录字符串时出错: {e}") self.chat_history_str = "[构建聊天记录出错]" # 更新未处理消息列表和计数 @@ -379,6 +348,27 @@ class ObservationInfo: self.new_messages_count = len(self.unprocessed_messages) self.chat_history_count = len(self.chat_history) - logger.info(f"[私聊][{self.private_name}]已清理 {cleared_count} 条消息,剩余未处理 {self.new_messages_count} 条,当前历史记录 {self.chat_history_count} 条。") + logger.info( + f"[私聊][{self.private_name}] 已清理 {cleared_count} 条消息 (IDs: {message_ids_to_clear}),剩余未处理 {self.new_messages_count} 条,当前历史记录 {self.chat_history_count} 条。" + ) - self.update_changed() # 状态改变 \ No newline at end of file + self.update_changed() # 状态改变 + + # --- Helper methods (可以根据需要添加) --- + def get_active_duration(self) -> float: + """获取当前活跃时长(距离最后一条消息的时间)""" + if not self.last_message_time: + return float("inf") # 或返回 0.0,取决于定义 + return time.time() - self.last_message_time + + def get_user_response_time(self) -> Optional[float]: + """获取对方最后响应时间(距离对方最后一条消息的时间)""" + if not self.last_user_speak_time: + return None + return time.time() - self.last_user_speak_time + + def get_bot_response_time(self) -> Optional[float]: + """获取机器人最后响应时间(距离机器人最后一条消息的时间)""" + if not self.last_bot_speak_time: + return None + return time.time() - self.last_bot_speak_time diff --git a/src/plugins/PFC/pfc_manager.py b/src/plugins/PFC/pfc_manager.py index 7837606c..dadf31f9 100644 --- a/src/plugins/PFC/pfc_manager.py +++ b/src/plugins/PFC/pfc_manager.py @@ -1,8 +1,10 @@ import time +import asyncio # 引入 asyncio +import traceback from typing import Dict, Optional + from src.common.logger import get_module_logger from .conversation import Conversation -import traceback logger = get_module_logger("pfc_manager") @@ -15,101 +17,182 @@ class PFCManager: # 会话实例管理 _instances: Dict[str, Conversation] = {} - _initializing: Dict[str, bool] = {} + _initializing: Dict[str, bool] = {} # 用于防止并发初始化同一个 stream_id @classmethod def get_instance(cls) -> "PFCManager": - """获取管理器单例 - - Returns: - PFCManager: 管理器实例 - """ + """获取管理器单例""" if cls._instance is None: cls._instance = PFCManager() return cls._instance async def get_or_create_conversation(self, stream_id: str, private_name: str) -> Optional[Conversation]: - """获取或创建对话实例 + """获取或创建对话实例,并确保其启动""" - Args: - stream_id: 聊天流ID - private_name: 私聊名称 + # 检查是否正在初始化 (防止并发问题) + if self._initializing.get(stream_id, False): + logger.debug(f"[私聊][{private_name}] 会话实例正在初始化中,请稍候: {stream_id}") + # 可以选择等待一小段时间或直接返回 None + await asyncio.sleep(0.5) # 短暂等待,让初始化有机会完成 + # 再次检查实例是否存在 + if stream_id in self._instances and self._instances[stream_id]._initialized: + logger.debug(f"[私聊][{private_name}] 初始化已完成,返回现有实例: {stream_id}") + return self._instances[stream_id] + else: + logger.warning(f"[私聊][{private_name}] 等待后实例仍未初始化完成或不存在。") + return None # 避免返回未完成的实例 - Returns: - Optional[Conversation]: 对话实例,创建失败则返回None - """ - # 检查是否已经有实例 - if stream_id in self._initializing and self._initializing[stream_id]: - logger.debug(f"[私聊][{private_name}]会话实例正在初始化中: {stream_id}") - return None - - if stream_id in self._instances and self._instances[stream_id].should_continue: - logger.debug(f"[私聊][{private_name}]使用现有会话实例: {stream_id}") - return self._instances[stream_id] + # 检查是否已有活动实例 if stream_id in self._instances: instance = self._instances[stream_id] + # 检查忽略状态 if ( hasattr(instance, "ignore_until_timestamp") and instance.ignore_until_timestamp and time.time() < instance.ignore_until_timestamp ): - logger.debug(f"[私聊][{private_name}]会话实例当前处于忽略状态: {stream_id}") - # 返回 None 阻止交互。或者可以返回实例但标记它被忽略了喵? - # 还是返回 None 吧喵。 - return None + logger.debug(f"[私聊][{private_name}] 会话实例当前处于忽略状态: {stream_id}") + return None # 处于忽略状态,不返回实例 - # 检查 should_continue 状态 - if instance.should_continue: - logger.debug(f"[私聊][{private_name}]使用现有会话实例: {stream_id}") + # 检查是否已初始化且应继续运行 + if instance._initialized and instance.should_continue: + logger.debug(f"[私聊][{private_name}] 使用现有活动会话实例: {stream_id}") return instance - # else: 实例存在但不应继续 + else: + # 如果实例存在但未初始化或不应继续,清理旧实例 + logger.warning(f"[私聊][{private_name}] 发现无效或已停止的旧实例,清理并重新创建: {stream_id}") + await self._cleanup_conversation(instance) + # 从字典中移除,确保下面能创建新的 + if stream_id in self._instances: + del self._instances[stream_id] + if stream_id in self._initializing: + del self._initializing[stream_id] + + # --- 创建并初始化新实例 --- + conversation_instance: Optional[Conversation] = None try: - # 创建新实例 - logger.info(f"[私聊][{private_name}]创建新的对话实例: {stream_id}") - self._initializing[stream_id] = True + logger.info(f"[私聊][{private_name}] 创建新的对话实例: {stream_id}") + self._initializing[stream_id] = True # 标记开始初始化 + # 创建实例 conversation_instance = Conversation(stream_id, private_name) - self._instances[stream_id] = conversation_instance + self._instances[stream_id] = conversation_instance # 立即存入字典 - # 启动实例初始化 + # **启动实例初始化** + # _initialize_conversation 会调用 conversation._initialize() await self._initialize_conversation(conversation_instance) + + # --- 关键修复:在初始化成功后调用 start() --- + if conversation_instance._initialized and conversation_instance.should_continue: + logger.info(f"[私聊][{private_name}] 初始化成功,调用 conversation.start() 启动主循环...") + await conversation_instance.start() # 确保调用 start 方法 + else: + # 如果 _initialize_conversation 内部初始化失败 + logger.error(f"[私聊][{private_name}] 初始化未成功完成,无法启动实例 {stream_id}。") + # 清理可能部分创建的实例 + await self._cleanup_conversation(conversation_instance) + if stream_id in self._instances: + del self._instances[stream_id] + conversation_instance = None # 返回 None 表示失败 + except Exception as e: - logger.error(f"[私聊][{private_name}]创建会话实例失败: {stream_id}, 错误: {e}") - return None + logger.error(f"[私聊][{private_name}] 创建或启动会话实例时发生严重错误: {stream_id}, 错误: {e}") + logger.error(traceback.format_exc()) + # 确保清理 + if conversation_instance: + await self._cleanup_conversation(conversation_instance) + if stream_id in self._instances: + del self._instances[stream_id] + conversation_instance = None # 返回 None + + finally: + # 确保初始化标记被清除 + if stream_id in self._initializing: + self._initializing[stream_id] = False return conversation_instance async def _initialize_conversation(self, conversation: Conversation): - """初始化会话实例 - - Args: - conversation: 要初始化的会话实例 - """ + """(内部方法) 初始化会话实例的核心逻辑""" stream_id = conversation.stream_id private_name = conversation.private_name - try: - logger.info(f"[私聊][{private_name}]开始初始化会话实例: {stream_id}") - # 启动初始化流程 - await conversation._initialize() - - # 标记初始化完成 - self._initializing[stream_id] = False - - logger.info(f"[私聊][{private_name}]会话实例 {stream_id} 初始化完成") + logger.info(f"[私聊][{private_name}] 管理器开始调用 conversation._initialize(): {stream_id}") + await conversation._initialize() # 调用实例自身的初始化方法 + # 注意:初始化成功与否由 conversation._initialized 和 conversation.should_continue 标志决定 + if conversation._initialized: + logger.info( + f"[私聊][{private_name}] conversation._initialize() 调用完成,实例标记为已初始化: {stream_id}" + ) + else: + logger.warning( + f"[私聊][{private_name}] conversation._initialize() 调用完成,但实例未成功标记为已初始化: {stream_id}" + ) except Exception as e: - logger.error(f"[私聊][{private_name}]管理器初始化会话实例失败: {stream_id}, 错误: {e}") - logger.error(f"[私聊][{private_name}]{traceback.format_exc()}") - # 清理失败的初始化 + # _initialize 内部应该处理自己的异常,但这里也捕获以防万一 + logger.error( + f"[私聊][{private_name}] 调用 conversation._initialize() 时发生未捕获错误: {stream_id}, 错误: {e}" + ) + logger.error(traceback.format_exc()) + # 确保实例状态反映失败 + conversation._initialized = False + conversation.should_continue = False + + async def _cleanup_conversation(self, conversation: Conversation): + """清理会话实例的资源""" + if not conversation: + return + stream_id = conversation.stream_id + private_name = conversation.private_name + logger.info(f"[私聊][{private_name}] 开始清理会话实例资源: {stream_id}") + try: + # 调用 conversation 的 stop 方法来停止其内部组件 + if hasattr(conversation, "stop") and callable(conversation.stop): + await conversation.stop() # stop 方法应处理内部组件的停止 + else: + logger.warning(f"[私聊][{private_name}] Conversation 对象缺少 stop 方法,可能无法完全清理资源。") + # 尝试手动停止已知组件 (作为后备) + if hasattr(conversation, "idle_conversation_starter") and conversation.idle_conversation_starter: + conversation.idle_conversation_starter.stop() + if hasattr(conversation, "observation_info") and conversation.observation_info: + conversation.observation_info.unbind_from_chat_observer() + # ChatObserver 是单例,不在此处停止 + + logger.info(f"[私聊][{private_name}] 会话实例 {stream_id} 资源已清理") + except Exception as e: + logger.error(f"[私聊][{private_name}] 清理会话实例资源时失败: {stream_id}, 错误: {e}") + logger.error(traceback.format_exc()) async def get_conversation(self, stream_id: str) -> Optional[Conversation]: - """获取已存在的会话实例 + """获取已存在的会话实例 (只读)""" + instance = self._instances.get(stream_id) + if instance and instance._initialized and instance.should_continue: + # 检查忽略状态 + if ( + hasattr(instance, "ignore_until_timestamp") + and instance.ignore_until_timestamp + and time.time() < instance.ignore_until_timestamp + ): + return None # 忽略期间不返回 + return instance + return None # 不存在或无效则返回 None - Args: - stream_id: 聊天流ID - - Returns: - Optional[Conversation]: 会话实例,不存在则返回None - """ - return self._instances.get(stream_id) + async def remove_conversation(self, stream_id: str): + """移除并清理会话实例""" + if stream_id in self._instances: + instance_to_remove = self._instances[stream_id] + logger.info(f"[管理器] 准备移除并清理会话实例: {stream_id}") + try: + # 先从字典中移除引用,防止新的请求获取到正在清理的实例 + del self._instances[stream_id] + if stream_id in self._initializing: + del self._initializing[stream_id] + # 清理资源 + await self._cleanup_conversation(instance_to_remove) + logger.info(f"[管理器] 会话实例 {stream_id} 已成功移除并清理") + except Exception as e: + logger.error(f"[管理器] 移除或清理会话实例 {stream_id} 时失败: {e}") + logger.error(traceback.format_exc()) + else: + logger.warning(f"[管理器] 尝试移除不存在的会话实例: {stream_id}") diff --git a/src/plugins/PFC/pfc_types.py b/src/plugins/PFC/pfc_types.py index 0ea5eda6..16495134 100644 --- a/src/plugins/PFC/pfc_types.py +++ b/src/plugins/PFC/pfc_types.py @@ -18,6 +18,7 @@ class ConversationState(Enum): ENDED = "结束" JUDGING = "判断" IGNORED = "屏蔽" + ERROR = "错误" # <--- 添加 ERROR 状态 ActionType = Literal["direct_reply", "fetch_knowledge", "wait"] diff --git a/src/plugins/config_reload/__init__.py b/src/plugins/config_reload/__init__.py deleted file mode 100644 index 8b137891..00000000 --- a/src/plugins/config_reload/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/plugins/config_reload/api.py b/src/plugins/config_reload/api.py deleted file mode 100644 index 56240b88..00000000 --- a/src/plugins/config_reload/api.py +++ /dev/null @@ -1,19 +0,0 @@ -from fastapi import APIRouter, HTTPException -from rich.traceback import install - -install(extra_lines=3) - -# 创建APIRouter而不是FastAPI实例 -router = APIRouter() - - -@router.post("/reload-config") -async def reload_config(): - try: # TODO: 实现配置重载 - # bot_config_path = os.path.join(BotConfig.get_config_dir(), "bot_config.toml") - # BotConfig.reload_config(config_path=bot_config_path) - return {"message": "TODO: 实现配置重载", "status": "unimplemented"} - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) from e - except Exception as e: - raise HTTPException(status_code=500, detail=f"重载配置时发生错误: {str(e)}") from e diff --git a/src/plugins/config_reload/test.py b/src/plugins/config_reload/test.py deleted file mode 100644 index fc4fc1e8..00000000 --- a/src/plugins/config_reload/test.py +++ /dev/null @@ -1,4 +0,0 @@ -import requests - -response = requests.post("http://localhost:8080/api/reload-config") -print(response.json()) diff --git a/src/plugins/knowledge/src/embedding_store.py b/src/plugins/knowledge/src/embedding_store.py index e734f4e9..d1eb7f90 100644 --- a/src/plugins/knowledge/src/embedding_store.py +++ b/src/plugins/knowledge/src/embedding_store.py @@ -1,11 +1,13 @@ from dataclasses import dataclass import json import os +import math from typing import Dict, List, Tuple import numpy as np import pandas as pd -import tqdm + +# import tqdm import faiss from .llm_client import LLMClient @@ -25,9 +27,39 @@ from rich.progress import ( ) install(extra_lines=3) - +ROOT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) TOTAL_EMBEDDING_TIMES = 3 # 统计嵌入次数 +# 嵌入模型测试字符串,测试模型一致性,来自开发群的聊天记录 +# 这些字符串的嵌入结果应该是固定的,不能随时间变化 +EMBEDDING_TEST_STRINGS = [ + "阿卡伊真的太好玩了,神秘性感大女同等着你", + "你怎么知道我arc12.64了", + "我是蕾缪乐小姐的狗", + "关注Oct谢谢喵", + "不是w6我不草", + "关注千石可乐谢谢喵", + "来玩CLANNAD,AIR,樱之诗,樱之刻谢谢喵", + "关注墨梓柒谢谢喵", + "Ciallo~", + "来玩巧克甜恋谢谢喵", + "水印", + "我也在纠结晚饭,铁锅炒鸡听着就香!", + "test你妈喵", +] +EMBEDDING_TEST_FILE = os.path.join(ROOT_PATH, "data", "embedding_model_test.json") +EMBEDDING_SIM_THRESHOLD = 0.99 + + +def cosine_similarity(a, b): + # 计算余弦相似度 + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(x * x for x in b)) + if norm_a == 0 or norm_b == 0: + return 0.0 + return dot / (norm_a * norm_b) + @dataclass class EmbeddingStoreItem: @@ -64,6 +96,46 @@ class EmbeddingStore: def _get_embedding(self, s: str) -> List[float]: return self.llm_client.send_embedding_request(global_config["embedding"]["model"], s) + def get_test_file_path(self): + return EMBEDDING_TEST_FILE + + def save_embedding_test_vectors(self): + """保存测试字符串的嵌入到本地""" + test_vectors = {} + for idx, s in enumerate(EMBEDDING_TEST_STRINGS): + test_vectors[str(idx)] = self._get_embedding(s) + with open(self.get_test_file_path(), "w", encoding="utf-8") as f: + json.dump(test_vectors, f, ensure_ascii=False, indent=2) + + def load_embedding_test_vectors(self): + """加载本地保存的测试字符串嵌入""" + path = self.get_test_file_path() + if not os.path.exists(path): + return None + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + def check_embedding_model_consistency(self): + """校验当前模型与本地嵌入模型是否一致""" + local_vectors = self.load_embedding_test_vectors() + if local_vectors is None: + logger.warning("未检测到本地嵌入模型测试文件,将保存当前模型的测试嵌入。") + self.save_embedding_test_vectors() + return True + for idx, s in enumerate(EMBEDDING_TEST_STRINGS): + local_emb = local_vectors.get(str(idx)) + if local_emb is None: + logger.warning("本地嵌入模型测试文件缺失部分测试字符串,将重新保存。") + self.save_embedding_test_vectors() + return True + new_emb = self._get_embedding(s) + sim = cosine_similarity(local_emb, new_emb) + if sim < EMBEDDING_SIM_THRESHOLD: + logger.error("嵌入模型一致性校验失败") + return False + logger.info("嵌入模型一致性校验通过。") + return True + def batch_insert_strs(self, strs: List[str], times: int) -> None: """向库中存入字符串""" total = len(strs) @@ -123,11 +195,25 @@ class EmbeddingStore: """从文件中加载""" if not os.path.exists(self.embedding_file_path): raise Exception(f"文件{self.embedding_file_path}不存在") - logger.info(f"正在从文件{self.embedding_file_path}中加载{self.namespace}嵌入库") data_frame = pd.read_parquet(self.embedding_file_path, engine="pyarrow") - for _, row in tqdm.tqdm(data_frame.iterrows(), total=len(data_frame)): - self.store[row["hash"]] = EmbeddingStoreItem(row["hash"], row["embedding"], row["str"]) + total = len(data_frame) + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + MofNCompleteColumn(), + "•", + TimeElapsedColumn(), + "<", + TimeRemainingColumn(), + transient=False, + ) as progress: + task = progress.add_task("加载嵌入库", total=total) + for _, row in data_frame.iterrows(): + self.store[row["hash"]] = EmbeddingStoreItem(row["hash"], row["embedding"], row["str"]) + progress.update(task, advance=1) logger.info(f"{self.namespace}嵌入库加载成功") try: @@ -216,6 +302,17 @@ class EmbeddingManager: ) self.stored_pg_hashes = set() + def check_all_embedding_model_consistency(self): + """对所有嵌入库做模型一致性校验""" + for store in [ + self.paragraphs_embedding_store, + self.entities_embedding_store, + self.relation_embedding_store, + ]: + if not store.check_embedding_model_consistency(): + return False + return True + def _store_pg_into_embedding(self, raw_paragraphs: Dict[str, str]): """将段落编码存入Embedding库""" self.paragraphs_embedding_store.batch_insert_strs(list(raw_paragraphs.values()), times=1) @@ -239,6 +336,8 @@ class EmbeddingManager: def load_from_file(self): """从文件加载""" + if not self.check_all_embedding_model_consistency(): + raise Exception("嵌入模型与本地存储不一致,请检查模型设置或清空嵌入库后重试。") self.paragraphs_embedding_store.load_from_file() self.entities_embedding_store.load_from_file() self.relation_embedding_store.load_from_file() @@ -250,6 +349,8 @@ class EmbeddingManager: raw_paragraphs: Dict[str, str], triple_list_data: Dict[str, List[List[str]]], ): + if not self.check_all_embedding_model_consistency(): + raise Exception("嵌入模型与本地存储不一致,请检查模型设置或清空嵌入库后重试。") """存储新的数据集""" self._store_pg_into_embedding(raw_paragraphs) self._store_ent_into_embedding(triple_list_data) diff --git a/src/plugins/person_info/person_info.py b/src/plugins/person_info/person_info.py index 73886417..6578e0de 100644 --- a/src/plugins/person_info/person_info.py +++ b/src/plugins/person_info/person_info.py @@ -216,9 +216,11 @@ class PersonInfoManager: if old_name: qv_name_prompt += f"你之前叫他{old_name},是因为{old_reason}," - qv_name_prompt += f"\n其他取名的要求是:{request}" + qv_name_prompt += f"\n其他取名的要求是:{request},不要太浮夸" - qv_name_prompt += "\n请根据以上用户信息,想想你叫他什么比较好,请最好使用用户的qq昵称,可以稍作修改" + qv_name_prompt += ( + "\n请根据以上用户信息,想想你叫他什么比较好,不要太浮夸,请最好使用用户的qq昵称,可以稍作修改" + ) if existing_names: qv_name_prompt += f"\n请注意,以下名称已被使用,不要使用以下昵称:{existing_names}。\n" qv_name_prompt += "请用json给出你的想法,并给出理由,示例如下:" diff --git a/src/plugins/remote/remote.py b/src/plugins/remote/remote.py index 1ba32ec9..5d880271 100644 --- a/src/plugins/remote/remote.py +++ b/src/plugins/remote/remote.py @@ -15,33 +15,67 @@ remote_log_config = LogConfig( ) logger = get_module_logger("remote", config=remote_log_config) -# UUID文件路径 -UUID_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "client_uuid.json") +# --- 使用向上导航的方式定义路径 --- + +# 1. 获取当前文件 (remote.py) 所在的目录 +current_dir = os.path.dirname(os.path.abspath(__file__)) + +# 2. 从当前目录向上导航三级找到项目根目录 +# (src/plugins/remote/ -> src/plugins/ -> src/ -> project_root) +root_dir = os.path.abspath(os.path.join(current_dir, "..", "..", "..")) + +# 3. 定义 data 目录的路径 (位于项目根目录下) +data_dir = os.path.join(root_dir, "data") + +# 4. 定义 UUID 文件在 data 目录下的完整路径 +UUID_FILE = os.path.join(data_dir, "client_uuid.json") + +# --- 路径定义结束 --- # 生成或获取客户端唯一ID def get_unique_id(): + # --- 在尝试读写 UUID_FILE 之前确保 data 目录存在 --- + # 将目录检查和创建逻辑移到这里,在首次需要写入前执行 + try: + # exist_ok=True 意味着如果目录已存在也不会报错 + os.makedirs(data_dir, exist_ok=True) + except OSError as e: + # 处理可能的权限错误等 + logger.error(f"无法创建数据目录 {data_dir}: {e}") + # 根据你的错误处理逻辑,可能需要在这里返回错误或抛出异常 + # 暂且返回 None 或抛出,避免继续执行导致问题 + raise RuntimeError(f"无法创建必要的数据目录 {data_dir}") from e + # --- 目录检查结束 --- + # 检查是否已经有保存的UUID if os.path.exists(UUID_FILE): try: - with open(UUID_FILE, "r") as f: + with open(UUID_FILE, "r", encoding="utf-8") as f: # 指定 encoding data = json.load(f) if "client_id" in data: - # print("从本地文件读取客户端ID") + logger.debug(f"从本地文件读取客户端ID: {UUID_FILE}") return data["client_id"] except (json.JSONDecodeError, IOError) as e: - print(f"读取UUID文件出错: {e},将生成新的UUID") + logger.warning(f"读取UUID文件 {UUID_FILE} 出错: {e},将生成新的UUID") + except Exception as e: # 捕捉其他可能的异常 + logger.error(f"读取UUID文件 {UUID_FILE} 时发生未知错误: {e}") # 如果没有保存的UUID或读取出错,则生成新的 client_id = generate_unique_id() + logger.info(f"生成新的客户端ID: {client_id}") # 保存UUID到文件 try: - with open(UUID_FILE, "w") as f: - json.dump({"client_id": client_id}, f) - logger.info("已保存新生成的客户端ID到本地文件") + # 再次确认目录存在 (虽然理论上前面已创建,但更保险) + os.makedirs(data_dir, exist_ok=True) + with open(UUID_FILE, "w", encoding="utf-8") as f: # 指定 encoding + json.dump({"client_id": client_id}, f, indent=4) # 添加 indent 使json可读 + logger.info(f"已保存新生成的客户端ID到本地文件: {UUID_FILE}") except IOError as e: - logger.error(f"保存UUID时出错: {e}") + logger.error(f"保存UUID时出错: {UUID_FILE} - {e}") + except Exception as e: # 捕捉其他可能的异常 + logger.error(f"保存UUID文件 {UUID_FILE} 时发生未知错误: {e}") return client_id diff --git a/![新版麦麦开始学习.bat b/start_lpmm.bat similarity index 100% rename from ![新版麦麦开始学习.bat rename to start_lpmm.bat diff --git a/(测试版)麦麦生成人格 copy.bat b/start_personality.bat similarity index 100% rename from (测试版)麦麦生成人格 copy.bat rename to start_personality.bat diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 25ac7324..67518e12 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "1.6.1.5" +version = "1.6.2" #----以下是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- #如果你想要修改配置文件,请在修改后将version的值进行变更 @@ -70,8 +70,8 @@ nonebot-qq="http://127.0.0.1:18002/api/message" allow_focus_mode = true # 是否允许专注聊天状态 # 是否启用heart_flowC(HFC)模式 # 启用后麦麦会自主选择进入heart_flowC模式(持续一段时间),进行主动的观察和回复,并给出回复,比较消耗token -base_normal_chat_num = 3 # 最多允许多少个群进行普通聊天 -base_focused_chat_num = 2 # 最多允许多少个群进行专注聊天 +base_normal_chat_num = 8 # 最多允许多少个群进行普通聊天 +base_focused_chat_num = 5 # 最多允许多少个群进行专注聊天 allow_remove_duplicates = true # 是否开启心流去重(如果发现心流截断问题严重可尝试关闭) observation_context_size = 15 # 观察到的最长上下文大小,建议15,太短太长都会导致脑袋尖尖 @@ -202,6 +202,12 @@ talk_allowed_private = [] # 可以回复消息的QQ号 pfc_chatting = false # 是否启用PFC聊天,该功能仅作用于私聊,与回复模式独立 api_polling_max_retries = 3 +[idle_conversation] +enable_idle_conversation = true +idle_check_interval = 10 # 检查间隔,10分钟检查一次 +min_idle_time = 7200 # 最短无活动时间,2小时 (7200秒) +max_idle_time = 18000 # 最长无活动时间,5小时 (18000秒) + #下面的模型若使用硅基流动则不需要更改,使用ds官方则改成.env自定义的宏,使用自定义模型则选择定位相似的模型自己填写 #推理模型 diff --git a/template/lpmm_config_template.toml b/template/lpmm_config_template.toml index aae664d5..745cbaaf 100644 --- a/template/lpmm_config_template.toml +++ b/template/lpmm_config_template.toml @@ -38,8 +38,8 @@ synonym_threshold = 0.8 # 同义词阈值(相似度高于此阈值的词语 provider = "siliconflow" # 服务提供商 model = "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B" # 模型名称 -[info_extraction] -workers = 10 +[info_extraction] +workers = 3 # 实体提取同时执行线程数,非Pro模型不要设置超过5 [qa.params] # QA参数配置 diff --git a/(临时版)聊天兴趣监控.bat.bat b/(临时版)聊天兴趣监控.bat.bat deleted file mode 100644 index f26d14de..00000000 --- a/(临时版)聊天兴趣监控.bat.bat +++ /dev/null @@ -1,26 +0,0 @@ -@echo off -CHCP 65001 > nul -setlocal enabledelayedexpansion - -REM 查找venv虚拟环境 -set "venv_path=%~dp0venv\Scripts\activate.bat" -if not exist "%venv_path%" ( - echo 错误: 未找到虚拟环境,请确保venv目录存在 - pause - exit /b 1 -) - -REM 激活虚拟环境 -call "%venv_path%" -if %ERRORLEVEL% neq 0 ( - echo 错误: 虚拟环境激活失败 - pause - exit /b 1 -) - -echo 虚拟环境已激活,正在启动 GUI... - -REM 运行 Python 脚本 -python scripts/interest_monitor_gui.py - -pause \ No newline at end of file