Merge pull request #1133 from MaiM-with-u/dev

Dev
This commit is contained in:
SengokuCola
2025-07-25 17:03:29 +08:00
committed by GitHub
46 changed files with 1401 additions and 2032 deletions

View File

@@ -1,12 +1,12 @@
name: Ruff
on:
push:
branches:
- main
- dev
- dev-refactor # 例如:匹配所有以 feature/ 开头的分支
# 添加你希望触发此 workflow 的其他分支
# push:
# branches:
# - main
# - dev
# - dev-refactor # 例如:匹配所有以 feature/ 开头的分支
# # 添加你希望触发此 workflow 的其他分支
workflow_dispatch: # 允许手动触发工作流
branches:
- main

View File

@@ -46,7 +46,7 @@
## 🔥 更新和安装
**最新版本: v0.9.0** ([更新日志](changelogs/changelog.md))
**最新版本: v0.9.1** ([更新日志](changelogs/changelog.md))
可前往 [Release](https://github.com/MaiM-with-u/MaiBot/releases/) 页面下载最新版本
可前往 [启动器发布页面](https://github.com/MaiM-with-u/mailauncher/releases/)下载最新启动器

View File

@@ -1,6 +1,16 @@
# Changelog
## [0.9.0] - 2025-7-25
## [0.9.1] - 2025-7-25
- 修复reply导致的planner异常空跳
- 修复表达方式迁移空目录问题
- 修复reply_to空字段问题
- 将metioned bot 和 at应用到focus prompt中
- 更好的兴趣度计算
- 修复部分模型由于enable_thinking导致的400问题
- 优化关键词提取
## [0.9.0] - 2025-7-24
### 摘要
MaiBot 0.9.0 重磅升级!本版本带来两大核心突破:**全面重构的插件系统**提供更强大的扩展能力和管理功能;**normal和focus模式统一化处理**大幅简化架构并提升性能。同时新增s4u prompt模式优化、语音消息支持、全新情绪系统和mais4u直播互动功能为MaiBot带来更自然、更智能的交互体验

View File

@@ -21,6 +21,8 @@
- `database_api.py`中的`db_query`方法调整了参数顺序以增强参数限制的同时保证了typing正确`db_get`方法增加了`single_result`参数,与`db_query`保持一致。
5. 增加了`logging_api`,可以用`get_logger`来获取日志记录器。
6. 增加了插件和组件管理的API。
7. `BaseCommand`的`execute`方法现在返回一个三元组,包含是否执行成功、可选的回复消息和是否拦截消息。
- 这意味着你终于可以动态控制是否继续后续消息的处理了。
# 插件系统修改
1. 现在所有的匹配模式不再是关键字了,而是枚举类。**(可能有遗漏)**

View File

@@ -4,42 +4,183 @@
Action是给麦麦在回复之外提供额外功能的智能组件**由麦麦的决策系统自主选择是否使用**具有随机性和拟人化的调用特点。Action不是直接响应用户命令而是让麦麦根据聊天情境智能地选择合适的动作使其行为更加自然和真实。
### 🎯 Action的特点
### Action的特点
- 🧠 **智能激活**:麦麦根据多种条件智能判断是否使用
- 🎲 **随机性**:增加行为的不可预测性,更接近真人交流
- 🎲 **随机性**可以使用随机数激活,增加行为的不可预测性,更接近真人交流
- 🤖 **拟人化**:让麦麦的回应更自然、更有个性
- 🔄 **情境感知**:基于聊天上下文做出合适的反应
## 🎯 两层决策机制
---
## 🎯 Action组件的基本结构
首先所有的Action都应该继承`BaseAction`类。
其次每个Action组件都应该实现以下基本信息
```python
class ExampleAction(BaseAction):
action_name = "example_action" # 动作的唯一标识符
action_description = "这是一个示例动作" # 动作描述
activation_type = ActionActivationType.ALWAYS # 这里以 ALWAYS 为例
mode_enable = ChatMode.ALL # 这里以 ALL 为例
associated_types = ["text", "emoji", ...] # 关联类型
parallel_action = False # 是否允许与其他Action并行执行
action_parameters = {"param1": "参数1的说明", "param2": "参数2的说明", ...}
# Action使用场景描述 - 帮助LLM判断何时"选择"使用
action_require = ["使用场景描述1", "使用场景描述2", ...]
async def execute(self) -> Tuple[bool, str]:
"""
执行Action的主要逻辑
Returns:
Tuple[bool, str]: (是否成功, 执行结果描述)
"""
# ---- 执行动作的逻辑 ----
return True, "执行成功"
```
#### associated_types: 该Action会发送的消息类型例如文本、表情等。
这部分由Adapter传递给处理器。
以 MaiBot-Napcat-Adapter 为例,可选项目如下:
| 类型 | 说明 | 格式 |
| --- | --- | --- |
| text | 文本消息 | str |
| emoji | 表情消息 | str: 表情包的无头base64|
| image | 图片消息 | str: 图片的无头base64 |
| reply | 回复消息 | str: 回复的消息ID |
| voice | 语音消息 | str: wav格式语音的无头base64 |
| command | 命令消息 | 参见Adapter文档 |
| voiceurl | 语音URL消息 | str: wav格式语音的URL |
| music | 音乐消息 | str: 这首歌在网易云音乐的音乐id |
| videourl | 视频URL消息 | str: 视频的URL |
| file | 文件消息 | str: 文件的路径 |
**请知悉,对于不同的处理器,其支持的消息类型可能会有所不同。在开发时请注意。**
#### action_parameters: 该Action的参数说明。
这是一个字典键为参数名值为参数说明。这个字段可以帮助LLM理解如何使用这个Action并由LLM返回对应的参数最后传递到 Action 的 action_data 属性中。其格式与你定义的格式完全相同 **除非LLM哈气了返回了错误的内容**。
---
## 🎯 Action 调用的决策机制
Action采用**两层决策机制**来优化性能和决策质量:
### 第一层激活控制Activation Control
> 设计目的在加载许多插件的时候降低LLM决策压力避免让麦麦在过多的选项中纠结。
**激活决定麦麦是否"知道"这个Action的存在**即这个Action是否进入决策候选池。**不被激活的Action麦麦永远不会选择**
**第一层激活控制Activation Control**
> 🎯 **设计目的**在加载许多插件的时候降低LLM决策压力避免让麦麦在过多的选项中纠结
激活决定麦麦是否 **“知道”** 这个Action的存在即这个Action是否进入决策候选池。不被激活的Action麦麦永远不会选择
#### 激活类型说明
**第二层使用决策Usage Decision**
| 激活类型 | 说明 | 使用场景 |
| ------------- | ------------------------------------------- | ------------------------ |
| `NEVER` | 从不激活Action对麦麦不可见 | 临时禁用某个Action |
| `ALWAYS` | 永远激活Action总是在麦麦的候选池中 | 核心功能,如回复、不回复 |
| `LLM_JUDGE` | 通过LLM智能判断当前情境是否需要激活此Action | 需要智能判断的复杂场景 |
| `RANDOM` | 基于随机概率决定是否激活 | 增加行为随机性的功能 |
| `KEYWORD` | 当检测到特定关键词时激活 | 明确触发条件的功能 |
在Action被激活后使用条件决定麦麦什么时候会 **“选择”** 使用这个Action。
#### 聊天模式控制
### 决策参数详解 🔧
| 模式 | 说明 |
| ------------------- | ------------------------ |
| `ChatMode.FOCUS` | 仅在专注聊天模式下可激活 |
| `ChatMode.NORMAL` | 仅在普通聊天模式下可激活 |
| `ChatMode.ALL` | 所有模式下都可激活 |
#### 第一层ActivationType 激活类型说明
### 第二层使用决策Usage Decision
| 激活类型 | 说明 | 使用场景 |
| ----------- | ---------------------------------------- | ---------------------- |
| [`NEVER`](#never-激活) | 从不激活Action对麦麦不可见 | 临时禁用某个Action |
| [`ALWAYS`](#always-激活) | 永远激活Action总是在麦麦的候选池中 | 核心功能,如回复、不回复 |
| [`LLM_JUDGE`](#llm_judge-激活) | 通过LLM智能判断当前情境是否需要激活此Action | 需要智能判断的复杂场景 |
| `RANDOM` | 基于随机概率决定是否激活 | 增加行为随机性的功能 |
| `KEYWORD` | 当检测到特定关键词时激活 | 明确触发条件的功能 |
#### `NEVER` 激活
`ActionActivationType.NEVER` 会使得 Action 永远不会被激活
```python
class DisabledAction(BaseAction):
activation_type = ActionActivationType.NEVER # 永远不激活
async def execute(self) -> Tuple[bool, str]:
# 这个Action永远不会被执行
return False, "这个Action被禁用"
```
#### `ALWAYS` 激活
`ActionActivationType.ALWAYS` 会使得 Action 永远会被激活,即一直在 Action 候选池中
这种激活方式常用于核心功能,如回复或不回复。
```python
class AlwaysActivatedAction(BaseAction):
activation_type = ActionActivationType.ALWAYS # 永远激活
async def execute(self) -> Tuple[bool, str]:
# 执行核心功能
return True, "执行了核心功能"
```
#### `LLM_JUDGE` 激活
`ActionActivationType.LLM_JUDGE`会使得这个 Action 根据 LLM 的判断来决定是否加入候选池。
而 LLM 的判断是基于代码中预设的`llm_judge_prompt`和自动提供的聊天上下文进行的。
因此使用此种方法需要实现`llm_judge_prompt`属性。
```python
class LLMJudgedAction(BaseAction):
activation_type = ActionActivationType.LLM_JUDGE # 通过LLM判断激活
# LLM判断提示词
llm_judge_prompt = (
"判定是否需要使用这个动作的条件:\n"
"1. 用户希望调用XXX这个动作\n"
"...\n"
"请回答\"\"\"\"\n"
)
async def execute(self) -> Tuple[bool, str]:
# 根据LLM判断是否执行
return True, "执行了LLM判断功能"
```
#### `RANDOM` 激活
`ActionActivationType.RANDOM`会使得这个 Action 根据随机概率决定是否加入候选池。
概率则由代码中的`random_activation_probability`控制。在内部实现中我们使用了`random.random()`来生成一个0到1之间的随机数并与这个概率进行比较。
因此使用这个方法需要实现`random_activation_probability`属性。
```python
class SurpriseAction(BaseAction):
activation_type = ActionActivationType.RANDOM # 基于随机概率激活
# 随机激活概率
random_activation_probability = 0.1 # 10%概率激活
async def execute(self) -> Tuple[bool, str]:
# 执行惊喜动作
return True, "发送了惊喜内容"
```
#### `KEYWORD` 激活
`ActionActivationType.KEYWORD`会使得这个 Action 在检测到特定关键词时激活。
关键词由代码中的`activation_keywords`定义,而`keyword_case_sensitive`则控制关键词匹配时是否区分大小写。在内部实现中,我们使用了`in`操作符来检查消息内容是否包含这些关键词。
因此,使用此种方法需要实现`activation_keywords``keyword_case_sensitive`属性。
```python
class GreetingAction(BaseAction):
activation_type = ActionActivationType.KEYWORD # 关键词激活
activation_keywords = ["你好", "hello", "hi", ""] # 关键词配置
keyword_case_sensitive = False # 不区分大小写
async def execute(self) -> Tuple[bool, str]:
# 执行问候逻辑
return True, "发送了问候"
```
#### 第二层:使用决策
**在Action被激活后使用条件决定麦麦什么时候会"选择"使用这个Action**
@@ -49,17 +190,16 @@ Action采用**两层决策机制**来优化性能和决策质量:
- `action_parameters`所需参数影响Action的可执行性
- 当前聊天上下文和麦麦的决策逻辑
### 🎬 决策流程示例
---
假设有一个"发送表情"Action
### 决策流程示例
```python
class EmojiAction(BaseAction):
# 第一层:激活控制
focus_activation_type = ActionActivationType.RANDOM # 专注模式下随机激活
normal_activation_type = ActionActivationType.KEYWORD # 普通模式下关键词激活
activation_keywords = ["表情", "emoji", "😊"]
activation_type = ActionActivationType.RANDOM # 随机激活
random_activation_probability = 0.1 # 10%概率激活
# 第二层:使用决策
action_require = [
"表达情绪时可以选择使用",
@@ -72,311 +212,84 @@ class EmojiAction(BaseAction):
1. **第一层激活判断**
- 普通模式:只有当用户消息包含"表情"、"emoji"或"😊"时,麦麦才"知道"可以使用这个Action
- 专注模式:随机激活,有概率让麦麦"看到"这个Action
- 使用随机数进行决策,当`random.random() < self.random_activation_probability`时,麦麦才"知道"可以使用这个Action
2. **第二层使用决策**
- 即使Action被激活麦麦还会根据 `action_require`中的条件判断是否真正选择使用
- 即使Action被激活麦麦还会根据 `action_require` 中的条件判断是否真正选择使用
- 例如:如果刚刚已经发过表情,根据"不要连续发送多个表情"的要求麦麦可能不会选择这个Action
## 📋 Action必须项清单
每个Action类都**必须**包含以下属性:
### 1. 激活控制必须项
---
## Action 内置属性说明
```python
# 专注模式下的激活类型
focus_activation_type = ActionActivationType.LLM_JUDGE
# 普通模式下的激活类型
normal_activation_type = ActionActivationType.KEYWORD
# 启用的聊天模式
mode_enable = ChatMode.ALL
# 是否允许与其他Action并行执行
parallel_action = False
```
### 2. 基本信息必须项
```python
# Action的唯一标识名称
action_name = "my_action"
# Action的功能描述
action_description = "描述这个Action的具体功能和用途"
```
### 3. 功能定义必须项
```python
# Action参数定义 - 告诉LLM执行时需要什么参数
action_parameters = {
"param1": "参数1的说明",
"param2": "参数2的说明"
}
# Action使用场景描述 - 帮助LLM判断何时"选择"使用
action_require = [
"使用场景描述1",
"使用场景描述2"
]
# 关联的消息类型 - 说明Action能处理什么类型的内容
associated_types = ["text", "emoji", "image"]
```
### 4. 执行方法必须项
```python
async def execute(self) -> Tuple[bool, str]:
"""
执行Action的主要逻辑
Returns:
Tuple[bool, str]: (是否成功, 执行结果描述)
"""
# 执行动作的代码
success = True
message = "动作执行成功"
return success, message
```
## 🔧 激活类型详解
### KEYWORD激活
当检测到特定关键词时激活Action
```python
class GreetingAction(BaseAction):
focus_activation_type = ActionActivationType.KEYWORD
normal_activation_type = ActionActivationType.KEYWORD
# 关键词配置
activation_keywords = ["你好", "hello", "hi", ""]
keyword_case_sensitive = False # 不区分大小写
async def execute(self) -> Tuple[bool, str]:
# 执行问候逻辑
return True, "发送了问候"
```
### LLM_JUDGE激活
通过LLM智能判断是否激活
```python
class HelpAction(BaseAction):
focus_activation_type = ActionActivationType.LLM_JUDGE
normal_activation_type = ActionActivationType.LLM_JUDGE
# LLM判断提示词
llm_judge_prompt = """
判定是否需要使用帮助动作的条件:
1. 用户表达了困惑或需要帮助
2. 用户提出了问题但没有得到满意答案
3. 对话中出现了技术术语或复杂概念
请回答""""
"""
async def execute(self) -> Tuple[bool, str]:
# 执行帮助逻辑
return True, "提供了帮助"
```
### RANDOM激活
基于随机概率激活:
```python
class SurpriseAction(BaseAction):
focus_activation_type = ActionActivationType.RANDOM
normal_activation_type = ActionActivationType.RANDOM
# 随机激活概率
random_activation_probability = 0.1 # 10%概率激活
async def execute(self) -> Tuple[bool, str]:
# 执行惊喜动作
return True, "发送了惊喜内容"
```
### ALWAYS激活
永远激活,常用于核心功能:
```python
class CoreAction(BaseAction):
focus_activation_type = ActionActivationType.ALWAYS
normal_activation_type = ActionActivationType.ALWAYS
async def execute(self) -> Tuple[bool, str]:
# 执行核心功能
return True, "执行了核心功能"
```
### NEVER激活
从不激活,用于临时禁用:
```python
class DisabledAction(BaseAction):
focus_activation_type = ActionActivationType.NEVER
normal_activation_type = ActionActivationType.NEVER
async def execute(self) -> Tuple[bool, str]:
# 这个方法不会被调用
return False, "已禁用"
```
## 📚 BaseAction内置属性和方法
### 内置属性
```python
class MyAction(BaseAction):
class BaseAction:
def __init__(self):
# 消息相关属性
self.message # 当前消息对象
self.chat_stream # 聊天流对象
self.user_id # 用户ID
self.user_nickname # 用户昵称
self.platform # 平台类型 (qq, telegram等)
self.chat_id # 聊天ID
self.is_group # 是否群
self.log_prefix: str # 日志前缀
self.group_id: str # 群组ID
self.group_name: str # 群组名称
self.user_id: str # 用户ID
self.user_nickname: str # 用户昵称
self.platform: str # 平台类型 (qq, telegram等)
self.chat_id: str # 聊天ID
self.chat_stream: ChatStream # 聊天流对象
self.is_group: bool # 是否群聊
# 消息体
self.action_message: dict # 消息数据
# Action相关属性
self.action_data # Action执行时的数据
self.thinking_id # 思考ID
self.matched_groups # 匹配到的组(如果有正则匹配)
self.action_data: dict # Action执行时的数据
self.thinking_id: str # 思考ID
```
### 内置方法
action_message为一个字典包含的键值对如下省略了不必要的键值对
```python
class MyAction(BaseAction):
# 配置相关
{
"message_id": "1234567890", # 消息idstr
"time": 1627545600.0, # 时间戳float
"chat_id": "abcdef123456", # 聊天IDstr
"reply_to": None, # 回复消息idstr或None
"interest_value": 0.85, # 兴趣值float
"is_mentioned": True, # 是否被提及bool
"chat_info_last_active_time": 1627548600.0, # 最后活跃时间float
"processed_plain_text": None, # 处理后的文本str或None
"additional_config": None, # Adapter传来的additional_configdict或None
"is_emoji": False, # 是否为表情bool
"is_picid": False, # 是否为图片IDbool
"is_command": False # 是否为命令bool
}
```
部分值的格式请自行查询数据库。
---
## Action 内置方法说明
```python
class BaseAction:
def get_config(self, key: str, default=None):
"""获取配置值"""
pass
"""获取插件配置值,使用嵌套键访问"""
# 消息发送相关
async def send_text(self, text: str):
async def wait_for_new_message(self, timeout: int = 1200) -> Tuple[bool, str]:
"""等待新消息或超时"""
async def send_text(self, content: str, reply_to: str = "", reply_to_platform_id: str = "", typing: bool = False) -> bool:
"""发送文本消息"""
pass
async def send_emoji(self, emoji_base64: str):
async def send_emoji(self, emoji_base64: str) -> bool:
"""发送表情包"""
pass
async def send_image(self, image_base64: str):
async def send_image(self, image_base64: str) -> bool:
"""发送图片"""
pass
# 动作记录相关
async def store_action_info(self, **kwargs):
"""记录动作信息"""
pass
async def send_custom(self, message_type: str, content: str, typing: bool = False, reply_to: str = "") -> bool:
"""发送自定义类型消息"""
async def store_action_info(self, action_build_into_prompt: bool = False, action_prompt_display: str = "", action_done: bool = True) -> None:
"""存储动作信息到数据库"""
async def send_command(self, command_name: str, args: Optional[dict] = None, display_message: str = "", storage_message: bool = True) -> bool:
"""发送命令消息"""
```
## 🎯 完整Action示例
```python
from src.plugin_system import BaseAction, ActionActivationType, ChatMode
from typing import Tuple
class ExampleAction(BaseAction):
"""示例Action - 展示完整的Action结构"""
# === 激活控制 ===
focus_activation_type = ActionActivationType.LLM_JUDGE
normal_activation_type = ActionActivationType.KEYWORD
mode_enable = ChatMode.ALL
parallel_action = False
# 关键词激活配置
activation_keywords = ["示例", "测试", "example"]
keyword_case_sensitive = False
# LLM判断提示词
llm_judge_prompt = "当用户需要示例或测试功能时激活"
# 随机激活概率如果使用RANDOM类型
random_activation_probability = 0.2
# === 基本信息 ===
action_name = "example_action"
action_description = "这是一个示例Action用于演示Action的完整结构"
# === 功能定义 ===
action_parameters = {
"content": "要处理的内容",
"type": "处理类型",
"options": "可选配置"
}
action_require = [
"用户需要示例功能时使用",
"适合用于测试和演示",
"不要在正式对话中频繁使用"
]
associated_types = ["text", "emoji"]
async def execute(self) -> Tuple[bool, str]:
"""执行示例Action"""
try:
# 获取Action参数
content = self.action_data.get("content", "默认内容")
action_type = self.action_data.get("type", "default")
# 获取配置
enable_feature = self.get_config("example.enable_advanced", False)
max_length = self.get_config("example.max_length", 100)
# 执行具体逻辑
if action_type == "greeting":
await self.send_text(f"你好!这是示例内容:{content}")
elif action_type == "info":
await self.send_text(f"信息:{content[:max_length]}")
else:
await self.send_text("执行了示例Action")
# 记录动作信息
await self.store_action_info(
action_build_into_prompt=True,
action_prompt_display=f"执行了示例动作:{action_type}",
action_done=True
)
return True, f"示例Action执行成功类型{action_type}"
except Exception as e:
return False, f"执行失败:{str(e)}"
```
## 🎯 最佳实践
### 1. Action设计原则
- **单一职责**每个Action只负责一个明确的功能
- **智能激活**:合理选择激活类型,避免过度激活
- **清晰描述**:提供准确的`action_require`帮助LLM决策
- **错误处理**:妥善处理执行过程中的异常情况
### 2. 性能优化
- **激活控制**使用合适的激活类型减少不必要的LLM调用
- **并行执行**:谨慎设置`parallel_action`,避免冲突
- **资源管理**:及时释放占用的资源
### 3. 调试技巧
- **日志记录**:在关键位置添加日志
- **参数验证**:检查`action_data`的有效性
- **配置测试**:测试不同配置下的行为
具体参数与用法参见`BaseAction`基类的定义。

View File

@@ -2,7 +2,9 @@
## 📖 什么是Command
Command是直接响应用户明确指令的组件与Action不同Command是**被动触发**的,当用户输入特定格式的命令时立即执行。Command通过正则表达式匹配用户输入提供确定性的功能服务。
Command是直接响应用户明确指令的组件与Action不同Command是**被动触发**的,当用户输入特定格式的命令时立即执行。
Command通过正则表达式匹配用户输入提供确定性的功能服务。
### 🎯 Command的特点
@@ -12,501 +14,76 @@ Command是直接响应用户明确指令的组件与Action不同Command是
- 🛑 **拦截控制**:可以控制是否阻止消息继续处理
- 📝 **参数解析**:支持从用户输入中提取参数
## 🆚 Action vs Command 核心区别
---
| 特征 | Action | Command |
| ------------------ | --------------------- | ---------------- |
| **触发方式** | 麦麦主动决策使用 | 用户主动触发 |
| **决策机制** | 两层决策(激活+使用) | 直接匹配执行 |
| **随机性** | 有随机性和智能性 | 确定性执行 |
| **用途** | 增强麦麦行为拟人化 | 提供具体功能服务 |
| **性能影响** | 需要LLM决策 | 正则匹配,性能好 |
## 🛠️ Command组件的基本结构
## 🏗️ Command基本结构
### 必须属性
首先Command组件需要继承自`BaseCommand`类,并实现必要的方法。
```python
from src.plugin_system import BaseCommand
class ExampleCommand(BaseCommand):
command_name = "example" # 命令名称,作为唯一标识符
command_description = "这是一个示例命令" # 命令描述
command_pattern = r"" # 命令匹配的正则表达式
class MyCommand(BaseCommand):
# 正则表达式匹配模式
command_pattern = r"^/help\s+(?P<topic>\w+)$"
# 命令帮助说明
command_help = "显示指定主题的帮助信息"
# 使用示例
command_examples = ["/help action", "/help command"]
# 是否拦截后续处理
intercept_message = True
async def execute(self) -> Tuple[bool, Optional[str]]:
"""执行命令逻辑"""
# 命令执行逻辑
return True, "执行成功"
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)`
| 属性 | 类型 | 说明 |
| --------------------- | --------- | -------------------- |
| `command_pattern` | str | 正则表达式匹配模式 |
| `command_help` | str | 命令帮助说明 |
| `command_examples` | List[str] | 使用示例列表 |
| `intercept_message` | bool | 是否拦截消息继续处理 |
这样在匹配时,内部实现可以使用`re.match.groupdict()`方法获取到所有捕获组的参数,并以字典的形式存储在`self.matched_groups`中。
## 🔍 正则表达式匹配
### 基础匹配
### 匹配样例
假设我们有一个命令`/example param1=value1 param2=value2`,对应的正则表达式可以是:
```python
class SimpleCommand(BaseCommand):
# 匹配 /ping
command_pattern = r"^/ping$"
async def execute(self) -> Tuple[bool, Optional[str]]:
await self.send_text("Pong!")
return True, "发送了Pong回复"
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
```
### 参数捕获
使用命名组 `(?P<n>pattern)` 捕获参数:
---
## Command 内置方法说明
```python
class UserCommand(BaseCommand):
# 匹配 /user add 张三 或 /user del 李四
command_pattern = r"^/user\s+(?P<action>add|del|info)\s+(?P<username>\w+)$"
async def execute(self) -> Tuple[bool, Optional[str]]:
# 通过 self.matched_groups 获取捕获的参数
action = self.matched_groups.get("action")
username = self.matched_groups.get("username")
if action == "add":
await self.send_text(f"添加用户:{username}")
elif action == "del":
await self.send_text(f"删除用户:{username}")
elif action == "info":
await self.send_text(f"用户信息:{username}")
return True, f"执行了{action}操作"
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:
"""发送图片"""
```
### 可选参数
```python
class HelpCommand(BaseCommand):
# 匹配 /help 或 /help topic
command_pattern = r"^/help(?:\s+(?P<topic>\w+))?$"
async def execute(self) -> Tuple[bool, Optional[str]]:
topic = self.matched_groups.get("topic")
if topic:
await self.send_text(f"显示{topic}的帮助")
else:
await self.send_text("显示总体帮助")
return True, "显示了帮助信息"
```
## 🛑 拦截控制详解
### 拦截消息 (intercept_message = True)
```python
class AdminCommand(BaseCommand):
command_pattern = r"^/admin\s+.+"
command_help = "管理员命令"
intercept_message = True # 拦截,不继续处理
async def execute(self) -> Tuple[bool, Optional[str]]:
# 执行管理操作
await self.send_text("执行管理命令")
# 消息不会继续传递给其他组件
return True, "管理命令执行完成"
```
### 不拦截消息 (intercept_message = False)
```python
class LogCommand(BaseCommand):
command_pattern = r"^/log\s+.+"
command_help = "记录日志"
intercept_message = False # 不拦截,继续处理
async def execute(self) -> Tuple[bool, Optional[str]]:
# 记录日志但不阻止后续处理
await self.send_text("已记录到日志")
# 消息会继续传递可能触发Action等其他组件
return True, "日志记录完成"
```
### 拦截控制的用途
| 场景 | intercept_message | 说明 |
| -------- | ----------------- | -------------------------- |
| 系统命令 | True | 防止命令被当作普通消息处理 |
| 查询命令 | True | 直接返回结果,无需后续处理 |
| 日志命令 | False | 记录但允许消息继续流转 |
| 监控命令 | False | 监控但不影响正常聊天 |
## 🎨 完整Command示例
### 用户管理Command
```python
from src.plugin_system import BaseCommand
from typing import Tuple, Optional
class UserManagementCommand(BaseCommand):
"""用户管理Command - 展示复杂参数处理"""
command_pattern = r"^/user\s+(?P<action>add|del|list|info)\s*(?P<username>\w+)?(?:\s+--(?P<options>.+))?$"
command_help = "用户管理命令,支持添加、删除、列表、信息查询"
command_examples = [
"/user add 张三",
"/user del 李四",
"/user list",
"/user info 王五",
"/user add 赵六 --role=admin"
]
intercept_message = True
async def execute(self) -> Tuple[bool, Optional[str]]:
"""执行用户管理命令"""
try:
action = self.matched_groups.get("action")
username = self.matched_groups.get("username")
options = self.matched_groups.get("options")
# 解析选项
parsed_options = self._parse_options(options) if options else {}
if action == "add":
return await self._add_user(username, parsed_options)
elif action == "del":
return await self._delete_user(username)
elif action == "list":
return await self._list_users()
elif action == "info":
return await self._show_user_info(username)
else:
await self.send_text("❌ 不支持的操作")
return False, f"不支持的操作: {action}"
except Exception as e:
await self.send_text(f"❌ 命令执行失败: {str(e)}")
return False, f"执行失败: {e}"
def _parse_options(self, options_str: str) -> dict:
"""解析命令选项"""
options = {}
if options_str:
for opt in options_str.split():
if "=" in opt:
key, value = opt.split("=", 1)
options[key] = value
return options
async def _add_user(self, username: str, options: dict) -> Tuple[bool, str]:
"""添加用户"""
if not username:
await self.send_text("❌ 请指定用户名")
return False, "缺少用户名参数"
# 检查用户是否已存在
existing_users = await self._get_user_list()
if username in existing_users:
await self.send_text(f"❌ 用户 {username} 已存在")
return False, f"用户已存在: {username}"
# 添加用户逻辑
role = options.get("role", "user")
await self.send_text(f"✅ 成功添加用户 {username},角色: {role}")
return True, f"添加用户成功: {username}"
async def _delete_user(self, username: str) -> Tuple[bool, str]:
"""删除用户"""
if not username:
await self.send_text("❌ 请指定用户名")
return False, "缺少用户名参数"
await self.send_text(f"✅ 用户 {username} 已删除")
return True, f"删除用户成功: {username}"
async def _list_users(self) -> Tuple[bool, str]:
"""列出所有用户"""
users = await self._get_user_list()
if users:
user_list = "\n".join([f"{user}" for user in users])
await self.send_text(f"📋 用户列表:\n{user_list}")
else:
await self.send_text("📋 暂无用户")
return True, "显示用户列表"
async def _show_user_info(self, username: str) -> Tuple[bool, str]:
"""显示用户信息"""
if not username:
await self.send_text("❌ 请指定用户名")
return False, "缺少用户名参数"
# 模拟用户信息
user_info = f"""
👤 用户信息: {username}
📧 邮箱: {username}@example.com
🕒 注册时间: 2024-01-01
🎯 角色: 普通用户
""".strip()
await self.send_text(user_info)
return True, f"显示用户信息: {username}"
async def _get_user_list(self) -> list:
"""获取用户列表(示例)"""
return ["张三", "李四", "王五"]
```
### 系统信息Command
```python
class SystemInfoCommand(BaseCommand):
"""系统信息Command - 展示系统查询功能"""
command_pattern = r"^/(?:status|info)(?:\s+(?P<type>system|memory|plugins|all))?$"
command_help = "查询系统状态信息"
command_examples = [
"/status",
"/info system",
"/status memory",
"/info plugins"
]
intercept_message = True
async def execute(self) -> Tuple[bool, Optional[str]]:
"""执行系统信息查询"""
info_type = self.matched_groups.get("type", "all")
try:
if info_type in ["system", "all"]:
await self._show_system_info()
if info_type in ["memory", "all"]:
await self._show_memory_info()
if info_type in ["plugins", "all"]:
await self._show_plugin_info()
return True, f"显示了{info_type}类型的系统信息"
except Exception as e:
await self.send_text(f"❌ 获取系统信息失败: {str(e)}")
return False, f"查询失败: {e}"
async def _show_system_info(self):
"""显示系统信息"""
import platform
import datetime
system_info = f"""
🖥️ **系统信息**
📱 平台: {platform.system()} {platform.release()}
🐍 Python: {platform.python_version()}
⏰ 运行时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
""".strip()
await self.send_text(system_info)
async def _show_memory_info(self):
"""显示内存信息"""
import psutil
memory = psutil.virtual_memory()
memory_info = f"""
💾 **内存信息**
📊 总内存: {memory.total // (1024**3)} GB
🟢 可用内存: {memory.available // (1024**3)} GB
📈 使用率: {memory.percent}%
""".strip()
await self.send_text(memory_info)
async def _show_plugin_info(self):
"""显示插件信息"""
# 通过配置获取插件信息
plugins = await self._get_loaded_plugins()
plugin_info = f"""
🔌 **插件信息**
📦 已加载插件: {len(plugins)}
🔧 活跃插件: {len([p for p in plugins if p.get('active', False)])}
""".strip()
await self.send_text(plugin_info)
async def _get_loaded_plugins(self) -> list:
"""获取已加载的插件列表"""
# 这里可以通过配置或API获取实际的插件信息
return [
{"name": "core_actions", "active": True},
{"name": "example_plugin", "active": True},
]
```
### 自定义前缀Command
```python
class CustomPrefixCommand(BaseCommand):
"""自定义前缀Command - 展示非/前缀的命令"""
# 使用!前缀而不是/前缀
command_pattern = r"^[!](?P<command>roll|dice)\s*(?P<count>\d+)?$"
command_help = "骰子命令,使用!前缀"
command_examples = ["!roll", "!dice 6", "roll 20"]
intercept_message = True
async def execute(self) -> Tuple[bool, Optional[str]]:
"""执行骰子命令"""
import random
command = self.matched_groups.get("command")
count = int(self.matched_groups.get("count", "6"))
# 限制骰子面数
if count > 100:
await self.send_text("❌ 骰子面数不能超过100")
return False, "骰子面数超限"
result = random.randint(1, count)
await self.send_text(f"🎲 投掷{count}面骰子,结果: {result}")
return True, f"投掷了{count}面骰子,结果{result}"
```
## 📊 性能优化建议
### 1. 正则表达式优化
```python
# ✅ 好的做法 - 简单直接
command_pattern = r"^/ping$"
# ❌ 避免 - 过于复杂
command_pattern = r"^/(?:ping|pong|test|check|status|info|help|...)"
# ✅ 好的做法 - 分离复杂逻辑
```
### 2. 参数验证
```python
# ✅ 好的做法 - 早期验证
async def execute(self) -> Tuple[bool, Optional[str]]:
username = self.matched_groups.get("username")
if not username:
await self.send_text("❌ 请提供用户名")
return False, "缺少参数"
# 继续处理...
```
### 3. 错误处理
```python
# ✅ 好的做法 - 完整错误处理
async def execute(self) -> Tuple[bool, Optional[str]]:
try:
# 主要逻辑
result = await self._process_command()
return True, "执行成功"
except ValueError as e:
await self.send_text(f"❌ 参数错误: {e}")
return False, f"参数错误: {e}"
except Exception as e:
await self.send_text(f"❌ 执行失败: {e}")
return False, f"执行失败: {e}"
```
## 🎯 最佳实践
### 1. 命令设计原则
```python
# ✅ 好的命令设计
"/user add 张三" # 动作 + 对象 + 参数
"/config set key=value" # 动作 + 子动作 + 参数
"/help command" # 动作 + 可选参数
# ❌ 避免的设计
"/add_user_with_name_张三" # 过于冗长
"/u a 张三" # 过于简写
```
### 2. 帮助信息
```python
class WellDocumentedCommand(BaseCommand):
command_pattern = r"^/example\s+(?P<param>\w+)$"
command_help = "示例命令:处理指定参数并返回结果"
command_examples = [
"/example test",
"/example debug",
"/example production"
]
```
### 3. 错误处理
```python
async def execute(self) -> Tuple[bool, Optional[str]]:
param = self.matched_groups.get("param")
# 参数验证
if param not in ["test", "debug", "production"]:
await self.send_text("❌ 无效的参数,支持: test, debug, production")
return False, "无效参数"
# 执行逻辑
try:
result = await self._process_param(param)
await self.send_text(f"✅ 处理完成: {result}")
return True, f"处理{param}成功"
except Exception as e:
await self.send_text("❌ 处理失败,请稍后重试")
return False, f"处理失败: {e}"
```
### 4. 配置集成
```python
async def execute(self) -> Tuple[bool, Optional[str]]:
# 从配置读取设置
max_items = self.get_config("command.max_items", 10)
timeout = self.get_config("command.timeout", 30)
# 使用配置进行处理
...
```
## 📝 Command vs Action 选择指南
### 使用Command的场景
- ✅ 用户需要明确调用特定功能
- ✅ 需要精确的参数控制
- ✅ 管理和配置操作
- ✅ 查询和信息显示
- ✅ 系统维护命令
### 使用Action的场景
- ✅ 增强麦麦的智能行为
- ✅ 根据上下文自动触发
- ✅ 情绪和表情表达
- ✅ 智能建议和帮助
- ✅ 随机化的互动
具体参数与用法参见`BaseCommand`基类的定义。

View File

@@ -147,7 +147,7 @@ python scripts/manifest_tool.py validate src/plugins/my_plugin
## 📋 字段说明
### 基本信息
- `manifest_version`: manifest格式版本当前为3
- `manifest_version`: manifest格式版本当前为1
- `name`: 插件显示名称(必需)
- `version`: 插件版本号(必需)
- `description`: 插件功能描述(必需)
@@ -165,10 +165,12 @@ python scripts/manifest_tool.py validate src/plugins/my_plugin
- `categories`: 分类数组(可选,建议填写)
### 兼容性
- `host_application`: 主机应用兼容性(可选)
- `host_application`: 主机应用兼容性(可选,建议填写
- `min_version`: 最低兼容版本
- `max_version`: 最高兼容版本
⚠️ 在不填写的情况下,插件将默认支持所有版本。**(由于我们在不同版本对插件系统进行了大量的重构,这种情况几乎不可能。)**
### 国际化
- `default_locale`: 默认语言(可选)
- `locales_path`: 语言文件目录(可选)
@@ -185,24 +187,13 @@ python scripts/manifest_tool.py validate src/plugins/my_plugin
2. **编码格式**manifest文件必须使用UTF-8编码
3. **JSON格式**文件必须是有效的JSON格式
4. **必需字段**`manifest_version`、`name`、`version`、`description`、`author.name`是必需的
5. **版本兼容**当前只支持manifest_version = 3
5. **版本兼容**:当前只支持`manifest_version = 1`
## 🔍 常见问题
### Q: 为什么要强制要求manifest文件
A: Manifest文件提供了插件的标准化元数据使得插件管理、依赖检查、版本兼容性验证等功能成为可能。
### Q: 可以不填写可选字段吗?
A: 可以。所有标记为"可选"的字段都可以不填写,但建议至少填写`license`和`keywords`。
### Q: 如何快速为所有插件创建manifest
A: 可以编写脚本批量处理:
```bash
# 扫描并为每个缺少manifest的插件创建最小化manifest
python scripts/manifest_tool.py scan src/plugins
# 然后手动为每个插件运行create-minimal命令
```
### Q: manifest验证失败怎么办
A: 根据验证器的错误提示修复相应问题。错误会导致插件加载失败,警告不会。
@@ -210,5 +201,5 @@ A: 根据验证器的错误提示修复相应问题。错误会导致插件加
查看内置插件的manifest文件作为参考
- `src/plugins/built_in/core_actions/_manifest.json`
- `src/plugins/built_in/doubao_pic_plugin/_manifest.json`
- `src/plugins/built_in/tts_plugin/_manifest.json`
- `src/plugins/hello_world_plugin/_manifest.json`

View File

@@ -2,12 +2,11 @@
## 📖 什么是工具系统
工具系统是MaiBot的信息获取能力扩展组件**专门用于在Focus模式下扩宽麦麦能够获得的信息量**。如果说Action组件功能五花八门可以拓展麦麦能做的事情那么Tool就是在某个过程中拓宽了麦麦能够获得的信息量。
工具系统是MaiBot的信息获取能力扩展组件。如果说Action组件功能五花八门可以拓展麦麦能做的事情那么Tool就是在某个过程中拓宽了麦麦能够获得的信息量。
### 🎯 工具系统的特点
- 🔍 **信息获取增强**:扩展麦麦获取外部信息的能力
- 🎯 **Focus模式专用**:仅在专注聊天模式下工作,必须开启工具处理器
- 📊 **数据丰富**:帮助麦麦获得更多背景信息和实时数据
- 🔌 **插件式架构**:支持独立开发和注册新工具
-**自动发现**:工具会被系统自动识别和注册
@@ -17,7 +16,6 @@
| 特征 | Action | Command | Tool |
|-----|-------|---------|------|
| **主要用途** | 扩展麦麦行为能力 | 响应用户指令 | 扩展麦麦信息获取 |
| **适用模式** | 所有模式 | 所有模式 | 仅Focus模式 |
| **触发方式** | 麦麦智能决策 | 用户主动触发 | LLM根据需要调用 |
| **目标** | 让麦麦做更多事情 | 提供具体功能 | 让麦麦知道更多信息 |
| **使用场景** | 增强交互体验 | 功能服务 | 信息查询和分析 |
@@ -54,7 +52,7 @@ class MyTool(BaseTool):
"required": ["query"]
}
async def execute(self, function_args, message_txt=""):
async def execute(self, function_args: Dict[str, Any]):
"""执行工具逻辑"""
# 实现工具功能
result = f"查询结果: {function_args.get('query')}"
@@ -63,9 +61,6 @@ class MyTool(BaseTool):
"name": self.name,
"content": result
}
# 注册工具
register_tool(MyTool)
```
### 属性说明
@@ -80,7 +75,7 @@ register_tool(MyTool)
| 方法 | 参数 | 返回值 | 说明 |
|-----|------|--------|------|
| `execute` | `function_args`, `message_txt` | `dict` | 执行工具核心逻辑 |
| `execute` | `function_args` | `dict` | 执行工具核心逻辑 |
## 🔄 自动注册机制
@@ -88,28 +83,14 @@ register_tool(MyTool)
1. **文件扫描**:系统自动遍历 `tool_can_use` 目录中的所有Python文件
2. **类识别**:寻找继承自 `BaseTool` 的工具类
3. **自动注册**调用 `register_tool()` 的工具会被注册到系统中
3. **自动注册**只需要实现对应的类并把文件放在正确文件夹中就可自动注册
4. **即用即加载**:工具在需要时被实例化和调用
### 注册流程
```python
# 1. 创建工具类
class WeatherTool(BaseTool):
name = "weather_query"
description = "查询指定城市的天气信息"
# ...
# 2. 注册工具(在文件末尾)
register_tool(WeatherTool)
# 3. 系统自动发现(无需手动操作)
# discover_tools() 函数会自动完成注册
```
---
## 🎨 完整工具示例
### 天气查询工具
完成一个天气查询工具
```python
from src.tools.tool_can_use.base_tool import BaseTool, register_tool
@@ -192,102 +173,9 @@ class WeatherTool(BaseTool):
💧 湿度: {humidity}%
━━━━━━━━━━━━━━━━━━
""".strip()
# 注册工具
register_tool(WeatherTool)
```
### 知识查询工具
```python
from src.tools.tool_can_use.base_tool import BaseTool, register_tool
class KnowledgeSearchTool(BaseTool):
"""知识搜索工具 - 查询百科知识和专业信息"""
name = "knowledge_search"
description = "搜索百科知识、专业术语解释、历史事件等信息"
parameters = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "要搜索的知识关键词或问题"
},
"category": {
"type": "string",
"description": "知识分类science(科学)、history(历史)、technology(技术)、general(通用)等",
"enum": ["science", "history", "technology", "general"]
},
"language": {
"type": "string",
"description": "结果语言zh(中文)、en(英文)",
"enum": ["zh", "en"]
}
},
"required": ["query"]
}
async def execute(self, function_args, message_txt=""):
"""执行知识搜索"""
try:
query = function_args.get("query")
category = function_args.get("category", "general")
language = function_args.get("language", "zh")
# 执行搜索逻辑
search_results = await self._search_knowledge(query, category, language)
# 格式化结果
result = self._format_search_results(query, search_results)
return {
"name": self.name,
"content": result
}
except Exception as e:
return {
"name": self.name,
"content": f"知识搜索失败: {str(e)}"
}
async def _search_knowledge(self, query: str, category: str, language: str) -> list:
"""执行知识搜索"""
# 这里实现实际的搜索逻辑
# 可以对接维基百科API、百度百科API等
# 示例返回数据
return [
{
"title": f"{query}的定义",
"summary": f"关于{query}的详细解释...",
"source": "Wikipedia"
}
]
def _format_search_results(self, query: str, results: list) -> str:
"""格式化搜索结果"""
if not results:
return f"未找到关于 '{query}' 的相关信息"
formatted_text = f"📚 关于 '{query}' 的搜索结果:\n\n"
for i, result in enumerate(results[:3], 1): # 限制显示前3条
title = result.get("title", "无标题")
summary = result.get("summary", "无摘要")
source = result.get("source", "未知来源")
formatted_text += f"{i}. **{title}**\n"
formatted_text += f" {summary}\n"
formatted_text += f" 📖 来源: {source}\n\n"
return formatted_text.strip()
# 注册工具
register_tool(KnowledgeSearchTool)
```
---
## 📊 工具开发步骤
@@ -323,86 +211,21 @@ class MyNewTool(BaseTool):
"name": self.name,
"content": "执行结果"
}
register_tool(MyNewTool)
```
### 3. 测试工具
创建测试文件验证工具功能:
```python
import asyncio
from my_new_tool import MyNewTool
async def test_tool():
tool = MyNewTool()
result = await tool.execute({"param": "value"})
print(result)
asyncio.run(test_tool())
```
### 4. 系统集成
### 3. 系统集成
工具创建完成后,系统会自动发现和注册,无需额外配置。
## ⚙️ 工具处理器配置
### 启用工具处理器
工具系统仅在Focus模式下工作需要确保工具处理器已启用
```python
# 在Focus模式配置中
focus_config = {
"enable_tool_processor": True, # 必须启用
"tool_timeout": 30, # 工具执行超时时间(秒)
"max_tools_per_message": 3 # 单次消息最大工具调用数
}
```
### 工具使用流程
1. **用户发送消息**在Focus模式下发送需要信息查询的消息
2. **LLM判断需求**:麦麦分析消息,判断是否需要使用工具获取信息
3. **选择工具**:根据需求选择合适的工具
4. **调用工具**:执行工具获取信息
5. **整合回复**:将工具获取的信息整合到回复中
### 使用示例
```python
# 用户消息示例
"今天北京的天气怎么样?"
# 系统处理流程:
# 1. 麦麦识别这是天气查询需求
# 2. 调用 weather_query 工具
# 3. 获取北京天气信息
# 4. 整合信息生成回复
# 最终回复:
"根据最新天气数据北京今天晴天温度22°C湿度45%,适合外出活动。"
```
---
## 🚨 注意事项和限制
### 当前限制
1. **模式限制**仅在Focus模式下可用
2. **独立开发**需要单独编写,暂未完全融入插件系统
3. **适用范围**主要适用于信息获取场景
4. **配置要求**:必须开启工具处理器
### 未来改进
工具系统在之后可能会面临以下修改:
1. **插件系统融合**:更好地集成到插件系统中
2. **模式扩展**:可能扩展到其他聊天模式
3. **配置简化**:简化配置和部署流程
4. **性能优化**:提升工具调用效率
1. **独立开发**需要单独编写,暂未完全融入插件系统
2. **适用范围**主要适用于信息获取场景
3. **配置要求**必须开启工具处理器
### 开发建议

View File

@@ -20,6 +20,7 @@ class HelloAction(BaseAction):
# === 基本信息(必须填写)===
action_name = "hello_greeting"
action_description = "向用户发送问候消息"
activation_type = ActionActivationType.ALWAYS # 始终激活
# === 功能描述(必须填写)===
action_parameters = {"greeting_message": "要发送的问候消息"}
@@ -44,8 +45,7 @@ class ByeAction(BaseAction):
action_description = "向用户发送告别消息"
# 使用关键词激活
focus_activation_type = ActionActivationType.KEYWORD
normal_activation_type = ActionActivationType.KEYWORD
activation_type = ActionActivationType.KEYWORD
# 关键词设置
activation_keywords = ["再见", "bye", "88", "拜拜"]
@@ -75,11 +75,8 @@ class TimeCommand(BaseCommand):
# === 命令设置(必须填写)===
command_pattern = r"^/time$" # 精确匹配 "/time" 命令
command_help = "查询当前时间"
command_examples = ["/time"]
intercept_message = True # 拦截消息,不让其他组件处理
async def execute(self) -> Tuple[bool, str]:
async def execute(self) -> Tuple[bool, str, bool]:
"""执行时间查询"""
import datetime
@@ -92,7 +89,7 @@ class TimeCommand(BaseCommand):
message = f"⏰ 当前时间:{time_str}"
await self.send_text(message)
return True, f"显示了当前时间: {time_str}"
return True, f"显示了当前时间: {time_str}", True
class PrintMessage(BaseEventHandler):

View File

@@ -1,50 +0,0 @@
{
"manifest_version": 1,
"name": "AI拍照插件 (Take Picture Plugin)",
"version": "1.0.0",
"description": "基于AI图像生成的拍照插件可以生成逼真的自拍照片支持照片存储和展示功能。",
"author": {
"name": "SengokuCola",
"url": "https://github.com/SengokuCola"
},
"license": "GPL-v3.0-or-later",
"host_application": {
"min_version": "0.9.0"
},
"homepage_url": "https://github.com/MaiM-with-u/maibot",
"repository_url": "https://github.com/MaiM-with-u/maibot",
"keywords": ["camera", "photo", "selfie", "ai", "image", "generation"],
"categories": ["AI Tools", "Image Processing", "Entertainment"],
"default_locale": "zh-CN",
"locales_path": "_locales",
"plugin_info": {
"is_built_in": false,
"plugin_type": "image_generator",
"api_dependencies": ["volcengine"],
"components": [
{
"type": "action",
"name": "take_picture",
"description": "生成一张用手机拍摄的照片,比如自拍或者近照",
"activation_modes": ["keyword"],
"keywords": ["拍张照", "自拍", "发张照片", "看看你", "你的照片"]
},
{
"type": "command",
"name": "show_recent_pictures",
"description": "展示最近生成的5张照片",
"pattern": "/show_pics"
}
],
"features": [
"AI驱动的自拍照生成",
"个性化照片风格",
"照片历史记录",
"缓存机制优化",
"火山引擎API集成"
]
}
}

View File

@@ -1,517 +0,0 @@
"""
拍照插件
功能特性:
- Action: 生成一张自拍照prompt由人设和模板生成
- Command: 展示最近生成的照片
#此插件并不完善
#此插件并不完善
#此插件并不完善
#此插件并不完善
#此插件并不完善
#此插件并不完善
#此插件并不完善
包含组件:
- 拍照Action - 生成自拍照
- 展示照片Command - 展示最近生成的照片
"""
from typing import List, Tuple, Type, Optional
import random
import datetime
import json
import os
import asyncio
import urllib.request
import urllib.error
import base64
import traceback
from src.plugin_system.base.base_plugin import BasePlugin
from src.plugin_system.base.base_action import BaseAction
from src.plugin_system.base.base_command import BaseCommand
from src.plugin_system.base.component_types import ComponentInfo, ActionActivationType, ChatMode
from src.plugin_system.base.config_types import ConfigField
from src.plugin_system import register_plugin
from src.common.logger import get_logger
logger = get_logger("take_picture_plugin")
# 定义数据目录常量
DATA_DIR = os.path.join("data", "take_picture_data")
# 确保数据目录存在
os.makedirs(DATA_DIR, exist_ok=True)
# 创建全局锁
file_lock = asyncio.Lock()
class TakePictureAction(BaseAction):
"""生成一张自拍照"""
focus_activation_type = ActionActivationType.KEYWORD
normal_activation_type = ActionActivationType.KEYWORD
mode_enable = ChatMode.ALL
parallel_action = False
action_name = "take_picture"
action_description = "生成一张用手机拍摄,比如自拍或者近照"
activation_keywords = ["拍张照", "自拍", "发张照片", "看看你", "你的照片"]
keyword_case_sensitive = False
action_parameters = {}
action_require = ["当用户想看你的照片时使用", "当用户让你发自拍时使用当想随手拍眼前的场景时使用"]
associated_types = ["text", "image"]
# 内置的Prompt模板如果配置文件中没有定义将使用这些模板
DEFAULT_PROMPT_TEMPLATES = [
"极其频繁无奇的iPhone自拍照没有明确的主体或构图感就是随手一拍的快照照片略带运动模糊阳光或室内打光不均匀导致的轻微曝光过度整体呈现出一种刻意的平庸感就像是从口袋里拿手机时不小心拍到的一张自拍。主角是{name}{personality}"
]
# 简单的请求缓存,避免短时间内重复请求
_request_cache = {}
async def execute(self) -> Tuple[bool, Optional[str]]:
logger.info(f"{self.log_prefix} 执行拍照动作")
try:
# 配置验证
http_base_url = self.api.get_config("api.base_url")
http_api_key = self.api.get_config("api.volcano_generate_api_key")
if not (http_base_url and http_api_key):
error_msg = "抱歉照片生成功能所需的API配置如API地址或密钥不完整无法提供服务。"
await self.send_text(error_msg)
logger.error(f"{self.log_prefix} HTTP调用配置缺失: base_url 或 volcano_generate_api_key.")
return False, "API配置不完整"
# API密钥验证
if http_api_key == "YOUR_DOUBAO_API_KEY_HERE":
error_msg = "照片生成功能尚未配置请设置正确的API密钥。"
await self.send_text(error_msg)
logger.error(f"{self.log_prefix} API密钥未配置")
return False, "API密钥未配置"
# 获取全局配置信息
bot_nickname = self.api.get_global_config("bot.nickname", "麦麦")
bot_personality = self.api.get_global_config("personality.personality_core", "")
personality_side = self.api.get_global_config("personality.personality_side", [])
if personality_side:
bot_personality += random.choice(personality_side)
# 准备模板变量
template_vars = {"name": bot_nickname, "personality": bot_personality}
logger.info(f"{self.log_prefix} 使用的全局配置: name={bot_nickname}, personality={bot_personality}")
# 尝试从配置文件获取模板,如果没有则使用默认模板
templates = self.api.get_config("picture.prompt_templates", self.DEFAULT_PROMPT_TEMPLATES)
if not templates:
logger.warning(f"{self.log_prefix} 未找到有效的提示词模板,使用默认模板")
templates = self.DEFAULT_PROMPT_TEMPLATES
prompt_template = random.choice(templates)
# 填充模板
final_prompt = prompt_template.format(**template_vars)
logger.info(f"{self.log_prefix} 生成的最终Prompt: {final_prompt}")
# 从配置获取参数
model = self.api.get_config("picture.default_model", "doubao-seedream-3-0-t2i-250415")
size = self.api.get_config("picture.default_size", "1024x1024")
watermark = self.api.get_config("picture.default_watermark", True)
guidance_scale = self.api.get_config("picture.default_guidance_scale", 2.5)
seed = self.api.get_config("picture.default_seed", 42)
# 检查缓存
enable_cache = self.api.get_config("storage.enable_cache", True)
if enable_cache:
cache_key = self._get_cache_key(final_prompt, model, size)
if cache_key in self._request_cache:
cached_result = self._request_cache[cache_key]
logger.info(f"{self.log_prefix} 使用缓存的图片结果")
await self.send_text("我之前拍过类似的照片,用之前的结果~")
# 直接发送缓存的结果
send_success = await self._send_image(cached_result)
if send_success:
await self.send_text("这是我的照片,好看吗?")
return True, "照片已发送(缓存)"
else:
# 缓存失败,清除这个缓存项并继续正常流程
del self._request_cache[cache_key]
await self.send_text("正在为你拍照,请稍候...")
try:
seed = random.randint(1, 1000000)
success, result = await asyncio.to_thread(
self._make_http_image_request,
prompt=final_prompt,
model=model,
size=size,
seed=seed,
guidance_scale=guidance_scale,
watermark=watermark,
)
except Exception as e:
logger.error(f"{self.log_prefix} (HTTP) 异步请求执行失败: {e!r}", exc_info=True)
traceback.print_exc()
success = False
result = f"照片生成服务遇到意外问题: {str(e)[:100]}"
if success:
image_url = result
logger.info(f"{self.log_prefix} 图片URL获取成功: {image_url[:70]}... 下载并编码.")
try:
encode_success, encode_result = await asyncio.to_thread(self._download_and_encode_base64, image_url)
except Exception as e:
logger.error(f"{self.log_prefix} (B64) 异步下载/编码失败: {e!r}", exc_info=True)
traceback.print_exc()
encode_success = False
encode_result = f"图片下载或编码时发生内部错误: {str(e)[:100]}"
if encode_success:
base64_image_string = encode_result
# 更新缓存
if enable_cache:
self._update_cache(final_prompt, model, size, base64_image_string)
# 发送图片
send_success = await self._send_image(base64_image_string)
if send_success:
# 存储到文件
await self._store_picture_info(final_prompt, image_url)
logger.info(f"{self.log_prefix} 成功生成并存储照片: {image_url}")
await self.send_text("当当当当~这是我刚拍的照片,好看吗?")
return True, f"成功生成照片: {image_url}"
else:
await self.send_text("照片生成了,但发送失败了,可能是格式问题...")
return False, "照片发送失败"
else:
await self.send_text(f"照片下载失败: {encode_result}")
return False, encode_result
else:
await self.send_text(f"哎呀,拍照失败了: {result}")
return False, result
except Exception as e:
logger.error(f"{self.log_prefix} 执行拍照动作失败: {e}", exc_info=True)
traceback.print_exc()
await self.send_text("呜呜,拍照的时候出了一点小问题...")
return False, str(e)
async def _store_picture_info(self, prompt: str, image_url: str):
"""将照片信息存入日志文件"""
log_file = self.api.get_config("storage.log_file", "picture_log.json")
log_path = os.path.join(DATA_DIR, log_file)
max_photos = self.api.get_config("storage.max_photos", 50)
async with file_lock:
try:
if os.path.exists(log_path):
with open(log_path, "r", encoding="utf-8") as f:
log_data = json.load(f)
else:
log_data = []
except (json.JSONDecodeError, FileNotFoundError):
log_data = []
# 添加新照片
log_data.append(
{"prompt": prompt, "image_url": image_url, "timestamp": datetime.datetime.now().isoformat()}
)
# 如果超过最大数量,删除最旧的
if len(log_data) > max_photos:
log_data = sorted(log_data, key=lambda x: x.get("timestamp", ""), reverse=True)[:max_photos]
try:
with open(log_path, "w", encoding="utf-8") as f:
json.dump(log_data, f, ensure_ascii=False, indent=4)
except Exception as e:
logger.error(f"{self.log_prefix} 写入照片日志文件失败: {e}", exc_info=True)
def _make_http_image_request(
self, prompt: str, model: str, size: str, seed: int, guidance_scale: float, watermark: bool
) -> Tuple[bool, str]:
"""发送HTTP请求到火山引擎豆包API生成图片"""
try:
base_url = self.api.get_config("api.base_url")
api_key = self.api.get_config("api.volcano_generate_api_key")
# 构建请求URL和头部
endpoint = f"{base_url.rstrip('/')}/images/generations"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
# 构建请求体
request_body = {
"model": model,
"prompt": prompt,
"response_format": "url",
"size": size,
"seed": seed,
"guidance_scale": guidance_scale,
"watermark": watermark,
"api-key": api_key,
}
# 创建请求对象
req = urllib.request.Request(
endpoint,
data=json.dumps(request_body).encode("utf-8"),
headers=headers,
method="POST",
)
# 发送请求并获取响应
with urllib.request.urlopen(req, timeout=60) as response:
response_data = json.loads(response.read().decode("utf-8"))
# 解析响应
image_url = None
if (
isinstance(response_data.get("data"), list)
and response_data["data"]
and isinstance(response_data["data"][0], dict)
):
image_url = response_data["data"][0].get("url")
elif response_data.get("url"):
image_url = response_data.get("url")
if image_url:
return True, image_url
else:
error_msg = response_data.get("error", {}).get("message", "未知错误")
logger.error(f"API返回错误: {error_msg}")
return False, f"API错误: {error_msg}"
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8")
logger.error(f"HTTP错误 {e.code}: {error_body}")
return False, f"HTTP错误 {e.code}: {error_body[:100]}..."
except Exception as e:
logger.error(f"请求异常: {e}", exc_info=True)
return False, f"请求异常: {str(e)}"
def _download_and_encode_base64(self, image_url: str) -> Tuple[bool, str]:
"""下载图片并转换为Base64编码"""
try:
with urllib.request.urlopen(image_url) as response:
image_data = response.read()
base64_encoded = base64.b64encode(image_data).decode("utf-8")
return True, base64_encoded
except Exception as e:
logger.error(f"图片下载编码失败: {e}", exc_info=True)
return False, str(e)
async def _send_image(self, base64_image: str) -> bool:
"""发送图片"""
try:
# 使用聊天流信息确定发送目标
chat_stream = self.api.get_service("chat_stream")
if not chat_stream:
logger.error(f"{self.log_prefix} 没有可用的聊天流发送图片")
return False
if chat_stream.group_info:
# 群聊
return await self.api.send_message_to_target(
message_type="image",
content=base64_image,
platform=chat_stream.platform,
target_id=str(chat_stream.group_info.group_id),
is_group=True,
display_message="发送生成的照片",
)
else:
# 私聊
return await self.api.send_message_to_target(
message_type="image",
content=base64_image,
platform=chat_stream.platform,
target_id=str(chat_stream.user_info.user_id),
is_group=False,
display_message="发送生成的照片",
)
except Exception as e:
logger.error(f"{self.log_prefix} 发送图片时出错: {e}")
return False
@classmethod
def _get_cache_key(cls, description: str, model: str, size: str) -> str:
"""生成缓存键"""
return f"{description}|{model}|{size}"
def _update_cache(self, description: str, model: str, size: str, base64_image: str):
"""更新缓存"""
max_cache_size = self.api.get_config("storage.max_cache_size", 10)
cache_key = self._get_cache_key(description, model, size)
# 添加到缓存
self._request_cache[cache_key] = base64_image
# 如果缓存超过最大大小,删除最旧的项
if len(self._request_cache) > max_cache_size:
oldest_key = next(iter(self._request_cache))
del self._request_cache[oldest_key]
class ShowRecentPicturesCommand(BaseCommand):
"""展示最近生成的照片"""
command_name = "show_recent_pictures"
command_description = "展示最近生成的5张照片"
command_pattern = r"^/show_pics$"
command_help = "用法: /show_pics"
command_examples = ["/show_pics"]
intercept_message = True
async def execute(self) -> Tuple[bool, Optional[str]]:
logger.info(f"{self.log_prefix} 执行展示最近照片命令")
log_file = self.api.get_config("storage.log_file", "picture_log.json")
log_path = os.path.join(DATA_DIR, log_file)
async with file_lock:
try:
if not os.path.exists(log_path):
await self.send_text("最近还没有拍过照片哦,快让我自拍一张吧!")
return True, "没有照片日志文件"
with open(log_path, "r", encoding="utf-8") as f:
log_data = json.load(f)
if not log_data:
await self.send_text("最近还没有拍过照片哦,快让我自拍一张吧!")
return True, "没有照片"
# 获取最新的5张照片
recent_pics = sorted(log_data, key=lambda x: x["timestamp"], reverse=True)[:5]
# 先发送文本消息
await self.send_text("这是我最近拍的几张照片~")
# 逐个发送图片
for pic in recent_pics:
# 尝试获取图片URL
image_url = pic.get("image_url")
if image_url:
try:
# 下载图片并转换为Base64
with urllib.request.urlopen(image_url) as response:
image_data = response.read()
base64_encoded = base64.b64encode(image_data).decode("utf-8")
# 发送图片
await self.send_type(
message_type="image", content=base64_encoded, display_message="发送最近的照片"
)
except Exception as e:
logger.error(f"{self.log_prefix} 下载或发送照片失败: {e}", exc_info=True)
return True, "成功展示最近的照片"
except json.JSONDecodeError:
await self.send_text("照片记录文件好像损坏了...")
return False, "JSON解码错误"
except Exception as e:
logger.error(f"{self.log_prefix} 展示照片失败: {e}", exc_info=True)
await self.send_text("哎呀,查找照片的时候出错了。")
return False, str(e)
@register_plugin
class TakePicturePlugin(BasePlugin):
"""拍照插件"""
plugin_name = "take_picture_plugin" # 内部标识符
enable_plugin = False
dependencies = [] # 插件依赖列表
python_dependencies = [] # Python包依赖列表
config_file_name = "config.toml"
# 配置节描述
config_section_descriptions = {
"plugin": "插件基本信息配置",
"api": "API相关配置包含火山引擎API的访问信息",
"components": "组件启用控制",
"picture": "拍照功能核心配置",
"storage": "照片存储相关配置",
}
# 配置Schema定义
config_schema = {
"plugin": {
"enabled": ConfigField(type=bool, default=False, description="是否启用插件"),
},
"api": {
"base_url": ConfigField(
type=str,
default="https://ark.cn-beijing.volces.com/api/v3",
description="API基础URL",
example="https://api.example.com/v1",
),
"volcano_generate_api_key": ConfigField(
type=str, default="YOUR_DOUBAO_API_KEY_HERE", description="火山引擎豆包API密钥", required=True
),
},
"components": {
"enable_take_picture_action": ConfigField(type=bool, default=True, description="是否启用拍照Action"),
"enable_show_pics_command": ConfigField(type=bool, default=True, description="是否启用展示照片Command"),
},
"picture": {
"default_model": ConfigField(
type=str,
default="doubao-seedream-3-0-t2i-250415",
description="默认使用的文生图模型",
choices=["doubao-seedream-3-0-t2i-250415", "doubao-seedream-2-0-t2i"],
),
"default_size": ConfigField(
type=str,
default="1024x1024",
description="默认图片尺寸",
example="1024x1024",
choices=["1024x1024", "1024x1280", "1280x1024", "1024x1536", "1536x1024"],
),
"default_watermark": ConfigField(type=bool, default=True, description="是否默认添加水印"),
"default_guidance_scale": ConfigField(
type=float, default=2.5, description="模型指导强度,影响图片与提示的关联性", example="2.0"
),
"default_seed": ConfigField(type=int, default=42, description="随机种子,用于复现图片"),
"prompt_templates": ConfigField(
type=list, default=TakePictureAction.DEFAULT_PROMPT_TEMPLATES, description="用于生成自拍照的prompt模板"
),
},
"storage": {
"max_photos": ConfigField(type=int, default=50, description="最大保存的照片数量"),
"log_file": ConfigField(type=str, default="picture_log.json", description="照片日志文件名"),
"enable_cache": ConfigField(type=bool, default=True, description="是否启用请求缓存"),
"max_cache_size": ConfigField(type=int, default=10, description="最大缓存数量"),
},
}
def get_plugin_components(self) -> List[Tuple[ComponentInfo, Type]]:
"""返回插件包含的组件列表"""
components = []
if self.get_config("components.enable_take_picture_action", True):
components.append((TakePictureAction.get_action_info(), TakePictureAction))
if self.get_config("components.enable_show_pics_command", True):
components.append((ShowRecentPicturesCommand.get_command_info(), ShowRecentPicturesCommand))
return components

View File

@@ -3,10 +3,10 @@ import sys
import os
from typing import Dict, List, Tuple, Optional
from datetime import datetime
from src.common.database.database_model import Messages, ChatStreams
# Add project root to Python path
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, project_root)
from src.common.database.database_model import Messages, ChatStreams #noqa

View File

@@ -0,0 +1,394 @@
import time
import sys
import os
import re
from typing import Dict, List, Tuple, Optional
from datetime import datetime
# Add project root to Python path
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, project_root)
from src.common.database.database_model import Messages, ChatStreams #noqa
def contains_emoji_or_image_tags(text: str) -> bool:
"""Check if text contains [表情包xxxxx] or [图片xxxxx] tags"""
if not text:
return False
# 检查是否包含 [表情包] 或 [图片] 标记
emoji_pattern = r'\[表情包[^\]]*\]'
image_pattern = r'\[图片[^\]]*\]'
return bool(re.search(emoji_pattern, text) or re.search(image_pattern, text))
def clean_reply_text(text: str) -> str:
"""Remove reply references like [回复 xxxx...] from text"""
if not text:
return text
# 匹配 [回复 xxxx...] 格式的内容
# 使用非贪婪匹配,匹配到第一个 ] 就停止
cleaned_text = re.sub(r'\[回复[^\]]*\]', '', text)
# 去除多余的空白字符
cleaned_text = cleaned_text.strip()
return cleaned_text
def get_chat_name(chat_id: str) -> str:
"""Get chat name from chat_id by querying ChatStreams table directly"""
try:
chat_stream = ChatStreams.get_or_none(ChatStreams.stream_id == chat_id)
if chat_stream is None:
return f"未知聊天 ({chat_id})"
if chat_stream.group_name:
return f"{chat_stream.group_name} ({chat_id})"
elif chat_stream.user_nickname:
return f"{chat_stream.user_nickname}的私聊 ({chat_id})"
else:
return f"未知聊天 ({chat_id})"
except Exception:
return f"查询失败 ({chat_id})"
def format_timestamp(timestamp: float) -> str:
"""Format timestamp to readable date string"""
try:
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, OSError):
return "未知时间"
def calculate_text_length_distribution(messages) -> Dict[str, int]:
"""Calculate distribution of processed_plain_text length"""
distribution = {
'0': 0, # 空文本
'1-5': 0, # 极短文本
'6-10': 0, # 很短文本
'11-20': 0, # 短文本
'21-30': 0, # 较短文本
'31-50': 0, # 中短文本
'51-70': 0, # 中等文本
'71-100': 0, # 较长文本
'101-150': 0, # 长文本
'151-200': 0, # 很长文本
'201-300': 0, # 超长文本
'301-500': 0, # 极长文本
'501-1000': 0, # 巨长文本
'1000+': 0 # 超巨长文本
}
for msg in messages:
if msg.processed_plain_text is None:
continue
# 排除包含表情包或图片标记的消息
if contains_emoji_or_image_tags(msg.processed_plain_text):
continue
# 清理文本中的回复引用
cleaned_text = clean_reply_text(msg.processed_plain_text)
length = len(cleaned_text)
if length == 0:
distribution['0'] += 1
elif length <= 5:
distribution['1-5'] += 1
elif length <= 10:
distribution['6-10'] += 1
elif length <= 20:
distribution['11-20'] += 1
elif length <= 30:
distribution['21-30'] += 1
elif length <= 50:
distribution['31-50'] += 1
elif length <= 70:
distribution['51-70'] += 1
elif length <= 100:
distribution['71-100'] += 1
elif length <= 150:
distribution['101-150'] += 1
elif length <= 200:
distribution['151-200'] += 1
elif length <= 300:
distribution['201-300'] += 1
elif length <= 500:
distribution['301-500'] += 1
elif length <= 1000:
distribution['501-1000'] += 1
else:
distribution['1000+'] += 1
return distribution
def get_text_length_stats(messages) -> Dict[str, float]:
"""Calculate basic statistics for processed_plain_text length"""
lengths = []
null_count = 0
excluded_count = 0 # 被排除的消息数量
for msg in messages:
if msg.processed_plain_text is None:
null_count += 1
elif contains_emoji_or_image_tags(msg.processed_plain_text):
# 排除包含表情包或图片标记的消息
excluded_count += 1
else:
# 清理文本中的回复引用
cleaned_text = clean_reply_text(msg.processed_plain_text)
lengths.append(len(cleaned_text))
if not lengths:
return {
'count': 0,
'null_count': null_count,
'excluded_count': excluded_count,
'min': 0,
'max': 0,
'avg': 0,
'median': 0
}
lengths.sort()
count = len(lengths)
return {
'count': count,
'null_count': null_count,
'excluded_count': excluded_count,
'min': min(lengths),
'max': max(lengths),
'avg': sum(lengths) / count,
'median': lengths[count // 2] if count % 2 == 1 else (lengths[count // 2 - 1] + lengths[count // 2]) / 2
}
def get_available_chats() -> List[Tuple[str, str, int]]:
"""Get all available chats with message counts"""
try:
# 获取所有有消息的chat_id排除特殊类型消息
chat_counts = {}
for msg in Messages.select(Messages.chat_id).distinct():
chat_id = msg.chat_id
count = Messages.select().where(
(Messages.chat_id == chat_id) &
(Messages.is_emoji != 1) &
(Messages.is_picid != 1) &
(Messages.is_command != 1)
).count()
if count > 0:
chat_counts[chat_id] = count
# 获取聊天名称
result = []
for chat_id, count in chat_counts.items():
chat_name = get_chat_name(chat_id)
result.append((chat_id, chat_name, count))
# 按消息数量排序
result.sort(key=lambda x: x[2], reverse=True)
return result
except Exception as e:
print(f"获取聊天列表失败: {e}")
return []
def get_time_range_input() -> Tuple[Optional[float], Optional[float]]:
"""Get time range input from user"""
print("\n时间范围选择:")
print("1. 最近1天")
print("2. 最近3天")
print("3. 最近7天")
print("4. 最近30天")
print("5. 自定义时间范围")
print("6. 不限制时间")
choice = input("请选择时间范围 (1-6): ").strip()
now = time.time()
if choice == "1":
return now - 24*3600, now
elif choice == "2":
return now - 3*24*3600, now
elif choice == "3":
return now - 7*24*3600, now
elif choice == "4":
return now - 30*24*3600, now
elif choice == "5":
print("请输入开始时间 (格式: YYYY-MM-DD HH:MM:SS):")
start_str = input().strip()
print("请输入结束时间 (格式: YYYY-MM-DD HH:MM:SS):")
end_str = input().strip()
try:
start_time = datetime.strptime(start_str, "%Y-%m-%d %H:%M:%S").timestamp()
end_time = datetime.strptime(end_str, "%Y-%m-%d %H:%M:%S").timestamp()
return start_time, end_time
except ValueError:
print("时间格式错误,将不限制时间范围")
return None, None
else:
return None, None
def get_top_longest_messages(messages, top_n: int = 10) -> List[Tuple[str, int, str, str]]:
"""Get top N longest messages"""
message_lengths = []
for msg in messages:
if msg.processed_plain_text is not None:
# 排除包含表情包或图片标记的消息
if contains_emoji_or_image_tags(msg.processed_plain_text):
continue
# 清理文本中的回复引用
cleaned_text = clean_reply_text(msg.processed_plain_text)
length = len(cleaned_text)
chat_name = get_chat_name(msg.chat_id)
time_str = format_timestamp(msg.time)
# 截取前100个字符作为预览
preview = cleaned_text[:100] + "..." if len(cleaned_text) > 100 else cleaned_text
message_lengths.append((chat_name, length, time_str, preview))
# 按长度排序取前N个
message_lengths.sort(key=lambda x: x[1], reverse=True)
return message_lengths[:top_n]
def analyze_text_lengths(chat_id: Optional[str] = None, start_time: Optional[float] = None, end_time: Optional[float] = None) -> None:
"""Analyze processed_plain_text lengths with optional filters"""
# 构建查询条件,排除特殊类型的消息
query = Messages.select().where(
(Messages.is_emoji != 1) &
(Messages.is_picid != 1) &
(Messages.is_command != 1)
)
if chat_id:
query = query.where(Messages.chat_id == chat_id)
if start_time:
query = query.where(Messages.time >= start_time)
if end_time:
query = query.where(Messages.time <= end_time)
messages = list(query)
if not messages:
print("没有找到符合条件的消息")
return
# 计算统计信息
distribution = calculate_text_length_distribution(messages)
stats = get_text_length_stats(messages)
top_longest = get_top_longest_messages(messages, 10)
# 显示结果
print("\n=== Processed Plain Text 长度分析结果 ===")
print("(已排除表情、图片ID、命令类型消息已排除[表情包]和[图片]标记消息,已清理回复引用)")
if chat_id:
print(f"聊天: {get_chat_name(chat_id)}")
else:
print("聊天: 全部聊天")
if start_time and end_time:
print(f"时间范围: {format_timestamp(start_time)}{format_timestamp(end_time)}")
elif start_time:
print(f"时间范围: {format_timestamp(start_time)} 之后")
elif end_time:
print(f"时间范围: {format_timestamp(end_time)} 之前")
else:
print("时间范围: 不限制")
print("\n基本统计:")
print(f"总消息数量: {len(messages)}")
print(f"有文本消息数量: {stats['count']}")
print(f"空文本消息数量: {stats['null_count']}")
print(f"被排除的消息数量: {stats['excluded_count']}")
if stats['count'] > 0:
print(f"最短长度: {stats['min']} 字符")
print(f"最长长度: {stats['max']} 字符")
print(f"平均长度: {stats['avg']:.2f} 字符")
print(f"中位数长度: {stats['median']:.2f} 字符")
print("\n文本长度分布:")
total = stats['count']
if total > 0:
for range_name, count in distribution.items():
if count > 0:
percentage = count / total * 100
print(f"{range_name} 字符: {count} ({percentage:.2f}%)")
# 显示最长的消息
if top_longest:
print(f"\n最长的 {len(top_longest)} 条消息:")
for i, (chat_name, length, time_str, preview) in enumerate(top_longest, 1):
print(f"{i}. [{chat_name}] {time_str}")
print(f" 长度: {length} 字符")
print(f" 预览: {preview}")
print()
def interactive_menu() -> None:
"""Interactive menu for text length analysis"""
while True:
print("\n" + "="*50)
print("Processed Plain Text 长度分析工具")
print("="*50)
print("1. 分析全部聊天")
print("2. 选择特定聊天分析")
print("q. 退出")
choice = input("\n请选择分析模式 (1-2, q): ").strip()
if choice.lower() == 'q':
print("再见!")
break
chat_id = None
if choice == "2":
# 显示可用的聊天列表
chats = get_available_chats()
if not chats:
print("没有找到聊天数据")
continue
print(f"\n可用的聊天 (共{len(chats)}个):")
for i, (_cid, name, count) in enumerate(chats, 1):
print(f"{i}. {name} ({count}条消息)")
try:
chat_choice = int(input(f"\n请选择聊天 (1-{len(chats)}): ").strip())
if 1 <= chat_choice <= len(chats):
chat_id = chats[chat_choice - 1][0]
else:
print("无效选择")
continue
except ValueError:
print("请输入有效数字")
continue
elif choice != "1":
print("无效选择")
continue
# 获取时间范围
start_time, end_time = get_time_range_input()
# 执行分析
analyze_text_lengths(chat_id, start_time, end_time)
input("\n按回车键继续...")
if __name__ == "__main__":
interactive_menu()

View File

@@ -236,10 +236,10 @@ class HeartFChatting:
if if_think:
factor = max(global_config.chat.focus_value, 0.1)
self.energy_value *= 1.1 / factor
logger.info(f"{self.log_prefix} 麦麦进行了思考,能量值按倍数增加,当前能量值:{self.energy_value:.1f}")
logger.info(f"{self.log_prefix} 进行了思考,能量值按倍数增加,当前能量值:{self.energy_value:.1f}")
else:
self.energy_value += 0.1 / global_config.chat.focus_value
logger.info(f"{self.log_prefix} 麦麦没有进行思考,能量值线性增加,当前能量值:{self.energy_value:.1f}")
logger.debug(f"{self.log_prefix} 没有进行思考,能量值线性增加,当前能量值:{self.energy_value:.1f}")
logger.debug(f"{self.log_prefix} 当前能量值:{self.energy_value:.1f}")
return True
@@ -330,13 +330,13 @@ class HeartFChatting:
if self.loop_mode == ChatMode.NORMAL:
if action_type == "no_action":
logger.info(f"[{self.log_prefix}] {global_config.bot.nickname} 决定进行回复")
logger.info(f"{self.log_prefix}{global_config.bot.nickname} 决定进行回复")
elif is_parallel:
logger.info(
f"[{self.log_prefix}] {global_config.bot.nickname} 决定进行回复, 同时执行{action_type}动作"
f"{self.log_prefix}{global_config.bot.nickname} 决定进行回复, 同时执行{action_type}动作"
)
else:
logger.info(f"[{self.log_prefix}] {global_config.bot.nickname} 决定执行{action_type}动作")
logger.info(f"{self.log_prefix}{global_config.bot.nickname} 决定执行{action_type}动作")
if action_type == "no_action":
# 等待回复生成完毕
@@ -351,15 +351,15 @@ class HeartFChatting:
# 模型炸了,没有回复内容生成
if not response_set:
logger.warning(f"[{self.log_prefix}] 模型未生成回复内容")
logger.warning(f"{self.log_prefix}模型未生成回复内容")
return False
elif action_type not in ["no_action"] and not is_parallel:
logger.info(
f"[{self.log_prefix}] {global_config.bot.nickname} 原本想要回复:{content},但选择执行{action_type},不发表回复"
f"{self.log_prefix}{global_config.bot.nickname} 原本想要回复:{content},但选择执行{action_type},不发表回复"
)
return False
logger.info(f"[{self.log_prefix}] {global_config.bot.nickname} 决定的回复内容: {content}")
logger.info(f"{self.log_prefix}{global_config.bot.nickname} 决定的回复内容: {content}")
# 发送回复 (不再需要传入 chat)
reply_text = await self._send_response(response_set, reply_to_str, loop_start_time,message_data)
@@ -563,7 +563,7 @@ class HeartFChatting:
return reply_set
except Exception as e:
logger.error(f"[{self.log_prefix}] 回复生成出现错误:{str(e)} {traceback.format_exc()}")
logger.error(f"{self.log_prefix}回复生成出现错误:{str(e)} {traceback.format_exc()}")
return None
async def _send_response(self, reply_set, reply_to, thinking_start_time, message_data):

View File

@@ -87,36 +87,90 @@ class ExpressionLearner:
request_type="expressor.learner",
)
self.llm_model = None
self._ensure_expression_directories()
self._auto_migrate_json_to_db()
self._migrate_old_data_create_date()
def _ensure_expression_directories(self):
"""
确保表达方式相关的目录结构存在
"""
base_dir = os.path.join("data", "expression")
directories_to_create = [
base_dir,
os.path.join(base_dir, "learnt_style"),
os.path.join(base_dir, "learnt_grammar"),
]
for directory in directories_to_create:
try:
os.makedirs(directory, exist_ok=True)
logger.debug(f"确保目录存在: {directory}")
except Exception as e:
logger.error(f"创建目录失败 {directory}: {e}")
def _auto_migrate_json_to_db(self):
"""
自动将/data/expression/learnt_style 和 learnt_grammar 下所有expressions.json迁移到数据库。
迁移完成后在/data/expression/done.done写入标记文件存在则跳过。
"""
done_flag = os.path.join("data", "expression", "done.done")
base_dir = os.path.join("data", "expression")
done_flag = os.path.join(base_dir, "done.done")
# 确保基础目录存在
try:
os.makedirs(base_dir, exist_ok=True)
logger.debug(f"确保目录存在: {base_dir}")
except Exception as e:
logger.error(f"创建表达方式目录失败: {e}")
return
if os.path.exists(done_flag):
logger.info("表达方式JSON已迁移无需重复迁移。")
return
base_dir = os.path.join("data", "expression")
logger.info("开始迁移表达方式JSON到数据库...")
migrated_count = 0
for type in ["learnt_style", "learnt_grammar"]:
type_str = "style" if type == "learnt_style" else "grammar"
type_dir = os.path.join(base_dir, type)
if not os.path.exists(type_dir):
logger.debug(f"目录不存在,跳过: {type_dir}")
continue
for chat_id in os.listdir(type_dir):
try:
chat_ids = os.listdir(type_dir)
logger.debug(f"{type_dir} 中找到 {len(chat_ids)} 个聊天ID目录")
except Exception as e:
logger.error(f"读取目录失败 {type_dir}: {e}")
continue
for chat_id in chat_ids:
expr_file = os.path.join(type_dir, chat_id, "expressions.json")
if not os.path.exists(expr_file):
continue
try:
with open(expr_file, "r", encoding="utf-8") as f:
expressions = json.load(f)
if not isinstance(expressions, list):
logger.warning(f"表达方式文件格式错误,跳过: {expr_file}")
continue
for expr in expressions:
if not isinstance(expr, dict):
continue
situation = expr.get("situation")
style_val = expr.get("style")
count = expr.get("count", 1)
last_active_time = expr.get("last_active_time", time.time())
if not situation or not style_val:
logger.warning(f"表达方式缺少必要字段,跳过: {expr}")
continue
# 查重同chat_id+type+situation+style
from src.common.database.database_model import Expression
@@ -141,14 +195,28 @@ class ExpressionLearner:
type=type_str,
create_date=last_active_time, # 迁移时使用last_active_time作为创建时间
)
logger.info(f"已迁移 {expr_file} 到数据库")
migrated_count += 1
logger.info(f"已迁移 {expr_file} 到数据库,包含 {len(expressions)} 个表达方式")
except json.JSONDecodeError as e:
logger.error(f"JSON解析失败 {expr_file}: {e}")
except Exception as e:
logger.error(f"迁移表达方式 {expr_file} 失败: {e}")
# 标记迁移完成
try:
# 确保done.done文件的父目录存在
done_parent_dir = os.path.dirname(done_flag)
if not os.path.exists(done_parent_dir):
os.makedirs(done_parent_dir, exist_ok=True)
logger.debug(f"为done.done创建父目录: {done_parent_dir}")
with open(done_flag, "w", encoding="utf-8") as f:
f.write("done\n")
logger.info("表达方式JSON迁移已完成已写入done.done标记文件")
logger.info(f"表达方式JSON迁移已完成共迁移 {migrated_count} 个表达方式,已写入done.done标记文件")
except PermissionError as e:
logger.error(f"权限不足无法写入done.done标记文件: {e}")
except OSError as e:
logger.error(f"文件系统错误无法写入done.done标记文件: {e}")
except Exception as e:
logger.error(f"写入done.done标记文件失败: {e}")
@@ -266,9 +334,17 @@ class ExpressionLearner:
for type in ["style", "grammar"]:
base_dir = os.path.join("data", "expression", f"learnt_{type}")
if not os.path.exists(base_dir):
logger.debug(f"目录不存在,跳过衰减: {base_dir}")
continue
for chat_id in os.listdir(base_dir):
try:
chat_ids = os.listdir(base_dir)
logger.debug(f"{base_dir} 中找到 {len(chat_ids)} 个聊天ID目录进行衰减")
except Exception as e:
logger.error(f"读取目录失败 {base_dir}: {e}")
continue
for chat_id in chat_ids:
file_path = os.path.join(base_dir, chat_id, "expressions.json")
if not os.path.exists(file_path):
continue
@@ -277,14 +353,24 @@ class ExpressionLearner:
with open(file_path, "r", encoding="utf-8") as f:
expressions = json.load(f)
if not isinstance(expressions, list):
logger.warning(f"表达方式文件格式错误,跳过衰减: {file_path}")
continue
# 应用全局衰减
decayed_expressions = self.apply_decay_to_expressions(expressions, current_time)
# 保存衰减后的结果
with open(file_path, "w", encoding="utf-8") as f:
json.dump(decayed_expressions, f, ensure_ascii=False, indent=2)
logger.debug(f"已对 {file_path} 应用衰减,剩余 {len(decayed_expressions)} 个表达方式")
except json.JSONDecodeError as e:
logger.error(f"JSON解析失败跳过衰减 {file_path}: {e}")
except PermissionError as e:
logger.error(f"权限不足,无法更新 {file_path}: {e}")
except Exception as e:
logger.error(f"全局衰减{type}表达方式失败: {e}")
logger.error(f"全局衰减{type}表达方式失败 {file_path}: {e}")
continue
learnt_style: Optional[List[Tuple[str, str, str]]] = []

View File

@@ -12,6 +12,7 @@ from src.chat.message_receive.storage import MessageStorage
from src.chat.heart_flow.heartflow import heartflow
from src.chat.utils.utils import is_mentioned_bot_in_message
from src.chat.utils.timer_calculator import Timer
from src.chat.utils.chat_message_builder import replace_user_references_in_content
from src.common.logger import get_logger
from src.person_info.relationship_manager import get_relationship_manager
from src.mood.mood_manager import mood_manager
@@ -56,16 +57,41 @@ async def _calculate_interest(message: MessageRecv) -> Tuple[float, bool]:
with Timer("记忆激活"):
interested_rate = await hippocampus_manager.get_activate_from_text(
message.processed_plain_text,
max_depth= 5,
fast_retrieval=False,
)
logger.debug(f"记忆激活率: {interested_rate:.2f}")
text_len = len(message.processed_plain_text)
# 根据文本长度调整兴趣度,长度越大兴趣度越高但增长率递减最低0.01最高0.05
# 采用对数函数实现递减增长
base_interest = 0.01 + (0.05 - 0.01) * (math.log10(text_len + 1) / math.log10(1000 + 1))
base_interest = min(max(base_interest, 0.01), 0.05)
# 根据文本长度分布调整兴趣度,采用分段函数实现更精确的兴趣度计算
# 基于实际分布0-5字符(26.57%), 6-10字符(27.18%), 11-20字符(22.76%), 21-30字符(10.33%), 31+字符(13.86%)
if text_len == 0:
base_interest = 0.01 # 空消息最低兴趣度
elif text_len <= 5:
# 1-5字符线性增长 0.01 -> 0.03
base_interest = 0.01 + (text_len - 1) * (0.03 - 0.01) / 4
elif text_len <= 10:
# 6-10字符线性增长 0.03 -> 0.06
base_interest = 0.03 + (text_len - 5) * (0.06 - 0.03) / 5
elif text_len <= 20:
# 11-20字符线性增长 0.06 -> 0.12
base_interest = 0.06 + (text_len - 10) * (0.12 - 0.06) / 10
elif text_len <= 30:
# 21-30字符线性增长 0.12 -> 0.18
base_interest = 0.12 + (text_len - 20) * (0.18 - 0.12) / 10
elif text_len <= 50:
# 31-50字符线性增长 0.18 -> 0.22
base_interest = 0.18 + (text_len - 30) * (0.22 - 0.18) / 20
elif text_len <= 100:
# 51-100字符线性增长 0.22 -> 0.26
base_interest = 0.22 + (text_len - 50) * (0.26 - 0.22) / 50
else:
# 100+字符:对数增长 0.26 -> 0.3,增长率递减
base_interest = 0.26 + (0.3 - 0.26) * (math.log10(text_len - 99) / math.log10(901)) # 1000-99=901
# 确保在范围内
base_interest = min(max(base_interest, 0.01), 0.3)
interested_rate += base_interest
@@ -111,9 +137,9 @@ class HeartFCMessageReceiver:
subheartflow: SubHeartflow = await heartflow.get_or_create_subheartflow(chat.stream_id) # type: ignore
# subheartflow.add_message_to_normal_chat_cache(message, interested_rate, is_mentioned)
chat_mood = mood_manager.get_mood_by_chat_id(subheartflow.chat_id)
asyncio.create_task(chat_mood.update_mood_by_message(message, interested_rate))
if global_config.mood.enable_mood:
chat_mood = mood_manager.get_mood_by_chat_id(subheartflow.chat_id)
asyncio.create_task(chat_mood.update_mood_by_message(message, interested_rate))
# 3. 日志记录
mes_name = chat.group_info.group_name if chat.group_info else "私聊"
@@ -123,8 +149,16 @@ class HeartFCMessageReceiver:
# 如果消息中包含图片标识,则将 [picid:...] 替换为 [图片]
picid_pattern = r"\[picid:([^\]]+)\]"
processed_plain_text = re.sub(picid_pattern, "[图片]", message.processed_plain_text)
# 应用用户引用格式替换,将回复<aaa:bbb>和@<aaa:bbb>格式转换为可读格式
processed_plain_text = replace_user_references_in_content(
processed_plain_text,
message.message_info.platform,
is_async=False,
replace_bot_name=True
)
logger.info(f"[{mes_name}]{userinfo.user_nickname}:{processed_plain_text}") # type: ignore
logger.info(f"[{mes_name}]{userinfo.user_nickname}:{processed_plain_text}[兴趣度:{interested_rate:.2f}]") # type: ignore
logger.debug(f"[{mes_name}][当前时段回复频率: {current_talk_frequency}]")

View File

@@ -224,10 +224,15 @@ class Hippocampus:
return hash((source, target))
@staticmethod
def find_topic_llm(text, topic_num):
# sourcery skip: inline-immediately-returned-variable
def find_topic_llm(text:str, topic_num:int|list[int]):
topic_num_str = ""
if isinstance(topic_num, list):
topic_num_str = f"{topic_num[0]}-{topic_num[1]}"
else:
topic_num_str = topic_num
prompt = (
f"这是一段文字:\n{text}\n\n请你从这段话中总结出最多{topic_num}个关键的概念,可以是名词,动词,或者特定人物,帮我列出来,"
f"这是一段文字:\n{text}\n\n请你从这段话中总结出最多{topic_num_str}个关键的概念,可以是名词,动词,或者特定人物,帮我列出来,"
f"将主题用逗号隔开,并加上<>,例如<主题1>,<主题2>......尽可能精简。只需要列举最多{topic_num}个话题就好,不要有序号,不要告诉我其他内容。"
f"如果确定找不出主题或者没有明显主题,返回<none>。"
)
@@ -299,6 +304,59 @@ class Hippocampus:
# 按相似度降序排序
memories.sort(key=lambda x: x[2], reverse=True)
return memories
async def get_keywords_from_text(self, text: str) -> list:
"""从文本中提取关键词。
Args:
text (str): 输入文本
fast_retrieval (bool, optional): 是否使用快速检索。默认为False。
如果为True使用jieba分词提取关键词速度更快但可能不够准确。
如果为False使用LLM提取关键词速度较慢但更准确。
"""
if not text:
return []
# 使用LLM提取关键词 - 根据详细文本长度分布优化topic_num计算
text_length = len(text)
topic_num:str|list[int] = None
if text_length <= 5:
words = jieba.cut(text)
keywords = [word for word in words if len(word) > 1]
keywords = list(set(keywords))[:3] # 限制最多3个关键词
logger.info(f"提取关键词: {keywords}")
return keywords
elif text_length <= 10:
topic_num = [1,3] # 6-10字符: 1个关键词 (27.18%的文本)
elif text_length <= 20:
topic_num = [2,4] # 11-20字符: 2个关键词 (22.76%的文本)
elif text_length <= 30:
topic_num = [3,5] # 21-30字符: 3个关键词 (10.33%的文本)
elif text_length <= 50:
topic_num = [4,5] # 31-50字符: 4个关键词 (9.79%的文本)
else:
topic_num = 5 # 51+字符: 5个关键词 (其余长文本)
topics_response, (reasoning_content, model_name) = await self.model_summary.generate_response_async(
self.find_topic_llm(text, topic_num)
)
# 提取关键词
keywords = re.findall(r"<([^>]+)>", topics_response)
if not keywords:
keywords = []
else:
keywords = [
keyword.strip()
for keyword in ",".join(keywords).replace("", ",").replace("", ",").replace(" ", ",").split(",")
if keyword.strip()
]
logger.info(f"提取关键词: {keywords}")
return keywords
async def get_memory_from_text(
self,
@@ -325,39 +383,7 @@ class Hippocampus:
- memory_items: list, 该主题下的记忆项列表
- similarity: float, 与文本的相似度
"""
if not text:
return []
if fast_retrieval:
# 使用jieba分词提取关键词
words = jieba.cut(text)
# 过滤掉停用词和单字词
keywords = [word for word in words if len(word) > 1]
# 去重
keywords = list(set(keywords))
# 限制关键词数量
logger.debug(f"提取关键词: {keywords}")
else:
# 使用LLM提取关键词
topic_num = min(5, max(1, int(len(text) * 0.1))) # 根据文本长度动态调整关键词数量
# logger.info(f"提取关键词数量: {topic_num}")
topics_response, (reasoning_content, model_name) = await self.model_summary.generate_response_async(
self.find_topic_llm(text, topic_num)
)
# 提取关键词
keywords = re.findall(r"<([^>]+)>", topics_response)
if not keywords:
keywords = []
else:
keywords = [
keyword.strip()
for keyword in ",".join(keywords).replace("", ",").replace("", ",").replace(" ", ",").split(",")
if keyword.strip()
]
# logger.info(f"提取的关键词: {', '.join(keywords)}")
keywords = await self.get_keywords_from_text(text)
# 过滤掉不存在于记忆图中的关键词
valid_keywords = [keyword for keyword in keywords if keyword in self.memory_graph.G]
@@ -679,38 +705,7 @@ class Hippocampus:
Returns:
float: 激活节点数与总节点数的比值
"""
if not text:
return 0
if fast_retrieval:
# 使用jieba分词提取关键词
words = jieba.cut(text)
# 过滤掉停用词和单字词
keywords = [word for word in words if len(word) > 1]
# 去重
keywords = list(set(keywords))
# 限制关键词数量
keywords = keywords[:5]
else:
# 使用LLM提取关键词
topic_num = min(5, max(1, int(len(text) * 0.1))) # 根据文本长度动态调整关键词数量
# logger.info(f"提取关键词数量: {topic_num}")
topics_response, (reasoning_content, model_name) = await self.model_summary.generate_response_async(
self.find_topic_llm(text, topic_num)
)
# 提取关键词
keywords = re.findall(r"<([^>]+)>", topics_response)
if not keywords:
keywords = []
else:
keywords = [
keyword.strip()
for keyword in ",".join(keywords).replace("", ",").replace("", ",").replace(" ", ",").split(",")
if keyword.strip()
]
# logger.info(f"提取的关键词: {', '.join(keywords)}")
keywords = await self.get_keywords_from_text(text)
# 过滤掉不存在于记忆图中的关键词
valid_keywords = [keyword for keyword in keywords if keyword in self.memory_graph.G]
@@ -727,7 +722,7 @@ class Hippocampus:
for keyword in valid_keywords:
logger.debug(f"开始以关键词 '{keyword}' 为中心进行扩散检索 (最大深度: {max_depth}):")
# 初始化激活值
activation_values = {keyword: 1.0}
activation_values = {keyword: 1.5}
# 记录已访问的节点
visited_nodes = {keyword}
# 待处理的节点队列,每个元素是(节点, 激活值, 当前深度)

View File

@@ -92,7 +92,6 @@ class ChatBot:
command_result = component_registry.find_command_by_text(text)
if command_result:
command_class, matched_groups, command_info = command_result
intercept_message = command_info.intercept_message
plugin_name = command_info.plugin_name
command_name = command_info.name
if (
@@ -115,7 +114,7 @@ class ChatBot:
try:
# 执行命令
success, response = await command_instance.execute()
success, response, intercept_message = await command_instance.execute()
# 记录命令执行结果
if success:
@@ -128,8 +127,6 @@ class ChatBot:
except Exception as e:
logger.error(f"执行命令时出错: {command_class.__name__} - {e}")
import traceback
logger.error(traceback.format_exc())
try:
@@ -138,7 +135,7 @@ class ChatBot:
logger.error(f"发送错误消息失败: {send_error}")
# 命令出错时,根据命令的拦截设置决定是否继续处理消息
return True, str(e), not intercept_message
return True, str(e), False # 出错时继续处理消息
# 没有找到命令,继续处理消息
return False, None, True

View File

@@ -438,94 +438,4 @@ class ActionModifier:
return True
else:
logger.debug(f"{self.log_prefix}动作 {action_name} 未匹配到任何关键词: {activation_keywords}")
return False
# async def analyze_loop_actions(self, history_loop: List[CycleDetail]) -> List[tuple[str, str]]:
# """分析最近的循环内容并决定动作的移除
# Returns:
# List[Tuple[str, str]]: 包含要删除的动作及原因的元组列表
# [("action3", "some reason")]
# """
# removals = []
# # 获取最近10次循环
# recent_cycles = history_loop[-10:] if len(history_loop) > 10 else history_loop
# if not recent_cycles:
# return removals
# reply_sequence = [] # 记录最近的动作序列
# for cycle in recent_cycles:
# action_result = cycle.loop_plan_info.get("action_result", {})
# action_type = action_result.get("action_type", "unknown")
# reply_sequence.append(action_type == "reply")
# # 计算连续回复的相关阈值
# max_reply_num = int(global_config.focus_chat.consecutive_replies * 3.2)
# sec_thres_reply_num = int(global_config.focus_chat.consecutive_replies * 2)
# one_thres_reply_num = int(global_config.focus_chat.consecutive_replies * 1.5)
# # 获取最近max_reply_num次的reply状态
# if len(reply_sequence) >= max_reply_num:
# last_max_reply_num = reply_sequence[-max_reply_num:]
# else:
# last_max_reply_num = reply_sequence[:]
# # 详细打印阈值和序列信息,便于调试
# logger.info(
# f"连续回复阈值: max={max_reply_num}, sec={sec_thres_reply_num}, one={one_thres_reply_num}"
# f"最近reply序列: {last_max_reply_num}"
# )
# # print(f"consecutive_replies: {consecutive_replies}")
# # 根据最近的reply情况决定是否移除reply动作
# if len(last_max_reply_num) >= max_reply_num and all(last_max_reply_num):
# # 如果最近max_reply_num次都是reply直接移除
# reason = f"连续回复过多(最近{len(last_max_reply_num)}次全是reply超过阈值{max_reply_num}"
# removals.append(("reply", reason))
# # reply_count = len(last_max_reply_num) - no_reply_count
# elif len(last_max_reply_num) >= sec_thres_reply_num and all(last_max_reply_num[-sec_thres_reply_num:]):
# # 如果最近sec_thres_reply_num次都是reply40%概率移除
# removal_probability = 0.4 / global_config.focus_chat.consecutive_replies
# if random.random() < removal_probability:
# reason = (
# f"连续回复较多(最近{sec_thres_reply_num}次全是reply{removal_probability:.2f}概率移除,触发移除)"
# )
# removals.append(("reply", reason))
# elif len(last_max_reply_num) >= one_thres_reply_num and all(last_max_reply_num[-one_thres_reply_num:]):
# # 如果最近one_thres_reply_num次都是reply20%概率移除
# removal_probability = 0.2 / global_config.focus_chat.consecutive_replies
# if random.random() < removal_probability:
# reason = (
# f"连续回复检测(最近{one_thres_reply_num}次全是reply{removal_probability:.2f}概率移除,触发移除)"
# )
# removals.append(("reply", reason))
# else:
# logger.debug(f"{self.log_prefix}连续回复检测无需移除reply动作最近回复模式正常")
# return removals
# def get_available_actions_count(self, mode: str = "focus") -> int:
# """获取当前可用动作数量排除默认的no_action"""
# current_actions = self.action_manager.get_using_actions_for_mode(mode)
# # 排除no_action如果存在
# filtered_actions = {k: v for k, v in current_actions.items() if k != "no_action"}
# return len(filtered_actions)
# def should_skip_planning_for_no_reply(self) -> bool:
# """判断是否应该跳过规划过程"""
# current_actions = self.action_manager.get_using_actions_for_mode("focus")
# # 排除no_action如果存在
# if len(current_actions) == 1 and "no_reply" in current_actions:
# return True
# return False
# def should_skip_planning_for_no_action(self) -> bool:
# """判断是否应该跳过规划过程"""
# available_count = self.action_manager.get_using_actions_for_mode("normal")
# if available_count == 0:
# logger.debug(f"{self.log_prefix} 没有可用动作,跳过规划")
# return True
# return False
return False

View File

@@ -40,7 +40,6 @@ def init_prompt():
{moderation_prompt}
现在请你根据{by_what}选择合适的action和触发action的消息:
你刚刚选择并执行过的action是
{actions_before_now_block}
{no_action_block}
@@ -130,17 +129,18 @@ class ActionPlanner:
logger.warning(f"{self.log_prefix}使用中的动作 {action_name} 未在已注册动作中找到")
# 如果没有可用动作或只有no_reply动作直接返回no_reply
if not current_available_actions:
action = "no_reply" if mode == ChatMode.FOCUS else "no_action"
reasoning = "没有可用的动作"
logger.info(f"{self.log_prefix}{reasoning}")
return {
"action_result": {
"action_type": action,
"action_data": action_data,
"reasoning": reasoning,
},
}, None
# 因为现在reply是永远激活所以不需要空跳判定
# if not current_available_actions:
# action = "no_reply" if mode == ChatMode.FOCUS else "no_action"
# reasoning = "没有可用的动作"
# logger.info(f"{self.log_prefix}{reasoning}")
# return {
# "action_result": {
# "action_type": action,
# "action_data": action_data,
# "reasoning": reasoning,
# },
# }, None
# --- 构建提示词 (调用修改后的 PromptBuilder 方法) ---
prompt, message_id_list = await self.build_planner_prompt(
@@ -268,6 +268,7 @@ class ActionPlanner:
actions_before_now = get_actions_by_timestamp_with_chat(
chat_id=self.chat_id,
timestamp_start=time.time()-3600,
timestamp_end=time.time(),
limit=5,
)
@@ -275,27 +276,35 @@ class ActionPlanner:
actions_before_now_block = build_readable_actions(
actions=actions_before_now,
)
actions_before_now_block = f"你刚刚选择并执行过的action是\n{actions_before_now_block}"
self.last_obs_time_mark = time.time()
if mode == ChatMode.FOCUS:
mentioned_bonus = ""
if global_config.chat.mentioned_bot_inevitable_reply:
mentioned_bonus = "\n- 有人提到你"
if global_config.chat.at_bot_inevitable_reply:
mentioned_bonus = "\n- 有人提到你或者at你"
by_what = "聊天内容"
target_prompt = '\n "target_message_id":"触发action的消息id"'
no_action_block = """重要说明1
no_action_block = f"""重要说明:
- 'no_reply' 表示只进行不进行回复,等待合适的回复时机
- 当你刚刚发送了消息没有人回复时选择no_reply
- 当你一次发送了太多消息为了避免打扰聊天节奏选择no_reply
动作reply
动作描述:参与聊天回复,发送文本进行表达
- 你想要闲聊或者随便附和
- 有人提到你
- 你想要闲聊或者随便附和{mentioned_bonus}
- 如果你刚刚进行了回复,不要对同一个话题重复回应
{
{{
"action": "reply",
"target_message_id":"触发action的消息id",
"reason":"回复的原因"
}
}}
"""
else:

View File

@@ -17,7 +17,7 @@ from src.chat.message_receive.uni_message_sender import HeartFCSender
from src.chat.utils.timer_calculator import Timer # <--- Import Timer
from src.chat.utils.utils import get_chat_type_and_target_info
from src.chat.utils.prompt_builder import Prompt, global_prompt_manager
from src.chat.utils.chat_message_builder import build_readable_messages, get_raw_msg_before_timestamp_with_chat
from src.chat.utils.chat_message_builder import build_readable_messages, get_raw_msg_before_timestamp_with_chat, replace_user_references_in_content
from src.chat.express.expression_selector import expression_selector
from src.chat.knowledge.knowledge_lib import qa_manager
from src.chat.memory_system.memory_activator import MemoryActivator
@@ -74,6 +74,7 @@ def init_prompt():
你正在{chat_target_2},{reply_target_block}
对这句话,你想表达,原句:{raw_reply},原因是:{reason}。你现在要思考怎么组织回复
你现在的心情是:{mood_state}
你需要使用合适的语法和句法,参考聊天内容,组织一条日常且口语化的回复。请你修改你想表达的原句,符合你的表达风格和语言习惯
{config_expression_style},你可以完全重组回复,保留最基本的表达含义就好,但重组后保持语意通顺。
{keywords_reaction_prompt}
@@ -450,6 +451,9 @@ class DefaultReplyer:
def _parse_reply_target(self, target_message: str) -> tuple:
sender = ""
target = ""
# 添加None检查防止NoneType错误
if target_message is None:
return sender, target
if ":" in target_message or "" in target_message:
# 使用正则表达式匹配中文或英文冒号
parts = re.split(pattern=r"[:]", string=target_message, maxsplit=1)
@@ -462,6 +466,10 @@ class DefaultReplyer:
# 关键词检测与反应
keywords_reaction_prompt = ""
try:
# 添加None检查防止NoneType错误
if target is None:
return keywords_reaction_prompt
# 处理关键词规则
for rule in global_config.keyword_reaction.keyword_rules:
if any(keyword in target for keyword in rule.keywords):
@@ -524,7 +532,7 @@ class DefaultReplyer:
# 其他用户的对话
background_dialogue_list.append(msg_dict)
except Exception as e:
logger.error(f"无法处理历史消息记录: {msg_dict}, 错误: {e}")
logger.error(f"![1753364551656](image/default_generator/1753364551656.png)记录: {msg_dict}, 错误: {e}")
# 构建背景对话 prompt
background_dialogue_prompt = ""
@@ -613,11 +621,22 @@ class DefaultReplyer:
is_group_chat = bool(chat_stream.group_info)
reply_to = reply_data.get("reply_to", "none")
extra_info_block = reply_data.get("extra_info", "") or reply_data.get("extra_info_block", "")
chat_mood = mood_manager.get_mood_by_chat_id(chat_id)
mood_prompt = chat_mood.mood_state
if global_config.mood.enable_mood:
chat_mood = mood_manager.get_mood_by_chat_id(chat_id)
mood_prompt = chat_mood.mood_state
else:
mood_prompt = ""
sender, target = self._parse_reply_target(reply_to)
target = replace_user_references_in_content(
target,
chat_stream.platform,
is_async=False,
replace_bot_name=True
)
# 构建action描述 (如果启用planner)
action_descriptions = ""
@@ -876,6 +895,13 @@ class DefaultReplyer:
reason = reply_data.get("reason", "")
sender, target = self._parse_reply_target(reply_to)
# 添加情绪状态获取
if global_config.mood.enable_mood:
chat_mood = mood_manager.get_mood_by_chat_id(chat_id)
mood_prompt = chat_mood.mood_state
else:
mood_prompt = ""
message_list_before_now_half = get_raw_msg_before_timestamp_with_chat(
chat_id=chat_id,
timestamp=time.time(),
@@ -956,6 +982,7 @@ class DefaultReplyer:
reply_target_block=reply_target_block,
raw_reply=raw_reply,
reason=reason,
mood_state=mood_prompt, # 添加情绪状态参数
config_expression_style=global_config.expression.expression_style,
keywords_reaction_prompt=keywords_reaction_prompt,
moderation_prompt=moderation_prompt_block,

View File

@@ -2,7 +2,7 @@ import time # 导入 time 模块以获取当前时间
import random
import re
from typing import List, Dict, Any, Tuple, Optional
from typing import List, Dict, Any, Tuple, Optional, Union, Callable
from rich.traceback import install
from src.config.config import global_config
@@ -15,6 +15,155 @@ from src.chat.utils.utils import translate_timestamp_to_human_readable,assign_me
install(extra_lines=3)
def replace_user_references_in_content(
content: str,
platform: str,
name_resolver: Union[Callable[[str, str], str], Callable[[str, str], Any]] = None,
is_async: bool = False,
replace_bot_name: bool = True
) -> Union[str, Any]:
"""
替换内容中的用户引用格式,包括回复<aaa:bbb>和@<aaa:bbb>格式
Args:
content: 要处理的内容字符串
platform: 平台标识
name_resolver: 名称解析函数,接收(platform, user_id)参数,返回用户名称
如果为None则使用默认的person_info_manager
is_async: 是否为异步模式
replace_bot_name: 是否将机器人的user_id替换为"机器人昵称(你)"
Returns:
处理后的内容字符串同步模式或awaitable对象异步模式
"""
if is_async:
return _replace_user_references_async(content, platform, name_resolver, replace_bot_name)
else:
return _replace_user_references_sync(content, platform, name_resolver, replace_bot_name)
def _replace_user_references_sync(
content: str,
platform: str,
name_resolver: Optional[Callable[[str, str], str]] = None,
replace_bot_name: bool = True
) -> str:
"""同步版本的用户引用替换"""
if name_resolver is None:
person_info_manager = get_person_info_manager()
def default_resolver(platform: str, user_id: str) -> str:
# 检查是否是机器人自己
if replace_bot_name and user_id == global_config.bot.qq_account:
return f"{global_config.bot.nickname}(你)"
person_id = PersonInfoManager.get_person_id(platform, user_id)
return person_info_manager.get_value_sync(person_id, "person_name") or user_id
name_resolver = default_resolver
# 处理回复<aaa:bbb>格式
reply_pattern = r"回复<([^:<>]+):([^:<>]+)>"
match = re.search(reply_pattern, content)
if match:
aaa = match.group(1)
bbb = match.group(2)
try:
# 检查是否是机器人自己
if replace_bot_name and bbb == global_config.bot.qq_account:
reply_person_name = f"{global_config.bot.nickname}(你)"
else:
reply_person_name = name_resolver(platform, bbb) or aaa
content = re.sub(reply_pattern, f"回复 {reply_person_name}", content, count=1)
except Exception:
# 如果解析失败,使用原始昵称
content = re.sub(reply_pattern, f"回复 {aaa}", content, count=1)
# 处理@<aaa:bbb>格式
at_pattern = r"@<([^:<>]+):([^:<>]+)>"
at_matches = list(re.finditer(at_pattern, content))
if at_matches:
new_content = ""
last_end = 0
for m in at_matches:
new_content += content[last_end:m.start()]
aaa = m.group(1)
bbb = m.group(2)
try:
# 检查是否是机器人自己
if replace_bot_name and bbb == global_config.bot.qq_account:
at_person_name = f"{global_config.bot.nickname}(你)"
else:
at_person_name = name_resolver(platform, bbb) or aaa
new_content += f"@{at_person_name}"
except Exception:
# 如果解析失败,使用原始昵称
new_content += f"@{aaa}"
last_end = m.end()
new_content += content[last_end:]
content = new_content
return content
async def _replace_user_references_async(
content: str,
platform: str,
name_resolver: Optional[Callable[[str, str], Any]] = None,
replace_bot_name: bool = True
) -> str:
"""异步版本的用户引用替换"""
if name_resolver is None:
person_info_manager = get_person_info_manager()
async def default_resolver(platform: str, user_id: str) -> str:
# 检查是否是机器人自己
if replace_bot_name and user_id == global_config.bot.qq_account:
return f"{global_config.bot.nickname}(你)"
person_id = PersonInfoManager.get_person_id(platform, user_id)
return await person_info_manager.get_value(person_id, "person_name") or user_id
name_resolver = default_resolver
# 处理回复<aaa:bbb>格式
reply_pattern = r"回复<([^:<>]+):([^:<>]+)>"
match = re.search(reply_pattern, content)
if match:
aaa = match.group(1)
bbb = match.group(2)
try:
# 检查是否是机器人自己
if replace_bot_name and bbb == global_config.bot.qq_account:
reply_person_name = f"{global_config.bot.nickname}(你)"
else:
reply_person_name = await name_resolver(platform, bbb) or aaa
content = re.sub(reply_pattern, f"回复 {reply_person_name}", content, count=1)
except Exception:
# 如果解析失败,使用原始昵称
content = re.sub(reply_pattern, f"回复 {aaa}", content, count=1)
# 处理@<aaa:bbb>格式
at_pattern = r"@<([^:<>]+):([^:<>]+)>"
at_matches = list(re.finditer(at_pattern, content))
if at_matches:
new_content = ""
last_end = 0
for m in at_matches:
new_content += content[last_end:m.start()]
aaa = m.group(1)
bbb = m.group(2)
try:
# 检查是否是机器人自己
if replace_bot_name and bbb == global_config.bot.qq_account:
at_person_name = f"{global_config.bot.nickname}(你)"
else:
at_person_name = await name_resolver(platform, bbb) or aaa
new_content += f"@{at_person_name}"
except Exception:
# 如果解析失败,使用原始昵称
new_content += f"@{aaa}"
last_end = m.end()
new_content += content[last_end:]
content = new_content
return content
def get_raw_msg_by_timestamp(
timestamp_start: float, timestamp_end: float, limit: int = 0, limit_mode: str = "latest"
) -> List[Dict[str, Any]]:
@@ -374,33 +523,8 @@ def _build_readable_messages_internal(
else:
person_name = "某人"
# 检查是否有 回复<aaa:bbb> 字段
reply_pattern = r"回复<([^:<>]+):([^:<>]+)>"
match = re.search(reply_pattern, content)
if match:
aaa: str = match[1]
bbb: str = match[2]
reply_person_id = PersonInfoManager.get_person_id(platform, bbb)
reply_person_name = person_info_manager.get_value_sync(reply_person_id, "person_name") or aaa
# 在内容前加上回复信息
content = re.sub(reply_pattern, lambda m, name=reply_person_name: f"回复 {name}", content, count=1)
# 检查是否有 @<aaa:bbb> 字段 @<{member_info.get('nickname')}:{member_info.get('user_id')}>
at_pattern = r"@<([^:<>]+):([^:<>]+)>"
at_matches = list(re.finditer(at_pattern, content))
if at_matches:
new_content = ""
last_end = 0
for m in at_matches:
new_content += content[last_end : m.start()]
aaa = m.group(1)
bbb = m.group(2)
at_person_id = PersonInfoManager.get_person_id(platform, bbb)
at_person_name = person_info_manager.get_value_sync(at_person_id, "person_name") or aaa
new_content += f"@{at_person_name}"
last_end = m.end()
new_content += content[last_end:]
content = new_content
# 使用独立函数处理用户引用格式
content = replace_user_references_in_content(content, platform, is_async=False, replace_bot_name=replace_bot_name)
target_str = "这是QQ的一个功能用于提及某人但没那么明显"
if target_str in content and random.random() < 0.6:
@@ -916,38 +1040,14 @@ async def build_anonymous_messages(messages: List[Dict[str, Any]]) -> str:
anon_name = get_anon_name(platform, user_id)
# print(f"anon_name:{anon_name}")
# 处理 回复<aaa:bbb>
reply_pattern = r"回复<([^:<>]+):([^:<>]+)>"
match = re.search(reply_pattern, content)
if match:
# print(f"发现回复match:{match}")
bbb = match.group(2)
# 使用独立函数处理用户引用格式,传入自定义的匿名名称解析器
def anon_name_resolver(platform: str, user_id: str) -> str:
try:
anon_reply = get_anon_name(platform, bbb)
# print(f"anon_reply:{anon_reply}")
return get_anon_name(platform, user_id)
except Exception:
anon_reply = "?"
content = re.sub(reply_pattern, f"回复 {anon_reply}", content, count=1)
# 处理 @<aaa:bbb>无嵌套def
at_pattern = r"@<([^:<>]+):([^:<>]+)>"
at_matches = list(re.finditer(at_pattern, content))
if at_matches:
# print(f"发现@match:{at_matches}")
new_content = ""
last_end = 0
for m in at_matches:
new_content += content[last_end : m.start()]
bbb = m.group(2)
try:
anon_at = get_anon_name(platform, bbb)
# print(f"anon_at:{anon_at}")
except Exception:
anon_at = "?"
new_content += f"@{anon_at}"
last_end = m.end()
new_content += content[last_end:]
content = new_content
return "?"
content = replace_user_references_in_content(content, platform, anon_name_resolver, is_async=False, replace_bot_name=False)
header = f"{anon_name}"
output_lines.append(header)

View File

@@ -78,7 +78,7 @@ def is_mentioned_bot_in_message(message: MessageRecv) -> tuple[bool, float]:
# print(f"is_mentioned: {is_mentioned}")
# print(f"is_at: {is_at}")
if is_at and global_config.normal_chat.at_bot_inevitable_reply:
if is_at and global_config.chat.at_bot_inevitable_reply:
reply_probability = 1.0
logger.debug("被@回复概率设置为100%")
else:
@@ -103,7 +103,7 @@ def is_mentioned_bot_in_message(message: MessageRecv) -> tuple[bool, float]:
for nickname in nicknames:
if nickname in message_content:
is_mentioned = True
if is_mentioned and global_config.normal_chat.mentioned_bot_inevitable_reply:
if is_mentioned and global_config.chat.mentioned_bot_inevitable_reply:
reply_probability = 1.0
logger.debug("被提及回复概率设置为100%")
return is_mentioned, reply_probability

View File

@@ -35,7 +35,7 @@ class ClassicalWillingManager(BaseWillingManager):
if interested_rate > 0.2:
current_willing += interested_rate - 0.2
if willing_info.is_mentioned_bot and global_config.normal_chat.mentioned_bot_inevitable_reply and current_willing < 2:
if willing_info.is_mentioned_bot and global_config.chat.mentioned_bot_inevitable_reply and current_willing < 2:
current_willing += 1 if current_willing < 1.0 else 0.05
self.chat_reply_willing[chat_id] = min(current_willing, 1.0)

View File

@@ -390,7 +390,7 @@ MODULE_COLORS = {
"tts_action": "\033[38;5;58m", # 深黄色
"doubao_pic_plugin": "\033[38;5;64m", # 深绿色
# Action组件
"no_reply_action": "\033[38;5;196m", # 亮色,显眼
"no_reply_action": "\033[38;5;214m", # 亮色,显眼但不像警告
"reply_action": "\033[38;5;46m", # 亮绿色
"base_action": "\033[38;5;250m", # 浅灰色
# 数据库和消息

View File

@@ -49,7 +49,7 @@ TEMPLATE_DIR = os.path.join(PROJECT_ROOT, "template")
# 考虑到实际上配置文件中的mai_version是不会自动更新的,所以采用硬编码
# 对该字段的更新请严格参照语义化版本规范https://semver.org/lang/zh-CN/
MMC_VERSION = "0.9.0"
MMC_VERSION = "0.9.1"
def get_key_comment(toml_table, key):

View File

@@ -84,6 +84,12 @@ class ChatConfig(ConfigBase):
use_s4u_prompt_mode: bool = False
"""是否使用 s4u 对话构建模式,该模式会分开处理当前对话对象和其他所有对话的内容进行 prompt 构建"""
mentioned_bot_inevitable_reply: bool = False
"""提及 bot 必然回复"""
at_bot_inevitable_reply: bool = False
"""@bot 必然回复"""
# 修改:基于时段的回复频率配置,改为数组格式
time_based_talk_frequency: list[str] = field(default_factory=lambda: [])
"""
@@ -270,11 +276,7 @@ class NormalChatConfig(ConfigBase):
response_interested_rate_amplifier: float = 1.0
"""回复兴趣度放大系数"""
mentioned_bot_inevitable_reply: bool = False
"""提及 bot 必然回复"""
at_bot_inevitable_reply: bool = False
"""@bot 必然回复"""
@dataclass
@@ -406,15 +408,9 @@ class MoodConfig(ConfigBase):
enable_mood: bool = False
"""是否启用情绪系统"""
mood_update_interval: int = 1
"""情绪更新间隔(秒)"""
mood_decay_rate: float = 0.95
"""情绪衰减率"""
mood_intensity_factor: float = 0.7
"""情绪强度因子"""
mood_update_threshold: float = 1.0
"""情绪更新阈值,越高,更新越慢"""
@dataclass

View File

@@ -10,6 +10,7 @@ import base64
from PIL import Image
import io
import os
import copy # 添加copy模块用于深拷贝
from src.common.database.database import db # 确保 db 被导入用于 create_tables
from src.common.database.database_model import LLMUsage # 导入 LLMUsage 模型
from src.config.config import global_config
@@ -69,23 +70,28 @@ error_code_mapping = {
async def _safely_record(request_content: Dict[str, Any], payload: Dict[str, Any]):
"""安全地记录请求体用于调试日志不会修改原始payload对象"""
# 创建payload的深拷贝避免修改原始对象
safe_payload = copy.deepcopy(payload)
image_base64: str = request_content.get("image_base64")
image_format: str = request_content.get("image_format")
if (
image_base64
and payload
and isinstance(payload, dict)
and "messages" in payload
and len(payload["messages"]) > 0
and safe_payload
and isinstance(safe_payload, dict)
and "messages" in safe_payload
and len(safe_payload["messages"]) > 0
):
if isinstance(payload["messages"][0], dict) and "content" in payload["messages"][0]:
content = payload["messages"][0]["content"]
if isinstance(safe_payload["messages"][0], dict) and "content" in safe_payload["messages"][0]:
content = safe_payload["messages"][0]["content"]
if isinstance(content, list) and len(content) > 1 and "image_url" in content[1]:
payload["messages"][0]["content"][1]["image_url"]["url"] = (
# 只修改拷贝的对象,用于安全的日志记录
safe_payload["messages"][0]["content"][1]["image_url"]["url"] = (
f"data:image/{image_format.lower() if image_format else 'jpeg'};base64,"
f"{image_base64[:10]}...{image_base64[-10:]}"
)
return payload
return safe_payload
class LLMRequest:
@@ -109,10 +115,15 @@ class LLMRequest:
def __init__(self, model: dict, **kwargs):
# 将大写的配置键转换为小写并从config中获取实际值
logger.debug(f"🔍 [模型初始化] 开始初始化模型: {model.get('name', 'Unknown')}")
logger.debug(f"🔍 [模型初始化] 模型配置: {model}")
logger.debug(f"🔍 [模型初始化] 额外参数: {kwargs}")
try:
# print(f"model['provider']: {model['provider']}")
self.api_key = os.environ[f"{model['provider']}_KEY"]
self.base_url = os.environ[f"{model['provider']}_BASE_URL"]
logger.debug(f"🔍 [模型初始化] 成功获取环境变量: {model['provider']}_KEY 和 {model['provider']}_BASE_URL")
except AttributeError as e:
logger.error(f"原始 model dict 信息:{model}")
logger.error(f"配置错误:找不到对应的配置项 - {str(e)}")
@@ -124,6 +135,10 @@ class LLMRequest:
self.model_name: str = model["name"]
self.params = kwargs
# 记录配置文件中声明了哪些参数(不管值是什么)
self.has_enable_thinking = "enable_thinking" in model
self.has_thinking_budget = "thinking_budget" in model
self.enable_thinking = model.get("enable_thinking", False)
self.temp = model.get("temp", 0.7)
self.thinking_budget = model.get("thinking_budget", 4096)
@@ -132,12 +147,24 @@ class LLMRequest:
self.pri_out = model.get("pri_out", 0)
self.max_tokens = model.get("max_tokens", global_config.model.model_max_output_length)
# print(f"max_tokens: {self.max_tokens}")
logger.debug("🔍 [模型初始化] 模型参数设置完成:")
logger.debug(f" - model_name: {self.model_name}")
logger.debug(f" - has_enable_thinking: {self.has_enable_thinking}")
logger.debug(f" - enable_thinking: {self.enable_thinking}")
logger.debug(f" - has_thinking_budget: {self.has_thinking_budget}")
logger.debug(f" - thinking_budget: {self.thinking_budget}")
logger.debug(f" - temp: {self.temp}")
logger.debug(f" - stream: {self.stream}")
logger.debug(f" - max_tokens: {self.max_tokens}")
logger.debug(f" - base_url: {self.base_url}")
# 获取数据库实例
self._init_database()
# 从 kwargs 中提取 request_type如果没有提供则默认为 "default"
self.request_type = kwargs.pop("request_type", "default")
logger.debug(f"🔍 [模型初始化] 初始化完成request_type: {self.request_type}")
@staticmethod
def _init_database():
@@ -262,11 +289,12 @@ class LLMRequest:
if self.temp != 0.7:
payload["temperature"] = self.temp
# 添加enable_thinking参数如果不是默认值False
if not self.enable_thinking:
payload["enable_thinking"] = False
# 添加enable_thinking参数只有配置文件中声明了才添加不管值是true还是false
if self.has_enable_thinking:
payload["enable_thinking"] = self.enable_thinking
if self.thinking_budget != 4096:
# 添加thinking_budget参数只有配置文件中声明了才添加
if self.has_thinking_budget:
payload["thinking_budget"] = self.thinking_budget
if self.max_tokens:
@@ -334,6 +362,19 @@ class LLMRequest:
# 似乎是openai流式必须要的东西,不过阿里云的qwq-plus加了这个没有影响
if request_content["stream_mode"]:
headers["Accept"] = "text/event-stream"
# 添加请求发送前的调试信息
logger.debug(f"🔍 [请求调试] 模型 {self.model_name} 准备发送请求")
logger.debug(f"🔍 [请求调试] API URL: {request_content['api_url']}")
logger.debug(f"🔍 [请求调试] 请求头: {await self._build_headers(no_key=True, is_formdata=file_bytes is not None)}")
if not file_bytes:
# 安全地记录请求体(隐藏敏感信息)
safe_payload = await _safely_record(request_content, request_content["payload"])
logger.debug(f"🔍 [请求调试] 请求体: {json.dumps(safe_payload, indent=2, ensure_ascii=False)}")
else:
logger.debug(f"🔍 [请求调试] 文件上传请求,文件格式: {request_content['file_format']}")
async with aiohttp.ClientSession(connector=await get_tcp_connector()) as session:
post_kwargs = {"headers": headers}
# form-data数据上传方式不同
@@ -491,7 +532,36 @@ class LLMRequest:
logger.warning(f"模型 {self.model_name} 请求限制(429),等待{wait_time}秒后重试...")
raise RuntimeError("请求限制(429)")
elif response.status in policy["abort_codes"]:
if response.status != 403:
# 特别处理400错误添加详细调试信息
if response.status == 400:
logger.error(f"🔍 [调试信息] 模型 {self.model_name} 参数错误 (400) - 开始详细诊断")
logger.error(f"🔍 [调试信息] 模型名称: {self.model_name}")
logger.error(f"🔍 [调试信息] API地址: {self.base_url}")
logger.error("🔍 [调试信息] 模型配置参数:")
logger.error(f" - enable_thinking: {self.enable_thinking}")
logger.error(f" - temp: {self.temp}")
logger.error(f" - thinking_budget: {self.thinking_budget}")
logger.error(f" - stream: {self.stream}")
logger.error(f" - max_tokens: {self.max_tokens}")
logger.error(f" - pri_in: {self.pri_in}")
logger.error(f" - pri_out: {self.pri_out}")
logger.error(f"🔍 [调试信息] 原始params: {self.params}")
# 尝试获取服务器返回的详细错误信息
try:
error_text = await response.text()
logger.error(f"🔍 [调试信息] 服务器返回的原始错误内容: {error_text}")
try:
error_json = json.loads(error_text)
logger.error(f"🔍 [调试信息] 解析后的错误JSON: {json.dumps(error_json, indent=2, ensure_ascii=False)}")
except json.JSONDecodeError:
logger.error("🔍 [调试信息] 错误响应不是有效的JSON格式")
except Exception as e:
logger.error(f"🔍 [调试信息] 无法读取错误响应内容: {str(e)}")
raise RequestAbortException("参数错误,请检查调试信息", response)
elif response.status != 403:
raise RequestAbortException("请求出现错误,中断处理", response)
else:
raise PermissionDeniedException("模型禁止访问")
@@ -510,6 +580,19 @@ class LLMRequest:
logger.error(
f"模型 {self.model_name} 错误码: {response.status} - {error_code_mapping.get(response.status)}"
)
# 如果是400错误额外输出请求体信息用于调试
if response.status == 400:
logger.error("🔍 [异常调试] 400错误 - 请求体调试信息:")
try:
safe_payload = await _safely_record(request_content, payload)
logger.error(f"🔍 [异常调试] 发送的请求体: {json.dumps(safe_payload, indent=2, ensure_ascii=False)}")
except Exception as debug_error:
logger.error(f"🔍 [异常调试] 无法安全记录请求体: {str(debug_error)}")
logger.error(f"🔍 [异常调试] 原始payload类型: {type(payload)}")
if isinstance(payload, dict):
logger.error(f"🔍 [异常调试] 原始payload键: {list(payload.keys())}")
# print(request_content)
# print(response)
# 尝试获取并记录服务器返回的详细错误信息
@@ -654,14 +737,27 @@ class LLMRequest:
"""
# 复制一份参数,避免直接修改原始数据
new_params = dict(params)
logger.debug(f"🔍 [参数转换] 模型 {self.model_name} 开始参数转换")
logger.debug(f"🔍 [参数转换] 是否为CoT模型: {self.model_name.lower() in self.MODELS_NEEDING_TRANSFORMATION}")
logger.debug(f"🔍 [参数转换] CoT模型列表: {self.MODELS_NEEDING_TRANSFORMATION}")
if self.model_name.lower() in self.MODELS_NEEDING_TRANSFORMATION:
logger.debug("🔍 [参数转换] 检测到CoT模型开始参数转换")
# 删除 'temperature' 参数如果存在但避免删除我们在_build_payload中添加的自定义温度
if "temperature" in new_params and new_params["temperature"] == 0.7:
new_params.pop("temperature")
removed_temp = new_params.pop("temperature")
logger.debug(f"🔍 [参数转换] 移除默认temperature参数: {removed_temp}")
# 如果存在 'max_tokens',则重命名为 'max_completion_tokens'
if "max_tokens" in new_params:
old_value = new_params["max_tokens"]
new_params["max_completion_tokens"] = new_params.pop("max_tokens")
logger.debug(f"🔍 [参数转换] 参数重命名: max_tokens({old_value}) -> max_completion_tokens({new_params['max_completion_tokens']})")
else:
logger.debug("🔍 [参数转换] 非CoT模型无需参数转换")
logger.debug(f"🔍 [参数转换] 转换前参数: {params}")
logger.debug(f"🔍 [参数转换] 转换后参数: {new_params}")
return new_params
async def _build_formdata_payload(self, file_bytes: bytes, file_format: str) -> aiohttp.FormData:
@@ -693,7 +789,12 @@ class LLMRequest:
async def _build_payload(self, prompt: str, image_base64: str = None, image_format: str = None) -> dict:
"""构建请求体"""
# 复制一份参数,避免直接修改 self.params
logger.debug(f"🔍 [参数构建] 模型 {self.model_name} 开始构建请求体")
logger.debug(f"🔍 [参数构建] 原始self.params: {self.params}")
params_copy = await self._transform_parameters(self.params)
logger.debug(f"🔍 [参数构建] 转换后的params_copy: {params_copy}")
if image_base64:
messages = [
{
@@ -715,26 +816,37 @@ class LLMRequest:
"messages": messages,
**params_copy,
}
logger.debug(f"🔍 [参数构建] 基础payload构建完成: {list(payload.keys())}")
# 添加temp参数如果不是默认值0.7
if self.temp != 0.7:
payload["temperature"] = self.temp
logger.debug(f"🔍 [参数构建] 添加temperature参数: {self.temp}")
# 添加enable_thinking参数如果不是默认值False
if not self.enable_thinking:
payload["enable_thinking"] = False
# 添加enable_thinking参数只有配置文件中声明了才添加不管值是true还是false
if self.has_enable_thinking:
payload["enable_thinking"] = self.enable_thinking
logger.debug(f"🔍 [参数构建] 添加enable_thinking参数: {self.enable_thinking}")
if self.thinking_budget != 4096:
# 添加thinking_budget参数只有配置文件中声明了才添加
if self.has_thinking_budget:
payload["thinking_budget"] = self.thinking_budget
logger.debug(f"🔍 [参数构建] 添加thinking_budget参数: {self.thinking_budget}")
if self.max_tokens:
payload["max_tokens"] = self.max_tokens
logger.debug(f"🔍 [参数构建] 添加max_tokens参数: {self.max_tokens}")
# if "max_tokens" not in payload and "max_completion_tokens" not in payload:
# payload["max_tokens"] = global_config.model.model_max_output_length
# 如果 payload 中依然存在 max_tokens 且需要转换,在这里进行再次检查
if self.model_name.lower() in self.MODELS_NEEDING_TRANSFORMATION and "max_tokens" in payload:
old_value = payload["max_tokens"]
payload["max_completion_tokens"] = payload.pop("max_tokens")
logger.debug(f"🔍 [参数构建] CoT模型参数转换: max_tokens({old_value}) -> max_completion_tokens({payload['max_completion_tokens']})")
logger.debug(f"🔍 [参数构建] 最终payload键列表: {list(payload.keys())}")
return payload
def _default_response_handler(

View File

@@ -47,11 +47,35 @@ async def _calculate_interest(message: MessageRecv) -> Tuple[float, bool]:
logger.debug(f"记忆激活率: {interested_rate:.2f}")
text_len = len(message.processed_plain_text)
# 根据文本长度调整兴趣度,长度越大兴趣度越高但增长率递减最低0.01最高0.05
# 采用对数函数实现递减增长
base_interest = 0.01 + (0.05 - 0.01) * (math.log10(text_len + 1) / math.log10(1000 + 1))
base_interest = min(max(base_interest, 0.01), 0.05)
# 根据文本长度分布调整兴趣度,采用分段函数实现更精确的兴趣度计算
# 基于实际分布0-5字符(26.57%), 6-10字符(27.18%), 11-20字符(22.76%), 21-30字符(10.33%), 31+字符(13.86%)
if text_len == 0:
base_interest = 0.01 # 空消息最低兴趣度
elif text_len <= 5:
# 1-5字符线性增长 0.01 -> 0.03
base_interest = 0.01 + (text_len - 1) * (0.03 - 0.01) / 4
elif text_len <= 10:
# 6-10字符线性增长 0.03 -> 0.06
base_interest = 0.03 + (text_len - 5) * (0.06 - 0.03) / 5
elif text_len <= 20:
# 11-20字符线性增长 0.06 -> 0.12
base_interest = 0.06 + (text_len - 10) * (0.12 - 0.06) / 10
elif text_len <= 30:
# 21-30字符线性增长 0.12 -> 0.18
base_interest = 0.12 + (text_len - 20) * (0.18 - 0.12) / 10
elif text_len <= 50:
# 31-50字符线性增长 0.18 -> 0.22
base_interest = 0.18 + (text_len - 30) * (0.22 - 0.18) / 20
elif text_len <= 100:
# 51-100字符线性增长 0.22 -> 0.26
base_interest = 0.22 + (text_len - 50) * (0.26 - 0.22) / 50
else:
# 100+字符:对数增长 0.26 -> 0.3,增长率递减
base_interest = 0.26 + (0.3 - 0.26) * (math.log10(text_len - 99) / math.log10(901)) # 1000-99=901
# 确保在范围内
base_interest = min(max(base_interest, 0.01), 0.3)
interested_rate += base_interest

View File

@@ -78,12 +78,12 @@ class ChatMood:
if interested_rate <= 0:
interest_multiplier = 0
else:
interest_multiplier = 3 * math.pow(interested_rate, 0.25)
interest_multiplier = 2 * math.pow(interested_rate, 0.25)
logger.debug(
f"base_probability: {base_probability}, time_multiplier: {time_multiplier}, interest_multiplier: {interest_multiplier}"
)
update_probability = min(1.0, base_probability * time_multiplier * interest_multiplier)
update_probability = global_config.mood.mood_update_threshold * min(1.0, base_probability * time_multiplier * interest_multiplier)
if random.random() > update_probability:
return

View File

@@ -419,7 +419,7 @@ class RelationshipBuilder:
async def update_impression_on_segments(self, person_id: str, chat_id: str, segments: List[Dict[str, Any]]):
"""基于消息段更新用户印象"""
original_segment_count = len(segments)
logger.info(f"开始为 {person_id} 基于 {original_segment_count} 个消息段更新印象")
logger.debug(f"开始为 {person_id} 基于 {original_segment_count} 个消息段更新印象")
try:
# 筛选要处理的消息段每个消息段有10%的概率被丢弃
segments_to_process = [s for s in segments if random.random() >= 0.1]
@@ -432,7 +432,7 @@ class RelationshipBuilder:
dropped_count = original_segment_count - len(segments_to_process)
if dropped_count > 0:
logger.info(f"{person_id} 随机丢弃了 {dropped_count} / {original_segment_count} 个消息段")
logger.debug(f"{person_id} 随机丢弃了 {dropped_count} / {original_segment_count} 个消息段")
processed_messages = []

View File

@@ -19,6 +19,7 @@
await send_api.custom_message("video", video_data, "123456", True)
"""
import asyncio
import traceback
import time
import difflib
@@ -30,7 +31,7 @@ from src.common.logger import get_logger
from src.chat.message_receive.chat_stream import get_chat_manager
from src.chat.message_receive.uni_message_sender import HeartFCSender
from src.chat.message_receive.message import MessageSending, MessageRecv
from src.chat.utils.chat_message_builder import get_raw_msg_before_timestamp_with_chat
from src.chat.utils.chat_message_builder import get_raw_msg_before_timestamp_with_chat, replace_user_references_in_content
from src.person_info.person_info import get_person_info_manager
from maim_message import Seg, UserInfo
from src.config.config import global_config
@@ -183,32 +184,8 @@ async def _find_reply_message(target_stream, reply_to: str) -> Optional[MessageR
if person_name == sender:
translate_text = message["processed_plain_text"]
# 检查是否有 回复<aaa:bbb> 字段
reply_pattern = r"回复<([^:<>]+):([^:<>]+)>"
if match := re.search(reply_pattern, translate_text):
aaa = match.group(1)
bbb = match.group(2)
reply_person_id = get_person_info_manager().get_person_id(platform, bbb)
reply_person_name = await get_person_info_manager().get_value(reply_person_id, "person_name") or aaa
# 在内容前加上回复信息
translate_text = re.sub(reply_pattern, f"回复 {reply_person_name}", translate_text, count=1)
# 检查是否有 @<aaa:bbb> 字段
at_pattern = r"@<([^:<>]+):([^:<>]+)>"
at_matches = list(re.finditer(at_pattern, translate_text))
if at_matches:
new_content = ""
last_end = 0
for m in at_matches:
new_content += translate_text[last_end : m.start()]
aaa = m.group(1)
bbb = m.group(2)
at_person_id = get_person_info_manager().get_person_id(platform, bbb)
at_person_name = await get_person_info_manager().get_value(at_person_id, "person_name") or aaa
new_content += f"@{at_person_name}"
last_end = m.end()
new_content += translate_text[last_end:]
translate_text = new_content
# 使用独立函数处理用户引用格式
translate_text = await replace_user_references_in_content(translate_text, platform, is_async=True)
similarity = difflib.SequenceMatcher(None, text, translate_text).ratio()
if similarity >= 0.9:

View File

@@ -49,12 +49,10 @@ class BaseAction(ABC):
reasoning: 执行该动作的理由
cycle_timers: 计时器字典
thinking_id: 思考ID
expressor: 表达器对象
replyer: 回复器对象
chat_stream: 聊天流对象
log_prefix: 日志前缀
shutting_down: 是否正在关闭
plugin_config: 插件配置字典
action_message: 消息数据
**kwargs: 其他参数
"""
if plugin_config is None:
@@ -414,23 +412,11 @@ class BaseAction(ABC):
"""
return await self.execute()
# def get_action_context(self, key: str, default=None):
# """获取action上下文信息
# Args:
# key: 上下文键名
# default: 默认值
# Returns:
# Any: 上下文值或默认值
# """
# return self.api.get_action_context(key, default)
def get_config(self, key: str, default=None):
"""获取插件配置值,支持嵌套键访问
"""获取插件配置值,使用嵌套键访问
Args:
key: 配置键名,支持嵌套访问如 "section.subsection.key"
key: 配置键名,使用嵌套访问如 "section.subsection.key"
default: 默认值
Returns:

View File

@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from typing import Dict, Tuple, Optional, List
from typing import Dict, Tuple, Optional
from src.common.logger import get_logger
from src.plugin_system.base.component_types import CommandInfo, ComponentType
from src.chat.message_receive.message import MessageRecv
@@ -17,7 +17,6 @@ class BaseCommand(ABC):
- command_pattern: 命令匹配的正则表达式
- command_help: 命令帮助信息
- command_examples: 命令使用示例列表
- intercept_message: 是否拦截消息处理默认True拦截False继续传递
"""
command_name: str = ""
@@ -27,11 +26,6 @@ class BaseCommand(ABC):
# 默认命令设置
command_pattern: str = r""
"""命令匹配的正则表达式"""
command_help: str = ""
"""命令帮助信息"""
command_examples: List[str] = []
intercept_message: bool = True
"""是否拦截信息,默认拦截,不进行后续处理"""
def __init__(self, message: MessageRecv, plugin_config: Optional[dict] = None):
"""初始化Command组件
@@ -57,19 +51,19 @@ class BaseCommand(ABC):
self.matched_groups = groups
@abstractmethod
async def execute(self) -> Tuple[bool, Optional[str]]:
async def execute(self) -> Tuple[bool, Optional[str], bool]:
"""执行Command的抽象方法子类必须实现
Returns:
Tuple[bool, Optional[str]]: (是否执行成功, 可选的回复消息)
Tuple[bool, Optional[str], bool]: (是否执行成功, 可选的回复消息, 是否拦截消息 不进行 后续处理)
"""
pass
def get_config(self, key: str, default=None):
"""获取插件配置值,支持嵌套键访问
"""获取插件配置值,使用嵌套键访问
Args:
key: 配置键名,支持嵌套访问如 "section.subsection.key"
key: 配置键名,使用嵌套访问如 "section.subsection.key"
default: 默认值
Returns:
@@ -231,7 +225,4 @@ class BaseCommand(ABC):
component_type=ComponentType.COMMAND,
description=cls.command_description,
command_pattern=cls.command_pattern,
command_help=cls.command_help,
command_examples=cls.command_examples.copy() if cls.command_examples else [],
intercept_message=cls.intercept_message,
)

View File

@@ -140,14 +140,9 @@ class CommandInfo(ComponentInfo):
"""命令组件信息"""
command_pattern: str = "" # 命令匹配模式(正则表达式)
command_help: str = "" # 命令帮助信息
command_examples: List[str] = field(default_factory=list) # 命令使用示例
intercept_message: bool = True # 是否拦截消息处理(默认拦截)
def __post_init__(self):
super().__post_init__()
if self.command_examples is None:
self.command_examples = []
self.component_type = ComponentType.COMMAND

View File

@@ -182,17 +182,17 @@ class EventsManager:
async def cancel_handler_tasks(self, handler_name: str) -> None:
tasks_to_be_cancelled = self._handler_tasks.get(handler_name, [])
remaining_tasks = [task for task in tasks_to_be_cancelled if not task.done()]
for task in remaining_tasks:
task.cancel()
try:
await asyncio.wait_for(asyncio.gather(*remaining_tasks, return_exceptions=True), timeout=5)
logger.info(f"已取消事件处理器 {handler_name} 的所有任务")
except asyncio.TimeoutError:
logger.warning(f"取消事件处理器 {handler_name} 的任务超时,开始强制取消")
except Exception as e:
logger.error(f"取消事件处理器 {handler_name} 的任务时发生异常: {e}")
finally:
if remaining_tasks := [task for task in tasks_to_be_cancelled if not task.done()]:
for task in remaining_tasks:
task.cancel()
try:
await asyncio.wait_for(asyncio.gather(*remaining_tasks, return_exceptions=True), timeout=5)
logger.info(f"已取消事件处理器 {handler_name} 的所有任务")
except asyncio.TimeoutError:
logger.warning(f"取消事件处理器 {handler_name} 的任务超时,开始强制取消")
except Exception as e:
logger.error(f"取消事件处理器 {handler_name} 的任务时发生异常: {e}")
if handler_name in self._handler_tasks:
del self._handler_tasks[handler_name]
async def unregister_event_subscriber(self, handler_name: str) -> bool:

View File

@@ -163,12 +163,11 @@ class VersionComparator:
version_normalized, max_normalized
)
if is_compatible:
logger.info(f"版本兼容性检查:{compat_msg}")
return True, compat_msg
else:
if not is_compatible:
return False, f"版本 {version_normalized} 高于最大支持版本 {max_normalized},且无兼容性映射"
logger.info(f"版本兼容性检查:{compat_msg}")
return True, compat_msg
return True, ""
@staticmethod
@@ -358,14 +357,10 @@ class ManifestValidator:
if self.validation_errors:
report.append("❌ 验证错误:")
for error in self.validation_errors:
report.append(f" - {error}")
report.extend(f" - {error}" for error in self.validation_errors)
if self.validation_warnings:
report.append("⚠️ 验证警告:")
for warning in self.validation_warnings:
report.append(f" - {warning}")
report.extend(f" - {warning}" for warning in self.validation_warnings)
if not self.validation_errors and not self.validation_warnings:
report.append("✅ Manifest文件验证通过")

View File

@@ -20,8 +20,7 @@ class EmojiAction(BaseAction):
"""表情动作 - 发送表情包"""
# 激活设置
focus_activation_type = ActionActivationType.RANDOM
normal_activation_type = ActionActivationType.RANDOM
activation_type = ActionActivationType.RANDOM
mode_enable = ChatMode.ALL
parallel_action = True
random_activation_probability = 0.2 # 默认值,可通过配置覆盖

View File

@@ -66,13 +66,12 @@ class CoreActionsPlugin(BasePlugin):
if global_config.emoji.emoji_activate_type == "llm":
EmojiAction.random_activation_probability = 0.0
EmojiAction.focus_activation_type = ActionActivationType.LLM_JUDGE
EmojiAction.normal_activation_type = ActionActivationType.LLM_JUDGE
EmojiAction.activation_type = ActionActivationType.LLM_JUDGE
elif global_config.emoji.emoji_activate_type == "random":
EmojiAction.random_activation_probability = global_config.emoji.emoji_chance
EmojiAction.focus_activation_type = ActionActivationType.RANDOM
EmojiAction.normal_activation_type = ActionActivationType.RANDOM
EmojiAction.activation_type = ActionActivationType.RANDOM
# --- 根据配置注册组件 ---
components = []
if self.get_config("components.enable_reply", True):

View File

@@ -32,13 +32,13 @@ class ReplyAction(BaseAction):
# 动作基本信息
action_name = "reply"
action_description = "参与聊天回复,发送文本进行表达"
action_description = ""
# 动作参数定义
action_parameters = {}
# 动作使用场景
action_require = ["你想要闲聊或者随便附和", "有人提到你", "如果你刚刚进行了回复,不要对同一个话题重复回应"]
action_require = [""]
# 关联类型
associated_types = ["text"]
@@ -46,6 +46,9 @@ class ReplyAction(BaseAction):
def _parse_reply_target(self, target_message: str) -> tuple:
sender = ""
target = ""
# 添加None检查防止NoneType错误
if target_message is None:
return sender, target
if ":" in target_message or "" in target_message:
# 使用正则表达式匹配中文或英文冒号
parts = re.split(pattern=r"[:]", string=target_message, maxsplit=1)

View File

@@ -19,7 +19,7 @@ class ManagementCommand(BaseCommand):
description: str = "管理命令"
command_pattern: str = r"(?P<manage_command>^/pm(\s[a-zA-Z0-9_]+)*\s*$)"
async def execute(self) -> Tuple[bool, str]:
async def execute(self) -> Tuple[bool, str, bool]:
# sourcery skip: merge-duplicate-blocks
if (
not self.message
@@ -28,11 +28,11 @@ class ManagementCommand(BaseCommand):
or str(self.message.message_info.user_info.user_id) not in self.get_config("plugin.permission", []) # type: ignore
):
await self.send_text("你没有权限使用插件管理命令")
return False, "没有权限"
return False, "没有权限", True
command_list = self.matched_groups["manage_command"].strip().split(" ")
if len(command_list) == 1:
await self.show_help("all")
return True, "帮助已发送"
return True, "帮助已发送", True
if len(command_list) == 2:
match command_list[1]:
case "plugin":
@@ -43,7 +43,7 @@ class ManagementCommand(BaseCommand):
await self.show_help("all")
case _:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
if len(command_list) == 3:
if command_list[1] == "plugin":
match command_list[2]:
@@ -57,7 +57,7 @@ class ManagementCommand(BaseCommand):
await self._rescan_plugin_dirs()
case _:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
elif command_list[1] == "component":
if command_list[2] == "list":
await self._list_all_registered_components()
@@ -65,10 +65,10 @@ class ManagementCommand(BaseCommand):
await self.show_help("component")
else:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
else:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
if len(command_list) == 4:
if command_list[1] == "plugin":
match command_list[2]:
@@ -82,28 +82,28 @@ class ManagementCommand(BaseCommand):
await self._add_dir(command_list[3])
case _:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
elif command_list[1] == "component":
if command_list[2] != "list":
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
if command_list[3] == "enabled":
await self._list_enabled_components()
elif command_list[3] == "disabled":
await self._list_disabled_components()
else:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
else:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
if len(command_list) == 5:
if command_list[1] != "component":
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
if command_list[2] != "list":
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
if command_list[3] == "enabled":
await self._list_enabled_components(target_type=command_list[4])
elif command_list[3] == "disabled":
@@ -112,11 +112,11 @@ class ManagementCommand(BaseCommand):
await self._list_registered_components_by_type(command_list[4])
else:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
if len(command_list) == 6:
if command_list[1] != "component":
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
if command_list[2] == "enable":
if command_list[3] == "global":
await self._globally_enable_component(command_list[4], command_list[5])
@@ -124,7 +124,7 @@ class ManagementCommand(BaseCommand):
await self._locally_enable_component(command_list[4], command_list[5])
else:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
elif command_list[2] == "disable":
if command_list[3] == "global":
await self._globally_disable_component(command_list[4], command_list[5])
@@ -132,12 +132,12 @@ class ManagementCommand(BaseCommand):
await self._locally_disable_component(command_list[4], command_list[5])
else:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
else:
await self.send_text("插件管理命令不合法")
return False, "命令不合法"
return False, "命令不合法", True
return True, "命令执行完成"
return True, "命令执行完成", True
async def show_help(self, target: str):
help_msg = ""

View File

@@ -39,11 +39,7 @@ class CompareNumbersTool(BaseTool):
else:
result = f"{num1} 等于 {num2}"
return {"type": "comparison_result", "id": f"{num1}_vs_{num2}", "content": result}
return {"name": self.name, "content": result}
except Exception as e:
logger.error(f"比较数字失败: {str(e)}")
return {"type": "info", "id": f"{num1}_vs_{num2}", "content": f"比较数字失败,炸了: {str(e)}"}
# 注册工具
# register_tool(CompareNumbersTool)
return {"name": self.name, "content": f"比较数字失败,炸了: {str(e)}"}

View File

@@ -1,7 +1,6 @@
from src.tools.tool_can_use.base_tool import BaseTool
from src.person_info.person_info import get_person_info_manager
from src.common.logger import get_logger
import time
logger = get_logger("rename_person_tool")
@@ -24,7 +23,7 @@ class RenamePersonTool(BaseTool):
"required": ["person_name"],
}
async def execute(self, function_args: dict, message_txt=""):
async def execute(self, function_args: dict):
"""
执行取名工具逻辑
@@ -82,7 +81,7 @@ class RenamePersonTool(BaseTool):
content = f"已成功将用户 {person_name_to_find} 的备注名更新为 {new_name}"
logger.info(content)
return {"type": "info", "id": f"rename_success_{time.time()}", "content": content}
return {"name": self.name, "content": content}
else:
logger.warning(f"为用户 {person_id} 调用 qv_person_name 后未能成功获取新昵称。")
# 尝试从内存中获取可能已经更新的名字
@@ -101,4 +100,4 @@ class RenamePersonTool(BaseTool):
except Exception as e:
error_msg = f"重命名失败: {str(e)}"
logger.error(error_msg, exc_info=True)
return {"type": "info_error", "id": f"rename_error_{time.time()}", "content": error_msg}
return {"name": self.name, "content": error_msg}

View File

@@ -172,7 +172,7 @@ class ToolExecutor:
logger.debug(f"{self.log_prefix}执行工具: {tool_name}")
# 执行工具
result = await self.tool_instance._execute_tool_call(tool_call)
result = await self.tool_instance.execute_tool_call(tool_call)
if result:
tool_info = {
@@ -299,7 +299,7 @@ class ToolExecutor:
logger.info(f"{self.log_prefix}直接执行工具: {tool_name}")
result = await self.tool_instance._execute_tool_call(tool_call)
result = await self.tool_instance.execute_tool_call(tool_call)
if result:
tool_info = {

View File

@@ -16,7 +16,8 @@ class ToolUser:
return get_all_tool_definitions()
@staticmethod
async def _execute_tool_call(tool_call):
async def execute_tool_call(tool_call):
# sourcery skip: use-assigned-variable
"""执行特定的工具调用
Args:

View File

@@ -59,6 +59,9 @@ max_context_size = 25 # 上下文长度
thinking_timeout = 20 # 麦麦一次回复最长思考规划时间超过这个时间的思考会放弃往往是api反应太慢
replyer_random_probability = 0.5 # 首要replyer模型被选择的概率
mentioned_bot_inevitable_reply = true # 提及 bot 大概率回复
at_bot_inevitable_reply = true # @bot 或 提及bot 大概率回复
use_s4u_prompt_mode = true # 是否使用 s4u 对话构建模式,该模式会更好的把握当前对话对象的对话内容,但是对群聊整理理解能力较差(测试功能!!可能有未知问题!!)
@@ -101,11 +104,8 @@ ban_msgs_regex = [
]
[normal_chat] #普通聊天
#一般回复参数
willing_mode = "classical" # 回复意愿模式 —— 经典模式classicalmxp模式mxp自定义模式custom需要你自己实现
response_interested_rate_amplifier = 1 # 麦麦回复兴趣度放大系数
mentioned_bot_inevitable_reply = true # 提及 bot 必然回复
at_bot_inevitable_reply = true # @bot 必然回复(包含提及)
[tool]
enable_in_normal_chat = false # 是否在普通聊天中启用工具
@@ -148,9 +148,7 @@ enable_asr = false # 是否启用语音识别,启用后麦麦可以识别语
[mood]
enable_mood = true # 是否启用情绪系统
mood_update_interval = 1.0 # 情绪更新间隔 单位秒
mood_decay_rate = 0.95 # 情绪衰减率
mood_intensity_factor = 1.0 # 情绪强度因子
mood_update_threshold = 1 # 情绪更新阈值,越高,更新越慢
[lpmm_knowledge] # lpmm知识库配置
enable = false # 是否启用lpmm知识库