Compare commits

...

100 Commits

Author SHA1 Message Date
Soulter da546cfe7f 🎈 perf(telegram): 弱化无法注册指令的日志级别 2025-04-20 18:08:52 +08:00
Soulter a211933e83 📦 release: v3.5.4 2025-04-20 18:01:37 +08:00
Soulter 1d40b5a821 feat(updator): 替换为采用 Semver 语义化版本来比较版本 2025-04-20 17:30:01 +08:00
Soulter 33836daeb7 Merge pull request #1327 from YOOkoishi/tts-feat-branck
TTS : add text output alongside voice (Fix #1085)
2025-04-20 16:07:06 +08:00
Soulter 0de6d0e046 Merge pull request #1256 from Raven95676/better-stream
perf: 为不支持流式输出的平台提供fallback。
2025-04-20 15:24:31 +08:00
Soulter 9fedaa9f77 🎈perf(webui): 优化了 MCP 页面的效果 2025-04-20 11:26:53 +08:00
Soulter bf4c2ecd33 feat: MCP 支持 SSE 传输协议连接到服务器 2025-04-20 11:02:28 +08:00
Soulter f8c18cc1e0 Merge pull request #1341 from AstrBotDevs/fix-dashscope-error-1330
fix: 修复阿里云百炼 TTS 只能发送一次语音,第二次就会报错
2025-04-20 01:17:32 +08:00
Soulter 458b900412 Merge pull request #1340 from AstrBotDevs/perf-wecom-split-long-text
feature: 企业微信添加长文本分割功能以支持发送超过 2048 字符的消息
2025-04-20 01:15:48 +08:00
Soulter 192c776e0b 🐛 fix: 修复阿里云百炼 TTS 只能发送一次语音,第二次就会报错
fixes: #1330
2025-04-20 00:58:37 +08:00
anka 5cdec18863 improvement: 对标点符号分割而不是直接切分 2025-04-19 16:52:30 +00:00
Soulter 15f856f951 perf(wecom): 企业微信添加长文本分割功能以支持发送超过 2048 字符的消息
fixes: #564
2025-04-20 00:27:04 +08:00
Raven95676 01d52cef74 perf: 支持更多参数 2025-04-20 00:12:14 +08:00
YOO_koishi 31d8c40eca tts : add text output alongside voice (Fix #1085) 2025-04-19 14:44:02 +08:00
渡鸦95676 56001ed272 Merge pull request #1326 from Raven95676/session_waiter
perf: 修改默认会话过滤器标识符为umo
2025-04-19 13:45:06 +08:00
Raven95676 cfae655068 perf: 修改默认会话过滤器标识符为umo 2025-04-19 11:57:22 +08:00
Raven95676 5596565ec4 fix: 若启用Gemini原生工具,构建Content列表时忽略工具调用 2025-04-18 23:36:12 +08:00
Raven95676 e98c3d8393 fix: Gemini保证工具间的互斥 2025-04-18 16:19:36 +08:00
渡鸦95676 6687b816f0 Merge pull request #1303 from Raven95676/master
feat: 添加对Gemini原生搜索功能的支持
2025-04-17 20:48:02 +08:00
Raven95676 ea8035e854 feat: 添加对Gemini原生搜索功能的支持 2025-04-17 20:36:22 +08:00
Soulter 54b0171d49 Merge pull request #1296 from AstrBotDevs/feat-mcp-servers-market
[WIP] MCP 服务器市场
2025-04-17 16:26:41 +08:00
Soulter 676d4277b9 chore: 优化样式 2025-04-17 16:26:27 +08:00
Soulter a4b1da3ca2 perf: 警告 2025-04-17 16:24:50 +08:00
Soulter 9e9c16e770 Merge pull request #1295 from EdelweissHuirh/master
修改分段回复的分割逻辑
2025-04-17 16:11:08 +08:00
Soulter dc87006fed feat: 分页 2025-04-17 16:07:13 +08:00
Soulter b9b260f26a perf: 弱化显示 2025-04-17 14:02:40 +08:00
Soulter 33fd6a5016 perf: 优化 MCP 服务器的日志回显 2025-04-17 13:59:10 +08:00
Soulter 97cbccc2ba feat: mcp 服务器市场 2025-04-17 00:41:04 +08:00
Raven95676 1ee4685d5d perf: 允许行级别锚点匹配以保持一致性 2025-04-16 22:13:38 +08:00
Soulter aba18232b1 perf: docker 镜像自带 node 环境
fixes: #1290
2025-04-16 21:53:27 +08:00
huirh 0a02441b75 修改分段回复逻辑 2025-04-16 21:52:42 +08:00
Raven95676 1be5b4c7ff fix: 兼容旧版本google-genai sdk 2025-04-16 00:34:08 +08:00
Raven95676 a0ce0cf18a fix: 增加更多Gemini不支持多模态输出的情况 2025-04-16 00:11:46 +08:00
Soulter 7c54e5d093 perf: 优化已安装的插件页
fixes: #934
2025-04-15 22:53:40 +08:00
Soulter b825e51dab chore: clean useless logs 2025-04-15 21:56:23 +08:00
Soulter 589855c393 feat: 支持开关是否忽略自身发送的消息
某些平台如 gewechat 会将自身账号在其他 APP 端发送的消息也当做消息事件下发导致给自己发消息时唤醒机器人

fixes: #890
2025-04-15 21:55:21 +08:00
渡鸦95676 4c546f2f53 Merge branch 'master' into better-stream 2025-04-15 21:22:08 +08:00
Raven95676 3753fce912 perf: 为发送流式消息的Fallback可选 2025-04-15 21:21:02 +08:00
Soulter 4c02857ec5 🐛 fix: 修复 aiocqhttp 无法发图片
fixes: #1275
2025-04-15 21:15:39 +08:00
Soulter 33f87ff7d7 🎈 perf: enhance metrics tracking with installation ID and sender ID hashing 2025-04-15 21:08:45 +08:00
Soulter 784dcf2a9a Merge pull request #1228 from Raven95676/gemini
refactor: 使用Google官方SDK重构gemini_source
2025-04-15 20:04:20 +08:00
Soulter 43ee943acb 🐛 fix: 多轮函数调用的报错 2025-04-15 10:59:16 +08:00
Soulter a769fd7d13 chore: add google-genai dependency to project 2025-04-15 10:40:42 +08:00
渡鸦95676 2c4fd00b16 Merge pull request #1276 from Raven95676/master
fix: 移除TG注册命令时的调试信息,注册命令时添加合法性校验
2025-04-14 22:04:11 +08:00
Raven95676 264771fe98 fix: 移除注册时的调试信息,注册命令时添加合法性校验 2025-04-14 21:55:34 +08:00
Soulter ecd92dafef Merge pull request #1274 from AstrBotDevs/fix-1121
🐛 fix: 修复上下文带图的情况下,对话数据库页无法查看对话详情的问题
2025-04-14 21:35:54 +08:00
Soulter c8b6e4bea3 🐛 fix: 修复上下文带图的情况下,对话数据库页无法查看对话详情的问题
fixes: 1121
2025-04-14 21:34:11 +08:00
Soulter 3756cb766e 🎈 perf: 支持自定义 PyPI 软件仓库地址
fixes: #1165
2025-04-14 21:19:36 +08:00
Soulter 068d9ca60b Update README.md 2025-04-14 19:57:04 +08:00
Soulter 93f632d8b8 Update README.md 2025-04-14 19:56:32 +08:00
Soulter bb44ce7e74 Update README.md 2025-04-14 10:30:12 +08:00
Raven95676 6986c8d8f7 fix: clean code,处理Gemini流式输出最后一部分概率性为None的情况 2025-04-13 18:34:57 +08:00
Raven95676 fe95506db4 perf: 添加日志过滤器以抑制非文本部分警告信息 2025-04-13 17:50:44 +08:00
Raven95676 310ed76b18 fix: 仅在确实包含图片模态时降级 2025-04-13 17:28:34 +08:00
Raven95676 98830d147f fix: 限速增加到1.5秒 2025-04-13 17:14:51 +08:00
Raven95676 19c9177d7b chore: 移除对dingtalk、lark、wecom的fallback 2025-04-13 17:03:06 +08:00
渡鸦95676 f41c5f97f6 Merge branch 'master' into better-stream 2025-04-13 16:47:56 +08:00
Raven95676 648c125697 refactor: 提取缓冲处理逻辑到astr_message_event 2025-04-13 15:37:22 +08:00
Soulter 0dc2b89897 Merge pull request #1257 from KimigaiiWuyi/master
🐛 修复飞书适配器转换消息过程中无法正确转化Base64图片
2025-04-13 15:33:02 +08:00
Soulter 83745f83a5 🐛 fix: 对飞书适配器 base64 格式数据先保存到本地 2025-04-13 15:29:56 +08:00
Soulter 2f91fe4535 Merge pull request #1244 from Rail1bc/master
修复:dequeue_context_length的配置项的实际行为与描述不一致;调用函数工具可能导致400错误
2025-04-13 14:41:16 +08:00
Raven95676 739f09059e feat: 为Gemini原生代码执行器提供有限支持 2025-04-13 12:43:25 +08:00
渡鸦95676 c86f9f0f5f Merge pull request #1261 from Raven95676/master
fix: 修复文件不存在的情况
2025-04-13 11:40:33 +08:00
Raven95676 9470ca6bc5 fix: 修复文件不存在的情况 2025-04-13 11:36:06 +08:00
Raven95676 2a92c4d5de fix: 修复导入 2025-04-13 11:22:27 +08:00
Raven95676 bb6e892657 feat: 重构发送流以提高代码可读性 2025-04-13 11:19:40 +08:00
KimigaiiWuyi c9079b9299 🐛 修复飞书适配器转换消息过程中无法正确转化Base64图片 2025-04-13 06:06:02 +08:00
Raven95676 b6963c1bf9 perf: 为不支持流式输出的平台提供fallback。 2025-04-13 02:21:42 +08:00
Raven95676 9c29df47bb fix: 更新流式输出逻辑,禁用图片模态并添加日志警告。 2025-04-13 01:09:42 +08:00
Soulter fc146d3d00 Merge pull request #1245 from AstrBotDevs/perf-mcpserver
perf: 适配 MCP 配置文件带 mcpServers 的情况(Cursor)
2025-04-12 23:06:39 +08:00
Soulter 1bf5a21678 Merge pull request #1158 from Jackxwb/master
文件发送时支持路径映射
2025-04-12 21:01:25 +08:00
Soulter 011542dc2b Merge pull request #1247 from Raven95676/shared_preferences
perf: shared_preferences加载失败时自动删除无效文件
2025-04-12 20:04:19 +08:00
Raven95676 489784104e perf: shared_preferences加载失败时自动删除无效文件 2025-04-12 19:31:45 +08:00
Raven95676 3860634fd2 fix: 修复了多模态输出支持判断问题并对只输出图片的情况进行处理。 2025-04-12 19:15:39 +08:00
Soulter 709c324e18 🐛 fix: 修复 MCP 服务器配置处理逻辑,确保正确处理空 mcpServers 情况并优化代码可读性 2025-04-12 18:19:06 +08:00
Soulter b75d24d92c 🎈 perf: 适配 MCP 配置文件带 mcpServers 的情况(Cursor)
🐛 fix: 关闭/删除 MCP 服务器后 Tools 没有清除的问题
2025-04-12 17:56:23 +08:00
Raila23 ed80e9424c Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot 2025-04-12 16:28:14 +08:00
Raila23 2fe1f2060a 修复:调用函数工具或其他未知情况,可能导致400 BadRequestError 2025-04-12 16:26:02 +08:00
Raila23 c6df820164 修复:每次清除的消息,比实际上期望的多1条 2025-04-12 15:34:35 +08:00
Soulter d6239822db release: v3.5.3.2 2025-04-12 15:27:33 +08:00
Soulter bced9ffff9 🐛 fix: 修复zhipu工具调用问题 2025-04-12 15:24:37 +08:00
Soulter d7d1c1544a 🐛 fix: 修复重启bot时可能发生报错的问题
在 gewechat, wecom 等消息平台没启动成功的情况下重启bot会报错
2025-04-12 15:01:38 +08:00
Raven95676 44dbe475af refactor: 拆分方法以提高代码可读性 2025-04-12 00:23:57 +08:00
Raven95676 bd24cf3ea4 feat: 初步完成原生流式请求逻辑 2025-04-11 23:45:30 +08:00
Raven95676 b493a808fe fix: 处理更多多模态不支持错误 2025-04-11 20:25:20 +08:00
Raven95676 54035d108d Merge branch 'gemini' of https://github.com/Raven95676/AstrBot-Rdev into gemini 2025-04-11 18:57:55 +08:00
Raven95676 c5e8bc7e20 fix: 修复模型生成内容的重试机制。 2025-04-11 18:55:46 +08:00
渡鸦95676 3bbb4779a3 Merge branch 'master' into gemini 2025-04-11 18:15:44 +08:00
Raven95676 1b3963ebea fix: 更新类型提示,简化代码并修复潜在的空值问题。 2025-04-11 18:07:00 +08:00
Raven95676 e8ffebc006 fix: 修复消息处理流程中可能出现的空消息 2025-04-11 15:01:20 +08:00
Raven95676 2ca95eaa9f fix: 在设置新key后重新初始化Gemini客户端 2025-04-11 14:42:24 +08:00
Raven95676 0dc5b4cdfc perf: 增加对RECITATION完成原因的处理,提取内容处理逻辑到独立方法 2025-04-11 12:25:44 +08:00
Raven95676 cc6cd96d8e fix: 修复潜在的空消息 2025-04-11 11:03:17 +08:00
Raven95676 4244d37625 chore: 格式化代码,禁用gemini source debug输出 2025-04-11 01:06:20 +08:00
Raven95676 0b766095d4 refactor: 初步完成gemini_source的重写 2025-04-11 01:03:16 +08:00
Soulter 39693a27e3 Merge branch 'master' into master 2025-04-09 00:30:51 +08:00
Jackxwb b03fe438d0 Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot 2025-04-07 22:50:03 +08:00
Jackxwb 7f56824b42 🐛 修复: 移除路径映射函数中的多余日志记录 2025-04-06 14:52:34 +08:00
Jackxwb 627da3a2bc 分离path_Mapping函数 2025-04-06 14:50:15 +08:00
Jackxwb e6b69042de 文件发送时支持路径映射 2025-04-06 01:06:51 +08:00
51 changed files with 2336 additions and 926 deletions
+4 -1
View File
@@ -17,4 +17,7 @@ ENV/
.conda/
README*.md
dashboard/
data/
data/
changelogs/
tests/
.ruff_cache/
+5
View File
@@ -4,6 +4,8 @@ WORKDIR /AstrBot
COPY . /AstrBot/
RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs \
npm \
gcc \
build-essential \
python3-dev \
@@ -28,3 +30,6 @@ EXPOSE 6185
EXPOSE 6186
CMD [ "python", "main.py" ]
+4 -3
View File
@@ -13,10 +13,11 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot?style=for-the-badge&color=76bad9)](https://github.com/Soulter/AstrBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" alt="python">
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg?style=for-the-badge&color=76bad9)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B4%BB%E8%B7%83%E9%87%8F&cacheSeconds=10800&style=for-the-badge&color=3b618e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=7200)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B4%BB%E8%B7%83%E9%87%8F&cacheSeconds=3600&style=for-the-badge&color=3b618e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600)
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a>
+4 -1
View File
@@ -23,7 +23,10 @@ db_helper = SQLiteDatabase(DB_PATH)
sp = (
SharedPreferences()
) # 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
pip_installer = PipInstaller(astrbot_config.get("pip_install_arg", ""))
pip_installer = PipInstaller(
astrbot_config.get("pip_install_arg", ""),
astrbot_config.get("pypi_index_url", None),
)
web_chat_queue = asyncio.Queue(maxsize=32)
web_chat_back_queue = asyncio.Queue(maxsize=32)
WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
+38 -11
View File
@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.5.3.1"
VERSION = "3.5.4"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -38,6 +38,7 @@ DEFAULT_CONFIG = {
"no_permission_reply": True,
"empty_mention_waiting": True,
"friend_message_needs_wake_prefix": False,
"ignore_bot_self_message": False,
},
"provider": [],
"provider_settings": {
@@ -52,6 +53,7 @@ DEFAULT_CONFIG = {
"max_context_length": -1,
"dequeue_context_length": 1,
"streaming_response": False,
"streaming_segmented": False,
},
"provider_stt_settings": {
"enable": False,
@@ -60,6 +62,7 @@ DEFAULT_CONFIG = {
"provider_tts_settings": {
"enable": False,
"provider_id": "",
"dual_output": False,
},
"provider_ltm_settings": {
"group_icl_enable": False,
@@ -97,7 +100,7 @@ DEFAULT_CONFIG = {
"wake_prefix": ["/"],
"log_level": "INFO",
"pip_install_arg": "",
"plugin_repo_mirror": "",
"pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/",
"knowledge_db": {},
"persona": [],
"timezone": "",
@@ -287,6 +290,11 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,私聊消息需要唤醒前缀才会被处理,同群聊一样。",
},
"ignore_bot_self_message": {
"description": "是否忽略机器人自身的消息",
"type": "bool",
"hint": "某些平台如 gewechat 会将自身账号在其他 APP 端发送的消息也当做消息事件下发导致给自己发消息时唤醒机器人",
},
"segmented_reply": {
"description": "分段回复",
"type": "object",
@@ -528,6 +536,8 @@ CONFIG_METADATA_2 = {
"model": "gemini-2.0-flash-exp",
},
"gm_resp_image_modal": False,
"gm_native_search": False,
"gm_native_coderunner": False,
"gm_safety_settings": {
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
@@ -704,6 +714,18 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,将支持返回图片内容。需要模型支持,否则会报错。具体支持模型请查看 Google Gemini 官方网站。温馨提示,如果您需要生成图片,请关闭 `启用群员识别` 配置获得更好的效果。",
},
"gm_native_search": {
"description": "启用原生搜索功能",
"type": "bool",
"hint": "启用后所有函数工具将全部失效,免费次数限制请查阅官方文档",
"obvious_hint": True,
},
"gm_native_coderunner": {
"description": "启用原生代码执行器",
"type": "bool",
"hint": "启用后所有函数工具将全部失效",
"obvious_hint": True,
},
"gm_safety_settings": {
"description": "安全过滤器",
"type": "object",
@@ -1008,6 +1030,11 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,将会流式输出 LLM 的响应。目前仅支持 OpenAI API提供商 以及 Telegram、QQ Official 私聊 两个平台",
},
"streaming_segmented": {
"description": "不支持流式回复的平台分段输出",
"type": "bool",
"hint": "启用后,若平台不支持流式回复,会分段输出。目前仅支持 aiocqhttp 和 gewechat 两个平台,不支持或无需使用流式分段输出的平台会静默忽略此选项",
},
},
},
"persona": {
@@ -1082,6 +1109,12 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "文本转语音提供商 ID。如果不填写将使用载入的第一个提供商。",
},
"dual_output": {
"description": "启用语音和文字双输出",
"type": "bool",
"hint": "启用后,Bot 将同时输出语音和文字消息。",
"obvious_hint": True,
},
},
},
"provider_ltm_settings": {
@@ -1216,16 +1249,10 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。",
},
"plugin_repo_mirror": {
"description": "件仓库镜像",
"pypi_index_url": {
"description": "PyPI 软件仓库地址",
"type": "string",
"hint": "已废弃,请使用管理面板->设置页的代理地址选择",
"obvious_hint": True,
"options": [
"default",
"https://ghp.ci/",
"https://github-mirror.us.kg/",
],
"hint": "安装 Python 依赖时请求的 PyPI 软件仓库地址。默认为 https://mirrors.aliyun.com/pypi/simple/",
},
},
},
+1 -1
View File
@@ -106,7 +106,7 @@ class AstrBotCoreLifecycle:
await self.pipeline_scheduler.initialize()
# 初始化更新器
self.astrbot_updator = AstrBotUpdator(self.astrbot_config["plugin_repo_mirror"])
self.astrbot_updator = AstrBotUpdator()
# 初始化事件总线
self.event_bus = EventBus(self.event_queue, self.pipeline_scheduler)
+4 -1
View File
@@ -25,6 +25,7 @@ import logging
import colorlog
import asyncio
import os
import sys
from collections import deque
from asyncio import Queue
from typing import List
@@ -171,7 +172,9 @@ class LogManager:
if logger.hasHandlers():
return logger
# 如果logger没有处理器
console_handler = logging.StreamHandler() # 创建一个StreamHandler用于控制台输出
console_handler = logging.StreamHandler(
sys.stdout
) # 创建一个StreamHandler用于控制台输出
console_handler.setLevel(
logging.DEBUG
) # 将日志级别设置为DEBUG(最低级别, 显示所有日志), *如果插件没有设置级别, 默认为DEBUG
+2
View File
@@ -193,6 +193,7 @@ class Record(BaseMessageComponent):
bs64_data = file_to_base64(self.file)
else:
raise Exception(f"not a valid file: {self.file}")
bs64_data = bs64_data.removeprefix("base64://")
return bs64_data
@@ -397,6 +398,7 @@ class Image(BaseMessageComponent):
bs64_data = file_to_base64(url)
else:
raise Exception(f"not a valid file: {url}")
bs64_data = bs64_data.removeprefix("base64://")
return bs64_data
@@ -146,8 +146,12 @@ class LLMRequestSubStage(Stage):
):
logger.debug("上下文长度超过限制,将截断。")
req.contexts = req.contexts[
-(self.max_context_length - self.dequeue_context_length) * 2 :
-(self.max_context_length - self.dequeue_context_length + 1) * 2 :
]
# 找到第一个role 为 user 的索引,确保上下文格式正确
index = next((i for i, item in enumerate(req.contexts) if item.get("role") == "user"), None)
if index is not None and index > 0:
req.contexts = req.contexts[index:]
# session_id
if not req.session_id:
+16 -1
View File
@@ -12,6 +12,7 @@ from astrbot.core import logger
from astrbot.core.message.message_event_result import BaseMessageComponent
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
from astrbot.core.utils.path_util import path_Mapping
@register_stage
@@ -56,6 +57,8 @@ class RespondStage(Stage):
async def initialize(self, ctx: PipelineContext):
self.ctx = ctx
self.config = ctx.astrbot_config
self.platform_settings: dict = self.config.get("platform_settings", {})
self.reply_with_mention = ctx.astrbot_config["platform_settings"][
"reply_with_mention"
@@ -143,12 +146,23 @@ class RespondStage(Stage):
if result.result_content_type == ResultContentType.STREAMING_RESULT:
# 流式结果直接交付平台适配器处理
use_fallback = self.config.get("provider_settings", {}).get(
"streaming_segmented", False
)
logger.info(f"应用流式输出({event.get_platform_name()})")
await event._pre_send()
await event.send_streaming(result.async_stream)
await event.send_streaming(result.async_stream, use_fallback)
await event._post_send()
return
elif len(result.chain) > 0:
# 检查路径映射
if mappings := self.platform_settings.get("path_mapping", []):
for idx, component in enumerate(result.chain):
if isinstance(component, Comp.File) and component.file:
# 支持 File 消息段的路径映射。
component.file = path_Mapping(mappings, component.file)
event.get_result().chain[idx] = component
await event._pre_send()
# 检查消息链是否为空
@@ -191,6 +205,7 @@ class RespondStage(Stage):
try:
await event.send(result)
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"发送消息失败: {e} chain: {result.chain}")
await event._post_send()
logger.info(
@@ -151,9 +151,9 @@ class ResultDecorateStage(Stage):
# 不分段回复
new_chain.append(comp)
continue
split_response = []
for line in comp.text.split("\n"):
split_response.extend(re.findall(self.regex, line))
split_response = re.findall(
self.regex, comp.text, re.DOTALL | re.MULTILINE
)
if not split_response:
new_chain.append(comp)
continue
@@ -184,6 +184,8 @@ class ResultDecorateStage(Stage):
new_chain.append(
Record(file=audio_path, url=audio_path)
)
if(self.ctx.astrbot_config["provider_tts_settings"]["dual_output"]):
new_chain.append(comp)
else:
logger.error(
f"由于 TTS 音频文件没找到,消息段转语音失败: {comp.text}"
@@ -35,10 +35,21 @@ class WakingCheckStage(Stage):
self.friend_message_needs_wake_prefix = self.ctx.astrbot_config[
"platform_settings"
].get("friend_message_needs_wake_prefix", False)
# 是否忽略机器人自己发送的消息
self.ignore_bot_self_message = self.ctx.astrbot_config["platform_settings"].get(
"ignore_bot_self_message", False
)
async def process(
self, event: AstrMessageEvent
) -> Union[None, AsyncGenerator[None, None]]:
if (
self.ignore_bot_self_message
and event.get_self_id() == event.get_sender_id()
):
# 忽略机器人自己发送的消息
event.stop_event()
return
# 设置 sender 身份
event.message_str = event.message_str.strip()
for admin_id in self.ctx.astrbot_config["admins_id"]:
+27 -2
View File
@@ -1,5 +1,8 @@
import abc
import asyncio
import re
import hashlib
import uuid
from dataclasses import dataclass
from typing import List, Union, Optional, AsyncGenerator
@@ -205,9 +208,26 @@ class AstrMessageEvent(abc.ABC):
"""
return self.role == "admin"
async def send_streaming(self, generator: AsyncGenerator[MessageChain, None]):
async def process_buffer(self, buffer: str, pattern: re.Pattern) -> str:
"""
将消息缓冲区中的文本按指定正则表达式分割后发送至消息平台,作为不支持流式输出平台的Fallback。
"""
while True:
match = re.search(pattern, buffer)
if not match:
break
matched_text = match.group()
await self.send(MessageChain([Plain(matched_text)]))
buffer = buffer[match.end() :]
await asyncio.sleep(1.5) # 限速
return buffer
async def send_streaming(
self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False
):
"""发送流式消息到消息平台,使用异步生成器。
目前仅支持: telegramqq official 私聊。
Fallback仅支持 aiocqhttp, gewechat。
"""
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
@@ -384,8 +404,13 @@ class AstrMessageEvent(abc.ABC):
Args:
message (MessageChain): 消息链,具体使用方式请参考文档。
"""
# Leverage BLAKE2 hash function to generate a non-reversible hash of the sender ID for privacy.
hash_obj = hashlib.blake2b(self.get_sender_id().encode("utf-8"), digest_size=16)
sid = str(uuid.UUID(bytes=hash_obj.digest()))
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
Metric.upload(
msg_event_tick=1, adapter_name=self.platform_meta.name, sid=sid
)
)
self._has_send_oper = True
@@ -1,9 +1,10 @@
import asyncio
import typing
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import Group, MessageMember
from astrbot.api.message_components import Plain, Image, Record, At, Node, Nodes
import re
from typing import AsyncGenerator, Dict, List
from aiocqhttp import CQHttp
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record
from astrbot.api.platform import Group, MessageMember
class AiocqhttpMessageEvent(AstrMessageEvent):
@@ -29,7 +30,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
# convert to base64
bs64 = await segment.convert_to_base64()
d["data"] = {
"file": bs64,
"file": f"base64://{bs64}",
}
elif isinstance(segment, At):
d["data"] = {
@@ -82,18 +83,39 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
await super().send(message)
async def send_streaming(self, generator):
buffer = None
async for chain in generator:
async def send_streaming(
self, generator: AsyncGenerator, use_fallback: bool = False
):
if not use_fallback:
buffer = None
async for chain in generator:
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
return
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator)
return
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
buffer = ""
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
async for chain in generator:
if isinstance(chain, MessageChain):
for comp in chain.chain:
if isinstance(comp, Plain):
buffer += comp.text
if any(p in buffer for p in "。?!~…"):
buffer = await self.process_buffer(buffer, pattern)
else:
await self.send(MessageChain(chain=[comp]))
await asyncio.sleep(1.5) # 限速
if buffer.strip():
await self.send(MessageChain([Plain(buffer)]))
return await super().send_streaming(generator, use_fallback)
async def get_group(self, group_id=None, **kwargs):
if isinstance(group_id, str) and group_id.isdigit():
@@ -108,7 +130,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
group_id=group_id,
)
members: typing.List[typing.Dict] = await self.bot.call_action(
members: List[Dict] = await self.bot.call_action(
"get_group_member_list",
group_id=group_id,
)
@@ -61,7 +61,7 @@ class DingtalkMessageEvent(AstrMessageEvent):
await self.send_with_client(self.client, message)
await super().send(message)
async def send_streaming(self, generator):
async def send_streaming(self, generator, use_fallback: bool = False):
buffer = None
async for chain in generator:
if not buffer:
@@ -72,4 +72,4 @@ class DingtalkMessageEvent(AstrMessageEvent):
return
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator)
return await super().send_streaming(generator, use_fallback)
@@ -1,8 +1,11 @@
import asyncio
import re
import wave
import uuid
import traceback
import os
from typing import AsyncGenerator
from astrbot.core.utils.io import save_temp_img, download_file
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
from astrbot.api import logger
@@ -217,15 +220,36 @@ class GewechatPlatformEvent(AstrMessageEvent):
members=members,
)
async def send_streaming(self, generator):
buffer = None
async for chain in generator:
async def send_streaming(
self, generator: AsyncGenerator, use_fallback: bool = False
):
if not use_fallback:
buffer = None
async for chain in generator:
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
return
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator)
return
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
buffer = ""
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
async for chain in generator:
if isinstance(chain, MessageChain):
for comp in chain.chain:
if isinstance(comp, Plain):
buffer += comp.text
if any(p in buffer for p in "。?!~…"):
buffer = await self.process_buffer(buffer, pattern)
else:
await self.send(MessageChain(chain=[comp]))
await asyncio.sleep(1.5) # 限速
if buffer.strip():
await self.send(MessageChain([Plain(buffer)]))
return await super().send_streaming(generator, use_fallback)
@@ -67,7 +67,10 @@ class GewechatPlatformAdapter(Platform):
async def terminate(self):
self.client.shutdown_event.set()
await self.client.server.shutdown()
try:
await self.client.server.shutdown()
except Exception as _:
pass
logger.info("Gewechat 适配器已被优雅地关闭。")
async def logout(self):
@@ -1,6 +1,8 @@
import json
import uuid
import base64
import lark_oapi as lark
from io import BytesIO
from typing import List
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image as AstrBotImage, At
@@ -27,22 +29,32 @@ class LarkMessageEvent(AstrMessageEvent):
_stage.append({"tag": "at", "user_id": comp.qq, "style": []})
elif isinstance(comp, AstrBotImage):
file_path = ""
image_file = None
if comp.file and comp.file.startswith("file:///"):
file_path = comp.file.replace("file:///", "")
elif comp.file and comp.file.startswith("http"):
image_file_path = await download_image_by_url(comp.file)
file_path = image_file_path
elif comp.file and comp.file.startswith("base64://"):
pass
base64_str = comp.file.removeprefix("base64://")
image_data = base64.b64decode(base64_str)
# save as temp file
file_path = f"data/temp/{uuid.uuid4()}_test.jpg"
with open(file_path, "wb") as f:
f.write(BytesIO(image_data).getvalue())
else:
file_path = comp.file
if image_file is None:
image_file = open(file_path, "rb")
request = (
CreateImageRequest.builder()
.request_body(
CreateImageRequestBody.builder()
.image_type("message")
.image(open(file_path, "rb"))
.image(image_file)
.build()
)
.build()
@@ -51,7 +63,7 @@ class LarkMessageEvent(AstrMessageEvent):
if not response.success():
logger.error(f"无法上传飞书图片({response.code}): {response.msg}")
image_key = response.data.image_key
print(image_key)
logger.debug(image_key)
ret.append(_stage)
ret.append([{"tag": "img", "image_key": image_key}])
_stage.clear()
@@ -92,7 +104,7 @@ class LarkMessageEvent(AstrMessageEvent):
await super().send(message)
async def send_streaming(self, generator):
async def send_streaming(self, generator, use_fallback: bool = False):
buffer = None
async for chain in generator:
if not buffer:
@@ -103,4 +115,4 @@ class LarkMessageEvent(AstrMessageEvent):
return
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator)
return await super().send_streaming(generator, use_fallback)
@@ -33,7 +33,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
else:
self.send_buffer.chain.extend(message.chain)
async def send_streaming(self, generator):
async def send_streaming(self, generator, use_fallback: bool = False):
"""流式输出仅支持消息列表私聊"""
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
last_edit_time = 0 # 上次编辑消息的时间
@@ -66,7 +66,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
self.send_buffer = None
return await super().send_streaming(generator)
return await super().send_streaming(generator, use_fallback)
async def _post_send(self, stream: dict = None):
if not self.send_buffer:
@@ -97,7 +97,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"msg_id": self.message_obj.message_id,
}
if not isinstance(source, (botpy.message.Message,botpy.message.DirectMessage)):
if not isinstance(source, (botpy.message.Message, botpy.message.DirectMessage)):
payload["msg_seq"] = random.randint(1, 10000)
match type(source):
@@ -1,4 +1,5 @@
import asyncio
import re
import sys
import uuid
@@ -118,8 +119,6 @@ class TelegramPlatformAdapter(Platform):
if commands:
await self.client.set_my_commands(commands)
for cmd in commands:
logger.debug(f"已注册指令: /{cmd.command} - {cmd.description}")
except Exception as e:
logger.error(f"向 Telegram 注册指令时发生错误: {e!s}")
@@ -167,6 +166,10 @@ class TelegramPlatformAdapter(Platform):
if not cmd_name or cmd_name in skip_commands:
return None
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
logger.debug(f"跳过无法注册的命令: {cmd_name}")
return None
# Build description.
description = handler_metadata.desc or (
f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}"
@@ -91,7 +91,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
await self.send_with_client(self.client, message, self.get_sender_id())
await super().send(message)
async def send_streaming(self, generator):
async def send_streaming(self, generator, use_fallback: bool = False):
message_thread_id = None
if self.get_message_type() == MessageType.GROUP_MESSAGE:
@@ -183,16 +183,14 @@ class TelegramPlatformEvent(AstrMessageEvent):
text=markdown_text,
chat_id=payload["chat_id"],
message_id=message_id,
parse_mode="MarkdownV2"
parse_mode="MarkdownV2",
)
except Exception as e:
logger.warning(f"Markdown转换失败,使用普通文本: {e!s}")
await self.client.edit_message_text(
text=delta,
chat_id=payload["chat_id"],
message_id=message_id
text=delta, chat_id=payload["chat_id"], message_id=message_id
)
except Exception as e:
logger.warning(f"编辑消息失败(streaming): {e!s}")
return await super().send_streaming(generator)
return await super().send_streaming(generator, use_fallback)
@@ -21,7 +21,7 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{"type": "end", "data": "", "streaming": False}
)
return
return ""
cid = session_id.split("!")[-1]
data = ""
@@ -106,7 +106,7 @@ class WebChatMessageEvent(AstrMessageEvent):
)
await super().send(message)
async def send_streaming(self, generator):
async def send_streaming(self, generator, use_fallback: bool = False):
final_data = ""
async for chain in generator:
final_data += await WebChatMessageEvent._send(
@@ -121,4 +121,4 @@ class WebChatMessageEvent(AstrMessageEvent):
"cid": self.session_id.split("!")[-1],
}
)
await super().send_streaming(generator)
await super().send_streaming(generator, use_fallback)
@@ -237,5 +237,8 @@ class WecomPlatformAdapter(Platform):
async def terminate(self):
self.server.shutdown_event.set()
await self.server.server.shutdown()
try:
await self.server.server.shutdown()
except Exception as _:
pass
logger.info("企业微信 适配器已被优雅地关闭")
@@ -1,4 +1,5 @@
import uuid
import asyncio
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Record
@@ -33,14 +34,54 @@ class WecomPlatformEvent(AstrMessageEvent):
):
pass
async def split_plain(self, plain: str) -> list[str]:
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
Args:
plain (str): 要分割的长文本
Returns:
list[str]: 分割后的文本列表
"""
if len(plain) <= 2048:
return [plain]
else:
result = []
start = 0
while start < len(plain):
# 剩下的字符串长度<2048时结束
if start + 2048 >= len(plain):
result.append(plain[start:])
break
# 向前搜索分割标点符号
end = min(start + 2048, len(plain))
cut_position = end
for i in range(end, start, -1):
if i < len(plain) and plain[i-1] in ["", "", "", ".", "!", "?", "\n", ";", ""]:
cut_position = i
break
# 没找到合适的位置分割, 直接切分
if cut_position == end and end < len(plain):
cut_position = end
result.append(plain[start:cut_position])
start = cut_position
return result
async def send(self, message: MessageChain):
message_obj = self.message_obj
for comp in message.chain:
if isinstance(comp, Plain):
self.client.message.send_text(
message_obj.self_id, message_obj.session_id, comp.text
)
# Split long text messages if needed
plain_chunks = await self.split_plain(comp.text)
for chunk in plain_chunks:
self.client.message.send_text(
message_obj.self_id, message_obj.session_id, chunk
)
await asyncio.sleep(0.5) # Avoid sending too fast
elif isinstance(comp, Image):
img_path = await comp.convert_to_file_path()
@@ -85,7 +126,7 @@ class WecomPlatformEvent(AstrMessageEvent):
await super().send(message)
async def send_streaming(self, generator):
async def send_streaming(self, generator, use_fallback: bool = False):
buffer = None
async for chain in generator:
if not buffer:
@@ -96,4 +137,4 @@ class WecomPlatformEvent(AstrMessageEvent):
return
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator)
return await super().send_streaming(generator, use_fallback)
+1 -1
View File
@@ -155,7 +155,7 @@ class ProviderRequest:
if self.image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": self.prompt}],
"content": [{"type": "text", "text": self.prompt if self.prompt else "[图片]"}],
}
for image_url in self.image_urls:
if image_url.startswith("http"):
+65 -16
View File
@@ -4,15 +4,18 @@ import textwrap
import os
import asyncio
import copy
import logging
from typing import Dict, List, Awaitable, Literal, Any
from dataclasses import dataclass
from typing import Optional
from contextlib import AsyncExitStack
from astrbot import logger
from astrbot.core.utils.log_pipe import LogPipe
try:
import mcp
from mcp.client.sse import sse_client
except (ModuleNotFoundError, ImportError):
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
@@ -87,26 +90,58 @@ class MCPClient:
self.name = None
self.active: bool = True
self.tools: List[mcp.Tool] = []
self.server_errlogs: List[str] = []
async def connect_to_server(self, mcp_server_config: dict):
"""Connect to an MCP server
async def connect_to_server(self, mcp_server_config: dict, name: str):
"""连接到 MCP 服务器
如果 `url` 参数存在则使用 SSE 的方式连接到 MCP 服务
Args:
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
"""
cfg = mcp_server_config.copy()
cfg.pop("active", None)
server_params = mcp.StdioServerParameters(
**cfg,
)
if "mcpServers" in cfg and len(cfg["mcpServers"]) > 0:
key_0 = list(cfg["mcpServers"].keys())[0]
cfg = cfg["mcpServers"][key_0]
cfg.pop("active", None) # Remove active flag from config
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(server_params)
)
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(self.stdio, self.write)
)
if "url" in cfg:
# SSE transport method
self._streams_context = sse_client(url=cfg["url"])
streams = await self._streams_context.__aenter__()
# Create a new client session
# self.session = await self._session_context.__aenter__()
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(*streams)
)
else:
server_params = mcp.StdioServerParameters(
**cfg,
)
def callback(msg: str):
# 处理 MCP 服务的错误日志
self.server_errlogs.append(msg)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(
server_params,
errlog=LogPipe(
level=logging.ERROR,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
),
),
)
# Create a new client session
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(*stdio_transport)
)
await self.session.initialize()
@@ -260,6 +295,13 @@ class FuncCall:
if data["name"] in self.mcp_client_event:
self.mcp_client_event[data["name"]].set()
self.mcp_client_event.pop(data["name"], None)
self.func_list = [
f
for f in self.func_list
if not (
f.origin == "mcp" and f.mcp_server_name == data["name"]
)
]
else:
for name in self.mcp_client_dict.keys():
# await self._terminate_mcp_client(name)
@@ -267,6 +309,7 @@ class FuncCall:
if name in self.mcp_client_event:
self.mcp_client_event[name].set()
self.mcp_client_event.pop(name, None)
self.func_list = [f for f in self.func_list if f.origin != "mcp"]
async def _init_mcp_client_task_wrapper(
self, name: str, cfg: dict, event: asyncio.Event
@@ -278,6 +321,9 @@ class FuncCall:
logger.info(f"收到 MCP 客户端 {name} 终止信号")
await self._terminate_mcp_client(name)
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
async def _init_mcp_client(self, name: str, config: dict) -> None:
@@ -289,10 +335,10 @@ class FuncCall:
mcp_client = MCPClient()
mcp_client.name = name
await mcp_client.connect_to_server(config)
self.mcp_client_dict[name] = mcp_client
await mcp_client.connect_to_server(config, name)
tools_res = await mcp_client.list_tools_and_save()
tool_names = [tool.name for tool in tools_res.tools]
self.mcp_client_dict[name] = mcp_client
# 移除该MCP服务之前的工具(如有)
self.func_list = [
@@ -316,6 +362,9 @@ class FuncCall:
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
return True
except Exception as e:
import traceback
logger.error(traceback.format_exc())
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
# 发生错误时确保客户端被清理
if name in self.mcp_client_dict:
@@ -339,7 +388,7 @@ class FuncCall:
]
logger.info(f"已关闭 MCP 服务 {name}")
def get_func_desc_openai_style(self, omit_empty_parameter_field = True) -> list:
def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list:
"""
获得 OpenAI API 风格的**已经激活**的工具描述
"""
@@ -21,16 +21,15 @@ class ProviderDashscopeTTSAPI(TTSProvider):
self.voice: str = provider_config.get("dashscope_tts_voice", "loongstella")
self.set_model(provider_config.get("model", None))
self.timeout_ms = float(provider_config.get("timeout", 20)) * 1000
dashscope.api_key = self.chosen_api_key
async def get_audio(self, text: str) -> str:
path = f"data/temp/dashscope_tts_{uuid.uuid4()}.wav"
self.synthesizer = SpeechSynthesizer(
model=self.get_model(),
voice=self.voice,
format=AudioFormat.WAV_24000HZ_MONO_16BIT,
)
async def get_audio(self, text: str) -> str:
path = f"data/temp/dashscope_tts_{uuid.uuid4()}.wav"
audio = await asyncio.get_event_loop().run_in_executor(
None, self.synthesizer.call, text, self.timeout_ms
)
+457 -316
View File
@@ -1,121 +1,55 @@
import base64
import aiohttp
import json
import random
import asyncio
import base64
import json
import logging
import random
from typing import Dict, List, Optional
from collections.abc import AsyncGenerator
from google import genai
from google.genai import types
from google.genai.errors import APIError
import astrbot.core.message.components as Comp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.db import BaseDatabase
from astrbot.api.provider import Provider, Personality
from astrbot import logger
from astrbot.core.provider.func_tool_manager import FuncCall
from typing import List
from ..register import register_provider_adapter
from astrbot.api.provider import Personality, Provider
from astrbot.core.db import BaseDatabase
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.utils.io import download_image_by_url
from ..register import register_provider_adapter
class SimpleGoogleGenAIClient:
def __init__(self, api_key: str, api_base: str, timeout: int = 120) -> None:
self.api_key = api_key
if api_base.endswith("/"):
self.api_base = api_base[:-1]
else:
self.api_base = api_base
self.client = aiohttp.ClientSession(trust_env=True)
self.timeout = timeout
class SuppressNonTextPartsWarning(logging.Filter):
"""过滤 Gemini SDK 中的非文本部分警告"""
async def models_list(self) -> List[str]:
request_url = f"{self.api_base}/v1beta/models?key={self.api_key}"
async with self.client.get(request_url, timeout=self.timeout) as resp:
response = await resp.json()
def filter(self, record):
return "there are non-text parts in the response" not in record.getMessage()
models = []
for model in response["models"]:
if "generateContent" in model["supportedGenerationMethods"]:
models.append(model["name"].replace("models/", ""))
return models
async def generate_content(
self,
contents: List[dict],
model: str = "gemini-1.5-flash",
system_instruction: str = "",
tools: dict = None,
modalities: List[str] = ["Text"],
safety_settings: List[dict] = [],
):
payload = {}
if system_instruction:
payload["system_instruction"] = {"parts": {"text": system_instruction}}
if tools:
payload["tools"] = [tools]
payload["contents"] = contents
payload["generationConfig"] = {
"responseModalities": modalities,
}
payload["safetySettings"] = [
{"category": s["category"], "threshold": s["threshold"]}
for s in safety_settings
]
logger.debug(f"payload: {payload}")
request_url = (
f"{self.api_base}/v1beta/models/{model}:generateContent?key={self.api_key}"
)
async with self.client.post(
request_url, json=payload, timeout=self.timeout
) as resp:
if "application/json" in resp.headers.get("Content-Type"):
try:
response = await resp.json()
except Exception as e:
text = await resp.text()
logger.error(f"Gemini 返回了非 json 数据: {text}")
raise e
return response
else:
text = await resp.text()
logger.error(f"Gemini 返回了非 json 数据: {text}")
raise Exception("Gemini 返回了非 json 数据: ")
logging.getLogger("google_genai.types").addFilter(SuppressNonTextPartsWarning())
async def stream_generate_content(
self,
contents: List[dict],
model: str = "gemini-1.5-flash",
system_instruction: str = "",
tools: dict = None,
modalities: List[str] = ["Text"],
safety_settings: List[dict] = [],
):
payload = {}
if system_instruction:
payload["system_instruction"] = {"parts": {"text": system_instruction}}
if tools:
payload["tools"] = [tools]
payload["contents"] = contents
payload["generationConfig"] = {
"responseModalities": modalities,
"stream": True,
}
payload["safetySettings"] = [
{"category": s["category"], "threshold": s["threshold"]}
for s in safety_settings
]
logger.debug(f"payload: {payload}")
request_url = (
f"{self.api_base}/v1beta/models/{model}:streamGenerateContent?key={self.api_key}"
)
async with self.client.post(
request_url, json=payload, timeout=self.timeout
) as resp:
async for line in resp.content:
if line:
yield line
@register_provider_adapter(
"googlegenai_chat_completion", "Google Gemini Chat Completion 提供商适配器"
)
class ProviderGoogleGenAI(Provider):
CATEGORY_MAPPING = {
"harassment": types.HarmCategory.HARM_CATEGORY_HARASSMENT,
"hate_speech": types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
"sexually_explicit": types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
"dangerous_content": types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
}
THRESHOLD_MAPPING = {
"BLOCK_NONE": types.HarmBlockThreshold.BLOCK_NONE,
"BLOCK_ONLY_HIGH": types.HarmBlockThreshold.BLOCK_ONLY_HIGH,
"BLOCK_MEDIUM_AND_ABOVE": types.HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
"BLOCK_LOW_AND_ABOVE": types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
}
def __init__(
self,
provider_config: dict,
@@ -131,183 +65,384 @@ class ProviderGoogleGenAI(Provider):
db_helper,
default_persona,
)
self.chosen_api_key = None
self.api_keys: List = provider_config.get("key", [])
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None
self.timeout = provider_config.get("timeout", 180)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.client = SimpleGoogleGenAIClient(
api_key=self.chosen_api_key,
api_base=provider_config.get("api_base", None),
timeout=self.timeout,
)
self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else None
self.timeout: int = int(provider_config.get("timeout", 180))
self.api_base: Optional[str] = provider_config.get("api_base", None)
if self.api_base and self.api_base.endswith("/"):
self.api_base = self.api_base[:-1]
self._init_client()
self.set_model(provider_config["model_config"]["model"])
self._init_safety_settings()
safety_mapping = {
"harassment": "HARM_CATEGORY_HARASSMENT",
"hate_speech": "HARM_CATEGORY_HATE_SPEECH",
"sexually_explicit": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"dangerous_content": "HARM_CATEGORY_DANGEROUS_CONTENT",
}
def _init_client(self) -> None:
"""初始化Gemini客户端"""
self.client = genai.Client(
api_key=self.chosen_api_key,
http_options=types.HttpOptions(
base_url=self.api_base,
timeout=self.timeout * 1000, # 毫秒
),
).aio
self.safety_settings = []
def _init_safety_settings(self) -> None:
"""初始化安全设置"""
user_safety_config = self.provider_config.get("gm_safety_settings", {})
for config_key, harm_category in safety_mapping.items():
if threshold := user_safety_config.get(config_key):
self.safety_settings.append(
{"category": harm_category, "threshold": threshold}
self.safety_settings = [
types.SafetySetting(
category=harm_category, threshold=self.THRESHOLD_MAPPING[threshold_str]
)
for config_key, harm_category in self.CATEGORY_MAPPING.items()
if (threshold_str := user_safety_config.get(config_key))
and threshold_str in self.THRESHOLD_MAPPING
]
async def _handle_api_error(self, e: APIError, keys: List[str]) -> bool:
"""处理API错误,返回是否需要重试"""
if e.code == 429 or "API key not valid" in e.message:
keys.remove(self.chosen_api_key)
if len(keys) > 0:
self.set_key(random.choice(keys))
logger.info(
f"检测到 Key 异常({e.message}),正在尝试更换 API Key 重试... 当前 Key: {self.chosen_api_key[:12]}..."
)
await asyncio.sleep(1)
return True
else:
logger.error(
f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}..."
)
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
else:
logger.error(
f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}"
)
raise e
async def _prepare_query_config(
self,
payloads: dict,
tools: Optional[FuncCall] = None,
system_instruction: Optional[str] = None,
modalities: Optional[List[str]] = None,
temperature: float = 0.7,
) -> types.GenerateContentConfig:
"""准备查询配置"""
if not modalities:
modalities = ["Text"]
# 流式输出不支持图片模态
if (
self.provider_settings.get("streaming_response", False)
and "Image" in modalities
):
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
modalities = ["Text"]
tool_list = None
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
native_search = self.provider_config.get("gm_native_search", False)
if native_coderunner:
tool_list = [types.Tool(code_execution=types.ToolCodeExecution())]
if native_search:
logger.warning("已启用代码执行工具,搜索工具将被忽略")
if tools:
logger.warning("已启用代码执行工具,函数工具将被忽略")
elif native_search:
tool_list = [types.Tool(google_search=types.GoogleSearch())]
if tools:
logger.warning("已启用搜索工具,函数工具将被忽略")
elif tools and (func_desc := tools.get_func_desc_google_genai_style()):
tool_list = [
types.Tool(function_declarations=func_desc["function_declarations"])
]
return types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=temperature,
max_output_tokens=payloads.get("max_tokens") or payloads.get("maxOutputTokens"),
top_p=payloads.get("top_p") or payloads.get("topP"),
top_k=payloads.get("top_k") or payloads.get("topK"),
frequency_penalty=payloads.get("frequency_penalty") or payloads.get("frequencyPenalty"),
presence_penalty=payloads.get("presence_penalty") or payloads.get("presencePenalty"),
stop_sequences=payloads.get("stop") or payloads.get("stopSequences"),
response_logprobs=payloads.get("response_logprobs") or payloads.get("responseLogprobs"),
logprobs=payloads.get("logprobs"),
seed=payloads.get("seed"),
response_modalities=modalities,
tools=tool_list,
safety_settings=self.safety_settings if self.safety_settings else None,
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True
),
)
def _prepare_conversation(self, payloads: Dict) -> List[types.Content]:
"""准备 Gemini SDK 的 Content 列表"""
def create_text_part(text: str) -> types.UserContent:
content_a = text if text else " "
if not text:
logger.warning("文本内容为空,已添加空格占位")
return types.UserContent(parts=[types.Part.from_text(text=content_a)])
def process_image_url(image_url_dict: dict) -> types.Part:
url = image_url_dict["url"]
mime_type = url.split(":")[1].split(";")[0]
image_bytes = base64.b64decode(url.split(",", 1)[1])
return types.Part.from_bytes(data=image_bytes, mime_type=mime_type)
gemini_contents: List[types.Content] = []
native_tool_enabled = any(
[
self.provider_config.get("gm_native_coderunner", False),
self.provider_config.get("gm_native_search", False),
]
)
for message in payloads["messages"]:
role, content = message["role"], message.get("content")
if role == "user":
if isinstance(content, str):
gemini_contents.append(create_text_part(content))
elif isinstance(content, list):
parts = [
types.Part.from_text(text=item["text"] or " ")
if item["type"] == "text"
else process_image_url(item["image_url"])
for item in content
]
gemini_contents.append(types.UserContent(parts=parts))
elif role == "assistant":
if content:
gemini_contents.append(
types.ModelContent(parts=[types.Part.from_text(text=content)])
)
elif "tool_calls" in message and not native_tool_enabled:
gemini_contents.extend(
[
types.ModelContent(
parts=[
types.Part.from_function_call(
name=tool["function"]["name"],
args=json.loads(tool["function"]["arguments"]),
)
]
)
for tool in message["tool_calls"]
]
)
else:
logger.warning("assistant 角色的消息内容为空,已添加空格占位")
if native_tool_enabled:
logger.warning(
"检测到启用Gemini原生工具,且上下文中存在函数调用,建议使用 /reset 重置上下文"
)
gemini_contents.append(
types.ModelContent(parts=[types.Part.from_text(text=" ")])
)
elif role == "tool" and not native_tool_enabled:
gemini_contents.append(
types.UserContent(
parts=[
types.Part.from_function_response(
name=message["tool_call_id"],
response={
"name": message["tool_call_id"],
"content": message["content"],
},
)
]
)
)
async def get_models(self):
return await self.client.models_list()
return gemini_contents
async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
tool = None
if tools:
tool = tools.get_func_desc_google_genai_style()
if not tool:
tool = None
@staticmethod
def _process_content_parts(
result: types.GenerateContentResponse, llm_response: LLMResponse
) -> MessageChain:
"""处理内容部分并构建消息链"""
finish_reason = result.candidates[0].finish_reason
result_parts: Optional[types.Part] = result.candidates[0].content.parts
if finish_reason == types.FinishReason.SAFETY:
raise Exception("模型生成内容未通过用户定义的内容安全检查")
if finish_reason in {
types.FinishReason.PROHIBITED_CONTENT,
types.FinishReason.SPII,
types.FinishReason.BLOCKLIST,
}:
raise Exception("模型生成内容违反Gemini平台政策")
# 防止旧版本SDK不存在IMAGE_SAFETY
if hasattr(types.FinishReason, "IMAGE_SAFETY"):
if finish_reason == types.FinishReason.IMAGE_SAFETY:
raise Exception("模型生成内容违反Gemini平台政策")
if not result_parts:
logger.debug(result.candidates)
raise Exception("API 返回的内容为空。")
chain = []
part: types.Part
# 暂时这样Fallback
if all(
part.inline_data and part.inline_data.mime_type.startswith("image/")
for part in result_parts
):
chain.append(Comp.Plain("这是图片"))
for part in result_parts:
if part.text:
chain.append(Comp.Plain(part.text))
elif part.function_call:
llm_response.role = "tool"
llm_response.tools_call_name.append(part.function_call.name)
llm_response.tools_call_args.append(part.function_call.args)
# gemini 返回的 function_call.id 可能为 None
llm_response.tools_call_ids.append(
part.function_call.id or part.function_call.name
)
elif part.inline_data and part.inline_data.mime_type.startswith("image/"):
chain.append(Comp.Image.fromBytes(part.inline_data.data))
return MessageChain(chain=chain)
async def _query(
self, payloads: dict, tools: FuncCall
) -> LLMResponse:
"""非流式请求 Gemini API"""
system_instruction = next(
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
None,
)
modalities = ["Text"]
if self.provider_config.get("gm_resp_image_modal", False):
modalities.append("Image")
conversation = self._prepare_conversation(payloads)
temperature=payloads.get("temperature", 0.7)
result: Optional[types.GenerateContentResponse] = None
while True:
try:
config = await self._prepare_query_config(
payloads, tools, system_instruction, modalities, temperature
)
result = await self.client.models.generate_content(
model=self.get_model(),
contents=conversation,
config=config,
)
if result.candidates[0].finish_reason == types.FinishReason.RECITATION:
if temperature > 2:
raise Exception("温度参数已超过最大值2,仍然发生recitation")
temperature += 0.2
logger.warning(
f"发生了recitation,正在提高温度至{temperature:.1f}重试..."
)
continue
system_instruction = ""
for message in payloads["messages"]:
if message["role"] == "system":
system_instruction = message["content"]
break
google_genai_conversation = []
for message in payloads["messages"]:
if message["role"] == "user":
if isinstance(message["content"], str):
if not message["content"]:
message["content"] = " "
google_genai_conversation.append(
{"role": "user", "parts": [{"text": message["content"]}]}
except APIError as e:
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)"
)
elif isinstance(message["content"], list):
# images
parts = []
for part in message["content"]:
if part["type"] == "text":
if not part["text"]:
part["text"] = ""
parts.append({"text": part["text"]})
elif part["type"] == "image_url":
parts.append(
{
"inline_data": {
"mime_type": "image/jpeg",
"data": part["image_url"]["url"].replace(
"data:image/jpeg;base64,", ""
), # base64
}
}
)
google_genai_conversation.append({"role": "user", "parts": parts})
elif message["role"] == "assistant":
if "content" in message:
if not message["content"]:
message["content"] = " "
google_genai_conversation.append(
{"role": "model", "parts": [{"text": message["content"]}]}
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
tools = None
elif (
"Multi-modal output is not supported" in e.message
or "Model does not support the requested response modalities"
in e.message
or "only supports text output" in e.message
):
logger.warning(
f"{self.get_model()} 不支持多模态输出,降级为文本模态"
)
elif "tool_calls" in message:
# tool calls in the last turn
parts = []
for tool_call in message["tool_calls"]:
parts.append(
{
"functionCall": {
"name": tool_call["function"]["name"],
"args": json.loads(
tool_call["function"]["arguments"]
),
}
}
)
google_genai_conversation.append({"role": "model", "parts": parts})
elif message["role"] == "tool":
parts = []
parts.append(
{
"functionResponse": {
"name": message["tool_call_id"],
"response": {
"name": message["tool_call_id"],
"content": message["content"],
},
}
}
)
google_genai_conversation.append({"role": "user", "parts": parts})
modalities = ["Text"]
else:
raise
continue
logger.debug(f"google_genai_conversation: {google_genai_conversation}")
modalites = ["Text"]
if self.provider_config.get("gm_resp_image_modal", False):
modalites.append("Image")
loop = True
while loop:
loop = False
result = await self.client.generate_content(
contents=google_genai_conversation,
model=self.get_model(),
system_instruction=system_instruction,
tools=tool,
modalities=modalites,
safety_settings=self.safety_settings,
)
logger.debug(f"result: {result}")
# Developer instruction is not enabled for models/gemini-2.0-flash-exp
if "Developer instruction is not enabled" in str(result):
logger.warning(
f"{self.get_model()} 不支持 system prompt, 已自动去除, 将会影响人格设置。"
)
system_instruction = ""
loop = True
elif "Function calling is not enabled" in str(result):
logger.warning(
f"{self.get_model()} 不支持函数调用,已自动去除,不影响使用。"
)
tool = None
loop = True
elif "Multi-modal output is not supported" in str(result):
logger.warning(
f"{self.get_model()} 不支持多模态输出,降级为文本模态重新请求。"
)
modalites = ["Text"]
loop = True
elif "candidates" not in result:
raise Exception("Gemini 返回异常结果: " + str(result))
candidates = result["candidates"][0]["content"]["parts"]
llm_response = LLMResponse("assistant")
chain = []
for candidate in candidates:
if "text" in candidate:
chain.append(Comp.Plain(candidate["text"]))
elif "functionCall" in candidate:
llm_response.role = "tool"
llm_response.tools_call_args.append(candidate["functionCall"]["args"])
llm_response.tools_call_name.append(candidate["functionCall"]["name"])
llm_response.tools_call_ids.append(
candidate["functionCall"]["name"]
) # 没有 tool id
elif "inlineData" in candidate:
mime_type: str = candidate["inlineData"]["mimeType"]
if mime_type.startswith("image/"):
chain.append(Comp.Image.fromBase64(candidate["inlineData"]["data"]))
llm_response.result_chain = MessageChain(chain=chain)
llm_response.result_chain = self._process_content_parts(result, llm_response)
return llm_response
async def _query_stream(
self, payloads: dict, tools: FuncCall
) -> AsyncGenerator[LLMResponse, None]:
"""流式请求 Gemini API"""
system_instruction = next(
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
None,
)
conversation = self._prepare_conversation(payloads)
result = None
while True:
try:
config = await self._prepare_query_config(
payloads, tools, system_instruction
)
result = await self.client.models.generate_content_stream(
model=self.get_model(),
contents=conversation,
config=config,
)
break
except APIError as e:
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)"
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
tools = None
else:
raise
continue
async for chunk in result:
llm_response = LLMResponse("assistant", is_chunk=True)
if chunk.candidates[0].content.parts and any(
part.function_call for part in chunk.candidates[0].content.parts
):
llm_response = LLMResponse("assistant", is_chunk=False)
llm_response.result_chain = self._process_content_parts(
chunk, llm_response
)
yield llm_response
break
if chunk.text:
llm_response.result_chain = MessageChain(chain=[Comp.Plain(chunk.text)])
yield llm_response
if chunk.candidates[0].finish_reason:
llm_response = LLMResponse("assistant", is_chunk=False)
if not chunk.candidates[0].content.parts:
llm_response.result_chain = MessageChain(chain=[Comp.Plain(" ")])
else:
llm_response.result_chain = self._process_content_parts(
chunk, llm_response
)
yield llm_response
break
async def text_chat(
self,
prompt: str,
@@ -320,7 +455,6 @@ class ProviderGoogleGenAI(Provider):
**kwargs,
) -> LLMResponse:
new_record = await self.assemble_context(prompt, image_urls)
context_query = []
context_query = [*contexts, new_record]
if system_prompt:
context_query.insert(0, {"role": "system", "content": system_prompt})
@@ -337,82 +471,90 @@ class ProviderGoogleGenAI(Provider):
model_config["model"] = self.get_model()
payloads = {"messages": context_query, **model_config}
llm_response = None
retry = 10
keys = self.api_keys.copy()
chosen_key = random.choice(keys)
for i in range(retry):
for _ in range(retry):
try:
self.client.api_key = chosen_key
llm_response = await self._query(payloads, func_tool)
return await self._query(payloads, func_tool)
except APIError as e:
if await self._handle_api_error(e, keys):
continue
break
except Exception as e:
if "429" in str(e) or "API key not valid" in str(e):
keys.remove(chosen_key)
if len(keys) > 0:
chosen_key = random.choice(keys)
logger.info(
f"检测到 Key 异常({str(e)}),正在尝试更换 API Key 重试... 当前 Key: {chosen_key[:12]}..."
)
await asyncio.sleep(1)
continue
else:
logger.error(
f"检测到 Key 异常({str(e)}),且已没有可用的 Key。 当前 Key: {chosen_key[:12]}..."
)
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
else:
logger.error(
f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}"
)
raise e
return llm_response
async def text_chat_stream(
self,
prompt,
session_id=None,
image_urls=...,
func_tool=None,
contexts=...,
prompt: str,
session_id: str = None,
image_urls: List[str] = [],
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")
# 调用 text_chat 模拟流式
llm_response = await self.text_chat(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
)
llm_response.is_chunk = True
yield llm_response
llm_response.is_chunk = False
yield llm_response
) -> AsyncGenerator[LLMResponse, None]:
new_record = await self.assemble_context(prompt, image_urls)
context_query = [*contexts, new_record]
if system_prompt:
context_query.insert(0, {"role": "system", "content": system_prompt})
for part in context_query:
if "_no_save" in part:
del part["_no_save"]
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
payloads = {"messages": context_query, **model_config}
retry = 10
keys = self.api_keys.copy()
for _ in range(retry):
try:
async for response in self._query_stream(payloads, func_tool):
yield response
break
except APIError as e:
if await self._handle_api_error(e, keys):
continue
break
async def get_models(self):
try:
models = await self.client.models.list()
return [
m.name.replace("models/", "")
for m in models
if "generateContent" in m.supported_actions
]
except APIError as e:
raise Exception(f"获取模型列表失败: {e.message}")
def get_current_key(self) -> str:
return self.client.api_key
return self.chosen_api_key
def get_keys(self) -> List[str]:
return self.api_keys
def set_key(self, key):
self.client.api_key = key
self.chosen_api_key = key
self._init_client()
async def assemble_context(self, text: str, image_urls: List[str] = None):
"""
组装上下文
"""
if image_urls:
user_content = {"role": "user", "content": [{"type": "text", "text": text}]}
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
@@ -444,5 +586,4 @@ class ProviderGoogleGenAI(Provider):
return ""
async def terminate(self):
await self.client.client.close()
logger.info("Google GenAI 适配器已终止。")
@@ -87,7 +87,8 @@ class ProviderOpenAIOfficial(Provider):
async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
if tools:
omit_empty_param_field = "grok" not in payloads.get("model", "").lower()
model = payloads.get("model", "").lower()
omit_empty_param_field = "gemini" in model
tool_list = tools.get_func_desc_openai_style(
omit_empty_parameter_field=omit_empty_param_field
)
@@ -124,7 +125,8 @@ class ProviderOpenAIOfficial(Provider):
) -> AsyncGenerator[LLMResponse, None]:
"""流式查询API,逐步返回结果"""
if tools:
omit_empty_param_field = "grok" not in payloads.get("model", "").lower()
model = payloads.get("model", "").lower()
omit_empty_param_field = "gemini" in model
tool_list = tools.get_func_desc_openai_style(
omit_empty_parameter_field=omit_empty_param_field
)
@@ -503,7 +505,7 @@ class ProviderOpenAIOfficial(Provider):
async def assemble_context(self, text: str, image_urls: List[str] = None) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
if image_urls:
user_content = {"role": "user", "content": [{"type": "text", "text": text}]}
user_content = {"role": "user", "content": [{"type": "text", "text": text if text else "[图片]"}]}
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
+1 -1
View File
@@ -28,7 +28,7 @@ from .filter.permission import PermissionTypeFilter, PermissionType
class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig):
self.updator = PluginUpdator(config["plugin_repo_mirror"])
self.updator = PluginUpdator()
self.context = context
self.context._star_manager = self
+36
View File
@@ -0,0 +1,36 @@
import threading
import os
from logging import Logger
class LogPipe(threading.Thread):
def __init__(
self,
level,
logger: Logger,
identifier=None,
callback=None,
):
threading.Thread.__init__(self)
self.daemon = True
self.level = level
self.fd_read, self.fd_write = os.pipe()
self.identifier = identifier
self.logger = logger
self.callback = callback
self.reader = os.fdopen(self.fd_read)
self.start()
def fileno(self):
return self.fd_write
def run(self):
for line in iter(self.reader.readline, ""):
if self.callback:
self.callback(line.strip())
self.logger.log(self.level, f"[{self.identifier}] {line.strip()}")
self.reader.close()
def close(self):
os.close(self.fd_write)
+40
View File
@@ -1,10 +1,42 @@
import aiohttp
import sys
import os
import socket
import uuid
from astrbot.core.config import VERSION
from astrbot.core import db_helper, logger
class Metric:
_iid_cache = None
@staticmethod
def get_installation_id():
"""获取或创建一个唯一的安装ID"""
if Metric._iid_cache is not None:
return Metric._iid_cache
config_dir = os.path.join(os.path.expanduser("~"), ".astrbot")
id_file = os.path.join(config_dir, ".installation_id")
if os.path.exists(id_file):
try:
with open(id_file, "r") as f:
Metric._iid_cache = f.read().strip()
return Metric._iid_cache
except Exception:
pass
try:
os.makedirs(config_dir, exist_ok=True)
installation_id = str(uuid.uuid4())
with open(id_file, "w") as f:
f.write(installation_id)
Metric._iid_cache = installation_id
return installation_id
except Exception:
Metric._iid_cache = "null"
return "null"
@staticmethod
async def upload(**kwargs):
"""
@@ -16,6 +48,14 @@ class Metric:
kwargs["v"] = VERSION
kwargs["os"] = sys.platform
payload = {"metrics_data": kwargs}
try:
kwargs["hn"] = socket.gethostname()
except Exception:
pass
try:
kwargs["iid"] = Metric.get_installation_id()
except Exception:
pass
try:
if "adapter_name" in kwargs:
db_helper.insert_platform_metrics({kwargs["adapter_name"]: 1})
+70
View File
@@ -0,0 +1,70 @@
import os
from astrbot.core import logger
def path_Mapping(mappings, srcPath: str)->str:
"""路径映射处理函数。尝试支援 Windows 和 Linux 的路径映射。
Args:
mappings: 映射规则列表
srcPath: 原路径
Returns:
str: 处理后的路径
"""
for mapping in mappings:
rule = mapping.split(":")
if len(rule) == 2:
from_, to_ = mapping.split(":")
elif len(rule) > 4 or len(rule) == 1:
# 切割后大于4个项目,或者只有1个项目,那肯定是错误的,只能是2,3,4个项目
logger.warning(f"路径映射规则错误: {mapping}")
continue
else:
# rule.len == 3 or 4
if(os.path.exists(rule[0]+":"+rule[1])):
# 前面两个项目合并路径存在,说明是本地Window路径。后面一个或两个项目组成的路径本地大概率无法解析,直接拼接
from_ = rule[0] + ":" + rule[1]
if len(rule) == 3:
to_ = rule[2]
else:
to_ = rule[2] + ":" + rule[3]
else:
# 前面两个项目合并路径不存在,说明第一个项目是本地Linux路径,后面一个或两个项目直接拼接。
from_ = rule[0]
if len(rule) == 3:
to_ = rule[1] + ":" + rule[2]
else:
# 这种情况下存在四个项目,说明规则也是错误的
logger.warning(f"路径映射规则错误: {mapping}")
continue
from_ = from_.removesuffix("/")
from_ = from_.removesuffix("\\")
to_ = to_.removesuffix("/")
to_ = to_.removesuffix("\\")
# logger.debug(f"\t路径映射-规则(处理): {from_} -> {to_}")
url = srcPath.removeprefix("file://")
if url.startswith(from_):
srcPath = url.replace(from_, to_, 1)
if ":" in srcPath:
# Windows路径处理
srcPath = srcPath.replace("/", "\\")
else:
has_replaced_processed = False
if srcPath.startswith("."):
# 相对路径处理。如果是相对路径,可能是Linux路径,也可能是Windows路径
sign = srcPath[1]
# 处理两个点的情况
if sign == ".":
sign = srcPath[2]
if sign == "/":
srcPath = srcPath.replace("\\", "/")
has_replaced_processed = True
elif sign == "\\":
srcPath = srcPath.replace("/", "\\")
has_replaced_processed = True
if has_replaced_processed == False:
# 如果不是相对路径或不能处理,默认按照Linux路径处理
srcPath = srcPath.replace("\\", "/")
logger.info(f"路径映射: {url} -> {srcPath}")
return srcPath
return srcPath
+4 -4
View File
@@ -5,8 +5,9 @@ logger = logging.getLogger("astrbot")
class PipInstaller:
def __init__(self, pip_install_arg: str):
def __init__(self, pip_install_arg: str, pypi_index_url: str = None):
self.pip_install_arg = pip_install_arg
self.pypi_index_url = pypi_index_url
def install(
self,
@@ -20,10 +21,9 @@ class PipInstaller:
elif requirements_path:
args.extend(["-r", requirements_path])
if not mirror:
mirror = "https://mirrors.aliyun.com/pypi/simple/"
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", mirror])
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
if self.pip_install_arg:
args.extend(self.pip_install_arg.split())
+2 -2
View File
@@ -97,8 +97,8 @@ class SessionFilter:
class DefaultSessionFilter(SessionFilter):
def filter(self, event: AstrMessageEvent) -> str:
"""默认实现,返回发送者的 ID 作为会话标识符"""
return event.get_sender_id()
"""默认实现,返回统一消息来源字符串作为会话标识符"""
return event.unified_msg_origin
class SessionWaiter:
+5 -2
View File
@@ -9,8 +9,11 @@ class SharedPreferences:
def _load_preferences(self):
if os.path.exists(self.path):
with open(self.path, "r") as f:
return json.load(f)
try:
with open(self.path, "r") as f:
return json.load(f)
except json.JSONDecodeError:
os.remove(self.path)
return {}
def _save_preferences(self):
+88
View File
@@ -0,0 +1,88 @@
import re
class VersionComparator:
@staticmethod
def compare_version(v1: str, v2: str) -> int:
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
参考: https://semver.org/lang/zh-CN/
返回 1 表示 v1 > v2返回 -1 表示 v1 < v2返回 0 表示 v1 = v2
"""
v1 = v1.lower().replace("v", "")
v2 = v2.lower().replace("v", "")
def split_version(version):
match = re.match(
r"^([0-9]+(?:\.[0-9]+)*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(.+))?$",
version,
)
if not match:
return [], None
major_minor_patch = match.group(1).split(".")
prerelease = match.group(2)
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
parts = [int(x) for x in major_minor_patch]
prerelease = VersionComparator._split_prerelease(prerelease)
return parts, prerelease
v1_parts, v1_prerelease = split_version(v1)
v2_parts, v2_prerelease = split_version(v2)
# 比较数字部分
length = max(len(v1_parts), len(v2_parts))
v1_parts.extend([0] * (length - len(v1_parts)))
v2_parts.extend([0] * (length - len(v2_parts)))
for i in range(length):
if v1_parts[i] > v2_parts[i]:
return 1
elif v1_parts[i] < v2_parts[i]:
return -1
# 比较预发布标签
if v1_prerelease is None and v2_prerelease is not None:
return 1 # 没有预发布标签的版本高于有预发布标签的版本
elif v1_prerelease is not None and v2_prerelease is None:
return -1 # 有预发布标签的版本低于没有预发布标签的版本
elif v1_prerelease is not None and v2_prerelease is not None:
len_pre = max(len(v1_prerelease), len(v2_prerelease))
for i in range(len_pre):
p1 = v1_prerelease[i] if i < len(v1_prerelease) else None
p2 = v2_prerelease[i] if i < len(v2_prerelease) else None
if p1 is None and p2 is not None:
return -1
elif p1 is not None and p2 is None:
return 1
elif isinstance(p1, int) and isinstance(p2, str):
return -1
elif isinstance(p1, str) and isinstance(p2, int):
return 1
elif isinstance(p1, int) and isinstance(p2, int):
if p1 > p2:
return 1
elif p1 < p2:
return -1
elif isinstance(p1, str) and isinstance(p2, str):
if p1 > p2:
return 1
elif p1 < p2:
return -1
return 0 # 预发布标签完全相同
return 0 # 数字部分和预发布标签都相同
@staticmethod
def _split_prerelease(prerelease):
if not prerelease:
return None
parts = prerelease.split(".")
result = []
for part in parts:
if part.isdigit():
result.append(int(part))
else:
result.append(part)
return result
+4 -24
View File
@@ -8,6 +8,7 @@ import certifi
from astrbot.core.utils.io import on_error, download_file
from astrbot.core import logger
from astrbot.core.utils.version_comparator import VersionComparator
class ReleaseInfo:
@@ -102,31 +103,10 @@ class RepoZipUpdator:
raise NotImplementedError()
def compare_version(self, v1: str, v2: str) -> int:
"""
比较两个版本号的大小
返回 1 表示 v1 > v2返回 -1 表示 v1 < v2返回 0 表示 v1 = v2
支持任意长度的版本号如v1.2.3或v3.5.3.1
"""
v1 = v1.replace("v", "")
v2 = v2.replace("v", "")
v1_parts = v1.split(".")
v2_parts = v2.split(".")
"""Semver 版本比较"""
return VersionComparator.compare_version(v1, v2)
# 获取最长的版本号长度
length = max(len(v1_parts), len(v2_parts))
# 将短版本号补0以便比较
v1_parts.extend(["0"] * (length - len(v1_parts)))
v2_parts.extend(["0"] * (length - len(v2_parts)))
for i in range(length):
if int(v1_parts[i]) > int(v2_parts[i]):
return 1
elif int(v1_parts[i]) < int(v2_parts[i]):
return -1
return 0
async def check_update(self, url: str, current_version: str) -> ReleaseInfo:
async def check_update(self, url: str, current_version: str) -> ReleaseInfo | None:
update_data = await self.fetch_release_info(url)
tag_name = update_data[0]["tag_name"]
+44 -6
View File
@@ -1,5 +1,6 @@
import os
import json
import aiohttp
import traceback
from .route import Route, Response, RouteContext
from quart import request
@@ -20,6 +21,7 @@ class ToolsRoute(Route):
"/tools/mcp/add": ("POST", self.add_mcp_server),
"/tools/mcp/update": ("POST", self.update_mcp_server),
"/tools/mcp/delete": ("POST", self.delete_mcp_server),
"/tools/mcp/market": ("GET", self.get_mcp_markets),
}
self.register_routes()
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
@@ -78,6 +80,7 @@ class ToolsRoute(Route):
) in self.tool_mgr.mcp_client_dict.items():
if name_key == name:
server_info["tools"] = [tool.name for tool in mcp_client.tools]
server_info["errlogs"] = mcp_client.server_errlogs
break
else:
server_info["tools"] = []
@@ -105,8 +108,14 @@ class ToolsRoute(Route):
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
server_config[key] = value
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
if key == "mcpServers":
key_0 = list(server_data["mcpServers"].keys())[
0
] # 不考虑为空的情况
server_config = server_data["mcpServers"][key_0]
else:
server_config[key] = value
has_valid_config = True
if not has_valid_config:
@@ -121,7 +130,7 @@ class ToolsRoute(Route):
if self.save_mcp_config(config):
# 动态初始化新MCP客户端
self.tool_mgr.mcp_service_queue.put_nowait(
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
@@ -162,8 +171,14 @@ class ToolsRoute(Route):
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
server_config[key] = value
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
if key == "mcpServers":
key_0 = list(server_data["mcpServers"].keys())[
0
] # 不考虑为空的情况
server_config = server_data["mcpServers"][key_0]
else:
server_config[key] = value
only_update_active = False
# 如果只更新活动状态,保留原始配置
@@ -194,7 +209,7 @@ class ToolsRoute(Route):
)
else:
# 客户端不存在,初始化
self.tool_mgr.mcp_service_queue.put_nowait(
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
@@ -250,3 +265,26 @@ class ToolsRoute(Route):
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"删除 MCP 服务器失败: {str(e)}").__dict__
async def get_mcp_markets(self):
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 10, type=int)
BASE_URL = "https://api.soulter.top/astrbot/mcpservers?page={}&page_size={}".format(
page,
page_size,
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"{BASE_URL}") as response:
if response.status == 200:
data = await response.json()
return Response().ok(data["data"]).__dict__
else:
return (
Response()
.error(f"获取市场数据失败: HTTP {response.status}")
.__dict__
)
except Exception as _:
logger.error(traceback.format_exc())
return Response().error("获取市场数据失败").__dict__
+2 -1
View File
@@ -136,10 +136,11 @@ class UpdateRoute(Route):
data = await request.json
package = data.get("package", "")
mirror = data.get("mirror", None)
if not package:
return Response().error("缺少参数 package 或不合法。").__dict__
try:
pip_installer.install(package)
pip_installer.install(package, mirror=mirror)
return Response().ok(None, "安装成功。").__dict__
except Exception as e:
logger.error(f"/api/update_pip: {traceback.format_exc()}")
+41
View File
@@ -0,0 +1,41 @@
# What's Changed
> 📢 在升级前,请完整阅读本次更新日志。
> 此版本为针对 `v3.5.3` 的紧急修复版本
> 修复以下 BUG
> 1. 智谱 GLM 在函数工具有空参数时报错的问题。
---
v3.5.3
## ✨ 新增的功能
1. Telegram、Webchat、QQ官方机器人平台(私聊)支持流式输出(实验性)。@Soulter @Raven95676 @anka-afk
2. 支持针对不同消息平台开启/关闭插件 @zhx8702 @Raven95676 @Soulter
3. 插件市场支持显示 Star 个数、插件管理支持插件帮助对话框 @kterna
4. 飞书平台支持主动消息发送 @Soulter
5. Telegram 平台适配显示指令列表,支持自动补全 @Raven95676
6. 新增配置项允许配置当超出最多携带对话数量时,一次性丢弃多少条旧消息 @Rail1bc
7. StarTool 新增获取插件数据目录接口 @Raven95676
## 🎈 功能性优化
1. 优化 /his 指令对函数调用的显示 @anka-afk
2. QQ 官方机器人支持对同一条消息多次回复 @kuangfeng
## 🐛 修复的 Bug
1. ‼️ 修复使用 gemini 时,函数数工具调用会重复调用已经在过去会话中调用过的工具 @Soulter
2. 修复使用 Gemini 模型时出现 <empty_content> 的问题 @anka-afk
4. 修复使用 OneAPI + Gemini(openai) 传递空参数函数工具时可能报错的问题 @Soulter
5. 修复 permission 过滤算子的 raise_error 参数失效的问题 @Soulter
6. 修复函数调用时可能出现 `messages with role 'tool' must be a response to a preceeding message with 'tool_calls'` 报错的问题 @anka-afk
7. 修复 dify 下删除对话的报错问题 @Soulter
8. 修复人格预设对话多次插入上下文的问题 @Rail1bc
9. 修复了 event.get_sender_id() 返回值与函数注释不一致的问题 @zsbai
## 🧩 新增的插件
待补充
+156
View File
@@ -0,0 +1,156 @@
# What's Changed
> 📢 在升级前,请完整阅读本次更新日志。
## ✨ 新增的功能
1. 上线 MCP 市场(beta) @Soulter
2. MCP 服务器支持通过 SSE 连接 @Soulter
3. 支持自定义 PyPI 软件仓库地址 @Soulter
4. 支持开关是否忽略自身发送的消息 @Soulter
5. Docker 镜像自带 node 环境以适应 MCP 需要 @Soulter
6. 添加对 Gemini 原生搜索功能的支持 @Raven95676
7. 企业微信添加长文本分割功能以支持发送超过 2048 字符的消息 @Soulter @anka-afk
8. TTS 支持同时输出原始文本 @YOO-koishi
## 🎈 功能性优化
1. shared_preferences加载失败时自动删除无效文件 @Raven95676
2. 适配 MCP 配置文件带 mcpServers 的情况(Cursor) @Soulter
3. 采用 google-genai SDK 重构 Gemini 适配器 @Raven95676 @Soulter
4. 优化已安装的插件页,支持以列表展示 @Soulter
5. 分段回复优化 @huirh @Raven95676
6. 优化 MCP 服务器的日志回显 @Soulter
7. 为不支持流式输出的平台提供 Fallback @Raven95676
8. 替换为采用 Semver 语义化版本来比较版本号 @Soulter
9. 文件发送时支持路径映射 @Jackxwb
## 🐛 修复的 Bug
1. 修复关闭/删除 MCP 服务器后 Tools 没有清除的问题 @Soulter
2. 修复超出最大对话数时每次清除的消息比实际上期望的多 1 条 的问题 @Raila23
3. 修复调用函数工具可能导致 400 Bad Request 的问题 @Raila23
4. 修复飞书适配器无法发送 Base64 图片的问题 @KimigaiiWuyi @Soulter
5. 修复上下文带图的情况下,对话数据库页无法查看对话详情的问题 @Soulter
6. Telegram 适配器注册指令功能优化 @Raven95676
7. 修复阿里云百炼 TTS 只能发送一次语音,第二次就会报错 @Soulter
## 🧩 新增的插件
> Automatically generated by program.
- [Plugin] 60秒国内新闻 by @bbpn-cn in #970
- [Plugin] astrbot_plugin_memelite by @Zhalslar in #977
- [Plugin] 赛博打胶 by @tenno1174 in #980
- [Plugin] astrbot_plugin_PockAttack by @LouieKH359 in #981
- [Plugin] astrbot_plugin_saris_economic by @chengcheng0325 in #984
- [Plugin] astrbot_plugin_saris_db by @chengcheng0325 in #985
- [Plugin] astrbot_plugin_today_in_history by @Zhalslar in #987
- [Plugin] astrbot_plugin_history_day by @Zhalslar in #989
- [Plugin] astrbot_plugin_nachoneko by @Rinyin in #991
- [Plugin] jmcomicsget by @Ayachi2225 in #993
- [Plugin] astrbot_plugin_idiom by @zhx8702 in #994
- [Plugin] anime_gacha by @xco2 in #997
- [Plugin] jmcomic_downloader by @QiChenSn in #1007
- [Plugin] gewe_chatsummary by @NiceAir in #1013
- [Plugin] 群CCB by @tenno1174 in #1016
- [Plugin] 自动生成图表(思维导图、流程图等) by @kterna in #1018
- [Plugin] astrbot_plugin_jm_sender by @EnderPPT in #1019
- [Plugin] 插件名random image by @IGCrystal in #1021
- [Plugin] astrbot_plugin_saris_fish by @chengcheng0325 in #1022
- [Plugin] astrbot_plugin_membercontrast by @laopanmemz in #1027
- [Plugin] jm_search by @Ryonnoski0 in #1028
- [Plugin] astrbot_plugin_gomoku by @zhx8702 in #1029
- [Plugin] astrbot_plugin_CounterStrikle by @Last-emo-boy in #1036
- [Plugin] encrypt-and-decrypt by @Soffd in #1037
- [Plugin] emoji合成 by @ttq7 in #1041
- [Plugin] astrbot_plugin_file_reader by @xiewoc in #1043
- [Plugin] vv_pic by @LonelySky7490 in #1048
- [Plugin] bot代戳 by @791819 in #1049
- [Plugin] astrbot_plugin_weather_wttr_in by @xiewoc in #1051
- [Plugin] astrbot_plugin_kahunabot by @AraragiEro in #1053
- [Plugin] astrbot_plugin_answerbook by @litsum in #1058
- [Plugin] astrbot_plugin_ewords by @IGCrystal in #1059
- [Plugin] minecraft投影管理器 by @kterna in #1064
- [Plugin] minecraft投影管理器 by @kterna in #1063
- [Plugin] astrbot_plugin_encipherer by @Soffd in #1066
- [Plugin] 定时任务提醒插件 by @advent259141 in #1068
- [Plugin] 定时任务提醒插件 by @advent259141 in #1067
- [Plugin] bot_plugin_doro_today by @Futureppo in #1071
- [Plugin] JmCli by @gaxiic in #1076
- [Plugin] 用户自定义识别nickname by @MR-pofeng in #1078
- [Plugin] astrbot_plugin_timtip by @IGCrystal in #1082
- [Plugin] y @caomeiguodong in #1083
- [Plugin] astrbot_plugin_search_pic by @lyjlyjlyjly in #1084
- [Plugin] ot_plugin_quote_collocter by @litsum in #1089
- [Plugin] astrbot_plugin_ending by @clfpwp in #1090
- [Plugin] astrbot_plugin_GPT_SoVITS by @Zhalslar in #1091
- [Plugin] astrbot_plugin_Merge_WeMSG by @zj591227045 in #1092
- [Plugin] 随机维什戴尔游戏日语语音的AstrBot插件 by @zhewang448 in #1094
- [Plugin] astrbot_plugin_QQProfile by @Zhalslar in #1095
- [Plugin] astrbot_plugin_mcping by @Zhalslar in #1097
- [Plugin] astrbot_plugin_lorebook_lite by @Raven95676 in #1098
- [Plugin] astrbot_plugin_grok_filter by @Cheng-MaoMao in #1099
- [Plugin] astrbot_plugin_quarksave by @lm379 in #1100
- [Plugin] daily_limit by @left666 in #1102
- [Plugin] 赛博塔罗牌 by @XziXmn in #1103
- [Plugin] 把QQ里面不可保存的表情转化为可以保存的插件astrbot_plugins_ConvetPicture by @orchidsziyou in #1115
- [Plugin] astrbot_plugin_get_weather_cmd by @whzcc in #1117
- [Plugin] httpposter by @Wayzinx in #1118
- [Plugin] astrbot_plugin_get_weather_msg by @whzcc in #1119
- [Plugin] astrbot_plugin_cs2-box by @bvzrays in #1124
- [Plugin] astrbot_plugin_liars_bar by @xunxiing in #1125
- [Plugin] astrbot_plugin_cs2-box by @bvzrays in #1129
- [Plugin] astrbot_plugin_no_dragon_lord by @anka-afk in #1130
- [Plugin] astrbot_plugin_liars_bar by @xunxiing in #1134
- [Plugin] astrbot_plugin_QQAdmin by @Zhalslar in #1137
- [Plugin] astrbot_plugin_SessionFaker by @advent259141 in #1138
- [Plugin] astrbot_plugin_browser by @Zhalslar in #1140
- [Plugin] astrbot_plugin_aishit by @advent259141 in #1141
- [Plugin] Arch Linux 软件包搜索插件 by @xmengnet in #1142
- [Plugin] astrbot_plugin_composting_bucket by @Rail1bc in #1147
- [Plugin] 任务管理task-management by @zengweis in #1149
- [Plugin] astrbot_plugin_appreview by @qiqi55488 in #1151
- [Plugin] doro互动故事 by @ttq7 in #1153
- [Plugin] 斗牛牛 by @LaoZhuJackson in #1155
- [Plugin] astrbot_plugin_reread by @Zhalslar in #1162
- [Plugin] astrbot_plugin_media302-save by @Qoo-330ml in #1163
- [Plugin] astrbot_plugin_ehentai_bot by @drdon1234 in #1168
- [Plugin] 追番助手(AGE by @xiamuceer-j in #1181
- [Plugin] astrbot_plugin_zanwo by @Futureppo in #1183
- [Plugin] astrbot_plugin_jrrp by @exusiaiwei in #1189
- [Plugin] astrbot_plugin_group_chatsummary by @glidersxu in #1193
- [Plugin] astrbot_plugin_showmejm by @drdon1234 in #1202
- [Plugin] astrbot_plugin_gscore_adapter by @KimigaiiWuyi in #1206
- [Plugin] astrbot_portainer_plugin by @RC-CHN in #1209
- [Plugin] astrbot_plugin_goldprice by @waterfeet in #1210
- [Plugin] astrbot_plugin_xyzw by @XuYingJie-cmd in #1213
- [Plugin] astrbot_plugin_alist by @yukikazechan in #1217
- [Plugin] astrbot_plugin_showme_xjj by @drdon1234 in #1219
- [Plugin] astrbot_plugin_60s_news by @flyinsz in #1220
- [Plugin] astrbot_plugin_mp by @EWEDLCM in #1229
- [Plugin] 浅草寺抽签插件-PRO by @xiamuceer-j in #1230
- [Plugin] astrbot_plugin_gallery by @Zhalslar in #1238
- [Plugin] astrbot_plugin_openweaponscase by @luooka in #1250
- [Plugin] 多功能插件 by @ttq7 in #1254
- [Plugin] astrbot_plugin_douyin_bot by @drdon1234 in #1255
- [Plugin] astrbot_plugin_password by @Zhalslar in #1262
- [Plugin] FavorSystem by @wuyan1003 in #1264
- [Plugin] astrbot_plugin_ExchangeRateQuery by @MoonShadow1976 in #1271
- [Plugin] astrbot_plugin_pexels by @xiamuceer-j in #1278
- [Plugin] astrbot_plugin_hello-bye by @tinkerbellqwq in #1279
- [Plugin] astrbot_plugin_gotify by @BetaCatX in #1283
- [Plugin] astrbot_plugin_ccb_plus by @Koikokokokoro in #1287
- [Plugin] astrbot-gold-plugin by @RC-CHN in #1299
- [Plugin] 幻影坦克 by @bigshabei in #1305
- [Plugin] astrbot_plugin_repeat_after_me by @0d00-Ciallo-0721 in #1306
- [Plugin] astrbot_plugin_video by @guowenye in #1307
- [Plugin] astrbot_plugin_group_sum_ai by @Ayu-u in #1308
- [Plugin] 五子棋 by @bigshabei in #1309
- [Plugin] 舔狗日记 by @bigshabei in #1310
- [Plugin] astrbot_plugin_status-pro by @tinkerbellqwq in #1312
- [Plugin] 监听/转发 by @Cedar2352 in #1322
- [Plugin] astrbot_plugin_fuck by @vmoranv in #1338
- [Plugin] 食物推荐插件 by @Wayzinx in #1331
- [Plugin] astrbot_plugin_llmgo by @advent259141 in #1332
- [Plugin] astrbot_plugin_a2s by @ZvZPvz in #1337
@@ -81,7 +81,7 @@ const viewReadme = () => {
<div class="flex-grow-1">
<div>{{ extension.author }} /</div>
<p class="text-h3 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
<p class="text-h4 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
{{ extension.name }}
<v-tooltip location="top" v-if="extension?.has_update && !marketMode">
<template v-slot:activator="{ props: tooltipProps }">
@@ -10,7 +10,7 @@
<v-row v-else>
<v-col v-for="(item, index) in items" :key="index" cols="12" md="6" lg="4" xl="3">
<v-card class="item-card hover-elevation" :color="getItemEnabled(item) ? '' : 'grey-lighten-4'">
<div class="item-status-indicator" :class="{'active': getItemEnabled(item)}"></div>
<!-- <div class="item-status-indicator" :class="{'active': getItemEnabled(item)}"></div> -->
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h4 text-truncate" :title="getItemTitle(item)">{{ getItemTitle(item) }}</span>
<v-tooltip location="top">
@@ -114,19 +114,6 @@ export default {
justify-content: space-between;
}
.item-status-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #e0e0e0;
}
.item-status-indicator.active {
background-color: #4CAF50;
}
.hover-elevation:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
+2 -2
View File
@@ -27,8 +27,8 @@ import axios from 'axios';
</v-card-title>
<v-card-text>
<v-text-field v-model="pipInstallPayload.package" label="*库名,如 llmtuner" variant="outlined"></v-text-field>
<v-text-field v-model="pipInstallPayload.mirror" label="镜像站链接(可选)" variant="outlined"></v-text-field>
<small>如果不填镜像站链接默认使用阿里云镜像https://mirrors.aliyun.com/pypi/simple/</small>
<v-text-field v-model="pipInstallPayload.mirror" label="强制 PyPI 软件仓库链接(可选)" variant="outlined"></v-text-field>
<small>强制 PyPI 软件仓库链接 > 配置项 `PyPI 软件仓库地址`</small>
<div>
<small>{{ status }}</small>
</div>
+22 -2
View File
@@ -839,10 +839,30 @@ export default {
//
formatMessage(content) {
if (!content) return '空消息';
// content
// [{"type": "image_url", "image_url": {"url": url_or_base64}}, {"type": "text", "text": "text"}]
let final_content = content;
if (Array.isArray(content)) {
//
final_content = content.map(item => {
if (item.type === 'image_url') {
return `<img src="${item.image_url.url}" alt="Image" />`;
} else if (item.type === 'text') {
return item.text;
}
return '';
}).join('\n');
} else if (typeof content === 'object') {
//
final_content = Object.values(content).join('');
} else if (typeof content === 'string') {
//
final_content = content;
} else if (!final_content) return '空消息';
// 使markedMarkdown
return marked(content);
return marked(final_content);
},
//
+347 -155
View File
@@ -50,6 +50,11 @@ const platformEnableData = reactive({
});
const loadingPlatformData = ref(false);
//
const isListView = ref(false);
const pluginSearch = ref("");
const loading_ = ref(false);
const plugin_handler_info_headers = [
{ title: '行为类型', key: 'event_type_h' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
@@ -57,11 +62,36 @@ const plugin_handler_info_headers = [
{ title: '触发方式', key: 'cmd' },
];
//
const pluginHeaders = [
{ title: '名称', key: 'name', width: '200px' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
{ title: '版本', key: 'version', width: '100px' },
{ title: '作者', key: 'author', width: '100px' },
{ title: '状态', key: 'status', width: '80px' },
{ title: '操作', key: 'actions', sortable: false, width: '220px' }
];
//
const filteredExtensions = computed(() => {
if (showReserved.value) {
return extension_data.data;
if (!showReserved.value) {
return extension_data?.data?.filter(ext => !ext.reserved) || [];
}
return extension_data?.data?.filter(ext => !ext.reserved);
return extension_data.data || [];
});
//
const filteredPlugins = computed(() => {
if (!pluginSearch.value) {
return filteredExtensions.value;
}
const search = pluginSearch.value.toLowerCase();
return filteredExtensions.value.filter(plugin => {
return plugin.name?.toLowerCase().includes(search) ||
plugin.desc?.toLowerCase().includes(search) ||
plugin.author?.toLowerCase().includes(search);
});
});
//
@@ -90,12 +120,15 @@ const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
};
const getExtensions = async () => {
loading_.value = true;
try {
const res = await axios.get('/api/plugin/get');
Object.assign(extension_data, res.data);
checkUpdate();
} catch (err) {
toast(err, "error");
} finally {
loading_.value = false;
}
};
@@ -118,7 +151,7 @@ const checkUpdate = () => {
if (matchedPlugin) {
extension.online_version = matchedPlugin.version;
extension.has_update = extension.version !== matchedPlugin.version &&
extension.has_update = extension.version !== matchedPlugin.version &&
matchedPlugin.version !== "未知";
} else {
extension.has_update = false;
@@ -150,12 +183,12 @@ const updateExtension = async (extension_name) => {
name: extension_name,
proxy: localStorage.getItem('selectedGitHubProxy') || ""
});
if (res.data.status === "error") {
onLoadingDialogResult(2, res.data.message, -1);
return;
}
Object.assign(extension_data, res.data);
onLoadingDialogResult(1, res.data.message);
} catch (err) {
@@ -198,7 +231,7 @@ const openExtensionConfig = async (extension_name) => {
const res = await axios.get('/api/config/get?plugin_name=' + extension_name);
extension_config.metadata = res.data.data.metadata;
extension_config.config = res.data.data.config;
} catch (err) {
toast(err, "error");
}
@@ -255,11 +288,11 @@ const getPlatformEnableConfig = async () => {
toast(res.data.message, "error");
return;
}
platformEnableData.platforms = res.data.data.platforms;
platformEnableData.plugins = res.data.data.plugins;
platformEnableData.platform_enable = res.data.data.platform_enable;
//
if (platformEnableData.platforms.length === 0) {
toast("未添加任何平台适配器,请先在平台管理中添加平台", "warning");
@@ -269,7 +302,7 @@ const getPlatformEnableConfig = async () => {
if (!platformEnableData.platform_enable[platform.name]) {
platformEnableData.platform_enable[platform.name] = {};
}
//
platformEnableData.plugins.forEach(plugin => {
if (platformEnableData.platform_enable[platform.name][plugin.name] === undefined) {
@@ -278,7 +311,7 @@ const getPlatformEnableConfig = async () => {
});
});
}
platformEnableDialog.value = true;
} catch (err) {
toast("获取平台插件配置失败: " + err, "error");
@@ -294,12 +327,12 @@ const savePlatformEnableConfig = async () => {
const res = await axios.post('/api/plugin/platform_enable/set', {
platform_enable: platformEnableData.platform_enable
});
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
toast(res.data.message, "success");
platformEnableDialog.value = false;
} catch (err) {
@@ -315,7 +348,7 @@ const selectAllPluginsForPlatform = (platformName, isSelected, onlyReserved = nu
if (!platformEnableData.platform_enable[platformName]) {
platformEnableData.platform_enable[platformName] = {};
}
//
platformEnableData.plugins.forEach(plugin => {
// onlyReservednull
@@ -333,7 +366,7 @@ const toggleAllPluginsForPlatform = (platformName) => {
if (!platformEnableData.platform_enable[platformName]) {
platformEnableData.platform_enable[platformName] = {};
}
//
platformEnableData.plugins.forEach(plugin => {
const currentState = platformEnableData.platform_enable[platformName][plugin.name];
@@ -344,7 +377,7 @@ const toggleAllPluginsForPlatform = (platformName) => {
//
onMounted(async () => {
await getExtensions();
try {
const data = await commonStore.getPluginCollections();
pluginMarketData.value = data;
@@ -358,147 +391,308 @@ onMounted(async () => {
<template>
<v-row>
<v-col cols="12" md="12">
<div style="background-color: white; width: 100%; padding: 16px; border-radius: 10px;">
<div style="display: flex; align-items: center;">
<h3>🧩 已安装的插件</h3>
<v-btn class="text-none ml-2" size="small" variant="flat" border @click="toggleShowReserved">
{{ showReserved ? '隐藏系统保留插件' : '显示系统保留插件' }}
</v-btn>
<v-btn class="text-none ml-2" size="small" variant="flat" color="primary" border @click="getPlatformEnableConfig">
平台命令配置
</v-btn>
<v-dialog max-width="500px" v-if="extension_data.message">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon size="small" color="error" style="margin-left: auto;" variant="plain">
<v-icon>mdi-alert-circle</v-icon>
<v-card variant="flat" class="rounded-xl">
<v-card-item>
<template v-slot:prepend>
<div class="plugin-page-icon d-flex justify-center align-center rounded-lg mr-4">
<v-icon size="36" color="primary">mdi-puzzle</v-icon>
</div>
</template>
<v-card-title class="text-h4 font-weight-bold">
已安装的插件
</v-card-title>
<v-card-subtitle class="text-subtitle-1 mt-1 text-medium-emphasis">
管理已经安装的所有插件
</v-card-subtitle>
</v-card-item>
<v-card-text class="pt-2">
<v-row class="mb-4">
<v-col cols="12" sm="6" md="6" class="d-flex align-center">
<v-btn-group variant="outlined" density="comfortable" color="primary">
<v-btn @click="isListView = false" :color="!isListView ? 'primary' : undefined"
:variant="!isListView ? 'flat' : 'outlined'">
<v-icon>mdi-view-grid</v-icon>
</v-btn>
<v-btn @click="isListView = true" :color="isListView ? 'primary' : undefined"
:variant="isListView ? 'flat' : 'outlined'">
<v-icon>mdi-view-list</v-icon>
</v-btn>
</v-btn-group>
<v-btn class="ml-2" @click="toggleShowReserved" prepend-icon="mdi-eye-settings-outline"
:color="showReserved ? 'primary' : undefined" :variant="showReserved ? 'flat' : 'outlined'">
{{ showReserved ? '隐藏系统插件' : '显示系统插件' }}
</v-btn>
</template>
<template v-slot:default="{ isActive }">
<v-card>
<v-card-title class="headline">错误信息</v-card-title>
<v-card-text>
{{ extension_data.message }}<br>
<small>详情请检查控制台</small>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="isActive.value = false">关闭</v-btn>
</v-card-actions>
<v-btn class="ml-2" prepend-icon="mdi-tune-vertical" color="primary" variant="outlined"
@click="getPlatformEnableConfig">
平台命令配置
</v-btn>
</v-col>
<v-col cols="12" sm="auto" md="6" class="ml-auto">
<div class="search-container rounded-lg">
<v-text-field v-model="pluginSearch" density="compact" label="Search" prepend-inner-icon="mdi-magnify"
variant="solo-filled" flat hide-details single-line></v-text-field>
</div>
<v-dialog max-width="500px" v-if="extension_data.message">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon size="small" color="error" class="ml-2" variant="tonal">
<v-icon>mdi-alert-circle</v-icon>
<v-badge dot color="error" floating></v-badge>
</v-btn>
</template>
<template v-slot:default="{ isActive }">
<v-card class="rounded-lg">
<v-card-title class="headline d-flex align-center">
<v-icon color="error" class="mr-2">mdi-alert-circle</v-icon>
错误信息
</v-card-title>
<v-card-text>
<p class="text-body-1">{{ extension_data.message }}</p>
<p class="text-caption mt-2">详情请检查控制台</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="isActive.value = false">关闭</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</v-col>
</v-row>
<v-fade-transition hide-on-leave>
<!-- 表格视图 -->
<div v-if="isListView">
<v-card class="rounded-lg overflow-hidden elevation-1">
<v-data-table :headers="pluginHeaders" :items="filteredPlugins" :loading="loading_" item-key="name"
hover>
<template v-slot:loader>
<v-row class="py-8 d-flex align-center justify-center">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<span class="ml-2">加载中...</span>
</v-row>
</template>
<template v-slot:item.name="{ item }">
<div class="d-flex align-center py-2">
<div>
<div class="text-subtitle-1 font-weight-medium">{{ item.name }}</div>
<div v-if="item.reserved" class="d-flex align-center mt-1">
<v-chip color="primary" size="x-small" class="font-weight-medium">系统</v-chip>
</div>
</div>
</div>
</template>
<template v-slot:item.desc="{ item }">
<div class="text-body-2 text-medium-emphasis">{{ item.desc }}</div>
</template>
<template v-slot:item.version="{ item }">
<div class="d-flex align-center">
<span class="text-body-2">{{ item.version }}</span>
<v-icon v-if="item.has_update" color="warning" size="small" class="ml-1">mdi-alert</v-icon>
<v-tooltip v-if="item.has_update" activator="parent">
<span>有新版本: {{ item.online_version }}</span>
</v-tooltip>
</div>
</template>
<template v-slot:item.author="{ item }">
<div class="text-body-2">{{ item.author }}</div>
</template>
<template v-slot:item.status="{ item }">
<v-chip :color="item.activated ? 'success' : 'error'" size="small" class="font-weight-medium"
:variant="item.activated ? 'flat' : 'outlined'">
{{ item.activated ? '启用' : '禁用' }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<div class="d-flex align-center">
<v-btn-group density="comfortable" variant="text" color="primary">
<v-btn v-if="!item.activated" icon size="small" color="success" @click="pluginOn(item)">
<v-icon>mdi-play</v-icon>
<v-tooltip activator="parent" location="top">点击启用</v-tooltip>
</v-btn>
<v-btn v-else icon size="small" color="error" @click="pluginOff(item)">
<v-icon>mdi-pause</v-icon>
<v-tooltip activator="parent" location="top">点击禁用</v-tooltip>
</v-btn>
<v-btn icon size="small" color="info" @click="reloadPlugin(item.name)">
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">重载</v-tooltip>
</v-btn>
<v-btn icon size="small" @click="openExtensionConfig(item.name)">
<v-icon>mdi-cog</v-icon>
<v-tooltip activator="parent" location="top">配置</v-tooltip>
</v-btn>
<v-btn icon size="small" @click="showPluginInfo(item)">
<v-icon>mdi-information</v-icon>
<v-tooltip activator="parent" location="top">行为</v-tooltip>
</v-btn>
<v-btn v-if="item.repo" icon size="small" @click="viewReadme(item)">
<v-icon>mdi-book-open-page-variant</v-icon>
<v-tooltip activator="parent" location="top">文档</v-tooltip>
</v-btn>
<v-btn icon size="small" color="warning"
@click="updateExtension(item.name)" :v-show="item.has_update">
<v-icon>mdi-update</v-icon>
<v-tooltip activator="parent" location="top">更新</v-tooltip>
</v-btn>
<v-btn icon size="small" color="error"
@click="uninstallExtension(item.name)" :disabled="item.reserved">
<v-icon>mdi-delete</v-icon>
<v-tooltip activator="parent" location="top">卸载</v-tooltip>
</v-btn>
</v-btn-group>
</div>
</template>
<template v-slot:no-data>
<div class="text-center pa-8">
<v-icon size="64" color="info" class="mb-4">mdi-puzzle-outline</v-icon>
<div class="text-h5 mb-2">暂无插件</div>
<div class="text-body-1 mb-4">尝试安装插件或者显示系统插件</div>
</div>
</template>
</v-data-table>
</v-card>
</template>
</v-dialog>
</div>
</div>
</v-col>
<v-col cols="10" md="6" lg="6" v-for="extension in filteredExtensions" :key="extension.name">
<ExtensionCard :extension="extension"
@configure="openExtensionConfig(extension.name)"
@uninstall="uninstallExtension(extension.name)"
@update="updateExtension(extension.name)"
@reload="reloadPlugin(extension.name)"
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
@view-handlers="showPluginInfo(extension)"
@view-readme="viewReadme(extension)">
</ExtensionCard>
</div>
<!-- 卡片视图 -->
<div v-else>
<v-row v-if="filteredPlugins.length === 0" class="text-center">
<v-col cols="12" class="pa-8">
<v-icon size="64" color="info" class="mb-4">mdi-puzzle-outline</v-icon>
<div class="text-h5 mb-2">暂无插件</div>
<div class="text-body-1 mb-4">尝试安装插件或者显示系统插件</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6" lg="4" v-for="extension in filteredPlugins" :key="extension.name" class="pb-4">
<ExtensionCard :extension="extension" class="h-120 rounded-lg"
@configure="openExtensionConfig(extension.name)" @uninstall="uninstallExtension(extension.name)"
@update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)"
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
@view-handlers="showPluginInfo(extension)" @view-readme="viewReadme(extension)">
</ExtensionCard>
</v-col>
</v-row>
</div>
</v-fade-transition>
<v-row v-if="loading_">
<v-col cols="12" class="d-flex justify-center">
<v-progress-circular indeterminate color="primary" size="48"></v-progress-circular>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 插件平台配置对话框 -->
<v-dialog v-model="platformEnableDialog" max-width="800" persistent>
<v-card>
<v-card-title>
<span class="headline">平台命令可用性配置</span>
</v-card-title>
<v-card-subtitle>
设置每个插件在不同平台上的可用性勾选表示启用
</v-card-subtitle>
<v-card-text>
<v-overlay
:model-value="loadingPlatformData"
class="align-center justify-center"
persistent
>
<v-progress-circular
color="primary"
indeterminate
size="64"
></v-progress-circular>
<v-dialog v-model="platformEnableDialog" max-width="900" persistent>
<v-card class="rounded-lg">
<v-toolbar color="primary" density="comfortable" flat>
<v-toolbar-title class="text-white">平台命令可用性配置</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="platformEnableDialog = false" variant="text" color="white">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
<v-card-text class="pt-4">
<p class="text-body-2 mb-4">设置每个插件在不同平台上的可用性勾选表示启用</p>
<v-overlay :model-value="loadingPlatformData" class="align-center justify-center" persistent>
<v-progress-circular color="primary" indeterminate size="64"></v-progress-circular>
</v-overlay>
<div v-if="platformEnableData.platforms.length === 0" class="text-center pa-4">
<div v-if="platformEnableData.platforms.length === 0" class="text-center pa-8">
<v-icon icon="mdi-alert" color="warning" size="64" class="mb-4"></v-icon>
<div class="text-h6 mb-2">未找到平台适配器</div>
<div class="text-h5 mb-2">未找到平台适配器</div>
<div class="text-body-1 mb-4">请先在 <strong>平台管理</strong> 中添加并配置平台适配器然后再设置插件的平台可用性</div>
<v-btn color="primary" to="/platforms">前往平台管理</v-btn>
<v-btn color="primary" to="/platforms" variant="elevated">前往平台管理</v-btn>
</div>
<v-table v-else>
<thead>
<tr>
<th>插件名称</th>
<th v-for="platform in platformEnableData.platforms" :key="platform.name">
<div class="d-flex align-center">
{{ platform.display_name }}
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
icon
density="compact"
variant="text"
size="small"
v-bind="props"
class="ms-1"
>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true)">
<v-list-item-title>全选</v-list-item-title>
</v-list-item>
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true, false)">
<v-list-item-title>全选普通插件</v-list-item-title>
</v-list-item>
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true, true)">
<v-list-item-title>全选系统插件</v-list-item-title>
</v-list-item>
<v-list-item @click="selectAllPluginsForPlatform(platform.name, false)">
<v-list-item-title>全不选</v-list-item-title>
</v-list-item>
<v-list-item @click="toggleAllPluginsForPlatform(platform.name)">
<v-list-item-title>反选</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="plugin in platformEnableData.plugins" :key="plugin.name">
<td>
<div class="d-flex align-center">
{{ plugin.name }}
<v-chip v-if="plugin.reserved" color="primary" size="x-small" class="ml-2">系统</v-chip>
</div>
<div class="text-caption text-grey">{{ plugin.desc }}</div>
</td>
<td v-for="platform in platformEnableData.platforms" :key="platform.name">
<v-checkbox
v-model="platformEnableData.platform_enable[platform.name][plugin.name]"
hide-details
density="compact"
></v-checkbox>
</td>
</tr>
</tbody>
</v-table>
<v-sheet v-else class="rounded-lg overflow-hidden">
<v-table hover class="elevation-1">
<thead>
<tr>
<th class="text-left">插件名称</th>
<th v-for="platform in platformEnableData.platforms" :key="platform.name">
<div class="d-flex align-center">
{{ platform.display_name }}
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon density="compact" variant="text" size="small" v-bind="props" class="ms-1">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true)">
<v-list-item-title>全选</v-list-item-title>
</v-list-item>
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true, false)">
<v-list-item-title>全选普通插件</v-list-item-title>
</v-list-item>
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true, true)">
<v-list-item-title>全选系统插件</v-list-item-title>
</v-list-item>
<v-list-item @click="selectAllPluginsForPlatform(platform.name, false)">
<v-list-item-title></v-list-item-title>
</v-list-item>
<v-list-item @click="toggleAllPluginsForPlatform(platform.name)">
<v-list-item-title>反选</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="plugin in platformEnableData.plugins" :key="plugin.name">
<td>
<div class="d-flex align-center">
{{ plugin.name }}
<v-chip v-if="plugin.reserved" color="primary" size="x-small" class="ml-2">系统</v-chip>
</div>
<div class="text-caption text-grey">{{ plugin.desc }}</div>
</td>
<td v-for="platform in platformEnableData.platforms" :key="platform.name">
<v-checkbox v-model="platformEnableData.platform_enable[platform.name][plugin.name]" hide-details
density="compact"></v-checkbox>
</td>
</tr>
</tbody>
</v-table>
</v-sheet>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="platformEnableDialog = false">关闭</v-btn>
<v-btn v-if="platformEnableData.platforms.length > 0" color="primary" @click="savePlatformEnableConfig">保存</v-btn>
<v-btn v-if="platformEnableData.platforms.length > 0" color="primary"
@click="savePlatformEnableConfig">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -525,15 +719,16 @@ onMounted(async () => {
<v-card>
<v-card-title class="text-h5">{{ loadingDialog.title }}</v-card-title>
<v-card-text>
<v-progress-linear v-if="loadingDialog.statusCode === 0" indeterminate color="primary" class="mb-4"></v-progress-linear>
<v-progress-linear v-if="loadingDialog.statusCode === 0" indeterminate color="primary"
class="mb-4"></v-progress-linear>
<div v-if="loadingDialog.statusCode !== 0" class="py-8 text-center">
<v-icon class="mb-6" :color="loadingDialog.statusCode === 1 ? 'success' : 'error'"
:icon="loadingDialog.statusCode === 1 ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'"
<v-icon class="mb-6" :color="loadingDialog.statusCode === 1 ? 'success' : 'error'"
:icon="loadingDialog.statusCode === 1 ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'"
size="128"></v-icon>
<div class="text-h4 font-weight-bold">{{ loadingDialog.result }}</div>
</div>
<div style="margin-top: 32px;">
<h3>日志</h3>
<ConsoleDisplayer historyNum="10" style="height: 200px; margin-top: 16px;"></ConsoleDisplayer>
@@ -551,7 +746,7 @@ onMounted(async () => {
<v-card>
<v-card-title class="text-h5">{{ selectedPlugin.name }} 插件行为</v-card-title>
<v-card-text>
<v-data-table style="font-size: 17px;" :headers="plugin_handler_info_headers" :items="selectedPlugin.handlers"
<v-data-table style="font-size: 17px;" :headers="plugin_handler_info_headers" :items="selectedPlugin.handlers"
item-key="name">
<template v-slot:header.id="{ column }">
<p style="font-weight: bold;">{{ column.title }}</p>
@@ -584,12 +779,9 @@ onMounted(async () => {
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
<ReadmeDialog
v-model:show="readmeDialog.show"
:plugin-name="readmeDialog.pluginName"
:repo-url="readmeDialog.repoUrl"
/>
<ReadmeDialog v-model:show="readmeDialog.show" :plugin-name="readmeDialog.pluginName"
:repo-url="readmeDialog.repoUrl" />
</template>
<style scoped>
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "3.4.39"
version = "3.5.4"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"
@@ -19,6 +19,7 @@ dependencies = [
"defusedxml>=0.7.1",
"dingtalk-stream>=0.22.1",
"docstring-parser>=0.16",
"google-genai>=1.10.0",
"googlesearch-python>=1.3.0",
"lark-oapi>=1.4.12",
"lxml-html-clean>=0.4.1",
+2 -1
View File
@@ -29,4 +29,5 @@ defusedxml
mcp
certifi
pip
telegramify-markdown
telegramify-markdown
google-genai
Generated
+76
View File
@@ -209,6 +209,7 @@ dependencies = [
{ name = "defusedxml" },
{ name = "dingtalk-stream" },
{ name = "docstring-parser" },
{ name = "google-genai" },
{ name = "googlesearch-python" },
{ name = "lark-oapi" },
{ name = "lxml-html-clean" },
@@ -245,6 +246,7 @@ requires-dist = [
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "dingtalk-stream", specifier = ">=0.22.1" },
{ name = "docstring-parser", specifier = ">=0.16" },
{ name = "google-genai", specifier = ">=1.10.0" },
{ name = "googlesearch-python", specifier = ">=1.3.0" },
{ name = "lark-oapi", specifier = ">=1.4.12" },
{ name = "lxml-html-clean", specifier = ">=0.4.1" },
@@ -305,6 +307,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
]
[[package]]
name = "cachetools"
version = "5.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 },
]
[[package]]
name = "certifi"
version = "2025.1.31"
@@ -676,6 +687,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 },
]
[[package]]
name = "google-auth"
version = "2.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
{ name = "pyasn1-modules" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/8e/8f45c9a32f73e786e954b8f9761c61422955d23c45d1e8c347f9b4b59e8e/google_auth-2.39.0.tar.gz", hash = "sha256:73222d43cdc35a3aeacbfdcaf73142a97839f10de930550d89ebfe1d0a00cde7", size = 274834 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/12/ad37a1ef86006d0a0117fc06a4a00bd461c775356b534b425f00dde208ea/google_auth-2.39.0-py2.py3-none-any.whl", hash = "sha256:0150b6711e97fb9f52fe599f55648950cc4540015565d8fbb31be2ad6e1548a2", size = 212319 },
]
[[package]]
name = "google-genai"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "google-auth" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/7a/224e2f70c835202042969685ee3da00a6475508d1b64f0f1e90144f96beb/google_genai-1.10.0.tar.gz", hash = "sha256:f59423e0f155dc66b7792c8a0e6724c75c72dc699d1eb7907d4d0006d4f6186f", size = 156355 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/a0/56839a2e202d79c773edd1c1db124da8eb2a7b657267a888080b678d0369/google_genai-1.10.0-py3-none-any.whl", hash = "sha256:41b105a2fcf8a027fc45cc16694cd559b8cd1272eab7345ad58cfa2c353bf34f", size = 154705 },
]
[[package]]
name = "googlesearch-python"
version = "1.3.0"
@@ -1402,6 +1445,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885 },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 },
]
[[package]]
name = "pycparser"
version = "2.22"
@@ -1697,6 +1761,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 },
]
[[package]]
name = "rsa"
version = "4.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315 },
]
[[package]]
name = "silk-python"
version = "0.2.6"