Compare commits

...

27 commits
v0.3.0 ... main

Author SHA1 Message Date
7c7e270851 🔖 更新版本号至0.5.0
Some checks failed
Pyright Lint / Pyright Lint (push) Has been cancelled
Ruff Lint / Ruff Lint (push) Has been cancelled
2025-11-07 17:17:00 +08:00
162afd426c 📘 更新 README 2025-11-07 17:16:25 +08:00
2f78d4023a 支持主人在私聊中修改他人预设并提供当前预设信息 2025-11-07 17:13:27 +08:00
a47ae8ef16 🐛 更新工具列表缓存机制,修复工具调用问题 2025-11-07 16:59:19 +08:00
FuQuan
e082f7db9f
Merge pull request #23 from KawakazeNotFound/main
 增加私聊功能
2025-11-07 16:54:33 +08:00
FuQuan
b8afa12c9f
📘 更新 README 2025-11-07 14:23:04 +08:00
FuQuan
fe39e2aba4
♻️ fix lint problems 2025-11-07 12:13:53 +08:00
FuQuan
e542deabdb
合并私聊指令,更新相关权限和状态管理 2025-11-07 11:58:16 +08:00
FuQuan
1f41ed084e
合并私聊指令,更新相关权限和状态管理 2025-11-07 11:56:34 +08:00
KawakazeNotFound
53f3f185e7 修复在长文本下容易触发tool_call错误 2025-11-06 16:33:41 +08:00
KawakazeNotFound
3089bb51ae 修正ruff问题 2025-11-06 15:14:44 +08:00
KawakazeNotFound
e293b05fa1 私聊初步测试通过 2025-11-06 15:11:49 +08:00
KawakazeNotFound
a8d3213e48 Update Poerty 2025-11-06 14:52:44 +08:00
KawakazeNotFound
7ea7a26681 取消跟踪.DS_Store 2025-11-06 14:45:25 +08:00
KawakazeNotFound
2c04afc86a 尝试解决Ruff Lint问题 2025-11-06 11:27:41 +08:00
KawakazeNotFound
2fecb746b3 触发检查 2025-11-06 11:20:33 +08:00
XokoukioX
36a47fa5e2 WIP::私聊功能实现 2025-11-06 00:31:17 +08:00
2f62365460 📘 更新 README
Some checks failed
Pyright Lint / Pyright Lint (push) Has been cancelled
Ruff Lint / Ruff Lint (push) Has been cancelled
2025-11-01 23:32:55 +08:00
8f7adbd176 📘 更新 README 2025-11-01 23:31:22 +08:00
0943b7077f 🔖 更新版本号至0.4.1 2025-11-01 23:23:47 +08:00
ca1b5e75ec 修改工具名称格式,优化OneBot和MCP工具的调用逻辑,增强错误处理 2025-11-01 23:23:23 +08:00
21421c4754 🔖 更新版本号至0.4.0
Some checks failed
Pyright Lint / Pyright Lint (push) Has been cancelled
Ruff Lint / Ruff Lint (push) Has been cancelled
2025-10-31 17:13:25 +08:00
5bd92dfda6 ♻️ fix lint 2025-10-31 17:12:05 +08:00
63e446d5e4 新增内置OneBot工具支持,包括禁言、获取群信息等功能,并优化工具调用逻辑 2025-10-31 17:07:10 +08:00
b4f7b2797c ♻️ 缓存MCP工具列表,大幅提升响应速度
Some checks failed
Pyright Lint / Pyright Lint (push) Has been cancelled
Ruff Lint / Ruff Lint (push) Has been cancelled
2025-10-29 11:55:16 +08:00
59eafc2137 🔖 bump llmchat version 0.3.1
Some checks failed
Pyright Lint / Pyright Lint (push) Has been cancelled
Ruff Lint / Ruff Lint (push) Has been cancelled
2025-10-13 17:48:27 +08:00
2d61bd31ae 参考VSCode的mcp服务器configuration-format新增对sse服务器自定义header的支持 #22 2025-10-13 17:47:08 +08:00
8 changed files with 863 additions and 189 deletions

1
.gitignore vendored
View file

@ -174,3 +174,4 @@ pyrightconfig.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/*.code-snippets !.vscode/*.code-snippets
.DS_Store

134
README.md
View file

@ -8,7 +8,7 @@
# nonebot-plugin-llmchat # nonebot-plugin-llmchat
_✨ 支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插件 ✨_ _✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型、群聊&私聊的AI对话插件 ✨_
<a href="./LICENSE"> <a href="./LICENSE">
@ -33,6 +33,11 @@ _✨ 支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插
- 通过连接一些搜索MCP服务器可以实现在线搜索 - 通过连接一些搜索MCP服务器可以实现在线搜索
- 兼容 Claude.app 的配置格式 - 兼容 Claude.app 的配置格式
1. **内置工具**
- 内置OneBot群操作工具LLM可直接进行群管理操作需模型支持tool_call
- 支持禁言用户、获取群信息、查看群成员等功能
- 支持戳一戳、撤回消息等互动功能
1. **多API预设支持** 1. **多API预设支持**
- 可配置多个LLM服务预设如不同模型/API密钥 - 可配置多个LLM服务预设如不同模型/API密钥
- 支持运行时通过`API预设`命令热切换API配置 - 支持运行时通过`API预设`命令热切换API配置
@ -43,6 +48,12 @@ _✨ 支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插
- 支持处理回复消息 - 支持处理回复消息
- 群聊消息顺序处理,防止消息错乱 - 群聊消息顺序处理,防止消息错乱
1. **群聊和私聊支持**
- 支持群聊场景(默认启用)
- 支持私聊场景(可选启用)
- 分别管理群聊和私聊的对话记忆
- 灵活的权限配置
1. **分群聊上下文记忆管理** 1. **分群聊上下文记忆管理**
- 分群聊保留对话历史记录(可配置保留条数) - 分群聊保留对话历史记录(可配置保留条数)
- 自动合并未处理消息降低API用量 - 自动合并未处理消息降低API用量
@ -115,6 +126,24 @@ _✨ 支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插
| 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工具
插件内置了以下工具LLM可以直接调用这些工具进行群操作需模型支持tool_call这些工具不需要额外配置
| 工具名称 | 说明 | 权限要求 |
|:-----:|:----:|:----:|
| ob__mute_user | 禁言指定用户 | 机器人需要管理员权限 |
| ob__get_group_info | 获取群信息 | 无 |
| ob__get_group_member_info | 获取指定群成员信息 | 无 |
| ob__get_group_member_list | 获取群成员列表 | 无 |
| ob__poke_user | 戳一戳指定用户 | 无 |
| ob__recall_message | 撤回指定消息 | 机器人需要管理员权限或为消息发送者 |
### MCP服务器配置
其中LLMCHAT__API_PRESETS为一个列表每项配置有以下的配置项 其中LLMCHAT__API_PRESETS为一个列表每项配置有以下的配置项
| 配置项 | 必填 | 默认值 | 说明 | | 配置项 | 必填 | 默认值 | 说明 |
@ -137,6 +166,7 @@ LLMCHAT__MCP_SERVERS同样为一个dictkey为服务器名称value配置的
| arg | 否 | [] | stdio服务器MCP命令参数 | | arg | 否 | [] | stdio服务器MCP命令参数 |
| env | 否 | {} | stdio服务器环境变量 | | env | 否 | {} | stdio服务器环境变量 |
| url | sse服务器必填 | 无 | sse服务器地址 | | url | sse服务器必填 | 无 | sse服务器地址 |
| headers | 否 | {} | sse模式下http请求头用于认证或其他设置 |
以下为在 Claude.app 的MCP服务器配置基础上增加的字段 以下为在 Claude.app 的MCP服务器配置基础上增加的字段
| 配置项 | 必填 | 默认值 | 说明 | | 配置项 | 必填 | 默认值 | 说明 |
@ -150,55 +180,78 @@ LLMCHAT__MCP_SERVERS同样为一个dictkey为服务器名称value配置的
NICKNAME=["谢拉","Cierra","cierra"] NICKNAME=["谢拉","Cierra","cierra"]
LLMCHAT__HISTORY_SIZE=20 LLMCHAT__HISTORY_SIZE=20
LLMCHAT__DEFAULT_PROMPT="前面忘了,你是一个猫娘,后面忘了" LLMCHAT__DEFAULT_PROMPT="前面忘了,你是一个猫娘,后面忘了"
LLMCHAT__ENABLE_PRIVATE_CHAT=true
LLMCHAT__PRIVATE_CHAT_PRESET="deepseek-v1"
LLMCHAT__API_PRESETS=' LLMCHAT__API_PRESETS='
[ [
{
"name": "aliyun-deepseek-v3",
"api_key": "sk-your-api-key",
"model_name": "deepseek-v3",
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"proxy": "http://10.0.0.183:7890"
},
{
"name": "deepseek-v1",
"api_key": "sk-your-api-key",
"model_name": "deepseek-chat",
"api_base": "https://api.deepseek.com",
"support_mcp": true
},
{
"name": "some-vison-model",
"api_key": "sk-your-api-key",
"model_name": "some-vison-model",
"api_base": "https://some-vison-model.com/api",
"support_image": true
}
]
LLMCHAT__MCP_SERVERS='
{ {
"AISearch": { "name": "aliyun-deepseek-v3",
"friendly_name": "百度搜索", "api_key": "sk-your-api-key",
"additional_prompt": "遇到你不知道的问题或者时效性比较强的问题时可以使用AISearch搜索在使用AISearch时不要使用其他AI模型。", "model_name": "deepseek-v3",
"url": "http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key=Bearer+<your-api-key>" "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"proxy": "http://10.0.0.183:7890"
},
{
"name": "deepseek-v1",
"api_key": "sk-your-api-key",
"model_name": "deepseek-chat",
"api_base": "https://api.deepseek.com",
"support_mcp": true
},
{
"name": "some-vison-model",
"api_key": "sk-your-api-key",
"model_name": "some-vison-model",
"api_base": "https://some-vison-model.com/api",
"support_image": true
}
]
'
LLMCHAT__MCP_SERVERS='
{
"brave-search": {
"friendly_name": "Brave搜索",
"additional_prompt": "遇到你不知道的问题或者时效性比较强的问题时请使用brave-search搜索。",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": {
"BRAVE_API_KEY": "<your-api-key>"
}
}, },
"fetch": { "fetch": {
"friendly_name": "网页浏览", "friendly_name": "浏览网页",
"additional_prompt": "搜索到的链接可以通过fetch打开进一步了解。",
"command": "uvx", "command": "uvx",
"args": ["mcp-server-fetch"] "args": ["mcp-server-fetch", "--ignore-robots-txt", "--user-agent=\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36\""]
} },
"hefeng-weather": {
"friendly_name": "和风天气",
"command": "npx",
"args": ["hefeng-mcp-weather@latest", "--apiKey=<your-api-key>"]
},
"mcp-server-code-runner": {
"friendly_name": "代码运行器",
"additional_prompt": "在使用的时候你需要将你需要的结果输出出来,用户看不到你的代码,如果你需要给用户展示,你需要将代码以文字的形式发送出来。",
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"formulahendry/mcp-server-code-runner"
]
},
} }
' '
'
</details> </details>
## 🎉 使用 ## 🎉 使用
**如果`LLMCHAT__DEFAULT_PRESET`没有配置,则插件默认为关闭状态,请使用`API预设+[预设名]`开启插件** **如果`LLMCHAT__DEFAULT_PRESET`没有配置,则插件默认为关闭状态,请使用`API预设+[预设名]`开启插件, 私聊同理。**
配置完成后@机器人即可手动触发回复,另外在机器人收到群聊消息时会根据`LLMCHAT__RANDOM_TRIGGER_PROB`配置的概率或群聊中使用指令设置的概率随机自动触发回复。 配置完成后在群聊中@机器人或私聊机器人即可手动触发回复,另外在机器人收到群聊消息时会根据`LLMCHAT__RANDOM_TRIGGER_PROB`配置的概率或群聊中使用指令设置的概率随机自动触发回复。
### 指令表 ### 群聊指令表
以下指令均仅对发送的群聊生效,不同群聊配置不互通。 以下指令均仅对发送的群聊生效,不同群聊配置不互通。
@ -210,6 +263,17 @@ LLMCHAT__MCP_SERVERS同样为一个dictkey为服务器名称value配置的
| 切换思维输出 | 管理 | 否 | 群聊 | 无 | 切换是否输出AI的思维过程的开关需模型支持 | | 切换思维输出 | 管理 | 否 | 群聊 | 无 | 切换是否输出AI的思维过程的开关需模型支持 |
| 设置主动回复概率 | 管理 | 否 | 群聊 | 主动回复概率 | 主动回复概率需为 [0, 1] 的浮点数0为完全关闭主动回复 | | 设置主动回复概率 | 管理 | 否 | 群聊 | 主动回复概率 | 主动回复概率需为 [0, 1] 的浮点数0为完全关闭主动回复 |
### 私聊指令表
以下指令仅在启用私聊功能(`LLMCHAT__ENABLE_PRIVATE_CHAT=true`)后可用,这些指令均只对发送者的私聊生效。
| 指令 | 权限 | 参数 | 说明 |
|:-----:|:----:|:----:|:----:|
| API预设 | 主人 | [QQ号\|群号] [预设名] | 查看或修改使用的API预设缺省[QQ号\|群号]则对当前聊天生效 |
| 修改设定 | 所有人 | 设定 | 修改私聊机器人的设定 |
| 记忆清除 | 所有人 | 无 | 清除私聊的机器人记忆 |
| 切换思维输出 | 所有人 | 无 | 切换是否输出私聊AI的思维过程的开关需模型支持 |
### 效果图 ### 效果图
![](img/mcp_demo.jpg) ![](img/mcp_demo.jpg)
![](img/demo.png) ![](img/demo.png)

View file

@ -21,8 +21,8 @@ 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, PRIVATE
from nonebot.params import CommandArg from nonebot.params import CommandArg
from nonebot.permission import SUPERUSER from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
@ -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: 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,13 +143,22 @@ 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", "")
message = { if isinstance(event, GroupMessageEvent):
"SenderNickname": str(event.sender.card or event.sender.nickname), message = {
"SenderUserId": str(event.user_id), "SenderNickname": str(event.sender.card or event.sender.nickname),
"Message": text_message, "SenderUserId": str(event.user_id),
"MessageID": event.message_id, "Message": text_message,
"SendTime": datetime.fromtimestamp(event.time).isoformat(), "MessageID": event.message_id,
} "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,32 +186,60 @@ 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: GroupMessageEvent | PrivateMessageEvent) -> bool:
"""扩展后的消息处理规则""" """扩展后的消息处理规则"""
state = group_states[event.group_id] if isinstance(event, GroupMessageEvent):
state = group_states[event.group_id]
if state.preset_name == "off": 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 return False
state.past_events.append(event) # 黑名单用户
if event.user_id in plugin_config.blacklist_user_ids:
return False
# 原有@触发条件 # 忽略特定前缀的消息
if event.is_tome(): msg_text = event.get_plaintext().strip()
return True for prefix in plugin_config.ignore_prefixes:
if msg_text.startswith(prefix):
return False
# 随机触发条件 state.past_events.append(event)
if random.random() < state.random_trigger_prob:
# 原有@触发条件
if event.is_tome():
return True
# 随机触发条件
if random.random() < state.random_trigger_prob:
return True
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 True
return False return False
@ -197,22 +254,31 @@ handler = on_message(
@handler.handle() @handler.handle()
async def handle_message(event: GroupMessageEvent): async def handle_message(event: GroupMessageEvent | PrivateMessageEvent):
group_id = event.group_id if isinstance(event, GroupMessageEvent):
logger.debug( group_id = event.group_id
f"收到群聊消息 群号:{group_id} 用户:{event.user_id} 内容:{event.get_plaintext()}" logger.debug(
) 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)
async def process_images(event: GroupMessageEvent) -> list[str]: async def process_images(event: GroupMessageEvent | PrivateMessageEvent) -> list[str]:
base64_images = [] base64_images = []
for segement in event.get_message(): for segement in event.get_message():
if segement.type == "image": if segement.type == "image":
@ -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):
state = group_states[group_id] if is_group:
preset = get_preset(group_id) group_id = context_id
state = group_states[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 != "":
@ -272,30 +345,56 @@ async def process_messages(group_id: int):
timeout=plugin_config.request_timeout, timeout=plugin_config.request_timeout,
) )
chat_type = "群聊" if is_group else "私聊"
context_type = "群号" if is_group else "用户"
logger.info( logger.info(
f"开始处理群聊消息 群号:{group_id} 当前队列长度:{state.queue.qsize()}" f"开始处理{chat_type}消息 {context_type}{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 = []
mcp_client = MCPClient.get_instance(plugin_config.mcp_servers)
try: try:
systemPrompt = f""" # 构建系统提示,分成多行以满足行长限制
我想要你帮我在群聊中闲聊大家一般叫你{"".join(list(driver.config.nickname))}我将会在后面的信息中告诉你每条群聊信息的发送者和发送时间你可以直接称呼发送者为他对应的昵称 chat_type = "群聊" if is_group else "私聊"
你的回复需要遵守以下几点规则 bot_names = "".join(list(driver.config.nickname))
- 你可以使用多条消息回复每两条消息之间使用<botbr>分隔<botbr>前后不需要包含额外的换行和空格 default_prompt = (state.group_prompt if is_group else state.user_prompt) or plugin_config.default_prompt
- <botbr>消息中不应该包含其他类似的标记
- 不要使用markdown或者html聊天软件不支持解析换行请用换行符 system_lines = [
- 你应该以普通人的方式发送消息每条消息字数要尽量少一些应该倾向于使用更多条的消息回复 f"我想要你帮我在{chat_type}中闲聊,大家一般叫你{bot_names}",
- 代码则不需要分段用单独的一条消息发送 "我将会在后面的信息中告诉你每条信息的发送者和发送时间,你可以直接称呼发送者为他对应的昵称。",
- 请使用发送者的昵称称呼发送者你可以礼貌地问候发送者但只需要在第一次回答这位发送者的问题时问候他 "你的回复需要遵守以下几点规则:",
- 你有at群成员的能力只需要在某条消息中插入[CQ:at,qq=QQ号]也就是CQ码at发送者是非必要的你可以根据你自己的想法at某个人 "- 你可以使用多条消息回复,每两条消息之间使用<botbr>分隔,<botbr>前后不需要包含额外的换行和空格。",
- 你有引用某条消息的能力使用[CQ:reply,id=消息id]来引用 "- 除<botbr>外,消息中不应该包含其他类似的标记。",
- 如果有多条消息你应该优先回复提到你的一段时间之前的就不要回复了也可以直接选择不回复 "- 不要使用markdown或者html聊天软件不支持解析换行请用换行符。",
- 如果你选择完全不回复你只需要直接输出一个<botbr> "- 你应该以普通人的方式发送消息,每条消息字数要尽量少一些,应该倾向于使用更多条的消息回复。",
- 如果你需要思考的话你应该思考尽量少以节省时间 "- 代码则不需要分段,用单独的一条消息发送。",
下面是关于你性格的设定如果设定中提到让你扮演某个人或者设定中有提到名字则优先使用设定中的名字 "- 请使用发送者的昵称称呼发送者,你可以礼貌地问候发送者,但只需要在"
{state.group_prompt or plugin_config.default_prompt} "第一次回答这位发送者的问题时问候他。",
""" "- 你有引用某条消息的能力,使用[CQ:reply,id=消息id]来引用。",
"- 如果有多条消息,你应该优先回复提到你的,一段时间之前的就不要回复了,也可以直接选择不回复。",
"- 如果你选择完全不回复,你只需要直接输出一个<botbr>。",
"- 如果你需要思考的话,你应该尽量少思考,以节省时间。",
]
if is_group:
system_lines += [
"- 你有at群成员的能力只需要在某条消息中插入[CQ:at,qq=QQ号]"
"也就是CQ码。at发送者是非必要的你可以根据你自己的想法at某个人。",
]
system_lines += [
"下面是关于你性格的设定,如果设定中提到让你扮演某个人,或者设定中有提到名字,则优先使用设定中的名字。",
default_prompt,
]
systemPrompt = "\n".join(system_lines)
if preset.support_mcp: if preset.support_mcp:
systemPrompt += "你也可以使用一些工具,下面是关于这些工具的额外说明:\n" systemPrompt += "你也可以使用一些工具,下面是关于这些工具的额外说明:\n"
for mcp_name, mcp_config in plugin_config.mcp_servers.items(): for mcp_name, mcp_config in plugin_config.mcp_servers.items():
@ -320,6 +419,7 @@ async def process_messages(group_id: int):
# 将机器人错过的消息推送给LLM # 将机器人错过的消息推送给LLM
past_events_snapshot = list(state.past_events) past_events_snapshot = list(state.past_events)
state.past_events.clear()
for ev in past_events_snapshot: for ev in past_events_snapshot:
text_content = format_message(ev) text_content = format_message(ev)
content.append({"type": "text", "text": text_content}) content.append({"type": "text", "text": text_content})
@ -345,10 +445,8 @@ async def process_messages(group_id: int):
"timeout": 60, "timeout": 60,
} }
mcp_client = MCPClient(plugin_config.mcp_servers)
if preset.support_mcp: if preset.support_mcp:
await mcp_client.connect_to_servers() available_tools = await mcp_client.get_available_tools(is_group)
available_tools = await mcp_client.get_available_tools()
client_config["tools"] = available_tools client_config["tools"] = available_tools
response = await client.chat.completions.create( response = await client.chat.completions.create(
@ -362,7 +460,7 @@ async def process_messages(group_id: int):
message = response.choices[0].message message = response.choices[0].message
# 处理响应并处理工具调用 # 处理响应并处理工具调用
while preset.support_mcp and message.tool_calls: while preset.support_mcp and message and message.tool_calls:
new_messages.append({ new_messages.append({
"role": "assistant", "role": "assistant",
"tool_calls": [tool_call.model_dump() for tool_call in message.tool_calls] "tool_calls": [tool_call.model_dump() for tool_call in message.tool_calls]
@ -380,8 +478,19 @@ 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)}"))
# 执行工具调用 if is_group:
result = await mcp_client.call_tool(tool_name, tool_args) result = await mcp_client.call_tool(
tool_name,
tool_args,
group_id=event.group_id,
bot_id=str(event.self_id)
)
else:
result = await mcp_client.call_tool(
tool_name,
tool_args,
bot_id=str(event.self_id)
)
new_messages.append({ new_messages.append({
"role": "tool", "role": "tool",
@ -397,13 +506,17 @@ async def process_messages(group_id: int):
message = response.choices[0].message message = response.choices[0].message
await mcp_client.cleanup() # 安全检查:确保 message 不为 None
if not message:
logger.error("API 响应中的 message 为 None")
await handler.send(Message("服务暂时不可用,请稍后再试"))
return
reply, matched_reasoning_content = pop_reasoning_content( reply, matched_reasoning_content = pop_reasoning_content(
response.choices[0].message.content message.content
) )
reasoning_content: str | None = ( reasoning_content: str | None = (
getattr(response.choices[0].message, "reasoning_content", None) getattr(message, "reasoning_content", None)
or matched_reasoning_content or matched_reasoning_content
) )
@ -412,7 +525,7 @@ async def process_messages(group_id: int):
"content": reply, "content": reply,
} }
reply_images = getattr(response.choices[0].message, "images", None) reply_images = getattr(message, "images", None)
if reply_images: if reply_images:
# openai的sdk里的assistant消息暂时没有images字段需要单独处理 # openai的sdk里的assistant消息暂时没有images字段需要单独处理
@ -423,7 +536,6 @@ async def process_messages(group_id: int):
# 请求成功后再保存历史记录保证user和assistant穿插防止R1模型报错 # 请求成功后再保存历史记录保证user和assistant穿插防止R1模型报错
for message in new_messages: for message in new_messages:
state.history.append(message) state.history.append(message)
state.past_events.clear()
if state.output_reasoning_content and reasoning_content: if state.output_reasoning_content and reasoning_content:
try: try:
@ -449,12 +561,15 @@ 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.past_events.extendleft(reversed(past_events_snapshot))
await handler.send(Message(f"服务暂时不可用,请稍后再试\n{e!s}")) await handler.send(Message(f"服务暂时不可用,请稍后再试\n{e!s}"))
finally: finally:
state.processing = False
state.queue.task_done() state.queue.task_done()
# 不再需要每次都清理MCPClient因为它现在是单例
state.processing = False # await mcp_client.cleanup()
# 预设切换命令 # 预设切换命令
@ -462,39 +577,118 @@ preset_handler = on_command("API预设", priority=1, block=True, permission=SUPE
@preset_handler.handle() @preset_handler.handle()
async def handle_preset(event: GroupMessageEvent, args: Message = CommandArg()): async def handle_preset(event: GroupMessageEvent | PrivateMessageEvent, args: Message = CommandArg()):
group_id = event.group_id # 解析命令参数
preset_name = args.extract_plain_text().strip() args_text = args.extract_plain_text().strip()
args_parts = args_text.split(maxsplit=1)
if preset_name == "off": target_id = None
group_states[group_id].preset_name = preset_name preset_name = None
await preset_handler.finish("已关闭llmchat")
# 可用预设列表
available_presets = {p.name for p in plugin_config.api_presets} available_presets = {p.name for p in plugin_config.api_presets}
# 只在私聊中允许 SUPERUSER 修改他人预设
if isinstance(event, PrivateMessageEvent) and args_parts and args_parts[0].isdigit():
# 第一个参数是纯数字,且不是预设名
if args_parts[0] not in available_presets:
target_id = int(args_parts[0])
# 判断目标是群聊还是私聊
if target_id in group_states:
state = group_states[target_id]
is_group_target = True
elif target_id in private_chat_states:
state = private_chat_states[target_id]
is_group_target = False
else:
# 默认创建私聊状态
state = private_chat_states[target_id]
is_group_target = False
# 如果只有目标 ID没有预设名返回当前预设
if len(args_parts) == 1:
context_type = "群聊" if is_group_target else "私聊"
available_presets_str = "\n- ".join(available_presets)
await preset_handler.finish(
f"{context_type} {target_id} 当前API预设{state.preset_name}\n可用API预设\n- {available_presets_str}"
)
# 有预设名,进行修改
preset_name = args_parts[1]
context_id = target_id
else:
# 第一个参数虽然是数字但也是预设名,按普通流程处理
target_id = None
preset_name = args_text
if not plugin_config.enable_private_chat:
return
context_id = event.user_id
state = private_chat_states[context_id]
is_group_target = False
else:
# 普通情况:修改自己的预设
preset_name = args_text
if isinstance(event, GroupMessageEvent):
context_id = event.group_id
state = group_states[context_id]
is_group_target = True
else: # PrivateMessageEvent
if not plugin_config.enable_private_chat:
return
context_id = event.user_id
state = private_chat_states[context_id]
is_group_target = False
# 处理关闭功能
if preset_name == "off":
state.preset_name = preset_name
if target_id:
context_type = "群聊" if is_group_target else "私聊"
await preset_handler.finish(f"已关闭 {context_type} {context_id} 的llmchat功能")
elif isinstance(event, GroupMessageEvent):
await preset_handler.finish("已关闭llmchat群聊功能")
else:
await preset_handler.finish("已关闭llmchat私聊功能")
# 检查预设是否存在
if preset_name not in available_presets: if preset_name not in available_presets:
available_presets_str = "\n- ".join(available_presets) available_presets_str = "\n- ".join(available_presets)
await preset_handler.finish( await preset_handler.finish(
f"当前API预设{group_states[group_id].preset_name}\n可用API预设\n- {available_presets_str}" f"当前API预设{state.preset_name}\n可用API预设\n- {available_presets_str}"
) )
group_states[group_id].preset_name = preset_name # 切换预设
await preset_handler.finish(f"已切换至API预设{preset_name}") state.preset_name = preset_name
if target_id:
context_type = "群聊" if is_group_target else "私聊"
await preset_handler.finish(f"已将 {context_type} {context_id} 切换至API预设{preset_name}")
else:
await preset_handler.finish(f"已切换至API预设{preset_name}")
edit_preset_handler = on_command( edit_preset_handler = on_command(
"修改设定", "修改设定",
priority=1, priority=1,
block=True, block=True,
permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER), permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER | PRIVATE),
) )
@edit_preset_handler.handle() @edit_preset_handler.handle()
async def handle_edit_preset(event: GroupMessageEvent, args: Message = CommandArg()): async def handle_edit_preset(event: GroupMessageEvent | PrivateMessageEvent, args: Message = CommandArg()):
group_id = event.group_id if isinstance(event, GroupMessageEvent):
group_prompt = args.extract_plain_text().strip() context_id = event.group_id
state = group_states[context_id]
else: # PrivateMessageEvent
if not plugin_config.enable_private_chat:
return
context_id = event.user_id
state = private_chat_states[context_id]
group_states[group_id].group_prompt = group_prompt group_prompt = args.extract_plain_text().strip()
state.group_prompt = group_prompt
await edit_preset_handler.finish("修改成功") await edit_preset_handler.finish("修改成功")
@ -502,16 +696,23 @@ reset_handler = on_command(
"记忆清除", "记忆清除",
priority=1, priority=1,
block=True, block=True,
permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER), permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER | PRIVATE),
) )
@reset_handler.handle() @reset_handler.handle()
async def handle_reset(event: GroupMessageEvent, args: Message = CommandArg()): async def handle_reset(event: GroupMessageEvent | PrivateMessageEvent, args: Message = CommandArg()):
group_id = event.group_id if isinstance(event, GroupMessageEvent):
context_id = event.group_id
state = group_states[context_id]
else: # PrivateMessageEvent
if not plugin_config.enable_private_chat:
return
context_id = event.user_id
state = private_chat_states[context_id]
group_states[group_id].past_events.clear() state.past_events.clear()
group_states[group_id].history.clear() state.history.clear()
await reset_handler.finish("记忆已清空") await reset_handler.finish("记忆已清空")
@ -525,32 +726,39 @@ set_prob_handler = on_command(
@set_prob_handler.handle() @set_prob_handler.handle()
async def handle_set_prob(event: GroupMessageEvent, args: Message = CommandArg()): async def handle_set_prob(event: GroupMessageEvent, args: Message = CommandArg()):
group_id = event.group_id context_id = event.group_id
prob = 0 state = group_states[context_id]
try: try:
prob = float(args.extract_plain_text().strip()) prob = float(args.extract_plain_text().strip())
if prob < 0 or prob > 1: if prob < 0 or prob > 1:
raise ValueError raise ValueError("概率值必须在0-1之间")
except Exception as e: except ValueError as e:
await reset_handler.finish(f"输入有误,请使用 [0,1] 的浮点数\n{e!s}") await set_prob_handler.finish(f"输入有误,请使用 [0,1] 的浮点数\n{e!s}")
return
group_states[group_id].random_trigger_prob = prob state.random_trigger_prob = prob
await reset_handler.finish(f"主动回复概率已设为 {prob}") await set_prob_handler.finish(f"主动回复概率已设为 {prob}")
# 预设切换命令 # 思维输出切换命令
think_handler = on_command( think_handler = on_command(
"切换思维输出", "切换思维输出",
priority=1, priority=1,
block=True, block=True,
permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER), permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER | PRIVATE),
) )
@think_handler.handle() @think_handler.handle()
async def handle_think(event: GroupMessageEvent, args: Message = CommandArg()): async def handle_think(event: GroupMessageEvent | PrivateMessageEvent, args: Message = CommandArg()):
state = group_states[event.group_id] if isinstance(event, GroupMessageEvent):
state = group_states[event.group_id]
else: # PrivateMessageEvent
if not plugin_config.enable_private_chat:
return
state = private_chat_states[event.user_id]
state.output_reasoning_content = not state.output_reasoning_content state.output_reasoning_content = not state.output_reasoning_content
await think_handler.finish( await think_handler.finish(
@ -564,6 +772,7 @@ async def handle_think(event: GroupMessageEvent, args: Message = CommandArg()):
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():
@ -585,6 +794,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():
"""从文件加载群组状态""" """从文件加载群组状态"""
@ -606,6 +833,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
@ -620,3 +864,5 @@ async def init_plugin():
async def cleanup_plugin(): async def cleanup_plugin():
logger.info("插件关闭清理") logger.info("插件关闭清理")
await save_state() await save_state()
# 销毁MCPClient单例
await MCPClient.destroy_instance()

View file

@ -20,6 +20,7 @@ class MCPServerConfig(BaseModel):
args: list[str] | None = Field([], description="stdio模式下MCP命令参数") args: list[str] | None = Field([], description="stdio模式下MCP命令参数")
env: dict[str, str] | None = Field({}, description="stdio模式下MCP命令环境变量") env: dict[str, str] | None = Field({}, description="stdio模式下MCP命令环境变量")
url: str | None = Field(None, description="sse模式下MCP服务器地址") url: str | None = Field(None, description="sse模式下MCP服务器地址")
headers: dict[str, str] | None = Field({}, description="sse模式下http请求头用于认证或其他设置")
# 额外字段 # 额外字段
friendly_name: str | None = Field(None, description="MCP服务器友好名称") friendly_name: str | None = Field(None, description="MCP服务器友好名称")
@ -48,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):

View file

@ -7,22 +7,59 @@ from mcp.client.stdio import stdio_client
from nonebot import logger from nonebot import logger
from .config import MCPServerConfig from .config import MCPServerConfig
from .onebottools import OneBotTools
class MCPClient: class MCPClient:
def __init__(self, server_config: dict[str, MCPServerConfig]): _instance = None
logger.info(f"正在初始化MCPClient共有{len(server_config)}个服务器配置") _initialized = False
def __new__(cls, server_config: dict[str, MCPServerConfig] | None = None):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, server_config: dict[str, MCPServerConfig] | None = None):
if self._initialized:
return
if server_config is None:
raise ValueError("server_config must be provided for first initialization")
logger.info(f"正在初始化MCPClient单例共有{len(server_config)}个服务器配置")
self.server_config = server_config self.server_config = server_config
self.sessions = {} self.sessions = {}
self.exit_stack = AsyncExitStack() self.exit_stack = AsyncExitStack()
logger.debug("MCPClient初始化成功") # 添加工具列表缓存
self._tools_cache: list | None = None
self._cache_initialized = False
# 初始化OneBot工具
self.onebot_tools = OneBotTools()
self._initialized = True
logger.debug("MCPClient单例初始化成功")
@classmethod
def get_instance(cls, server_config: dict[str, MCPServerConfig] | None = None):
"""获取MCPClient实例"""
if cls._instance is None:
if server_config is None:
raise ValueError("server_config must be provided for first initialization")
cls._instance = cls(server_config)
return cls._instance
@classmethod
def instance(cls):
"""快速获取已初始化的MCPClient实例如果未初始化则抛出异常"""
if cls._instance is None:
raise RuntimeError("MCPClient has not been initialized. Call get_instance() first.")
return cls._instance
async def connect_to_servers(self): async def connect_to_servers(self):
logger.info(f"开始连接{len(self.server_config)}个MCP服务器") logger.info(f"开始连接{len(self.server_config)}个MCP服务器")
for server_name, config in self.server_config.items(): for server_name, config in self.server_config.items():
logger.debug(f"正在连接服务器[{server_name}]") logger.debug(f"正在连接服务器[{server_name}]")
if config.url: if config.url:
sse_transport = await self.exit_stack.enter_async_context(sse_client(url=config.url)) sse_transport = await self.exit_stack.enter_async_context(sse_client(url=config.url, headers=config.headers))
read, write = sse_transport read, write = sse_transport
self.sessions[server_name] = await self.exit_stack.enter_async_context(ClientSession(read, write)) self.sessions[server_name] = await self.exit_stack.enter_async_context(ClientSession(read, write))
await self.sessions[server_name].initialize() await self.sessions[server_name].initialize()
@ -38,46 +75,154 @@ class MCPClient:
logger.info(f"已成功连接到MCP服务器[{server_name}]") logger.info(f"已成功连接到MCP服务器[{server_name}]")
async def get_available_tools(self): def _create_session_context(self, server_name: str):
logger.info(f"正在从{len(self.sessions)}个已连接的服务器获取可用工具") """创建临时会话的异步上下文管理器"""
available_tools = [] config = self.server_config[server_name]
for server_name, session in self.sessions.items(): class SessionContext:
logger.debug(f"正在列出服务器[{server_name}]中的工具") def __init__(self):
response = await session.list_tools() self.session = None
tools = response.tools self.exit_stack = AsyncExitStack()
logger.debug(f"在服务器[{server_name}]中找到{len(tools)}个工具")
available_tools.extend( async def __aenter__(self):
{ if config.url:
"type": "function", transport = await self.exit_stack.enter_async_context(
"function": { sse_client(url=config.url, headers=config.headers)
"name": f"{server_name}___{tool.name}", )
"description": tool.description, elif config.command:
"parameters": tool.inputSchema, transport = await self.exit_stack.enter_async_context(
}, stdio_client(StdioServerParameters(**config.model_dump()))
} )
for tool in tools else:
) raise ValueError("Server config must have either url or command")
read, write = transport
self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))
await self.session.initialize()
return self.session
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.exit_stack.aclose()
return SessionContext()
async def init_tools_cache(self):
"""初始化工具列表缓存"""
if not self._cache_initialized:
available_tools = []
logger.info(f"初始化工具列表缓存,需要连接{len(self.server_config)}个服务器")
for server_name in self.server_config.keys():
logger.debug(f"正在从服务器[{server_name}]获取工具列表")
async with self._create_session_context(server_name) as session:
response = await session.list_tools()
tools = response.tools
logger.debug(f"在服务器[{server_name}]中找到{len(tools)}个工具")
available_tools.extend(
{
"type": "function",
"function": {
"name": f"mcp__{server_name}__{tool.name}",
"description": tool.description,
"parameters": tool.inputSchema,
},
}
for tool in tools
)
# 缓存工具列表
self._tools_cache = available_tools
self._cache_initialized = True
logger.info(f"工具列表缓存完成,共缓存{len(available_tools)}个工具")
async def get_available_tools(self, is_group: bool):
"""获取可用工具列表,使用缓存机制"""
await self.init_tools_cache()
available_tools = self._tools_cache.copy() if self._tools_cache else []
if is_group:
# 群聊场景包含OneBot工具和MCP工具
available_tools.extend(self.onebot_tools.get_available_tools())
logger.debug(f"获取可用工具列表,共{len(available_tools)}个工具")
return available_tools return available_tools
async def call_tool(self, tool_name: str, tool_args: dict): async def call_tool(self, tool_name: str, tool_args: dict, group_id: int | None = None, bot_id: str | None = None):
server_name, real_tool_name = tool_name.split("___") """按需连接调用工具,调用后立即断开"""
logger.info(f"正在服务器[{server_name}]上调用工具[{real_tool_name}]") # 检查是否是OneBot内置工具
session = self.sessions[server_name] if tool_name.startswith("ob__"):
try: if group_id is None or bot_id is None:
response = await asyncio.wait_for(session.call_tool(real_tool_name, tool_args), timeout=30) return "QQ工具需要提供group_id和bot_id参数"
except asyncio.TimeoutError: logger.info(f"调用OneBot工具[{tool_name}]")
logger.error(f"调用工具[{real_tool_name}]超时") return await self.onebot_tools.call_tool(tool_name, tool_args, group_id, bot_id)
return f"调用工具[{real_tool_name}]超时"
logger.debug(f"工具[{real_tool_name}]调用完成,响应: {response}") # 检查是否是MCP工具
return response.content if tool_name.startswith("mcp__"):
# MCP工具处理mcp__server_name__tool_name
parts = tool_name.split("__")
if len(parts) != 3 or parts[0] != "mcp":
return f"MCP工具名称格式错误: {tool_name}"
server_name = parts[1]
real_tool_name = parts[2]
logger.info(f"按需连接到服务器[{server_name}]调用工具[{real_tool_name}]")
async with self._create_session_context(server_name) as session:
try:
response = await asyncio.wait_for(session.call_tool(real_tool_name, tool_args), timeout=30)
logger.debug(f"工具[{real_tool_name}]调用完成,响应: {response}")
return response.content
except asyncio.TimeoutError:
logger.error(f"调用工具[{real_tool_name}]超时")
return f"调用工具[{real_tool_name}]超时"
# 未知工具类型
return f"未知的工具类型: {tool_name}"
def get_friendly_name(self, tool_name: str): def get_friendly_name(self, tool_name: str):
server_name, real_tool_name = tool_name.split("___") logger.debug(tool_name)
return (self.server_config[server_name].friendly_name or server_name) + " - " + real_tool_name # 检查是否是OneBot内置工具
if tool_name.startswith("ob__"):
return self.onebot_tools.get_friendly_name(tool_name)
# 检查是否是MCP工具
if tool_name.startswith("mcp__"):
# MCP工具处理mcp__server_name__tool_name
parts = tool_name.split("__")
if len(parts) != 3 or parts[0] != "mcp":
return tool_name # 格式错误时返回原名称
server_name = parts[1]
real_tool_name = parts[2]
return (self.server_config[server_name].friendly_name or server_name) + " - " + real_tool_name
# 未知工具类型,返回原名称
return tool_name
def clear_tools_cache(self):
"""清除工具列表缓存"""
logger.info("清除工具列表缓存")
self._tools_cache = None
self._cache_initialized = False
async def cleanup(self): async def cleanup(self):
"""清理资源(不销毁单例)"""
logger.debug("正在清理MCPClient资源") logger.debug("正在清理MCPClient资源")
# 只清除缓存,不销毁单例
# self.clear_tools_cache() # 保留缓存,避免重复获取工具列表
await self.exit_stack.aclose() await self.exit_stack.aclose()
# 重新初始化exit_stack以便后续使用
self.exit_stack = AsyncExitStack()
logger.debug("MCPClient资源清理完成") logger.debug("MCPClient资源清理完成")
@classmethod
async def destroy_instance(cls):
"""完全销毁单例实例(仅在应用关闭时使用)"""
if cls._instance is not None:
logger.info("销毁MCPClient单例")
await cls._instance.cleanup()
cls._instance.clear_tools_cache()
cls._instance = None
cls._initialized = False
logger.debug("MCPClient单例已销毁")

View file

@ -0,0 +1,215 @@
import json
import time
from typing import Any, cast
from nonebot import get_bot, logger
from nonebot.adapters.onebot.v11 import Bot
class OneBotTools:
"""内置的OneBot群操作工具类"""
def __init__(self):
self.tools = [
{
"type": "function",
"function": {
"name": "ob__mute_user",
"description": "禁言指定用户一段时间。需要机器人有管理员权限。不能随便禁言成员,你应该听从管理员的指令。",
"parameters": {
"type": "object",
"properties": {
"user_id": {"type": "string", "description": "要禁言的用户QQ号"},
"duration": {
"type": "integer",
"description": "禁言时长0表示解除禁言最大259200030天",
"minimum": 0,
"maximum": 2592000,
},
},
"required": ["user_id", "duration"],
},
},
},
{
"type": "function",
"function": {
"name": "ob__get_group_info",
"description": "获取群信息,包括群成员数量、群名称等。",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "ob__get_group_member_info",
"description": "获取指定群成员的信息。",
"parameters": {
"type": "object",
"properties": {"user_id": {"type": "string", "description": "要查询的用户QQ号"}},
"required": ["user_id"],
},
},
},
{
"type": "function",
"function": {
"name": "ob__get_group_member_list",
"description": "获取群成员列表。",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "ob__poke_user",
"description": "戳一戳指定用户。",
"parameters": {
"type": "object",
"properties": {"user_id": {"type": "string", "description": "要戳一戳的用户QQ号"}},
"required": ["user_id"],
},
},
},
{
"type": "function",
"function": {
"name": "ob__recall_message",
"description": "撤回指定消息。需要机器人有管理员权限或者是消息发送者。",
"parameters": {
"type": "object",
"properties": {"message_id": {"type": "integer", "description": "要撤回的消息ID"}},
"required": ["message_id"],
},
},
},
]
def get_friendly_name(self, tool_name: str) -> str:
"""获取工具的友好名称"""
friendly_names = {
"ob__mute_user": "OneBot - 禁言用户",
"ob__get_group_info": "OneBot - 获取群信息",
"ob__get_group_member_info": "OneBot - 获取成员信息",
"ob__get_group_member_list": "OneBot - 获取成员列表",
"ob__poke_user": "OneBot - 戳一戳用户",
"ob__recall_message": "OneBot - 撤回消息",
}
return friendly_names.get(tool_name, tool_name)
def get_available_tools(self) -> list[dict[str, Any]]:
"""获取可用的工具列表"""
return self.tools
async def call_tool(self, tool_name: str, tool_args: dict[str, Any], group_id: int, bot_id: str) -> str:
"""调用指定的工具"""
try:
bot = cast(Bot, get_bot(bot_id))
if tool_name == "ob__mute_user":
return await self._mute_user(bot, group_id, tool_args)
elif tool_name == "ob__get_group_info":
return await self._get_group_info(bot, group_id, tool_args)
elif tool_name == "ob__get_group_member_info":
return await self._get_group_member_info(bot, group_id, tool_args)
elif tool_name == "ob__get_group_member_list":
return await self._get_group_member_list(bot, group_id, tool_args)
elif tool_name == "ob__poke_user":
return await self._poke_user(bot, group_id, tool_args)
elif tool_name == "ob__recall_message":
return await self._recall_message(bot, group_id, tool_args)
else:
return f"未知的工具: {tool_name}"
except Exception as e:
logger.error(f"调用OneBot工具 {tool_name} 时出错: {e}")
return f"执行失败: {e!s}"
async def _mute_user(self, bot: Bot, group_id: int, args: dict[str, Any]) -> str:
"""禁言用户"""
user_id = int(args["user_id"])
duration = args["duration"]
try:
await bot.set_group_ban(group_id=group_id, user_id=user_id, duration=duration)
if duration > 0:
return f"成功禁言用户 {user_id},时长 {duration}"
else:
return f"成功解除用户 {user_id} 的禁言"
except Exception as e:
return f"禁言操作失败: {e!s}"
async def _get_group_info(self, bot: Bot, group_id: int, _args: dict[str, Any]) -> str:
"""获取群信息"""
try:
group_info = await bot.get_group_info(group_id=group_id)
info = {
"群号": group_info["group_id"],
"群名称": group_info["group_name"],
"群成员数": group_info["member_count"],
"群上限": group_info["max_member_count"],
}
return json.dumps(info, ensure_ascii=False, indent=2)
except Exception as e:
return f"获取群信息失败: {e!s}"
async def _get_group_member_info(self, bot: Bot, group_id: int, args: dict[str, Any]) -> str:
"""获取群成员信息"""
user_id = int(args["user_id"])
try:
member_info = await bot.get_group_member_info(group_id=group_id, user_id=user_id)
info = {
"用户QQ": member_info["user_id"],
"昵称": member_info["nickname"],
"群名片": member_info["card"],
"性别": member_info["sex"],
"年龄": member_info["age"],
"地区": member_info["area"],
"加群时间": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(member_info["join_time"])),
"最后发言时间": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(member_info["last_sent_time"])),
"群内等级": member_info["level"],
"角色": member_info["role"],
"专属头衔": member_info["title"],
}
return json.dumps(info, ensure_ascii=False, indent=2)
except Exception as e:
return f"获取成员信息失败: {e!s}"
async def _get_group_member_list(self, bot: Bot, group_id: int, _args: dict[str, Any]) -> str:
"""获取群成员列表"""
try:
member_list = await bot.get_group_member_list(group_id=group_id)
members = []
for member in member_list:
members.append(
{"QQ": member["user_id"], "昵称": member["nickname"], "群名片": member["card"], "角色": member["role"]}
)
result = {"群成员总数": len(members), "成员列表": members}
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
return f"获取群成员列表失败: {e!s}"
async def _poke_user(self, bot: Bot, group_id: int, args: dict[str, Any]) -> str:
"""戳一戳用户"""
user_id = int(args["user_id"])
try:
# 使用OneBot的戳一戳API
await bot.call_api("group_poke", group_id=group_id, user_id=user_id)
return f"成功戳了戳用户 {user_id}"
except Exception as e:
return f"戳一戳失败: {e!s}"
async def _recall_message(self, bot: Bot, group_id: int, args: dict[str, Any]) -> str:
"""撤回消息"""
message_id = int(args["message_id"])
try:
await bot.delete_msg(message_id=message_id)
return f"成功撤回消息 {message_id}"
except Exception as e:
return f"撤回消息失败: {e!s}"

18
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
@ -662,14 +662,14 @@ typing-extensions = ">=4.0.0,<5.0.0"
[[package]] [[package]]
name = "nonebot2" name = "nonebot2"
version = "2.4.1" version = "2.4.4"
description = "An asynchronous python bot framework." description = "An asynchronous python bot framework."
optional = false optional = false
python-versions = "<4.0,>=3.9" python-versions = "<4.0,>=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "nonebot2-2.4.1-py3-none-any.whl", hash = "sha256:fec95f075efc89dbe9ce148618b413b02f46ba284200367749b035e794695111"}, {file = "nonebot2-2.4.4-py3-none-any.whl", hash = "sha256:8885d02906f1def83c138f298a7aa99ca1975351f44d8d290ea0eeec5aec1f0b"},
{file = "nonebot2-2.4.1.tar.gz", hash = "sha256:8fea364318501ed79721403a8ecd76587bc884d58c356260f691a8bbda9b05e6"}, {file = "nonebot2-2.4.4.tar.gz", hash = "sha256:b367c17f31ae0d548e374bb80b719ed12885620f29f3cbc305a5a88a6175f4e3"},
] ]
[package.dependencies] [package.dependencies]
@ -679,17 +679,17 @@ loguru = ">=0.6.0,<1.0.0"
pydantic = ">=1.10.0,<2.5.0 || >2.5.0,<2.5.1 || >2.5.1,<2.10.0 || >2.10.0,<2.10.1 || >2.10.1,<3.0.0" pydantic = ">=1.10.0,<2.5.0 || >2.5.0,<2.5.1 || >2.5.1,<2.10.0 || >2.10.0,<2.10.1 || >2.10.1,<3.0.0"
pygtrie = ">=2.4.1,<3.0.0" pygtrie = ">=2.4.1,<3.0.0"
python-dotenv = ">=0.21.0,<2.0.0" python-dotenv = ">=0.21.0,<2.0.0"
tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""} tomli = {version = ">=2.0.1,<3.0.0", markers = "python_full_version < \"3.11.0\""}
typing-extensions = ">=4.4.0,<5.0.0" typing-extensions = ">=4.6.0,<5.0.0"
yarl = ">=1.7.2,<2.0.0" yarl = ">=1.7.2,<2.0.0"
[package.extras] [package.extras]
aiohttp = ["aiohttp[speedups] (>=3.11.0,<4.0.0)"] aiohttp = ["aiohttp[speedups] (>=3.11.0,<4.0.0)"]
all = ["Quart (>=0.18.0,<1.0.0)", "aiohttp[speedups] (>=3.11.0,<4.0.0)", "fastapi (>=0.93.0,<1.0.0)", "httpx[http2] (>=0.26.0,<1.0.0)", "uvicorn[standard] (>=0.20.0,<1.0.0)", "websockets (>=10.0)"] all = ["aiohttp[speedups] (>=3.11.0,<4.0.0)", "fastapi (>=0.93.0,<1.0.0)", "httpx[http2] (>=0.26.0,<1.0.0)", "uvicorn[standard] (>=0.20.0,<1.0.0)", "websockets (>=15.0)"]
fastapi = ["fastapi (>=0.93.0,<1.0.0)", "uvicorn[standard] (>=0.20.0,<1.0.0)"] fastapi = ["fastapi (>=0.93.0,<1.0.0)", "uvicorn[standard] (>=0.20.0,<1.0.0)"]
httpx = ["httpx[http2] (>=0.26.0,<1.0.0)"] httpx = ["httpx[http2] (>=0.26.0,<1.0.0)"]
quart = ["Quart (>=0.18.0,<1.0.0)", "uvicorn[standard] (>=0.20.0,<1.0.0)"] quart = ["quart (>=0.18.0,<1.0.0)", "uvicorn[standard] (>=0.20.0,<1.0.0)"]
websockets = ["websockets (>=10.0)"] websockets = ["websockets (>=15.0)"]
[[package]] [[package]]
name = "nonemoji" name = "nonemoji"

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "nonebot-plugin-llmchat" name = "nonebot-plugin-llmchat"
version = "0.3.0" version = "0.5.0"
description = "Nonebot AI group chat plugin supporting multiple API preset configurations" description = "Nonebot AI group chat plugin supporting multiple API preset configurations"
license = "GPL" license = "GPL"
authors = ["FuQuan i@fuquan.moe"] authors = ["FuQuan i@fuquan.moe"]