mirror of
https://github.com/FuQuan233/nonebot-plugin-llmchat.git
synced 2025-09-08 04:48:51 +00:00
Compare commits
88 commits
Author | SHA1 | Date | |
---|---|---|---|
d640f16abe | |||
1600cba172 | |||
9f81a38d5b | |||
53d57beba3 | |||
ea635fd147 | |||
5014d3014b | |||
89baec6abc | |||
19ff0026c0 | |||
52ada66616 | |||
cf2d549f02 | |||
6c27cf56fa | |||
3d85ea90ef | |||
7edd7c913e | |||
84d3851936 | |||
ee2a045116 | |||
6f69cc3cff | |||
ed1b9792e7 | |||
|
0ddf8e5626 | ||
|
5e048c9472 | ||
|
f2d1521158 | ||
db9794a18a | |||
|
c9c22a8630 | ||
8013df564a | |||
e3973baa37 | |||
fd18f41f17 | |||
3e9e691faf | |||
48fe2515e9 | |||
506024c8f5 | |||
318e5b91c1 | |||
6aea492281 | |||
0f09015042 | |||
3c1ac4b68b | |||
ed2f9051ef | |||
eb1038e09e | |||
dfe3b5308c | |||
17564b5463 | |||
7408f90b3c | |||
da5621abbe | |||
f89e41754c | |||
05eb132f85 | |||
a7b57ae375 | |||
4af60b8145 | |||
b01f2825d1 | |||
405d5d367f | |||
a6fa27dd9a | |||
e8db066647 | |||
6db55055a2 | |||
505fab406b | |||
04883cda00 | |||
1406672b8d | |||
2ef3addeee | |||
99bb698f3c | |||
df8944ec73 | |||
ede61a028a | |||
948583828f | |||
cf9175c077 | |||
|
54e4766dfd | ||
|
65ea3dc582 | ||
f662d4ae00 | |||
b4c3e71faf | |||
b4a9ce8a7f | |||
f4c44a0623 | |||
|
5b0fe9564d | ||
|
9e478272cb | ||
|
812545f99d | ||
|
e88ac6bcd5 | ||
|
a729d77d4e | ||
|
980c272504 | ||
|
8ba2bb511c | ||
|
bb9ae962e6 | ||
|
29e966eb21 | ||
|
40ff908a32 | ||
25a51a6d05 | |||
|
f0a5dd8bb9 | ||
b4402af2c8 | |||
bb106c991b | |||
78e7ac4e1d | |||
634fad6eed | |||
4683127566 | |||
46d854aeae | |||
8e6dfe4f14 | |||
|
4adf580493 | ||
|
620245f420 | ||
|
5fd1c5ff40 | ||
|
7f284ec4c4 | ||
65f66be04a | |||
4947fccdd3 | |||
a87d3e64ff |
12 changed files with 2323 additions and 170 deletions
32
.editorconfig
Normal file
32
.editorconfig
Normal 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
23
.github/actions/setup-python/action.yml
vendored
Normal 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
33
.github/workflows/pyright.yml
vendored
Normal 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
26
.github/workflows/ruff.yml
vendored
Normal 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
22
.pre-commit-config.yaml
Normal 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]
|
88
README.md
88
README.md
|
@ -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同样为一个dict,key为服务器名称,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为完全关闭主动回复 |
|
||||
|
||||
### 效果图
|
||||

|
||||

|
||||

|
||||
|
|
BIN
img/mcp_demo.jpg
Normal file
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
560
nonebot_plugin_llmchat/__init__.py
Normal file → Executable 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
41
nonebot_plugin_llmchat/config.py
Normal file → Executable 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
|
||||
|
|
83
nonebot_plugin_llmchat/mcpclient.py
Normal file
83
nonebot_plugin_llmchat/mcpclient.py
Normal 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
1523
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue