Compare commits

...

88 commits
v0.1.1 ... main

Author SHA1 Message Date
d640f16abe 🔖 bump llmchat version 0.2.5
Some checks failed
Pyright Lint / Pyright Lint (push) Has been cancelled
Ruff Lint / Ruff Lint (push) Has been cancelled
2025-09-01 10:56:31 +08:00
1600cba172 支持忽略特定前缀的消息 #21 2025-09-01 10:51:30 +08:00
9f81a38d5b 🐛 将mcp超时延长到30秒,避免执行失败 2025-09-01 10:45:18 +08:00
53d57beba3 🔖 bump llmchat version 0.2.4 2025-08-20 12:48:13 +08:00
ea635fd147 🐛 修复重复发送消息给llm的问题 2025-08-20 12:38:39 +08:00
5014d3014b 🐛 修复mcp服务器卡住导致的卡死 2025-08-20 11:40:54 +08:00
89baec6abc 📘 更新 README 2025-05-19 14:17:25 +08:00
19ff0026c0 🐛 修复deque mutated during iteration 2025-05-16 21:43:08 +08:00
52ada66616 🔖 bump llmchat version 0.2.3 2025-05-13 14:02:23 +08:00
cf2d549f02 📘 更新meta信息 2025-05-13 14:02:03 +08:00
6c27cf56fa 🐛 修复命令本身会触发回复的问题 2025-05-13 13:43:06 +08:00
3d85ea90ef 🐛 修复多条消息中只处理最后一条消息的图片的问题 2025-05-13 13:41:28 +08:00
7edd7c913e 🐛 修复MCP调用过程中回复不分条的问题 2025-05-13 11:23:52 +08:00
84d3851936 🐛 修复某些协议端找不到图片url的问题 2025-05-12 15:26:39 +08:00
ee2a045116 🔖 bump llmchat version 0.2.2 2025-05-11 15:45:57 +08:00
6f69cc3cff 支持用户黑名单 #20 2025-05-11 15:42:13 +08:00
ed1b9792e7 📘 更新 README 2025-05-11 15:05:26 +08:00
FuQuan
0ddf8e5626
Merge pull request #19 from duolanda/main
 support vision models
2025-05-11 14:51:14 +08:00
duolanda
5e048c9472 ♻️ fix lint problems 2025-05-11 00:41:05 +08:00
duolanda
f2d1521158 support vision models 2025-05-10 22:58:44 +08:00
db9794a18a 🐛 修复可能出现首条消息不为user消息导致报错的问题 2025-04-28 20:19:47 +08:00
FuQuan
c9c22a8630
📘 更新 README 2025-04-27 18:08:50 +08:00
8013df564a 🔖 bump llmchat version 0.2.1 2025-04-27 11:57:34 +08:00
e3973baa37 🐛 修复assistant消息没有正确添加到历史记录的问题 2025-04-27 11:56:38 +08:00
fd18f41f17 🔖 bump llmchat version 0.2.0 2025-04-26 23:00:14 +08:00
3e9e691faf 📘 更新插件description 2025-04-26 22:47:40 +08:00
48fe2515e9 📘 更新 README 2025-04-26 22:46:32 +08:00
506024c8f5 ♻️ 增加MCP服务器友好名称为空时的处理 2025-04-26 22:45:53 +08:00
318e5b91c1 ♻️ fix lint problems 2025-04-26 22:02:23 +08:00
6aea492281 ♻️ fix lint problems 2025-04-26 21:55:44 +08:00
0f09015042 ♻️ fix lint problems 2025-04-26 21:54:37 +08:00
3c1ac4b68b ♻️ fix lint problems 2025-04-26 00:27:10 +08:00
ed2f9051ef 🐛 add missing dependencies 2025-04-26 00:18:36 +08:00
eb1038e09e support Model Context Protocol (MCP) 2025-04-25 23:52:18 +08:00
dfe3b5308c 🔖 bump llmchat version 0.1.11 2025-03-01 21:57:52 +08:00
17564b5463 🐛 fix error when the history_size was set to an odd number 2025-03-01 21:57:27 +08:00
7408f90b3c 📘 update README #14 2025-02-28 21:09:27 +08:00
da5621abbe ♻️ fix lint problems 2025-02-28 21:06:59 +08:00
f89e41754c ♻️ fix lint problems 2025-02-28 18:27:50 +08:00
05eb132f85 🔖 bump llmchat version 0.1.10 2025-02-28 18:19:43 +08:00
a7b57ae375 support api proxy #14 2025-02-28 18:19:04 +08:00
4af60b8145 🐛 fix random_trigger_prob per group not working 2025-02-28 18:02:15 +08:00
b01f2825d1 🔖 bump llmchat version 0.1.9 2025-02-28 11:31:07 +08:00
405d5d367f ♻️ use logging when forwarding messages fails 2025-02-28 11:28:20 +08:00
a6fa27dd9a 📘 update README 2025-02-28 11:23:36 +08:00
e8db066647 ♻️ code format 2025-02-28 11:21:15 +08:00
6db55055a2 set random_trigger_prob per group 2025-02-28 11:15:22 +08:00
505fab406b 🐛 Fix message history order and add error handling for reasoning content 2025-02-21 18:07:28 +08:00
04883cda00 🔖 bump llmchat version 0.1.8 2025-02-21 09:46:38 +08:00
1406672b8d ♻️ modify forwarding nodes to be onebot v11 compliant 2025-02-21 09:46:00 +08:00
2ef3addeee reply support 2025-02-21 09:43:19 +08:00
99bb698f3c ♻️ fix lint problems 2025-02-18 19:15:02 +08:00
df8944ec73 send reasoning content via forward message 2025-02-18 18:17:53 +08:00
ede61a028a 🔖 bump llmchat version 0.1.7 2025-02-17 20:56:32 +08:00
948583828f ♻️ Simplify message formatting for better compatibility #11 2025-02-17 20:56:03 +08:00
cf9175c077 📘 update README 2025-02-17 16:53:03 +08:00
FuQuan233
54e4766dfd
Merge pull request #10 from StarHeartHunt/fix/reasoning-tag
Fix: 移除普通内容里的 `<think>` 标签
2025-02-17 16:36:20 +08:00
StarHeartHunt
65ea3dc582 🐛 remove tag in normal element 2025-02-17 16:12:41 +08:00
f662d4ae00 🔖 bump llmchat version 0.1.6 2025-02-16 14:50:23 +08:00
b4c3e71faf 🐛 fix #5 2025-02-16 14:49:52 +08:00
b4a9ce8a7f 📘 update README 2025-02-16 13:01:21 +08:00
f4c44a0623 🐛 fix README typo 2025-02-16 12:51:38 +08:00
FuQuan233
5b0fe9564d
Merge pull request #7 from StarHeartHunt/main
🐛 ♻️ 使用 defaultdict & 过滤内容时不输出<think>标签
2025-02-16 12:48:54 +08:00
FuQuan233
9e478272cb
Merge pull request #6 from StarHeartHunt/ci/add-ruff-ci
👷 add lint ci
2025-02-16 12:47:46 +08:00
StarHeartHunt
812545f99d 🐛 ♻️ use defaultdict and remove think matched content 2025-02-16 10:29:30 +08:00
StarHeartHunt
e88ac6bcd5 💚 fix ci 2025-02-16 10:17:15 +08:00
StarHeartHunt
a729d77d4e 💚 fix setup 2025-02-16 10:14:49 +08:00
StarHeartHunt
980c272504 👷 add pre-commit config 2025-02-16 10:11:18 +08:00
StarHeartHunt
8ba2bb511c 👷 add lint ci 2025-02-16 10:09:24 +08:00
FuQuan233
bb9ae962e6
Merge pull request #3 from StarHeartHunt/fix/output-reasoning
♻️ 重构思考输出部分
2025-02-15 18:25:07 +08:00
StarHeartHunt
29e966eb21 ♻️ tidy up 2025-02-15 18:17:10 +08:00
StarHeart
40ff908a32
Merge branch 'main' into fix/output-reasoning 2025-02-15 18:09:03 +08:00
25a51a6d05 🔖 bump llmchat version 0.1.5 2025-02-15 18:07:45 +08:00
StarHeartHunt
f0a5dd8bb9 🐛 fix output reasoning content control 2025-02-15 18:06:31 +08:00
b4402af2c8 🐛 fix #2 2025-02-15 18:03:53 +08:00
bb106c991b 🔖 bump llmchat version 0.1.4 2025-02-15 17:51:11 +08:00
78e7ac4e1d 🐛 Fix reasoning content output condition 2025-02-15 17:50:43 +08:00
634fad6eed 🔖 bump llmchat version 0.1.3 2025-02-15 17:02:05 +08:00
4683127566 ♻️ Ensure sending reasoning content in separate message 2025-02-15 17:01:14 +08:00
46d854aeae ♻️ Replace manual whitespace removal loops with built-in string strip method 2025-02-15 16:44:30 +08:00
8e6dfe4f14 🐛 fix string index out of range while sending messages 2025-02-15 16:35:02 +08:00
FuQuan233
4adf580493
Merge pull request #1 from StarHeartHunt/main
♻️ 重构代码
2025-02-15 16:25:32 +08:00
StarHeartHunt
620245f420 🚨 move tc type into type_checking guard 2025-02-15 16:13:51 +08:00
StarHeartHunt
5fd1c5ff40 ♻️ fix all lint problems in plugin 2025-02-15 16:06:47 +08:00
StarHeartHunt
7f284ec4c4 🐛 filter out think in normal content 2025-02-15 14:46:25 +08:00
65f66be04a 🔖 bump llmchat version 0.1.2 2025-02-14 20:33:28 +08:00
4947fccdd3 🔨 use nonebot_plugin_apscheduler 2025-02-14 20:32:23 +08:00
a87d3e64ff 🔨 use nonebot_plugin_localstore to save data 2025-02-14 20:27:09 +08:00
12 changed files with 2323 additions and 170 deletions

32
.editorconfig Normal file
View file

@ -0,0 +1,32 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
# The JSON files contain newlines inconsistently
[*.json]
insert_final_newline = ignore
# Makefiles always use tabs for indentation
[Makefile]
indent_style = tab
# Batch files use tabs for indentation
[*.bat]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
# Matches the exact files either package.json or .travis.yml
[{package.json,.travis.yml}]
indent_size = 2
[{*.py,*.pyi}]
indent_size = 4

23
.github/actions/setup-python/action.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: Setup Python
description: Setup Python
inputs:
python-version:
description: Python version
required: false
default: "3.10"
runs:
using: "composite"
steps:
- name: Install poetry
run: pipx install poetry
shell: bash
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: "poetry"
- run: poetry install --all-extras
shell: bash

33
.github/workflows/pyright.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: Pyright Lint
on:
push:
branches:
- main
pull_request:
paths:
- "nonebot_plugin_llmchat/**"
- ".github/workflows/pyright.yml"
- "pyproject.toml"
- "poetry.lock"
jobs:
pyright:
name: Pyright Lint
runs-on: ubuntu-latest
concurrency:
group: lint-${{ github.ref }}-${{ matrix.env }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
- name: Setup Python environment
uses: ./.github/actions/setup-python
- run: echo "$(poetry env info --path)/bin" >> $GITHUB_PATH
- name: Run Pyright
uses: jakebailey/pyright-action@v2
with:
pylance-version: latest-release

26
.github/workflows/ruff.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: Ruff Lint
on:
push:
branches:
- main
pull_request:
paths:
- "nonebot_plugin_llmchat/**"
- ".github/workflows/ruff.yml"
- "pyproject.toml"
- "poetry.lock"
jobs:
ruff:
name: Ruff Lint
runs-on: ubuntu-latest
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
- name: Run Ruff Lint
uses: astral-sh/ruff-action@v3

22
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,22 @@
default_install_hook_types: [pre-commit, prepare-commit-msg]
ci:
autofix_commit_msg: ":rotating_light: auto fix by pre-commit hooks"
autofix_prs: true
autoupdate_branch: main
autoupdate_schedule: monthly
autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks"
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.4
hooks:
- id: ruff
args: [--fix]
stages: [pre-commit]
- id: ruff-format
stages: [pre-commit]
- repo: https://github.com/nonebot/nonemoji
rev: v0.1.4
hooks:
- id: nonemoji
stages: [prepare-commit-msg]

View file

@ -8,7 +8,7 @@
# nonebot-plugin-llmchat
_✨ 支持多API预设配置的AI群聊插件 ✨_
_✨ 支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插件 ✨_
<a href="./LICENSE">
@ -17,33 +17,39 @@ _✨ 支持多API预设配置的AI群聊插件 ✨_
<a href="https://pypi.python.org/pypi/nonebot-plugin-llmchat">
<img src="https://img.shields.io/pypi/v/nonebot-plugin-llmchat.svg" alt="pypi">
</a>
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="python">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<a href="https://deepwiki.com/FuQuan233/nonebot-plugin-llmchat"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
</div>
## 📖 介绍
1. **多API预设支持**
1. **支持MCP协议**
- 可以连接各种支持MCP协议的LLM工具
- 通过连接一些搜索MCP服务器可以实现在线搜索
- 兼容 Claude.app 的配置格式
2. **多API预设支持**
- 可配置多个LLM服务预设如不同模型/API密钥
- 支持运行时通过`API预设`命令热切换API配置
- 内置服务开关功能(预设名为`off`时停用)
2. **多种回复触发方式**
3. **多种回复触发方式**
- @触发 + 随机概率触发
- 支持处理回复消息
- 群聊消息顺序处理,防止消息错乱
3. **分群聊上下文记忆管理**
4. **分群聊上下文记忆管理**
- 分群聊保留对话历史记录(可配置保留条数)
- 自动合并未处理消息降低API用量
- 支持`记忆清除`命令手动重置对话上下文
4. **分段回复支持**
5. **分段回复支持**
- 支持多段式回复由LLM决定如何回复
- 可@群成员由LLM插入
- 可选输出AI的思维过程需模型支持
5. **可自定义性格**
6. **可自定义性格**
- 可动态修改群组专属系统提示词(`/修改设定`
- 支持自定义默认提示词
@ -99,10 +105,12 @@ _✨ 支持多API预设配置的AI群聊插件 ✨_
| LLMCHAT__HISTORY_SIZE | 否 | 20 | LLM上下文消息保留数量1-40越大token消耗量越多 |
| LLMCHAT__PAST_EVENTS_SIZE | 否 | 10 | 触发回复时发送的群消息数量1-20越大token消耗量越多 |
| LLMCHAT__REQUEST_TIMEOUT | 否 | 30 | API请求超时时间 |
| LLMCHAT__DEFAULT_PRESENT | 否 | off | 默认使用的预设名称配置为off则为关闭 |
| LLMCHAT__RANDOM_TRIGGER_PROB | 否 | 0.05 | 随机触发概率0-1] |
| LLMCHAT__STORAGE_PATH | 否 | data/llmchat_state.json | 状态存储文件路径 |
| LLMCHAT__DEFAULT_PRESET | 否 | off | 默认使用的预设名称配置为off则为关闭 |
| LLMCHAT__RANDOM_TRIGGER_PROB | 否 | 0.05 | 默认随机触发概率 [0, 1] |
| LLMCHAT__DEFAULT_PROMPT | 否 | 你的回答应该尽量简洁、幽默、可以使用一些语气词、颜文字。你应该拒绝回答任何政治相关的问题。 | 默认提示词 |
| LLMCHAT__BLACKLIST_USER_IDS | 否 | [] | 黑名单用户ID列表机器人将不会处理黑名单用户的消息 |
| LLMCHAT__IGNORE_PREFIXES | 否 | [] | 需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理 |
| LLMCHAT__MCP_SERVERS | 否 | {} | MCP服务器配置具体见下表 |
其中LLMCHAT__API_PRESETS为一个列表每项配置有以下的配置项
| 配置项 | 必填 | 默认值 | 说明 |
@ -113,11 +121,29 @@ _✨ 支持多API预设配置的AI群聊插件 ✨_
| model_name | 是 | 无 | 模型名称 |
| max_tokens | 否 | 2048 | 最大响应token数 |
| temperature | 否 | 0.7 | 生成温度 |
| proxy | 否 | 无 | 请求API时使用的HTTP代理 |
| support_mcp | 否 | False | 是否支持MCP协议 |
| support_image | 否 | False | 是否支持图片输入 |
LLMCHAT__MCP_SERVERS同样为一个dictkey为服务器名称value配置的格式基本兼容 Claude.app 的配置格式,具体支持如下
| 配置项 | 必填 | 默认值 | 说明 |
|:-----:|:----:|:----:|:----:|
| command | stdio服务器必填 | 无 | stdio服务器MCP命令 |
| arg | 否 | [] | stdio服务器MCP命令参数 |
| env | 否 | {} | stdio服务器环境变量 |
| url | sse服务器必填 | 无 | sse服务器地址 |
以下为在 Claude.app 的MCP服务器配置基础上增加的字段
| 配置项 | 必填 | 默认值 | 说明 |
|:-----:|:----:|:----:|:----:|
| friendly_name | 否 | 无 | 友好名称,用于调用时发送提示信息 |
| additional_prompt | 否 | 无 | 关于这个工具的附加提示词 |
<details open>
<summary>配置示例</summary>
NICKNAME=["谢拉","Cierra","cierra"]
NICKNAME=["谢拉","Cierra","cierra"]
LLMCHAT__HISTORY_SIZE=20
LLMCHAT__DEFAULT_PROMPT="前面忘了,你是一个猫娘,后面忘了"
LLMCHAT__API_PRESETS='
@ -126,21 +152,47 @@ _✨ 支持多API预设配置的AI群聊插件 ✨_
"name": "aliyun-deepseek-v3",
"api_key": "sk-your-api-key",
"model_name": "deepseek-v3",
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1"
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"proxy": "http://10.0.0.183:7890"
},
{
"name": "deepseek-r1",
"name": "deepseek-v1",
"api_key": "sk-your-api-key",
"model_name": "deepseek-reasoner",
"api_base": "https://api.deepseek.com"
"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": {
"friendly_name": "百度搜索",
"additional_prompt": "遇到你不知道的问题或者时效性比较强的问题时可以使用AISearch搜索在使用AISearch时不要使用其他AI模型。",
"url": "http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key=Bearer+<your-api-key>"
},
"fetch": {
"friendly_name": "网页浏览",
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
'
'
</details>
## 🎉 使用
配置完成后@机器人即可手动触发回复,另外在机器人收到群聊消息时会根据`LLMCHAT__RANDOM_TRIGGER_PROB`配置的概率随机自动触发回复。
**如果`LLMCHAT__DEFAULT_PRESET`没有配置,则插件默认为关闭状态,请使用`API预设+[预设名]`开启插件**
配置完成后@机器人即可手动触发回复,另外在机器人收到群聊消息时会根据`LLMCHAT__RANDOM_TRIGGER_PROB`配置的概率或群聊中使用指令设置的概率随机自动触发回复。
### 指令表
@ -152,6 +204,8 @@ _✨ 支持多API预设配置的AI群聊插件 ✨_
| 修改设定 | 管理 | 否 | 群聊 | 设定 | 修改机器人的设定,最好在修改之后执行一次记忆清除 |
| 记忆清除 | 管理 | 否 | 群聊 | 无 | 清除机器人的记忆 |
| 切换思维输出 | 管理 | 否 | 群聊 | 无 | 切换是否输出AI的思维过程的开关需模型支持 |
| 设置主动回复概率 | 管理 | 否 | 群聊 | 主动回复概率 | 主动回复概率需为 [0, 1] 的浮点数0为完全关闭主动回复 |
### 效果图
![](img/demo.png)
![](img/mcp_demo.jpg)
![](img/demo.png)

BIN
img/mcp_demo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

560
nonebot_plugin_llmchat/__init__.py Normal file → Executable file
View file

@ -1,27 +1,52 @@
import aiofiles
from nonebot import get_plugin_config, on_message, logger, on_command, get_driver
from nonebot.plugin import PluginMetadata
from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message
from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER
from nonebot.params import CommandArg
from nonebot.rule import Rule
from nonebot.permission import SUPERUSER
from typing import Dict
from datetime import datetime
from collections import deque
import asyncio
from openai import AsyncOpenAI
from .config import Config, PresetConfig
import time
import base64
from collections import defaultdict, deque
from datetime import datetime
import json
import os
import random
from apscheduler.schedulers.asyncio import AsyncIOScheduler
import re
import ssl
import time
from typing import TYPE_CHECKING
import aiofiles
import httpx
from nonebot import (
get_bot,
get_driver,
get_plugin_config,
logger,
on_command,
on_message,
require,
)
from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message
from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER
from nonebot.params import CommandArg
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot.rule import Rule
from openai import AsyncOpenAI
from .config import Config, PresetConfig
from .mcpclient import MCPClient
require("nonebot_plugin_localstore")
import nonebot_plugin_localstore as store
require("nonebot_plugin_apscheduler")
from nonebot_plugin_apscheduler import scheduler
if TYPE_CHECKING:
from openai.types.chat import (
ChatCompletionContentPartParam,
ChatCompletionMessageParam,
)
__plugin_meta__ = PluginMetadata(
name="llmchat",
description="支持多API预设配置的AI群聊插件",
description="支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插件",
usage="""@机器人 + 消息 开启对话""",
type="application",
homepage="https://github.com/FuQuan233/nonebot-plugin-llmchat",
@ -29,39 +54,62 @@ __plugin_meta__ = PluginMetadata(
supported_adapters={"~onebot.v11"},
)
pluginConfig = get_plugin_config(Config).llmchat
plugin_config = get_plugin_config(Config).llmchat
driver = get_driver()
tasks: set["asyncio.Task"] = set()
def pop_reasoning_content(
content: str | None,
) -> tuple[str | None, str | None]:
if content is None:
return None, None
# 如果找到了 <think> 标签内容返回过滤后的文本和标签内的内容否则只返回过滤后的文本和None
if matched := re.match(r"<think>(.*?)</think>", content, flags=re.DOTALL):
reasoning_element = matched.group(0)
reasoning_content = matched.group(1).strip()
filtered_content = content.replace(reasoning_element, "").strip()
return filtered_content, reasoning_content
else:
return content, None
# 初始化群组状态
class GroupState:
def __init__(self):
self.preset_name = pluginConfig.default_preset
self.history = deque(maxlen=pluginConfig.history_size)
self.preset_name = plugin_config.default_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=pluginConfig.past_events_size)
self.group_prompt = None
self.past_events = deque(maxlen=plugin_config.past_events_size)
self.group_prompt: str | None = None
self.output_reasoning_content = False
self.random_trigger_prob = plugin_config.random_trigger_prob
group_states: dict[int, GroupState] = defaultdict(GroupState)
group_states: Dict[int, GroupState] = {}
# 获取当前预设配置
def get_preset(group_id: int) -> PresetConfig:
state = group_states[group_id]
for preset in pluginConfig.api_presets:
for preset in plugin_config.api_presets:
if preset.name == state.preset_name:
return preset
return pluginConfig.api_presets[0] # 默认返回第一个预设
return plugin_config.api_presets[0] # 默认返回第一个预设
# 消息格式转换
def format_message(event: GroupMessageEvent) -> Dict:
def format_message(event: GroupMessageEvent) -> str:
text_message = ""
if event.reply != None:
if event.reply is not None:
text_message += f"[回复 {event.reply.sender.nickname} 的消息 {event.reply.message.extract_plain_text()}]\n"
if event.is_tome():
text_message += f"@{list(driver.config.nickname)[0]} "
text_message += f"@{next(iter(driver.config.nickname))} "
for msgseg in event.get_message():
if msgseg.type == "at":
@ -75,258 +123,482 @@ def format_message(event: GroupMessageEvent) -> Dict:
elif msgseg.type == "text":
text_message += msgseg.data.get("text", "")
message = {
message = {
"SenderNickname": str(event.sender.card or event.sender.nickname),
"SenderUserId": str(event.user_id),
"Message": text_message,
"SendTime" : datetime.fromtimestamp(event.time).isoformat()
"MessageID": event.message_id,
"SendTime": datetime.fromtimestamp(event.time).isoformat(),
}
return json.dumps(message, ensure_ascii=False)
async def isTriggered(event: GroupMessageEvent) -> bool:
"""扩展后的消息处理规则"""
group_id = event.group_id
if group_id not in group_states:
logger.info(f"初始化群组状态,群号:{group_id}")
group_states[group_id] = GroupState()
state = group_states[group_id]
def build_reasoning_forward_nodes(self_id: str, reasoning_content: str):
self_nickname = next(iter(driver.config.nickname))
nodes = [
{
"type": "node",
"data": {
"nickname": self_nickname,
"user_id": self_id,
"content": f"{self_nickname}的内心OS:",
},
},
{
"type": "node",
"data": {
"nickname": self_nickname,
"user_id": self_id,
"content": reasoning_content,
},
},
]
return nodes
async def is_triggered(event: GroupMessageEvent) -> bool:
"""扩展后的消息处理规则"""
state = group_states[event.group_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)
# 原有@触发条件
if event.is_tome():
return True
# 随机触发条件
if random.random() < pluginConfig.random_trigger_prob:
if random.random() < state.random_trigger_prob:
return True
return False
# 消息处理器
handler = on_message(
rule=Rule(isTriggered),
priority=10,
rule=Rule(is_triggered),
priority=99,
block=False,
)
@handler.handle()
async def handle_message(event: GroupMessageEvent):
group_id = event.group_id
logger.debug(f"收到群聊消息 群号:{group_id} 用户:{event.user_id} 内容:{event.get_plaintext()}")
logger.debug(
f"收到群聊消息 群号:{group_id} 用户:{event.user_id} 内容:{event.get_plaintext()}"
)
if group_id not in group_states:
group_states[group_id] = GroupState()
state = group_states[group_id]
await state.queue.put(event)
if not state.processing:
state.processing = True
asyncio.create_task(process_messages(group_id))
task = asyncio.create_task(process_messages(group_id))
task.add_done_callback(tasks.discard)
tasks.add(task)
async def process_images(event: GroupMessageEvent) -> list[str]:
base64_images = []
for segement in event.get_message():
if segement.type == "image":
image_url = segement.data.get("url") or segement.data.get("file")
if image_url:
try:
# 处理高版本 httpx 的 [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] 报错
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
ssl_context.set_ciphers("DEFAULT@SECLEVEL=2")
# 下载图片并将图片转换为base64
async with httpx.AsyncClient(verify=ssl_context) as client:
response = await client.get(image_url, timeout=10.0)
if response.status_code != 200:
logger.error(f"下载图片失败: {image_url}, 状态码: {response.status_code}")
continue
image_data = response.content
base64_data = base64.b64encode(image_data).decode("utf-8")
base64_images.append(base64_data)
except Exception as e:
logger.error(f"处理图片时出错: {e}")
logger.debug(f"共处理 {len(base64_images)} 张图片")
return base64_images
async def send_split_messages(message_handler, content: str):
"""
将消息按分隔符<botbr>分段并发送
"""
logger.info(f"准备发送分段消息,分段数:{len(content.split('<botbr>'))}")
for segment in content.split("<botbr>"):
# 跳过空消息
if not segment.strip():
continue
segment = segment.strip() # 删除前后多余的换行和空格
await asyncio.sleep(2) # 避免发送过快
logger.debug(f"发送消息分段 内容:{segment[:50]}...") # 只记录前50个字符避免日志过大
await message_handler.send(Message(segment))
async def process_messages(group_id: int):
state = group_states[group_id]
preset = get_preset(group_id)
# 初始化OpenAI客户端
client = AsyncOpenAI(
base_url=preset.api_base,
api_key=preset.api_key,
timeout=pluginConfig.request_timeout
if preset.proxy != "":
client = AsyncOpenAI(
base_url=preset.api_base,
api_key=preset.api_key,
timeout=plugin_config.request_timeout,
http_client=httpx.AsyncClient(proxy=preset.proxy),
)
else:
client = AsyncOpenAI(
base_url=preset.api_base,
api_key=preset.api_key,
timeout=plugin_config.request_timeout,
)
logger.info(
f"开始处理群聊消息 群号:{group_id} 当前队列长度:{state.queue.qsize()}"
)
logger.info(f"开始处理群聊消息 群号:{group_id} 当前队列长度:{state.queue.qsize()}")
while not state.queue.empty():
event = await state.queue.get()
logger.debug(f"从队列获取消息 群号:{group_id} 消息ID{event.message_id}")
try:
systemPrompt = (
f'''
systemPrompt = f"""
我想要你帮我在群聊中闲聊大家一般叫你{"".join(list(driver.config.nickname))}我将会在后面的信息中告诉你每条群聊信息的发送者和发送时间你可以直接称呼发送者为他对应的昵称
你的回复需要遵守以下几点规则
- 你可以使用多条消息回复每两条消息之间使用<botbr>分隔<botbr>前后不需要包含额外的换行和空格
- <botbr>消息中不应该包含其他类似的标记
- 不要使用markdown格式聊天软件不支持markdown解析
- 不要使用markdown或者html聊天软件不支持解析换行请用换行符
- 你应该以普通人的方式发送消息每条消息字数要尽量少一些应该倾向于使用更多条的消息回复
- 代码则不需要分段用单独的一条消息发送
- 请使用发送者的昵称称呼发送者你可以礼貌地问候发送者但只需要在第一次回答这位发送者的问题时问候他
- 你有at群成员的能力只需要在某条消息中插入[CQ:at,qq=QQ号]也就是CQ码at发送者是非必要的你可以根据你自己的想法at某个人
- 你有引用某条消息的能力使用[CQ:reply,id=消息id]来引用
- 如果有多条消息你应该优先回复提到你的一段时间之前的就不要回复了也可以直接选择不回复
- 如果你选择完全不回复你只需要直接输出一个<botbr>
- 如果你需要思考的话你应该思考尽量少以节省时间
下面是关于你性格的设定如果设定中提到让你扮演某个人或者设定中有提到名字则优先使用设定中的名字
{state.group_prompt or pluginConfig.default_prompt}
'''
)
{state.group_prompt or plugin_config.default_prompt}
"""
if preset.support_mcp:
systemPrompt += "你也可以使用一些工具,下面是关于这些工具的额外说明:\n"
for mcp_name, mcp_config in plugin_config.mcp_servers.items():
if mcp_config.addtional_prompt:
systemPrompt += f"{mcp_name}{mcp_config.addtional_prompt}"
systemPrompt += "\n"
messages = [{"role": "system", "content": systemPrompt}]
messages: list[ChatCompletionMessageParam] = [
{"role": "system", "content": systemPrompt}
]
messages += list(state.history)[-pluginConfig.history_size:]
while len(state.history) > 0 and state.history[0]["role"] != "user":
state.history.popleft()
messages += list(state.history)[-plugin_config.history_size * 2 :]
# 没有未处理的消息说明已经被处理了,跳过
if state.past_events.__len__() < 1:
break
content: list[ChatCompletionContentPartParam] = []
# 将机器人错过的消息推送给LLM
content = ",".join([format_message(ev) for ev in state.past_events])
past_events_snapshot = list(state.past_events)
for ev in past_events_snapshot:
text_content = format_message(ev)
content.append({"type": "text", "text": text_content})
logger.debug(f"发送API请求 模型:{preset.model_name} 历史消息数:{len(messages)}")
response = await client.chat.completions.create(
model=preset.model_name,
messages=messages + [{"role": "user", "content": content}],
max_tokens=preset.max_tokens,
temperature=preset.temperature,
timeout=60
# 将消息中的图片转成 base64
if preset.support_image:
base64_images = await process_images(ev)
for base64_image in base64_images:
content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}})
new_messages: list[ChatCompletionMessageParam] = [
{"role": "user", "content": content}
]
logger.debug(
f"发送API请求 模型:{preset.model_name} 历史消息数:{len(messages)}"
)
logger.debug(f"收到API响应 使用token数{response.usage.total_tokens}")
reply = response.choices[0].message.content
client_config = {
"model": preset.model_name,
"max_tokens": preset.max_tokens,
"temperature": preset.temperature,
"timeout": 60,
}
# 请求成功后再保存历史记录保证user和assistant穿插防止R1模型报错
state.history.append({"role": "user", "content": content})
state.past_events.clear()
mcp_client = MCPClient(plugin_config.mcp_servers)
if preset.support_mcp:
await mcp_client.connect_to_servers()
available_tools = await mcp_client.get_available_tools()
client_config["tools"] = available_tools
reasoning_content: str | None = getattr(response.choices[0].message, "reasoning_content", None)
if state.output_reasoning_content and reasoning_content:
await handler.send(Message(reasoning_content))
response = await client.chat.completions.create(
**client_config,
messages=messages + new_messages,
)
logger.info(f"准备发送回复消息 群号:{group_id} 消息分段数:{len(reply.split('<botbr>'))}")
for r in reply.split("<botbr>"):
# 删除前后多余的换行和空格
while r[0] == "\n" or r[0] == " ": r = r[1:]
while r[-1] == "\n" or r[0] == " ": r = r[:-1]
await asyncio.sleep(2)
logger.debug(f"发送消息分段 内容:{r[:50]}...") # 只记录前50个字符避免日志过大
await handler.send(Message(r))
# 添加助手回复到历史
state.history.append({
if response.usage is not None:
logger.debug(f"收到API响应 使用token数{response.usage.total_tokens}")
message = response.choices[0].message
# 处理响应并处理工具调用
while preset.support_mcp and message.tool_calls:
new_messages.append({
"role": "assistant",
"tool_calls": [tool_call.model_dump() for tool_call in message.tool_calls]
})
# 发送LLM调用工具时的回复一般没有
if message.content:
await send_split_messages(handler, message.content)
# 处理每个工具调用
for tool_call in message.tool_calls:
tool_name = tool_call.function.name
tool_args = json.loads(tool_call.function.arguments)
# 发送工具调用提示
await handler.send(Message(f"正在使用{mcp_client.get_friendly_name(tool_name)}"))
# 执行工具调用
result = await mcp_client.call_tool(tool_name, tool_args)
new_messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result)
})
# 将工具调用的结果交给 LLM
response = await client.chat.completions.create(
**client_config,
messages=messages + new_messages,
)
message = response.choices[0].message
await mcp_client.cleanup()
reply, matched_reasoning_content = pop_reasoning_content(
response.choices[0].message.content
)
reasoning_content: str | None = (
getattr(response.choices[0].message, "reasoning_content", None)
or matched_reasoning_content
)
new_messages.append({
"role": "assistant",
"content": reply,
})
# 请求成功后再保存历史记录保证user和assistant穿插防止R1模型报错
for message in new_messages:
state.history.append(message)
state.past_events.clear()
if state.output_reasoning_content and reasoning_content:
try:
bot = get_bot(str(event.self_id))
await bot.send_group_forward_msg(
group_id=group_id,
messages=build_reasoning_forward_nodes(
bot.self_id, reasoning_content
),
)
except Exception as e:
logger.error(f"合并转发消息发送失败:\n{e!s}\n")
assert reply is not None
await send_split_messages(handler, reply)
except Exception as e:
logger.error(f"API请求失败 群号:{group_id} 错误:{str(e)}", exc_info=True)
await handler.send(Message(f"服务暂时不可用,请稍后再试\n{str(e)}"))
logger.opt(exception=e).error(f"API请求失败 群号:{group_id}")
await handler.send(Message(f"服务暂时不可用,请稍后再试\n{e!s}"))
finally:
state.queue.task_done()
state.processing = False
# 预设切换命令
preset_handler = on_command("API预设", priority=1, block=True, permission=SUPERUSER)
@preset_handler.handle()
async def handle_preset(event: GroupMessageEvent, args: Message = CommandArg()):
group_id = event.group_id
preset_name = args.extract_plain_text().strip()
if group_id not in group_states:
group_states[group_id] = GroupState()
if preset_name == "off":
group_states[group_id].preset_name = preset_name
await preset_handler.finish(f"已关闭llmchat")
await preset_handler.finish("已关闭llmchat")
available_presets = {p.name for p in pluginConfig.api_presets}
available_presets = {p.name for p in plugin_config.api_presets}
if preset_name not in available_presets:
await preset_handler.finish(f"当前API预设{group_states[group_id].preset_name}\n可用API预设\n- {'\n- '.join(available_presets)}")
available_presets_str = "\n- ".join(available_presets)
await preset_handler.finish(
f"当前API预设{group_states[group_id].preset_name}\n可用API预设\n- {available_presets_str}"
)
group_states[group_id].preset_name = preset_name
await preset_handler.finish(f"已切换至API预设{preset_name}")
preset_handler = on_command("修改设定", priority=1, block=True, permission=(SUPERUSER|GROUP_ADMIN|GROUP_OWNER))
@preset_handler.handle()
async def handle_preset(event: GroupMessageEvent, args: Message = CommandArg()):
edit_preset_handler = on_command(
"修改设定",
priority=1,
block=True,
permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER),
)
@edit_preset_handler.handle()
async def handle_edit_preset(event: GroupMessageEvent, args: Message = CommandArg()):
group_id = event.group_id
group_prompt = args.extract_plain_text().strip()
if group_id not in group_states:
group_states[group_id] = GroupState()
group_states[group_id].group_prompt = group_prompt
await preset_handler.finish("修改成功")
reset_handler = on_command("记忆清除", priority=99, block=True, permission=(SUPERUSER|GROUP_ADMIN|GROUP_OWNER))
group_states[group_id].group_prompt = group_prompt
await edit_preset_handler.finish("修改成功")
reset_handler = on_command(
"记忆清除",
priority=1,
block=True,
permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER),
)
@reset_handler.handle()
async def handle_reset(event: GroupMessageEvent, args: Message = CommandArg()):
group_id = event.group_id
if group_id not in group_states:
group_states[group_id] = GroupState()
group_states[group_id].past_events.clear()
group_states[group_id].history.clear()
await preset_handler.finish(f"记忆已清空")
await reset_handler.finish("记忆已清空")
set_prob_handler = on_command(
"设置主动回复概率",
priority=1,
block=True,
permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER),
)
@set_prob_handler.handle()
async def handle_set_prob(event: GroupMessageEvent, args: Message = CommandArg()):
group_id = event.group_id
prob = 0
try:
prob = float(args.extract_plain_text().strip())
if prob < 0 or prob > 1:
raise ValueError
except Exception as e:
await reset_handler.finish(f"输入有误,请使用 [0,1] 的浮点数\n{e!s}")
group_states[group_id].random_trigger_prob = prob
await reset_handler.finish(f"主动回复概率已设为 {prob}")
# 预设切换命令
preset_handler = on_command("切换思维输出", priority=1, block=True, permission=(SUPERUSER|GROUP_ADMIN|GROUP_OWNER))
@preset_handler.handle()
async def handle_preset(event: GroupMessageEvent, args: Message = CommandArg()):
group_id = event.group_id
if group_id not in group_states:
group_states[group_id] = GroupState()
if group_states[group_id].output_reasoning_content:
group_states[group_id].output_reasoning_content = False
await preset_handler.finish("已关闭思维输出")
else:
group_states[group_id].output_reasoning_content = True
await preset_handler.finish("已开启思维输出")
think_handler = on_command(
"切换思维输出",
priority=1,
block=True,
permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER),
)
@think_handler.handle()
async def handle_think(event: GroupMessageEvent, args: Message = CommandArg()):
state = group_states[event.group_id]
state.output_reasoning_content = not state.output_reasoning_content
await think_handler.finish(
f"{(state.output_reasoning_content and '开启') or '关闭'}思维输出"
)
# region 持久化与定时任务
# 获取插件数据目录
data_dir = store.get_plugin_data_dir()
# 获取插件数据文件
data_file = store.get_plugin_data_file("llmchat_state.json")
async def save_state():
"""保存群组状态到文件"""
logger.info(f"开始保存群组状态到文件:{pluginConfig.storage_path}")
logger.info(f"开始保存群组状态到文件:{data_file}")
data = {
gid: {
"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
"output_reasoning_content": state.output_reasoning_content,
"random_trigger_prob": state.random_trigger_prob,
}
for gid, state in group_states.items()
}
os.makedirs(os.path.dirname(pluginConfig.storage_path), exist_ok=True)
async with aiofiles.open(pluginConfig.storage_path, "w") as f:
os.makedirs(os.path.dirname(data_file), exist_ok=True)
async with aiofiles.open(data_file, "w", encoding="utf8") as f:
await f.write(json.dumps(data, ensure_ascii=False))
async def load_state():
"""从文件加载群组状态"""
logger.info(f"从文件加载群组状态:{pluginConfig.storage_path}")
if not os.path.exists(pluginConfig.storage_path):
logger.info(f"从文件加载群组状态:{data_file}")
if not os.path.exists(data_file):
return
async with aiofiles.open(pluginConfig.storage_path, "r") as f:
async with aiofiles.open(data_file, encoding="utf8") as f:
data = json.loads(await f.read())
for gid, state_data in data.items():
state = GroupState()
state.preset_name = state_data["preset"]
state.history = deque(state_data["history"], maxlen=pluginConfig.history_size)
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"]
state.random_trigger_prob = state_data.get("random_trigger_prob", plugin_config.random_trigger_prob)
group_states[int(gid)] = state
# 注册生命周期事件
@driver.on_startup
async def init_plugin():
logger.info("插件启动初始化")
await load_state()
scheduler = AsyncIOScheduler()
# 每5分钟保存状态
scheduler.add_job(save_state, 'interval', minutes=5)
scheduler.start()
scheduler.add_job(save_state, "interval", minutes=5)
@driver.on_shutdown
async def cleanup_plugin():

41
nonebot_plugin_llmchat/config.py Normal file → Executable file
View file

@ -1,25 +1,54 @@
from pydantic import BaseModel, Field
from typing import List, Dict, Optional
class PresetConfig(BaseModel):
"""API预设配置"""
name: str = Field(..., description="预设名称(唯一标识)")
api_base: str = Field(..., description="API基础地址")
api_key: str = Field(..., description="API密钥")
model_name: str = Field(..., description="模型名称")
max_tokens: int = Field(2048, description="最大响应token数")
temperature: float = Field(0.7, description="生成温度0-2]")
proxy: str = Field("", description="HTTP代理服务器")
support_mcp: bool = Field(False, description="是否支持MCP")
support_image: bool = Field(False, description="是否支持图片输入")
class MCPServerConfig(BaseModel):
"""MCP服务器配置"""
command: str | None = Field(None, description="stdio模式下MCP命令")
args: list[str] | None = Field([], description="stdio模式下MCP命令参数")
env: dict[str, str] | None = Field({}, description="stdio模式下MCP命令环境变量")
url: str | None = Field(None, description="sse模式下MCP服务器地址")
# 额外字段
friendly_name: str | None = Field(None, description="MCP服务器友好名称")
addtional_prompt: str | None = Field(None, description="额外提示词")
class ScopedConfig(BaseModel):
"""LLM Chat Plugin配置"""
api_presets: List[PresetConfig] = Field(...,description="API预设列表至少配置1个预设")
api_presets: list[PresetConfig] = Field(
..., description="API预设列表至少配置1个预设"
)
history_size: int = Field(20, description="LLM上下文消息保留数量")
past_events_size : int = Field(10, description="触发回复时发送的群消息数量")
past_events_size: int = Field(10, description="触发回复时发送的群消息数量")
request_timeout: int = Field(30, description="API请求超时时间")
default_preset: str = Field("off", description="默认使用的预设名称")
random_trigger_prob: float = Field(0.05, ge=0.0, le=1.0, description="随机触发概率0-1]")
storage_path: str = Field("data/llmchat_state.json", description="状态存储文件路径")
default_prompt: str = Field("你的回答应该尽量简洁、幽默、可以使用一些语气词、颜文字。你应该拒绝回答任何政治相关的问题。", description="默认提示词")
random_trigger_prob: float = Field(
0.05, ge=0.0, le=1.0, description="随机触发概率0-1]"
)
default_prompt: str = Field(
"你的回答应该尽量简洁、幽默、可以使用一些语气词、颜文字。你应该拒绝回答任何政治相关的问题。",
description="默认提示词",
)
mcp_servers: dict[str, MCPServerConfig] = Field({}, description="MCP服务器配置")
blacklist_user_ids: set[int] = Field(set(), description="黑名单用户ID列表")
ignore_prefixes: list[str] = Field(
default_factory=list,
description="需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理"
)
class Config(BaseModel):
llmchat: ScopedConfig

View file

@ -0,0 +1,83 @@
import asyncio
from contextlib import AsyncExitStack
from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from nonebot import logger
from .config import MCPServerConfig
class MCPClient:
def __init__(self, server_config: dict[str, MCPServerConfig]):
logger.info(f"正在初始化MCPClient共有{len(server_config)}个服务器配置")
self.server_config = server_config
self.sessions = {}
self.exit_stack = AsyncExitStack()
logger.debug("MCPClient初始化成功")
async def connect_to_servers(self):
logger.info(f"开始连接{len(self.server_config)}个MCP服务器")
for server_name, config in self.server_config.items():
logger.debug(f"正在连接服务器[{server_name}]")
if config.url:
sse_transport = await self.exit_stack.enter_async_context(sse_client(url=config.url))
read, write = sse_transport
self.sessions[server_name] = await self.exit_stack.enter_async_context(ClientSession(read, write))
await self.sessions[server_name].initialize()
elif config.command:
stdio_transport = await self.exit_stack.enter_async_context(
stdio_client(StdioServerParameters(**config.model_dump()))
)
read, write = stdio_transport
self.sessions[server_name] = await self.exit_stack.enter_async_context(ClientSession(read, write))
await self.sessions[server_name].initialize()
else:
raise ValueError("Server config must have either url or command")
logger.info(f"已成功连接到MCP服务器[{server_name}]")
async def get_available_tools(self):
logger.info(f"正在从{len(self.sessions)}个已连接的服务器获取可用工具")
available_tools = []
for server_name, session in self.sessions.items():
logger.debug(f"正在列出服务器[{server_name}]中的工具")
response = await session.list_tools()
tools = response.tools
logger.debug(f"在服务器[{server_name}]中找到{len(tools)}个工具")
available_tools.extend(
{
"type": "function",
"function": {
"name": f"{server_name}___{tool.name}",
"description": tool.description,
"parameters": tool.inputSchema,
},
}
for tool in tools
)
return available_tools
async def call_tool(self, tool_name: str, tool_args: dict):
server_name, real_tool_name = tool_name.split("___")
logger.info(f"正在服务器[{server_name}]上调用工具[{real_tool_name}]")
session = self.sessions[server_name]
try:
response = await asyncio.wait_for(session.call_tool(real_tool_name, tool_args), timeout=30)
except asyncio.TimeoutError:
logger.error(f"调用工具[{real_tool_name}]超时")
return f"调用工具[{real_tool_name}]超时"
logger.debug(f"工具[{real_tool_name}]调用完成,响应: {response}")
return response.content
def get_friendly_name(self, tool_name: str):
server_name, real_tool_name = tool_name.split("___")
return (self.server_config[server_name].friendly_name or server_name) + " - " + real_tool_name
async def cleanup(self):
logger.debug("正在清理MCPClient资源")
await self.exit_stack.aclose()
logger.debug("MCPClient资源清理完成")

1523
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "nonebot-plugin-llmchat"
version = "0.1.1"
version = "0.2.5"
description = "Nonebot AI group chat plugin supporting multiple API preset configurations"
license = "GPL"
authors = ["FuQuan i@fuquan.moe"]
@ -11,13 +11,69 @@ documentation = "https://github.com/FuQuan233/nonebot-plugin-llmchat#readme"
keywords = ["nonebot", "nonebot2", "llm", "ai"]
[tool.poetry.dependencies]
python = "^3.9"
python = "^3.10"
openai = ">=1.0.0"
nonebot2 = "^2.2.0"
aiofiles = ">=24.0.0"
nonebot-plugin-apscheduler = "^0.5.0"
nonebot-adapter-onebot = "^2.0.0"
nonebot-plugin-localstore = "^0.7.3"
mcp = "^1.6.0"
[tool.poetry.group.dev.dependencies]
ruff = "^0.8.0"
nonemoji = "^0.1.2"
pre-commit = "^4.0.0"
[tool.ruff]
line-length = 130
target-version = "py310"
[tool.ruff.format]
line-ending = "lf"
[tool.ruff.lint]
select = [
"F", # Pyflakes
"W", # pycodestyle warnings
"E", # pycodestyle errors
"I", # isort
"UP", # pyupgrade
"ASYNC", # flake8-async
"C4", # flake8-comprehensions
"T10", # flake8-debugger
"T20", # flake8-print
"PYI", # flake8-pyi
"PT", # flake8-pytest-style
"Q", # flake8-quotes
"TID", # flake8-tidy-imports
"TC", # flake8-type-checking
"RUF", # Ruff-specific rules
]
ignore = [
"E402", # module-import-not-at-top-of-file
"UP037", # quoted-annotation
"RUF001", # ambiguous-unicode-character-string
"RUF002", # ambiguous-unicode-character-docstring
"RUF003", # ambiguous-unicode-character-comment
]
[tool.ruff.lint.isort]
force-sort-within-sections = true
[tool.ruff.lint.pyupgrade]
keep-runtime-typing = true
[tool.pyright]
pythonVersion = "3.10"
pythonPlatform = "All"
defineConstant = { PYDANTIC_V2 = true }
executionEnvironments = [{ root = "./" }]
typeCheckingMode = "standard"
reportShadowedImports = false
disableBytesTypePromotions = true
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
build-backend = "poetry.core.masonry.api"