Merge branch 'master' of https://github.com/MoFox-Studio/MoFox_Bot
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
124
docs/deployment_guide.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# MoFox_Bot 部署指南
|
||||
|
||||
欢迎使用 MoFox_Bot!本指南将引导您完成在 Windows 环境下部署 MoFox_Bot 的全部过程。
|
||||
|
||||
## 1. 系统要求
|
||||
|
||||
- **操作系统**: Windows 10 或 Windows 11
|
||||
- **Python**: 版本 >= 3.10
|
||||
- **Git**: 用于克隆项目仓库
|
||||
- **uv**: 推荐的 Python 包管理器 (版本 >= 0.1.0)
|
||||
|
||||
## 2. 部署步骤
|
||||
|
||||
### 第一步:获取必要的文件
|
||||
|
||||
首先,创建一个用于存放 MoFox_Bot 相关文件的文件夹,并通过 `git` 克隆 MoFox_Bot 主程序和 Napcat 适配器。
|
||||
|
||||
```shell
|
||||
mkdir MoFox_Bot_Deployment
|
||||
cd MoFox_Bot_Deployment
|
||||
git clone hhttps://github.com/MoFox-Studio/MoFox_Bot.git
|
||||
git clone https://github.com/MoFox-Studio/Napcat-Adapter.git
|
||||
```
|
||||
|
||||
### 第二步:环境配置
|
||||
|
||||
我们推荐使用 `uv` 来管理 Python 环境和依赖,因为它提供了更快的安装速度和更好的依赖管理体验。
|
||||
|
||||
**安装 uv:**
|
||||
|
||||
```shell
|
||||
pip install uv
|
||||
```
|
||||
|
||||
### 第三步:依赖安装
|
||||
|
||||
**1. 安装 MoFox_Bot 依赖:**
|
||||
|
||||
进入 `mmc` 文件夹,创建虚拟环境并安装依赖。
|
||||
|
||||
```shell
|
||||
cd mmc
|
||||
uv venv
|
||||
uv pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple --upgrade
|
||||
```
|
||||
|
||||
**2. 安装 Napcat-Adapter 依赖:**
|
||||
|
||||
回到上一级目录,进入 `Napcat-Adapter` 文件夹,创建虚拟环境并安装依赖。
|
||||
|
||||
```shell
|
||||
cd ..
|
||||
cd Napcat-Adapter
|
||||
uv venv
|
||||
uv pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple --upgrade
|
||||
```
|
||||
|
||||
### 第四步:配置 MoFox_Bot 和 Adapter
|
||||
|
||||
**1. MoFox_Bot 配置:**
|
||||
|
||||
- 在 `mmc` 文件夹中,将 `template/bot_config_template.toml` 复制到 `config/bot_config.toml`。
|
||||
- 将 `template/model_config_template.toml` 复制到 `config/model_config.toml`。
|
||||
- 根据 [模型配置指南](guides/model_configuration_guide.md) 和 `bot_config.toml` 文件中的注释,填写您的 API Key 和其他相关配置。
|
||||
|
||||
**2. Napcat-Adapter 配置:**
|
||||
|
||||
- 在 `Napcat-Adapter` 文件夹中,将 `template/template_config.toml` 复制到根目录并改名为 `config.toml`。
|
||||
- 打开 `config.toml` 文件,配置 `[Napcat_Server]` 和 `[MaiBot_Server]` 字段。
|
||||
- `[Napcat_Server]` 的 `port` 应与 Napcat 设置的反向代理 URL 中的端口相同。
|
||||
- `[MaiBot_Server]` 的 `port` 应与 MoFox_Bot 的 `bot_config.toml` 中设置的端口相同。
|
||||
|
||||
### 第五步:运行
|
||||
|
||||
**1. 启动 Napcat:**
|
||||
|
||||
请参考 [NapCatQQ 文档](https://napcat-qq.github.io/) 进行部署和启动。
|
||||
|
||||
**2. 启动 MoFox_Bot:**
|
||||
|
||||
进入 `mmc` 文件夹,使用 `uv` 运行。
|
||||
|
||||
```shell
|
||||
cd mmc
|
||||
uv run python bot.py
|
||||
```
|
||||
|
||||
**3. 启动 Napcat-Adapter:**
|
||||
|
||||
打开一个新的终端窗口,进入 `Napcat-Adapter` 文件夹,使用 `uv` 运行。
|
||||
|
||||
```shell
|
||||
cd Napcat-Adapter
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
至此,MoFox_Bot 已成功部署并运行。
|
||||
|
||||
## 3. 详细配置说明
|
||||
|
||||
### `bot_config.toml`
|
||||
|
||||
这是 MoFox_Bot 的主配置文件,包含了机器人昵称、主人QQ、命令前缀、数据库设置等。请根据文件内的注释进行详细配置。
|
||||
|
||||
### `model_config.toml`
|
||||
|
||||
此文件用于配置 AI 模型和 API 服务提供商。详细配置方法请参考 [模型配置指南](guides/model_configuration_guide.md)。
|
||||
|
||||
### 插件配置
|
||||
|
||||
每个插件都有独立的配置文件,位于 `mmc/config/plugins/` 目录下。插件的配置由其 `config_schema` 自动生成。详细信息请参考 [插件配置完整指南](plugins/configuration-guide.md)。
|
||||
|
||||
## 4. 故障排除
|
||||
|
||||
- **依赖安装失败**:
|
||||
- 尝试更换 PyPI 镜像源。
|
||||
- 检查网络连接。
|
||||
- **API 调用失败**:
|
||||
- 检查 `model_config.toml` 中的 API Key 和 `base_url` 是否正确。
|
||||
- **无法连接到 Napcat**:
|
||||
- 检查 Napcat 是否正常运行。
|
||||
- 确认 `Napcat-Adapter` 的 `config.toml` 中 `[Napcat_Server]` 的 `port` 是否与 Napcat 设置的端口一致。
|
||||
|
||||
如果遇到其他问题,请查看 `logs/` 目录下的日志文件以获取详细的错误信息。
|
||||
@@ -43,12 +43,11 @@ retry_interval = 10 # 重试间隔(秒)
|
||||
| `name` | ✅ | 服务商名称,需要在模型配置中引用 | - |
|
||||
| `base_url` | ✅ | API服务的基础URL | - |
|
||||
| `api_key` | ✅ | API密钥,请替换为实际密钥 | - |
|
||||
| `client_type` | ❌ | 客户端类型:`openai`(OpenAI格式)或 `gemini`(Gemini格式,现在支持不良好) | `openai` |
|
||||
| `client_type` | ❌ | 客户端类型:`openai`、`gemini` 或 `aiohttp_gemini` | `openai` |
|
||||
| `max_retry` | ❌ | API调用失败时的最大重试次数 | 2 |
|
||||
| `timeout` | ❌ | API请求超时时间(秒) | 30 |
|
||||
| `retry_interval` | ❌ | 重试间隔时间(秒) | 10 |
|
||||
|
||||
**请注意,对于`client_type`为`gemini`的模型,`base_url`字段无效。**
|
||||
### 2.3 支持的服务商示例
|
||||
|
||||
#### DeepSeek
|
||||
@@ -73,9 +72,9 @@ client_type = "openai"
|
||||
```toml
|
||||
[[api_providers]]
|
||||
name = "Google"
|
||||
base_url = "https://api.google.com/v1"
|
||||
base_url = "https://generativelanguage.googleapis.com/v1beta" # 在MoFox-Bot中, 使用aiohttp_gemini客户端的提供商可以自定义base_url
|
||||
api_key = "your-google-api-key"
|
||||
client_type = "gemini" # 注意:Gemini需要使用特殊客户端
|
||||
client_type = "aiohttp_gemini" # 注意:Gemini需要使用特殊客户端
|
||||
```
|
||||
|
||||
## 3. 模型配置
|
||||
@@ -118,11 +117,11 @@ enable_thinking = false # 禁用思考
|
||||
|
||||
比如上面就是参考SiliconFlow的文档配置配置的`Qwen3`禁用思考参数。
|
||||
|
||||

|
||||

|
||||
|
||||
以豆包文档为另一个例子
|
||||
|
||||

|
||||

|
||||
|
||||
得到豆包`"doubao-seed-1-6-250615"`的禁用思考配置方法为
|
||||
```toml
|
||||
@@ -133,7 +132,6 @@ thinking = {type = "disabled"} # 禁用思考
|
||||
```
|
||||
请注意,`extra_params` 的配置应该构成一个合法的TOML字典结构,具体内容取决于API服务商的要求。
|
||||
|
||||
**请注意,对于`client_type`为`gemini`的模型,此字段无效。**
|
||||
### 3.3 配置参数说明
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
@@ -145,6 +143,7 @@ thinking = {type = "disabled"} # 禁用思考
|
||||
| `price_out` | ❌ | 输出价格(元/M token),用于成本统计 |
|
||||
| `force_stream_mode` | ❌ | 是否强制使用流式输出 |
|
||||
| `extra_params` | ❌ | 额外的模型参数配置 |
|
||||
| `anti_truncation` | ❌ | 是否启用反截断功能 |
|
||||
|
||||
## 4. 模型任务配置
|
||||
|
||||
@@ -184,7 +183,7 @@ max_tokens = 800
|
||||
```
|
||||
|
||||
### planner - 决策模型
|
||||
负责决定MaiBot该做什么:
|
||||
负责决定MoFox_Bot该做什么:
|
||||
```toml
|
||||
[model_task_config.planner]
|
||||
model_list = ["siliconflow-deepseek-v3"]
|
||||
@@ -193,7 +192,7 @@ max_tokens = 800
|
||||
```
|
||||
|
||||
### emotion - 情绪模型
|
||||
负责MaiBot的情绪变化:
|
||||
负责MoFox_Bot的情绪变化:
|
||||
```toml
|
||||
[model_task_config.emotion]
|
||||
model_list = ["siliconflow-deepseek-v3"]
|
||||
@@ -262,6 +261,44 @@ temperature = 0.7
|
||||
max_tokens = 800
|
||||
```
|
||||
|
||||
### schedule_generator - 日程生成模型
|
||||
```toml
|
||||
[model_task_config.schedule_generator]
|
||||
model_list = ["deepseek-v3"]
|
||||
temperature = 0.5
|
||||
max_tokens = 1024
|
||||
```
|
||||
|
||||
### monthly_plan_generator - 月度计划生成模型
|
||||
```toml
|
||||
[model_task_config.monthly_plan_generator]
|
||||
model_list = ["deepseek-v3"]
|
||||
temperature = 0.7
|
||||
max_tokens = 1024
|
||||
```
|
||||
|
||||
### emoji_vlm - 表情包VLM模型
|
||||
```toml
|
||||
[model_task_config.emoji_vlm]
|
||||
model_list = ["qwen-vl-max"]
|
||||
max_tokens = 800
|
||||
```
|
||||
|
||||
### anti_injection - 反注入模型
|
||||
```toml
|
||||
[model_task_config.anti_injection]
|
||||
model_list = ["deepseek-v3"]
|
||||
temperature = 0.1
|
||||
max_tokens = 512
|
||||
```
|
||||
|
||||
### utils_video - 视频分析模型
|
||||
```toml
|
||||
[model_task_config.utils_video]
|
||||
model_list = ["qwen-vl-max"]
|
||||
max_tokens = 800
|
||||
```
|
||||
|
||||
## 5. 配置建议
|
||||
|
||||
### 5.1 Temperature 参数选择
|
||||
@@ -276,7 +313,7 @@ max_tokens = 800
|
||||
|
||||
| 任务类型 | 推荐模型类型 | 示例 |
|
||||
|----------|--------------|------|
|
||||
| 高精度任务 | 大模型 | DeepSeek-V3, GPT-4 |
|
||||
| 高精度任务 | 大模型 | DeepSeek-V3, GPT-5,Gemini-2.5-Pro |
|
||||
| 高频率任务 | 小模型 | Qwen3-8B |
|
||||
| 多模态任务 | 专用模型 | Qwen2.5-VL, SenseVoice |
|
||||
| 工具调用 | 支持Function Call的模型 | Qwen3-14B |
|
||||
@@ -285,7 +322,6 @@ max_tokens = 800
|
||||
|
||||
1. **分层使用**:核心功能使用高质量模型,辅助功能使用经济模型
|
||||
2. **合理配置max_tokens**:根据实际需求设置,避免浪费
|
||||
3. **选择免费模型**:对于测试环境,优先使用price为0的模型
|
||||
|
||||
## 6. 配置验证
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# 💻 Command组件详解
|
||||
|
||||
## 📖 什么是Command
|
||||
|
||||
Command是直接响应用户明确指令的组件,与Action不同,Command是**被动触发**的,当用户输入特定格式的命令时立即执行。
|
||||
|
||||
Command通过正则表达式匹配用户输入,提供确定性的功能服务。
|
||||
|
||||
### 🎯 Command的特点
|
||||
|
||||
- 🎯 **确定性执行**:匹配到命令立即执行,无随机性
|
||||
- ⚡ **即时响应**:用户主动触发,快速响应
|
||||
- 🔍 **正则匹配**:通过正则表达式精确匹配用户输入
|
||||
- 🛑 **拦截控制**:可以控制是否阻止消息继续处理
|
||||
- 📝 **参数解析**:支持从用户输入中提取参数
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Command组件的基本结构
|
||||
|
||||
首先,Command组件需要继承自`BaseCommand`类,并实现必要的方法。
|
||||
|
||||
```python
|
||||
class ExampleCommand(BaseCommand):
|
||||
command_name = "example" # 命令名称,作为唯一标识符
|
||||
command_description = "这是一个示例命令" # 命令描述
|
||||
command_pattern = r"" # 命令匹配的正则表达式
|
||||
|
||||
async def execute(self) -> Tuple[bool, Optional[str], bool]:
|
||||
"""
|
||||
执行Command的主要逻辑
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str, bool]:
|
||||
- 第一个bool表示是否成功执行
|
||||
- 第二个str是执行结果消息
|
||||
- 第三个bool表示是否需要阻止消息继续处理
|
||||
"""
|
||||
# ---- 执行命令的逻辑 ----
|
||||
return True, "执行成功", False
|
||||
```
|
||||
**`command_pattern`**: 该Command匹配的正则表达式,用于精确匹配用户输入。
|
||||
|
||||
请注意:如果希望能获取到命令中的参数,请在正则表达式中使用有命名的捕获组,例如`(?P<param_name>pattern)`。
|
||||
|
||||
这样在匹配时,内部实现可以使用`re.match.groupdict()`方法获取到所有捕获组的参数,并以字典的形式存储在`self.matched_groups`中。
|
||||
|
||||
### 匹配样例
|
||||
假设我们有一个命令`/example param1=value1 param2=value2`,对应的正则表达式可以是:
|
||||
|
||||
```python
|
||||
class ExampleCommand(BaseCommand):
|
||||
command_name = "example"
|
||||
command_description = "这是一个示例命令"
|
||||
command_pattern = r"/example (?P<param1>\w+) (?P<param2>\w+)"
|
||||
|
||||
async def execute(self) -> Tuple[bool, Optional[str], bool]:
|
||||
# 获取匹配的参数
|
||||
param1 = self.matched_groups.get("param1")
|
||||
param2 = self.matched_groups.get("param2")
|
||||
|
||||
# 执行逻辑
|
||||
return True, f"参数1: {param1}, 参数2: {param2}", False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command 内置方法说明
|
||||
```python
|
||||
class BaseCommand:
|
||||
def get_config(self, key: str, default=None):
|
||||
"""获取插件配置值,使用嵌套键访问"""
|
||||
|
||||
async def send_text(self, content: str, reply_to: str = "") -> bool:
|
||||
"""发送回复消息"""
|
||||
|
||||
async def send_type(self, message_type: str, content: str, display_message: str = "", typing: bool = False, reply_to: str = "") -> bool:
|
||||
"""发送指定类型的回复消息到当前聊天环境"""
|
||||
|
||||
async def send_command(self, command_name: str, args: Optional[dict] = None, display_message: str = "", storage_message: bool = True) -> bool:
|
||||
"""发送命令消息"""
|
||||
|
||||
async def send_emoji(self, emoji_base64: str) -> bool:
|
||||
"""发送表情包"""
|
||||
|
||||
async def send_image(self, image_base64: str) -> bool:
|
||||
"""发送图片"""
|
||||
```
|
||||
具体参数与用法参见`BaseCommand`基类的定义。
|
||||
@@ -9,7 +9,7 @@
|
||||
## 组件功能详解
|
||||
|
||||
- [🧱 Action组件详解](action-components.md) - 掌握最核心的Action组件
|
||||
- [💻 Command组件详解](command-components.md) - 学习直接响应命令的组件
|
||||
- [💻 Command组件详解](PLUS_COMMAND_GUIDE.md) - 学习直接响应命令的组件
|
||||
- [🔧 Tool组件详解](tool-components.md) - 了解如何扩展信息获取能力
|
||||
- [⚙️ 配置文件系统指南](configuration-guide.md) - 学会使用自动生成的插件配置文件
|
||||
- [📄 Manifest系统指南](manifest-guide.md) - 了解插件元数据管理和配置架构
|
||||
|
||||
@@ -90,7 +90,7 @@ class HelloWorldPlugin(BasePlugin):
|
||||
|
||||
在日志中你应该能看到插件被加载的信息。虽然插件还没有任何功能,但它已经成功运行了!
|
||||
|
||||

|
||||

|
||||
|
||||
### 5. 添加第一个功能:问候Action
|
||||
|
||||
@@ -180,7 +180,7 @@ MoFox_Bot可能会选择使用你的问候Action,发送回复:
|
||||
嗨!很开心见到你!😊
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
> **💡 小提示**:MoFox_Bot会智能地决定什么时候使用它。如果没有立即看到效果,多试几次不同的消息。
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
# 自动化工具缓存系统使用指南
|
||||
|
||||
为了提升性能并减少不必要的重复计算或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的自动化系统来处理。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 📖 什么是工具
|
||||
|
||||
工具是MoFox_Bot的信息获取能力扩展组件。如果说Action组件功能五花八门,可以拓展麦麦能做的事情,那么Tool就是在某个过程中拓宽了麦麦能够获得的信息量。
|
||||
工具是MoFox_Bot的信息获取能力扩展组件。如果说Action组件功能五花八门,可以拓展麦麦能做的事情,那么Tool就是在某个过程中拓宽了MoFox_Bot能够获得的信息量。
|
||||
|
||||
### 🎯 工具的特点
|
||||
|
||||
@@ -191,8 +191,7 @@ class WeatherTool(BaseTool):
|
||||
name = "weather_query" # 清晰表达功能
|
||||
name = "knowledge_search" # 描述性强
|
||||
name = "stock_price_check" # 功能明确
|
||||
```
|
||||
#### ❌ 避免的命名
|
||||
```#### ❌ 避免的命名
|
||||
```python
|
||||
name = "tool1" # 无意义
|
||||
name = "wq" # 过于简短
|
||||
@@ -244,3 +243,130 @@ def _format_result(self, data):
|
||||
def _format_result(self, data):
|
||||
return str(data) # 直接返回原始数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 自动化工具缓存系统使用指南
|
||||
|
||||
为了提升性能并减少不必要的重复计算或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的自动化系统来处理。
|
||||
@@ -1,121 +0,0 @@
|
||||
# “月层计划”系统架构设计文档
|
||||
|
||||
## 1. 系统概述与目标
|
||||
|
||||
本系统旨在为MoFox_Bot引入一个动态的、由大型语言模型(LLM)驱动的“月层计划”机制。其核心目标是取代静态、预设的任务模板,转而利用LLM在程序启动时自动生成符合Bot人设的、具有时效性的月度计划。这些计划将被存储、管理,并在构建每日日程时被动态抽取和使用,从而极大地丰富日程内容的个性和多样性。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心设计原则
|
||||
|
||||
- **动态性与智能化:** 所有计划内容均由LLM实时生成,确保其独特性和创造性。
|
||||
- **人设一致性:** 计划的生成将严格围绕Bot的核心人设进行,强化角色形象。
|
||||
- **持久化与可管理:** 生成的计划将被存入专用数据库表,便于管理和追溯。
|
||||
- **消耗性与随机性:** 计划在使用后有一定几率被消耗(删除),模拟真实世界中计划的完成与迭代。
|
||||
|
||||
---
|
||||
|
||||
## 3. 系统核心流程规划
|
||||
|
||||
本系统包含两大核心流程:**启动时的计划生成流程**和**日程构建时的计划使用流程**。
|
||||
|
||||
### 3.1 流程一:启动时计划生成
|
||||
|
||||
此流程在每次程序启动时触发,负责填充当月的计划池。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[程序启动] --> B{检查当月计划池};
|
||||
B -- 计划数量低于阈值 --> C[构建LLM Prompt];
|
||||
C -- prompt包含Bot人设、月份等信息 --> D[调用LLM服务];
|
||||
D -- LLM返回多个计划文本 --> E[解析并格式化计划];
|
||||
E -- 逐条处理 --> F[存入`monthly_plans`数据库表];
|
||||
F --> G[完成启动任务];
|
||||
B -- 计划数量充足 --> G;
|
||||
```
|
||||
|
||||
### 3.2 流程二:日程构建时计划使用
|
||||
|
||||
此流程在构建每日日程的提示词(Prompt)时触发。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
H[构建日程Prompt] --> I{查询数据库};
|
||||
I -- 读取当月未使用的计划 --> J[随机抽取N个计划];
|
||||
J --> K[将计划文本嵌入日程Prompt];
|
||||
K --> L{随机数判断};
|
||||
L -- 概率命中 --> M[将已抽取的计划标记为删除];
|
||||
M --> N[完成Prompt构建];
|
||||
L -- 概率未命中 --> N;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据库模型设计
|
||||
|
||||
为支撑本系统,需要新增一个数据库表。
|
||||
|
||||
**表名:** `monthly_plans`
|
||||
|
||||
| 字段名 | 类型 | 描述 |
|
||||
| :--- | :--- | :--- |
|
||||
| `id` | Integer | 主键,自增。 |
|
||||
| `plan_text` | Text | 由LLM生成的计划内容原文。 |
|
||||
| `target_month` | String(7) | 计划所属的月份,格式为 "YYYY-MM"。 |
|
||||
| `is_deleted` | Boolean | 软删除标记,默认为 `false`。 |
|
||||
| `created_at` | DateTime | 记录创建时间。 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 详细模块规划
|
||||
|
||||
### 5.1 LLM Prompt生成模块
|
||||
|
||||
- **职责:** 构建高质量的Prompt以引导LLM生成符合要求的计划。
|
||||
- **输入:** Bot人设描述、当前月份、期望生成的计划数量。
|
||||
- **输出:** 一个结构化的Prompt字符串。
|
||||
- **Prompt示例:**
|
||||
```
|
||||
你是一个[此处填入Bot人设描述,例如:活泼开朗、偶尔有些小迷糊的虚拟助手]。
|
||||
请为即将到来的[YYYY年MM月]设计[N]个符合你身份的月度计划或目标。
|
||||
|
||||
要求:
|
||||
1. 每个计划都是独立的、积极向上的。
|
||||
2. 语言风格要自然、口语化,符合你的性格。
|
||||
3. 每个计划用一句话或两句话简短描述。
|
||||
4. 以JSON格式返回,格式为:{"plans": ["计划一", "计划二", ...]}
|
||||
```
|
||||
|
||||
### 5.2 数据库交互模块
|
||||
|
||||
- **职责:** 提供对 `monthly_plans` 表的增、删、改、查接口。
|
||||
- **规划函数列表:**
|
||||
- `add_new_plans(plans: list[str], month: str)`: 批量添加新生成的计划。
|
||||
- `get_active_plans_for_month(month: str) -> list`: 获取指定月份所有未被删除的计划。
|
||||
- `soft_delete_plans(plan_ids: list[int])`: 将指定ID的计划标记为软删除。
|
||||
|
||||
### 5.3 配置项规划
|
||||
|
||||
需要在主配置文件 `config/bot_config.toml` 中添加以下配置项,以控制系统行为。
|
||||
|
||||
```toml
|
||||
# ----------------------------------------------------------------
|
||||
# 月层计划系统设置 (Monthly Plan System Settings)
|
||||
# ----------------------------------------------------------------
|
||||
[monthly_plan_system]
|
||||
|
||||
# 是否启用本功能
|
||||
enable = true
|
||||
|
||||
# 启动时,如果当月计划少于此数量,则触发LLM生成
|
||||
generation_threshold = 10
|
||||
|
||||
# 每次调用LLM期望生成的计划数量
|
||||
plans_per_generation = 5
|
||||
|
||||
# 计划被使用后,被删除的概率 (0.0 到 1.0)
|
||||
deletion_probability_on_use = 0.5
|
||||
```
|
||||
|
||||
---
|
||||
**文档结束。** 本文档纯粹为架构规划,旨在提供清晰的设计思路和开发指引,不包含任何实现代码。
|
||||
@@ -10,7 +10,6 @@ from src.chat.express.expression_learner import expression_learner_manager
|
||||
from src.plugin_system.base.component_types import ChatMode
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ import hashlib
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import faiss
|
||||
from typing import Any, Dict, Optional, Union, List
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from src.common.logger import get_logger
|
||||
from src.llm_models.utils_model import LLMRequest
|
||||
from src.config.config import global_config, model_config
|
||||
@@ -14,6 +14,7 @@ from src.common.vector_db import vector_db_service
|
||||
|
||||
logger = get_logger("cache_manager")
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""
|
||||
一个支持分层和语义缓存的通用工具缓存管理器。
|
||||
@@ -21,6 +22,7 @@ class CacheManager:
|
||||
L1缓存: 内存字典 (KV) + FAISS (Vector)。
|
||||
L2缓存: 数据库 (KV) + ChromaDB (Vector)。
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
@@ -32,7 +34,7 @@ class CacheManager:
|
||||
"""
|
||||
初始化缓存管理器。
|
||||
"""
|
||||
if not hasattr(self, '_initialized'):
|
||||
if not hasattr(self, "_initialized"):
|
||||
self.default_ttl = default_ttl
|
||||
self.semantic_cache_collection_name = "semantic_cache"
|
||||
|
||||
@@ -79,7 +81,7 @@ class CacheManager:
|
||||
logger.warning("嵌入向量包含无效的数值 (NaN 或 Inf)")
|
||||
return None
|
||||
|
||||
return embedding_array.astype('float32')
|
||||
return embedding_array.astype("float32")
|
||||
else:
|
||||
logger.warning(f"嵌入结果格式不支持: {type(embedding_result)}")
|
||||
return None
|
||||
@@ -104,12 +106,18 @@ class CacheManager:
|
||||
logger.warning(f"无法获取文件信息: {tool_file_path},错误: {e}")
|
||||
|
||||
try:
|
||||
sorted_args = orjson.dumps(function_args, option=orjson.OPT_SORT_KEYS).decode('utf-8')
|
||||
sorted_args = orjson.dumps(function_args, option=orjson.OPT_SORT_KEYS).decode("utf-8")
|
||||
except TypeError:
|
||||
sorted_args = repr(sorted(function_args.items()))
|
||||
return f"{tool_name}::{sorted_args}::{file_hash}"
|
||||
|
||||
async def get(self, tool_name: str, function_args: Dict[str, Any], tool_file_path: Union[str, Path], semantic_query: Optional[str] = None) -> Optional[Any]:
|
||||
async def get(
|
||||
self,
|
||||
tool_name: str,
|
||||
function_args: Dict[str, Any],
|
||||
tool_file_path: Union[str, Path],
|
||||
semantic_query: Optional[str] = None,
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
从缓存获取结果,查询顺序: L1-KV -> L1-Vector -> L2-KV -> L2-Vector。
|
||||
"""
|
||||
@@ -136,7 +144,7 @@ class CacheManager:
|
||||
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:
|
||||
query_embedding = np.array([validated_embedding], dtype='float32')
|
||||
query_embedding = np.array([validated_embedding], dtype="float32")
|
||||
|
||||
# 步骤 2a: L1 语义缓存 (FAISS)
|
||||
if query_embedding is not None and self.l1_vector_index.ntotal > 0:
|
||||
@@ -151,10 +159,7 @@ class CacheManager:
|
||||
|
||||
# 步骤 2b: L2 精确缓存 (数据库)
|
||||
cache_results_obj = await db_query(
|
||||
model_class=CacheEntries,
|
||||
query_type="get",
|
||||
filters={"cache_key": key},
|
||||
single_result=True
|
||||
model_class=CacheEntries, query_type="get", filters={"cache_key": key}, single_result=True
|
||||
)
|
||||
|
||||
if cache_results_obj:
|
||||
@@ -172,8 +177,8 @@ class CacheManager:
|
||||
filters={"cache_key": key},
|
||||
data={
|
||||
"last_accessed": time.time(),
|
||||
"access_count": getattr(cache_results_obj, "access_count", 0) + 1
|
||||
}
|
||||
"access_count": getattr(cache_results_obj, "access_count", 0) + 1,
|
||||
},
|
||||
)
|
||||
|
||||
# 回填 L1
|
||||
@@ -181,11 +186,7 @@ class CacheManager:
|
||||
return data
|
||||
else:
|
||||
# 删除过期的缓存条目
|
||||
await db_query(
|
||||
model_class=CacheEntries,
|
||||
query_type="delete",
|
||||
filters={"cache_key": key}
|
||||
)
|
||||
await db_query(model_class=CacheEntries, query_type="delete", filters={"cache_key": key})
|
||||
|
||||
# 步骤 2c: L2 语义缓存 (VectorDB Service)
|
||||
if query_embedding is not None:
|
||||
@@ -193,14 +194,16 @@ class CacheManager:
|
||||
results = vector_db_service.query(
|
||||
collection_name=self.semantic_cache_collection_name,
|
||||
query_embeddings=query_embedding.tolist(),
|
||||
n_results=1
|
||||
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"
|
||||
)
|
||||
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]
|
||||
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}")
|
||||
|
||||
# 从数据库获取缓存数据
|
||||
@@ -208,7 +211,7 @@ class CacheManager:
|
||||
model_class=CacheEntries,
|
||||
query_type="get",
|
||||
filters={"cache_key": l2_hit_key},
|
||||
single_result=True
|
||||
single_result=True,
|
||||
)
|
||||
|
||||
if semantic_cache_results_obj:
|
||||
@@ -235,7 +238,15 @@ class CacheManager:
|
||||
logger.debug(f"缓存未命中: {key}")
|
||||
return None
|
||||
|
||||
async def set(self, tool_name: str, function_args: Dict[str, Any], tool_file_path: Union[str, Path], data: Any, ttl: Optional[int] = None, semantic_query: Optional[str] = None):
|
||||
async def set(
|
||||
self,
|
||||
tool_name: str,
|
||||
function_args: Dict[str, Any],
|
||||
tool_file_path: Union[str, Path],
|
||||
data: Any,
|
||||
ttl: Optional[int] = None,
|
||||
semantic_query: Optional[str] = None,
|
||||
):
|
||||
"""将结果存入所有缓存层。"""
|
||||
if ttl is None:
|
||||
ttl = self.default_ttl
|
||||
@@ -251,20 +262,15 @@ class CacheManager:
|
||||
# 写入 L2 (数据库)
|
||||
cache_data = {
|
||||
"cache_key": key,
|
||||
"cache_value": orjson.dumps(data).decode('utf-8'),
|
||||
"cache_value": orjson.dumps(data).decode("utf-8"),
|
||||
"expires_at": expires_at,
|
||||
"tool_name": tool_name,
|
||||
"created_at": time.time(),
|
||||
"last_accessed": time.time(),
|
||||
"access_count": 1
|
||||
"access_count": 1,
|
||||
}
|
||||
|
||||
await db_save(
|
||||
model_class=CacheEntries,
|
||||
data=cache_data,
|
||||
key_field="cache_key",
|
||||
key_value=key
|
||||
)
|
||||
await db_save(model_class=CacheEntries, data=cache_data, key_field="cache_key", key_value=key)
|
||||
|
||||
# 写入语义缓存
|
||||
if semantic_query and self.embedding_model:
|
||||
@@ -274,7 +280,7 @@ class CacheManager:
|
||||
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')
|
||||
embedding = np.array([validated_embedding], dtype="float32")
|
||||
|
||||
# 写入 L1 Vector
|
||||
new_id = self.l1_vector_index.ntotal
|
||||
@@ -286,7 +292,7 @@ class CacheManager:
|
||||
vector_db_service.add(
|
||||
collection_name=self.semantic_cache_collection_name,
|
||||
embeddings=embedding.tolist(),
|
||||
ids=[key]
|
||||
ids=[key],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"语义缓存写入失败: {e}")
|
||||
@@ -306,7 +312,7 @@ class CacheManager:
|
||||
await db_query(
|
||||
model_class=CacheEntries,
|
||||
query_type="delete",
|
||||
filters={} # 删除所有记录
|
||||
filters={}, # 删除所有记录
|
||||
)
|
||||
|
||||
# 清空 VectorDB
|
||||
@@ -338,74 +344,12 @@ class CacheManager:
|
||||
del self.l1_kv_cache[key]
|
||||
|
||||
# 清理L2过期条目
|
||||
await db_query(
|
||||
model_class=CacheEntries,
|
||||
query_type="delete",
|
||||
filters={"expires_at": {"$lt": current_time}}
|
||||
)
|
||||
await db_query(model_class=CacheEntries, query_type="delete", filters={"expires_at": {"$lt": current_time}})
|
||||
|
||||
if expired_keys:
|
||||
logger.info(f"清理了 {len(expired_keys)} 个过期的L1缓存条目")
|
||||
|
||||
|
||||
# 全局实例
|
||||
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
|
||||
@@ -5,7 +5,7 @@ import random
|
||||
|
||||
from enum import Enum
|
||||
from rich.traceback import install
|
||||
from typing import Tuple, List, Dict, Optional, Callable, Any, Coroutine
|
||||
from typing import Tuple, List, Dict, Optional, Callable, Any, Coroutine, Generator
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.config.config import model_config
|
||||
@@ -283,19 +283,26 @@ class LLMRequest:
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
raise_when_empty: bool = True,
|
||||
) -> Tuple[str, Tuple[str, str, Optional[List[ToolCall]]]]:
|
||||
"""执行单次请求"""
|
||||
# 模型选择和请求准备
|
||||
start_time = time.time()
|
||||
model_info, api_provider, client = self._select_model()
|
||||
model_name = model_info.name
|
||||
"""
|
||||
执行单次请求,并在模型失败时按顺序切换到下一个可用模型。
|
||||
"""
|
||||
failed_models = set()
|
||||
last_exception: Optional[Exception] = None
|
||||
|
||||
model_scheduler = self._model_scheduler(failed_models)
|
||||
|
||||
for model_info, api_provider, client in model_scheduler:
|
||||
start_time = time.time()
|
||||
model_name = model_info.name
|
||||
logger.info(f"正在尝试使用模型: {model_name}")
|
||||
|
||||
try:
|
||||
# 检查是否启用反截断
|
||||
use_anti_truncation = getattr(api_provider, "anti_truncation", False)
|
||||
|
||||
processed_prompt = prompt
|
||||
if use_anti_truncation:
|
||||
processed_prompt += self.anti_truncation_instruction
|
||||
logger.info(f"{api_provider} '{self.task_name}' 已启用反截断功能")
|
||||
logger.info(f"'{model_name}' for task '{self.task_name}' 已启用反截断功能")
|
||||
|
||||
processed_prompt = self._apply_content_obfuscation(processed_prompt, api_provider)
|
||||
|
||||
@@ -304,13 +311,12 @@ class LLMRequest:
|
||||
messages = [message_builder.build()]
|
||||
tool_built = self._build_tool_options(tools)
|
||||
|
||||
# 空回复重试逻辑
|
||||
# 针对当前模型的空回复/截断重试逻辑
|
||||
empty_retry_count = 0
|
||||
max_empty_retry = api_provider.max_retry
|
||||
empty_retry_interval = api_provider.retry_interval
|
||||
|
||||
while empty_retry_count <= max_empty_retry:
|
||||
try:
|
||||
response = await self._execute_request(
|
||||
api_provider=api_provider,
|
||||
client=client,
|
||||
@@ -321,93 +327,86 @@ class LLMRequest:
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
content = response.content or ""
|
||||
reasoning_content = response.reasoning_content or ""
|
||||
tool_calls = response.tool_calls
|
||||
# 从内容中提取<think>标签的推理内容(向后兼容)
|
||||
|
||||
if not reasoning_content and content:
|
||||
content, extracted_reasoning = self._extract_reasoning(content)
|
||||
reasoning_content = extracted_reasoning
|
||||
|
||||
is_empty_reply = False
|
||||
is_empty_reply = not tool_calls and (not content or content.strip() == "")
|
||||
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]"):
|
||||
content = content[:-6].strip()
|
||||
logger.debug("检测到并已移除 [done] 标记")
|
||||
else:
|
||||
is_truncated = True
|
||||
logger.warning("未检测到 [done] 标记,判定为截断")
|
||||
|
||||
if is_empty_reply or is_truncated:
|
||||
if empty_retry_count < max_empty_retry:
|
||||
empty_retry_count += 1
|
||||
reason = "空回复" if is_empty_reply else "截断"
|
||||
logger.warning(f"检测到{reason},正在进行第 {empty_retry_count}/{max_empty_retry} 次重新生成")
|
||||
|
||||
if empty_retry_interval > 0:
|
||||
await asyncio.sleep(empty_retry_interval)
|
||||
|
||||
model_info, api_provider, client = self._select_model()
|
||||
continue
|
||||
else:
|
||||
# 已达到最大重试次数,但仍然是空回复或截断
|
||||
reason = "空回复" if is_empty_reply else "截断"
|
||||
# 抛出异常,由外层重试逻辑或最终的异常处理器捕获
|
||||
raise RuntimeError(f"经过 {max_empty_retry + 1} 次尝试后仍然是{reason}的回复")
|
||||
|
||||
# 记录使用情况
|
||||
if usage := response.usage:
|
||||
llm_usage_recorder.record_usage_to_database(
|
||||
model_info=model_info,
|
||||
model_usage=usage,
|
||||
time_cost=time.time() - start_time,
|
||||
user_id="system",
|
||||
request_type=self.request_type,
|
||||
endpoint="/chat/completions",
|
||||
)
|
||||
|
||||
# 处理空回复
|
||||
if not content and not tool_calls:
|
||||
if raise_when_empty:
|
||||
raise RuntimeError(f"经过 {empty_retry_count} 次重试后仍然生成空回复")
|
||||
content = "生成的响应为空,请检查模型配置或输入内容是否正确"
|
||||
elif empty_retry_count > 0:
|
||||
logger.info(f"经过 {empty_retry_count} 次重试后成功生成回复")
|
||||
|
||||
return content, (reasoning_content, model_info.name, tool_calls)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"请求执行失败: {e}")
|
||||
if raise_when_empty:
|
||||
# 在非并发模式下,如果第一次尝试就失败,则直接抛出异常
|
||||
if empty_retry_count == 0:
|
||||
raise
|
||||
|
||||
# 如果在重试过程中失败,则继续重试
|
||||
empty_retry_count += 1
|
||||
if empty_retry_count <= max_empty_retry:
|
||||
logger.warning(f"请求失败,将在 {empty_retry_interval} 秒后进行第 {empty_retry_count}/{max_empty_retry} 次重试...")
|
||||
reason = "空回复" if is_empty_reply else "截断"
|
||||
logger.warning(f"模型 '{model_name}' 检测到{reason},正在进行第 {empty_retry_count}/{max_empty_retry} 次重新生成...")
|
||||
if empty_retry_interval > 0:
|
||||
await asyncio.sleep(empty_retry_interval)
|
||||
continue
|
||||
continue # 继续使用当前模型重试
|
||||
else:
|
||||
logger.error(f"经过 {max_empty_retry} 次重试后仍然失败")
|
||||
raise RuntimeError(f"经过 {max_empty_retry} 次重试后仍然无法生成有效回复") from e
|
||||
else:
|
||||
# 在并发模式下,单个请求的失败不应中断整个并发流程,
|
||||
# 而是将异常返回给调用者(即 execute_concurrently)进行统一处理
|
||||
raise # 重新抛出异常,由 execute_concurrently 中的 gather 捕获
|
||||
# 当前模型重试次数用尽,跳出内层循环,触发外层循环切换模型
|
||||
reason = "空回复" if is_empty_reply else "截断"
|
||||
logger.error(f"模型 '{model_name}' 经过 {max_empty_retry} 次重试后仍然是{reason}的回复。")
|
||||
raise RuntimeError(f"模型 '{model_name}' 达到最大空回复/截断重试次数")
|
||||
|
||||
# 重试失败
|
||||
# 成功获取响应
|
||||
if usage := response.usage:
|
||||
llm_usage_recorder.record_usage_to_database(
|
||||
model_info=model_info, model_usage=usage, time_cost=time.time() - start_time,
|
||||
user_id="system", request_type=self.request_type, endpoint="/chat/completions",
|
||||
)
|
||||
|
||||
if not content and not tool_calls:
|
||||
if raise_when_empty:
|
||||
raise RuntimeError(f"经过 {max_empty_retry} 次重试后仍然无法生成有效回复")
|
||||
return "生成的响应为空,请检查模型配置或输入内容是否正确", ("", model_name, None)
|
||||
raise RuntimeError("生成空回复")
|
||||
content = "生成的响应为空"
|
||||
|
||||
logger.info(f"模型 '{model_name}' 成功生成回复。")
|
||||
return content, (reasoning_content, model_name, tool_calls)
|
||||
|
||||
except RespNotOkException as e:
|
||||
if e.status_code in [401, 403]:
|
||||
logger.error(f"模型 '{model_name}' 遇到认证/权限错误 (Code: {e.status_code}),将尝试下一个模型。")
|
||||
failed_models.add(model_name)
|
||||
last_exception = e
|
||||
continue # 切换到下一个模型
|
||||
else:
|
||||
logger.error(f"模型 '{model_name}' 请求失败,HTTP状态码: {e.status_code}")
|
||||
if raise_when_empty:
|
||||
raise
|
||||
# 对于其他HTTP错误,直接抛出,不再尝试其他模型
|
||||
return f"请求失败: {e}", ("", model_name, None)
|
||||
|
||||
except RuntimeError as e:
|
||||
# 捕获所有重试失败(包括空回复和网络问题)
|
||||
logger.error(f"模型 '{model_name}' 在所有重试后仍然失败: {e},将尝试下一个模型。")
|
||||
failed_models.add(model_name)
|
||||
last_exception = e
|
||||
continue # 切换到下一个模型
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"使用模型 '{model_name}' 时发生未知异常: {e}")
|
||||
failed_models.add(model_name)
|
||||
last_exception = e
|
||||
continue # 切换到下一个模型
|
||||
|
||||
# 所有模型都尝试失败
|
||||
logger.error("所有可用模型都已尝试失败。")
|
||||
if raise_when_empty:
|
||||
if last_exception:
|
||||
raise RuntimeError("所有模型都请求失败") from last_exception
|
||||
raise RuntimeError("所有模型都请求失败,且没有具体的异常信息")
|
||||
|
||||
return "所有模型都请求失败", ("", "unknown", None)
|
||||
|
||||
async def get_embedding(self, embedding_input: str) -> Tuple[List[float], str]:
|
||||
"""获取嵌入向量
|
||||
@@ -446,9 +445,24 @@ class LLMRequest:
|
||||
|
||||
return embedding, model_info.name
|
||||
|
||||
def _model_scheduler(self, failed_models: set) -> Generator[Tuple[ModelInfo, APIProvider, BaseClient], None, None]:
|
||||
"""
|
||||
一个模型调度器,按顺序提供模型,并跳过已失败的模型。
|
||||
"""
|
||||
for model_name in self.model_for_task.model_list:
|
||||
if model_name in failed_models:
|
||||
continue
|
||||
|
||||
model_info = model_config.get_model_info(model_name)
|
||||
api_provider = model_config.get_provider(model_info.api_provider)
|
||||
force_new_client = (self.request_type == "embedding")
|
||||
client = client_registry.get_client_class_instance(api_provider, force_new=force_new_client)
|
||||
|
||||
yield model_info, api_provider, client
|
||||
|
||||
def _select_model(self) -> Tuple[ModelInfo, APIProvider, BaseClient]:
|
||||
"""
|
||||
根据总tokens和惩罚值选择的模型
|
||||
根据总tokens和惩罚值选择的模型 (负载均衡)
|
||||
"""
|
||||
least_used_model_name = min(
|
||||
self.model_usage,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
from typing import Optional, Type
|
||||
from src.plugin_system.base.base_tool import BaseTool
|
||||
from src.plugin_system.base.component_types import ComponentType
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from abc import abstractmethod
|
||||
from typing import List, Type, Tuple, Union, TYPE_CHECKING
|
||||
from typing import List, Type, Tuple, Union
|
||||
from .plugin_base import PluginBase
|
||||
|
||||
from src.common.logger import get_logger
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Tuple, Optional, List
|
||||
from typing import Tuple, Optional, List
|
||||
import re
|
||||
|
||||
from src.common.logger import get_logger
|
||||
|
||||
@@ -7,8 +7,10 @@ from src.llm_models.utils_model import LLMRequest
|
||||
from src.llm_models.payload_content import ToolCall
|
||||
from src.config.config import global_config, model_config
|
||||
from src.chat.utils.prompt_builder import Prompt, global_prompt_manager
|
||||
import inspect
|
||||
from src.chat.message_receive.chat_stream import get_chat_manager
|
||||
from src.common.logger import get_logger
|
||||
from src.common.cache_manager import tool_cache
|
||||
|
||||
logger = get_logger("tool_use")
|
||||
|
||||
@@ -184,21 +186,65 @@ class ToolExecutor:
|
||||
return tool_results, used_tools
|
||||
|
||||
async def execute_tool_call(self, tool_call: ToolCall, tool_instance: Optional[BaseTool] = None) -> Optional[Dict[str, Any]]:
|
||||
# sourcery skip: use-assigned-variable
|
||||
"""执行单个工具调用
|
||||
"""执行单个工具调用,并处理缓存"""
|
||||
|
||||
Args:
|
||||
tool_call: 工具调用对象
|
||||
function_args = tool_call.args or {}
|
||||
tool_instance = tool_instance or get_tool_instance(tool_call.func_name)
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 工具调用结果,如果失败则返回None
|
||||
"""
|
||||
# 如果工具不存在或未启用缓存,则直接执行
|
||||
if not tool_instance or not tool_instance.enable_cache:
|
||||
return await self._original_execute_tool_call(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 = function_args.get(tool_instance.semantic_cache_query_key)
|
||||
|
||||
cached_result = await tool_cache.get(
|
||||
tool_name=tool_call.func_name,
|
||||
function_args=function_args,
|
||||
tool_file_path=tool_file_path,
|
||||
semantic_query=semantic_query
|
||||
)
|
||||
if cached_result:
|
||||
logger.info(f"{self.log_prefix}使用缓存结果,跳过工具 {tool_call.func_name} 执行")
|
||||
return cached_result
|
||||
except Exception as e:
|
||||
logger.error(f"{self.log_prefix}检查工具缓存时出错: {e}")
|
||||
|
||||
# 缓存未命中,执行原始工具调用
|
||||
result = await self._original_execute_tool_call(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 = function_args.get(tool_instance.semantic_cache_query_key)
|
||||
|
||||
await tool_cache.set(
|
||||
tool_name=tool_call.func_name,
|
||||
function_args=function_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"{self.log_prefix}设置工具缓存时出错: {e}")
|
||||
# --- 缓存逻辑结束 ---
|
||||
|
||||
return result
|
||||
|
||||
async def _original_execute_tool_call(self, tool_call: ToolCall, tool_instance: Optional[BaseTool] = None) -> Optional[Dict[str, Any]]:
|
||||
"""执行单个工具调用的原始逻辑"""
|
||||
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}")
|
||||
logger.info(f"{self.log_prefix} 正在执行工具: [bold green]{function_name}[/bold green] | 参数: {function_args}")
|
||||
function_args["llm_called"] = True # 标记为LLM调用
|
||||
|
||||
# 获取对应工具实例
|
||||
tool_instance = tool_instance or get_tool_instance(function_name)
|
||||
if not tool_instance:
|
||||
|
||||
@@ -24,6 +24,7 @@ from .services.qzone_service import QZoneService
|
||||
from .services.scheduler_service import SchedulerService
|
||||
from .services.monitor_service import MonitorService
|
||||
from .services.cookie_service import CookieService
|
||||
from .services.reply_tracker_service import ReplyTrackerService
|
||||
from .services.manager import register_service
|
||||
|
||||
logger = get_logger("MaiZone.Plugin")
|
||||
@@ -92,11 +93,13 @@ class MaiZoneRefactoredPlugin(BasePlugin):
|
||||
content_service = ContentService(self.get_config)
|
||||
image_service = ImageService(self.get_config)
|
||||
cookie_service = CookieService(self.get_config)
|
||||
reply_tracker_service = ReplyTrackerService()
|
||||
qzone_service = QZoneService(self.get_config, content_service, image_service, cookie_service)
|
||||
scheduler_service = SchedulerService(self.get_config, qzone_service)
|
||||
monitor_service = MonitorService(self.get_config, qzone_service)
|
||||
|
||||
register_service("qzone", qzone_service)
|
||||
register_service("reply_tracker", reply_tracker_service)
|
||||
register_service("get_config", self.get_config)
|
||||
|
||||
asyncio.create_task(scheduler_service.start())
|
||||
|
||||
@@ -9,12 +9,9 @@ import datetime
|
||||
import base64
|
||||
import aiohttp
|
||||
from src.common.logger import get_logger
|
||||
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.plugin_system.apis import llm_api, config_api, generator_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
|
||||
|
||||
@@ -27,6 +27,7 @@ from src.chat.utils.chat_message_builder import (
|
||||
from .content_service import ContentService
|
||||
from .image_service import ImageService
|
||||
from .cookie_service import CookieService
|
||||
from .reply_tracker_service import ReplyTrackerService
|
||||
|
||||
logger = get_logger("MaiZone.QZoneService")
|
||||
|
||||
@@ -55,6 +56,7 @@ class QZoneService:
|
||||
self.content_service = content_service
|
||||
self.image_service = image_service
|
||||
self.cookie_service = cookie_service
|
||||
self.reply_tracker = ReplyTrackerService()
|
||||
|
||||
# --- Public Methods (High-Level Business Logic) ---
|
||||
|
||||
@@ -154,7 +156,8 @@ class QZoneService:
|
||||
# --- 第一步: 单独处理自己说说的评论 ---
|
||||
if self.get_config("monitor.enable_auto_reply", False):
|
||||
try:
|
||||
own_feeds = await api_client["list_feeds"](qq_account, 5) # 获取自己最近5条说说
|
||||
# 传入新参数,表明正在检查自己的说说
|
||||
own_feeds = await api_client["list_feeds"](qq_account, 5, is_monitoring_own_feeds=True)
|
||||
if own_feeds:
|
||||
logger.info(f"获取到自己 {len(own_feeds)} 条说说,检查评论...")
|
||||
for feed in own_feeds:
|
||||
@@ -248,42 +251,83 @@ class QZoneService:
|
||||
content = feed.get("content", "")
|
||||
fid = feed.get("tid", "")
|
||||
|
||||
if not comments:
|
||||
if not comments or not fid:
|
||||
return
|
||||
|
||||
# 筛选出未被自己回复过的评论
|
||||
if not comments:
|
||||
# 1. 将评论分为用户评论和自己的回复
|
||||
user_comments = [c for c in comments if str(c.get('qq_account')) != str(qq_account)]
|
||||
my_replies = [c for c in comments if str(c.get('qq_account')) == str(qq_account)]
|
||||
|
||||
if not user_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)
|
||||
}
|
||||
# 2. 验证已记录的回复是否仍然存在,清理已删除的回复记录
|
||||
await self._validate_and_cleanup_reply_records(fid, my_replies)
|
||||
|
||||
# 找出所有非我发出且我未回复过的评论
|
||||
comments_to_reply = [
|
||||
c for c in comments
|
||||
if str(c.get('qq_account')) != str(qq_account) and c.get('comment_tid') not in replied_to_tids
|
||||
]
|
||||
# 3. 使用验证后的持久化记录来筛选未回复的评论
|
||||
comments_to_reply = []
|
||||
for comment in user_comments:
|
||||
comment_tid = comment.get('comment_tid')
|
||||
if not comment_tid:
|
||||
continue
|
||||
|
||||
# 检查是否已经在持久化记录中标记为已回复
|
||||
if not self.reply_tracker.has_replied(fid, comment_tid):
|
||||
comments_to_reply.append(comment)
|
||||
|
||||
if not comments_to_reply:
|
||||
logger.debug(f"说说 {fid} 下的所有评论都已回复过")
|
||||
return
|
||||
|
||||
logger.info(f"发现自己说说下的 {len(comments_to_reply)} 条新评论,准备回复...")
|
||||
for comment in comments_to_reply:
|
||||
comment_tid = comment.get("comment_tid")
|
||||
nickname = comment.get("nickname", "")
|
||||
comment_content = comment.get("content", "")
|
||||
|
||||
try:
|
||||
reply_content = await self.content_service.generate_comment_reply(
|
||||
content, comment.get("content", ""), comment.get("nickname", "")
|
||||
content, comment_content, nickname
|
||||
)
|
||||
if reply_content:
|
||||
success = await api_client["reply"](
|
||||
fid, qq_account, comment.get("nickname", ""), reply_content, comment.get("comment_tid")
|
||||
fid, qq_account, nickname, reply_content, comment_tid
|
||||
)
|
||||
if success:
|
||||
logger.info(f"成功回复'{comment.get('nickname', '')}'的评论: '{reply_content}'")
|
||||
# 标记为已回复
|
||||
self.reply_tracker.mark_as_replied(fid, comment_tid)
|
||||
logger.info(f"成功回复'{nickname}'的评论: '{reply_content}'")
|
||||
else:
|
||||
logger.error(f"回复'{comment.get('nickname', '')}'的评论失败")
|
||||
logger.error(f"回复'{nickname}'的评论失败")
|
||||
await asyncio.sleep(random.uniform(10, 20))
|
||||
else:
|
||||
logger.warning(f"生成回复内容失败,跳过回复'{nickname}'的评论")
|
||||
except Exception as e:
|
||||
logger.error(f"回复'{nickname}'的评论时发生异常: {e}", exc_info=True)
|
||||
|
||||
async def _validate_and_cleanup_reply_records(self, fid: str, my_replies: List[Dict]):
|
||||
"""验证并清理已删除的回复记录"""
|
||||
# 获取当前记录中该说说的所有已回复评论ID
|
||||
recorded_replied_comments = self.reply_tracker.get_replied_comments(fid)
|
||||
|
||||
if not recorded_replied_comments:
|
||||
return
|
||||
|
||||
# 从API返回的我的回复中提取parent_tid(即被回复的评论ID)
|
||||
current_replied_comments = set()
|
||||
for reply in my_replies:
|
||||
parent_tid = reply.get('parent_tid')
|
||||
if parent_tid:
|
||||
current_replied_comments.add(parent_tid)
|
||||
|
||||
# 找出记录中有但实际已不存在的回复
|
||||
deleted_replies = recorded_replied_comments - current_replied_comments
|
||||
|
||||
if deleted_replies:
|
||||
logger.info(f"检测到 {len(deleted_replies)} 个回复已被删除,清理记录...")
|
||||
for comment_tid in deleted_replies:
|
||||
self.reply_tracker.remove_reply_record(fid, comment_tid)
|
||||
logger.debug(f"已清理删除的回复记录: feed_id={fid}, comment_id={comment_tid}")
|
||||
|
||||
async def _process_single_feed(self, feed: Dict, api_client: Dict, target_qq: str, target_name: str):
|
||||
"""处理单条说说,决定是否评论和点赞"""
|
||||
@@ -641,7 +685,7 @@ class QZoneService:
|
||||
logger.error(f"上传图片 {index+1} 异常: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _list_feeds(t_qq: str, num: int) -> List[Dict]:
|
||||
async def _list_feeds(t_qq: str, num: int, is_monitoring_own_feeds: bool = False) -> List[Dict]:
|
||||
"""获取指定用户说说列表"""
|
||||
try:
|
||||
params = {
|
||||
@@ -667,10 +711,14 @@ class QZoneService:
|
||||
feeds_list = []
|
||||
my_name = json_data.get("logininfo", {}).get("name", "")
|
||||
for msg in json_data.get("msglist", []):
|
||||
# 只有在处理好友说说时,才检查是否已评论并跳过
|
||||
if not is_monitoring_own_feeds:
|
||||
is_commented = any(
|
||||
c.get("name") == my_name for c in msg.get("commentlist", []) if isinstance(c, dict)
|
||||
)
|
||||
if not is_commented:
|
||||
if is_commented:
|
||||
continue
|
||||
|
||||
images = [pic['url1'] for pic in msg.get('pictotal', []) if 'url1' in pic]
|
||||
|
||||
comments = []
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
评论回复跟踪服务
|
||||
负责记录和管理已回复过的评论ID,避免重复回复
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Set, Dict, Any
|
||||
from src.common.logger import get_logger
|
||||
|
||||
logger = get_logger("MaiZone.ReplyTrackerService")
|
||||
|
||||
|
||||
class ReplyTrackerService:
|
||||
"""
|
||||
评论回复跟踪服务
|
||||
使用本地JSON文件持久化存储已回复的评论ID
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 数据存储路径
|
||||
self.data_dir = Path(__file__).resolve().parent.parent / "data"
|
||||
self.data_dir.mkdir(exist_ok=True)
|
||||
self.reply_record_file = self.data_dir / "replied_comments.json"
|
||||
|
||||
# 内存中的已回复评论记录
|
||||
# 格式: {feed_id: {comment_id: timestamp, ...}, ...}
|
||||
self.replied_comments: Dict[str, Dict[str, float]] = {}
|
||||
|
||||
# 数据清理配置
|
||||
self.max_record_days = 30 # 保留30天的记录
|
||||
|
||||
# 加载已有数据
|
||||
self._load_data()
|
||||
|
||||
def _load_data(self):
|
||||
"""从文件加载已回复评论数据"""
|
||||
try:
|
||||
if self.reply_record_file.exists():
|
||||
with open(self.reply_record_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.replied_comments = data
|
||||
logger.info(f"已加载 {len(self.replied_comments)} 条说说的回复记录")
|
||||
else:
|
||||
logger.info("未找到回复记录文件,将创建新的记录")
|
||||
except Exception as e:
|
||||
logger.error(f"加载回复记录失败: {e}")
|
||||
self.replied_comments = {}
|
||||
|
||||
def _save_data(self):
|
||||
"""保存已回复评论数据到文件"""
|
||||
try:
|
||||
# 清理过期数据
|
||||
self._cleanup_old_records()
|
||||
|
||||
with open(self.reply_record_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.replied_comments, f, ensure_ascii=False, indent=2)
|
||||
logger.debug("回复记录已保存")
|
||||
except Exception as e:
|
||||
logger.error(f"保存回复记录失败: {e}")
|
||||
|
||||
def _cleanup_old_records(self):
|
||||
"""清理超过保留期限的记录"""
|
||||
current_time = time.time()
|
||||
cutoff_time = current_time - (self.max_record_days * 24 * 60 * 60)
|
||||
|
||||
feeds_to_remove = []
|
||||
total_removed = 0
|
||||
|
||||
for feed_id, comments in self.replied_comments.items():
|
||||
comments_to_remove = []
|
||||
|
||||
for comment_id, timestamp in comments.items():
|
||||
if timestamp < cutoff_time:
|
||||
comments_to_remove.append(comment_id)
|
||||
|
||||
# 移除过期的评论记录
|
||||
for comment_id in comments_to_remove:
|
||||
del comments[comment_id]
|
||||
total_removed += 1
|
||||
|
||||
# 如果该说说下没有任何记录了,标记删除整个说说记录
|
||||
if not comments:
|
||||
feeds_to_remove.append(feed_id)
|
||||
|
||||
# 移除空的说说记录
|
||||
for feed_id in feeds_to_remove:
|
||||
del self.replied_comments[feed_id]
|
||||
|
||||
if total_removed > 0:
|
||||
logger.info(f"清理了 {total_removed} 条过期的回复记录")
|
||||
|
||||
def has_replied(self, feed_id: str, comment_id: str) -> bool:
|
||||
"""
|
||||
检查是否已经回复过指定的评论
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
comment_id: 评论ID
|
||||
|
||||
Returns:
|
||||
bool: 如果已回复过返回True,否则返回False
|
||||
"""
|
||||
if not feed_id or not comment_id:
|
||||
return False
|
||||
|
||||
return (feed_id in self.replied_comments and
|
||||
comment_id in self.replied_comments[feed_id])
|
||||
|
||||
def mark_as_replied(self, feed_id: str, comment_id: str):
|
||||
"""
|
||||
标记指定评论为已回复
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
comment_id: 评论ID
|
||||
"""
|
||||
if not feed_id or not comment_id:
|
||||
logger.warning("feed_id 或 comment_id 为空,无法标记为已回复")
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
if feed_id not in self.replied_comments:
|
||||
self.replied_comments[feed_id] = {}
|
||||
|
||||
self.replied_comments[feed_id][comment_id] = current_time
|
||||
|
||||
# 保存到文件
|
||||
self._save_data()
|
||||
|
||||
logger.info(f"已标记评论为已回复: feed_id={feed_id}, comment_id={comment_id}")
|
||||
|
||||
def get_replied_comments(self, feed_id: str) -> Set[str]:
|
||||
"""
|
||||
获取指定说说下所有已回复的评论ID
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
|
||||
Returns:
|
||||
Set[str]: 已回复的评论ID集合
|
||||
"""
|
||||
if feed_id in self.replied_comments:
|
||||
return set(self.replied_comments[feed_id].keys())
|
||||
return set()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取回复记录统计信息
|
||||
|
||||
Returns:
|
||||
Dict: 包含统计信息的字典
|
||||
"""
|
||||
total_feeds = len(self.replied_comments)
|
||||
total_replies = sum(len(comments) for comments in self.replied_comments.values())
|
||||
|
||||
return {
|
||||
"total_feeds_with_replies": total_feeds,
|
||||
"total_replied_comments": total_replies,
|
||||
"data_file": str(self.reply_record_file),
|
||||
"max_record_days": self.max_record_days
|
||||
}
|
||||
|
||||
def remove_reply_record(self, feed_id: str, comment_id: str):
|
||||
"""
|
||||
移除指定评论的回复记录
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
comment_id: 评论ID
|
||||
"""
|
||||
if feed_id in self.replied_comments and comment_id in self.replied_comments[feed_id]:
|
||||
del self.replied_comments[feed_id][comment_id]
|
||||
|
||||
# 如果该说说下没有任何回复记录了,删除整个说说记录
|
||||
if not self.replied_comments[feed_id]:
|
||||
del self.replied_comments[feed_id]
|
||||
|
||||
self._save_data()
|
||||
logger.debug(f"已移除回复记录: feed_id={feed_id}, comment_id={comment_id}")
|
||||
|
||||
def remove_feed_records(self, feed_id: str):
|
||||
"""
|
||||
移除指定说说的所有回复记录
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
"""
|
||||
if feed_id in self.replied_comments:
|
||||
del self.replied_comments[feed_id]
|
||||
self._save_data()
|
||||
logger.info(f"已移除说说 {feed_id} 的所有回复记录")
|
||||
@@ -16,7 +16,7 @@ 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 PlusCommandInfo, ChatType
|
||||
from src.plugin_system.base.config_types import ConfigField
|
||||
from src.plugin_system.utils.permission_decorators import require_permission, require_master, PermissionChecker
|
||||
from src.plugin_system.utils.permission_decorators import require_permission
|
||||
|
||||
|
||||
logger = get_logger("Permission")
|
||||
|
||||
@@ -411,7 +411,6 @@ class ScheduleManager:
|
||||
通过关键词匹配、唤醒度、睡眠压力等综合判断是否处于休眠时间。
|
||||
新增弹性睡眠机制,允许在压力低时延迟入睡,并在入睡前发送通知。
|
||||
"""
|
||||
from src.chat.chat_loop.wakeup_manager import WakeUpManager
|
||||
# --- 基础检查 ---
|
||||
if not global_config.schedule.enable_is_sleep:
|
||||
return False
|
||||
|
||||