mirror of
https://github.com/FuQuan233/nonebot-plugin-llmchat.git
synced 2026-02-05 03:28:05 +00:00
WIP::私聊功能实现
This commit is contained in:
parent
2f62365460
commit
36a47fa5e2
4 changed files with 510 additions and 57 deletions
183
PRIVATE_CHAT_CHANGES.md
Normal file
183
PRIVATE_CHAT_CHANGES.md
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
# 私聊功能实现总结
|
||||||
|
|
||||||
|
## 📝 概览
|
||||||
|
|
||||||
|
已成功为 nonebot-plugin-llmchat 项目添加了完整的私聊功能支持。用户现在可以在私聊中与机器人进行对话,同时保持群聊功能完全不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 主要改动
|
||||||
|
|
||||||
|
### 1. **config.py** - 配置模块
|
||||||
|
|
||||||
|
#### 新增配置项:
|
||||||
|
- `LLMCHAT__ENABLE_PRIVATE_CHAT` (bool, 默认值: False)
|
||||||
|
- 是否启用私聊功能
|
||||||
|
|
||||||
|
- `LLMCHAT__PRIVATE_CHAT_PRESET` (str, 默认值: "off")
|
||||||
|
- 私聊默认使用的预设名称
|
||||||
|
|
||||||
|
### 2. **__init__.py** - 主程序模块
|
||||||
|
|
||||||
|
#### 新增导入:
|
||||||
|
```python
|
||||||
|
from typing import Union
|
||||||
|
from nonebot.adapters.onebot.v11 import PrivateMessageEvent
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 新增数据结构:
|
||||||
|
|
||||||
|
**PrivateChatState 类**
|
||||||
|
- 用于管理每个用户的私聊状态
|
||||||
|
- 结构与 GroupState 类似,但针对单个用户独立管理
|
||||||
|
- 包含:preset_name、history、queue、processing 等属性
|
||||||
|
|
||||||
|
**private_chat_states 字典**
|
||||||
|
- 类型:`dict[int, PrivateChatState]`
|
||||||
|
- 按用户ID存储私聊状态
|
||||||
|
|
||||||
|
#### 修改的函数:
|
||||||
|
|
||||||
|
1. **format_message()**
|
||||||
|
- 参数改为:`event: Union[GroupMessageEvent, PrivateMessageEvent]`
|
||||||
|
- 支持两种消息事件类型的格式化
|
||||||
|
|
||||||
|
2. **is_triggered()**
|
||||||
|
- 参数改为:`event: Union[GroupMessageEvent, PrivateMessageEvent]`
|
||||||
|
- 新增私聊事件检测逻辑
|
||||||
|
- 私聊消息在启用且预设不为"off"时自动触发
|
||||||
|
|
||||||
|
3. **get_preset()**
|
||||||
|
- 新增参数:`is_group: bool = True`
|
||||||
|
- 支持从群组或私聊状态获取预设配置
|
||||||
|
|
||||||
|
4. **process_messages()**
|
||||||
|
- 新增参数:`context_id: int, is_group: bool = True`
|
||||||
|
- 支持处理群组和私聊消息
|
||||||
|
- 私聊时跳过OneBot群操作工具(ob__开头的工具)
|
||||||
|
|
||||||
|
5. **handle_message()**
|
||||||
|
- 参数改为:`event: Union[GroupMessageEvent, PrivateMessageEvent]`
|
||||||
|
- 支持路由到不同的处理逻辑
|
||||||
|
|
||||||
|
6. **save_state()** / **load_state()**
|
||||||
|
- 新增私聊状态的持久化
|
||||||
|
- 私聊状态保存到单独的文件:`llmchat_private_state.json`
|
||||||
|
|
||||||
|
#### 新增命令处理器(私聊相关):
|
||||||
|
|
||||||
|
所有私聊命令需要主人权限,且仅在启用私聊功能时可用:
|
||||||
|
|
||||||
|
1. **私聊API预设**
|
||||||
|
- 查看或修改私聊使用的API预设
|
||||||
|
- 用法:`私聊API预设 [预设名]`
|
||||||
|
|
||||||
|
2. **私聊修改设定**
|
||||||
|
- 修改私聊机器人的性格设定
|
||||||
|
- 用法:`私聊修改设定 [新设定]`
|
||||||
|
|
||||||
|
3. **私聊记忆清除**
|
||||||
|
- 清除私聊的对话历史记录
|
||||||
|
- 用法:`私聊记忆清除`
|
||||||
|
|
||||||
|
4. **私聊切换思维输出**
|
||||||
|
- 切换是否输出AI的思维过程
|
||||||
|
- 用法:`私聊切换思维输出`
|
||||||
|
|
||||||
|
### 3. **README.md** - 文档更新
|
||||||
|
|
||||||
|
#### 更新的章节:
|
||||||
|
|
||||||
|
1. **项目介绍**
|
||||||
|
- 更新标题为"群聊&私聊的AI对话插件"
|
||||||
|
- 添加"群聊和私聊支持"功能说明
|
||||||
|
|
||||||
|
2. **配置表格**
|
||||||
|
- 添加两个新配置项的说明
|
||||||
|
|
||||||
|
3. **使用指南**
|
||||||
|
- 将原"指令表"改名为"群聊指令表"
|
||||||
|
- 新增"私聊指令表"
|
||||||
|
- 添加"私聊功能启用示例"部分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 使用指南
|
||||||
|
|
||||||
|
### 启用私聊功能
|
||||||
|
|
||||||
|
在 `.env` 文件中添加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启用私聊功能
|
||||||
|
LLMCHAT__ENABLE_PRIVATE_CHAT=true
|
||||||
|
|
||||||
|
# 设置私聊默认预设
|
||||||
|
LLMCHAT__PRIVATE_CHAT_PRESET="deepseek-v1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 私聊命令示例
|
||||||
|
|
||||||
|
```
|
||||||
|
# 主人私聊机器人
|
||||||
|
|
||||||
|
私聊API预设 # 查看当前预设
|
||||||
|
私聊API预设 aliyun-deepseek-v3 # 切换预设
|
||||||
|
|
||||||
|
私聊修改设定 你是一个有趣的AI # 修改性格设定
|
||||||
|
|
||||||
|
私聊记忆清除 # 清除对话记忆
|
||||||
|
|
||||||
|
私聊切换思维输出 # 开关思维过程输出
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 关键特性
|
||||||
|
|
||||||
|
✅ **独立管理** - 群聊和私聊拥有完全独立的对话记忆和配置
|
||||||
|
|
||||||
|
✅ **灵活控制** - 可单独启用/禁用私聊功能,无需影响群聊
|
||||||
|
|
||||||
|
✅ **自动触发** - 私聊消息自动触发回复,无需@机器人
|
||||||
|
|
||||||
|
✅ **权限隔离** - 私聊命令仅主人可用
|
||||||
|
|
||||||
|
✅ **工具适配** - 私聊时自动跳过不适用的群操作工具
|
||||||
|
|
||||||
|
✅ **状态持久化** - 私聊状态独立保存和恢复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 文件对比
|
||||||
|
|
||||||
|
| 文件 | 变更类型 | 主要改动 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| config.py | 修改 | 新增2个配置项 |
|
||||||
|
| __init__.py | 修改 | 新增私聊类、处理器、命令 |
|
||||||
|
| README.md | 修改 | 更新文档说明 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **默认禁用** - 私聊功能默认为禁用状态,需要在配置文件中显式启用
|
||||||
|
|
||||||
|
2. **群操作工具** - OneBot群操作工具(禁言、撤回等)在私聊中不可用
|
||||||
|
|
||||||
|
3. **状态文件** - 私聊状态存储在 `llmchat_private_state.json` 文件中
|
||||||
|
|
||||||
|
4. **权限限制** - 所有私聊命令都需要主人权限
|
||||||
|
|
||||||
|
5. **独立预设** - 私聊和群聊可以使用不同的API预设
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 后续改进建议
|
||||||
|
|
||||||
|
- [ ] 支持多用户私聊会话管理面板
|
||||||
|
- [ ] 添加私聊消息转发到管理员功能
|
||||||
|
- [ ] 实现私聊速率限制
|
||||||
|
- [ ] 添加私聊用户黑名单
|
||||||
|
- [ ] 支持私聊消息加密存储
|
||||||
|
|
||||||
46
README.md
46
README.md
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
# nonebot-plugin-llmchat
|
# nonebot-plugin-llmchat
|
||||||
|
|
||||||
_✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型的AI群聊插件 ✨_
|
_✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型、群聊&私聊的AI对话插件 ✨_
|
||||||
|
|
||||||
|
|
||||||
<a href="./LICENSE">
|
<a href="./LICENSE">
|
||||||
|
|
@ -48,6 +48,12 @@ _✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型
|
||||||
- 支持处理回复消息
|
- 支持处理回复消息
|
||||||
- 群聊消息顺序处理,防止消息错乱
|
- 群聊消息顺序处理,防止消息错乱
|
||||||
|
|
||||||
|
1. **群聊和私聊支持**
|
||||||
|
- 支持群聊场景(默认启用)
|
||||||
|
- 支持私聊场景(可选启用)
|
||||||
|
- 分别管理群聊和私聊的对话记忆
|
||||||
|
- 灵活的权限配置
|
||||||
|
|
||||||
1. **分群聊上下文记忆管理**
|
1. **分群聊上下文记忆管理**
|
||||||
- 分群聊保留对话历史记录(可配置保留条数)
|
- 分群聊保留对话历史记录(可配置保留条数)
|
||||||
- 自动合并未处理消息,降低API用量
|
- 自动合并未处理消息,降低API用量
|
||||||
|
|
@ -120,6 +126,8 @@ _✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型
|
||||||
| LLMCHAT__BLACKLIST_USER_IDS | 否 | [] | 黑名单用户ID列表,机器人将不会处理黑名单用户的消息 |
|
| LLMCHAT__BLACKLIST_USER_IDS | 否 | [] | 黑名单用户ID列表,机器人将不会处理黑名单用户的消息 |
|
||||||
| LLMCHAT__IGNORE_PREFIXES | 否 | [] | 需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理 |
|
| LLMCHAT__IGNORE_PREFIXES | 否 | [] | 需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理 |
|
||||||
| LLMCHAT__MCP_SERVERS | 否 | {} | MCP服务器配置,具体见下表 |
|
| LLMCHAT__MCP_SERVERS | 否 | {} | MCP服务器配置,具体见下表 |
|
||||||
|
| LLMCHAT__ENABLE_PRIVATE_CHAT | 否 | False | 是否启用私聊功能 |
|
||||||
|
| LLMCHAT__PRIVATE_CHAT_PRESET | 否 | off | 私聊默认使用的预设名称 |
|
||||||
|
|
||||||
### 内置OneBot工具
|
### 内置OneBot工具
|
||||||
|
|
||||||
|
|
@ -241,7 +249,7 @@ LLMCHAT__MCP_SERVERS同样为一个dict,key为服务器名称,value配置的
|
||||||
|
|
||||||
配置完成后@机器人即可手动触发回复,另外在机器人收到群聊消息时会根据`LLMCHAT__RANDOM_TRIGGER_PROB`配置的概率或群聊中使用指令设置的概率随机自动触发回复。
|
配置完成后@机器人即可手动触发回复,另外在机器人收到群聊消息时会根据`LLMCHAT__RANDOM_TRIGGER_PROB`配置的概率或群聊中使用指令设置的概率随机自动触发回复。
|
||||||
|
|
||||||
### 指令表
|
### 群聊指令表
|
||||||
|
|
||||||
以下指令均仅对发送的群聊生效,不同群聊配置不互通。
|
以下指令均仅对发送的群聊生效,不同群聊配置不互通。
|
||||||
|
|
||||||
|
|
@ -253,6 +261,40 @@ LLMCHAT__MCP_SERVERS同样为一个dict,key为服务器名称,value配置的
|
||||||
| 切换思维输出 | 管理 | 否 | 群聊 | 无 | 切换是否输出AI的思维过程的开关(需模型支持) |
|
| 切换思维输出 | 管理 | 否 | 群聊 | 无 | 切换是否输出AI的思维过程的开关(需模型支持) |
|
||||||
| 设置主动回复概率 | 管理 | 否 | 群聊 | 主动回复概率 | 主动回复概率需为 [0, 1] 的浮点数,0为完全关闭主动回复 |
|
| 设置主动回复概率 | 管理 | 否 | 群聊 | 主动回复概率 | 主动回复概率需为 [0, 1] 的浮点数,0为完全关闭主动回复 |
|
||||||
|
|
||||||
|
### 私聊指令表
|
||||||
|
|
||||||
|
以下指令仅在启用私聊功能(`LLMCHAT__ENABLE_PRIVATE_CHAT=true`)后可用,这些指令均只对发送者的私聊生效。
|
||||||
|
|
||||||
|
| 指令 | 权限 | 参数 | 说明 |
|
||||||
|
|:-----:|:----:|:----:|:----:|
|
||||||
|
| 私聊API预设 | 主人 | [预设名] | 查看或修改私聊使用的API预设 |
|
||||||
|
| 私聊修改设定 | 主人 | 设定 | 修改私聊机器人的设定 |
|
||||||
|
| 私聊记忆清除 | 主人 | 无 | 清除私聊的机器人记忆 |
|
||||||
|
| 私聊切换思维输出 | 主人 | 无 | 切换是否输出私聊AI的思维过程的开关(需模型支持) |
|
||||||
|
|
||||||
|
**私聊功能说明:**
|
||||||
|
|
||||||
|
- 私聊消息默认触发回复(无需@或随机触发)
|
||||||
|
- 私聊和群聊的对话记忆独立管理
|
||||||
|
- OneBot群操作工具(如禁言、撤回等)在私聊中不可用
|
||||||
|
|
||||||
|
## 📝 私聊功能启用示例
|
||||||
|
|
||||||
|
在 `.env` 文件中添加以下配置以启用私聊功能:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLMCHAT__ENABLE_PRIVATE_CHAT=true
|
||||||
|
LLMCHAT__PRIVATE_CHAT_PRESET="deepseek-v1"
|
||||||
|
```
|
||||||
|
|
||||||
|
然后你可以在私聊中与机器人交互。使用以下命令管理私聊:
|
||||||
|
|
||||||
|
- 切换预设:`私聊API预设 aliyun-deepseek-v3`
|
||||||
|
- 清除记忆:`私聊记忆清除`
|
||||||
|
- 修改设定:`私聊修改设定 你是一个有趣的AI助手`
|
||||||
|
| 切换思维输出 | 管理 | 否 | 群聊 | 无 | 切换是否输出AI的思维过程的开关(需模型支持) |
|
||||||
|
| 设置主动回复概率 | 管理 | 否 | 群聊 | 主动回复概率 | 主动回复概率需为 [0, 1] 的浮点数,0为完全关闭主动回复 |
|
||||||
|
|
||||||
### 效果图
|
### 效果图
|
||||||

|

|
||||||

|

|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import random
|
||||||
import re
|
import re
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Union
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -21,7 +21,7 @@ from nonebot import (
|
||||||
on_message,
|
on_message,
|
||||||
require,
|
require,
|
||||||
)
|
)
|
||||||
from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageSegment
|
from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageSegment, PrivateMessageEvent
|
||||||
from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER
|
from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER
|
||||||
from nonebot.params import CommandArg
|
from nonebot.params import CommandArg
|
||||||
from nonebot.permission import SUPERUSER
|
from nonebot.permission import SUPERUSER
|
||||||
|
|
@ -86,16 +86,36 @@ class GroupState:
|
||||||
self.last_active = time.time()
|
self.last_active = time.time()
|
||||||
self.past_events = deque(maxlen=plugin_config.past_events_size)
|
self.past_events = deque(maxlen=plugin_config.past_events_size)
|
||||||
self.group_prompt: str | None = None
|
self.group_prompt: str | None = None
|
||||||
|
self.user_prompt: str | None = None
|
||||||
self.output_reasoning_content = False
|
self.output_reasoning_content = False
|
||||||
self.random_trigger_prob = plugin_config.random_trigger_prob
|
self.random_trigger_prob = plugin_config.random_trigger_prob
|
||||||
|
|
||||||
|
|
||||||
|
# 初始化私聊状态
|
||||||
|
class PrivateChatState:
|
||||||
|
def __init__(self):
|
||||||
|
self.preset_name = plugin_config.private_chat_preset
|
||||||
|
self.history = deque(maxlen=plugin_config.history_size * 2)
|
||||||
|
self.queue = asyncio.Queue()
|
||||||
|
self.processing = False
|
||||||
|
self.last_active = time.time()
|
||||||
|
self.past_events = deque(maxlen=plugin_config.past_events_size)
|
||||||
|
self.group_prompt: str | None = None
|
||||||
|
self.user_prompt: str | None = None
|
||||||
|
self.output_reasoning_content = False
|
||||||
|
|
||||||
|
|
||||||
group_states: dict[int, GroupState] = defaultdict(GroupState)
|
group_states: dict[int, GroupState] = defaultdict(GroupState)
|
||||||
|
private_chat_states: dict[int, PrivateChatState] = defaultdict(PrivateChatState)
|
||||||
|
|
||||||
|
|
||||||
# 获取当前预设配置
|
# 获取当前预设配置
|
||||||
def get_preset(group_id: int) -> PresetConfig:
|
def get_preset(context_id: int, is_group: bool = True) -> PresetConfig:
|
||||||
state = group_states[group_id]
|
if is_group:
|
||||||
|
state = group_states[context_id]
|
||||||
|
else:
|
||||||
|
state = private_chat_states[context_id]
|
||||||
|
|
||||||
for preset in plugin_config.api_presets:
|
for preset in plugin_config.api_presets:
|
||||||
if preset.name == state.preset_name:
|
if preset.name == state.preset_name:
|
||||||
return preset
|
return preset
|
||||||
|
|
@ -103,12 +123,12 @@ def get_preset(group_id: int) -> PresetConfig:
|
||||||
|
|
||||||
|
|
||||||
# 消息格式转换
|
# 消息格式转换
|
||||||
def format_message(event: GroupMessageEvent) -> str:
|
def format_message(event: Union[GroupMessageEvent, PrivateMessageEvent]) -> str:
|
||||||
text_message = ""
|
text_message = ""
|
||||||
if event.reply is not None:
|
if isinstance(event, GroupMessageEvent) and event.reply is not None:
|
||||||
text_message += f"[回复 {event.reply.sender.nickname} 的消息 {event.reply.message.extract_plain_text()}]\n"
|
text_message += f"[回复 {event.reply.sender.nickname} 的消息 {event.reply.message.extract_plain_text()}]\n"
|
||||||
|
|
||||||
if event.is_tome():
|
if isinstance(event, GroupMessageEvent) and event.is_tome():
|
||||||
text_message += f"@{next(iter(driver.config.nickname))} "
|
text_message += f"@{next(iter(driver.config.nickname))} "
|
||||||
|
|
||||||
for msgseg in event.get_message():
|
for msgseg in event.get_message():
|
||||||
|
|
@ -123,6 +143,7 @@ def format_message(event: GroupMessageEvent) -> str:
|
||||||
elif msgseg.type == "text":
|
elif msgseg.type == "text":
|
||||||
text_message += msgseg.data.get("text", "")
|
text_message += msgseg.data.get("text", "")
|
||||||
|
|
||||||
|
if isinstance(event, GroupMessageEvent):
|
||||||
message = {
|
message = {
|
||||||
"SenderNickname": str(event.sender.card or event.sender.nickname),
|
"SenderNickname": str(event.sender.card or event.sender.nickname),
|
||||||
"SenderUserId": str(event.user_id),
|
"SenderUserId": str(event.user_id),
|
||||||
|
|
@ -130,6 +151,14 @@ def format_message(event: GroupMessageEvent) -> str:
|
||||||
"MessageID": event.message_id,
|
"MessageID": event.message_id,
|
||||||
"SendTime": datetime.fromtimestamp(event.time).isoformat(),
|
"SendTime": datetime.fromtimestamp(event.time).isoformat(),
|
||||||
}
|
}
|
||||||
|
else: # PrivateMessageEvent
|
||||||
|
message = {
|
||||||
|
"SenderNickname": str(event.sender.nickname),
|
||||||
|
"SenderUserId": str(event.user_id),
|
||||||
|
"Message": text_message,
|
||||||
|
"MessageID": event.message_id,
|
||||||
|
"SendTime": datetime.fromtimestamp(event.time).isoformat(),
|
||||||
|
}
|
||||||
return json.dumps(message, ensure_ascii=False)
|
return json.dumps(message, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -157,9 +186,10 @@ def build_reasoning_forward_nodes(self_id: str, reasoning_content: str):
|
||||||
return nodes
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
async def is_triggered(event: GroupMessageEvent) -> bool:
|
async def is_triggered(event: Union[GroupMessageEvent, PrivateMessageEvent]) -> bool:
|
||||||
"""扩展后的消息处理规则"""
|
"""扩展后的消息处理规则"""
|
||||||
|
|
||||||
|
if isinstance(event, GroupMessageEvent):
|
||||||
state = group_states[event.group_id]
|
state = group_states[event.group_id]
|
||||||
|
|
||||||
if state.preset_name == "off":
|
if state.preset_name == "off":
|
||||||
|
|
@ -187,6 +217,33 @@ async def is_triggered(event: GroupMessageEvent) -> bool:
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
elif isinstance(event, PrivateMessageEvent):
|
||||||
|
# 检查私聊功能是否启用
|
||||||
|
if not plugin_config.enable_private_chat:
|
||||||
|
return False
|
||||||
|
|
||||||
|
state = private_chat_states[event.user_id]
|
||||||
|
|
||||||
|
if state.preset_name == "off":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 黑名单用户
|
||||||
|
if event.user_id in plugin_config.blacklist_user_ids:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 忽略特定前缀的消息
|
||||||
|
msg_text = event.get_plaintext().strip()
|
||||||
|
for prefix in plugin_config.ignore_prefixes:
|
||||||
|
if msg_text.startswith(prefix):
|
||||||
|
return False
|
||||||
|
|
||||||
|
state.past_events.append(event)
|
||||||
|
|
||||||
|
# 私聊默认触发
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# 消息处理器
|
# 消息处理器
|
||||||
handler = on_message(
|
handler = on_message(
|
||||||
|
|
@ -197,18 +254,27 @@ handler = on_message(
|
||||||
|
|
||||||
|
|
||||||
@handler.handle()
|
@handler.handle()
|
||||||
async def handle_message(event: GroupMessageEvent):
|
async def handle_message(event: Union[GroupMessageEvent, PrivateMessageEvent]):
|
||||||
|
if isinstance(event, GroupMessageEvent):
|
||||||
group_id = event.group_id
|
group_id = event.group_id
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"收到群聊消息 群号:{group_id} 用户:{event.user_id} 内容:{event.get_plaintext()}"
|
f"收到群聊消息 群号:{group_id} 用户:{event.user_id} 内容:{event.get_plaintext()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
state = group_states[group_id]
|
state = group_states[group_id]
|
||||||
|
context_id = group_id
|
||||||
|
else: # PrivateMessageEvent
|
||||||
|
user_id = event.user_id
|
||||||
|
logger.debug(
|
||||||
|
f"收到私聊消息 用户:{user_id} 内容:{event.get_plaintext()}"
|
||||||
|
)
|
||||||
|
state = private_chat_states[user_id]
|
||||||
|
context_id = user_id
|
||||||
|
|
||||||
await state.queue.put(event)
|
await state.queue.put(event)
|
||||||
if not state.processing:
|
if not state.processing:
|
||||||
state.processing = True
|
state.processing = True
|
||||||
task = asyncio.create_task(process_messages(group_id))
|
is_group = isinstance(event, GroupMessageEvent)
|
||||||
|
task = asyncio.create_task(process_messages(context_id, is_group))
|
||||||
task.add_done_callback(tasks.discard)
|
task.add_done_callback(tasks.discard)
|
||||||
tasks.add(task)
|
tasks.add(task)
|
||||||
|
|
||||||
|
|
@ -253,9 +319,16 @@ async def send_split_messages(message_handler, content: str):
|
||||||
logger.debug(f"发送消息分段 内容:{segment[:50]}...") # 只记录前50个字符避免日志过大
|
logger.debug(f"发送消息分段 内容:{segment[:50]}...") # 只记录前50个字符避免日志过大
|
||||||
await message_handler.send(Message(segment))
|
await message_handler.send(Message(segment))
|
||||||
|
|
||||||
async def process_messages(group_id: int):
|
async def process_messages(context_id: int, is_group: bool = True):
|
||||||
|
if is_group:
|
||||||
|
group_id = context_id
|
||||||
state = group_states[group_id]
|
state = group_states[group_id]
|
||||||
preset = get_preset(group_id)
|
else:
|
||||||
|
user_id = context_id
|
||||||
|
state = private_chat_states[user_id]
|
||||||
|
group_id = None
|
||||||
|
|
||||||
|
preset = get_preset(context_id, is_group)
|
||||||
|
|
||||||
# 初始化OpenAI客户端
|
# 初始化OpenAI客户端
|
||||||
if preset.proxy != "":
|
if preset.proxy != "":
|
||||||
|
|
@ -273,16 +346,21 @@ async def process_messages(group_id: int):
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"开始处理群聊消息 群号:{group_id} 当前队列长度:{state.queue.qsize()}"
|
f"开始处理{'群聊' if is_group else '私聊'}消息 {'群号' if is_group else '用户'}:{context_id} 当前队列长度:{state.queue.qsize()}"
|
||||||
)
|
)
|
||||||
while not state.queue.empty():
|
while not state.queue.empty():
|
||||||
event = await state.queue.get()
|
event = await state.queue.get()
|
||||||
logger.debug(f"从队列获取消息 群号:{group_id} 消息ID:{event.message_id}")
|
if is_group:
|
||||||
|
logger.debug(f"从队列获取消息 群号:{context_id} 消息ID:{event.message_id}")
|
||||||
|
group_id = context_id
|
||||||
|
else:
|
||||||
|
logger.debug(f"从队列获取消息 用户:{context_id} 消息ID:{event.message_id}")
|
||||||
|
group_id = None
|
||||||
past_events_snapshot = []
|
past_events_snapshot = []
|
||||||
mcp_client = MCPClient.get_instance(plugin_config.mcp_servers)
|
mcp_client = MCPClient.get_instance(plugin_config.mcp_servers)
|
||||||
try:
|
try:
|
||||||
systemPrompt = f"""
|
systemPrompt = f"""
|
||||||
我想要你帮我在群聊中闲聊,大家一般叫你{"、".join(list(driver.config.nickname))},我将会在后面的信息中告诉你每条群聊信息的发送者和发送时间,你可以直接称呼发送者为他对应的昵称。
|
我想要你帮我在{"群聊" if is_group else "私聊"}中闲聊,大家一般叫你{"、".join(list(driver.config.nickname))},我将会在后面的信息中告诉你每条{"群聊" if is_group else "私聊"}信息的发送者和发送时间,你可以直接称呼发送者为他对应的昵称。
|
||||||
你的回复需要遵守以下几点规则:
|
你的回复需要遵守以下几点规则:
|
||||||
- 你可以使用多条消息回复,每两条消息之间使用<botbr>分隔,<botbr>前后不需要包含额外的换行和空格。
|
- 你可以使用多条消息回复,每两条消息之间使用<botbr>分隔,<botbr>前后不需要包含额外的换行和空格。
|
||||||
- 除<botbr>外,消息中不应该包含其他类似的标记。
|
- 除<botbr>外,消息中不应该包含其他类似的标记。
|
||||||
|
|
@ -296,7 +374,7 @@ async def process_messages(group_id: int):
|
||||||
- 如果你选择完全不回复,你只需要直接输出一个<botbr>。
|
- 如果你选择完全不回复,你只需要直接输出一个<botbr>。
|
||||||
- 如果你需要思考的话,你应该思考尽量少,以节省时间。
|
- 如果你需要思考的话,你应该思考尽量少,以节省时间。
|
||||||
下面是关于你性格的设定,如果设定中提到让你扮演某个人,或者设定中有提到名字,则优先使用设定中的名字。
|
下面是关于你性格的设定,如果设定中提到让你扮演某个人,或者设定中有提到名字,则优先使用设定中的名字。
|
||||||
{state.group_prompt or plugin_config.default_prompt}
|
{(state.group_prompt if is_group else state.user_prompt) or plugin_config.default_prompt}
|
||||||
"""
|
"""
|
||||||
if preset.support_mcp:
|
if preset.support_mcp:
|
||||||
systemPrompt += "你也可以使用一些工具,下面是关于这些工具的额外说明:\n"
|
systemPrompt += "你也可以使用一些工具,下面是关于这些工具的额外说明:\n"
|
||||||
|
|
@ -382,12 +460,24 @@ async def process_messages(group_id: int):
|
||||||
await handler.send(Message(f"正在使用{mcp_client.get_friendly_name(tool_name)}"))
|
await handler.send(Message(f"正在使用{mcp_client.get_friendly_name(tool_name)}"))
|
||||||
|
|
||||||
# 执行工具调用,传递群组和机器人信息用于QQ工具
|
# 执行工具调用,传递群组和机器人信息用于QQ工具
|
||||||
|
if is_group:
|
||||||
result = await mcp_client.call_tool(
|
result = await mcp_client.call_tool(
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_args,
|
tool_args,
|
||||||
group_id=event.group_id,
|
group_id=event.group_id,
|
||||||
bot_id=str(event.self_id)
|
bot_id=str(event.self_id)
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# 私聊时某些工具不可用(如群操作工具),跳过这些工具
|
||||||
|
if tool_name.startswith("ob__"):
|
||||||
|
result = f"私聊不支持{mcp_client.get_friendly_name(tool_name)}工具"
|
||||||
|
else:
|
||||||
|
result = await mcp_client.call_tool(
|
||||||
|
tool_name,
|
||||||
|
tool_args,
|
||||||
|
group_id=None,
|
||||||
|
bot_id=str(event.self_id)
|
||||||
|
)
|
||||||
|
|
||||||
new_messages.append({
|
new_messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
|
@ -452,7 +542,7 @@ async def process_messages(group_id: int):
|
||||||
await handler.send(image_msg)
|
await handler.send(image_msg)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.opt(exception=e).error(f"API请求失败 群号:{group_id}")
|
logger.opt(exception=e).error(f"API请求失败 {'群号' if is_group else '用户'}:{context_id}")
|
||||||
# 如果在处理过程中出现异常,恢复未处理的消息到state中
|
# 如果在处理过程中出现异常,恢复未处理的消息到state中
|
||||||
state.past_events.extendleft(reversed(past_events_snapshot))
|
state.past_events.extendleft(reversed(past_events_snapshot))
|
||||||
await handler.send(Message(f"服务暂时不可用,请稍后再试\n{e!s}"))
|
await handler.send(Message(f"服务暂时不可用,请稍后再试\n{e!s}"))
|
||||||
|
|
@ -564,12 +654,113 @@ async def handle_think(event: GroupMessageEvent, args: Message = CommandArg()):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# region 私聊相关指令
|
||||||
|
|
||||||
|
# 私聊预设切换命令
|
||||||
|
private_preset_handler = on_command(
|
||||||
|
"私聊API预设",
|
||||||
|
priority=1,
|
||||||
|
block=True,
|
||||||
|
permission=SUPERUSER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@private_preset_handler.handle()
|
||||||
|
async def handle_private_preset(event: PrivateMessageEvent, args: Message = CommandArg()):
|
||||||
|
if not plugin_config.enable_private_chat:
|
||||||
|
await private_preset_handler.finish("私聊功能未启用")
|
||||||
|
|
||||||
|
user_id = event.user_id
|
||||||
|
preset_name = args.extract_plain_text().strip()
|
||||||
|
|
||||||
|
if preset_name == "off":
|
||||||
|
private_chat_states[user_id].preset_name = preset_name
|
||||||
|
await private_preset_handler.finish("已关闭llmchat私聊功能")
|
||||||
|
|
||||||
|
available_presets = {p.name for p in plugin_config.api_presets}
|
||||||
|
if preset_name not in available_presets:
|
||||||
|
available_presets_str = "\n- ".join(available_presets)
|
||||||
|
await private_preset_handler.finish(
|
||||||
|
f"当前API预设:{private_chat_states[user_id].preset_name}\n可用API预设:\n- {available_presets_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
private_chat_states[user_id].preset_name = preset_name
|
||||||
|
await private_preset_handler.finish(f"已切换至API预设:{preset_name}")
|
||||||
|
|
||||||
|
|
||||||
|
# 私聊设定修改命令
|
||||||
|
private_edit_preset_handler = on_command(
|
||||||
|
"私聊修改设定",
|
||||||
|
priority=1,
|
||||||
|
block=True,
|
||||||
|
permission=SUPERUSER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@private_edit_preset_handler.handle()
|
||||||
|
async def handle_private_edit_preset(event: PrivateMessageEvent, args: Message = CommandArg()):
|
||||||
|
if not plugin_config.enable_private_chat:
|
||||||
|
await private_edit_preset_handler.finish("私聊功能未启用")
|
||||||
|
|
||||||
|
user_id = event.user_id
|
||||||
|
user_prompt = args.extract_plain_text().strip()
|
||||||
|
|
||||||
|
private_chat_states[user_id].group_prompt = user_prompt
|
||||||
|
await private_edit_preset_handler.finish("修改成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 私聊记忆清除命令
|
||||||
|
private_reset_handler = on_command(
|
||||||
|
"私聊记忆清除",
|
||||||
|
priority=1,
|
||||||
|
block=True,
|
||||||
|
permission=SUPERUSER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@private_reset_handler.handle()
|
||||||
|
async def handle_private_reset(event: PrivateMessageEvent, args: Message = CommandArg()):
|
||||||
|
if not plugin_config.enable_private_chat:
|
||||||
|
await private_reset_handler.finish("私聊功能未启用")
|
||||||
|
|
||||||
|
user_id = event.user_id
|
||||||
|
|
||||||
|
private_chat_states[user_id].past_events.clear()
|
||||||
|
private_chat_states[user_id].history.clear()
|
||||||
|
await private_reset_handler.finish("记忆已清空")
|
||||||
|
|
||||||
|
|
||||||
|
# 私聊思维输出切换命令
|
||||||
|
private_think_handler = on_command(
|
||||||
|
"私聊切换思维输出",
|
||||||
|
priority=1,
|
||||||
|
block=True,
|
||||||
|
permission=SUPERUSER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@private_think_handler.handle()
|
||||||
|
async def handle_private_think(event: PrivateMessageEvent, args: Message = CommandArg()):
|
||||||
|
if not plugin_config.enable_private_chat:
|
||||||
|
await private_think_handler.finish("私聊功能未启用")
|
||||||
|
|
||||||
|
state = private_chat_states[event.user_id]
|
||||||
|
state.output_reasoning_content = not state.output_reasoning_content
|
||||||
|
|
||||||
|
await private_think_handler.finish(
|
||||||
|
f"已{(state.output_reasoning_content and '开启') or '关闭'}思维输出"
|
||||||
|
)
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
# region 持久化与定时任务
|
# region 持久化与定时任务
|
||||||
|
|
||||||
# 获取插件数据目录
|
# 获取插件数据目录
|
||||||
data_dir = store.get_plugin_data_dir()
|
data_dir = store.get_plugin_data_dir()
|
||||||
# 获取插件数据文件
|
# 获取插件数据文件
|
||||||
data_file = store.get_plugin_data_file("llmchat_state.json")
|
data_file = store.get_plugin_data_file("llmchat_state.json")
|
||||||
|
private_data_file = store.get_plugin_data_file("llmchat_private_state.json")
|
||||||
|
|
||||||
|
|
||||||
async def save_state():
|
async def save_state():
|
||||||
|
|
@ -591,6 +782,24 @@ async def save_state():
|
||||||
async with aiofiles.open(data_file, "w", encoding="utf8") as f:
|
async with aiofiles.open(data_file, "w", encoding="utf8") as f:
|
||||||
await f.write(json.dumps(data, ensure_ascii=False))
|
await f.write(json.dumps(data, ensure_ascii=False))
|
||||||
|
|
||||||
|
# 保存私聊状态
|
||||||
|
if plugin_config.enable_private_chat:
|
||||||
|
logger.info(f"开始保存私聊状态到文件:{private_data_file}")
|
||||||
|
private_data = {
|
||||||
|
uid: {
|
||||||
|
"preset": state.preset_name,
|
||||||
|
"history": list(state.history),
|
||||||
|
"last_active": state.last_active,
|
||||||
|
"group_prompt": state.group_prompt,
|
||||||
|
"output_reasoning_content": state.output_reasoning_content,
|
||||||
|
}
|
||||||
|
for uid, state in private_chat_states.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(private_data_file), exist_ok=True)
|
||||||
|
async with aiofiles.open(private_data_file, "w", encoding="utf8") as f:
|
||||||
|
await f.write(json.dumps(private_data, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
async def load_state():
|
async def load_state():
|
||||||
"""从文件加载群组状态"""
|
"""从文件加载群组状态"""
|
||||||
|
|
@ -612,6 +821,23 @@ async def load_state():
|
||||||
state.random_trigger_prob = state_data.get("random_trigger_prob", plugin_config.random_trigger_prob)
|
state.random_trigger_prob = state_data.get("random_trigger_prob", plugin_config.random_trigger_prob)
|
||||||
group_states[int(gid)] = state
|
group_states[int(gid)] = state
|
||||||
|
|
||||||
|
# 加载私聊状态
|
||||||
|
if plugin_config.enable_private_chat:
|
||||||
|
logger.info(f"从文件加载私聊状态:{private_data_file}")
|
||||||
|
if os.path.exists(private_data_file):
|
||||||
|
async with aiofiles.open(private_data_file, encoding="utf8") as f:
|
||||||
|
private_data = json.loads(await f.read())
|
||||||
|
for uid, state_data in private_data.items():
|
||||||
|
state = PrivateChatState()
|
||||||
|
state.preset_name = state_data["preset"]
|
||||||
|
state.history = deque(
|
||||||
|
state_data["history"], maxlen=plugin_config.history_size * 2
|
||||||
|
)
|
||||||
|
state.last_active = state_data["last_active"]
|
||||||
|
state.group_prompt = state_data["group_prompt"]
|
||||||
|
state.output_reasoning_content = state_data["output_reasoning_content"]
|
||||||
|
private_chat_states[int(uid)] = state
|
||||||
|
|
||||||
|
|
||||||
# 注册生命周期事件
|
# 注册生命周期事件
|
||||||
@driver.on_startup
|
@driver.on_startup
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ class ScopedConfig(BaseModel):
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理"
|
description="需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理"
|
||||||
)
|
)
|
||||||
|
enable_private_chat: bool = Field(False, description="是否启用私聊功能")
|
||||||
|
private_chat_preset: str = Field("off", description="私聊默认使用的预设名称")
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue