diff --git a/.gitignore b/.gitignore index edfd11892..e9506861e 100644 --- a/.gitignore +++ b/.gitignore @@ -327,6 +327,7 @@ run_pet.bat !/plugins/hello_world_plugin !/plugins/take_picture_plugin !/plugins/napcat_adapter_plugin +!/plugins/echo_example config.toml diff --git a/EULA.md b/EULA.md index b8ba3ab24..f34235dae 100644 --- a/EULA.md +++ b/EULA.md @@ -2,7 +2,7 @@ **版本:V1.1** **更新日期:2025年8月26日** **生效日期:2025年8月26日** -**适用的MaiMbot-Pro-Max版本号:所有版本** +**适用的MoFox_Bot版本号:所有版本** **2025© MoFox_Bot项目团队** diff --git a/README.md b/README.md index 2017f28d3..698bba594 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ [![Python](https://img.shields.io/badge/Python-3.10+-3776ab?logo=python&logoColor=white&style=for-the-badge)](https://www.python.org/) [![License](https://img.shields.io/badge/License-GPLv3-d73a49?logo=gnu&logoColor=white&style=for-the-badge)](https://github.com/MoFox-Studio/MoFox_Bot/blob/master/LICENSE) [![Contributors](https://img.shields.io/badge/Contributors-Welcome-brightgreen?logo=github&logoColor=white&style=for-the-badge)](https://github.com/MoFox-Studio/MoFox_Bot/graphs/contributors) -[![Stars](https://img.shields.io/github/stars/MaiBot-Plus/MaiMbot-Pro-Max?style=for-the-badge&logo=star&logoColor=white&color=yellow&label=Stars)](https://github.com/MoFox-Studio/MoFox_Bot/stargazers) -[![Release](https://img.shields.io/github/v/release/MaiBot-Plus/MaiMbot-Pro-Max?style=for-the-badge&logo=github&logoColor=white&color=orange)](https://github.com/MaiBot-Plus/MaiMbot-Pro-Max/releases) +[![Stars](https://img.shields.io/github/stars/MoFox-Studio/MoFox_Bot?style=for-the-badge&logo=star&logoColor=white&color=yellow&label=Stars)](https://github.com/MoFox-Studio/MoFox_Bot/stargazers) +[![Release](https://img.shields.io/github/v/release/MoFox-Studio/MoFox_Bot?style=for-the-badge&logo=github&logoColor=white&color=orange)](https://github.com/MoFox-Studio/MoFox_Bot/releases) [![QQ](https://img.shields.io/badge/QQ-Bot-blue?style=for-the-badge&logo=tencentqq&logoColor=white)](https://github.com/NapNeko/NapCatQQ) @@ -114,8 +114,8 @@ ```bash # 克隆项目 -git clone https://github.com/MaiBot-Plus/MaiMbot-Pro-Max.git -cd MaiMbot-Pro-Max +git clone https://github.com/MoFox-Studio/MoFox_Bot.git +cd MoFox_Bot # 安装依赖 pip install -r requirements.txt diff --git a/bot.py b/bot.py index 80b3f9f1b..29cae9a90 100644 --- a/bot.py +++ b/bot.py @@ -31,7 +31,6 @@ from src.manager.async_task_manager import async_task_manager # noqa from src.config.config import global_config # noqa from src.common.database.database import initialize_sql_database # noqa from src.common.database.sqlalchemy_models import initialize_database as init_db # noqa - logger = get_logger("main") @@ -240,6 +239,7 @@ class MaiBotMain(BaseMain): self.setup_timezone() self.check_and_confirm_eula() self.initialize_database() + return self.create_main_system() diff --git a/docs/PLUS_COMMAND_GUIDE.md b/docs/PLUS_COMMAND_GUIDE.md new file mode 100644 index 000000000..9ea67f47d --- /dev/null +++ b/docs/PLUS_COMMAND_GUIDE.md @@ -0,0 +1,260 @@ +# 增强命令系统使用指南 + +## 概述 + +增强命令系统是MaiBot插件系统的一个扩展,让命令的定义和使用变得更加简单直观。你不再需要编写复杂的正则表达式,只需要定义命令名、别名和参数处理逻辑即可。 + +## 核心特性 + +- **无需正则表达式**:只需定义命令名和别名 +- **自动参数解析**:提供`CommandArgs`类处理参数 +- **命令别名支持**:一个命令可以有多个别名 +- **优先级控制**:支持命令优先级设置 +- **聊天类型限制**:可限制命令在群聊或私聊中使用 +- **消息拦截**:可选择是否拦截消息进行后续处理 + +## 快速开始 + +### 1. 创建基础命令 + +```python +from src.plugin_system import PlusCommand, CommandArgs, ChatType +from typing import Tuple, Optional + +class EchoCommand(PlusCommand): + """Echo命令示例""" + + command_name = "echo" + command_description = "回显命令" + command_aliases = ["say", "repeat"] # 可选:命令别名 + priority = 5 # 可选:优先级,数字越大优先级越高 + chat_type_allow = ChatType.ALL # 可选:ALL, GROUP, PRIVATE + intercept_message = True # 可选:是否拦截消息 + + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + """执行命令""" + if args.is_empty(): + await self.send_text("❓ 请提供要回显的内容\\n用法: /echo <内容>") + return True, "参数不足", True + + content = args.get_raw() + await self.send_text(f"🔊 {content}") + + return True, "Echo命令执行成功", True +``` + +### 2. 在插件中注册命令 + +```python +from src.plugin_system import BasePlugin, create_plus_command_adapter, register_plugin + +@register_plugin +class MyPlugin(BasePlugin): + plugin_name = "my_plugin" + enable_plugin = True + dependencies = [] + python_dependencies = [] + config_file_name = "config.toml" + + def get_plugin_components(self): + components = [] + + # 使用工厂函数创建适配器 + echo_adapter = create_plus_command_adapter(EchoCommand) + components.append((EchoCommand.get_command_info(), echo_adapter)) + + return components +``` + +## CommandArgs 类详解 + +`CommandArgs`类提供了丰富的参数处理功能: + +### 基础方法 + +```python +# 获取原始参数字符串 +raw_text = args.get_raw() + +# 获取解析后的参数列表(按空格分割,支持引号) +arg_list = args.get_args() + +# 检查是否有参数 +if args.is_empty(): + # 没有参数的处理 + +# 获取参数数量 +count = args.count() +``` + +### 获取特定参数 + +```python +# 获取第一个参数 +first_arg = args.get_first("默认值") + +# 获取指定索引的参数 +second_arg = args.get_arg(1, "默认值") + +# 获取从指定位置开始的剩余参数 +remaining = args.get_remaining(1) # 从第2个参数开始 +``` + +### 标志参数处理 + +```python +# 检查是否包含标志 +if args.has_flag("--verbose"): + # 处理verbose模式 + +# 获取标志的值 +output_file = args.get_flag_value("--output", "default.txt") +name = args.get_flag_value("--name", "Anonymous") +``` + +## 高级示例 + +### 1. 带子命令的复杂命令 + +```python +class TestCommand(PlusCommand): + command_name = "test" + command_description = "测试命令,展示参数解析功能" + command_aliases = ["t"] + + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + if args.is_empty(): + await self.send_text("用法: /test <子命令> [参数]") + return True, "显示帮助", True + + subcommand = args.get_first().lower() + + if subcommand == "args": + result = f""" +🔍 参数解析结果: +原始字符串: '{args.get_raw()}' +解析后参数: {args.get_args()} +参数数量: {args.count()} +第一个参数: '{args.get_first()}' +剩余参数: '{args.get_remaining()}' + """ + await self.send_text(result) + + elif subcommand == "flags": + result = f""" +🏴 标志测试结果: +包含 --verbose: {args.has_flag('--verbose')} +包含 -v: {args.has_flag('-v')} +--output 的值: '{args.get_flag_value('--output', '未设置')}' +--name 的值: '{args.get_flag_value('--name', '未设置')}' + """ + await self.send_text(result) + + else: + await self.send_text(f"❓ 未知的子命令: {subcommand}") + + return True, "Test命令执行成功", True +``` + +### 2. 聊天类型限制示例 + +```python +class PrivateOnlyCommand(PlusCommand): + command_name = "private" + command_description = "仅私聊可用的命令" + chat_type_allow = ChatType.PRIVATE + + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + await self.send_text("这是一个仅私聊可用的命令") + return True, "私聊命令执行", True + +class GroupOnlyCommand(PlusCommand): + command_name = "group" + command_description = "仅群聊可用的命令" + chat_type_allow = ChatType.GROUP + + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + await self.send_text("这是一个仅群聊可用的命令") + return True, "群聊命令执行", True +``` + +### 3. 配置驱动的命令 + +```python +class ConfigurableCommand(PlusCommand): + command_name = "config_cmd" + command_description = "可配置的命令" + + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + # 从插件配置中获取设置 + max_length = self.get_config("commands.max_length", 100) + enabled_features = self.get_config("commands.features", []) + + if args.is_empty(): + await self.send_text("请提供参数") + return True, "无参数", True + + content = args.get_raw() + if len(content) > max_length: + await self.send_text(f"内容过长,最大允许 {max_length} 字符") + return True, "内容过长", True + + # 根据配置决定功能 + if "uppercase" in enabled_features: + content = content.upper() + + await self.send_text(f"处理结果: {content}") + return True, "配置命令执行", True +``` + +## 支持的命令前缀 + +系统支持以下命令前缀(在`config/bot_config.toml`中配置): + +- `/` - 斜杠(默认) +- `!` - 感叹号 +- `.` - 点号 +- `#` - 井号 + +例如,对于echo命令,以下调用都是有效的: +- `/echo Hello` +- `!echo Hello` +- `.echo Hello` +- `#echo Hello` + +## 返回值说明 + +`execute`方法需要返回一个三元组: + +```python +return (执行成功标志, 可选消息, 是否拦截后续处理) +``` + +- **执行成功标志** (bool): True表示命令执行成功,False表示失败 +- **可选消息** (Optional[str]): 用于日志记录的消息 +- **是否拦截后续处理** (bool): True表示拦截消息,不进行后续处理 + +## 最佳实践 + +1. **命令命名**:使用简短、直观的命令名 +2. **别名设置**:为常用命令提供简短别名 +3. **参数验证**:总是检查参数的有效性 +4. **错误处理**:提供清晰的错误提示和使用说明 +5. **配置支持**:重要设置应该可配置 +6. **聊天类型**:根据命令功能选择合适的聊天类型限制 + +## 完整示例 + +完整的插件示例请参考 `plugins/echo_example/plugin.py` 文件。 + +## 与传统BaseCommand的区别 + +| 特性 | PlusCommand | BaseCommand | +|------|-------------|-------------| +| 正则表达式 | 自动生成 | 手动编写 | +| 参数解析 | CommandArgs类 | 手动处理 | +| 别名支持 | 内置支持 | 需要在正则中处理 | +| 代码复杂度 | 简单 | 复杂 | +| 学习曲线 | 平缓 | 陡峭 | + +增强命令系统让插件开发变得更加简单和高效,特别适合新手开发者快速上手。 diff --git a/docs/plugins/action-components.md b/docs/plugins/action-components.md index 56608454c..d23df823c 100644 --- a/docs/plugins/action-components.md +++ b/docs/plugins/action-components.md @@ -68,15 +68,15 @@ class ExampleAction(BaseAction): Action采用**两层决策机制**来优化性能和决策质量: -> 设计目的:在加载许多插件的时候降低LLM决策压力,避免让麦麦在过多的选项中纠结。 +> 设计目的:在加载许多插件的时候降低LLM决策压力,避免让MoFox-Bot在过多的选项中纠结。 **第一层:激活控制(Activation Control)** -激活决定麦麦是否 **“知道”** 这个Action的存在,即这个Action是否进入决策候选池。不被激活的Action麦麦永远不会选择。 +激活决定MoFox-Bot是否 **“知道”** 这个Action的存在,即这个Action是否进入决策候选池。不被激活的ActionMoFox-Bot永远不会选择。 **第二层:使用决策(Usage Decision)** -在Action被激活后,使用条件决定麦麦什么时候会 **“选择”** 使用这个Action。 +在Action被激活后,使用条件决定MoFox-Bot什么时候会 **“选择”** 使用这个Action。 ### 决策参数详解 🔧 @@ -84,8 +84,8 @@ Action采用**两层决策机制**来优化性能和决策质量: | 激活类型 | 说明 | 使用场景 | | ----------- | ---------------------------------------- | ---------------------- | -| [`NEVER`](#never-激活) | 从不激活,Action对麦麦不可见 | 临时禁用某个Action | -| [`ALWAYS`](#always-激活) | 永远激活,Action总是在麦麦的候选池中 | 核心功能,如回复、不回复 | +| [`NEVER`](#never-激活) | 从不激活,Action对MoFox-Bot不可见 | 临时禁用某个Action | +| [`ALWAYS`](#always-激活) | 永远激活,Action总是在MoFox-Bot的候选池中 | 核心功能,如回复、不回复 | | [`LLM_JUDGE`](#llm_judge-激活) | 通过LLM智能判断当前情境是否需要激活此Action | 需要智能判断的复杂场景 | | `RANDOM` | 基于随机概率决定是否激活 | 增加行为随机性的功能 | | `KEYWORD` | 当检测到特定关键词时激活 | 明确触发条件的功能 | @@ -184,13 +184,13 @@ class GreetingAction(BaseAction): #### 第二层:使用决策 -**在Action被激活后,使用条件决定麦麦什么时候会"选择"使用这个Action**。 +**在Action被激活后,使用条件决定MoFox-Bot什么时候会"选择"使用这个Action**。 这一层由以下因素综合决定: - `action_require`:使用场景描述,帮助LLM判断何时选择 - `action_parameters`:所需参数,影响Action的可执行性 -- 当前聊天上下文和麦麦的决策逻辑 +- 当前聊天上下文和MoFox-Bot的决策逻辑 --- @@ -214,11 +214,11 @@ class EmojiAction(BaseAction): 1. **第一层激活判断**: - - 使用随机数进行决策,当`random.random() < self.random_activation_probability`时,麦麦才"知道"可以使用这个Action + - 使用随机数进行决策,当`random.random() < self.random_activation_probability`时,MoFox-Bot才"知道"可以使用这个Action 2. **第二层使用决策**: - - 即使Action被激活,麦麦还会根据 `action_require` 中的条件判断是否真正选择使用 - - 例如:如果刚刚已经发过表情,根据"不要连续发送多个表情"的要求,麦麦可能不会选择这个Action + - 即使Action被激活,MoFox-Bot还会根据 `action_require` 中的条件判断是否真正选择使用 + - 例如:如果刚刚已经发过表情,根据"不要连续发送多个表情"的要求,MoFox-Bot可能不会选择这个Action --- diff --git a/docs/plugins/tool_caching_guide.md b/docs/plugins/tool_caching_guide.md new file mode 100644 index 000000000..d670a9f1a --- /dev/null +++ b/docs/plugins/tool_caching_guide.md @@ -0,0 +1,124 @@ +# 自动化工具缓存系统使用指南 + +为了提升性能并减少不必要的重复计算或API调用,MMC内置了一套强大且易于使用的自动化工具缓存系统。该系统同时支持传统的**精确缓存**和先进的**语义缓存**。工具开发者无需编写任何手动缓存逻辑,只需在工具类中设置几个属性,即可轻松启用和配置缓存行为。 + +## 核心概念 + +- **精确缓存 (KV Cache)**: 当一个工具被调用时,系统会根据工具名称和所有参数生成一个唯一的键。只有当**下一次调用的工具名和所有参数与之前完全一致**时,才会命中缓存。 +- **语义缓存 (Vector Cache)**: 它不要求参数完全一致,而是理解参数的**语义和意图**。例如,`"查询深圳今天的天气"` 和 `"今天深圳天气怎么样"` 这两个不同的查询,在语义上是高度相似的。如果启用了语义缓存,第二个查询就能成功命中由第一个查询产生的缓存结果。 + +## 如何为你的工具启用缓存 + +为你的工具(必须继承自 `BaseTool`)启用缓存非常简单,只需在你的工具类定义中添加以下一个或多个属性即可: + +### 1. `enable_cache: bool` + +这是启用缓存的总开关。 + +- **类型**: `bool` +- **默认值**: `False` +- **作用**: 设置为 `True` 即可为该工具启用缓存功能。如果为 `False`,后续的所有缓存配置都将无效。 + +**示例**: +```python +class MyAwesomeTool(BaseTool): + # ... 其他定义 ... + enable_cache: bool = True +``` + +### 2. `cache_ttl: int` + +设置缓存的生存时间(Time-To-Live)。 + +- **类型**: `int` +- **单位**: 秒 +- **默认值**: `3600` (1小时) +- **作用**: 定义缓存条目在被视为过期之前可以存活多长时间。 + +**示例**: +```python +class MyLongTermCacheTool(BaseTool): + # ... 其他定义 ... + enable_cache: bool = True + cache_ttl: int = 86400 # 缓存24小时 +``` + +### 3. `semantic_cache_query_key: Optional[str]` + +启用语义缓存的关键。 + +- **类型**: `Optional[str]` +- **默认值**: `None` +- **作用**: + - 将此属性的值设置为你工具的某个**参数的名称**(字符串)。 + - 自动化缓存系统在工作时,会提取该参数的值,将其转换为向量,并进行语义相似度搜索。 + - 如果该值为 `None`,则此工具**仅使用精确缓存**。 + +**示例**: +```python +class WebSurfingTool(BaseTool): + name: str = "web_search" + parameters = [ + ("query", ToolParamType.STRING, "要搜索的关键词或问题。", True, None), + # ... 其他参数 ... + ] + + # --- 缓存配置 --- + enable_cache: bool = True + cache_ttl: int = 7200 # 缓存2小时 + semantic_cache_query_key: str = "query" # <-- 关键! +``` +在上面的例子中,`web_search` 工具的 `"query"` 参数值(例如,用户输入的搜索词)将被用于语义缓存搜索。 + +## 完整示例 + +假设我们有一个调用外部API来获取股票价格的工具。由于股价在短时间内相对稳定,且查询意图可能相似(如 "苹果股价" vs "AAPL股价"),因此非常适合使用缓存。 + +```python +# in your_plugin/tools/stock_checker.py + +from src.plugin_system import BaseTool, ToolParamType + +class StockCheckerTool(BaseTool): + """ + 一个用于查询股票价格的工具。 + """ + name: str = "get_stock_price" + description: str = "获取指定公司或股票代码的最新价格。" + available_for_llm: bool = True + parameters = [ + ("symbol", ToolParamType.STRING, "公司名称或股票代码 (e.g., 'AAPL', '苹果')", True, None), + ] + + # --- 缓存配置 --- + # 1. 开启缓存 + enable_cache: bool = True + # 2. 股价信息缓存10分钟 + cache_ttl: int = 600 + # 3. 使用 "symbol" 参数进行语义搜索 + semantic_cache_query_key: str = "symbol" + # -------------------- + + async def execute(self, function_args: dict[str, Any]) -> dict[str, Any]: + symbol = function_args.get("symbol") + + # ... 这里是你调用外部API获取股票价格的逻辑 ... + # price = await some_stock_api.get_price(symbol) + price = 123.45 # 示例价格 + + return { + "type": "stock_price_result", + "content": f"{symbol} 的当前价格是 ${price}" + } + +``` + +通过以上简单的三行配置,`StockCheckerTool` 现在就拥有了强大的自动化缓存能力: + +- 当用户查询 `"苹果"` 时,工具会执行并缓存结果。 +- 在接下来的10分钟内,如果再次查询 `"苹果"`,将直接从精确缓存返回结果。 +- 更智能的是,如果另一个用户查询 `"AAPL"`,语义缓存系统会识别出 `"AAPL"` 和 `"苹果"` 在语义上高度相关,大概率也会直接返回缓存的结果,而无需再次调用API。 + +--- + +现在,你可以专注于实现工具的核心逻辑,把缓存的复杂性交给MMC的自动化系统来处理。 \ No newline at end of file diff --git a/docs/vector_db_usage_guide.md b/docs/vector_db_usage_guide.md new file mode 100644 index 000000000..49f826810 --- /dev/null +++ b/docs/vector_db_usage_guide.md @@ -0,0 +1,128 @@ +# 统一向量数据库服务使用指南 + +本文档旨在说明如何在 `mmc` 项目中使用新集成的统一向量数据库服务。该服务提供了一个标准化的接口,用于与底层向量数据库(当前为 ChromaDB)进行交互,同时确保了代码的解耦和未来的可扩展性。 + +## 核心设计理念 + +1. **统一入口**: 所有对向量数据库的操作都应通过全局单例 `vector_db_service` 进行。 +2. **抽象接口**: 服务遵循 `VectorDBBase` 抽象基类定义的接口,未来可以轻松替换为其他向量数据库(如 Milvus, FAISS)而无需修改业务代码。 +3. **单例模式**: 整个应用程序共享一个数据库客户端实例,避免了资源浪费和管理混乱。 +4. **数据隔离**: 使用不同的 `collection` 名称来隔离不同业务模块(如语义缓存、瞬时记忆)的数据。在 `collection` 内部,使用 `metadata` 字段(如 `chat_id`)来隔离不同用户或会话的数据。 + +## 如何使用 + +### 1. 导入服务 + +在任何需要使用向量数据库的文件中,只需导入全局服务实例: + +```python +from src.common.vector_db import vector_db_service +``` + +### 2. 主要操作 + +`vector_db_service` 对象提供了所有你需要的方法,这些方法都定义在 `VectorDBBase` 中。 + +#### a. 获取或创建集合 (Collection) + +在操作数据之前,你需要先指定一个集合。如果集合不存在,它将被自动创建。 + +```python +# 为语义缓存创建一个集合 +vector_db_service.get_or_create_collection(name="semantic_cache") + +# 为瞬时记忆创建一个集合 +vector_db_service.get_or_create_collection( + name="instant_memory", + metadata={"hnsw:space": "cosine"} # 可以传入特定于实现的参数 +) +``` + +#### b. 添加数据 + +使用 `add` 方法向指定集合中添加向量、文档和元数据。 + +```python +collection_name = "instant_memory" +chat_id = "user_123" +message_id = "msg_abc" +embedding_vector = [0.1, 0.2, 0.3, ...] # 你的 embedding 向量 +content = "你好,这是一个测试消息" + +vector_db_service.add( + collection_name=collection_name, + embeddings=[embedding_vector], + documents=[content], + metadatas=[{ + "chat_id": chat_id, + "timestamp": 1678886400.0, + "sender": "user" + }], + ids=[message_id] +) +``` + +#### c. 查询数据 + +使用 `query` 方法来查找相似的向量。你可以使用 `where` 子句来过滤元数据。 + +```python +query_vector = [0.11, 0.22, 0.33, ...] # 用于查询的向量 +collection_name = "instant_memory" +chat_id_to_query = "user_123" + +results = vector_db_service.query( + collection_name=collection_name, + query_embeddings=[query_vector], + n_results=5, # 返回最相似的5个结果 + where={"chat_id": chat_id_to_query} # **重要**: 使用 where 来隔离不同聊天的数据 +) + +# results 的结构: +# { +# 'ids': [['msg_abc']], +# 'distances': [[0.0123]], +# 'metadatas': [[{'chat_id': 'user_123', ...}]], +# 'embeddings': None, +# 'documents': [['你好,这是一个测试消息']] +# } +print(results) +``` + +#### d. 删除数据 + +你可以根据 `id` 或 `where` 条件来删除数据。 + +```python +# 根据 ID 删除 +vector_db_service.delete( + collection_name="instant_memory", + ids=["msg_abc"] +) + +# 根据 where 条件删除 (例如,删除某个用户的所有记忆) +vector_db_service.delete( + collection_name="instant_memory", + where={"chat_id": "user_123"} +) +``` + +#### e. 获取集合数量 + +使用 `count` 方法获取一个集合中的条目总数。 + +```python +count = vector_db_service.count(collection_name="semantic_cache") +print(f"语义缓存集合中有 {count} 条数据。") +``` +**注意**: `count` 方法目前返回整个集合的条目数,不会根据 `where` 条件进行过滤。 + +### 3. 代码位置 + +- **抽象基类**: [`mmc/src/common/vector_db/base.py`](mmc/src/common/vector_db/base.py) +- **ChromaDB 实现**: [`mmc/src/common/vector_db/chromadb_impl.py`](mmc/src/common/vector_db/chromadb_impl.py) +- **服务入口**: [`mmc/src/common/vector_db/__init__.py`](mmc/src/common/vector_db/__init__.py) + +--- + +这份完整的文档应该能帮助您和团队的其他成员正确地使用新的向量数据库服务。如果您有任何其他问题,请随时提出。 \ No newline at end of file diff --git a/plugins/echo_example/_manifest.json b/plugins/echo_example/_manifest.json new file mode 100644 index 000000000..02cd1a72f --- /dev/null +++ b/plugins/echo_example/_manifest.json @@ -0,0 +1,53 @@ +{ + "manifest_version": 1, + "format_version": "1.0.0", + "name": "Echo 示例插件", + "description": "展示增强命令系统的Echo命令示例插件", + "version": "1.0.0", + "author": { + "name": "MoFox" + }, + "license": "MIT", + "keywords": ["echo", "example", "command"], + "categories": ["utility", "example"], + "host_application": { + "name": "MaiBot", + "min_version": "0.10.0" + }, + "entry_points": { + "main": "plugin.py" + }, + "plugin_info": { + "is_built_in": false, + "plugin_type": "example", + "components": [ + { + "type": "command", + "name": "echo", + "description": "回显命令,支持别名 say, repeat" + }, + { + "type": "command", + "name": "hello", + "description": "问候命令,支持别名 hi, greet" + }, + { + "type": "command", + "name": "info", + "description": "显示插件信息,支持别名 about" + }, + { + "type": "command", + "name": "test", + "description": "测试命令,展示参数解析功能" + } + ], + "features": [ + "增强命令系统示例", + "无需正则表达式的命令定义", + "命令别名支持", + "参数解析功能", + "聊天类型限制" + ] + } +} diff --git a/plugins/echo_example/plugin.py b/plugins/echo_example/plugin.py new file mode 100644 index 000000000..96a5af204 --- /dev/null +++ b/plugins/echo_example/plugin.py @@ -0,0 +1,203 @@ +""" +Echo 示例插件 + +展示增强命令系统的使用方法 +""" + +from typing import List, Tuple, Type, Optional, Union +from src.plugin_system import ( + BasePlugin, + PlusCommand, + CommandArgs, + PlusCommandInfo, + ConfigField, + ChatType, + register_plugin, +) +from src.plugin_system.base.component_types import PythonDependency + + +class EchoCommand(PlusCommand): + """Echo命令示例""" + + command_name = "echo" + command_description = "回显命令" + command_aliases = ["say", "repeat"] + priority = 5 + chat_type_allow = ChatType.ALL + intercept_message = True + + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + """执行echo命令""" + if args.is_empty(): + await self.send_text("❓ 请提供要回显的内容\n用法: /echo <内容>") + return True, "参数不足", True + + content = args.get_raw() + + # 检查内容长度限制 + max_length = self.get_config("commands.max_content_length", 500) + if len(content) > max_length: + await self.send_text(f"❌ 内容过长,最大允许 {max_length} 字符") + return True, "内容过长", True + + await self.send_text(f"🔊 {content}") + + return True, "Echo命令执行成功", True + + +class HelloCommand(PlusCommand): + """Hello命令示例""" + + command_name = "hello" + command_description = "问候命令" + command_aliases = ["hi", "greet"] + priority = 3 + chat_type_allow = ChatType.ALL + intercept_message = True + + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + """执行hello命令""" + if args.is_empty(): + await self.send_text("👋 Hello! 很高兴见到你!") + else: + name = args.get_first() + await self.send_text(f"👋 Hello, {name}! 很高兴见到你!") + + return True, "Hello命令执行成功", True + + +class InfoCommand(PlusCommand): + """信息命令示例""" + + command_name = "info" + command_description = "显示插件信息" + command_aliases = ["about"] + priority = 1 + chat_type_allow = ChatType.ALL + intercept_message = True + + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + """执行info命令""" + info_text = ( + "📋 Echo 示例插件信息\n" + "版本: 1.0.0\n" + "作者: MaiBot Team\n" + "描述: 展示增强命令系统的使用方法\n\n" + "🎯 可用命令:\n" + "• /echo|/say|/repeat <内容> - 回显内容\n" + "• /hello|/hi|/greet [名字] - 问候\n" + "• /info|/about - 显示此信息\n" + "• /test <子命令> [参数] - 测试各种功能" + ) + await self.send_text(info_text) + + return True, "Info命令执行成功", True + + +class TestCommand(PlusCommand): + """测试命令示例,展示参数解析功能""" + + command_name = "test" + command_description = "测试命令,展示参数解析功能" + command_aliases = ["t"] + priority = 2 + chat_type_allow = ChatType.ALL + intercept_message = True + + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + """执行test命令""" + if args.is_empty(): + help_text = ( + "🧪 测试命令帮助\n" + "用法: /test <子命令> [参数]\n\n" + "可用子命令:\n" + "• args - 显示参数解析结果\n" + "• flags - 测试标志参数\n" + "• count - 计算参数数量\n" + "• join - 连接所有参数" + ) + await self.send_text(help_text) + return True, "显示帮助", True + + subcommand = args.get_first().lower() + + if subcommand == "args": + result = ( + f"🔍 参数解析结果:\n" + f"原始字符串: '{args.get_raw()}'\n" + f"解析后参数: {args.get_args()}\n" + f"参数数量: {args.count()}\n" + f"第一个参数: '{args.get_first()}'\n" + f"剩余参数: '{args.get_remaining()}'" + ) + await self.send_text(result) + + elif subcommand == "flags": + result = ( + f"🏴 标志测试结果:\n" + f"包含 --verbose: {args.has_flag('--verbose')}\n" + f"包含 -v: {args.has_flag('-v')}\n" + f"--output 的值: '{args.get_flag_value('--output', '未设置')}'\n" + f"--name 的值: '{args.get_flag_value('--name', '未设置')}'" + ) + await self.send_text(result) + + elif subcommand == "count": + count = args.count() - 1 # 减去子命令本身 + await self.send_text(f"📊 除子命令外的参数数量: {count}") + + elif subcommand == "join": + remaining = args.get_remaining() + if remaining: + await self.send_text(f"🔗 连接结果: {remaining}") + else: + await self.send_text("❌ 没有可连接的参数") + + else: + await self.send_text(f"❓ 未知的子命令: {subcommand}") + + return True, "Test命令执行成功", True + + +@register_plugin +class EchoExamplePlugin(BasePlugin): + """Echo 示例插件""" + + plugin_name: str = "echo_example_plugin" + enable_plugin: bool = True + dependencies: List[str] = [] + python_dependencies: List[Union[str, "PythonDependency"]] = [] + config_file_name: str = "config.toml" + + config_schema = { + "plugin": { + "enabled": ConfigField(bool, default=True, description="是否启用插件"), + "config_version": ConfigField(str, default="1.0.0", description="配置文件版本"), + }, + "commands": { + "echo_enabled": ConfigField(bool, default=True, description="是否启用 Echo 命令"), + "cooldown": ConfigField(int, default=0, description="命令冷却时间(秒)"), + "max_content_length": ConfigField(int, default=500, description="最大回显内容长度"), + }, + } + + config_section_descriptions = { + "plugin": "插件基本配置", + "commands": "命令相关配置", + } + + def get_plugin_components(self) -> List[Tuple[PlusCommandInfo, Type]]: + """获取插件组件""" + components = [] + + if self.get_config("plugin.enabled", True): + # 添加所有命令,直接使用PlusCommand类 + if self.get_config("commands.echo_enabled", True): + components.append((EchoCommand.get_plus_command_info(), EchoCommand)) + + components.append((HelloCommand.get_plus_command_info(), HelloCommand)) + components.append((InfoCommand.get_plus_command_info(), InfoCommand)) + components.append((TestCommand.get_plus_command_info(), TestCommand)) + + return components diff --git a/src/chat/chat_loop/cycle_processor.py b/src/chat/chat_loop/cycle_processor.py index 043282924..4a946bb72 100644 --- a/src/chat/chat_loop/cycle_processor.py +++ b/src/chat/chat_loop/cycle_processor.py @@ -100,7 +100,7 @@ class CycleProcessor: from src.plugin_system.core.event_manager import event_manager from src.plugin_system.base.component_types import EventType # 触发 ON_PLAN 事件 - result = await event_manager.trigger_event(EventType.ON_PLAN, stream_id=self.context.stream_id) + result = await event_manager.trigger_event(EventType.ON_PLAN, plugin_name="SYSTEM", stream_id=self.context.stream_id) if result and not result.all_continue_process(): return @@ -131,6 +131,11 @@ class CycleProcessor: if ENABLE_S4U: await stop_typing() + + # 在一轮动作执行完毕后,增加睡眠压力 + if self.context.energy_manager and global_config.wakeup_system.enable_insomnia_system: + if action_type not in ["no_reply", "no_action"]: + self.context.energy_manager.increase_sleep_pressure() return True diff --git a/src/chat/chat_loop/energy_manager.py b/src/chat/chat_loop/energy_manager.py index 78d0b10c5..a2c444326 100644 --- a/src/chat/chat_loop/energy_manager.py +++ b/src/chat/chat_loop/energy_manager.py @@ -5,6 +5,7 @@ from src.common.logger import get_logger from src.config.config import global_config from src.plugin_system.base.component_types import ChatMode from .hfc_context import HfcContext +from src.schedule.schedule_manager import schedule_manager logger = get_logger("hfc") @@ -77,7 +78,7 @@ class EnergyManager: async def _energy_loop(self): """ - 能量管理的主循环 + 能量与睡眠压力管理的主循环 功能说明: - 每10秒执行一次能量更新 @@ -92,24 +93,35 @@ class EnergyManager: if not self.context.chat_stream: continue - is_group_chat = self.context.chat_stream.group_info is not None - if is_group_chat and global_config.chat.group_chat_mode != "auto": - if global_config.chat.group_chat_mode == "focus": - self.context.loop_mode = ChatMode.FOCUS - self.context.energy_value = 35 - elif global_config.chat.group_chat_mode == "normal": - self.context.loop_mode = ChatMode.NORMAL - self.context.energy_value = 15 - continue + # 判断当前是否为睡眠时间 + is_sleeping = schedule_manager.is_sleeping(self.context.wakeup_manager) - if self.context.loop_mode == ChatMode.NORMAL: - self.context.energy_value -= 0.3 - self.context.energy_value = max(self.context.energy_value, 0.3) - if self.context.loop_mode == ChatMode.FOCUS: - self.context.energy_value -= 0.6 - self.context.energy_value = max(self.context.energy_value, 0.3) - - self._log_energy_change("能量值衰减") + if is_sleeping: + # 睡眠中:减少睡眠压力 + decay_per_10s = global_config.wakeup_system.sleep_pressure_decay_rate / 6 + self.context.sleep_pressure -= decay_per_10s + self.context.sleep_pressure = max(self.context.sleep_pressure, 0) + self._log_sleep_pressure_change("睡眠压力释放") + else: + # 清醒时:处理能量衰减 + is_group_chat = self.context.chat_stream.group_info is not None + if is_group_chat and global_config.chat.group_chat_mode != "auto": + if global_config.chat.group_chat_mode == "focus": + self.context.loop_mode = ChatMode.FOCUS + self.context.energy_value = 35 + elif global_config.chat.group_chat_mode == "normal": + self.context.loop_mode = ChatMode.NORMAL + self.context.energy_value = 15 + continue + + if self.context.loop_mode == ChatMode.NORMAL: + self.context.energy_value -= 0.3 + self.context.energy_value = max(self.context.energy_value, 0.3) + if self.context.loop_mode == ChatMode.FOCUS: + self.context.energy_value -= 0.6 + self.context.energy_value = max(self.context.energy_value, 0.3) + + self._log_energy_change("能量值衰减") def _should_log_energy(self) -> bool: """ @@ -129,6 +141,15 @@ class EnergyManager: return True return False + def increase_sleep_pressure(self): + """ + 在执行动作后增加睡眠压力 + """ + increment = global_config.wakeup_system.sleep_pressure_increment + self.context.sleep_pressure += increment + self.context.sleep_pressure = min(self.context.sleep_pressure, 100.0) # 设置一个100的上限 + self._log_sleep_pressure_change("执行动作,睡眠压力累积") + def _log_energy_change(self, action: str, reason: str = ""): """ 记录能量变化日志 @@ -151,4 +172,14 @@ class EnergyManager: log_message = f"{self.context.log_prefix} {action},当前能量值:{self.context.energy_value:.1f}" if reason: log_message = f"{self.context.log_prefix} {action},{reason},当前能量值:{self.context.energy_value:.1f}" - logger.debug(log_message) \ No newline at end of file + logger.debug(log_message) + + def _log_sleep_pressure_change(self, action: str): + """ + 记录睡眠压力变化日志 + """ + # 使用与能量日志相同的频率控制 + if self._should_log_energy(): + logger.info(f"{self.context.log_prefix} {action},当前睡眠压力:{self.context.sleep_pressure:.1f}") + else: + logger.debug(f"{self.context.log_prefix} {action},当前睡眠压力:{self.context.sleep_pressure:.1f}") \ No newline at end of file diff --git a/src/chat/chat_loop/heartFC_chat.py b/src/chat/chat_loop/heartFC_chat.py index da67eac81..6ca4dc916 100644 --- a/src/chat/chat_loop/heartFC_chat.py +++ b/src/chat/chat_loop/heartFC_chat.py @@ -8,8 +8,9 @@ from src.config.config import global_config from src.person_info.relationship_builder_manager import relationship_builder_manager from src.chat.express.expression_learner import expression_learner_manager from src.plugin_system.base.component_types import ChatMode -from src.manager.schedule_manager import schedule_manager +from src.schedule.schedule_manager import schedule_manager from src.plugin_system.apis import message_api +from src.mood.mood_manager import mood_manager from .hfc_context import HfcContext from .energy_manager import EnergyManager @@ -48,6 +49,7 @@ class HeartFChatting: # 将唤醒度管理器设置到上下文中 self.context.wakeup_manager = self.wakeup_manager + self.context.energy_manager = self.energy_manager self._loop_task: Optional[asyncio.Task] = None @@ -196,8 +198,28 @@ class HeartFChatting: - NORMAL模式:检查进入FOCUS模式的条件,并通过normal_mode_handler处理消息 """ is_sleeping = schedule_manager.is_sleeping(self.wakeup_manager) - - # 核心修复:在睡眠模式下获取消息时,不过滤命令消息,以确保@消息能被接收 + + # --- 失眠状态管理 --- + if self.context.is_in_insomnia and time.time() > self.context.insomnia_end_time: + # 失眠状态结束 + self.context.is_in_insomnia = False + await self.proactive_thinker.trigger_goodnight_thinking() + + if is_sleeping and not self.context.was_sleeping: + # 刚刚进入睡眠状态,进行一次入睡检查 + if self.wakeup_manager and self.wakeup_manager.check_for_insomnia(): + # 触发失眠 + self.context.is_in_insomnia = True + duration = global_config.wakeup_system.insomnia_duration_minutes * 60 + self.context.insomnia_end_time = time.time() + duration + + # 判断失眠原因并触发思考 + reason = "random" + if self.context.sleep_pressure < global_config.wakeup_system.sleep_pressure_threshold: + reason = "low_pressure" + await self.proactive_thinker.trigger_insomnia_thinking(reason) + + # 核心修复:在睡眠模式(包括失眠)下获取消息时,不过滤命令消息,以确保@消息能被接收 filter_command_flag = not is_sleeping recent_messages = message_api.get_messages_by_time_in_chat( @@ -220,9 +242,16 @@ class HeartFChatting: # 处理唤醒度逻辑 if is_sleeping: self._handle_wakeup_messages(recent_messages) - # 如果仍在睡眠状态,跳过正常处理但仍返回有新消息 - if schedule_manager.is_sleeping(self.wakeup_manager): + # 再次检查睡眠状态,因为_handle_wakeup_messages可能会触发唤醒 + current_is_sleeping = schedule_manager.is_sleeping(self.wakeup_manager) + + if not self.context.is_in_insomnia and current_is_sleeping: + # 仍然在睡眠,跳过本轮的消息处理 return has_new_messages + else: + # 从睡眠中被唤醒,需要继续处理本轮消息 + logger.info(f"{self.context.log_prefix} 从睡眠中被唤醒,将处理积压的消息。") + self.context.last_wakeup_time = time.time() # 根据聊天模式处理新消息 if self.context.loop_mode == ChatMode.FOCUS: @@ -239,6 +268,21 @@ class HeartFChatting: self._check_focus_exit() elif self.context.loop_mode == ChatMode.NORMAL: self._check_focus_entry(0) # 传入0表示无新消息 + + # 更新上一帧的睡眠状态 + self.context.was_sleeping = is_sleeping + + # --- 重新入睡逻辑 --- + # 如果被吵醒了,并且在一定时间内没有新消息,则尝试重新入睡 + if schedule_manager._is_woken_up and not has_new_messages: + re_sleep_delay = global_config.wakeup_system.re_sleep_delay_minutes * 60 + # 使用 last_message_time 来判断空闲时间 + if time.time() - self.context.last_message_time > re_sleep_delay: + logger.info(f"{self.context.log_prefix} 已被唤醒且超过 {re_sleep_delay / 60} 分钟无新消息,尝试重新入睡。") + schedule_manager.reset_wakeup_state() + + # 保存HFC上下文状态 + self.context.save_context_state() return has_new_messages diff --git a/src/chat/chat_loop/hfc_context.py b/src/chat/chat_loop/hfc_context.py index 6bbbdb7ee..767ee60bc 100644 --- a/src/chat/chat_loop/hfc_context.py +++ b/src/chat/chat_loop/hfc_context.py @@ -1,6 +1,8 @@ from typing import List, Optional, TYPE_CHECKING import time from src.chat.message_receive.chat_stream import ChatStream, get_chat_manager +from src.common.logger import get_logger +from src.manager.local_store_manager import local_storage from src.person_info.relationship_builder_manager import RelationshipBuilder from src.chat.express.expression_learner import ExpressionLearner from src.plugin_system.base.component_types import ChatMode @@ -9,6 +11,7 @@ from src.chat.chat_loop.hfc_utils import CycleDetail if TYPE_CHECKING: from .wakeup_manager import WakeUpManager + from .energy_manager import EnergyManager class HfcContext: def __init__(self, chat_id: str): @@ -40,6 +43,13 @@ class HfcContext: self.loop_mode = ChatMode.NORMAL self.energy_value = 5.0 + self.sleep_pressure = 0.0 + self.was_sleeping = False # 用于检测睡眠状态的切换 + + # 失眠状态 + self.is_in_insomnia: bool = False + self.insomnia_end_time: float = 0.0 + self.last_wakeup_time: float = 0.0 # 被吵醒的时间 self.last_message_time = time.time() self.last_read_time = time.time() - 10 @@ -53,4 +63,38 @@ class HfcContext: self.current_cycle_detail: Optional[CycleDetail] = None # 唤醒度管理器 - 延迟初始化以避免循环导入 - self.wakeup_manager: Optional['WakeUpManager'] = None \ No newline at end of file + self.wakeup_manager: Optional['WakeUpManager'] = None + self.energy_manager: Optional['EnergyManager'] = None + + self._load_context_state() + + def _get_storage_key(self) -> str: + """获取当前聊天流的本地存储键""" + return f"hfc_context_state_{self.stream_id}" + + def _load_context_state(self): + """从本地存储加载状态""" + state = local_storage[self._get_storage_key()] + if state and isinstance(state, dict): + self.energy_value = state.get("energy_value", 5.0) + self.sleep_pressure = state.get("sleep_pressure", 0.0) + self.is_in_insomnia = state.get("is_in_insomnia", False) + self.insomnia_end_time = state.get("insomnia_end_time", 0.0) + logger = get_logger("hfc_context") + logger.info(f"{self.log_prefix} 成功从本地存储加载HFC上下文状态: {state}") + else: + logger = get_logger("hfc_context") + logger.info(f"{self.log_prefix} 未找到本地HFC上下文状态,将使用默认值初始化。") + + def save_context_state(self): + """将当前状态保存到本地存储""" + state = { + "energy_value": self.energy_value, + "sleep_pressure": self.sleep_pressure, + "is_in_insomnia": self.is_in_insomnia, + "insomnia_end_time": self.insomnia_end_time, + "last_wakeup_time": self.last_wakeup_time, + } + local_storage[self._get_storage_key()] = state + logger = get_logger("hfc_context") + logger.debug(f"{self.log_prefix} 已将HFC上下文状态保存到本地存储: {state}") \ No newline at end of file diff --git a/src/chat/chat_loop/proactive_thinker.py b/src/chat/chat_loop/proactive_thinker.py index 5bf02d064..69fec753d 100644 --- a/src/chat/chat_loop/proactive_thinker.py +++ b/src/chat/chat_loop/proactive_thinker.py @@ -279,3 +279,56 @@ class ProactiveThinker: except Exception as e: logger.error(f"{self.context.log_prefix} 主动思考执行异常: {e}") logger.error(traceback.format_exc()) + + async def trigger_insomnia_thinking(self, reason: str): + """ + 由外部事件(如失眠)触发的一次性主动思考 + + Args: + reason: 触发的原因 (e.g., "low_pressure", "random") + """ + logger.info(f"{self.context.log_prefix} 因“{reason}”触发失眠,开始深夜思考...") + + # 1. 根据原因修改情绪 + try: + from src.mood.mood_manager import mood_manager + mood_obj = mood_manager.get_mood_by_chat_id(self.context.stream_id) + if reason == "low_pressure": + mood_obj.mood_state = "精力过剩,毫无睡意" + elif reason == "random": + mood_obj.mood_state = "深夜emo,胡思乱想" + mood_obj.last_change_time = time.time() # 更新时间戳以允许后续的情绪回归 + logger.info(f"{self.context.log_prefix} 因失眠,情绪状态被强制更新为: {mood_obj.mood_state}") + except Exception as e: + logger.error(f"{self.context.log_prefix} 设置失眠情绪时出错: {e}") + + # 2. 直接执行主动思考逻辑 + try: + # 传入一个象征性的silence_duration,因为它在这里不重要 + await self._execute_proactive_thinking(silence_duration=1) + except Exception as e: + logger.error(f"{self.context.log_prefix} 失眠思考执行出错: {e}") + logger.error(traceback.format_exc()) + + async def trigger_goodnight_thinking(self): + """ + 在失眠状态结束后,触发一次准备睡觉的主动思考 + """ + logger.info(f"{self.context.log_prefix} 失眠状态结束,准备睡觉,触发告别思考...") + + # 1. 设置一个准备睡觉的特定情绪 + try: + from src.mood.mood_manager import mood_manager + mood_obj = mood_manager.get_mood_by_chat_id(self.context.stream_id) + mood_obj.mood_state = "有点困了,准备睡觉了" + mood_obj.last_change_time = time.time() + logger.info(f"{self.context.log_prefix} 情绪状态更新为: {mood_obj.mood_state}") + except Exception as e: + logger.error(f"{self.context.log_prefix} 设置睡前情绪时出错: {e}") + + # 2. 直接执行主动思考逻辑 + try: + await self._execute_proactive_thinking(silence_duration=1) + except Exception as e: + logger.error(f"{self.context.log_prefix} 睡前告别思考执行出错: {e}") + logger.error(traceback.format_exc()) diff --git a/src/chat/chat_loop/wakeup_manager.py b/src/chat/chat_loop/wakeup_manager.py index 46af60249..37fd755aa 100644 --- a/src/chat/chat_loop/wakeup_manager.py +++ b/src/chat/chat_loop/wakeup_manager.py @@ -3,6 +3,7 @@ import time from typing import Optional from src.common.logger import get_logger from src.config.config import global_config +from src.manager.local_store_manager import local_storage from .hfc_context import HfcContext logger = get_logger("wakeup") @@ -39,6 +40,40 @@ class WakeUpManager: self.angry_duration = wakeup_config.angry_duration self.enabled = wakeup_config.enable self.angry_prompt = wakeup_config.angry_prompt + + # 失眠系统参数 + self.insomnia_enabled = wakeup_config.enable_insomnia_system + self.sleep_pressure_threshold = wakeup_config.sleep_pressure_threshold + self.deep_sleep_threshold = wakeup_config.deep_sleep_threshold + self.insomnia_chance_low_pressure = wakeup_config.insomnia_chance_low_pressure + self.insomnia_chance_normal_pressure = wakeup_config.insomnia_chance_normal_pressure + + self._load_wakeup_state() + + def _get_storage_key(self) -> str: + """获取当前聊天流的本地存储键""" + return f"wakeup_manager_state_{self.context.stream_id}" + + def _load_wakeup_state(self): + """从本地存储加载状态""" + state = local_storage[self._get_storage_key()] + if state and isinstance(state, dict): + self.wakeup_value = state.get("wakeup_value", 0.0) + self.is_angry = state.get("is_angry", False) + self.angry_start_time = state.get("angry_start_time", 0.0) + logger.info(f"{self.context.log_prefix} 成功从本地存储加载唤醒状态: {state}") + else: + logger.info(f"{self.context.log_prefix} 未找到本地唤醒状态,将使用默认值初始化。") + + def _save_wakeup_state(self): + """将当前状态保存到本地存储""" + state = { + "wakeup_value": self.wakeup_value, + "is_angry": self.is_angry, + "angry_start_time": self.angry_start_time, + } + local_storage[self._get_storage_key()] = state + logger.debug(f"{self.context.log_prefix} 已将唤醒状态保存到本地存储: {state}") async def start(self): """启动唤醒度管理器""" @@ -82,6 +117,7 @@ class WakeUpManager: from src.mood.mood_manager import mood_manager mood_manager.clear_angry_from_wakeup(self.context.stream_id) logger.info(f"{self.context.log_prefix} 愤怒状态结束,恢复正常") + self._save_wakeup_state() # 唤醒度自然衰减 if self.wakeup_value > 0: @@ -89,6 +125,7 @@ class WakeUpManager: self.wakeup_value = max(0, self.wakeup_value - self.decay_rate) if old_value != self.wakeup_value: logger.debug(f"{self.context.log_prefix} 唤醒度衰减: {old_value:.1f} -> {self.wakeup_value:.1f}") + self._save_wakeup_state() def add_wakeup_value(self, is_private_chat: bool, is_mentioned: bool = False) -> bool: """ @@ -105,10 +142,9 @@ class WakeUpManager: if not self.enabled: return False - from src.manager.schedule_manager import schedule_manager - - # 只有在休眠状态下才累积唤醒度 - if not schedule_manager.is_sleeping(): + # 只有在休眠且非失眠状态下才累积唤醒度 + from src.schedule.schedule_manager import schedule_manager + if not schedule_manager.is_sleeping() or self.context.is_in_insomnia: return False old_value = self.wakeup_value @@ -136,7 +172,8 @@ class WakeUpManager: if self.wakeup_value >= self.wakeup_threshold: self._trigger_wakeup() return True - + + self._save_wakeup_state() return False def _trigger_wakeup(self): @@ -145,10 +182,16 @@ class WakeUpManager: self.angry_start_time = time.time() self.wakeup_value = 0.0 # 重置唤醒度 + self._save_wakeup_state() + # 通知情绪管理系统进入愤怒状态 from src.mood.mood_manager import mood_manager mood_manager.set_angry_from_wakeup(self.context.stream_id) + # 通知日程管理器重置睡眠状态 + from src.schedule.schedule_manager import schedule_manager + schedule_manager.reset_sleep_state_after_wakeup() + logger.info(f"{self.context.log_prefix} 唤醒度达到阈值({self.wakeup_threshold}),被吵醒进入愤怒状态!") def get_angry_prompt_addition(self) -> str: @@ -177,4 +220,39 @@ class WakeUpManager: "wakeup_threshold": self.wakeup_threshold, "is_angry": self.is_angry, "angry_remaining_time": max(0, self.angry_duration - (time.time() - self.angry_start_time)) if self.is_angry else 0 - } \ No newline at end of file + } + + def check_for_insomnia(self) -> bool: + """ + 在尝试入睡时检查是否会失眠 + + Returns: + bool: 如果失眠则返回 True,否则返回 False + """ + if not self.insomnia_enabled: + return False + + import random + + pressure = self.context.sleep_pressure + + # 压力过高,深度睡眠,极难失眠 + if pressure > self.deep_sleep_threshold: + return False + + # 根据睡眠压力决定失眠概率 + from src.schedule.schedule_manager import schedule_manager + if pressure < self.sleep_pressure_threshold: + # 压力不足型失眠 + if schedule_manager._is_in_voluntary_delay: + logger.debug(f"{self.context.log_prefix} 处于主动延迟睡眠期间,跳过压力不足型失眠判断。") + elif random.random() < self.insomnia_chance_low_pressure: + logger.info(f"{self.context.log_prefix} 睡眠压力不足 ({pressure:.1f}),触发失眠!") + return True + else: + # 压力正常,随机失眠 + if random.random() < self.insomnia_chance_normal_pressure: + logger.info(f"{self.context.log_prefix} 睡眠压力正常 ({pressure:.1f}),触发随机失眠!") + return True + + return False \ No newline at end of file diff --git a/src/chat/emoji_system/emoji_manager.py b/src/chat/emoji_system/emoji_manager.py index b00eab417..70b0d00b9 100644 --- a/src/chat/emoji_system/emoji_manager.py +++ b/src/chat/emoji_system/emoji_manager.py @@ -326,7 +326,7 @@ async def clear_temp_emoji() -> None: if os.path.exists(need_clear): files = os.listdir(need_clear) # 如果文件数超过100就全部删除 - if len(files) > 100: + if len(files) > 1000: for filename in files: file_path = os.path.join(need_clear, filename) if os.path.isfile(file_path): diff --git a/src/chat/express/expression_selector.py b/src/chat/express/expression_selector.py index 4ea77f87c..9cc5c2668 100644 --- a/src/chat/express/expression_selector.py +++ b/src/chat/express/expression_selector.py @@ -114,16 +114,27 @@ class ExpressionSelector: return None def get_related_chat_ids(self, chat_id: str) -> List[str]: - """根据expression_groups配置,获取与当前chat_id相关的所有chat_id(包括自身)""" - groups = global_config.expression.expression_groups - for group in groups: - group_chat_ids = [] - for stream_config_str in group: - if chat_id_candidate := self._parse_stream_config_to_chat_id(stream_config_str): - group_chat_ids.append(chat_id_candidate) - if chat_id in group_chat_ids: - return group_chat_ids - return [chat_id] + """根据expression.rules配置,获取与当前chat_id相关的所有chat_id(包括自身)""" + rules = global_config.expression.rules + current_group = None + + # 找到当前chat_id所在的组 + for rule in rules: + if rule.chat_stream_id and self._parse_stream_config_to_chat_id(rule.chat_stream_id) == chat_id: + current_group = rule.group + break + + if not current_group: + return [chat_id] + + # 找出同一组的所有chat_id + related_chat_ids = [] + for rule in rules: + if rule.group == current_group and rule.chat_stream_id: + if chat_id_candidate := self._parse_stream_config_to_chat_id(rule.chat_stream_id): + related_chat_ids.append(chat_id_candidate) + + return related_chat_ids if related_chat_ids else [chat_id] def get_random_expressions( self, chat_id: str, total_num: int, style_percentage: float, grammar_percentage: float diff --git a/src/chat/memory_system/instant_memory.py b/src/chat/memory_system/instant_memory.py index 7865a57e0..d8bcb0539 100644 --- a/src/chat/memory_system/instant_memory.py +++ b/src/chat/memory_system/instant_memory.py @@ -108,7 +108,7 @@ class InstantMemory: memory_id=memory_item.memory_id, chat_id=memory_item.chat_id, memory_text=memory_item.memory_text, - keywords=json.dumps(memory_item.keywords, ensure_ascii=False), + keywords=orjson.dumps(memory_item.keywords).decode('utf-8'), create_time=memory_item.create_time, last_view_time=memory_item.last_view_time, ) @@ -172,8 +172,8 @@ class InstantMemory: # 对每条记忆 mem_keywords_str = mem.keywords or "[]" try: - mem_keywords = json.loads(mem_keywords_str) - except json.JSONDecodeError: + mem_keywords = orjson.loads(mem_keywords_str) + except orjson.JSONDecodeError: mem_keywords = [] # logger.info(f"mem_keywords: {mem_keywords}") # logger.info(f"keywords_list: {keywords_list}") diff --git a/src/chat/memory_system/vector_instant_memory.py b/src/chat/memory_system/vector_instant_memory.py index 201076ffb..9c7824d9a 100644 --- a/src/chat/memory_system/vector_instant_memory.py +++ b/src/chat/memory_system/vector_instant_memory.py @@ -4,10 +4,9 @@ from typing import List, Dict, Any from dataclasses import dataclass import threading -import chromadb -from chromadb.config import Settings from src.common.logger import get_logger from src.chat.utils.utils import get_embedding +from src.common.vector_db import vector_db_service logger = get_logger("vector_instant_memory_v2") @@ -45,10 +44,7 @@ class VectorInstantMemoryV2: self.chat_id = chat_id self.retention_hours = retention_hours self.cleanup_interval = cleanup_interval - - # ChromaDB相关 - self.client = None - self.collection = None + self.collection_name = "instant_memory" # 清理任务相关 self.cleanup_task = None @@ -61,22 +57,16 @@ class VectorInstantMemoryV2: logger.info(f"向量瞬时记忆系统V2初始化完成: {chat_id} (保留{retention_hours}小时)") def _init_chroma(self): - """初始化ChromaDB连接""" + """使用全局服务初始化向量数据库集合""" try: - db_path = f"./data/memory_vectors/{self.chat_id}" - self.client = chromadb.PersistentClient( - path=db_path, - settings=Settings(anonymized_telemetry=False) - ) - self.collection = self.client.get_or_create_collection( - name="chat_messages", + # 现在我们只获取集合,而不是创建新的客户端 + vector_db_service.get_or_create_collection( + name=self.collection_name, metadata={"hnsw:space": "cosine"} ) - logger.info(f"向量记忆数据库初始化成功: {db_path}") + logger.info(f"向量记忆集合 '{self.collection_name}' 已准备就绪") except Exception as e: - logger.error(f"ChromaDB初始化失败: {e}") - self.client = None - self.collection = None + logger.error(f"获取向量记忆集合失败: {e}") def _start_cleanup_task(self): """启动定时清理任务""" @@ -95,36 +85,40 @@ class VectorInstantMemoryV2: def _cleanup_expired_messages(self): """清理过期的聊天记录""" - if not self.collection: - return - try: - # 计算过期时间戳 expire_time = time.time() - (self.retention_hours * 3600) - - # 查询所有记录 - all_results = self.collection.get( + + # 采用 get -> filter -> delete 模式,避免复杂的 where 查询 + # 1. 获取当前 chat_id 的所有文档 + results = vector_db_service.get( + collection_name=self.collection_name, where={"chat_id": self.chat_id}, include=["metadatas"] ) - - # 找出过期的记录ID + + if not results or not results.get('ids'): + logger.info(f"chat_id '{self.chat_id}' 没有找到任何记录,无需清理") + return + + # 2. 在内存中过滤出过期的文档 expired_ids = [] - metadatas = all_results.get("metadatas") or [] - ids = all_results.get("ids") or [] - + metadatas = results.get('metadatas', []) + ids = results.get('ids', []) + for i, metadata in enumerate(metadatas): - if metadata and isinstance(metadata, dict): - timestamp = metadata.get("timestamp", 0) - if isinstance(timestamp, (int, float)) and timestamp < expire_time: - if i < len(ids): - expired_ids.append(ids[i]) - - # 批量删除过期记录 + if metadata and metadata.get('timestamp', float('inf')) < expire_time: + expired_ids.append(ids[i]) + + # 3. 如果有过期文档,根据 ID 进行删除 if expired_ids: - self.collection.delete(ids=expired_ids) - logger.info(f"清理了 {len(expired_ids)} 条过期聊天记录") - + vector_db_service.delete( + collection_name=self.collection_name, + ids=expired_ids + ) + logger.info(f"为 chat_id '{self.chat_id}' 清理了 {len(expired_ids)} 条过期记录") + else: + logger.info(f"chat_id '{self.chat_id}' 没有需要清理的过期记录") + except Exception as e: logger.error(f"清理过期记录失败: {e}") @@ -139,7 +133,7 @@ class VectorInstantMemoryV2: Returns: bool: 是否存储成功 """ - if not self.collection or not content.strip(): + if not content.strip(): return False try: @@ -149,10 +143,8 @@ class VectorInstantMemoryV2: logger.warning(f"消息向量生成失败: {content[:50]}...") return False - # 生成唯一消息ID message_id = f"{self.chat_id}_{int(time.time() * 1000)}_{hash(content) % 10000}" - # 创建消息对象 message = ChatMessage( message_id=message_id, chat_id=self.chat_id, @@ -161,8 +153,9 @@ class VectorInstantMemoryV2: sender=sender ) - # 存储到ChromaDB - self.collection.add( + # 使用新的服务存储 + vector_db_service.add( + collection_name=self.collection_name, embeddings=[message_vector], documents=[content], metadatas=[{ @@ -194,23 +187,23 @@ class VectorInstantMemoryV2: Returns: List[Dict]: 相似消息列表,包含content、similarity、timestamp等信息 """ - if not self.collection or not query.strip(): + if not query.strip(): return [] try: - # 生成查询向量 query_vector = await get_embedding(query) if not query_vector: return [] - # 向量相似度搜索 - results = self.collection.query( + # 使用新的服务进行查询 + results = vector_db_service.query( + collection_name=self.collection_name, query_embeddings=[query_vector], n_results=top_k, where={"chat_id": self.chat_id} ) - if not results['documents'] or not results['documents'][0]: + if not results.get('documents') or not results['documents'][0]: return [] # 处理搜索结果 @@ -311,15 +304,18 @@ class VectorInstantMemoryV2: "cleanup_interval": self.cleanup_interval, "system_status": "running" if self.is_running else "stopped", "total_messages": 0, - "db_status": "connected" if self.collection else "disconnected" + "db_status": "connected" } - if self.collection: - try: - result = self.collection.count() - stats["total_messages"] = result - except Exception: - stats["total_messages"] = "查询失败" + try: + # 注意:count() 现在没有 chat_id 过滤,返回的是整个集合的数量 + # 若要精确计数,需要 get(where={"chat_id": ...}) 然后 len(results['ids']) + # 这里为了简化,暂时显示集合总数 + result = vector_db_service.count(collection_name=self.collection_name) + stats["total_messages"] = result + except Exception: + stats["total_messages"] = "查询失败" + stats["db_status"] = "disconnected" return stats diff --git a/src/chat/message_receive/bot.py b/src/chat/message_receive/bot.py index 661ceb38f..9a29998d8 100644 --- a/src/chat/message_receive/bot.py +++ b/src/chat/message_receive/bot.py @@ -98,6 +98,118 @@ class ChatBot: self._started = True + async def _process_plus_commands(self, message: MessageRecv): + """独立处理PlusCommand系统""" + try: + text = message.processed_plain_text + + # 获取配置的命令前缀 + from src.config.config import global_config + prefixes = global_config.command.command_prefixes + + # 检查是否以任何前缀开头 + matched_prefix = None + for prefix in prefixes: + if text.startswith(prefix): + matched_prefix = prefix + break + + if not matched_prefix: + return False, None, True # 不是命令,继续处理 + + # 移除前缀 + command_part = text[len(matched_prefix):].strip() + + # 分离命令名和参数 + parts = command_part.split(None, 1) + if not parts: + return False, None, True # 没有命令名,继续处理 + + command_word = parts[0].lower() + args_text = parts[1] if len(parts) > 1 else "" + + # 查找匹配的PlusCommand + plus_command_registry = component_registry.get_plus_command_registry() + matching_commands = [] + + for plus_command_name, plus_command_class in plus_command_registry.items(): + plus_command_info = component_registry.get_registered_plus_command_info(plus_command_name) + if not plus_command_info: + continue + + # 检查命令名是否匹配(命令名和别名) + all_commands = [plus_command_name.lower()] + [alias.lower() for alias in plus_command_info.command_aliases] + if command_word in all_commands: + matching_commands.append((plus_command_class, plus_command_info, plus_command_name)) + + if not matching_commands: + return False, None, True # 没有找到匹配的PlusCommand,继续处理 + + # 如果有多个匹配,按优先级排序 + if len(matching_commands) > 1: + matching_commands.sort(key=lambda x: x[1].priority, reverse=True) + logger.warning(f"文本 '{text}' 匹配到多个PlusCommand: {[cmd[2] for cmd in matching_commands]},使用优先级最高的") + + plus_command_class, plus_command_info, plus_command_name = matching_commands[0] + + # 检查命令是否被禁用 + if ( + message.chat_stream + and message.chat_stream.stream_id + and plus_command_name + in global_announcement_manager.get_disabled_chat_commands(message.chat_stream.stream_id) + ): + logger.info("用户禁用的PlusCommand,跳过处理") + return False, None, True + + message.is_command = True + + # 获取插件配置 + plugin_config = component_registry.get_plugin_config(plus_command_name) + + # 创建PlusCommand实例 + plus_command_instance = plus_command_class(message, plugin_config) + + try: + # 检查聊天类型限制 + if not plus_command_instance.is_chat_type_allowed(): + is_group = hasattr(message, 'is_group_message') and message.is_group_message + logger.info(f"PlusCommand {plus_command_class.__name__} 不支持当前聊天类型: {'群聊' if is_group else '私聊'}") + return False, None, True # 跳过此命令,继续处理其他消息 + + # 设置参数 + from src.plugin_system.base.command_args import CommandArgs + command_args = CommandArgs(args_text) + plus_command_instance.args = command_args + + # 执行命令 + success, response, intercept_message = await plus_command_instance.execute(command_args) + + # 记录命令执行结果 + if success: + logger.info(f"PlusCommand执行成功: {plus_command_class.__name__} (拦截: {intercept_message})") + else: + logger.warning(f"PlusCommand执行失败: {plus_command_class.__name__} - {response}") + + # 根据命令的拦截设置决定是否继续处理消息 + return True, response, not intercept_message # 找到命令,根据intercept_message决定是否继续 + + except Exception as e: + logger.error(f"执行PlusCommand时出错: {plus_command_class.__name__} - {e}") + logger.error(traceback.format_exc()) + + try: + await plus_command_instance.send_text(f"命令执行出错: {str(e)}") + except Exception as send_error: + logger.error(f"发送错误消息失败: {send_error}") + + # 命令出错时,根据命令的拦截设置决定是否继续处理消息 + return True, str(e), False # 出错时继续处理消息 + + except Exception as e: + logger.error(f"处理PlusCommand时出错: {e}") + return False, None, True # 出错时继续处理消息 + async def _process_commands_with_new_system(self, message: MessageRecv): # sourcery skip: use-named-expression """使用新插件系统处理命令""" @@ -306,16 +418,26 @@ class ChatBot: ): return - # 命令处理 - 使用新插件系统检查并处理命令 - is_command, cmd_result, continue_process = await self._process_commands_with_new_system(message) - - # 如果是命令且不需要继续处理,则直接返回 - if is_command and not continue_process: + # 命令处理 - 首先尝试PlusCommand独立处理 + is_plus_command, plus_cmd_result, plus_continue_process = await self._process_plus_commands(message) + + # 如果是PlusCommand且不需要继续处理,则直接返回 + if is_plus_command and not plus_continue_process: await MessageStorage.store_message(message, chat) - logger.info(f"命令处理完成,跳过后续消息处理: {cmd_result}") + logger.info(f"PlusCommand处理完成,跳过后续消息处理: {plus_cmd_result}") return + + # 如果不是PlusCommand,尝试传统的BaseCommand处理 + if not is_plus_command: + is_command, cmd_result, continue_process = await self._process_commands_with_new_system(message) + + # 如果是命令且不需要继续处理,则直接返回 + if is_command and not continue_process: + await MessageStorage.store_message(message, chat) + logger.info(f"命令处理完成,跳过后续消息处理: {cmd_result}") + return - result = await event_manager.trigger_event(EventType.ON_MESSAGE,message=message) + result = await event_manager.trigger_event(EventType.ON_MESSAGE,plugin_name="SYSTEM",message=message) if not result.all_continue_process(): raise UserWarning(f"插件{result.get_summary().get('stopped_handlers','')}于消息到达时取消了消息处理") diff --git a/src/chat/planner_actions/planner.py b/src/chat/planner_actions/planner.py index 92b037781..6ea19c2be 100644 --- a/src/chat/planner_actions/planner.py +++ b/src/chat/planner_actions/planner.py @@ -21,7 +21,7 @@ from src.chat.planner_actions.action_manager import ActionManager from src.chat.message_receive.chat_stream import get_chat_manager from src.plugin_system.base.component_types import ActionInfo, ChatMode, ComponentType from src.plugin_system.core.component_registry import component_registry -from src.manager.schedule_manager import schedule_manager +from src.schedule.schedule_manager import schedule_manager from src.mood.mood_manager import mood_manager from src.chat.memory_system.Hippocampus import hippocampus_manager logger = get_logger("planner") diff --git a/src/chat/replyer/default_generator.py b/src/chat/replyer/default_generator.py index 1b5dbc1d5..77033472d 100644 --- a/src/chat/replyer/default_generator.py +++ b/src/chat/replyer/default_generator.py @@ -32,7 +32,7 @@ from src.person_info.relationship_fetcher import relationship_fetcher_manager from src.person_info.person_info import get_person_info_manager from src.plugin_system.base.component_types import ActionInfo, EventType from src.plugin_system.apis import llm_api -from src.manager.schedule_manager import schedule_manager +from src.schedule.schedule_manager import schedule_manager logger = get_logger("replyer") @@ -235,7 +235,7 @@ class DefaultReplyer: from src.plugin_system.core.tool_use import ToolExecutor # 延迟导入ToolExecutor,不然会循环依赖 - self.tool_executor = ToolExecutor(chat_id=self.chat_stream.stream_id, enable_cache=False) + self.tool_executor = ToolExecutor(chat_id=self.chat_stream.stream_id) async def _build_cross_context_block(self, current_chat_id: str, target_user_info: Optional[Dict[str, Any]]) -> str: """构建跨群聊上下文""" @@ -370,7 +370,7 @@ class DefaultReplyer: from src.plugin_system.core.event_manager import event_manager if not from_plugin: - result = await event_manager.trigger_event(EventType.POST_LLM,prompt=prompt,stream_id=stream_id) + result = await event_manager.trigger_event(EventType.POST_LLM,plugin_name="SYSTEM",prompt=prompt,stream_id=stream_id) if not result.all_continue_process(): raise UserWarning(f"插件{result.get_summary().get('stopped_handlers', '')}于请求前中断了内容生成") @@ -390,7 +390,7 @@ class DefaultReplyer: } # 触发 AFTER_LLM 事件 if not from_plugin: - result = await event_manager.trigger_event(EventType.AFTER_LLM,prompt=prompt,llm_response=llm_response,stream_id=stream_id) + result = await event_manager.trigger_event(EventType.AFTER_LLM,plugin_name="SYSTEM",prompt=prompt,llm_response=llm_response,stream_id=stream_id) if not result.all_continue_process(): raise UserWarning(f"插件{result.get_summary().get('stopped_handlers','')}于请求后取消了内容生成") except UserWarning as e: diff --git a/src/chat/utils/prompt_builder.py b/src/chat/utils/prompt_builder.py index 498934339..1db532b5d 100644 --- a/src/chat/utils/prompt_builder.py +++ b/src/chat/utils/prompt_builder.py @@ -12,7 +12,6 @@ install(extra_lines=3) logger = get_logger("prompt_build") - class PromptContext: def __init__(self): self._context_prompts: Dict[str, Dict[str, "Prompt"]] = {} @@ -28,7 +27,7 @@ class PromptContext: @_current_context.setter def _current_context(self, value: Optional[str]): """设置当前协程的上下文ID""" - self._current_context_var.set(value) + self._current_context_var.set(value) # type: ignore @asynccontextmanager async def async_scope(self, context_id: Optional[str] = None): @@ -52,7 +51,7 @@ class PromptContext: # 保存当前协程的上下文值,不影响其他协程 previous_context = self._current_context # 设置当前协程的新上下文 - token = self._current_context_var.set(context_id) if context_id else None + token = self._current_context_var.set(context_id) if context_id else None # type: ignore else: # 如果没有提供新上下文,保持当前上下文不变 previous_context = self._current_context @@ -90,7 +89,8 @@ class PromptContext: """异步注册提示模板到指定作用域""" async with self._context_lock: if target_context := context_id or self._current_context: - self._context_prompts.setdefault(target_context, {})[prompt.name] = prompt + if prompt.name: + self._context_prompts.setdefault(target_context, {})[prompt.name] = prompt class PromptManager: @@ -132,12 +132,16 @@ class PromptManager: def add_prompt(self, name: str, fstr: str) -> "Prompt": prompt = Prompt(fstr, name=name) - self._prompts[prompt.name] = prompt + if prompt.name: + self._prompts[prompt.name] = prompt return prompt async def format_prompt(self, name: str, **kwargs) -> str: + # 获取当前提示词 prompt = await self.get_prompt_async(name) - return prompt.format(**kwargs) + # 获取基本格式化结果 + result = prompt.format(**kwargs) + return result # 全局单例 @@ -145,6 +149,11 @@ global_prompt_manager = PromptManager() class Prompt(str): + template: str + name: Optional[str] + args: List[str] + _args: List[Any] + _kwargs: Dict[str, Any] # 临时标记,作为类常量 _TEMP_LEFT_BRACE = "__ESCAPED_LEFT_BRACE__" _TEMP_RIGHT_BRACE = "__ESCAPED_RIGHT_BRACE__" @@ -165,7 +174,7 @@ class Prompt(str): """将临时标记还原为实际的花括号字符""" return template.replace(Prompt._TEMP_LEFT_BRACE, "{").replace(Prompt._TEMP_RIGHT_BRACE, "}") - def __new__(cls, fstr, name: Optional[str] = None, args: Union[List[Any], tuple[Any, ...]] = None, **kwargs): + def __new__(cls, fstr, name: Optional[str] = None, args: Optional[Union[List[Any], tuple[Any, ...]]] = None, **kwargs): # 如果传入的是元组,转换为列表 if isinstance(args, tuple): args = list(args) @@ -201,7 +210,7 @@ class Prompt(str): @classmethod async def create_async( - cls, fstr, name: Optional[str] = None, args: Union[List[Any], tuple[Any, ...]] = None, **kwargs + cls, fstr, name: Optional[str] = None, args: Optional[Union[List[Any], tuple[Any, ...]]] = None, **kwargs ): """异步创建Prompt实例""" prompt = cls(fstr, name, args, **kwargs) @@ -210,7 +219,9 @@ class Prompt(str): return prompt @classmethod - def _format_template(cls, template, args: List[Any] = None, kwargs: Dict[str, Any] = None) -> str: + def _format_template(cls, template, args: Optional[List[Any]] = None, kwargs: Optional[Dict[str, Any]] = None) -> str: + if kwargs is None: + kwargs = {} # 预处理模板中的转义花括号 processed_template = cls._process_escaped_braces(template) diff --git a/src/chat/utils/utils.py b/src/chat/utils/utils.py index 8ea9a4d50..802f6ab83 100644 --- a/src/chat/utils/utils.py +++ b/src/chat/utils/utils.py @@ -449,6 +449,8 @@ def find_similar_topics_simple(text: str, topics: list, top_k: int = 5) -> list: def truncate_message(message: str, max_length=20) -> str: """截断消息,使其不超过指定长度""" + if message is None: + return "" return f"{message[:max_length]}..." if len(message) > max_length else message diff --git a/src/common/cache_manager.py b/src/common/cache_manager.py index f85f37721..d4f872d30 100644 --- a/src/common/cache_manager.py +++ b/src/common/cache_manager.py @@ -4,13 +4,13 @@ import hashlib from pathlib import Path import numpy as np import faiss -import chromadb -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional, Union, List from src.common.logger import get_logger from src.llm_models.utils_model import LLMRequest from src.config.config import global_config, model_config from src.common.database.sqlalchemy_models import CacheEntries from src.common.database.sqlalchemy_database_api import db_query, db_save +from src.common.vector_db import vector_db_service logger = get_logger("cache_manager") @@ -28,25 +28,23 @@ class CacheManager: cls._instance = super(CacheManager, cls).__new__(cls) return cls._instance - def __init__(self, default_ttl: int = 3600, chroma_path: str = "data/chroma_db"): + def __init__(self, default_ttl: int = 3600): """ 初始化缓存管理器。 """ if not hasattr(self, '_initialized'): self.default_ttl = default_ttl - + self.semantic_cache_collection_name = "semantic_cache" + # L1 缓存 (内存) self.l1_kv_cache: Dict[str, Dict[str, Any]] = {} embedding_dim = global_config.lpmm_knowledge.embedding_dimension self.l1_vector_index = faiss.IndexFlatIP(embedding_dim) self.l1_vector_id_to_key: Dict[int, str] = {} - # 语义缓存 (ChromaDB) - - self.chroma_client = chromadb.PersistentClient(path=chroma_path) - self.chroma_collection = self.chroma_client.get_or_create_collection(name="semantic_cache") + # L2 向量缓存 (使用新的服务) + vector_db_service.get_or_create_collection(self.semantic_cache_collection_name) - # 嵌入模型 self.embedding_model = LLMRequest(model_config.model_task_config.embedding) @@ -143,7 +141,7 @@ class CacheManager: # 步骤 2a: L1 语义缓存 (FAISS) if query_embedding is not None and self.l1_vector_index.ntotal > 0: faiss.normalize_L2(query_embedding) - distances, indices = self.l1_vector_index.search(query_embedding, 1) + distances, indices = self.l1_vector_index.search(query_embedding, 1) # type: ignore if indices.size > 0 and distances[0][0] > 0.75: # IP 越大越相似 hit_index = indices[0][0] l1_hit_key = self.l1_vector_id_to_key.get(hit_index) @@ -152,18 +150,20 @@ class CacheManager: return self.l1_kv_cache[l1_hit_key]["data"] # 步骤 2b: L2 精确缓存 (数据库) - cache_results = await db_query( + cache_results_obj = await db_query( model_class=CacheEntries, query_type="get", filters={"cache_key": key}, single_result=True ) - if cache_results: - expires_at = cache_results["expires_at"] + if cache_results_obj: + # 使用 getattr 安全访问属性,避免 Pylance 类型检查错误 + expires_at = getattr(cache_results_obj, "expires_at", 0) if time.time() < expires_at: logger.info(f"命中L2键值缓存: {key}") - data = orjson.loads(cache_results["cache_value"]) + cache_value = getattr(cache_results_obj, "cache_value", "{}") + data = orjson.loads(cache_value) # 更新访问统计 await db_query( @@ -172,7 +172,7 @@ class CacheManager: filters={"cache_key": key}, data={ "last_accessed": time.time(), - "access_count": cache_results["access_count"] + 1 + "access_count": getattr(cache_results_obj, "access_count", 0) + 1 } ) @@ -187,29 +187,35 @@ class CacheManager: filters={"cache_key": key} ) - # 步骤 2c: L2 语义缓存 (ChromaDB) - if query_embedding is not None and self.chroma_collection: + # 步骤 2c: L2 语义缓存 (VectorDB Service) + if query_embedding is not None: try: - results = self.chroma_collection.query(query_embeddings=query_embedding.tolist(), n_results=1) - if results and results['ids'] and results['ids'][0]: - distance = results['distances'][0][0] if results['distances'] and results['distances'][0] else 'N/A' + results = vector_db_service.query( + collection_name=self.semantic_cache_collection_name, + query_embeddings=query_embedding.tolist(), + n_results=1 + ) + if results and results.get('ids') and results['ids'][0]: + distance = results['distances'][0][0] if results.get('distances') and results['distances'][0] else 'N/A' logger.debug(f"L2语义搜索找到最相似的结果: id={results['ids'][0]}, 距离={distance}") + if distance != 'N/A' and distance < 0.75: l2_hit_key = results['ids'][0][0] if isinstance(results['ids'][0], list) else results['ids'][0] logger.info(f"命中L2语义缓存: key='{l2_hit_key}', 距离={distance:.4f}") # 从数据库获取缓存数据 - semantic_cache_results = await db_query( + semantic_cache_results_obj = await db_query( model_class=CacheEntries, query_type="get", filters={"cache_key": l2_hit_key}, single_result=True ) - if semantic_cache_results: - expires_at = semantic_cache_results["expires_at"] + if semantic_cache_results_obj: + expires_at = getattr(semantic_cache_results_obj, "expires_at", 0) if time.time() < expires_at: - data = orjson.loads(semantic_cache_results["cache_value"]) + cache_value = getattr(semantic_cache_results_obj, "cache_value", "{}") + data = orjson.loads(cache_value) logger.debug(f"L2语义缓存返回的数据: {data}") # 回填 L1 @@ -218,13 +224,13 @@ class CacheManager: try: new_id = self.l1_vector_index.ntotal faiss.normalize_L2(query_embedding) - self.l1_vector_index.add(x=query_embedding) + self.l1_vector_index.add(x=query_embedding) # type: ignore self.l1_vector_id_to_key[new_id] = key except Exception as e: logger.error(f"回填L1向量索引时发生错误: {e}") return data except Exception as e: - logger.warning(f"ChromaDB查询失败: {e}") + logger.warning(f"VectorDB Service 查询失败: {e}") logger.debug(f"缓存未命中: {key}") return None @@ -261,22 +267,27 @@ class CacheManager: ) # 写入语义缓存 - if semantic_query and self.embedding_model and self.chroma_collection: + if semantic_query and self.embedding_model: try: embedding_result = await self.embedding_model.get_embedding(semantic_query) if embedding_result: - # embedding_result是一个元组(embedding_vector, model_name),取第一个元素 embedding_vector = embedding_result[0] if isinstance(embedding_result, tuple) else embedding_result validated_embedding = self._validate_embedding(embedding_vector) if validated_embedding is not None: embedding = np.array([validated_embedding], dtype='float32') + # 写入 L1 Vector new_id = self.l1_vector_index.ntotal faiss.normalize_L2(embedding) - self.l1_vector_index.add(x=embedding) + self.l1_vector_index.add(x=embedding) # type: ignore self.l1_vector_id_to_key[new_id] = key - # 写入 L2 Vector - self.chroma_collection.add(embeddings=embedding.tolist(), ids=[key]) + + # 写入 L2 Vector (使用新的服务) + vector_db_service.add( + collection_name=self.semantic_cache_collection_name, + embeddings=embedding.tolist(), + ids=[key] + ) except Exception as e: logger.warning(f"语义缓存写入失败: {e}") @@ -298,15 +309,14 @@ class CacheManager: filters={} # 删除所有记录 ) - # 清空ChromaDB - if self.chroma_collection: - try: - self.chroma_client.delete_collection(name="semantic_cache") - self.chroma_collection = self.chroma_client.get_or_create_collection(name="semantic_cache") - except Exception as e: - logger.warning(f"清空ChromaDB失败: {e}") + # 清空 VectorDB + try: + vector_db_service.delete_collection(name=self.semantic_cache_collection_name) + vector_db_service.get_or_create_collection(name=self.semantic_cache_collection_name) + except Exception as e: + logger.warning(f"清空 VectorDB 集合失败: {e}") - logger.info("L2 (数据库 & ChromaDB) 缓存已清空。") + logger.info("L2 (数据库 & VectorDB) 缓存已清空。") async def clear_all(self): """清空所有缓存。""" @@ -338,4 +348,64 @@ class CacheManager: logger.info(f"清理了 {len(expired_keys)} 个过期的L1缓存条目") # 全局实例 -tool_cache = CacheManager() \ No newline at end of file +tool_cache = CacheManager() + +import inspect +import time + +def wrap_tool_executor(): + """ + 包装工具执行器以添加缓存功能 + 这个函数应该在系统启动时被调用一次 + """ + from src.plugin_system.core.tool_use import ToolExecutor + from src.plugin_system.apis.tool_api import get_tool_instance + original_execute = ToolExecutor.execute_tool_call + + async def wrapped_execute_tool_call(self, tool_call, tool_instance=None): + if not tool_instance: + tool_instance = get_tool_instance(tool_call.func_name) + + if not tool_instance or not tool_instance.enable_cache: + return await original_execute(self, tool_call, tool_instance) + + try: + tool_file_path = inspect.getfile(tool_instance.__class__) + semantic_query = None + if tool_instance.semantic_cache_query_key: + semantic_query = tool_call.args.get(tool_instance.semantic_cache_query_key) + + cached_result = await tool_cache.get( + tool_name=tool_call.func_name, + function_args=tool_call.args, + tool_file_path=tool_file_path, + semantic_query=semantic_query + ) + if cached_result: + logger.info(f"{getattr(self, 'log_prefix', '')}使用缓存结果,跳过工具 {tool_call.func_name} 执行") + return cached_result + except Exception as e: + logger.error(f"{getattr(self, 'log_prefix', '')}检查工具缓存时出错: {e}") + + result = await original_execute(self, tool_call, tool_instance) + + try: + tool_file_path = inspect.getfile(tool_instance.__class__) + semantic_query = None + if tool_instance.semantic_cache_query_key: + semantic_query = tool_call.args.get(tool_instance.semantic_cache_query_key) + + await tool_cache.set( + tool_name=tool_call.func_name, + function_args=tool_call.args, + tool_file_path=tool_file_path, + data=result, + ttl=tool_instance.cache_ttl, + semantic_query=semantic_query + ) + except Exception as e: + logger.error(f"{getattr(self, 'log_prefix', '')}设置工具缓存时出错: {e}") + + return result + + ToolExecutor.execute_tool_call = wrapped_execute_tool_call \ No newline at end of file diff --git a/src/common/database/monthly_plan_db.py b/src/common/database/monthly_plan_db.py index a65e6f547..01acf2d5a 100644 --- a/src/common/database/monthly_plan_db.py +++ b/src/common/database/monthly_plan_db.py @@ -80,16 +80,54 @@ def mark_plans_completed(plan_ids: List[int]): with get_db_session() as session: try: + plans_to_mark = session.query(MonthlyPlan).filter(MonthlyPlan.id.in_(plan_ids)).all() + if not plans_to_mark: + logger.info("没有需要标记为完成的月度计划。") + return + + plan_details = "\n".join([f" {i+1}. {plan.plan_text}" for i, plan in enumerate(plans_to_mark)]) + logger.info(f"以下 {len(plans_to_mark)} 条月度计划将被标记为已完成:\n{plan_details}") + session.query(MonthlyPlan).filter( MonthlyPlan.id.in_(plan_ids) ).update({"status": "completed"}, synchronize_session=False) session.commit() - logger.info(f"成功将 {len(plan_ids)} 条月度计划标记为已完成。") except Exception as e: logger.error(f"标记月度计划为完成时发生错误: {e}") session.rollback() raise +def delete_plans_by_ids(plan_ids: List[int]): + """ + 根据ID列表从数据库中物理删除月度计划。 + + :param plan_ids: 需要删除的计划ID列表。 + """ + if not plan_ids: + return + + with get_db_session() as session: + try: + # 先查询要删除的计划,用于日志记录 + plans_to_delete = session.query(MonthlyPlan).filter(MonthlyPlan.id.in_(plan_ids)).all() + if not plans_to_delete: + logger.info("没有找到需要删除的月度计划。") + return + + plan_details = "\n".join([f" {i+1}. {plan.plan_text}" for i, plan in enumerate(plans_to_delete)]) + logger.info(f"检测到月度计划超额,将删除以下 {len(plans_to_delete)} 条计划:\n{plan_details}") + + # 执行删除 + session.query(MonthlyPlan).filter( + MonthlyPlan.id.in_(plan_ids) + ).delete(synchronize_session=False) + session.commit() + + except Exception as e: + logger.error(f"删除月度计划时发生错误: {e}") + session.rollback() + raise + def soft_delete_plans(plan_ids: List[int]): """ 将指定ID的计划标记为软删除(兼容旧接口)。 diff --git a/src/common/database/sqlalchemy_models.py b/src/common/database/sqlalchemy_models.py index b63b65661..779179ff9 100644 --- a/src/common/database/sqlalchemy_models.py +++ b/src/common/database/sqlalchemy_models.py @@ -5,12 +5,12 @@ from sqlalchemy import Column, String, Float, Integer, Boolean, Text, Index, create_engine, DateTime from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.orm import sessionmaker, Session, Mapped, mapped_column from sqlalchemy.pool import QueuePool import os import datetime import time -from typing import Iterator, Optional +from typing import Iterator, Optional, Any, Dict from src.common.logger import get_logger from contextlib import contextmanager @@ -306,14 +306,14 @@ class Expression(Base): """表达风格模型""" __tablename__ = 'expression' - id = Column(Integer, primary_key=True, autoincrement=True) - situation = Column(Text, nullable=False) - style = Column(Text, nullable=False) - count = Column(Float, nullable=False) - last_active_time = Column(Float, nullable=False) - chat_id = Column(get_string_field(64), nullable=False, index=True) - type = Column(Text, nullable=False) - create_date = Column(Float, nullable=True) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + situation: Mapped[str] = mapped_column(Text, nullable=False) + style: Mapped[str] = mapped_column(Text, nullable=False) + count: Mapped[float] = mapped_column(Float, nullable=False) + last_active_time: Mapped[float] = mapped_column(Float, nullable=False) + chat_id: Mapped[str] = mapped_column(get_string_field(64), nullable=False, index=True) + type: Mapped[str] = mapped_column(Text, nullable=False) + create_date: Mapped[Optional[float]] = mapped_column(Float, nullable=True) __table_args__ = ( Index('idx_expression_chat_id', 'chat_id'), @@ -589,7 +589,7 @@ def initialize_database(): config = global_config.database # 配置引擎参数 - engine_kwargs = { + engine_kwargs: Dict[str, Any] = { 'echo': False, # 生产环境关闭SQL日志 'future': True, } @@ -642,7 +642,9 @@ def get_db_session() -> Iterator[Session]: """数据库会话上下文管理器 - 推荐使用这个而不是get_session()""" session: Optional[Session] = None try: - _, SessionLocal = initialize_database() + engine, SessionLocal = initialize_database() + if not SessionLocal: + raise RuntimeError("Database session not initialized") session = SessionLocal() yield session #session.commit() diff --git a/src/common/vector_db/__init__.py b/src/common/vector_db/__init__.py new file mode 100644 index 000000000..e9cd42a98 --- /dev/null +++ b/src/common/vector_db/__init__.py @@ -0,0 +1,19 @@ +from .base import VectorDBBase +from .chromadb_impl import ChromaDBImpl + +def get_vector_db_service() -> VectorDBBase: + """ + 工厂函数,初始化并返回向量数据库服务实例。 + + 目前硬编码为 ChromaDB,未来可以从配置中读取。 + """ + # TODO: 从全局配置中读取数据库类型和路径 + db_path = "data/chroma_db" + + # ChromaDBImpl 是一个单例,所以这里每次调用都会返回同一个实例 + return ChromaDBImpl(path=db_path) + +# 全局向量数据库服务实例 +vector_db_service: VectorDBBase = get_vector_db_service() + +__all__ = ["vector_db_service", "VectorDBBase"] \ No newline at end of file diff --git a/src/common/vector_db/base.py b/src/common/vector_db/base.py new file mode 100644 index 000000000..e94b74cba --- /dev/null +++ b/src/common/vector_db/base.py @@ -0,0 +1,145 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +class VectorDBBase(ABC): + """ + 向量数据库的抽象基类 (ABC),定义了所有向量数据库实现必须遵循的接口。 + """ + + @abstractmethod + def __init__(self, path: str, **kwargs: Any): + """ + 初始化向量数据库客户端。 + + Args: + path (str): 数据库文件的存储路径。 + **kwargs: 其他特定于实现的参数。 + """ + pass + + @abstractmethod + def get_or_create_collection(self, name: str, **kwargs: Any) -> Any: + """ + 获取或创建一个集合 (Collection)。 + + Args: + name (str): 集合的名称。 + **kwargs: 其他特定于实现的参数 (例如 metadata)。 + + Returns: + Any: 代表集合的对象。 + """ + pass + + @abstractmethod + def add( + self, + collection_name: str, + embeddings: List[List[float]], + documents: Optional[List[str]] = None, + metadatas: Optional[List[Dict[str, Any]]] = None, + ids: Optional[List[str]] = None, + ) -> None: + """ + 向指定集合中添加数据。 + + Args: + collection_name (str): 目标集合的名称。 + embeddings (List[List[float]]): 向量列表。 + documents (Optional[List[str]], optional): 文档列表。Defaults to None. + metadatas (Optional[List[Dict[str, Any]]], optional): 元数据列表。Defaults to None. + ids (Optional[List[str]], optional): ID 列表。Defaults to None. + """ + pass + + @abstractmethod + def query( + self, + collection_name: str, + query_embeddings: List[List[float]], + n_results: int = 1, + where: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> Dict[str, List[Any]]: + """ + 在指定集合中查询相似向量。 + + Args: + collection_name (str): 目标集合的名称。 + query_embeddings (List[List[float]]): 用于查询的向量列表。 + n_results (int, optional): 返回结果的数量。Defaults to 1. + where (Optional[Dict[str, Any]], optional): 元数据过滤条件。Defaults to None. + **kwargs: 其他特定于实现的参数。 + + Returns: + Dict[str, List[Any]]: 查询结果,通常包含 ids, distances, metadatas, documents。 + """ + pass + + @abstractmethod + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + where: Optional[Dict[str, Any]] = None, + ) -> None: + """ + 从指定集合中删除数据。 + + Args: + collection_name (str): 目标集合的名称。 + ids (Optional[List[str]], optional): 要删除的条目的 ID 列表。Defaults to None. + where (Optional[Dict[str, Any]], optional): 基于元数据的过滤条件。Defaults to None. + """ + pass + + @abstractmethod + def get( + self, + collection_name: str, + ids: Optional[List[str]] = None, + where: Optional[Dict[str, Any]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + where_document: Optional[Dict[str, Any]] = None, + include: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + 根据条件从集合中获取数据。 + + Args: + collection_name (str): 目标集合的名称。 + ids (Optional[List[str]], optional): 要获取的条目的 ID 列表。Defaults to None. + where (Optional[Dict[str, Any]], optional): 基于元数据的过滤条件。Defaults to None. + limit (Optional[int], optional): 返回结果的数量限制。Defaults to None. + offset (Optional[int], optional): 返回结果的偏移量。Defaults to None. + where_document (Optional[Dict[str, Any]], optional): 基于文档内容的过滤条件。Defaults to None. + include (Optional[List[str]], optional): 指定返回的数据字段 (e.g., ["metadatas", "documents"])。Defaults to None. + + Returns: + Dict[str, Any]: 获取到的数据。 + """ + pass + + @abstractmethod + def count(self, collection_name: str) -> int: + """ + 获取指定集合中的条目总数。 + + Args: + collection_name (str): 目标集合的名称。 + + Returns: + int: 条目总数。 + """ + pass + + @abstractmethod + def delete_collection(self, name: str) -> None: + """ + 删除一个集合。 + + Args: + name (str): 要删除的集合的名称。 + """ + pass \ No newline at end of file diff --git a/src/common/vector_db/chromadb_impl.py b/src/common/vector_db/chromadb_impl.py new file mode 100644 index 000000000..8e9313b3b --- /dev/null +++ b/src/common/vector_db/chromadb_impl.py @@ -0,0 +1,163 @@ +import threading +from typing import Any, Dict, List, Optional + +import chromadb +from chromadb.config import Settings + +from .base import VectorDBBase +from src.common.logger import get_logger + +logger = get_logger("chromadb_impl") + +class ChromaDBImpl(VectorDBBase): + """ + ChromaDB 的具体实现,遵循 VectorDBBase 接口。 + 采用单例模式,确保全局只有一个 ChromaDB 客户端实例。 + """ + _instance = None + _lock = threading.Lock() + + def __new__(cls, *args, **kwargs): + if not cls._instance: + with cls._lock: + if not cls._instance: + cls._instance = super(ChromaDBImpl, cls).__new__(cls) + return cls._instance + + def __init__(self, path: str = "data/chroma_db", **kwargs: Any): + """ + 初始化 ChromaDB 客户端。 + 由于是单例,这个初始化只会执行一次。 + """ + if not hasattr(self, '_initialized'): + with self._lock: + if not hasattr(self, '_initialized'): + try: + self.client = chromadb.PersistentClient( + path=path, + settings=Settings(anonymized_telemetry=False) + ) + self._collections: Dict[str, Any] = {} + self._initialized = True + logger.info(f"ChromaDB 客户端已初始化,数据库路径: {path}") + except Exception as e: + logger.error(f"ChromaDB 初始化失败: {e}") + self.client = None + self._initialized = False + + def get_or_create_collection(self, name: str, **kwargs: Any) -> Any: + if not self.client: + raise ConnectionError("ChromaDB 客户端未初始化") + + if name in self._collections: + return self._collections[name] + + try: + collection = self.client.get_or_create_collection(name=name, **kwargs) + self._collections[name] = collection + logger.info(f"成功获取或创建集合: '{name}'") + return collection + except Exception as e: + logger.error(f"获取或创建集合 '{name}' 失败: {e}") + return None + + def add( + self, + collection_name: str, + embeddings: List[List[float]], + documents: Optional[List[str]] = None, + metadatas: Optional[List[Dict[str, Any]]] = None, + ids: Optional[List[str]] = None, + ) -> None: + collection = self.get_or_create_collection(collection_name) + if collection: + try: + collection.add( + embeddings=embeddings, + documents=documents, + metadatas=metadatas, + ids=ids, + ) + except Exception as e: + logger.error(f"向集合 '{collection_name}' 添加数据失败: {e}") + + def query( + self, + collection_name: str, + query_embeddings: List[List[float]], + n_results: int = 1, + where: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> Dict[str, List[Any]]: + collection = self.get_or_create_collection(collection_name) + if collection: + try: + return collection.query( + query_embeddings=query_embeddings, + n_results=n_results, + where=where or {}, + **kwargs, + ) + except Exception as e: + logger.error(f"查询集合 '{collection_name}' 失败: {e}") + return {} + + def get( + self, + collection_name: str, + ids: Optional[List[str]] = None, + where: Optional[Dict[str, Any]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + where_document: Optional[Dict[str, Any]] = None, + include: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """根据条件从集合中获取数据""" + collection = self.get_or_create_collection(collection_name) + if collection: + try: + return collection.get( + ids=ids, + where=where, + limit=limit, + offset=offset, + where_document=where_document, + include=include, + ) + except Exception as e: + logger.error(f"从集合 '{collection_name}' 获取数据失败: {e}") + return {} + + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + where: Optional[Dict[str, Any]] = None, + ) -> None: + collection = self.get_or_create_collection(collection_name) + if collection: + try: + collection.delete(ids=ids, where=where) + except Exception as e: + logger.error(f"从集合 '{collection_name}' 删除数据失败: {e}") + + def count(self, collection_name: str) -> int: + collection = self.get_or_create_collection(collection_name) + if collection: + try: + return collection.count() + except Exception as e: + logger.error(f"获取集合 '{collection_name}' 计数失败: {e}") + return 0 + + def delete_collection(self, name: str) -> None: + if not self.client: + raise ConnectionError("ChromaDB 客户端未初始化") + + try: + self.client.delete_collection(name=name) + if name in self._collections: + del self._collections[name] + logger.info(f"集合 '{name}' 已被删除") + except Exception as e: + logger.error(f"删除集合 '{name}' 失败: {e}") \ No newline at end of file diff --git a/src/config/config.py b/src/config/config.py index bd69399f4..e403f7ce0 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -45,6 +45,7 @@ from src.config.official_configs import ( MonthlyPlanSystemConfig, CrossContextConfig, PermissionConfig, + CommandConfig, MaizoneIntercomConfig, ) @@ -372,7 +373,7 @@ class Config(ValidatedConfigBase): chinese_typo: ChineseTypoConfig = Field(..., description="中文错别字配置") response_post_process: ResponsePostProcessConfig = Field(..., description="响应后处理配置") response_splitter: ResponseSplitterConfig = Field(..., description="响应分割配置") - experimental: ExperimentalConfig = Field(..., description="实验性功能配置") + experimental: ExperimentalConfig = Field(default_factory=lambda: ExperimentalConfig(), description="实验性功能配置") maim_message: MaimMessageConfig = Field(..., description="Maim消息配置") lpmm_knowledge: LPMMKnowledgeConfig = Field(..., description="LPMM知识配置") tool: ToolConfig = Field(..., description="工具配置") @@ -381,6 +382,7 @@ class Config(ValidatedConfigBase): voice: VoiceConfig = Field(..., description="语音配置") schedule: ScheduleConfig = Field(..., description="调度配置") permission: PermissionConfig = Field(..., description="权限配置") + command: CommandConfig = Field(..., description="命令系统配置") # 有默认值的字段放在后面 anti_prompt_injection: AntiPromptInjectionConfig = Field(default_factory=lambda: AntiPromptInjectionConfig(), description="反提示注入配置") diff --git a/src/config/official_configs.py b/src/config/official_configs.py index e8f41d00f..6a8d47187 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -263,11 +263,20 @@ class NormalChatConfig(ValidatedConfigBase): +class ExpressionRule(ValidatedConfigBase): + """表达学习规则""" + + chat_stream_id: str = Field(..., description="聊天流ID,空字符串表示全局") + use_expression: bool = Field(default=True, description="是否使用学到的表达") + learn_expression: bool = Field(default=True, description="是否学习表达") + learning_strength: float = Field(default=1.0, description="学习强度") + group: Optional[str] = Field(default=None, description="表达共享组") + + class ExpressionConfig(ValidatedConfigBase): """表达配置类""" - expression_learning: list[list] = Field(default_factory=lambda: [], description="表达学习") - expression_groups: list[list[str]] = Field(default_factory=list, description="表达组") + rules: List[ExpressionRule] = Field(default_factory=list, description="表达学习规则") def _parse_stream_config_to_chat_id(self, stream_config_str: str) -> Optional[str]: """ @@ -314,87 +323,39 @@ class ExpressionConfig(ValidatedConfigBase): Returns: tuple: (是否使用表达, 是否学习表达, 学习间隔) """ - if not self.expression_learning: - # 如果没有配置,使用默认值:启用表达,启用学习,300秒间隔 - return True, True, 300 + if not self.rules: + # 如果没有配置,使用默认值:启用表达,启用学习,强度1.0 + return True, True, 1.0 # 优先检查聊天流特定的配置 if chat_stream_id: - specific_config = self._get_stream_specific_config(chat_stream_id) - if specific_config is not None: - return specific_config + for rule in self.rules: + if rule.chat_stream_id and self._parse_stream_config_to_chat_id(rule.chat_stream_id) == chat_stream_id: + return rule.use_expression, rule.learn_expression, rule.learning_strength - # 检查全局配置(第一个元素为空字符串的配置) - global_config = self._get_global_config() - if global_config is not None: - return global_config + # 检查全局配置(chat_stream_id为空字符串的配置) + for rule in self.rules: + if rule.chat_stream_id == "": + return rule.use_expression, rule.learn_expression, rule.learning_strength # 如果都没有匹配,返回默认值 - return True, True, 300 + return True, True, 1.0 - def _get_stream_specific_config(self, chat_stream_id: str) -> Optional[tuple[bool, bool, float]]: - """ - 获取特定聊天流的表达配置 - Args: - chat_stream_id: 聊天流ID(哈希值) +class ToolHistoryConfig(ValidatedConfigBase): + """工具历史记录配置类""" - Returns: - tuple: (是否使用表达, 是否学习表达, 学习间隔),如果没有配置则返回 None - """ - for config_item in self.expression_learning: - if not config_item or len(config_item) < 4: - continue + enable_history: bool = True + """是否启用工具历史记录""" - stream_config_str = config_item[0] # 例如 "qq:1026294844:group" + enable_prompt_history: bool = True + """是否在提示词中加入工具历史记录""" - # 如果是空字符串,跳过(这是全局配置) - if stream_config_str == "": - continue - - # 解析配置字符串并生成对应的 chat_id - config_chat_id = self._parse_stream_config_to_chat_id(stream_config_str) - if config_chat_id is None: - continue - - # 比较生成的 chat_id - if config_chat_id != chat_stream_id: - continue - - # 解析配置 - try: - use_expression = config_item[1].lower() == "enable" - enable_learning = config_item[2].lower() == "enable" - learning_intensity = float(config_item[3]) - return use_expression, enable_learning, learning_intensity - except (ValueError, IndexError): - continue - - return None - - def _get_global_config(self) -> Optional[tuple[bool, bool, float]]: - """ - 获取全局表达配置 - - Returns: - tuple: (是否使用表达, 是否学习表达, 学习间隔),如果没有配置则返回 None - """ - for config_item in self.expression_learning: - if not config_item or len(config_item) < 4: - continue - - # 检查是否为全局配置(第一个元素为空字符串) - if config_item[0] == "": - try: - use_expression = config_item[1].lower() == "enable" - enable_learning = config_item[2].lower() == "enable" - learning_intensity = float(config_item[3]) - return use_expression, enable_learning, learning_intensity - except (ValueError, IndexError): - continue - - return None + max_history: int = 5 + """注入到提示词中的最大工具历史记录数量""" + data_dir: str = "data/tool_history" + """历史记录保存目录""" class ToolConfig(ValidatedConfigBase): @@ -402,7 +363,8 @@ class ToolConfig(ValidatedConfigBase): enable_tool: bool = Field(default=False, description="启用工具") - + history: ToolHistoryConfig = Field(default_factory=ToolHistoryConfig) + """工具历史记录配置""" class VoiceConfig(ValidatedConfigBase): """语音识别配置类""" @@ -568,6 +530,14 @@ class ScheduleConfig(ValidatedConfigBase): enable: bool = Field(default=True, description="启用") guidelines: Optional[str] = Field(default=None, description="指导方针") enable_is_sleep: bool = Field(default=True, description="让AI会根据日程表睡觉和苏醒") + + enable_flexible_sleep: bool = Field(default=True, description="是否启用弹性睡眠") + flexible_sleep_pressure_threshold: float = Field(default=40.0, description="触发弹性睡眠的睡眠压力阈值,低于该值可能延迟入睡") + max_sleep_delay_minutes: int = Field(default=60, description="单日最大延迟入睡分钟数") + + enable_pre_sleep_notification: bool = Field(default=True, description="是否启用睡前消息") + pre_sleep_notification_groups: List[str] = Field(default_factory=list, description="接收睡前消息的群号列表, 格式: [\"platform:group_id1\", \"platform:group_id2\"]") + pre_sleep_prompt: str = Field(default="我准备睡觉了,请生成一句简短自然的晚安问候。", description="用于生成睡前消息的提示") @@ -641,7 +611,7 @@ class PluginsConfig(ValidatedConfigBase): class WakeUpSystemConfig(ValidatedConfigBase): - """唤醒度系统配置类""" + """唤醒度与失眠系统配置类""" enable: bool = Field(default=True, description="是否启用唤醒度系统") wakeup_threshold: float = Field(default=15.0, ge=1.0, description="唤醒阈值,达到此值时会被唤醒") @@ -652,6 +622,16 @@ class WakeUpSystemConfig(ValidatedConfigBase): angry_duration: float = Field(default=300.0, ge=10.0, description="愤怒状态持续时间(秒)") angry_prompt: str = Field(default="你被人吵醒了非常生气,说话带着怒气", description="被吵醒后的愤怒提示词") + # --- 失眠机制相关参数 --- + enable_insomnia_system: bool = Field(default=True, description="是否启用失眠系统") + insomnia_duration_minutes: int = Field(default=30, ge=1, description="单次失眠状态的持续时间(分钟)") + sleep_pressure_threshold: float = Field(default=30.0, description="触发“压力不足型失眠”的睡眠压力阈值") + deep_sleep_threshold: float = Field(default=80.0, description="进入“深度睡眠”的睡眠压力阈值") + insomnia_chance_low_pressure: float = Field(default=0.6, ge=0.0, le=1.0, description="压力不足时的失眠基础概率") + insomnia_chance_normal_pressure: float = Field(default=0.1, ge=0.0, le=1.0, description="压力正常时的失眠基础概率") + sleep_pressure_increment: float = Field(default=1.5, ge=0.0, description="每次AI执行动作后,增加的睡眠压力值") + sleep_pressure_decay_rate: float = Field(default=1.5, ge=0.0, description="睡眠时,每分钟衰减的睡眠压力值") + class MonthlyPlanSystemConfig(ValidatedConfigBase): """月度计划系统配置类""" @@ -681,6 +661,12 @@ class MaizoneIntercomConfig(ValidatedConfigBase): groups: List[ContextGroup] = Field(default_factory=list, description="Maizone互通组列表") +class CommandConfig(ValidatedConfigBase): + """命令系统配置类""" + + command_prefixes: List[str] = Field(default_factory=lambda: ['/', '!', '.', '#'], description="支持的命令前缀列表") + + class PermissionConfig(ValidatedConfigBase): """权限系统配置类""" diff --git a/src/llm_models/utils_model.py b/src/llm_models/utils_model.py index eb956abff..7c9f19869 100644 --- a/src/llm_models/utils_model.py +++ b/src/llm_models/utils_model.py @@ -328,10 +328,13 @@ class LLMRequest: if not reasoning_content and content: content, extracted_reasoning = self._extract_reasoning(content) reasoning_content = extracted_reasoning - - # 检测是否为空回复或截断 - is_empty_reply = not content or content.strip() == "" + + is_empty_reply = False is_truncated = False + # 检测是否为空回复或截断 + if not tool_calls: + is_empty_reply = not content or content.strip() == "" + is_truncated = False if use_anti_truncation: if content.endswith("[done]"): @@ -370,7 +373,7 @@ class LLMRequest: ) # 处理空回复 - if not content: + if not content and not tool_calls: if raise_when_empty: raise RuntimeError(f"经过 {empty_retry_count} 次重试后仍然生成空回复") content = "生成的响应为空,请检查模型配置或输入内容是否正确" diff --git a/src/main.py b/src/main.py index 1c826cbb9..6a5f989b0 100644 --- a/src/main.py +++ b/src/main.py @@ -17,8 +17,8 @@ from src.individuality.individuality import get_individuality, Individuality from src.common.server import get_global_server, Server from src.mood.mood_manager import mood_manager from rich.traceback import install -from src.manager.schedule_manager import schedule_manager -from src.manager.monthly_plan_manager import monthly_plan_manager +from src.schedule.schedule_manager import schedule_manager +from src.schedule.monthly_plan_manager import monthly_plan_manager from src.plugin_system.core.event_manager import event_manager from src.plugin_system.base.component_types import EventType # from src.api.main import start_api_server @@ -141,10 +141,10 @@ class MainSystem: logger.info(f""" 全部系统初始化完成,{global_config.bot.nickname}已成功唤醒 ========================================================= -MaiMbot-Pro-Max(第三方修改版) +MoFox_Bot(第三方修改版) 全部组件已成功启动! ========================================================= -🌐 项目地址: https://github.com/MaiBot-Plus/MaiMbot-Pro-Max +🌐 项目地址: https://github.com/MoFox-Studio/MoFox_Bot 🏠 官方项目: https://github.com/MaiM-with-u/MaiBot ========================================================= 这是基于原版MMC的社区改版,包含增强功能和优化(同时也有更多的'特性') @@ -254,7 +254,7 @@ MaiMbot-Pro-Max(第三方修改版) try: - await event_manager.trigger_event(EventType.ON_START) + await event_manager.trigger_event(EventType.ON_START,plugin_name="SYSTEM") init_time = int(1000 * (time.time() - init_start_time)) logger.info(f"初始化完成,神经元放电{init_time}次") except Exception as e: diff --git a/src/manager/monthly_plan_manager.py b/src/manager/monthly_plan_manager.py deleted file mode 100644 index f5f20cef2..000000000 --- a/src/manager/monthly_plan_manager.py +++ /dev/null @@ -1,313 +0,0 @@ -# mmc/src/manager/monthly_plan_manager.py - -import asyncio -from datetime import datetime, timedelta -from typing import List, Optional - -from src.common.database.monthly_plan_db import ( - add_new_plans, - get_archived_plans_for_month, - archive_active_plans_for_month, - has_active_plans -) -from src.config.config import global_config, model_config -from src.llm_models.utils_model import LLMRequest -from src.common.logger import get_logger -from src.manager.async_task_manager import AsyncTask, async_task_manager - -logger = get_logger("monthly_plan_manager") - -# 默认的月度计划生成指导原则 -DEFAULT_MONTHLY_PLAN_GUIDELINES = """ -我希望你能为自己制定一些有意义的月度小目标和计划。 -这些计划应该涵盖学习、娱乐、社交、个人成长等各个方面。 -每个计划都应该是具体可行的,能够在一个月内通过日常活动逐步实现。 -请确保计划既有挑战性又不会过于繁重,保持生活的平衡和乐趣。 -""" - -class MonthlyPlanManager: - """月度计划管理器 - - 负责月度计划的生成、管理和生命周期控制。 - 与 ScheduleManager 解耦,专注于月度层面的计划管理。 - """ - - def __init__(self): - self.llm = LLMRequest( - model_set=model_config.model_task_config.schedule_generator, - request_type="monthly_plan" - ) - self.generation_running = False - self.monthly_task_started = False - - async def start_monthly_plan_generation(self): - """启动每月初自动生成新月度计划的任务,并在启动时检查一次""" - if not self.monthly_task_started: - logger.info("正在启动每月月度计划生成任务...") - task = MonthlyPlanGenerationTask(self) - await async_task_manager.add_task(task) - self.monthly_task_started = True - logger.info("每月月度计划生成任务已成功启动。") - - # 启动时立即检查并按需生成 - logger.info("执行启动时月度计划检查...") - await self.ensure_and_generate_plans_if_needed() - else: - logger.info("每月月度计划生成任务已在运行中。") - - async def ensure_and_generate_plans_if_needed(self, target_month: Optional[str] = None) -> bool: - """ - 确保指定月份有计划,如果没有则触发生成。 - 这是按需生成的主要入口点。 - """ - if target_month is None: - target_month = datetime.now().strftime("%Y-%m") - - if not has_active_plans(target_month): - logger.info(f"{target_month} 没有任何有效的月度计划,将立即生成。") - return await self.generate_monthly_plans(target_month) - else: - # logger.info(f"{target_month} 已存在有效的月度计划,跳过生成。") - return True # 已经有计划,也算成功 - - async def generate_monthly_plans(self, target_month: Optional[str] = None) -> bool: - """ - 生成指定月份的月度计划 - - :param target_month: 目标月份,格式为 "YYYY-MM"。如果为 None,则为当前月份。 - :return: 是否生成成功 - """ - if self.generation_running: - logger.info("月度计划生成任务已在运行中,跳过重复启动") - return False - - self.generation_running = True - - try: - # 确定目标月份 - if target_month is None: - target_month = datetime.now().strftime("%Y-%m") - - logger.info(f"开始为 {target_month} 生成月度计划...") - - # 检查是否启用月度计划系统 - if not global_config.monthly_plan_system or not global_config.monthly_plan_system.enable: - logger.info("月度计划系统已禁用,跳过计划生成。") - return False - - # 获取上个月的归档计划作为参考 - last_month = self._get_previous_month(target_month) - archived_plans = get_archived_plans_for_month(last_month) - - # 构建生成 Prompt - prompt = self._build_generation_prompt(target_month, archived_plans) - - # 调用 LLM 生成计划 - plans = await self._generate_plans_with_llm(prompt) - - if plans: - # 保存到数据库 - add_new_plans(plans, target_month) - logger.info(f"成功为 {target_month} 生成并保存了 {len(plans)} 条月度计划。") - return True - else: - logger.warning(f"未能为 {target_month} 生成有效的月度计划。") - return False - - except Exception as e: - logger.error(f"生成 {target_month} 月度计划时发生错误: {e}") - return False - finally: - self.generation_running = False - - def _get_previous_month(self, current_month: str) -> str: - """获取上个月的月份字符串""" - try: - year, month = map(int, current_month.split('-')) - if month == 1: - return f"{year-1}-12" - else: - return f"{year}-{month-1:02d}" - except Exception: - # 如果解析失败,返回一个不存在的月份 - return "1900-01" - - def _build_generation_prompt(self, target_month: str, archived_plans: List) -> str: - """构建月度计划生成的 Prompt""" - - # 获取配置 - guidelines = getattr(global_config.monthly_plan_system, 'guidelines', None) or DEFAULT_MONTHLY_PLAN_GUIDELINES - personality = global_config.personality.personality_core - personality_side = global_config.personality.personality_side - max_plans = global_config.monthly_plan_system.max_plans_per_month - - # 构建上月未完成计划的参考信息 - archived_plans_block = "" - if archived_plans: - archived_texts = [f"- {plan.plan_text}" for plan in archived_plans[:5]] # 最多显示5个 - archived_plans_block = f""" -**上个月未完成的一些计划(可作为参考)**: -{chr(10).join(archived_texts)} - -你可以考虑是否要在这个月继续推进这些计划,或者制定全新的计划。 -""" - - prompt = f""" -我,{global_config.bot.nickname},需要为自己制定 {target_month} 的月度计划。 - -**关于我**: -- **核心人设**: {personality} -- **具体习惯与兴趣**: -{personality_side} - -{archived_plans_block} - -**我的月度计划制定原则**: -{guidelines} - -**重要要求**: -1. 请为我生成 {max_plans} 条左右的月度计划 -2. 每条计划都应该是一句话,简洁明了,具体可行 -3. 计划应该涵盖不同的生活方面(学习、娱乐、社交、个人成长等) -4. 返回格式必须是纯文本,每行一条计划,不要使用 JSON 或其他格式 -5. 不要包含任何解释性文字,只返回计划列表 - -**示例格式**: -学习一门新的编程语言或技术 -每周至少看两部有趣的电影 -与朋友们组织一次户外活动 -阅读3本感兴趣的书籍 -尝试制作一道新的料理 - -请你扮演我,以我的身份和兴趣,为 {target_month} 制定合适的月度计划。 -""" - - return prompt - - async def _generate_plans_with_llm(self, prompt: str) -> List[str]: - """使用 LLM 生成月度计划列表""" - max_retries = 3 - - for attempt in range(1, max_retries + 1): - try: - logger.info(f"正在生成月度计划 (第 {attempt} 次尝试)") - - response, _ = await self.llm.generate_response_async(prompt) - - # 解析响应 - plans = self._parse_plans_response(response) - - if plans: - logger.info(f"成功生成 {len(plans)} 条月度计划") - return plans - else: - logger.warning(f"第 {attempt} 次生成的计划为空,继续重试...") - - except Exception as e: - logger.error(f"第 {attempt} 次生成月度计划失败: {e}") - - # 添加短暂延迟,避免过于频繁的请求 - if attempt < max_retries: - await asyncio.sleep(2) - - logger.error("所有尝试都失败,无法生成月度计划") - return [] - - def _parse_plans_response(self, response: str) -> List[str]: - """解析 LLM 响应,提取计划列表""" - try: - # 清理响应文本 - response = response.strip() - - # 按行分割 - lines = [line.strip() for line in response.split('\n') if line.strip()] - - # 过滤掉明显不是计划的行(比如包含特殊标记的行) - plans = [] - for line in lines: - # 跳过包含特殊标记的行 - if any(marker in line for marker in ['**', '##', '```', '---', '===', '###']): - continue - - # 移除可能的序号前缀 - line = line.lstrip('0123456789.- ') - - # 确保计划不为空且有意义 - if len(line) > 5 and not line.startswith(('请', '以上', '总结', '注意')): - plans.append(line) - - # 限制计划数量 - max_plans = global_config.monthly_plan_system.max_plans_per_month - if len(plans) > max_plans: - plans = plans[:max_plans] - - return plans - - except Exception as e: - logger.error(f"解析月度计划响应时发生错误: {e}") - return [] - - async def archive_current_month_plans(self, target_month: Optional[str] = None): - """ - 归档当前月份的活跃计划 - - :param target_month: 目标月份,格式为 "YYYY-MM"。如果为 None,则为当前月份。 - """ - try: - if target_month is None: - target_month = datetime.now().strftime("%Y-%m") - - logger.info(f"开始归档 {target_month} 的活跃月度计划...") - archived_count = archive_active_plans_for_month(target_month) - logger.info(f"成功归档了 {archived_count} 条 {target_month} 的月度计划。") - - except Exception as e: - logger.error(f"归档 {target_month} 月度计划时发生错误: {e}") - - -class MonthlyPlanGenerationTask(AsyncTask): - """每月初自动生成新月度计划的任务""" - - def __init__(self, monthly_plan_manager: MonthlyPlanManager): - super().__init__(task_name="MonthlyPlanGenerationTask") - self.monthly_plan_manager = monthly_plan_manager - - async def run(self): - while True: - try: - # 计算到下个月1号凌晨的时间 - now = datetime.now() - - # 获取下个月的第一天 - if now.month == 12: - next_month = datetime(now.year + 1, 1, 1) - else: - next_month = datetime(now.year, now.month + 1, 1) - - sleep_seconds = (next_month - now).total_seconds() - - logger.info(f"下一次月度计划生成任务将在 {sleep_seconds:.2f} 秒后运行 (北京时间 {next_month.strftime('%Y-%m-%d %H:%M:%S')})") - - # 等待直到下个月1号 - await asyncio.sleep(sleep_seconds) - - # 先归档上个月的计划 - last_month = (next_month - timedelta(days=1)).strftime("%Y-%m") - await self.monthly_plan_manager.archive_current_month_plans(last_month) - - # 生成新月份的计划 - current_month = next_month.strftime("%Y-%m") - logger.info(f"到达月初,开始生成 {current_month} 的月度计划...") - await self.monthly_plan_manager.generate_monthly_plans(current_month) - - except asyncio.CancelledError: - logger.info("每月月度计划生成任务被取消。") - break - except Exception as e: - logger.error(f"每月月度计划生成任务发生未知错误: {e}") - # 发生错误后,等待1小时再重试,避免频繁失败 - await asyncio.sleep(3600) - - -# 全局实例 -monthly_plan_manager = MonthlyPlanManager() \ No newline at end of file diff --git a/src/mood/mood_manager.py b/src/mood/mood_manager.py index b288953f4..8e533d7c0 100644 --- a/src/mood/mood_manager.py +++ b/src/mood/mood_manager.py @@ -66,6 +66,11 @@ class ChatMood: self.last_change_time: float = 0 async def update_mood_by_message(self, message: MessageRecv, interested_rate: float): + # 如果当前聊天处于失眠状态,则锁定情绪,不允许更新 + if self.chat_id in mood_manager.insomnia_chats: + logger.debug(f"{self.log_prefix} 处于失眠状态,情绪已锁定,跳过更新。") + return + self.regression_count = 0 during_last_time = message.message_info.time - self.last_change_time # type: ignore @@ -216,6 +221,7 @@ class MoodManager: self.mood_list: list[ChatMood] = [] """当前情绪状态""" self.task_started: bool = False + self.insomnia_chats: set[str] = set() # 正在失眠的聊天ID列表 async def start(self): """启动情绪回归后台任务""" @@ -262,6 +268,16 @@ class MoodManager: mood.mood_state = "感觉很平静" logger.info(f"{mood.log_prefix} 清除被吵醒的愤怒状态") + def start_insomnia(self, chat_id: str): + """开始一个聊天的失眠状态,锁定情绪更新""" + logger.info(f"Chat [{chat_id}]进入失眠状态,情绪已锁定。") + self.insomnia_chats.add(chat_id) + + def stop_insomnia(self, chat_id: str): + """停止一个聊天的失眠状态,解锁情绪更新""" + logger.info(f"Chat [{chat_id}]失眠状态结束,情绪已解锁。") + self.insomnia_chats.discard(chat_id) + def get_angry_prompt_addition(self, chat_id: str) -> str: """获取愤怒状态下的提示词补充""" mood = self.get_mood_by_chat_id(chat_id) diff --git a/src/plugin_system/__init__.py b/src/plugin_system/__init__.py index ecadc0e80..26cc166f6 100644 --- a/src/plugin_system/__init__.py +++ b/src/plugin_system/__init__.py @@ -17,6 +17,7 @@ from .base import ( ComponentInfo, ActionInfo, CommandInfo, + PlusCommandInfo, PluginInfo, ToolInfo, PythonDependency, @@ -25,6 +26,12 @@ from .base import ( EventType, MaiMessages, ToolParamType, + # 新增的增强命令系统 + PlusCommand, + CommandArgs, + PlusCommandAdapter, + create_plus_command_adapter, + ChatType, ) # 导入工具模块 @@ -81,10 +88,17 @@ __all__ = [ "BaseCommand", "BaseTool", "BaseEventHandler", + # 增强命令系统 + "PlusCommand", + "CommandArgs", + "PlusCommandAdapter", + "create_plus_command_adapter", + "create_plus_command_adapter", # 类型定义 "ComponentType", "ActionActivationType", "ChatMode", + "ChatType", "ComponentInfo", "ActionInfo", "CommandInfo", diff --git a/src/plugin_system/apis/tool_api.py b/src/plugin_system/apis/tool_api.py index c3472243a..da17f9305 100644 --- a/src/plugin_system/apis/tool_api.py +++ b/src/plugin_system/apis/tool_api.py @@ -1,4 +1,4 @@ -from typing import Optional, Type +from typing import Any, Dict, List, Optional, Type from src.plugin_system.base.base_tool import BaseTool from src.plugin_system.base.component_types import ComponentType @@ -31,4 +31,4 @@ def get_llm_available_tool_definitions(): from src.plugin_system.core import component_registry llm_available_tools = component_registry.get_llm_available_tools() - return [(name, tool_class.get_tool_definition()) for name, tool_class in llm_available_tools.items()] + return [(name, tool_class.get_tool_definition()) for name, tool_class in llm_available_tools.items()] \ No newline at end of file diff --git a/src/plugin_system/base/__init__.py b/src/plugin_system/base/__init__.py index bc63d35d1..83debab01 100644 --- a/src/plugin_system/base/__init__.py +++ b/src/plugin_system/base/__init__.py @@ -13,9 +13,11 @@ from .component_types import ( ComponentType, ActionActivationType, ChatMode, + ChatType, ComponentInfo, ActionInfo, CommandInfo, + PlusCommandInfo, ToolInfo, PluginInfo, PythonDependency, @@ -25,6 +27,8 @@ from .component_types import ( ToolParamType, ) from .config_types import ConfigField +from .plus_command import PlusCommand, PlusCommandAdapter, create_plus_command_adapter +from .command_args import CommandArgs __all__ = [ "BasePlugin", @@ -34,9 +38,11 @@ __all__ = [ "ComponentType", "ActionActivationType", "ChatMode", + "ChatType", "ComponentInfo", "ActionInfo", "CommandInfo", + "PlusCommandInfo", "ToolInfo", "PluginInfo", "PythonDependency", @@ -46,4 +52,9 @@ __all__ = [ "BaseEventHandler", "MaiMessages", "ToolParamType", + # 增强命令系统 + "PlusCommand", + "CommandArgs", + "PlusCommandAdapter", + "create_plus_command_adapter", ] diff --git a/src/plugin_system/base/base_event.py b/src/plugin_system/base/base_event.py index d010d33dc..c527752d5 100644 --- a/src/plugin_system/base/base_event.py +++ b/src/plugin_system/base/base_event.py @@ -1,19 +1,20 @@ +import asyncio from typing import List, Dict, Any, Optional from src.common.logger import get_logger logger = get_logger("base_event") - + class HandlerResult: """事件处理器执行结果 所有事件处理器必须返回此类的实例 """ - def __init__(self, success: bool, continue_process: bool, message: str = "", handler_name: str = ""): + def __init__(self, success: bool, continue_process: bool, message: Any = {}, handler_name: str = ""): self.success = success self.continue_process = continue_process self.message = message self.handler_name = handler_name - + def __repr__(self): return f"HandlerResult(success={self.success}, continue_process={self.continue_process}, message='{self.message}', handler_name='{self.handler_name}')" @@ -66,13 +67,22 @@ class HandlerResultsCollection: } class BaseEvent: - def __init__(self, name: str): + def __init__( + self, + name: str, + allowed_subscribers: List[str]=[], + allowed_triggers: List[str]=[] + ): self.name = name self.enabled = True + self.allowed_subscribers = allowed_subscribers # 记录事件处理器名 + self.allowed_triggers = allowed_triggers # 记录插件名 from src.plugin_system.base.base_events_handler import BaseEventHandler self.subscribers: List["BaseEventHandler"] = [] # 订阅该事件的事件处理器列表 + self.event_handle_lock = asyncio.Lock() + def __name__(self): return self.name @@ -88,22 +98,45 @@ class BaseEvent: if not self.enabled: return HandlerResultsCollection([]) - # 按权重从高到低排序订阅者 - # 使用直接属性访问,-1代表自动权重 - sorted_subscribers = sorted(self.subscribers, key=lambda h: h.weight if hasattr(h, 'weight') and h.weight != -1 else 0, reverse=True) - - results = [] - for subscriber in sorted_subscribers: - try: - result = await subscriber.execute(params) - if not result.handler_name: - # 补充handler_name - result.handler_name = subscriber.handler_name if hasattr(subscriber, 'handler_name') else subscriber.__class__.__name__ - results.append(result) - except Exception as e: - # 处理执行异常 + # 使用锁确保同一个事件不能同时激活多次 + async with self.event_handle_lock: + # 按权重从高到低排序订阅者 + # 使用直接属性访问,-1代表自动权重 + sorted_subscribers = sorted(self.subscribers, key=lambda h: h.weight if hasattr(h, 'weight') and h.weight != -1 else 0, reverse=True) + + # 并行执行所有订阅者 + tasks = [] + for subscriber in sorted_subscribers: + # 为每个订阅者创建执行任务 + task = self._execute_subscriber(subscriber, params) + tasks.append(task) + + # 等待所有任务完成 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 处理执行结果 + processed_results = [] + for i, result in enumerate(results): + subscriber = sorted_subscribers[i] handler_name = subscriber.handler_name if hasattr(subscriber, 'handler_name') else subscriber.__class__.__name__ - logger.error(f"事件处理器 {handler_name} 执行失败: {e}") - results.append(HandlerResult(False, True, str(e), handler_name)) - - return HandlerResultsCollection(results) \ No newline at end of file + + if isinstance(result, Exception): + # 处理执行异常 + logger.error(f"事件处理器 {handler_name} 执行失败: {result}") + processed_results.append(HandlerResult(False, True, str(result), handler_name)) + else: + # 正常执行结果 + if not result.handler_name: + # 补充handler_name + result.handler_name = handler_name + processed_results.append(result) + + return HandlerResultsCollection(processed_results) + + async def _execute_subscriber(self, subscriber, params: dict) -> HandlerResult: + """执行单个订阅者处理器""" + try: + return await subscriber.execute(params) + except Exception as e: + # 异常会在 gather 中捕获,这里直接抛出让 gather 处理 + raise e \ No newline at end of file diff --git a/src/plugin_system/base/base_plugin.py b/src/plugin_system/base/base_plugin.py index ea28c5143..57f131ba1 100644 --- a/src/plugin_system/base/base_plugin.py +++ b/src/plugin_system/base/base_plugin.py @@ -1,13 +1,14 @@ from abc import abstractmethod -from typing import List, Type, Tuple, Union +from typing import List, Type, Tuple, Union, TYPE_CHECKING from .plugin_base import PluginBase from src.common.logger import get_logger -from src.plugin_system.base.component_types import ActionInfo, CommandInfo, EventHandlerInfo, ToolInfo +from src.plugin_system.base.component_types import ActionInfo, CommandInfo, PlusCommandInfo, EventHandlerInfo, ToolInfo from .base_action import BaseAction from .base_command import BaseCommand from .base_events_handler import BaseEventHandler from .base_tool import BaseTool +from .plus_command import PlusCommand logger = get_logger("base_plugin") @@ -31,6 +32,7 @@ class BasePlugin(PluginBase): Union[ Tuple[ActionInfo, Type[BaseAction]], Tuple[CommandInfo, Type[BaseCommand]], + Tuple[PlusCommandInfo, Type[PlusCommand]], Tuple[EventHandlerInfo, Type[BaseEventHandler]], Tuple[ToolInfo, Type[BaseTool]], ] diff --git a/src/plugin_system/base/base_tool.py b/src/plugin_system/base/base_tool.py index 974488063..b5022ea2a 100644 --- a/src/plugin_system/base/base_tool.py +++ b/src/plugin_system/base/base_tool.py @@ -28,6 +28,15 @@ class BaseTool(ABC): """ available_for_llm: bool = False """是否可供LLM使用""" + history_ttl: int = 5 + """工具调用历史记录的TTL值,默认为5。设为0表示不记录历史""" + + enable_cache: bool = False + """是否为该工具启用缓存""" + cache_ttl: int = 3600 + """缓存的TTL值(秒),默认为3600秒(1小时)""" + semantic_cache_query_key: Optional[str] = None + """用于语义缓存的查询参数键名。如果设置,将使用此参数的值进行语义相似度搜索""" def __init__(self, plugin_config: Optional[dict] = None): self.plugin_config = plugin_config or {} # 直接存储插件配置字典 diff --git a/src/plugin_system/base/command_args.py b/src/plugin_system/base/command_args.py new file mode 100644 index 000000000..b3d2611cf --- /dev/null +++ b/src/plugin_system/base/command_args.py @@ -0,0 +1,158 @@ +"""命令参数解析类 + +提供简单易用的命令参数解析功能 +""" + +from typing import List, Optional +import shlex + + +class CommandArgs: + """命令参数解析类 + + 提供方便的方法来处理命令参数 + """ + + def __init__(self, raw_args: str = ""): + """初始化命令参数 + + Args: + raw_args: 原始参数字符串 + """ + self._raw_args = raw_args.strip() + self._parsed_args: Optional[List[str]] = None + + def get_raw(self) -> str: + """获取完整的参数字符串 + + Returns: + str: 原始参数字符串 + """ + return self._raw_args + + def get_args(self) -> List[str]: + """获取解析后的参数列表 + + 将参数按空格分割,支持引号包围的参数 + + Returns: + List[str]: 参数列表 + """ + if self._parsed_args is None: + if not self._raw_args: + self._parsed_args = [] + else: + try: + # 使用shlex来正确处理引号和转义字符 + self._parsed_args = shlex.split(self._raw_args) + except ValueError: + # 如果shlex解析失败,fallback到简单的split + self._parsed_args = self._raw_args.split() + + return self._parsed_args + + @property + def is_empty(self) -> bool: + """检查参数是否为空 + + Returns: + bool: 如果没有参数返回True + """ + return len(self.get_args()) == 0 + + def get_arg(self, index: int, default: str = "") -> str: + """获取指定索引的参数 + + Args: + index: 参数索引(从0开始) + default: 默认值 + + Returns: + str: 参数值或默认值 + """ + args = self.get_args() + if 0 <= index < len(args): + return args[index] + return default + + @property + def get_first(self, default: str = "") -> str: + """获取第一个参数 + + Args: + default: 默认值 + + Returns: + str: 第一个参数或默认值 + """ + return self.get_arg(0, default) + + def get_remaining(self, start_index: int = 0) -> str: + """获取从指定索引开始的剩余参数字符串 + + Args: + start_index: 起始索引 + + Returns: + str: 剩余参数组成的字符串 + """ + args = self.get_args() + if start_index < len(args): + return " ".join(args[start_index:]) + return "" + + def count(self) -> int: + """获取参数数量 + + Returns: + int: 参数数量 + """ + return len(self.get_args()) + + def has_flag(self, flag: str) -> bool: + """检查是否包含指定的标志参数 + + Args: + flag: 标志名(如 "--verbose" 或 "-v") + + Returns: + bool: 如果包含该标志返回True + """ + return flag in self.get_args() + + def get_flag_value(self, flag: str, default: str = "") -> str: + """获取标志参数的值 + + 查找 --key=value 或 --key value 形式的参数 + + Args: + flag: 标志名(如 "--output") + default: 默认值 + + Returns: + str: 标志的值或默认值 + """ + args = self.get_args() + + # 查找 --key=value 形式 + for arg in args: + if arg.startswith(f"{flag}="): + return arg[len(flag) + 1:] + + # 查找 --key value 形式 + try: + flag_index = args.index(flag) + if flag_index + 1 < len(args): + return args[flag_index + 1] + except ValueError: + pass + + return default + + def __str__(self) -> str: + """字符串表示""" + return self._raw_args + + def __repr__(self) -> str: + """调试表示""" + return f"CommandArgs(raw='{self._raw_args}', parsed={self.get_args()})" diff --git a/src/plugin_system/base/component_types.py b/src/plugin_system/base/component_types.py index 0d22bf63e..63b32dec7 100644 --- a/src/plugin_system/base/component_types.py +++ b/src/plugin_system/base/component_types.py @@ -12,6 +12,7 @@ class ComponentType(Enum): ACTION = "action" # 动作组件 COMMAND = "command" # 命令组件 + PLUS_COMMAND = "plus_command" # 增强命令组件 TOOL = "tool" # 工具组件 SCHEDULER = "scheduler" # 定时任务组件(预留) EVENT_HANDLER = "event_handler" # 事件处理组件 @@ -164,6 +165,22 @@ class CommandInfo(ComponentInfo): self.component_type = ComponentType.COMMAND +@dataclass +class PlusCommandInfo(ComponentInfo): + """增强命令组件信息""" + + command_aliases: List[str] = field(default_factory=list) # 命令别名列表 + priority: int = 0 # 命令优先级 + chat_type_allow: ChatType = ChatType.ALL # 允许的聊天类型 + intercept_message: bool = False # 是否拦截消息 + + def __post_init__(self): + super().__post_init__() + if self.command_aliases is None: + self.command_aliases = [] + self.component_type = ComponentType.PLUS_COMMAND + + @dataclass class ToolInfo(ComponentInfo): """工具组件信息""" diff --git a/src/plugin_system/base/plus_command.py b/src/plugin_system/base/plus_command.py new file mode 100644 index 000000000..16af685a1 --- /dev/null +++ b/src/plugin_system/base/plus_command.py @@ -0,0 +1,459 @@ +"""增强版命令处理器 + +提供更简单易用的命令处理方式,无需手写正则表达式 +""" + +from abc import ABC, abstractmethod +from typing import Dict, Tuple, Optional, List +import re + +from src.common.logger import get_logger +from src.plugin_system.base.component_types import CommandInfo, PlusCommandInfo, ComponentType, ChatType +from src.chat.message_receive.message import MessageRecv +from src.plugin_system.apis import send_api +from src.plugin_system.base.command_args import CommandArgs +from src.plugin_system.base.base_command import BaseCommand +from src.config.config import global_config + +logger = get_logger("plus_command") + + +class PlusCommand(ABC): + """增强版命令基类 + + 提供更简单的命令定义方式,无需手写正则表达式 + + 子类只需要定义: + - command_name: 命令名称 + - command_description: 命令描述 + - command_aliases: 命令别名列表(可选) + - priority: 优先级(可选,数字越大优先级越高) + - chat_type_allow: 允许的聊天类型(可选) + - intercept_message: 是否拦截消息(可选) + """ + + # 子类需要定义的属性 + command_name: str = "" + """命令名称,如 'echo'""" + + command_description: str = "" + """命令描述""" + + command_aliases: List[str] = [] + """命令别名列表,如 ['say', 'repeat']""" + + priority: int = 0 + """命令优先级,数字越大优先级越高""" + + chat_type_allow: ChatType = ChatType.ALL + """允许的聊天类型""" + + intercept_message: bool = False + """是否拦截消息,不进行后续处理""" + + def __init__(self, message: MessageRecv, plugin_config: Optional[dict] = None): + """初始化命令组件 + + Args: + message: 接收到的消息对象 + plugin_config: 插件配置字典 + """ + self.message = message + self.plugin_config = plugin_config or {} + self.log_prefix = "[PlusCommand]" + + # 解析命令参数 + self._parse_command() + + # 验证聊天类型限制 + if not self._validate_chat_type(): + is_group = hasattr(self.message, 'is_group_message') and self.message.is_group_message + logger.warning( + f"{self.log_prefix} 命令 '{self.command_name}' 不支持当前聊天类型: " + f"{'群聊' if is_group else '私聊'}, 允许类型: {self.chat_type_allow.value}" + ) + + def _parse_command(self) -> None: + """解析命令和参数""" + if not hasattr(self.message, 'plain_text') or not self.message.plain_text: + self.args = CommandArgs("") + return + + plain_text = self.message.plain_text.strip() + + # 获取配置的命令前缀 + prefixes = global_config.command.command_prefixes + + # 检查是否以任何前缀开头 + matched_prefix = None + for prefix in prefixes: + if plain_text.startswith(prefix): + matched_prefix = prefix + break + + if not matched_prefix: + self.args = CommandArgs("") + return + + # 移除前缀 + command_part = plain_text[len(matched_prefix):].strip() + + # 分离命令名和参数 + parts = command_part.split(None, 1) + if not parts: + self.args = CommandArgs("") + return + + command_word = parts[0].lower() + args_text = parts[1] if len(parts) > 1 else "" + + # 检查命令名是否匹配 + all_commands = [self.command_name.lower()] + [alias.lower() for alias in self.command_aliases] + if command_word not in all_commands: + self.args = CommandArgs("") + return + + # 创建参数对象 + self.args = CommandArgs(args_text) + + def _validate_chat_type(self) -> bool: + """验证当前聊天类型是否允许执行此命令 + + Returns: + bool: 如果允许执行返回True,否则返回False + """ + if self.chat_type_allow == ChatType.ALL: + return True + + # 检查是否为群聊消息 + is_group = hasattr(self.message, 'is_group_message') and self.message.is_group_message + + if self.chat_type_allow == ChatType.GROUP and is_group: + return True + elif self.chat_type_allow == ChatType.PRIVATE and not is_group: + return True + else: + return False + + def is_chat_type_allowed(self) -> bool: + """检查当前聊天类型是否允许执行此命令 + + Returns: + bool: 如果允许执行返回True,否则返回False + """ + return self._validate_chat_type() + + def is_command_match(self) -> bool: + """检查当前消息是否匹配此命令 + + Returns: + bool: 如果匹配返回True + """ + return not self.args.is_empty() or self._is_exact_command_call() + + def _is_exact_command_call(self) -> bool: + """检查是否是精确的命令调用(无参数)""" + if not hasattr(self.message, 'plain_text') or not self.message.plain_text: + return False + + plain_text = self.message.plain_text.strip() + + # 获取配置的命令前缀 + prefixes = global_config.command.command_prefixes + + # 检查每个前缀 + for prefix in prefixes: + if plain_text.startswith(prefix): + command_part = plain_text[len(prefix):].strip() + all_commands = [self.command_name.lower()] + [alias.lower() for alias in self.command_aliases] + if command_part.lower() in all_commands: + return True + + return False + + @abstractmethod + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + """执行命令的抽象方法,子类必须实现 + + Args: + args: 解析后的命令参数 + + Returns: + Tuple[bool, Optional[str], bool]: (是否执行成功, 可选的回复消息, 是否拦截消息) + """ + pass + + def get_config(self, key: str, default=None): + """获取插件配置值,使用嵌套键访问 + + Args: + key: 配置键名,使用嵌套访问如 "section.subsection.key" + default: 默认值 + + Returns: + Any: 配置值或默认值 + """ + if not self.plugin_config: + return default + + # 支持嵌套键访问 + keys = key.split(".") + current = self.plugin_config + + for k in keys: + if isinstance(current, dict) and k in current: + current = current[k] + else: + return default + + return current + + async def send_text(self, content: str, reply_to: str = "") -> bool: + """发送回复消息 + + Args: + content: 回复内容 + reply_to: 回复消息,格式为"发送者:消息内容" + + Returns: + bool: 是否发送成功 + """ + # 获取聊天流信息 + chat_stream = self.message.chat_stream + if not chat_stream or not hasattr(chat_stream, "stream_id"): + logger.error(f"{self.log_prefix} 缺少聊天流或stream_id") + return False + + return await send_api.text_to_stream(text=content, stream_id=chat_stream.stream_id, reply_to=reply_to) + + async def send_type( + self, message_type: str, content: str, display_message: str = "", typing: bool = False, reply_to: str = "" + ) -> bool: + """发送指定类型的回复消息到当前聊天环境 + + Args: + message_type: 消息类型,如"text"、"image"、"emoji"等 + content: 消息内容 + display_message: 显示消息(可选) + typing: 是否显示正在输入 + reply_to: 回复消息,格式为"发送者:消息内容" + + Returns: + bool: 是否发送成功 + """ + # 获取聊天流信息 + chat_stream = self.message.chat_stream + if not chat_stream or not hasattr(chat_stream, "stream_id"): + logger.error(f"{self.log_prefix} 缺少聊天流或stream_id") + return False + + return await send_api.custom_to_stream( + message_type=message_type, + content=content, + stream_id=chat_stream.stream_id, + display_message=display_message, + typing=typing, + reply_to=reply_to, + ) + + async def send_emoji(self, emoji_base64: str) -> bool: + """发送表情包 + + Args: + emoji_base64: 表情包的base64编码 + + Returns: + bool: 是否发送成功 + """ + chat_stream = self.message.chat_stream + if not chat_stream or not hasattr(chat_stream, "stream_id"): + logger.error(f"{self.log_prefix} 缺少聊天流或stream_id") + return False + + return await send_api.emoji_to_stream(emoji_base64, chat_stream.stream_id) + + async def send_image(self, image_base64: str) -> bool: + """发送图片 + + Args: + image_base64: 图片的base64编码 + + Returns: + bool: 是否发送成功 + """ + chat_stream = self.message.chat_stream + if not chat_stream or not hasattr(chat_stream, "stream_id"): + logger.error(f"{self.log_prefix} 缺少聊天流或stream_id") + return False + + return await send_api.image_to_stream(image_base64, chat_stream.stream_id) + + @classmethod + def get_command_info(cls) -> "CommandInfo": + """从类属性生成CommandInfo + + Returns: + CommandInfo: 生成的命令信息对象 + """ + if "." in cls.command_name: + logger.error(f"命令名称 '{cls.command_name}' 包含非法字符 '.',请使用下划线替代") + raise ValueError(f"命令名称 '{cls.command_name}' 包含非法字符 '.',请使用下划线替代") + + # 生成正则表达式模式来匹配命令 + command_pattern = cls._generate_command_pattern() + + return CommandInfo( + name=cls.command_name, + component_type=ComponentType.COMMAND, + description=cls.command_description, + command_pattern=command_pattern, + chat_type_allow=getattr(cls, "chat_type_allow", ChatType.ALL), + ) + + @classmethod + def get_plus_command_info(cls) -> "PlusCommandInfo": + """从类属性生成PlusCommandInfo + + Returns: + PlusCommandInfo: 生成的增强命令信息对象 + """ + if "." in cls.command_name: + logger.error(f"命令名称 '{cls.command_name}' 包含非法字符 '.',请使用下划线替代") + raise ValueError(f"命令名称 '{cls.command_name}' 包含非法字符 '.',请使用下划线替代") + + return PlusCommandInfo( + name=cls.command_name, + component_type=ComponentType.PLUS_COMMAND, + description=cls.command_description, + command_aliases=getattr(cls, "command_aliases", []), + priority=getattr(cls, "priority", 0), + chat_type_allow=getattr(cls, "chat_type_allow", ChatType.ALL), + intercept_message=getattr(cls, "intercept_message", False), + ) + + @classmethod + def _generate_command_pattern(cls) -> str: + """生成命令匹配的正则表达式 + + Returns: + str: 正则表达式字符串 + """ + # 获取所有可能的命令名(主命令名 + 别名) + all_commands = [cls.command_name] + getattr(cls, 'command_aliases', []) + + # 转义特殊字符并创建选择组 + escaped_commands = [re.escape(cmd) for cmd in all_commands] + commands_pattern = "|".join(escaped_commands) + + # 获取默认前缀列表(这里先用硬编码,后续可以优化为动态获取) + default_prefixes = ["/", "!", ".", "#"] + escaped_prefixes = [re.escape(prefix) for prefix in default_prefixes] + prefixes_pattern = "|".join(escaped_prefixes) + + # 生成完整的正则表达式 + # 匹配: [前缀][命令名][可选空白][任意参数] + pattern = f"^(?P{prefixes_pattern})(?P{commands_pattern})(?P\\s.*)?$" + + return pattern + + +class PlusCommandAdapter(BaseCommand): + """PlusCommand适配器 + + 将PlusCommand适配到现有的插件系统,继承BaseCommand + """ + + def __init__(self, plus_command_class, message: MessageRecv, plugin_config: Optional[dict] = None): + """初始化适配器 + + Args: + plus_command_class: PlusCommand子类 + message: 消息对象 + plugin_config: 插件配置 + """ + # 先设置必要的类属性 + self.command_name = plus_command_class.command_name + self.command_description = plus_command_class.command_description + self.command_pattern = plus_command_class._generate_command_pattern() + self.chat_type_allow = getattr(plus_command_class, "chat_type_allow", ChatType.ALL) + self.priority = getattr(plus_command_class, "priority", 0) + self.intercept_message = getattr(plus_command_class, "intercept_message", False) + + # 调用父类初始化 + super().__init__(message, plugin_config) + + # 创建PlusCommand实例 + self.plus_command = plus_command_class(message, plugin_config) + + async def execute(self) -> Tuple[bool, Optional[str], bool]: + """执行命令 + + Returns: + Tuple[bool, Optional[str], bool]: 执行结果 + """ + # 检查命令是否匹配 + if not self.plus_command.is_command_match(): + return False, "命令不匹配", False + + # 检查聊天类型权限 + if not self.plus_command.is_chat_type_allowed(): + return False, "不支持当前聊天类型", self.intercept_message + + # 执行命令 + try: + return await self.plus_command.execute(self.plus_command.args) + except Exception as e: + logger.error(f"执行命令时出错: {e}", exc_info=True) + return False, f"命令执行出错: {str(e)}", self.intercept_message + + +def create_plus_command_adapter(plus_command_class): + """创建PlusCommand适配器的工厂函数 + + Args: + plus_command_class: PlusCommand子类 + + Returns: + 适配器类 + """ + class AdapterClass(BaseCommand): + command_name = plus_command_class.command_name + command_description = plus_command_class.command_description + command_pattern = plus_command_class._generate_command_pattern() + chat_type_allow = getattr(plus_command_class, "chat_type_allow", ChatType.ALL) + + def __init__(self, message: MessageRecv, plugin_config: Optional[dict] = None): + super().__init__(message, plugin_config) + self.plus_command = plus_command_class(message, plugin_config) + self.priority = getattr(plus_command_class, "priority", 0) + self.intercept_message = getattr(plus_command_class, "intercept_message", False) + + async def execute(self) -> Tuple[bool, Optional[str], bool]: + """执行命令""" + # 从BaseCommand的正则匹配结果中提取参数 + args_text = "" + if hasattr(self, 'matched_groups') and self.matched_groups: + # 从正则匹配组中获取参数部分 + args_match = self.matched_groups.get('args', '') + if args_match: + args_text = args_match.strip() + + # 创建CommandArgs对象 + command_args = CommandArgs(args_text) + + # 检查聊天类型权限 + if not self.plus_command.is_chat_type_allowed(): + return False, "不支持当前聊天类型", self.intercept_message + + # 执行命令,传递正确解析的参数 + try: + return await self.plus_command.execute(command_args) + except Exception as e: + logger.error(f"执行命令时出错: {e}", exc_info=True) + return False, f"命令执行出错: {str(e)}", self.intercept_message + + return AdapterClass + + +# 兼容旧的命名 +PlusCommandAdapter = create_plus_command_adapter diff --git a/src/plugin_system/base/plus_plugin.py b/src/plugin_system/base/plus_plugin.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/plugin_system/core/component_registry.py b/src/plugin_system/core/component_registry.py index 69a2d2a3b..7e925e3f0 100644 --- a/src/plugin_system/core/component_registry.py +++ b/src/plugin_system/core/component_registry.py @@ -8,6 +8,7 @@ from src.plugin_system.base.component_types import ( ActionInfo, ToolInfo, CommandInfo, + PlusCommandInfo, EventHandlerInfo, PluginInfo, ComponentType, @@ -16,6 +17,7 @@ from src.plugin_system.base.base_command import BaseCommand from src.plugin_system.base.base_action import BaseAction from src.plugin_system.base.base_tool import BaseTool from src.plugin_system.base.base_events_handler import BaseEventHandler +from src.plugin_system.base.plus_command import PlusCommand logger = get_logger("component_registry") @@ -32,7 +34,7 @@ class ComponentRegistry: """组件注册表 命名空间式组件名 -> 组件信息""" self._components_by_type: Dict[ComponentType, Dict[str, ComponentInfo]] = {types: {} for types in ComponentType} """类型 -> 组件原名称 -> 组件信息""" - self._components_classes: Dict[str, Type[Union[BaseCommand, BaseAction, BaseTool, BaseEventHandler]]] = {} + self._components_classes: Dict[str, Type[Union[BaseCommand, BaseAction, BaseTool, BaseEventHandler, PlusCommand]]] = {} """命名空间式组件名 -> 组件类""" # 插件注册表 @@ -133,6 +135,10 @@ class ComponentRegistry: assert isinstance(component_info, CommandInfo) assert issubclass(component_class, BaseCommand) ret = self._register_command_component(component_info, component_class) + case ComponentType.PLUS_COMMAND: + assert isinstance(component_info, PlusCommandInfo) + assert issubclass(component_class, PlusCommand) + ret = self._register_plus_command_component(component_info, component_class) case ComponentType.TOOL: assert isinstance(component_info, ToolInfo) assert issubclass(component_class, BaseTool) @@ -192,6 +198,26 @@ class ComponentRegistry: return True + def _register_plus_command_component(self, plus_command_info: PlusCommandInfo, plus_command_class: Type[PlusCommand]) -> bool: + """注册PlusCommand组件到特定注册表""" + plus_command_name = plus_command_info.name + + if not plus_command_name: + logger.error(f"PlusCommand组件 {plus_command_class.__name__} 必须指定名称") + return False + if not isinstance(plus_command_info, PlusCommandInfo) or not issubclass(plus_command_class, PlusCommand): + logger.error(f"注册失败: {plus_command_name} 不是有效的PlusCommand") + return False + + # 创建专门的PlusCommand注册表(如果还没有) + if not hasattr(self, '_plus_command_registry'): + self._plus_command_registry: Dict[str, Type[PlusCommand]] = {} + + self._plus_command_registry[plus_command_name] = plus_command_class + + logger.debug(f"已注册PlusCommand组件: {plus_command_name}") + return True + def _register_tool_component(self, tool_info: ToolInfo, tool_class: Type[BaseTool]) -> bool: """注册Tool组件到Tool特定注册表""" tool_name = tool_info.name @@ -248,6 +274,12 @@ class ComponentRegistry: self._command_patterns.pop(key, None) logger.debug(f"已移除Command组件: {component_name} (清理了 {len(keys_to_remove)} 个模式)") + case ComponentType.PLUS_COMMAND: + # 移除PlusCommand注册 + if hasattr(self, '_plus_command_registry'): + self._plus_command_registry.pop(component_name, None) + logger.debug(f"已移除PlusCommand组件: {component_name}") + case ComponentType.TOOL: # 移除Tool注册 self._tool_registry.pop(component_name, None) @@ -520,21 +552,23 @@ class ComponentRegistry: text: 输入文本 Returns: - Tuple: (命令类, 匹配的命名组, 是否拦截消息, 插件名) 或 None + Tuple: (命令类, 匹配的命名组, 命令信息) 或 None """ + # 只查找传统的BaseCommand candidates = [pattern for pattern in self._command_patterns if pattern.match(text)] - if not candidates: - return None - if len(candidates) > 1: - logger.warning(f"文本 '{text}' 匹配到多个命令模式: {candidates},使用第一个匹配") - command_name = self._command_patterns[candidates[0]] - command_info: CommandInfo = self.get_registered_command_info(command_name) # type: ignore - return ( - self._command_registry[command_name], - candidates[0].match(text).groupdict(), # type: ignore - command_info, - ) + if candidates: + if len(candidates) > 1: + logger.warning(f"文本 '{text}' 匹配到多个命令模式: {candidates},使用第一个匹配") + command_name = self._command_patterns[candidates[0]] + command_info: CommandInfo = self.get_registered_command_info(command_name) # type: ignore + return ( + self._command_registry[command_name], + candidates[0].match(text).groupdict(), # type: ignore + command_info, + ) + + return None # === Tool 特定查询方法 === def get_tool_registry(self) -> Dict[str, Type[BaseTool]]: @@ -557,6 +591,25 @@ class ComponentRegistry: info = self.get_component_info(tool_name, ComponentType.TOOL) return info if isinstance(info, ToolInfo) else None + # === PlusCommand 特定查询方法 === + def get_plus_command_registry(self) -> Dict[str, Type[PlusCommand]]: + """获取PlusCommand注册表""" + if not hasattr(self, '_plus_command_registry'): + self._plus_command_registry: Dict[str, Type[PlusCommand]] = {} + return self._plus_command_registry.copy() + + def get_registered_plus_command_info(self, command_name: str) -> Optional[PlusCommandInfo]: + """获取PlusCommand信息 + + Args: + command_name: 命令名称 + + Returns: + PlusCommandInfo: 命令信息对象,如果命令不存在则返回 None + """ + info = self.get_component_info(command_name, ComponentType.PLUS_COMMAND) + return info if isinstance(info, PlusCommandInfo) else None + # === EventHandler 特定查询方法 === def get_event_handler_registry(self) -> Dict[str, Type[BaseEventHandler]]: @@ -612,6 +665,7 @@ class ComponentRegistry: command_components: int = 0 tool_components: int = 0 events_handlers: int = 0 + plus_command_components: int = 0 for component in self._components.values(): if component.component_type == ComponentType.ACTION: action_components += 1 @@ -621,11 +675,14 @@ class ComponentRegistry: tool_components += 1 elif component.component_type == ComponentType.EVENT_HANDLER: events_handlers += 1 + elif component.component_type == ComponentType.PLUS_COMMAND: + plus_command_components += 1 return { "action_components": action_components, "command_components": command_components, "tool_components": tool_components, "event_handlers": events_handlers, + "plus_command_components": plus_command_components, "total_components": len(self._components), "total_plugins": len(self._plugins), "components_by_type": { diff --git a/src/plugin_system/core/event_manager.py b/src/plugin_system/core/event_manager.py index ea3d04a70..38d5775da 100644 --- a/src/plugin_system/core/event_manager.py +++ b/src/plugin_system/core/event_manager.py @@ -2,7 +2,6 @@ 事件管理器 - 实现Event和EventHandler的单例管理 提供统一的事件注册、管理和触发接口 """ - from typing import Dict, Type, List, Optional, Any, Union from threading import Lock @@ -41,12 +40,18 @@ class EventManager: self._initialized = True logger.info("EventManager 单例初始化完成") - def register_event(self, event_name: Union[EventType, str]) -> bool: + def register_event( + self, + event_name: Union[EventType, str], + allowed_subscribers: List[str]=[], + allowed_triggers: List[str]=[] + ) -> bool: """注册一个新的事件 Args: event_name Union[EventType, str]: 事件名称 - + allowed_subscribers: List[str]: 事件订阅者白名单, + allowed_triggers: List[str]: 事件触发插件白名单 Returns: bool: 注册成功返回True,已存在返回False """ @@ -54,7 +59,7 @@ class EventManager: logger.warning(f"事件 {event_name} 已存在,跳过注册") return False - event = BaseEvent(event_name) + event = BaseEvent(event_name,allowed_subscribers,allowed_triggers) self._events[event_name] = event logger.info(f"事件 {event_name} 注册成功") @@ -211,7 +216,12 @@ class EventManager: if handler_instance in event.subscribers: logger.warning(f"事件处理器 {handler_name} 已经订阅了事件 {event_name},跳过重复订阅") return True - + + # 白名单检查 + if event.allowed_subscribers and handler_name not in event.allowed_subscribers: + logger.warning(f"事件处理器 {handler_name} 不在事件 {event_name} 的订阅者白名单中,无法订阅") + return False + event.subscribers.append(handler_instance) # 按权重从高到低排序订阅者 @@ -265,11 +275,12 @@ class EventManager: return {handler.handler_name: handler for handler in event.subscribers} - async def trigger_event(self, event_name: Union[EventType, str], **kwargs) -> Optional[HandlerResultsCollection]: + async def trigger_event(self, event_name: Union[EventType, str], plugin_name: Optional[str]="", **kwargs) -> Optional[HandlerResultsCollection]: """触发指定事件 Args: event_name Union[EventType, str]: 事件名称 + plugin_name str: 触发事件的插件名 **kwargs: 传递给处理器的参数 Returns: @@ -281,7 +292,15 @@ class EventManager: if event is None: logger.error(f"事件 {event_name} 不存在,无法触发") return None - + + # 插件白名单检查 + if event.allowed_triggers and not plugin_name: + logger.warning(f"事件 {event_name} 存在触发者白名单,缺少plugin_name无法验证权限,已拒绝触发!") + return None + elif event.allowed_triggers and plugin_name not in event.allowed_triggers: + logger.warning(f"插件 {plugin_name} 没有权限触发事件 {event_name},已拒绝触发!") + return None + return await event.activate(params) def init_default_events(self) -> None: @@ -294,12 +313,11 @@ class EventManager: EventType.POST_LLM, EventType.AFTER_LLM, EventType.POST_SEND, - EventType.AFTER_SEND, - EventType.UNKNOWN + EventType.AFTER_SEND ] for event_name in default_events: - self.register_event(event_name) + self.register_event(event_name,allowed_triggers=["SYSTEM"]) logger.info("默认事件初始化完成") diff --git a/src/plugin_system/core/plugin_manager.py b/src/plugin_system/core/plugin_manager.py index 775edd1d9..5da07369f 100644 --- a/src/plugin_system/core/plugin_manager.py +++ b/src/plugin_system/core/plugin_manager.py @@ -363,13 +363,14 @@ class PluginManager: command_count = stats.get("command_components", 0) tool_count = stats.get("tool_components", 0) event_handler_count = stats.get("event_handlers", 0) + plus_command_count = stats.get("plus_command_components", 0) total_components = stats.get("total_components", 0) # 📋 显示插件加载总览 if total_registered > 0: logger.info("🎉 插件系统加载完成!") logger.info( - f"📊 总览: {total_registered}个插件, {total_components}个组件 (Action: {action_count}, Command: {command_count}, Tool: {tool_count}, EventHandler: {event_handler_count})" + f"📊 总览: {total_registered}个插件, {total_components}个组件 (Action: {action_count}, Command: {command_count}, Tool: {tool_count}, PlusCommand: {plus_command_count}, EventHandler: {event_handler_count})" ) # 显示详细的插件列表 @@ -410,6 +411,9 @@ class PluginManager: event_handler_components = [ c for c in plugin_info.components if c.component_type == ComponentType.EVENT_HANDLER ] + plus_command_components = [ + c for c in plugin_info.components if c.component_type == ComponentType.PLUS_COMMAND + ] if action_components: action_names = [c.name for c in action_components] @@ -421,6 +425,9 @@ class PluginManager: if tool_components: tool_names = [c.name for c in tool_components] logger.info(f" 🛠️ Tool组件: {', '.join(tool_names)}") + if plus_command_components: + plus_command_names = [c.name for c in plus_command_components] + logger.info(f" ⚡ PlusCommand组件: {', '.join(plus_command_names)}") if event_handler_components: event_handler_names = [c.name for c in event_handler_components] logger.info(f" 📢 EventHandler组件: {', '.join(event_handler_names)}") diff --git a/src/plugin_system/core/tool_use.py b/src/plugin_system/core/tool_use.py index 0d50219c6..dee611c8c 100644 --- a/src/plugin_system/core/tool_use.py +++ b/src/plugin_system/core/tool_use.py @@ -40,13 +40,12 @@ class ToolExecutor: 可以直接输入聊天消息内容,自动判断并执行相应的工具,返回结构化的工具执行结果。 """ - def __init__(self, chat_id: str, enable_cache: bool = False, cache_ttl: int = 3): + def __init__(self, chat_id: str): """初始化工具执行器 Args: executor_id: 执行器标识符,用于日志记录 - enable_cache: 是否启用缓存机制 - cache_ttl: 缓存生存时间(周期数) + chat_id: 聊天标识符,用于日志记录 """ self.chat_id = chat_id self.chat_stream = get_chat_manager().get_stream(self.chat_id) @@ -54,12 +53,7 @@ class ToolExecutor: self.llm_model = LLMRequest(model_set=model_config.model_task_config.tool_use, request_type="tool_executor") - # 缓存配置 - self.enable_cache = enable_cache - self.cache_ttl = cache_ttl - self.tool_cache = {} # 格式: {cache_key: {"result": result, "ttl": ttl, "timestamp": timestamp}} - - logger.info(f"{self.log_prefix}工具执行器初始化完成,缓存{'启用' if enable_cache else '禁用'},TTL={cache_ttl}") + logger.info(f"{self.log_prefix}工具执行器初始化完成") async def execute_from_chat_message( self, target_message: str, chat_history: str, sender: str, return_details: bool = False @@ -77,18 +71,6 @@ class ToolExecutor: 如果return_details为True: Tuple[List[Dict], List[str], str] - (结果列表, 使用的工具, 提示词) """ - # 首先检查缓存 - cache_key = self._generate_cache_key(target_message, chat_history, sender) - if cached_result := self._get_from_cache(cache_key): - logger.info(f"{self.log_prefix}使用缓存结果,跳过工具执行") - if not return_details: - return cached_result, [], "" - - # 从缓存结果中提取工具名称 - used_tools = [result.get("tool_name", "unknown") for result in cached_result] - return cached_result, used_tools, "" - - # 缓存未命中,执行工具调用 # 获取可用工具 tools = self._get_tool_definitions() @@ -117,10 +99,6 @@ class ToolExecutor: # 执行工具调用 tool_results, used_tools = await self.execute_tool_calls(tool_calls) - # 缓存结果 - if tool_results: - self._set_cache(cache_key, tool_results) - if used_tools: logger.info(f"{self.log_prefix}工具执行完成,共执行{len(used_tools)}个工具: {used_tools}") @@ -151,9 +129,19 @@ class ToolExecutor: return [], [] # 提取tool_calls中的函数名称 - func_names = [call.func_name for call in tool_calls if call.func_name] + func_names = [] + for call in tool_calls: + try: + if hasattr(call, 'func_name'): + func_names.append(call.func_name) + except Exception as e: + logger.error(f"{self.log_prefix}获取工具名称失败: {e}") + continue - logger.info(f"{self.log_prefix}开始执行工具调用: {func_names}") + if func_names: + logger.info(f"{self.log_prefix}开始执行工具调用: {func_names}") + else: + logger.warning(f"{self.log_prefix}未找到有效的工具调用") # 执行每个工具调用 for tool_call in tool_calls: @@ -208,6 +196,7 @@ class ToolExecutor: try: function_name = tool_call.func_name function_args = tool_call.args or {} + logger.info(f"🤖 {self.log_prefix} 正在执行工具: [bold green]{function_name}[/bold green] | 参数: {function_args}") function_args["llm_called"] = True # 标记为LLM调用 # 获取对应工具实例 @@ -216,88 +205,24 @@ class ToolExecutor: logger.warning(f"未知工具名称: {function_name}") return None - # 执行工具 + # 执行工具并记录日志 + logger.debug(f"{self.log_prefix}执行工具 {function_name},参数: {function_args}") result = await tool_instance.execute(function_args) if result: + logger.debug(f"{self.log_prefix}工具 {function_name} 执行成功,结果: {result}") return { "tool_call_id": tool_call.call_id, "role": "tool", "name": function_name, "type": "function", - "content": result["content"], + "content": result.get("content", "") } + logger.warning(f"{self.log_prefix}工具 {function_name} 返回空结果") return None except Exception as e: logger.error(f"执行工具调用时发生错误: {str(e)}") raise e - def _generate_cache_key(self, target_message: str, chat_history: str, sender: str) -> str: - """生成缓存键 - - Args: - target_message: 目标消息内容 - chat_history: 聊天历史 - sender: 发送者 - - Returns: - str: 缓存键 - """ - import hashlib - - # 使用消息内容和群聊状态生成唯一缓存键 - content = f"{target_message}_{chat_history}_{sender}" - return hashlib.md5(content.encode()).hexdigest() - - def _get_from_cache(self, cache_key: str) -> Optional[List[Dict]]: - """从缓存获取结果 - - Args: - cache_key: 缓存键 - - Returns: - Optional[List[Dict]]: 缓存的结果,如果不存在或过期则返回None - """ - if not self.enable_cache or cache_key not in self.tool_cache: - return None - - cache_item = self.tool_cache[cache_key] - if cache_item["ttl"] <= 0: - # 缓存过期,删除 - del self.tool_cache[cache_key] - logger.debug(f"{self.log_prefix}缓存过期,删除缓存键: {cache_key}") - return None - - # 减少TTL - cache_item["ttl"] -= 1 - logger.debug(f"{self.log_prefix}使用缓存结果,剩余TTL: {cache_item['ttl']}") - return cache_item["result"] - - def _set_cache(self, cache_key: str, result: List[Dict]): - """设置缓存 - - Args: - cache_key: 缓存键 - result: 要缓存的结果 - """ - if not self.enable_cache: - return - - self.tool_cache[cache_key] = {"result": result, "ttl": self.cache_ttl, "timestamp": time.time()} - logger.debug(f"{self.log_prefix}设置缓存,TTL: {self.cache_ttl}") - - def _cleanup_expired_cache(self): - """清理过期的缓存""" - if not self.enable_cache: - return - - expired_keys = [] - expired_keys.extend(cache_key for cache_key, cache_item in self.tool_cache.items() if cache_item["ttl"] <= 0) - for key in expired_keys: - del self.tool_cache[key] - - if expired_keys: - logger.debug(f"{self.log_prefix}清理了{len(expired_keys)}个过期缓存") - async def execute_specific_tool_simple(self, tool_name: str, tool_args: Dict) -> Optional[Dict]: """直接执行指定工具 @@ -336,86 +261,30 @@ class ToolExecutor: return None - def clear_cache(self): - """清空所有缓存""" - if self.enable_cache: - cache_count = len(self.tool_cache) - self.tool_cache.clear() - logger.info(f"{self.log_prefix}清空了{cache_count}个缓存项") - - def get_cache_status(self) -> Dict: - """获取缓存状态信息 - - Returns: - Dict: 包含缓存统计信息的字典 - """ - if not self.enable_cache: - return {"enabled": False, "cache_count": 0} - - # 清理过期缓存 - self._cleanup_expired_cache() - - total_count = len(self.tool_cache) - ttl_distribution = {} - - for cache_item in self.tool_cache.values(): - ttl = cache_item["ttl"] - ttl_distribution[ttl] = ttl_distribution.get(ttl, 0) + 1 - - return { - "enabled": True, - "cache_count": total_count, - "cache_ttl": self.cache_ttl, - "ttl_distribution": ttl_distribution, - } - - def set_cache_config(self, enable_cache: Optional[bool] = None, cache_ttl: int = -1): - """动态修改缓存配置 - - Args: - enable_cache: 是否启用缓存 - cache_ttl: 缓存TTL - """ - if enable_cache is not None: - self.enable_cache = enable_cache - logger.info(f"{self.log_prefix}缓存状态修改为: {'启用' if enable_cache else '禁用'}") - - if cache_ttl > 0: - self.cache_ttl = cache_ttl - logger.info(f"{self.log_prefix}缓存TTL修改为: {cache_ttl}") """ ToolExecutor使用示例: -# 1. 基础使用 - 从聊天消息执行工具(启用缓存,默认TTL=3) -executor = ToolExecutor(executor_id="my_executor") +# 1. 基础使用 - 从聊天消息执行工具 +executor = ToolExecutor(chat_id=my_chat_id) results, _, _ = await executor.execute_from_chat_message( - talking_message_str="今天天气怎么样?现在几点了?", - is_group_chat=False + target_message="今天天气怎么样?现在几点了?", + chat_history="", + sender="用户" ) -# 2. 禁用缓存的执行器 -no_cache_executor = ToolExecutor(executor_id="no_cache", enable_cache=False) - -# 3. 自定义缓存TTL -long_cache_executor = ToolExecutor(executor_id="long_cache", cache_ttl=10) - -# 4. 获取详细信息 +# 2. 获取详细信息 results, used_tools, prompt = await executor.execute_from_chat_message( - talking_message_str="帮我查询Python相关知识", - is_group_chat=False, + target_message="帮我查询Python相关知识", + chat_history="", + sender="用户", return_details=True ) -# 5. 直接执行特定工具 +# 3. 直接执行特定工具 result = await executor.execute_specific_tool_simple( tool_name="get_knowledge", tool_args={"query": "机器学习"} ) - -# 6. 缓存管理 -cache_status = executor.get_cache_status() # 查看缓存状态 -executor.clear_cache() # 清空缓存 -executor.set_cache_config(cache_ttl=5) # 动态修改缓存配置 """ diff --git a/src/plugin_system/utils/permission_decorators.py b/src/plugin_system/utils/permission_decorators.py index ae5b48e0e..0f31a94f9 100644 --- a/src/plugin_system/utils/permission_decorators.py +++ b/src/plugin_system/utils/permission_decorators.py @@ -9,9 +9,9 @@ from typing import Callable, Optional from inspect import iscoroutinefunction from src.plugin_system.apis.permission_api import permission_api -from src.plugin_system.apis.send_api import send_message +from src.plugin_system.apis.send_api import text_to_stream from src.plugin_system.apis.logging_api import get_logger -from src.common.message import ChatStream +from src.chat.message_receive.chat_stream import ChatStream logger = get_logger(__name__) @@ -37,6 +37,8 @@ def require_permission(permission_node: str, deny_message: Optional[str] = None) async def async_wrapper(*args, **kwargs): # 尝试从参数中提取 ChatStream 对象 chat_stream = None + + # 首先检查位置参数中的 ChatStream for arg in args: if isinstance(arg, ChatStream): chat_stream = arg @@ -46,21 +48,31 @@ def require_permission(permission_node: str, deny_message: Optional[str] = None) if chat_stream is None: chat_stream = kwargs.get('chat_stream') + # 如果还没找到,检查是否是 PlusCommand 方法调用 + if chat_stream is None and args: + # 检查第一个参数是否有 message.chat_stream 属性(PlusCommand 实例) + instance = args[0] + if hasattr(instance, 'message') and hasattr(instance.message, 'chat_stream'): + chat_stream = instance.message.chat_stream + if chat_stream is None: logger.error(f"权限装饰器无法找到 ChatStream 对象,函数: {func.__name__}") return # 检查权限 has_permission = permission_api.check_permission( - chat_stream.user_platform, - chat_stream.user_id, + chat_stream.platform, + chat_stream.user_info.user_id, permission_node ) if not has_permission: # 权限不足,发送拒绝消息 message = deny_message or f"❌ 你没有执行此操作的权限\n需要权限: {permission_node}" - await send_message(chat_stream, message) + await text_to_stream(message, chat_stream.stream_id) + # 对于PlusCommand的execute方法,需要返回适当的元组 + if func.__name__ == 'execute' and hasattr(args[0], 'send_text'): + return False, "权限不足", True return # 权限检查通过,执行原函数 @@ -83,13 +95,13 @@ def require_permission(permission_node: str, deny_message: Optional[str] = None) # 检查权限 has_permission = permission_api.check_permission( - chat_stream.user_platform, - chat_stream.user_id, + chat_stream.platform, + chat_stream.user_info.user_id, permission_node ) if not has_permission: - logger.warning(f"用户 {chat_stream.user_platform}:{chat_stream.user_id} 没有权限 {permission_node}") + logger.warning(f"用户 {chat_stream.platform}:{chat_stream.user_info.user_id} 没有权限 {permission_node}") return # 权限检查通过,执行原函数 @@ -124,6 +136,8 @@ def require_master(deny_message: Optional[str] = None): async def async_wrapper(*args, **kwargs): # 尝试从参数中提取 ChatStream 对象 chat_stream = None + + # 首先检查位置参数中的 ChatStream for arg in args: if isinstance(arg, ChatStream): chat_stream = arg @@ -133,20 +147,28 @@ def require_master(deny_message: Optional[str] = None): if chat_stream is None: chat_stream = kwargs.get('chat_stream') + # 如果还没找到,检查是否是 PlusCommand 方法调用 + if chat_stream is None and args: + # 检查第一个参数是否有 message.chat_stream 属性(PlusCommand 实例) + instance = args[0] + if hasattr(instance, 'message') and hasattr(instance.message, 'chat_stream'): + chat_stream = instance.message.chat_stream + if chat_stream is None: logger.error(f"Master权限装饰器无法找到 ChatStream 对象,函数: {func.__name__}") return # 检查是否为Master用户 is_master = permission_api.is_master( - chat_stream.user_platform, - chat_stream.user_id + chat_stream.platform, + chat_stream.user_info.user_id ) if not is_master: - # 权限不足,发送拒绝消息 message = deny_message or "❌ 此操作仅限Master用户执行" - await send_message(chat_stream, message) + await text_to_stream(message, chat_stream.stream_id) + if func.__name__ == 'execute' and hasattr(args[0], 'send_text'): + return False, "需要Master权限", True return # 权限检查通过,执行原函数 @@ -169,12 +191,12 @@ def require_master(deny_message: Optional[str] = None): # 检查是否为Master用户 is_master = permission_api.is_master( - chat_stream.user_platform, - chat_stream.user_id + chat_stream.platform, + chat_stream.user_info.user_id ) if not is_master: - logger.warning(f"用户 {chat_stream.user_platform}:{chat_stream.user_id} 不是Master用户") + logger.warning(f"用户 {chat_stream.platform}:{chat_stream.user_info.user_id} 不是Master用户") return # 权限检查通过,执行原函数 @@ -209,8 +231,8 @@ class PermissionChecker: bool: 是否拥有权限 """ return permission_api.check_permission( - chat_stream.user_platform, - chat_stream.user_id, + chat_stream.platform, + chat_stream.user_info.user_id, permission_node ) @@ -226,8 +248,8 @@ class PermissionChecker: bool: 是否为Master用户 """ return permission_api.is_master( - chat_stream.user_platform, - chat_stream.user_id + chat_stream.platform, + chat_stream.user_info.user_id ) @staticmethod @@ -248,7 +270,7 @@ class PermissionChecker: if not has_permission: message = deny_message or f"❌ 你没有执行此操作的权限\n需要权限: {permission_node}" - await send_message(chat_stream, message) + await text_to_stream(message, chat_stream.stream_id) return has_permission @@ -269,6 +291,6 @@ class PermissionChecker: if not is_master: message = deny_message or "❌ 此操作仅限Master用户执行" - await send_message(chat_stream, message) + await text_to_stream(message, chat_stream.stream_id) return is_master diff --git a/src/plugins/built_in/maizone_refactored/commands/send_feed_command.py b/src/plugins/built_in/maizone_refactored/commands/send_feed_command.py index 0f6903b2f..3a7be39d5 100644 --- a/src/plugins/built_in/maizone_refactored/commands/send_feed_command.py +++ b/src/plugins/built_in/maizone_refactored/commands/send_feed_command.py @@ -5,52 +5,32 @@ from typing import Tuple from src.common.logger import get_logger -from src.plugin_system import BaseCommand +from src.plugin_system.base.plus_command import PlusCommand +from src.plugin_system.base.command_args import CommandArgs +from src.plugin_system.utils.permission_decorators import require_permission from ..services.manager import get_qzone_service, get_config_getter logger = get_logger("MaiZone.SendFeedCommand") -class SendFeedCommand(BaseCommand): +class SendFeedCommand(PlusCommand): """ 响应用户通过 `/send_feed` 命令发送说说的请求。 """ command_name: str = "send_feed" command_description: str = "发送一条QQ空间说说" - command_pattern: str = r"^/send_feed(?:\s+(?P.*))?$" - command_help: str = "使用 /send_feed [主题] 来发送一条说说" + command_aliases = ["发空间"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def _check_permission(self) -> bool: - """检查当前用户是否有权限执行此命令""" - user_id = self.message.message_info.user_info.user_id - if not user_id: - return False - - get_config = get_config_getter() - permission_list = get_config("send.permission", []) - permission_type = get_config("send.permission_type", "whitelist") - - if not isinstance(permission_list, list): - return False - - if permission_type == 'whitelist': - return user_id in permission_list - elif permission_type == 'blacklist': - return user_id not in permission_list - return False - - async def execute(self) -> Tuple[bool, str, bool]: + @require_permission("plugin.send.permission") + async def execute(self, args: CommandArgs) -> Tuple[bool, str, bool]: """ 执行命令的核心逻辑。 """ - if not self._check_permission(): - await self.send_text("抱歉,你没有权限使用这个命令哦。") - return False, "权限不足", True - topic = self.matched_groups.get("topic", "") + topic = args.get_remaining() stream_id = self.message.chat_stream.stream_id await self.send_text(f"收到!正在为你生成关于“{topic or '随机'}”的说说,请稍候...") diff --git a/src/plugins/built_in/maizone_refactored/plugin.py b/src/plugins/built_in/maizone_refactored/plugin.py index 8f6481a55..f6c6fbb29 100644 --- a/src/plugins/built_in/maizone_refactored/plugin.py +++ b/src/plugins/built_in/maizone_refactored/plugin.py @@ -13,6 +13,7 @@ from src.plugin_system import ( register_plugin ) from src.plugin_system.base.config_types import ConfigField +from src.plugin_system.apis.permission_api import permission_api from .actions.read_feed_action import ReadFeedAction from .actions.send_feed_action import SendFeedAction @@ -42,6 +43,7 @@ class MaiZoneRefactoredPlugin(BasePlugin): "plugin": {"enable": ConfigField(type=bool, default=True, description="是否启用插件")}, "models": { "text_model": ConfigField(type=str, default="maizone", description="生成文本的模型名称"), + "vision_model": ConfigField(type=str, default="YISHAN-gemini-2.5-flash", description="识别图片的模型名称"), "siliconflow_apikey": ConfigField(type=str, default="", description="硅基流动AI生图API密钥"), }, "send": { @@ -81,7 +83,12 @@ class MaiZoneRefactoredPlugin(BasePlugin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + permission_api.register_permission_node( + "plugin.send.permission", + "是否可以使用机器人发送说说", + "maiZone", + False + ) content_service = ContentService(self.get_config) image_service = ImageService(self.get_config) cookie_service = CookieService(self.get_config) @@ -101,5 +108,5 @@ class MaiZoneRefactoredPlugin(BasePlugin): return [ (SendFeedAction.get_action_info(), SendFeedAction), (ReadFeedAction.get_action_info(), ReadFeedAction), - (SendFeedCommand.get_command_info(), SendFeedCommand), + (SendFeedCommand.get_plus_command_info(), SendFeedCommand), ] \ No newline at end of file diff --git a/src/plugins/built_in/maizone_refactored/services/content_service.py b/src/plugins/built_in/maizone_refactored/services/content_service.py index 6b7900cc3..7a98a7cdc 100644 --- a/src/plugins/built_in/maizone_refactored/services/content_service.py +++ b/src/plugins/built_in/maizone_refactored/services/content_service.py @@ -6,8 +6,19 @@ from typing import Callable, Optional import datetime +import base64 +import aiohttp from src.common.logger import get_logger -from src.plugin_system.apis import llm_api, config_api +import base64 +import aiohttp +import imghdr +import asyncio +from src.common.logger import get_logger +from src.plugin_system.apis import llm_api, config_api, generator_api, person_api +from src.chat.message_receive.chat_stream import get_chat_manager +from maim_message import UserInfo +from src.llm_models.utils_model import LLMRequest +from src.config.api_ada_configs import TaskConfig # 导入旧的工具函数,我们稍后会考虑是否也需要重构它 from ..utils.history_utils import get_send_history @@ -97,110 +108,181 @@ class ContentService: logger.error(f"生成说说内容时发生异常: {e}") return "" - async def generate_comment(self, content: str, target_name: str, rt_con: str = "") -> str: + async def generate_comment(self, content: str, target_name: str, rt_con: str = "", images: list = []) -> str: """ 针对一条具体的说说内容生成评论。 - - :param content: 好友的说说内容。 - :param target_name: 好友的昵称。 - :param rt_con: 如果是转发的说说,这里是原说说内容。 - :return: 生成的评论内容,如果失败则返回空字符串。 """ - try: - # 获取模型配置 - models = llm_api.get_available_models() - text_model = str(self.get_config("models.text_model", "replyer_1")) - model_config = models.get(text_model) + for i in range(3): # 重试3次 + try: + chat_manager = get_chat_manager() + bot_platform = config_api.get_global_config('bot.platform') + bot_qq = str(config_api.get_global_config('bot.qq_account')) + bot_nickname = config_api.get_global_config('bot.nickname') + + bot_user_info = UserInfo( + platform=bot_platform, + user_id=bot_qq, + user_nickname=bot_nickname + ) - if not model_config: - logger.error("未配置LLM模型") - return "" + chat_stream = await chat_manager.get_or_create_stream( + platform=bot_platform, + user_info=bot_user_info + ) - # 获取机器人信息 - bot_personality = config_api.get_global_config("personality.personality_core", "一个机器人") - bot_expression = config_api.get_global_config("expression.expression_style", "内容积极向上") + if not chat_stream: + logger.error(f"无法为QQ号 {bot_qq} 创建聊天流") + return "" - # 构建提示词 - if not rt_con: - prompt = f""" - 你是'{bot_personality}',你正在浏览你好友'{target_name}'的QQ空间, - 你看到了你的好友'{target_name}'qq空间上内容是'{content}'的说说,你想要发表你的一条评论, - {bot_expression},回复的平淡一些,简短一些,说中文, - 不要刻意突出自身学科背景,不要浮夸,不要夸张修辞,不要输出多余内容(包括前后缀,冒号和引号,括号(),表情包,at或 @等 )。只输出回复内容 - """ - else: - prompt = f""" - 你是'{bot_personality}',你正在浏览你好友'{target_name}'的QQ空间, - 你看到了你的好友'{target_name}'在qq空间上转发了一条内容为'{rt_con}'的说说,你的好友的评论为'{content}' - 你想要发表你的一条评论,{bot_expression},回复的平淡一些,简短一些,说中文, - 不要刻意突出自身学科背景,不要浮夸,不要夸张修辞,不要输出多余内容(包括前后缀,冒号和引号,括号(),表情包,at或 @等 )。只输出回复内容 - """ + image_descriptions = [] + if images: + for image_url in images: + description = await self._describe_image(image_url) + if description: + image_descriptions.append(description) + + extra_info = "正在评论QQ空间的好友说说。" + if image_descriptions: + extra_info += "说说中包含的图片内容如下:\n" + "\n".join(image_descriptions) - logger.info(f"正在为'{target_name}'的说说生成评论: {content[:20]}...") + reply_to = f"{target_name}:{content}" + if rt_con: + reply_to += f"\n[转发内容]: {rt_con}" - # 调用LLM生成评论 - success, comment, _, _ = await llm_api.generate_with_model( - prompt=prompt, - model_config=model_config, - request_type="comment.generate", - temperature=0.3, - max_tokens=100 - ) + success, reply_set, _ = await generator_api.generate_reply( + chat_stream=chat_stream, + reply_to=reply_to, + extra_info=extra_info, + request_type="maizone.comment" + ) - if success: - logger.info(f"成功生成评论内容:'{comment}'") - return comment - else: - logger.error("生成评论内容失败") - return "" - - except Exception as e: - logger.error(f"生成评论内容时发生异常: {e}") - return "" + if success and reply_set: + comment = "".join([content for type, content in reply_set if type == 'text']) + logger.info(f"成功生成评论内容:'{comment}'") + return comment + else: + # 如果生成失败,则进行重试 + if i < 2: + logger.warning(f"生成评论失败,将在5秒后重试 (尝试 {i+1}/3)") + await asyncio.sleep(5) + continue + else: + logger.error("使用 generator_api 生成评论失败") + return "" + except Exception as e: + if i < 2: + logger.warning(f"生成评论时发生异常,将在5秒后重试 (尝试 {i+1}/3): {e}") + await asyncio.sleep(5) + continue + else: + logger.error(f"生成评论时发生异常: {e}") + return "" + return "" async def generate_comment_reply(self, story_content: str, comment_content: str, commenter_name: str) -> str: """ 针对自己说说的评论,生成回复。 - - :param story_content: 原始说说内容。 - :param comment_content: 好友的评论内容。 - :param commenter_name: 评论者的昵称。 - :return: 生成的回复内容。 """ - try: - models = llm_api.get_available_models() - text_model = str(self.get_config("models.text_model", "replyer_1")) - model_config = models.get(text_model) - if not model_config: - return "" + for i in range(3): # 重试3次 + try: + chat_manager = get_chat_manager() + bot_platform = config_api.get_global_config('bot.platform') + bot_qq = str(config_api.get_global_config('bot.qq_account')) + bot_nickname = config_api.get_global_config('bot.nickname') - bot_personality = config_api.get_global_config("personality.personality_core", "一个机器人") - bot_expression = config_api.get_global_config("expression.expression_style", "内容积极向上") + bot_user_info = UserInfo( + platform=bot_platform, + user_id=bot_qq, + user_nickname=bot_nickname + ) - prompt = f""" - 你是'{bot_personality}',你的好友'{commenter_name}'评论了你QQ空间上的一条内容为“{story_content}”说说, - 你的好友对该说说的评论为:“{comment_content}”,你想要对此评论进行回复 - {bot_expression},回复的平淡一些,简短一些,说中文, - 不要刻意突出自身学科背景,不要浮夸,不要夸张修辞,不要输出多余内容(包括前后缀,冒号和引号,括号(),表情包,at或 @等 )。只输出回复内容 - """ - - success, reply, _, _ = await llm_api.generate_with_model( - prompt=prompt, - model_config=model_config, - request_type="comment.reply.generate", - temperature=0.3, - max_tokens=100 - ) + chat_stream = await chat_manager.get_or_create_stream( + platform=bot_platform, + user_info=bot_user_info + ) - if success: - logger.info(f"成功为'{commenter_name}'的评论生成回复: '{reply}'") - return reply - else: - logger.error("生成评论回复失败") - return "" - except Exception as e: - logger.error(f"生成评论回复时发生异常: {e}") - return "" + if not chat_stream: + logger.error(f"无法为QQ号 {bot_qq} 创建聊天流") + return "" + + reply_to = f"{commenter_name}:{comment_content}" + extra_info = f"正在回复我的QQ空间说说“{story_content}”下的评论。" + + success, reply_set, _ = await generator_api.generate_reply( + chat_stream=chat_stream, + reply_to=reply_to, + extra_info=extra_info, + request_type="maizone.comment_reply" + ) + + if success and reply_set: + reply = "".join([content for type, content in reply_set if type == 'text']) + logger.info(f"成功为'{commenter_name}'的评论生成回复: '{reply}'") + return reply + else: + if i < 2: + logger.warning(f"生成评论回复失败,将在5秒后重试 (尝试 {i+1}/3)") + await asyncio.sleep(5) + continue + else: + logger.error("使用 generator_api 生成评论回复失败") + return "" + except Exception as e: + if i < 2: + logger.warning(f"生成评论回复时发生异常,将在5秒后重试 (尝试 {i+1}/3): {e}") + await asyncio.sleep(5) + continue + else: + logger.error(f"生成评论回复时发生异常: {e}") + return "" + return "" + + async def _describe_image(self, image_url: str) -> Optional[str]: + """ + 使用LLM识别图片内容。 + """ + for i in range(3): # 重试3次 + try: + async with aiohttp.ClientSession() as session: + async with session.get(image_url, timeout=30) as resp: + if resp.status != 200: + logger.error(f"下载图片失败: {image_url}, status: {resp.status}") + await asyncio.sleep(2) + continue + image_bytes = await resp.read() + + image_format = imghdr.what(None, image_bytes) + if not image_format: + logger.error(f"无法识别图片格式: {image_url}") + return None + + image_base64 = base64.b64encode(image_bytes).decode("utf-8") + + vision_model_name = self.get_config("models.vision_model", "vision") + if not vision_model_name: + logger.error("未在插件配置中指定视觉模型") + return None + + vision_model_config = TaskConfig( + model_list=[vision_model_name], + temperature=0.3, + max_tokens=1500 + ) + + llm_request = LLMRequest(model_set=vision_model_config, request_type="maizone.image_describe") + + prompt = config_api.get_global_config("custom_prompt.image_prompt", "请描述这张图片") + + description, _ = await llm_request.generate_response_for_image( + prompt=prompt, + image_base64=image_base64, + image_format=image_format, + ) + return description + except Exception as e: + logger.error(f"识别图片时发生异常 (尝试 {i+1}/3): {e}") + await asyncio.sleep(2) + return None async def generate_story_from_activity(self, activity: str) -> str: """ diff --git a/src/plugins/built_in/maizone_refactored/services/qzone_service.py b/src/plugins/built_in/maizone_refactored/services/qzone_service.py index 07426ac55..f97ff0991 100644 --- a/src/plugins/built_in/maizone_refactored/services/qzone_service.py +++ b/src/plugins/built_in/maizone_refactored/services/qzone_service.py @@ -17,7 +17,7 @@ import aiohttp import bs4 import json5 from src.common.logger import get_logger -from src.plugin_system.apis import config_api, person_api,chat_api +from src.plugin_system.apis import config_api, person_api from src.chat.message_receive.chat_stream import get_chat_manager from src.chat.utils.chat_message_builder import ( build_readable_messages_with_id, @@ -151,24 +151,31 @@ class QZoneService: return try: - feeds = await api_client["monitor_list_feeds"](20) # 监控时检查最近20条动态 - if not feeds: - logger.info("监控完成:未发现新说说") + # --- 第一步: 单独处理自己说说的评论 --- + if self.get_config("monitor.enable_auto_reply", False): + try: + own_feeds = await api_client["list_feeds"](qq_account, 5) # 获取自己最近5条说说 + if own_feeds: + logger.info(f"获取到自己 {len(own_feeds)} 条说说,检查评论...") + for feed in own_feeds: + await self._reply_to_own_feed_comments(feed, api_client) + await asyncio.sleep(random.uniform(3, 5)) + except Exception as e: + logger.error(f"处理自己说说评论时发生异常: {e}", exc_info=True) + + # --- 第二步: 处理好友的动态 --- + friend_feeds = await api_client["monitor_list_feeds"](20) + if not friend_feeds: + logger.info("监控完成:未发现好友新说说") return - logger.info(f"监控任务: 发现 {len(feeds)} 条新动态,准备处理...") - for feed in feeds: + logger.info(f"监控任务: 发现 {len(friend_feeds)} 条好友新动态,准备处理...") + for feed in friend_feeds: target_qq = feed.get("target_qq") - if not target_qq: + if not target_qq or str(target_qq) == str(qq_account): # 确保不重复处理自己的 continue - - # 区分是自己的说说还是他人的说说 - if target_qq == qq_account: - if self.get_config("monitor.enable_auto_reply", False): - await self._reply_to_own_feed_comments(feed, api_client) - else: - await self._process_single_feed(feed, api_client, target_qq, target_qq) - + + await self._process_single_feed(feed, api_client, target_qq, target_qq) await asyncio.sleep(random.uniform(5, 10)) except Exception as e: logger.error(f"监控好友动态时发生异常: {e}", exc_info=True) @@ -244,12 +251,20 @@ class QZoneService: if not comments: return - # 筛选出未被自己回复过的主评论 - my_comment_tids = { - c["parent_tid"] for c in comments if c.get("parent_tid") and c.get("qq_account") == qq_account + # 筛选出未被自己回复过的评论 + if not comments: + return + + # 找到所有我已经回复过的评论的ID + replied_to_tids = { + c['parent_tid'] for c in comments + if c.get('parent_tid') and str(c.get('qq_account')) == str(qq_account) } + + # 找出所有非我发出且我未回复过的评论 comments_to_reply = [ - c for c in comments if not c.get("parent_tid") and c.get("comment_tid") not in my_comment_tids + c for c in comments + if str(c.get('qq_account')) != str(qq_account) and c.get('comment_tid') not in replied_to_tids ] if not comments_to_reply: @@ -275,9 +290,10 @@ class QZoneService: content = feed.get("content", "") fid = feed.get("tid", "") rt_con = feed.get("rt_con", "") + images = feed.get("images", []) if random.random() <= self.get_config("read.comment_possibility", 0.3): - comment_text = await self.content_service.generate_comment(content, target_name, rt_con) + comment_text = await self.content_service.generate_comment(content, target_name, rt_con, images) if comment_text: await api_client["comment"](target_qq, fid, comment_text) @@ -655,6 +671,19 @@ class QZoneService: c.get("name") == my_name for c in msg.get("commentlist", []) if isinstance(c, dict) ) if not is_commented: + images = [pic['url1'] for pic in msg.get('pictotal', []) if 'url1' in pic] + + comments = [] + if 'commentlist' in msg: + for c in msg['commentlist']: + comments.append({ + 'qq_account': c.get('uin'), + 'nickname': c.get('name'), + 'content': c.get('content'), + 'comment_tid': c.get('tid'), + 'parent_tid': c.get('parent_tid') # API直接返回了父ID + }) + feeds_list.append( { "tid": msg.get("tid", ""), @@ -665,6 +694,8 @@ class QZoneService: "rt_con": msg.get("rt_con", {}).get("content", "") if isinstance(msg.get("rt_con"), dict) else "", + "images": images, + "comments": comments } ) return feeds_list @@ -815,10 +846,61 @@ class QZoneService: text_div = soup.find('div', class_='f-info') text = text_div.get_text(strip=True) if text_div else "" + # --- 借鉴原版插件的精确图片提取逻辑 --- + image_urls = [] + img_box = soup.find('div', class_='img-box') + if img_box: + for img in img_box.find_all('img'): + src = img.get('src') + # 排除QQ空间的小图标和表情 + if src and 'qzonestyle.gtimg.cn' not in src: + image_urls.append(src) + + # 视频封面也视为图片 + video_thumb = soup.select_one('div.video-img img') + if video_thumb and 'src' in video_thumb.attrs: + image_urls.append(video_thumb['src']) + + # 去重 + images = list(set(image_urls)) + + comments = [] + comment_divs = soup.find_all('div', class_='f-single-comment') + for comment_div in comment_divs: + # --- 处理主评论 --- + author_a = comment_div.find('a', class_='f-nick') + content_span = comment_div.find('span', class_='f-re-con') + + if author_a and content_span: + comments.append({ + 'qq_account': str(comment_div.get('data-uin', '')), + 'nickname': author_a.get_text(strip=True), + 'content': content_span.get_text(strip=True), + 'comment_tid': comment_div.get('data-tid', ''), + 'parent_tid': None # 主评论没有父ID + }) + + # --- 处理这条主评论下的所有回复 --- + reply_divs = comment_div.find_all('div', class_='f-single-re') + for reply_div in reply_divs: + reply_author_a = reply_div.find('a', class_='f-nick') + reply_content_span = reply_div.find('span', class_='f-re-con') + + if reply_author_a and reply_content_span: + comments.append({ + 'qq_account': str(reply_div.get('data-uin', '')), + 'nickname': reply_author_a.get_text(strip=True), + 'content': reply_content_span.get_text(strip=True).lstrip(': '), # 移除回复内容前多余的冒号和空格 + 'comment_tid': reply_div.get('data-tid', ''), + 'parent_tid': reply_div.get('data-parent-tid', comment_div.get('data-tid', '')) # 如果没有父ID,则将父ID设为主评论ID + }) + feeds_list.append({ 'target_qq': target_qq, 'tid': tid, 'content': text, + 'images': images, + 'comments': comments }) logger.info(f"监控任务发现 {len(feeds_list)} 条未处理的新说说。") return feeds_list diff --git a/src/plugins/built_in/maizone_refactored/services/scheduler_service.py b/src/plugins/built_in/maizone_refactored/services/scheduler_service.py index 86dea92e9..e6498ab9b 100644 --- a/src/plugins/built_in/maizone_refactored/services/scheduler_service.py +++ b/src/plugins/built_in/maizone_refactored/services/scheduler_service.py @@ -10,7 +10,7 @@ import traceback from typing import Callable from src.common.logger import get_logger -from src.manager.schedule_manager import schedule_manager +from src.schedule.schedule_manager import schedule_manager from src.common.database.sqlalchemy_database_api import get_db_session from src.common.database.sqlalchemy_models import MaiZoneScheduleStatus diff --git a/src/plugins/built_in/permission_management/plugin.py b/src/plugins/built_in/permission_management/plugin.py index 13164b384..d8a39107a 100644 --- a/src/plugins/built_in/permission_management/plugin.py +++ b/src/plugins/built_in/permission_management/plugin.py @@ -2,6 +2,7 @@ 权限管理插件 提供权限系统的管理命令,包括权限授权、撤销、查询等功能。 +使用新的PlusCommand系统重构。 """ import re @@ -9,23 +10,26 @@ from typing import List, Optional, Tuple, Type from src.plugin_system.apis.plugin_register_api import register_plugin from src.plugin_system.base.base_plugin import BasePlugin -from src.plugin_system.base.base_command import BaseCommand +from src.plugin_system.base.plus_command import PlusCommand +from src.plugin_system.base.command_args import CommandArgs from src.plugin_system.apis.permission_api import permission_api from src.plugin_system.apis.logging_api import get_logger -from src.plugin_system.base.component_types import CommandInfo +from src.plugin_system.base.component_types import PlusCommandInfo, ChatType from src.plugin_system.base.config_types import ConfigField +from src.plugin_system.utils.permission_decorators import require_permission, require_master, PermissionChecker logger = get_logger("Permission") -class PermissionCommand(BaseCommand): - """权限管理命令""" +class PermissionCommand(PlusCommand): + """权限管理命令 - 使用PlusCommand系统""" command_name = "permission" - command_description = "权限管理命令" - command_pattern = r"^/permission(?:\s|$)" - command_help = "/permission <子命令> [参数...]" + command_description = "权限管理命令,支持授权、撤销、查询等功能" + command_aliases = ["perm", "权限"] + priority = 10 + chat_type_allow = ChatType.ALL intercept_message = True def __init__(self, *args, **kwargs): @@ -43,70 +47,48 @@ class PermissionCommand(BaseCommand): "permission_manager", True ) - - def can_execute(self) -> bool: - """检查命令是否可以执行""" - # 基本权限检查由权限系统处理 - return True - - async def execute(self, args: List[str]) -> None: - """执行权限管理命令""" - if not args: - await self._show_help() - return - - subcommand = args[0].lower() - remaining_args = args[1:] - chat_stream = self.message.chat_stream - # 检查基本查看权限 - can_view = permission_api.check_permission( - chat_stream.platform, - chat_stream.user_info.user_id, - "plugin.permission.view" - ) or permission_api.is_master(chat_stream.platform, chat_stream.user_info.user_id) - # 检查管理权限 - can_manage = permission_api.check_permission( - chat_stream.platform, - chat_stream.user_info.user_id, - "plugin.permission.manage" - ) or permission_api.is_master(chat_stream.platform, chat_stream.user_info.user_id) + async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]: + """执行权限管理命令""" + if args.is_empty: + await self._show_help() + return True, "显示帮助信息", True + + subcommand = args.get_first.lower() + remaining_args = args.get_args()[1:] # 获取除第一个参数外的所有参数 + chat_stream = self.message.chat_stream if subcommand in ["grant", "授权", "give"]: - if not can_manage: - await self.send_text("❌ 你没有权限管理的权限") - return await self._grant_permission(chat_stream, remaining_args) + return True, "执行授权命令", True elif subcommand in ["revoke", "撤销", "remove"]: - if not can_manage: - await self.send_text("❌ 你没有权限管理的权限") - return await self._revoke_permission(chat_stream, remaining_args) + return True, "执行撤销命令", True elif subcommand in ["list", "列表", "ls"]: - if not can_view: - await self.send_text("❌ 你没有查看权限的权限") - return await self._list_permissions(chat_stream, remaining_args) + return True, "执行列表命令", True elif subcommand in ["check", "检查"]: - if not can_view: - await self.send_text("❌ 你没有查看权限的权限") - return await self._check_permission(chat_stream, remaining_args) + return True, "执行检查命令", True elif subcommand in ["nodes", "节点"]: - if not can_view: - await self.send_text("❌ 你没有查看权限的权限") - return await self._list_nodes(chat_stream, remaining_args) + return True, "执行节点命令", True + + elif subcommand in ["allnodes", "全部节点", "all"]: + await self._list_all_nodes_with_description(chat_stream) + return True, "执行全部节点命令", True elif subcommand in ["help", "帮助"]: - await self._show_help(chat_stream) + await self._show_help() + return True, "显示帮助信息", True else: await self.send_text(f"❌ 未知的子命令: {subcommand}\n使用 /permission help 查看帮助") + return True, "未知子命令", True async def _show_help(self): """显示帮助信息""" @@ -120,6 +102,7 @@ class PermissionCommand(BaseCommand): • /permission list [用户] - 查看用户权限列表 • /permission check <@用户|QQ号> <权限节点> - 检查用户是否拥有权限 • /permission nodes [插件名] - 查看权限节点列表 +• /permission allnodes - 查看所有插件的权限节点详情 ❓ 其他: • /permission help - 显示此帮助 @@ -127,16 +110,26 @@ class PermissionCommand(BaseCommand): 📝 示例: • /permission grant @张三 plugin.example.command • /permission list 123456789 -• /permission nodes example_plugin""" +• /permission nodes example_plugin +• /permission allnodes + +🔄 别名:可以使用 /perm 或 /权限 代替 /permission""" await self.send_text(help_text) def _parse_user_mention(self, mention: str) -> Optional[str]: - """解析用户提及,提取QQ号""" - # 匹配 @用户 格式,提取QQ号 - at_match = re.search(r'\[CQ:at,qq=(\d+)\]', mention) + """解析用户提及,提取QQ号 + + 支持的格式: + - @<用户名:QQ号> 格式 + - [CQ:at,qq=QQ号] 格式 + - 直接的QQ号 + """ + # 匹配 @<用户名:QQ号> 格式,提取QQ号 + at_match = re.search(r'@<[^:]+:(\d+)>', mention) if at_match: return at_match.group(1) + # 直接是数字 if mention.isdigit(): @@ -144,62 +137,94 @@ class PermissionCommand(BaseCommand): return None - async def _grant_permission(self, chat_stream , args: List[str]): + @staticmethod + def parse_user_from_args(args: CommandArgs, index: int = 0) -> Optional[str]: + """从CommandArgs中解析用户ID + + Args: + args: 命令参数对象 + index: 参数索引,默认为0(第一个参数) + + Returns: + Optional[str]: 解析出的用户ID,如果解析失败返回None + """ + if index >= args.count(): + return None + + mention = args.get_arg(index) + + # 匹配 @<用户名:QQ号> 格式,提取QQ号 + at_match = re.search(r'@<[^:]+:(\d+)>', mention) + if at_match: + return at_match.group(1) + + # 匹配传统的 [CQ:at,qq=数字] 格式 + cq_match = re.search(r'\[CQ:at,qq=(\d+)\]', mention) + if cq_match: + return cq_match.group(1) + + # 直接是数字 + if mention.isdigit(): + return mention + + return None + + @require_permission("plugin.permission.manage", "❌ 你没有权限管理的权限") + async def _grant_permission(self, chat_stream, args: List[str]): """授权用户权限""" if len(args) < 2: await self.send_text("❌ 用法: /permission grant <@用户|QQ号> <权限节点>") return - user_mention = args[0] - permission_node = args[1] - - # 解析用户ID - user_id = self._parse_user_mention(user_mention) + # 解析用户ID - 使用新的解析方法 + user_id = self._parse_user_mention(args[0]) if not user_id: - await self.send_text("❌ 无效的用户格式,请使用 @用户 或直接输入QQ号") + await self.send_text("❌ 无效的用户格式,请使用 @<用户名:QQ号> 或直接输入QQ号") return + permission_node = args[1] + # 执行授权 success = permission_api.grant_permission(chat_stream.platform, user_id, permission_node) if success: - await self.send_text(f"✅ 已授权用户 {user_id} 权限节点 {permission_node}") + await self.send_text(f"✅ 已授权用户 {user_id} 权限节点 `{permission_node}`") else: await self.send_text("❌ 授权失败,请检查权限节点是否存在") + @require_permission("plugin.permission.manage", "❌ 你没有权限管理的权限") async def _revoke_permission(self, chat_stream, args: List[str]): """撤销用户权限""" if len(args) < 2: await self.send_text("❌ 用法: /permission revoke <@用户|QQ号> <权限节点>") return - user_mention = args[0] - permission_node = args[1] - - # 解析用户ID - user_id = self._parse_user_mention(user_mention) + # 解析用户ID - 使用新的解析方法 + user_id = self._parse_user_mention(args[0]) if not user_id: - await self.send_text("❌ 无效的用户格式,请使用 @用户 或直接输入QQ号") + await self.send_text("❌ 无效的用户格式,请使用 @<用户名:QQ号> 或直接输入QQ号") return + permission_node = args[1] + # 执行撤销 success = permission_api.revoke_permission(chat_stream.platform, user_id, permission_node) if success: - await self.send_text(f"✅ 已撤销用户 {user_id} 权限节点 {permission_node}") + await self.send_text(f"✅ 已撤销用户 {user_id} 权限节点 `{permission_node}`") else: await self.send_text("❌ 撤销失败,请检查权限节点是否存在") + @require_permission("plugin.permission.view", "❌ 你没有查看权限的权限") async def _list_permissions(self, chat_stream, args: List[str]): """列出用户权限""" target_user_id = None if args: - # 指定了用户 - user_mention = args[0] - target_user_id = self._parse_user_mention(user_mention) + # 指定了用户 - 使用新的解析方法 + target_user_id = self._parse_user_mention(args[0]) if not target_user_id: - await self.send_text("❌ 无效的用户格式,请使用 @用户 或直接输入QQ号") + await self.send_text("❌ 无效的用户格式,请使用 @<用户名:QQ号> 或直接输入QQ号") return else: # 查看自己的权限 @@ -212,45 +237,46 @@ class PermissionCommand(BaseCommand): permissions = permission_api.get_user_permissions(chat_stream.platform, target_user_id) if is_master: - response = f"👑 用户 {target_user_id} 是Master用户,拥有所有权限" + response = f"👑 用户 `{target_user_id}` 是Master用户,拥有所有权限" else: if permissions: - perm_list = "\n".join([f"• {perm}" for perm in permissions]) - response = f"📋 用户 {target_user_id} 拥有的权限:\n{perm_list}" + perm_list = "\n".join([f"• `{perm}`" for perm in permissions]) + response = f"📋 用户 `{target_user_id}` 拥有的权限:\n{perm_list}" else: - response = f"📋 用户 {target_user_id} 没有任何权限" + response = f"📋 用户 `{target_user_id}` 没有任何权限" await self.send_text(response) + @require_permission("plugin.permission.view", "❌ 你没有查看权限的权限") async def _check_permission(self, chat_stream, args: List[str]): """检查用户权限""" if len(args) < 2: await self.send_text("❌ 用法: /permission check <@用户|QQ号> <权限节点>") return - user_mention = args[0] - permission_node = args[1] - - # 解析用户ID - user_id = self._parse_user_mention(user_mention) + # 解析用户ID - 使用新的解析方法 + user_id = self._parse_user_mention(args[0]) if not user_id: - await self.send_text("❌ 无效的用户格式,请使用 @用户 或直接输入QQ号") + await self.send_text("❌ 无效的用户格式,请使用 @<用户名:QQ号> 或直接输入QQ号") return + permission_node = args[1] + # 检查权限 has_permission = permission_api.check_permission(chat_stream.platform, user_id, permission_node) is_master = permission_api.is_master(chat_stream.platform, user_id) if has_permission: if is_master: - response = f"✅ 用户 {user_id} 拥有权限 {permission_node}(Master用户)" + response = f"✅ 用户 `{user_id}` 拥有权限 `{permission_node}`(Master用户)" else: - response = f"✅ 用户 {user_id} 拥有权限 {permission_node}" + response = f"✅ 用户 `{user_id}` 拥有权限 `{permission_node}`" else: - response = f"❌ 用户 {user_id} 没有权限 {permission_node}" + response = f"❌ 用户 `{user_id}` 没有权限 `{permission_node}`" await self.send_text(response) + @require_permission("plugin.permission.view", "❌ 你没有查看权限的权限") async def _list_nodes(self, chat_stream, args: List[str]): """列出权限节点""" plugin_name = args[0] if args else None @@ -283,6 +309,74 @@ class PermissionCommand(BaseCommand): await self.send_text(response) + @require_permission("plugin.permission.view", "❌ 你没有查看权限的权限") + async def _list_all_nodes_with_description(self, chat_stream): + """列出所有插件的权限节点(带详细描述)""" + # 获取所有权限节点 + all_nodes = permission_api.get_all_permission_nodes() + + if not all_nodes: + response = "📋 系统中没有任何权限节点" + await self.send_text(response) + return + + # 按插件名分组节点 + plugins_dict = {} + for node in all_nodes: + plugin_name = node["plugin_name"] + if plugin_name not in plugins_dict: + plugins_dict[plugin_name] = [] + plugins_dict[plugin_name].append(node) + + # 构建响应消息 + response_parts = ["📋 所有插件权限节点详情:\n"] + + for plugin_name in sorted(plugins_dict.keys()): + nodes = plugins_dict[plugin_name] + response_parts.append(f"🔌 **{plugin_name}** ({len(nodes)}个节点):") + + for node in nodes: + default_text = "✅默认授权" if node["default_granted"] else "❌默认拒绝" + response_parts.append(f" • `{node['node_name']}` - {default_text}") + response_parts.append(f" 📄 {node['description']}") + + response_parts.append("") # 插件间空行分隔 + + # 添加统计信息 + total_nodes = len(all_nodes) + total_plugins = len(plugins_dict) + response_parts.append(f"📊 统计:共 {total_plugins} 个插件,{total_nodes} 个权限节点") + + response = "\n".join(response_parts) + + # 如果消息太长,分段发送 + if len(response) > 4000: # 预留一些空间避免超出限制 + await self._send_long_message(response) + else: + await self.send_text(response) + + async def _send_long_message(self, message: str): + """发送长消息,自动分段""" + lines = message.split('\n') + current_chunk = [] + current_length = 0 + + for line in lines: + line_length = len(line) + 1 # +1 for newline + + # 如果添加这一行会超出限制,先发送当前块 + if current_length + line_length > 3500 and current_chunk: + await self.send_text('\n'.join(current_chunk)) + current_chunk = [] + current_length = 0 + + current_chunk.append(line) + current_length += line_length + + # 发送最后一块 + if current_chunk: + await self.send_text('\n'.join(current_chunk)) + @register_plugin class PermissionManagerPlugin(BasePlugin): @@ -298,5 +392,6 @@ class PermissionManagerPlugin(BasePlugin): } } - def get_plugin_components(self) -> List[Tuple[CommandInfo, Type[BaseCommand]]]: - return [(PermissionCommand.get_command_info(), PermissionCommand)] \ No newline at end of file + def get_plugin_components(self) -> List[Tuple[PlusCommandInfo, Type[PlusCommand]]]: + """返回插件的PlusCommand组件""" + return [(PermissionCommand.get_plus_command_info(), PermissionCommand)] \ No newline at end of file diff --git a/src/plugins/built_in/poke_plugin/_manifest.json b/src/plugins/built_in/poke_plugin/_manifest.json index 432743857..3aae7f727 100644 --- a/src/plugins/built_in/poke_plugin/_manifest.json +++ b/src/plugins/built_in/poke_plugin/_manifest.json @@ -12,8 +12,8 @@ "host_application": { "min_version": "0.8.0" }, - "homepage_url": "https://github.com/MaiBot-Plus/MaiMbot-Pro-Max", - "repository_url": "https://github.com/MaiBot-Plus/MaiMbot-Pro-Max", + "homepage_url": "https://github.com/MoFox-Studio/MoFox_Bot", + "repository_url": "https://github.com/MoFox-Studio/MoFox_Bot", "keywords": ["poke", "interaction", "fun", "social", "ai-reply", "auto-response"], "categories": ["Social", "Interactive", "Fun"], diff --git a/src/plugins/built_in/web_search_tool/tools/url_parser.py b/src/plugins/built_in/web_search_tool/tools/url_parser.py index 315e06271..b8381f333 100644 --- a/src/plugins/built_in/web_search_tool/tools/url_parser.py +++ b/src/plugins/built_in/web_search_tool/tools/url_parser.py @@ -11,7 +11,6 @@ from bs4 import BeautifulSoup from src.common.logger import get_logger from src.plugin_system import BaseTool, ToolParamType, llm_api from src.plugin_system.apis import config_api -from src.common.cache_manager import tool_cache from ..utils.formatters import format_url_parse_results from ..utils.url_utils import parse_urls_from_input, validate_urls @@ -30,6 +29,12 @@ class URLParserTool(BaseTool): parameters = [ ("urls", ToolParamType.STRING, "要理解的网站", True, None), ] + + # --- 新的缓存配置 --- + enable_cache: bool = True + cache_ttl: int = 86400 # 缓存24小时 + semantic_cache_query_key: str = "urls" + # -------------------- def __init__(self, plugin_config=None): super().__init__(plugin_config) @@ -42,10 +47,11 @@ class URLParserTool(BaseTool): if exa_api_keys is None: # 从插件配置文件读取 exa_api_keys = self.get_config("exa.api_keys", []) - + # 创建API密钥管理器 + from typing import cast, List self.api_manager = create_api_key_manager_from_config( - exa_api_keys, + cast(List[str], exa_api_keys), lambda key: Exa(api_key=key), "Exa URL Parser" ) @@ -135,16 +141,6 @@ class URLParserTool(BaseTool): """ 执行URL内容提取和总结。优先使用Exa,失败后尝试本地解析。 """ - # 获取当前文件路径用于缓存键 - import os - current_file_path = os.path.abspath(__file__) - - # 检查缓存 - cached_result = await tool_cache.get(self.name, function_args, current_file_path) - if cached_result: - logger.info(f"缓存命中: {self.name} -> {function_args}") - return cached_result - urls_input = function_args.get("urls") if not urls_input: return {"error": "URL列表不能为空。"} @@ -235,8 +231,4 @@ class URLParserTool(BaseTool): "errors": error_messages } - # 保存到缓存 - if "error" not in result: - await tool_cache.set(self.name, function_args, current_file_path, result) - return result diff --git a/src/plugins/built_in/web_search_tool/tools/web_search.py b/src/plugins/built_in/web_search_tool/tools/web_search.py index c09ad5e92..1bd4feea2 100644 --- a/src/plugins/built_in/web_search_tool/tools/web_search.py +++ b/src/plugins/built_in/web_search_tool/tools/web_search.py @@ -7,7 +7,6 @@ from typing import Any, Dict, List from src.common.logger import get_logger from src.plugin_system import BaseTool, ToolParamType from src.plugin_system.apis import config_api -from src.common.cache_manager import tool_cache from ..engines.exa_engine import ExaSearchEngine from ..engines.tavily_engine import TavilySearchEngine @@ -31,6 +30,12 @@ class WebSurfingTool(BaseTool): ("time_range", ToolParamType.STRING, "指定搜索的时间范围,可以是 'any', 'week', 'month'。默认为 'any'。", False, ["any", "week", "month"]) ] # type: ignore + # --- 新的缓存配置 --- + enable_cache: bool = True + cache_ttl: int = 7200 # 缓存2小时 + semantic_cache_query_key: str = "query" + # -------------------- + def __init__(self, plugin_config=None): super().__init__(plugin_config) # 初始化搜索引擎 @@ -46,16 +51,6 @@ class WebSurfingTool(BaseTool): if not query: return {"error": "搜索查询不能为空。"} - # 获取当前文件路径用于缓存键 - import os - current_file_path = os.path.abspath(__file__) - - # 检查缓存 - cached_result = await tool_cache.get(self.name, function_args, current_file_path, semantic_query=query) - if cached_result: - logger.info(f"缓存命中: {self.name} -> {function_args}") - return cached_result - # 读取搜索配置 enabled_engines = config_api.get_global_config("web_search.enabled_engines", ["ddg"]) search_strategy = config_api.get_global_config("web_search.search_strategy", "single") @@ -69,10 +64,6 @@ class WebSurfingTool(BaseTool): result = await self._execute_fallback_search(function_args, enabled_engines) else: # single result = await self._execute_single_search(function_args, enabled_engines) - - # 保存到缓存 - if "error" not in result: - await tool_cache.set(self.name, function_args, current_file_path, result, semantic_query=query) return result diff --git a/src/schedule/monthly_plan_manager.py b/src/schedule/monthly_plan_manager.py index 85ec08019..1e25cdf11 100644 --- a/src/schedule/monthly_plan_manager.py +++ b/src/schedule/monthly_plan_manager.py @@ -1,106 +1,331 @@ -# mmc/src/schedule/monthly_plan_manager.py -# 我要混提交 -import datetime -from src.config.config import global_config -from src.common.database.monthly_plan_db import get_active_plans_for_month, add_new_plans -from src.schedule.plan_generator import PlanGenerator +# mmc/src/manager/monthly_plan_manager.py + +import asyncio +from datetime import datetime, timedelta +from typing import List, Optional + +from src.common.database.monthly_plan_db import ( + add_new_plans, + get_archived_plans_for_month, + archive_active_plans_for_month, + has_active_plans, + get_active_plans_for_month, + delete_plans_by_ids +) +from src.config.config import global_config, model_config +from src.llm_models.utils_model import LLMRequest from src.common.logger import get_logger +from src.manager.async_task_manager import AsyncTask, async_task_manager logger = get_logger("monthly_plan_manager") +# 默认的月度计划生成指导原则 +DEFAULT_MONTHLY_PLAN_GUIDELINES = """ +我希望你能为自己制定一些有意义的月度小目标和计划。 +这些计划应该涵盖学习、娱乐、社交、个人成长等各个方面。 +每个计划都应该是具体可行的,能够在一个月内通过日常活动逐步实现。 +请确保计划既有挑战性又不会过于繁重,保持生活的平衡和乐趣。 +""" + class MonthlyPlanManager: + """月度计划管理器 + + 负责月度计划的生成、管理和生命周期控制。 + 与 ScheduleManager 解耦,专注于月度层面的计划管理。 """ - 管理月度计划的生成和填充。 - """ + + def __init__(self): + self.llm = LLMRequest( + model_set=model_config.model_task_config.schedule_generator, + request_type="monthly_plan" + ) + self.generation_running = False + self.monthly_task_started = False - @staticmethod - async def initialize_monthly_plans(): - """ - 程序启动时调用,检查并按需填充当月的计划池。 - """ - config = global_config.monthly_plan_system - if not config or not config.enable: - logger.info("月层计划系统未启用,跳过初始化。") - return + async def start_monthly_plan_generation(self): + """启动每月初自动生成新月度计划的任务,并在启动时检查一次""" + if not self.monthly_task_started: + logger.info(" 正在启动每月月度计划生成任务...") + task = MonthlyPlanGenerationTask(self) + await async_task_manager.add_task(task) + self.monthly_task_started = True + logger.info(" 每月月度计划生成任务已成功启动。") + + # 启动时立即检查并按需生成 + logger.info(" 执行启动时月度计划检查...") + await self.ensure_and_generate_plans_if_needed() + else: + logger.info(" 每月月度计划生成任务已在运行中。") - now = datetime.datetime.now() - current_month_str = now.strftime("%Y-%m") + async def ensure_and_generate_plans_if_needed(self, target_month: Optional[str] = None) -> bool: + """ + 确保指定月份有计划,如果没有则触发生成。 + 这是按需生成的主要入口点。 + """ + if target_month is None: + target_month = datetime.now().strftime("%Y-%m") + + if not has_active_plans(target_month): + logger.info(f" {target_month} 没有任何有效的月度计划,将立即生成。") + return await self.generate_monthly_plans(target_month) + else: + logger.info(f"{target_month} 已存在有效的月度计划。") + plans = get_active_plans_for_month(target_month) + + # 检查是否超出上限 + max_plans = global_config.monthly_plan_system.max_plans_per_month + if len(plans) > max_plans: + logger.warning(f"当前月度计划数量 ({len(plans)}) 超出上限 ({max_plans}),将自动删除多余的计划。") + # 按创建时间升序排序(旧的在前),然后删除超出上限的部分(新的) + plans_to_delete = sorted(plans, key=lambda p: p.created_at)[max_plans:] + delete_ids = [p.id for p in plans_to_delete] + delete_plans_by_ids(delete_ids) + # 重新获取计划列表 + plans = get_active_plans_for_month(target_month) + + if plans: + plan_texts = "\n".join([f" {i+1}. {plan.plan_text}" for i, plan in enumerate(plans)]) + logger.info(f"当前月度计划内容:\n{plan_texts}") + return True # 已经有计划,也算成功 + + async def generate_monthly_plans(self, target_month: Optional[str] = None) -> bool: + """ + 生成指定月份的月度计划 + + :param target_month: 目标月份,格式为 "YYYY-MM"。如果为 None,则为当前月份。 + :return: 是否生成成功 + """ + if self.generation_running: + logger.info("月度计划生成任务已在运行中,跳过重复启动") + return False + + self.generation_running = True try: - # 1. 检查当月已有计划数量 - existing_plans = get_active_plans_for_month(current_month_str) - plan_count = len(existing_plans) + # 确定目标月份 + if target_month is None: + target_month = datetime.now().strftime("%Y-%m") - header = "📅 月度计划检查" + logger.info(f"开始为 {target_month} 生成月度计划...") - # 2. 判断是否需要生成新计划 - if plan_count >= config.generation_threshold: - summary = f"计划数量充足 ({plan_count}/{config.generation_threshold}),无需生成。" - log_message = ( - f"\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n" - f"┃ {header}\n" - f"┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫\n" - f"┃ 月份: {current_month_str}\n" - f"┃ 状态: {summary}\n" - f"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" - ) - logger.info(log_message) - return - - # 3. 计算需要生成的计划数量并调用生成器 - needed_plans = config.generation_threshold - plan_count - summary = f"计划不足 ({plan_count}/{config.generation_threshold}),需要生成 {needed_plans} 条。" - generation_info = f"即将生成 {config.plans_per_generation} 条新计划..." - log_message = ( - f"\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n" - f"┃ {header}\n" - f"┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫\n" - f"┃ 月份: {current_month_str}\n" - f"┃ 状态: {summary}\n" - f"┃ 操作: {generation_info}\n" - f"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" - ) - logger.info(log_message) + # 检查是否启用月度计划系统 + if not global_config.monthly_plan_system or not global_config.monthly_plan_system.enable: + logger.info(" 月度计划系统已禁用,跳过计划生成。") + return False - generator = PlanGenerator() - new_plans = await generator.generate_plans( - year=now.year, - month=now.month, - count=config.plans_per_generation # 每次生成固定数量以保证质量 - ) - - # 4. 将新计划存入数据库 - if new_plans: - add_new_plans(new_plans, current_month_str) - completion_header = "✅ 月度计划生成完毕" - completion_summary = f"成功添加 {len(new_plans)} 条新计划。" - - # 构建计划详情 - plan_details = "\n".join([f"┃ - {plan}" for plan in new_plans]) - - log_message = ( - f"\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n" - f"┃ {completion_header}\n" - f"┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫\n" - f"┃ 月份: {current_month_str}\n" - f"┃ 结果: {completion_summary}\n" - f"┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫\n" - f"{plan_details}\n" - f"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" - ) - logger.info(log_message) + # 获取上个月的归档计划作为参考 + last_month = self._get_previous_month(target_month) + archived_plans = get_archived_plans_for_month(last_month) + + # 构建生成 Prompt + prompt = self._build_generation_prompt(target_month, archived_plans) + + # 调用 LLM 生成计划 + plans = await self._generate_plans_with_llm(prompt) + + if plans: + # 保存到数据库 + add_new_plans(plans, target_month) + logger.info(f"成功为 {target_month} 生成并保存了 {len(plans)} 条月度计划。") + return True else: - completion_header = "❌ 月度计划生成失败" - completion_summary = "未能生成任何新的月度计划。" - log_message = ( - f"\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n" - f"┃ {completion_header}\n" - f"┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫\n" - f"┃ 月份: {current_month_str}\n" - f"┃ 结果: {completion_summary}\n" - f"┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" - ) - logger.warning(log_message) - + logger.warning(f"未能为 {target_month} 生成有效的月度计划。") + return False + except Exception as e: - logger.error(f"初始化月度计划时发生严重错误: {e}", exc_info=True) \ No newline at end of file + logger.error(f" 生成 {target_month} 月度计划时发生错误: {e}") + return False + finally: + self.generation_running = False + + def _get_previous_month(self, current_month: str) -> str: + """获取上个月的月份字符串""" + try: + year, month = map(int, current_month.split('-')) + if month == 1: + return f"{year-1}-12" + else: + return f"{year}-{month-1:02d}" + except Exception: + # 如果解析失败,返回一个不存在的月份 + return "1900-01" + + def _build_generation_prompt(self, target_month: str, archived_plans: List) -> str: + """构建月度计划生成的 Prompt""" + + # 获取配置 + guidelines = getattr(global_config.monthly_plan_system, 'guidelines', None) or DEFAULT_MONTHLY_PLAN_GUIDELINES + personality = global_config.personality.personality_core + personality_side = global_config.personality.personality_side + max_plans = global_config.monthly_plan_system.max_plans_per_month + + # 构建上月未完成计划的参考信息 + archived_plans_block = "" + if archived_plans: + archived_texts = [f"- {plan.plan_text}" for plan in archived_plans[:5]] # 最多显示5个 + archived_plans_block = f""" +**上个月未完成的一些计划(可作为参考)**: +{chr(10).join(archived_texts)} + +你可以考虑是否要在这个月继续推进这些计划,或者制定全新的计划。 +""" + + prompt = f""" +我,{global_config.bot.nickname},需要为自己制定 {target_month} 的月度计划。 + +**关于我**: +- **核心人设**: {personality} +- **具体习惯与兴趣**: +{personality_side} + +{archived_plans_block} + +**我的月度计划制定原则**: +{guidelines} + +**重要要求**: +1. 请为我生成 {max_plans} 条左右的月度计划 +2. 每条计划都应该是一句话,简洁明了,具体可行 +3. 计划应该涵盖不同的生活方面(学习、娱乐、社交、个人成长等) +4. 返回格式必须是纯文本,每行一条计划,不要使用 JSON 或其他格式 +5. 不要包含任何解释性文字,只返回计划列表 + +**示例格式**: +学习一门新的编程语言或技术 +每周至少看两部有趣的电影 +与朋友们组织一次户外活动 +阅读3本感兴趣的书籍 +尝试制作一道新的料理 + +请你扮演我,以我的身份和兴趣,为 {target_month} 制定合适的月度计划。 +""" + + return prompt + + async def _generate_plans_with_llm(self, prompt: str) -> List[str]: + """使用 LLM 生成月度计划列表""" + max_retries = 3 + + for attempt in range(1, max_retries + 1): + try: + logger.info(f" 正在生成月度计划 (第 {attempt} 次尝试)") + + response, _ = await self.llm.generate_response_async(prompt) + + # 解析响应 + plans = self._parse_plans_response(response) + + if plans: + logger.info(f"成功生成 {len(plans)} 条月度计划") + return plans + else: + logger.warning(f"第 {attempt} 次生成的计划为空,继续重试...") + + except Exception as e: + logger.error(f"第 {attempt} 次生成月度计划失败: {e}") + + # 添加短暂延迟,避免过于频繁的请求 + if attempt < max_retries: + await asyncio.sleep(2) + + logger.error(" 所有尝试都失败,无法生成月度计划") + return [] + + def _parse_plans_response(self, response: str) -> List[str]: + """解析 LLM 响应,提取计划列表""" + try: + # 清理响应文本 + response = response.strip() + + # 按行分割 + lines = [line.strip() for line in response.split('\n') if line.strip()] + + # 过滤掉明显不是计划的行(比如包含特殊标记的行) + plans = [] + for line in lines: + # 跳过包含特殊标记的行 + if any(marker in line for marker in ['**', '##', '```', '---', '===', '###']): + continue + + # 移除可能的序号前缀 + line = line.lstrip('0123456789.- ') + + # 确保计划不为空且有意义 + if len(line) > 5 and not line.startswith(('请', '以上', '总结', '注意')): + plans.append(line) + + # 限制计划数量 + max_plans = global_config.monthly_plan_system.max_plans_per_month + if len(plans) > max_plans: + plans = plans[:max_plans] + + return plans + + except Exception as e: + logger.error(f"解析月度计划响应时发生错误: {e}") + return [] + + async def archive_current_month_plans(self, target_month: Optional[str] = None): + """ + 归档当前月份的活跃计划 + + :param target_month: 目标月份,格式为 "YYYY-MM"。如果为 None,则为当前月份。 + """ + try: + if target_month is None: + target_month = datetime.now().strftime("%Y-%m") + + logger.info(f" 开始归档 {target_month} 的活跃月度计划...") + archived_count = archive_active_plans_for_month(target_month) + logger.info(f" 成功归档了 {archived_count} 条 {target_month} 的月度计划。") + + except Exception as e: + logger.error(f" 归档 {target_month} 月度计划时发生错误: {e}") + + +class MonthlyPlanGenerationTask(AsyncTask): + """每月初自动生成新月度计划的任务""" + + def __init__(self, monthly_plan_manager: MonthlyPlanManager): + super().__init__(task_name="MonthlyPlanGenerationTask") + self.monthly_plan_manager = monthly_plan_manager + + async def run(self): + while True: + try: + # 计算到下个月1号凌晨的时间 + now = datetime.now() + + # 获取下个月的第一天 + if now.month == 12: + next_month = datetime(now.year + 1, 1, 1) + else: + next_month = datetime(now.year, now.month + 1, 1) + + sleep_seconds = (next_month - now).total_seconds() + + logger.info(f" 下一次月度计划生成任务将在 {sleep_seconds:.2f} 秒后运行 (北京时间 {next_month.strftime('%Y-%m-%d %H:%M:%S')})") + + # 等待直到下个月1号 + await asyncio.sleep(sleep_seconds) + + # 先归档上个月的计划 + last_month = (next_month - timedelta(days=1)).strftime("%Y-%m") + await self.monthly_plan_manager.archive_current_month_plans(last_month) + + # 生成新月份的计划 + current_month = next_month.strftime("%Y-%m") + logger.info(f" 到达月初,开始生成 {current_month} 的月度计划...") + await self.monthly_plan_manager.generate_monthly_plans(current_month) + + except asyncio.CancelledError: + logger.info(" 每月月度计划生成任务被取消。") + break + except Exception as e: + logger.error(f" 每月月度计划生成任务发生未知错误: {e}") + # 发生错误后,等待1小时再重试,避免频繁失败 + await asyncio.sleep(3600) + + +# 全局实例 +monthly_plan_manager = MonthlyPlanManager() \ No newline at end of file diff --git a/src/schedule/plan_generator.py b/src/schedule/plan_generator.py deleted file mode 100644 index 6599cb7de..000000000 --- a/src/schedule/plan_generator.py +++ /dev/null @@ -1,116 +0,0 @@ -# mmc/src/schedule/plan_generator.py - -import orjson -from typing import List -from pydantic import BaseModel, ValidationError -from json_repair import repair_json - -from src.config.config import global_config, model_config -from src.llm_models.utils_model import LLMRequest -from src.common.logger import get_logger - -logger = get_logger("plan_generator") - -class PlanResponse(BaseModel): - """ - 用于验证月度计划LLM响应的Pydantic模型。 - """ - plans: List[str] - -class PlanGenerator: - """ - 负责生成月度计划。 - """ - - def __init__(self): - self.bot_personality = self._get_bot_personality() - task_config = model_config.model_task_config.get_task("monthly_plan_generator") - self.llm_request = LLMRequest(model_set=task_config, request_type="monthly_plan_generator") - - def _get_bot_personality(self) -> str: - """ - 从全局配置中获取Bot的人设描述。 - """ - core = global_config.personality.personality_core or "" - side = global_config.personality.personality_side or "" - identity = global_config.personality.identity or "" - return f"核心人设: {core}\n侧面人设: {side}\n身份设定: {identity}" - - def _build_prompt(self, year: int, month: int, count: int) -> str: - """ - 构建用于生成月度计划的Prompt。 - """ - prompt_template = f""" - 你是一个富有想象力的助手,你的任务是为一位虚拟角色生成月度计划。 - - **角色设定:** - --- - {self.bot_personality} - --- - - 请为即将到来的 **{year}年{month}月** 设计 **{count}** 个符合该角色身份的、独立的、积极向上的月度计划或小目标。 - - **要求:** - 1. 每个计划都应简短、清晰,用一两句话描述。 - 2. 语言风格必须自然、口语化,严格符合角色的性格设定。 - 3. 计划内容要具有创造性,避免陈词滥调。 - 4. 请以严格的JSON格式返回,格式为:{{"plans": ["计划一", "计划二", ...]}} - 5. 除了JSON对象,不要包含任何额外的解释、注释或前后导语。 - """ - return prompt_template.strip() - - async def generate_plans(self, year: int, month: int, count: int) -> List[str]: - """ - 调用LLM生成指定月份的计划。 - - :param year: 年份 - :param month: 月份 - :param count: 需要生成的计划数量 - :return: 生成的计划文本列表 - """ - try: - # 1. 构建Prompt - prompt = self._build_prompt(year, month, count) - logger.info(f"正在为 {year}-{month} 生成 {count} 个月度计划...") - - # 2. 调用LLM - llm_content, (reasoning, model_name, _) = await self.llm_request.generate_response_async(prompt=prompt) - - logger.info(f"使用模型 '{model_name}' 生成完成。") - if reasoning: - logger.debug(f"模型推理过程: {reasoning}") - - if not llm_content: - logger.error("LLM未能返回有效的计划内容。") - return [] - - # 3. 解析并验证LLM返回的JSON - try: - # 移除可能的Markdown代码块标记 - clean_content = llm_content.strip() - if clean_content.startswith("```json"): - clean_content = clean_content[7:] - if clean_content.endswith("```"): - clean_content = clean_content[:-3] - - # 修复并解析JSON - repaired_json_str = repair_json(clean_content) - data = orjson.loads(repaired_json_str) - - # 使用Pydantic进行验证 - validated_response = PlanResponse.model_validate(data) - plans = validated_response.plans - - logger.info(f"成功生成并验证了 {len(plans)} 个月度计划。") - return plans - - except orjson.JSONDecodeError: - logger.error(f"修复后仍然无法解析LLM返回的JSON: {llm_content}") - return [] - except ValidationError as e: - logger.error(f"LLM返回的JSON格式不符合预期: {e}\n原始响应: {llm_content}") - return [] - - except Exception as e: - logger.error(f"调用LLM生成月度计划时发生未知错误: {e}", exc_info=True) - return [] \ No newline at end of file diff --git a/src/manager/schedule_manager.py b/src/schedule/schedule_manager.py similarity index 64% rename from src/manager/schedule_manager.py rename to src/schedule/schedule_manager.py index 95cfbb0ee..82578046d 100644 --- a/src/manager/schedule_manager.py +++ b/src/schedule/schedule_manager.py @@ -1,5 +1,6 @@ import orjson import asyncio +import random from datetime import datetime, time, timedelta from typing import Optional, List, Dict, Any from lunar_python import Lunar @@ -15,6 +16,8 @@ from src.llm_models.utils_model import LLMRequest from src.common.logger import get_logger from json_repair import repair_json from src.manager.async_task_manager import AsyncTask, async_task_manager +from src.manager.local_store_manager import local_storage +from src.plugin_system.apis import send_api, generator_api logger = get_logger("schedule_manager") @@ -128,6 +131,17 @@ class ScheduleManager: self.sleep_log_interval = 35 # 日志记录间隔,单位秒 self.schedule_generation_running = False # 防止重复生成任务 + # 弹性睡眠相关状态 + self._is_preparing_sleep: bool = False + self._sleep_buffer_end_time: Optional[datetime] = None + self._total_delayed_minutes_today: int = 0 + self._last_sleep_check_date: Optional[datetime.date] = None + self._last_fully_slept_log_time: float = 0 + self._is_in_voluntary_delay: bool = False # 新增:标记是否处于主动延迟睡眠状态 + self._is_woken_up: bool = False # 新增:标记是否被吵醒 + + self._load_sleep_state() + async def start_daily_schedule_generation(self): """启动每日零点自动生成新日程的任务""" if not self.daily_task_started: @@ -226,7 +240,7 @@ class ScheduleManager: # 如果计划耗尽,则触发补充生成 if not sampled_plans: logger.info("可用的月度计划已耗尽或不足,尝试进行补充生成...") - from src.manager.monthly_plan_manager import monthly_plan_manager + from mmc.src.schedule.monthly_plan_manager import monthly_plan_manager success = await monthly_plan_manager.generate_monthly_plans(current_month_str) if success: logger.info("补充生成完成,重新抽取月度计划...") @@ -392,27 +406,118 @@ class ScheduleManager: continue return None - def is_sleeping(self, wakeup_manager=None) -> bool: + def is_sleeping(self, wakeup_manager: Optional["WakeUpManager"] = None) -> bool: """ - 通过关键词匹配检查当前是否处于休眠时间。 - - Args: - wakeup_manager: 可选的唤醒度管理器,用于检查是否被唤醒。 - - Returns: - bool: 是否处于休眠状态。 + 通过关键词匹配、唤醒度、睡眠压力等综合判断是否处于休眠时间。 + 新增弹性睡眠机制,允许在压力低时延迟入睡,并在入睡前发送通知。 """ + from src.chat.chat_loop.wakeup_manager import WakeUpManager + # --- 基础检查 --- if not global_config.schedule.enable_is_sleep: return False if not self.today_schedule: return False - # 从配置获取关键词,如果配置中没有则使用默认列表 - sleep_keywords = ["休眠", "睡觉", "梦乡",] - - now = datetime.now().time() + now = datetime.now() + today = now.date() - # 遍历当天的所有日程 + # --- 每日状态重置 --- + if self._last_sleep_check_date != today: + logger.info(f"新的一天 ({today}),重置弹性睡眠状态。") + self._total_delayed_minutes_today = 0 + self._is_preparing_sleep = False + self._sleep_buffer_end_time = None + self._last_sleep_check_date = today + self._is_in_voluntary_delay = False + self._save_sleep_state() + + # --- 检查是否在“准备入睡”的缓冲期 --- + if self._is_preparing_sleep and self._sleep_buffer_end_time: + if now >= self._sleep_buffer_end_time: + current_timestamp = now.timestamp() + if current_timestamp - self._last_fully_slept_log_time > 45: + logger.info("睡眠缓冲期结束,正式进入休眠状态。") + self._last_fully_slept_log_time = current_timestamp + return True + else: + remaining_seconds = (self._sleep_buffer_end_time - now).total_seconds() + logger.debug(f"处于入睡缓冲期,剩余 {remaining_seconds:.1f} 秒。") + return False + + # --- 判断当前是否为理论上的睡眠时间 --- + is_in_theoretical_sleep, activity = self._is_in_theoretical_sleep_time(now.time()) + + if not is_in_theoretical_sleep: + # 如果不在理论睡眠时间,确保重置准备状态 + if self._is_preparing_sleep: + logger.info("已离开理论休眠时间,取消“准备入睡”状态。") + self._is_preparing_sleep = False + self._sleep_buffer_end_time = None + self._is_in_voluntary_delay = False + self._is_woken_up = False # 离开睡眠时间,重置唤醒状态 + self._save_sleep_state() + return False + + # --- 处理唤醒状态 --- + if self._is_woken_up: + current_timestamp = now.timestamp() + if current_timestamp - self.last_sleep_log_time > self.sleep_log_interval: + logger.info(f"在休眠活动 '{activity}' 期间,但已被唤醒,保持清醒状态。") + self.last_sleep_log_time = current_timestamp + return False + + # --- 核心:弹性睡眠逻辑 --- + if global_config.schedule.enable_flexible_sleep and not self._is_preparing_sleep: + # 首次进入理论睡眠时间,触发弹性判断 + logger.info(f"进入理论休眠时间 '{activity}',开始弹性睡眠判断...") + + # 1. 获取睡眠压力 + sleep_pressure = wakeup_manager.context.sleep_pressure if wakeup_manager else 999 + pressure_threshold = global_config.schedule.flexible_sleep_pressure_threshold + + # 2. 判断是否延迟 + if sleep_pressure < pressure_threshold and self._total_delayed_minutes_today < global_config.schedule.max_sleep_delay_minutes: + delay_minutes = 15 # 每次延迟15分钟 + self._total_delayed_minutes_today += delay_minutes + self._sleep_buffer_end_time = now + timedelta(minutes=delay_minutes) + self._is_in_voluntary_delay = True # 标记进入主动延迟 + logger.info(f"睡眠压力 ({sleep_pressure:.1f}) 低于阈值 ({pressure_threshold}),延迟入睡 {delay_minutes} 分钟。今日已累计延迟 {self._total_delayed_minutes_today} 分钟。") + else: + # 3. 计算5-10分钟的入睡缓冲 + self._is_in_voluntary_delay = False # 非主动延迟 + buffer_seconds = random.randint(5 * 60, 10 * 60) + self._sleep_buffer_end_time = now + timedelta(seconds=buffer_seconds) + logger.info(f"睡眠压力正常或已达今日最大延迟,将在 {buffer_seconds / 60:.1f} 分钟内入睡。") + + # 4. 发送睡前通知 + if global_config.schedule.enable_pre_sleep_notification: + asyncio.create_task(self._send_pre_sleep_notification()) + + self._is_preparing_sleep = True + self._save_sleep_state() + return False # 进入准备阶段,但尚未正式入睡 + + # --- 经典模式或已在弹性睡眠流程中 --- + current_timestamp = now.timestamp() + if current_timestamp - self.last_sleep_log_time > self.sleep_log_interval: + logger.info(f"当前处于休眠活动 '{activity}' 中 (经典模式)。") + self.last_sleep_log_time = current_timestamp + return True + + def reset_sleep_state_after_wakeup(self): + """被唤醒后重置睡眠状态""" + if self._is_preparing_sleep or self.is_sleeping(): + logger.info("被唤醒,重置所有睡眠准备状态,恢复清醒!") + self._is_preparing_sleep = False + self._sleep_buffer_end_time = None + self._is_in_voluntary_delay = False + self._is_woken_up = True # 标记为已被唤醒 + self._save_sleep_state() + + def _is_in_theoretical_sleep_time(self, now_time: time) -> (bool, Optional[str]): + """检查当前时间是否落在日程表的任何一个睡眠活动中""" + sleep_keywords = ["休眠", "睡觉", "梦乡"] + for event in self.today_schedule: try: activity = event.get("activity", "").strip() @@ -421,47 +526,130 @@ class ScheduleManager: if not activity or not time_range: continue - # 1. 检查活动内容是否包含任一休眠关键词 if any(keyword in activity for keyword in sleep_keywords): - # 2. 如果包含,再检查当前时间是否在该时间段内 start_str, end_str = time_range.split('-') start_time = datetime.strptime(start_str.strip(), "%H:%M").time() end_time = datetime.strptime(end_str.strip(), "%H:%M").time() - is_in_time_range = False if start_time <= end_time: # 同一天 - if start_time <= now < end_time: - is_in_time_range = True + if start_time <= now_time < end_time: + return True, activity else: # 跨天 - if now >= start_time or now < end_time: - is_in_time_range = True - - # 如果时间匹配,则进入最终判断 - if is_in_time_range: - # 检查是否被唤醒 - if wakeup_manager and wakeup_manager.is_in_angry_state(): - current_timestamp = datetime.now().timestamp() - if current_timestamp - self.last_sleep_log_time > self.sleep_log_interval: - logger.info(f"在休眠活动 '{activity}' 期间,但已被唤醒。") - self.last_sleep_log_time = current_timestamp - else: - logger.debug(f"在休眠活动 '{activity}' 期间,但已被唤醒。") - return False - - current_timestamp = datetime.now().timestamp() - if current_timestamp - self.last_sleep_log_time > self.sleep_log_interval: - logger.info(f"当前处于休眠活动 '{activity}' 中。") - self.last_sleep_log_time = current_timestamp - else: - logger.debug(f"当前处于休眠活动 '{activity}' 中。") - return True # 找到匹配的休眠活动,直接返回True - + if now_time >= start_time or now_time < end_time: + return True, activity except (ValueError, KeyError, AttributeError) as e: logger.warning(f"解析日程事件时出错: {event}, 错误: {e}") continue + + return False, None + + async def _send_pre_sleep_notification(self): + """异步生成并发送睡前通知""" + try: + groups = global_config.schedule.pre_sleep_notification_groups + prompt = global_config.schedule.pre_sleep_prompt + + if not groups: + logger.info("未配置睡前通知的群组,跳过发送。") + return + + if not prompt: + logger.warning("睡前通知的prompt为空,跳过发送。") + return + + # 为防止消息风暴,稍微延迟一下 + await asyncio.sleep(random.uniform(5, 15)) + + for group_id_str in groups: + try: + # 格式 "platform:group_id" + parts = group_id_str.split(":") + if len(parts) != 2: + logger.warning(f"无效的群组ID格式: {group_id_str}") + continue + + platform, group_id = parts + + # 使用与 ChatStream.get_stream_id 相同的逻辑生成 stream_id + import hashlib + key = "_".join([platform, group_id]) + stream_id = hashlib.md5(key.encode()).hexdigest() + + logger.info(f"正在为群组 {group_id_str} (Stream ID: {stream_id}) 生成睡前消息...") + + # 调用 generator_api 生成回复 + success, reply_set, _ = await generator_api.generate_reply( + chat_id=stream_id, + extra_info=prompt, + request_type="schedule.pre_sleep_notification" + ) + + if success and reply_set: + # 提取文本内容并发送 + reply_text = "".join([content for msg_type, content in reply_set if msg_type == "text"]) + if reply_text: + logger.info(f"向群组 {group_id_str} 发送睡前消息: {reply_text}") + await send_api.text_to_stream(text=reply_text, stream_id=stream_id) + else: + logger.warning(f"为群组 {group_id_str} 生成的回复内容为空。") + else: + logger.error(f"为群组 {group_id_str} 生成睡前消息失败。") + + await asyncio.sleep(random.uniform(2, 5)) # 避免发送过快 + + except Exception as e: + logger.error(f"向群组 {group_id_str} 发送睡前消息失败: {e}") + + except Exception as e: + logger.error(f"发送睡前通知任务失败: {e}") + + def _save_sleep_state(self): + """将当前弹性睡眠状态保存到本地存储""" + try: + state = { + "is_preparing_sleep": self._is_preparing_sleep, + "sleep_buffer_end_time_ts": self._sleep_buffer_end_time.timestamp() if self._sleep_buffer_end_time else None, + "total_delayed_minutes_today": self._total_delayed_minutes_today, + "last_sleep_check_date_str": self._last_sleep_check_date.isoformat() if self._last_sleep_check_date else None, + "is_in_voluntary_delay": self._is_in_voluntary_delay, + "is_woken_up": self._is_woken_up, + } + local_storage["schedule_sleep_state"] = state + logger.debug(f"已保存睡眠状态: {state}") + except Exception as e: + logger.error(f"保存睡眠状态失败: {e}") + + def _load_sleep_state(self): + """从本地存储加载弹性睡眠状态""" + try: + state = local_storage["schedule_sleep_state"] + if state and isinstance(state, dict): + self._is_preparing_sleep = state.get("is_preparing_sleep", False) - # 遍历完所有日程都未找到匹配的休眠活动 - return False + end_time_ts = state.get("sleep_buffer_end_time_ts") + if end_time_ts: + self._sleep_buffer_end_time = datetime.fromtimestamp(end_time_ts) + + self._total_delayed_minutes_today = state.get("total_delayed_minutes_today", 0) + self._is_in_voluntary_delay = state.get("is_in_voluntary_delay", False) + self._is_woken_up = state.get("is_woken_up", False) + + date_str = state.get("last_sleep_check_date_str") + if date_str: + self._last_sleep_check_date = datetime.fromisoformat(date_str).date() + + logger.info(f"成功从本地存储加载睡眠状态: {state}") + except Exception as e: + logger.warning(f"加载睡眠状态失败,将使用默认值: {e}") + + def reset_wakeup_state(self): + """重置被唤醒的状态,允许重新尝试入睡""" + if self._is_woken_up: + logger.info("重置唤醒状态,将重新尝试入睡。") + self._is_woken_up = False + self._is_preparing_sleep = False # 允许重新进入弹性睡眠判断 + self._sleep_buffer_end_time = None + self._save_sleep_state() def _validate_schedule_with_pydantic(self, schedule_data) -> bool: """使用Pydantic验证日程数据格式和完整性""" diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 24fe3dd0a..d3b997526 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,7 +1,7 @@ [inner] -version = "6.5.2" +version = "6.5.7" -#----以下是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- +#----以下是给开发人员阅读的,如果你只是部署了MoFox-Bot,不需要阅读---- #如果你想要修改配置文件,请递增version的值 #如果新增项目,请阅读src/config/official_configs.py中的说明 # @@ -9,7 +9,7 @@ version = "6.5.2" # 主版本号:MMC版本更新 # 次版本号:配置文件内容大更新 # 修订号:配置文件内容小更新 -#----以上是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- +#----以上是给开发人员阅读的,如果你只是部署了MoFox-Bot,不需要阅读---- [database]# 数据库配置 database_type = "sqlite" # 数据库类型,支持 "sqlite" 或 "mysql" @@ -44,15 +44,16 @@ connection_timeout = 10 # 连接超时时间(秒) # Master用户配置(拥有最高权限,无视所有权限节点) # 格式:[[platform, user_id], ...] # 示例:[["qq", "123456"], ["telegram", "user789"]] -master_users = [ - # ["qq", "123456789"], # 示例:QQ平台的Master用户 -] +master_users = []# ["qq", "123456789"], # 示例:QQ平台的Master用户 [bot] platform = "qq" -qq_account = 1145141919810 # 麦麦的QQ账号 -nickname = "麦麦" # 麦麦的昵称 -alias_names = ["麦叠", "牢麦"] # 麦麦的别名 +qq_account = 1145141919810 # MoFox-Bot的QQ账号 +nickname = "MoFox-Bot" # MoFox-Bot的昵称 +alias_names = ["麦叠", "牢麦"] # MoFox-Bot的别名 + +[command] +command_prefixes = ['/', '!', '.', '#'] [personality] # 建议50字以内,描述人格的核心特质 @@ -63,7 +64,7 @@ personality_side = "用一句话或几句话描述人格的侧面特质" # 可以描述外貌,性别,身高,职业,属性等等描述 identity = "年龄为19岁,是女孩子,身高为160cm,有黑色的短发" -# 描述麦麦说话的表达风格,表达习惯,如要修改,可以酌情新增内容 +# 描述MoFox-Bot说话的表达风格,表达习惯,如要修改,可以酌情新增内容 reply_style = "回复可以简短一些。可以参考贴吧,知乎和微博的回复风格,回复不要浮夸,不要用夸张修辞,平淡一些。" #回复的Prompt模式选择:s4u为原有s4u样式,normal为0.9之前的模式 @@ -74,32 +75,46 @@ compress_identity = true # 是否压缩身份,压缩后会精简身份信息 [expression] # 表达学习配置 -expression_learning = [ # 表达学习配置列表,支持按聊天流配置 - ["", "enable", "enable", 1.0], # 全局配置:使用表达,启用学习,学习强度1.0 - ["qq:1919810:group", "enable", "enable", 1.5], # 特定群聊配置:使用表达,启用学习,学习强度1.5 - ["qq:114514:private", "enable", "disable", 0.5], # 特定私聊配置:使用表达,禁用学习,学习强度0.5 - # 格式说明: - # 第一位: chat_stream_id,空字符串表示全局配置 - # 第二位: 是否使用学到的表达 ("enable"/"disable") - # 第三位: 是否学习表达 ("enable"/"disable") - # 第四位: 学习强度(浮点数),影响学习频率,最短学习时间间隔 = 300/学习强度(秒) - # 学习强度越高,学习越频繁;学习强度越低,学习越少 -] +# rules是一个列表,每个元素都是一个学习规则 +# chat_stream_id: 聊天流ID,格式为 "platform:id:type",例如 "qq:123456:private"。空字符串""表示全局配置 +# use_expression: 是否使用学到的表达 (true/false) +# learn_expression: 是否学习新的表达 (true/false) +# learning_strength: 学习强度(浮点数),影响学习频率 +# group: 表达共享组的名称(字符串),相同组的聊天会共享学习到的表达方式 +[[expression.rules]] +chat_stream_id = "" +use_expression = true +learn_expression = true +learning_strength = 1.0 -expression_groups = [ - ["qq:1919810:private","qq:114514:private","qq:1111111:group"], # 在这里设置互通组,相同组的chat_id会共享学习到的表达方式 - # 格式:["qq:123456:private","qq:654321:group"] - # 注意:如果为群聊,则需要设置为group,如果设置为私聊,则需要设置为private -] +[[expression.rules]] +chat_stream_id = "qq:1919810:group" +use_expression = true +learn_expression = true +learning_strength = 1.5 + +[[expression.rules]] +chat_stream_id = "qq:114514:private" +group = "group_A" +use_expression = true +learn_expression = false +learning_strength = 0.5 + +[[expression.rules]] +chat_stream_id = "qq:1919810:private" +group = "group_A" +use_expression = true +learn_expression = true +learning_strength = 1.0 -[chat] #麦麦的聊天通用设置 +[chat] #MoFox-Bot的聊天通用设置 focus_value = 1 -# 麦麦的专注思考能力,越高越容易专注,可能消耗更多token +# MoFox-Bot的专注思考能力,越高越容易专注,可能消耗更多token # 专注时能更好把握发言时机,能够进行持久的连续对话 -talk_frequency = 1 # 麦麦活跃度,越高,麦麦回复越频繁 +talk_frequency = 1 # MoFox-Bot活跃度,越高,MoFox-Bot回复越频繁 # 强制私聊专注模式 force_focus_private = false # 是否强制私聊进入专注模式,开启后私聊将始终保持专注状态 @@ -109,7 +124,7 @@ group_chat_mode = "auto" # 群聊聊天模式:auto-自动切换,normal-强 max_context_size = 25 # 上下文长度 -thinking_timeout = 40 # 麦麦一次回复最长思考规划时间,超过这个时间的思考会放弃(往往是api反应太慢) +thinking_timeout = 40 # MoFox-Bot一次回复最长思考规划时间,超过这个时间的思考会放弃(往往是api反应太慢) replyer_random_probability = 0.5 # 首要replyer模型被选择的概率 mentioned_bot_inevitable_reply = true # 提及 bot 必然回复 @@ -166,7 +181,7 @@ delta_sigma = 120 # 正态分布的标准差,控制时间间隔的随机程度 [relationship] enable_relationship = true # 是否启用关系系统 -relation_frequency = 1 # 关系频率,麦麦构建关系的频率 +relation_frequency = 1 # 关系频率,MoFox-Bot构建关系的频率 [message_receive] @@ -213,35 +228,40 @@ willing_mode = "classical" # 回复意愿模式 —— 经典模式:classical [tool] enable_tool = true # 是否在普通聊天中启用工具 +[tool.history] +enable_history = true # 是否启用工具调用历史记录 +enable_prompt_history = true # 是否在提示词中加入工具历史记录 +max_history = 5 # 每个会话最多保留的历史记录数 + [mood] enable_mood = true # 是否启用情绪系统 mood_update_threshold = 1 # 情绪更新阈值,越高,更新越慢 [emoji] -emoji_chance = 0.6 # 麦麦激活表情包动作的概率 +emoji_chance = 0.6 # MoFox-Bot激活表情包动作的概率 emoji_activate_type = "llm" # 表情包激活类型,可选:random,llm ; random下,表情包动作随机启用,llm下,表情包动作根据llm判断是否启用 max_reg_num = 60 # 表情包最大注册数量 do_replace = true # 开启则在达到最大数量时删除(替换)表情包,关闭则达到最大数量时不会继续收集表情包 check_interval = 10 # 检查表情包(注册,破损,删除)的时间间隔(分钟) -steal_emoji = true # 是否偷取表情包,让麦麦可以将一些表情包据为己有 +steal_emoji = true # 是否偷取表情包,让MoFox-Bot可以将一些表情包据为己有 content_filtration = false # 是否启用表情包过滤,只有符合该要求的表情包才会被保存 filtration_prompt = "符合公序良俗" # 表情包过滤要求,只有符合该要求的表情包才会被保存 enable_emotion_analysis = false # 是否启用表情包感情关键词二次识别,启用后表情包在第一次识别完毕后将送入第二次大模型识别来总结感情关键词,并构建进回复和决策器的上下文消息中 [memory] enable_memory = true # 是否启用记忆系统 -memory_build_interval = 600 # 记忆构建间隔 单位秒 间隔越低,麦麦学习越多,但是冗余信息也会增多 +memory_build_interval = 600 # 记忆构建间隔 单位秒 间隔越低,MoFox-Bot学习越多,但是冗余信息也会增多 memory_build_distribution = [6.0, 3.0, 0.6, 32.0, 12.0, 0.4] # 记忆构建分布,参数:分布1均值,标准差,权重,分布2均值,标准差,权重 memory_build_sample_num = 8 # 采样数量,数值越高记忆采样次数越多 memory_build_sample_length = 30 # 采样长度,数值越高一段记忆内容越丰富 memory_compress_rate = 0.1 # 记忆压缩率 控制记忆精简程度 建议保持默认,调高可以获得更多信息,但是冗余信息也会增多 -forget_memory_interval = 3000 # 记忆遗忘间隔 单位秒 间隔越低,麦麦遗忘越频繁,记忆更精简,但更难学习 +forget_memory_interval = 3000 # 记忆遗忘间隔 单位秒 间隔越低,MoFox-Bot遗忘越频繁,记忆更精简,但更难学习 memory_forget_time = 48 #多长时间后的记忆会被遗忘 单位小时 memory_forget_percentage = 0.008 # 记忆遗忘比例 控制记忆遗忘程度 越大遗忘越多 建议保持默认 -consolidate_memory_interval = 1000 # 记忆整合间隔 单位秒 间隔越低,麦麦整合越频繁,记忆更精简 +consolidate_memory_interval = 1000 # 记忆整合间隔 单位秒 间隔越低,MoFox-Bot整合越频繁,记忆更精简 consolidation_similarity_threshold = 0.7 # 相似度阈值 consolidation_check_percentage = 0.05 # 检查节点比例 @@ -253,7 +273,7 @@ enable_vector_instant_memory = true # 是否启用基于向量的瞬时记忆 memory_ban_words = [ "表情包", "图片", "回复", "聊天记录" ] [voice] -enable_asr = false # 是否启用语音识别,启用后麦麦可以识别语音消息,启用该功能需要配置语音识别模型[model.voice]s +enable_asr = false # 是否启用语音识别,启用后MoFox-Bot可以识别语音消息,启用该功能需要配置语音识别模型[model.voice]s [lpmm_knowledge] # lpmm知识库配置 enable = false # 是否启用lpmm知识库 @@ -358,6 +378,21 @@ guidelines = """ """ enable_is_sleep = false +# --- 弹性睡眠与睡前消息 --- +# 是否启用弹性睡眠。启用后,AI不会到点立刻入睡,而是会根据睡眠压力增加5-10分钟的缓冲,并可能因为压力不足而推迟睡眠。 +enable_flexible_sleep = true +# 触发弹性睡眠的睡眠压力阈值。当AI的睡眠压力低于此值时,可能会推迟入睡。 +flexible_sleep_pressure_threshold = 40.0 +# 每日最大可推迟入睡的总分钟数。 +max_sleep_delay_minutes = 60 + +# 是否在进入“准备入睡”状态时发送一条消息通知。 +enable_pre_sleep_notification = true +# 接收睡前消息的群组列表。格式为: ["platform:group_id1", "platform:group_id2"],例如 ["qq:12345678"] +pre_sleep_notification_groups = [] +# 用于生成睡前消息的提示。AI会根据这个提示生成一句晚安问候。 +pre_sleep_prompt = "我准备睡觉了,请生成一句简短自然的晚安问候。" + [video_analysis] # 视频分析配置 enable = true # 是否启用视频分析功能 analysis_mode = "batch_frames" # 分析模式:"frame_by_frame"(逐帧分析,非常慢 "建议frames大于8时不要使用这个" ...但是详细)、"batch_frames"(批量分析,快但可能略简单 -其实效果也差不多)或 "auto"(自动选择) @@ -422,7 +457,7 @@ guidelines = """ """ [wakeup_system] -enable = true #"是否启用唤醒度系统" +enable = false #"是否启用唤醒度系统" wakeup_threshold = 15.0 #唤醒阈值,达到此值时会被唤醒" private_message_increment = 3.0 #"私聊消息增加的唤醒度" group_mention_increment = 2.0 #"群聊艾特增加的唤醒度" @@ -431,6 +466,22 @@ decay_interval = 30.0 #"唤醒度衰减间隔(秒)" angry_duration = 300.0 #"愤怒状态持续时间(秒)" angry_prompt = "你被人吵醒了非常生气,说话带着怒气" # "被吵醒后的愤怒提示词" +# --- 失眠机制相关参数 --- +enable_insomnia_system = false # 是否启用失眠系统 +# 触发“压力不足型失眠”的睡眠压力阈值 +sleep_pressure_threshold = 30.0 +# 进入“深度睡眠”的睡眠压力阈值 +deep_sleep_threshold = 80.0 +# 压力不足时的失眠基础概率 (0.0 to 1.0) +insomnia_chance_low_pressure = 0.6 +# 压力正常时的失眠基础概率 (0.0 to 1.0) +insomnia_chance_normal_pressure = 0.1 +# 每次AI执行动作后,增加的睡眠压力值 +sleep_pressure_increment = 1.5 +# 睡眠时,每分钟衰减的睡眠压力值 +sleep_pressure_decay_rate = 1.5 +insomnia_duration_minutes = 30 # 单次失眠状态的持续时间(分钟) + [cross_context] # 跨群聊上下文共享配置 # 这是总开关,用于一键启用或禁用此功能 enable = false