diff --git a/.github/actions/setup-python/action.yml b/.github/actions/setup-python/action.yml index 33816c5..382fd60 100644 --- a/.github/actions/setup-python/action.yml +++ b/.github/actions/setup-python/action.yml @@ -5,7 +5,7 @@ inputs: python-version: description: Python version required: false - default: "3.10" + default: "3.9" runs: using: "composite" diff --git a/README.md b/README.md index 356effc..cefcdb2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ # nonebot-plugin-llmchat -_✨ 支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插件 ✨_ +_✨ 支持多API预设配置的AI群聊插件 ✨_ @@ -17,39 +17,33 @@ _✨ 支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插 pypi -python -Ask DeepWiki +python ## 📖 介绍 -1. **支持MCP协议** - - 可以连接各种支持MCP协议的LLM工具 - - 通过连接一些搜索MCP服务器可以实现在线搜索 - - 兼容 Claude.app 的配置格式 - -2. **多API预设支持** +1. **多API预设支持** - 可配置多个LLM服务预设(如不同模型/API密钥) - 支持运行时通过`API预设`命令热切换API配置 - 内置服务开关功能(预设名为`off`时停用) -3. **多种回复触发方式** +2. **多种回复触发方式** - @触发 + 随机概率触发 - 支持处理回复消息 - 群聊消息顺序处理,防止消息错乱 -4. **分群聊上下文记忆管理** +3. **分群聊上下文记忆管理** - 分群聊保留对话历史记录(可配置保留条数) - 自动合并未处理消息,降低API用量 - 支持`记忆清除`命令手动重置对话上下文 -5. **分段回复支持** +4. **分段回复支持** - 支持多段式回复(由LLM决定如何回复) - 可@群成员(由LLM插入) - 可选输出AI的思维过程(需模型支持) -6. **可自定义性格** +5. **可自定义性格** - 可动态修改群组专属系统提示词(`/修改设定`) - 支持自定义默认提示词 @@ -108,9 +102,6 @@ _✨ 支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插 | 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为一个列表,每项配置有以下的配置项 | 配置项 | 必填 | 默认值 | 说明 | @@ -121,24 +112,6 @@ _✨ 支持多API预设、MCP协议、联网搜索、视觉模型的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 | 否 | 无 | 关于这个工具的附加提示词 |
配置示例 @@ -152,38 +125,15 @@ LLMCHAT__MCP_SERVERS同样为一个dict,key为服务器名称,value配置的 "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" + "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1" }, { - "name": "deepseek-v1", + "name": "deepseek-r1", "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 + "model_name": "deepseek-reasoner", + "api_base": "https://api.deepseek.com" } ] - LLMCHAT__MCP_SERVERS=' - { - "AISearch": { - "friendly_name": "百度搜索", - "additional_prompt": "遇到你不知道的问题或者时效性比较强的问题时,可以使用AISearch搜索,在使用AISearch时不要使用其他AI模型。", - "url": "http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key=Bearer+" - }, - "fetch": { - "friendly_name": "网页浏览", - "command": "uvx", - "args": ["mcp-server-fetch"] - } - } - ' '
@@ -207,5 +157,4 @@ LLMCHAT__MCP_SERVERS同样为一个dict,key为服务器名称,value配置的 | 设置主动回复概率 | 管理 | 否 | 群聊 | 主动回复概率 | 主动回复概率需为 [0, 1] 的浮点数,0为完全关闭主动回复 | ### 效果图 -![](img/mcp_demo.jpg) -![](img/demo.png) +![](img/demo.png) \ No newline at end of file diff --git a/img/mcp_demo.jpg b/img/mcp_demo.jpg deleted file mode 100644 index 159954c..0000000 Binary files a/img/mcp_demo.jpg and /dev/null differ diff --git a/nonebot_plugin_llmchat/__init__.py b/nonebot_plugin_llmchat/__init__.py old mode 100755 new mode 100644 index d3c6605..e220485 --- a/nonebot_plugin_llmchat/__init__.py +++ b/nonebot_plugin_llmchat/__init__.py @@ -1,17 +1,14 @@ import asyncio -import base64 from collections import defaultdict, deque from datetime import datetime import json import os import random import re -import ssl import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import aiofiles -import httpx from nonebot import ( get_bot, get_driver, @@ -30,7 +27,6 @@ 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 @@ -39,14 +35,13 @@ require("nonebot_plugin_apscheduler") from nonebot_plugin_apscheduler import scheduler if TYPE_CHECKING: - from openai.types.chat import ( - ChatCompletionContentPartParam, - ChatCompletionMessageParam, - ) + from collections.abc import Iterable + + from openai.types.chat import ChatCompletionMessageParam __plugin_meta__ = PluginMetadata( name="llmchat", - description="支持多API预设、MCP协议、联网搜索、视觉模型的AI群聊插件", + description="支持多API预设配置的AI群聊插件", usage="""@机器人 + 消息 开启对话""", type="application", homepage="https://github.com/FuQuan233/nonebot-plugin-llmchat", @@ -60,8 +55,8 @@ tasks: set["asyncio.Task"] = set() def pop_reasoning_content( - content: str | None, -) -> tuple[str | None, str | None]: + content: Optional[str], +) -> tuple[Optional[str], Optional[str]]: if content is None: return None, None @@ -80,12 +75,12 @@ def pop_reasoning_content( class GroupState: def __init__(self): self.preset_name = plugin_config.default_preset - self.history = deque(maxlen=plugin_config.history_size * 2) + self.history = deque(maxlen=plugin_config.history_size) 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.group_prompt: Optional[str] = None self.output_reasoning_content = False self.random_trigger_prob = plugin_config.random_trigger_prob @@ -165,16 +160,6 @@ async def is_triggered(event: GroupMessageEvent) -> bool: 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) # 原有@触发条件 @@ -182,7 +167,7 @@ async def is_triggered(event: GroupMessageEvent) -> bool: return True # 随机触发条件 - if random.random() < state.random_trigger_prob: + if random.random() < plugin_config.random_trigger_prob: return True return False @@ -191,7 +176,7 @@ async def is_triggered(event: GroupMessageEvent) -> bool: # 消息处理器 handler = on_message( rule=Rule(is_triggered), - priority=99, + priority=10, block=False, ) @@ -212,65 +197,17 @@ async def handle_message(event: GroupMessageEvent): 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): - """ - 将消息按分隔符分段并发送 - """ - logger.info(f"准备发送分段消息,分段数:{len(content.split(''))}") - for segment in content.split(""): - # 跳过空消息 - 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客户端 - 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, - ) + 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()}" @@ -296,125 +233,46 @@ async def process_messages(group_id: int): 下面是关于你性格的设定,如果设定中提到让你扮演某个人,或者设定中有提到名字,则优先使用设定中的名字。 {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: list[ChatCompletionMessageParam] = [ + messages: Iterable[ChatCompletionMessageParam] = [ {"role": "system", "content": systemPrompt} ] - while len(state.history) > 0 and state.history[0]["role"] != "user": - state.history.popleft() - - messages += list(state.history)[-plugin_config.history_size * 2 :] + messages += list(state.history)[-plugin_config.history_size :] # 没有未处理的消息说明已经被处理了,跳过 if state.past_events.__len__() < 1: break - content: list[ChatCompletionContentPartParam] = [] - # 将机器人错过的消息推送给LLM - 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}) - - # 将消息中的图片转成 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} - ] + content = ",".join([format_message(ev) for ev in state.past_events]) logger.debug( f"发送API请求 模型:{preset.model_name} 历史消息数:{len(messages)}" ) - - client_config = { - "model": preset.model_name, - "max_tokens": preset.max_tokens, - "temperature": preset.temperature, - "timeout": 60, - } - - 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 - response = await client.chat.completions.create( - **client_config, - messages=messages + new_messages, + model=preset.model_name, + messages=[*messages, {"role": "user", "content": content}], + max_tokens=preset.max_tokens, + temperature=preset.temperature, + timeout=60, ) 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 = ( + reasoning_content: Optional[str] = ( 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.history.append({"role": "user", "content": content}) + # 添加助手回复到历史 + state.history.append({"role": "assistant", "content": reply}) state.past_events.clear() if state.output_reasoning_content and reasoning_content: @@ -430,7 +288,20 @@ async def process_messages(group_id: int): logger.error(f"合并转发消息发送失败:\n{e!s}\n") assert reply is not None - await send_split_messages(handler, reply) + logger.info( + f"准备发送回复消息 群号:{group_id} 消息分段数:{len(reply.split(''))}" + ) + for r in reply.split(""): + # 似乎会有空消息的情况导致string index out of range异常 + if len(r) == 0 or r.isspace(): + continue + # 删除前后多余的换行和空格 + r = r.strip() + await asyncio.sleep(2) + logger.debug( + f"发送消息分段 内容:{r[:50]}..." + ) # 只记录前50个字符避免日志过大 + await handler.send(Message(r)) except Exception as e: logger.opt(exception=e).error(f"API请求失败 群号:{group_id}") @@ -467,7 +338,7 @@ async def handle_preset(event: GroupMessageEvent, args: Message = CommandArg()): edit_preset_handler = on_command( "修改设定", - priority=1, + priority=99, block=True, permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER), ) @@ -484,7 +355,7 @@ async def handle_edit_preset(event: GroupMessageEvent, args: Message = CommandAr reset_handler = on_command( "记忆清除", - priority=1, + priority=99, block=True, permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER), ) @@ -501,7 +372,7 @@ async def handle_reset(event: GroupMessageEvent, args: Message = CommandArg()): set_prob_handler = on_command( "设置主动回复概率", - priority=1, + priority=99, block=True, permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER), ) @@ -582,12 +453,15 @@ async def load_state(): state = GroupState() state.preset_name = state_data["preset"] state.history = deque( - state_data["history"], maxlen=plugin_config.history_size * 2 + state_data["history"], maxlen=plugin_config.history_size ) 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) + state.random_trigger_prob = ( + state_data.get("random_trigger_prob") + or plugin_config.random_trigger_prob + ) group_states[int(gid)] = state diff --git a/nonebot_plugin_llmchat/config.py b/nonebot_plugin_llmchat/config.py old mode 100755 new mode 100644 index d658875..c5e4f37 --- a/nonebot_plugin_llmchat/config.py +++ b/nonebot_plugin_llmchat/config.py @@ -10,20 +10,7 @@ class PresetConfig(BaseModel): 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配置""" @@ -42,12 +29,6 @@ class ScopedConfig(BaseModel): "你的回答应该尽量简洁、幽默、可以使用一些语气词、颜文字。你应该拒绝回答任何政治相关的问题。", 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): diff --git a/nonebot_plugin_llmchat/mcpclient.py b/nonebot_plugin_llmchat/mcpclient.py deleted file mode 100644 index 55e1b44..0000000 --- a/nonebot_plugin_llmchat/mcpclient.py +++ /dev/null @@ -1,83 +0,0 @@ -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资源清理完成") diff --git a/poetry.lock b/poetry.lock index 50d98ce..4cc2b18 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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.0.1 and should not be changed by hand. [[package]] name = "aiofiles" @@ -44,7 +44,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] trio = ["trio (>=0.26.1)"] [[package]] @@ -70,7 +70,7 @@ mongodb = ["pymongo (>=3.0)"] redis = ["redis (>=3.0)"] rethinkdb = ["rethinkdb (>=2.4.0)"] sqlalchemy = ["sqlalchemy (>=1.4)"] -test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6", "anyio (>=4.5.2)", "gevent", "pytest", "pytz", "twisted"] tornado = ["tornado (>=4.3)"] twisted = ["twisted"] zookeeper = ["kazoo"] @@ -99,21 +99,6 @@ files = [ {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] -[[package]] -name = "click" -version = "8.1.8" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "colorama" version = "0.4.6" @@ -181,7 +166,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] -typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] +typing = ["typing-extensions (>=4.12.2)"] [[package]] name = "h11" @@ -236,24 +221,12 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] -[[package]] -name = "httpx-sse" -version = "0.4.0" -description = "Consume Server-Sent Event (SSE) messages with HTTPX." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, - {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, -] - [[package]] name = "identify" version = "2.6.7" @@ -387,34 +360,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] -dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] - -[[package]] -name = "mcp" -version = "1.6.0" -description = "Model Context Protocol SDK" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0"}, - {file = "mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723"}, -] - -[package.dependencies] -anyio = ">=4.5" -httpx = ">=0.27" -httpx-sse = ">=0.4" -pydantic = ">=2.7.2,<3.0.0" -pydantic-settings = ">=2.5.2" -sse-starlette = ">=1.6.1" -starlette = ">=0.27" -uvicorn = ">=0.23.1" - -[package.extras] -cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"] -rich = ["rich (>=13.9.4)"] -ws = ["websockets (>=15.0.1)"] +dev = ["Sphinx (==8.1.3)", "build (==1.2.2)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.5.0)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.13.0)", "mypy (==v1.4.1)", "myst-parser (==4.0.0)", "pre-commit (==4.0.1)", "pytest (==6.1.2)", "pytest (==8.3.2)", "pytest-cov (==2.12.1)", "pytest-cov (==5.0.0)", "pytest-cov (==6.0.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.1.0)", "sphinx-rtd-theme (==3.0.2)", "tox (==3.27.1)", "tox (==4.23.2)", "twine (==6.0.1)"] [[package]] name = "msgpack" @@ -921,7 +867,7 @@ typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] +timezone = ["tzdata"] [[package]] name = "pydantic-core" @@ -1036,30 +982,6 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" -[[package]] -name = "pydantic-settings" -version = "2.9.1" -description = "Settings management using Pydantic" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef"}, - {file = "pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268"}, -] - -[package.dependencies] -pydantic = ">=2.7.0" -python-dotenv = ">=0.21.0" -typing-inspection = ">=0.4.0" - -[package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] -azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] -gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] -toml = ["tomli (>=2.0.1)"] -yaml = ["pyyaml (>=6.0.1)"] - [[package]] name = "pygtrie" version = "2.5.0" @@ -1190,44 +1112,6 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] -[[package]] -name = "sse-starlette" -version = "2.3.3" -description = "SSE plugin for Starlette" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "sse_starlette-2.3.3-py3-none-any.whl", hash = "sha256:8b0a0ced04a329ff7341b01007580dd8cf71331cc21c0ccea677d500618da1e0"}, - {file = "sse_starlette-2.3.3.tar.gz", hash = "sha256:fdd47c254aad42907cfd5c5b83e2282be15be6c51197bf1a9b70b8e990522072"}, -] - -[package.dependencies] -anyio = ">=4.7.0" -starlette = ">=0.41.3" - -[package.extras] -examples = ["fastapi"] -uvicorn = ["uvicorn (>=0.34.0)"] - -[[package]] -name = "starlette" -version = "0.46.2" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, - {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, -] - -[package.dependencies] -anyio = ">=3.6.2,<5" - -[package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] - [[package]] name = "tomli" version = "2.2.1" @@ -1235,7 +1119,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "python_version == \"3.10\"" +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1305,21 +1189,6 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -[[package]] -name = "typing-inspection" -version = "0.4.0" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"}, - {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - [[package]] name = "tzdata" version = "2025.1" @@ -1351,26 +1220,6 @@ tzdata = {version = "*", markers = "platform_system == \"Windows\""} [package.extras] devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] -[[package]] -name = "uvicorn" -version = "0.34.2" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403"}, - {file = "uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328"}, -] - -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] - [[package]] name = "virtualenv" version = "20.29.2" @@ -1390,7 +1239,7 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] name = "wcwidth" @@ -1418,7 +1267,7 @@ files = [ ] [package.extras] -dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [[package]] name = "yarl" @@ -1519,5 +1368,5 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" -python-versions = "^3.10" -content-hash = "c33b411db9144768bcd4d912397c3a9789dd34edfc67b8e1458b00d2a2e2733a" +python-versions = "^3.9" +content-hash = "5675eb652e3b158a0e30e448971b218da514dae36513a5ab99ea5c2f7a216a05" diff --git a/pyproject.toml b/pyproject.toml index 7c17df2..60efedd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "nonebot-plugin-llmchat" -version = "0.2.5" +version = "0.1.9" description = "Nonebot AI group chat plugin supporting multiple API preset configurations" license = "GPL" authors = ["FuQuan i@fuquan.moe"] @@ -11,14 +11,13 @@ documentation = "https://github.com/FuQuan233/nonebot-plugin-llmchat#readme" keywords = ["nonebot", "nonebot2", "llm", "ai"] [tool.poetry.dependencies] -python = "^3.10" +python = "^3.9" 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" @@ -27,7 +26,7 @@ pre-commit = "^4.0.0" [tool.ruff] line-length = 130 -target-version = "py310" +target-version = "py39" [tool.ruff.format] line-ending = "lf" @@ -65,7 +64,7 @@ force-sort-within-sections = true keep-runtime-typing = true [tool.pyright] -pythonVersion = "3.10" +pythonVersion = "3.9" pythonPlatform = "All" defineConstant = { PYDANTIC_V2 = true } executionEnvironments = [{ root = "./" }]