Merge branch 'dev' of https://github.com/MaiM-with-u/MaiBot into dev
This commit is contained in:
@@ -4,42 +4,183 @@
|
|||||||
|
|
||||||
Action是给麦麦在回复之外提供额外功能的智能组件,**由麦麦的决策系统自主选择是否使用**,具有随机性和拟人化的调用特点。Action不是直接响应用户命令,而是让麦麦根据聊天情境智能地选择合适的动作,使其行为更加自然和真实。
|
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采用**两层决策机制**来优化性能和决策质量:
|
Action采用**两层决策机制**来优化性能和决策质量:
|
||||||
|
|
||||||
### 第一层:激活控制(Activation Control)
|
> 设计目的:在加载许多插件的时候降低LLM决策压力,避免让麦麦在过多的选项中纠结。
|
||||||
|
|
||||||
**激活决定麦麦是否"知道"这个Action的存在**,即这个Action是否进入决策候选池。**不被激活的Action麦麦永远不会选择**。
|
**第一层:激活控制(Activation Control)**
|
||||||
|
|
||||||
> 🎯 **设计目的**:在加载许多插件的时候降低LLM决策压力,避免让麦麦在过多的选项中纠结。
|
激活决定麦麦是否 **“知道”** 这个Action的存在,即这个Action是否进入决策候选池。不被激活的Action麦麦永远不会选择。
|
||||||
|
|
||||||
#### 激活类型说明
|
**第二层:使用决策(Usage Decision)**
|
||||||
|
|
||||||
|
在Action被激活后,使用条件决定麦麦什么时候会 **“选择”** 使用这个Action。
|
||||||
|
|
||||||
|
### 决策参数详解 🔧
|
||||||
|
|
||||||
|
#### 第一层:ActivationType 激活类型说明
|
||||||
|
|
||||||
| 激活类型 | 说明 | 使用场景 |
|
| 激活类型 | 说明 | 使用场景 |
|
||||||
| ------------- | ------------------------------------------- | ------------------------ |
|
| ----------- | ---------------------------------------- | ---------------------- |
|
||||||
| `NEVER` | 从不激活,Action对麦麦不可见 | 临时禁用某个Action |
|
| [`NEVER`](#never-激活) | 从不激活,Action对麦麦不可见 | 临时禁用某个Action |
|
||||||
| `ALWAYS` | 永远激活,Action总是在麦麦的候选池中 | 核心功能,如回复、不回复 |
|
| [`ALWAYS`](#always-激活) | 永远激活,Action总是在麦麦的候选池中 | 核心功能,如回复、不回复 |
|
||||||
| `LLM_JUDGE` | 通过LLM智能判断当前情境是否需要激活此Action | 需要智能判断的复杂场景 |
|
| [`LLM_JUDGE`](#llm_judge-激活) | 通过LLM智能判断当前情境是否需要激活此Action | 需要智能判断的复杂场景 |
|
||||||
| `RANDOM` | 基于随机概率决定是否激活 | 增加行为随机性的功能 |
|
| `RANDOM` | 基于随机概率决定是否激活 | 增加行为随机性的功能 |
|
||||||
| `KEYWORD` | 当检测到特定关键词时激活 | 明确触发条件的功能 |
|
| `KEYWORD` | 当检测到特定关键词时激活 | 明确触发条件的功能 |
|
||||||
|
|
||||||
#### 聊天模式控制
|
#### `NEVER` 激活
|
||||||
|
|
||||||
| 模式 | 说明 |
|
`ActionActivationType.NEVER` 会使得 Action 永远不会被激活
|
||||||
| ------------------- | ------------------------ |
|
|
||||||
| `ChatMode.FOCUS` | 仅在专注聊天模式下可激活 |
|
|
||||||
| `ChatMode.NORMAL` | 仅在普通聊天模式下可激活 |
|
|
||||||
| `ChatMode.ALL` | 所有模式下都可激活 |
|
|
||||||
|
|
||||||
### 第二层:使用决策(Usage Decision)
|
```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**。
|
**在Action被激活后,使用条件决定麦麦什么时候会"选择"使用这个Action**。
|
||||||
|
|
||||||
@@ -49,16 +190,15 @@ Action采用**两层决策机制**来优化性能和决策质量:
|
|||||||
- `action_parameters`:所需参数,影响Action的可执行性
|
- `action_parameters`:所需参数,影响Action的可执行性
|
||||||
- 当前聊天上下文和麦麦的决策逻辑
|
- 当前聊天上下文和麦麦的决策逻辑
|
||||||
|
|
||||||
### 🎬 决策流程示例
|
---
|
||||||
|
|
||||||
假设有一个"发送表情"Action:
|
### 决策流程示例
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class EmojiAction(BaseAction):
|
class EmojiAction(BaseAction):
|
||||||
# 第一层:激活控制
|
# 第一层:激活控制
|
||||||
focus_activation_type = ActionActivationType.RANDOM # 专注模式下随机激活
|
activation_type = ActionActivationType.RANDOM # 随机激活
|
||||||
normal_activation_type = ActionActivationType.KEYWORD # 普通模式下关键词激活
|
random_activation_probability = 0.1 # 10%概率激活
|
||||||
activation_keywords = ["表情", "emoji", "😊"]
|
|
||||||
|
|
||||||
# 第二层:使用决策
|
# 第二层:使用决策
|
||||||
action_require = [
|
action_require = [
|
||||||
@@ -72,311 +212,85 @@ class EmojiAction(BaseAction):
|
|||||||
|
|
||||||
1. **第一层激活判断**:
|
1. **第一层激活判断**:
|
||||||
|
|
||||||
- 普通模式:只有当用户消息包含"表情"、"emoji"或"😊"时,麦麦才"知道"可以使用这个Action
|
- 使用随机数进行决策,当`random.random() < self.random_activation_probability`时,麦麦才"知道"可以使用这个Action
|
||||||
- 专注模式:随机激活,有概率让麦麦"看到"这个Action
|
|
||||||
2. **第二层使用决策**:
|
2. **第二层使用决策**:
|
||||||
|
|
||||||
- 即使Action被激活,麦麦还会根据 `action_require`中的条件判断是否真正选择使用
|
- 即使Action被激活,麦麦还会根据 `action_require` 中的条件判断是否真正选择使用
|
||||||
- 例如:如果刚刚已经发过表情,根据"不要连续发送多个表情"的要求,麦麦可能不会选择这个Action
|
- 例如:如果刚刚已经发过表情,根据"不要连续发送多个表情"的要求,麦麦可能不会选择这个Action
|
||||||
|
|
||||||
## 📋 Action必须项清单
|
---
|
||||||
|
|
||||||
每个Action类都**必须**包含以下属性:
|
|
||||||
|
|
||||||
### 1. 激活控制必须项
|
|
||||||
|
|
||||||
|
## Action 内置属性说明
|
||||||
```python
|
```python
|
||||||
# 专注模式下的激活类型
|
class BaseAction:
|
||||||
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):
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# 消息相关属性
|
# 消息相关属性
|
||||||
self.message # 当前消息对象
|
self.log_prefix: str # 日志前缀
|
||||||
self.chat_stream # 聊天流对象
|
self.group_id: str # 群组ID
|
||||||
self.user_id # 用户ID
|
self.group_name: str # 群组名称
|
||||||
self.user_nickname # 用户昵称
|
self.user_id: str # 用户ID
|
||||||
self.platform # 平台类型 (qq, telegram等)
|
self.user_nickname: str # 用户昵称
|
||||||
self.chat_id # 聊天ID
|
self.platform: str # 平台类型 (qq, telegram等)
|
||||||
self.is_group # 是否群聊
|
self.chat_id: str # 聊天ID
|
||||||
|
self.chat_stream: ChatStream # 聊天流对象
|
||||||
|
self.is_group: bool # 是否群聊
|
||||||
|
|
||||||
|
# 消息体
|
||||||
|
self.action_message: dict # 消息数据
|
||||||
|
|
||||||
# Action相关属性
|
# Action相关属性
|
||||||
self.action_data # Action执行时的数据
|
self.action_data: dict # Action执行时的数据
|
||||||
self.thinking_id # 思考ID
|
self.thinking_id: str # 思考ID
|
||||||
self.matched_groups # 匹配到的组(如果有正则匹配)
|
|
||||||
```
|
```
|
||||||
|
action_message为一个字典,包含的键值对如下(省略了不必要的键值对)
|
||||||
### 内置方法
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class MyAction(BaseAction):
|
{
|
||||||
|
"message_id": "1234567890", # 消息id,str
|
||||||
|
"time": 1627545600.0, # 时间戳,float
|
||||||
|
"chat_id": "abcdef123456", # 聊天ID,str
|
||||||
|
"reply_to": None, # 回复消息id,str或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_config,dict或None
|
||||||
|
"is_emoji": False, # 是否为表情,bool
|
||||||
|
"is_picid": False, # 是否为图片ID,bool
|
||||||
|
"is_command": False # 是否为命令,bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
部分值的格式请自行查询数据库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Action 内置方法说明
|
||||||
|
```python
|
||||||
|
class BaseAction:
|
||||||
# 配置相关
|
# 配置相关
|
||||||
def get_config(self, key: str, default=None):
|
def get_config(self, key: str, default=None):
|
||||||
"""获取配置值"""
|
"""获取插件配置值,使用嵌套键访问"""
|
||||||
pass
|
|
||||||
|
|
||||||
# 消息发送相关
|
async def wait_for_new_message(self, timeout: int = 1200) -> Tuple[bool, str]:
|
||||||
async def send_text(self, text: 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 send_custom(self, message_type: str, content: str, typing: bool = False, reply_to: str = "") -> bool:
|
||||||
async def store_action_info(self, **kwargs):
|
"""发送自定义类型消息"""
|
||||||
"""记录动作信息"""
|
|
||||||
pass
|
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:
|
||||||
|
"""发送命令消息"""
|
||||||
```
|
```
|
||||||
|
具体参数与用法参见`BaseAction`基类的定义。
|
||||||
## 🎯 完整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`的有效性
|
|
||||||
- **配置测试**:测试不同配置下的行为
|
|
||||||
@@ -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集成"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -49,12 +49,10 @@ class BaseAction(ABC):
|
|||||||
reasoning: 执行该动作的理由
|
reasoning: 执行该动作的理由
|
||||||
cycle_timers: 计时器字典
|
cycle_timers: 计时器字典
|
||||||
thinking_id: 思考ID
|
thinking_id: 思考ID
|
||||||
expressor: 表达器对象
|
|
||||||
replyer: 回复器对象
|
|
||||||
chat_stream: 聊天流对象
|
chat_stream: 聊天流对象
|
||||||
log_prefix: 日志前缀
|
log_prefix: 日志前缀
|
||||||
shutting_down: 是否正在关闭
|
|
||||||
plugin_config: 插件配置字典
|
plugin_config: 插件配置字典
|
||||||
|
action_message: 消息数据
|
||||||
**kwargs: 其他参数
|
**kwargs: 其他参数
|
||||||
"""
|
"""
|
||||||
if plugin_config is None:
|
if plugin_config is None:
|
||||||
@@ -414,23 +412,11 @@ class BaseAction(ABC):
|
|||||||
"""
|
"""
|
||||||
return await self.execute()
|
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):
|
def get_config(self, key: str, default=None):
|
||||||
"""获取插件配置值,支持嵌套键访问
|
"""获取插件配置值,使用嵌套键访问
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: 配置键名,支持嵌套访问如 "section.subsection.key"
|
key: 配置键名,使用嵌套访问如 "section.subsection.key"
|
||||||
default: 默认值
|
default: 默认值
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
Reference in New Issue
Block a user