From 2b7559b8cc969f3f7bbfe756768f1487f241e44e Mon Sep 17 00:00:00 2001 From: Ronifue Date: Tue, 2 Dec 2025 16:00:05 +0800 Subject: [PATCH 01/23] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BF=9D?= =?UTF-8?q?=E5=AD=98toml=E6=97=B6=E5=81=B6=E5=8F=91=E7=9A=84=E7=A9=BA?= =?UTF-8?q?=E8=A1=8C=E7=B4=AF=E8=AE=A1bug=E5=92=8C=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/toml_utils.py | 66 +++++++++++++++++++++++++++++++++++--- src/webui/config_routes.py | 44 +++---------------------- 2 files changed, 67 insertions(+), 43 deletions(-) diff --git a/src/common/toml_utils.py b/src/common/toml_utils.py index 0c34f6ab..0a88c458 100644 --- a/src/common/toml_utils.py +++ b/src/common/toml_utils.py @@ -4,6 +4,7 @@ TOML 工具函数 提供 TOML 文件的格式化保存功能,确保数组等元素以美观的多行格式输出。 """ +import re from typing import Any import tomlkit from tomlkit.items import AoT, Table, Array @@ -54,14 +55,71 @@ def _format_toml_value(obj: Any, threshold: int, depth: int = 0) -> Any: return obj -def save_toml_with_format(data: Any, file_path: str, multiline_threshold: int = 1) -> None: - """格式化 TOML 数据并保存到文件""" +def _update_toml_doc(target: Any, source: Any) -> None: + """ + 递归合并字典,将 source 的值更新到 target 中,保留 target 的注释和格式。 + - 已存在的键:更新值(递归处理嵌套字典) + - 新增的键:添加到 target + - 跳过 version 字段 + """ + if isinstance(source, list) or not isinstance(source, dict) or not isinstance(target, dict): + return + + for key, value in source.items(): + if key == "version": + continue + if key in target: + # 已存在的键:递归更新或直接赋值 + target_value = target[key] + if isinstance(value, dict) and isinstance(target_value, dict): + _update_toml_doc(target_value, value) + else: + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + else: + # 新增的键:添加到 target + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + + +def save_toml_with_format( + data: Any, file_path: str, multiline_threshold: int = 1, preserve_comments: bool = True +) -> None: + """ + 格式化 TOML 数据并保存到文件。 + + Args: + data: 要保存的数据(dict 或 tomlkit 文档) + file_path: 保存路径 + multiline_threshold: 数组多行格式化阈值,-1 表示不格式化 + preserve_comments: 是否保留原文件的注释和格式(默认 True) + 若为 True 且文件已存在且 data 不是 tomlkit 文档,会先读取原文件,再将 data 合并进去 + """ + import os + from tomlkit import TOMLDocument + + # 如果需要保留注释、文件存在、且 data 不是已有的 tomlkit 文档,先读取原文件再合并 + if preserve_comments and os.path.exists(file_path) and not isinstance(data, TOMLDocument): + with open(file_path, "r", encoding="utf-8") as f: + doc = tomlkit.load(f) + _update_toml_doc(doc, data) + data = doc + formatted = _format_toml_value(data, multiline_threshold) if multiline_threshold >= 0 else data + output = tomlkit.dumps(formatted) + # 规范化:将 3+ 连续空行压缩为 1 个空行,防止空行累积 + output = re.sub(r'\n{3,}', '\n\n', output) with open(file_path, "w", encoding="utf-8") as f: - tomlkit.dump(formatted, f) + f.write(output) def format_toml_string(data: Any, multiline_threshold: int = 1) -> str: """格式化 TOML 数据并返回字符串""" formatted = _format_toml_value(data, multiline_threshold) if multiline_threshold >= 0 else data - return tomlkit.dumps(formatted) \ No newline at end of file + output = tomlkit.dumps(formatted) + # 规范化:将 3+ 连续空行压缩为 1 个空行,防止空行累积 + return re.sub(r'\n{3,}', '\n\n', output) \ No newline at end of file diff --git a/src/webui/config_routes.py b/src/webui/config_routes.py index 5e54c8f7..63803d5e 100644 --- a/src/webui/config_routes.py +++ b/src/webui/config_routes.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Body from typing import Any, Annotated from src.common.logger import get_logger -from src.common.toml_utils import save_toml_with_format +from src.common.toml_utils import save_toml_with_format, _update_toml_doc from src.config.config import Config, APIAdapterConfig, CONFIG_DIR, PROJECT_ROOT from src.config.official_configs import ( BotConfig, @@ -51,40 +51,6 @@ PathBody = Annotated[dict[str, str], Body()] router = APIRouter(prefix="/config", tags=["config"]) -# ===== 辅助函数 ===== - - -def _update_dict_preserve_comments(target: Any, source: Any) -> None: - """ - 递归合并字典,保留 target 中的注释和格式 - 将 source 的值更新到 target 中(仅更新已存在的键) - - Args: - target: 目标字典(tomlkit 对象,包含注释) - source: 源字典(普通 dict 或 list) - """ - # 如果 source 是列表,直接替换(数组表没有注释保留的意义) - if isinstance(source, list): - return # 调用者需要直接赋值 - - # 如果都是字典,递归合并 - if isinstance(source, dict) and isinstance(target, dict): - for key, value in source.items(): - if key == "version": - continue # 跳过版本号 - if key in target: - target_value = target[key] - # 递归处理嵌套字典 - if isinstance(value, dict) and isinstance(target_value, dict): - _update_dict_preserve_comments(target_value, value) - else: - # 使用 tomlkit.item 保持类型 - try: - target[key] = tomlkit.item(value) - except (TypeError, ValueError): - target[key] = value - - # ===== 架构获取接口 ===== @@ -238,7 +204,7 @@ async def update_bot_config(config_data: ConfigBody): except Exception as e: raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e - # 保存配置文件(格式化数组为多行) + # 保存配置文件(自动保留注释和格式) config_path = os.path.join(CONFIG_DIR, "bot_config.toml") save_toml_with_format(config_data, config_path) @@ -261,7 +227,7 @@ async def update_model_config(config_data: ConfigBody): except Exception as e: raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e - # 保存配置文件(格式化数组为多行) + # 保存配置文件(自动保留注释和格式) config_path = os.path.join(CONFIG_DIR, "model_config.toml") save_toml_with_format(config_data, config_path) @@ -300,7 +266,7 @@ async def update_bot_config_section(section_name: str, section_data: SectionBody config_data[section_name] = section_data elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): # 字典递归合并 - _update_dict_preserve_comments(config_data[section_name], section_data) + _update_toml_doc(config_data[section_name], section_data) else: # 其他类型直接替换 config_data[section_name] = section_data @@ -398,7 +364,7 @@ async def update_model_config_section(section_name: str, section_data: SectionBo config_data[section_name] = section_data elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): # 字典递归合并 - _update_dict_preserve_comments(config_data[section_name], section_data) + _update_toml_doc(config_data[section_name], section_data) else: # 其他类型直接替换 config_data[section_name] = section_data From dd7303091924c72668de02e0112ec4fc88b5dcc9 Mon Sep 17 00:00:00 2001 From: Ronifue Date: Tue, 2 Dec 2025 16:20:44 +0800 Subject: [PATCH 02/23] =?UTF-8?q?fix:=20=E7=AE=80=E5=8C=96=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webui/plugin_routes.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/webui/plugin_routes.py b/src/webui/plugin_routes.py index 1f2d85da..9eedc9d7 100644 --- a/src/webui/plugin_routes.py +++ b/src/webui/plugin_routes.py @@ -1420,18 +1420,8 @@ async def update_plugin_config( shutil.copy(config_path, backup_path) logger.info(f"已备份配置文件: {backup_path}") - # 写入新配置(使用 tomlkit 保留注释) - import tomlkit - - # 先读取原配置以保留注释和格式 - existing_doc = tomlkit.document() - if config_path.exists(): - with open(config_path, "r", encoding="utf-8") as f: - existing_doc = tomlkit.load(f) - # 更新值 - for key, value in request.config.items(): - existing_doc[key] = value - save_toml_with_format(existing_doc, str(config_path)) + # 写入新配置(自动保留注释和格式) + save_toml_with_format(request.config, str(config_path)) logger.info(f"已更新插件配置: {plugin_id}") From 7f66d5588d25f2c0d2733c7d3874ad424bd5e879 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 3 Dec 2025 00:21:02 +0800 Subject: [PATCH 03/23] =?UTF-8?q?better=EF=BC=9A=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=A1=A8=E8=BE=BE=E6=96=B9=E5=BC=8F=E6=8F=90=E5=8F=96=E7=9A=84?= =?UTF-8?q?token=E6=B6=88=E8=80=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/utils/statistic.py | 36 +- src/common/database/database_model.py | 1 - src/config/config.py | 2 +- src/express/expression_learner.py | 406 +++++------------- .../chat_history_summarizer.py | 33 +- src/webui/expression_routes.py | 9 +- 6 files changed, 155 insertions(+), 332 deletions(-) diff --git a/src/chat/utils/statistic.py b/src/chat/utils/statistic.py index fa865d48..4a115e06 100644 --- a/src/chat/utils/statistic.py +++ b/src/chat/utils/statistic.py @@ -8,7 +8,7 @@ from typing import Any, Dict, Tuple, List from src.common.logger import get_logger from src.common.database.database import db -from src.common.database.database_model import OnlineTime, LLMUsage, Messages +from src.common.database.database_model import OnlineTime, LLMUsage, Messages, ActionRecords from src.manager.async_task_manager import AsyncTask from src.manager.local_store_manager import local_storage from src.config.config import global_config @@ -505,13 +505,6 @@ class StatisticOutputTask(AsyncTask): for period_key, _ in collect_period } - # 获取bot的QQ账号 - bot_qq_account = ( - str(global_config.bot.qq_account) - if hasattr(global_config, "bot") and hasattr(global_config.bot, "qq_account") - else "" - ) - query_start_timestamp = collect_period[-1][1].timestamp() # Messages.time is a DoubleField (timestamp) for message in Messages.select().where(Messages.time >= query_start_timestamp): # type: ignore message_time_ts = message.time # This is a float timestamp @@ -537,7 +530,7 @@ class StatisticOutputTask(AsyncTask): if not chat_id: # Should not happen if above logic is correct continue - # Update name_mapping + # Update name_mapping(仅用于展示聊天名称) try: if chat_id in self.name_mapping: if chat_name != self.name_mapping[chat_id][0] and message_time_ts > self.name_mapping[chat_id][1]: @@ -549,19 +542,30 @@ class StatisticOutputTask(AsyncTask): # 重置为正确的格式 self.name_mapping[chat_id] = (chat_name, message_time_ts) - # 检查是否是bot发送的消息(回复) - is_bot_reply = False - if bot_qq_account and message.user_id == bot_qq_account: - is_bot_reply = True - for idx, (_, period_start_dt) in enumerate(collect_period): if message_time_ts >= period_start_dt.timestamp(): for period_key, _ in collect_period[idx:]: stats[period_key][TOTAL_MSG_CNT] += 1 stats[period_key][MSG_CNT_BY_CHAT][chat_id] += 1 - if is_bot_reply: - stats[period_key][TOTAL_REPLY_CNT] += 1 break + + # 使用 ActionRecords 中的 reply 动作次数作为回复数基准 + try: + action_query_start_timestamp = collect_period[-1][1].timestamp() + for action in ActionRecords.select().where(ActionRecords.time >= action_query_start_timestamp): # type: ignore + # 仅统计已完成的 reply 动作 + if action.action_name != "reply" or not action.action_done: + continue + + action_time_ts = action.time + for idx, (_, period_start_dt) in enumerate(collect_period): + if action_time_ts >= period_start_dt.timestamp(): + for period_key, _ in collect_period[idx:]: + stats[period_key][TOTAL_REPLY_CNT] += 1 + break + except Exception as e: + logger.warning(f"统计 reply 动作次数失败,将回复数视为 0,错误信息:{e}") + return stats def _collect_all_statistics(self, now: datetime) -> Dict[str, Dict[str, Any]]: diff --git a/src/common/database/database_model.py b/src/common/database/database_model.py index 9b1f18df..a8be368d 100644 --- a/src/common/database/database_model.py +++ b/src/common/database/database_model.py @@ -324,7 +324,6 @@ class Expression(BaseModel): # new mode fields context = TextField(null=True) - up_content = TextField(null=True) content_list = TextField(null=True) count = IntegerField(default=1) diff --git a/src/config/config.py b/src/config/config.py index 9342cbb4..28384f99 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -57,7 +57,7 @@ TEMPLATE_DIR = os.path.join(PROJECT_ROOT, "template") # 考虑到,实际上配置文件中的mai_version是不会自动更新的,所以采用硬编码 # 对该字段的更新,请严格参照语义化版本规范:https://semver.org/lang/zh-CN/ -MMC_VERSION = "0.11.6" +MMC_VERSION = "0.11.7-snapshot.1" def get_key_comment(toml_table, key): diff --git a/src/express/expression_learner.py b/src/express/expression_learner.py index af69cad6..0c335488 100644 --- a/src/express/expression_learner.py +++ b/src/express/expression_learner.py @@ -12,11 +12,10 @@ from src.config.config import model_config, global_config from src.chat.utils.chat_message_builder import ( get_raw_msg_by_timestamp_with_chat_inclusive, build_anonymous_messages, - build_bare_messages, ) from src.chat.utils.prompt_builder import Prompt, global_prompt_manager from src.chat.message_receive.chat_stream import get_chat_manager -from src.express.express_utils import filter_message_content, calculate_similarity +from src.express.express_utils import filter_message_content from json_repair import repair_json @@ -26,10 +25,10 @@ logger = get_logger("expressor") def init_prompt() -> None: - learn_style_prompt = """ -{chat_str} + learn_style_prompt = """{chat_str} -请从上面这段群聊中概括除了人名为"SELF"之外的人的语言风格 +请从上面这段群聊中概括除了人名为"SELF"之外的人的语言风格。 +每一行消息前面的方括号中的数字(如 [1]、[2])是该行消息的唯一编号,请在输出中引用这些编号来标注“表达方式的来源行”。 1. 只考虑文字,不要考虑表情包和图片 2. 不要涉及具体的人名,但是可以涉及具体名词 3. 思考有没有特殊的梗,一并总结成语言风格 @@ -37,41 +36,29 @@ def init_prompt() -> None: 注意:总结成如下格式的规律,总结的内容要详细,但具有概括性: 例如:当"AAAAA"时,可以"BBBBB", AAAAA代表某个具体的场景,不超过20个字。BBBBB代表对应的语言风格,特定句式或表达方式,不超过20个字。 -例如: -当"对某件事表示十分惊叹"时,使用"我嘞个xxxx" -当"表示讽刺的赞同,不讲道理"时,使用"对对对" -当"想说明某个具体的事实观点,但懒得明说,使用"懂的都懂" -当"当涉及游戏相关时,夸赞,略带戏谑意味"时,使用"这么强!" +请严格以 JSON 数组的形式输出结果,每个元素为一个对象,结构如下(注意字段名): +[ + {{"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}}, + {{"situation": "CCCC", "style": "DDDD", "source_id": "7"}} + {{"situation": "对某件事表示十分惊叹", "style": "我嘞个xxxx", "source_id": "[消息编号]"}}, + {{"situation": "表示讽刺的赞同,不讲道理", "style": "对对对", "source_id": "[消息编号]"}}, + {{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "这么强!", "source_id": "[消息编号]"}}, +] -请注意:不要总结你自己(SELF)的发言,尽量保证总结内容的逻辑性 -现在请你概括 +请注意: +- 不要总结你自己(SELF)的发言,尽量保证总结内容的逻辑性 +- 请只针对最重要的若干条表达方式进行总结,避免输出太多重复或相似的条目 + +其中: +- situation:表示“在什么情境下”的简短概括(不超过20个字) +- style:表示对应的语言风格或常用表达(不超过20个字) +- source_id:该表达方式对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 + +现在请你输出 JSON: """ Prompt(learn_style_prompt, "learn_style_prompt") - match_expression_context_prompt = """ -**聊天内容** -{chat_str} -**从聊天内容总结的表达方式pairs** -{expression_pairs} - -请你为上面的每一条表达方式,找到该表达方式的原文句子,并输出匹配结果,expression_pair不能有重复,每个expression_pair仅输出一个最合适的context。 -如果找不到原句,就不输出该句的匹配结果。 -以json格式输出: -格式如下: -{{ - "expression_pair": "表达方式pair的序号(数字)", - "context": "与表达方式对应的原文句子的原始内容,不要修改原文句子的内容", -}}, -{{ - "expression_pair": "表达方式pair的序号(数字)", - "context": "与表达方式对应的原文句子的原始内容,不要修改原文句子的内容", -}}, -... - -现在请你输出匹配结果: -""" - Prompt(match_expression_context_prompt, "match_expression_context_prompt") class ExpressionLearner: @@ -193,7 +180,6 @@ class ExpressionLearner: situation, style, _context, - _up_content, ) in learnt_expressions: learnt_expressions_str += f"{situation}->{style}\n" logger.info(f"在 {self.chat_name} 学习到表达风格:\n{learnt_expressions_str}") @@ -205,193 +191,17 @@ class ExpressionLearner: situation, style, context, - up_content, ) in learnt_expressions: await self._upsert_expression_record( situation=situation, style=style, context=context, - up_content=up_content, current_time=current_time, ) return learnt_expressions - async def match_expression_context( - self, expression_pairs: List[Tuple[str, str]], random_msg_match_str: str - ) -> List[Tuple[str, str, str]]: - # 为expression_pairs逐个条目赋予编号,并构建成字符串 - numbered_pairs = [] - for i, (situation, style) in enumerate(expression_pairs, 1): - numbered_pairs.append(f'{i}. 当"{situation}"时,使用"{style}"') - - expression_pairs_str = "\n".join(numbered_pairs) - - prompt = "match_expression_context_prompt" - prompt = await global_prompt_manager.format_prompt( - prompt, - expression_pairs=expression_pairs_str, - chat_str=random_msg_match_str, - ) - - response, _ = await self.express_learn_model.generate_response_async(prompt, temperature=0.3) - - # print(f"match_expression_context_prompt: {prompt}") - # print(f"{response}") - - # 解析JSON响应 - match_responses = [] - try: - response = response.strip() - - # 尝试提取JSON代码块(如果存在) - json_pattern = r"```json\s*(.*?)\s*```" - matches = re.findall(json_pattern, response, re.DOTALL) - if matches: - response = matches[0].strip() - - # 移除可能的markdown代码块标记(如果没有找到```json,但可能有```) - if not matches: - response = re.sub(r"^```\s*", "", response, flags=re.MULTILINE) - response = re.sub(r"```\s*$", "", response, flags=re.MULTILINE) - response = response.strip() - - # 检查是否已经是标准JSON数组格式 - if response.startswith("[") and response.endswith("]"): - match_responses = json.loads(response) - else: - # 尝试直接解析多个JSON对象 - try: - # 如果是多个JSON对象用逗号分隔,包装成数组 - if response.startswith("{") and not response.startswith("["): - response = "[" + response + "]" - match_responses = json.loads(response) - else: - # 使用repair_json处理响应 - repaired_content = repair_json(response) - - # 确保repaired_content是列表格式 - if isinstance(repaired_content, str): - try: - parsed_data = json.loads(repaired_content) - if isinstance(parsed_data, dict): - # 如果是字典,包装成列表 - match_responses = [parsed_data] - elif isinstance(parsed_data, list): - match_responses = parsed_data - else: - match_responses = [] - except json.JSONDecodeError: - match_responses = [] - elif isinstance(repaired_content, dict): - # 如果是字典,包装成列表 - match_responses = [repaired_content] - elif isinstance(repaired_content, list): - match_responses = repaired_content - else: - match_responses = [] - except json.JSONDecodeError: - # 如果还是失败,尝试repair_json - repaired_content = repair_json(response) - if isinstance(repaired_content, str): - parsed_data = json.loads(repaired_content) - match_responses = parsed_data if isinstance(parsed_data, list) else [parsed_data] - else: - match_responses = repaired_content if isinstance(repaired_content, list) else [repaired_content] - - except (json.JSONDecodeError, Exception) as e: - logger.error(f"解析匹配响应JSON失败: {e}, 响应内容: \n{response}") - return [] - - # 确保 match_responses 是一个列表 - if not isinstance(match_responses, list): - if isinstance(match_responses, dict): - match_responses = [match_responses] - else: - logger.error(f"match_responses 不是列表或字典类型: {type(match_responses)}, 内容: {match_responses}") - return [] - - # 清理和规范化 match_responses 中的元素 - normalized_responses = [] - for item in match_responses: - if isinstance(item, dict): - # 已经是字典,直接添加 - normalized_responses.append(item) - elif isinstance(item, str): - # 如果是字符串,尝试解析为 JSON - try: - parsed = json.loads(item) - if isinstance(parsed, dict): - normalized_responses.append(parsed) - elif isinstance(parsed, list): - # 如果是列表,递归处理 - for sub_item in parsed: - if isinstance(sub_item, dict): - normalized_responses.append(sub_item) - else: - logger.debug(f"跳过非字典类型的子元素: {type(sub_item)}, 内容: {sub_item}") - else: - logger.debug(f"跳过无法转换为字典的字符串元素: {item}") - except (json.JSONDecodeError, TypeError): - logger.debug(f"跳过无法解析为JSON的字符串元素: {item}") - elif isinstance(item, list): - # 如果是列表,展开并处理其中的字典 - for sub_item in item: - if isinstance(sub_item, dict): - normalized_responses.append(sub_item) - elif isinstance(sub_item, str): - # 尝试解析字符串 - try: - parsed = json.loads(sub_item) - if isinstance(parsed, dict): - normalized_responses.append(parsed) - else: - logger.debug(f"跳过非字典类型的解析结果: {type(parsed)}, 内容: {parsed}") - except (json.JSONDecodeError, TypeError): - logger.debug(f"跳过无法解析为JSON的字符串子元素: {sub_item}") - else: - logger.debug(f"跳过非字典类型的列表元素: {type(sub_item)}, 内容: {sub_item}") - else: - logger.debug(f"跳过无法处理的元素类型: {type(item)}, 内容: {item}") - - match_responses = normalized_responses - - matched_expressions = [] - used_pair_indices = set() # 用于跟踪已经使用的expression_pair索引 - - logger.debug(f"规范化后的 match_responses 类型: {type(match_responses)}, 长度: {len(match_responses)}") - logger.debug(f"规范化后的 match_responses 内容: {match_responses}") - - for match_response in match_responses: - try: - # 检查 match_response 的类型(此时应该都是字典) - if not isinstance(match_response, dict): - logger.error(f"match_response 不是字典类型: {type(match_response)}, 内容: {match_response}") - continue - - # 获取表达方式序号 - if "expression_pair" not in match_response: - logger.error(f"match_response 缺少 'expression_pair' 字段: {match_response}") - continue - - pair_index = int(match_response["expression_pair"]) - 1 # 转换为0-based索引 - - # 检查索引是否有效且未被使用过 - if 0 <= pair_index < len(expression_pairs) and pair_index not in used_pair_indices: - situation, style = expression_pairs[pair_index] - context = match_response.get("context", "") - matched_expressions.append((situation, style, context)) - used_pair_indices.add(pair_index) # 标记该索引已使用 - logger.debug(f"成功匹配表达方式 {pair_index + 1}: {situation} -> {style}") - elif pair_index in used_pair_indices: - logger.debug(f"跳过重复的表达方式 {pair_index + 1}") - except (ValueError, KeyError, IndexError, TypeError) as e: - logger.error(f"解析匹配条目失败: {e}, 条目: {match_response}") - continue - - return matched_expressions - - async def learn_expression(self, num: int = 10, timestamp_start: Optional[float] = None) -> Optional[List[Tuple[str, str, str, str]]]: + async def learn_expression(self, num: int = 10, timestamp_start: Optional[float] = None) -> Optional[List[Tuple[str, str, str]]]: """从指定聊天流学习表达方式 Args: @@ -414,10 +224,8 @@ class ExpressionLearner: if not random_msg or random_msg == []: return None - # 学习用 - random_msg_str: str = await build_anonymous_messages(random_msg) - # 溯源用 - random_msg_match_str: str = await build_bare_messages(random_msg) + # 学习用(开启行编号,便于溯源) + random_msg_str: str = await build_anonymous_messages(random_msg, show_ids=True) prompt: str = await global_prompt_manager.format_prompt( "learn_style_prompt", @@ -432,83 +240,107 @@ class ExpressionLearner: except Exception as e: logger.error(f"学习表达方式失败,模型生成出错: {e}") return None - expressions: List[Tuple[str, str]] = self.parse_expression_response(response) + + # 解析 LLM 返回的表达方式列表(包含来源行编号) + expressions: List[Tuple[str, str, str]] = self.parse_expression_response(response) expressions = self._filter_self_reference_styles(expressions) if not expressions: logger.info("过滤后没有可用的表达方式(style 与机器人名称重复)") return None # logger.debug(f"学习{type_str}的response: {response}") - # 对表达方式溯源 - matched_expressions: List[Tuple[str, str, str]] = await self.match_expression_context( - expressions, random_msg_match_str - ) - # 为每条消息构建精简文本列表,保留到原消息索引的映射 - bare_lines: List[Tuple[int, str]] = self._build_bare_lines(random_msg) - # 将 matched_expressions 结合上一句 up_content(若不存在上一句则跳过) - filtered_with_up: List[Tuple[str, str, str, str]] = [] # (situation, style, context, up_content) - for situation, style, context in matched_expressions: - # 在 bare_lines 中找到第一处相似度达到85%的行 - pos = None - for i, (_, c) in enumerate(bare_lines): - similarity = calculate_similarity(c, context) - if similarity >= 0.85: # 85%相似度阈值 - pos = i - break + # 直接根据 source_id 在 random_msg 中溯源,获取 context + filtered_expressions: List[Tuple[str, str, str]] = [] # (situation, style, context) - if pos is None or pos == 0: - # 没有匹配到目标句或没有上一句,跳过该表达 + for situation, style, source_id in expressions: + source_id_str = (source_id or "").strip() + if not source_id_str.isdigit(): + # 无效的来源行编号,跳过 continue - # 检查目标句是否为空 - target_content = bare_lines[pos][1] - if not target_content: - # 目标句为空,跳过该表达 + line_index = int(source_id_str) - 1 # build_anonymous_messages 的编号从 1 开始 + if line_index < 0 or line_index >= len(random_msg): + # 超出范围,跳过 continue - prev_original_idx = bare_lines[pos - 1][0] - up_content = filter_message_content(random_msg[prev_original_idx].processed_plain_text or "") - if not up_content: - # 上一句为空,跳过该表达 + # 当前行的原始内容 + current_msg = random_msg[line_index] + context = filter_message_content(current_msg.processed_plain_text or "") + if not context: continue - filtered_with_up.append((situation, style, context, up_content)) - if not filtered_with_up: + filtered_expressions.append((situation, style, context)) + + if not filtered_expressions: return None - return filtered_with_up + return filtered_expressions def parse_expression_response(self, response: str) -> List[Tuple[str, str, str]]: """ - 解析LLM返回的表达风格总结,每一行提取"当"和"使用"之间的内容,存储为(situation, style)元组 + 解析 LLM 返回的表达风格总结 JSON,提取 (situation, style, source_id) 元组列表。 + + 期望的 JSON 结构: + [ + {"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}, + ... + ] """ + if not response: + return [] + + raw = response.strip() + + # 尝试提取 ```json 代码块 + json_block_pattern = r"```json\s*(.*?)\s*```" + match = re.search(json_block_pattern, raw, re.DOTALL) + if match: + raw = match.group(1).strip() + else: + # 去掉可能存在的通用 ``` 包裹 + raw = re.sub(r"^```\s*", "", raw, flags=re.MULTILINE) + raw = re.sub(r"```\s*$", "", raw, flags=re.MULTILINE) + raw = raw.strip() + + parsed = None expressions: List[Tuple[str, str, str]] = [] - for line in response.splitlines(): - line = line.strip() - if not line: + + try: + # 优先尝试直接解析 + if raw.startswith("[") and raw.endswith("]"): + parsed = json.loads(raw) + else: + repaired = repair_json(raw) + if isinstance(repaired, str): + parsed = json.loads(repaired) + else: + parsed = repaired + except Exception: + logger.error(f"解析表达风格 JSON 失败,原始响应:{response}") + return [] + + if isinstance(parsed, dict): + parsed_list = [parsed] + elif isinstance(parsed, list): + parsed_list = parsed + else: + logger.error(f"表达风格解析结果类型异常: {type(parsed)}, 内容: {parsed}") + return [] + + for item in parsed_list: + if not isinstance(item, dict): continue - # 查找"当"和下一个引号 - idx_when = line.find('当"') - if idx_when == -1: + situation = str(item.get("situation", "")).strip() + style = str(item.get("style", "")).strip() + source_id = str(item.get("source_id", "")).strip() + if not situation or not style or not source_id: + # 三个字段必须同时存在 continue - idx_quote1 = idx_when + 1 - idx_quote2 = line.find('"', idx_quote1 + 1) - if idx_quote2 == -1: - continue - situation = line[idx_quote1 + 1 : idx_quote2] - # 查找"使用" - idx_use = line.find('使用"', idx_quote2) - if idx_use == -1: - continue - idx_quote3 = idx_use + 2 - idx_quote4 = line.find('"', idx_quote3 + 1) - if idx_quote4 == -1: - continue - style = line[idx_quote3 + 1 : idx_quote4] - expressions.append((situation, style)) + expressions.append((situation, style, source_id)) + return expressions - def _filter_self_reference_styles(self, expressions: List[Tuple[str, str]]) -> List[Tuple[str, str]]: + def _filter_self_reference_styles(self, expressions: List[Tuple[str, str, str]]) -> List[Tuple[str, str, str]]: """ 过滤掉style与机器人名称/昵称重复的表达 """ @@ -525,12 +357,12 @@ class ExpressionLearner: banned_casefold = {name.casefold() for name in banned_names if name} - filtered: List[Tuple[str, str]] = [] + filtered: List[Tuple[str, str, str]] = [] removed_count = 0 - for situation, style in expressions: + for situation, style, source_id in expressions: normalized_style = (style or "").strip() if normalized_style and normalized_style.casefold() not in banned_casefold: - filtered.append((situation, style)) + filtered.append((situation, style, source_id)) else: removed_count += 1 @@ -544,7 +376,6 @@ class ExpressionLearner: situation: str, style: str, context: str, - up_content: str, current_time: float, ) -> None: expr_obj = Expression.select().where((Expression.chat_id == self.chat_id) & (Expression.style == style)).first() @@ -554,7 +385,6 @@ class ExpressionLearner: expr_obj=expr_obj, situation=situation, context=context, - up_content=up_content, current_time=current_time, ) return @@ -563,7 +393,6 @@ class ExpressionLearner: situation=situation, style=style, context=context, - up_content=up_content, current_time=current_time, ) @@ -572,7 +401,6 @@ class ExpressionLearner: situation: str, style: str, context: str, - up_content: str, current_time: float, ) -> None: content_list = [situation] @@ -587,7 +415,6 @@ class ExpressionLearner: chat_id=self.chat_id, create_date=current_time, context=context, - up_content=up_content, ) async def _update_existing_expression( @@ -595,7 +422,6 @@ class ExpressionLearner: expr_obj: Expression, situation: str, context: str, - up_content: str, current_time: float, ) -> None: content_list = self._parse_content_list(expr_obj.content_list) @@ -605,7 +431,6 @@ class ExpressionLearner: expr_obj.count = (expr_obj.count or 0) + 1 expr_obj.last_active_time = current_time expr_obj.context = context - expr_obj.up_content = up_content new_situation = await self._compose_situation_text( content_list=content_list, @@ -651,27 +476,6 @@ class ExpressionLearner: logger.error(f"概括表达情境失败: {e}") return None - def _build_bare_lines(self, messages: List) -> List[Tuple[int, str]]: - """ - 为每条消息构建精简文本列表,保留到原消息索引的映射 - - Args: - messages: 消息列表 - - Returns: - List[Tuple[int, str]]: (original_index, bare_content) 元组列表 - """ - bare_lines: List[Tuple[int, str]] = [] - - for idx, msg in enumerate(messages): - content = msg.processed_plain_text or "" - content = filter_message_content(content) - # 即使content为空也要记录,防止错位 - bare_lines.append((idx, content)) - - return bare_lines - - init_prompt() diff --git a/src/hippo_memorizer/chat_history_summarizer.py b/src/hippo_memorizer/chat_history_summarizer.py index 70847de0..456a0f2e 100644 --- a/src/hippo_memorizer/chat_history_summarizer.py +++ b/src/hippo_memorizer/chat_history_summarizer.py @@ -429,15 +429,36 @@ class ChatHistorySummarizer: # 2. 构造编号后的消息字符串和参与者信息 numbered_lines, index_to_msg_str, index_to_msg_text, index_to_participants = self._build_numbered_messages_for_llm(messages) - # 3. 调用 LLM 识别话题,并得到 topic -> indices + # 3. 调用 LLM 识别话题,并得到 topic -> indices(失败时最多重试 3 次) existing_topics = list(self.topic_cache.keys()) - success, topic_to_indices = await self._analyze_topics_with_llm( - numbered_lines=numbered_lines, - existing_topics=existing_topics, - ) + max_retries = 3 + attempt = 0 + success = False + topic_to_indices: Dict[str, List[int]] = {} + + while attempt < max_retries: + attempt += 1 + success, topic_to_indices = await self._analyze_topics_with_llm( + numbered_lines=numbered_lines, + existing_topics=existing_topics, + ) + + if success and topic_to_indices: + if attempt > 1: + logger.info( + f"{self.log_prefix} 话题识别在第 {attempt} 次重试后成功 | 话题数: {len(topic_to_indices)}" + ) + break + + logger.warning( + f"{self.log_prefix} 话题识别失败或无有效话题,第 {attempt} 次尝试失败" + + ("" if attempt >= max_retries else ",准备重试") + ) if not success or not topic_to_indices: - logger.warning(f"{self.log_prefix} 话题识别失败或无有效话题,本次检查忽略") + logger.error( + f"{self.log_prefix} 话题识别连续 {max_retries} 次失败或始终无有效话题,本次检查放弃" + ) # 即使识别失败,也认为是一次“检查”,但不更新 no_update_checks(保持原状) return diff --git a/src/webui/expression_routes.py b/src/webui/expression_routes.py index bef93b30..1fa0ce7d 100644 --- a/src/webui/expression_routes.py +++ b/src/webui/expression_routes.py @@ -1,7 +1,7 @@ """表达方式管理 API 路由""" from fastapi import APIRouter, HTTPException, Header, Query, Cookie -from pydantic import BaseModel +from pydantic import BaseModel, NonNegativeFloat from typing import Optional, List, Dict from src.common.logger import get_logger from src.common.database.database_model import Expression, ChatStreams @@ -21,7 +21,6 @@ class ExpressionResponse(BaseModel): situation: str style: str context: Optional[str] - up_content: Optional[str] last_active_time: float chat_id: str create_date: Optional[float] @@ -49,8 +48,7 @@ class ExpressionCreateRequest(BaseModel): situation: str style: str - context: Optional[str] = None - up_content: Optional[str] = None + context: Optional[str] = NonNegativeFloat chat_id: str @@ -60,7 +58,6 @@ class ExpressionUpdateRequest(BaseModel): situation: Optional[str] = None style: Optional[str] = None context: Optional[str] = None - up_content: Optional[str] = None chat_id: Optional[str] = None @@ -102,7 +99,6 @@ def expression_to_response(expression: Expression) -> ExpressionResponse: situation=expression.situation, style=expression.style, context=expression.context, - up_content=expression.up_content, last_active_time=expression.last_active_time, chat_id=expression.chat_id, create_date=expression.create_date, @@ -310,7 +306,6 @@ async def create_expression(request: ExpressionCreateRequest, maibot_session: Op situation=request.situation, style=request.style, context=request.context, - up_content=request.up_content, chat_id=request.chat_id, last_active_time=current_time, create_date=current_time, From f85c5e9b5f26971434a253cb4d491d1f542e38e9 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 3 Dec 2025 01:00:43 +0800 Subject: [PATCH 04/23] =?UTF-8?q?feat=20=EF=BC=9A=E5=81=9A=E6=A2=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/config.py | 2 + src/config/official_configs.py | 18 + src/dream/dream_agent.py | 775 ++++++++++++++++++++++++++++++ src/main.py | 3 + template/bot_config_template.toml | 6 +- 5 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 src/dream/dream_agent.py diff --git a/src/config/config.py b/src/config/config.py index 28384f99..061fa07d 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -35,6 +35,7 @@ from src.config.official_configs import ( MemoryConfig, DebugConfig, JargonConfig, + DreamConfig, ) from .api_ada_configs import ( @@ -357,6 +358,7 @@ class Config(ConfigBase): mood: MoodConfig voice: VoiceConfig jargon: JargonConfig + dream: DreamConfig @dataclass diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 5e88f8cd..e7fce623 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -714,3 +714,21 @@ class JargonConfig(ConfigBase): all_global: bool = False """是否将所有新增的jargon项目默认为全局(is_global=True),chat_id记录第一次存储时的id""" + + +@dataclass +class DreamConfig(ConfigBase): + """Dream配置类""" + + interval_minutes: int = 30 + """做梦时间间隔(分钟),默认30分钟""" + + max_iterations: int = 20 + """做梦最大轮次,默认20轮""" + + def __post_init__(self): + """验证配置值""" + if self.interval_minutes < 1: + raise ValueError(f"interval_minutes 必须至少为1,当前值: {self.interval_minutes}") + if self.max_iterations < 1: + raise ValueError(f"max_iterations 必须至少为1,当前值: {self.max_iterations}") \ No newline at end of file diff --git a/src/dream/dream_agent.py b/src/dream/dream_agent.py new file mode 100644 index 00000000..2bf6a11a --- /dev/null +++ b/src/dream/dream_agent.py @@ -0,0 +1,775 @@ +import asyncio +import random +import time +from typing import Any, Dict, List, Optional, Tuple + +from src.common.logger import get_logger +from src.config.config import global_config, model_config +from src.common.database.database_model import ChatHistory +from src.chat.utils.prompt_builder import Prompt, global_prompt_manager +from src.llm_models.payload_content.message import MessageBuilder, RoleType, Message +from src.plugin_system.apis import llm_api +from src.llm_models.utils_model import LLMRequest + + +logger = get_logger("dream_agent") + +# 初始化 utils 模型用于生成梦境总结 +_dream_summary_model: Optional[LLMRequest] = None + + +def _get_dream_summary_model() -> LLMRequest: + """获取用于生成梦境总结的 utils 模型实例""" + global _dream_summary_model + if _dream_summary_model is None: + _dream_summary_model = LLMRequest( + model_set=model_config.model_task_config.utils, + request_type="dream.summary", + ) + return _dream_summary_model + + +def init_dream_prompts() -> None: + """初始化 dream agent 的提示词""" + Prompt( + """ +你的名字是{bot_name},你现在处于"梦境维护模式(dream agent)"。 +你可以自由地在 ChatHistory 库中探索、整理、合并和删改记录,以帮助自己在未来更好地回忆和理解对话历史。 + +本轮要维护的聊天ID:{chat_id} +本轮随机选中的起始记忆 ID:{start_memory_id} +请优先以这条起始记忆为切入点,先理解它的内容与上下文,再决定如何在其附近进行合并、重写或删除等整理操作;如果起始记忆为空,则由你自行选择合适的切入点。 + +你可以使用的工具包括: +- list_chat_histories:浏览该 chat_id 下的历史概括列表 +- get_chat_history_detail:查看某条概括的详细内容 +- merge_chat_histories:把多条内容高度相似或应当归为一类的记录合并到一条 +- update_chat_history:在不改变事实的前提下重写或精炼主题、概括、关键词、关键信息 +- delete_chat_history:删除明显噪声、错误或无意义的记录(要格外谨慎) +- finish_maintenance:当你认为当前 chat_id 下的维护工作已经完成,没有更多需要整理的内容时,调用此工具来结束本次运行 + +**工作目标**: +- 发现冗余、重复或高度相似的记录,并进行合并或删除; +- 发现主题/概括过于含糊、啰嗦或缺少关键信息的记录,进行重写和精简; +- 尽量保持信息的真实与可用性,不要凭空捏造事实。 + +**轮次信息**: +- 本次维护最多执行 {max_iterations} 轮 +- 每轮开始时,系统会告知你当前是第几轮,还剩多少轮 +- 如果提前完成维护工作,可以调用 finish_maintenance 工具主动结束 + +**每一轮的执行方式(必须遵守):** +- 第一步:先用一小段中文自然语言,写出你的「思考」和本轮计划(例如要查什么、准备怎么合并/修改); +- 第二步:在这段思考之后,再通过工具调用来执行你的计划(可以调用 0~N 个工具); +- 第三步:收到工具结果后,在下一轮继续先写出新的思考,再视情况继续调用工具。 + +请不要在没有先写出思考的情况下直接调用工具。 +只输出你的思考内容或工具调用结果,由系统负责真正执行工具调用。 +""", + name="dream_react_head_prompt", + ) + + Prompt( + """ +你刚刚完成了一次对聊天记录的记忆整理工作。以下是整理过程的摘要: +整理过程: +{conversation_text} + +请将这次整理涉及的相关信息改写为一个富有诗意和想象力的"梦境",请你仅使用具体的记忆的内容,而不是整理过程编写。 +要求: +1. 使用第一人称视角 +2. 叙述直白,不要复杂修辞,口语化 +3. 保持诗意和想象力,自由编写 +4. 长度控制在200-800字 +5. 用中文输出 +请直接输出梦境内容,不要添加其他说明: +""", + name="dream_summary_prompt", + ) + + +class DreamTool: + """dream 模块内部使用的简易工具封装""" + + def __init__(self, name: str, description: str, parameters: List[Tuple], execute_func): + self.name = name + self.description = description + self.parameters = parameters + self.execute_func = execute_func + + def get_tool_definition(self) -> Dict[str, Any]: + return { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + } + + async def execute(self, **kwargs) -> str: + return await self.execute_func(**kwargs) + + +class DreamToolRegistry: + def __init__(self) -> None: + self.tools: Dict[str, DreamTool] = {} + + def register_tool(self, tool: DreamTool) -> None: + if tool.name in self.tools: + logger.debug(f"dream 工具 {tool.name} 已存在,跳过重复注册") + return + self.tools[tool.name] = tool + logger.info(f"注册 dream 工具: {tool.name}") + + def get_tool(self, name: str) -> Optional[DreamTool]: + return self.tools.get(name) + + def get_tool_definitions(self) -> List[Dict[str, Any]]: + return [tool.get_tool_definition() for tool in self.tools.values()] + + +_dream_tool_registry = DreamToolRegistry() + + +def get_dream_tool_registry() -> DreamToolRegistry: + return _dream_tool_registry + + +def init_dream_tools() -> None: + """注册 dream agent 可用的 ChatHistory 相关工具""" + from src.plugin_system.apis import database_api + + async def list_chat_histories(chat_id: str, limit: int = 50) -> str: + """列出某个 chat_id 下的 chat_history 概览""" + try: + logger.info(f"[dream][tool] 调用 list_chat_histories(chat_id={chat_id}, limit={limit})") + query = ( + ChatHistory.select() + .where(ChatHistory.chat_id == chat_id) + .order_by(ChatHistory.start_time.asc()) + .limit(max(10, min(limit, 200))) + ) + records = list(query) + if not records: + msg = f"chat_id={chat_id} 在 ChatHistory 中暂时没有记录。" + logger.info(f"[dream][tool] list_chat_histories 无记录: {msg}") + return msg + + lines: List[str] = [] + for r in records: + lines.append( + f"ID={r.id} | 时间范围={r.start_time:.0f}-{r.end_time:.0f} | " + f"主题={r.theme or '无'} | 被检索次数={r.count} | 被遗忘次数={r.forget_times}" + ) + result = "\n".join(lines) + logger.info( + f"[dream][tool] list_chat_histories 返回 {len(records)} 条记录,预览: {result[:200].replace(chr(10), ' ')}" + ) + return result + except Exception as e: + logger.error(f"list_chat_histories 失败: {e}") + return f"list_chat_histories 执行失败: {e}" + + async def get_chat_history_detail(memory_id: int) -> str: + """获取单条 chat_history 的完整内容""" + try: + logger.info(f"[dream][tool] 调用 get_chat_history_detail(memory_id={memory_id})") + record = ChatHistory.get_or_none(ChatHistory.id == memory_id) + if not record: + msg = f"未找到 ID={memory_id} 的 ChatHistory 记录。" + logger.info(f"[dream][tool] get_chat_history_detail 未找到记录: {msg}") + return msg + + result = ( + f"ID={record.id}\n" + f"chat_id={record.chat_id}\n" + f"时间范围={record.start_time}-{record.end_time}\n" + f"主题={record.theme}\n" + f"关键词={record.keywords}\n" + f"参与者={record.participants}\n" + f"概括={record.summary}\n" + f"关键信息={record.key_point}\n" + f"原文:\n{record.original_text}" + ) + logger.info( + f"[dream][tool] get_chat_history_detail 成功,原文长度={len(record.original_text or '')},预览: {result[:200].replace(chr(10), ' ')}" + ) + return result + except Exception as e: + logger.error(f"get_chat_history_detail 失败: {e}") + return f"get_chat_history_detail 执行失败: {e}" + + async def delete_chat_history(memory_id: int) -> str: + """删除一条 chat_history 记录""" + try: + logger.info(f"[dream][tool] 调用 delete_chat_history(memory_id={memory_id})") + record = ChatHistory.get_or_none(ChatHistory.id == memory_id) + if not record: + msg = f"未找到 ID={memory_id} 的 ChatHistory 记录,无法删除。" + logger.info(f"[dream][tool] delete_chat_history 未找到记录: {msg}") + return msg + rows = ChatHistory.delete().where(ChatHistory.id == memory_id).execute() + msg = f"已删除 ID={memory_id} 的 ChatHistory 记录,受影响行数={rows}。" + logger.info(f"[dream][tool] delete_chat_history 完成: {msg}") + return msg + except Exception as e: + logger.error(f"delete_chat_history 失败: {e}") + return f"delete_chat_history 执行失败: {e}" + + async def update_chat_history( + memory_id: int, + theme: Optional[str] = None, + summary: Optional[str] = None, + keywords: Optional[str] = None, + key_point: Optional[str] = None, + ) -> str: + """按字段更新 chat_history(字符串字段要求 JSON 的字段须传入已序列化的字符串)""" + try: + logger.info( + f"[dream][tool] 调用 update_chat_history(memory_id={memory_id}, " + f"theme={bool(theme)}, summary={bool(summary)}, keywords={bool(keywords)}, key_point={bool(key_point)})" + ) + record = ChatHistory.get_or_none(ChatHistory.id == memory_id) + if not record: + msg = f"未找到 ID={memory_id} 的 ChatHistory 记录,无法更新。" + logger.info(f"[dream][tool] update_chat_history 未找到记录: {msg}") + return msg + + data: Dict[str, Any] = {} + if theme is not None: + data["theme"] = theme + if summary is not None: + data["summary"] = summary + if keywords is not None: + data["keywords"] = keywords + if key_point is not None: + data["key_point"] = key_point + + if not data: + return "未提供任何需要更新的字段。" + + await database_api.db_save(ChatHistory, data=data, key_field="id", key_value=memory_id) + msg = f"已更新 ChatHistory 记录 ID={memory_id},更新字段={list(data.keys())}。" + logger.info(f"[dream][tool] update_chat_history 完成: {msg}") + return msg + except Exception as e: + logger.error(f"update_chat_history 失败: {e}") + return f"update_chat_history 执行失败: {e}" + + async def merge_chat_histories(target_id: int, from_ids: List[int] | str) -> str: + """将多条 chat_history 合并到 target_id(合并文本与统计字段,删除 from_ids) + + from_ids 可以是整数列表,也可以是逗号/空格分隔的字符串,由本函数负责解析。 + """ + try: + logger.info(f"[dream][tool] 调用 merge_chat_histories(target_id={target_id}, from_ids={from_ids})") + # 兼容字符串形式的 from_ids,方便 LLM 传参 + if isinstance(from_ids, str): + raw_parts = [p.strip() for p in from_ids.replace(",", ",").replace(" ", ",").split(",") if p.strip()] + parsed_ids: List[int] = [] + for part in raw_parts: + try: + parsed_ids.append(int(part)) + except ValueError: + continue + from_ids = parsed_ids + + if target_id in from_ids: + from_ids = [i for i in from_ids if i != target_id] + if not from_ids: + msg = "from_ids 为空或只包含 target_id,本次不执行合并。" + logger.info(f"[dream][tool] merge_chat_histories 参数不足: {msg}") + return msg + + target = ChatHistory.get_or_none(ChatHistory.id == target_id) + if not target: + msg = f"未找到合并目标 ID={target_id} 的记录。" + logger.info(f"[dream][tool] merge_chat_histories 失败: {msg}") + return msg + + others = list(ChatHistory.select().where(ChatHistory.id.in_(from_ids))) + if not others: + msg = f"未找到需要合并的来源记录: {from_ids}" + logger.info(f"[dream][tool] merge_chat_histories 无来源记录: {msg}") + return msg + + # 合并原文与统计字段(简单拼接) + original_parts = [target.original_text or ""] + for r in others: + original_parts.append(r.original_text or "") + target.original_text = "\n\n--- 合并分隔线 ---\n\n".join(p for p in original_parts if p) + + target.count = (target.count or 0) + sum(r.count or 0 for r in others) + target.forget_times = (target.forget_times or 0) + sum(r.forget_times or 0 for r in others) + + target.save() + deleted = ChatHistory.delete().where(ChatHistory.id.in_(from_ids)).execute() + msg = ( + f"已将 ChatHistory {from_ids} 合并到 {target_id}," + f"合并后原文长度={len(target.original_text or '')},删除记录数={deleted}。" + ) + logger.info(f"[dream][tool] merge_chat_histories 完成: {msg}") + return msg + except Exception as e: + logger.error(f"merge_chat_histories 失败: {e}") + return f"merge_chat_histories 执行失败: {e}" + + async def finish_maintenance(reason: Optional[str] = None) -> str: + """结束本次 dream 维护任务。当你认为当前 chat_id 下的维护工作已经完成,没有更多需要整理的内容时,调用此工具来结束本次运行。""" + reason_text = f",原因:{reason}" if reason else "" + msg = f"DREAM_MAINTENANCE_COMPLETE{reason_text}" + logger.info(f"[dream][tool] 调用 finish_maintenance,结束本次维护{reason_text}") + return msg + + # 参数元组格式与 BaseTool 一致: (name, ToolParamType, desc, required, enum_values) + from src.llm_models.payload_content.tool_option import ToolParamType + + _dream_tool_registry.register_tool( + DreamTool( + "list_chat_histories", + "列出某个 chat_id 下的 ChatHistory 概览,便于全局理解该聊天的历史记忆。", + [ + ("chat_id", ToolParamType.STRING, "需要浏览的 chat_id。", True, None), + ("limit", ToolParamType.INTEGER, "最多返回多少条记录,默认 50,最大 200。", False, None), + ], + list_chat_histories, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "get_chat_history_detail", + "根据 memory_id 获取单条 ChatHistory 的详细内容,包含原文与所有字段。", + [ + ("memory_id", ToolParamType.INTEGER, "ChatHistory 主键 ID。", True, None), + ], + get_chat_history_detail, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "delete_chat_history", + "根据 memory_id 删除一条 ChatHistory 记录(请谨慎使用)。", + [ + ("memory_id", ToolParamType.INTEGER, "需要删除的 ChatHistory 主键 ID。", True, None), + ], + delete_chat_history, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "update_chat_history", + "按字段更新 ChatHistory 记录,可用于清理、重写或补充信息。", + [ + ("memory_id", ToolParamType.INTEGER, "需要更新的 ChatHistory 主键 ID。", True, None), + ("theme", ToolParamType.STRING, "新的主题标题,如果不需要修改可不填。", False, None), + ("summary", ToolParamType.STRING, "新的概括内容,如果不需要修改可不填。", False, None), + ("keywords", ToolParamType.STRING, "新的关键词 JSON 字符串,如 ['关键词1','关键词2']。", False, None), + ("key_point", ToolParamType.STRING, "新的关键信息 JSON 字符串,如 ['要点1','要点2']。", False, None), + ], + update_chat_history, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "merge_chat_histories", + "将多条 ChatHistory 记录合并到一条中,可用于去重或把同一主题的多段记录整合在一起。", + [ + ("target_id", ToolParamType.INTEGER, "合并的目标记录 ID。", True, None), + ( + "from_ids", + ToolParamType.STRING, + "需要被合并并删除的来源记录 ID 列表,可以是用逗号/空格分隔的字符串,如 '12, 13, 15'。", + True, + None, + ), + ], + merge_chat_histories, + ) + ) + + _dream_tool_registry.register_tool( + DreamTool( + "finish_maintenance", + "结束本次 dream 维护任务。当你认为当前 chat_id 下的维护工作已经完成,没有更多需要整理、合并或修改的内容时,调用此工具来主动结束本次运行。", + [ + ("reason", ToolParamType.STRING, "结束维护的原因说明(可选),例如 '已完成所有记录的整理' 或 '当前记录质量良好,无需进一步维护'。", False, None), + ], + finish_maintenance, + ) + ) + + +async def run_dream_agent_once( + chat_id: str, + max_iterations: Optional[int] = None, + start_memory_id: Optional[int] = None, +) -> None: + """ + 运行一次 dream agent,对指定 chat_id 的 ChatHistory 进行最多 max_iterations 轮的整理。 + 如果 max_iterations 为 None,则使用配置文件中的默认值。 + """ + if max_iterations is None: + max_iterations = global_config.dream.max_iterations + + start_ts = time.time() + logger.info(f"[dream] 开始对 chat_id={chat_id} 进行 dream 维护,最多迭代 {max_iterations} 轮") + + # 初始化工具 + init_dream_tools() + + tool_registry = get_dream_tool_registry() + tool_defs = tool_registry.get_tool_definitions() + + bot_name = global_config.bot.nickname + time_now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + head_prompt = await global_prompt_manager.format_prompt( + "dream_react_head_prompt", + bot_name=bot_name, + time_now=time_now, + chat_id=chat_id, + start_memory_id=start_memory_id if start_memory_id is not None else "无(本轮由你自由选择切入点)", + max_iterations=max_iterations, + ) + + conversation_messages: List[Message] = [] + + # 注意:message_factory 必须是同步函数,返回消息列表(不能是 async/coroutine) + def message_factory( + _client, + *, + _head_prompt: str = head_prompt, + _conversation_messages: List[Message] = conversation_messages, + ) -> List[Message]: + messages: List[Message] = [] + system_builder = MessageBuilder() + system_builder.set_role(RoleType.System) + system_builder.add_text_content(_head_prompt) + messages.append(system_builder.build()) + messages.extend(_conversation_messages) + return messages + + for iteration in range(1, max_iterations + 1): + # 在每轮开始时,添加轮次信息到对话中 + remaining_rounds = max_iterations - iteration + 1 + round_info_builder = MessageBuilder() + round_info_builder.set_role(RoleType.User) + round_info_builder.add_text_content( + f"【轮次信息】当前是第 {iteration}/{max_iterations} 轮,还剩 {remaining_rounds} 轮。" + ) + conversation_messages.append(round_info_builder.build()) + + # 调用 LLM 让其决定是否要使用工具 + success, response, reasoning_content, model_name, tool_calls = ( + await llm_api.generate_with_model_with_tools_by_message_factory( + message_factory, + model_config=model_config.model_task_config.tool_use, + tool_options=tool_defs, + request_type="dream.react", + ) + ) + + if not success: + logger.error(f"[dream] 第 {iteration} 轮 LLM 调用失败: {response}") + break + + # 先输出「思考」内容,再输出工具调用信息 + thought_log = reasoning_content or (response[:300] if response else "") + if thought_log: + logger.info(f"[dream] 第 {iteration} 轮思考内容: {thought_log}") + + logger.info( + f"[dream] 第 {iteration} 轮响应,模型={model_name},工具调用数={len(tool_calls) if tool_calls else 0}" + ) + + assistant_msg: Optional[Message] = None + if tool_calls: + builder = MessageBuilder() + builder.set_role(RoleType.Assistant) + if response and response.strip(): + builder.add_text_content(response) + builder.set_tool_calls(tool_calls) + assistant_msg = builder.build() + elif response and response.strip(): + builder = MessageBuilder() + builder.set_role(RoleType.Assistant) + builder.add_text_content(response) + assistant_msg = builder.build() + + if assistant_msg: + conversation_messages.append(assistant_msg) + + # 如果本轮没有工具调用,仅作为思考记录,继续下一轮 + if not tool_calls: + logger.debug(f"[dream] 第 {iteration} 轮未调用任何工具,仅记录思考。") + continue + + # 执行所有工具调用 + tasks = [] + finish_maintenance_called = False + for tc in tool_calls: + tool = tool_registry.get_tool(tc.func_name) + if not tool: + logger.warning(f"[dream] 未知工具:{tc.func_name}") + continue + + # 检测是否调用了 finish_maintenance 工具 + if tc.func_name == "finish_maintenance": + finish_maintenance_called = True + + params = tc.args or {} + + async def _run_single(t: DreamTool, p: Dict[str, Any], call_id: str, it: int): + try: + result = await t.execute(**p) + logger.info(f"[dream] 第 {it} 轮 工具 {t.name} 执行完成。") + return call_id, result + except Exception as e: + logger.error(f"[dream] 工具 {t.name} 执行失败: {e}") + return call_id, f"工具 {t.name} 执行失败: {e}" + + tasks.append(_run_single(tool, params, tc.call_id, iteration)) + + if not tasks: + continue + + tool_results = await asyncio.gather(*tasks, return_exceptions=False) + + # 将工具结果作为 Tool 消息追加 + for call_id, obs in tool_results: + tool_builder = MessageBuilder() + tool_builder.set_role(RoleType.Tool) + tool_builder.add_text_content(str(obs)) + tool_builder.add_tool_call(call_id) + conversation_messages.append(tool_builder.build()) + + # 如果调用了 finish_maintenance 工具,提前结束本次运行 + if finish_maintenance_called: + logger.info(f"[dream] 第 {iteration} 轮检测到 finish_maintenance 工具调用,提前结束本次维护。") + break + + cost = time.time() - start_ts + logger.info(f"[dream] 对 chat_id={chat_id} 的 dream 维护结束,共迭代 {iteration} 轮,耗时 {cost:.1f} 秒") + + # 生成梦境总结 + await _generate_dream_summary(chat_id, conversation_messages, iteration, cost) + + +async def _generate_dream_summary( + chat_id: str, + conversation_messages: List[Message], + total_iterations: int, + time_cost: float, +) -> None: + """生成梦境总结并输出到日志""" + try: + import json + + # 第一步:建立工具调用结果映射 (call_id -> result) + tool_results_map: Dict[str, str] = {} + for msg in conversation_messages: + if msg.role == RoleType.Tool and msg.tool_call_id: + content = "" + if msg.content: + if isinstance(msg.content, list) and msg.content: + content = msg.content[0].text if hasattr(msg.content[0], "text") else str(msg.content[0]) + else: + content = str(msg.content) + tool_results_map[msg.tool_call_id] = content + + # 第二步:详细记录所有工具调用操作和结果到日志 + tool_call_count = 0 + logger.info(f"[dream][工具调用详情] 开始记录 chat_id={chat_id} 的所有工具调用操作:") + + for msg in conversation_messages: + if msg.role == RoleType.Assistant and msg.tool_calls: + tool_call_count += 1 + # 提取思考内容 + thought_content = "" + if msg.content: + if isinstance(msg.content, list) and msg.content: + thought_content = msg.content[0].text if hasattr(msg.content[0], "text") else str(msg.content[0]) + else: + thought_content = str(msg.content) + + logger.info(f"[dream][工具调用详情] === 第 {tool_call_count} 组工具调用 ===") + if thought_content: + logger.info(f"[dream][工具调用详情] 思考内容:{thought_content[:500]}{'...' if len(thought_content) > 500 else ''}") + + # 记录每个工具调用的详细信息 + for idx, tool_call in enumerate(msg.tool_calls, 1): + tool_name = tool_call.func_name + tool_args = tool_call.args or {} + tool_call_id = tool_call.call_id + tool_result = tool_results_map.get(tool_call_id, "未找到执行结果") + + # 格式化参数 + try: + args_str = json.dumps(tool_args, ensure_ascii=False, indent=2) if tool_args else "无参数" + except Exception: + args_str = str(tool_args) + + logger.info(f"[dream][工具调用详情] --- 工具 {idx}: {tool_name} ---") + logger.info(f"[dream][工具调用详情] 调用参数:\n{args_str}") + logger.info(f"[dream][工具调用详情] 执行结果:\n{tool_result}") + logger.info(f"[dream][工具调用详情] {'-' * 60}") + + logger.info(f"[dream][工具调用详情] 共记录了 {tool_call_count} 组工具调用操作") + + # 第三步:构建对话历史摘要(用于生成梦境) + conversation_summary = [] + for msg in conversation_messages: + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + content = "" + if msg.content: + content = msg.content[0].text if isinstance(msg.content, list) and msg.content else str(msg.content) + + if role == "user" and "轮次信息" in content: + # 跳过轮次信息消息 + continue + + if role == "assistant": + # 只保留思考内容,简化工具调用信息 + if content: + # 截取前500字符,避免过长 + content_preview = content[:500] + ("..." if len(content) > 500 else "") + conversation_summary.append(f"[{role}] {content_preview}") + elif role == "tool": + # 工具结果,只保留关键信息 + if content: + # 截取前300字符 + content_preview = content[:300] + ("..." if len(content) > 300 else "") + conversation_summary.append(f"[工具执行] {content_preview}") + + conversation_text = "\n".join(conversation_summary[-20:]) # 只保留最后20条消息 + + # 使用 Prompt 管理器格式化梦境生成 prompt + dream_prompt = await global_prompt_manager.format_prompt( + "dream_summary_prompt", + chat_id=chat_id, + total_iterations=total_iterations, + time_cost=time_cost, + conversation_text=conversation_text, + ) + + # 调用 utils 模型生成梦境 + summary_model = _get_dream_summary_model() + dream_content, (reasoning, model_name, _) = await summary_model.generate_response_async( + dream_prompt, + max_tokens=512, + temperature=0.8, + ) + + if dream_content: + logger.info(f"[dream][梦境总结] 对 chat_id={chat_id} 的整理过程梦境:\n{dream_content}") + else: + logger.warning("[dream][梦境总结] 未能生成梦境总结") + + except Exception as e: + logger.error(f"[dream][梦境总结] 生成梦境总结失败: {e}", exc_info=True) + + +def _pick_random_chat_id() -> Optional[str]: + """从 ChatHistory 中随机选择一个 chat_id,用于 dream agent 本次维护""" + try: + rows = ( + ChatHistory.select(ChatHistory.chat_id) + .distinct() + .order_by(ChatHistory.chat_id) + .limit(200) + ) + ids = [r.chat_id for r in rows] + if not ids: + logger.warning("[dream] ChatHistory 中暂无可用 chat_id,本轮 dream 任务跳过。") + return None + return random.choice(ids) + except Exception as e: + logger.error(f"[dream] 随机选择 chat_id 失败: {e}") + return None + + +def _pick_random_memory_for_chat(chat_id: str) -> Optional[int]: + """ + 在给定 chat_id 下随机选择一条 ChatHistory 记录,作为本轮整理的起始记忆。 + """ + try: + rows = ( + ChatHistory.select(ChatHistory.id) + .where(ChatHistory.chat_id == chat_id) + .order_by(ChatHistory.start_time.asc()) + .limit(200) + ) + ids = [r.id for r in rows] + if not ids: + logger.warning(f"[dream] chat_id={chat_id} 下暂无 ChatHistory 记录,无法选择起始记忆。") + return None + return random.choice(ids) + except Exception as e: + logger.error(f"[dream] 在 chat_id={chat_id} 下随机选择起始记忆失败: {e}") + return None + + +async def run_dream_cycle_once() -> None: + """ + 单次 dream 周期: + - 随机选择一个 chat_id + - 在该 chat_id 下随机选择一条 ChatHistory 作为起始记忆 + - 以这条起始记忆为切入点,对该 chat_id 运行一次 dream agent(最多 15 轮) + """ + chat_id = _pick_random_chat_id() + if not chat_id: + return + + start_memory_id = _pick_random_memory_for_chat(chat_id) + await run_dream_agent_once( + chat_id=chat_id, + max_iterations=None, # 使用配置文件中的默认值 + start_memory_id=start_memory_id, + ) + + +async def start_dream_scheduler( + first_delay_seconds: int = 60, + interval_seconds: Optional[int] = None, + stop_event: Optional[asyncio.Event] = None, +) -> None: + """ + dream 调度器: + - 程序启动后先等待 first_delay_seconds(默认 60s) + - 然后每隔 interval_seconds(如果为 None,则使用配置文件中的值,默认 30 分钟)运行一次 dream agent 周期 + - 如果提供 stop_event,则在 stop_event 被 set() 后优雅退出循环 + """ + if interval_seconds is None: + interval_seconds = global_config.dream.interval_minutes * 60 + + logger.info( + f"[dream] dream 调度器启动:首次延迟 {first_delay_seconds}s,之后每隔 {interval_seconds}s ({interval_seconds // 60} 分钟) 运行一次 dream agent" + ) + + try: + await asyncio.sleep(first_delay_seconds) + while True: + if stop_event is not None and stop_event.is_set(): + logger.info("[dream] 收到停止事件,结束 dream 调度器循环。") + break + + start_ts = time.time() + try: + await run_dream_cycle_once() + except Exception as e: + logger.error(f"[dream] 单次 dream 周期执行异常: {e}") + + elapsed = time.time() - start_ts + # 保证两次执行之间至少间隔 interval_seconds + to_sleep = max(0.0, interval_seconds - elapsed) + await asyncio.sleep(to_sleep) + except asyncio.CancelledError: + logger.info("[dream] dream 调度器任务被取消,准备退出。") + raise + + +# 初始化提示词 +init_dream_prompts() + diff --git a/src/main.py b/src/main.py index 3bcc9695..03580008 100644 --- a/src/main.py +++ b/src/main.py @@ -16,6 +16,7 @@ from src.common.server import get_global_server, Server from src.mood.mood_manager import mood_manager from src.chat.knowledge import lpmm_start_up from rich.traceback import install + # from src.api.main import start_api_server # 导入新的插件管理器 @@ -23,6 +24,7 @@ from src.plugin_system.core.plugin_manager import plugin_manager # 导入消息API和traceback模块 from src.common.message import get_global_api +from src.dream.dream_agent import start_dream_scheduler # 插件系统现在使用统一的插件加载器 @@ -159,6 +161,7 @@ class MainSystem: try: tasks = [ get_emoji_manager().start_periodic_check_register(), + start_dream_scheduler(), self.app.run(), self.server.run(), ] diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 9daf8739..465ab682 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "6.23.5" +version = "7.0.0" #----以下是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- # 如果你想要修改配置文件,请递增version的值 @@ -111,6 +111,10 @@ include_planner_reasoning = false # 是否将planner推理加入replyer,默认 max_agent_iterations = 3 # 记忆思考深度(最低为1(不深入思考)) enable_jargon_detection = true # 记忆检索过程中是否启用黑话识别 +[dream] +interval_minutes = 30 # 做梦时间间隔(分钟),默认30分钟 +max_iterations = 20 # 做梦最大轮次,默认20轮 + [jargon] all_global = true # 是否开启全局黑话模式,注意,此功能关闭后,已经记录的全局黑话不会改变,需要手动删除 From 8cb902e3e3715dbff9ef23e05d90f2eec3144d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Wed, 3 Dec 2025 09:16:54 +0800 Subject: [PATCH 05/23] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E8=81=94=E7=B3=BB=E7=9A=84=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CODE_OF_CONDUCT.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 98d846ac..f95b6c9b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -71,7 +71,6 @@ 1. **GitHub Issues**: 对于公开的违规行为,可以在相关issue中直接指出 2. **私下联系**: 可以通过GitHub私信联系项目维护者 -3. **邮件联系**: [如果有项目邮箱地址,请在此提供] 所有报告都将得到及时和公正的处理。我们承诺保护报告者的隐私和安全。 From 056a909c7724abdc9d662359d8d1abc6a82d3ee5 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 3 Dec 2025 09:52:23 +0800 Subject: [PATCH 06/23] =?UTF-8?q?feat=EF=BC=9A=E5=B0=86=E6=98=AF=E5=90=A6?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E6=94=B9=E4=B8=BA=E6=8B=A6=E6=88=AA=E7=99=BB?= =?UTF-8?q?=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/brain_chat/brain_chat.py | 4 ++-- src/chat/brain_chat/brain_planner.py | 2 +- src/chat/heart_flow/heartFC_chat.py | 4 ++-- src/chat/message_receive/bot.py | 12 ++++++------ src/chat/message_receive/message.py | 2 +- src/chat/message_receive/storage.py | 6 +++--- src/chat/planner_actions/action_modifier.py | 2 +- src/chat/planner_actions/planner.py | 2 +- src/chat/replyer/group_generator.py | 6 +++--- src/chat/replyer/private_generator.py | 6 +++--- src/chat/utils/chat_message_builder.py | 12 ++++++------ src/common/data_models/database_data_model.py | 6 +++--- src/common/data_models/message_data_model.py | 4 ++-- src/common/database/database_model.py | 2 +- src/common/message_repository.py | 7 ++++--- src/plugin_system/apis/message_api.py | 12 ++++++------ src/plugin_system/base/base_command.py | 4 ++-- 17 files changed, 47 insertions(+), 46 deletions(-) diff --git a/src/chat/brain_chat/brain_chat.py b/src/chat/brain_chat/brain_chat.py index 8702248f..61f7ca9f 100644 --- a/src/chat/brain_chat/brain_chat.py +++ b/src/chat/brain_chat/brain_chat.py @@ -165,7 +165,7 @@ class BrainChatting: limit_mode="latest", filter_mai=True, filter_command=False, - filter_no_read_command=True, + filter_intercept_message_level=1, ) if len(recent_messages_list) >= 1: @@ -279,7 +279,7 @@ class BrainChatting: chat_id=self.stream_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.6), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_content_block, message_id_list = build_readable_messages_with_id( messages=message_list_before_now, diff --git a/src/chat/brain_chat/brain_planner.py b/src/chat/brain_chat/brain_planner.py index 739ff106..ac68dca3 100644 --- a/src/chat/brain_chat/brain_planner.py +++ b/src/chat/brain_chat/brain_planner.py @@ -226,7 +226,7 @@ class BrainPlanner: chat_id=self.chat_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.6), - filter_no_read_command=True, + filter_intercept_message_level=1, ) message_id_list: list[Tuple[str, "DatabaseMessages"]] = [] chat_content_block, message_id_list = build_readable_messages_with_id( diff --git a/src/chat/heart_flow/heartFC_chat.py b/src/chat/heart_flow/heartFC_chat.py index 1af21a4f..eb101741 100644 --- a/src/chat/heart_flow/heartFC_chat.py +++ b/src/chat/heart_flow/heartFC_chat.py @@ -190,7 +190,7 @@ class HeartFChatting: limit_mode="latest", filter_mai=True, filter_command=False, - filter_no_read_command=True, + filter_intercept_message_level=0, ) # 根据连续 no_reply 次数动态调整阈值 @@ -485,7 +485,7 @@ class HeartFChatting: chat_id=self.stream_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.6), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_content_block, message_id_list = build_readable_messages_with_id( messages=message_list_before_now, diff --git a/src/chat/message_receive/bot.py b/src/chat/message_receive/bot.py index 2d798f54..1f5c7012 100644 --- a/src/chat/message_receive/bot.py +++ b/src/chat/message_receive/bot.py @@ -83,7 +83,7 @@ class ChatBot: self._started = True - async def _process_commands_with_new_system(self, message: MessageRecv): + async def _process_commands(self, message: MessageRecv): # sourcery skip: use-named-expression """使用新插件系统处理命令""" try: @@ -115,17 +115,17 @@ class ChatBot: try: # 执行命令 - success, response, intercept_message = await command_instance.execute() - message.is_no_read_command = bool(intercept_message) + success, response, intercept_message_level = await command_instance.execute() + message.intercept_message_level = intercept_message_level # 记录命令执行结果 if success: - logger.info(f"命令执行成功: {command_class.__name__} (拦截: {intercept_message})") + logger.info(f"命令执行成功: {command_class.__name__} (拦截等级: {intercept_message_level})") else: logger.warning(f"命令执行失败: {command_class.__name__} - {response}") # 根据命令的拦截设置决定是否继续处理消息 - return True, response, not intercept_message # 找到命令,根据intercept_message决定是否继续 + return True, response, not bool(intercept_message_level) # 找到命令,根据intercept_message决定是否继续 except Exception as e: logger.error(f"执行命令时出错: {command_class.__name__} - {e}") @@ -295,7 +295,7 @@ class ChatBot: # return # 命令处理 - 使用新插件系统检查并处理命令 - is_command, cmd_result, continue_process = await self._process_commands_with_new_system(message) + is_command, cmd_result, continue_process = await self._process_commands(message) # 如果是命令且不需要继续处理,则直接返回 if is_command and not continue_process: diff --git a/src/chat/message_receive/message.py b/src/chat/message_receive/message.py index 9528785e..727dd3b0 100644 --- a/src/chat/message_receive/message.py +++ b/src/chat/message_receive/message.py @@ -122,7 +122,7 @@ class MessageRecv(Message): self.is_notify = False self.is_command = False - self.is_no_read_command = False + self.intercept_message_level = 0 self.priority_mode = "interest" self.priority_info = None diff --git a/src/chat/message_receive/storage.py b/src/chat/message_receive/storage.py index b70f30ff..b73b04a3 100644 --- a/src/chat/message_receive/storage.py +++ b/src/chat/message_receive/storage.py @@ -72,7 +72,7 @@ class MessageStorage: key_words = "" key_words_lite = "" selected_expressions = message.selected_expressions - is_no_read_command = False + intercept_message_level = 0 else: filtered_display_message = "" interest_value = message.interest_value @@ -86,7 +86,7 @@ class MessageStorage: is_picid = message.is_picid is_notify = message.is_notify is_command = message.is_command - is_no_read_command = getattr(message, "is_no_read_command", False) + intercept_message_level = getattr(message, "intercept_message_level", 0) # 序列化关键词列表为JSON字符串 key_words = MessageStorage._serialize_keywords(message.key_words) key_words_lite = MessageStorage._serialize_keywords(message.key_words_lite) @@ -138,7 +138,7 @@ class MessageStorage: is_picid=is_picid, is_notify=is_notify, is_command=is_command, - is_no_read_command=is_no_read_command, + intercept_message_level=intercept_message_level, key_words=key_words, key_words_lite=key_words_lite, selected_expressions=selected_expressions, diff --git a/src/chat/planner_actions/action_modifier.py b/src/chat/planner_actions/action_modifier.py index 739d9999..baf1e1c5 100644 --- a/src/chat/planner_actions/action_modifier.py +++ b/src/chat/planner_actions/action_modifier.py @@ -69,7 +69,7 @@ class ActionModifier: chat_id=self.chat_stream.stream_id, timestamp=time.time(), limit=min(int(global_config.chat.max_context_size * 0.33), 10), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_content = build_readable_messages( diff --git a/src/chat/planner_actions/planner.py b/src/chat/planner_actions/planner.py index 5c498e30..f8bab0bb 100644 --- a/src/chat/planner_actions/planner.py +++ b/src/chat/planner_actions/planner.py @@ -316,7 +316,7 @@ class ActionPlanner: chat_id=self.chat_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.6), - filter_no_read_command=True, + filter_intercept_message_level=1, ) message_id_list: list[Tuple[str, "DatabaseMessages"]] = [] chat_content_block, message_id_list = build_readable_messages_with_id( diff --git a/src/chat/replyer/group_generator.py b/src/chat/replyer/group_generator.py index 3dd52272..18812cce 100644 --- a/src/chat/replyer/group_generator.py +++ b/src/chat/replyer/group_generator.py @@ -751,14 +751,14 @@ class DefaultReplyer: chat_id=chat_id, timestamp=reply_time_point, limit=global_config.chat.max_context_size * 1, - filter_no_read_command=True, + filter_intercept_message_level=1, ) message_list_before_short = get_raw_msg_before_timestamp_with_chat( chat_id=chat_id, timestamp=reply_time_point, limit=int(global_config.chat.max_context_size * 0.33), - filter_no_read_command=True, + filter_intercept_message_level=1, ) person_list_short: List[Person] = [] @@ -941,7 +941,7 @@ class DefaultReplyer: chat_id=chat_id, timestamp=time.time(), limit=min(int(global_config.chat.max_context_size * 0.33), 15), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_talking_prompt_half = build_readable_messages( message_list_before_now_half, diff --git a/src/chat/replyer/private_generator.py b/src/chat/replyer/private_generator.py index 396e806f..ecba37f9 100644 --- a/src/chat/replyer/private_generator.py +++ b/src/chat/replyer/private_generator.py @@ -663,7 +663,7 @@ class PrivateReplyer: chat_id=chat_id, timestamp=time.time(), limit=global_config.chat.max_context_size, - filter_no_read_command=True, + filter_intercept_message_level=1, ) dialogue_prompt = build_readable_messages( @@ -678,7 +678,7 @@ class PrivateReplyer: chat_id=chat_id, timestamp=time.time(), limit=int(global_config.chat.max_context_size * 0.33), - filter_no_read_command=True, + filter_intercept_message_level=1, ) person_list_short: List[Person] = [] @@ -878,7 +878,7 @@ class PrivateReplyer: chat_id=chat_id, timestamp=time.time(), limit=min(int(global_config.chat.max_context_size * 0.33), 15), - filter_no_read_command=True, + filter_intercept_message_level=1, ) chat_talking_prompt_half = build_readable_messages( message_list_before_now_half, diff --git a/src/chat/utils/chat_message_builder.py b/src/chat/utils/chat_message_builder.py index 5592e6cf..6a634fb4 100644 --- a/src/chat/utils/chat_message_builder.py +++ b/src/chat/utils/chat_message_builder.py @@ -120,7 +120,7 @@ def get_raw_msg_by_timestamp_with_chat( limit_mode: str = "latest", filter_bot=False, filter_command=False, - filter_no_read_command=False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """获取在特定聊天从指定时间戳到指定时间戳的消息,按时间升序排序,返回消息列表 limit: 限制返回的消息数量,0为不限制 @@ -138,7 +138,7 @@ def get_raw_msg_by_timestamp_with_chat( limit_mode=limit_mode, filter_bot=filter_bot, filter_command=filter_command, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) @@ -150,7 +150,7 @@ def get_raw_msg_by_timestamp_with_chat_inclusive( limit_mode: str = "latest", filter_bot=False, filter_command=False, - filter_no_read_command=False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """获取在特定聊天从指定时间戳到指定时间戳的消息(包含边界),按时间升序排序,返回消息列表 limit: 限制返回的消息数量,0为不限制 @@ -167,7 +167,7 @@ def get_raw_msg_by_timestamp_with_chat_inclusive( limit_mode=limit_mode, filter_bot=filter_bot, filter_command=filter_command, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) @@ -303,7 +303,7 @@ def get_raw_msg_before_timestamp(timestamp: float, limit: int = 0) -> List[Datab def get_raw_msg_before_timestamp_with_chat( - chat_id: str, timestamp: float, limit: int = 0, filter_no_read_command: bool = False + chat_id: str, timestamp: float, limit: int = 0, filter_intercept_message_level: Optional[int] = None ) -> List[DatabaseMessages]: """获取指定时间戳之前的消息,按时间升序排序,返回消息列表 limit: 限制返回的消息数量,0为不限制 @@ -311,7 +311,7 @@ def get_raw_msg_before_timestamp_with_chat( filter_query = {"chat_id": chat_id, "time": {"$lt": timestamp}} sort_order = [("time", 1)] return find_messages( - message_filter=filter_query, sort=sort_order, limit=limit, filter_no_read_command=filter_no_read_command + message_filter=filter_query, sort=sort_order, limit=limit, filter_intercept_message_level=filter_intercept_message_level ) diff --git a/src/common/data_models/database_data_model.py b/src/common/data_models/database_data_model.py index 9707add8..5bd35873 100644 --- a/src/common/data_models/database_data_model.py +++ b/src/common/data_models/database_data_model.py @@ -77,7 +77,7 @@ class DatabaseMessages(BaseDataModel): is_emoji: bool = False, is_picid: bool = False, is_command: bool = False, - is_no_read_command: bool = False, + intercept_message_level: int = 0, is_notify: bool = False, selected_expressions: Optional[str] = None, user_id: str = "", @@ -120,7 +120,7 @@ class DatabaseMessages(BaseDataModel): self.is_emoji = is_emoji self.is_picid = is_picid self.is_command = is_command - self.is_no_read_command = is_no_read_command + self.intercept_message_level = intercept_message_level self.is_notify = is_notify self.selected_expressions = selected_expressions @@ -188,7 +188,7 @@ class DatabaseMessages(BaseDataModel): "is_emoji": self.is_emoji, "is_picid": self.is_picid, "is_command": self.is_command, - "is_no_read_command": self.is_no_read_command, + "intercept_message_level": self.intercept_message_level, "is_notify": self.is_notify, "selected_expressions": self.selected_expressions, "user_id": self.user_info.user_id, diff --git a/src/common/data_models/message_data_model.py b/src/common/data_models/message_data_model.py index 3c4c5505..dc4ad951 100644 --- a/src/common/data_models/message_data_model.py +++ b/src/common/data_models/message_data_model.py @@ -22,7 +22,7 @@ class MessageAndActionModel(BaseDataModel): is_action_record: bool = field(default=False) action_name: Optional[str] = None is_command: bool = field(default=False) - is_no_read_command: bool = field(default=False) + intercept_message_level: int = field(default=0) @classmethod def from_DatabaseMessages(cls, message: "DatabaseMessages"): @@ -37,7 +37,7 @@ class MessageAndActionModel(BaseDataModel): display_message=message.display_message, chat_info_platform=message.chat_info.platform, is_command=message.is_command, - is_no_read_command=getattr(message, "is_no_read_command", False), + intercept_message_level=getattr(message, "intercept_message_level", 0), ) diff --git a/src/common/database/database_model.py b/src/common/database/database_model.py index a8be368d..5e916317 100644 --- a/src/common/database/database_model.py +++ b/src/common/database/database_model.py @@ -170,7 +170,7 @@ class Messages(BaseModel): is_emoji = BooleanField(default=False) is_picid = BooleanField(default=False) is_command = BooleanField(default=False) - is_no_read_command = BooleanField(default=False) + intercept_message_level = IntegerField(default=0) is_notify = BooleanField(default=False) selected_expressions = TextField(null=True) diff --git a/src/common/message_repository.py b/src/common/message_repository.py index 4b9c6b35..fa0126d4 100644 --- a/src/common/message_repository.py +++ b/src/common/message_repository.py @@ -25,7 +25,7 @@ def find_messages( limit_mode: str = "latest", filter_bot=False, filter_command=False, - filter_no_read_command=False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """ 根据提供的过滤器、排序和限制条件查找消息。 @@ -85,8 +85,9 @@ def find_messages( # 使用按位取反构造 Peewee 的 NOT 条件,避免直接与 False 比较 query = query.where(~Messages.is_command) - if filter_no_read_command: - query = query.where(~Messages.is_no_read_command) + if filter_intercept_message_level is not None: + # 过滤掉所有 intercept_message_level > filter_intercept_message_level 的消息 + query = query.where(Messages.intercept_message_level <= filter_intercept_message_level) if limit > 0: if limit_mode == "earliest": diff --git a/src/plugin_system/apis/message_api.py b/src/plugin_system/apis/message_api.py index 6a4805b2..94ee9cbc 100644 --- a/src/plugin_system/apis/message_api.py +++ b/src/plugin_system/apis/message_api.py @@ -72,7 +72,7 @@ def get_messages_by_time_in_chat( limit_mode: str = "latest", filter_mai: bool = False, filter_command: bool = False, - filter_no_read_command: bool = False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """ 获取指定聊天中指定时间范围内的消息 @@ -111,7 +111,7 @@ def get_messages_by_time_in_chat( limit_mode=limit_mode, filter_bot=filter_mai, filter_command=filter_command, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) @@ -123,7 +123,7 @@ def get_messages_by_time_in_chat_inclusive( limit_mode: str = "latest", filter_mai: bool = False, filter_command: bool = False, - filter_no_read_command: bool = False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """ 获取指定聊天中指定时间范围内的消息(包含边界) @@ -158,7 +158,7 @@ def get_messages_by_time_in_chat_inclusive( limit_mode=limit_mode, filter_bot=filter_mai, filter_command=filter_command, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) if filter_mai: return filter_mai_messages(messages) @@ -284,7 +284,7 @@ def get_messages_before_time_in_chat( timestamp: float, limit: int = 0, filter_mai: bool = False, - filter_no_read_command: bool = False, + filter_intercept_message_level: Optional[int] = None, ) -> List[DatabaseMessages]: """ 获取指定聊天中指定时间戳之前的消息 @@ -313,7 +313,7 @@ def get_messages_before_time_in_chat( chat_id=chat_id, timestamp=timestamp, limit=limit, - filter_no_read_command=filter_no_read_command, + filter_intercept_message_level=filter_intercept_message_level, ) if filter_mai: return filter_mai_messages(messages) diff --git a/src/plugin_system/base/base_command.py b/src/plugin_system/base/base_command.py index 4b098869..0cfdb5d0 100644 --- a/src/plugin_system/base/base_command.py +++ b/src/plugin_system/base/base_command.py @@ -55,11 +55,11 @@ class BaseCommand(ABC): self.matched_groups = groups @abstractmethod - async def execute(self) -> Tuple[bool, Optional[str], bool]: + async def execute(self) -> Tuple[bool, Optional[str], int]: """执行Command的抽象方法,子类必须实现 Returns: - Tuple[bool, Optional[str], bool]: (是否执行成功, 可选的回复消息, 是否拦截消息 不进行 后续处理) + Tuple[bool, Optional[str], int]: (是否执行成功, 可选的回复消息, 拦截消息力度,0代表不拦截,1代表仅不触发回复,replyer可见,2代表不触发回复,replyer不可见) """ pass From d97c6aa9483db6e792205ab138e14a8b50c2f2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Wed, 3 Dec 2025 10:45:20 +0800 Subject: [PATCH 07/23] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=BA=A7=E5=88=AB=E6=B8=A9=E5=BA=A6=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E6=B8=A9=E5=BA=A6=E4=BC=98=E5=85=88?= =?UTF-8?q?=E7=BA=A7=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/api_ada_configs.py | 3 +++ src/llm_models/utils_model.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/config/api_ada_configs.py b/src/config/api_ada_configs.py index 897e1f87..24b7896e 100644 --- a/src/config/api_ada_configs.py +++ b/src/config/api_ada_configs.py @@ -60,6 +60,9 @@ class ModelInfo(ConfigBase): price_out: float = field(default=0.0) """每M token输出价格""" + temperature: float | None = field(default=None) + """模型级别温度(可选),会覆盖任务配置中的温度""" + force_stream_mode: bool = field(default=False) """是否强制使用流式输出模式""" diff --git a/src/llm_models/utils_model.py b/src/llm_models/utils_model.py index 44ff2de3..2d2c1c86 100644 --- a/src/llm_models/utils_model.py +++ b/src/llm_models/utils_model.py @@ -315,12 +315,21 @@ class LLMRequest: while retry_remain > 0: try: if request_type == RequestType.RESPONSE: + # 温度优先级:参数传入 > 模型级别配置 > extra_params > 任务配置 + effective_temperature = temperature + if effective_temperature is None: + effective_temperature = model_info.temperature + if effective_temperature is None: + effective_temperature = (model_info.extra_params or {}).get("temperature") + if effective_temperature is None: + effective_temperature = self.model_for_task.temperature + return await client.get_response( model_info=model_info, message_list=(compressed_messages or message_list), tool_options=tool_options, max_tokens=self.model_for_task.max_tokens if max_tokens is None else max_tokens, - temperature=temperature if temperature is not None else (model_info.extra_params or {}).get("temperature", self.model_for_task.temperature), + temperature=effective_temperature, response_format=response_format, stream_response_handler=stream_response_handler, async_response_parser=async_response_parser, From 6cdd03814adf15646b81f014f0cb2de0f0629d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Wed, 3 Dec 2025 10:46:33 +0800 Subject: [PATCH 08/23] WebUI ce4f3d071ee29261e31adbdcbdb6c84058ed1e2a --- webui/dist/assets/index-QJDQd8Xo.css | Bin 183501 -> 0 bytes .../{index-DJb_iiTR.js => index-Utqxo0Hn.js} | Bin 758094 -> 767678 bytes webui/dist/assets/index-g7kfURqA.css | Bin 0 -> 184129 bytes webui/dist/index.html | Bin 1626 -> 1626 bytes 4 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 webui/dist/assets/index-QJDQd8Xo.css rename webui/dist/assets/{index-DJb_iiTR.js => index-Utqxo0Hn.js} (52%) create mode 100644 webui/dist/assets/index-g7kfURqA.css diff --git a/webui/dist/assets/index-QJDQd8Xo.css b/webui/dist/assets/index-QJDQd8Xo.css deleted file mode 100644 index 06b5dafa32f179fab01f4d36cd1fc06c6c598161..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 183501 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 I75P8^4@4^|vH$=8 diff --git a/webui/dist/assets/index-DJb_iiTR.js b/webui/dist/assets/index-Utqxo0Hn.js similarity index 52% rename from webui/dist/assets/index-DJb_iiTR.js rename to webui/dist/assets/index-Utqxo0Hn.js index 886bc66f7e7f64687065c6d1f90cd0d3fb808319..08b38b1a81ee8397ee9df23355933fedd928c9dd 100644 GIT binary patch delta 105417 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͊lx-k(jkTm}mkR=m)Zk0BHx)A@yIp25K3Ur?n{`s5&7^pS(}5*& z6!|TcC$eGFdJ%ivvWAYVk()5e`5jn(OK4{Y?uyC3Q{ICyc(fBUy^p@_L=P{akuJy( z>gc&H`F3nTt!w3_DZcj4>SS%t#AMYZQJVK?G96zl52E9my1@`(K}N5KJBDI0^IQ)G zDw)phmA^&K)BEHraQ9H3T#C!DeJC8>p6lf70I1Y4%jwz|fxdF&Qyziz``5`&gcYd@ zOT{KBp$Q!$d~)dPrvu~Y=6-omR#AFuuYHZGmxKYZwh&9L>y+tN0bBguRhWY%prVyf z?0HuD`eDB*hEn9RGs_Su9J42o!znw3FQ{z`OiR9X$_tDoqgPqtsP6JA-Ty96^KPk71r1Li1cuBt|>{Kb)6-Ctt;}-D}+h6ktU6?Y>ZcUFTPVsIioH z0=@HPyCH}+J&LpZl8tf&FG7DPP_hm>yiwkxul2g)OrmEll8YroWLsZ500m{x#qwmo zB2cZKYJ{>x%`^$>#*5{G&;r3AHY1j-J6dgPiyHUx)r;joeYbK0l;K<85hPtA=L2vW zjxO~V$rH$ai5#oXA5(Y?y>W@$r7z8*!b|0~Vx?RvpR0G~QpII**gU7=U6MH0hOl=7 zYdZUSa=_uhO+_Kxob_2NOzo{5cFm#HS%1z7@V)JQYdYL1RB)NRR)2mL1z(Saa&;!+ znp|*sK%jf;IP^(C81E2*zoxd_(kxf(<#L{07^>YDFqK})M7*q#D?w}IQ`l8_=#!)A$?I@_Eq{9*UpV;J1J?xjP@39G|t|)SDmbd7NT|w956wnXAN;*pp zbAA4M9MOjJvrH6ny&UeEeuMnJPQNURDke&it|^3l2fNX}8|9@jbuGsAifx_+$Cvwn zR3~vUW!wZ&dN;yiK%~?l0)u#Q-Bvke(h}cAsusSE_H31>Pprc{_EZm_ zEmks0qzO06;{vJ&kYcC!o8>tH)gsd#@q0RTb%2M7?+@U?9)EmGM?aqTdY%XRJn9sW zZ0e(f9kPV^D$4*S$pY4+*Iv_Yw_CdKf+Cff1Z1n; zBEgarxS&YgSKcNR+)ek%N&2PgGl*Nn_u{}j@CbYjjy(h^ zF2a!Q#w$y-&l`7(&#C!gIYFc~?3H7(JleVb1~;{>YJYmLSt#0{Q4~p!v7Fw1ST2lq zj@e0ijLB#&2E_>)X-Ln zw6xUW2737sdC8<@r|%rAY4)SCF??NvPyR~AJw|&!C8YR=o(msSWiEDGS{gig+Ys2u SbJLHn6-Gv!)#6`jHvfM&v&e`5 diff --git a/webui/dist/assets/index-g7kfURqA.css b/webui/dist/assets/index-g7kfURqA.css new file mode 100644 index 0000000000000000000000000000000000000000..a42b1eda2bad468a829657bfff98cd089e712135 GIT binary patch literal 184129 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%0DWpGsx14^Pc58AqJt3rmU<@UI4tf*73 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 Date: Wed, 3 Dec 2025 11:31:13 +0800 Subject: [PATCH 09/23] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20token=20?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B8=85=E9=99=A4?= =?UTF-8?q?=20Cookie=20=E5=B9=B6=E8=A6=81=E6=B1=82=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=99=BB=E5=BD=95=EF=BC=9B=E4=BF=9D=E7=95=99?= =?UTF-8?q?=20first=5Fsetup=5Fcompleted=20=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webui/routes.py | 8 ++++---- src/webui/token_manager.py | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/webui/routes.py b/src/webui/routes.py index 2c0fc9f7..96942f1d 100644 --- a/src/webui/routes.py +++ b/src/webui/routes.py @@ -223,9 +223,9 @@ async def update_token( # 更新 token success, message = token_manager.update_token(request.new_token) - # 如果更新成功,更新 Cookie + # 如果更新成功,清除 Cookie,要求用户重新登录 if success: - set_auth_cookie(response, request.new_token) + clear_auth_cookie(response) return TokenUpdateResponse(success=success, message=message) except HTTPException: @@ -272,8 +272,8 @@ async def regenerate_token( # 重新生成 token new_token = token_manager.regenerate_token() - # 更新 Cookie - set_auth_cookie(response, new_token) + # 清除 Cookie,要求用户重新登录 + clear_auth_cookie(response) return TokenRegenerateResponse(success=True, token=new_token, message="Token 已重新生成") except HTTPException: diff --git a/src/webui/token_manager.py b/src/webui/token_manager.py index 69abf1d8..17e3f068 100644 --- a/src/webui/token_manager.py +++ b/src/webui/token_manager.py @@ -160,13 +160,29 @@ class TokenManager: def regenerate_token(self) -> str: """ - 重新生成 token + 重新生成 token(保留 first_setup_completed 状态) Returns: str: 新生成的 token """ logger.info("正在重新生成 WebUI Token...") - return self._create_new_token() + + # 生成新的 64 位十六进制字符串 + new_token = secrets.token_hex(32) + + # 加载现有配置,保留 first_setup_completed 状态 + config = self._load_config() + old_token = config.get("access_token", "")[:8] if config.get("access_token") else "无" + first_setup_completed = config.get("first_setup_completed", True) # 默认为 True,表示已完成配置 + + config["access_token"] = new_token + config["updated_at"] = self._get_current_timestamp() + config["first_setup_completed"] = first_setup_completed # 保留原来的状态 + + self._save_config(config) + logger.info(f"WebUI Token 已重新生成: {old_token}... -> {new_token[:8]}...") + + return new_token def _validate_token_format(self, token: str) -> bool: """ From 12bc6617901f5ab2f60a69f415478099ee67f6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Wed, 3 Dec 2025 11:45:15 +0800 Subject: [PATCH 10/23] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=BA=A7=E5=88=AB=E6=9C=80=E5=A4=A7token=E6=95=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E9=80=BB=E8=BE=91=E4=BB=A5=E6=94=AF=E6=8C=81=E4=BC=98?= =?UTF-8?q?=E5=85=88=E7=BA=A7=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/api_ada_configs.py | 3 +++ src/llm_models/utils_model.py | 11 ++++++++++- template/model_config_template.toml | 10 +++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/config/api_ada_configs.py b/src/config/api_ada_configs.py index 24b7896e..a5d5f059 100644 --- a/src/config/api_ada_configs.py +++ b/src/config/api_ada_configs.py @@ -63,6 +63,9 @@ class ModelInfo(ConfigBase): temperature: float | None = field(default=None) """模型级别温度(可选),会覆盖任务配置中的温度""" + max_tokens: int | None = field(default=None) + """模型级别最大token数(可选),会覆盖任务配置中的max_tokens""" + force_stream_mode: bool = field(default=False) """是否强制使用流式输出模式""" diff --git a/src/llm_models/utils_model.py b/src/llm_models/utils_model.py index 2d2c1c86..7fb4cfd0 100644 --- a/src/llm_models/utils_model.py +++ b/src/llm_models/utils_model.py @@ -324,11 +324,20 @@ class LLMRequest: if effective_temperature is None: effective_temperature = self.model_for_task.temperature + # max_tokens 优先级:参数传入 > 模型级别配置 > extra_params > 任务配置 + effective_max_tokens = max_tokens + if effective_max_tokens is None: + effective_max_tokens = model_info.max_tokens + if effective_max_tokens is None: + effective_max_tokens = (model_info.extra_params or {}).get("max_tokens") + if effective_max_tokens is None: + effective_max_tokens = self.model_for_task.max_tokens + return await client.get_response( model_info=model_info, message_list=(compressed_messages or message_list), tool_options=tool_options, - max_tokens=self.model_for_task.max_tokens if max_tokens is None else max_tokens, + max_tokens=effective_max_tokens, temperature=effective_temperature, response_format=response_format, stream_response_handler=stream_response_handler, diff --git a/template/model_config_template.toml b/template/model_config_template.toml index 1e072d13..225b94f1 100644 --- a/template/model_config_template.toml +++ b/template/model_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "1.8.2" +version = "1.9.0" # 配置文件版本号迭代规则同bot_config.toml @@ -54,9 +54,11 @@ name = "siliconflow-deepseek-v3.2" api_provider = "SiliconFlow" price_in = 2.0 price_out = 3.0 +# temperature = 0.5 # 可选:为该模型单独指定温度,会覆盖任务配置中的温度 +# max_tokens = 4096 # 可选:为该模型单独指定最大token数,会覆盖任务配置中的max_tokens [models.extra_params] # 可选的额外参数配置 enable_thinking = false # 不启用思考 -# temperature = 0.5 # 可选:为该模型单独指定温度,会覆盖任务配置中的温度 + [[models]] model_identifier = "deepseek-ai/DeepSeek-V3.2-Exp" @@ -64,9 +66,11 @@ name = "siliconflow-deepseek-v3.2-think" api_provider = "SiliconFlow" price_in = 2.0 price_out = 3.0 +# temperature = 0.7 # 可选:为该模型单独指定温度,会覆盖任务配置中的温度 +# max_tokens = 4096 # 可选:为该模型单独指定最大token数,会覆盖任务配置中的max_tokens [models.extra_params] # 可选的额外参数配置 enable_thinking = true # 启用思考 -# temperature = 0.7 # 可选:为该模型单独指定温度,会覆盖任务配置中的温度 + [[models]] model_identifier = "Qwen/Qwen3-Next-80B-A3B-Instruct" From 79b488632670a9962a94c6a65dcc1db17078e4c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Wed, 3 Dec 2025 11:49:10 +0800 Subject: [PATCH 11/23] WebUI 55f390ed83dd0acf032fa3158bb9e1e2275d1c41 --- webui/dist/assets/index-DM1UfLap.css | Bin 0 -> 184146 bytes webui/dist/assets/index-g7kfURqA.css | Bin 184129 -> 0 bytes .../{index-Utqxo0Hn.js => index-siV9e-l5.js} | Bin 767678 -> 768870 bytes webui/dist/index.html | Bin 1626 -> 1626 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 webui/dist/assets/index-DM1UfLap.css delete mode 100644 webui/dist/assets/index-g7kfURqA.css rename webui/dist/assets/{index-Utqxo0Hn.js => index-siV9e-l5.js} (88%) diff --git a/webui/dist/assets/index-DM1UfLap.css b/webui/dist/assets/index-DM1UfLap.css new file mode 100644 index 0000000000000000000000000000000000000000..997524d699f59e42dbda52b957208f3a90297b4c GIT binary patch literal 184146 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 literal 0 HcmV?d00001 diff --git a/webui/dist/assets/index-g7kfURqA.css b/webui/dist/assets/index-g7kfURqA.css deleted file mode 100644 index a42b1eda2bad468a829657bfff98cd089e712135..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 184129 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%0DWpGsx14^Pc58AqJt3rmU<@UI4tf*73 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 bFs~Sn2vp%r^PgxW>p(zARC6lLcLx3spj8(7 delta 20083 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 Date: Wed, 3 Dec 2025 20:02:37 +0800 Subject: [PATCH 12/23] =?UTF-8?q?=F0=9F=93=A6=20build(deps):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20msgpack,=20python-multipart,=20zstandard=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 msgpack, python-multipart, zstandard 依赖以支持新的功能或优化 - 确保项目依赖的完整性 --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index fb613e19..00f91cf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "json-repair>=0.47.6", "maim-message", "matplotlib>=3.10.3", + "msgpack>=1.1.2", "numpy>=2.2.6", "openai>=1.95.0", "pandas>=2.3.1", @@ -23,6 +24,7 @@ dependencies = [ "pydantic>=2.11.7", "pypinyin>=0.54.0", "python-dotenv>=1.1.1", + "python-multipart>=0.0.20", "quick-algo>=0.1.3", "rich>=14.0.0", "ruff>=0.12.2", @@ -32,6 +34,7 @@ dependencies = [ "tomlkit>=0.13.3", "urllib3>=2.5.0", "uvicorn>=0.35.0", + "zstandard>=0.25.0", ] From 82b605c901765b9f53fc8cc163ed202e30761212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=81=BA=E2=9C=9E=CA=9A=E5=B0=8F=E9=B3=A5=E9=81=8A?= =?UTF-8?q?=E5=85=AD=E8=8A=B1=E9=87=8D=E5=BA=A6=E4=BE=9D=E8=B5=96=C9=9E?= =?UTF-8?q?=E2=9C=9F=E2=82=8A?= <2985772684@qq.com> Date: Wed, 3 Dec 2025 21:05:52 +0800 Subject: [PATCH 13/23] Discard changes to docker-compose.yml --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 217ae187..1161e7d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: # image: infinitycat/maibot:dev environment: - TZ=Asia/Shanghai -# - EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d # 同意EULA +# - EULA_AGREE=99f08e0cab0190de853cb6af7d64d4de # 同意EULA # - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 # 同意EULA ports: - "18001:8001" # webui端口 From 0399f878f0a7465cd051f48616c8f86ccabe76f8 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 4 Dec 2025 19:59:57 +0800 Subject: [PATCH 14/23] =?UTF-8?q?config=EF=BC=9A=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E9=85=8D=E7=BD=AE=E9=A1=B9=EF=BC=8C=E6=A2=A6?= =?UTF-8?q?=E8=B5=B7=E5=A7=8B=E6=97=B6=E9=97=B4=E5=92=8C=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelogs/changelog.md | 6 ++ dummy | 10 ++++ src/chat/replyer/group_generator.py | 2 +- src/chat/replyer/private_generator.py | 2 +- src/chat/replyer/prompt/replyer_prompt.py | 4 +- src/config/official_configs.py | 10 +++- src/express/expression_learner.py | 4 +- src/main.py | 2 +- .../retrieval_tools/query_chat_history.py | 60 ++++++++++++++++--- template/bot_config_template.toml | 6 +- 10 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 dummy diff --git a/changelogs/changelog.md b/changelogs/changelog.md index c12e726b..690f797d 100644 --- a/changelogs/changelog.md +++ b/changelogs/changelog.md @@ -1,5 +1,11 @@ # Changelog +## [0.11.7] - 2025-12-2 +- 增加麦麦做梦功能 + +- 添加全局记忆配置项 + + ## [0.11.6] - 2025-12-2 ### 🌟 重大更新 - 大幅提高记忆检索能力,略微提高token消耗 diff --git a/dummy b/dummy new file mode 100644 index 00000000..5cae3661 --- /dev/null +++ b/dummy @@ -0,0 +1,10 @@ +{ + "cells": [], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} \ No newline at end of file diff --git a/src/chat/replyer/group_generator.py b/src/chat/replyer/group_generator.py index 18812cce..a37925c2 100644 --- a/src/chat/replyer/group_generator.py +++ b/src/chat/replyer/group_generator.py @@ -256,7 +256,7 @@ class DefaultReplyer: logger.debug(f"使用处理器选中的{len(selected_expressions)}个表达方式") for expr in selected_expressions: if isinstance(expr, dict) and "situation" in expr and "style" in expr: - style_habits.append(f"当{expr['situation']}时,使用 {expr['style']}") + style_habits.append(f"当{expr['situation']}时:{expr['style']}") else: logger.debug("没有从处理器获得表达方式,将使用空的表达方式") # 不再在replyer中进行随机选择,全部交给处理器处理 diff --git a/src/chat/replyer/private_generator.py b/src/chat/replyer/private_generator.py index ecba37f9..726ce4b2 100644 --- a/src/chat/replyer/private_generator.py +++ b/src/chat/replyer/private_generator.py @@ -271,7 +271,7 @@ class PrivateReplyer: logger.debug(f"使用处理器选中的{len(selected_expressions)}个表达方式") for expr in selected_expressions: if isinstance(expr, dict) and "situation" in expr and "style" in expr: - style_habits.append(f"当{expr['situation']}时,使用 {expr['style']}") + style_habits.append(f"当{expr['situation']}时:{expr['style']}") else: logger.debug("没有从处理器获得表达方式,将使用空的表达方式") # 不再在replyer中进行随机选择,全部交给处理器处理 diff --git a/src/chat/replyer/prompt/replyer_prompt.py b/src/chat/replyer/prompt/replyer_prompt.py index f9ee25a4..c1c89124 100644 --- a/src/chat/replyer/prompt/replyer_prompt.py +++ b/src/chat/replyer/prompt/replyer_prompt.py @@ -19,7 +19,7 @@ def init_replyer_prompt(): {planner_reasoning} {identity} {chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些,{mood_state} -尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理,可以有个性。 +尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。 {reply_style} 请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出一句回复内容就好。 不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 @@ -39,7 +39,7 @@ def init_replyer_prompt(): {planner_reasoning} {identity} {chat_prompt}你正在和{sender_name}聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些,{mood_state} -尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理,可以有个性。 +尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。 {reply_style} 请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 {moderation_prompt}不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。""", diff --git a/src/config/official_configs.py b/src/config/official_configs.py index e7fce623..2def18f2 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -247,6 +247,9 @@ class MemoryConfig(ConfigBase): enable_jargon_detection: bool = True """记忆检索过程中是否启用黑话识别""" + global_memory: bool = False + """是否允许记忆检索在聊天记录中进行全局查询(忽略当前chat_id,仅对 search_chat_history 等工具生效)""" + def __post_init__(self): """验证配置值""" if self.max_agent_iterations < 1: @@ -726,9 +729,14 @@ class DreamConfig(ConfigBase): max_iterations: int = 20 """做梦最大轮次,默认20轮""" + first_delay_seconds: int = 60 + """程序启动后首次做梦前的延迟时间(秒),默认60秒""" + def __post_init__(self): """验证配置值""" if self.interval_minutes < 1: raise ValueError(f"interval_minutes 必须至少为1,当前值: {self.interval_minutes}") if self.max_iterations < 1: - raise ValueError(f"max_iterations 必须至少为1,当前值: {self.max_iterations}") \ No newline at end of file + raise ValueError(f"max_iterations 必须至少为1,当前值: {self.max_iterations}") + if self.first_delay_seconds < 0: + raise ValueError(f"first_delay_seconds 不能为负数,当前值: {self.first_delay_seconds}") \ No newline at end of file diff --git a/src/express/expression_learner.py b/src/express/expression_learner.py index 0c335488..c18f3b6d 100644 --- a/src/express/expression_learner.py +++ b/src/express/expression_learner.py @@ -40,9 +40,9 @@ def init_prompt() -> None: [ {{"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}}, {{"situation": "CCCC", "style": "DDDD", "source_id": "7"}} - {{"situation": "对某件事表示十分惊叹", "style": "我嘞个xxxx", "source_id": "[消息编号]"}}, + {{"situation": "对某件事表示十分惊叹", "style": "使用 我嘞个xxxx", "source_id": "[消息编号]"}}, {{"situation": "表示讽刺的赞同,不讲道理", "style": "对对对", "source_id": "[消息编号]"}}, - {{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "这么强!", "source_id": "[消息编号]"}}, + {{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "使用 这么强!", "source_id": "[消息编号]"}}, ] 请注意: diff --git a/src/main.py b/src/main.py index 03580008..d2ca213b 100644 --- a/src/main.py +++ b/src/main.py @@ -108,7 +108,7 @@ class MainSystem: await async_task_manager.add_task(TelemetryHeartBeatTask()) # 添加记忆遗忘任务 - from src.chat.utils.memory_forget_task import MemoryForgetTask + from src.hippo_memorizer.memory_forget_task import MemoryForgetTask await async_task_manager.add_task(MemoryForgetTask()) diff --git a/src/memory_system/retrieval_tools/query_chat_history.py b/src/memory_system/retrieval_tools/query_chat_history.py index 097632c4..0d2d2cc7 100644 --- a/src/memory_system/retrieval_tools/query_chat_history.py +++ b/src/memory_system/retrieval_tools/query_chat_history.py @@ -5,11 +5,13 @@ import json from typing import Optional +from datetime import datetime + from src.common.logger import get_logger from src.common.database.database_model import ChatHistory from src.chat.utils.utils import parse_keywords_string +from src.config.config import global_config from .tool_registry import register_memory_retrieval_tool -from datetime import datetime logger = get_logger("memory_retrieval_tools") @@ -33,7 +35,18 @@ async def search_chat_history( return "未指定查询参数(需要提供keyword或participant之一)" # 构建查询条件 - query = ChatHistory.select().where(ChatHistory.chat_id == chat_id) + # 根据配置决定是否限制在当前 chat_id 内查询 + use_global_search = global_config.memory.global_memory + + if use_global_search: + # 全局查询所有聊天记录 + query = ChatHistory.select() + logger.debug( + f"search_chat_history 启用全局查询模式,忽略 chat_id 过滤,keyword={keyword}, participant={participant}" + ) + else: + # 仅在当前聊天流内查询 + query = ChatHistory.select().where(ChatHistory.chat_id == chat_id) # 执行查询 records = list(query.order_by(ChatHistory.start_time.desc()).limit(50)) @@ -139,9 +152,45 @@ async def search_chat_history( else: return "未找到相关聊天记录" - # 构建结果文本,返回id、theme和keywords + # 如果匹配结果超过20条,不返回具体记录,只返回提示和所有相关关键词 + if len(filtered_records) > 20: + # 统计所有记录上的关键词并去重 + all_keywords_set = set() + for record in filtered_records: + if record.keywords: + try: + keywords_data = ( + json.loads(record.keywords) + if isinstance(record.keywords, str) + else record.keywords + ) + if isinstance(keywords_data, list): + for k in keywords_data: + k_str = str(k).strip() + if k_str: + all_keywords_set.add(k_str) + except (json.JSONDecodeError, TypeError, ValueError): + continue + + # xxx 使用用户原始查询词,优先 keyword,其次 participant,最后退化成“当前条件” + search_label = keyword or participant or "当前条件" + + if all_keywords_set: + keywords_str = "、".join(sorted(all_keywords_set)) + return ( + f"包含“{search_label}”的结果过多,请尝试更多关键词精确查找\n\n" + f"有关\"{search_label}\"的关键词:\n" + f"{keywords_str}" + ) + else: + return ( + f"包含“{search_label}”的结果过多,请尝试更多关键词精确查找\n\n" + f"有关\"{search_label}\"的关键词信息为空" + ) + + # 构建结果文本,返回id、theme和keywords(最多20条) results = [] - for record in filtered_records[:20]: # 最多返回20条记录 + for record in filtered_records[:20]: result_parts = [] # 添加记忆ID @@ -173,9 +222,6 @@ async def search_chat_history( return "未找到相关聊天记录" response_text = "\n\n---\n\n".join(results) - if len(filtered_records) > 20: - omitted_count = len(filtered_records) - 20 - response_text += f"\n\n(还有{omitted_count}条记录已省略,可使用记忆ID查询详细信息)" return response_text except Exception as e: diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 465ab682..2832ba93 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "7.0.0" +version = "7.0.1" #----以下是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- # 如果你想要修改配置文件,请递增version的值 @@ -110,10 +110,12 @@ include_planner_reasoning = false # 是否将planner推理加入replyer,默认 [memory] max_agent_iterations = 3 # 记忆思考深度(最低为1(不深入思考)) enable_jargon_detection = true # 记忆检索过程中是否启用黑话识别 +global_memory = false # 是否允许记忆检索进行全局查询 [dream] -interval_minutes = 30 # 做梦时间间隔(分钟),默认30分钟 +interval_minutes = 45 # 做梦时间间隔(分钟),默认30分钟 max_iterations = 20 # 做梦最大轮次,默认20轮 +first_delay_seconds = 1200 # 程序启动后首次做梦前的延迟时间(秒),默认60秒 [jargon] all_global = true # 是否开启全局黑话模式,注意,此功能关闭后,已经记录的全局黑话不会改变,需要手动删除 From add29efefc3f716cd8b0f8c6cffec06707eb70e8 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 4 Dec 2025 20:00:20 +0800 Subject: [PATCH 15/23] =?UTF-8?q?better=EF=BC=9A=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=81=9A=E6=A2=A6=E8=A1=A8=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dream/dream_agent.py | 531 +++++------------- src/dream/dream_generator.py | 198 +++++++ src/dream/tools/__init__.py | 9 + src/dream/tools/create_chat_history_tool.py | 65 +++ src/dream/tools/delete_chat_history_tool.py | 28 + src/dream/tools/delete_jargon_tool.py | 28 + src/dream/tools/finish_maintenance_tool.py | 19 + .../tools/get_chat_history_detail_tool.py | 54 ++ src/dream/tools/search_chat_history_tool.py | 225 ++++++++ src/dream/tools/update_chat_history_tool.py | 54 ++ src/dream/tools/update_jargon_tool.py | 54 ++ .../memory_forget_task.py | 8 +- 12 files changed, 893 insertions(+), 380 deletions(-) create mode 100644 src/dream/dream_generator.py create mode 100644 src/dream/tools/__init__.py create mode 100644 src/dream/tools/create_chat_history_tool.py create mode 100644 src/dream/tools/delete_chat_history_tool.py create mode 100644 src/dream/tools/delete_jargon_tool.py create mode 100644 src/dream/tools/finish_maintenance_tool.py create mode 100644 src/dream/tools/get_chat_history_detail_tool.py create mode 100644 src/dream/tools/search_chat_history_tool.py create mode 100644 src/dream/tools/update_chat_history_tool.py create mode 100644 src/dream/tools/update_jargon_tool.py rename src/{chat/utils => hippo_memorizer}/memory_forget_task.py (98%) diff --git a/src/dream/dream_agent.py b/src/dream/dream_agent.py index 2bf6a11a..c05080b5 100644 --- a/src/dream/dream_agent.py +++ b/src/dream/dream_agent.py @@ -1,58 +1,70 @@ import asyncio import random import time +import json from typing import Any, Dict, List, Optional, Tuple +from peewee import fn + from src.common.logger import get_logger from src.config.config import global_config, model_config -from src.common.database.database_model import ChatHistory +from src.common.database.database_model import ChatHistory, Jargon from src.chat.utils.prompt_builder import Prompt, global_prompt_manager from src.llm_models.payload_content.message import MessageBuilder, RoleType, Message from src.plugin_system.apis import llm_api -from src.llm_models.utils_model import LLMRequest +from src.dream.dream_generator import generate_dream_summary +# dream 工具工厂函数 +from src.dream.tools.search_chat_history_tool import make_search_chat_history +from src.dream.tools.get_chat_history_detail_tool import make_get_chat_history_detail +from src.dream.tools.delete_chat_history_tool import make_delete_chat_history +from src.dream.tools.create_chat_history_tool import make_create_chat_history +from src.dream.tools.update_chat_history_tool import make_update_chat_history +from src.dream.tools.finish_maintenance_tool import make_finish_maintenance +from src.dream.tools.search_jargon_tool import make_search_jargon +from src.dream.tools.delete_jargon_tool import make_delete_jargon +from src.dream.tools.update_jargon_tool import make_update_jargon logger = get_logger("dream_agent") -# 初始化 utils 模型用于生成梦境总结 -_dream_summary_model: Optional[LLMRequest] = None - - -def _get_dream_summary_model() -> LLMRequest: - """获取用于生成梦境总结的 utils 模型实例""" - global _dream_summary_model - if _dream_summary_model is None: - _dream_summary_model = LLMRequest( - model_set=model_config.model_task_config.utils, - request_type="dream.summary", - ) - return _dream_summary_model - def init_dream_prompts() -> None: """初始化 dream agent 的提示词""" Prompt( """ 你的名字是{bot_name},你现在处于"梦境维护模式(dream agent)"。 -你可以自由地在 ChatHistory 库中探索、整理、合并和删改记录,以帮助自己在未来更好地回忆和理解对话历史。 +你可以自由地在 ChatHistory 库中探索、整理、创建和删改记录,以帮助自己在未来更好地回忆和理解对话历史。 本轮要维护的聊天ID:{chat_id} 本轮随机选中的起始记忆 ID:{start_memory_id} -请优先以这条起始记忆为切入点,先理解它的内容与上下文,再决定如何在其附近进行合并、重写或删除等整理操作;如果起始记忆为空,则由你自行选择合适的切入点。 +请优先以这条起始记忆为切入点,先理解它的内容与上下文,再决定如何在其附近进行创建新概括、重写或删除等整理操作;如果起始记忆为空,则由你自行选择合适的切入点。 你可以使用的工具包括: -- list_chat_histories:浏览该 chat_id 下的历史概括列表 +**ChatHistory 维护工具:** +- search_chat_history:根据关键词或参与人搜索该 chat_id 下的历史记忆概括列表 - get_chat_history_detail:查看某条概括的详细内容 -- merge_chat_histories:把多条内容高度相似或应当归为一类的记录合并到一条 +- create_chat_history:根据整理后的理解创建一条新的 ChatHistory 概括记录(主题、概括、关键词、关键信息等) - update_chat_history:在不改变事实的前提下重写或精炼主题、概括、关键词、关键信息 -- delete_chat_history:删除明显噪声、错误或无意义的记录(要格外谨慎) -- finish_maintenance:当你认为当前 chat_id 下的维护工作已经完成,没有更多需要整理的内容时,调用此工具来结束本次运行 +- delete_chat_history:删除明显冗余、噪声、错误或无意义的记录,或者非常有时效性的信息,或者无太多有用信息的日常互动。 +你也可以先用 create_chat_history 创建一条新的综合概括,再对旧的冗余记录执行多次 delete_chat_history 来完成“合并”效果。 + +**Jargon(黑话)维护工具(只读,禁止修改):** +- search_jargon:根据一个或多个关键词搜索Jargon 记录,通常是含义不明确的词条或者特殊的缩写 + +**通用工具:** +- finish_maintenance:当你认为当前维护工作已经完成,没有更多需要整理的内容时,调用此工具来结束本次运行 **工作目标**: - 发现冗余、重复或高度相似的记录,并进行合并或删除; - 发现主题/概括过于含糊、啰嗦或缺少关键信息的记录,进行重写和精简; +- summary要尽可能保持有用的信息; - 尽量保持信息的真实与可用性,不要凭空捏造事实。 +**合并准则** +- 你可以新建一个记录,然后删除旧记录来实现合并。 +- 如果两个或多个记录的主题相似,内容是对主题不同方面的信息或讨论,且信息量较少,则可以合并为一条记录。 +- 如果两个记录冲突,可以根据逻辑保留一个或者进行整合,也可以采取更新的记录,删除旧的记录 + **轮次信息**: - 本次维护最多执行 {max_iterations} 轮 - 每轮开始时,系统会告知你当前是第几轮,还剩多少轮 @@ -69,23 +81,6 @@ def init_dream_prompts() -> None: name="dream_react_head_prompt", ) - Prompt( - """ -你刚刚完成了一次对聊天记录的记忆整理工作。以下是整理过程的摘要: -整理过程: -{conversation_text} - -请将这次整理涉及的相关信息改写为一个富有诗意和想象力的"梦境",请你仅使用具体的记忆的内容,而不是整理过程编写。 -要求: -1. 使用第一人称视角 -2. 叙述直白,不要复杂修辞,口语化 -3. 保持诗意和想象力,自由编写 -4. 长度控制在200-800字 -5. 用中文输出 -请直接输出梦境内容,不要添加其他说明: -""", - name="dream_summary_prompt", - ) class DreamTool: @@ -113,11 +108,12 @@ class DreamToolRegistry: self.tools: Dict[str, DreamTool] = {} def register_tool(self, tool: DreamTool) -> None: - if tool.name in self.tools: - logger.debug(f"dream 工具 {tool.name} 已存在,跳过重复注册") - return + """ + 注册或更新 dream 工具。 + 注意:dream agent 每个 chat_id 会重新初始化工具,这里允许覆盖已有同名工具。 + """ self.tools[tool.name] = tool - logger.info(f"注册 dream 工具: {tool.name}") + logger.info(f"注册/更新 dream 工具: {tool.name}") def get_tool(self, name: str) -> Optional[DreamTool]: return self.tools.get(name) @@ -133,211 +129,38 @@ def get_dream_tool_registry() -> DreamToolRegistry: return _dream_tool_registry -def init_dream_tools() -> None: - """注册 dream agent 可用的 ChatHistory 相关工具""" - from src.plugin_system.apis import database_api - - async def list_chat_histories(chat_id: str, limit: int = 50) -> str: - """列出某个 chat_id 下的 chat_history 概览""" - try: - logger.info(f"[dream][tool] 调用 list_chat_histories(chat_id={chat_id}, limit={limit})") - query = ( - ChatHistory.select() - .where(ChatHistory.chat_id == chat_id) - .order_by(ChatHistory.start_time.asc()) - .limit(max(10, min(limit, 200))) - ) - records = list(query) - if not records: - msg = f"chat_id={chat_id} 在 ChatHistory 中暂时没有记录。" - logger.info(f"[dream][tool] list_chat_histories 无记录: {msg}") - return msg - - lines: List[str] = [] - for r in records: - lines.append( - f"ID={r.id} | 时间范围={r.start_time:.0f}-{r.end_time:.0f} | " - f"主题={r.theme or '无'} | 被检索次数={r.count} | 被遗忘次数={r.forget_times}" - ) - result = "\n".join(lines) - logger.info( - f"[dream][tool] list_chat_histories 返回 {len(records)} 条记录,预览: {result[:200].replace(chr(10), ' ')}" - ) - return result - except Exception as e: - logger.error(f"list_chat_histories 失败: {e}") - return f"list_chat_histories 执行失败: {e}" - - async def get_chat_history_detail(memory_id: int) -> str: - """获取单条 chat_history 的完整内容""" - try: - logger.info(f"[dream][tool] 调用 get_chat_history_detail(memory_id={memory_id})") - record = ChatHistory.get_or_none(ChatHistory.id == memory_id) - if not record: - msg = f"未找到 ID={memory_id} 的 ChatHistory 记录。" - logger.info(f"[dream][tool] get_chat_history_detail 未找到记录: {msg}") - return msg - - result = ( - f"ID={record.id}\n" - f"chat_id={record.chat_id}\n" - f"时间范围={record.start_time}-{record.end_time}\n" - f"主题={record.theme}\n" - f"关键词={record.keywords}\n" - f"参与者={record.participants}\n" - f"概括={record.summary}\n" - f"关键信息={record.key_point}\n" - f"原文:\n{record.original_text}" - ) - logger.info( - f"[dream][tool] get_chat_history_detail 成功,原文长度={len(record.original_text or '')},预览: {result[:200].replace(chr(10), ' ')}" - ) - return result - except Exception as e: - logger.error(f"get_chat_history_detail 失败: {e}") - return f"get_chat_history_detail 执行失败: {e}" - - async def delete_chat_history(memory_id: int) -> str: - """删除一条 chat_history 记录""" - try: - logger.info(f"[dream][tool] 调用 delete_chat_history(memory_id={memory_id})") - record = ChatHistory.get_or_none(ChatHistory.id == memory_id) - if not record: - msg = f"未找到 ID={memory_id} 的 ChatHistory 记录,无法删除。" - logger.info(f"[dream][tool] delete_chat_history 未找到记录: {msg}") - return msg - rows = ChatHistory.delete().where(ChatHistory.id == memory_id).execute() - msg = f"已删除 ID={memory_id} 的 ChatHistory 记录,受影响行数={rows}。" - logger.info(f"[dream][tool] delete_chat_history 完成: {msg}") - return msg - except Exception as e: - logger.error(f"delete_chat_history 失败: {e}") - return f"delete_chat_history 执行失败: {e}" - - async def update_chat_history( - memory_id: int, - theme: Optional[str] = None, - summary: Optional[str] = None, - keywords: Optional[str] = None, - key_point: Optional[str] = None, - ) -> str: - """按字段更新 chat_history(字符串字段要求 JSON 的字段须传入已序列化的字符串)""" - try: - logger.info( - f"[dream][tool] 调用 update_chat_history(memory_id={memory_id}, " - f"theme={bool(theme)}, summary={bool(summary)}, keywords={bool(keywords)}, key_point={bool(key_point)})" - ) - record = ChatHistory.get_or_none(ChatHistory.id == memory_id) - if not record: - msg = f"未找到 ID={memory_id} 的 ChatHistory 记录,无法更新。" - logger.info(f"[dream][tool] update_chat_history 未找到记录: {msg}") - return msg - - data: Dict[str, Any] = {} - if theme is not None: - data["theme"] = theme - if summary is not None: - data["summary"] = summary - if keywords is not None: - data["keywords"] = keywords - if key_point is not None: - data["key_point"] = key_point - - if not data: - return "未提供任何需要更新的字段。" - - await database_api.db_save(ChatHistory, data=data, key_field="id", key_value=memory_id) - msg = f"已更新 ChatHistory 记录 ID={memory_id},更新字段={list(data.keys())}。" - logger.info(f"[dream][tool] update_chat_history 完成: {msg}") - return msg - except Exception as e: - logger.error(f"update_chat_history 失败: {e}") - return f"update_chat_history 执行失败: {e}" - - async def merge_chat_histories(target_id: int, from_ids: List[int] | str) -> str: - """将多条 chat_history 合并到 target_id(合并文本与统计字段,删除 from_ids) - - from_ids 可以是整数列表,也可以是逗号/空格分隔的字符串,由本函数负责解析。 - """ - try: - logger.info(f"[dream][tool] 调用 merge_chat_histories(target_id={target_id}, from_ids={from_ids})") - # 兼容字符串形式的 from_ids,方便 LLM 传参 - if isinstance(from_ids, str): - raw_parts = [p.strip() for p in from_ids.replace(",", ",").replace(" ", ",").split(",") if p.strip()] - parsed_ids: List[int] = [] - for part in raw_parts: - try: - parsed_ids.append(int(part)) - except ValueError: - continue - from_ids = parsed_ids - - if target_id in from_ids: - from_ids = [i for i in from_ids if i != target_id] - if not from_ids: - msg = "from_ids 为空或只包含 target_id,本次不执行合并。" - logger.info(f"[dream][tool] merge_chat_histories 参数不足: {msg}") - return msg - - target = ChatHistory.get_or_none(ChatHistory.id == target_id) - if not target: - msg = f"未找到合并目标 ID={target_id} 的记录。" - logger.info(f"[dream][tool] merge_chat_histories 失败: {msg}") - return msg - - others = list(ChatHistory.select().where(ChatHistory.id.in_(from_ids))) - if not others: - msg = f"未找到需要合并的来源记录: {from_ids}" - logger.info(f"[dream][tool] merge_chat_histories 无来源记录: {msg}") - return msg - - # 合并原文与统计字段(简单拼接) - original_parts = [target.original_text or ""] - for r in others: - original_parts.append(r.original_text or "") - target.original_text = "\n\n--- 合并分隔线 ---\n\n".join(p for p in original_parts if p) - - target.count = (target.count or 0) + sum(r.count or 0 for r in others) - target.forget_times = (target.forget_times or 0) + sum(r.forget_times or 0 for r in others) - - target.save() - deleted = ChatHistory.delete().where(ChatHistory.id.in_(from_ids)).execute() - msg = ( - f"已将 ChatHistory {from_ids} 合并到 {target_id}," - f"合并后原文长度={len(target.original_text or '')},删除记录数={deleted}。" - ) - logger.info(f"[dream][tool] merge_chat_histories 完成: {msg}") - return msg - except Exception as e: - logger.error(f"merge_chat_histories 失败: {e}") - return f"merge_chat_histories 执行失败: {e}" - - async def finish_maintenance(reason: Optional[str] = None) -> str: - """结束本次 dream 维护任务。当你认为当前 chat_id 下的维护工作已经完成,没有更多需要整理的内容时,调用此工具来结束本次运行。""" - reason_text = f",原因:{reason}" if reason else "" - msg = f"DREAM_MAINTENANCE_COMPLETE{reason_text}" - logger.info(f"[dream][tool] 调用 finish_maintenance,结束本次维护{reason_text}") - return msg - - # 参数元组格式与 BaseTool 一致: (name, ToolParamType, desc, required, enum_values) +def init_dream_tools(chat_id: str) -> None: + """注册 dream agent 可用的 ChatHistory / Jargon 相关工具(限定在当前 chat_id 作用域内)""" from src.llm_models.payload_content.tool_option import ToolParamType + # 通过工厂函数生成绑定当前 chat_id 的工具实现 + search_chat_history = make_search_chat_history(chat_id) + get_chat_history_detail = make_get_chat_history_detail(chat_id) + delete_chat_history = make_delete_chat_history(chat_id) + create_chat_history = make_create_chat_history(chat_id) + update_chat_history = make_update_chat_history(chat_id) + finish_maintenance = make_finish_maintenance(chat_id) + + search_jargon = make_search_jargon(chat_id) + delete_jargon = make_delete_jargon(chat_id) + update_jargon = make_update_jargon(chat_id) + _dream_tool_registry.register_tool( DreamTool( - "list_chat_histories", - "列出某个 chat_id 下的 ChatHistory 概览,便于全局理解该聊天的历史记忆。", + "search_chat_history", + "根据关键词或参与人查询当前 chat_id 下的 ChatHistory 概览,便于快速定位相关记忆。", [ - ("chat_id", ToolParamType.STRING, "需要浏览的 chat_id。", True, None), - ("limit", ToolParamType.INTEGER, "最多返回多少条记录,默认 50,最大 200。", False, None), + ("keyword", ToolParamType.STRING, "关键词(可选,支持多个关键词,可用空格、逗号等分隔)。", False, None), + ("participant", ToolParamType.STRING, "参与人昵称(可选)。", False, None), ], - list_chat_histories, + search_chat_history, ) ) _dream_tool_registry.register_tool( DreamTool( "get_chat_history_detail", - "根据 memory_id 获取单条 ChatHistory 的详细内容,包含原文与所有字段。", + "根据 memory_id 获取单条 ChatHistory 的详细内容,包含主题、概括、关键词、关键信息等字段(不包含原文)。", [ ("memory_id", ToolParamType.INTEGER, "ChatHistory 主键 ID。", True, None), ], @@ -373,19 +196,17 @@ def init_dream_tools() -> None: _dream_tool_registry.register_tool( DreamTool( - "merge_chat_histories", - "将多条 ChatHistory 记录合并到一条中,可用于去重或把同一主题的多段记录整合在一起。", + "create_chat_history", + "根据整理后的理解创建一条新的 ChatHistory 概括记录(主题、概括、关键词、关键信息等)。", [ - ("target_id", ToolParamType.INTEGER, "合并的目标记录 ID。", True, None), - ( - "from_ids", - ToolParamType.STRING, - "需要被合并并删除的来源记录 ID 列表,可以是用逗号/空格分隔的字符串,如 '12, 13, 15'。", - True, - None, - ), + ("theme", ToolParamType.STRING, "新的主题标题(必填)。", True, None), + ("summary", ToolParamType.STRING, "新的概括内容(必填)。", True, None), + ("keywords", ToolParamType.STRING, "新的关键词 JSON 字符串,如 ['关键词1','关键词2'](必填)。", True, None), + ("key_point", ToolParamType.STRING, "新的关键信息 JSON 字符串,如 ['要点1','要点2'](必填)。", True, None), + ("start_time", ToolParamType.STRING, "起始时间戳(秒,Unix 时间,必填)。", True, None), + ("end_time", ToolParamType.STRING, "结束时间戳(秒,Unix 时间,必填)。", True, None), ], - merge_chat_histories, + create_chat_history, ) ) @@ -400,6 +221,19 @@ def init_dream_tools() -> None: ) ) + # ==================== Jargon 维护工具 ==================== + # 注册 Jargon 工具 + _dream_tool_registry.register_tool( + DreamTool( + "search_jargon", + "根据一个或多个关键词搜索当前 chat_id 相关的 Jargon 记录概览(只包含 is_jargon=True,含全局 Jargon),便于快速理解黑话库。", + [ + ("keyword", ToolParamType.STRING, "按一个或多个关键词搜索内容/含义/推断结果(必填)。", True, None), + ], + search_jargon, + ) + ) + async def run_dream_agent_once( chat_id: str, @@ -416,8 +250,8 @@ async def run_dream_agent_once( start_ts = time.time() logger.info(f"[dream] 开始对 chat_id={chat_id} 进行 dream 维护,最多迭代 {max_iterations} 轮") - # 初始化工具 - init_dream_tools() + # 初始化工具(作用域限定在当前 chat_id) + init_dream_tools(chat_id) tool_registry = get_dream_tool_registry() tool_defs = tool_registry.get_tool_definitions() @@ -436,6 +270,53 @@ async def run_dream_agent_once( conversation_messages: List[Message] = [] + # 如果提供了起始记忆 ID,则在对话正式开始前,先把这条记忆的详细信息放入上下文, + # 避免 LLM 还需要额外调用一次 get_chat_history_detail 才能看到起始记忆内容。 + if start_memory_id is not None: + try: + record = ChatHistory.get_or_none(ChatHistory.id == start_memory_id) + if record: + start_time_str = ( + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.start_time)) + if record.start_time + else "未知" + ) + end_time_str = ( + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.end_time)) + if record.end_time + else "未知" + ) + detail_text = ( + f"ID={record.id}\n" + f"chat_id={record.chat_id}\n" + f"时间范围={start_time_str} 至 {end_time_str}\n" + f"主题={record.theme or '无'}\n" + f"关键词={record.keywords or '无'}\n" + f"参与者={record.participants or '无'}\n" + f"概括={record.summary or '无'}\n" + f"关键信息={record.key_point or '无'}" + ) + + logger.debug( + f"[dream] 预加载起始记忆详情 memory_id={start_memory_id}," + f"预览: {detail_text[:200].replace(chr(10), ' ')}" + ) + + start_detail_builder = MessageBuilder() + start_detail_builder.set_role(RoleType.User) + start_detail_builder.add_text_content( + "【起始记忆详情】以下是本轮随机/指定的起始记忆的详细信息,供你在整理时优先参考:\n\n" + + detail_text + ) + conversation_messages.append(start_detail_builder.build()) + else: + logger.warning( + f"[dream] 提供的 start_memory_id={start_memory_id} 未找到对应 ChatHistory 记录," + "将不预加载起始记忆详情。" + ) + except Exception as e: + logger.error(f"[dream] 预加载起始记忆详情失败 start_memory_id={start_memory_id}: {e}") + # 注意:message_factory 必须是同步函数,返回消息列表(不能是 async/coroutine) def message_factory( _client, @@ -475,10 +356,10 @@ async def run_dream_agent_once( logger.error(f"[dream] 第 {iteration} 轮 LLM 调用失败: {response}") break - # 先输出「思考」内容,再输出工具调用信息 + # 先输出「思考」内容,再输出工具调用信息(思考文本较长,仅在 debug 下输出) thought_log = reasoning_content or (response[:300] if response else "") if thought_log: - logger.info(f"[dream] 第 {iteration} 轮思考内容: {thought_log}") + logger.debug(f"[dream] 第 {iteration} 轮思考内容: {thought_log}") logger.info( f"[dream] 第 {iteration} 轮响应,模型={model_name},工具调用数={len(tool_calls) if tool_calls else 0}" @@ -524,7 +405,7 @@ async def run_dream_agent_once( async def _run_single(t: DreamTool, p: Dict[str, Any], call_id: str, it: int): try: result = await t.execute(**p) - logger.info(f"[dream] 第 {it} 轮 工具 {t.name} 执行完成。") + logger.debug(f"[dream] 第 {it} 轮 工具 {t.name} 执行完成") return call_id, result except Exception as e: logger.error(f"[dream] 工具 {t.name} 执行失败: {e}") @@ -554,137 +435,32 @@ async def run_dream_agent_once( logger.info(f"[dream] 对 chat_id={chat_id} 的 dream 维护结束,共迭代 {iteration} 轮,耗时 {cost:.1f} 秒") # 生成梦境总结 - await _generate_dream_summary(chat_id, conversation_messages, iteration, cost) - - -async def _generate_dream_summary( - chat_id: str, - conversation_messages: List[Message], - total_iterations: int, - time_cost: float, -) -> None: - """生成梦境总结并输出到日志""" - try: - import json - - # 第一步:建立工具调用结果映射 (call_id -> result) - tool_results_map: Dict[str, str] = {} - for msg in conversation_messages: - if msg.role == RoleType.Tool and msg.tool_call_id: - content = "" - if msg.content: - if isinstance(msg.content, list) and msg.content: - content = msg.content[0].text if hasattr(msg.content[0], "text") else str(msg.content[0]) - else: - content = str(msg.content) - tool_results_map[msg.tool_call_id] = content - - # 第二步:详细记录所有工具调用操作和结果到日志 - tool_call_count = 0 - logger.info(f"[dream][工具调用详情] 开始记录 chat_id={chat_id} 的所有工具调用操作:") - - for msg in conversation_messages: - if msg.role == RoleType.Assistant and msg.tool_calls: - tool_call_count += 1 - # 提取思考内容 - thought_content = "" - if msg.content: - if isinstance(msg.content, list) and msg.content: - thought_content = msg.content[0].text if hasattr(msg.content[0], "text") else str(msg.content[0]) - else: - thought_content = str(msg.content) - - logger.info(f"[dream][工具调用详情] === 第 {tool_call_count} 组工具调用 ===") - if thought_content: - logger.info(f"[dream][工具调用详情] 思考内容:{thought_content[:500]}{'...' if len(thought_content) > 500 else ''}") - - # 记录每个工具调用的详细信息 - for idx, tool_call in enumerate(msg.tool_calls, 1): - tool_name = tool_call.func_name - tool_args = tool_call.args or {} - tool_call_id = tool_call.call_id - tool_result = tool_results_map.get(tool_call_id, "未找到执行结果") - - # 格式化参数 - try: - args_str = json.dumps(tool_args, ensure_ascii=False, indent=2) if tool_args else "无参数" - except Exception: - args_str = str(tool_args) - - logger.info(f"[dream][工具调用详情] --- 工具 {idx}: {tool_name} ---") - logger.info(f"[dream][工具调用详情] 调用参数:\n{args_str}") - logger.info(f"[dream][工具调用详情] 执行结果:\n{tool_result}") - logger.info(f"[dream][工具调用详情] {'-' * 60}") - - logger.info(f"[dream][工具调用详情] 共记录了 {tool_call_count} 组工具调用操作") - - # 第三步:构建对话历史摘要(用于生成梦境) - conversation_summary = [] - for msg in conversation_messages: - role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) - content = "" - if msg.content: - content = msg.content[0].text if isinstance(msg.content, list) and msg.content else str(msg.content) - - if role == "user" and "轮次信息" in content: - # 跳过轮次信息消息 - continue - - if role == "assistant": - # 只保留思考内容,简化工具调用信息 - if content: - # 截取前500字符,避免过长 - content_preview = content[:500] + ("..." if len(content) > 500 else "") - conversation_summary.append(f"[{role}] {content_preview}") - elif role == "tool": - # 工具结果,只保留关键信息 - if content: - # 截取前300字符 - content_preview = content[:300] + ("..." if len(content) > 300 else "") - conversation_summary.append(f"[工具执行] {content_preview}") - - conversation_text = "\n".join(conversation_summary[-20:]) # 只保留最后20条消息 - - # 使用 Prompt 管理器格式化梦境生成 prompt - dream_prompt = await global_prompt_manager.format_prompt( - "dream_summary_prompt", - chat_id=chat_id, - total_iterations=total_iterations, - time_cost=time_cost, - conversation_text=conversation_text, - ) - - # 调用 utils 模型生成梦境 - summary_model = _get_dream_summary_model() - dream_content, (reasoning, model_name, _) = await summary_model.generate_response_async( - dream_prompt, - max_tokens=512, - temperature=0.8, - ) - - if dream_content: - logger.info(f"[dream][梦境总结] 对 chat_id={chat_id} 的整理过程梦境:\n{dream_content}") - else: - logger.warning("[dream][梦境总结] 未能生成梦境总结") - - except Exception as e: - logger.error(f"[dream][梦境总结] 生成梦境总结失败: {e}", exc_info=True) + await generate_dream_summary(chat_id, conversation_messages, iteration, cost) def _pick_random_chat_id() -> Optional[str]: - """从 ChatHistory 中随机选择一个 chat_id,用于 dream agent 本次维护""" + """从 ChatHistory 中随机选择一个 chat_id,用于 dream agent 本次维护 + + 规则: + - 只在 chat_id 所属的 ChatHistory 记录数 >= 10 时才会参与随机选择; + - 记录数不足 10 的 chat_id 将被跳过,不会触发做梦 react。 + """ try: + # 统计每个 chat_id 的记录数,只保留记录数 >= 10 的 chat_id rows = ( - ChatHistory.select(ChatHistory.chat_id) - .distinct() + ChatHistory.select(ChatHistory.chat_id, fn.COUNT(ChatHistory.id).alias("cnt")) + .group_by(ChatHistory.chat_id) + .having(fn.COUNT(ChatHistory.id) >= 10) .order_by(ChatHistory.chat_id) .limit(200) ) - ids = [r.chat_id for r in rows] - if not ids: - logger.warning("[dream] ChatHistory 中暂无可用 chat_id,本轮 dream 任务跳过。") + eligible_ids = [r.chat_id for r in rows] + if not eligible_ids: + logger.warning("[dream] ChatHistory 中暂无满足条件(记录数 >= 10)的 chat_id,本轮 dream 任务跳过。") return None - return random.choice(ids) + chosen = random.choice(eligible_ids) + logger.info(f"[dream] 从 {len(eligible_ids)} 个满足条件的 chat_id 中随机选择:{chosen}") + return chosen except Exception as e: logger.error(f"[dream] 随机选择 chat_id 失败: {e}") return None @@ -731,16 +507,19 @@ async def run_dream_cycle_once() -> None: async def start_dream_scheduler( - first_delay_seconds: int = 60, + first_delay_seconds: Optional[int] = None, interval_seconds: Optional[int] = None, stop_event: Optional[asyncio.Event] = None, ) -> None: """ dream 调度器: - - 程序启动后先等待 first_delay_seconds(默认 60s) + - 程序启动后先等待 first_delay_seconds(如果为 None,则使用配置文件中的值,默认 60s) - 然后每隔 interval_seconds(如果为 None,则使用配置文件中的值,默认 30 分钟)运行一次 dream agent 周期 - 如果提供 stop_event,则在 stop_event 被 set() 后优雅退出循环 """ + if first_delay_seconds is None: + first_delay_seconds = global_config.dream.first_delay_seconds + if interval_seconds is None: interval_seconds = global_config.dream.interval_minutes * 60 diff --git a/src/dream/dream_generator.py b/src/dream/dream_generator.py new file mode 100644 index 00000000..174d1b69 --- /dev/null +++ b/src/dream/dream_generator.py @@ -0,0 +1,198 @@ +import random +from typing import List, Optional + +from src.common.logger import get_logger +from src.config.config import model_config +from src.chat.utils.prompt_builder import Prompt +from src.llm_models.payload_content.message import RoleType, Message +from src.llm_models.utils_model import LLMRequest + +logger = get_logger("dream_generator") + +# 初始化 utils 模型用于生成梦境总结 +_dream_summary_model: Optional[LLMRequest] = None + +# 梦境风格列表(21种) +DREAM_STYLES = [ + "保持诗意和想象力,自由编写", + "诗意朦胧,如薄雾笼罩的清晨", + "奇幻冒险,充满未知与探索", + "温暖怀旧,带着时光的痕迹", + "神秘悬疑,暗藏深意", + "浪漫唯美,如诗如画", + "科幻未来,科技与想象交织", + "自然清新,如山林间的微风", + "深沉哲思,引人深思", + "轻松幽默,充满趣味", + "悲伤忧郁,带着淡淡哀愁", + "激昂热烈,充满活力", + "宁静平和,如湖面般平静", + "荒诞离奇,打破常规", + "细腻温柔,如春风拂面", + "壮阔宏大,气势磅礴", + "简约纯粹,返璞归真", + "复杂多变,层次丰富", + "梦幻迷离,虚实难辨", + "现实写意,贴近生活", + "抽象概念,超越具象", +] + + +def get_random_dream_styles(count: int = 2) -> List[str]: + """从梦境风格列表中随机选择指定数量的风格""" + return random.sample(DREAM_STYLES, min(count, len(DREAM_STYLES))) + + +def get_dream_summary_model() -> LLMRequest: + """获取用于生成梦境总结的 utils 模型实例""" + global _dream_summary_model + if _dream_summary_model is None: + _dream_summary_model = LLMRequest( + model_set=model_config.model_task_config.utils, + request_type="dream.summary", + ) + return _dream_summary_model + + +def init_dream_summary_prompt() -> None: + """初始化梦境总结的提示词""" + Prompt( + """ +你刚刚完成了一次对聊天记录的记忆整理工作。以下是整理过程的摘要: +整理过程: +{conversation_text} + +请将这次整理涉及的相关信息改写为一个富有诗意和想象力的"梦境",请你仅使用具体的记忆的内容,而不是整理过程编写。 +要求: +1. 使用第一人称视角 +2. 叙述直白,不要复杂修辞,口语化 +3. 长度控制在200-800字 +4. 用中文输出 +梦境风格: +{dream_styles} +请直接输出梦境内容,不要添加其他说明: +""", + name="dream_summary_prompt", + ) + + +async def generate_dream_summary( + chat_id: str, + conversation_messages: List[Message], + total_iterations: int, + time_cost: float, +) -> None: + """生成梦境总结并输出到日志""" + try: + import json + from src.chat.utils.prompt_builder import global_prompt_manager + + # 第一步:建立工具调用结果映射 (call_id -> result) + tool_results_map: dict[str, str] = {} + for msg in conversation_messages: + if msg.role == RoleType.Tool and msg.tool_call_id: + content = "" + if msg.content: + if isinstance(msg.content, list) and msg.content: + content = msg.content[0].text if hasattr(msg.content[0], "text") else str(msg.content[0]) + else: + content = str(msg.content) + tool_results_map[msg.tool_call_id] = content + + # 第二步:详细记录所有工具调用操作和结果到日志 + tool_call_count = 0 + logger.info(f"[dream][工具调用详情] 开始记录 chat_id={chat_id} 的所有工具调用操作:") + + for msg in conversation_messages: + if msg.role == RoleType.Assistant and msg.tool_calls: + tool_call_count += 1 + # 提取思考内容 + thought_content = "" + if msg.content: + if isinstance(msg.content, list) and msg.content: + thought_content = msg.content[0].text if hasattr(msg.content[0], "text") else str(msg.content[0]) + else: + thought_content = str(msg.content) + + logger.info(f"[dream][工具调用详情] === 第 {tool_call_count} 组工具调用 ===") + if thought_content: + logger.info(f"[dream][工具调用详情] 思考内容:{thought_content[:500]}{'...' if len(thought_content) > 500 else ''}") + + # 记录每个工具调用的详细信息 + for idx, tool_call in enumerate(msg.tool_calls, 1): + tool_name = tool_call.func_name + tool_args = tool_call.args or {} + tool_call_id = tool_call.call_id + tool_result = tool_results_map.get(tool_call_id, "未找到执行结果") + + # 格式化参数 + try: + args_str = json.dumps(tool_args, ensure_ascii=False, indent=2) if tool_args else "无参数" + except Exception: + args_str = str(tool_args) + + logger.info(f"[dream][工具调用详情] --- 工具 {idx}: {tool_name} ---") + logger.info(f"[dream][工具调用详情] 调用参数:\n{args_str}") + logger.info(f"[dream][工具调用详情] 执行结果:\n{tool_result}") + logger.info(f"[dream][工具调用详情] {'-' * 60}") + + logger.info(f"[dream][工具调用详情] 共记录了 {tool_call_count} 组工具调用操作") + + # 第三步:构建对话历史摘要(用于生成梦境) + conversation_summary = [] + for msg in conversation_messages: + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + content = "" + if msg.content: + content = msg.content[0].text if isinstance(msg.content, list) and msg.content else str(msg.content) + + if role == "user" and "轮次信息" in content: + # 跳过轮次信息消息 + continue + + if role == "assistant": + # 只保留思考内容,简化工具调用信息 + if content: + # 截取前500字符,避免过长 + content_preview = content[:500] + ("..." if len(content) > 500 else "") + conversation_summary.append(f"[{role}] {content_preview}") + elif role == "tool": + # 工具结果,只保留关键信息 + if content: + # 截取前300字符 + content_preview = content[:300] + ("..." if len(content) > 300 else "") + conversation_summary.append(f"[工具执行] {content_preview}") + + conversation_text = "\n".join(conversation_summary[-20:]) # 只保留最后20条消息 + + # 随机选择2个梦境风格 + selected_styles = get_random_dream_styles(2) + dream_styles_text = "\n".join([f"{i+1}. {style}" for i, style in enumerate(selected_styles)]) + + # 使用 Prompt 管理器格式化梦境生成 prompt + dream_prompt = await global_prompt_manager.format_prompt( + "dream_summary_prompt", + chat_id=chat_id, + total_iterations=total_iterations, + time_cost=time_cost, + conversation_text=conversation_text, + dream_styles=dream_styles_text, + ) + + # 调用 utils 模型生成梦境 + summary_model = get_dream_summary_model() + dream_content, (reasoning, model_name, _) = await summary_model.generate_response_async( + dream_prompt, + max_tokens=512, + temperature=0.8, + ) + + if dream_content: + logger.info(f"[dream][梦境总结] 对 chat_id={chat_id} 的整理过程梦境:\n{dream_content}") + else: + logger.warning("[dream][梦境总结] 未能生成梦境总结") + + except Exception as e: + logger.error(f"[dream][梦境总结] 生成梦境总结失败: {e}", exc_info=True) + +init_dream_summary_prompt() \ No newline at end of file diff --git a/src/dream/tools/__init__.py b/src/dream/tools/__init__.py new file mode 100644 index 00000000..0841b09c --- /dev/null +++ b/src/dream/tools/__init__.py @@ -0,0 +1,9 @@ +""" +dream agent 工具实现模块。 + +每个工具的具体实现放在独立文件中,通过 make_xxx(chat_id) 工厂函数 +生成绑定到特定 chat_id 的协程函数,由 dream_agent.init_dream_tools 统一注册。 +""" + + + diff --git a/src/dream/tools/create_chat_history_tool.py b/src/dream/tools/create_chat_history_tool.py new file mode 100644 index 00000000..8fe96552 --- /dev/null +++ b/src/dream/tools/create_chat_history_tool.py @@ -0,0 +1,65 @@ +import time + +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory + +logger = get_logger("dream_agent") + + +def make_create_chat_history(chat_id: str): + async def create_chat_history( + theme: str, + summary: str, + keywords: str, + key_point: str, + start_time: float, + end_time: float, + ) -> str: + """创建一条新的 ChatHistory 概括记录(用于整理/合并后的新记忆)""" + try: + logger.info( + f"[dream][tool] 调用 create_chat_history(" + f"theme={bool(theme)}, summary={bool(summary)}, " + f"keywords={bool(keywords)}, key_point={bool(key_point)}, " + f"start_time={start_time}, end_time={end_time}) (chat_id={chat_id})" + ) + + now_ts = time.time() + + # 将传入的 start_time/end_time(如果有)解析为时间戳;否则回退为当前时间 + def _parse_ts(value, default): + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + start_ts = _parse_ts(start_time, now_ts) + end_ts = _parse_ts(end_time, now_ts) + + record = ChatHistory.create( + chat_id=chat_id, + theme=theme, + summary=summary, + keywords=keywords, + key_point=key_point, + # 对于由 dream 整理产生的新概括,时间范围优先使用工具提供的时间,否则使用当前时间占位 + start_time=start_ts, + end_time=end_ts, + ) + + msg = ( + f"已创建新的 ChatHistory 记录,ID={record.id}," + f"theme={record.theme or '无'},summary={'有' if record.summary else '无'}。" + ) + logger.info(f"[dream][tool] create_chat_history 完成: {msg}") + return msg + except Exception as e: + logger.error(f"create_chat_history 失败: {e}") + return f"create_chat_history 执行失败: {e}" + + return create_chat_history + + + diff --git a/src/dream/tools/delete_chat_history_tool.py b/src/dream/tools/delete_chat_history_tool.py new file mode 100644 index 00000000..2e600894 --- /dev/null +++ b/src/dream/tools/delete_chat_history_tool.py @@ -0,0 +1,28 @@ +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory + +logger = get_logger("dream_agent") + + +def make_delete_chat_history(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def delete_chat_history(memory_id: int) -> str: + """删除一条 chat_history 记录""" + try: + logger.info(f"[dream][tool] 调用 delete_chat_history(memory_id={memory_id})") + record = ChatHistory.get_or_none(ChatHistory.id == memory_id) + if not record: + msg = f"未找到 ID={memory_id} 的 ChatHistory 记录,无法删除。" + logger.info(f"[dream][tool] delete_chat_history 未找到记录: {msg}") + return msg + rows = ChatHistory.delete().where(ChatHistory.id == memory_id).execute() + msg = f"已删除 ID={memory_id} 的 ChatHistory 记录,受影响行数={rows}。" + logger.info(f"[dream][tool] delete_chat_history 完成: {msg}") + return msg + except Exception as e: + logger.error(f"delete_chat_history 失败: {e}") + return f"delete_chat_history 执行失败: {e}" + + return delete_chat_history + + + diff --git a/src/dream/tools/delete_jargon_tool.py b/src/dream/tools/delete_jargon_tool.py new file mode 100644 index 00000000..5becd0c8 --- /dev/null +++ b/src/dream/tools/delete_jargon_tool.py @@ -0,0 +1,28 @@ +from src.common.logger import get_logger +from src.common.database.database_model import Jargon + +logger = get_logger("dream_agent") + + +def make_delete_jargon(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def delete_jargon(jargon_id: int) -> str: + """删除一条 Jargon 记录""" + try: + logger.info(f"[dream][tool] 调用 delete_jargon(jargon_id={jargon_id})") + record = Jargon.get_or_none(Jargon.id == jargon_id) + if not record: + msg = f"未找到 ID={jargon_id} 的 Jargon 记录,无法删除。" + logger.info(f"[dream][tool] delete_jargon 未找到记录: {msg}") + return msg + rows = Jargon.delete().where(Jargon.id == jargon_id).execute() + msg = f"已删除 ID={jargon_id} 的 Jargon 记录(内容:{record.content}),受影响行数={rows}。" + logger.info(f"[dream][tool] delete_jargon 完成: {msg}") + return msg + except Exception as e: + logger.error(f"delete_jargon 失败: {e}") + return f"delete_jargon 执行失败: {e}" + + return delete_jargon + + + diff --git a/src/dream/tools/finish_maintenance_tool.py b/src/dream/tools/finish_maintenance_tool.py new file mode 100644 index 00000000..5858a625 --- /dev/null +++ b/src/dream/tools/finish_maintenance_tool.py @@ -0,0 +1,19 @@ +from typing import Optional + +from src.common.logger import get_logger + +logger = get_logger("dream_agent") + + +def make_finish_maintenance(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def finish_maintenance(reason: Optional[str] = None) -> str: + """结束本次 dream 维护任务。当你认为当前 chat_id 下的维护工作已经完成,没有更多需要整理的内容时,调用此工具来结束本次运行。""" + reason_text = f",原因:{reason}" if reason else "" + msg = f"DREAM_MAINTENANCE_COMPLETE{reason_text}" + logger.info(f"[dream][tool] 调用 finish_maintenance,结束本次维护{reason_text}") + return msg + + return finish_maintenance + + + diff --git a/src/dream/tools/get_chat_history_detail_tool.py b/src/dream/tools/get_chat_history_detail_tool.py new file mode 100644 index 00000000..8e6c6011 --- /dev/null +++ b/src/dream/tools/get_chat_history_detail_tool.py @@ -0,0 +1,54 @@ +import time +from typing import Optional + +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory + +logger = get_logger("dream_agent") + + +def make_get_chat_history_detail(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def get_chat_history_detail(memory_id: int) -> str: + """获取单条 chat_history 的完整内容""" + try: + logger.info(f"[dream][tool] 调用 get_chat_history_detail(memory_id={memory_id})") + record = ChatHistory.get_or_none(ChatHistory.id == memory_id) + if not record: + msg = f"未找到 ID={memory_id} 的 ChatHistory 记录。" + logger.info(f"[dream][tool] get_chat_history_detail 未找到记录: {msg}") + return msg + + # 将时间戳转换为可读时间格式 + start_time_str = ( + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.start_time)) + if record.start_time + else "未知" + ) + end_time_str = ( + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.end_time)) + if record.end_time + else "未知" + ) + + result = ( + f"ID={record.id}\n" + # f"chat_id={record.chat_id}\n" + f"时间范围={start_time_str} 至 {end_time_str}\n" + f"主题={record.theme or '无'}\n" + f"关键词={record.keywords or '无'}\n" + f"参与者={record.participants or '无'}\n" + f"概括={record.summary or '无'}\n" + f"关键信息={record.key_point or '无'}" + ) + logger.debug( + f"[dream][tool] get_chat_history_detail 成功,预览: {result[:200].replace(chr(10), ' ')}" + ) + return result + except Exception as e: + logger.error(f"get_chat_history_detail 失败: {e}") + return f"get_chat_history_detail 执行失败: {e}" + + return get_chat_history_detail + + + diff --git a/src/dream/tools/search_chat_history_tool.py b/src/dream/tools/search_chat_history_tool.py new file mode 100644 index 00000000..e8a2b4ac --- /dev/null +++ b/src/dream/tools/search_chat_history_tool.py @@ -0,0 +1,225 @@ +import json +from typing import List, Optional + +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory +from src.chat.utils.utils import parse_keywords_string + +logger = get_logger("dream_agent") + + +def make_search_chat_history(chat_id: str): + async def search_chat_history( + keyword: Optional[str] = None, + participant: Optional[str] = None, + ) -> str: + """根据关键词或参与人查询记忆,返回匹配的记忆id、记忆标题theme和关键词keywords(dream 维护专用版本)""" + try: + # 检查参数 + if not keyword and not participant: + return "未指定查询参数(需要提供keyword或participant之一)" + + logger.info( + f"[dream][tool] 调用 search_chat_history(keyword={keyword}, participant={participant}) " + f"(作用域 chat_id={chat_id})" + ) + + # 构建查询条件 + query = ChatHistory.select().where(ChatHistory.chat_id == chat_id) + + # 执行查询(按时间倒序,最近的在前) + records = list(query.order_by(ChatHistory.start_time.desc()).limit(50)) + + filtered_records: List[ChatHistory] = [] + + for record in records: + participant_matched = True # 如果没有participant条件,默认为True + keyword_matched = True # 如果没有keyword条件,默认为True + + # 检查参与人匹配 + if participant: + participant_matched = False + participants_list: List[str] = [] + if record.participants: + try: + participants_data = ( + json.loads(record.participants) + if isinstance(record.participants, str) + else record.participants + ) + if isinstance(participants_data, list): + participants_list = [str(p).lower() for p in participants_data] + except (json.JSONDecodeError, TypeError, ValueError): + pass + + participant_lower = participant.lower().strip() + if participant_lower and any(participant_lower in p for p in participants_list): + participant_matched = True + + # 检查关键词匹配 + if keyword: + keyword_matched = False + # 解析多个关键词(支持空格、逗号等分隔符) + keywords_list = parse_keywords_string(keyword) + if not keywords_list: + keywords_list = [keyword.strip()] if keyword.strip() else [] + + # 转换为小写以便匹配 + keywords_lower = [kw.lower() for kw in keywords_list if kw.strip()] + + if keywords_lower: + # 在theme、keywords、summary、original_text中搜索 + theme = (record.theme or "").lower() + summary = (record.summary or "").lower() + original_text = (record.original_text or "").lower() + + # 解析record中的keywords JSON + record_keywords_list: List[str] = [] + if record.keywords: + try: + keywords_data = ( + json.loads(record.keywords) + if isinstance(record.keywords, str) + else record.keywords + ) + if isinstance(keywords_data, list): + record_keywords_list = [str(k).lower() for k in keywords_data] + except (json.JSONDecodeError, TypeError, ValueError): + pass + + # 有容错的全匹配:如果关键词数量>2,允许n-1个关键词匹配;否则必须全部匹配 + matched_count = 0 + for kw in keywords_lower: + kw_matched = ( + kw in theme + or kw in summary + or kw in original_text + or any(kw in k for k in record_keywords_list) + ) + if kw_matched: + matched_count += 1 + + # 计算需要匹配的关键词数量 + total_keywords = len(keywords_lower) + if total_keywords > 2: + # 关键词数量>2,允许n-1个关键词匹配 + required_matches = total_keywords - 1 + else: + # 关键词数量<=2,必须全部匹配 + required_matches = total_keywords + + keyword_matched = matched_count >= required_matches + + # 两者都匹配(如果同时有participant和keyword,需要两者都匹配;如果只有一个条件,只需要该条件匹配) + matched = participant_matched and keyword_matched + + if matched: + filtered_records.append(record) + + if not filtered_records: + if keyword and participant: + keywords_str = "、".join(parse_keywords_string(keyword) if keyword else []) + return f"未找到包含关键词'{keywords_str}'且参与人包含'{participant}'的聊天记录" + elif keyword: + keywords_list = parse_keywords_string(keyword) + keywords_str = "、".join(keywords_list) + if len(keywords_list) > 2: + required_count = len(keywords_list) - 1 + return ( + f"未找到包含至少{required_count}个关键词(共{len(keywords_list)}个)'{keywords_str}'的聊天记录" + ) + else: + return f"未找到包含所有关键词'{keywords_str}'的聊天记录" + elif participant: + return f"未找到参与人包含'{participant}'的聊天记录" + else: + return "未找到相关聊天记录" + + # 如果匹配结果超过20条,不返回具体记录,只返回提示和所有相关关键词 + if len(filtered_records) > 20: + all_keywords_set = set() + for record in filtered_records: + if record.keywords: + try: + keywords_data = ( + json.loads(record.keywords) + if isinstance(record.keywords, str) + else record.keywords + ) + if isinstance(keywords_data, list): + for k in keywords_data: + k_str = str(k).strip() + if k_str: + all_keywords_set.add(k_str) + except (json.JSONDecodeError, TypeError, ValueError): + continue + + search_label = keyword or participant or "当前条件" + + if all_keywords_set: + keywords_str = "、".join(sorted(all_keywords_set)) + response_text = ( + f"包含“{search_label}”的结果过多,请尝试更多关键词精确查找\n\n" + f"有关\"{search_label}\"的关键词:\n" + f"{keywords_str}" + ) + else: + response_text = ( + f"包含“{search_label}”的结果过多,请尝试更多关键词精确查找\n\n" + f"有关\"{search_label}\"的关键词信息为空" + ) + + logger.info( + f"[dream][tool] search_chat_history 匹配结果超过20条,返回关键词汇总提示,总数={len(filtered_records)}" + ) + return response_text + + # 构建结果文本,返回id、theme和keywords(最多20条) + results: List[str] = [] + for record in filtered_records[:20]: + result_parts: List[str] = [] + + # 记忆ID + result_parts.append(f"记忆ID:{record.id}") + + # 主题 + if record.theme: + result_parts.append(f"主题:{record.theme}") + else: + result_parts.append("主题:(无)") + + # 关键词 + if record.keywords: + try: + keywords_data = ( + json.loads(record.keywords) + if isinstance(record.keywords, str) + else record.keywords + ) + if isinstance(keywords_data, list) and keywords_data: + keywords_str = "、".join([str(k) for k in keywords_data]) + result_parts.append(f"关键词:{keywords_str}") + else: + result_parts.append("关键词:(无)") + except (json.JSONDecodeError, TypeError, ValueError): + result_parts.append("关键词:(无)") + else: + result_parts.append("关键词:(无)") + + results.append("\n".join(result_parts)) + + if not results: + return "未找到相关聊天记录" + + response_text = "\n\n---\n\n".join(results) + + logger.info(f"[dream][tool] search_chat_history 返回 {len(filtered_records)} 条匹配记录") + return response_text + except Exception as e: + logger.error(f"search_chat_history 失败: {e}") + return f"search_chat_history 执行失败: {e}" + + return search_chat_history + + + diff --git a/src/dream/tools/update_chat_history_tool.py b/src/dream/tools/update_chat_history_tool.py new file mode 100644 index 00000000..767a41db --- /dev/null +++ b/src/dream/tools/update_chat_history_tool.py @@ -0,0 +1,54 @@ +from typing import Any, Dict, Optional + +from src.common.logger import get_logger +from src.common.database.database_model import ChatHistory +from src.plugin_system.apis import database_api + +logger = get_logger("dream_agent") + + +def make_update_chat_history(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def update_chat_history( + memory_id: int, + theme: Optional[str] = None, + summary: Optional[str] = None, + keywords: Optional[str] = None, + key_point: Optional[str] = None, + ) -> str: + """按字段更新 chat_history(字符串字段要求 JSON 的字段须传入已序列化的字符串)""" + try: + logger.info( + f"[dream][tool] 调用 update_chat_history(memory_id={memory_id}, " + f"theme={bool(theme)}, summary={bool(summary)}, keywords={bool(keywords)}, key_point={bool(key_point)})" + ) + record = ChatHistory.get_or_none(ChatHistory.id == memory_id) + if not record: + msg = f"未找到 ID={memory_id} 的 ChatHistory 记录,无法更新。" + logger.info(f"[dream][tool] update_chat_history 未找到记录: {msg}") + return msg + + data: Dict[str, Any] = {} + if theme is not None: + data["theme"] = theme + if summary is not None: + data["summary"] = summary + if keywords is not None: + data["keywords"] = keywords + if key_point is not None: + data["key_point"] = key_point + + if not data: + return "未提供任何需要更新的字段。" + + await database_api.db_save(ChatHistory, data=data, key_field="id", key_value=memory_id) + msg = f"已更新 ChatHistory 记录 ID={memory_id},更新字段={list(data.keys())}。" + logger.info(f"[dream][tool] update_chat_history 完成: {msg}") + return msg + except Exception as e: + logger.error(f"update_chat_history 失败: {e}") + return f"update_chat_history 执行失败: {e}" + + return update_chat_history + + + diff --git a/src/dream/tools/update_jargon_tool.py b/src/dream/tools/update_jargon_tool.py new file mode 100644 index 00000000..28e00e47 --- /dev/null +++ b/src/dream/tools/update_jargon_tool.py @@ -0,0 +1,54 @@ +from typing import Any, Dict, Optional + +from src.common.logger import get_logger +from src.common.database.database_model import Jargon +from src.plugin_system.apis import database_api + +logger = get_logger("dream_agent") + + +def make_update_jargon(chat_id: str): # chat_id 目前未直接使用,预留以备扩展 + async def update_jargon( + jargon_id: int, + meaning: Optional[str] = None, + is_global: Optional[bool] = None, + is_jargon: Optional[bool] = None, + content: Optional[str] = None, + ) -> str: + """按字段更新 Jargon 记录,可用于修正含义、调整全局性、标记是否为黑话等""" + try: + logger.info( + f"[dream][tool] 调用 update_jargon(jargon_id={jargon_id}, " + f"meaning={bool(meaning)}, is_global={is_global}, is_jargon={is_jargon}, content={bool(content)})" + ) + record = Jargon.get_or_none(Jargon.id == jargon_id) + if not record: + msg = f"未找到 ID={jargon_id} 的 Jargon 记录,无法更新。" + logger.info(f"[dream][tool] update_jargon 未找到记录: {msg}") + return msg + + data: Dict[str, Any] = {} + if meaning is not None: + data["meaning"] = meaning + if is_global is not None: + data["is_global"] = is_global + if is_jargon is not None: + data["is_jargon"] = is_jargon + if content is not None: + data["content"] = content + + if not data: + return "未提供任何需要更新的字段。" + + await database_api.db_save(Jargon, data=data, key_field="id", key_value=jargon_id) + msg = f"已更新 Jargon 记录 ID={jargon_id},更新字段={list(data.keys())}。" + logger.info(f"[dream][tool] update_jargon 完成: {msg}") + return msg + except Exception as e: + logger.error(f"update_jargon 失败: {e}") + return f"update_jargon 执行失败: {e}" + + return update_jargon + + + diff --git a/src/chat/utils/memory_forget_task.py b/src/hippo_memorizer/memory_forget_task.py similarity index 98% rename from src/chat/utils/memory_forget_task.py rename to src/hippo_memorizer/memory_forget_task.py index cd9144a1..ce2a9b2a 100644 --- a/src/chat/utils/memory_forget_task.py +++ b/src/hippo_memorizer/memory_forget_task.py @@ -28,10 +28,10 @@ class MemoryForgetTask(AsyncTask): # logger.info("[记忆遗忘] 开始遗忘检查...") # 执行4个阶段的遗忘检查 - await self._forget_stage_1(current_time) - await self._forget_stage_2(current_time) - await self._forget_stage_3(current_time) - await self._forget_stage_4(current_time) + # await self._forget_stage_1(current_time) + # await self._forget_stage_2(current_time) + # await self._forget_stage_3(current_time) + # await self._forget_stage_4(current_time) # logger.info("[记忆遗忘] 遗忘检查完成") except Exception as e: From a155194157d1029d059c923391cd0f83734cc672 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 4 Dec 2025 20:38:49 +0800 Subject: [PATCH 16/23] Create search_jargon_tool.py --- src/dream/tools/search_jargon_tool.py | 106 ++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/dream/tools/search_jargon_tool.py diff --git a/src/dream/tools/search_jargon_tool.py b/src/dream/tools/search_jargon_tool.py new file mode 100644 index 00000000..d20f170b --- /dev/null +++ b/src/dream/tools/search_jargon_tool.py @@ -0,0 +1,106 @@ +from typing import List + +from src.common.logger import get_logger +from src.common.database.database_model import Jargon +from src.config.config import global_config +from src.chat.utils.utils import parse_keywords_string +from src.jargon.jargon_utils import parse_chat_id_list, chat_id_list_contains + +logger = get_logger("dream_agent") + + +def make_search_jargon(chat_id: str): + async def search_jargon(keyword: str) -> str: + """根据一个或多个关键词搜索当前 chat_id 相关的 Jargon 记录概览(只包含 is_jargon=True,是否跨 chat_id 由 all_global 决定)""" + try: + if not keyword or not keyword.strip(): + return "未指定查询关键词(参数 keyword 为必填,且不能为空)" + + logger.info( + f"[dream][tool] 调用 search_jargon(keyword={keyword}) (作用域 chat_id={chat_id})" + ) + + # 基础条件:只查 is_jargon=True 的记录 + query = Jargon.select().where(Jargon.is_jargon) + + # 根据 all_global 配置决定 chat_id 作用域 + if global_config.jargon.all_global: + # 开启全局黑话:只看 is_global=True 的记录,不区分 chat_id + query = query.where(Jargon.is_global) + else: + # 关闭全局黑话:后续在 Python 层按 chat_id 列表过滤(包含 is_global=True) + pass + + # 先按使用次数排序取一批候选,做一个安全上限 + query = query.order_by(Jargon.count.desc()).limit(200) + candidates = list(query) + + if not candidates: + msg = "未找到符合条件的 Jargon 记录。" + logger.info(f"[dream][tool] search_jargon 无记录: {msg}") + return msg + + # 关键词为必填,因此此处必然执行关键词过滤(支持多个关键词,大小写不敏感) + keywords_list = parse_keywords_string(keyword) or [] + if not keywords_list and keyword.strip(): + keywords_list = [keyword.strip()] + keywords_lower = [kw.lower() for kw in keywords_list if kw.strip()] + + # 先按关键词过滤(仅对 content 字段进行匹配) + filtered_keyword: List[Jargon] = [] + for r in candidates: + content = (r.content or "").lower() + + # 只要命中任意一个关键词即可视为匹配(OR 逻辑) + any_matched = False + for kw in keywords_lower: + if not kw: + continue + if kw in content: + any_matched = True + break + + if any_matched: + filtered_keyword.append(r) + + if global_config.jargon.all_global: + # 全局黑话模式:不再做 chat_id 过滤,直接使用关键词过滤结果 + records = filtered_keyword + else: + # 非全局模式:仅保留全局黑话或 chat_id 列表中包含当前 chat_id 的记录 + records = [] + for r in filtered_keyword: + if r.is_global: + records.append(r) + continue + chat_id_list = parse_chat_id_list(r.chat_id) + if chat_id_list_contains(chat_id_list, chat_id): + records.append(r) + + if not records: + scope_note = ( + "(当前为全局黑话模式,仅统计 is_global=True 的条目)" + if global_config.jargon.all_global + else "(当前为按 chat_id 作用域模式,仅统计全局黑话或与当前 chat_id 相关的条目)" + ) + return f"未找到包含关键词'{keyword}'的 Jargon 记录{scope_note}" + + lines: List[str] = [] + for r in records: + is_jargon_str = "是" if r.is_jargon else "否" if r.is_jargon is False else "未判定" + is_global_str = "全局" if r.is_global else "非全局" + lines.append( + f"ID={r.id} | 内容={r.content} | 含义={r.meaning or '无'} | " + f"chat_id={r.chat_id} | {is_global_str} | 是否黑话={is_jargon_str}" + ) + + result = "\n".join(lines) + logger.info(f"[dream][tool] search_jargon 返回 {len(records)} 条记录") + return result + except Exception as e: + logger.error(f"search_jargon 失败: {e}") + return f"search_jargon 执行失败: {e}" + + return search_jargon + + From 95a1712c90104b43056ee103ecd64d8a9064628a Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 4 Dec 2025 23:39:09 +0800 Subject: [PATCH 17/23] =?UTF-8?q?feat=EF=BC=9A=E4=B8=BA=E7=A7=81=E8=81=8A?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0checker=E5=92=8C=E6=AC=A1=E7=BA=A7=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=EF=BC=8C=E6=9B=B4=E5=A4=9A=E5=8A=A8=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/brain_chat/PFC/action_planner.py | 491 ++++++++++++ src/chat/brain_chat/PFC/chat_observer.py | 379 ++++++++++ src/chat/brain_chat/PFC/chat_states.py | 290 ++++++++ src/chat/brain_chat/PFC/conversation.py | 701 ++++++++++++++++++ src/chat/brain_chat/PFC/conversation_info.py | 10 + src/chat/brain_chat/PFC/message_sender.py | 81 ++ src/chat/brain_chat/PFC/message_storage.py | 119 +++ src/chat/brain_chat/PFC/observation_info.py | 389 ++++++++++ src/chat/brain_chat/PFC/pfc.py | 345 +++++++++ .../brain_chat/PFC/pfc_KnowledgeFetcher.py | 85 +++ src/chat/brain_chat/PFC/pfc_manager.py | 115 +++ src/chat/brain_chat/PFC/pfc_types.py | 23 + src/chat/brain_chat/PFC/pfc_utils.py | 127 ++++ src/chat/brain_chat/PFC/reply_checker.py | 183 +++++ src/chat/brain_chat/PFC/reply_generator.py | 228 ++++++ src/chat/brain_chat/PFC/waiter.py | 79 ++ src/chat/brain_chat/brain_chat.py | 186 ++++- src/chat/brain_chat/brain_planner.py | 140 +++- src/chat/brain_chat/brain_reply_checker.py | 217 ++++++ 19 files changed, 4146 insertions(+), 42 deletions(-) create mode 100644 src/chat/brain_chat/PFC/action_planner.py create mode 100644 src/chat/brain_chat/PFC/chat_observer.py create mode 100644 src/chat/brain_chat/PFC/chat_states.py create mode 100644 src/chat/brain_chat/PFC/conversation.py create mode 100644 src/chat/brain_chat/PFC/conversation_info.py create mode 100644 src/chat/brain_chat/PFC/message_sender.py create mode 100644 src/chat/brain_chat/PFC/message_storage.py create mode 100644 src/chat/brain_chat/PFC/observation_info.py create mode 100644 src/chat/brain_chat/PFC/pfc.py create mode 100644 src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py create mode 100644 src/chat/brain_chat/PFC/pfc_manager.py create mode 100644 src/chat/brain_chat/PFC/pfc_types.py create mode 100644 src/chat/brain_chat/PFC/pfc_utils.py create mode 100644 src/chat/brain_chat/PFC/reply_checker.py create mode 100644 src/chat/brain_chat/PFC/reply_generator.py create mode 100644 src/chat/brain_chat/PFC/waiter.py create mode 100644 src/chat/brain_chat/brain_reply_checker.py diff --git a/src/chat/brain_chat/PFC/action_planner.py b/src/chat/brain_chat/PFC/action_planner.py new file mode 100644 index 00000000..4770c6ce --- /dev/null +++ b/src/chat/brain_chat/PFC/action_planner.py @@ -0,0 +1,491 @@ +import time +from typing import Tuple, Optional # 增加了 Optional +from src.common.logger_manager import get_logger +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from .chat_observer import ChatObserver +from .pfc_utils import get_items_from_json +from src.individuality.individuality import Individuality +from .observation_info import ObservationInfo +from .conversation_info import ConversationInfo +from src.plugins.utils.chat_message_builder import build_readable_messages + + +logger = get_logger("pfc_action_planner") + + +# --- 定义 Prompt 模板 --- + +# Prompt(1): 首次回复或非连续回复时的决策 Prompt +PROMPT_INITIAL_REPLY = """{persona_text}。现在你在参与一场QQ私聊,请根据以下【所有信息】审慎且灵活的决策下一步行动,可以回复,可以倾听,可以调取知识,甚至可以屏蔽对方: + +【当前对话目标】 +{goals_str} +{knowledge_info_str} + +【最近行动历史概要】 +{action_history_summary} +【上一次行动的详细情况和结果】 +{last_action_context} +【时间和超时提示】 +{time_since_last_bot_message_info}{timeout_context} +【最近的对话记录】(包括你已成功发送的消息 和 新收到的消息) +{chat_history_text} + +------ +可选行动类型以及解释: +fetch_knowledge: 需要调取知识或记忆,当需要专业知识或特定信息时选择,对方若提到你不太认识的人名或实体也可以尝试选择 +listening: 倾听对方发言,当你认为对方话才说到一半,发言明显未结束时选择 +direct_reply: 直接回复对方 +rethink_goal: 思考一个对话目标,当你觉得目前对话需要目标,或当前目标不再适用,或话题卡住时选择。注意私聊的环境是灵活的,有可能需要经常选择 +end_conversation: 结束对话,对方长时间没回复或者当你觉得对话告一段落时可以选择 +block_and_ignore: 更加极端的结束对话方式,直接结束对话并在一段时间内无视对方所有发言(屏蔽),当对话让你感到十分不适,或你遭到各类骚扰时选择 + +请以JSON格式输出你的决策: +{{ + "action": "选择的行动类型 (必须是上面列表中的一个)", + "reason": "选择该行动的详细原因 (必须有解释你是如何根据“上一次行动结果”、“对话记录”和自身设定人设做出合理判断的)" +}} + +注意:请严格按照JSON格式输出,不要包含任何其他内容。""" + +# Prompt(2): 上一次成功回复后,决定继续发言时的决策 Prompt +PROMPT_FOLLOW_UP = """{persona_text}。现在你在参与一场QQ私聊,刚刚你已经回复了对方,请根据以下【所有信息】审慎且灵活的决策下一步行动,可以继续发送新消息,可以等待,可以倾听,可以调取知识,甚至可以屏蔽对方: + +【当前对话目标】 +{goals_str} +{knowledge_info_str} + +【最近行动历史概要】 +{action_history_summary} +【上一次行动的详细情况和结果】 +{last_action_context} +【时间和超时提示】 +{time_since_last_bot_message_info}{timeout_context} +【最近的对话记录】(包括你已成功发送的消息 和 新收到的消息) +{chat_history_text} + +------ +可选行动类型以及解释: +fetch_knowledge: 需要调取知识,当需要专业知识或特定信息时选择,对方若提到你不太认识的人名或实体也可以尝试选择 +wait: 暂时不说话,留给对方交互空间,等待对方回复(尤其是在你刚发言后、或上次发言因重复、发言过多被拒时、或不确定做什么时,这是不错的选择) +listening: 倾听对方发言(虽然你刚发过言,但如果对方立刻回复且明显话没说完,可以选择这个) +send_new_message: 发送一条新消息继续对话,允许适当的追问、补充、深入话题,或开启相关新话题。**但是避免在因重复被拒后立即使用,也不要在对方没有回复的情况下过多的“消息轰炸”或重复发言** +rethink_goal: 思考一个对话目标,当你觉得目前对话需要目标,或当前目标不再适用,或话题卡住时选择。注意私聊的环境是灵活的,有可能需要经常选择 +end_conversation: 结束对话,对方长时间没回复或者当你觉得对话告一段落时可以选择 +block_and_ignore: 更加极端的结束对话方式,直接结束对话并在一段时间内无视对方所有发言(屏蔽),当对话让你感到十分不适,或你遭到各类骚扰时选择 + +请以JSON格式输出你的决策: +{{ + "action": "选择的行动类型 (必须是上面列表中的一个)", + "reason": "选择该行动的详细原因 (必须有解释你是如何根据“上一次行动结果”、“对话记录”和自身设定人设做出合理判断的。请说明你为什么选择继续发言而不是等待,以及打算发送什么类型的新消息连续发言,必须记录已经发言了几次)" +}} + +注意:请严格按照JSON格式输出,不要包含任何其他内容。""" + +# 新增:Prompt(3): 决定是否在结束对话前发送告别语 +PROMPT_END_DECISION = """{persona_text}。刚刚你决定结束一场 QQ 私聊。 + +【你们之前的聊天记录】 +{chat_history_text} + +你觉得你们的对话已经完整结束了吗?有时候,在对话自然结束后再说点什么可能会有点奇怪,但有时也可能需要一条简短的消息来圆满结束。 +如果觉得确实有必要再发一条简短、自然、符合你人设的告别消息(比如 "好,下次再聊~" 或 "嗯,先这样吧"),就输出 "yes"。 +如果觉得当前状态下直接结束对话更好,没有必要再发消息,就输出 "no"。 + +请以 JSON 格式输出你的选择: +{{ + "say_bye": "yes/no", + "reason": "选择 yes 或 no 的原因和内心想法 (简要说明)" +}} + +注意:请严格按照 JSON 格式输出,不要包含任何其他内容。""" + + +# ActionPlanner 类定义,顶格 +class ActionPlanner: + """行动规划器""" + + def __init__(self, stream_id: str, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_PFC_action_planner, + temperature=global_config.llm_PFC_action_planner["temp"], + max_tokens=1500, + request_type="action_planning", + ) + self.personality_info = Individuality.get_instance().get_prompt(x_person=2, level=3) + self.name = global_config.BOT_NICKNAME + self.private_name = private_name + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + # self.action_planner_info = ActionPlannerInfo() # 移除未使用的变量 + + # 修改 plan 方法签名,增加 last_successful_reply_action 参数 + async def plan( + self, + observation_info: ObservationInfo, + conversation_info: ConversationInfo, + last_successful_reply_action: Optional[str], + ) -> Tuple[str, str]: + """规划下一步行动 + + Args: + observation_info: 决策信息 + conversation_info: 对话信息 + last_successful_reply_action: 上一次成功的回复动作类型 ('direct_reply' 或 'send_new_message' 或 None) + + Returns: + Tuple[str, str]: (行动类型, 行动原因) + """ + # --- 获取 Bot 上次发言时间信息 --- + # (这部分逻辑不变) + time_since_last_bot_message_info = "" + try: + bot_id = str(global_config.BOT_QQ) + if hasattr(observation_info, "chat_history") and observation_info.chat_history: + for i in range(len(observation_info.chat_history) - 1, -1, -1): + msg = observation_info.chat_history[i] + if not isinstance(msg, dict): + continue + sender_info = msg.get("user_info", {}) + sender_id = str(sender_info.get("user_id")) if isinstance(sender_info, dict) else None + msg_time = msg.get("time") + if sender_id == bot_id and msg_time: + time_diff = time.time() - msg_time + if time_diff < 60.0: + time_since_last_bot_message_info = ( + f"提示:你上一条成功发送的消息是在 {time_diff:.1f} 秒前。\n" + ) + break + else: + logger.debug( + f"[私聊][{self.private_name}]Observation info chat history is empty or not available for bot time check." + ) + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo object might not have chat_history attribute yet for bot time check." + ) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]获取 Bot 上次发言时间时出错: {e}") + + # --- 获取超时提示信息 --- + # (这部分逻辑不变) + timeout_context = "" + try: + if hasattr(conversation_info, "goal_list") and conversation_info.goal_list: + last_goal_dict = conversation_info.goal_list[-1] + if isinstance(last_goal_dict, dict) and "goal" in last_goal_dict: + last_goal_text = last_goal_dict["goal"] + if isinstance(last_goal_text, str) and "分钟,思考接下来要做什么" in last_goal_text: + try: + timeout_minutes_text = last_goal_text.split(",")[0].replace("你等待了", "") + timeout_context = f"重要提示:对方已经长时间({timeout_minutes_text})没有回复你的消息了(这可能代表对方繁忙/不想回复/没注意到你的消息等情况,或在对方看来本次聊天已告一段落),请基于此情况规划下一步。\n" + except Exception: + timeout_context = "重要提示:对方已经长时间没有回复你的消息了(这可能代表对方繁忙/不想回复/没注意到你的消息等情况,或在对方看来本次聊天已告一段落),请基于此情况规划下一步。\n" + else: + logger.debug( + f"[私聊][{self.private_name}]Conversation info goal_list is empty or not available for timeout check." + ) + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ConversationInfo object might not have goal_list attribute yet for timeout check." + ) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]检查超时目标时出错: {e}") + + # --- 构建通用 Prompt 参数 --- + logger.debug( + f"[私聊][{self.private_name}]开始规划行动:当前目标: {getattr(conversation_info, 'goal_list', '不可用')}" + ) + + # 构建对话目标 (goals_str) + goals_str = "" + try: + if hasattr(conversation_info, "goal_list") and conversation_info.goal_list: + for goal_reason in conversation_info.goal_list: + if isinstance(goal_reason, dict): + goal = goal_reason.get("goal", "目标内容缺失") + reasoning = goal_reason.get("reasoning", "没有明确原因") + else: + goal = str(goal_reason) + reasoning = "没有明确原因" + + goal = str(goal) if goal is not None else "目标内容缺失" + reasoning = str(reasoning) if reasoning is not None else "没有明确原因" + goals_str += f"- 目标:{goal}\n 原因:{reasoning}\n" + + if not goals_str: + goals_str = "- 目前没有明确对话目标,请考虑设定一个。\n" + else: + goals_str = "- 目前没有明确对话目标,请考虑设定一个。\n" + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ConversationInfo object might not have goal_list attribute yet." + ) + goals_str = "- 获取对话目标时出错。\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}]构建对话目标字符串时出错: {e}") + goals_str = "- 构建对话目标时出错。\n" + + # --- 知识信息字符串构建开始 --- + knowledge_info_str = "【已获取的相关知识和记忆】\n" + try: + # 检查 conversation_info 是否有 knowledge_list 并且不为空 + if hasattr(conversation_info, "knowledge_list") and conversation_info.knowledge_list: + # 最多只显示最近的 5 条知识,防止 Prompt 过长 + recent_knowledge = conversation_info.knowledge_list[-5:] + for i, knowledge_item in enumerate(recent_knowledge): + if isinstance(knowledge_item, dict): + query = knowledge_item.get("query", "未知查询") + knowledge = knowledge_item.get("knowledge", "无知识内容") + source = knowledge_item.get("source", "未知来源") + # 只取知识内容的前 2000 个字,避免太长 + knowledge_snippet = knowledge[:2000] + "..." if len(knowledge) > 2000 else knowledge + knowledge_info_str += ( + f"{i + 1}. 关于 '{query}' 的知识 (来源: {source}):\n {knowledge_snippet}\n" + ) + else: + # 处理列表里不是字典的异常情况 + knowledge_info_str += f"{i + 1}. 发现一条格式不正确的知识记录。\n" + + if not recent_knowledge: # 如果 knowledge_list 存在但为空 + knowledge_info_str += "- 暂无相关知识和记忆。\n" + + else: + # 如果 conversation_info 没有 knowledge_list 属性,或者列表为空 + knowledge_info_str += "- 暂无相关知识记忆。\n" + except AttributeError: + logger.warning(f"[私聊][{self.private_name}]ConversationInfo 对象可能缺少 knowledge_list 属性。") + knowledge_info_str += "- 获取知识列表时出错。\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}]构建知识信息字符串时出错: {e}") + knowledge_info_str += "- 处理知识列表时出错。\n" + # --- 知识信息字符串构建结束 --- + + # 获取聊天历史记录 (chat_history_text) + try: + if hasattr(observation_info, "chat_history") and observation_info.chat_history: + chat_history_text = observation_info.chat_history_str + if not chat_history_text: + chat_history_text = "还没有聊天记录。\n" + else: + chat_history_text = "还没有聊天记录。\n" + + if hasattr(observation_info, "new_messages_count") and observation_info.new_messages_count > 0: + if hasattr(observation_info, "unprocessed_messages") and observation_info.unprocessed_messages: + new_messages_list = observation_info.unprocessed_messages + new_messages_str = await build_readable_messages( + new_messages_list, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + chat_history_text += ( + f"\n--- 以下是 {observation_info.new_messages_count} 条新消息 ---\n{new_messages_str}" + ) + else: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo has new_messages_count > 0 but unprocessed_messages is empty or missing." + ) + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo object might be missing expected attributes for chat history." + ) + chat_history_text = "获取聊天记录时出错。\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}]处理聊天记录时发生未知错误: {e}") + chat_history_text = "处理聊天记录时出错。\n" + + # 构建 Persona 文本 (persona_text) + persona_text = f"你的名字是{self.name},{self.personality_info}。" + + # 构建行动历史和上一次行动结果 (action_history_summary, last_action_context) + # (这部分逻辑不变) + action_history_summary = "你最近执行的行动历史:\n" + last_action_context = "关于你【上一次尝试】的行动:\n" + action_history_list = [] + try: + if hasattr(conversation_info, "done_action") and conversation_info.done_action: + action_history_list = conversation_info.done_action[-5:] + else: + logger.debug(f"[私聊][{self.private_name}]Conversation info done_action is empty or not available.") + except AttributeError: + logger.warning( + f"[私聊][{self.private_name}]ConversationInfo object might not have done_action attribute yet." + ) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]访问行动历史时出错: {e}") + + if not action_history_list: + action_history_summary += "- 还没有执行过行动。\n" + last_action_context += "- 这是你规划的第一个行动。\n" + else: + for i, action_data in enumerate(action_history_list): + action_type = "未知" + plan_reason = "未知" + status = "未知" + final_reason = "" + action_time = "" + + if isinstance(action_data, dict): + action_type = action_data.get("action", "未知") + plan_reason = action_data.get("plan_reason", "未知规划原因") + status = action_data.get("status", "未知") + final_reason = action_data.get("final_reason", "") + action_time = action_data.get("time", "") + elif isinstance(action_data, tuple): + # 假设旧格式兼容 + if len(action_data) > 0: + action_type = action_data[0] + if len(action_data) > 1: + plan_reason = action_data[1] # 可能是规划原因或最终原因 + if len(action_data) > 2: + status = action_data[2] + if status == "recall" and len(action_data) > 3: + final_reason = action_data[3] + elif status == "done" and action_type in ["direct_reply", "send_new_message"]: + plan_reason = "成功发送" # 简化显示 + + reason_text = f", 失败/取消原因: {final_reason}" if final_reason else "" + summary_line = f"- 时间:{action_time}, 尝试行动:'{action_type}', 状态:{status}{reason_text}" + action_history_summary += summary_line + "\n" + + if i == len(action_history_list) - 1: + last_action_context += f"- 上次【规划】的行动是: '{action_type}'\n" + last_action_context += f"- 当时规划的【原因】是: {plan_reason}\n" + if status == "done": + last_action_context += "- 该行动已【成功执行】。\n" + # 记录这次成功的行动类型,供下次决策 + # self.last_successful_action_type = action_type # 不在这里记录,由 conversation 控制 + elif status == "recall": + last_action_context += "- 但该行动最终【未能执行/被取消】。\n" + if final_reason: + last_action_context += f"- 【重要】失败/取消的具体原因是: “{final_reason}”\n" + else: + last_action_context += "- 【重要】失败/取消原因未明确记录。\n" + # self.last_successful_action_type = None # 行动失败,清除记录 + else: + last_action_context += f"- 该行动当前状态: {status}\n" + # self.last_successful_action_type = None # 非完成状态,清除记录 + + # --- 选择 Prompt --- + if last_successful_reply_action in ["direct_reply", "send_new_message"]: + prompt_template = PROMPT_FOLLOW_UP + logger.debug(f"[私聊][{self.private_name}]使用 PROMPT_FOLLOW_UP (追问决策)") + else: + prompt_template = PROMPT_INITIAL_REPLY + logger.debug(f"[私聊][{self.private_name}]使用 PROMPT_INITIAL_REPLY (首次/非连续回复决策)") + + # --- 格式化最终的 Prompt --- + prompt = prompt_template.format( + persona_text=persona_text, + goals_str=goals_str if goals_str.strip() else "- 目前没有明确对话目标,请考虑设定一个。", + action_history_summary=action_history_summary, + last_action_context=last_action_context, + time_since_last_bot_message_info=time_since_last_bot_message_info, + timeout_context=timeout_context, + chat_history_text=chat_history_text if chat_history_text.strip() else "还没有聊天记录。", + knowledge_info_str=knowledge_info_str, + ) + + logger.debug(f"[私聊][{self.private_name}]发送到LLM的最终提示词:\n------\n{prompt}\n------") + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]LLM (行动规划) 原始返回内容: {content}") + + # --- 初始行动规划解析 --- + success, initial_result = get_items_from_json( + content, + self.private_name, + "action", + "reason", + default_values={"action": "wait", "reason": "LLM返回格式错误或未提供原因,默认等待"}, + ) + + initial_action = initial_result.get("action", "wait") + initial_reason = initial_result.get("reason", "LLM未提供原因,默认等待") + + # 检查是否需要进行结束对话决策 --- + if initial_action == "end_conversation": + logger.info(f"[私聊][{self.private_name}]初步规划结束对话,进入告别决策...") + + # 使用新的 PROMPT_END_DECISION + end_decision_prompt = PROMPT_END_DECISION.format( + persona_text=persona_text, # 复用之前的 persona_text + chat_history_text=chat_history_text, # 复用之前的 chat_history_text + ) + + logger.debug( + f"[私聊][{self.private_name}]发送到LLM的结束决策提示词:\n------\n{end_decision_prompt}\n------" + ) + try: + end_content, _ = await self.llm.generate_response_async(end_decision_prompt) # 再次调用LLM + logger.debug(f"[私聊][{self.private_name}]LLM (结束决策) 原始返回内容: {end_content}") + + # 解析结束决策的JSON + end_success, end_result = get_items_from_json( + end_content, + self.private_name, + "say_bye", + "reason", + default_values={"say_bye": "no", "reason": "结束决策LLM返回格式错误,默认不告别"}, + required_types={"say_bye": str, "reason": str}, # 明确类型 + ) + + say_bye_decision = end_result.get("say_bye", "no").lower() # 转小写方便比较 + end_decision_reason = end_result.get("reason", "未提供原因") + + if end_success and say_bye_decision == "yes": + # 决定要告别,返回新的 'say_goodbye' 动作 + logger.info( + f"[私聊][{self.private_name}]结束决策: yes, 准备生成告别语. 原因: {end_decision_reason}" + ) + # 注意:这里的 reason 可以考虑拼接初始原因和结束决策原因,或者只用结束决策原因 + final_action = "say_goodbye" + final_reason = f"决定发送告别语。决策原因: {end_decision_reason} (原结束理由: {initial_reason})" + return final_action, final_reason + else: + # 决定不告别 (包括解析失败或明确说no) + logger.info( + f"[私聊][{self.private_name}]结束决策: no, 直接结束对话. 原因: {end_decision_reason}" + ) + # 返回原始的 'end_conversation' 动作 + final_action = "end_conversation" + final_reason = initial_reason # 保持原始的结束理由 + return final_action, final_reason + + except Exception as end_e: + logger.error(f"[私聊][{self.private_name}]调用结束决策LLM或处理结果时出错: {str(end_e)}") + # 出错时,默认执行原始的结束对话 + logger.warning(f"[私聊][{self.private_name}]结束决策出错,将按原计划执行 end_conversation") + return "end_conversation", initial_reason # 返回原始动作和原因 + + else: + action = initial_action + reason = initial_reason + + # 验证action类型 (保持不变) + valid_actions = [ + "direct_reply", + "send_new_message", + "fetch_knowledge", + "wait", + "listening", + "rethink_goal", + "end_conversation", # 仍然需要验证,因为可能从上面决策后返回 + "block_and_ignore", + "say_goodbye", # 也要验证这个新动作 + ] + if action not in valid_actions: + logger.warning(f"[私聊][{self.private_name}]LLM返回了未知的行动类型: '{action}',强制改为 wait") + reason = f"(原始行动'{action}'无效,已强制改为wait) {reason}" + action = "wait" + + logger.info(f"[私聊][{self.private_name}]规划的行动: {action}") + logger.info(f"[私聊][{self.private_name}]行动原因: {reason}") + return action, reason + + except Exception as e: + # 外层异常处理保持不变 + logger.error(f"[私聊][{self.private_name}]规划行动时调用 LLM 或处理结果出错: {str(e)}") + return "wait", f"行动规划处理中发生错误,暂时等待: {str(e)}" diff --git a/src/chat/brain_chat/PFC/chat_observer.py b/src/chat/brain_chat/PFC/chat_observer.py new file mode 100644 index 00000000..22cbf27d --- /dev/null +++ b/src/chat/brain_chat/PFC/chat_observer.py @@ -0,0 +1,379 @@ +import time +import asyncio +import traceback +from typing import Optional, Dict, Any, List +from src.common.logger import get_module_logger +from maim_message import UserInfo +from ...config.config import global_config +from .chat_states import NotificationManager, create_new_message_notification, create_cold_chat_notification +from .message_storage import MongoDBMessageStorage +from rich.traceback import install + +install(extra_lines=3) + +logger = get_module_logger("chat_observer") + + +class ChatObserver: + """聊天状态观察器""" + + # 类级别的实例管理 + _instances: Dict[str, "ChatObserver"] = {} + + @classmethod + def get_instance(cls, stream_id: str, private_name: str) -> "ChatObserver": + """获取或创建观察器实例 + + Args: + stream_id: 聊天流ID + private_name: 私聊名称 + + Returns: + ChatObserver: 观察器实例 + """ + if stream_id not in cls._instances: + cls._instances[stream_id] = cls(stream_id, private_name) + return cls._instances[stream_id] + + def __init__(self, stream_id: str, private_name: str): + """初始化观察器 + + Args: + stream_id: 聊天流ID + """ + self.last_check_time = None + self.last_bot_speak_time = None + self.last_user_speak_time = None + if stream_id in self._instances: + raise RuntimeError(f"ChatObserver for {stream_id} already exists. Use get_instance() instead.") + + self.stream_id = stream_id + self.private_name = private_name + self.message_storage = MongoDBMessageStorage() + + # self.last_user_speak_time: Optional[float] = None # 对方上次发言时间 + # self.last_bot_speak_time: Optional[float] = None # 机器人上次发言时间 + # self.last_check_time: float = time.time() # 上次查看聊天记录时间 + self.last_message_read: Optional[Dict[str, Any]] = None # 最后读取的消息ID + self.last_message_time: float = time.time() + + self.waiting_start_time: float = time.time() # 等待开始时间,初始化为当前时间 + + # 运行状态 + self._running: bool = False + self._task: Optional[asyncio.Task] = None + self._update_event = asyncio.Event() # 触发更新的事件 + self._update_complete = asyncio.Event() # 更新完成的事件 + + # 通知管理器 + self.notification_manager = NotificationManager() + + # 冷场检查配置 + self.cold_chat_threshold: float = 60.0 # 60秒无消息判定为冷场 + self.last_cold_chat_check: float = time.time() + self.is_cold_chat_state: bool = False + + self.update_event = asyncio.Event() + self.update_interval = 2 # 更新间隔(秒) + self.message_cache = [] + self.update_running = False + + async def check(self) -> bool: + """检查距离上一次观察之后是否有了新消息 + + Returns: + bool: 是否有新消息 + """ + logger.debug(f"[私聊][{self.private_name}]检查距离上一次观察之后是否有了新消息: {self.last_check_time}") + + new_message_exists = await self.message_storage.has_new_messages(self.stream_id, self.last_check_time) + + if new_message_exists: + logger.debug(f"[私聊][{self.private_name}]发现新消息") + self.last_check_time = time.time() + + return new_message_exists + + async def _add_message_to_history(self, message: Dict[str, Any]): + """添加消息到历史记录并发送通知 + + Args: + message: 消息数据 + """ + try: + # 发送新消息通知 + notification = create_new_message_notification( + sender="chat_observer", target="observation_info", message=message + ) + # print(self.notification_manager) + await self.notification_manager.send_notification(notification) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]添加消息到历史记录时出错: {e}") + print(traceback.format_exc()) + + # 检查并更新冷场状态 + await self._check_cold_chat() + + async def _check_cold_chat(self): + """检查是否处于冷场状态并发送通知""" + current_time = time.time() + + # 每10秒检查一次冷场状态 + if current_time - self.last_cold_chat_check < 10: + return + + self.last_cold_chat_check = current_time + + # 判断是否冷场 + is_cold = ( + True + if self.last_message_time is None + else (current_time - self.last_message_time) > self.cold_chat_threshold + ) + + # 如果冷场状态发生变化,发送通知 + if is_cold != self.is_cold_chat_state: + self.is_cold_chat_state = is_cold + notification = create_cold_chat_notification(sender="chat_observer", target="pfc", is_cold=is_cold) + await self.notification_manager.send_notification(notification) + + def new_message_after(self, time_point: float) -> bool: + """判断是否在指定时间点后有新消息 + + Args: + time_point: 时间戳 + + Returns: + bool: 是否有新消息 + """ + + if self.last_message_time is None: + logger.debug(f"[私聊][{self.private_name}]没有最后消息时间,返回 False") + return False + + has_new = self.last_message_time > time_point + logger.debug( + f"[私聊][{self.private_name}]判断是否在指定时间点后有新消息: {self.last_message_time} > {time_point} = {has_new}" + ) + return has_new + + def get_message_history( + self, + start_time: Optional[float] = None, + end_time: Optional[float] = None, + limit: Optional[int] = None, + user_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """获取消息历史 + + Args: + start_time: 开始时间戳 + end_time: 结束时间戳 + limit: 限制返回消息数量 + user_id: 指定用户ID + + Returns: + List[Dict[str, Any]]: 消息列表 + """ + filtered_messages = self.message_history + + if start_time is not None: + filtered_messages = [m for m in filtered_messages if m["time"] >= start_time] + + if end_time is not None: + filtered_messages = [m for m in filtered_messages if m["time"] <= end_time] + + if user_id is not None: + filtered_messages = [ + m for m in filtered_messages if UserInfo.from_dict(m.get("user_info", {})).user_id == user_id + ] + + if limit is not None: + filtered_messages = filtered_messages[-limit:] + + return filtered_messages + + async def _fetch_new_messages(self) -> List[Dict[str, Any]]: + """获取新消息 + + Returns: + List[Dict[str, Any]]: 新消息列表 + """ + new_messages = await self.message_storage.get_messages_after(self.stream_id, self.last_message_time) + + if new_messages: + self.last_message_read = new_messages[-1] + self.last_message_time = new_messages[-1]["time"] + + # print(f"获取数据库中找到的新消息: {new_messages}") + + return new_messages + + async def _fetch_new_messages_before(self, time_point: float) -> List[Dict[str, Any]]: + """获取指定时间点之前的消息 + + Args: + time_point: 时间戳 + + Returns: + List[Dict[str, Any]]: 最多5条消息 + """ + new_messages = await self.message_storage.get_messages_before(self.stream_id, time_point) + + if new_messages: + self.last_message_read = new_messages[-1]["message_id"] + + logger.debug(f"[私聊][{self.private_name}]获取指定时间点111之前的消息: {new_messages}") + + return new_messages + + """主要观察循环""" + + async def _update_loop(self): + """更新循环""" + # try: + # start_time = time.time() + # messages = await self._fetch_new_messages_before(start_time) + # for message in messages: + # await self._add_message_to_history(message) + # logger.debug(f"[私聊][{self.private_name}]缓冲消息: {messages}") + # except Exception as e: + # logger.error(f"[私聊][{self.private_name}]缓冲消息出错: {e}") + + while self._running: + try: + # 等待事件或超时(1秒) + try: + # print("等待事件") + await asyncio.wait_for(self._update_event.wait(), timeout=1) + + except asyncio.TimeoutError: + # print("超时") + pass # 超时后也执行一次检查 + + self._update_event.clear() # 重置触发事件 + self._update_complete.clear() # 重置完成事件 + + # 获取新消息 + new_messages = await self._fetch_new_messages() + + if new_messages: + # 处理新消息 + for message in new_messages: + await self._add_message_to_history(message) + + # 设置完成事件 + self._update_complete.set() + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]更新循环出错: {e}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + self._update_complete.set() # 即使出错也要设置完成事件 + + def trigger_update(self): + """触发一次立即更新""" + self._update_event.set() + + async def wait_for_update(self, timeout: float = 5.0) -> bool: + """等待更新完成 + + Args: + timeout: 超时时间(秒) + + Returns: + bool: 是否成功完成更新(False表示超时) + """ + try: + await asyncio.wait_for(self._update_complete.wait(), timeout=timeout) + return True + except asyncio.TimeoutError: + logger.warning(f"[私聊][{self.private_name}]等待更新完成超时({timeout}秒)") + return False + + def start(self): + """启动观察器""" + if self._running: + return + + self._running = True + self._task = asyncio.create_task(self._update_loop()) + logger.debug(f"[私聊][{self.private_name}]ChatObserver for {self.stream_id} started") + + def stop(self): + """停止观察器""" + self._running = False + self._update_event.set() # 设置事件以解除等待 + self._update_complete.set() # 设置完成事件以解除等待 + if self._task: + self._task.cancel() + logger.debug(f"[私聊][{self.private_name}]ChatObserver for {self.stream_id} stopped") + + async def process_chat_history(self, messages: list): + """处理聊天历史 + + Args: + messages: 消息列表 + """ + self.update_check_time() + + for msg in messages: + try: + user_info = UserInfo.from_dict(msg.get("user_info", {})) + if user_info.user_id == global_config.BOT_QQ: + self.update_bot_speak_time(msg["time"]) + else: + self.update_user_speak_time(msg["time"]) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]处理消息时间时出错: {e}") + continue + + def update_check_time(self): + """更新查看时间""" + self.last_check_time = time.time() + + def update_bot_speak_time(self, speak_time: Optional[float] = None): + """更新机器人说话时间""" + self.last_bot_speak_time = speak_time or time.time() + + def update_user_speak_time(self, speak_time: Optional[float] = None): + """更新用户说话时间""" + self.last_user_speak_time = speak_time or time.time() + + def get_time_info(self) -> str: + """获取时间信息文本""" + current_time = time.time() + time_info = "" + + if self.last_bot_speak_time: + bot_speak_ago = current_time - self.last_bot_speak_time + time_info += f"\n距离你上次发言已经过去了{int(bot_speak_ago)}秒" + + if self.last_user_speak_time: + user_speak_ago = current_time - self.last_user_speak_time + time_info += f"\n距离对方上次发言已经过去了{int(user_speak_ago)}秒" + + return time_info + + def get_cached_messages(self, limit: int = 50) -> List[Dict[str, Any]]: + """获取缓存的消息历史 + + Args: + limit: 获取的最大消息数量,默认50 + + Returns: + List[Dict[str, Any]]: 缓存的消息历史列表 + """ + return self.message_cache[-limit:] + + def get_last_message(self) -> Optional[Dict[str, Any]]: + """获取最后一条消息 + + Returns: + Optional[Dict[str, Any]]: 最后一条消息,如果没有则返回None + """ + if not self.message_cache: + return None + return self.message_cache[-1] + + def __str__(self): + return f"ChatObserver for {self.stream_id}" diff --git a/src/chat/brain_chat/PFC/chat_states.py b/src/chat/brain_chat/PFC/chat_states.py new file mode 100644 index 00000000..4b839b7b --- /dev/null +++ b/src/chat/brain_chat/PFC/chat_states.py @@ -0,0 +1,290 @@ +from enum import Enum, auto +from typing import Optional, Dict, Any, List, Set +from dataclasses import dataclass +from datetime import datetime +from abc import ABC, abstractmethod + + +class ChatState(Enum): + """聊天状态枚举""" + + NORMAL = auto() # 正常状态 + NEW_MESSAGE = auto() # 有新消息 + COLD_CHAT = auto() # 冷场状态 + ACTIVE_CHAT = auto() # 活跃状态 + BOT_SPEAKING = auto() # 机器人正在说话 + USER_SPEAKING = auto() # 用户正在说话 + SILENT = auto() # 沉默状态 + ERROR = auto() # 错误状态 + + +class NotificationType(Enum): + """通知类型枚举""" + + NEW_MESSAGE = auto() # 新消息通知 + COLD_CHAT = auto() # 冷场通知 + ACTIVE_CHAT = auto() # 活跃通知 + BOT_SPEAKING = auto() # 机器人说话通知 + USER_SPEAKING = auto() # 用户说话通知 + MESSAGE_DELETED = auto() # 消息删除通知 + USER_JOINED = auto() # 用户加入通知 + USER_LEFT = auto() # 用户离开通知 + ERROR = auto() # 错误通知 + + +@dataclass +class ChatStateInfo: + """聊天状态信息""" + + state: ChatState + last_message_time: Optional[float] = None + last_message_content: Optional[str] = None + last_speaker: Optional[str] = None + message_count: int = 0 + cold_duration: float = 0.0 # 冷场持续时间(秒) + active_duration: float = 0.0 # 活跃持续时间(秒) + + +@dataclass +class Notification: + """通知基类""" + + type: NotificationType + timestamp: float + sender: str # 发送者标识 + target: str # 接收者标识 + data: Dict[str, Any] + + def to_dict(self) -> Dict[str, Any]: + """转换为字典格式""" + return {"type": self.type.name, "timestamp": self.timestamp, "data": self.data} + + +@dataclass +class StateNotification(Notification): + """持续状态通知""" + + is_active: bool = True + + def to_dict(self) -> Dict[str, Any]: + base_dict = super().to_dict() + base_dict["is_active"] = self.is_active + return base_dict + + +class NotificationHandler(ABC): + """通知处理器接口""" + + @abstractmethod + async def handle_notification(self, notification: Notification): + """处理通知""" + pass + + +class NotificationManager: + """通知管理器""" + + def __init__(self): + # 按接收者和通知类型存储处理器 + self._handlers: Dict[str, Dict[NotificationType, List[NotificationHandler]]] = {} + self._active_states: Set[NotificationType] = set() + self._notification_history: List[Notification] = [] + + def register_handler(self, target: str, notification_type: NotificationType, handler: NotificationHandler): + """注册通知处理器 + + Args: + target: 接收者标识(例如:"pfc") + notification_type: 要处理的通知类型 + handler: 处理器实例 + """ + if target not in self._handlers: + self._handlers[target] = {} + if notification_type not in self._handlers[target]: + self._handlers[target][notification_type] = [] + # print(self._handlers[target][notification_type]) + self._handlers[target][notification_type].append(handler) + # print(self._handlers[target][notification_type]) + + def unregister_handler(self, target: str, notification_type: NotificationType, handler: NotificationHandler): + """注销通知处理器 + + Args: + target: 接收者标识 + notification_type: 通知类型 + handler: 要注销的处理器实例 + """ + if target in self._handlers and notification_type in self._handlers[target]: + handlers = self._handlers[target][notification_type] + if handler in handlers: + handlers.remove(handler) + # 如果该类型的处理器列表为空,删除该类型 + if not handlers: + del self._handlers[target][notification_type] + # 如果该目标没有任何处理器,删除该目标 + if not self._handlers[target]: + del self._handlers[target] + + async def send_notification(self, notification: Notification): + """发送通知""" + self._notification_history.append(notification) + + # 如果是状态通知,更新活跃状态 + if isinstance(notification, StateNotification): + if notification.is_active: + self._active_states.add(notification.type) + else: + self._active_states.discard(notification.type) + + # 调用目标接收者的处理器 + target = notification.target + if target in self._handlers: + handlers = self._handlers[target].get(notification.type, []) + # print(handlers) + for handler in handlers: + # print(f"调用处理器: {handler}") + await handler.handle_notification(notification) + + def get_active_states(self) -> Set[NotificationType]: + """获取当前活跃的状态""" + return self._active_states.copy() + + def is_state_active(self, state_type: NotificationType) -> bool: + """检查特定状态是否活跃""" + return state_type in self._active_states + + def get_notification_history( + self, sender: Optional[str] = None, target: Optional[str] = None, limit: Optional[int] = None + ) -> List[Notification]: + """获取通知历史 + + Args: + sender: 过滤特定发送者的通知 + target: 过滤特定接收者的通知 + limit: 限制返回数量 + """ + history = self._notification_history + + if sender: + history = [n for n in history if n.sender == sender] + if target: + history = [n for n in history if n.target == target] + + if limit is not None: + history = history[-limit:] + + return history + + def __str__(self): + str = "" + for target, handlers in self._handlers.items(): + for notification_type, handler_list in handlers.items(): + str += f"NotificationManager for {target} {notification_type} {handler_list}" + return str + + +# 一些常用的通知创建函数 +def create_new_message_notification(sender: str, target: str, message: Dict[str, Any]) -> Notification: + """创建新消息通知""" + return Notification( + type=NotificationType.NEW_MESSAGE, + timestamp=datetime.now().timestamp(), + sender=sender, + target=target, + data={ + "message_id": message.get("message_id"), + "processed_plain_text": message.get("processed_plain_text"), + "detailed_plain_text": message.get("detailed_plain_text"), + "user_info": message.get("user_info"), + "time": message.get("time"), + }, + ) + + +def create_cold_chat_notification(sender: str, target: str, is_cold: bool) -> StateNotification: + """创建冷场状态通知""" + return StateNotification( + type=NotificationType.COLD_CHAT, + timestamp=datetime.now().timestamp(), + sender=sender, + target=target, + data={"is_cold": is_cold}, + is_active=is_cold, + ) + + +def create_active_chat_notification(sender: str, target: str, is_active: bool) -> StateNotification: + """创建活跃状态通知""" + return StateNotification( + type=NotificationType.ACTIVE_CHAT, + timestamp=datetime.now().timestamp(), + sender=sender, + target=target, + data={"is_active": is_active}, + is_active=is_active, + ) + + +class ChatStateManager: + """聊天状态管理器""" + + def __init__(self): + self.current_state = ChatState.NORMAL + self.state_info = ChatStateInfo(state=ChatState.NORMAL) + self.state_history: list[ChatStateInfo] = [] + + def update_state(self, new_state: ChatState, **kwargs): + """更新聊天状态 + + Args: + new_state: 新的状态 + **kwargs: 其他状态信息 + """ + self.current_state = new_state + self.state_info.state = new_state + + # 更新其他状态信息 + for key, value in kwargs.items(): + if hasattr(self.state_info, key): + setattr(self.state_info, key, value) + + # 记录状态历史 + self.state_history.append(self.state_info) + + def get_current_state_info(self) -> ChatStateInfo: + """获取当前状态信息""" + return self.state_info + + def get_state_history(self) -> list[ChatStateInfo]: + """获取状态历史""" + return self.state_history + + def is_cold_chat(self, threshold: float = 60.0) -> bool: + """判断是否处于冷场状态 + + Args: + threshold: 冷场阈值(秒) + + Returns: + bool: 是否冷场 + """ + if not self.state_info.last_message_time: + return True + + current_time = datetime.now().timestamp() + return (current_time - self.state_info.last_message_time) > threshold + + def is_active_chat(self, threshold: float = 5.0) -> bool: + """判断是否处于活跃状态 + + Args: + threshold: 活跃阈值(秒) + + Returns: + bool: 是否活跃 + """ + if not self.state_info.last_message_time: + return False + + current_time = datetime.now().timestamp() + return (current_time - self.state_info.last_message_time) <= threshold diff --git a/src/chat/brain_chat/PFC/conversation.py b/src/chat/brain_chat/PFC/conversation.py new file mode 100644 index 00000000..0bc4cae8 --- /dev/null +++ b/src/chat/brain_chat/PFC/conversation.py @@ -0,0 +1,701 @@ +import time +import asyncio +import datetime + +# from .message_storage import MongoDBMessageStorage +from src.plugins.utils.chat_message_builder import build_readable_messages, get_raw_msg_before_timestamp_with_chat + +# from ...config.config import global_config +from typing import Dict, Any, Optional +from ..chat.message import Message +from .pfc_types import ConversationState +from .pfc import ChatObserver, GoalAnalyzer +from .message_sender import DirectMessageSender +from src.common.logger_manager import get_logger +from .action_planner import ActionPlanner +from .observation_info import ObservationInfo +from .conversation_info import ConversationInfo # 确保导入 ConversationInfo +from .reply_generator import ReplyGenerator +from ..chat.chat_stream import ChatStream +from maim_message import UserInfo +from src.plugins.chat.chat_stream import chat_manager +from .pfc_KnowledgeFetcher import KnowledgeFetcher +from .waiter import Waiter + +import traceback +from rich.traceback import install + +install(extra_lines=3) + +logger = get_logger("pfc") + + +class Conversation: + """对话类,负责管理单个对话的状态和行为""" + + def __init__(self, stream_id: str, private_name: str): + """初始化对话实例 + + Args: + stream_id: 聊天流ID + """ + self.stream_id = stream_id + self.private_name = private_name + self.state = ConversationState.INIT + self.should_continue = False + self.ignore_until_timestamp: Optional[float] = None + + # 回复相关 + self.generated_reply = "" + + async def _initialize(self): + """初始化实例,注册所有组件""" + + try: + self.action_planner = ActionPlanner(self.stream_id, self.private_name) + self.goal_analyzer = GoalAnalyzer(self.stream_id, self.private_name) + self.reply_generator = ReplyGenerator(self.stream_id, self.private_name) + self.knowledge_fetcher = KnowledgeFetcher(self.private_name) + self.waiter = Waiter(self.stream_id, self.private_name) + self.direct_sender = DirectMessageSender(self.private_name) + + # 获取聊天流信息 + self.chat_stream = chat_manager.get_stream(self.stream_id) + + self.stop_action_planner = False + except Exception as e: + logger.error(f"[私聊][{self.private_name}]初始化对话实例:注册运行组件失败: {e}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + raise + + try: + # 决策所需要的信息,包括自身自信和观察信息两部分 + # 注册观察器和观测信息 + self.chat_observer = ChatObserver.get_instance(self.stream_id, self.private_name) + self.chat_observer.start() + self.observation_info = ObservationInfo(self.private_name) + self.observation_info.bind_to_chat_observer(self.chat_observer) + # print(self.chat_observer.get_cached_messages(limit=) + + self.conversation_info = ConversationInfo() + except Exception as e: + logger.error(f"[私聊][{self.private_name}]初始化对话实例:注册信息组件失败: {e}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + raise + try: + logger.info(f"[私聊][{self.private_name}]为 {self.stream_id} 加载初始聊天记录...") + initial_messages = get_raw_msg_before_timestamp_with_chat( # + chat_id=self.stream_id, + timestamp=time.time(), + limit=30, # 加载最近30条作为初始上下文,可以调整 + ) + chat_talking_prompt = await build_readable_messages( + initial_messages, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + if initial_messages: + # 将加载的消息填充到 ObservationInfo 的 chat_history + self.observation_info.chat_history = initial_messages + self.observation_info.chat_history_str = chat_talking_prompt + "\n" + self.observation_info.chat_history_count = len(initial_messages) + + # 更新 ObservationInfo 中的时间戳等信息 + last_msg = initial_messages[-1] + self.observation_info.last_message_time = last_msg.get("time") + last_user_info = UserInfo.from_dict(last_msg.get("user_info", {})) + self.observation_info.last_message_sender = last_user_info.user_id + self.observation_info.last_message_content = last_msg.get("processed_plain_text", "") + + logger.info( + f"[私聊][{self.private_name}]成功加载 {len(initial_messages)} 条初始聊天记录。最后一条消息时间: {self.observation_info.last_message_time}" + ) + + # 让 ChatObserver 从加载的最后一条消息之后开始同步 + self.chat_observer.last_message_time = self.observation_info.last_message_time + self.chat_observer.last_message_read = last_msg # 更新 observer 的最后读取记录 + else: + logger.info(f"[私聊][{self.private_name}]没有找到初始聊天记录。") + + except Exception as load_err: + logger.error(f"[私聊][{self.private_name}]加载初始聊天记录时出错: {load_err}") + # 出错也要继续,只是没有历史记录而已 + # 组件准备完成,启动该论对话 + self.should_continue = True + asyncio.create_task(self.start()) + + async def start(self): + """开始对话流程""" + try: + logger.info(f"[私聊][{self.private_name}]对话系统启动中...") + asyncio.create_task(self._plan_and_action_loop()) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]启动对话系统失败: {e}") + raise + + async def _plan_and_action_loop(self): + """思考步,PFC核心循环模块""" + while self.should_continue: + # 忽略逻辑 + if self.ignore_until_timestamp and time.time() < self.ignore_until_timestamp: + await asyncio.sleep(30) + continue + elif self.ignore_until_timestamp and time.time() >= self.ignore_until_timestamp: + logger.info(f"[私聊][{self.private_name}]忽略时间已到 {self.stream_id},准备结束对话。") + self.ignore_until_timestamp = None + self.should_continue = False + continue + try: + # --- 在规划前记录当前新消息数量 --- + initial_new_message_count = 0 + if hasattr(self.observation_info, "new_messages_count"): + initial_new_message_count = self.observation_info.new_messages_count + 1 # 算上麦麦自己发的那一条 + else: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo missing 'new_messages_count' before planning." + ) + + # --- 调用 Action Planner --- + # 传递 self.conversation_info.last_successful_reply_action + action, reason = await self.action_planner.plan( + self.observation_info, self.conversation_info, self.conversation_info.last_successful_reply_action + ) + + # --- 规划后检查是否有 *更多* 新消息到达 --- + current_new_message_count = 0 + if hasattr(self.observation_info, "new_messages_count"): + current_new_message_count = self.observation_info.new_messages_count + else: + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo missing 'new_messages_count' after planning." + ) + + if current_new_message_count > initial_new_message_count + 2: + logger.info( + f"[私聊][{self.private_name}]规划期间发现新增消息 ({initial_new_message_count} -> {current_new_message_count}),跳过本次行动,重新规划" + ) + # 如果规划期间有新消息,也应该重置上次回复状态,因为现在要响应新消息了 + self.conversation_info.last_successful_reply_action = None + await asyncio.sleep(0.1) + continue + + # 包含 send_new_message + if initial_new_message_count > 0 and action in ["direct_reply", "send_new_message"]: + if hasattr(self.observation_info, "clear_unprocessed_messages"): + logger.debug( + f"[私聊][{self.private_name}]准备执行 {action},清理 {initial_new_message_count} 条规划时已知的新消息。" + ) + await self.observation_info.clear_unprocessed_messages() + if hasattr(self.observation_info, "new_messages_count"): + self.observation_info.new_messages_count = 0 + else: + logger.error( + f"[私聊][{self.private_name}]无法清理未处理消息: ObservationInfo 缺少 clear_unprocessed_messages 方法!" + ) + + await self._handle_action(action, reason, self.observation_info, self.conversation_info) + + # 检查是否需要结束对话 (逻辑不变) + goal_ended = False + if hasattr(self.conversation_info, "goal_list") and self.conversation_info.goal_list: + for goal_item in self.conversation_info.goal_list: + if isinstance(goal_item, dict): + current_goal = goal_item.get("goal") + + if current_goal == "结束对话": + goal_ended = True + break + + if goal_ended: + self.should_continue = False + logger.info(f"[私聊][{self.private_name}]检测到'结束对话'目标,停止循环。") + + except Exception as loop_err: + logger.error(f"[私聊][{self.private_name}]PFC主循环出错: {loop_err}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + await asyncio.sleep(1) + + if self.should_continue: + await asyncio.sleep(0.1) + + logger.info(f"[私聊][{self.private_name}]PFC 循环结束 for stream_id: {self.stream_id}") + + def _check_new_messages_after_planning(self): + """检查在规划后是否有新消息""" + # 检查 ObservationInfo 是否已初始化并且有 new_messages_count 属性 + if not hasattr(self, "observation_info") or not hasattr(self.observation_info, "new_messages_count"): + logger.warning( + f"[私聊][{self.private_name}]ObservationInfo 未初始化或缺少 'new_messages_count' 属性,无法检查新消息。" + ) + return False # 或者根据需要抛出错误 + + if self.observation_info.new_messages_count > 2: + logger.info( + f"[私聊][{self.private_name}]生成/执行动作期间收到 {self.observation_info.new_messages_count} 条新消息,取消当前动作并重新规划" + ) + # 如果有新消息,也应该重置上次回复状态 + if hasattr(self, "conversation_info"): # 确保 conversation_info 已初始化 + self.conversation_info.last_successful_reply_action = None + else: + logger.warning( + f"[私聊][{self.private_name}]ConversationInfo 未初始化,无法重置 last_successful_reply_action。" + ) + return True + return False + + def _convert_to_message(self, msg_dict: Dict[str, Any]) -> Message: + """将消息字典转换为Message对象""" + try: + # 尝试从 msg_dict 直接获取 chat_stream,如果失败则从全局 chat_manager 获取 + chat_info = msg_dict.get("chat_info") + if chat_info and isinstance(chat_info, dict): + chat_stream = ChatStream.from_dict(chat_info) + elif self.chat_stream: # 使用实例变量中的 chat_stream + chat_stream = self.chat_stream + else: # Fallback: 尝试从 manager 获取 (可能需要 stream_id) + chat_stream = chat_manager.get_stream(self.stream_id) + if not chat_stream: + raise ValueError(f"无法确定 ChatStream for stream_id {self.stream_id}") + + user_info = UserInfo.from_dict(msg_dict.get("user_info", {})) + + return Message( + message_id=msg_dict.get("message_id", f"gen_{time.time()}"), # 提供默认 ID + chat_stream=chat_stream, # 使用确定的 chat_stream + time=msg_dict.get("time", time.time()), # 提供默认时间 + user_info=user_info, + processed_plain_text=msg_dict.get("processed_plain_text", ""), + detailed_plain_text=msg_dict.get("detailed_plain_text", ""), + ) + except Exception as e: + logger.warning(f"[私聊][{self.private_name}]转换消息时出错: {e}") + # 可以选择返回 None 或重新抛出异常,这里选择重新抛出以指示问题 + raise ValueError(f"无法将字典转换为 Message 对象: {e}") from e + + async def _handle_action( + self, action: str, reason: str, observation_info: ObservationInfo, conversation_info: ConversationInfo + ): + """处理规划的行动""" + + logger.debug(f"[私聊][{self.private_name}]执行行动: {action}, 原因: {reason}") + + # 记录action历史 (逻辑不变) + current_action_record = { + "action": action, + "plan_reason": reason, + "status": "start", + "time": datetime.datetime.now().strftime("%H:%M:%S"), + "final_reason": None, + } + # 确保 done_action 列表存在 + if not hasattr(conversation_info, "done_action"): + conversation_info.done_action = [] + conversation_info.done_action.append(current_action_record) + action_index = len(conversation_info.done_action) - 1 + + action_successful = False # 用于标记动作是否成功完成 + + # --- 根据不同的 action 执行 --- + + # send_new_message 失败后执行 wait + if action == "send_new_message": + max_reply_attempts = 3 + reply_attempt_count = 0 + is_suitable = False + need_replan = False + check_reason = "未进行尝试" + final_reply_to_send = "" + + while reply_attempt_count < max_reply_attempts and not is_suitable: + reply_attempt_count += 1 + logger.info( + f"[私聊][{self.private_name}]尝试生成追问回复 (第 {reply_attempt_count}/{max_reply_attempts} 次)..." + ) + self.state = ConversationState.GENERATING + + # 1. 生成回复 (调用 generate 时传入 action_type) + self.generated_reply = await self.reply_generator.generate( + observation_info, conversation_info, action_type="send_new_message" + ) + logger.info( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次生成的追问回复: {self.generated_reply}" + ) + + # 2. 检查回复 (逻辑不变) + self.state = ConversationState.CHECKING + try: + current_goal_str = conversation_info.goal_list[0]["goal"] if conversation_info.goal_list else "" + is_suitable, check_reason, need_replan = await self.reply_generator.check_reply( + reply=self.generated_reply, + goal=current_goal_str, + chat_history=observation_info.chat_history, + chat_history_str=observation_info.chat_history_str, + retry_count=reply_attempt_count - 1, + ) + logger.info( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次追问检查结果: 合适={is_suitable}, 原因='{check_reason}', 需重新规划={need_replan}" + ) + if is_suitable: + final_reply_to_send = self.generated_reply + break + elif need_replan: + logger.warning( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次追问检查建议重新规划,停止尝试。原因: {check_reason}" + ) + break + except Exception as check_err: + logger.error( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次调用 ReplyChecker (追问) 时出错: {check_err}" + ) + check_reason = f"第 {reply_attempt_count} 次检查过程出错: {check_err}" + break + + # 循环结束,处理最终结果 + if is_suitable: + # 检查是否有新消息 + if self._check_new_messages_after_planning(): + logger.info(f"[私聊][{self.private_name}]生成追问回复期间收到新消息,取消发送,重新规划行动") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"有新消息,取消发送追问: {final_reply_to_send}"} + ) + return # 直接返回,重新规划 + + # 发送合适的回复 + self.generated_reply = final_reply_to_send + # --- 在这里调用 _send_reply --- + await self._send_reply() # <--- 调用恢复后的函数 + + # 更新状态: 标记上次成功是 send_new_message + self.conversation_info.last_successful_reply_action = "send_new_message" + action_successful = True # 标记动作成功 + + elif need_replan: + # 打回动作决策 + logger.warning( + f"[私聊][{self.private_name}]经过 {reply_attempt_count} 次尝试,追问回复决定打回动作决策。打回原因: {check_reason}" + ) + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"追问尝试{reply_attempt_count}次后打回: {check_reason}"} + ) + + else: + # 追问失败 + logger.warning( + f"[私聊][{self.private_name}]经过 {reply_attempt_count} 次尝试,未能生成合适的追问回复。最终原因: {check_reason}" + ) + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"追问尝试{reply_attempt_count}次后失败: {check_reason}"} + ) + # 重置状态: 追问失败,下次用初始 prompt + self.conversation_info.last_successful_reply_action = None + + # 执行 Wait 操作 + logger.info(f"[私聊][{self.private_name}]由于无法生成合适追问回复,执行 'wait' 操作...") + self.state = ConversationState.WAITING + await self.waiter.wait(self.conversation_info) + wait_action_record = { + "action": "wait", + "plan_reason": "因 send_new_message 多次尝试失败而执行的后备等待", + "status": "done", + "time": datetime.datetime.now().strftime("%H:%M:%S"), + "final_reason": None, + } + conversation_info.done_action.append(wait_action_record) + + elif action == "direct_reply": + max_reply_attempts = 3 + reply_attempt_count = 0 + is_suitable = False + need_replan = False + check_reason = "未进行尝试" + final_reply_to_send = "" + + while reply_attempt_count < max_reply_attempts and not is_suitable: + reply_attempt_count += 1 + logger.info( + f"[私聊][{self.private_name}]尝试生成首次回复 (第 {reply_attempt_count}/{max_reply_attempts} 次)..." + ) + self.state = ConversationState.GENERATING + + # 1. 生成回复 + self.generated_reply = await self.reply_generator.generate( + observation_info, conversation_info, action_type="direct_reply" + ) + logger.info( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次生成的首次回复: {self.generated_reply}" + ) + + # 2. 检查回复 + self.state = ConversationState.CHECKING + try: + current_goal_str = conversation_info.goal_list[0]["goal"] if conversation_info.goal_list else "" + is_suitable, check_reason, need_replan = await self.reply_generator.check_reply( + reply=self.generated_reply, + goal=current_goal_str, + chat_history=observation_info.chat_history, + chat_history_str=observation_info.chat_history_str, + retry_count=reply_attempt_count - 1, + ) + logger.info( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次首次回复检查结果: 合适={is_suitable}, 原因='{check_reason}', 需重新规划={need_replan}" + ) + if is_suitable: + final_reply_to_send = self.generated_reply + break + elif need_replan: + logger.warning( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次首次回复检查建议重新规划,停止尝试。原因: {check_reason}" + ) + break + except Exception as check_err: + logger.error( + f"[私聊][{self.private_name}]第 {reply_attempt_count} 次调用 ReplyChecker (首次回复) 时出错: {check_err}" + ) + check_reason = f"第 {reply_attempt_count} 次检查过程出错: {check_err}" + break + + # 循环结束,处理最终结果 + if is_suitable: + # 检查是否有新消息 + if self._check_new_messages_after_planning(): + logger.info(f"[私聊][{self.private_name}]生成首次回复期间收到新消息,取消发送,重新规划行动") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"有新消息,取消发送首次回复: {final_reply_to_send}"} + ) + return # 直接返回,重新规划 + + # 发送合适的回复 + self.generated_reply = final_reply_to_send + # --- 在这里调用 _send_reply --- + await self._send_reply() # <--- 调用恢复后的函数 + + # 更新状态: 标记上次成功是 direct_reply + self.conversation_info.last_successful_reply_action = "direct_reply" + action_successful = True # 标记动作成功 + + elif need_replan: + # 打回动作决策 + logger.warning( + f"[私聊][{self.private_name}]经过 {reply_attempt_count} 次尝试,首次回复决定打回动作决策。打回原因: {check_reason}" + ) + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"首次回复尝试{reply_attempt_count}次后打回: {check_reason}"} + ) + + else: + # 首次回复失败 + logger.warning( + f"[私聊][{self.private_name}]经过 {reply_attempt_count} 次尝试,未能生成合适的首次回复。最终原因: {check_reason}" + ) + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"首次回复尝试{reply_attempt_count}次后失败: {check_reason}"} + ) + # 重置状态: 首次回复失败,下次还是用初始 prompt + self.conversation_info.last_successful_reply_action = None + + # 执行 Wait 操作 (保持原有逻辑) + logger.info(f"[私聊][{self.private_name}]由于无法生成合适首次回复,执行 'wait' 操作...") + self.state = ConversationState.WAITING + await self.waiter.wait(self.conversation_info) + wait_action_record = { + "action": "wait", + "plan_reason": "因 direct_reply 多次尝试失败而执行的后备等待", + "status": "done", + "time": datetime.datetime.now().strftime("%H:%M:%S"), + "final_reason": None, + } + conversation_info.done_action.append(wait_action_record) + + elif action == "fetch_knowledge": + self.state = ConversationState.FETCHING + knowledge_query = reason + try: + # 检查 knowledge_fetcher 是否存在 + if not hasattr(self, "knowledge_fetcher"): + logger.error(f"[私聊][{self.private_name}]KnowledgeFetcher 未初始化,无法获取知识。") + raise AttributeError("KnowledgeFetcher not initialized") + + knowledge, source = await self.knowledge_fetcher.fetch(knowledge_query, observation_info.chat_history) + logger.info(f"[私聊][{self.private_name}]获取到知识: {knowledge[:100]}..., 来源: {source}") + if knowledge: + # 确保 knowledge_list 存在 + if not hasattr(conversation_info, "knowledge_list"): + conversation_info.knowledge_list = [] + conversation_info.knowledge_list.append( + {"query": knowledge_query, "knowledge": knowledge, "source": source} + ) + action_successful = True + except Exception as fetch_err: + logger.error(f"[私聊][{self.private_name}]获取知识时出错: {str(fetch_err)}") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"获取知识失败: {str(fetch_err)}"} + ) + self.conversation_info.last_successful_reply_action = None # 重置状态 + + elif action == "rethink_goal": + self.state = ConversationState.RETHINKING + try: + # 检查 goal_analyzer 是否存在 + if not hasattr(self, "goal_analyzer"): + logger.error(f"[私聊][{self.private_name}]GoalAnalyzer 未初始化,无法重新思考目标。") + raise AttributeError("GoalAnalyzer not initialized") + await self.goal_analyzer.analyze_goal(conversation_info, observation_info) + action_successful = True + except Exception as rethink_err: + logger.error(f"[私聊][{self.private_name}]重新思考目标时出错: {rethink_err}") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"重新思考目标失败: {rethink_err}"} + ) + self.conversation_info.last_successful_reply_action = None # 重置状态 + + elif action == "listening": + self.state = ConversationState.LISTENING + logger.info(f"[私聊][{self.private_name}]倾听对方发言...") + try: + # 检查 waiter 是否存在 + if not hasattr(self, "waiter"): + logger.error(f"[私聊][{self.private_name}]Waiter 未初始化,无法倾听。") + raise AttributeError("Waiter not initialized") + await self.waiter.wait_listening(conversation_info) + action_successful = True # Listening 完成就算成功 + except Exception as listen_err: + logger.error(f"[私聊][{self.private_name}]倾听时出错: {listen_err}") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"倾听失败: {listen_err}"} + ) + self.conversation_info.last_successful_reply_action = None # 重置状态 + + elif action == "say_goodbye": + self.state = ConversationState.GENERATING # 也可以定义一个新的状态,如 ENDING + logger.info(f"[私聊][{self.private_name}]执行行动: 生成并发送告别语...") + try: + # 1. 生成告别语 (使用 'say_goodbye' action_type) + self.generated_reply = await self.reply_generator.generate( + observation_info, conversation_info, action_type="say_goodbye" + ) + logger.info(f"[私聊][{self.private_name}]生成的告别语: {self.generated_reply}") + + # 2. 直接发送告别语 (不经过检查) + if self.generated_reply: # 确保生成了内容 + await self._send_reply() # 调用发送方法 + # 发送成功后,标记动作成功 + action_successful = True + logger.info(f"[私聊][{self.private_name}]告别语已发送。") + else: + logger.warning(f"[私聊][{self.private_name}]未能生成告别语内容,无法发送。") + action_successful = False # 标记动作失败 + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": "未能生成告别语内容"} + ) + + # 3. 无论是否发送成功,都准备结束对话 + self.should_continue = False + logger.info(f"[私聊][{self.private_name}]发送告别语流程结束,即将停止对话实例。") + + except Exception as goodbye_err: + logger.error(f"[私聊][{self.private_name}]生成或发送告别语时出错: {goodbye_err}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + # 即使出错,也结束对话 + self.should_continue = False + action_successful = False # 标记动作失败 + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"生成或发送告别语时出错: {goodbye_err}"} + ) + + elif action == "end_conversation": + # 这个分支现在只会在 action_planner 最终决定不告别时被调用 + self.should_continue = False + logger.info(f"[私聊][{self.private_name}]收到最终结束指令,停止对话...") + action_successful = True # 标记这个指令本身是成功的 + + elif action == "block_and_ignore": + logger.info(f"[私聊][{self.private_name}]不想再理你了...") + ignore_duration_seconds = 10 * 60 + self.ignore_until_timestamp = time.time() + ignore_duration_seconds + logger.info( + f"[私聊][{self.private_name}]将忽略此对话直到: {datetime.datetime.fromtimestamp(self.ignore_until_timestamp)}" + ) + self.state = ConversationState.IGNORED + action_successful = True # 标记动作成功 + + else: # 对应 'wait' 动作 + self.state = ConversationState.WAITING + logger.info(f"[私聊][{self.private_name}]等待更多信息...") + try: + # 检查 waiter 是否存在 + if not hasattr(self, "waiter"): + logger.error(f"[私聊][{self.private_name}]Waiter 未初始化,无法等待。") + raise AttributeError("Waiter not initialized") + _timeout_occurred = await self.waiter.wait(self.conversation_info) + action_successful = True # Wait 完成就算成功 + except Exception as wait_err: + logger.error(f"[私聊][{self.private_name}]等待时出错: {wait_err}") + conversation_info.done_action[action_index].update( + {"status": "recall", "final_reason": f"等待失败: {wait_err}"} + ) + self.conversation_info.last_successful_reply_action = None # 重置状态 + + # --- 更新 Action History 状态 --- + # 只有当动作本身成功时,才更新状态为 done + if action_successful: + conversation_info.done_action[action_index].update( + { + "status": "done", + "time": datetime.datetime.now().strftime("%H:%M:%S"), + } + ) + # 重置状态: 对于非回复类动作的成功,清除上次回复状态 + if action not in ["direct_reply", "send_new_message"]: + self.conversation_info.last_successful_reply_action = None + logger.debug(f"[私聊][{self.private_name}]动作 {action} 成功完成,重置 last_successful_reply_action") + # 如果动作是 recall 状态,在各自的处理逻辑中已经更新了 done_action + + async def _send_reply(self): + """发送回复""" + if not self.generated_reply: + logger.warning(f"[私聊][{self.private_name}]没有生成回复内容,无法发送。") + return + + try: + _current_time = time.time() + reply_content = self.generated_reply + + # 发送消息 (确保 direct_sender 和 chat_stream 有效) + if not hasattr(self, "direct_sender") or not self.direct_sender: + logger.error(f"[私聊][{self.private_name}]DirectMessageSender 未初始化,无法发送回复。") + return + if not self.chat_stream: + logger.error(f"[私聊][{self.private_name}]ChatStream 未初始化,无法发送回复。") + return + + await self.direct_sender.send_message(chat_stream=self.chat_stream, content=reply_content) + + # 发送成功后,手动触发 observer 更新可能导致重复处理自己发送的消息 + # 更好的做法是依赖 observer 的自动轮询或数据库触发器(如果支持) + # 暂时注释掉,观察是否影响 ObservationInfo 的更新 + # self.chat_observer.trigger_update() + # if not await self.chat_observer.wait_for_update(): + # logger.warning(f"[私聊][{self.private_name}]等待 ChatObserver 更新完成超时") + + self.state = ConversationState.ANALYZING # 更新状态 + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]发送消息或更新状态时失败: {str(e)}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") + self.state = ConversationState.ANALYZING + + async def _send_timeout_message(self): + """发送超时结束消息""" + try: + messages = self.chat_observer.get_cached_messages(limit=1) + if not messages: + return + + latest_message = self._convert_to_message(messages[0]) + await self.direct_sender.send_message( + chat_stream=self.chat_stream, content="TODO:超时消息", reply_to_message=latest_message + ) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]发送超时消息失败: {str(e)}") diff --git a/src/chat/brain_chat/PFC/conversation_info.py b/src/chat/brain_chat/PFC/conversation_info.py new file mode 100644 index 00000000..04524b69 --- /dev/null +++ b/src/chat/brain_chat/PFC/conversation_info.py @@ -0,0 +1,10 @@ +from typing import Optional + + +class ConversationInfo: + def __init__(self): + self.done_action = [] + self.goal_list = [] + self.knowledge_list = [] + self.memory_list = [] + self.last_successful_reply_action: Optional[str] = None diff --git a/src/chat/brain_chat/PFC/message_sender.py b/src/chat/brain_chat/PFC/message_sender.py new file mode 100644 index 00000000..12c2143e --- /dev/null +++ b/src/chat/brain_chat/PFC/message_sender.py @@ -0,0 +1,81 @@ +import time +from typing import Optional +from src.common.logger import get_module_logger +from ..chat.chat_stream import ChatStream +from ..chat.message import Message +from maim_message import UserInfo, Seg +from src.plugins.chat.message import MessageSending, MessageSet +from src.plugins.chat.message_sender import message_manager +from ..storage.storage import MessageStorage +from ...config.config import global_config +from rich.traceback import install + +install(extra_lines=3) + + +logger = get_module_logger("message_sender") + + +class DirectMessageSender: + """直接消息发送器""" + + def __init__(self, private_name: str): + self.private_name = private_name + self.storage = MessageStorage() + + async def send_message( + self, + chat_stream: ChatStream, + content: str, + reply_to_message: Optional[Message] = None, + ) -> None: + """发送消息到聊天流 + + Args: + chat_stream: 聊天流 + content: 消息内容 + reply_to_message: 要回复的消息(可选) + """ + try: + # 创建消息内容 + segments = Seg(type="seglist", data=[Seg(type="text", data=content)]) + + # 获取麦麦的信息 + bot_user_info = UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=chat_stream.platform, + ) + + # 用当前时间作为message_id,和之前那套sender一样 + message_id = f"dm{round(time.time(), 2)}" + + # 构建消息对象 + message = MessageSending( + message_id=message_id, + chat_stream=chat_stream, + bot_user_info=bot_user_info, + sender_info=reply_to_message.message_info.user_info if reply_to_message else None, + message_segment=segments, + reply=reply_to_message, + is_head=True, + is_emoji=False, + thinking_start_time=time.time(), + ) + + # 处理消息 + await message.process() + + # 不知道有什么用,先留下来了,和之前那套sender一样 + _message_json = message.to_dict() + + # 发送消息 + message_set = MessageSet(chat_stream, message_id) + message_set.add_message(message) + await message_manager.add_message(message_set) + await self.storage.store_message(message, chat_stream) + logger.info(f"[私聊][{self.private_name}]PFC消息已发送: {content}") + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]PFC消息发送失败: {str(e)}") + raise diff --git a/src/chat/brain_chat/PFC/message_storage.py b/src/chat/brain_chat/PFC/message_storage.py new file mode 100644 index 00000000..cd6a01e3 --- /dev/null +++ b/src/chat/brain_chat/PFC/message_storage.py @@ -0,0 +1,119 @@ +from abc import ABC, abstractmethod +from typing import List, Dict, Any +from src.common.database import db + + +class MessageStorage(ABC): + """消息存储接口""" + + @abstractmethod + async def get_messages_after(self, chat_id: str, message: Dict[str, Any]) -> List[Dict[str, Any]]: + """获取指定消息ID之后的所有消息 + + Args: + chat_id: 聊天ID + message: 消息 + + Returns: + List[Dict[str, Any]]: 消息列表 + """ + pass + + @abstractmethod + async def get_messages_before(self, chat_id: str, time_point: float, limit: int = 5) -> List[Dict[str, Any]]: + """获取指定时间点之前的消息 + + Args: + chat_id: 聊天ID + time_point: 时间戳 + limit: 最大消息数量 + + Returns: + List[Dict[str, Any]]: 消息列表 + """ + pass + + @abstractmethod + async def has_new_messages(self, chat_id: str, after_time: float) -> bool: + """检查是否有新消息 + + Args: + chat_id: 聊天ID + after_time: 时间戳 + + Returns: + bool: 是否有新消息 + """ + pass + + +class MongoDBMessageStorage(MessageStorage): + """MongoDB消息存储实现""" + + async def get_messages_after(self, chat_id: str, message_time: float) -> List[Dict[str, Any]]: + query = {"chat_id": chat_id, "time": {"$gt": message_time}} + # print(f"storage_check_message: {message_time}") + + return list(db.messages.find(query).sort("time", 1)) + + async def get_messages_before(self, chat_id: str, time_point: float, limit: int = 5) -> List[Dict[str, Any]]: + query = {"chat_id": chat_id, "time": {"$lt": time_point}} + + messages = list(db.messages.find(query).sort("time", -1).limit(limit)) + + # 将消息按时间正序排列 + messages.reverse() + return messages + + async def has_new_messages(self, chat_id: str, after_time: float) -> bool: + query = {"chat_id": chat_id, "time": {"$gt": after_time}} + + return db.messages.find_one(query) is not None + + +# # 创建一个内存消息存储实现,用于测试 +# class InMemoryMessageStorage(MessageStorage): +# """内存消息存储实现,主要用于测试""" + +# def __init__(self): +# self.messages: Dict[str, List[Dict[str, Any]]] = {} + +# async def get_messages_after(self, chat_id: str, message_id: Optional[str] = None) -> List[Dict[str, Any]]: +# if chat_id not in self.messages: +# return [] + +# messages = self.messages[chat_id] +# if not message_id: +# return messages + +# # 找到message_id的索引 +# try: +# index = next(i for i, m in enumerate(messages) if m["message_id"] == message_id) +# return messages[index + 1:] +# except StopIteration: +# return [] + +# async def get_messages_before(self, chat_id: str, time_point: float, limit: int = 5) -> List[Dict[str, Any]]: +# if chat_id not in self.messages: +# return [] + +# messages = [ +# m for m in self.messages[chat_id] +# if m["time"] < time_point +# ] + +# return messages[-limit:] + +# async def has_new_messages(self, chat_id: str, after_time: float) -> bool: +# if chat_id not in self.messages: +# return False + +# return any(m["time"] > after_time for m in self.messages[chat_id]) + +# # 测试辅助方法 +# def add_message(self, chat_id: str, message: Dict[str, Any]): +# """添加测试消息""" +# if chat_id not in self.messages: +# self.messages[chat_id] = [] +# self.messages[chat_id].append(message) +# self.messages[chat_id].sort(key=lambda m: m["time"]) diff --git a/src/chat/brain_chat/PFC/observation_info.py b/src/chat/brain_chat/PFC/observation_info.py new file mode 100644 index 00000000..c7572955 --- /dev/null +++ b/src/chat/brain_chat/PFC/observation_info.py @@ -0,0 +1,389 @@ +from typing import List, Optional, Dict, Any, Set +from maim_message import UserInfo +import time +from src.common.logger import get_module_logger +from .chat_observer import ChatObserver +from .chat_states import NotificationHandler, NotificationType, Notification +from src.plugins.utils.chat_message_builder import build_readable_messages +import traceback # 导入 traceback 用于调试 + +logger = get_module_logger("observation_info") + + +class ObservationInfoHandler(NotificationHandler): + """ObservationInfo的通知处理器""" + + def __init__(self, observation_info: "ObservationInfo", private_name: str): + """初始化处理器 + + Args: + observation_info: 要更新的ObservationInfo实例 + private_name: 私聊对象的名称,用于日志记录 + """ + self.observation_info = observation_info + # 将 private_name 存储在 handler 实例中 + self.private_name = private_name + + async def handle_notification(self, notification: Notification): # 添加类型提示 + # 获取通知类型和数据 + notification_type = notification.type + data = notification.data + + try: # 添加错误处理块 + if notification_type == NotificationType.NEW_MESSAGE: + # 处理新消息通知 + # logger.debug(f"[私聊][{self.private_name}]收到新消息通知data: {data}") # 可以在需要时取消注释 + message_id = data.get("message_id") + processed_plain_text = data.get("processed_plain_text") + detailed_plain_text = data.get("detailed_plain_text") + user_info_dict = data.get("user_info") # 先获取字典 + time_value = data.get("time") + + # 确保 user_info 是字典类型再创建 UserInfo 对象 + user_info = None + if isinstance(user_info_dict, dict): + try: + user_info = UserInfo.from_dict(user_info_dict) + except Exception as e: + logger.error( + f"[私聊][{self.private_name}]从字典创建 UserInfo 时出错: {e}, 字典内容: {user_info_dict}" + ) + # 可以选择在这里返回或记录错误,避免后续代码出错 + return + elif user_info_dict is not None: + logger.warning( + f"[私聊][{self.private_name}]收到的 user_info 不是预期的字典类型: {type(user_info_dict)}" + ) + # 根据需要处理非字典情况,这里暂时返回 + return + + message = { + "message_id": message_id, + "processed_plain_text": processed_plain_text, + "detailed_plain_text": detailed_plain_text, + "user_info": user_info_dict, # 存储原始字典或 UserInfo 对象,取决于你的 update_from_message 如何处理 + "time": time_value, + } + # 传递 UserInfo 对象(如果成功创建)或原始字典 + await self.observation_info.update_from_message(message, user_info) # 修改:传递 user_info 对象 + + elif notification_type == NotificationType.COLD_CHAT: + # 处理冷场通知 + is_cold = data.get("is_cold", False) + await self.observation_info.update_cold_chat_status(is_cold, time.time()) # 修改:改为 await 调用 + + elif notification_type == NotificationType.ACTIVE_CHAT: + # 处理活跃通知 (通常由 COLD_CHAT 的反向状态处理) + is_active = data.get("is_active", False) + self.observation_info.is_cold = not is_active + + elif notification_type == NotificationType.BOT_SPEAKING: + # 处理机器人说话通知 (按需实现) + self.observation_info.is_typing = False + self.observation_info.last_bot_speak_time = time.time() + + elif notification_type == NotificationType.USER_SPEAKING: + # 处理用户说话通知 + self.observation_info.is_typing = False + self.observation_info.last_user_speak_time = time.time() + + elif notification_type == NotificationType.MESSAGE_DELETED: + # 处理消息删除通知 + message_id = data.get("message_id") + # 从 unprocessed_messages 中移除被删除的消息 + original_count = len(self.observation_info.unprocessed_messages) + self.observation_info.unprocessed_messages = [ + msg for msg in self.observation_info.unprocessed_messages if msg.get("message_id") != message_id + ] + if len(self.observation_info.unprocessed_messages) < original_count: + logger.info(f"[私聊][{self.private_name}]移除了未处理的消息 (ID: {message_id})") + + elif notification_type == NotificationType.USER_JOINED: + # 处理用户加入通知 (如果适用私聊场景) + user_id = data.get("user_id") + if user_id: + self.observation_info.active_users.add(str(user_id)) # 确保是字符串 + + elif notification_type == NotificationType.USER_LEFT: + # 处理用户离开通知 (如果适用私聊场景) + user_id = data.get("user_id") + if user_id: + self.observation_info.active_users.discard(str(user_id)) # 确保是字符串 + + elif notification_type == NotificationType.ERROR: + # 处理错误通知 + error_msg = data.get("error", "未提供错误信息") + logger.error(f"[私聊][{self.private_name}]收到错误通知: {error_msg}") + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]处理通知时发生错误: {e}") + logger.error(traceback.format_exc()) # 打印详细堆栈信息 + + +# @dataclass <-- 这个,不需要了(递黄瓜) +class ObservationInfo: + """决策信息类,用于收集和管理来自chat_observer的通知信息 (手动实现 __init__)""" + + # 类型提示保留,可用于文档和静态分析 + private_name: str + chat_history: List[Dict[str, Any]] + chat_history_str: str + unprocessed_messages: List[Dict[str, Any]] + active_users: Set[str] + last_bot_speak_time: Optional[float] + last_user_speak_time: Optional[float] + last_message_time: Optional[float] + last_message_id: Optional[str] + last_message_content: str + last_message_sender: Optional[str] + bot_id: Optional[str] + chat_history_count: int + new_messages_count: int + cold_chat_start_time: Optional[float] + cold_chat_duration: float + is_typing: bool + is_cold_chat: bool + changed: bool + chat_observer: Optional[ChatObserver] + handler: Optional[ObservationInfoHandler] + + def __init__(self, private_name: str): + """ + 手动初始化 ObservationInfo 的所有实例变量。 + """ + + # 接收的参数 + self.private_name: str = private_name + + # data_list + self.chat_history: List[Dict[str, Any]] = [] + self.chat_history_str: str = "" + self.unprocessed_messages: List[Dict[str, Any]] = [] + self.active_users: Set[str] = set() + + # data + self.last_bot_speak_time: Optional[float] = None + self.last_user_speak_time: Optional[float] = None + self.last_message_time: Optional[float] = None + self.last_message_id: Optional[str] = None + self.last_message_content: str = "" + self.last_message_sender: Optional[str] = None + self.bot_id: Optional[str] = None + self.chat_history_count: int = 0 + self.new_messages_count: int = 0 + self.cold_chat_start_time: Optional[float] = None + self.cold_chat_duration: float = 0.0 + + # state + self.is_typing: bool = False + self.is_cold_chat: bool = False + self.changed: bool = False + + # 关联对象 + self.chat_observer: Optional[ChatObserver] = None + + self.handler: ObservationInfoHandler = ObservationInfoHandler(self, self.private_name) + + def bind_to_chat_observer(self, chat_observer: ChatObserver): + """绑定到指定的chat_observer + + Args: + chat_observer: 要绑定的 ChatObserver 实例 + """ + if self.chat_observer: + logger.warning(f"[私聊][{self.private_name}]尝试重复绑定 ChatObserver") + return + + self.chat_observer = chat_observer + try: + if not self.handler: # 确保 handler 已经被创建 + logger.error(f"[私聊][{self.private_name}] 尝试绑定时 handler 未初始化!") + self.chat_observer = None # 重置,防止后续错误 + return + + # 注册关心的通知类型 + self.chat_observer.notification_manager.register_handler( + target="observation_info", notification_type=NotificationType.NEW_MESSAGE, handler=self.handler + ) + self.chat_observer.notification_manager.register_handler( + target="observation_info", notification_type=NotificationType.COLD_CHAT, handler=self.handler + ) + # 可以根据需要注册更多通知类型 + # self.chat_observer.notification_manager.register_handler( + # target="observation_info", notification_type=NotificationType.MESSAGE_DELETED, handler=self.handler + # ) + logger.info(f"[私聊][{self.private_name}]成功绑定到 ChatObserver") + except Exception as e: + logger.error(f"[私聊][{self.private_name}]绑定到 ChatObserver 时出错: {e}") + self.chat_observer = None # 绑定失败,重置 + + def unbind_from_chat_observer(self): + """解除与chat_observer的绑定""" + if ( + self.chat_observer and hasattr(self.chat_observer, "notification_manager") and self.handler + ): # 增加 handler 检查 + try: + self.chat_observer.notification_manager.unregister_handler( + target="observation_info", notification_type=NotificationType.NEW_MESSAGE, handler=self.handler + ) + self.chat_observer.notification_manager.unregister_handler( + target="observation_info", notification_type=NotificationType.COLD_CHAT, handler=self.handler + ) + # 如果注册了其他类型,也要在这里注销 + # self.chat_observer.notification_manager.unregister_handler( + # target="observation_info", notification_type=NotificationType.MESSAGE_DELETED, handler=self.handler + # ) + logger.info(f"[私聊][{self.private_name}]成功从 ChatObserver 解绑") + except Exception as e: + logger.error(f"[私聊][{self.private_name}]从 ChatObserver 解绑时出错: {e}") + finally: # 确保 chat_observer 被重置 + self.chat_observer = None + else: + logger.warning(f"[私聊][{self.private_name}]尝试解绑时 ChatObserver 不存在、无效或 handler 未设置") + + # 修改:update_from_message 接收 UserInfo 对象 + async def update_from_message(self, message: Dict[str, Any], user_info: Optional[UserInfo]): + """从消息更新信息 + + Args: + message: 消息数据字典 + user_info: 解析后的 UserInfo 对象 (可能为 None) + """ + message_time = message.get("time") + message_id = message.get("message_id") + processed_text = message.get("processed_plain_text", "") + + # 只有在新消息到达时才更新 last_message 相关信息 + if message_time and message_time > (self.last_message_time or 0): + self.last_message_time = message_time + self.last_message_id = message_id + self.last_message_content = processed_text + # 重置冷场计时器 + self.is_cold_chat = False + self.cold_chat_start_time = None + self.cold_chat_duration = 0.0 + + if user_info: + sender_id = str(user_info.user_id) # 确保是字符串 + self.last_message_sender = sender_id + # 更新发言时间 + if sender_id == self.bot_id: + self.last_bot_speak_time = message_time + else: + self.last_user_speak_time = message_time + self.active_users.add(sender_id) # 用户发言则认为其活跃 + else: + logger.warning( + f"[私聊][{self.private_name}]处理消息更新时缺少有效的 UserInfo 对象, message_id: {message_id}" + ) + self.last_message_sender = None # 发送者未知 + + # 将原始消息字典添加到未处理列表 + self.unprocessed_messages.append(message) + self.new_messages_count = len(self.unprocessed_messages) # 直接用列表长度 + + # logger.debug(f"[私聊][{self.private_name}]消息更新: last_time={self.last_message_time}, new_count={self.new_messages_count}") + self.update_changed() # 标记状态已改变 + else: + # 如果消息时间戳不是最新的,可能不需要处理,或者记录一个警告 + pass + # logger.warning(f"[私聊][{self.private_name}]收到过时或无效时间戳的消息: ID={message_id}, time={message_time}") + + def update_changed(self): + """标记状态已改变,并重置标记""" + # logger.debug(f"[私聊][{self.private_name}]状态标记为已改变 (changed=True)") + self.changed = True + + async def update_cold_chat_status(self, is_cold: bool, current_time: float): + """更新冷场状态 + + Args: + is_cold: 是否处于冷场状态 + current_time: 当前时间戳 + """ + if is_cold != self.is_cold_chat: # 仅在状态变化时更新 + self.is_cold_chat = is_cold + if is_cold: + # 进入冷场状态 + self.cold_chat_start_time = ( + self.last_message_time or current_time + ) # 从最后消息时间开始算,或从当前时间开始 + logger.info(f"[私聊][{self.private_name}]进入冷场状态,开始时间: {self.cold_chat_start_time}") + else: + # 结束冷场状态 + if self.cold_chat_start_time: + self.cold_chat_duration = current_time - self.cold_chat_start_time + logger.info(f"[私聊][{self.private_name}]结束冷场状态,持续时间: {self.cold_chat_duration:.2f} 秒") + self.cold_chat_start_time = None # 重置开始时间 + self.update_changed() # 状态变化,标记改变 + + # 即使状态没变,如果是冷场状态,也更新持续时间 + if self.is_cold_chat and self.cold_chat_start_time: + self.cold_chat_duration = current_time - self.cold_chat_start_time + + def get_active_duration(self) -> float: + """获取当前活跃时长 (距离最后一条消息的时间) + + Returns: + float: 最后一条消息到现在的时长(秒) + """ + if not self.last_message_time: + return 0.0 + return time.time() - self.last_message_time + + def get_user_response_time(self) -> Optional[float]: + """获取用户最后响应时间 (距离用户最后发言的时间) + + Returns: + Optional[float]: 用户最后发言到现在的时长(秒),如果没有用户发言则返回None + """ + if not self.last_user_speak_time: + return None + return time.time() - self.last_user_speak_time + + def get_bot_response_time(self) -> Optional[float]: + """获取机器人最后响应时间 (距离机器人最后发言的时间) + + Returns: + Optional[float]: 机器人最后发言到现在的时长(秒),如果没有机器人发言则返回None + """ + if not self.last_bot_speak_time: + return None + return time.time() - self.last_bot_speak_time + + async def clear_unprocessed_messages(self): + """将未处理消息移入历史记录,并更新相关状态""" + if not self.unprocessed_messages: + return # 没有未处理消息,直接返回 + + # logger.debug(f"[私聊][{self.private_name}]处理 {len(self.unprocessed_messages)} 条未处理消息...") + # 将未处理消息添加到历史记录中 (确保历史记录有长度限制,避免无限增长) + max_history_len = 100 # 示例:最多保留100条历史记录 + self.chat_history.extend(self.unprocessed_messages) + if len(self.chat_history) > max_history_len: + self.chat_history = self.chat_history[-max_history_len:] + + # 更新历史记录字符串 (只使用最近一部分生成,例如20条) + history_slice_for_str = self.chat_history[-20:] + try: + self.chat_history_str = await build_readable_messages( + history_slice_for_str, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, # read_mark 可能需要根据逻辑调整 + ) + except Exception as e: + logger.error(f"[私聊][{self.private_name}]构建聊天记录字符串时出错: {e}") + self.chat_history_str = "[构建聊天记录出错]" # 提供错误提示 + + # 清空未处理消息列表和计数 + # cleared_count = len(self.unprocessed_messages) + self.unprocessed_messages.clear() + self.new_messages_count = 0 + # self.has_unread_messages = False # 这个状态可以通过 new_messages_count 判断 + + self.chat_history_count = len(self.chat_history) # 更新历史记录总数 + # logger.debug(f"[私聊][{self.private_name}]已处理 {cleared_count} 条消息,当前历史记录 {self.chat_history_count} 条。") + + self.update_changed() # 状态改变 diff --git a/src/chat/brain_chat/PFC/pfc.py b/src/chat/brain_chat/PFC/pfc.py new file mode 100644 index 00000000..b17ee21d --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc.py @@ -0,0 +1,345 @@ +from typing import List, Tuple, TYPE_CHECKING +from src.common.logger import get_module_logger +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from .chat_observer import ChatObserver +from .pfc_utils import get_items_from_json +from src.individuality.individuality import Individuality +from .conversation_info import ConversationInfo +from .observation_info import ObservationInfo +from src.plugins.utils.chat_message_builder import build_readable_messages +from rich.traceback import install + +install(extra_lines=3) + +if TYPE_CHECKING: + pass + +logger = get_module_logger("pfc") + + +def _calculate_similarity(goal1: str, goal2: str) -> float: + """简单计算两个目标之间的相似度 + + 这里使用一个简单的实现,实际可以使用更复杂的文本相似度算法 + + Args: + goal1: 第一个目标 + goal2: 第二个目标 + + Returns: + float: 相似度得分 (0-1) + """ + # 简单实现:检查重叠字数比例 + words1 = set(goal1) + words2 = set(goal2) + overlap = len(words1.intersection(words2)) + total = len(words1.union(words2)) + return overlap / total if total > 0 else 0 + + +class GoalAnalyzer: + """对话目标分析器""" + + def __init__(self, stream_id: str, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_normal, temperature=0.7, max_tokens=1000, request_type="conversation_goal" + ) + + self.personality_info = Individuality.get_instance().get_prompt(x_person=2, level=3) + self.name = global_config.BOT_NICKNAME + self.nick_name = global_config.BOT_ALIAS_NAMES + self.private_name = private_name + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + + # 多目标存储结构 + self.goals = [] # 存储多个目标 + self.max_goals = 3 # 同时保持的最大目标数量 + self.current_goal_and_reason = None + + async def analyze_goal(self, conversation_info: ConversationInfo, observation_info: ObservationInfo): + """分析对话历史并设定目标 + + Args: + conversation_info: 对话信息 + observation_info: 观察信息 + + Returns: + Tuple[str, str, str]: (目标, 方法, 原因) + """ + # 构建对话目标 + goals_str = "" + if conversation_info.goal_list: + for goal_reason in conversation_info.goal_list: + if isinstance(goal_reason, dict): + goal = goal_reason.get("goal", "目标内容缺失") + reasoning = goal_reason.get("reasoning", "没有明确原因") + else: + goal = str(goal_reason) + reasoning = "没有明确原因" + + goal_str = f"目标:{goal},产生该对话目标的原因:{reasoning}\n" + goals_str += goal_str + else: + goal = "目前没有明确对话目标" + reasoning = "目前没有明确对话目标,最好思考一个对话目标" + goals_str = f"目标:{goal},产生该对话目标的原因:{reasoning}\n" + + # 获取聊天历史记录 + chat_history_text = observation_info.chat_history_str + + if observation_info.new_messages_count > 0: + new_messages_list = observation_info.unprocessed_messages + new_messages_str = await build_readable_messages( + new_messages_list, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + chat_history_text += f"\n--- 以下是 {observation_info.new_messages_count} 条新消息 ---\n{new_messages_str}" + + # await observation_info.clear_unprocessed_messages() + + persona_text = f"你的名字是{self.name},{self.personality_info}。" + # 构建action历史文本 + action_history_list = conversation_info.done_action + action_history_text = "你之前做的事情是:" + for action in action_history_list: + action_history_text += f"{action}\n" + + prompt = f"""{persona_text}。现在你在参与一场QQ聊天,请分析以下聊天记录,并根据你的性格特征确定多个明确的对话目标。 +这些目标应该反映出对话的不同方面和意图。 + +{action_history_text} +当前对话目标: +{goals_str} + +聊天记录: +{chat_history_text} + +请分析当前对话并确定最适合的对话目标。你可以: +1. 保持现有目标不变 +2. 修改现有目标 +3. 添加新目标 +4. 删除不再相关的目标 +5. 如果你想结束对话,请设置一个目标,目标goal为"结束对话",原因reasoning为你希望结束对话 + +请以JSON数组格式输出当前的所有对话目标,每个目标包含以下字段: +1. goal: 对话目标(简短的一句话) +2. reasoning: 对话原因,为什么设定这个目标(简要解释) + +输出格式示例: +[ +{{ + "goal": "回答用户关于Python编程的具体问题", + "reasoning": "用户提出了关于Python的技术问题,需要专业且准确的解答" +}}, +{{ + "goal": "回答用户关于python安装的具体问题", + "reasoning": "用户提出了关于Python的技术问题,需要专业且准确的解答" +}} +]""" + + logger.debug(f"[私聊][{self.private_name}]发送到LLM的提示词: {prompt}") + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]LLM原始返回内容: {content}") + except Exception as e: + logger.error(f"[私聊][{self.private_name}]分析对话目标时出错: {str(e)}") + content = "" + + # 使用改进后的get_items_from_json函数处理JSON数组 + success, result = get_items_from_json( + content, + self.private_name, + "goal", + "reasoning", + required_types={"goal": str, "reasoning": str}, + allow_array=True, + ) + + if success: + # 判断结果是单个字典还是字典列表 + if isinstance(result, list): + # 清空现有目标列表并添加新目标 + conversation_info.goal_list = [] + for item in result: + conversation_info.goal_list.append(item) + + # 返回第一个目标作为当前主要目标(如果有) + if result: + first_goal = result[0] + return first_goal.get("goal", ""), "", first_goal.get("reasoning", "") + else: + # 单个目标的情况 + conversation_info.goal_list.append(result) + return goal, "", reasoning + + # 如果解析失败,返回默认值 + return "", "", "" + + async def _update_goals(self, new_goal: str, method: str, reasoning: str): + """更新目标列表 + + Args: + new_goal: 新的目标 + method: 实现目标的方法 + reasoning: 目标的原因 + """ + # 检查新目标是否与现有目标相似 + for i, (existing_goal, _, _) in enumerate(self.goals): + if _calculate_similarity(new_goal, existing_goal) > 0.7: # 相似度阈值 + # 更新现有目标 + self.goals[i] = (new_goal, method, reasoning) + # 将此目标移到列表前面(最主要的位置) + self.goals.insert(0, self.goals.pop(i)) + return + + # 添加新目标到列表前面 + self.goals.insert(0, (new_goal, method, reasoning)) + + # 限制目标数量 + if len(self.goals) > self.max_goals: + self.goals.pop() # 移除最老的目标 + + async def get_all_goals(self) -> List[Tuple[str, str, str]]: + """获取所有当前目标 + + Returns: + List[Tuple[str, str, str]]: 目标列表,每项为(目标, 方法, 原因) + """ + return self.goals.copy() + + async def get_alternative_goals(self) -> List[Tuple[str, str, str]]: + """获取除了当前主要目标外的其他备选目标 + + Returns: + List[Tuple[str, str, str]]: 备选目标列表 + """ + if len(self.goals) <= 1: + return [] + return self.goals[1:].copy() + + async def analyze_conversation(self, goal, reasoning): + messages = self.chat_observer.get_cached_messages() + chat_history_text = await build_readable_messages( + messages, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + + persona_text = f"你的名字是{self.name},{self.personality_info}。" + # ===> Persona 文本构建结束 <=== + + # --- 修改 Prompt 字符串,使用 persona_text --- + prompt = f"""{persona_text}。现在你在参与一场QQ聊天, + 当前对话目标:{goal} + 产生该对话目标的原因:{reasoning} + + 请分析以下聊天记录,并根据你的性格特征评估该目标是否已经达到,或者你是否希望停止该次对话。 + 聊天记录: + {chat_history_text} + 请以JSON格式输出,包含以下字段: + 1. goal_achieved: 对话目标是否已经达到(true/false) + 2. stop_conversation: 是否希望停止该次对话(true/false) + 3. reason: 为什么希望停止该次对话(简要解释) + +输出格式示例: +{{ + "goal_achieved": true, + "stop_conversation": false, + "reason": "虽然目标已达成,但对话仍然有继续的价值" +}}""" + + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]LLM原始返回内容: {content}") + + # 尝试解析JSON + success, result = get_items_from_json( + content, + self.private_name, + "goal_achieved", + "stop_conversation", + "reason", + required_types={"goal_achieved": bool, "stop_conversation": bool, "reason": str}, + ) + + if not success: + logger.error(f"[私聊][{self.private_name}]无法解析对话分析结果JSON") + return False, False, "解析结果失败" + + goal_achieved = result["goal_achieved"] + stop_conversation = result["stop_conversation"] + reason = result["reason"] + + return goal_achieved, stop_conversation, reason + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]分析对话状态时出错: {str(e)}") + return False, False, f"分析出错: {str(e)}" + + +# 先注释掉,万一以后出问题了还能开回来((( +# class DirectMessageSender: +# """直接发送消息到平台的发送器""" + +# def __init__(self, private_name: str): +# self.logger = get_module_logger("direct_sender") +# self.storage = MessageStorage() +# self.private_name = private_name + +# async def send_via_ws(self, message: MessageSending) -> None: +# try: +# await global_api.send_message(message) +# except Exception as e: +# raise ValueError(f"未找到平台:{message.message_info.platform} 的url配置,请检查配置文件") from e + +# async def send_message( +# self, +# chat_stream: ChatStream, +# content: str, +# reply_to_message: Optional[Message] = None, +# ) -> None: +# """直接发送消息到平台 + +# Args: +# chat_stream: 聊天流 +# content: 消息内容 +# reply_to_message: 要回复的消息 +# """ +# # 构建消息对象 +# message_segment = Seg(type="text", data=content) +# bot_user_info = UserInfo( +# user_id=global_config.BOT_QQ, +# user_nickname=global_config.BOT_NICKNAME, +# platform=chat_stream.platform, +# ) + +# message = MessageSending( +# message_id=f"dm{round(time.time(), 2)}", +# chat_stream=chat_stream, +# bot_user_info=bot_user_info, +# sender_info=reply_to_message.message_info.user_info if reply_to_message else None, +# message_segment=message_segment, +# reply=reply_to_message, +# is_head=True, +# is_emoji=False, +# thinking_start_time=time.time(), +# ) + +# # 处理消息 +# await message.process() + +# _message_json = message.to_dict() + +# # 发送消息 +# try: +# await self.send_via_ws(message) +# await self.storage.store_message(message, chat_stream) +# logger.success(f"[私聊][{self.private_name}]PFC消息已发送: {content}") +# except Exception as e: +# logger.error(f"[私聊][{self.private_name}]PFC消息发送失败: {str(e)}") diff --git a/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py b/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py new file mode 100644 index 00000000..0989339d --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py @@ -0,0 +1,85 @@ +from typing import List, Tuple +from src.common.logger import get_module_logger +from src.plugins.memory_system.Hippocampus import HippocampusManager +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from ..chat.message import Message +from ..knowledge.knowledge_lib import qa_manager +from ..utils.chat_message_builder import build_readable_messages + +logger = get_module_logger("knowledge_fetcher") + + +class KnowledgeFetcher: + """知识调取器""" + + def __init__(self, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_normal, + temperature=global_config.llm_normal["temp"], + max_tokens=1000, + request_type="knowledge_fetch", + ) + self.private_name = private_name + + def _lpmm_get_knowledge(self, query: str) -> str: + """获取相关知识 + + Args: + query: 查询内容 + + Returns: + str: 构造好的,带相关度的知识 + """ + + logger.debug(f"[私聊][{self.private_name}]正在从LPMM知识库中获取知识") + try: + knowledge_info = qa_manager.get_knowledge(query) + logger.debug(f"[私聊][{self.private_name}]LPMM知识库查询结果: {knowledge_info:150}") + return knowledge_info + except Exception as e: + logger.error(f"[私聊][{self.private_name}]LPMM知识库搜索工具执行失败: {str(e)}") + return "未找到匹配的知识" + + async def fetch(self, query: str, chat_history: List[Message]) -> Tuple[str, str]: + """获取相关知识 + + Args: + query: 查询内容 + chat_history: 聊天历史 + + Returns: + Tuple[str, str]: (获取的知识, 知识来源) + """ + # 构建查询上下文 + chat_history_text = await build_readable_messages( + chat_history, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + + # 从记忆中获取相关知识 + related_memory = await HippocampusManager.get_instance().get_memory_from_text( + text=f"{query}\n{chat_history_text}", + max_memory_num=3, + max_memory_length=2, + max_depth=3, + fast_retrieval=False, + ) + knowledge_text = "" + sources_text = "无记忆匹配" # 默认值 + if related_memory: + sources = [] + for memory in related_memory: + knowledge_text += memory[1] + "\n" + sources.append(f"记忆片段{memory[0]}") + knowledge_text = knowledge_text.strip() + sources_text = ",".join(sources) + + knowledge_text += "\n现在有以下**知识**可供参考:\n " + knowledge_text += self._lpmm_get_knowledge(query) + knowledge_text += "\n请记住这些**知识**,并根据**知识**回答问题。\n" + + return knowledge_text or "未找到相关知识", sources_text or "无记忆匹配" diff --git a/src/chat/brain_chat/PFC/pfc_manager.py b/src/chat/brain_chat/PFC/pfc_manager.py new file mode 100644 index 00000000..7837606c --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc_manager.py @@ -0,0 +1,115 @@ +import time +from typing import Dict, Optional +from src.common.logger import get_module_logger +from .conversation import Conversation +import traceback + +logger = get_module_logger("pfc_manager") + + +class PFCManager: + """PFC对话管理器,负责管理所有对话实例""" + + # 单例模式 + _instance = None + + # 会话实例管理 + _instances: Dict[str, Conversation] = {} + _initializing: Dict[str, bool] = {} + + @classmethod + def get_instance(cls) -> "PFCManager": + """获取管理器单例 + + Returns: + PFCManager: 管理器实例 + """ + if cls._instance is None: + cls._instance = PFCManager() + return cls._instance + + async def get_or_create_conversation(self, stream_id: str, private_name: str) -> Optional[Conversation]: + """获取或创建对话实例 + + Args: + stream_id: 聊天流ID + private_name: 私聊名称 + + Returns: + Optional[Conversation]: 对话实例,创建失败则返回None + """ + # 检查是否已经有实例 + if stream_id in self._initializing and self._initializing[stream_id]: + logger.debug(f"[私聊][{private_name}]会话实例正在初始化中: {stream_id}") + return None + + if stream_id in self._instances and self._instances[stream_id].should_continue: + logger.debug(f"[私聊][{private_name}]使用现有会话实例: {stream_id}") + return self._instances[stream_id] + if stream_id in self._instances: + instance = self._instances[stream_id] + if ( + hasattr(instance, "ignore_until_timestamp") + and instance.ignore_until_timestamp + and time.time() < instance.ignore_until_timestamp + ): + logger.debug(f"[私聊][{private_name}]会话实例当前处于忽略状态: {stream_id}") + # 返回 None 阻止交互。或者可以返回实例但标记它被忽略了喵? + # 还是返回 None 吧喵。 + return None + + # 检查 should_continue 状态 + if instance.should_continue: + logger.debug(f"[私聊][{private_name}]使用现有会话实例: {stream_id}") + return instance + # else: 实例存在但不应继续 + try: + # 创建新实例 + logger.info(f"[私聊][{private_name}]创建新的对话实例: {stream_id}") + self._initializing[stream_id] = True + # 创建实例 + conversation_instance = Conversation(stream_id, private_name) + self._instances[stream_id] = conversation_instance + + # 启动实例初始化 + await self._initialize_conversation(conversation_instance) + except Exception as e: + logger.error(f"[私聊][{private_name}]创建会话实例失败: {stream_id}, 错误: {e}") + return None + + return conversation_instance + + async def _initialize_conversation(self, conversation: Conversation): + """初始化会话实例 + + Args: + conversation: 要初始化的会话实例 + """ + stream_id = conversation.stream_id + private_name = conversation.private_name + + try: + logger.info(f"[私聊][{private_name}]开始初始化会话实例: {stream_id}") + # 启动初始化流程 + await conversation._initialize() + + # 标记初始化完成 + self._initializing[stream_id] = False + + logger.info(f"[私聊][{private_name}]会话实例 {stream_id} 初始化完成") + + except Exception as e: + logger.error(f"[私聊][{private_name}]管理器初始化会话实例失败: {stream_id}, 错误: {e}") + logger.error(f"[私聊][{private_name}]{traceback.format_exc()}") + # 清理失败的初始化 + + async def get_conversation(self, stream_id: str) -> Optional[Conversation]: + """获取已存在的会话实例 + + Args: + stream_id: 聊天流ID + + Returns: + Optional[Conversation]: 会话实例,不存在则返回None + """ + return self._instances.get(stream_id) diff --git a/src/chat/brain_chat/PFC/pfc_types.py b/src/chat/brain_chat/PFC/pfc_types.py new file mode 100644 index 00000000..0ea5eda6 --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc_types.py @@ -0,0 +1,23 @@ +from enum import Enum +from typing import Literal + + +class ConversationState(Enum): + """对话状态""" + + INIT = "初始化" + RETHINKING = "重新思考" + ANALYZING = "分析历史" + PLANNING = "规划目标" + GENERATING = "生成回复" + CHECKING = "检查回复" + SENDING = "发送消息" + FETCHING = "获取知识" + WAITING = "等待" + LISTENING = "倾听" + ENDED = "结束" + JUDGING = "判断" + IGNORED = "屏蔽" + + +ActionType = Literal["direct_reply", "fetch_knowledge", "wait"] diff --git a/src/chat/brain_chat/PFC/pfc_utils.py b/src/chat/brain_chat/PFC/pfc_utils.py new file mode 100644 index 00000000..2f7bd5e0 --- /dev/null +++ b/src/chat/brain_chat/PFC/pfc_utils.py @@ -0,0 +1,127 @@ +import json +import re +from typing import Dict, Any, Optional, Tuple, List, Union +from src.common.logger import get_module_logger + +logger = get_module_logger("pfc_utils") + + +def get_items_from_json( + content: str, + private_name: str, + *items: str, + default_values: Optional[Dict[str, Any]] = None, + required_types: Optional[Dict[str, type]] = None, + allow_array: bool = True, +) -> Tuple[bool, Union[Dict[str, Any], List[Dict[str, Any]]]]: + """从文本中提取JSON内容并获取指定字段 + + Args: + content: 包含JSON的文本 + private_name: 私聊名称 + *items: 要提取的字段名 + default_values: 字段的默认值,格式为 {字段名: 默认值} + required_types: 字段的必需类型,格式为 {字段名: 类型} + allow_array: 是否允许解析JSON数组 + + Returns: + Tuple[bool, Union[Dict[str, Any], List[Dict[str, Any]]]]: (是否成功, 提取的字段字典或字典列表) + """ + content = content.strip() + result = {} + + # 设置默认值 + if default_values: + result.update(default_values) + + # 首先尝试解析为JSON数组 + if allow_array: + try: + # 尝试找到文本中的JSON数组 + array_pattern = r"\[[\s\S]*\]" + array_match = re.search(array_pattern, content) + if array_match: + array_content = array_match.group() + json_array = json.loads(array_content) + + # 确认是数组类型 + if isinstance(json_array, list): + # 验证数组中的每个项目是否包含所有必需字段 + valid_items = [] + for item in json_array: + if not isinstance(item, dict): + continue + + # 检查是否有所有必需字段 + if all(field in item for field in items): + # 验证字段类型 + if required_types: + type_valid = True + for field, expected_type in required_types.items(): + if field in item and not isinstance(item[field], expected_type): + type_valid = False + break + + if not type_valid: + continue + + # 验证字符串字段不为空 + string_valid = True + for field in items: + if isinstance(item[field], str) and not item[field].strip(): + string_valid = False + break + + if not string_valid: + continue + + valid_items.append(item) + + if valid_items: + return True, valid_items + except json.JSONDecodeError: + logger.debug(f"[私聊][{private_name}]JSON数组解析失败,尝试解析单个JSON对象") + except Exception as e: + logger.debug(f"[私聊][{private_name}]尝试解析JSON数组时出错: {str(e)}") + + # 尝试解析JSON对象 + try: + json_data = json.loads(content) + except json.JSONDecodeError: + # 如果直接解析失败,尝试查找和提取JSON部分 + json_pattern = r"\{[^{}]*\}" + json_match = re.search(json_pattern, content) + if json_match: + try: + json_data = json.loads(json_match.group()) + except json.JSONDecodeError: + logger.error(f"[私聊][{private_name}]提取的JSON内容解析失败") + return False, result + else: + logger.error(f"[私聊][{private_name}]无法在返回内容中找到有效的JSON") + return False, result + + # 提取字段 + for item in items: + if item in json_data: + result[item] = json_data[item] + + # 验证必需字段 + if not all(item in result for item in items): + logger.error(f"[私聊][{private_name}]JSON缺少必要字段,实际内容: {json_data}") + return False, result + + # 验证字段类型 + if required_types: + for field, expected_type in required_types.items(): + if field in result and not isinstance(result[field], expected_type): + logger.error(f"[私聊][{private_name}]{field} 必须是 {expected_type.__name__} 类型") + return False, result + + # 验证字符串字段不为空 + for field in items: + if isinstance(result[field], str) and not result[field].strip(): + logger.error(f"[私聊][{private_name}]{field} 不能为空") + return False, result + + return True, result diff --git a/src/chat/brain_chat/PFC/reply_checker.py b/src/chat/brain_chat/PFC/reply_checker.py new file mode 100644 index 00000000..35e9af50 --- /dev/null +++ b/src/chat/brain_chat/PFC/reply_checker.py @@ -0,0 +1,183 @@ +import json +from typing import Tuple, List, Dict, Any +from src.common.logger import get_module_logger +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from .chat_observer import ChatObserver +from maim_message import UserInfo + +logger = get_module_logger("reply_checker") + + +class ReplyChecker: + """回复检查器""" + + def __init__(self, stream_id: str, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_PFC_reply_checker, temperature=0.50, max_tokens=1000, request_type="reply_check" + ) + self.name = global_config.BOT_NICKNAME + self.private_name = private_name + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + self.max_retries = 3 # 最大重试次数 + + async def check( + self, reply: str, goal: str, chat_history: List[Dict[str, Any]], chat_history_text: str, retry_count: int = 0 + ) -> Tuple[bool, str, bool]: + """检查生成的回复是否合适 + + Args: + reply: 生成的回复 + goal: 对话目标 + chat_history: 对话历史记录 + chat_history_text: 对话历史记录文本 + retry_count: 当前重试次数 + + Returns: + Tuple[bool, str, bool]: (是否合适, 原因, 是否需要重新规划) + """ + # 不再从 observer 获取,直接使用传入的 chat_history + # messages = self.chat_observer.get_cached_messages(limit=20) + try: + # 筛选出最近由 Bot 自己发送的消息 + bot_messages = [] + for msg in reversed(chat_history): + user_info = UserInfo.from_dict(msg.get("user_info", {})) + if str(user_info.user_id) == str(global_config.BOT_QQ): # 确保比较的是字符串 + bot_messages.append(msg.get("processed_plain_text", "")) + if len(bot_messages) >= 2: # 只和最近的两条比较 + break + # 进行比较 + if bot_messages: + # 可以用简单比较,或者更复杂的相似度库 (如 difflib) + # 简单比较:是否完全相同 + if reply == bot_messages[0]: # 和最近一条完全一样 + logger.warning( + f"[私聊][{self.private_name}]ReplyChecker 检测到回复与上一条 Bot 消息完全相同: '{reply}'" + ) + return ( + False, + "被逻辑检查拒绝:回复内容与你上一条发言完全相同,可以选择深入话题或寻找其它话题或等待", + True, + ) # 不合适,需要返回至决策层 + # 2. 相似度检查 (如果精确匹配未通过) + import difflib # 导入 difflib 库 + + # 计算编辑距离相似度,ratio() 返回 0 到 1 之间的浮点数 + similarity_ratio = difflib.SequenceMatcher(None, reply, bot_messages[0]).ratio() + logger.debug(f"[私聊][{self.private_name}]ReplyChecker - 相似度: {similarity_ratio:.2f}") + + # 设置一个相似度阈值 + similarity_threshold = 0.9 + if similarity_ratio > similarity_threshold: + logger.warning( + f"[私聊][{self.private_name}]ReplyChecker 检测到回复与上一条 Bot 消息高度相似 (相似度 {similarity_ratio:.2f}): '{reply}'" + ) + return ( + False, + f"被逻辑检查拒绝:回复内容与你上一条发言高度相似 (相似度 {similarity_ratio:.2f}),可以选择深入话题或寻找其它话题或等待。", + True, + ) + + except Exception as e: + import traceback + + logger.error(f"[私聊][{self.private_name}]检查回复时出错: 类型={type(e)}, 值={e}") + logger.error(f"[私聊][{self.private_name}]{traceback.format_exc()}") # 打印详细的回溯信息 + + prompt = f"""你是一个聊天逻辑检查器,请检查以下回复或消息是否合适: + +当前对话目标:{goal} +最新的对话记录: +{chat_history_text} + +待检查的消息: +{reply} + +请结合聊天记录检查以下几点: +1. 这条消息是否依然符合当前对话目标和实现方式 +2. 这条消息是否与最新的对话记录保持一致性 +3. 是否存在重复发言,或重复表达同质内容(尤其是只是换一种方式表达了相同的含义) +4. 这条消息是否包含违规内容(例如血腥暴力,政治敏感等) +5. 这条消息是否以发送者的角度发言(不要让发送者自己回复自己的消息) +6. 这条消息是否通俗易懂 +7. 这条消息是否有些多余,例如在对方没有回复的情况下,依然连续多次“消息轰炸”(尤其是已经连续发送3条信息的情况,这很可能不合理,需要着重判断) +8. 这条消息是否使用了完全没必要的修辞 +9. 这条消息是否逻辑通顺 +10. 这条消息是否太过冗长了(通常私聊的每条消息长度在20字以内,除非特殊情况) +11. 在连续多次发送消息的情况下,这条消息是否衔接自然,会不会显得奇怪(例如连续两条消息中部分内容重叠) + +请以JSON格式输出,包含以下字段: +1. suitable: 是否合适 (true/false) +2. reason: 原因说明 +3. need_replan: 是否需要重新决策 (true/false),当你认为此时已经不适合发消息,需要规划其它行动时,设为true + +输出格式示例: +{{ + "suitable": true, + "reason": "回复符合要求,虽然有可能略微偏离目标,但是整体内容流畅得体", + "need_replan": false +}} + +注意:请严格按照JSON格式输出,不要包含任何其他内容。""" + + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]检查回复的原始返回: {content}") + + # 清理内容,尝试提取JSON部分 + content = content.strip() + try: + # 尝试直接解析 + result = json.loads(content) + except json.JSONDecodeError: + # 如果直接解析失败,尝试查找和提取JSON部分 + import re + + json_pattern = r"\{[^{}]*\}" + json_match = re.search(json_pattern, content) + if json_match: + try: + result = json.loads(json_match.group()) + except json.JSONDecodeError: + # 如果JSON解析失败,尝试从文本中提取结果 + is_suitable = "不合适" not in content.lower() and "违规" not in content.lower() + reason = content[:100] if content else "无法解析响应" + need_replan = "重新规划" in content.lower() or "目标不适合" in content.lower() + return is_suitable, reason, need_replan + else: + # 如果找不到JSON,从文本中判断 + is_suitable = "不合适" not in content.lower() and "违规" not in content.lower() + reason = content[:100] if content else "无法解析响应" + need_replan = "重新规划" in content.lower() or "目标不适合" in content.lower() + return is_suitable, reason, need_replan + + # 验证JSON字段 + suitable = result.get("suitable", None) + reason = result.get("reason", "未提供原因") + need_replan = result.get("need_replan", False) + + # 如果suitable字段是字符串,转换为布尔值 + if isinstance(suitable, str): + suitable = suitable.lower() == "true" + + # 如果suitable字段不存在或不是布尔值,从reason中判断 + if suitable is None: + suitable = "不合适" not in reason.lower() and "违规" not in reason.lower() + + # 如果不合适且未达到最大重试次数,返回需要重试 + if not suitable and retry_count < self.max_retries: + return False, reason, False + + # 如果不合适且已达到最大重试次数,返回需要重新规划 + if not suitable and retry_count >= self.max_retries: + return False, f"多次重试后仍不合适: {reason}", True + + return suitable, reason, need_replan + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]检查回复时出错: {e}") + # 如果出错且已达到最大重试次数,建议重新规划 + if retry_count >= self.max_retries: + return False, "多次检查失败,建议重新规划", True + return False, f"检查过程出错,建议重试: {str(e)}", False diff --git a/src/chat/brain_chat/PFC/reply_generator.py b/src/chat/brain_chat/PFC/reply_generator.py new file mode 100644 index 00000000..890f807c --- /dev/null +++ b/src/chat/brain_chat/PFC/reply_generator.py @@ -0,0 +1,228 @@ +from typing import Tuple, List, Dict, Any +from src.common.logger import get_module_logger +from ..models.utils_model import LLMRequest +from ...config.config import global_config +from .chat_observer import ChatObserver +from .reply_checker import ReplyChecker +from src.individuality.individuality import Individuality +from .observation_info import ObservationInfo +from .conversation_info import ConversationInfo +from src.plugins.utils.chat_message_builder import build_readable_messages + +logger = get_module_logger("reply_generator") + +# --- 定义 Prompt 模板 --- + +# Prompt for direct_reply (首次回复) +PROMPT_DIRECT_REPLY = """{persona_text}。现在你在参与一场QQ私聊,请根据以下信息生成一条回复: + +当前对话目标:{goals_str} + +{knowledge_info_str} + +最近的聊天记录: +{chat_history_text} + + +请根据上述信息,结合聊天记录,回复对方。该回复应该: +1. 符合对话目标,以"你"的角度发言(不要自己与自己对话!) +2. 符合你的性格特征和身份细节 +3. 通俗易懂,自然流畅,像正常聊天一样,简短(通常20字以内,除非特殊情况) +4. 可以适当利用相关知识,但不要生硬引用 +5. 自然、得体,结合聊天记录逻辑合理,且没有重复表达同质内容 + +请注意把握聊天内容,不要回复的太有条理,可以有个性。请分清"你"和对方说的话,不要把"你"说的话当做对方说的话,这是你自己说的话。 +可以回复得自然随意自然一些,就像真人一样,注意把握聊天内容,整体风格可以平和、简短,不要刻意突出自身学科背景,不要说你说过的话,可以简短,多简短都可以,但是避免冗长。 +请你注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 +不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 + +请直接输出回复内容,不需要任何额外格式。""" + +# Prompt for send_new_message (追问/补充) +PROMPT_SEND_NEW_MESSAGE = """{persona_text}。现在你在参与一场QQ私聊,**刚刚你已经发送了一条或多条消息**,现在请根据以下信息再发一条新消息: + +当前对话目标:{goals_str} + +{knowledge_info_str} + +最近的聊天记录: +{chat_history_text} + + +请根据上述信息,结合聊天记录,继续发一条新消息(例如对之前消息的补充,深入话题,或追问等等)。该消息应该: +1. 符合对话目标,以"你"的角度发言(不要自己与自己对话!) +2. 符合你的性格特征和身份细节 +3. 通俗易懂,自然流畅,像正常聊天一样,简短(通常20字以内,除非特殊情况) +4. 可以适当利用相关知识,但不要生硬引用 +5. 跟之前你发的消息自然的衔接,逻辑合理,且没有重复表达同质内容或部分重叠内容 + +请注意把握聊天内容,不用太有条理,可以有个性。请分清"你"和对方说的话,不要把"你"说的话当做对方说的话,这是你自己说的话。 +这条消息可以自然随意自然一些,就像真人一样,注意把握聊天内容,整体风格可以平和、简短,不要刻意突出自身学科背景,不要说你说过的话,可以简短,多简短都可以,但是避免冗长。 +请你注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出消息内容。 +不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 + +请直接输出回复内容,不需要任何额外格式。""" + +# Prompt for say_goodbye (告别语生成) +PROMPT_FAREWELL = """{persona_text}。你在参与一场 QQ 私聊,现在对话似乎已经结束,你决定再发一条最后的消息来圆满结束。 + +最近的聊天记录: +{chat_history_text} + +请根据上述信息,结合聊天记录,构思一条**简短、自然、符合你人设**的最后的消息。 +这条消息应该: +1. 从你自己的角度发言。 +2. 符合你的性格特征和身份细节。 +3. 通俗易懂,自然流畅,通常很简短。 +4. 自然地为这场对话画上句号,避免开启新话题或显得冗长、刻意。 + +请像真人一样随意自然,**简洁是关键**。 +不要输出多余内容(包括前后缀、冒号、引号、括号、表情包、at或@等)。 + +请直接输出最终的告别消息内容,不需要任何额外格式。""" + + +class ReplyGenerator: + """回复生成器""" + + def __init__(self, stream_id: str, private_name: str): + self.llm = LLMRequest( + model=global_config.llm_PFC_chat, + temperature=global_config.llm_PFC_chat["temp"], + max_tokens=300, + request_type="reply_generation", + ) + self.personality_info = Individuality.get_instance().get_prompt(x_person=2, level=3) + self.name = global_config.BOT_NICKNAME + self.private_name = private_name + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + self.reply_checker = ReplyChecker(stream_id, private_name) + + # 修改 generate 方法签名,增加 action_type 参数 + async def generate( + self, observation_info: ObservationInfo, conversation_info: ConversationInfo, action_type: str + ) -> str: + """生成回复 + + Args: + observation_info: 观察信息 + conversation_info: 对话信息 + action_type: 当前执行的动作类型 ('direct_reply' 或 'send_new_message') + + Returns: + str: 生成的回复 + """ + # 构建提示词 + logger.debug( + f"[私聊][{self.private_name}]开始生成回复 (动作类型: {action_type}):当前目标: {conversation_info.goal_list}" + ) + + # --- 构建通用 Prompt 参数 --- + # (这部分逻辑基本不变) + + # 构建对话目标 (goals_str) + goals_str = "" + if conversation_info.goal_list: + for goal_reason in conversation_info.goal_list: + if isinstance(goal_reason, dict): + goal = goal_reason.get("goal", "目标内容缺失") + reasoning = goal_reason.get("reasoning", "没有明确原因") + else: + goal = str(goal_reason) + reasoning = "没有明确原因" + + goal = str(goal) if goal is not None else "目标内容缺失" + reasoning = str(reasoning) if reasoning is not None else "没有明确原因" + goals_str += f"- 目标:{goal}\n 原因:{reasoning}\n" + else: + goals_str = "- 目前没有明确对话目标\n" # 简化无目标情况 + + # --- 新增:构建知识信息字符串 --- + knowledge_info_str = "【供参考的相关知识和记忆】\n" # 稍微改下标题,表明是供参考 + try: + # 检查 conversation_info 是否有 knowledge_list 并且不为空 + if hasattr(conversation_info, "knowledge_list") and conversation_info.knowledge_list: + # 最多只显示最近的 5 条知识 + recent_knowledge = conversation_info.knowledge_list[-5:] + for i, knowledge_item in enumerate(recent_knowledge): + if isinstance(knowledge_item, dict): + query = knowledge_item.get("query", "未知查询") + knowledge = knowledge_item.get("knowledge", "无知识内容") + source = knowledge_item.get("source", "未知来源") + # 只取知识内容的前 2000 个字 + knowledge_snippet = knowledge[:2000] + "..." if len(knowledge) > 2000 else knowledge + knowledge_info_str += ( + f"{i + 1}. 关于 '{query}' (来源: {source}): {knowledge_snippet}\n" # 格式微调,更简洁 + ) + else: + knowledge_info_str += f"{i + 1}. 发现一条格式不正确的知识记录。\n" + + if not recent_knowledge: + knowledge_info_str += "- 暂无。\n" # 更简洁的提示 + + else: + knowledge_info_str += "- 暂无。\n" + except AttributeError: + logger.warning(f"[私聊][{self.private_name}]ConversationInfo 对象可能缺少 knowledge_list 属性。") + knowledge_info_str += "- 获取知识列表时出错。\n" + except Exception as e: + logger.error(f"[私聊][{self.private_name}]构建知识信息字符串时出错: {e}") + knowledge_info_str += "- 处理知识列表时出错。\n" + + # 获取聊天历史记录 (chat_history_text) + chat_history_text = observation_info.chat_history_str + if observation_info.new_messages_count > 0 and observation_info.unprocessed_messages: + new_messages_list = observation_info.unprocessed_messages + new_messages_str = await build_readable_messages( + new_messages_list, + replace_bot_name=True, + merge_messages=False, + timestamp_mode="relative", + read_mark=0.0, + ) + chat_history_text += f"\n--- 以下是 {observation_info.new_messages_count} 条新消息 ---\n{new_messages_str}" + elif not chat_history_text: + chat_history_text = "还没有聊天记录。" + + # 构建 Persona 文本 (persona_text) + persona_text = f"你的名字是{self.name},{self.personality_info}。" + + # --- 选择 Prompt --- + if action_type == "send_new_message": + prompt_template = PROMPT_SEND_NEW_MESSAGE + logger.info(f"[私聊][{self.private_name}]使用 PROMPT_SEND_NEW_MESSAGE (追问生成)") + elif action_type == "say_goodbye": # 处理告别动作 + prompt_template = PROMPT_FAREWELL + logger.info(f"[私聊][{self.private_name}]使用 PROMPT_FAREWELL (告别语生成)") + else: # 默认使用 direct_reply 的 prompt (包括 'direct_reply' 或其他未明确处理的类型) + prompt_template = PROMPT_DIRECT_REPLY + logger.info(f"[私聊][{self.private_name}]使用 PROMPT_DIRECT_REPLY (首次/非连续回复生成)") + + # --- 格式化最终的 Prompt --- + prompt = prompt_template.format( + persona_text=persona_text, + goals_str=goals_str, + chat_history_text=chat_history_text, + knowledge_info_str=knowledge_info_str, + ) + + # --- 调用 LLM 生成 --- + logger.debug(f"[私聊][{self.private_name}]发送到LLM的生成提示词:\n------\n{prompt}\n------") + try: + content, _ = await self.llm.generate_response_async(prompt) + logger.debug(f"[私聊][{self.private_name}]生成的回复: {content}") + # 移除旧的检查新消息逻辑,这应该由 conversation 控制流处理 + return content + + except Exception as e: + logger.error(f"[私聊][{self.private_name}]生成回复时出错: {e}") + return "抱歉,我现在有点混乱,让我重新思考一下..." + + # check_reply 方法保持不变 + async def check_reply( + self, reply: str, goal: str, chat_history: List[Dict[str, Any]], chat_history_str: str, retry_count: int = 0 + ) -> Tuple[bool, str, bool]: + """检查回复是否合适 + (此方法逻辑保持不变) + """ + return await self.reply_checker.check(reply, goal, chat_history, chat_history_str, retry_count) diff --git a/src/chat/brain_chat/PFC/waiter.py b/src/chat/brain_chat/PFC/waiter.py new file mode 100644 index 00000000..0f5881fc --- /dev/null +++ b/src/chat/brain_chat/PFC/waiter.py @@ -0,0 +1,79 @@ +from src.common.logger import get_module_logger +from .chat_observer import ChatObserver +from .conversation_info import ConversationInfo + +# from src.individuality.individuality import Individuality # 不再需要 +from ...config.config import global_config +import time +import asyncio + +logger = get_module_logger("waiter") + +# --- 在这里设定你想要的超时时间(秒) --- +# 例如: 120 秒 = 2 分钟 +DESIRED_TIMEOUT_SECONDS = 300 + + +class Waiter: + """等待处理类""" + + def __init__(self, stream_id: str, private_name: str): + self.chat_observer = ChatObserver.get_instance(stream_id, private_name) + self.name = global_config.BOT_NICKNAME + self.private_name = private_name + # self.wait_accumulated_time = 0 # 不再需要累加计时 + + async def wait(self, conversation_info: ConversationInfo) -> bool: + """等待用户新消息或超时""" + wait_start_time = time.time() + logger.info(f"[私聊][{self.private_name}]进入常规等待状态 (超时: {DESIRED_TIMEOUT_SECONDS} 秒)...") + + while True: + # 检查是否有新消息 + if self.chat_observer.new_message_after(wait_start_time): + logger.info(f"[私聊][{self.private_name}]等待结束,收到新消息") + return False # 返回 False 表示不是超时 + + # 检查是否超时 + elapsed_time = time.time() - wait_start_time + if elapsed_time > DESIRED_TIMEOUT_SECONDS: + logger.info(f"[私聊][{self.private_name}]等待超过 {DESIRED_TIMEOUT_SECONDS} 秒...添加思考目标。") + wait_goal = { + "goal": f"你等待了{elapsed_time / 60:.1f}分钟,注意可能在对方看来聊天已经结束,思考接下来要做什么", + "reasoning": "对方很久没有回复你的消息了", + } + conversation_info.goal_list.append(wait_goal) + logger.info(f"[私聊][{self.private_name}]添加目标: {wait_goal}") + return True # 返回 True 表示超时 + + await asyncio.sleep(5) # 每 5 秒检查一次 + logger.debug( + f"[私聊][{self.private_name}]等待中..." + ) # 可以考虑把这个频繁日志注释掉,只在超时或收到消息时输出 + + async def wait_listening(self, conversation_info: ConversationInfo) -> bool: + """倾听用户发言或超时""" + wait_start_time = time.time() + logger.info(f"[私聊][{self.private_name}]进入倾听等待状态 (超时: {DESIRED_TIMEOUT_SECONDS} 秒)...") + + while True: + # 检查是否有新消息 + if self.chat_observer.new_message_after(wait_start_time): + logger.info(f"[私聊][{self.private_name}]倾听等待结束,收到新消息") + return False # 返回 False 表示不是超时 + + # 检查是否超时 + elapsed_time = time.time() - wait_start_time + if elapsed_time > DESIRED_TIMEOUT_SECONDS: + logger.info(f"[私聊][{self.private_name}]倾听等待超过 {DESIRED_TIMEOUT_SECONDS} 秒...添加思考目标。") + wait_goal = { + # 保持 goal 文本一致 + "goal": f"你等待了{elapsed_time / 60:.1f}分钟,对方似乎话说一半突然消失了,可能忙去了?也可能忘记了回复?要问问吗?还是结束对话?或继续等待?思考接下来要做什么", + "reasoning": "对方话说一半消失了,很久没有回复", + } + conversation_info.goal_list.append(wait_goal) + logger.info(f"[私聊][{self.private_name}]添加目标: {wait_goal}") + return True # 返回 True 表示超时 + + await asyncio.sleep(5) # 每 5 秒检查一次 + logger.debug(f"[私聊][{self.private_name}]倾听等待中...") # 同上,可以考虑注释掉 diff --git a/src/chat/brain_chat/brain_chat.py b/src/chat/brain_chat/brain_chat.py index 61f7ca9f..51607547 100644 --- a/src/chat/brain_chat/brain_chat.py +++ b/src/chat/brain_chat/brain_chat.py @@ -25,6 +25,7 @@ from src.chat.utils.chat_message_builder import ( build_readable_messages_with_id, get_raw_msg_before_timestamp_with_chat, ) +from src.chat.brain_chat.brain_reply_checker import BrainReplyChecker, BrainLLMReplyChecker if TYPE_CHECKING: from src.common.data_models.database_data_model import DatabaseMessages @@ -87,6 +88,11 @@ class BrainChatting: self.running: bool = False self._loop_task: Optional[asyncio.Task] = None # 主循环任务 + # 轻量级回复检查器(比 PFC 更宽松) + self.reply_checker = BrainReplyChecker(chat_id=self.stream_id) + # 使用 planner 模型的一次性 LLM 检查器 + self.llm_reply_checker = BrainLLMReplyChecker(chat_id=self.stream_id, max_retries=1) + # 添加循环信息管理相关的属性 self.history_loop: List[CycleDetail] = [] self._cycle_counter = 0 @@ -96,6 +102,12 @@ class BrainChatting: self.more_plan = False + # 最近一次是否成功进行了 reply,用于选择 BrainPlanner 的 Prompt + self._last_successful_reply: bool = False + + # 类似 PFC 的 block_and_ignore:在该时间点之前不主动参与该聊天 + self._ignore_until_timestamp: Optional[float] = None + async def start(self): """检查是否需要启动主循环,如果未激活则启动。""" @@ -157,6 +169,14 @@ class BrainChatting: ) async def _loopbody(self): # sourcery skip: hoist-if-from-if + # 如果当前处于 block_and_ignore 冷却期,直接跳过本轮思考 + if self._ignore_until_timestamp and time.time() < self._ignore_until_timestamp: + await asyncio.sleep(0.5) + return True + elif self._ignore_until_timestamp and time.time() >= self._ignore_until_timestamp: + logger.info(f"{self.log_prefix} block_and_ignore 冷却结束,恢复该聊天的正常思考") + self._ignore_until_timestamp = None + recent_messages_list = message_api.get_messages_by_time_in_chat( chat_id=self.stream_id, start_time=self.last_read_time, @@ -296,6 +316,10 @@ class BrainChatting: chat_content_block=chat_content_block, message_id_list=message_id_list, interest=global_config.personality.interest, + prompt_key=( + "brain_planner_prompt_follow_up" if self._last_successful_reply else "brain_planner_prompt_initial" + ), + log_prompt=True, ) continue_flag, modified_message = await events_manager.handle_mai_events( EventType.ON_PLAN, None, prompt_info[0], None, self.chat_stream.stream_id @@ -309,6 +333,7 @@ class BrainChatting: action_to_use_info = await self.action_planner.plan( loop_start_time=self.last_read_time, available_actions=available_actions, + last_successful_reply=self._last_successful_reply, ) # 3. 并行执行所有动作 @@ -524,50 +549,131 @@ class BrainChatting: return {"action_type": "no_reply", "success": True, "reply_text": "", "command": ""} elif action_planner_info.action_type == "reply": - try: - success, llm_response = await generator_api.generate_reply( - chat_stream=self.chat_stream, - reply_message=action_planner_info.action_message, - available_actions=available_actions, - chosen_actions=chosen_action_plan_infos, - reply_reason=action_planner_info.reasoning or "", - enable_tool=global_config.tool.enable_tool, - request_type="replyer", - from_plugin=False, - ) + # 使用规则 + 一次 LLM ReplyChecker 包一层重试逻辑 + retry_count = 0 - if not success or not llm_response or not llm_response.reply_set: - if action_planner_info.action_message: - logger.info( - f"对 {action_planner_info.action_message.processed_plain_text} 的回复生成失败" - ) - else: - logger.info("回复生成失败") + while True: + try: + success, llm_response = await generator_api.generate_reply( + chat_stream=self.chat_stream, + reply_message=action_planner_info.action_message, + available_actions=available_actions, + chosen_actions=chosen_action_plan_infos, + reply_reason=action_planner_info.reasoning or "", + enable_tool=global_config.tool.enable_tool, + request_type="replyer", + from_plugin=False, + ) + + if not success or not llm_response or not llm_response.reply_set: + if action_planner_info.action_message: + logger.info( + f"对 {action_planner_info.action_message.processed_plain_text} 的回复生成失败" + ) + else: + logger.info("回复生成失败") + return { + "action_type": "reply", + "success": False, + "reply_text": "", + "loop_info": None, + } + + except asyncio.CancelledError: + logger.debug(f"{self.log_prefix} 并行执行:回复生成任务已被取消") return {"action_type": "reply", "success": False, "reply_text": "", "loop_info": None} - except asyncio.CancelledError: - logger.debug(f"{self.log_prefix} 并行执行:回复生成任务已被取消") - return {"action_type": "reply", "success": False, "reply_text": "", "loop_info": None} - response_set = llm_response.reply_set - selected_expressions = llm_response.selected_expressions - loop_info, reply_text, _ = await self._send_and_store_reply( - response_set=response_set, - action_message=action_planner_info.action_message, # type: ignore - cycle_timers=cycle_timers, - thinking_id=thinking_id, - actions=chosen_action_plan_infos, - selected_expressions=selected_expressions, - ) - return { - "action_type": "reply", - "success": True, - "reply_text": reply_text, - "loop_info": loop_info, - } + response_set = llm_response.reply_set + + # 预先拼接一次纯文本,供检查使用(与发送逻辑解耦) + preview_text = "" + for reply_content in response_set.reply_data: + if reply_content.content_type != ReplyContentType.TEXT: + continue + data: str = reply_content.content # type: ignore + preview_text += data + + # 规则检查(不调用 LLM) + rule_suitable, rule_reason, rule_need_retry = self.reply_checker.check( + reply_text=preview_text, retry_count=retry_count + ) + + # LLM 检查(使用 planner 模型,一次机会) + llm_suitable, llm_reason, llm_need_retry = await self.llm_reply_checker.check( + reply_text=preview_text, retry_count=retry_count + ) + + # 是否需要重生成:只要有一方建议重试,且还在重试次数之内 + if (rule_need_retry or llm_need_retry) and retry_count < max( + self.reply_checker.max_retries, self.llm_reply_checker.max_retries + ): + retry_count += 1 + logger.info( + f"{self.log_prefix} ReplyChecker 建议重试(第 {retry_count} 次)," + f"rule: {rule_reason}; llm: {llm_reason}" + ) + continue + + # 到这里为止,不再重试:即使有一方认为“不太理想”,也只记录原因并放行 + if not rule_suitable or not llm_suitable: + logger.info( + f"{self.log_prefix} ReplyChecker 判断回复可能不太理想," + f"rule: {rule_reason}; llm: {llm_reason},本次仍将发送。" + ) + + selected_expressions = llm_response.selected_expressions + loop_info, reply_text, _ = await self._send_and_store_reply( + response_set=response_set, + action_message=action_planner_info.action_message, # type: ignore + cycle_timers=cycle_timers, + thinking_id=thinking_id, + actions=chosen_action_plan_infos, + selected_expressions=selected_expressions, + ) + # 标记这次循环已经成功进行了回复,下一轮 Planner 使用 follow_up Prompt + self._last_successful_reply = True + return { + "action_type": "reply", + "success": True, + "reply_text": reply_text, + "loop_info": loop_info, + } # 其他动作 else: - # 执行普通动作 + # 内建 wait / listening / block_and_ignore:不通过插件系统,直接在这里处理 + if action_planner_info.action_type in ["wait", "listening", "block_and_ignore"]: + reason = action_planner_info.reasoning or "" + + if action_planner_info.action_type == "block_and_ignore": + # 设置一段时间的忽略窗口,例如 10 分钟 + ignore_minutes = 10 + self._ignore_until_timestamp = time.time() + ignore_minutes * 60 + logger.info( + f"{self.log_prefix} 收到 block_and_ignore 动作,将在接下来 {ignore_minutes} 分钟内不再主动参与该聊天" + ) + + # 统一将这三种策略动作记录到数据库,便于后续分析 + await database_api.store_action_info( + chat_stream=self.chat_stream, + action_build_into_prompt=False, + action_prompt_display=reason or f"执行动作: {action_planner_info.action_type}", + action_done=True, + thinking_id=thinking_id, + action_data={"reason": reason}, + action_name=action_planner_info.action_type, + ) + + # 这些动作本身不产生文本回复 + self._last_successful_reply = False + return { + "action_type": action_planner_info.action_type, + "success": True, + "reply_text": "", + "command": "", + } + + # 其余动作:走原有插件 Action 体系 with Timer("动作执行", cycle_timers): success, reply_text, command = await self._handle_action( action_planner_info.action_type, @@ -577,6 +683,10 @@ class BrainChatting: thinking_id, action_planner_info.action_message, ) + # 非 reply 类动作执行成功时,清空最近成功回复标记,让下一轮回到 initial Prompt + if success and action_planner_info.action_type != "reply": + self._last_successful_reply = False + return { "action_type": action_planner_info.action_type, "success": success, diff --git a/src/chat/brain_chat/brain_planner.py b/src/chat/brain_chat/brain_planner.py index ac68dca3..aa3841a3 100644 --- a/src/chat/brain_chat/brain_planner.py +++ b/src/chat/brain_chat/brain_planner.py @@ -35,6 +35,7 @@ install(extra_lines=3) def init_prompt(): + # 初次 / 非连续回复时使用的 Planner Prompt Prompt( """ {time_block} @@ -64,6 +65,33 @@ no_reply "action": "no_reply", }} +wait +动作描述: +在当前轮次暂时不再发言,等待对方进一步发言或后续更合适的时机再回复。这通常用于你已经表达清楚一轮,想给对方留出空间。 +{{ + "action": "wait", + "target_message_id":"想要作为这次等待依据的消息id(通常是对方的最新消息)", + "reason":"选择等待的原因" +}} + +listening +动作描述: +倾听对方继续说话,你感觉对方的话还没说完,或者刚刚发了好几条连续消息,这时你可以选择保持安静,专注“听”而不是马上回复。 +{{ + "action": "listening", + "target_message_id":"你正在倾听的那条消息id(通常是对方的最新消息)", + "reason":"选择倾听的原因" +}} + +block_and_ignore +动作描述: +当你觉得当前对话让你非常不适、存在明显骚扰或恶意时,可以选择在一段时间内不再主动回应该对话(对方再发消息你也先不理)。 +{{ + "action": "block_and_ignore", + "target_message_id":"触发你做出这一决定的消息id", + "reason":"为什么你认为需要暂时屏蔽这段对话" +}} + {action_options_text} 请选择合适的action,并说明触发action的消息id和选择该action的原因。消息id格式:m+数字 @@ -92,7 +120,97 @@ no_reply ``` """, - "brain_planner_prompt", + "brain_planner_prompt_initial", + ) + + # 刚刚已经回复过,对“要不要继续说 / 追问”更敏感的 Planner Prompt + Prompt( + """ +{time_block} +{name_block} +你的兴趣是:{interest} +{chat_context_description},以下是具体的聊天内容 +**聊天内容** +{chat_content_block} + +**动作记录** +{actions_before_now_block} + +**可用的action** +reply +动作描述: +在你刚刚已经进行过一次或多次回复的前提下,你可以选择: +- 继续顺着正在进行的聊天内容进行补充或追问 +- 也可以选择暂时不再回复,给对方留出回复空间 +{{ + "action": "reply", + "target_message_id":"想要回复的消息id", + "reason":"继续回复的原因(或者解释为什么当前仍然适合连续发言)" +}} + +no_reply +动作描述: +保持沉默,等待对方发言,特别是在你已经连续发言或对方长时间未回复的情况下可以更多考虑这一选项 +{{ + "action": "no_reply", +}} + +wait +动作描述: +你刚刚已经发过一轮,现在选择暂时不再继续追问或补充,给对方更多时间和空间来回应。 +{{ + "action": "wait", + "target_message_id":"想要作为这次等待依据的消息id(通常是你刚刚回复的那条或对方的最新消息)", + "reason":"为什么此时更适合等待而不是继续连续发言" +}} + +listening +动作描述: +你感觉对方还有话要说,或者刚刚连续发送了多条消息,这时你可以选择继续“听”而不是马上再插话。 +{{ + "action": "listening", + "target_message_id":"你正在倾听的那条消息id(通常是对方的最新消息)", + "reason":"你为什么认为对方还需要继续表达" +}} + +block_and_ignore +动作描述: +如果你在连续若干轮对话后,明确感到这是不友善的骚扰或让你极度不适的对话,可以选择在一段时间内不再回应这条对话。 +{{ + "action": "block_and_ignore", + "target_message_id":"触发你做出这一决定的消息id", + "reason":"为什么你认为需要暂时屏蔽这段对话" +}} + +{action_options_text} + +请选择合适的action,并说明触发action的消息id和选择该action的原因。消息id格式:m+数字 +先输出你的选择思考理由,再输出你选择的action,理由是一段平文本,不要分点,精简。 +**动作选择要求** +请你根据聊天内容,用户的最新消息和以下标准选择合适的动作: +{plan_style} +{moderation_prompt} + +请选择所有符合使用要求的action,动作用json格式输出,如果输出多个json,每个json都要单独用```json包裹,你可以重复使用同一个动作或不同动作: +**示例** +// 理由文本 +```json +{{ + "action":"动作名", + "target_message_id":"触发动作的消息id", + //对应参数 +}} +``` +```json +{{ + "action":"动作名", + "target_message_id":"触发动作的消息id", + //对应参数 +}} +``` + +""", + "brain_planner_prompt_follow_up", ) Prompt( @@ -171,7 +289,8 @@ class BrainPlanner: # 验证action是否可用 available_action_names = [action_name for action_name, _ in current_available_actions] - internal_action_names = ["no_reply", "reply", "wait_time"] + # 内部保留动作(不依赖插件系统) + internal_action_names = ["no_reply", "reply", "wait_time", "wait", "listening", "block_and_ignore"] if action not in internal_action_names and action not in available_action_names: logger.warning( @@ -215,6 +334,7 @@ class BrainPlanner: self, available_actions: Dict[str, ActionInfo], loop_start_time: float = 0.0, + last_successful_reply: bool = False, ) -> List[ActionPlannerInfo]: # sourcery skip: use-named-expression """ @@ -257,7 +377,11 @@ class BrainPlanner: logger.debug(f"{self.log_prefix}过滤后有{len(filtered_actions)}个可用动作") - # 构建包含所有动作的提示词 + # 构建包含所有动作的提示词:根据是否刚刚成功回复来选择不同的 Prompt + prompt_key = ( + "brain_planner_prompt_follow_up" if last_successful_reply else "brain_planner_prompt_initial" + ) + # 这里不记录日志,避免重复打印,由调用方按需控制 log_prompt prompt, message_id_list = await self.build_planner_prompt( is_group_chat=is_group_chat, chat_target_info=chat_target_info, @@ -265,6 +389,8 @@ class BrainPlanner: chat_content_block=chat_content_block, message_id_list=message_id_list, interest=global_config.personality.interest, + prompt_key=prompt_key, + log_prompt=False, ) # 调用LLM获取决策 @@ -286,6 +412,8 @@ class BrainPlanner: message_id_list: List[Tuple[str, "DatabaseMessages"]], chat_content_block: str = "", interest: str = "", + prompt_key: str = "brain_planner_prompt_initial", + log_prompt: bool = False, ) -> tuple[str, List[Tuple[str, "DatabaseMessages"]]]: """构建 Planner LLM 的提示词 (获取模板并填充数据)""" try: @@ -321,7 +449,7 @@ class BrainPlanner: name_block = f"你的名字是{bot_name}{bot_nickname},请注意哪些是你自己的发言。" # 获取主规划器模板并填充 - planner_prompt_template = await global_prompt_manager.get_prompt_async("brain_planner_prompt") + planner_prompt_template = await global_prompt_manager.get_prompt_async(prompt_key) prompt = planner_prompt_template.format( time_block=time_block, chat_context_description=chat_context_description, @@ -334,6 +462,10 @@ class BrainPlanner: plan_style=global_config.personality.private_plan_style, ) + # 调试:按需展示本次 Planner 使用的 Prompt + if log_prompt: + logger.info(f"{self.log_prefix} BrainPlanner Prompt [{prompt_key}]:\n{prompt}") + return prompt, message_id_list except Exception as e: logger.error(f"构建 Planner 提示词时出错: {e}") diff --git a/src/chat/brain_chat/brain_reply_checker.py b/src/chat/brain_chat/brain_reply_checker.py new file mode 100644 index 00000000..9c2c8602 --- /dev/null +++ b/src/chat/brain_chat/brain_reply_checker.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import traceback +from typing import Tuple, Optional +import time + +from src.common.logger import get_logger +from src.config.config import global_config, model_config +from src.plugin_system.apis import message_api +from src.llm_models.utils_model import LLMRequest + + +logger = get_logger("bc_reply_checker") + + +class BrainReplyChecker: + """ + BrainChat 的轻量级回复检查器 + + 设计目标: + - 与 BrainChat 主循环低耦合:只依赖 chat_id 和 message_api + - 更宽松:只做少量简单检查,尽量不阻塞发送 + - 非 LLM:避免额外的模型调用开销 + """ + + def __init__(self, chat_id: str, max_retries: int = 1) -> None: + self.chat_id = chat_id + # 比 PFC 更宽松:默认只允许 1 次重试 + self.max_retries = max_retries + + def _get_last_bot_text(self) -> Optional[str]: + """ + 获取当前会话中 Bot 最近一次发送的文本内容(如果有)。 + """ + try: + # end_time 必须是数字,这里使用当前时间戳 + recent_messages = message_api.get_messages_by_time_in_chat( + chat_id=self.chat_id, + start_time=0, + end_time=time.time(), + limit=20, + limit_mode="latest", + filter_mai=False, + filter_command=False, + filter_intercept_message_level=1, + ) + + # 使用新配置中的 QQ 账号字段 + bot_id = str(global_config.bot.qq_account) + for msg in reversed(recent_messages): + try: + if str(getattr(msg.user_info, "user_id", "")) == bot_id: + text = getattr(msg, "processed_plain_text", None) + if text: + return str(text) + except Exception: + # 单条消息解析失败不影响整体 + continue + except Exception as e: + logger.warning(f"[{self.chat_id}] 获取最近 Bot 消息失败: {e}") + + return None + + def check( + self, + reply_text: str, + retry_count: int = 0, + ) -> Tuple[bool, str, bool]: + """ + 检查生成的回复是否合适(宽松版本)。 + + 返回: + (suitable, reason, need_retry) + """ + reply_text = reply_text or "" + reply_text = reply_text.strip() + + if not reply_text: + return False, "回复内容为空", retry_count < self.max_retries + + # 1. 与最近一条 Bot 消息做重复/高度相似检查 + last_bot_text = self._get_last_bot_text() + if last_bot_text: + last_bot_text = last_bot_text.strip() + if reply_text == last_bot_text: + logger.info(f"[{self.chat_id}] ReplyChecker: 与上一条 Bot 消息完全相同,尝试重试生成。") + need_retry = retry_count < self.max_retries + return ( + not need_retry, # 如果已经没有重试机会,就放行 + "回复内容与上一条完全相同", + need_retry, + ) + + # 2. 粗略长度限制(过长时给一次重试机会,但整体仍偏宽松) + max_len = 300 + if len(reply_text) > max_len: + logger.info(f"[{self.chat_id}] ReplyChecker: 回复长度为 {len(reply_text)},超过 {max_len} 字。") + need_retry = retry_count < self.max_retries + return ( + not need_retry, # 超过长度但重试耗尽时也允许发送 + f"回复内容偏长({len(reply_text)} 字)", + need_retry, + ) + + # 其他情况全部放行 + return True, "通过检查", False + + +class BrainLLMReplyChecker: + """ + 使用 planner 模型做一次轻量 LLM 逻辑检查。 + + - 不参与主决策,只作为“这句话现在说合适吗”的顾问 + - 至多触发一次重生成机会 + """ + + def __init__(self, chat_id: str, max_retries: int = 1) -> None: + self.chat_id = chat_id + self.max_retries = max_retries + # 复用 planner 模型配置 + self.llm = LLMRequest(model_set=model_config.model_task_config.planner, request_type="brain_reply_check") + + def _build_chat_history_text(self, limit: int = 15) -> str: + """构造一段简短的聊天文本上下文,供 LLM 参考。""" + try: + recent_messages = message_api.get_messages_by_time_in_chat( + chat_id=self.chat_id, + start_time=0, + end_time=time.time(), # end_time 也必须是数字 + limit=limit, + limit_mode="latest", + filter_mai=False, + filter_command=False, + filter_intercept_message_level=1, + ) + + lines = [] + for msg in recent_messages: + try: + user = getattr(msg.user_info, "user_nickname", None) or getattr( + msg.user_info, "user_id", "unknown" + ) + text = getattr(msg, "processed_plain_text", "") or "" + if text: + lines.append(f"{user}: {text}") + except Exception: + continue + + return "\n".join(lines) if lines else "(当前几乎没有聊天记录)" + except Exception as e: + logger.warning(f"[{self.chat_id}] 构造聊天上下文文本失败: {e}") + return "(构造聊天上下文时出错)" + + async def check(self, reply_text: str, retry_count: int = 0) -> Tuple[bool, str, bool]: + """ + 使用 planner 模型检查一次回复是否合适。 + + 返回: + (suitable, reason, need_retry) + """ + reply_text = (reply_text or "").strip() + if not reply_text: + return False, "回复内容为空", retry_count < self.max_retries + + chat_history_text = self._build_chat_history_text() + + prompt = f"""你是一个聊天逻辑检查器,使用 JSON 评估下面这条回复是否适合当前上下文。 + +最近的聊天记录(按时间从旧到新): +{chat_history_text} + +候选回复: +{reply_text} + +请综合考虑: +1. 是否和最近的聊天内容衔接自然 +2. 是否明显重复、啰嗦或完全没必要 +3. 是否有可能被认为不礼貌或不合时宜 +4. 是否在当前时机继续说话会打扰对方(如果对方已经长时间没回,可以宽松一点,只要内容自然即可) + +请只用 JSON 格式回答,不要输出多余文字,例如: +{{ + "suitable": true, + "reason": "整体自然得体" +}} + +其中: +- suitable: 是否建议发送 (true/false) +- reason: 你的简短理由 +""" + + # 调试:展示用于 LLM 检查的 Prompt + logger.info(f"[{self.chat_id}] BrainLLMReplyChecker Prompt:\n{prompt}") + + try: + content, _ = await self.llm.generate_response_async(prompt=prompt) + content = (content or "").strip() + + import json + + result = json.loads(content) + suitable = bool(result.get("suitable", True)) + reason = str(result.get("reason", "未提供原因")).strip() or "未提供原因" + except Exception as e: + logger.warning(f"[{self.chat_id}] LLM 回复检查失败,将默认放行: {e}") + logger.debug(f"[{self.chat_id}] LLM 返回内容: {content[:200] if content else '(空)'}") + logger.debug(traceback.format_exc()) + return True, "LLM 检查失败,默认放行", False + + if not suitable and retry_count < self.max_retries: + # 给一次重新生成机会 + return False, reason, True + + # 不适合但已经没有重试机会时,只记录原因但不强制拦截 + return True, reason, False + + From cef094e1252f095eb991c9528c6ded115f227797 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 5 Dec 2025 01:51:19 +0800 Subject: [PATCH 18/23] =?UTF-8?q?feat=EF=BC=9A=E9=87=8D=E6=9E=84pf=20brain?= =?UTF-8?q?=20chat=E4=BD=BF=E7=94=A8react?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/brain_chat/brain_chat.py | 315 +++++++++++---------- src/chat/brain_chat/brain_planner.py | 172 +++-------- src/chat/brain_chat/brain_reply_checker.py | 217 -------------- 3 files changed, 199 insertions(+), 505 deletions(-) delete mode 100644 src/chat/brain_chat/brain_reply_checker.py diff --git a/src/chat/brain_chat/brain_chat.py b/src/chat/brain_chat/brain_chat.py index 51607547..f20c8865 100644 --- a/src/chat/brain_chat/brain_chat.py +++ b/src/chat/brain_chat/brain_chat.py @@ -25,7 +25,6 @@ from src.chat.utils.chat_message_builder import ( build_readable_messages_with_id, get_raw_msg_before_timestamp_with_chat, ) -from src.chat.brain_chat.brain_reply_checker import BrainReplyChecker, BrainLLMReplyChecker if TYPE_CHECKING: from src.common.data_models.database_data_model import DatabaseMessages @@ -88,11 +87,6 @@ class BrainChatting: self.running: bool = False self._loop_task: Optional[asyncio.Task] = None # 主循环任务 - # 轻量级回复检查器(比 PFC 更宽松) - self.reply_checker = BrainReplyChecker(chat_id=self.stream_id) - # 使用 planner 模型的一次性 LLM 检查器 - self.llm_reply_checker = BrainLLMReplyChecker(chat_id=self.stream_id, max_retries=1) - # 添加循环信息管理相关的属性 self.history_loop: List[CycleDetail] = [] self._cycle_counter = 0 @@ -105,9 +99,6 @@ class BrainChatting: # 最近一次是否成功进行了 reply,用于选择 BrainPlanner 的 Prompt self._last_successful_reply: bool = False - # 类似 PFC 的 block_and_ignore:在该时间点之前不主动参与该聊天 - self._ignore_until_timestamp: Optional[float] = None - async def start(self): """检查是否需要启动主循环,如果未激活则启动。""" @@ -169,14 +160,7 @@ class BrainChatting: ) async def _loopbody(self): # sourcery skip: hoist-if-from-if - # 如果当前处于 block_and_ignore 冷却期,直接跳过本轮思考 - if self._ignore_until_timestamp and time.time() < self._ignore_until_timestamp: - await asyncio.sleep(0.5) - return True - elif self._ignore_until_timestamp and time.time() >= self._ignore_until_timestamp: - logger.info(f"{self.log_prefix} block_and_ignore 冷却结束,恢复该聊天的正常思考") - self._ignore_until_timestamp = None - + # 获取最新消息(用于上下文,但不影响是否调用 observe) recent_messages_list = message_api.get_messages_by_time_in_chat( chat_id=self.stream_id, start_time=self.last_read_time, @@ -188,14 +172,22 @@ class BrainChatting: filter_intercept_message_level=1, ) + # 如果有新消息,更新 last_read_time if len(recent_messages_list) >= 1: self.last_read_time = time.time() - await self._observe(recent_messages_list=recent_messages_list) - - else: - # Normal模式:消息数量不足,等待 - await asyncio.sleep(0.2) + + # 总是执行一次思考迭代(不管有没有新消息) + # wait 动作会在其内部等待,不需要在这里处理 + should_continue = await self._observe(recent_messages_list=recent_messages_list) + + if not should_continue: + # 选择了 complete_talk,停止循环 return True + + # 继续下一次迭代(除非选择了 complete_talk) + # 短暂等待后再继续,避免过于频繁的循环 + await asyncio.sleep(0.1) + return True async def _send_and_store_reply( @@ -292,9 +284,11 @@ class BrainChatting: except Exception as e: logger.error(f"{self.log_prefix} 动作修改失败: {e}") - # 执行planner + # 获取必要信息 is_group_chat, chat_target_info, _ = self.action_planner.get_necessary_info() + # 一次思考迭代:Think - Act - Observe + # 获取聊天上下文 message_list_before_now = get_raw_msg_before_timestamp_with_chat( chat_id=self.stream_id, timestamp=time.time(), @@ -316,9 +310,7 @@ class BrainChatting: chat_content_block=chat_content_block, message_id_list=message_id_list, interest=global_config.personality.interest, - prompt_key=( - "brain_planner_prompt_follow_up" if self._last_successful_reply else "brain_planner_prompt_initial" - ), + prompt_key="brain_planner_prompt_react", log_prompt=True, ) continue_flag, modified_message = await events_manager.handle_mai_events( @@ -333,10 +325,14 @@ class BrainChatting: action_to_use_info = await self.action_planner.plan( loop_start_time=self.last_read_time, available_actions=available_actions, - last_successful_reply=self._last_successful_reply, ) - # 3. 并行执行所有动作 + # 检查是否有 complete_talk 动作(会停止后续迭代) + has_complete_talk = any( + action.action_type == "complete_talk" for action in action_to_use_info + ) + + # 并行执行所有动作 action_tasks = [ asyncio.create_task( self._execute_action(action, action_to_use_info, thinking_id, available_actions, cycle_timers) @@ -368,7 +364,14 @@ class BrainChatting: else: logger.warning(f"{self.log_prefix} 回复动作执行失败") - # 构建最终的循环信息 + # 更新观察时间标记 + self.action_planner.last_obs_time_mark = time.time() + + # 如果选择了 complete_talk,标记为完成,不再继续迭代 + if has_complete_talk: + logger.info(f"{self.log_prefix} 检测到 complete_talk 动作,本次思考完成") + + # 构建循环信息 if reply_loop_info: # 如果有回复信息,使用回复的loop_info作为基础 loop_info = reply_loop_info @@ -394,10 +397,16 @@ class BrainChatting: } _reply_text = action_reply_text + # 如果选择了 complete_talk,返回 False 以停止 _loopbody 的循环 + # 否则返回 True,让 _loopbody 继续下一次迭代 + should_continue = not has_complete_talk + self.end_cycle(loop_info, cycle_timers) self.print_cycle_info(cycle_timers) - return True + # 如果选择了 complete_talk,返回 False 停止循环 + # 否则返回 True,继续下一次思考迭代 + return should_continue async def _main_chat_loop(self): """主循环,持续进行计划并可能回复消息,直到被外部取消。""" @@ -531,12 +540,12 @@ class BrainChatting: """执行单个动作的通用函数""" try: with Timer(f"动作{action_planner_info.action_type}", cycle_timers): - if action_planner_info.action_type == "no_reply": - # 直接处理no_reply逻辑,不再通过动作系统 - reason = action_planner_info.reasoning or "选择不回复" - # logger.info(f"{self.log_prefix} 选择不回复,原因: {reason}") + if action_planner_info.action_type == "complete_talk": + # 直接处理complete_talk逻辑,不再通过动作系统 + reason = action_planner_info.reasoning or "选择完成对话" + logger.info(f"{self.log_prefix} 选择完成对话,原因: {reason}") - # 存储no_reply信息到数据库 + # 存储complete_talk信息到数据库 await database_api.store_action_info( chat_stream=self.chat_stream, action_build_into_prompt=False, @@ -544,134 +553,142 @@ class BrainChatting: action_done=True, thinking_id=thinking_id, action_data={"reason": reason}, - action_name="no_reply", + action_name="complete_talk", ) - return {"action_type": "no_reply", "success": True, "reply_text": "", "command": ""} + return {"action_type": "complete_talk", "success": True, "reply_text": "", "command": ""} elif action_planner_info.action_type == "reply": - # 使用规则 + 一次 LLM ReplyChecker 包一层重试逻辑 - retry_count = 0 - - while True: - try: - success, llm_response = await generator_api.generate_reply( - chat_stream=self.chat_stream, - reply_message=action_planner_info.action_message, - available_actions=available_actions, - chosen_actions=chosen_action_plan_infos, - reply_reason=action_planner_info.reasoning or "", - enable_tool=global_config.tool.enable_tool, - request_type="replyer", - from_plugin=False, - ) - - if not success or not llm_response or not llm_response.reply_set: - if action_planner_info.action_message: - logger.info( - f"对 {action_planner_info.action_message.processed_plain_text} 的回复生成失败" - ) - else: - logger.info("回复生成失败") - return { - "action_type": "reply", - "success": False, - "reply_text": "", - "loop_info": None, - } - - except asyncio.CancelledError: - logger.debug(f"{self.log_prefix} 并行执行:回复生成任务已被取消") - return {"action_type": "reply", "success": False, "reply_text": "", "loop_info": None} - - response_set = llm_response.reply_set - - # 预先拼接一次纯文本,供检查使用(与发送逻辑解耦) - preview_text = "" - for reply_content in response_set.reply_data: - if reply_content.content_type != ReplyContentType.TEXT: - continue - data: str = reply_content.content # type: ignore - preview_text += data - - # 规则检查(不调用 LLM) - rule_suitable, rule_reason, rule_need_retry = self.reply_checker.check( - reply_text=preview_text, retry_count=retry_count + try: + success, llm_response = await generator_api.generate_reply( + chat_stream=self.chat_stream, + reply_message=action_planner_info.action_message, + available_actions=available_actions, + chosen_actions=chosen_action_plan_infos, + reply_reason=action_planner_info.reasoning or "", + enable_tool=global_config.tool.enable_tool, + request_type="replyer", + from_plugin=False, ) - # LLM 检查(使用 planner 模型,一次机会) - llm_suitable, llm_reason, llm_need_retry = await self.llm_reply_checker.check( - reply_text=preview_text, retry_count=retry_count - ) + if not success or not llm_response or not llm_response.reply_set: + if action_planner_info.action_message: + logger.info( + f"对 {action_planner_info.action_message.processed_plain_text} 的回复生成失败" + ) + else: + logger.info("回复生成失败") + return { + "action_type": "reply", + "success": False, + "reply_text": "", + "loop_info": None, + } - # 是否需要重生成:只要有一方建议重试,且还在重试次数之内 - if (rule_need_retry or llm_need_retry) and retry_count < max( - self.reply_checker.max_retries, self.llm_reply_checker.max_retries - ): - retry_count += 1 - logger.info( - f"{self.log_prefix} ReplyChecker 建议重试(第 {retry_count} 次)," - f"rule: {rule_reason}; llm: {llm_reason}" - ) - continue + except asyncio.CancelledError: + logger.debug(f"{self.log_prefix} 并行执行:回复生成任务已被取消") + return {"action_type": "reply", "success": False, "reply_text": "", "loop_info": None} - # 到这里为止,不再重试:即使有一方认为“不太理想”,也只记录原因并放行 - if not rule_suitable or not llm_suitable: - logger.info( - f"{self.log_prefix} ReplyChecker 判断回复可能不太理想," - f"rule: {rule_reason}; llm: {llm_reason},本次仍将发送。" - ) - - selected_expressions = llm_response.selected_expressions - loop_info, reply_text, _ = await self._send_and_store_reply( - response_set=response_set, - action_message=action_planner_info.action_message, # type: ignore - cycle_timers=cycle_timers, - thinking_id=thinking_id, - actions=chosen_action_plan_infos, - selected_expressions=selected_expressions, - ) - # 标记这次循环已经成功进行了回复,下一轮 Planner 使用 follow_up Prompt - self._last_successful_reply = True - return { - "action_type": "reply", - "success": True, - "reply_text": reply_text, - "loop_info": loop_info, - } + response_set = llm_response.reply_set + selected_expressions = llm_response.selected_expressions + loop_info, reply_text, _ = await self._send_and_store_reply( + response_set=response_set, + action_message=action_planner_info.action_message, # type: ignore + cycle_timers=cycle_timers, + thinking_id=thinking_id, + actions=chosen_action_plan_infos, + selected_expressions=selected_expressions, + ) + # 标记这次循环已经成功进行了回复 + self._last_successful_reply = True + return { + "action_type": "reply", + "success": True, + "reply_text": reply_text, + "loop_info": loop_info, + } # 其他动作 else: - # 内建 wait / listening / block_and_ignore:不通过插件系统,直接在这里处理 - if action_planner_info.action_type in ["wait", "listening", "block_and_ignore"]: + # 内建 wait / listening:不通过插件系统,直接在这里处理 + if action_planner_info.action_type in ["wait", "listening"]: reason = action_planner_info.reasoning or "" + action_data = action_planner_info.action_data or {} - if action_planner_info.action_type == "block_and_ignore": - # 设置一段时间的忽略窗口,例如 10 分钟 - ignore_minutes = 10 - self._ignore_until_timestamp = time.time() + ignore_minutes * 60 - logger.info( - f"{self.log_prefix} 收到 block_and_ignore 动作,将在接下来 {ignore_minutes} 分钟内不再主动参与该聊天" + if action_planner_info.action_type == "wait": + # 获取等待时间(必填) + wait_seconds = action_data.get("wait_seconds") + if wait_seconds is None: + logger.warning(f"{self.log_prefix} wait 动作缺少 wait_seconds 参数,使用默认值 5 秒") + wait_seconds = 5 + else: + try: + wait_seconds = float(wait_seconds) + if wait_seconds < 0: + logger.warning(f"{self.log_prefix} wait_seconds 不能为负数,使用默认值 5 秒") + wait_seconds = 5 + except (ValueError, TypeError): + logger.warning(f"{self.log_prefix} wait_seconds 参数格式错误,使用默认值 5 秒") + wait_seconds = 5 + + logger.info(f"{self.log_prefix} 执行 wait 动作,等待 {wait_seconds} 秒") + + # 记录动作信息 + await database_api.store_action_info( + chat_stream=self.chat_stream, + action_build_into_prompt=False, + action_prompt_display=reason or f"等待 {wait_seconds} 秒", + action_done=True, + thinking_id=thinking_id, + action_data={"reason": reason, "wait_seconds": wait_seconds}, + action_name="wait", ) + + # 等待指定时间 + await asyncio.sleep(wait_seconds) + + logger.info(f"{self.log_prefix} wait 动作完成,继续下一次思考") + + # 这些动作本身不产生文本回复 + self._last_successful_reply = False + return { + "action_type": "wait", + "success": True, + "reply_text": "", + "command": "", + } - # 统一将这三种策略动作记录到数据库,便于后续分析 - await database_api.store_action_info( - chat_stream=self.chat_stream, - action_build_into_prompt=False, - action_prompt_display=reason or f"执行动作: {action_planner_info.action_type}", - action_done=True, - thinking_id=thinking_id, - action_data={"reason": reason}, - action_name=action_planner_info.action_type, - ) - - # 这些动作本身不产生文本回复 - self._last_successful_reply = False - return { - "action_type": action_planner_info.action_type, - "success": True, - "reply_text": "", - "command": "", - } + # listening 已合并到 wait,如果遇到则转换为 wait(向后兼容) + elif action_planner_info.action_type == "listening": + logger.debug(f"{self.log_prefix} 检测到 listening 动作,已合并到 wait,自动转换") + # 使用默认等待时间 + wait_seconds = 3 + + logger.info(f"{self.log_prefix} 执行 listening(转换为 wait)动作,等待 {wait_seconds} 秒") + + # 记录动作信息 + await database_api.store_action_info( + chat_stream=self.chat_stream, + action_build_into_prompt=False, + action_prompt_display=reason or f"倾听并等待 {wait_seconds} 秒", + action_done=True, + thinking_id=thinking_id, + action_data={"reason": reason, "wait_seconds": wait_seconds}, + action_name="listening", + ) + + # 等待指定时间 + await asyncio.sleep(wait_seconds) + + logger.info(f"{self.log_prefix} listening 动作完成,继续下一次思考") + + # 这些动作本身不产生文本回复 + self._last_successful_reply = False + return { + "action_type": "listening", + "success": True, + "reply_text": "", + "command": "", + } # 其余动作:走原有插件 Action 体系 with Timer("动作执行", cycle_timers): diff --git a/src/chat/brain_chat/brain_planner.py b/src/chat/brain_chat/brain_planner.py index aa3841a3..fb7725d2 100644 --- a/src/chat/brain_chat/brain_planner.py +++ b/src/chat/brain_chat/brain_planner.py @@ -35,13 +35,14 @@ install(extra_lines=3) def init_prompt(): - # 初次 / 非连续回复时使用的 Planner Prompt + # ReAct 形式的 Planner Prompt Prompt( """ {time_block} {name_block} 你的兴趣是:{interest} {chat_context_description},以下是具体的聊天内容 + **聊天内容** {chat_content_block} @@ -58,40 +59,20 @@ reply "reason":"回复的原因" }} -no_reply -动作描述: -等待,保持沉默,等待对方发言 -{{ - "action": "no_reply", -}} - wait 动作描述: -在当前轮次暂时不再发言,等待对方进一步发言或后续更合适的时机再回复。这通常用于你已经表达清楚一轮,想给对方留出空间。 +暂时不再发言,等待指定时间后再继续下一次思考。适用于以下情况: +- 你已经表达清楚一轮,想给对方留出空间 +- 你感觉对方的话还没说完,或者刚刚发了好几条连续消息 +- 你想保持安静,专注"听"而不是马上回复 +请你根据上下文来判断要等待多久,请你灵活判断 {{ "action": "wait", "target_message_id":"想要作为这次等待依据的消息id(通常是对方的最新消息)", + "wait_seconds": 等待的秒数(必填,例如:5 表示等待5秒), "reason":"选择等待的原因" }} -listening -动作描述: -倾听对方继续说话,你感觉对方的话还没说完,或者刚刚发了好几条连续消息,这时你可以选择保持安静,专注“听”而不是马上回复。 -{{ - "action": "listening", - "target_message_id":"你正在倾听的那条消息id(通常是对方的最新消息)", - "reason":"选择倾听的原因" -}} - -block_and_ignore -动作描述: -当你觉得当前对话让你非常不适、存在明显骚扰或恶意时,可以选择在一段时间内不再主动回应该对话(对方再发消息你也先不理)。 -{{ - "action": "block_and_ignore", - "target_message_id":"触发你做出这一决定的消息id", - "reason":"为什么你认为需要暂时屏蔽这段对话" -}} - {action_options_text} 请选择合适的action,并说明触发action的消息id和选择该action的原因。消息id格式:m+数字 @@ -120,97 +101,7 @@ block_and_ignore ``` """, - "brain_planner_prompt_initial", - ) - - # 刚刚已经回复过,对“要不要继续说 / 追问”更敏感的 Planner Prompt - Prompt( - """ -{time_block} -{name_block} -你的兴趣是:{interest} -{chat_context_description},以下是具体的聊天内容 -**聊天内容** -{chat_content_block} - -**动作记录** -{actions_before_now_block} - -**可用的action** -reply -动作描述: -在你刚刚已经进行过一次或多次回复的前提下,你可以选择: -- 继续顺着正在进行的聊天内容进行补充或追问 -- 也可以选择暂时不再回复,给对方留出回复空间 -{{ - "action": "reply", - "target_message_id":"想要回复的消息id", - "reason":"继续回复的原因(或者解释为什么当前仍然适合连续发言)" -}} - -no_reply -动作描述: -保持沉默,等待对方发言,特别是在你已经连续发言或对方长时间未回复的情况下可以更多考虑这一选项 -{{ - "action": "no_reply", -}} - -wait -动作描述: -你刚刚已经发过一轮,现在选择暂时不再继续追问或补充,给对方更多时间和空间来回应。 -{{ - "action": "wait", - "target_message_id":"想要作为这次等待依据的消息id(通常是你刚刚回复的那条或对方的最新消息)", - "reason":"为什么此时更适合等待而不是继续连续发言" -}} - -listening -动作描述: -你感觉对方还有话要说,或者刚刚连续发送了多条消息,这时你可以选择继续“听”而不是马上再插话。 -{{ - "action": "listening", - "target_message_id":"你正在倾听的那条消息id(通常是对方的最新消息)", - "reason":"你为什么认为对方还需要继续表达" -}} - -block_and_ignore -动作描述: -如果你在连续若干轮对话后,明确感到这是不友善的骚扰或让你极度不适的对话,可以选择在一段时间内不再回应这条对话。 -{{ - "action": "block_and_ignore", - "target_message_id":"触发你做出这一决定的消息id", - "reason":"为什么你认为需要暂时屏蔽这段对话" -}} - -{action_options_text} - -请选择合适的action,并说明触发action的消息id和选择该action的原因。消息id格式:m+数字 -先输出你的选择思考理由,再输出你选择的action,理由是一段平文本,不要分点,精简。 -**动作选择要求** -请你根据聊天内容,用户的最新消息和以下标准选择合适的动作: -{plan_style} -{moderation_prompt} - -请选择所有符合使用要求的action,动作用json格式输出,如果输出多个json,每个json都要单独用```json包裹,你可以重复使用同一个动作或不同动作: -**示例** -// 理由文本 -```json -{{ - "action":"动作名", - "target_message_id":"触发动作的消息id", - //对应参数 -}} -``` -```json -{{ - "action":"动作名", - "target_message_id":"触发动作的消息id", - //对应参数 -}} -``` - -""", - "brain_planner_prompt_follow_up", + "brain_planner_prompt_react", ) Prompt( @@ -270,10 +161,10 @@ class BrainPlanner: action_planner_infos = [] try: - action = action_json.get("action", "no_reply") + action = action_json.get("action", "complete_talk") reasoning = action_json.get("reason", "未提供原因") action_data = {key: value for key, value in action_json.items() if key not in ["action", "reason"]} - # 非no_reply动作需要target_message_id + # 非complete_talk动作需要target_message_id target_message = None if target_message_id := action_json.get("target_message_id"): @@ -290,16 +181,22 @@ class BrainPlanner: # 验证action是否可用 available_action_names = [action_name for action_name, _ in current_available_actions] # 内部保留动作(不依赖插件系统) - internal_action_names = ["no_reply", "reply", "wait_time", "wait", "listening", "block_and_ignore"] + # 注意:listening 已合并到 wait 中,如果遇到 listening 则转换为 wait + internal_action_names = ["complete_talk", "reply", "wait_time", "wait", "listening"] + + # 将 listening 转换为 wait(向后兼容) + if action == "listening": + logger.debug(f"{self.log_prefix}检测到 listening 动作,已合并到 wait,自动转换") + action = "wait" if action not in internal_action_names and action not in available_action_names: logger.warning( - f"{self.log_prefix}LLM 返回了当前不可用或无效的动作: '{action}' (可用: {available_action_names}),将强制使用 'no_reply'" + f"{self.log_prefix}LLM 返回了当前不可用或无效的动作: '{action}' (可用: {available_action_names}),将强制使用 'complete_talk'" ) reasoning = ( f"LLM 返回了当前不可用的动作 '{action}' (可用: {available_action_names})。原始理由: {reasoning}" ) - action = "no_reply" + action = "complete_talk" # 创建ActionPlannerInfo对象 # 将列表转换为字典格式 @@ -320,7 +217,7 @@ class BrainPlanner: available_actions_dict = dict(current_available_actions) action_planner_infos.append( ActionPlannerInfo( - action_type="no_reply", + action_type="complete_talk", reasoning=f"解析单个action时出错: {e}", action_data={}, action_message=None, @@ -334,11 +231,10 @@ class BrainPlanner: self, available_actions: Dict[str, ActionInfo], loop_start_time: float = 0.0, - last_successful_reply: bool = False, ) -> List[ActionPlannerInfo]: # sourcery skip: use-named-expression """ - 规划器 (Planner): 使用LLM根据上下文决定做出什么动作。 + 规划器 (Planner): 使用LLM根据上下文决定做出什么动作(ReAct模式)。 """ # 获取聊天上下文 @@ -377,10 +273,8 @@ class BrainPlanner: logger.debug(f"{self.log_prefix}过滤后有{len(filtered_actions)}个可用动作") - # 构建包含所有动作的提示词:根据是否刚刚成功回复来选择不同的 Prompt - prompt_key = ( - "brain_planner_prompt_follow_up" if last_successful_reply else "brain_planner_prompt_initial" - ) + # 构建包含所有动作的提示词:使用统一的 ReAct Prompt + prompt_key = "brain_planner_prompt_react" # 这里不记录日志,避免重复打印,由调用方按需控制 log_prompt prompt, message_id_list = await self.build_planner_prompt( is_group_chat=is_group_chat, @@ -412,7 +306,7 @@ class BrainPlanner: message_id_list: List[Tuple[str, "DatabaseMessages"]], chat_content_block: str = "", interest: str = "", - prompt_key: str = "brain_planner_prompt_initial", + prompt_key: str = "brain_planner_prompt_react", log_prompt: bool = False, ) -> tuple[str, List[Tuple[str, "DatabaseMessages"]]]: """构建 Planner LLM 的提示词 (获取模板并填充数据)""" @@ -590,7 +484,7 @@ class BrainPlanner: logger.error(f"{self.log_prefix}LLM 请求执行失败: {req_e}") return [ ActionPlannerInfo( - action_type="no_reply", + action_type="complete_talk", reasoning=f"LLM 请求失败,模型出现问题: {req_e}", action_data={}, action_message=None, @@ -609,16 +503,16 @@ class BrainPlanner: else: # 尝试解析为直接的JSON logger.warning(f"{self.log_prefix}LLM没有返回可用动作: {llm_content}") - actions = self._create_no_reply("LLM没有返回可用动作", available_actions) + actions = self._create_complete_talk("LLM没有返回可用动作", available_actions) except Exception as json_e: logger.warning(f"{self.log_prefix}解析LLM响应JSON失败 {json_e}. LLM原始输出: '{llm_content}'") - actions = self._create_no_reply(f"解析LLM响应JSON失败: {json_e}", available_actions) + actions = self._create_complete_talk(f"解析LLM响应JSON失败: {json_e}", available_actions) traceback.print_exc() else: - actions = self._create_no_reply("规划器没有获得LLM响应", available_actions) + actions = self._create_complete_talk("规划器没有获得LLM响应", available_actions) - # 添加循环开始时间到所有非no_reply动作 + # 添加循环开始时间到所有动作 for action in actions: action.action_data = action.action_data or {} action.action_data["loop_start_time"] = loop_start_time @@ -629,11 +523,11 @@ class BrainPlanner: return actions - def _create_no_reply(self, reasoning: str, available_actions: Dict[str, ActionInfo]) -> List[ActionPlannerInfo]: - """创建no_reply""" + def _create_complete_talk(self, reasoning: str, available_actions: Dict[str, ActionInfo]) -> List[ActionPlannerInfo]: + """创建complete_talk""" return [ ActionPlannerInfo( - action_type="no_reply", + action_type="complete_talk", reasoning=reasoning, action_data={}, action_message=None, diff --git a/src/chat/brain_chat/brain_reply_checker.py b/src/chat/brain_chat/brain_reply_checker.py deleted file mode 100644 index 9c2c8602..00000000 --- a/src/chat/brain_chat/brain_reply_checker.py +++ /dev/null @@ -1,217 +0,0 @@ -from __future__ import annotations - -import traceback -from typing import Tuple, Optional -import time - -from src.common.logger import get_logger -from src.config.config import global_config, model_config -from src.plugin_system.apis import message_api -from src.llm_models.utils_model import LLMRequest - - -logger = get_logger("bc_reply_checker") - - -class BrainReplyChecker: - """ - BrainChat 的轻量级回复检查器 - - 设计目标: - - 与 BrainChat 主循环低耦合:只依赖 chat_id 和 message_api - - 更宽松:只做少量简单检查,尽量不阻塞发送 - - 非 LLM:避免额外的模型调用开销 - """ - - def __init__(self, chat_id: str, max_retries: int = 1) -> None: - self.chat_id = chat_id - # 比 PFC 更宽松:默认只允许 1 次重试 - self.max_retries = max_retries - - def _get_last_bot_text(self) -> Optional[str]: - """ - 获取当前会话中 Bot 最近一次发送的文本内容(如果有)。 - """ - try: - # end_time 必须是数字,这里使用当前时间戳 - recent_messages = message_api.get_messages_by_time_in_chat( - chat_id=self.chat_id, - start_time=0, - end_time=time.time(), - limit=20, - limit_mode="latest", - filter_mai=False, - filter_command=False, - filter_intercept_message_level=1, - ) - - # 使用新配置中的 QQ 账号字段 - bot_id = str(global_config.bot.qq_account) - for msg in reversed(recent_messages): - try: - if str(getattr(msg.user_info, "user_id", "")) == bot_id: - text = getattr(msg, "processed_plain_text", None) - if text: - return str(text) - except Exception: - # 单条消息解析失败不影响整体 - continue - except Exception as e: - logger.warning(f"[{self.chat_id}] 获取最近 Bot 消息失败: {e}") - - return None - - def check( - self, - reply_text: str, - retry_count: int = 0, - ) -> Tuple[bool, str, bool]: - """ - 检查生成的回复是否合适(宽松版本)。 - - 返回: - (suitable, reason, need_retry) - """ - reply_text = reply_text or "" - reply_text = reply_text.strip() - - if not reply_text: - return False, "回复内容为空", retry_count < self.max_retries - - # 1. 与最近一条 Bot 消息做重复/高度相似检查 - last_bot_text = self._get_last_bot_text() - if last_bot_text: - last_bot_text = last_bot_text.strip() - if reply_text == last_bot_text: - logger.info(f"[{self.chat_id}] ReplyChecker: 与上一条 Bot 消息完全相同,尝试重试生成。") - need_retry = retry_count < self.max_retries - return ( - not need_retry, # 如果已经没有重试机会,就放行 - "回复内容与上一条完全相同", - need_retry, - ) - - # 2. 粗略长度限制(过长时给一次重试机会,但整体仍偏宽松) - max_len = 300 - if len(reply_text) > max_len: - logger.info(f"[{self.chat_id}] ReplyChecker: 回复长度为 {len(reply_text)},超过 {max_len} 字。") - need_retry = retry_count < self.max_retries - return ( - not need_retry, # 超过长度但重试耗尽时也允许发送 - f"回复内容偏长({len(reply_text)} 字)", - need_retry, - ) - - # 其他情况全部放行 - return True, "通过检查", False - - -class BrainLLMReplyChecker: - """ - 使用 planner 模型做一次轻量 LLM 逻辑检查。 - - - 不参与主决策,只作为“这句话现在说合适吗”的顾问 - - 至多触发一次重生成机会 - """ - - def __init__(self, chat_id: str, max_retries: int = 1) -> None: - self.chat_id = chat_id - self.max_retries = max_retries - # 复用 planner 模型配置 - self.llm = LLMRequest(model_set=model_config.model_task_config.planner, request_type="brain_reply_check") - - def _build_chat_history_text(self, limit: int = 15) -> str: - """构造一段简短的聊天文本上下文,供 LLM 参考。""" - try: - recent_messages = message_api.get_messages_by_time_in_chat( - chat_id=self.chat_id, - start_time=0, - end_time=time.time(), # end_time 也必须是数字 - limit=limit, - limit_mode="latest", - filter_mai=False, - filter_command=False, - filter_intercept_message_level=1, - ) - - lines = [] - for msg in recent_messages: - try: - user = getattr(msg.user_info, "user_nickname", None) or getattr( - msg.user_info, "user_id", "unknown" - ) - text = getattr(msg, "processed_plain_text", "") or "" - if text: - lines.append(f"{user}: {text}") - except Exception: - continue - - return "\n".join(lines) if lines else "(当前几乎没有聊天记录)" - except Exception as e: - logger.warning(f"[{self.chat_id}] 构造聊天上下文文本失败: {e}") - return "(构造聊天上下文时出错)" - - async def check(self, reply_text: str, retry_count: int = 0) -> Tuple[bool, str, bool]: - """ - 使用 planner 模型检查一次回复是否合适。 - - 返回: - (suitable, reason, need_retry) - """ - reply_text = (reply_text or "").strip() - if not reply_text: - return False, "回复内容为空", retry_count < self.max_retries - - chat_history_text = self._build_chat_history_text() - - prompt = f"""你是一个聊天逻辑检查器,使用 JSON 评估下面这条回复是否适合当前上下文。 - -最近的聊天记录(按时间从旧到新): -{chat_history_text} - -候选回复: -{reply_text} - -请综合考虑: -1. 是否和最近的聊天内容衔接自然 -2. 是否明显重复、啰嗦或完全没必要 -3. 是否有可能被认为不礼貌或不合时宜 -4. 是否在当前时机继续说话会打扰对方(如果对方已经长时间没回,可以宽松一点,只要内容自然即可) - -请只用 JSON 格式回答,不要输出多余文字,例如: -{{ - "suitable": true, - "reason": "整体自然得体" -}} - -其中: -- suitable: 是否建议发送 (true/false) -- reason: 你的简短理由 -""" - - # 调试:展示用于 LLM 检查的 Prompt - logger.info(f"[{self.chat_id}] BrainLLMReplyChecker Prompt:\n{prompt}") - - try: - content, _ = await self.llm.generate_response_async(prompt=prompt) - content = (content or "").strip() - - import json - - result = json.loads(content) - suitable = bool(result.get("suitable", True)) - reason = str(result.get("reason", "未提供原因")).strip() or "未提供原因" - except Exception as e: - logger.warning(f"[{self.chat_id}] LLM 回复检查失败,将默认放行: {e}") - logger.debug(f"[{self.chat_id}] LLM 返回内容: {content[:200] if content else '(空)'}") - logger.debug(traceback.format_exc()) - return True, "LLM 检查失败,默认放行", False - - if not suitable and retry_count < self.max_retries: - # 给一次重新生成机会 - return False, reason, True - - # 不适合但已经没有重试机会时,只记录原因但不强制拦截 - return True, reason, False - - From 541799b1c2ed4c8371c6cb3a7ff27de0fd5b704c Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 5 Dec 2025 13:24:02 +0800 Subject: [PATCH 19/23] =?UTF-8?q?feat=EF=BC=9A=E6=B7=BB=E5=8A=A0=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E5=AF=B9=E8=AF=9D=E5=8A=A8=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/brain_chat/brain_chat.py | 39 +++++++++++++++++++++++++--- src/chat/brain_chat/brain_planner.py | 23 +++++++++++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/chat/brain_chat/brain_chat.py b/src/chat/brain_chat/brain_chat.py index f20c8865..31e34ff2 100644 --- a/src/chat/brain_chat/brain_chat.py +++ b/src/chat/brain_chat/brain_chat.py @@ -181,8 +181,8 @@ class BrainChatting: should_continue = await self._observe(recent_messages_list=recent_messages_list) if not should_continue: - # 选择了 complete_talk,停止循环 - return True + # 选择了 complete_talk,返回 False 表示需要等待新消息 + return False # 继续下一次迭代(除非选择了 complete_talk) # 短暂等待后再继续,避免过于频繁的循环 @@ -414,9 +414,13 @@ class BrainChatting: while self.running: # 主循环 success = await self._loopbody() - await asyncio.sleep(0.1) if not success: - break + # 选择了 complete,等待新消息 + logger.info(f"{self.log_prefix} 选择了 complete,等待新消息...") + await self._wait_for_new_message() + # 有新消息后继续循环 + continue + await asyncio.sleep(0.1) except asyncio.CancelledError: # 设置了关闭标志位后被取消是正常流程 logger.info(f"{self.log_prefix} 麦麦已关闭聊天") @@ -426,6 +430,33 @@ class BrainChatting: await asyncio.sleep(3) self._loop_task = asyncio.create_task(self._main_chat_loop()) logger.error(f"{self.log_prefix} 结束了当前聊天循环") + + async def _wait_for_new_message(self): + """等待新消息到达""" + last_check_time = self.last_read_time + check_interval = 1.0 # 每秒检查一次 + + while self.running: + # 检查是否有新消息 + recent_messages_list = message_api.get_messages_by_time_in_chat( + chat_id=self.stream_id, + start_time=last_check_time, + end_time=time.time(), + limit=20, + limit_mode="latest", + filter_mai=True, + filter_command=False, + filter_intercept_message_level=1, + ) + + # 如果有新消息,更新 last_read_time 并返回 + if len(recent_messages_list) >= 1: + self.last_read_time = time.time() + logger.info(f"{self.log_prefix} 检测到新消息,恢复循环") + return + + # 等待一段时间后再次检查 + await asyncio.sleep(check_interval) async def _handle_action( self, diff --git a/src/chat/brain_chat/brain_planner.py b/src/chat/brain_chat/brain_planner.py index fb7725d2..1bfceec8 100644 --- a/src/chat/brain_chat/brain_planner.py +++ b/src/chat/brain_chat/brain_planner.py @@ -61,11 +61,14 @@ reply wait 动作描述: -暂时不再发言,等待指定时间后再继续下一次思考。适用于以下情况: +暂时不再发言,等待指定时间。适用于以下情况: - 你已经表达清楚一轮,想给对方留出空间 -- 你感觉对方的话还没说完,或者刚刚发了好几条连续消息 +- 你感觉对方的话还没说完,或者自己刚刚发了好几条连续消息 +- 你想要等待一定时间来让对方把话说完,或者等待对方反应 - 你想保持安静,专注"听"而不是马上回复 -请你根据上下文来判断要等待多久,请你灵活判断 +请你根据上下文来判断要等待多久,请你灵活判断: +- 如果你们交流间隔时间很短,聊的很频繁,不宜等待太久 +- 如果你们交流间隔时间很长,聊的很少,可以等待较长时间 {{ "action": "wait", "target_message_id":"想要作为这次等待依据的消息id(通常是对方的最新消息)", @@ -73,6 +76,20 @@ wait "reason":"选择等待的原因" }} +complete_talk +动作描述: +当前聊天暂时结束了,对方离开,没有更多话题了 +你可以使用该动作来暂时休息,等待对方有新发言再继续: +- 多次wait之后,对方迟迟不回复消息才用 +- 如果对方只是短暂不回复,应该使用wait而不是complete_talk +- 聊天内容显示当前聊天已经结束或者没有新内容时候,选择complete_talk +选择此动作后,将不再继续循环思考,直到收到对方的新消息 +{{ + "action": "complete_talk", + "target_message_id":"触发完成对话的消息id(通常是对方的最新消息)", + "reason":"选择完成对话的原因" +}} + {action_options_text} 请选择合适的action,并说明触发action的消息id和选择该action的原因。消息id格式:m+数字 From c3798e1a8d65256705d4c02be0908e9a4c5f1f6f Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 5 Dec 2025 13:46:09 +0800 Subject: [PATCH 20/23] =?UTF-8?q?fix=EF=BC=9A=E8=A7=A3=E6=9E=90=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/brain_chat/brain_chat.py | 2 - src/chat/brain_chat/brain_planner.py | 174 +++++++++++++++++++++------ 2 files changed, 139 insertions(+), 37 deletions(-) diff --git a/src/chat/brain_chat/brain_chat.py b/src/chat/brain_chat/brain_chat.py index 31e34ff2..9945b496 100644 --- a/src/chat/brain_chat/brain_chat.py +++ b/src/chat/brain_chat/brain_chat.py @@ -304,14 +304,12 @@ class BrainChatting: ) prompt_info = await self.action_planner.build_planner_prompt( - is_group_chat=is_group_chat, chat_target_info=chat_target_info, current_available_actions=available_actions, chat_content_block=chat_content_block, message_id_list=message_id_list, interest=global_config.personality.interest, prompt_key="brain_planner_prompt_react", - log_prompt=True, ) continue_flag, modified_message = await events_manager.handle_mai_events( EventType.ON_PLAN, None, prompt_info[0], None, self.chat_stream.stream_id diff --git a/src/chat/brain_chat/brain_planner.py b/src/chat/brain_chat/brain_planner.py index 1bfceec8..df143ead 100644 --- a/src/chat/brain_chat/brain_planner.py +++ b/src/chat/brain_chat/brain_planner.py @@ -148,6 +148,9 @@ class BrainPlanner: ) # 用于动作规划 self.last_obs_time_mark = 0.0 + + # 计划日志记录 + self.plan_log: List[Tuple[str, float, List[ActionPlannerInfo]]] = [] def find_message_by_id( self, message_id: str, message_id_list: List[Tuple[str, "DatabaseMessages"]] @@ -179,6 +182,7 @@ class BrainPlanner: try: action = action_json.get("action", "complete_talk") + logger.debug(f"{self.log_prefix}解析动作JSON: action={action}, json={action_json}") reasoning = action_json.get("reason", "未提供原因") action_data = {key: value for key, value in action_json.items() if key not in ["action", "reason"]} # 非complete_talk动作需要target_message_id @@ -201,6 +205,8 @@ class BrainPlanner: # 注意:listening 已合并到 wait 中,如果遇到 listening 则转换为 wait internal_action_names = ["complete_talk", "reply", "wait_time", "wait", "listening"] + logger.debug(f"{self.log_prefix}动作验证: action={action}, internal={internal_action_names}, available={available_action_names}") + # 将 listening 转换为 wait(向后兼容) if action == "listening": logger.debug(f"{self.log_prefix}检测到 listening 动作,已合并到 wait,自动转换") @@ -208,12 +214,13 @@ class BrainPlanner: if action not in internal_action_names and action not in available_action_names: logger.warning( - f"{self.log_prefix}LLM 返回了当前不可用或无效的动作: '{action}' (可用: {available_action_names}),将强制使用 'complete_talk'" + f"{self.log_prefix}LLM 返回了当前不可用或无效的动作: '{action}' (内部动作: {internal_action_names}, 可用插件动作: {available_action_names}),将强制使用 'complete_talk'" ) reasoning = ( f"LLM 返回了当前不可用的动作 '{action}' (可用: {available_action_names})。原始理由: {reasoning}" ) action = "complete_talk" + logger.warning(f"{self.log_prefix}动作已转换为 complete_talk") # 创建ActionPlannerInfo对象 # 将列表转换为字典格式 @@ -294,18 +301,16 @@ class BrainPlanner: prompt_key = "brain_planner_prompt_react" # 这里不记录日志,避免重复打印,由调用方按需控制 log_prompt prompt, message_id_list = await self.build_planner_prompt( - is_group_chat=is_group_chat, chat_target_info=chat_target_info, current_available_actions=filtered_actions, chat_content_block=chat_content_block, message_id_list=message_id_list, interest=global_config.personality.interest, prompt_key=prompt_key, - log_prompt=False, ) # 调用LLM获取决策 - actions = await self._execute_main_planner( + reasoning, actions = await self._execute_main_planner( prompt=prompt, message_id_list=message_id_list, filtered_actions=filtered_actions, @@ -313,18 +318,22 @@ class BrainPlanner: loop_start_time=loop_start_time, ) + # 记录和展示计划日志 + logger.info( + f"{self.log_prefix}Planner: {reasoning}。选择了{len(actions)}个动作: {' '.join([a.action_type for a in actions])}" + ) + self.add_plan_log(reasoning, actions) + return actions async def build_planner_prompt( self, - is_group_chat: bool, chat_target_info: Optional["TargetPersonInfo"], current_available_actions: Dict[str, ActionInfo], message_id_list: List[Tuple[str, "DatabaseMessages"]], chat_content_block: str = "", interest: str = "", prompt_key: str = "brain_planner_prompt_react", - log_prompt: bool = False, ) -> tuple[str, List[Tuple[str, "DatabaseMessages"]]]: """构建 Planner LLM 的提示词 (获取模板并填充数据)""" try: @@ -373,10 +382,6 @@ class BrainPlanner: plan_style=global_config.personality.private_plan_style, ) - # 调试:按需展示本次 Planner 使用的 Prompt - if log_prompt: - logger.info(f"{self.log_prefix} BrainPlanner Prompt [{prompt_key}]:\n{prompt}") - return prompt, message_id_list except Exception as e: logger.error(f"构建 Planner 提示词时出错: {e}") @@ -474,17 +479,18 @@ class BrainPlanner: filtered_actions: Dict[str, ActionInfo], available_actions: Dict[str, ActionInfo], loop_start_time: float, - ) -> List[ActionPlannerInfo]: + ) -> Tuple[str, List[ActionPlannerInfo]]: """执行主规划器""" llm_content = None actions: List[ActionPlannerInfo] = [] + extracted_reasoning = "" try: # 调用LLM llm_content, (reasoning_content, _, _) = await self.planner_llm.generate_response_async(prompt=prompt) - # logger.info(f"{self.log_prefix}规划器原始提示词: {prompt}") - # logger.info(f"{self.log_prefix}规划器原始响应: {llm_content}") + logger.info(f"{self.log_prefix}规划器原始提示词: {prompt}") + logger.info(f"{self.log_prefix}规划器原始响应: {llm_content}") if global_config.debug.show_planner_prompt: logger.info(f"{self.log_prefix}规划器原始提示词: {prompt}") @@ -499,10 +505,11 @@ class BrainPlanner: except Exception as req_e: logger.error(f"{self.log_prefix}LLM 请求执行失败: {req_e}") - return [ + extracted_reasoning = f"LLM 请求失败,模型出现问题: {req_e}" + return extracted_reasoning, [ ActionPlannerInfo( action_type="complete_talk", - reasoning=f"LLM 请求失败,模型出现问题: {req_e}", + reasoning=extracted_reasoning, action_data={}, action_message=None, available_actions=available_actions, @@ -512,22 +519,30 @@ class BrainPlanner: # 解析LLM响应 if llm_content: try: - if json_objects := self._extract_json_from_markdown(llm_content): - logger.debug(f"{self.log_prefix}从响应中提取到{len(json_objects)}个JSON对象") + json_objects, extracted_reasoning = self._extract_json_from_markdown(llm_content) + if json_objects: + logger.info(f"{self.log_prefix}从响应中提取到{len(json_objects)}个JSON对象") + for i, json_obj in enumerate(json_objects): + logger.info(f"{self.log_prefix}解析第{i+1}个JSON对象: {json_obj}") filtered_actions_list = list(filtered_actions.items()) for json_obj in json_objects: - actions.extend(self._parse_single_action(json_obj, message_id_list, filtered_actions_list)) + parsed_actions = self._parse_single_action(json_obj, message_id_list, filtered_actions_list) + logger.info(f"{self.log_prefix}解析后的动作: {[a.action_type for a in parsed_actions]}") + actions.extend(parsed_actions) else: # 尝试解析为直接的JSON logger.warning(f"{self.log_prefix}LLM没有返回可用动作: {llm_content}") - actions = self._create_complete_talk("LLM没有返回可用动作", available_actions) + extracted_reasoning = extracted_reasoning or "LLM没有返回可用动作" + actions = self._create_complete_talk(extracted_reasoning, available_actions) except Exception as json_e: logger.warning(f"{self.log_prefix}解析LLM响应JSON失败 {json_e}. LLM原始输出: '{llm_content}'") - actions = self._create_complete_talk(f"解析LLM响应JSON失败: {json_e}", available_actions) + extracted_reasoning = f"解析LLM响应JSON失败: {json_e}" + actions = self._create_complete_talk(extracted_reasoning, available_actions) traceback.print_exc() else: - actions = self._create_complete_talk("规划器没有获得LLM响应", available_actions) + extracted_reasoning = "规划器没有获得LLM响应" + actions = self._create_complete_talk(extracted_reasoning, available_actions) # 添加循环开始时间到所有动作 for action in actions: @@ -538,7 +553,7 @@ class BrainPlanner: f"{self.log_prefix}规划器决定执行{len(actions)}个动作: {' '.join([a.action_type for a in actions])}" ) - return actions + return extracted_reasoning, actions def _create_complete_talk(self, reasoning: str, available_actions: Dict[str, ActionInfo]) -> List[ActionPlannerInfo]: """创建complete_talk""" @@ -551,34 +566,123 @@ class BrainPlanner: available_actions=available_actions, ) ] + + def add_plan_log(self, reasoning: str, actions: List[ActionPlannerInfo]): + """添加计划日志""" + self.plan_log.append((reasoning, time.time(), actions)) + if len(self.plan_log) > 20: + self.plan_log.pop(0) - def _extract_json_from_markdown(self, content: str) -> List[dict]: + def _extract_json_from_markdown(self, content: str) -> Tuple[List[dict], str]: # sourcery skip: for-append-to-extend - """从Markdown格式的内容中提取JSON对象""" + """从Markdown格式的内容中提取JSON对象和推理内容""" json_objects = [] + reasoning_content = "" # 使用正则表达式查找```json包裹的JSON内容 json_pattern = r"```json\s*(.*?)\s*```" - matches = re.findall(json_pattern, content, re.DOTALL) + markdown_matches = re.findall(json_pattern, content, re.DOTALL) - for match in matches: + # 提取JSON之前的内容作为推理文本 + first_json_pos = len(content) + if markdown_matches: + # 找到第一个```json的位置 + first_json_pos = content.find("```json") + if first_json_pos > 0: + reasoning_content = content[:first_json_pos].strip() + # 清理推理内容中的注释标记 + reasoning_content = re.sub(r"^//\s*", "", reasoning_content, flags=re.MULTILINE) + reasoning_content = reasoning_content.strip() + + # 处理```json包裹的JSON + for match in markdown_matches: try: # 清理可能的注释和格式问题 json_str = re.sub(r"//.*?\n", "\n", match) # 移除单行注释 json_str = re.sub(r"/\*.*?\*/", "", json_str, flags=re.DOTALL) # 移除多行注释 if json_str := json_str.strip(): - json_obj = json.loads(repair_json(json_str)) - if isinstance(json_obj, dict): - json_objects.append(json_obj) - elif isinstance(json_obj, list): - for item in json_obj: - if isinstance(item, dict): - json_objects.append(item) + # 先尝试将整个块作为一个JSON对象或数组(适用于多行JSON) + try: + json_obj = json.loads(repair_json(json_str)) + if isinstance(json_obj, dict): + json_objects.append(json_obj) + elif isinstance(json_obj, list): + for item in json_obj: + if isinstance(item, dict): + json_objects.append(item) + except json.JSONDecodeError: + # 如果整个块解析失败,尝试按行分割(适用于多个单行JSON对象) + lines = [line.strip() for line in json_str.split("\n") if line.strip()] + for line in lines: + try: + # 尝试解析每一行作为独立的JSON对象 + json_obj = json.loads(repair_json(line)) + if isinstance(json_obj, dict): + json_objects.append(json_obj) + elif isinstance(json_obj, list): + for item in json_obj: + if isinstance(item, dict): + json_objects.append(item) + except json.JSONDecodeError: + # 单行解析失败,继续下一行 + continue except Exception as e: - logger.warning(f"解析JSON块失败: {e}, 块内容: {match[:100]}...") + logger.warning(f"{self.log_prefix}解析JSON块失败: {e}, 块内容: {match[:100]}...") continue - return json_objects + # 如果没有找到完整的```json```块,尝试查找不完整的代码块(缺少结尾```) + if not json_objects: + json_start_pos = content.find("```json") + if json_start_pos != -1: + # 找到```json之后的内容 + json_content_start = json_start_pos + 7 # ```json的长度 + # 提取从```json之后到内容结尾的所有内容 + incomplete_json_str = content[json_content_start:].strip() + + # 提取JSON之前的内容作为推理文本 + if json_start_pos > 0: + reasoning_content = content[:json_start_pos].strip() + reasoning_content = re.sub(r"^//\s*", "", reasoning_content, flags=re.MULTILINE) + reasoning_content = reasoning_content.strip() + + if incomplete_json_str: + try: + # 清理可能的注释和格式问题 + json_str = re.sub(r"//.*?\n", "\n", incomplete_json_str) + json_str = re.sub(r"/\*.*?\*/", "", json_str, flags=re.DOTALL) + json_str = json_str.strip() + + if json_str: + # 尝试按行分割,每行可能是一个JSON对象 + lines = [line.strip() for line in json_str.split("\n") if line.strip()] + for line in lines: + try: + json_obj = json.loads(repair_json(line)) + if isinstance(json_obj, dict): + json_objects.append(json_obj) + elif isinstance(json_obj, list): + for item in json_obj: + if isinstance(item, dict): + json_objects.append(item) + except json.JSONDecodeError: + pass + + # 如果按行解析没有成功,尝试将整个块作为一个JSON对象或数组 + if not json_objects: + try: + json_obj = json.loads(repair_json(json_str)) + if isinstance(json_obj, dict): + json_objects.append(json_obj) + elif isinstance(json_obj, list): + for item in json_obj: + if isinstance(item, dict): + json_objects.append(item) + except Exception as e: + logger.debug(f"尝试解析不完整的JSON代码块失败: {e}") + except Exception as e: + logger.debug(f"处理不完整的JSON代码块时出错: {e}") + + return json_objects, reasoning_content init_prompt() From bc045a0c85b2aa9538edef08e2521cb8bae15dbd Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 5 Dec 2025 13:51:51 +0800 Subject: [PATCH 21/23] =?UTF-8?q?feat=EF=BC=9A=E5=8F=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=81=9A=E6=A2=A6=E6=97=B6=E9=97=B4=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update bot_config_template.toml --- src/config/official_configs.py | 63 +++++++++++++++++++++++++++++++ src/dream/dream_agent.py | 12 ++++-- template/bot_config_template.toml | 14 ++++++- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 2def18f2..a1f07b29 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -732,6 +732,69 @@ class DreamConfig(ConfigBase): first_delay_seconds: int = 60 """程序启动后首次做梦前的延迟时间(秒),默认60秒""" + dream_time_ranges: list[str] = field(default_factory=lambda: []) + """ + 做梦时间段配置列表,格式:["HH:MM-HH:MM", ...] + 如果列表为空,则表示全天允许做梦。 + 如果配置了时间段,则只有在这些时间段内才会实际执行做梦流程。 + 时间段外,调度器仍会按间隔检查,但不会进入做梦流程。 + + 示例: + [ + "09:00-22:00", # 白天允许做梦 + "23:00-02:00", # 跨夜时间段(23:00到次日02:00) + ] + + 支持跨夜区间,例如 "23:00-02:00" 表示从23:00到次日02:00。 + """ + + def _now_minutes(self) -> int: + """返回本地时间的分钟数(0-1439)。""" + lt = time.localtime() + return lt.tm_hour * 60 + lt.tm_min + + def _parse_range(self, range_str: str) -> Optional[tuple[int, int]]: + """解析 "HH:MM-HH:MM" 到 (start_min, end_min)。""" + try: + start_str, end_str = [s.strip() for s in range_str.split("-")] + sh, sm = [int(x) for x in start_str.split(":")] + eh, em = [int(x) for x in end_str.split(":")] + return sh * 60 + sm, eh * 60 + em + except Exception: + return None + + def _in_range(self, now_min: int, start_min: int, end_min: int) -> bool: + """ + 判断 now_min 是否在 [start_min, end_min] 区间内。 + 支持跨夜:如果 start > end,则表示跨越午夜。 + """ + if start_min <= end_min: + return start_min <= now_min <= end_min + # 跨夜:例如 23:00-02:00 + return now_min >= start_min or now_min <= end_min + + def is_in_dream_time(self) -> bool: + """ + 检查当前时间是否在允许做梦的时间段内。 + 如果 dream_time_ranges 为空,则返回 True(全天允许)。 + """ + if not self.dream_time_ranges: + return True + + now_min = self._now_minutes() + + for time_range in self.dream_time_ranges: + if not isinstance(time_range, str): + continue + parsed = self._parse_range(time_range) + if not parsed: + continue + start_min, end_min = parsed + if self._in_range(now_min, start_min, end_min): + return True + + return False + def __post_init__(self): """验证配置值""" if self.interval_minutes < 1: diff --git a/src/dream/dream_agent.py b/src/dream/dream_agent.py index c05080b5..c14f8061 100644 --- a/src/dream/dream_agent.py +++ b/src/dream/dream_agent.py @@ -535,10 +535,14 @@ async def start_dream_scheduler( break start_ts = time.time() - try: - await run_dream_cycle_once() - except Exception as e: - logger.error(f"[dream] 单次 dream 周期执行异常: {e}") + # 检查当前时间是否在允许做梦的时间段内 + if not global_config.dream.is_in_dream_time(): + logger.debug("[dream] 当前时间不在允许做梦的时间段内,跳过本次执行") + else: + try: + await run_dream_cycle_once() + except Exception as e: + logger.error(f"[dream] 单次 dream 周期执行异常: {e}") elapsed = time.time() - start_ts # 保证两次执行之间至少间隔 interval_seconds diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 2832ba93..c6e22121 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "7.0.1" +version = "7.0.2" #----以下是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- # 如果你想要修改配置文件,请递增version的值 @@ -117,6 +117,18 @@ interval_minutes = 45 # 做梦时间间隔(分钟),默认30分钟 max_iterations = 20 # 做梦最大轮次,默认20轮 first_delay_seconds = 1200 # 程序启动后首次做梦前的延迟时间(秒),默认60秒 +# 做梦时间段配置,格式:["HH:MM-HH:MM", ...] +# 如果列表为空,则表示全天允许做梦。 +# 如果配置了时间段,则只有在这些时间段内才会实际执行做梦流程。 +# 时间段外,调度器仍会按间隔检查,但不会进入做梦流程。 +# 支持跨夜区间,例如 "23:00-02:00" 表示从23:00到次日02:00。 +# 示例: +dream_time_ranges = [ + # "09:00-22:00", # 白天允许做梦 + "23:00-10:00", # 跨夜时间段(23:00到次日10:00) +] +# dream_time_ranges = [] + [jargon] all_global = true # 是否开启全局黑话模式,注意,此功能关闭后,已经记录的全局黑话不会改变,需要手动删除 From 34825abaa74a8fb6141a4c09b511e79aab87a10d Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 5 Dec 2025 21:23:00 +0800 Subject: [PATCH 22/23] =?UTF-8?q?fix=EF=BC=9A=E8=A1=A8=E8=BE=BE=E5=92=8C?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E9=85=8D=E7=BD=AE=E9=A1=B9=E4=B8=BA0?= =?UTF-8?q?=E6=97=B6bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/official_configs.py | 50 ++++++++++++++++++++++++------- src/express/expression_learner.py | 4 +++ template/bot_config_template.toml | 5 ++-- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index a1f07b29..3e93b56f 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -173,7 +173,11 @@ class ChatConfig(ConfigBase): def get_talk_value(self, chat_id: Optional[str]) -> float: """根据规则返回当前 chat 的动态 talk_value,未匹配则回退到基础值。""" if not self.enable_talk_value_rules or not self.talk_value_rules: - return self.talk_value + result = self.talk_value + # 防止返回0值,自动转换为0.0001 + if result == 0: + return 0.0000001 + return result now_min = self._now_minutes() @@ -199,7 +203,11 @@ class ChatConfig(ConfigBase): start_min, end_min = parsed if self._in_range(now_min, start_min, end_min): try: - return float(value) + result = float(value) + # 防止返回0值,自动转换为0.0001 + if result == 0: + return 0.0000001 + return result except Exception: continue @@ -218,12 +226,20 @@ class ChatConfig(ConfigBase): start_min, end_min = parsed if self._in_range(now_min, start_min, end_min): try: - return float(value) + result = float(value) + # 防止返回0值,自动转换为0.0001 + if result == 0: + return 0.0000001 + return result except Exception: continue # 3) 未命中规则返回基础值 - return self.talk_value + result = self.talk_value + # 防止返回0值,自动转换为0.0001 + if result == 0: + return 0.0000001 + return result @dataclass @@ -345,22 +361,30 @@ class ExpressionConfig(ConfigBase): tuple: (是否使用表达, 是否学习表达, 学习间隔) """ if not self.learning_list: - # 如果没有配置,使用默认值:启用表达,启用学习,300秒间隔 - return True, True, 300 + # 如果没有配置,使用默认值:启用表达,启用学习,学习强度1.0(对应300秒间隔) + return True, True, 1.0 # 优先检查聊天流特定的配置 if chat_stream_id: specific_expression_config = self._get_stream_specific_config(chat_stream_id) if specific_expression_config is not None: - return specific_expression_config + use_expression, enable_learning, learning_intensity = specific_expression_config + # 防止学习强度为0,自动转换为0.0001 + if learning_intensity == 0: + learning_intensity = 0.0000001 + return use_expression, enable_learning, learning_intensity # 检查全局配置(第一个元素为空字符串的配置) global_expression_config = self._get_global_config() if global_expression_config is not None: - return global_expression_config + use_expression, enable_learning, learning_intensity = global_expression_config + # 防止学习强度为0,自动转换为0.0001 + if learning_intensity == 0: + learning_intensity = 0.0000001 + return use_expression, enable_learning, learning_intensity - # 如果都没有匹配,返回默认值 - return True, True, 300 + # 如果都没有匹配,返回默认值:启用表达,启用学习,学习强度1.0(对应300秒间隔) + return True, True, 1.0 def _get_stream_specific_config(self, chat_stream_id: str) -> Optional[tuple[bool, bool, int]]: """ @@ -396,6 +420,9 @@ class ExpressionConfig(ConfigBase): use_expression: bool = config_item[1].lower() == "enable" enable_learning: bool = config_item[2].lower() == "enable" learning_intensity: float = float(config_item[3]) + # 防止学习强度为0,自动转换为0.0001 + if learning_intensity == 0: + learning_intensity = 0.0000001 return use_expression, enable_learning, learning_intensity # type: ignore except (ValueError, IndexError): continue @@ -419,6 +446,9 @@ class ExpressionConfig(ConfigBase): use_expression: bool = config_item[1].lower() == "enable" enable_learning: bool = config_item[2].lower() == "enable" learning_intensity = float(config_item[3]) + # 防止学习强度为0,自动转换为0.0001 + if learning_intensity == 0: + learning_intensity = 0.0000001 return use_expression, enable_learning, learning_intensity # type: ignore except (ValueError, IndexError): continue diff --git a/src/express/expression_learner.py b/src/express/expression_learner.py index c18f3b6d..e39677a9 100644 --- a/src/express/expression_learner.py +++ b/src/express/expression_learner.py @@ -86,6 +86,10 @@ class ExpressionLearner: _, self.enable_learning, self.learning_intensity = global_config.expression.get_expression_config_for_chat( self.chat_id ) + # 防止除以零:如果学习强度为0或负数,使用最小值0.0001 + if self.learning_intensity <= 0: + logger.warning(f"学习强度为 {self.learning_intensity},已自动调整为 0.0001 以避免除以零错误") + self.learning_intensity = 0.0000001 self.min_messages_for_learning = 15 / self.learning_intensity # 触发学习所需的最少消息数 self.min_learning_interval = 120 / self.learning_intensity diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index c6e22121..a978c93a 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -69,6 +69,7 @@ learning_list = [ # 表达学习配置列表,支持按聊天流配置 # 第三位: 是否学习表达 ("enable"/"disable") # 第四位: 学习强度(浮点数),影响学习频率,最短学习时间间隔 = 300/学习强度(秒) # 学习强度越高,学习越频繁;学习强度越低,学习越少 + # 如果学习强度设置为0会自动转换为0.0001以避免除以零错误 ] expression_groups = [ @@ -86,7 +87,7 @@ allow_reflect = [] # 允许进行表达反思的聊天流ID列表,格式:["q [chat] # 麦麦的聊天设置 -talk_value = 1 # 聊天频率,越小越沉默,范围0-1 +talk_value = 1 # 聊天频率,越小越沉默,范围0-1,如果设置为0会自动转换为0.0001以避免除以零错误 mentioned_bot_reply = true # 是否启用提及必回复 max_context_size = 30 # 上下文长度 planner_smooth = 2 # 规划器平滑,增大数值会减小planner负荷,略微降低反应速度,推荐1-5,0为关闭,必须大于等于0 @@ -97,7 +98,7 @@ enable_talk_value_rules = true # 是否启用动态发言频率规则 # 推荐格式(对象数组):{ target="platform:id:type" 或 "", time="HH:MM-HH:MM", value=0.5 } # 说明: # - target 为空字符串表示全局;type 为 group/private,例如:"qq:1919810:group" 或 "qq:114514:private"; -# - 支持跨夜区间,例如 "23:00-02:00";数值范围建议 0-1。 +# - 支持跨夜区间,例如 "23:00-02:00";数值范围建议 0-1,如果 value 设置为0会自动转换为0.0001以避免除以零错误。 talk_value_rules = [ { target = "", time = "00:00-08:59", value = 0.8 }, { target = "", time = "09:00-22:59", value = 1.0 }, From 553a4123fbb5ccb0180a307e84d5f2926b213205 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sat, 6 Dec 2025 00:32:50 +0800 Subject: [PATCH 23/23] =?UTF-8?q?fix=EF=BC=9A=E4=B8=8D=E7=9F=A5=E9=81=93?= =?UTF-8?q?=E4=B8=BA=E4=BB=80=E4=B9=88=E4=B8=80=E7=9B=B4=E5=86=92=E5=87=BA?= =?UTF-8?q?=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dream/tools/__init__.py | 1 + src/dream/tools/create_chat_history_tool.py | 1 + src/dream/tools/delete_chat_history_tool.py | 1 + src/dream/tools/delete_jargon_tool.py | 1 + src/dream/tools/finish_maintenance_tool.py | 1 + src/dream/tools/get_chat_history_detail_tool.py | 1 + src/dream/tools/search_chat_history_tool.py | 1 + src/dream/tools/update_chat_history_tool.py | 1 + src/dream/tools/update_jargon_tool.py | 1 + 9 files changed, 9 insertions(+) diff --git a/src/dream/tools/__init__.py b/src/dream/tools/__init__.py index 0841b09c..488675a6 100644 --- a/src/dream/tools/__init__.py +++ b/src/dream/tools/__init__.py @@ -7,3 +7,4 @@ dream agent 工具实现模块。 + diff --git a/src/dream/tools/create_chat_history_tool.py b/src/dream/tools/create_chat_history_tool.py index 8fe96552..43535c29 100644 --- a/src/dream/tools/create_chat_history_tool.py +++ b/src/dream/tools/create_chat_history_tool.py @@ -63,3 +63,4 @@ def make_create_chat_history(chat_id: str): + diff --git a/src/dream/tools/delete_chat_history_tool.py b/src/dream/tools/delete_chat_history_tool.py index 2e600894..02cedf94 100644 --- a/src/dream/tools/delete_chat_history_tool.py +++ b/src/dream/tools/delete_chat_history_tool.py @@ -26,3 +26,4 @@ def make_delete_chat_history(chat_id: str): # chat_id 目前未直接使用, + diff --git a/src/dream/tools/delete_jargon_tool.py b/src/dream/tools/delete_jargon_tool.py index 5becd0c8..2cf38e56 100644 --- a/src/dream/tools/delete_jargon_tool.py +++ b/src/dream/tools/delete_jargon_tool.py @@ -26,3 +26,4 @@ def make_delete_jargon(chat_id: str): # chat_id 目前未直接使用,预留 + diff --git a/src/dream/tools/finish_maintenance_tool.py b/src/dream/tools/finish_maintenance_tool.py index 5858a625..dbb9f15f 100644 --- a/src/dream/tools/finish_maintenance_tool.py +++ b/src/dream/tools/finish_maintenance_tool.py @@ -17,3 +17,4 @@ def make_finish_maintenance(chat_id: str): # chat_id 目前未直接使用, + diff --git a/src/dream/tools/get_chat_history_detail_tool.py b/src/dream/tools/get_chat_history_detail_tool.py index 8e6c6011..b8b0da54 100644 --- a/src/dream/tools/get_chat_history_detail_tool.py +++ b/src/dream/tools/get_chat_history_detail_tool.py @@ -52,3 +52,4 @@ def make_get_chat_history_detail(chat_id: str): # chat_id 目前未直接使用 + diff --git a/src/dream/tools/search_chat_history_tool.py b/src/dream/tools/search_chat_history_tool.py index e8a2b4ac..0c7d1da6 100644 --- a/src/dream/tools/search_chat_history_tool.py +++ b/src/dream/tools/search_chat_history_tool.py @@ -223,3 +223,4 @@ def make_search_chat_history(chat_id: str): + diff --git a/src/dream/tools/update_chat_history_tool.py b/src/dream/tools/update_chat_history_tool.py index 767a41db..2070facf 100644 --- a/src/dream/tools/update_chat_history_tool.py +++ b/src/dream/tools/update_chat_history_tool.py @@ -52,3 +52,4 @@ def make_update_chat_history(chat_id: str): # chat_id 目前未直接使用, + diff --git a/src/dream/tools/update_jargon_tool.py b/src/dream/tools/update_jargon_tool.py index 28e00e47..9ebfe7b9 100644 --- a/src/dream/tools/update_jargon_tool.py +++ b/src/dream/tools/update_jargon_tool.py @@ -52,3 +52,4 @@ def make_update_jargon(chat_id: str): # chat_id 目前未直接使用,预留 +