Compare commits

...

147 Commits

Author SHA1 Message Date
Soulter 23ee5e81c9 🐛 fix: 修复 LLM 响应后事件钩子无法生效的问题 2025-03-26 17:56:55 +08:00
Soulter 483f55e4b1 Update README.md 2025-03-26 16:16:03 +08:00
Soulter 1bb1bc2553 🐛 fix: remove error logging for empty result and refresh extensions after upload 2025-03-26 15:43:56 +08:00
Soulter a4e4e36f94 📦 release: v3.5.0 2025-03-26 15:30:09 +08:00
Soulter 6849415812 Merge pull request #996 from zhx8702/fix-star-manager
fix: install_plugin_from_file 方法load传参数改为文件名
2025-03-26 15:26:53 +08:00
zhx 86f6cb038e fix: install_plugin_from_file 方法load传参数改为文件名 2025-03-26 15:06:33 +08:00
Soulter 7480a1d6ce feat: 支持通过指令下载插件 /plugin get 2025-03-26 14:33:45 +08:00
Soulter 3cd10117dd 🎈 perf: 优化新版本时的信息显示 2025-03-26 14:14:01 +08:00
Soulter 0caf19d390 Merge pull request #937 from advent259141/master
将对只有一个 @ 的消息内容的处理改成调用llm回复
2025-03-26 13:54:43 +08:00
Soulter 9c9ab50d1a 🎈 perf: 优化 WebUI 对话数据库中文历史检索 2025-03-26 13:50:11 +08:00
Soulter d4bcb8174e 🐛 fix: 修复部分可能形成 SQL 注入的风险 2025-03-26 13:41:18 +08:00
Soulter aca18fab0f feat: 优化配置文件中的提示信息,增强可读性 2025-03-26 00:56:51 +08:00
Soulter 691de01b79 feat: 支持设置最多携带对话数量 2025-03-26 00:46:15 +08:00
Soulter 3383f15142 Merge pull request #988 from Soulter/NiceAir/master
 feat: Update UI elements and improve layout in various components
2025-03-25 23:17:11 +08:00
Soulter 84c1593889 feat: Update UI elements and improve layout in various components 2025-03-25 21:52:15 +08:00
Soulter 3c80fa1e33 Update README.md 2025-03-25 21:31:23 +08:00
Soulter 06b16a1deb Merge pull request #983 from Soulter/feat-conversation-webui-mgr
 支持 WebUI 对话管理
2025-03-25 21:26:00 +08:00
Soulter 4c4246fb09 Merge pull request #982 from NiceAir/master
添加对gewe的表情包、引用消息、视频的支持
2025-03-25 21:25:00 +08:00
Soulter 364be1e9f6 🐛 fix: Handle missing defusedxml dependency for Gewechat message parsing 2025-03-25 21:21:38 +08:00
NiceAir f959ed71aa feat: Gewechat 支持表情包、引用消息、视频
Co-authored-by: Soulter <905617992@qq.com>
2025-03-25 21:00:12 +08:00
Soulter 125fc3a622 feat: 支持 WebUI 对话管理 2025-03-25 19:44:46 +08:00
Soulter 6b9e785db3 Merge pull request #968 from Soulter/pre-commit-ci-update-config
🎈 pre-commit autoupdate
2025-03-25 15:03:39 +08:00
Soulter 25d34e9a43 Merge pull request #974 from zhx8702/feat-webui-add-search-keys
feat: 插件市场列表卡片过滤条件提出变量保持一致
2025-03-25 15:03:09 +08:00
Soulter 457d4aa1dc Merge pull request #976 from Raven95676/master
Improves Telegram adapter termination
2025-03-25 15:01:04 +08:00
Raven95676 ff0c0992ff Improves Telegram adapter termination 2025-03-25 14:46:20 +08:00
Soulter d379e012c4 🐛 fix: telegram /start issue #751 2025-03-25 14:03:46 +08:00
zhx 151fff26fd feat: 插件市场列表卡片过滤条件提出变量保持一致 2025-03-25 13:50:16 +08:00
Soulter 3d0d561215 Update compose.yml 2025-03-25 13:24:37 +08:00
Soulter 22d586ed7b Update compose.yml 2025-03-25 13:24:19 +08:00
Soulter 6dc19b29e8 🐛 fix: remove redundant validation call in config validation function #901 2025-03-25 12:56:48 +08:00
Soulter 50975a87d4 🐛 fix: handle message sending failures with error logging 2025-03-25 12:34:43 +08:00
Soulter ce721d9f0f 🐛 fix: platform adapter server blocks ctrl+c 2025-03-25 11:31:46 +08:00
Soulter 20510a33f7 feat: improve pyproject and use uv as package mgr 2025-03-25 11:07:20 +08:00
pre-commit-ci[bot] 3abd9c8763 🎈 pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.0 → v0.11.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.0...v0.11.2)
2025-03-24 17:08:12 +00:00
Soulter e9eff7420b feat: 更加完善和美观的 本地 Markdown 渲染 2025-03-25 00:56:19 +08:00
Soulter 64c250c9d8 🎈perf: 优化可能的 conversation 为 None 的问题 2025-03-25 00:06:25 +08:00
Soulter 8047f82bfd 🎈perf: 优化删除插件目录的逻辑,抛出异常细节;完善 mcp 未安装时的提示 2025-03-24 23:07:56 +08:00
Soulter af6467fb3d Merge pull request #962 from zhx8702/feat-webui-add-double-confirm
feat: 删除插件添加二次确认,插件列表添加非空判断
2025-03-24 23:01:43 +08:00
zhx 3ff1664aec feat: 删除多余代码 2025-03-24 20:27:05 +08:00
zhx 34ea2b44b8 Merge remote-tracking branch 'upstream/master' into feat-webui-add-double-confirm 2025-03-24 19:42:47 +08:00
Soulter 6c8d851109 Merge pull request #955 from Raven95676/master
Telegram适配器消息处理功能增强
2025-03-24 18:10:51 +08:00
Soulter d678299a74 Merge branch 'master' into master 2025-03-24 18:10:27 +08:00
Soulter 7aed0db2b6 Merge pull request #951 from IGCrystal/master
fix: fix SSLCertVerificationError
2025-03-24 18:05:49 +08:00
Soulter 0355524345 Merge branch 'master' into master 2025-03-24 17:58:00 +08:00
Soulter 0a43e4672e style: format codes 2025-03-24 17:57:28 +08:00
zhx 71e0ccdfec feat: 删除插件添加二次确认,插件列表添加非空判断 2025-03-24 16:41:54 +08:00
冰苷晶 1df33ac3c8 fix: fix error 2025-03-24 13:28:14 +08:00
pre-commit-ci[bot] 7334090ac1 🎈 auto fixes by pre-commit hooks 2025-03-24 05:20:37 +00:00
冰苷晶 6b0f044198 fix: fix other errors 2025-03-24 13:20:05 +08:00
pre-commit-ci[bot] ddf54c9cf8 🎈 auto fixes by pre-commit hooks 2025-03-24 04:32:21 +00:00
IGCrystal 7c64e184e2 Merge branch 'Soulter:master' into master 2025-03-24 12:32:16 +08:00
渡鸦95676 a904db033c Merge branch 'Soulter:master' into master 2025-03-24 12:19:17 +08:00
渡鸦95676 b234856b02 Remove unused variable
移除以通过ruff检查
在Ubuntu24.04LTS中,移除未见对现有功能的影响
2025-03-24 11:36:46 +08:00
Soulter 89d51d2afc 🎈 perf: config UI 2025-03-24 11:36:38 +08:00
Soulter 37cb9678e9 Merge pull request #826 from XuYingJie-cmd/master
新增了关于gewe发送视频的功能
2025-03-24 11:25:24 +08:00
pre-commit-ci[bot] 0500ff333a 🎈 auto fixes by pre-commit hooks 2025-03-24 02:50:28 +00:00
Raven95676 08528510ef Fix incorrect handling of reply messages within topics 2025-03-24 10:41:33 +08:00
Raven95676 ddbd03dc1e Adds sticker handling in Telegram adapter 2025-03-24 10:40:20 +08:00
Soulter ade87f378a 🎈 perf: UI 优化 2025-03-24 00:32:40 +08:00
冰苷晶 4db14b905f fix: fix error 2025-03-23 23:40:06 +08:00
pre-commit-ci[bot] b669b31451 🎈 auto fixes by pre-commit hooks 2025-03-23 15:07:22 +00:00
冰苷晶 1cb2b62f81 fix: fix error 2025-03-23 23:02:34 +08:00
Soulter e5828713cf 🎈 perf: improve ChatPage and ConfigPage UI 2025-03-23 22:57:02 +08:00
冰苷晶 d10cb84068 fix: fix SSLCertVerificationError 2025-03-23 22:55:07 +08:00
Soulter 4222f8516f Merge pull request #844 from AraragiEro/mcp_adapt
支持 MCP 服务并优化函数调用流程
2025-03-23 22:35:35 +08:00
Soulter 7f998c7611 chore: remove useless print output 2025-03-23 22:28:00 +08:00
Soulter db46000337 🎨 style: format codes 2025-03-23 22:22:11 +08:00
Soulter 1aac8d8041 feat: 适配完整的 function-calling 流程 2025-03-23 22:21:47 +08:00
Soulter c59c8e05f7 🐛 fix: tools result 2025-03-23 17:03:18 +08:00
Soulter 4942d0a629 feat: 在工具使用页面添加函数调用信息提示和链接功能 2025-03-23 17:00:38 +08:00
Soulter 873b7715f4 🎈 perf: 优化 MCP Client 异步 Event 管理 2025-03-23 16:51:28 +08:00
pre-commit-ci[bot] 98e7ed6920 🎈 auto fixes by pre-commit hooks 2025-03-23 08:34:05 +00:00
Soulter 046f5e645e feat: 完善 MCP 管理和实现 WebUI MCP 相关的页面 2025-03-23 16:33:44 +08:00
pre-commit-ci[bot] f5e5a7094c 🎈 auto fixes by pre-commit hooks 2025-03-23 06:39:13 +00:00
Gao Jinzhe 154125fee6 Add files via upload 2025-03-23 14:35:44 +08:00
pre-commit-ci[bot] 9f8e960ebe 🎈 auto fixes by pre-commit hooks 2025-03-23 03:31:20 +00:00
Soulter 4179b0be0a chore: 优化注解格式和 requirements.txt 2025-03-23 11:31:10 +08:00
Soulter 28bafa38db Merge branch 'master' into mcp_adapt 2025-03-23 11:01:44 +08:00
Soulter b07552565e Merge pull request #926 from Soulter/perf-graceful-shutdown
支持所有消息平台的优雅退出
2025-03-23 10:56:56 +08:00
Soulter c4427471d2 🎨 style: format codes 2025-03-23 00:25:26 +08:00
Soulter 08f81c6784 🐛 fix: 修复图片没有被存储到上下文中的问题 2025-03-23 00:23:42 +08:00
Soulter a471e98aca 🐛 fix: Telegram 下无法识别图片描述(Caption) #910 2025-03-23 00:23:01 +08:00
Soulter 75a8fcc8a0 🐛 fix: 修复 Telegram 下非默认群组话题引用消息异常 #906 2025-03-22 23:39:21 +08:00
Soulter 46ef76c168 feat: 支持消息平台的热重载 2025-03-22 19:54:54 +08:00
Soulter 66637446c9 Merge remote-tracking branch 'origin/master' into perf-graceful-shutdown 2025-03-22 19:26:35 +08:00
Soulter 21efeb888a Merge pull request #904 from LunarMeal/master
新增了newgroup指令
2025-03-22 19:18:06 +08:00
Soulter a4ee8b5322 Merge remote-tracking branch 'origin/master' into LunarMeal/master 2025-03-22 19:17:12 +08:00
Soulter 36519ac47e 🐛 fix: groupnew 设置为管理员指令 2025-03-22 19:14:58 +08:00
Soulter 3f514fceca 🎨 style: format codes 2025-03-22 19:07:47 +08:00
pre-commit-ci[bot] c2249fdfac 🎈 auto fixes by pre-commit hooks 2025-03-22 11:06:42 +00:00
Soulter c610719a44 feat: 为各平台适配器支持优雅关闭 2025-03-22 19:02:49 +08:00
Soulter 36a6c2461a 🐛 fix: 修复 Telegram Topic 群组下LLM 上下文及主动消息混乱的问题 #908 2025-03-22 18:15:43 +08:00
Soulter c29f22c39e Update PLUGIN_PUBLISH.yml 2025-03-22 15:51:35 +08:00
Soulter 30d3062944 🎈 perf: 优化钉钉在配置错误之后堵塞整个线程的问题 #885
a.k.a 帮钉钉擦屁股
2025-03-22 15:44:42 +08:00
Soulter 69ba75abf4 Update README.md 2025-03-22 01:26:03 +08:00
Soulter e4d486fec5 docs: 宝塔面板部署方式 2025-03-22 00:42:04 +08:00
Soulter f242144dcf 更新 README.md 2025-03-21 19:21:35 +08:00
Soulter 02dee2d664 🎈 perf: add error handling for missing pyffmpeg library in video sending functionality 2025-03-21 16:51:23 +08:00
Soulter a3dd2c3069 Merge remote-tracking branch 'origin/master' into XuYingJie-cmd/master 2025-03-21 16:49:15 +08:00
Soulter a23425e8aa Merge pull request #781 from Moyuyanli/master
添加gewe的群相关操作
2025-03-21 16:31:10 +08:00
Moyuyanli be79ddc9a3 fix:去掉跟post_text功能相同的接口方法 2025-03-21 16:24:31 +08:00
Soulter 7d71015e8c Update README.md 2025-03-21 16:12:25 +08:00
Soulter ad54549b51 Update README.md 2025-03-21 15:58:40 +08:00
Soulter 6cf032a164 Update compose.yml 2025-03-21 11:06:22 +08:00
Soulter 6390d796ac Update compose.yml 2025-03-21 11:05:44 +08:00
Soulter 98b8411905 Update compose.yml 2025-03-21 10:53:09 +08:00
LunarMeal ddf1029afa Merge branch 'master' of https://github.com/LunarMeal/AstrBot 2025-03-20 22:53:29 +08:00
LunarMeal 1effbc5cc9 fix 2025-03-20 22:53:21 +08:00
pre-commit-ci[bot] 414b645e9f 🎈 auto fixes by pre-commit hooks 2025-03-20 14:42:37 +00:00
LunarMeal 398c76f496 新增了newgroup指令 2025-03-20 22:39:49 +08:00
Soulter 1bc456dd95 🎈 perf: 改善一些术语描述 2025-03-20 20:31:36 +08:00
Soulter 2e8421884e Merge pull request #864 from Soulter/pre-commit-ci-update-config
🎈 pre-commit autoupdate
2025-03-20 20:23:45 +08:00
Soulter 70d9b193ac 🐛 fix: 修复私聊下 get_group 的一些问题 2025-03-20 20:18:20 +08:00
Moyuyanli b49c11004a fix:还原回原来的依赖信息 2025-03-20 19:57:35 +08:00
Soulter 34843eea90 🎨 style: format codes 2025-03-20 18:07:24 +08:00
pre-commit-ci[bot] 2d6d7f31e8 🎈 auto fixes by pre-commit hooks 2025-03-20 10:06:11 +00:00
Soulter 7a24cbff1c feat: 支持 aiocqhttp 适配器下的获取群消息 2025-03-20 18:05:44 +08:00
pre-commit-ci[bot] 1e7eb2cf1c 🎈 auto fixes by pre-commit hooks 2025-03-20 09:21:32 +00:00
Soulter 361256e016 chore: 添加了一些 gewechat client 的注释 2025-03-20 17:20:32 +08:00
Soulter 8838dbd003 🎨 style: format codes 2025-03-20 16:54:27 +08:00
pre-commit-ci[bot] 13a95e1f2b 🎈 auto fixes by pre-commit hooks 2025-03-20 08:42:40 +00:00
Soulter 1aaa451a3e Merge branch 'master' into Moyuyanli/master 2025-03-20 16:42:13 +08:00
Soulter cbba81e54d 🐛 fix: 无法接收图片 aiocqhttp 2025-03-20 16:03:41 +08:00
Soulter 370868dfac 🎈 perf: 消息平台和配置提供商配置页中,自动更新旧的配置,添加新的配置项 2025-03-20 13:22:49 +08:00
Soulter 77f692aae2 🎈 perf: 配置项显示优化 2025-03-20 13:17:27 +08:00
Soulter 9318e205ea feat: 阿里云百炼应用支持 RAG 应用 #878 2025-03-20 13:17:06 +08:00
Soulter ebcc717c19 🎈 perf: Dify 下支持更多类型的图片输入及提高代码复用性 #893
🐛 fix: 修复飞书下无法进行图片输入的问题
2025-03-20 11:21:45 +08:00
Soulter 4c16b564ee 🎈 perf: 忽略微信团队消息 #859 2025-03-19 01:09:01 +08:00
Soulter e2283d1453 🐛 fix: 修复 dify 下某些修改了 LLM 响应的插件可能不生效的问题 #876 2025-03-19 01:05:28 +08:00
pre-commit-ci[bot] b2c6e12647 🎈 auto fixes by pre-commit hooks 2025-03-17 17:10:06 +00:00
pre-commit-ci[bot] caffb83780 🎈 pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.10 → v0.11.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.10...v0.11.0)
2025-03-17 17:09:59 +00:00
Moyuyanli 2e4fef6c66 feat:添加消息记录器 2025-03-17 16:02:55 +08:00
Alero 8585cd8e21 修复codecheck 2025-03-15 20:26:17 +08:00
Alero 9fa2a7eeea 修复codecheck 2025-03-15 20:24:36 +08:00
pre-commit-ci[bot] 2d1f74228d 🎈 auto fixes by pre-commit hooks 2025-03-15 12:10:17 +00:00
Alero 3d6f7aa0e1 修复codecheck 2025-03-15 20:09:49 +08:00
pre-commit-ci[bot] 3dea60366a 🎈 auto fixes by pre-commit hooks 2025-03-15 11:54:09 +00:00
Alero d4d9a1df4c feat:新增MCP服务支持并优化工具调用逻辑
引入MCP客户端支持,增加mcp_server.json配置样例,完善工具描述生成及调用逻辑以支持MCP服务工具功能。同时调整相关逻辑以区分本地工具与MCP工具的调用方式,提升扩展性和灵活性。
2025-03-15 19:47:06 +08:00
Moyuyanli c095248176 Merge remote-tracking branch 'origin/master' 2025-03-14 18:30:42 +08:00
Moyuyanli 44601c8954 fix:修复gewe的ModContacts消息类型 2025-03-14 18:30:27 +08:00
pre-commit-ci[bot] c95682a0c7 🎈 auto fixes by pre-commit hooks 2025-03-14 09:11:21 +00:00
Moyuyanli d177b9f7fa feat:添加主动添加好友事件 2025-03-14 17:11:10 +08:00
徐英杰 9b57615d94 新增了关于gewe发送视频的功能 2025-03-14 16:19:41 +08:00
pre-commit-ci[bot] 00f5189f58 🎈 auto fixes by pre-commit hooks 2025-03-11 09:16:43 +00:00
Moyuyanli 4a8309ed1f style:idea默认格式化了部分代码
feat:添加根据消息事件获取群信息的接口
2025-03-11 17:10:55 +08:00
Moyuyanli 76cfc31a1d feat:添加 Group 类型 2025-03-11 17:10:04 +08:00
Moyuyanli d9ec434699 feat:gewe的client添加 添加好友接口
feat:gewe的client添加 获取群信息/群成员接口
feat:gewe的client添加 添加群成员为好友接口
2025-03-11 17:08:33 +08:00
96 changed files with 10488 additions and 1877 deletions
+5 -4
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
欢迎发布插件到插件市场!
欢迎发布插件到插件市场!请确保您的插件经过**完整的**测试。
- type: textarea
attributes:
@@ -22,9 +22,10 @@ body:
插件名:
插件作者:
插件简介:
标签: (可选)
社交链接: (可选, 将会在插件市场作者名称上作为可点击的链接)
description: 必填。请以列表的字段按顺序将插件名、插件作者、插件简介放在这里。
支持的消息平台:(必填,如 QQ、微信、飞书)
标签:(可选)
社交链接:(可选, 将会在插件市场作者名称上作为可点击的链接)
description: 必填。请以列表的字段按顺序将插件名、插件作者、插件简介放在这里。如果您不知道支持哪些消息平台,请填写测试过的消息平台。
- type: checkboxes
attributes:
+3 -1
View File
@@ -1,6 +1,8 @@
__pycache__
botpy.log
.vscode
.venv*
.idea
data_v2.db
data_v3.db
configs/session
@@ -26,5 +28,5 @@ venv/*
packages/python_interpreter/workplace
.venv/*
.conda/
.idea/
.idea
pytest.ini
+1 -1
View File
@@ -7,7 +7,7 @@ ci:
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.10
rev: v0.11.2
hooks:
- id: ruff
- id: ruff-format
+24 -35
View File
@@ -10,14 +10,12 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot)](https://github.com/Soulter/AstrBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" 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"/></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群-630166526-purple"></a>
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](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%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=60)
[![codecov](https://codecov.io/gh/Soulter/AstrBot/graph/badge.svg?token=FF3P5967B8)](https://codecov.io/gh/Soulter/AstrBot)
[![star](https://gitcode.com/Soulter/AstrBot/star/badge.svg)](https://gitcode.com/Soulter/AstrBot)
[![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>
[![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=60&style=for-the-badge&color=3b618e)
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a>
<a href="https://github.com/Soulter/AstrBot/blob/master/README_ja.md">日本語</a>
@@ -27,14 +25,20 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用的插件系统和完善的大语言模型(LLM)接入功能的聊天机器人及开发框架。
[![star](https://gitcode.com/Soulter/AstrBot/star/badge.svg?style=for-the-badge)](https://gitcode.com/Soulter/AstrBot)
<!-- [![codecov](https://img.shields.io/codecov/c/github/soulter/astrbot?style=for-the-badge)](https://codecov.io/gh/Soulter/AstrBot)
-->
## ✨ 主要功能
1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力,支持图片理解、语音转文字(Whisper)。
2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核
3. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://astrbot.app/others/dify.html),便捷接入 Dify 智能助手、知识库和 Dify 工作流
4. **插件扩展**深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件
5. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话
6. **高稳定性、高模块化**。基于事件总线和流水线的架构设计,高度模块化,低耦合
2. **支持 MCP**。AstrBot 现已支持接入 MCP 服务器
3. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核
4. **Agent**原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://astrbot.app/others/dify.html),便捷接入 Dify 智能助手、知识库和 Dify 工作流
5. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件
6. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话
7. **高稳定性、高模块化**。基于事件总线和流水线的架构设计,高度模块化,低耦合。
> [!TIP]
> 管理面板在线体验 Demo: [https://demo.astrbot.app/](https://demo.astrbot.app/)
@@ -49,30 +53,25 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
#### Windows 一键安装器部署
需要电脑上安装有 Python>3.10)。请参阅官方文档 [使用 Windows 一键安装器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html) 。
请参阅官方文档 [使用 Windows 一键安装器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html) 。
#### Replit 部署
#### 宝塔面板部署
[![Run on Repl.it](https://repl.it/badge/github/Soulter/AstrBot)](https://repl.it/github/Soulter/AstrBot)
请参阅官方文档 [宝塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html) 。
#### CasaOS 部署
社区贡献的部署方式。
请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/casaos.html) 。
请参阅官方文档 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html) 。
#### 手动部署
请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
## 🚀 路线图
#### Replit 部署
### 垂类功能
1. 更好的上下文管理:限制 token 总数、对话上下文总结
3. AstrBot in Minecraft
### 横功能
[![Run on Repl.it](https://repl.it/badge/github/Soulter/AstrBot)](https://repl.it/github/Soulter/AstrBot)
## ⚡ 消息平台支持情况
@@ -106,6 +105,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
| Whisper | ✔ | 语音转文本 | 支持 API、本地部署 |
| SenseVoice | ✔ | 语音转文本 | 本地部署 |
| OpenAI TTS API | ✔ | 文本转语音 | |
| GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference |
| Fishaudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
| Edge-TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
@@ -184,16 +184,5 @@ _✨ 内置 Web Chat,在线与机器人交互 ✨_
2. The deployment of WeChat (personal account) utilizes [Gewechat](https://github.com/Devo919/Gewechat) service. AstrBot only guarantees connectivity with Gewechat and recommends using a WeChat account that is not frequently used. In the event of account risk control, the author of this project shall not bear any responsibility.
3. Please ensure compliance with local laws and regulations when using this project.
<!-- ## ✨ ATRI [Beta 测试]
该功能作为插件载入。插件仓库地址:[astrbot_plugin_atri](https://github.com/Soulter/astrbot_plugin_atri)
1. 基于《ATRI ~ My Dear Moments》主角 ATRI 角色台词作为微调数据集的 `Qwen1.5-7B-Chat Lora` 微调模型。
2. 长期记忆
3. 表情包理解与回复
4. TTS
-->
_私は、高性能ですから!_
+2
View File
@@ -5,6 +5,7 @@ from astrbot.core.platform import (
MessageMember,
MessageType,
PlatformMetadata,
Group,
)
from astrbot.core.platform.register import register_platform_adapter
@@ -18,4 +19,5 @@ __all__ = [
"MessageType",
"PlatformMetadata",
"register_platform_adapter",
"Group",
]
+59 -15
View File
@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.4.39"
VERSION = "3.5.0"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -49,6 +49,7 @@ DEFAULT_CONFIG = {
"datetime_system_prompt": True,
"default_personality": "default",
"prompt_prefix": "",
"max_context_length": -1,
},
"provider_stt_settings": {
"enable": False,
@@ -80,6 +81,8 @@ DEFAULT_CONFIG = {
"admins_id": ["astrbot"],
"t2i": False,
"t2i_word_threshold": 150,
"t2i_strategy": "remote",
"t2i_endpoint": "",
"http_proxy": "",
"dashboard": {
"enable": True,
@@ -91,7 +94,6 @@ DEFAULT_CONFIG = {
"platform": [],
"wake_prefix": ["/"],
"log_level": "INFO",
"t2i_endpoint": "",
"pip_install_arg": "",
"plugin_repo_mirror": "",
"knowledge_db": {},
@@ -223,7 +225,7 @@ CONFIG_METADATA_2 = {
"hint": "启用后,机器人可以接收到频道的私聊消息。",
},
"ws_reverse_host": {
"description": "反向 Websocket 主机地址",
"description": "反向 Websocket 主机地址(AstrBot 为服务器端)",
"type": "string",
"hint": "aiocqhttp 适配器的反向 Websocket 服务器 IP 地址,不包含端口号。",
},
@@ -345,7 +347,7 @@ CONFIG_METADATA_2 = {
"type": "list",
"items": {"type": "string"},
"obvious_hint": True,
"hint": "只处理填写的 ID 发来的消息事件为空时不启用白名单过滤。可使用 /sid 指令获取在某个平台上的会话 ID。会话 ID 类似 aiocqhttp:GroupMessage:547540978。管理员可使用 /wl 添加白名单",
"hint": "只处理填写的 ID 发来的消息事件为空时不启用。可使用 /sid 指令获取在平台上的会话 ID(类似 abc:GroupMessage:123)。管理员可使用 /wl 添加白名单",
},
"id_whitelist_log": {
"description": "打印白名单日志",
@@ -581,7 +583,7 @@ CONFIG_METADATA_2 = {
"dify_api_type": "chat",
"dify_api_key": "",
"dify_api_base": "https://api.dify.ai/v1",
"dify_workflow_output_key": "",
"dify_workflow_output_key": "astrbot_wf_output",
"dify_query_input_key": "astrbot_text_query",
"variables": {},
"timeout": 60,
@@ -593,6 +595,11 @@ CONFIG_METADATA_2 = {
"dashscope_app_type": "agent",
"dashscope_api_key": "",
"dashscope_app_id": "",
"rag_options": {
"pipeline_ids": [],
"file_ids": [],
"output_reference": False,
},
"variables": {},
"timeout": 60,
},
@@ -665,6 +672,30 @@ CONFIG_METADATA_2 = {
},
},
"items": {
"rag_options": {
"description": "RAG 选项",
"type": "object",
"hint": "检索知识库设置, 非必填。仅 Agent 应用类型支持(智能体应用, 包括 RAG 应用)",
"items": {
"pipeline_ids": {
"description": "知识库 ID 列表",
"type": "list",
"items": {"type": "string"},
"hint": "对指定知识库内所有文档进行检索, 前往 https://bailian.console.aliyun.com/ 数据应用->知识索引创建和获取 ID。",
},
"file_ids": {
"description": "非结构化文档 ID, 传入该参数将对指定非结构化文档进行检索。",
"type": "list",
"items": {"type": "string"},
"hint": "对指定非结构化文档进行检索。前往 https://bailian.console.aliyun.com/ 数据管理创建和获取 ID。",
},
"output_reference": {
"description": "是否输出知识库/文档的引用",
"type": "bool",
"hint": "在每次回答尾部加上引用源。默认为 False。",
},
},
},
"sensevoice_hint": {
"description": "部署SenseVoice",
"type": "string",
@@ -681,12 +712,14 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "modelscope 上的模型名称。默认:iic/SenseVoiceSmall。",
},
# "variables": {
# "description": "工作流固定输入变量",
# "type": "object",
# "obvious_hint": True,
# "hint": "可选。工作流固定输入变量,将会作为工作流的输入。也可以在对话时使用 /set 指令动态设置变量。如果变量名冲突,优先使用动态设置的变量。",
# },
"variables": {
"description": "工作流固定输入变量",
"type": "object",
"obvious_hint": True,
"items": {},
"hint": "可选。工作流固定输入变量,将会作为工作流的输入。也可以在对话时使用 /set 指令动态设置变量。如果变量名冲突,优先使用动态设置的变量。",
"invisible": True,
},
# "fastgpt_app_type": {
# "description": "应用类型",
# "type": "string",
@@ -697,7 +730,7 @@ CONFIG_METADATA_2 = {
"dashscope_app_type": {
"description": "应用类型",
"type": "string",
"hint": "阿里云百炼应用的应用类型。",
"hint": "百炼应用的应用类型。",
"options": [
"agent",
"agent-arrange",
@@ -877,6 +910,11 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "添加之后,会在每次对话的 Prompt 前加上此文本。",
},
"max_context_length": {
"description": "最多携带对话数量(条)",
"type": "int",
"hint": "超出这个数量时将丢弃最旧的部分,用户和AI的一轮聊天记为 1 条。-1 表示不限制,默认为不限制。",
},
},
},
"persona": {
@@ -970,10 +1008,10 @@ CONFIG_METADATA_2 = {
"hint": "群聊消息最大数量。超过此数量后,会自动清除旧消息。",
},
"image_caption": {
"description": "启用图像转述(需模型支持)",
"description": "群聊图像转述(需模型支持)",
"type": "bool",
"obvious_hint": True,
"hint": "启用后,当接收到图片消息时,会使用模型先将图片转述为文字再进行后续处理。推荐使用 gpt-4o-mini 模型",
"hint": "用模型将群聊中的图片消息转述为文字,推荐 gpt-4o-mini 模型。和机器人的唤醒聊天中的图片消息仍然会直接作为上下文输入",
},
"image_caption_provider_id": {
"description": "图像转述提供商 ID",
@@ -1063,10 +1101,16 @@ CONFIG_METADATA_2 = {
"hint": "控制台输出日志的级别。",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
},
"t2i_strategy": {
"description": "文本转图像渲染源",
"type": "string",
"hint": "文本转图像策略。`remote` 为使用远程基于 HTML 的渲染服务,`local` 为使用 PIL 本地渲染。当使用 local 时,将 ttf 字体命名为 'font.ttf' 放在 data/ 目录下可自定义字体。",
"options": ["remote", "local"],
},
"t2i_endpoint": {
"description": "文本转图像服务接口",
"type": "string",
"hint": "为空时使用 AstrBot API 服务",
"hint": "当 t2i_strategy 为 remote 时生效。为空时使用 AstrBot API 服务",
},
"pip_install_arg": {
"description": "pip 安装参数",
+8 -4
View File
@@ -40,7 +40,6 @@ class AstrBotCoreLifecycle:
else:
logger.setLevel(self.astrbot_config["log_level"])
self.event_queue = Queue()
self.event_queue.closed = False
self.provider_manager = ProviderManager(self.astrbot_config, self.db)
@@ -81,6 +80,8 @@ class AstrBotCoreLifecycle:
await self.platform_manager.initialize()
"""根据配置实例化各个平台适配器"""
self.dashboard_shutdown_event = asyncio.Event()
def _load(self):
event_bus_task = asyncio.create_task(
self.event_bus.dispatch(), name="event_bus"
@@ -129,11 +130,12 @@ class AstrBotCoreLifecycle:
await asyncio.gather(*self.curr_tasks, return_exceptions=True)
async def stop(self):
self.event_queue.closed = True
for task in self.curr_tasks:
task.cancel()
await self.provider_manager.terminate()
await self.platform_manager.terminate()
self.dashboard_shutdown_event.set()
for task in self.curr_tasks:
try:
@@ -143,8 +145,10 @@ class AstrBotCoreLifecycle:
except Exception as e:
logger.error(f"任务 {task.get_name()} 发生错误: {e}")
def restart(self):
self.event_queue.closed = True
async def restart(self):
await self.provider_manager.terminate()
await self.platform_manager.terminate()
self.dashboard_shutdown_event.set()
threading.Thread(
target=self.astrbot_updator._reboot, name="restart", daemon=True
).start()
+43 -1
View File
@@ -1,6 +1,6 @@
import abc
from dataclasses import dataclass
from typing import List
from typing import List, Dict, Any, Tuple
from astrbot.core.db.po import Stats, LLMHistory, ATRIVision, Conversation
@@ -117,3 +117,45 @@ class BaseDatabase(abc.ABC):
def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str):
"""更新 Conversation Persona ID"""
raise NotImplementedError
@abc.abstractmethod
def get_all_conversations(
self, page: int = 1, page_size: int = 20
) -> Tuple[List[Dict[str, Any]], int]:
"""获取所有对话,支持分页
Args:
page: 页码,从1开始
page_size: 每页数量
Returns:
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
"""
raise NotImplementedError
@abc.abstractmethod
def get_filtered_conversations(
self,
page: int = 1,
page_size: int = 20,
platforms: List[str] = None,
message_types: List[str] = None,
search_query: str = None,
exclude_ids: List[str] = None,
exclude_platforms: List[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
"""获取筛选后的对话列表
Args:
page: 页码
page_size: 每页数量
platforms: 平台筛选列表
message_types: 消息类型筛选列表
search_query: 搜索关键词
exclude_ids: 排除的用户ID列表
exclude_platforms: 排除的平台列表
Returns:
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
"""
raise NotImplementedError
+192 -18
View File
@@ -3,7 +3,7 @@ import os
import time
from astrbot.core.db.po import Platform, Stats, LLMHistory, ATRIVision, Conversation
from . import BaseDatabase
from typing import Tuple
from typing import Tuple, List, Dict, Any
class SQLiteDatabase(BaseDatabase):
@@ -128,24 +128,23 @@ class SQLiteDatabase(BaseDatabase):
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
where_clause = ""
if session_id or provider_type:
where_clause += " WHERE "
has = False
if session_id:
where_clause += f"session_id = '{session_id}'"
has = True
if provider_type:
if has:
where_clause += " AND "
where_clause += f"provider_type = '{provider_type}'"
conditions = []
params = []
if session_id:
conditions.append("session_id = ?")
params.append(session_id)
if provider_type:
conditions.append("provider_type = ?")
params.append(provider_type)
sql = "SELECT * FROM llm_history"
if conditions:
sql += " WHERE " + " AND ".join(conditions)
c.execute(sql, params)
c.execute(
"""
SELECT * FROM llm_history
"""
+ where_clause
)
res = c.fetchall()
histories = []
for row in res:
@@ -389,3 +388,178 @@ class SQLiteDatabase(BaseDatabase):
if res:
return ATRIVision(*res)
return None
def get_all_conversations(
self, page: int = 1, page_size: int = 20
) -> Tuple[List[Dict[str, Any]], int]:
"""获取所有对话,支持分页,按更新时间降序排序"""
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
try:
# 获取总记录数
c.execute("""
SELECT COUNT(*) FROM webchat_conversation
""")
total_count = c.fetchone()[0]
# 计算偏移量
offset = (page - 1) * page_size
# 获取分页数据,按更新时间降序排序
c.execute(
"""
SELECT user_id, cid, created_at, updated_at, title, persona_id
FROM webchat_conversation
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
""",
(page_size, offset),
)
rows = c.fetchall()
conversations = []
for row in rows:
user_id, cid, created_at, updated_at, title, persona_id = row
# 确保 cid 是字符串类型且至少有8个字符,否则使用一个默认值
safe_cid = str(cid) if cid else "unknown"
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
conversations.append(
{
"user_id": user_id or "",
"cid": safe_cid,
"title": title or f"对话 {display_cid}",
"persona_id": persona_id or "",
"created_at": created_at or 0,
"updated_at": updated_at or 0,
}
)
return conversations, total_count
except Exception as _:
# 返回空列表和0,确保即使出错也有有效的返回值
return [], 0
finally:
c.close()
def get_filtered_conversations(
self,
page: int = 1,
page_size: int = 20,
platforms: List[str] = None,
message_types: List[str] = None,
search_query: str = None,
exclude_ids: List[str] = None,
exclude_platforms: List[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
"""获取筛选后的对话列表"""
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
try:
# 构建查询条件
where_clauses = []
params = []
# 平台筛选
if platforms and len(platforms) > 0:
platform_conditions = []
for platform in platforms:
platform_conditions.append("user_id LIKE ?")
params.append(f"{platform}:%")
if platform_conditions:
where_clauses.append(f"({' OR '.join(platform_conditions)})")
# 消息类型筛选
if message_types and len(message_types) > 0:
message_type_conditions = []
for msg_type in message_types:
message_type_conditions.append("user_id LIKE ?")
params.append(f"%:{msg_type}:%")
if message_type_conditions:
where_clauses.append(f"({' OR '.join(message_type_conditions)})")
# 搜索关键词
if search_query:
search_query = search_query.encode("unicode_escape").decode("utf-8")
where_clauses.append(
"(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)"
)
search_param = f"%{search_query}%"
params.extend([search_param, search_param, search_param, search_param])
# 排除特定用户ID
if exclude_ids and len(exclude_ids) > 0:
for exclude_id in exclude_ids:
where_clauses.append("user_id NOT LIKE ?")
params.append(f"{exclude_id}%")
# 排除特定平台
if exclude_platforms and len(exclude_platforms) > 0:
for exclude_platform in exclude_platforms:
where_clauses.append("user_id NOT LIKE ?")
params.append(f"{exclude_platform}:%")
# 构建完整的 WHERE 子句
where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# 构建计数查询
count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}"
# 获取总记录数
c.execute(count_sql, params)
total_count = c.fetchone()[0]
# 计算偏移量
offset = (page - 1) * page_size
# 构建分页数据查询
data_sql = f"""
SELECT user_id, cid, created_at, updated_at, title, persona_id
FROM webchat_conversation
{where_sql}
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
"""
query_params = params + [page_size, offset]
# 获取分页数据
c.execute(data_sql, query_params)
rows = c.fetchall()
conversations = []
for row in rows:
user_id, cid, created_at, updated_at, title, persona_id = row
# 确保 cid 是字符串类型,否则使用一个默认值
safe_cid = str(cid) if cid else "unknown"
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
conversations.append(
{
"user_id": user_id or "",
"cid": safe_cid,
"title": title or f"对话 {display_cid}",
"persona_id": persona_id or "",
"created_at": created_at or 0,
"updated_at": updated_at or 0,
}
)
return conversations, total_count
except Exception as _:
# 返回空列表和0,确保即使出错也有有效的返回值
return [], 0
finally:
c.close()
+5 -3
View File
@@ -38,11 +38,13 @@ CREATE TABLE IF NOT EXISTS atri_vision(
);
CREATE TABLE IF NOT EXISTS webchat_conversation(
user_id TEXT,
cid TEXT,
user_id TEXT, -- 会话 id
cid TEXT, -- 对话 id
history TEXT,
created_at INTEGER,
updated_at INTEGER,
title TEXT,
persona_id TEXT
);
);
PRAGMA encoding = 'UTF-8';
@@ -2,17 +2,16 @@ import asyncio
import traceback
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from .server import AstrBotDashboard
from astrbot.core.db import BaseDatabase
from astrbot.core import LogBroker
from astrbot.dashboard.server import AstrBotDashboard
class AstrBotDashBoardLifecycle:
class InitialLoader:
def __init__(self, db: BaseDatabase, log_broker: LogBroker):
self.db = db
self.logger = logger
self.log_broker = log_broker
self.dashboard_server = None
async def start(self):
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
@@ -25,7 +24,9 @@ class AstrBotDashBoardLifecycle:
logger.critical(traceback.format_exc())
logger.critical(f"😭 初始化 AstrBot 失败:{e} !!!")
self.dashboard_server = AstrBotDashboard(core_lifecycle, self.db)
self.dashboard_server = AstrBotDashboard(
core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event
)
task = asyncio.gather(core_task, self.dashboard_server.run())
try:
+109 -4
View File
@@ -25,9 +25,11 @@ SOFTWARE.
import base64
import json
import os
import uuid
import typing as T
from enum import Enum
from pydantic.v1 import BaseModel
from astrbot.core.utils.io import download_image_by_url, file_to_base64
class ComponentType(Enum):
@@ -59,6 +61,8 @@ class ComponentType(Enum):
TTS = "TTS"
Unknown = "Unknown"
WechatEmoji = "WechatEmoji" # Wechat 下的 emoji 表情包
class BaseMessageComponent(BaseModel):
type: ComponentType
@@ -146,6 +150,51 @@ class Record(BaseMessageComponent):
return Record(file=url, **_)
raise Exception("not a valid url")
async def convert_to_file_path(self) -> str:
"""将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。
Returns:
str: 语音的本地路径,以绝对路径表示。
"""
if self.file and self.file.startswith("file:///"):
file_path = self.file[8:]
return file_path
elif self.file and self.file.startswith("http"):
file_path = await download_image_by_url(self.file)
return os.path.abspath(file_path)
elif self.file and self.file.startswith("base64://"):
bs64_data = self.file.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data)
file_path = f"data/temp/{uuid.uuid4()}.jpg"
with open(file_path, "wb") as f:
f.write(image_bytes)
return os.path.abspath(file_path)
elif os.path.exists(self.file):
file_path = self.file
return os.path.abspath(file_path)
else:
raise Exception(f"not a valid file: {self.file}")
async def convert_to_base64(self) -> str:
"""将语音统一转换为 base64 编码。这个方法避免了手动判断语音数据类型,直接返回语音数据的 base64 编码。
Returns:
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
"""
# convert to base64
if self.file and self.file.startswith("file:///"):
bs64_data = file_to_base64(self.file[8:])
elif self.file and self.file.startswith("http"):
file_path = await download_image_by_url(self.file)
bs64_data = file_to_base64(file_path)
elif self.file and self.file.startswith("base64://"):
bs64_data = self.file
elif os.path.exists(self.file):
bs64_data = file_to_base64(self.file)
else:
raise Exception(f"not a valid file: {self.file}")
return bs64_data
class Video(BaseMessageComponent):
type: ComponentType = "Video"
@@ -279,10 +328,6 @@ class Image(BaseMessageComponent):
file_unique: T.Optional[str] = "" # 某些平台可能有图片缓存的唯一标识
def __init__(self, file: T.Optional[str], **_):
# for k in _.keys():
# if (k == "_type" and _[k] not in ["flash", "show", None]) or \
# (k == "c" and _[k] not in [2, 3]):
# logger.warn(f"Protocol: {k}={_[k]} doesn't match values")
super().__init__(file=file, **_)
@staticmethod
@@ -307,6 +352,53 @@ class Image(BaseMessageComponent):
def fromIO(IO):
return Image.fromBytes(IO.read())
async def convert_to_file_path(self) -> str:
"""将这个图片统一转换为本地文件路径。这个方法避免了手动判断图片数据类型,直接返回图片数据的本地路径(如果是网络 URL, 则会自动进行下载)。
Returns:
str: 图片的本地路径,以绝对路径表示。
"""
url = self.url if self.url else self.file
if url and url.startswith("file:///"):
image_file_path = url[8:]
return image_file_path
elif url and url.startswith("http"):
image_file_path = await download_image_by_url(url)
return os.path.abspath(image_file_path)
elif url and url.startswith("base64://"):
bs64_data = url.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data)
image_file_path = f"data/temp/{uuid.uuid4()}.jpg"
with open(image_file_path, "wb") as f:
f.write(image_bytes)
return os.path.abspath(image_file_path)
elif os.path.exists(url):
image_file_path = url
return os.path.abspath(image_file_path)
else:
raise Exception(f"not a valid file: {url}")
async def convert_to_base64(self) -> str:
"""将这个图片统一转换为 base64 编码。这个方法避免了手动判断图片数据类型,直接返回图片数据的 base64 编码。
Returns:
str: 图片的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
"""
# convert to base64
url = self.url if self.url else self.file
if url and url.startswith("file:///"):
bs64_data = file_to_base64(url[8:])
elif url and url.startswith("http"):
image_file_path = await download_image_by_url(url)
bs64_data = file_to_base64(image_file_path)
elif url and url.startswith("base64://"):
bs64_data = url
elif os.path.exists(url):
bs64_data = file_to_base64(url)
else:
raise Exception(f"not a valid file: {url}")
return bs64_data
class Reply(BaseMessageComponent):
type: ComponentType = "Reply"
@@ -322,6 +414,8 @@ class Reply(BaseMessageComponent):
"""引用的消息发送时间"""
message_str: T.Optional[str] = ""
"""解析后的纯文本消息字符串"""
sender_str: T.Optional[str] = ""
"""被引用的消息纯文本"""
text: T.Optional[str] = ""
"""deprecated"""
@@ -469,6 +563,16 @@ class File(BaseMessageComponent):
super().__init__(name=name, file=file)
class WechatEmoji(BaseMessageComponent):
type: ComponentType = "WechatEmoji"
md5: T.Optional[str] = ""
md5_len: T.Optional[int] = 0
cdnurl: T.Optional[str] = ""
def __init__(self, **_):
super().__init__(**_)
ComponentTypes = {
"plain": Plain,
"text": Plain,
@@ -497,4 +601,5 @@ ComponentTypes = {
"tts": TTS,
"unknown": Unknown,
"file": File,
"WechatEmoji": WechatEmoji,
}
+4 -4
View File
@@ -77,6 +77,10 @@ class MessageChain:
self.use_t2i_ = use_t2i
return self
def get_plain_text(self) -> str:
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
return " ".join([comp.text for comp in self.chain if isinstance(comp, Plain)])
class EventResultType(enum.Enum):
"""用于描述事件处理的结果类型。
@@ -147,9 +151,5 @@ class MessageEventResult(MessageChain):
"""是否为 LLM 结果。"""
return self.result_content_type == ResultContentType.LLM_RESULT
def get_plain_text(self) -> str:
"""获取纯文本消息。这个方法将获取所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
return " ".join([comp.text for comp in self.chain if isinstance(comp, Plain)])
CommandResult = MessageEventResult
@@ -16,7 +16,13 @@ from astrbot.core.message.message_event_result import (
from astrbot.core.message.components import Image
from astrbot.core import logger
from astrbot.core.utils.metrics import Metric
from astrbot.core.provider.entites import ProviderRequest, LLMResponse
from astrbot.core.provider.entites import (
ProviderRequest,
LLMResponse,
ToolCallMessageSegment,
AssistantMessageSegment,
ToolCallsResult,
)
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
@@ -28,6 +34,9 @@ class LLMRequestSubStage(Stage):
self.provider_wake_prefix = ctx.astrbot_config["provider_settings"][
"wake_prefix"
] # str
self.max_context_length = ctx.astrbot_config["provider_settings"][
"max_context_length"
] # int
for bwp in self.bot_wake_prefixs:
if self.provider_wake_prefix.startswith(bwp):
@@ -64,21 +73,28 @@ class LLMRequestSubStage(Stage):
req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
for comp in event.message_obj.message:
if isinstance(comp, Image):
image_url = comp.url if comp.url else comp.file
req.image_urls.append(image_url)
image_path = await comp.convert_to_file_path()
req.image_urls.append(image_path)
# 获取对话上下文
conversation_id = await self.conv_manager.get_curr_conversation_id(
event.unified_msg_origin
)
req.session_id = event.unified_msg_origin
if not conversation_id:
conversation_id = await self.conv_manager.new_conversation(
event.unified_msg_origin
)
req.session_id = event.unified_msg_origin
conversation = await self.conv_manager.get_conversation(
event.unified_msg_origin, conversation_id
)
if not conversation:
conversation_id = await self.conv_manager.new_conversation(
event.unified_msg_origin
)
conversation = await self.conv_manager.get_conversation(
event.unified_msg_origin, conversation_id
)
req.conversation = conversation
req.contexts = json.loads(conversation.history)
@@ -110,33 +126,47 @@ class LLMRequestSubStage(Stage):
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)
# max context length
if (
self.max_context_length != -1 # -1 为不限制
and len(req.contexts) // 2 > self.max_context_length
):
logger.debug("上下文长度超过限制,将截断。")
req.contexts = req.contexts[-self.max_context_length * 2 :]
try:
logger.debug(f"提供商请求 Payload: {req}")
if _nested:
req.func_tool = None # 暂时不支持递归工具调用
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
need_loop = True
while need_loop:
need_loop = False
logger.debug(f"提供商请求 Payload: {req}")
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
# 执行 LLM 响应后的事件钩子。
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnLLMResponseEvent
)
for handler in handlers:
try:
logger.debug(
f"hook(on_llm_response) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
)
await handler.handler(event, llm_response)
except BaseException:
logger.error(traceback.format_exc())
# 执行 LLM 响应后的事件钩子。
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnLLMResponseEvent
)
for handler in handlers:
try:
logger.debug(
f"hook(on_llm_response) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
)
await handler.handler(event, llm_response)
except BaseException:
logger.error(traceback.format_exc())
if event.is_stopped():
logger.info(
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
)
return
if event.is_stopped():
logger.info(
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
)
return
# 保存到历史记录
await self._save_to_history(event, req, llm_response)
async for result in self._handle_llm_response(event, req, llm_response):
if isinstance(result, ProviderRequest):
# 有函数工具调用并且返回了结果,我们需要再次请求 LLM
req = result
need_loop = True
else:
yield
asyncio.create_task(
Metric.upload(
@@ -146,72 +176,8 @@ class LLMRequestSubStage(Stage):
)
)
if llm_response.role == "assistant":
# text completion
if llm_response.result_chain:
event.set_result(
MessageEventResult(
chain=llm_response.result_chain.chain
).set_result_content_type(ResultContentType.LLM_RESULT)
)
else:
event.set_result(
MessageEventResult()
.message(llm_response.completion_text)
.set_result_content_type(ResultContentType.LLM_RESULT)
)
elif llm_response.role == "err":
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
)
)
elif llm_response.role == "tool":
# function calling
function_calling_result = {}
logger.info(
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
)
for func_tool_name, func_tool_args in zip(
llm_response.tools_call_name, llm_response.tools_call_args
):
func_tool = req.func_tool.get_func(func_tool_name)
logger.info(
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
)
try:
# 尝试调用工具函数
wrapper = self._call_handler(
self.ctx, event, func_tool.handler, **func_tool_args
)
async for resp in wrapper:
if resp is not None: # 有 return 返回
function_calling_result[func_tool_name] = resp
else:
yield # 有生成器返回
event.clear_result() # 清除上一个 handler 的结果
except BaseException as e:
logger.warning(traceback.format_exc())
function_calling_result[func_tool_name] = (
"When calling the function, an error occurred: " + str(e)
)
if function_calling_result:
# 工具返回 LLM 资源。比如 RAG、网页 得到的相关结果等。
# 我们重新执行一遍这个 stage
req.func_tool = None # 暂时不支持递归工具调用
extra_prompt = "\n\nSystem executed some external tools for this task and here are the results:\n"
for tool_name, tool_result in function_calling_result.items():
extra_prompt += (
f"Tool: {tool_name}\nTool Result: {tool_result}\n"
)
req.prompt += extra_prompt
async for _ in self.process(event, _nested=True):
yield
else:
if llm_response.completion_text:
event.set_result(
MessageEventResult().message(llm_response.completion_text)
)
# 保存到历史记录
await self._save_to_history(event, req, llm_response)
except BaseException as e:
logger.error(traceback.format_exc())
@@ -222,6 +188,116 @@ class LLMRequestSubStage(Stage):
)
return
async def _handle_llm_response(
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
) -> AsyncGenerator[None, None]:
"""处理 LLM 响应。
Returns:
bool: 是否需要继续调用 LLM
Yields:
Iterator[bool]: 将 event 交付给下一个 stage
"""
if llm_response.role == "assistant":
# text completion
if llm_response.result_chain:
event.set_result(
MessageEventResult(
chain=llm_response.result_chain.chain
).set_result_content_type(ResultContentType.LLM_RESULT)
)
else:
event.set_result(
MessageEventResult()
.message(llm_response.completion_text)
.set_result_content_type(ResultContentType.LLM_RESULT)
)
elif llm_response.role == "err":
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
)
)
elif llm_response.role == "tool":
# function calling
tool_call_result: list[ToolCallMessageSegment] = []
logger.info(
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
)
for func_tool_name, func_tool_args, func_tool_id in zip(
llm_response.tools_call_name,
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
try:
func_tool = req.func_tool.get_func(func_tool_name)
if func_tool.origin == "mcp":
logger.info(
f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}"
)
client = req.func_tool.mcp_client_dict[
func_tool.mcp_server_name
]
res = await client.session.call_tool(
func_tool.name, func_tool_args
)
if res:
# TODO content的类型可能包括list[TextContent | ImageContent | EmbeddedResource],这里只处理了TextContent。
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=res.content[0].text,
)
)
else:
logger.info(
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
)
# 尝试调用工具函数
wrapper = self._call_handler(
self.ctx, event, func_tool.handler, **func_tool_args
)
async for resp in wrapper:
if resp is not None: # 有 return 返回
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=resp,
)
)
else:
yield # 有生成器返回
event.clear_result() # 清除上一个 handler 的结果
except BaseException as e:
logger.warning(traceback.format_exc())
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=f"error: {str(e)}",
)
)
if tool_call_result:
# 函数调用结果
req.func_tool = None # 暂时不支持递归工具调用
assistant_msg_seg = AssistantMessageSegment(
role="assistant", tool_calls=llm_response.to_openai_tool_calls()
)
# 在多轮 Tool 调用的情况下,这里始终保持最新的 Tool 调用结果,减少上下文长度。
req.tool_calls_result = ToolCallsResult(
tool_calls_info=assistant_msg_seg,
tool_calls_result=tool_call_result,
)
yield req # 再次执行 LLM 请求
else:
if llm_response.completion_text:
event.set_result(
MessageEventResult().message(llm_response.completion_text)
)
async def _save_to_history(
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
):
@@ -231,8 +307,12 @@ class LLMRequestSubStage(Stage):
if llm_response.role == "assistant":
# 文本回复
contexts = req.contexts
new_record = {"role": "user", "content": req.prompt}
contexts.append(new_record)
contexts.append(await req.assemble_context())
# tool calls result
if req.tool_calls_result:
contexts.extend(req.tool_calls_result.to_openai_messages())
contexts.append(
{"role": "assistant", "content": llm_response.completion_text}
)
+9 -2
View File
@@ -103,9 +103,16 @@ class RespondStage(Stage):
for comp in result.chain:
i = await self._calc_comp_interval(comp)
await asyncio.sleep(i)
await event.send(MessageChain([*decorated_comps, comp]))
try:
await event.send(MessageChain([*decorated_comps, comp]))
except Exception as e:
logger.error(f"发送消息失败: {e} chain: {result.chain}")
break
else:
await event.send(result)
try:
await event.send(result)
except Exception as e:
logger.error(f"发送消息失败: {e} chain: {result.chain}")
await event._post_send()
logger.info(
f"AstrBot -> {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"
@@ -31,6 +31,8 @@ class ResultDecorateStage(Stage):
self.t2i_word_threshold = 50
except BaseException:
self.t2i_word_threshold = 150
self.t2i_strategy = ctx.astrbot_config["t2i_strategy"]
self.t2i_use_network = self.t2i_strategy == "remote"
self.forward_threshold = ctx.astrbot_config["platform_settings"][
"forward_threshold"
@@ -192,7 +194,9 @@ class ResultDecorateStage(Stage):
if plain_str and len(plain_str) > self.t2i_word_threshold:
render_start = time.time()
try:
url = await html_renderer.render_t2i(plain_str, return_url=True)
url = await html_renderer.render_t2i(
plain_str, return_url=True, use_network=self.t2i_use_network
)
except BaseException:
logger.error("文本转图片失败,使用文本发送。")
return
@@ -201,7 +205,10 @@ class ResultDecorateStage(Stage):
"文本转图片耗时超过了 3 秒,如果觉得很慢可以使用 /t2i 关闭文本转图片模式。"
)
if url:
result.chain = [Image.fromURL(url)]
if url.startswith("http"):
result.chain = [Image.fromURL(url)]
else:
result.chain = [Image.fromFileSystem(url)]
# 触发转发消息
has_forwarded = False
+2 -1
View File
@@ -1,7 +1,7 @@
from .platform import Platform
from .astr_message_event import AstrMessageEvent
from .platform_metadata import PlatformMetadata
from .astrbot_message import AstrBotMessage, MessageMember, MessageType
from .astrbot_message import AstrBotMessage, MessageMember, MessageType, Group
__all__ = [
"Platform",
@@ -10,4 +10,5 @@ __all__ = [
"AstrBotMessage",
"MessageMember",
"MessageType",
"Group",
]
+31 -16
View File
@@ -1,11 +1,9 @@
import abc
import asyncio
from dataclasses import dataclass
from .astrbot_message import AstrBotMessage
from .platform_metadata import PlatformMetadata
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
from astrbot.core.platform.message_type import MessageType
from typing import List, Union
from typing import List, Union, Optional
from astrbot.core.db.po import Conversation
from astrbot.core.message.components import (
Plain,
Image,
@@ -16,9 +14,12 @@ from astrbot.core.message.components import (
Forward,
Reply,
)
from astrbot.core.utils.metrics import Metric
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.db.po import Conversation
from astrbot.core.utils.metrics import Metric
from .astrbot_message import AstrBotMessage, Group
from .platform_metadata import PlatformMetadata
@dataclass
@@ -201,15 +202,6 @@ class AstrMessageEvent(abc.ABC):
"""
return self.role == "admin"
async def send(self, message: MessageChain):
"""
发送消息到消息平台。
"""
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
)
self._has_send_oper = True
async def _pre_send(self):
"""调度器会在执行 send() 前调用该方法"""
@@ -371,3 +363,26 @@ class AstrMessageEvent(abc.ABC):
system_prompt=system_prompt,
conversation=conversation,
)
"""平台适配器"""
async def send(self, message: MessageChain):
"""发送消息到消息平台。
Args:
message (MessageChain): 消息链,具体使用方式请参考文档。
"""
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
)
self._has_send_oper = True
async def get_group(self, group_id: str = None, **kwargs) -> Optional[Group]:
"""获取一个群聊的数据, 如果不填写 group_id: 如果是私聊消息,返回 None。如果是群聊消息,返回当前群聊的数据。
适配情况:
- gewechat
- aiocqhttp(OneBotv11)
"""
...
+35
View File
@@ -10,6 +10,41 @@ class MessageMember:
user_id: str # 发送者id
nickname: str = None
def __str__(self):
# 使用 f-string 来构建返回的字符串表示形式
return (
f"User ID: {self.user_id},"
f"Nickname: {self.nickname if self.nickname else 'N/A'}"
)
@dataclass
class Group:
group_id: str
"""群号"""
group_name: str = None
"""群名称"""
group_avatar: str = None
"""群头像"""
group_owner: str = None
"""群主 id"""
group_admins: List[str] = None
"""群管理员 id"""
members: List[MessageMember] = None
"""所有群成员"""
def __str__(self):
# 使用 f-string 来构建返回的字符串表示形式
return (
f"Group ID: {self.group_id}\n"
f"Name: {self.group_name if self.group_name else 'N/A'}\n"
f"Avatar: {self.group_avatar if self.group_avatar else 'N/A'}\n"
f"Owner ID: {self.group_owner if self.group_owner else 'N/A'}\n"
f"Admin IDs: {self.group_admins if self.group_admins else 'N/A'}\n"
f"Members Len: {len(self.members) if self.members else 0}\n"
f"First Member: {self.members[0] if self.members else 'N/A'}\n"
)
class AstrBotMessage:
"""
+40 -32
View File
@@ -85,14 +85,18 @@ class PlatformManager:
)
return
cls_type = platform_cls_map[platform_config["type"]]
inst = cls_type(platform_config, self.settings, self.event_queue)
self._inst_map[platform_config["id"]] = inst
inst: Platform = cls_type(platform_config, self.settings, self.event_queue)
self._inst_map[platform_config["id"]] = {
"inst": inst,
"client_id": inst.client_self_id,
}
self.platform_insts.append(inst)
asyncio.create_task(
self._task_wrapper(
asyncio.create_task(
inst.run(), name=platform_config["id"] + "_platform"
inst.run(),
name=f"platform_{platform_config['type']}_{platform_config['id']}",
)
)
)
@@ -109,38 +113,42 @@ class PlatformManager:
logger.error("-------")
async def reload(self, platform_config: dict):
# 还未实现完成,不要调用此方法
if platform_config["id"] in self._inst_map:
# 正在运行
if getattr(self._inst_map[platform_config["id"]], "terminate", None):
logger.info(f"正在尝试终止 {platform_config['id']} 平台适配器 ...")
await self._inst_map[platform_config["id"]].terminate()
logger.info(f"{platform_config['id']} 平台适配器已终止。")
del self._inst_map[platform_config["id"]]
self.platform_insts.remove(self._inst_map[platform_config["id"]])
else:
logger.warning(f"可能无法正常终止 {platform_config['id']} 平台适配器。")
# 再启动新的实例
await self.terminate_platform(platform_config["id"])
if platform_config["enable"]:
await self.load_platform(platform_config)
else:
# 先将 _inst_map 中在 platform_config 中不存在的实例删除
config_ids = [platform["id"] for platform in self.platforms_config]
for key in list(self._inst_map.keys()):
if key not in config_ids:
if getattr(self._inst_map[key], "terminate", None):
logger.info(f"正在尝试终止 {key} 平台适配器 ...")
await self._inst_map[key].terminate()
logger.info(f"{key} 平台适配器已终止。")
del self._inst_map[key]
self.platform_insts.remove(self._inst_map[key])
else:
logger.warning(f"可能无法正常终止 {key} 平台适配器。")
# 和配置文件保持同步
config_ids = [provider["id"] for provider in self.platforms_config]
for key in list(self._inst_map.keys()):
if key not in config_ids:
await self.terminate_platform(key)
# 再启动新的实例
await self.load_platform(platform_config)
async def terminate_platform(self, platform_id: str):
if platform_id in self._inst_map:
logger.info(f"正在尝试终止 {platform_id} 平台适配器 ...")
# client_id = self._inst_map.pop(platform_id, None)
info = self._inst_map.pop(platform_id, None)
client_id = info["client_id"]
inst = info["inst"]
try:
self.platform_insts.remove(
next(
inst
for inst in self.platform_insts
if inst.client_self_id == client_id
)
)
except Exception:
logger.warning(f"可能未完全移除 {platform_id} 平台适配器")
if getattr(inst, "terminate", None):
await inst.terminate()
async def terminate(self):
for inst in self.platform_insts:
if getattr(inst, "terminate", None):
await inst.terminate()
def get_insts(self):
return self.platform_insts
+3 -1
View File
@@ -1,4 +1,5 @@
import abc
import uuid
from typing import Awaitable, Any
from asyncio import Queue
from .platform_metadata import PlatformMetadata
@@ -13,6 +14,7 @@ class Platform(abc.ABC):
super().__init__()
# 维护了消息平台的事件队列,EventBus 会从这里取出事件并处理。
self._event_queue = event_queue
self.client_self_id = uuid.uuid4().hex
@abc.abstractmethod
def run(self) -> Awaitable[Any]:
@@ -25,7 +27,7 @@ class Platform(abc.ABC):
"""
终止一个平台的运行实例。
"""
pass
...
@abc.abstractmethod
def meta(self) -> PlatformMetadata:
@@ -1,9 +1,9 @@
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
from aiocqhttp import CQHttp
from astrbot.core.utils.io import file_to_base64, download_image_by_url
class AiocqhttpMessageEvent(AstrMessageEvent):
@@ -24,18 +24,9 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
d["data"]["text"] = segment.text.strip()
elif isinstance(segment, (Image, Record)):
# convert to base64
if segment.file and segment.file.startswith("file:///"):
bs64_data = file_to_base64(segment.file[8:])
image_file_path = segment.file[8:]
elif segment.file and segment.file.startswith("http"):
image_file_path = await download_image_by_url(segment.file)
bs64_data = file_to_base64(image_file_path)
elif segment.file and segment.file.startswith("base64://"):
bs64_data = segment.file
else:
bs64_data = file_to_base64(segment.file)
bs64 = await segment.convert_to_base64()
d["data"] = {
"file": bs64_data,
"file": bs64,
}
elif isinstance(segment, At):
d["data"] = {
@@ -84,3 +75,46 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
await self.bot.send(self.message_obj.raw_message, ret)
await super().send(message)
async def get_group(self, group_id=None, **kwargs):
if isinstance(group_id, str) and group_id.isdigit():
group_id = int(group_id)
elif self.get_group_id():
group_id = int(self.get_group_id())
else:
return None
info: dict = await self.bot.call_action(
"get_group_info",
group_id=group_id,
)
members: typing.List[typing.Dict] = await self.bot.call_action(
"get_group_member_list",
group_id=group_id,
)
owner_id = None
admin_ids = []
for member in members:
if member["role"] == "owner":
owner_id = member["user_id"]
if member["role"] == "admin":
admin_ids.append(member["user_id"])
group = Group(
group_id=str(group_id),
group_name=info.get("group_name"),
group_avatar="",
group_admins=admin_ids,
group_owner=str(owner_id),
members=[
MessageMember(
user_id=member["user_id"],
nickname=member.get("nickname") or member.get("card"),
)
for member in members
],
)
return group
@@ -43,8 +43,6 @@ class AiocqhttpAdapter(Platform):
"适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。",
)
self.stop = False
self.bot = CQHttp(
use_ws_reverse=True, import_name="aiocqhttp", api_timeout_sec=180
)
@@ -303,22 +301,19 @@ class AiocqhttpAdapter(Platform):
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
logging.getLogger("aiocqhttp").setLevel(logging.ERROR)
self.shutdown_event = asyncio.Event()
return coro
async def terminate(self):
self.stop = True
await asyncio.sleep(1)
self.shutdown_event.set()
async def shutdown_trigger_placeholder(self):
await self.shutdown_event.wait()
logger.info("aiocqhttp 适配器已被优雅地关闭")
def meta(self) -> PlatformMetadata:
return self.metadata
async def shutdown_trigger_placeholder(self):
# TODO: use asyncio.Event
while not self._event_queue.closed and not self.stop: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("aiocqhttp 适配器已关闭。")
async def handle_msg(self, message: AstrBotMessage):
message_event = AiocqhttpMessageEvent(
message_str=message.message_str,
@@ -2,6 +2,7 @@ import asyncio
import uuid
import aiohttp
import dingtalk_stream
import threading
from astrbot.api.platform import (
Platform,
@@ -196,7 +197,31 @@ class DingtalkPlatformAdapter(Platform):
self._event_queue.put_nowait(event)
async def run(self):
await self.client_.start()
# await self.client_.start()
# 钉钉的 SDK 并没有实现真正的异步,start() 里面有堵塞方法。
def start_client(loop: asyncio.AbstractEventLoop):
try:
self._shutdown_event = threading.Event()
task = loop.create_task(self.client_.start())
self._shutdown_event.wait()
if task.done():
task.result()
except Exception as e:
if "Graceful shutdown" in str(e):
logger.info("钉钉适配器已被优雅地关闭")
return
logger.error(f"钉钉机器人启动失败: {e}")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, start_client, loop)
async def terminate(self):
def monkey_patch_close():
raise Exception("Graceful shutdown")
self.client_.open_connection = monkey_patch_close
await self.client_.websocket.close(code=1000, reason="Graceful shutdown")
self._shutdown_event.set()
def get_client(self):
return self.client
+247 -33
View File
@@ -1,17 +1,26 @@
import threading
import asyncio
import aiohttp
import quart
import base64
import datetime
import re
import os
import re
import threading
import aiohttp
import anyio
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
from astrbot.api.message_components import Plain, Image, At, Record
import quart
from astrbot.api import logger, sp
from .downloader import GeweDownloader
from astrbot.api.message_components import Plain, Image, At, Record, Video
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
from astrbot.core.utils.io import download_image_by_url
from .downloader import GeweDownloader
try:
from .xml_data_parser import GeweDataParser
except (ImportError, ModuleNotFoundError) as e:
logger.warning(
f"警告: 可能未安装 defusedxml 依赖库,将导致无法解析微信的 表情包、引用 类型的消息: {str(e)}"
)
class SimpleGewechatClient:
@@ -51,11 +60,11 @@ class SimpleGewechatClient:
self.server = quart.Quart(__name__)
self.server.add_url_rule(
"/astrbot-gewechat/callback", view_func=self.callback, methods=["POST"]
"/astrbot-gewechat/callback", view_func=self._callback, methods=["POST"]
)
self.server.add_url_rule(
"/astrbot-gewechat/file/<file_id>",
view_func=self.handle_file,
view_func=self._handle_file,
methods=["GET"],
)
@@ -70,9 +79,10 @@ class SimpleGewechatClient:
self.userrealnames = {}
self.stop = False
self.shutdown_event = asyncio.Event()
async def get_token_id(self):
"""获取 Gewechat Token。"""
async with aiohttp.ClientSession() as session:
async with session.post(f"{self.base_url}/tools/getTokenId") as resp:
json_blob = await resp.json()
@@ -192,6 +202,11 @@ class SimpleGewechatClient:
abm.sender = MessageMember(user_id, user_real_name)
abm.raw_message = d
abm.message_str = ""
if user_id == "weixin":
# 忽略微信团队消息
return
# 不同消息类型
match d["MsgType"]:
case 1:
@@ -209,15 +224,10 @@ class SimpleGewechatClient:
case 34:
# 语音消息
# data = await self.multimedia_downloader.download_voice(
# self.appid,
# content,
# abm.message_id
# )
# print(data)
if "ImgBuf" in d and "buffer" in d["ImgBuf"]:
voice_data = base64.b64decode(d["ImgBuf"]["buffer"])
file_path = f"data/temp/gewe_voice_{abm.message_id}.silk"
async with await anyio.open_file(file_path, "wb") as f:
await f.write(voice_data)
abm.message.append(Record(file=file_path, url=file_path))
@@ -228,15 +238,19 @@ class SimpleGewechatClient:
case 42: # 名片
logger.info("消息类型(42):名片")
case 43: # 视频
logger.info("消息类型(43):视频")
video = Video(file="", cover=content)
abm.message.append(video)
case 47: # emoji
logger.info("消息类型(47)emoji")
data_parser = GeweDataParser(content, abm.group_id == "")
emoji = data_parser.parse_emoji()
abm.message.append(emoji)
case 48: # 地理位置
logger.info("消息类型(48):地理位置")
case 49: # 公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请
logger.info(
"消息类型(49):公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请"
)
data_parser = GeweDataParser(content, abm.group_id == "")
abm_data = data_parser.parse_mutil_49()
if abm_data:
abm.message.append(abm_data)
case 51: # 帐号消息同步?
logger.info("消息类型(51):帐号消息同步?")
case 10000: # 被踢出群聊/更换群主/修改群名称
@@ -253,7 +267,7 @@ class SimpleGewechatClient:
logger.debug(f"abm: {abm}")
return abm
async def callback(self):
async def _callback(self):
data = await quart.request.json
logger.debug(f"收到 gewechat 回调: {data}")
@@ -275,7 +289,7 @@ class SimpleGewechatClient:
return quart.jsonify({"r": "AstrBot ACK"})
async def handle_file(self, file_id):
async def _handle_file(self, file_id):
file_path = f"data/temp/{file_id}"
return await quart.send_file(file_path)
@@ -301,17 +315,14 @@ class SimpleGewechatClient:
await self.server.run_task(
host="0.0.0.0",
port=self.port,
shutdown_trigger=self.shutdown_trigger_placeholder,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger_placeholder(self):
# TODO: use asyncio.Event
while not self.event_queue.closed and not self.stop: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("gewechat 适配器已关闭。")
async def shutdown_trigger(self):
await self.shutdown_event.wait()
async def check_online(self, appid: str):
# /login/checkOnline
"""检查 APPID 对应的设备是否在线。"""
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/login/checkOnline",
@@ -322,6 +333,7 @@ class SimpleGewechatClient:
return json_blob["data"]
async def logout(self):
"""登出 gewechat。"""
if self.appid:
online = await self.check_online(self.appid)
if online:
@@ -335,6 +347,7 @@ class SimpleGewechatClient:
logger.info(f"登出结果: {json_blob}")
async def login(self):
"""登录 gewechat。一般来说插件用不到这个方法。"""
if self.token is None:
await self.get_token_id()
@@ -446,9 +459,18 @@ class SimpleGewechatClient:
self.appid = appid
logger.info(f"已保存 APPID: {appid}")
"""API"""
"""API 部分。Gewechat 的 API 文档请参考: https://apifox.com/apidoc/shared/69ba62ca-cb7d-437e-85e4-6f3d3df271b1
"""
async def get_chatroom_member_list(self, chatroom_wxid: str):
async def get_chatroom_member_list(self, chatroom_wxid: str) -> dict:
"""获取群成员列表。
Args:
chatroom_wxid (str): 微信群聊的id。可以通过 event.get_group_id() 获取。
Returns:
dict: 返回群成员列表字典。其中键为 memberList 的值为群成员列表。
"""
payload = {"appId": self.appid, "chatroomId": chatroom_wxid}
async with aiohttp.ClientSession() as session:
@@ -461,6 +483,7 @@ class SimpleGewechatClient:
return json_blob["data"]
async def post_text(self, to_wxid, content: str, ats: str = ""):
"""发送纯文本消息"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
@@ -477,6 +500,7 @@ class SimpleGewechatClient:
logger.debug(f"发送消息结果: {json_blob}")
async def post_image(self, to_wxid, image_url: str):
"""发送图片消息"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
@@ -490,7 +514,79 @@ class SimpleGewechatClient:
json_blob = await resp.json()
logger.debug(f"发送图片结果: {json_blob}")
async def post_emoji(self, to_wxid, emoji_md5, emoji_size, cdnurl=""):
"""发送emoji消息"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"emojiMd5": emoji_md5,
"emojiSize": emoji_size,
}
# 优先表情包,若拿不到表情包的md5,就用当作图片发
try:
if emoji_md5 != "" and emoji_size != "":
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/message/postEmoji",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.info(
f"发送emoji消息结果: {json_blob.get('msg', '操作失败')}"
)
else:
await self.post_image(to_wxid, cdnurl)
except Exception as e:
logger.error(e)
async def post_video(
self, to_wxid, video_url: str, thumb_url: str, video_duration: int
):
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"videoUrl": video_url,
"thumbUrl": thumb_url,
"videoDuration": video_duration,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/message/postVideo", headers=self.headers, json=payload
) as resp:
json_blob = await resp.json()
logger.debug(f"发送视频结果: {json_blob}")
async def forward_video(self, to_wxid, cnd_xml: str):
"""转发视频
Args:
to_wxid (str): 发送给谁
cnd_xml (str): 视频消息的cdn信息
"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"xml": cnd_xml,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/message/forwardVideo",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"转发视频结果: {json_blob}")
async def post_voice(self, to_wxid, voice_url: str, voice_duration: int):
"""发送语音信息
Args:
voice_url (str): 语音文件的网络链接
voice_duration (int): 语音时长,毫秒
"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
@@ -505,9 +601,16 @@ class SimpleGewechatClient:
f"{self.base_url}/message/postVoice", headers=self.headers, json=payload
) as resp:
json_blob = await resp.json()
logger.debug(f"发送语音结果: {json_blob}")
logger.info(f"发送语音结果: {json_blob.get('msg', '操作失败')}")
async def post_file(self, to_wxid, file_url: str, file_name: str):
"""发送文件
Args:
to_wxid (string): 微信ID
file_url (str): 文件的网络链接
file_name (str): 文件名
"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
@@ -521,3 +624,114 @@ class SimpleGewechatClient:
) as resp:
json_blob = await resp.json()
logger.debug(f"发送文件结果: {json_blob}")
async def add_friend(self, v3: str, v4: str, content: str):
"""申请添加好友"""
payload = {
"appId": self.appid,
"scene": 3,
"content": content,
"v4": v4,
"v3": v3,
"option": 2,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/contacts/addContacts",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"申请添加好友结果: {json_blob}")
return json_blob
async def get_group(self, group_id: str):
payload = {
"appId": self.appid,
"chatroomId": group_id,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/group/getChatroomInfo",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
async def get_group_member(self, group_id: str):
payload = {
"appId": self.appid,
"chatroomId": group_id,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/group/getChatroomMemberList",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
async def accept_group_invite(self, url: str):
"""同意进群"""
payload = {"appId": self.appid, "url": url}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/group/agreeJoinRoom",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
async def add_group_member_to_friend(
self, group_id: str, to_wxid: str, content: str
):
payload = {
"appId": self.appid,
"chatroomId": group_id,
"content": content,
"memberWxid": to_wxid,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/group/addGroupMemberAsFriend",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
async def get_user_or_group_info(self, *ids):
"""
获取用户或群组信息。
:param ids: 可变数量的 wxid 参数
"""
wxids_str = list(ids)
payload = {
"appId": self.appid,
"wxids": wxids_str, # 使用逗号分隔的字符串
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/contacts/getDetailInfo",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
@@ -39,3 +39,17 @@ class GeweDownloader:
continue
raise Exception("无法下载图片")
async def download_emoji_md5(self, app_id, emoji_md5):
"""下载emoji"""
try:
payload = {"appId": app_id, "emojiMd5": emoji_md5}
# gewe 计划中的接口,暂时没有实现。返回代码404
data = await self._post_json(
self.base_url, "/message/downloadEmojiMd5", payload
)
json_blob = json.loads(data)
return json_blob
except BaseException as e:
logger.error(f"gewe download emoji: {e}")
@@ -2,12 +2,21 @@ import wave
import uuid
import traceback
import os
from astrbot.core.utils.io import save_temp_img, download_image_by_url, download_file
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
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Record, At, File
from astrbot.api.platform import AstrBotMessage, PlatformMetadata, Group, MessageMember
from astrbot.api.message_components import (
Plain,
Image,
Record,
At,
File,
Video,
WechatEmoji as Emoji,
)
from .client import SimpleGewechatClient
@@ -70,18 +79,10 @@ class GewechatPlatformEvent(AstrMessageEvent):
await client.post_text(**payload)
elif isinstance(comp, Image):
img_url = comp.file
img_path = ""
if img_url.startswith("file:///"):
img_path = img_url[8:]
elif comp.file and comp.file.startswith("http"):
img_path = await download_image_by_url(comp.file)
else:
img_path = img_url
img_path = await comp.convert_to_file_path()
# 检查 record_path 是否在 data/temp 目录中, record_path 可能是绝对路径
# 检查 record_path 是否在 data/temp 目录中
temp_directory = os.path.abspath("data/temp")
img_path = os.path.abspath(img_path)
if os.path.commonpath([temp_directory, img_path]) != temp_directory:
with open(img_path, "rb") as f:
img_path = save_temp_img(f.read())
@@ -90,17 +91,65 @@ class GewechatPlatformEvent(AstrMessageEvent):
img_url = f"{client.file_server_url}/{file_id}"
logger.debug(f"gewe callback img url: {img_url}")
await client.post_image(to_wxid, img_url)
elif isinstance(comp, Video):
if comp.cover != "":
await client.forward_video(to_wxid, comp.cover)
else:
try:
from pyffmpeg import FFmpeg
except (ImportError, ModuleNotFoundError):
logger.error(
"需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg"
)
raise ModuleNotFoundError(
"需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg"
)
video_url = comp.file
# 根据 url 下载视频
video_filename = f"{uuid.uuid4()}.mp4"
video_path = f"data/temp/{video_filename}"
await download_file(video_url, video_path)
# 获取视频第一帧
thumb_path = f"data/temp/{uuid.uuid4()}.jpg"
try:
ff = FFmpeg()
command = f'-i "{video_path}" -ss 0 -vframes 1 "{thumb_path}"'
ff.options(command)
thumb_file_id = os.path.basename(thumb_path)
thumb_url = f"{client.file_server_url}/{thumb_file_id}"
except Exception as e:
logger.error(f"获取视频第一帧失败: {e}")
# 获取视频时长
try:
from pyffmpeg import FFprobe
# 创建 FFprobe 实例
ffprobe = FFprobe(video_url)
# 获取时长字符串
duration_str = ffprobe.duration
# 处理时长字符串
video_duration = float(duration_str.replace(":", ""))
except Exception as e:
logger.error(f"获取时长失败: {e}")
video_duration = 10
file_id = os.path.basename(video_path)
video_url = f"{client.file_server_url}/{file_id}"
await client.post_video(
to_wxid, video_url, thumb_url, video_duration
)
# 删除临时视频和缩略图文件
if os.path.exists(video_path):
os.remove(video_path)
if os.path.exists(thumb_path):
os.remove(thumb_path)
elif isinstance(comp, Record):
# 默认已经存在 data/temp 中
record_url = comp.file
record_path = ""
if record_url.startswith("file:///"):
record_path = record_url[8:]
elif record_url.startswith("http"):
await download_file(record_url, f"data/temp/{uuid.uuid4()}.wav")
else:
record_path = record_url
record_path = await comp.convert_to_file_path()
silk_path = f"data/temp/{uuid.uuid4()}.silk"
try:
@@ -129,6 +178,8 @@ class GewechatPlatformEvent(AstrMessageEvent):
file_url = f"{client.file_server_url}/{file_id}"
logger.debug(f"gewe callback file url: {file_url}")
await client.post_file(to_wxid, file_url, file_id)
elif isinstance(comp, Emoji):
await client.post_emoji(to_wxid, comp.md5, comp.md5_len, comp.cdnurl)
elif isinstance(comp, At):
pass
else:
@@ -138,3 +189,30 @@ class GewechatPlatformEvent(AstrMessageEvent):
to_wxid = self.message_obj.raw_message.get("to_wxid", None)
await GewechatPlatformEvent.send_with_client(message, to_wxid, self.client)
await super().send(message)
async def get_group(self, group_id=None, **kwargs):
# 确定有效的 group_id
if group_id is None:
group_id = self.get_group_id()
if not group_id:
return None
res = await self.client.get_group(group_id)
data: dict = res["data"]
if not data["chatroomId"]:
return None
members = [
MessageMember(user_id=member["wxid"], nickname=member["nickName"])
for member in data.get("memberList", [])
]
return Group(
group_id=data["chatroomId"],
group_name=data.get("nickName"),
group_avatar=data.get("smallHeadImgUrl"),
group_owner=data.get("chatRoomOwner"),
members=members,
)
@@ -8,6 +8,7 @@ from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
from .gewechat_event import GewechatPlatformEvent
from .client import SimpleGewechatClient
from astrbot import logger
if sys.version_info >= (3, 12):
from typing import override
@@ -64,8 +65,9 @@ class GewechatPlatformAdapter(Platform):
)
async def terminate(self):
self.client.stop = True
await asyncio.sleep(1)
self.client.shutdown_event.set()
await self.client.server.shutdown()
logger.info("Gewechat 适配器已被优雅地关闭。")
async def logout(self):
await self.client.logout()
@@ -0,0 +1,78 @@
from defusedxml import ElementTree as eT
from astrbot.api import logger
from astrbot.api.message_components import WechatEmoji as Emoji, Reply, Plain
class GeweDataParser:
def __init__(self, data, is_private_chat):
self.data = data
self.is_private_chat = is_private_chat
def _format_to_xml(self):
return eT.fromstring(self.data)
def parse_mutil_49(self):
appmsg_type = self._format_to_xml().find(".//appmsg/type")
if appmsg_type is None:
return
match appmsg_type.text:
case "57":
return self.parse_reply()
def parse_emoji(self) -> Emoji | None:
try:
emoji_element = self._format_to_xml().find(".//emoji")
# 提取 md5 和 len 属性
if emoji_element is not None:
md5_value = emoji_element.get("md5")
emoji_size = emoji_element.get("len")
cdnurl = emoji_element.get("cdnurl")
return Emoji(md5=md5_value, md5_len=emoji_size, cdnurl=cdnurl)
except Exception as e:
logger.error(f"gewechat: parse_emoji failed, {e}")
def parse_reply(self) -> Reply | None:
try:
replied_id = -1
replied_uid = 0
replied_nickname = ""
replied_content = ""
content = ""
root = self._format_to_xml()
refermsg = root.find(".//refermsg")
if refermsg is not None:
# 被引用的信息
svrid = refermsg.find("svrid")
fromusr = refermsg.find("fromusr")
displayname = refermsg.find("displayname")
refermsg_content = refermsg.find("content")
if svrid is not None:
replied_id = svrid.text
if fromusr is not None:
replied_uid = fromusr.text
if displayname is not None:
replied_nickname = displayname.text
if refermsg_content is not None:
replied_content = refermsg_content.text
# 提取引用者说的内容
title = root.find(".//appmsg/title")
if title is not None:
content = title.text
r = Reply(
id=replied_id,
chain=[Plain(content)],
sender_id=replied_uid,
sender_nickname=replied_nickname,
sender_str=replied_content,
message_str=content,
)
return r
except Exception as e:
logger.error(f"gewechat: parse_reply failed, {e}")
@@ -2,6 +2,7 @@ import base64
import asyncio
import json
import re
import astrbot.api.message_components as Comp
from astrbot.api.platform import (
Platform,
@@ -11,7 +12,6 @@ from astrbot.api.platform import (
PlatformMetadata,
)
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Image, Plain, At
from astrbot.core.platform.astr_message_event import MessageSesion
from .lark_event import LarkMessageEvent
from ...register import register_platform_adapter
@@ -92,7 +92,7 @@ class LarkPlatformAdapter(Platform):
at_list = {}
if message.mentions:
for m in message.mentions:
at_list[m.key] = At(qq=m.id.open_id, name=m.name)
at_list[m.key] = Comp.At(qq=m.id.open_id, name=m.name)
if m.name == self.bot_name:
abm.self_id = m.id.open_id
@@ -111,7 +111,7 @@ class LarkPlatformAdapter(Platform):
if s in at_list:
abm.message.append(at_list[s])
else:
abm.message.append(Plain(parts[i].strip()))
abm.message.append(Comp.Plain(parts[i].strip()))
elif message.message_type == "post":
_ls = []
@@ -132,7 +132,7 @@ class LarkPlatformAdapter(Platform):
if comp["tag"] == "at":
abm.message.append(at_list[comp["user_id"]])
elif comp["tag"] == "text" and comp["text"].strip():
abm.message.append(Plain(comp["text"].strip()))
abm.message.append(Comp.Plain(comp["text"].strip()))
elif comp["tag"] == "img":
image_key = comp["image_key"]
request = (
@@ -147,10 +147,10 @@ class LarkPlatformAdapter(Platform):
logger.error(f"无法下载飞书图片: {image_key}")
image_bytes = response.file.read()
image_base64 = base64.b64encode(image_bytes).decode()
abm.message.append(Image.fromBase64(image_base64))
abm.message.append(Comp.Image.fromBase64(image_base64))
for comp in abm.message:
if isinstance(comp, Plain):
if isinstance(comp, Comp.Plain):
abm.message_str += comp.text
abm.message_id = message.message_id
abm.raw_message = message
@@ -185,5 +185,9 @@ class LarkPlatformAdapter(Platform):
# self.client.start()
await self.client._connect()
async def terminate(self):
await self.client._disconnect()
logger.info("飞书(Lark) 适配器已被优雅地关闭")
def get_client(self) -> lark.Client:
return self.client
@@ -17,6 +17,7 @@ from astrbot.api.platform import (
MessageType,
PlatformMetadata,
)
from astrbot import logger
from astrbot.api.event import MessageChain
from typing import Union, List
from astrbot.api.message_components import Image, Plain, At
@@ -204,3 +205,7 @@ class QQOfficialPlatformAdapter(Platform):
def get_client(self) -> botClient:
return self.client
async def terminate(self):
await self.client.close()
logger.info("QQ 官方机器人接口 适配器已被优雅地关闭")
@@ -13,6 +13,7 @@ from .qo_webhook_event import QQOfficialWebhookMessageEvent
from ...register import register_platform_adapter
from .qo_webhook_server import QQOfficialWebhook
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
from astrbot import logger
# remove logger handler
for handler in logging.root.handlers[:]:
@@ -111,3 +112,9 @@ class QQOfficialWebhookPlatformAdapter(Platform):
def get_client(self) -> botClient:
return self.client
async def terminate(self):
self.webhook_helper.shutdown_event.set()
await self.client.close()
await self.webhook_helper.server.shutdown()
logger.info("QQ 机器人官方 API 适配器已经被优雅地关闭")
@@ -30,6 +30,7 @@ class QQOfficialWebhook:
)
self.client = botpy_client
self.event_queue = event_queue
self.shutdown_event = asyncio.Event()
async def initialize(self):
logger.info("正在登录到 QQ 官方机器人...")
@@ -102,10 +103,8 @@ class QQOfficialWebhook:
await self.server.run_task(
host=self.callback_server_host,
port=self.port,
shutdown_trigger=self.shutdown_trigger_placeholder,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger_placeholder(self):
while not self.event_queue.closed: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("qq_official_webhook 适配器已关闭。")
async def shutdown_trigger(self):
await self.shutdown_event.wait()
@@ -1,6 +1,7 @@
import sys
import uuid
import asyncio
import astrbot.api.message_components as Comp
from astrbot.api.platform import (
Platform,
@@ -10,15 +11,6 @@ from astrbot.api.platform import (
MessageType,
)
from astrbot.api.event import MessageChain
from astrbot.api.message_components import (
Plain,
Image,
Record,
File as AstrBotFile,
Video,
At,
Reply,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.api.platform import register_platform_adapter
@@ -108,7 +100,8 @@ class TelegramPlatformAdapter(Platform):
async def message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
logger.debug(f"Telegram message: {update.message}")
abm = await self.convert_message(update, context)
await self.handle_msg(abm)
if abm:
await self.handle_msg(abm)
async def convert_message(
self, update: Update, context: ContextTypes.DEFAULT_TYPE, get_reply=True
@@ -120,6 +113,7 @@ class TelegramPlatformAdapter(Platform):
@param get_reply: 是否获取回复消息。这个参数是为了防止多个回复嵌套。
"""
message = AstrBotMessage()
message.session_id = str(update.message.chat.id)
# 获得是群聊还是私聊
if update.message.chat.type == ChatType.PRIVATE:
message.type = MessageType.FRIEND_MESSAGE
@@ -129,9 +123,9 @@ class TelegramPlatformAdapter(Platform):
if update.message.message_thread_id:
# Topic Group
message.group_id += "#" + str(update.message.message_thread_id)
message.session_id = message.group_id
message.message_id = str(update.message.message_id)
message.session_id = str(update.message.chat.id)
message.sender = MessageMember(
str(update.message.from_user.id), update.message.from_user.username
)
@@ -140,7 +134,11 @@ class TelegramPlatformAdapter(Platform):
message.message_str = ""
message.message = []
if update.message.reply_to_message:
if update.message.reply_to_message and not (
update.message.is_topic_message
and update.message.message_thread_id
== update.message.reply_to_message.message_id
):
# 获取回复消息
reply_update = Update(
update_id=1,
@@ -149,7 +147,7 @@ class TelegramPlatformAdapter(Platform):
reply_abm = await self.convert_message(reply_update, context, False)
message.message.append(
Reply(
Comp.Reply(
id=reply_abm.message_id,
chain=reply_abm.message,
sender_id=reply_abm.sender.user_id,
@@ -171,43 +169,60 @@ class TelegramPlatformAdapter(Platform):
name = plain_text[
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(At(qq=name, name=name))
message.message.append(Comp.At(qq=name, name=name))
plain_text = (
plain_text[: entity.offset]
+ plain_text[entity.offset + entity.length :]
)
if plain_text:
message.message.append(Plain(plain_text))
message.message.append(Comp.Plain(plain_text))
message.message_str = plain_text
if message.message_str == "/start":
if message.message_str.strip() == "/start":
await self.start(update, context)
return
elif update.message.voice:
file = await update.message.voice.get_file()
message.message = [
Record(file=file.file_path, url=file.file_path),
Comp.Record(file=file.file_path, url=file.file_path),
]
elif update.message.photo:
photo = update.message.photo[-1] # get the largest photo
file = await photo.get_file()
message.message.append(Image(file=file.file_path, url=file.file_path))
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
if update.message.caption:
message.message_str = update.message.caption
message.message.append(Comp.Plain(message.message_str))
if update.message.caption_entities:
for entity in update.message.caption_entities:
if entity.type == "mention":
name = message.message_str[
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(Comp.At(qq=name, name=name))
elif update.message.sticker:
# 将sticker当作图片处理
file = await update.message.sticker.get_file()
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
if update.message.sticker.emoji:
sticker_text = f"Sticker: {update.message.sticker.emoji}"
message.message_str = sticker_text
message.message.append(Comp.Plain(sticker_text))
elif update.message.document:
file = await update.message.document.get_file()
message.message = [
AstrBotFile(
file=file.file_path, name=update.message.document.file_name
),
Comp.File(file=file.file_path, name=update.message.document.file_name),
]
elif update.message.video:
file = await update.message.video.get_file()
message.message = [
Video(file=file.file_path, path=file.file_path),
Comp.Video(file=file.file_path, path=file.file_path),
]
return message
@@ -224,3 +239,15 @@ class TelegramPlatformAdapter(Platform):
def get_client(self) -> ExtBot:
return self.client
async def terminate(self):
try:
await self.application.stop()
# 保险起见先判断是否存在updater对象
if self.application.updater is not None:
await self.application.updater.stop()
logger.info("Telegram 适配器已被优雅地关闭")
except Exception as e:
logger.error(f"Telegram 适配器关闭时出错: {e}")
@@ -43,7 +43,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
if has_reply:
payload["reply_to_message_id"] = reply_message_id
if message_thread_id:
payload["reply_to_message_id"] = message_thread_id
payload["message_thread_id"] = message_thread_id
if isinstance(i, Plain):
if at_user_id and not at_flag:
@@ -51,19 +51,8 @@ class TelegramPlatformEvent(AstrMessageEvent):
at_flag = True
await client.send_message(text=i.text, **payload)
elif isinstance(i, Image):
if i.path:
image_path = i.path
else:
image_path = i.file
if image_path.startswith("base64://"):
import base64
base64_data = image_path[9:]
image_bytes = base64.b64decode(base64_data)
await client.send_photo(photo=image_bytes, **payload)
else:
await client.send_photo(photo=image_path, **payload)
image_path = await i.convert_to_file_path()
await client.send_photo(photo=image_path, **payload)
elif isinstance(i, File):
if i.file.startswith("https://"):
path = "data/temp/" + i.name
@@ -72,7 +61,8 @@ class TelegramPlatformEvent(AstrMessageEvent):
await client.send_document(document=i.file, filename=i.name, **payload)
elif isinstance(i, Record):
await client.send_voice(voice=i.file, **payload)
path = await i.convert_to_file_path()
await client.send_voice(voice=path, **payload)
async def send(self, message: MessageChain):
if self.get_message_type() == MessageType.GROUP_MESSAGE:
@@ -119,3 +119,7 @@ class WebChatAdapter(Platform):
)
self.commit_event(message_event)
async def terminate(self):
# Do nothing
pass
@@ -50,6 +50,7 @@ class WecomServer:
)
self.callback = None
self.shutdown_event = asyncio.Event()
async def verify(self):
logger.info(f"验证请求有效性: {quart.request.args}")
@@ -93,13 +94,11 @@ class WecomServer:
await self.server.run_task(
host=self.callback_server_host,
port=self.port,
shutdown_trigger=self.shutdown_trigger_placeholder,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger_placeholder(self):
while not self.event_queue.closed: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("企业微信 适配器已关闭。")
async def shutdown_trigger(self):
await self.shutdown_event.wait()
@register_platform_adapter("wecom", "wecom 适配器")
@@ -235,3 +234,8 @@ class WecomPlatformAdapter(Platform):
def get_client(self) -> WeChatClient:
return self.client
async def terminate(self):
self.server.shutdown_event.set()
await self.server.server.shutdown()
logger.info("企业微信 适配器已被优雅地关闭")
@@ -3,7 +3,6 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Record
from wechatpy.enterprise import WeChatClient
from astrbot.core.utils.io import download_image_by_url, download_file
from astrbot.api import logger
@@ -43,14 +42,7 @@ class WecomPlatformEvent(AstrMessageEvent):
message_obj.self_id, message_obj.session_id, comp.text
)
elif isinstance(comp, Image):
img_url = comp.file
img_path = ""
if img_url.startswith("file:///"):
img_path = img_url[8:]
elif comp.file and comp.file.startswith("http"):
img_path = await download_image_by_url(comp.file)
else:
img_path = img_url
img_path = await comp.convert_to_file_path()
with open(img_path, "rb") as f:
try:
@@ -68,16 +60,7 @@ class WecomPlatformEvent(AstrMessageEvent):
response["media_id"],
)
elif isinstance(comp, Record):
record_url = comp.file
record_path = ""
if record_url.startswith("file:///"):
record_path = record_url[8:]
elif record_url.startswith("http"):
await download_file(record_url, f"data/temp/{uuid.uuid4()}.wav")
else:
record_path = record_url
record_path = await comp.convert_to_file_path()
# 转成amr
record_path_amr = f"data/temp/{uuid.uuid4()}.amr"
pydub.AudioSegment.from_wav(record_path).export(
+203 -4
View File
@@ -1,10 +1,18 @@
import enum
import base64
import json
from astrbot.core.utils.io import download_image_by_url
from astrbot import logger
from dataclasses import dataclass, field
from typing import List, Dict, Type
from .func_tool_manager import FuncCall
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
)
from astrbot.core.db.po import Conversation
from astrbot.core.message.message_event_result import MessageChain
import astrbot.core.message.components as Comp
class ProviderType(enum.Enum):
@@ -28,6 +36,58 @@ class ProviderMetaData:
"""显示在 WebUI 配置页中的提供商名称,如空则是 type"""
@dataclass
class ToolCallMessageSegment:
"""OpenAI 格式的上下文中 role 为 tool 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
tool_call_id: str
content: str
role: str = "tool"
def to_dict(self):
return {
"tool_call_id": self.tool_call_id,
"content": self.content,
"role": self.role,
}
@dataclass
class AssistantMessageSegment:
"""OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
content: str = None
tool_calls: List[ChatCompletionMessageToolCall | Dict] = None
role: str = "assistant"
def to_dict(self):
ret = {
"role": self.role,
}
if self.content:
ret["content"] = self.content
elif self.tool_calls:
ret["tool_calls"] = self.tool_calls
return ret
@dataclass
class ToolCallsResult:
"""工具调用结果"""
tool_calls_info: AssistantMessageSegment
"""函数调用的信息"""
tool_calls_result: List[ToolCallMessageSegment]
"""函数调用的结果"""
def to_openai_messages(self) -> List[Dict]:
ret = [
self.tool_calls_info.to_dict(),
*[item.to_dict() for item in self.tool_calls_result],
]
return ret
@dataclass
class ProviderRequest:
prompt: str
@@ -37,7 +97,7 @@ class ProviderRequest:
image_urls: List[str] = None
"""图片 URL 列表"""
func_tool: FuncCall = None
"""工具"""
"""可用的函数工具"""
contexts: List = None
"""上下文。格式与 openai 的上下文格式一致:
参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages
@@ -46,12 +106,85 @@ class ProviderRequest:
"""系统提示词"""
conversation: Conversation = None
tool_calls_result: ToolCallsResult = None
"""附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls"""
def __repr__(self):
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self.contexts}, system_prompt={self.system_prompt.strip()})"
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self._print_friendly_context()}, system_prompt={self.system_prompt.strip()}, tool_calls_result={self.tool_calls_result})"
def __str__(self):
return self.__repr__()
def _print_friendly_context(self):
"""打印友好的消息上下文。将 image_url 的值替换为 <Image>"""
if not self.contexts:
return f"prompt: {self.prompt}, image_count: {len(self.image_urls or [])}"
result_parts = []
for ctx in self.contexts:
role = ctx.get("role", "unknown")
content = ctx.get("content", "")
if isinstance(content, str):
result_parts.append(f"{role}: {content}")
elif isinstance(content, list):
msg_parts = []
image_count = 0
for item in content:
item_type = item.get("type", "")
if item_type == "text":
msg_parts.append(item.get("text", ""))
elif item_type == "image_url":
image_count += 1
if image_count > 0:
if msg_parts:
msg_parts.append(f"[+{image_count} images]")
else:
msg_parts.append(f"[{image_count} images]")
result_parts.append(f"{role}: {''.join(msg_parts)}")
return result_parts
async def assemble_context(self) -> Dict:
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
if self.image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": self.prompt}],
}
for image_url in self.image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self._encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self._encode_image_bs64(image_path)
else:
image_data = await self._encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
user_content["content"].append(
{"type": "image_url", "image_url": {"url": image_data}}
)
return user_content
else:
return {"role": "user", "content": self.prompt}
async def _encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
if image_url.startswith("base64://"):
return image_url.replace("base64://", "data:image/jpeg;base64,")
with open(image_url, "rb") as f:
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
return ""
@dataclass
class LLMResponse:
@@ -59,12 +192,78 @@ class LLMResponse:
"""角色, assistant, tool, err"""
result_chain: MessageChain = None
"""返回的消息链"""
completion_text: str = ""
"""LLM 返回的文本, 已经废弃但仍然兼容。使用 result_chain 替代"""
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
"""工具调用参数"""
tools_call_name: List[str] = field(default_factory=list)
"""工具调用名称"""
tools_call_ids: List[str] = field(default_factory=list)
"""工具调用 ID"""
raw_completion: ChatCompletion = None
_new_record: Dict[str, any] = None
_completion_text: str = ""
def __init__(
self,
role: str,
completion_text: str = "",
result_chain: MessageChain = None,
tools_call_args: List[Dict[str, any]] = [],
tools_call_name: List[str] = [],
tools_call_ids: List[str] = [],
raw_completion: ChatCompletion = None,
_new_record: Dict[str, any] = None,
):
"""初始化 LLMResponse
Args:
role (str): 角色, assistant, tool, err
completion_text (str, optional): 返回的结果文本,已经过时,推荐使用 result_chain. Defaults to "".
result_chain (MessageChain, optional): 返回的消息链. Defaults to None.
tools_call_args (List[Dict[str, any]], optional): 工具调用参数. Defaults to None.
tools_call_name (List[str], optional): 工具调用名称. Defaults to None.
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
"""
self.role = role
self.completion_text = completion_text
self.result_chain = result_chain
self.tools_call_args = tools_call_args
self.tools_call_name = tools_call_name
self.tools_call_ids = tools_call_ids
self.raw_completion = raw_completion
self._new_record = _new_record
@property
def completion_text(self):
if self.result_chain:
return self.result_chain.get_plain_text()
return self._completion_text
@completion_text.setter
def completion_text(self, value):
if self.result_chain:
self.result_chain.chain = [
comp
for comp in self.result_chain.chain
if not isinstance(comp, Comp.Plain)
] # 清空 Plain 组件
self.result_chain.chain.insert(0, Comp.Plain(value))
else:
self._completion_text = value
def to_openai_tool_calls(self) -> List[Dict]:
"""将工具调用信息转换为 OpenAI 格式"""
ret = []
for idx, tool_call_arg in enumerate(self.tools_call_args):
ret.append(
{
"id": self.tools_call_ids[idx],
"function": {
"name": self.tools_call_name[idx],
"arguments": json.dumps(tool_call_arg),
},
"type": "function",
}
)
return ret
+279 -21
View File
@@ -1,9 +1,31 @@
from __future__ import annotations
import json
import textwrap
from typing import Dict, List, Awaitable
import os
import asyncio
import copy
from typing import Dict, List, Awaitable, Literal, Any
from dataclasses import dataclass
from typing import Optional
from contextlib import AsyncExitStack
from astrbot import logger
try:
import mcp
except (ModuleNotFoundError, ImportError):
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
SUPPORTED_TYPES = [
"string",
"number",
"object",
"array",
"boolean",
] # json schema 支持的数据类型
@dataclass
class FuncTool:
@@ -14,28 +36,101 @@ class FuncTool:
name: str
parameters: Dict
description: str
handler: Awaitable
handler_module_path: str = None # 必须要保留这个,handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
handler: Awaitable = None
"""处理函数, 当 origin 为 mcp 时,这个为空"""
handler_module_path: str = None
"""处理函数的模块路径,当 origin 为 mcp 时,这个为空
必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
"""
active: bool = True
"""是否激活"""
origin: Literal["local", "mcp"] = "local"
"""函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务"""
# MCP 相关字段
mcp_server_name: str = None
"""MCP 服务名称,当 origin 为 mcp 时有效"""
mcp_client: MCPClient = None
"""MCP 客户端,当 origin 为 mcp 时有效"""
def __repr__(self):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}), active={self.active})"
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})"
async def execute(self, **args) -> Any:
"""执行函数调用"""
if self.origin == "local":
if not self.handler:
raise Exception(f"Local function {self.name} has no handler")
return await self.handler(**args)
elif self.origin == "mcp":
if not self.mcp_client or not self.mcp_client.session:
raise Exception(f"MCP client for {self.name} is not available")
# 使用name属性而不是额外的mcp_tool_name
if ":" in self.name:
# 如果名字是格式为 mcp:server:tool_name,提取实际的工具名
actual_tool_name = self.name.split(":")[-1]
return await self.mcp_client.session.call_tool(actual_tool_name, args)
else:
return await self.mcp_client.session.call_tool(self.name, args)
else:
raise Exception(f"Unknown function origin: {self.origin}")
SUPPORTED_TYPES = [
"string",
"number",
"object",
"array",
"boolean",
] # json schema 支持的数据类型
class MCPClient:
def __init__(self):
# Initialize session and client objects
self.session: Optional[mcp.ClientSession] = None
self.exit_stack = AsyncExitStack()
self.name = None
self.active: bool = True
self.tools: List[mcp.Tool] = []
async def connect_to_server(self, mcp_server_config: dict):
"""Connect to an MCP server
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,
)
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)
)
await self.session.initialize()
async def list_tools_and_save(self) -> mcp.ListToolsResult:
"""List all tools from the server and save them to self.tools"""
response = await self.session.list_tools()
logger.debug(f"MCP server {self.name} list tools response: {response}")
self.tools = response.tools
return response
async def cleanup(self):
"""Clean up resources"""
await self.exit_stack.aclose()
class FuncCall:
def __init__(self) -> None:
self.func_list: List[FuncTool] = []
"""内部加载的 func tools"""
self.mcp_client_dict: Dict[str, MCPClient] = {}
"""MCP 服务列表"""
self.mcp_service_queue = asyncio.Queue()
"""用于外部控制 MCP 服务的启停"""
self.mcp_client_event: Dict[str, asyncio.Event] = {}
def empty(self) -> bool:
return len(self.func_list) == 0
@@ -90,11 +185,166 @@ class FuncCall:
return f
return None
async def _init_mcp_clients(self) -> None:
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
```
{
"mcpServers": {
"weather": {
"command": "uv",
"args": [
"--directory",
"/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather",
"run",
"weather.py"
]
}
}
...
}
```
"""
current_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
mcp_json_file = os.path.join(data_dir, "mcp_server.json")
if not os.path.exists(mcp_json_file):
# 配置文件不存在错误处理
with open(mcp_json_file, "w", encoding="utf-8") as f:
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
return
mcp_server_json_obj: Dict[str, Dict] = json.load(
open(mcp_json_file, "r", encoding="utf-8")
)["mcpServers"]
for name in mcp_server_json_obj.keys():
cfg = mcp_server_json_obj[name]
if cfg.get("active", True):
event = asyncio.Event()
asyncio.create_task(
self._init_mcp_client_task_wrapper(name, cfg, event)
)
self.mcp_client_event[name] = event
async def mcp_service_selector(self):
"""为了避免在不同异步任务中控制 MCP 服务导致的报错,整个项目统一通过这个 Task 来控制
使用 self.mcp_service_queue.put_nowait() 来控制 MCP 服务的启停,数据格式如下:
{"type": "init"} 初始化所有MCP客户端
{"type": "init", "name": "mcp_server_name", "cfg": {...}} 初始化指定的MCP客户端
{"type": "terminate"} 终止所有MCP客户端
{"type": "terminate", "name": "mcp_server_name"} 终止指定的MCP客户端
"""
while True:
data = await self.mcp_service_queue.get()
if data["type"] == "init":
if "name" in data:
event = asyncio.Event()
asyncio.create_task(
self._init_mcp_client_task_wrapper(
data["name"], data["cfg"], event
)
)
self.mcp_client_event[data["name"]] = event
else:
await self._init_mcp_clients()
elif data["type"] == "terminate":
if "name" in data:
# await self._terminate_mcp_client(data["name"])
if data["name"] in self.mcp_client_event:
self.mcp_client_event[data["name"]].set()
self.mcp_client_event.pop(data["name"], None)
else:
for name in self.mcp_client_dict.keys():
# await self._terminate_mcp_client(name)
# self.mcp_client_event[name].set()
if name in self.mcp_client_event:
self.mcp_client_event[name].set()
self.mcp_client_event.pop(name, None)
async def _init_mcp_client_task_wrapper(
self, name: str, cfg: dict, event: asyncio.Event
) -> None:
"""初始化 MCP 客户端的包装函数,用于捕获异常"""
try:
await self._init_mcp_client(name, cfg)
await event.wait()
logger.info(f"收到 MCP 客户端 {name} 终止信号")
await self._terminate_mcp_client(name)
except Exception as e:
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
async def _init_mcp_client(self, name: str, config: dict) -> None:
"""初始化单个MCP客户端"""
try:
# 先清理之前的客户端,如果存在
if name in self.mcp_client_dict:
await self._terminate_mcp_client(name)
mcp_client = MCPClient()
mcp_client.name = name
await mcp_client.connect_to_server(config)
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 = [
f
for f in self.func_list
if not (f.origin == "mcp" and f.mcp_server_name == name)
]
# 将 MCP 工具转换为 FuncTool 并添加到 func_list
for tool in mcp_client.tools:
func_tool = FuncTool(
name=tool.name,
parameters=tool.inputSchema,
description=tool.description,
origin="mcp",
mcp_server_name=name,
mcp_client=mcp_client,
)
self.func_list.append(func_tool)
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
return True
except Exception as e:
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
# 发生错误时确保客户端被清理
if name in self.mcp_client_dict:
await self._terminate_mcp_client(name)
return False
async def _terminate_mcp_client(self, name: str) -> None:
"""关闭并清理MCP客户端"""
if name in self.mcp_client_dict:
try:
# 关闭MCP连接
await self.mcp_client_dict[name].cleanup()
del self.mcp_client_dict[name]
except Exception as e:
logger.info(f"清空 MCP 客户端资源 {name}: {e}")
# 移除关联的FuncTool
self.func_list = [
f
for f in self.func_list
if not (f.origin == "mcp" and f.mcp_server_name == name)
]
logger.info(f"已关闭 MCP 服务 {name}")
def get_func_desc_openai_style(self) -> list:
"""
获得 OpenAI API 风格的**已经激活**的工具描述
"""
_l = []
# 处理所有工具(包括本地和MCP工具)
for f in self.func_list:
if not f.active:
continue
@@ -144,7 +394,13 @@ class FuncCall:
# 检查并添加非空的properties参数
params = f.parameters if isinstance(f.parameters, dict) else {}
params = copy.deepcopy(params)
if params.get("properties", {}):
properties = params["properties"]
for key, value in properties.items():
if "default" in value:
del value["default"]
params["properties"] = properties
func_declaration["parameters"] = params
tools.append(func_declaration)
@@ -160,9 +416,9 @@ class FuncCall:
continue
_l.append(
{
"name": f["name"],
"parameters": f["parameters"],
"description": f["description"],
"name": f.name,
"parameters": f.parameters,
"description": f.description,
}
)
func_definition = json.dumps(_l, ensure_ascii=False)
@@ -212,14 +468,11 @@ class FuncCall:
func_name = tool["name"]
args = tool["args"]
# 调用函数
tool_callable = None
for func in self.func_list:
if func.name == func_name:
tool_callable = func.star_handler_metadata.handler
break
if not tool_callable:
func_tool = self.get_func(func_name)
if not func_tool:
raise Exception(f"Request function {func_name} not found.")
ret = await tool_callable(**args)
ret = await func_tool.execute(**args)
if ret:
tool_call_result.append(str(ret))
return tool_call_result, True
@@ -229,3 +482,8 @@ class FuncCall:
def __repr__(self):
return str(self.func_list)
async def terminate(self):
for name in self.mcp_client_dict.keys():
await self._terminate_mcp_client(name)
logger.debug(f"清理 MCP 客户端 {name} 资源")
+9
View File
@@ -1,4 +1,5 @@
import traceback
import asyncio
from astrbot.core.config.astrbot_config import AstrBotConfig
from .provider import Provider, STTProvider, TTSProvider, Personality
from .entites import ProviderType
@@ -127,6 +128,12 @@ class ProviderManager:
if self.tts_enabled and not self.curr_tts_provider_inst:
logger.warning("未启用任何用于 文本转语音 的提供商适配器。")
# 初始化 MCP Client 连接
asyncio.create_task(
self.llm_tools.mcp_service_selector(), name="mcp-service-handler"
)
self.llm_tools.mcp_service_queue.put_nowait({"type": "init"})
async def load_provider(self, provider_config: dict):
if not provider_config["enable"]:
return
@@ -339,3 +346,5 @@ class ProviderManager:
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
await provider_inst.terminate()
# 清理 MCP Client 连接
await self.llm_tools.mcp_service_queue.put({"type": "terminate"})
+3 -1
View File
@@ -3,7 +3,7 @@ from typing import List
from astrbot.core.db import BaseDatabase
from typing import TypedDict
from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.provider.entites import LLMResponse
from astrbot.core.provider.entites import LLMResponse, ToolCallsResult
from dataclasses import dataclass
@@ -90,6 +90,7 @@ class Provider(AbstractProvider):
func_tool: FuncCall = None,
contexts: List = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult = None,
**kwargs,
) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -100,6 +101,7 @@ class Provider(AbstractProvider):
image_urls: 图片 URL 列表
tools: Function-calling 工具
contexts: 上下文
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
kwargs: 其他参数
Notes:
@@ -10,7 +10,7 @@ from astrbot.api.provider import Provider, Personality
from astrbot import logger
from astrbot.core.provider.func_tool_manager import FuncCall
from ..register import register_provider_adapter
from astrbot.core.provider.entites import LLMResponse
from astrbot.core.provider.entites import LLMResponse, ToolCallsResult
from .openai_source import ProviderOpenAIOfficial
@@ -79,11 +79,14 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
# tools call (function calling)
args_ls = []
func_name_ls = []
tool_use_ids = []
func_name_ls.append(content.name)
args_ls.append(content.input)
tool_use_ids.append(content.id)
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
llm_response.tools_call_ids = tool_use_ids
if not llm_response.completion_text and not llm_response.tools_call_args:
logger.error(f"API 返回的 completion 无法解析:{completion}")
@@ -101,6 +104,7 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result: ToolCallsResult = None,
**kwargs,
) -> LLMResponse:
if not prompt:
@@ -113,6 +117,10 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
if "_no_save" in part:
del part["_no_save"]
if tool_calls_result:
# 暂时这样写。
prompt += f"Here are the related results via using tools: {str(tool_calls_result.tool_calls_result)}"
model_config = self.provider_config.get("model_config", {})
payloads = {"messages": context_query, **model_config}
@@ -1,3 +1,4 @@
import re
import asyncio
import functools
from typing import List
@@ -40,11 +41,24 @@ class ProviderDashscope(ProviderOpenAIOfficial):
raise Exception("阿里云百炼 APP 类型不能为空。")
self.model_name = "dashscope"
self.variables: dict = provider_config.get("variables", {})
self.rag_options: dict = provider_config.get("rag_options", {})
self.output_reference = self.rag_options.get("output_reference", False)
self.rag_options = self.rag_options.copy()
self.rag_options.pop("output_reference", None)
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
def has_rag_options(self):
if (
self.rag_options
and self.rag_options.get("pipeline_ids", None)
and self.rag_options.get("file_ids", None)
):
return True
return False
async def text_chat(
self,
prompt: str,
@@ -62,7 +76,10 @@ class ProviderDashscope(ProviderOpenAIOfficial):
session_var = session_vars.get(session_id, {})
payload_vars.update(session_var)
if self.dashscope_app_type in ["agent", "dialog-workflow"]:
if (
self.dashscope_app_type in ["agent", "dialog-workflow"]
and self.has_rag_options()
):
# 支持多轮对话的
new_record = {"role": "user", "content": prompt}
if image_urls:
@@ -86,12 +103,17 @@ class ProviderDashscope(ProviderOpenAIOfficial):
else:
# 不支持多轮对话的
# 调用阿里云百炼 API
payload = {
"app_id": self.app_id,
"prompt": prompt,
"api_key": self.api_key,
"biz_params": payload_vars or None,
}
if self.rag_options:
payload["rag_options"] = self.rag_options
partial = functools.partial(
Application.call,
app_id=self.app_id,
promtp=prompt,
api_key=self.api_key,
biz_params=payload_vars or None,
**payload,
)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
@@ -107,6 +129,14 @@ class ProviderDashscope(ProviderOpenAIOfficial):
)
output_text = response.output.get("text", "")
# RAG 引用脚标格式化
output_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", output_text)
if self.output_reference and response.output.get("doc_references", None):
ref_str = ""
for ref in response.output.get("doc_references", []):
ref_str += f"{ref['index_id']}. {ref['title']}\n"
output_text += f"\n\n回答来源:\n{ref_str}"
return LLMResponse(role="assistant", completion_text=output_text)
async def forget(self, session_id):
+25 -21
View File
@@ -33,7 +33,6 @@ class ProviderDify(Provider):
if not self.api_key:
raise Exception("Dify API Key 不能为空。")
api_base = provider_config.get("dify_api_base", "https://api.dify.ai/v1")
self.api_client = DifyAPIClient(self.api_key, api_base)
self.api_type = provider_config.get("dify_api_type", "")
if not self.api_type:
raise Exception("Dify API 类型不能为空。")
@@ -44,15 +43,19 @@ class ProviderDify(Provider):
self.dify_query_input_key = provider_config.get(
"dify_query_input_key", "astrbot_text_query"
)
self.variables: dict = provider_config.get("variables", {})
if not self.dify_query_input_key:
self.dify_query_input_key = "astrbot_text_query"
if not self.workflow_output_key:
self.workflow_output_key = "astrbot_wf_output"
self.variables: dict = provider_config.get("variables", {})
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.conversation_ids = {}
"""记录当前 session id 的对话 ID"""
self.api_client = DifyAPIClient(self.api_key, api_base)
async def text_chat(
self,
prompt: str,
@@ -68,26 +71,27 @@ class ProviderDify(Provider):
files_payload = []
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
file_response = await self.api_client.file_upload(
image_path, user=session_id
image_path = (
await download_image_by_url(image_url)
if image_url.startswith("http")
else image_url
)
file_response = await self.api_client.file_upload(
image_path, user=session_id
)
logger.debug(f"Dify 上传图片响应:{file_response}")
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
)
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
)
continue
files_payload.append(
{
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
}
)
else:
# TODO: 处理更多情况
logger.warning(f"未知的图片链接:{image_url},图片将忽略。")
continue
files_payload.append(
{
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
}
)
# 获得会话变量
payload_vars = self.variables.copy()
+43 -4
View File
@@ -1,5 +1,6 @@
import base64
import aiohttp
import json
import random
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.db import BaseDatabase
@@ -146,11 +147,41 @@ class ProviderGoogleGenAI(Provider):
google_genai_conversation.append({"role": "user", "parts": parts})
elif message["role"] == "assistant":
if not message["content"]:
message["content"] = "<empty_content>"
google_genai_conversation.append(
{"role": "model", "parts": [{"text": message["content"]}]}
if "content" in message:
if not message["content"]:
message["content"] = "<empty_content>"
google_genai_conversation.append(
{"role": "model", "parts": [{"text": message["content"]}]}
)
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})
logger.debug(f"google_genai_conversation: {google_genai_conversation}")
@@ -174,6 +205,9 @@ class ProviderGoogleGenAI(Provider):
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
llm_response.completion_text = llm_response.completion_text.strip()
return llm_response
@@ -186,6 +220,7 @@ class ProviderGoogleGenAI(Provider):
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result=None,
**kwargs,
) -> LLMResponse:
new_record = await self.assemble_context(prompt, image_urls)
@@ -198,6 +233,10 @@ class ProviderGoogleGenAI(Provider):
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()
+11 -4
View File
@@ -120,15 +120,18 @@ class ProviderOpenAIOfficial(Provider):
# tools call (function calling)
args_ls = []
func_name_ls = []
tool_call_ids = []
for tool_call in choice.message.tool_calls:
for tool in tools.func_list:
if tool.name == tool_call.function.name:
args = json.loads(tool_call.function.arguments)
args_ls.append(args)
func_name_ls.append(tool_call.function.name)
tool_call_ids.append(tool_call.id)
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
llm_response.tools_call_ids = tool_call_ids
if choice.finish_reason == "content_filter":
raise Exception(
@@ -151,6 +154,7 @@ class ProviderOpenAIOfficial(Provider):
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result=None,
**kwargs,
) -> LLMResponse:
new_record = await self.assemble_context(prompt, image_urls)
@@ -162,10 +166,15 @@ class ProviderOpenAIOfficial(Provider):
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}
llm_response = None
try:
llm_response = await self._query(payloads, func_tool)
@@ -275,10 +284,8 @@ class ProviderOpenAIOfficial(Provider):
def set_key(self, key):
self.client.api_key = key
async def assemble_context(self, text: str, image_urls: List[str] = None):
"""
组装上下文。
"""
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}]}
for image_url in image_urls:
+9 -4
View File
@@ -332,7 +332,10 @@ class PluginManager:
)
# 绑定 llm_tool handler
for func_tool in llm_tools.func_list:
if func_tool.handler.__module__ == metadata.module_path:
if (
func_tool.handler
and func_tool.handler.__module__ == metadata.module_path
):
func_tool.handler_module_path = metadata.module_path
func_tool.handler = functools.partial(
func_tool.handler, metadata.star_cls
@@ -471,9 +474,11 @@ class PluginManager:
# 从 star_registry 和 star_map 中删除
await self._unbind_plugin(plugin_name, plugin.module_path)
if not remove_dir(os.path.join(ppath, root_dir_name)):
try:
remove_dir(os.path.join(ppath, root_dir_name))
except Exception as e:
raise Exception(
"移除插件成功,但是删除插件文件夹失败。您可以手动删除该文件夹,位于 addons/plugins/ 下。"
f"移除插件成功,但是删除插件文件夹失败: {str(e)}。您可以手动删除该文件夹,位于 addons/plugins/ 下。"
)
async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str):
@@ -601,4 +606,4 @@ class PluginManager:
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {str(e)}")
# await self.reload()
await self.load(desti_dir)
await self.load(specified_dir_name=dir_name)
+20 -9
View File
@@ -8,6 +8,9 @@ import base64
import zipfile
import uuid
import psutil
import certifi
from typing import Union
from PIL import Image
@@ -17,24 +20,20 @@ def on_error(func, path, exc_info):
"""
a callback of the rmtree function.
"""
print(f"remove {path} failed.")
import stat
if not os.access(path, os.W_OK):
os.chmod(path, stat.S_IWUSR)
func(path)
else:
raise
raise exc_info[1]
def remove_dir(file_path) -> bool:
if not os.path.exists(file_path):
return True
try:
shutil.rmtree(file_path, onerror=on_error)
return True
except BaseException:
return False
shutil.rmtree(file_path, onerror=on_error)
return True
def port_checker(port: int, host: str = "localhost"):
@@ -81,7 +80,13 @@ async def download_image_by_url(
下载图片, 返回 path
"""
try:
async with aiohttp.ClientSession(trust_env=True) as session:
ssl_context = ssl.create_default_context(
cafile=certifi.where()
) # 使用 certifi 提供的 CA 证书
connector = aiohttp.TCPConnector(ssl=ssl_context) # 使用 certifi 的根证书
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
if post:
async with session.post(url, json=post_data) as resp:
if not path:
@@ -118,7 +123,13 @@ async def download_file(url: str, path: str, show_progress: bool = False):
从指定 url 下载文件到指定路径 path
"""
try:
async with aiohttp.ClientSession(trust_env=True) as session:
ssl_context = ssl.create_default_context(
cafile=certifi.where()
) # 使用 certifi 提供的 CA 证书
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.get(url, timeout=1800) as resp:
if resp.status != 200:
raise Exception(f"下载文件失败: {resp.status}")
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1,5 +1,7 @@
import aiohttp
import os
import ssl
import certifi
from . import RenderStrategy
from astrbot.core.config import VERSION
@@ -46,7 +48,11 @@ class NetworkRenderStrategy(RenderStrategy):
},
}
if return_url:
async with aiohttp.ClientSession(trust_env=True) as session:
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.post(
f"{self.BASE_RENDER_URL}/generate", json=post_data
) as resp:
+23 -3
View File
@@ -2,6 +2,10 @@ import aiohttp
import os
import zipfile
import shutil
import ssl
import certifi
from astrbot.core.utils.io import on_error, download_file
from astrbot.core import logger
@@ -19,7 +23,7 @@ class ReleaseInfo:
self.body = body
def __str__(self) -> str:
return f"新版本: {self.version}, 发布于: {self.published_at}, 详细内容: {self.body}"
return f"\n{self.body}\n\n版本: {self.version} | 发布于: {self.published_at}"
class RepoZipUpdator:
@@ -33,8 +37,23 @@ class RepoZipUpdator:
返回一个列表,每个元素是一个字典,包含版本号、发布时间、更新内容、commit hash等信息。
"""
try:
async with aiohttp.ClientSession(trust_env=True) as session:
ssl_context = ssl.create_default_context(
cafile=certifi.where()
) # 新增:创建基于 certifi 的 SSL 上下文
connector = aiohttp.TCPConnector(
ssl=ssl_context
) # 新增:使用 TCPConnector 指定 SSL 上下文
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.get(url) as response:
# 检查 HTTP 状态码
if response.status != 200:
text = await response.text()
logger.error(
f"请求 {url} 失败,状态码: {response.status}, 内容: {text}"
)
raise Exception(f"请求失败,状态码: {response.status}")
result = await response.json()
if not result:
return []
@@ -53,7 +72,8 @@ class RepoZipUpdator:
"zipball_url": release["zipball_url"],
}
)
except BaseException:
except Exception as e:
logger.error(f"解析版本信息时发生异常: {e}")
raise Exception("解析版本信息失败")
return ret
-3
View File
@@ -1,3 +0,0 @@
from .dashboard_lifecycle import AstrBotDashBoardLifecycle
__all__ = ["AstrBotDashBoardLifecycle"]
+4
View File
@@ -6,6 +6,8 @@ from .stat import StatRoute
from .log import LogRoute
from .static_file import StaticFileRoute
from .chat import ChatRoute
from .tools import ToolsRoute # 导入新的ToolsRoute
from .conversation import ConversationRoute
__all__ = [
@@ -17,4 +19,6 @@ __all__ = [
"LogRoute",
"StaticFileRoute",
"ChatRoute",
"ToolsRoute", # 添加新的ToolsRoute
"ConversationRoute",
]
+12 -5
View File
@@ -31,7 +31,6 @@ def validate_config(
def validate(data: dict, metadata: dict = schema, path=""):
for key, value in data.items():
print(key, value)
if key not in metadata:
# 无 schema 的配置项,执行类型猜测
if isinstance(value, str):
@@ -97,7 +96,6 @@ def validate_config(
errors.append(
f"错误的类型 {path}{key}: 期望是 dict, 得到了 {type(value).__name__}"
)
validate(value, meta["items"], path=f"{path}{key}.")
if is_core:
for key, group in schema.items():
@@ -147,6 +145,7 @@ class ConfigRoute(Route):
"/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider),
"/config/llmtools": ("GET", self.get_llm_tools),
}
self.register_routes()
@@ -220,7 +219,8 @@ class ConfigRoute(Route):
return Response().error("未找到对应平台").__dict__
try:
await self._save_astrbot_configs(self.config)
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.platform_manager.reload(new_config)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "更新平台配置成功~").__dict__
@@ -256,7 +256,8 @@ class ConfigRoute(Route):
else:
return Response().error("未找到对应平台").__dict__
try:
await self._save_astrbot_configs(self.config)
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.platform_manager.terminate_platform(platform_id)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "删除平台配置成功~").__dict__
@@ -277,6 +278,12 @@ class ConfigRoute(Route):
return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效~").__dict__
async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
tool_mgr = self.core_lifecycle.provider_manager.llm_tools
tools = tool_mgr.get_func_desc_openai_style()
return Response().ok(tools).__dict__
async def _get_astrbot_config(self):
config = self.config
@@ -322,7 +329,7 @@ class ConfigRoute(Route):
async def _save_astrbot_configs(self, post_configs: dict):
try:
save_config(post_configs, self.config, is_core=True)
self.core_lifecycle.restart()
await self.core_lifecycle.restart()
except Exception as e:
raise e
+215
View File
@@ -0,0 +1,215 @@
import traceback
import json
from .route import Route, Response, RouteContext
from astrbot.core import logger
from quart import request
from astrbot.core.db import BaseDatabase
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
class ConversationRoute(Route):
def __init__(
self,
context: RouteContext,
db_helper: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle,
) -> None:
super().__init__(context)
self.routes = {
"/conversation/list": ("GET", self.list_conversations),
"/conversation/detail": (
"POST",
self.get_conv_detail,
),
"/conversation/update": ("POST", self.upd_conv),
"/conversation/delete": ("POST", self.del_conv),
"/conversation/update_history": (
"POST",
self.update_history,
),
}
self.db_helper = db_helper
self.register_routes()
async def list_conversations(self):
"""获取对话列表,支持分页、排序和筛选"""
try:
# 获取分页参数
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 20, type=int)
# 获取筛选参数
platforms = request.args.get("platforms", "")
message_types = request.args.get("message_types", "")
search_query = request.args.get("search", "")
exclude_ids = request.args.get("exclude_ids", "")
exclude_platforms = request.args.get("exclude_platforms", "")
# 转换为列表
platform_list = platforms.split(",") if platforms else []
message_type_list = message_types.split(",") if message_types else []
exclude_id_list = exclude_ids.split(",") if exclude_ids else []
exclude_platform_list = (
exclude_platforms.split(",") if exclude_platforms else []
)
# 限制页面大小,防止请求过大数据
if page < 1:
page = 1
if page_size < 1:
page_size = 20
if page_size > 100:
page_size = 100
# 使用数据库的分页方法获取会话列表和总数,传入筛选条件
try:
conversations, total_count = self.db_helper.get_filtered_conversations(
page=page,
page_size=page_size,
platforms=platform_list,
message_types=message_type_list,
search_query=search_query,
exclude_ids=exclude_id_list,
exclude_platforms=exclude_platform_list,
)
except Exception as e:
logger.error(f"数据库查询出错: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"数据库查询出错: {str(e)}").__dict__
# 计算总页数
total_pages = (
(total_count + page_size - 1) // page_size if total_count > 0 else 1
)
result = {
"conversations": conversations,
"pagination": {
"page": page,
"page_size": page_size,
"total": total_count,
"total_pages": total_pages,
},
}
return Response().ok(result).__dict__
except Exception as e:
error_msg = f"获取对话列表失败: {str(e)}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"获取对话列表失败: {str(e)}").__dict__
async def get_conv_detail(self):
"""获取指定对话详情(通过POST请求)"""
try:
data = await request.get_json()
user_id = data.get("user_id")
cid = data.get("cid")
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
if not conversation:
return Response().error("对话不存在").__dict__
return (
Response()
.ok(
{
"user_id": user_id,
"cid": cid,
"title": conversation.title,
"persona_id": conversation.persona_id,
"history": conversation.history,
"created_at": conversation.created_at,
"updated_at": conversation.updated_at,
}
)
.__dict__
)
except Exception as e:
logger.error(f"获取对话详情失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"获取对话详情失败: {str(e)}").__dict__
async def upd_conv(self):
"""更新对话信息(标题和角色ID)"""
try:
data = await request.get_json()
user_id = data.get("user_id")
cid = data.get("cid")
title = data.get("title")
persona_id = data.get("persona_id", "")
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
if not conversation:
return Response().error("对话不存在").__dict__
if title is not None:
self.db_helper.update_conversation_title(user_id, cid, title)
if persona_id is not None:
self.db_helper.update_conversation_persona_id(user_id, cid, persona_id)
return Response().ok({"message": "对话信息更新成功"}).__dict__
except Exception as e:
logger.error(f"更新对话信息失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"更新对话信息失败: {str(e)}").__dict__
async def del_conv(self):
"""删除对话"""
try:
data = await request.get_json()
user_id = data.get("user_id")
cid = data.get("cid")
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
if not conversation:
return Response().error("对话不存在").__dict__
self.db_helper.delete_conversation(user_id, cid)
return Response().ok({"message": "对话删除成功"}).__dict__
except Exception as e:
logger.error(f"删除对话失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"删除对话失败: {str(e)}").__dict__
async def update_history(self):
"""更新对话历史内容"""
try:
data = await request.get_json()
user_id = data.get("user_id")
cid = data.get("cid")
history = data.get("history")
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
if history is None:
return Response().error("缺少必要参数: history").__dict__
# 历史记录必须是合法的 JSON 字符串
try:
if isinstance(history, list):
history = json.dumps(history)
else:
# 验证是否为有效的 JSON 字符串
json.loads(history)
except json.JSONDecodeError:
return (
Response().error("history 必须是有效的 JSON 字符串或数组").__dict__
)
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
if not conversation:
return Response().error("对话不存在").__dict__
self.db_helper.update_conversation(user_id, cid, history)
return Response().ok({"message": "对话历史更新成功"}).__dict__
except Exception as e:
logger.error(f"更新对话历史失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"更新对话历史失败: {str(e)}").__dict__
+10 -1
View File
@@ -1,5 +1,9 @@
import traceback
import aiohttp
import ssl
import certifi
from .route import Route, Response, RouteContext
from astrbot.core import logger
from quart import request
@@ -65,9 +69,14 @@ class PluginRoute(Route):
else:
urls = ["https://api.soulter.top/astrbot/plugins"]
# 新增:创建 SSL 上下文,使用 certifi 提供的根证书
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
for url in urls:
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.get(url) as response:
if response.status == 200:
result = await response.json()
+21 -4
View File
@@ -1,6 +1,7 @@
import traceback
import psutil
import time
import threading
from .route import Route, Response, RouteContext
from astrbot.core import logger
from quart import request
@@ -28,7 +29,7 @@ class StatRoute(Route):
self.core_lifecycle = core_lifecycle
async def restart_core(self):
self.core_lifecycle.restart()
await self.core_lifecycle.restart()
return Response().ok().__dict__
def format_sec(self, sec: int):
@@ -64,6 +65,20 @@ class StatRoute(Route):
stat_dict = stat.__dict__
cpu_percent = psutil.cpu_percent(interval=0.5)
thread_count = threading.active_count()
# 获取插件信息
plugins = self.core_lifecycle.star_context.get_all_stars()
plugin_info = []
for plugin in plugins:
info = {
"name": getattr(plugin, "name", plugin.__class__.__name__),
"version": getattr(plugin, "version", "1.0.0"),
"is_enabled": True,
}
plugin_info.append(info)
stat_dict.update(
{
"platform": self.db_helper.get_grouped_base_stats(
@@ -73,9 +88,8 @@ class StatRoute(Route):
"platform_count": len(
self.core_lifecycle.platform_manager.get_insts()
),
"plugin_count": len(
self.core_lifecycle.star_context.get_all_stars()
),
"plugin_count": len(plugins),
"plugins": plugin_info,
"message_time_series": message_time_based_stats,
"running": self.format_sec(
int(time.time()) - self.core_lifecycle.start_time
@@ -84,6 +98,9 @@ class StatRoute(Route):
"process": psutil.Process().memory_info().rss >> 20,
"system": psutil.virtual_memory().total >> 20,
},
"cpu_percent": round(cpu_percent, 1),
"thread_count": thread_count,
"start_time": self.core_lifecycle.start_time,
}
)
+252
View File
@@ -0,0 +1,252 @@
import os
import json
import traceback
from .route import Route, Response, RouteContext
from quart import request
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core import logger
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
class ToolsRoute(Route):
def __init__(
self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle
) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.routes = {
"/tools/mcp/servers": ("GET", self.get_mcp_servers),
"/tools/mcp/add": ("POST", self.add_mcp_server),
"/tools/mcp/update": ("POST", self.update_mcp_server),
"/tools/mcp/delete": ("POST", self.delete_mcp_server),
}
self.register_routes()
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
@property
def mcp_config_path(self):
current_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
return os.path.join(data_dir, "mcp_server.json")
def load_mcp_config(self):
if not os.path.exists(self.mcp_config_path):
# 配置文件不存在,创建默认配置
os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True)
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
return DEFAULT_MCP_CONFIG
try:
with open(self.mcp_config_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"加载 MCP 配置失败: {e}")
return DEFAULT_MCP_CONFIG
def save_mcp_config(self, config):
try:
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
return True
except Exception as e:
logger.error(f"保存 MCP 配置失败: {e}")
return False
async def get_mcp_servers(self):
try:
config = self.load_mcp_config()
servers = []
# 获取所有服务器并添加它们的工具列表
for name, server_config in config["mcpServers"].items():
server_info = {
"name": name,
"active": server_config.get("active", True),
}
# 复制所有配置字段
for key, value in server_config.items():
if key != "active": # active 已经处理
server_info[key] = value
# 如果MCP客户端已初始化,从客户端获取工具名称
for (
name_key,
mcp_client,
) in self.tool_mgr.mcp_client_dict.items():
if name_key == name:
server_info["tools"] = [tool.name for tool in mcp_client.tools]
break
else:
server_info["tools"] = []
servers.append(server_info)
return Response().ok(servers).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"获取 MCP 服务器列表失败: {str(e)}").__dict__
async def add_mcp_server(self):
try:
server_data = await request.json
name = server_data.get("name", "")
# 检查必填字段
if not name:
return Response().error("服务器名称不能为空").__dict__
# 移除特殊字段并检查配置是否有效
has_valid_config = False
server_config = {"active": server_data.get("active", True)}
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
server_config[key] = value
has_valid_config = True
if not has_valid_config:
return Response().error("必须提供有效的服务器配置").__dict__
config = self.load_mcp_config()
if name in config["mcpServers"]:
return Response().error(f"服务器 {name} 已存在").__dict__
config["mcpServers"][name] = server_config
if self.save_mcp_config(config):
# 动态初始化新MCP客户端
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"添加 MCP 服务器失败: {str(e)}").__dict__
async def update_mcp_server(self):
try:
server_data = await request.json
name = server_data.get("name", "")
if not name:
return Response().error("服务器名称不能为空").__dict__
config = self.load_mcp_config()
if name not in config["mcpServers"]:
return Response().error(f"服务器 {name} 不存在").__dict__
# 获取活动状态
active = server_data.get(
"active", config["mcpServers"][name].get("active", True)
)
# 创建新的配置对象
server_config = {"active": active}
# 仅更新活动状态的特殊处理
only_update_active = True
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
server_config[key] = value
only_update_active = False
# 如果只更新活动状态,保留原始配置
if only_update_active:
for key, value in config["mcpServers"][name].items():
if key != "active": # 除了active之外的所有字段都保留
server_config[key] = value
config["mcpServers"][name] = server_config
if self.save_mcp_config(config):
# 处理MCP客户端状态变化
if active:
# 如果要激活服务器或者配置已更改
if name in self.tool_mgr.mcp_client_dict or not only_update_active:
await self.tool_mgr.mcp_service_queue.put(
{
"type": "terminate",
"name": name,
}
)
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
else:
# 客户端不存在,初始化
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
else:
# 如果要停用服务器
if name in self.tool_mgr.mcp_client_dict:
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "terminate",
"name": name,
}
)
return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"更新 MCP 服务器失败: {str(e)}").__dict__
async def delete_mcp_server(self):
try:
server_data = await request.json
name = server_data.get("name", "")
if not name:
return Response().error("服务器名称不能为空").__dict__
config = self.load_mcp_config()
if name not in config["mcpServers"]:
return Response().error(f"服务器 {name} 不存在").__dict__
# 删除服务器配置
del config["mcpServers"][name]
if self.save_mcp_config(config):
# 关闭并删除MCP客户端
if name in self.tool_mgr.mcp_client_dict:
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "terminate",
"name": name,
}
)
return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"删除 MCP 服务器失败: {str(e)}").__dict__
+1 -2
View File
@@ -95,8 +95,7 @@ class UpdateRoute(Route):
logger.error(f"更新依赖失败: {e}")
if reboot:
# threading.Thread(target=self.astrbot_updator._reboot, args=(2, )).start()
self.core_lifecycle.restart()
await self.core_lifecycle.restart()
return (
Response()
.ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。")
+15 -9
View File
@@ -20,7 +20,12 @@ DATAPATH = os.path.abspath(
class AstrBotDashboard:
def __init__(self, core_lifecycle: AstrBotCoreLifecycle, db: BaseDatabase) -> None:
def __init__(
self,
core_lifecycle: AstrBotCoreLifecycle,
db: BaseDatabase,
shutdown_event: asyncio.Event,
) -> None:
self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config
self.data_path = os.path.abspath(os.path.join(DATAPATH, "dist"))
@@ -45,6 +50,10 @@ class AstrBotDashboard:
self.sfr = StaticFileRoute(self.context)
self.ar = AuthRoute(self.context)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.shutdown_event = shutdown_event
async def auth_middleware(self):
if not request.path.startswith("/api"):
@@ -73,11 +82,6 @@ class AstrBotDashboard:
r.status_code = 401
return r
async def shutdown_trigger_placeholder(self):
while not self.core_lifecycle.event_queue.closed: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("管理面板已关闭。")
def check_port_in_use(self, port: int) -> bool:
"""
跨平台检测端口是否被占用
@@ -166,7 +170,9 @@ class AstrBotDashboard:
logger.info(display)
return self.app.run_task(
host=host,
port=port,
shutdown_trigger=self.shutdown_trigger_placeholder,
host=host, port=port, shutdown_trigger=self.shutdown_trigger
)
async def shutdown_trigger(self):
await self.shutdown_event.wait()
logger.info("AstrBot WebUI 已经被优雅地关闭")
+59
View File
@@ -0,0 +1,59 @@
# What's Changed
> 📢 AstrBot 上架宝塔面板 Docker 应用商店了!
> 📢 在升级前,请完整阅读本次更新日志。
## ✨ 新增的功能
1. ‼️ 新增支持接入 MCP 服务器 @Soulter @AraragiEro
1. ‼️ 新增支持本地渲染 Markdown,并支持自定义字体,详见 -> [#957](https://github.com/Soulter/AstrBot/issues/957#issuecomment-2749981802)
2. 新增支持在 WebUI 管理所有与大模型的对话
3. 适配完整的 function-calling 流程。[#804](https://github.com/Soulter/AstrBot/issues/804) [#566](https://github.com/Soulter/AstrBot/issues/566)
4. 新增支持消息平台热重载,不再需要重启 AstrBot
5. 新增支持阿里云百炼应用的 RAG 应用 [#878](https://github.com/Soulter/AstrBot/issues/878)
6. 新增 `/plugin get` OP 指令下载插件。如 `/plugin get Raven95676/astrbot_plugin_wordle`
7. 新增 `/newgroup` OP 指令,支持私聊 bot 给指定群聊创建新的对话。by @LunarMeal
8. Gewechat 下支持 `添加好友`, `接收/发送视频`, `获取群信息`, `接收/发送表情包` by @Moyuyanli @Soulter @XuYingJie-cmd @NiceAir
9. Telegram 下支持接收和处理表情包(Sticker) @Raven95676
## 🎈 功能性优化
0. 更加美观的 WebUI 设计,降低疲劳程度。
1. 微信下,忽略 `微信团队` 的消息 [#859](https://github.com/Soulter/AstrBot/issues/859)
2. 完善 Dify 的图片输入功能 [#893](https://github.com/Soulter/AstrBot/issues/893)
3. 消息平台和配置提供商配置页中,自动更新旧的配置项
4. 优化钉钉在配置错误之后堵塞整个线程的问题 [#885](https://github.com/Soulter/AstrBot/issues/885)
5. WebUI 删除插件时提供二次确认避免误删 @zhx8702
6. WebUI 优化新版本时的信息显示
7. 发送消息失败时的报错回显优化
8. 改善所有消息平台的优雅退出逻辑
9. 空 @ 时调用 LLM 获得更加富有人格的回复 by @advent259141
## 🐛 修复的 Bug
1. 修复图片没有被存储到聊天上下文历史记录
2. 修复 Telegram 下无法识别图片描述(Caption) [#910](https://github.com/Soulter/AstrBot/issues/910)
3. 修复 Telegram Topic 群组下引用消息来源错误的问题 [#908](https://github.com/Soulter/AstrBot/issues/908)
4. 修复 Telegram 下 `/start` 指令的一些问题 [#751](https://github.com/Soulter/AstrBot/issues/751)
5. WebUI 插件市场卡片显示风格的过滤问题。[#927](https://github.com/Soulter/AstrBot/issues/927)
6. 统一 SSL 证书验证逻辑,修复 `SSLCertVerificationError` 的问题。by @IGCrystal [#950](https://github.com/Soulter/AstrBot/issues/950)
7. 修复可能形成 SQL 注入的风险
8. 修复本地上传插件时无法重载插件的问题 [#995](https://github.com/Soulter/AstrBot/issues/995) by @zhx8702
## 🧩 新增的插件
1. astrbot_plugin_majsoul-master - 雀魂多功能插件 - by @kterna
2. astrbot_plugin_server - 可视化服务器状态卡片,/status 或 /状态查询 查看 - by @yanfd @Meguminlove
3. astrbot_plugin_Getcwm - 刺猬猫小说数据获取与画图插件 - by @Li-shi-ling
4. astrbot_plugin_anti_withdrawal - 防撤回插件,目前只支持微信私聊群聊的文本消息,将撤回的消息记录并发送给设定的人 - by @NiceAir
5. astrbot_plugin_hello77 - 游戏梗自动回复插件 - by @ttq7
6. astrbot_plugin_push_lite - Webhook 轻量级推送插件 - @Raven95676
7. astrbot_plugin_pokecheck - 检测“戳”关键词的插件 - @huanyan434
8. astrbot_plugin_MultiAI_PollPad - 轮询调用配置的大语言模型输出多个结果。同时将 AI 结果拷贝至在线文本编辑器 - by @Ynkcc
9. astrbot_plugin_box - / - by @Zhalslar
10. astrbot_plugin_Translation - 通过调用百度翻译 API 实现翻译文本 - by @zengweis
11. astrbot_plugin_wordle_2 - Wordle 游戏插件 - by @Raven95676 @whzcc
12. astrbot_plugin_mai_sgin - 舞萌出勤与退勤签到插件 - by @Rinyin
13. astrbot_plugin_Lolicon - Lolicon API 随机动漫图片插件 - by @ttq7
14. astrbot_plugin_aiocensor - 综合内容安全+群管插件 - by @Raven95676
+11 -6
View File
@@ -1,16 +1,21 @@
version: '3.8'
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
services:
astrbot:
image: soulter/astrbot:latest
container_name: astrbot
restart: always
ports: # mappings description: https://github.com/Soulter/AstrBot/issues/497
- "6185:6185"
- "6195:6195" # optional, wecom default port
- "6199:6199" # optional, aiocqhttp default port
- "6196:6196" # optional, qq official webhook default port
- "11451:11451" # optional, gewechat default port
- "6185:6185" # 必选,AstrBot WebUI 端口
- "6195:6195" # 可选, 企业微信 Webhook 端口
- "6199:6199" # 可选, QQ 个人号 WebSocket 端口
- "6196:6196" # 可选, QQ 官方接口 Webhook 端口
- "11451:11451" # 可选, 微信个人号 Webhook 端口
environment:
- TZ=Asia/Shanghai
volumes:
- ./data:/AstrBot/data
- /etc/timezone:/etc/timezone:ro
# - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
@@ -0,0 +1,44 @@
<template>
<v-dialog v-model="isOpen" max-width="400">
<v-card>
<v-card-title class="text-h6">{{ title }}</v-card-title>
<v-card-text>{{ message }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="gray" @click="handleCancel">取消</v-btn>
<v-btn color="red" @click="handleConfirm">确定</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref } from "vue";
const isOpen = ref(false);
const title = ref("");
const message = ref("");
let resolvePromise = null; // ✅ 确保 Promise 句柄可用
const open = (options) => {
title.value = options.title || "确认操作";
message.value = options.message || "你确定要执行此操作吗?";
isOpen.value = true;
return new Promise((resolve) => {
resolvePromise = resolve; // ✅ 赋值 Promise 解析方法
});
};
const handleConfirm = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise(true); // ✅ 解析 Promise
};
const handleCancel = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise(false); // ✅ 解析 Promise
};
defineExpose({ open }); // ✅ 确保 `confirmPlugin.ts` 可以访问 `open`
</script>
+359 -139
View File
@@ -1,154 +1,374 @@
<template>
<div style="margin-bottom: 6px;" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title style="font-weight: bold;">
{{ metadata[metadataKey]?.description }} ({{ metadataKey }})
</v-list-item-title>
<v-list-item-subtitle style="font-size: 12px;">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
style="opacity: 1.0;"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
<div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title">
{{ metadata[metadataKey]?.description }} <span class="metadata-key">({{ metadataKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</div>
<v-card-text class="px-0 py-1">
<!-- Object Type Configuration -->
<div v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template" class="object-config">
<div v-for="(val, key, index) in iterable" :key="key" class="config-item">
<!-- Nested Object -->
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" class="nested-object">
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible" class="nested-container">
<v-expand-transition>
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey="key">
</AstrBotConfig>
</v-expand-transition>
</div>
</div>
<!-- Regular Property -->
<template v-else>
<v-row v-if="!metadata[metadataKey].items[key]?.invisible" class="config-row">
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
<span v-if="metadata[metadataKey].items[key]?.description">
{{ metadata[metadataKey].items[key]?.description }}
<span class="property-key">({{ key }})</span>
</span>
<span v-else>{{ key }}</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
class="important-hint"></span>
{{ metadata[metadataKey].items[key]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible"
color="primary"
label
size="x-small"
variant="flat">
{{ metadata[metadataKey].items[key]?.type || 'string' }}
</v-chip>
</v-col>
<v-col cols="12" sm="5" class="config-input">
<div v-if="metadata[metadataKey].items[key]" class="w-100">
<!-- Select input -->
<v-select
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
:items="metadata[metadataKey].items[key]?.options"
:disabled="metadata[metadataKey].items[key]?.readonly"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-select>
<!-- String input -->
<v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-text-field>
<!-- Numeric input -->
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
<!-- Text area -->
<v-textarea
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
variant="outlined"
auto-grow
rows="3"
class="config-field"
hide-details
></v-textarea>
<!-- Boolean switch -->
<v-switch
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
color="primary"
inset
density="compact"
hide-details
></v-switch>
<!-- List item -->
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]"
class="config-field"
/>
</div>
<!-- Fallback for unknown metadata -->
<div v-else class="w-100">
<v-text-field
v-model="iterable[key]"
:label="key"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-text-field>
</div>
</v-col>
</v-row>
<v-divider
v-if="index !== Object.keys(iterable).length - 1 && !metadata[metadataKey].items[key]?.invisible"
class="config-divider"
></v-divider>
</template>
</div>
</div>
<!-- Simple Value Configuration -->
<div v-else class="simple-config">
<v-row class="config-row">
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ metadata[metadataKey]?.description }}
<span class="property-key">({{ metadataKey }})</span>
</v-list-item-title>
<v-card-text style="padding: 0px;">
<div v-for="(val, key, index) in iterable" :key="key" style="margin-bottom: 0.5px;"
v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template">
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" style="padding-left: 16px;">
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible"
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px; margin-top: 16px">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey=key>
</AstrBotConfig>
</div>
</div>
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
<v-chip v-if="!metadata[metadataKey]?.invisible"
color="primary"
label
size="x-small"
variant="flat">
{{ metadata[metadataKey]?.type }}
</v-chip>
</v-col>
<v-row v-else style="margin: 0; align-items: center;">
<v-col cols="6" style="padding: 0px;">
<v-list-item>
<v-list-item-title style="font-size: 14px; font-weight: bold;">
{{ metadata[metadataKey].items[key]?.description + '(' + key + ')' }}
</v-list-item-title>
<v-col cols="12" sm="5" class="config-input">
<div class="w-100">
<!-- Select input -->
<v-select
v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:items="metadata[metadataKey]?.options"
:disabled="metadata[metadataKey]?.readonly"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-select>
<!-- String input -->
<v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-text-field>
<!-- Numeric input -->
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
<!-- Text area -->
<v-textarea
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
variant="outlined"
auto-grow
rows="3"
class="config-field"
hide-details
></v-textarea>
<!-- Boolean switch -->
<v-switch
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
color="primary"
inset
density="compact"
hide-details
></v-switch>
<!-- List item -->
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]"
class="config-field"
/>
</div>
</v-col>
</v-row>
<v-list-item-subtitle style="font-size: 12px;">
<span
v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
style="opacity: 1.0;"></span>
{{ metadata[metadataKey].items[key]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="1">
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" color="primary" label size="x-small"
class="mb-1">{{
metadata[metadataKey].items[key]?.type }}
</v-chip>
</v-col>
<v-col cols="5">
<div style="width: 100%;" v-if="metadata[metadataKey].items[key]">
<v-select
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined"
:items="metadata[metadataKey].items[key]?.options" dense
:disabled="metadata[metadataKey].items[key]?.readonly" density="compact" flat hide-details
single-line></v-select>
<v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-textarea
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" dense flat hide-details single-line></v-textarea>
<v-switch
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" color="primary" hide-details></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]" />
</div>
<div style="width: 100%;" v-else>
<!-- metadata 中没有 key -->
<v-text-field v-model="iterable[key]" :label="key" variant="outlined" dense></v-text-field>
</div>
</v-col>
</v-row>
<v-divider style="border-color: #ccc;" v-if="index !== Object.keys(iterable).length - 1"></v-divider>
</div>
<div v-else>
<v-row style="margin: 0; align-items: center;">
<v-col cols="6" style="padding: 0px;">
<v-list-item>
<v-list-item-title style="font-size: 14px; font-weight: bold">
{{ metadata[metadataKey]?.description + '(' + metadataKey + ')' }}
</v-list-item-title>
<v-list-item-subtitle style="font-size: 12px;">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="1">
<v-chip v-if="!metadata[metadataKey]?.invisible" color="primary" label size="x-small"
class="mb-1">{{
metadata[metadataKey]?.type }}
</v-chip>
</v-col>
<v-col cols="5">
<div style="width: 100%;">
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" :items="metadata[metadataKey]?.options"
dense :disabled="metadata[metadataKey]?.readonly" density="compact" flat hide-details
single-line></v-select>
<v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-textarea
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
single-line></v-textarea>
<v-switch
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" color="primary" hide-details></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]" />
</div>
</v-col>
</v-row>
<v-divider style="border-color: #ddd;"></v-divider>
</div>
</v-card-text>
<v-divider class="my-2 config-divider"></v-divider>
</div>
</v-card-text>
</template>
<script>
import ListConfigItem from './ListConfigItem.vue';
export default {
components: {
ListConfigItem
name: 'AstrBotConfig',
components: {
ListConfigItem
},
props: {
metadata: {
type: Object,
required: true
},
props: {
metadata: Object,
iterable: Object,
metadataKey: String
iterable: {
type: Object,
required: true
},
metadataKey: {
type: String,
required: true
}
}
}
</script>
</script>
<style scoped>
.config-section {
margin-bottom: 12px;
}
.config-title {
font-weight: 600;
font-size: 1rem;
color: var(--v-primary-darken1);
}
.config-hint {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6);
margin-top: 2px;
}
.metadata-key, .property-key {
font-size: 0.85em;
opacity: 0.7;
font-weight: normal;
}
.important-hint {
opacity: 1;
margin-right: 4px;
}
.object-config, .simple-config {
width: 100%;
}
.config-item {
margin-bottom: 2px;
}
.nested-object {
padding-left: 16px;
}
.nested-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 12px;
margin: 12px 0;
background-color: rgba(0, 0, 0, 0.02);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.config-row {
margin: 0;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.config-row:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.property-info {
padding: 0;
}
.property-name {
font-size: 0.875rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.87);
}
.property-hint {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6);
margin-top: 2px;
}
.type-indicator {
display: flex;
justify-content: center;
}
.config-input {
padding: 4px 8px;
}
.config-field {
margin-bottom: 0;
}
.config-divider {
border-color: rgba(0, 0, 0, 0.1);
margin: 4px 0;
}
@media (max-width: 600px) {
.nested-object {
padding-left: 8px;
}
.config-row {
padding: 8px 0;
}
.property-info, .type-indicator, .config-input {
padding: 4px;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, inject } from 'vue';
const props = defineProps({
extension: {
@@ -46,8 +46,21 @@ const reloadExtension = () => {
emit('reload', props.extension);
};
const uninstallExtension = () => {
emit('uninstall', props.extension);
const $confirm = inject("$confirm");
const uninstallExtension = async () => {
if (typeof $confirm !== "function") {
console.error("$confirm 未正确注册");
return;
}
const confirmed = await $confirm({
title: "删除确认",
message: "你确定要删除当前插件吗?",
});
if (confirmed) {
emit("uninstall", props.extension);
}
};
const toggleActivation = () => {
@@ -0,0 +1,134 @@
<template>
<div>
<v-row v-if="items.length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">{{ emptyIcon }}</v-icon>
<p class="text-grey mt-4">{{ emptyText }}</p>
</v-col>
</v-row>
<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>
<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">
<template v-slot:activator="{ props }">
<v-switch
color="primary"
hide-details
density="compact"
:model-value="getItemEnabled(item)"
v-bind="props"
@update:model-value="toggleEnabled(item)"
></v-switch>
</template>
<span>{{ getItemEnabled(item) ? '已启用' : '已禁用' }}</span>
</v-tooltip>
</v-card-title>
<v-card-text>
<slot name="item-details" :item="item"></slot>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-2">
<v-spacer></v-spacer>
<v-btn
variant="text"
size="small"
color="error"
prepend-icon="mdi-delete"
@click="$emit('delete', item)"
>
删除
</v-btn>
<v-btn
variant="text"
size="small"
color="primary"
prepend-icon="mdi-pencil"
@click="$emit('edit', item)"
>
编辑
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script>
export default {
name: 'ItemCardGrid',
props: {
items: {
type: Array,
required: true
},
titleField: {
type: String,
default: 'id'
},
enabledField: {
type: String,
default: 'enable'
},
emptyIcon: {
type: String,
default: 'mdi-alert-circle-outline'
},
emptyText: {
type: String,
default: '暂无数据'
}
},
emits: ['toggle-enabled', 'delete', 'edit'],
methods: {
getItemTitle(item) {
return item[this.titleField];
},
getItemEnabled(item) {
return item[this.enabledField];
},
toggleEnabled(item) {
this.$emit('toggle-enabled', item);
}
}
}
</script>
<style scoped>
.item-card {
position: relative;
border-radius: 8px;
transition: all 0.3s ease;
overflow: hidden;
min-height: 220px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
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);
}
</style>
@@ -5,6 +5,7 @@ import axios from 'axios';
import { md5 } from 'js-md5';
import { useAuthStore } from '@/stores/auth';
import { useCommonStore } from '@/stores/common';
import { marked } from 'marked';
const customizer = useCustomizerStore();
let dialog = ref(false);
@@ -15,6 +16,7 @@ let newPassword = ref('');
let newUsername = ref('');
let status = ref('');
let updateStatus = ref('')
let releaseMessage = ref('');
let hasNewVersion = ref(false);
let botCurrVersion = ref('');
let dashboardHasNewVersion = ref(false);
@@ -81,7 +83,13 @@ function checkUpdate() {
axios.get('/api/update/check')
.then((res) => {
hasNewVersion.value = res.data.data.has_new_version;
updateStatus.value = res.data.message;
if (res.data.data.has_new_version) {
releaseMessage.value = res.data.message;
updateStatus.value = '有新版本!';
} else {
updateStatus.value = res.data.message;
}
botCurrVersion.value = res.data.data.version;
dashboardCurrentVersion.value = res.data.data.dashboard_version;
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
@@ -226,15 +234,23 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
<v-card-text>
<v-container>
<v-progress-linear v-show="installLoading" class="mb-4" indeterminate color="primary"></v-progress-linear>
<div>
<h1 style="display:inline-block;">{{ botCurrVersion }}</h1>
<small style="margin-left: 4px;">{{ updateStatus }}</small>
</div>
<div
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
v-html="marked(releaseMessage)" class="markdown-content">
</div>
<div class="mb-4 mt-4">
<small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件这可能会造成部分数据显示错误您可在 <a href="https://github.com/Soulter/AstrBot/releases">此处</a>
找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可当然前端源代码在 dashboard 目录下你也可以自己使用 npm install npm build 构建</small>
<small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件这可能会造成部分数据显示错误您可在 <a
href="https://github.com/Soulter/AstrBot/releases">此处</a>
找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可当然前端源代码在 dashboard 目录下你也可以自己使用 npm install npm build
构建</small>
</div>
<v-tabs v-model="tab">
@@ -269,7 +285,7 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</template>
</v-data-table>
</v-tabs-window-item>
<!-- 开发版 -->
<v-tabs-window-item key="1" v-show="tab == 1">
<div style="margin-top: 16px;">
@@ -319,7 +335,8 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</p>
</div>
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()" :disabled="!dashboardHasNewVersion">
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()"
:disabled="!dashboardHasNewVersion">
下载并更新
</v-btn>
</div>
@@ -379,4 +396,24 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
</v-card>
</v-dialog>
</v-app-bar>
</template>
</template>
<style>
.markdown-content h1 {
font-size: 1.3em;
}
.markdown-content ol {
padding-left: 24px;
/* Adds indentation to ordered lists */
margin-top: 8px;
margin-bottom: 8px;
}
.markdown-content ul {
padding-left: 24px;
/* Adds indentation to unordered lists */
margin-top: 8px;
margin-bottom: 8px;
}
</style>
@@ -16,7 +16,7 @@ const props = defineProps({ item: Object, level: Number });
<template v-slot:prepend>
<v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
</template>
<v-list-item-title style="font-size: 15px;">{{ item.title }}</v-list-item-title>
<v-list-item-title style="font-size: 14px;">{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.subCaption" class="text-caption mt-n1 hide-menu">
{{ item.subCaption }}
</v-list-item-subtitle>
@@ -31,7 +31,12 @@ const sidebarItem: menu[] = [
to: '/providers',
},
{
title: '配置',
title: 'MCP',
icon: 'mdi-function-variant',
to: '/tool-use'
},
{
title: '配置文件',
icon: 'mdi-cog',
to: '/config',
},
@@ -50,6 +55,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-chat',
to: '/chat'
},
{
title: '对话数据库',
icon: 'mdi-database',
to: '/conversation'
},
{
title: '控制台',
icon: 'mdi-console',
+5 -1
View File
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
import vuetify from './plugins/vuetify';
import confirmPlugin from './plugins/confirmPlugin';
import '@/scss/style.scss';
import VueApexCharts from 'vue3-apexcharts';
@@ -15,7 +16,10 @@ app.use(router);
app.use(createPinia());
app.use(print);
app.use(VueApexCharts);
app.use(vuetify).mount('#app');
app.use(vuetify);
app.use(confirmPlugin);
app.mount('#app');
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
+23
View File
@@ -0,0 +1,23 @@
import type { App } from "vue";
import { h, render } from "vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
export default {
install(app: App) {
const mountNode = document.createElement("div");
document.body.appendChild(mountNode);
const vNode = h(ConfirmDialog);
vNode.appContext = app._context;
render(vNode, mountNode);
const confirm = (options: { title?: string; message?: string }) => {
return new Promise<boolean>((resolve) => {
vNode.component?.exposed?.open(options).then(resolve); // ✅ 确保返回 `Promise<boolean>`
});
};
app.config.globalProperties.$confirm = confirm;
app.provide("$confirm", confirm);
},
};
+10
View File
@@ -31,6 +31,11 @@ const MainRoutes = {
path: '/providers',
component: () => import('@/views/ProviderPage.vue')
},
{
name: 'ToolUsePage',
path: '/tool-use',
component: () => import('@/views/ToolUsePage.vue')
},
{
name: 'Configs',
path: '/config',
@@ -41,6 +46,11 @@ const MainRoutes = {
path: '/dashboard/default',
component: () => import('@/views/dashboards/default/DefaultDashboard.vue')
},
{
name: 'Conversation',
path: '/conversation',
component: () => import('@/views/ConversationPage.vue')
},
{
name: 'Console',
path: '/console',
+3
View File
@@ -16,6 +16,9 @@
color: rgb(var(--v-theme-secondary));
}
}
.v-list-item--density-default.v-list-item--one-line {
min-height: 42px;
}
.leftPadding {
margin-left: 4px;
}
+519 -127
View File
@@ -8,164 +8,181 @@ marked.setOptions({
</script>
<template>
<v-card class="chat-page-card">
<v-card-text class="chat-page-container">
<div class="chat-layout">
<!-- 左侧对话列表面板 -->
<div class="sidebar-panel">
<v-btn variant="tonal" rounded="xl" class="new-chat-btn" @click="newC"
:disabled="!currCid">
<v-icon class="mr-2">mdi-plus</v-icon>创建对话
</v-btn>
<v-card style="margin-bottom: 16px; width: 100%; background-color: #fff; height: 100%;">
<v-card-text style="width: 100%; height: calc(100vh - 120px);">
<div style="height: 100%; display: flex; gap: 16px;">
<div style="max-width: 200px;">
<!-- conversation -->
<v-btn variant="tonal" rounded="xl" style="margin-bottom: 16px; min-width: 200px;" @click="newC"
:disabled="!currCid">+ 创建对话</v-btn>
<v-card class="mx-auto" min-width="200">
<v-list dense nav v-if="conversations.length > 0" style="max-height: 500px; overflow-y: auto;"
@update:selected="getConversationMessages">
<v-card class="conversation-list-card" v-if="conversations.length > 0">
<v-list density="compact" nav class="conversation-list" @update:selected="getConversationMessages">
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
color="primary" rounded="xl">
color="primary" rounded="xl" class="conversation-item">
<v-list-item-title>新对话</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(item.updated_at) }}</v-list-item-subtitle>
<v-list-item-subtitle class="timestamp">{{ formatDate(item.updated_at) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
<div>
<v-chip class="mt-4" color="primary" :append-icon="status?.llm_enabled ? 'mdi-check' : 'mdi-close'">
<div class="status-chips">
<v-chip class="status-chip" color="primary" :append-icon="status?.llm_enabled ? 'mdi-check' : 'mdi-close'">
LLM
</v-chip>
<v-chip class="mt-4 ml-2" color="success" :append-icon="status?.stt_enabled ? 'mdi-check' : 'mdi-close'">
<v-chip class="status-chip" color="success" :append-icon="status?.stt_enabled ? 'mdi-check' : 'mdi-close'">
语音转文本
</v-chip>
</div>
<v-btn variant="tonal" rounded="xl"
style="position: fixed; bottom: 48px; margin-bottom: 16px; min-width: 200px;" v-if="currCid"
@click="deleteConversation(currCid)" color="error">删除此对话</v-btn>
<v-btn variant="tonal" rounded="xl" class="delete-chat-btn" v-if="currCid"
@click="deleteConversation(currCid)" color="error">
<v-icon class="mr-2">mdi-delete</v-icon>删除此对话
</v-btn>
</div>
<div style="height: 100%; width: 100%;">
<div style="height: calc(100% - 120px); overflow-y: auto; padding: 16px; " ref="messageContainer">
<div class="fade-in" v-if="messages.length == 0"
style="height: 100%; display: flex; justify-content: center; align-items: center; flex-direction: column;">
<div>
<span style="font-size: 28px;">Hello, I'm</span>
<span style="font-weight: 1000; font-size: 28px; margin-left: 8px;">AstrBot ⭐</span>
<!-- 右侧聊天内容区域 -->
<div class="chat-content-panel">
<div class="messages-container" ref="messageContainer">
<!-- 空聊天欢迎页 -->
<div class="welcome-container fade-in" v-if="messages.length == 0">
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
<div style="margin-top: 8px; color: #aaa;">
<div class="welcome-hint">
<span>输入</span>
<span
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">help</span>
<code>help</code>
<span>获取帮助 😊</span>
</div>
<div style="margin-top: 8px; color: #aaa;">
<div class="welcome-hint">
<span>长按</span>
<span
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">Ctrl</span>
<code>Ctrl</code>
<span>录制语音 🎤</span>
</div>
<div style="margin-top: 8px; color: #aaa;">
<div class="welcome-hint">
<span>按</span>
<span
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">Ctrl + V</span>
<code>Ctrl + V</code>
<span>粘贴图片 🏞️</span>
</div>
</div>
<div v-else style="max-height: 100%; padding: 16px; max-width: 700px; margin: 0 auto;">
<div class="fade-in" v-for="(msg, index) in messages" :key="index"
style="margin-bottom: 16px;">
<div v-if="msg.type == 'user'" style="display: flex; justify-content: flex-end;">
<div
style="padding: 12px; border-radius: 8px; background-color: rgba(94, 53, 177, 0.15)">
<!-- 聊天消息列表 -->
<div v-else class="message-list">
<div class="message-item fade-in" v-for="(msg, index) in messages" :key="index">
<!-- 用户消息 -->
<div v-if="msg.type == 'user'" class="user-message">
<div class="message-bubble user-bubble">
<span>{{ msg.message }}</span>
<div style="display: flex; gap: 8px; margin-top: 8px;"
v-if="msg.image_url && msg.image_url.length > 0">
<div v-for="(img, index) in msg.image_url" :key="index"
style="position: relative; display: inline-block;">
<img :src="img"
style="width: 100px; height: 100px; border-radius: 8px; box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);" />
<!-- 图片附件 -->
<div class="image-attachments" v-if="msg.image_url && msg.image_url.length > 0">
<div v-for="(img, index) in msg.image_url" :key="index" class="image-attachment">
<img :src="img" class="attached-image" />
</div>
</div>
<!-- audio -->
<div>
<audio controls v-if="msg.audio_url && msg.audio_url.length > 0">
<!-- 音频附件 -->
<div class="audio-attachment" v-if="msg.audio_url && msg.audio_url.length > 0">
<audio controls class="audio-player">
<source :src="msg.audio_url" type="audio/wav">
Your browser does not support the audio element.
您的浏览器不支持音频播放。
</audio>
</div>
</div>
<v-avatar class="user-avatar" color="deep-purple-lighten-3" size="36">
<v-icon icon="mdi-account" />
</v-avatar>
</div>
<div v-else style="display: flex; justify-content: flex-start; gap: 16px;">
<span style="font-size: 32px;">✨</span>
<div v-html="marked(msg.message)" class="mc" style="font-family: inherit;"></div>
<!-- 机器人消息 -->
<div v-else class="bot-message">
<v-avatar class="bot-avatar" color="deep-purple" size="36">
<span class="text-h6">✨</span>
</v-avatar>
<div class="message-bubble bot-bubble">
<div v-html="marked(msg.message)" class="markdown-content"></div>
</div>
</div>
</div>
</div>
</div>
<div class="fade-in" style="bottom: 16px; width: 100%; padding: 8px; ">
<!-- 输入区域 -->
<div class="input-area fade-in">
<v-text-field
id="input-field"
variant="outlined"
v-model="prompt"
:label="inputFieldLabel"
placeholder="开始输入..."
:loading="loadingChat"
clear-icon="mdi-close-circle"
clearable
@click:clear="clearMessage"
class="message-input"
@keydown="handleInputKeyDown"
hide-details
>
<template v-slot:loader>
<v-progress-linear :active="loadingChat" height="3" color="deep-purple" indeterminate></v-progress-linear>
</template>
<div
style="width: 100%; justify-content: center; align-items: center; display: flex; flex-direction: column; margin-top: 8px;">
<template v-slot:append>
<v-tooltip text="发送">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
@click="sendMessage"
class="send-btn"
icon="mdi-send"
variant="text"
color="deep-purple"
:disabled="!prompt && stagedImagesUrl.length === 0 && !stagedAudioUrl"
/>
</template>
</v-tooltip>
<v-text-field id="input-field" variant="outlined" v-model="prompt" :label="inputFieldLabel"
placeholder="Start typing..." loading clear-icon="mdi-close-circle" clearable
@click:clear="clearMessage" style="width: 100%; max-width: 850px;"
@keydown="handleInputKeyDown">
<template v-slot:loader>
<v-progress-linear :active="loadingChat" height="6"
indeterminate></v-progress-linear>
</template>
<v-tooltip text="语音输入">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
@click="isRecording ? stopRecording() : startRecording()"
class="record-btn"
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'"
variant="text"
:color="isRecording ? 'error' : 'deep-purple'"
/>
</template>
</v-tooltip>
</template>
</v-text-field>
<template v-slot:append>
<v-tooltip text="发送">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" @click="sendMessage" size="35"
icon="mdi-arrow-up-circle" />
</template>
</v-tooltip>
<v-tooltip text="语音输入">
<template v-slot:activator="{ props }">
<v-icon :color="isRecording ? 'error' : ''" v-bind="props"
@click="isRecording ? stopRecording() : startRecording()" size="35"
icon="mdi-record-circle" />
</template>
</v-tooltip>
</template>
</v-text-field>
<div style="display: flex; gap: 8px; margin-top: -8px;">
<div v-for="(img, index) in stagedImagesUrl" :key="index"
style="position: relative; display: inline-block;">
<img :src="img"
style="width: 50px; height: 50px; border-radius: 8px; box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);" />
<v-icon @click="removeImage(index)" size="20" color="red"
style="position: absolute; top: 0; right: 0; cursor: pointer;">mdi-close-circle</v-icon>
</div>
<div style="display: inline-block; width: 50px; height: 50px;">
<div v-if="stagedAudioUrl"
style="position: relative; padding: 6px; border-radius: 8px; background-color: rgba(94, 53, 177, 0.15); display: inline-block;">
新录音
<v-icon @click="removeAudio" size="20" color="red"
style="position: absolute; top: 0; right: 0; cursor: pointer;">mdi-close-circle</v-icon>
</div>
</div>
<!-- 附件预览区 -->
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl">
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview">
<img :src="img" class="preview-image" />
<v-btn @click="removeImage(index)" class="remove-attachment-btn" icon="mdi-close" size="small" color="error" variant="text" />
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="deep-purple-lighten-4" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
新录音
</v-chip>
<v-btn @click="removeAudio" class="remove-attachment-btn" icon="mdi-close" size="small" color="error" variant="text" />
</div>
</div>
</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'ChatPage',
@@ -192,7 +209,7 @@ export default {
eventSource: null,
// 添加Ctrl键长按相关变量
// Ctrl键长按相关变量
ctrlKeyDown: false,
ctrlKeyTimer: null,
ctrlKeyLongPressThreshold: 300 // 长按阈值,单位毫秒
@@ -574,40 +591,415 @@ export default {
},
},
}
</script>
<style>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.2s ease-in-out;
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.mc h1,
.mc h2,
.mc h3,
.mc h4,
.mc h5,
.mc h6 {
@keyframes slideIn {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* 聊天页面布局 */
.chat-page-card {
margin-bottom: 16px;
width: 100%;
height: 100%;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
background-color: #fff;
}
.chat-page-container {
width: 100%;
height: calc(100vh - 120px);
padding: 0;
}
.chat-layout {
height: 100%;
display: flex;
gap: 24px;
}
/* 侧边栏样式 */
.sidebar-panel {
max-width: 240px;
min-width: 200px;
display: flex;
flex-direction: column;
padding: 16px 8px;
border-right: 1px solid #f0f0f0;
}
.new-chat-btn {
margin-bottom: 16px;
min-width: 200px;
background-color: #f5f0ff !important;
color: #673ab7 !important;
font-weight: 500;
box-shadow: none !important;
transition: all 0.2s ease;
}
.new-chat-btn:hover {
background-color: #ede7f6 !important;
transform: translateY(-1px);
}
.conversation-list-card {
border-radius: 12px;
box-shadow: none !important;
border: 1px solid #f0f0f0;
background-color: #fafafa;
}
.conversation-list {
max-height: 500px;
overflow-y: auto;
padding: 4px;
}
.conversation-item {
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
}
.conversation-item:hover {
background-color: #f5f0ff;
}
.timestamp {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.status-chips {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status-chip {
font-size: 12px;
}
.delete-chat-btn {
position: fixed;
bottom: 24px;
margin-bottom: 16px;
min-width: 200px;
background-color: #feecec !important;
color: #d32f2f !important;
font-weight: 500;
box-shadow: none !important;
}
.delete-chat-btn:hover {
background-color: #ffebee !important;
}
/* 聊天内容区域 */
.chat-content-panel {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
.messages-container {
height: calc(100% - 80px);
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
}
/* 欢迎页样式 */
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.welcome-title {
font-size: 28px;
margin-bottom: 16px;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: #673ab7;
}
.welcome-hint {
margin-top: 8px;
color: #666;
font-size: 14px;
}
.welcome-hint code {
background-color: #f5f0ff;
padding: 2px 6px;
margin: 0 4px;
border-radius: 4px;
color: #673ab7;
font-family: 'Fira Code', monospace;
font-size: 13px;
}
/* 消息列表样式 */
.message-list {
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.message-item {
margin-bottom: 24px;
animation: fadeIn 0.3s ease-out;
}
.user-message {
display: flex;
justify-content: flex-end;
align-items: flex-start;
gap: 12px;
}
.bot-message {
display: flex;
justify-content: flex-start;
align-items: flex-start;
gap: 12px;
}
.message-bubble {
padding: 12px 16px;
border-radius: 18px;
max-width: 80%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.user-bubble {
background-color: #f5f0ff;
color: #333;
border-top-right-radius: 4px;
}
.bot-bubble {
background-color: #fff;
border: 1px solid #e8e8e8;
color: #333;
border-top-left-radius: 4px;
}
.user-avatar, .bot-avatar {
align-self: flex-end;
}
/* 附件样式 */
.image-attachments {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.image-attachment {
position: relative;
display: inline-block;
}
.attached-image {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.attached-image:hover {
transform: scale(1.02);
}
.audio-attachment {
margin-top: 8px;
}
.audio-player {
width: 100%;
height: 36px;
border-radius: 18px;
}
/* 输入区域样式 */
.input-area {
padding: 16px;
background-color: #fff;
position: relative;
border-top: 1px solid #f5f5f5;
}
.message-input {
border-radius: 24px;
max-width: 900px;
margin: 0 auto;
}
.send-btn, .record-btn {
margin-left: 4px;
}
/* 附件预览区 */
.attachments-preview {
display: flex;
gap: 8px;
margin-top: 8px;
max-width: 900px;
margin: 8px auto 0;
flex-wrap: wrap;
}
.image-preview, .audio-preview {
position: relative;
display: inline-flex;
}
.preview-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.audio-chip {
height: 36px;
border-radius: 18px;
}
.remove-attachment-btn {
position: absolute;
top: -8px;
right: -8px;
opacity: 0.8;
transition: opacity 0.2s;
}
.remove-attachment-btn:hover {
opacity: 1;
}
/* Markdown内容样式 */
.markdown-content {
font-family: inherit;
line-height: 1.6;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 16px;
margin-bottom: 10px;
font-weight: 600;
color: #333;
}
.mc li {
.markdown-content h1 {
font-size: 1.8em;
border-bottom: 1px solid #eee;
padding-bottom: 6px;
}
.markdown-content h2 {
font-size: 1.5em;
}
.markdown-content h3 {
font-size: 1.3em;
}
.markdown-content li {
margin-left: 16px;
margin-bottom: 4px;
}
.mc p {
.markdown-content p {
margin-top: 10px;
margin-bottom: 10px;
}
.markdown-content pre {
background-color: #f8f8f8;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
}
.markdown-content code {
background-color: #f5f0ff;
padding: 2px 4px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 0.9em;
color: #673ab7;
}
.markdown-content img {
max-width: 100%;
border-radius: 8px;
margin: 10px 0;
}
.markdown-content blockquote {
border-left: 4px solid #673ab7;
padding-left: 16px;
color: #666;
margin: 16px 0;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #eee;
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background-color: #f5f0ff;
}
/* 动画类 */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
</style>
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -67,7 +67,7 @@ import { useCommonStore } from '@/stores/common';
<v-col cols="12" md="12" style="padding: 0px;">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
:loading="loading_" v-model:search="marketSearch"
:filter-keys="['name', 'desc', 'author']">
:filter-keys="filterKeys">
<template v-slot:item.name="{ item }">
<div class="d-flex align-center">
<img v-if="item.logo" :src="item.logo"
@@ -221,7 +221,9 @@ export default {
],
marketSearch: "",
commonStore: useCommonStore()
commonStore: useCommonStore(),
filterKeys: ['name', 'desc', 'author']
}
},
computed: {
@@ -231,8 +233,9 @@ export default {
}
const search = this.marketSearch.toLowerCase();
return this.pluginMarketData.filter(plugin =>
plugin.name.toLowerCase().includes(search)
);
this.filterKeys.some(key =>
plugin[key]?.toLowerCase().includes(search)
));
},
pinnedPlugins() {
return this.pluginMarketData.filter(plugin => plugin?.pinned);
@@ -354,6 +357,7 @@ export default {
this.upload_file = "";
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
this.getExtensions();
// this.$refs.wfr.check();
}).catch((err) => {
this.loading_ = false;
@@ -377,6 +381,7 @@ export default {
this.extension_url = "";
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
this.getExtensions();
// this.$refs.wfr.check();
}).catch((err) => {
this.loading_ = false;
+1 -1
View File
@@ -46,7 +46,7 @@ const filteredExtensions = computed(() => {
if (showReserved.value) {
return extension_data.data;
}
return extension_data.data.filter(ext => !ext.reserved);
return extension_data?.data?.filter(ext => !ext.reserved);
});
//
+277 -239
View File
@@ -1,267 +1,305 @@
<template>
<v-card style="height: 100%;">
<v-card-text style="padding: 32px; height: 100%;">
<div class="platform-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-connection</v-icon>平台适配器管理
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
管理机器人的平台适配器连接到不同的聊天平台
</p>
</v-col>
</v-row>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn class="flex-grow-1" variant="tonal" @click="new_platform_dialog = true" size="large"
rounded="lg" v-bind="props" color="primary">
<template v-slot:default>
<v-icon>mdi-plus</v-icon>
新增平台适配器
</template>
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['platform_group']['metadata']['platform'].config_template"
:key="index" rounded="xl" :value="index">
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-row style="margin-top: 16px;">
<v-col v-for="(platform, index) in config_data['platform']" :key="index" cols="12" md="6" lg="3">
<v-card class="fade-in"
style="margin-bottom: 16px; min-height: 250px; max-height: 250px; display: flex; justify-content: space-between; flex-direction: column;">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h4">{{ platform.id }}</span>
<v-switch color="primary" hide-details density="compact" v-model="platform['enable']"
@update:modelValue="platformStatusChange(platform)"></v-switch>
</v-card-title>
<v-card-text>
<div>
<span style="font-size:12px">适配器类型: </span>
<v-chip size="small" color="primary" text>{{ platform.type }}</v-chip>
</div>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="error" text @click="deletePlatform(platform.id);">
删除
</v-btn>
<v-btn color="blue-darken-1" text
@click="updatingMode = true; showPlatformCfg = true; newSelectedPlatformConfig = platform; newSelectedPlatformName = platform.id">
配置
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="showPlatformCfg">
<v-card>
<v-card-title>
<span class="text-h4">{{ newSelectedPlatformName }} 配置</span>
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<AstrBotConfig :iterable="newSelectedPlatformConfig"
:metadata="metadata['platform_group']['metadata']" metadataKey="platform" />
</v-col>
<v-col cols="12" md="6">
<v-btn :loading="iframeLoading" @click="refreshIframe" variant="tonal" color="primary" style="float: right;">
<v-icon>mdi-refresh</v-icon>
刷新
</v-btn>
<iframe v-show="!iframeLoading"
:src="store.getTutorialLink(newSelectedPlatformConfig.type)"
@load="iframeLoading = false" style="width: 100%; border: none; height: 100%;">
</iframe>
</v-col>
</v-row>
<!-- 平台适配器部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-apps</v-icon>
<span class="text-h6">平台适配器</span>
<v-chip color="info" size="small" class="ml-2">{{ config_data.platform?.length || 0 }}</v-chip>
<v-spacer></v-spacer>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" v-bind="props">
新增适配器
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['platform_group']?.metadata?.platform?.config_template || {}"
:key="index"
rounded="xl"
:value="index"
>
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-title>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="newPlatform" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-btn style="margin-top: 16px" class="flex-grow-1" variant="tonal" size="large" rounded="lg" color="gray"
@click="showConsole = !showConsole">
<template v-slot:default>
<v-icon>mdi-console-line</v-icon>
{{ showConsole ? '隐藏' : '显示' }}日志
</template>
</v-btn>
<div v-if="showConsole" style="margin-top: 32px">
<ConsoleDisplayer style="background-color: #000; height: 300px"></ConsoleDisplayer>
</div>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<item-card-grid
:items="config_data.platform || []"
title-field="id"
enabled-field="enable"
empty-icon="mdi-connection"
empty-text="暂无平台适配器,点击 新增适配器 添加"
@toggle-enabled="platformStatusChange"
@delete="deletePlatform"
@edit="editPlatform"
>
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
<span class="text-caption text-medium-emphasis">
适配器类型:
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
</span>
</div>
<div v-if="item.token" class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-key</v-icon>
<span class="text-caption text-medium-emphasis">Token: </span>
</div>
<div v-if="item.description" class="d-flex align-center">
<v-icon size="small" color="grey" class="me-2">mdi-information-outline</v-icon>
<span class="text-caption text-medium-emphasis text-truncate">{{ item.description }}</span>
</div>
</template>
</item-card-grid>
</v-card-text>
</v-card>
</v-card>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
<!-- 日志部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-console-line</v-icon>
<span class="text-h6">平台日志</span>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
{{ showConsole ? '收起' : '展开' }}
<v-icon>{{ showConsole ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-expand-transition>
<v-card-text class="pa-0" v-if="showConsole">
<ConsoleDisplayer style="background-color: #1e1e1e; height: 300px; border-radius: 0"></ConsoleDisplayer>
</v-card-text>
</v-expand-transition>
</v-card>
</v-container>
<!-- 配置对话框 -->
<v-dialog v-model="showPlatformCfg" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ updatingMode ? '编辑' : '新增' }} {{ newSelectedPlatformName }} 平台适配器</span>
</v-card-title>
<v-card-text class="py-4">
<v-row>
<v-col cols="12" md="8">
<AstrBotConfig :iterable="newSelectedPlatformConfig"
:metadata="metadata['platform_group']?.metadata"
metadataKey="platform" />
</v-col>
<v-col cols="12" md="4">
<v-btn :loading="iframeLoading" @click="refreshIframe" variant="tonal" color="primary" style="float: right;">
<v-icon>mdi-refresh</v-icon>
刷新
</v-btn>
<iframe v-show="!iframeLoading"
:src="store.getTutorialLink(newSelectedPlatformConfig.type)"
@load="iframeLoading = false" style="width: 100%; border: none; height: 100%; min-height: 400px;">
</iframe>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showPlatformCfg = false" :disabled="loading">
取消
</v-btn>
<v-btn color="primary" @click="newPlatform" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</div>
</template>
<script>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
import { useCommonStore } from '@/stores/common';
export default {
name: 'PlatformPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showPlatformCfg: false,
name: 'PlatformPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer,
ItemCardGrid
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showPlatformCfg: false,
newSelectedPlatformName: '',
newSelectedPlatformConfig: {},
updatingMode: false,
newSelectedPlatformName: '',
newSelectedPlatformConfig: {},
updatingMode: false,
loading: false,
loading: false,
save_message_snack: false,
save_message: "",
save_message_success: "",
save_message_snack: false,
save_message: "",
save_message_success: "success",
showConsole: false,
iframeLoading: true,
store: useCommonStore()
}
},
mounted() {
this.getConfig();
},
methods: {
refreshIframe() {
this.iframeLoading = true;
const iframe = document.querySelector('iframe');
console.log(iframe.src);
iframe.src = iframe.src + '?t=' + new Date().getTime();
},
getConfig() {
//
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
addFromDefaultConfigTmpl(index) {
//
console.log(index);
this.newSelectedPlatformName = index[0];
this.showPlatformCfg = true;
this.updatingMode = false;
this.newSelectedPlatformConfig = this.metadata['platform_group']['metadata']['platform'].config_template[index[0]];
},
newPlatform() {
//
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/platform/update', {
id: this.newSelectedPlatformName,
config: this.newSelectedPlatformConfig
}).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
this.updatingMode = false;
} else {
axios.post('/api/config/platform/new', this.newSelectedPlatformConfig).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
},
deletePlatform(platform_id) {
//
axios.post('/api/config/platform/delete', { id: platform_id }).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
platformStatusChange(platform) {
//
axios.post('/api/config/platform/update', {
id: platform.id,
config: platform
}).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
showConsole: false,
iframeLoading: true,
store: useCommonStore()
}
}
},
mounted() {
this.getConfig();
},
methods: {
refreshIframe() {
this.iframeLoading = true;
const iframe = document.querySelector('iframe');
iframe.src = iframe.src + '?t=' + new Date().getTime();
},
getConfig() {
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
this.showError(err);
});
},
addFromDefaultConfigTmpl(index) {
this.newSelectedPlatformName = index[0];
this.showPlatformCfg = true;
this.updatingMode = false;
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(
this.metadata['platform_group']?.metadata?.platform?.config_template[index[0]] || {}
));
},
editPlatform(platform) {
this.newSelectedPlatformName = platform.id;
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(platform));
this.updatingMode = true;
this.showPlatformCfg = true;
},
newPlatform() {
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/platform/update', {
id: this.newSelectedPlatformName,
config: this.newSelectedPlatformConfig
}).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || "更新成功!");
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
this.updatingMode = false;
} else {
axios.post('/api/config/platform/new', this.newSelectedPlatformConfig).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.showSuccess(res.data.message || "添加成功!");
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
}
},
deletePlatform(platform) {
if (confirm(`确定要删除平台适配器 ${platform.id} 吗?`)) {
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || "删除成功!");
}).catch((err) => {
this.showError(err.response?.data?.message || err.message);
});
}
},
platformStatusChange(platform) {
platform.enable = !platform.enable; //
axios.post('/api/config/platform/update', {
id: platform.id,
config: platform
}).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || "状态更新成功!");
}).catch((err) => {
platform.enable = !platform.enable; //
this.showError(err.response?.data?.message || err.message);
});
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
this.save_message_snack = true;
}
}
}
</script>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.2s ease-in-out;
<style scoped>
.platform-page {
padding: 20px;
padding-top: 8px;
}
</style>
+303 -212
View File
@@ -1,238 +1,329 @@
<template>
<v-card style="height: 100%;">
<v-card-text style="padding: 32px; height: 100%;">
<div class="provider-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-creation</v-icon>服务提供商管理
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
管理AI服务提供商连接到不同的大语言模型
</p>
</v-col>
</v-row>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn class="flex-grow-1" variant="tonal" @click="new_provider_dialog = true" size="large"
rounded="lg" v-bind="props" color="primary">
<template v-slot:default>
<v-icon>mdi-plus</v-icon>
新增服务提供商
</template>
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['provider_group']['metadata']['provider'].config_template"
:key="index" rounded="xl" :value="index">
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-row style="margin-top: 16px;">
<v-col v-for="(provider, index) in config_data['provider']" :key="index" cols="12" md="6" lg="3">
<v-card class="fade-in" style="margin-bottom: 16px; min-height: 250px; max-height: 250px; display: flex; justify-content: space-between; flex-direction: column;">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h4">{{ provider.id }}</span>
<v-switch color="primary" hide-details density="compact" v-model="provider['enable']"
@update:modelValue="providerStatusChange(provider)"></v-switch>
</v-card-title>
<v-card-text>
<div>
<span style="font-size:12px">适配器类型: </span> <v-chip size="small" color="primary" text>{{ provider.type }}</v-chip>
</div>
<div v-if="provider?.api_base" style="margin-top: 8px;">
<span style="font-size:12px">API Base: </span> <v-chip size="small" color="primary" text>{{ provider?.api_base }}</v-chip>
</div>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="error" text @click="deleteprovider(provider.id);">
删除
</v-btn>
<v-btn color="blue-darken-1" text
@click="updatingMode = true; showproviderCfg = true; newSelectedproviderConfig = provider; newSelectedproviderName = provider.id">
配置
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="showproviderCfg" width="700">
<v-card>
<v-card-title>
<span class="text-h4">{{ newSelectedproviderName }} 配置</span>
</v-card-title>
<v-card-text>
<AstrBotConfig :iterable="newSelectedproviderConfig"
:metadata="metadata['provider_group']['metadata']" metadataKey="provider" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="newprovider" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 服务提供商部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-api</v-icon>
<span class="text-h6">服务提供商</span>
<v-chip color="info" size="small" class="ml-2">{{ config_data.provider?.length || 0 }}</v-chip>
<v-spacer></v-spacer>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" v-bind="props">
新增服务提供商
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['provider_group']?.metadata?.provider?.config_template || {}"
:key="index"
rounded="xl"
:value="index"
>
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-title>
<v-btn style="margin-top: 16px" class="flex-grow-1" variant="tonal" size="large" rounded="lg" color="gray" @click="showConsole = !showConsole">
<template v-slot:default>
<v-icon>mdi-console-line</v-icon>
{{ showConsole ? '隐藏' : '显示' }}日志
</template>
</v-btn>
<div v-if="showConsole" style="margin-top: 32px">
<ConsoleDisplayer style="background-color: #000; height: 300px"></ConsoleDisplayer>
</div>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<item-card-grid
:items="config_data.provider || []"
title-field="id"
enabled-field="enable"
empty-icon="mdi-api-off"
empty-text="暂无服务提供商,点击 新增服务提供商 添加"
@toggle-enabled="providerStatusChange"
@delete="deleteProvider"
@edit="configExistingProvider"
>
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
<span class="text-caption text-medium-emphasis">
提供商类型:
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
</span>
</div>
<div v-if="item.api_base" class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-web</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="item.api_base">
API Base: {{ item.api_base }}
</span>
</div>
<div v-if="item.api_key" class="d-flex align-center">
<v-icon size="small" color="grey" class="me-2">mdi-key</v-icon>
<span class="text-caption text-medium-emphasis">API Key: </span>
</div>
</template>
</item-card-grid>
</v-card-text>
</v-card>
</v-card>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
<!-- 日志部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-console-line</v-icon>
<span class="text-h6">服务日志</span>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
{{ showConsole ? '收起' : '展开' }}
<v-icon>{{ showConsole ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-expand-transition>
<v-card-text class="pa-0" v-if="showConsole">
<ConsoleDisplayer style="background-color: #1e1e1e; height: 300px; border-radius: 0"></ConsoleDisplayer>
</v-card-text>
</v-expand-transition>
</v-card>
</v-container>
<!-- 配置对话框 -->
<v-dialog v-model="showProviderCfg" width="900" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ updatingMode ? '编辑' : '新增' }} {{ newSelectedProviderName }} 服务提供商</span>
</v-card-title>
<v-card-text class="py-4">
<AstrBotConfig
:iterable="newSelectedProviderConfig"
:metadata="metadata['provider_group']?.metadata"
metadataKey="provider"
/>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
取消
</v-btn>
<v-btn color="primary" @click="newProvider" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</div>
</template>
<script>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
export default {
name: 'ProviderPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showproviderCfg: false,
name: 'ProviderPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer,
ItemCardGrid
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showProviderCfg: false,
newSelectedproviderName: '',
newSelectedproviderConfig: {},
updatingMode: false,
newSelectedProviderName: '',
newSelectedProviderConfig: {},
updatingMode: false,
loading: false,
loading: false,
save_message_snack: false,
save_message: "",
save_message_success: "",
showConsole: false,
}
},
mounted() {
this.getConfig();
},
methods: {
getConfig() {
//
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
save_message = err;
save_message_snack = true;
save_message_success = "error";
});
},
addFromDefaultConfigTmpl(index) {
//
console.log(index);
this.newSelectedproviderName = index[0];
this.showproviderCfg = true;
this.updatingMode = false;
this.newSelectedproviderConfig = this.metadata['provider_group']['metadata']['provider'].config_template[index[0]];
},
newprovider() {
//
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/provider/update', {
id: this.newSelectedproviderName,
config: this.newSelectedproviderConfig
}).then((res) => {
this.loading = false;
this.showproviderCfg = false;
this.getConfig();
// this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
this.updatingMode = false;
} else {
axios.post('/api/config/provider/new', this.newSelectedproviderConfig).then((res) => {
this.loading = false;
this.showproviderCfg = false;
this.getConfig();
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
},
deleteprovider(provider_id) {
//
axios.post('/api/config/provider/delete', { id: provider_id }).then((res) => {
this.getConfig();
// this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
providerStatusChange(provider) {
//
axios.post('/api/config/provider/update', {
id: provider.id,
config: provider
}).then((res) => {
this.getConfig();
// this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
save_message_snack: false,
save_message: "",
save_message_success: "success",
showConsole: false,
}
}
},
mounted() {
this.getConfig();
},
methods: {
getConfig() {
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
this.showError(err.response?.data?.message || err.message);
});
},
addFromDefaultConfigTmpl(index) {
this.newSelectedProviderName = index[0];
this.showProviderCfg = true;
this.updatingMode = false;
this.newSelectedProviderConfig = JSON.parse(JSON.stringify(
this.metadata['provider_group']?.metadata?.provider?.config_template[index[0]] || {}
));
},
configExistingProvider(provider) {
this.newSelectedProviderName = provider.id;
this.newSelectedProviderConfig = {};
//
let templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
let defaultConfig = {};
for (let key in templates) {
if (templates[key]?.type === provider.type) {
defaultConfig = templates[key];
break;
}
}
const mergeConfigWithOrder = (target, source, reference) => {
// sourcetarget
if (source && typeof source === 'object' && !Array.isArray(source)) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = Array.isArray(source[key]) ? [...source[key]] : {...source[key]};
} else {
target[key] = source[key];
}
}
}
}
// reference
for (let key in reference) {
if (typeof reference[key] === 'object' && reference[key] !== null) {
if (!(key in target)) {
target[key] = Array.isArray(reference[key]) ? [] : {};
}
mergeConfigWithOrder(
target[key],
source && source[key] ? source[key] : {},
reference[key]
);
} else if (!(key in target)) {
// targetreference
target[key] = reference[key];
}
}
};
if (defaultConfig) {
mergeConfigWithOrder(this.newSelectedProviderConfig, provider, defaultConfig);
}
this.showProviderCfg = true;
this.updatingMode = true;
},
newProvider() {
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/provider/update', {
id: this.newSelectedProviderName,
config: this.newSelectedProviderConfig
}).then((res) => {
this.loading = false;
this.showProviderCfg = false;
this.getConfig();
this.showSuccess(res.data.message || "更新成功!");
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
this.updatingMode = false;
} else {
axios.post('/api/config/provider/new', this.newSelectedProviderConfig).then((res) => {
this.loading = false;
this.showProviderCfg = false;
this.getConfig();
this.showSuccess(res.data.message || "添加成功!");
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
}
},
deleteProvider(provider) {
if (confirm(`确定要删除服务提供商 ${provider.id} 吗?`)) {
axios.post('/api/config/provider/delete', { id: provider.id }).then((res) => {
this.getConfig();
this.showSuccess(res.data.message || "删除成功!");
}).catch((err) => {
this.showError(err.response?.data?.message || err.message);
});
}
},
providerStatusChange(provider) {
provider.enable = !provider.enable; //
axios.post('/api/config/provider/update', {
id: provider.id,
config: provider
}).then((res) => {
this.getConfig();
this.showSuccess(res.data.message || "状态更新成功!");
}).catch((err) => {
provider.enable = !provider.enable; //
this.showError(err.response?.data?.message || err.message);
});
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
this.save_message_snack = true;
}
}
}
</script>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.2s ease-in-out;
<style scoped>
.provider-page {
padding: 20px;
padding-top: 8px;
}
</style>
+1 -4
View File
@@ -5,7 +5,7 @@
<v-list lines="two">
<v-list-subheader>网络</v-list-subheader>
<v-list-item subtitle="设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效" title="GitHub 加速地址">
<v-list-item subtitle="设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效。所有地址均不保证稳定性,如果在更新插件/项目时出现报错,请首先检查加速地址是否能正常使用。" title="GitHub 加速地址">
<v-combobox variant="outlined" style="width: 100%; margin-top: 16px;" v-model="selectedGitHubProxy" :items="githubProxies"
label="选择 GitHub 加速地址">
@@ -41,11 +41,8 @@ export default {
data() {
return {
githubProxies: [
"https://ghproxy.cn",
"https://gh.llkk.cc",
"https://ghproxy.net",
"https://gitproxy.click",
"https://github.tbedu.top"
],
selectedGitHubProxy: "",
}
+649
View File
@@ -0,0 +1,649 @@
<template>
<div class="tools-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-function-variant</v-icon>函数工具管理
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4 d-flex align-center">
管理 MCP 服务器和查看可用的函数工具
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon
v-bind="props"
size="small"
color="primary"
class="ms-1 cursor-pointer"
@click="openurl('https://astrbot.app/use/function-calling.html')"
>
mdi-information
</v-icon>
</template>
<span>函数调用和 MCP 是什么</span>
</v-tooltip>
</p>
</v-col>
</v-row>
<!-- MCP 服务器部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-server</v-icon>
<span class="text-h6">MCP 服务器</span>
<v-spacer></v-spacer>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showMcpServerDialog = true">
新增服务器
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<v-row v-if="mcpServers.length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">暂无 MCP 服务器点击"新增服务器"添加</p>
</v-col>
</v-row>
<v-row v-else>
<v-col v-for="(server, index) in mcpServers" :key="index" cols="12" md="6" lg="4" xl="3">
<v-card class="server-card hover-elevation" :color="server.active ? '' : 'grey-lighten-4'">
<div class="server-status-indicator" :class="{'active': server.active}"></div>
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h6 text-truncate" :title="server.name">{{ server.name }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-switch color="primary" hide-details density="compact" v-model="server.active"
v-bind="props" @update:modelValue="updateServerStatus(server)"></v-switch>
</template>
<span>{{ server.active ? '已启用' : '已禁用' }}</span>
</v-tooltip>
</v-card-title>
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(server)">
{{ getServerConfigSummary(server) }}
</span>
</div>
<div v-if="server.tools && server.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<span class="text-caption text-medium-emphasis">可用工具 ({{ server.tools.length }})</span>
</div>
<v-chip-group class="tool-chips">
<v-chip v-for="(tool, idx) in server.tools" :key="idx" size="x-small"
density="compact" color="info" class="text-caption">
{{ tool }}
</v-chip>
</v-chip-group>
</div>
<div v-else class="text-caption text-medium-emphasis mt-2">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
无可用工具
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-2">
<v-spacer></v-spacer>
<v-btn variant="text" size="small" color="error" prepend-icon="mdi-delete"
@click="deleteServer(server.name)">
删除
</v-btn>
<v-btn variant="text" size="small" color="primary" prepend-icon="mdi-pencil"
@click="editServer(server)">
编辑
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 函数工具部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-function</v-icon>
<span class="text-h6">函数工具</span>
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showTools = !showTools">
{{ showTools ? '收起' : '展开' }}
<v-icon>{{ showTools ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-expand-transition>
<v-card-text class="pa-3" v-if="showTools">
<div v-if="tools.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">没有可用的函数工具</p>
</div>
<div v-else>
<v-text-field
v-model="toolSearch"
prepend-inner-icon="mdi-magnify"
label="搜索函数工具"
variant="outlined"
density="compact"
class="mb-4"
hide-details
clearable
></v-text-field>
<v-expansion-panels v-model="openedPanel" multiple>
<v-expansion-panel
v-for="(tool, index) in filteredTools"
:key="index"
:value="index"
class="mb-2 tool-panel"
rounded="lg"
>
<v-expansion-panel-title>
<v-row no-gutters align="center">
<v-col cols="3">
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">
{{ tool.function.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
:title="tool.function.name">
{{ formatToolName(tool.function.name) }}
</span>
</div>
</v-col>
<v-col cols="9" class="text-grey">
{{ tool.function.description }}
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
功能描述
</p>
<p class="text-body-2 ml-6 mb-4">{{ tool.function.description }}</p>
<template v-if="tool.function.parameters && tool.function.parameters.properties">
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
参数列表
</p>
<v-table density="compact" class="params-table mt-1">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.function.parameters.properties" :key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text class="text-caption">
{{ param.type }}
</v-chip>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
<div v-else class="text-center pa-4 text-medium-emphasis">
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
<p>此工具没有参数</p>
</div>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card-text>
</v-expand-transition>
</v-card>
</v-container>
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showMcpServerDialog" max-width="750px" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">{{ isEditMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ isEditMode ? '编辑' : '新增' }} MCP 服务器</span>
</v-card-title>
<v-card-text class="py-4">
<v-form @submit.prevent="saveServer" ref="form">
<v-text-field
v-model="currentServer.name"
label="服务器名称"
variant="outlined"
:rules="[v => !!v || '名称是必填项']"
required
class="mb-3"
></v-text-field>
<v-switch
v-model="currentServer.active"
label="启用服务器"
color="primary"
hide-details
class="mb-3"
></v-switch>
<div class="mb-2 d-flex align-center">
<span class="text-subtitle-1">服务器配置</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" class="ms-2" size="small" color="primary">mdi-information</v-icon>
</template>
<div>
<p class="mb-1">MCP 服务器(stdio)配置支持以下字段:</p>
<p class="mb-1"><code>command</code>: 命令名称 (例如 python uv)</p>
<p class="mb-1"><code>args</code>: 命令参数数组 (例如 ["run", "server.py"])</p>
<p class="mb-1"><code>env</code>: 环境变量对象 (例如 {"api_key": "abc"})</p>
<p class="mb-1"><code>cwd</code>: 工作目录路径 (例如 /path/to/server)</p>
<p class="mb-1"><code>encoding</code>: 输出编码 (默认 utf-8)</p>
<p class="mb-1"><code>encoding_error_handler</code>: The text encoding error handler. Defaults to strict.</p>
<p class="mb-1">其他字段请参考 MCP 文档</p>
<p class="mb-1"> 如果您使用 Docker 部署 AstrBot, 请务必将 MCP 服务器装在 AstrBot 挂载好的 data 目录下</p>
</div>
</v-tooltip>
<v-spacer></v-spacer>
<v-btn
size="small"
color="info"
variant="text"
@click="setConfigTemplate"
class="me-1"
>
使用模板
</v-btn>
</div>
<div class="monaco-container">
<VueMonacoEditor
v-model:value="serverConfigJson"
theme="vs-dark"
language="json"
:options="{
minimap: {
enabled: false
},
scrollBeyondLastLine: false,
automaticLayout: true,
lineNumbers: 'on',
roundedSelection: true,
tabSize: 2
}"
@change="validateJson"
/>
</div>
<div v-if="jsonError" class="mt-2 text-error">
<v-icon color="error" size="small" class="me-1">mdi-alert-circle</v-icon>
<span>{{ jsonError }}</span>
</div>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="closeServerDialog" :disabled="loading">
取消
</v-btn>
<v-btn
color="primary"
@click="saveServer"
:loading="loading"
:disabled="!isServerFormValid"
>
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
export default {
name: 'ToolUsePage',
components: {
AstrBotConfig,
VueMonacoEditor
},
data() {
return {
mcpServers: [],
tools: [],
showMcpServerDialog: false,
showTools: true,
loading: false,
isEditMode: false,
serverConfigJson: '',
jsonError: null,
currentServer: {
name: '',
active: true,
tools: []
},
save_message_snack: false,
save_message: "",
save_message_success: "success",
toolSearch: '',
openedPanel: [], //
}
},
computed: {
filteredTools() {
if (!this.toolSearch) return this.tools;
const searchTerm = this.toolSearch.toLowerCase();
return this.tools.filter(tool =>
tool.function.name.toLowerCase().includes(searchTerm) ||
tool.function.description.toLowerCase().includes(searchTerm)
);
},
isServerFormValid() {
return !!this.currentServer.name && !this.jsonError;
},
//
getServerConfigSummary() {
return (server) => {
if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`;
}
// command
const configKeys = Object.keys(server).filter(key =>
!['name', 'active', 'tools'].includes(key)
);
if (configKeys.length > 0) {
return `配置: ${configKeys.join(', ')}`;
}
return '未设置配置';
}
}
},
mounted() {
this.getServers();
this.getTools();
},
methods: {
openurl(url) {
window.open(url, '_blank');
},
formatToolName(name) {
if (name.includes(':')) {
// MCP mcp:server:tool
const parts = name.split(':');
return parts[parts.length - 1]; //
}
return name;
},
getServers() {
axios.get('/api/tools/mcp/servers')
.then(response => {
this.mcpServers = response.data.data || [];
})
.catch(error => {
this.showError("获取 MCP 服务器列表失败: " + error.message);
});
},
getTools() {
axios.get('/api/config/llmtools')
.then(response => {
this.tools = response.data.data || [];
})
.catch(error => {
this.showError("获取函数工具列表失败: " + error.message);
});
},
validateJson() {
try {
if (!this.serverConfigJson.trim()) {
this.jsonError = '配置不能为空';
return false;
}
JSON.parse(this.serverConfigJson);
this.jsonError = null;
return true;
} catch (e) {
this.jsonError = `JSON 格式错误: ${e.message}`;
return false;
}
},
setConfigTemplate() {
//
const template = {
command: "python",
args: ["-m", "your_module"],
// MCP
};
this.serverConfigJson = JSON.stringify(template, null, 2);
},
saveServer() {
if (!this.validateJson()) {
return;
}
this.loading = true;
// JSON
try {
const configObj = JSON.parse(this.serverConfigJson);
//
const serverData = {
name: this.currentServer.name,
active: this.currentServer.active,
...configObj
};
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
axios.post(endpoint, serverData)
.then(response => {
this.loading = false;
this.showMcpServerDialog = false;
this.getServers();
this.getTools();
this.showSuccess(response.data.message || "保存成功!");
this.resetForm();
})
.catch(error => {
this.loading = false;
this.showError("保存失败: " + (error.response?.data?.message || error.message));
});
} catch (e) {
this.loading = false;
this.showError(`JSON 解析错误: ${e.message}`);
}
},
deleteServer(serverName) {
if (confirm(`确定要删除服务器 ${serverName} 吗?`)) {
axios.post('/api/tools/mcp/delete', { name: serverName })
.then(response => {
this.getServers();
this.getTools();
this.showSuccess(response.data.message || "删除成功!");
})
.catch(error => {
this.showError("删除失败: " + (error.response?.data?.message || error.message));
});
}
},
editServer(server) {
//
const configCopy = { ...server };
//
delete configCopy.name;
delete configCopy.active;
delete configCopy.tools;
//
this.currentServer = {
name: server.name,
active: server.active,
tools: server.tools || []
};
// JSON
this.serverConfigJson = JSON.stringify(configCopy, null, 2);
this.isEditMode = true;
this.showMcpServerDialog = true;
},
updateServerStatus(server) {
axios.post('/api/tools/mcp/update', server)
.then(response => {
this.getServers();
this.showSuccess(response.data.message || "更新成功!");
})
.catch(error => {
this.showError("更新失败: " + (error.response?.data?.message || error.message));
//
server.active = !server.active;
});
},
closeServerDialog() {
this.showMcpServerDialog = false;
this.resetForm();
},
resetForm() {
this.currentServer = {
name: '',
active: true,
tools: []
};
this.serverConfigJson = '';
this.jsonError = null;
this.isEditMode = false;
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
this.save_message_snack = true;
}
}
}
</script>
<style scoped>
.tools-page {
padding: 20px;
padding-top: 8px;
}
.server-card {
position: relative;
border-radius: 8px;
transition: all 0.3s ease;
overflow: hidden;
}
.server-status-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #e0e0e0;
}
.server-status-indicator.active {
background-color: #4CAF50;
}
.hover-elevation:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.tool-chips {
max-height: 60px;
overflow-y: auto;
}
.tool-panel {
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.tool-panel:hover {
border-color: rgba(0, 0, 0, 0.1);
}
.params-table {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
}
.params-table th {
background-color: rgba(0, 0, 0, 0.02);
}
.monaco-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 4px;
height: 300px;
overflow: hidden;
}
</style>
@@ -1,37 +1,84 @@
<template>
<v-row style="margin: 2px;">
<v-alert
:type="noticeType"
:text="noticeContent"
:title="noticeTitle"
v-if="noticeTitle && noticeContent"
closable
></v-alert>
</v-row>
<v-row>
<v-col cols="12" md="4">
<TotalMessage :stat="stat" />
</v-col>
<v-col cols="12" md="4">
<OnlinePlatform :stat="stat" />
</v-col>
<v-col cols="12" md="4">
<OnlineTime :stat="stat" />
</v-col>
<v-col cols="12" lg="8">
<MessageStat :stat="stat" />
</v-col>
<v-col cols="12" lg="4">
<PlatformStat :stat="stat" />
</v-col>
</v-row>
<div class="dashboard-container">
<div class="dashboard-header">
<h1 class="dashboard-title">控制台</h1>
<div class="dashboard-subtitle">实时监控和统计数据</div>
</div>
<v-slide-y-transition>
<v-row v-if="noticeTitle && noticeContent" class="notice-row">
<v-alert
:type="noticeType"
:text="noticeContent"
:title="noticeTitle"
closable
class="dashboard-alert"
variant="tonal"
border="start"
></v-alert>
</v-row>
</v-slide-y-transition>
<!-- 主指标卡片行 -->
<v-row class="stats-row">
<v-col cols="12" md="3">
<v-slide-y-transition>
<TotalMessage :stat="stat" />
</v-slide-y-transition>
</v-col>
<v-col cols="12" md="3">
<v-slide-y-transition>
<OnlinePlatform :stat="stat" />
</v-slide-y-transition>
</v-col>
<v-col cols="12" md="3">
<v-slide-y-transition>
<RunningTime :stat="stat" />
</v-slide-y-transition>
</v-col>
<v-col cols="12" md="3">
<v-slide-y-transition>
<MemoryUsage :stat="stat" />
</v-slide-y-transition>
</v-col>
</v-row>
<!-- 图表行 -->
<v-row class="charts-row">
<v-col cols="12" lg="8">
<v-slide-y-transition>
<MessageStat />
</v-slide-y-transition>
</v-col>
<v-col cols="12" lg="4">
<v-slide-y-transition>
<PlatformStat :stat="stat" />
</v-slide-y-transition>
</v-col>
</v-row>
<div class="dashboard-footer">
<v-chip size="small" color="primary" variant="flat" prepend-icon="mdi-refresh">
最后更新: {{ lastUpdated }}
</v-chip>
<v-btn
icon="mdi-refresh"
size="small"
color="primary"
variant="text"
class="ml-2"
@click="fetchData"
:loading="isRefreshing"
></v-btn>
</div>
</div>
</template>
<script>
import TotalMessage from './components/TotalMessage.vue';
import OnlinePlatform from './components/OnlinePlatform.vue';
import OnlineTime from './components/OnlineTime.vue';
import RunningTime from './components/RunningTime.vue';
import MemoryUsage from './components/MemoryUsage.vue';
import MessageStat from './components/MessageStat.vue';
import PlatformStat from './components/PlatformStat.vue';
import axios from 'axios';
@@ -41,7 +88,8 @@ export default {
components: {
TotalMessage,
OnlinePlatform,
OnlineTime,
RunningTime,
MemoryUsage,
MessageStat,
PlatformStat,
},
@@ -50,23 +98,142 @@ export default {
noticeTitle: '',
noticeContent: '',
noticeType: '',
lastUpdated: '加载中...',
refreshInterval: null,
isRefreshing: false
}),
mounted() {
axios.get('/api/stat/get').then((res) => {
this.stat = res.data.data;
});
axios.get('https://api.soulter.top/astrbot-announcement').then((res) => {
let data = res.data.data;
// dashboard-notice
if (data['dashboard-notice']) {
this.noticeTitle = data['dashboard-notice'].title;
this.noticeContent = data['dashboard-notice'].content;
this.noticeType = data['dashboard-notice'].type;
}
});
this.fetchData();
this.fetchNotice();
// 60
this.refreshInterval = setInterval(() => {
this.fetchData();
}, 60000);
},
beforeUnmount() {
//
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: {
async fetchData() {
this.isRefreshing = true;
try {
const res = await axios.get('/api/stat/get');
this.stat = res.data.data;
this.lastUpdated = new Date().toLocaleTimeString();
console.log('Dashboard data:', this.stat);
} catch (error) {
console.error('获取数据失败:', error);
} finally {
this.isRefreshing = false;
}
},
fetchNotice() {
axios.get('https://api.soulter.top/astrbot-announcement').then((res) => {
let data = res.data.data;
// dashboard-notice
if (data['dashboard-notice']) {
this.noticeTitle = data['dashboard-notice'].title;
this.noticeContent = data['dashboard-notice'].content;
this.noticeType = data['dashboard-notice'].type;
}
}).catch(error => {
console.error('获取公告失败:', error);
});
}
}
};
</script>
<style scoped>
.dashboard-container {
padding: 16px;
background-color: #f9fafc;
min-height: calc(100vh - 64px);
border-radius: 10px;
}
.dashboard-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.dashboard-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.dashboard-subtitle {
font-size: 14px;
color: #666;
}
.notice-row {
margin-bottom: 20px;
}
.dashboard-alert {
width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
}
.stats-row, .charts-row, .plugin-row {
margin-bottom: 24px;
}
.plugin-card {
border-radius: 8px;
background-color: white;
}
.plugin-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.plugin-subtitle {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.plugin-item {
transition: transform 0.2s, box-shadow 0.2s;
}
.plugin-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05) !important;
}
.plugin-name {
font-size: 14px;
font-weight: 500;
}
.plugin-version {
font-size: 12px;
color: #666;
}
.dashboard-footer {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
</style>
@@ -0,0 +1,140 @@
<template>
<v-card elevation="1" class="stat-card memory-card">
<v-card-text>
<div class="d-flex align-start">
<div class="icon-wrapper">
<v-icon icon="mdi-memory" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">内存占用</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ stat.memory?.process || 0 }} <span class="memory-unit">MiB / {{ stat.memory?.system || 0 }} MiB</span></h2>
<v-chip :color="memoryStatus.color" size="x-small" class="status-chip">
{{ memoryStatus.label }}
</v-chip>
</div>
</div>
</div>
<div class="metrics-container">
<div class="metric-item">
<div class="metric-label">CPU 负载</div>
<div class="metric-value">{{ stat.cpu_percent || '0' }}%</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'MemoryUsage',
props: ['stat'],
computed: {
memoryPercentage() {
if (!this.stat.memory || !this.stat.memory.process || !this.stat.memory.system) return 0;
return Math.round((this.stat.memory.process / this.stat.memory.system) * 100);
},
memoryStatus() {
const percentage = this.memoryPercentage;
if (percentage < 30) {
return { color: 'success', label: '良好' };
} else if (percentage < 70) {
return { color: 'warning', label: '正常' };
} else {
return { color: 'error', label: '偏高' };
}
}
}
};
</script>
<style scoped>
.stat-card {
height: 100%;
border-radius: 8px;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.memory-card {
background-color: #ff9800;
color: white;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value-wrapper {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.memory-unit {
font-size: 14px;
font-weight: 400;
opacity: 0.8;
}
.status-chip {
font-weight: 500;
}
.metrics-container {
display: flex;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px;
margin-top: 4px;
justify-content: center;
}
.metric-item {
flex: 1;
text-align: center;
}
.metric-label {
font-size: 12px;
opacity: 0.7;
margin-bottom: 4px;
}
.metric-value {
font-size: 14px;
font-weight: 600;
}
</style>
@@ -1,65 +1,136 @@
<script setup>
//
</script>
<template>
<v-card elevation="0">
<v-card variant="outlined">
<v-card-text>
<v-row>
<v-col cols="12" sm="7">
<span class="text-subtitle-2 text-disabled font-weight-bold">总消息趋势</span>
<!-- <h3 class="text-h3 mt-1">{{ total_cnt }}</h3> -->
</v-col>
<v-col cols="12" sm="5">
<v-select color="primary" variant="outlined" hide-details v-model="select" :items="items" item-title="state"
item-value="abbr" label="Select" persistent-hint return-object single-line>
</v-select>
</v-col>
</v-row>
<div class="mt-4">
<apexchart type="area" height="280" :options="chartOptions1" :series="lineChart1.series" ref="rtchart">
</apexchart>
<v-card elevation="1" class="chart-card">
<v-card-text>
<div class="chart-header">
<div>
<div class="chart-title">消息趋势分析</div>
<div class="chart-subtitle">跟踪消息数量随时间的变化</div>
</div>
</v-card-text>
</v-card>
<v-select
color="primary"
variant="outlined"
density="compact"
hide-details
v-model="selectedTimeRange"
:items="timeRanges"
item-title="label"
item-value="value"
class="time-select"
@update:model-value="fetchMessageSeries"
return-object
single-line
>
<template v-slot:selection="{ item }">
<div class="d-flex align-center">
<v-icon start size="small">mdi-calendar-range</v-icon>
{{ item.raw.label }}
</div>
</template>
</v-select>
</div>
<div class="chart-stats">
<div class="stat-box">
<div class="stat-label">总消息数</div>
<div class="stat-number">{{ totalMessages }}</div>
</div>
<div class="stat-box">
<div class="stat-label">平均每天</div>
<div class="stat-number">{{ dailyAverage }}</div>
</div>
<div class="stat-box" :class="{'trend-up': growthRate > 0, 'trend-down': growthRate < 0}">
<div class="stat-label">增长率</div>
<div class="stat-number">
<v-icon size="small" :icon="growthRate > 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'"></v-icon>
{{ Math.abs(growthRate) }}%
</div>
</div>
</div>
<div class="chart-container">
<div v-if="loading" class="loading-overlay">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<div class="loading-text">加载中...</div>
</div>
<apexchart
type="area"
height="280"
:options="chartOptions"
:series="chartSeries"
ref="chart"
></apexchart>
</div>
</v-card-text>
</v-card>
</template>
<script>
import axios from 'axios';
export default {
name: 'MessageStat',
components: {
},
props: ['stat'],
data: () => ({
total_cnt: 0,
select: { state: 'Today', abbr: 'FL' },
items: [
{ state: '过去 1 天', abbr: 'FL' },
totalMessages: '0',
dailyAverage: '0',
growthRate: 0,
loading: false,
selectedTimeRange: { label: '过去 1 天', value: 86400 },
timeRanges: [
{ label: '过去 1 天', value: 86400 },
{ label: '过去 3 天', value: 259200 },
{ label: '过去 7 天', value: 604800 },
{ label: '过去 30 天', value: 2592000 },
],
chartOptions1: {
chartOptions: {
chart: {
type: 'area',
height: 400,
fontFamily: `inherit`,
foreColor: '#a1aab2',
toolbar: {
show: true,
tools: {
download: true,
selection: false,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
},
},
animations: {
enabled: true,
easing: 'easeinout',
speed: 800,
},
},
colors: ['#5e35b1'],
fill: {
type: 'solid',
opacity: 0.3,
},
dataLabels: {
enabled: false
},
stroke: {
curve: 'smooth',
width: 1
width: 2
},
markers: {
size: 3,
strokeWidth: 2,
hover: {
size: 5,
}
},
tooltip: {
fixed: {
enabled: false
},
theme: 'light',
x: {
show: true,
format: 'yyyy-MM-dd HH:mm'
},
y: {
@@ -75,45 +146,225 @@ export default {
},
labels: {
formatter: function (value) {
return new Date(value).toLocaleString();
return new Date(value).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
},
tooltip: {
enabled: false
}
},
yaxis: {
title: {
text: '消息条数'
}
},
min: function(min) {
return min < 10 ? 0 : Math.floor(min * 0.8);
},
},
grid: {
show: true
borderColor: '#f1f1f1',
row: {
colors: ['transparent', 'transparent'],
opacity: 0.2
},
column: {
colors: ['transparent', 'transparent'],
},
padding: {
left: 0,
right: 0
}
}
},
lineChart1: {
series: [
{
name: '消息条数',
data: []
}
]
},
chartSeries: [
{
name: '消息条数',
data: []
}
],
messageTimeSeries: []
}),
watch: {
stat: {
handler: function (val, oldVal) {
val = val.message_time_series
// this.total_cnt = val.message_count
// [[timestamp, cnt], ...]
this.lineChart1.series[0].data = val.map((item) => {
return [new Date(item[0]*1000).getTime(), item[1]];
});
},
deep: true
}
mounted() {
//
this.fetchMessageSeries();
},
};
</script>
methods: {
formatNumber(num) {
return new Intl.NumberFormat('zh-CN').format(num);
},
async fetchMessageSeries() {
this.loading = true;
try {
const response = await axios.get(`/api/stat/get?offset_sec=${this.selectedTimeRange.value}`);
const data = response.data.data;
if (data && data.message_time_series) {
this.messageTimeSeries = data.message_time_series;
this.processTimeSeriesData();
}
} catch (error) {
console.error('获取消息趋势数据失败:', error);
} finally {
this.loading = false;
}
},
processTimeSeriesData() {
//
this.chartSeries[0].data = this.messageTimeSeries.map((item) => {
return [new Date(item[0]*1000).getTime(), item[1]];
});
//
let total = 0;
this.messageTimeSeries.forEach(item => {
total += item[1];
});
this.totalMessages = this.formatNumber(total);
//
if (this.messageTimeSeries.length > 0) {
const daysSpan = this.selectedTimeRange.value / 86400; //
this.dailyAverage = this.formatNumber(Math.round(total / daysSpan));
}
//
this.calculateGrowthRate();
},
calculateGrowthRate() {
if (this.messageTimeSeries.length < 4) {
this.growthRate = 0;
return;
}
//
const halfIndex = Math.floor(this.messageTimeSeries.length / 2);
const firstHalf = this.messageTimeSeries
.slice(0, halfIndex)
.reduce((sum, item) => sum + item[1], 0);
const secondHalf = this.messageTimeSeries
.slice(halfIndex)
.reduce((sum, item) => sum + item[1], 0);
//
if (firstHalf > 0) {
this.growthRate = Math.round(((secondHalf - firstHalf) / firstHalf) * 100);
} else {
this.growthRate = secondHalf > 0 ? 100 : 0;
}
}
}
};
</script>
<style scoped>
.chart-card {
height: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
transition: transform 0.2s;
}
.chart-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.chart-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.chart-subtitle {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.time-select {
max-width: 150px;
font-size: 14px;
}
.chart-stats {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.stat-box {
padding: 12px 16px;
background: #f5f5f5;
border-radius: 8px;
flex: 1;
}
.stat-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.stat-number {
font-size: 18px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
}
.trend-up .stat-number {
color: #4caf50;
}
.trend-down .stat-number {
color: #f44336;
}
.chart-container {
border-top: 1px solid #f0f0f0;
padding-top: 20px;
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 10;
}
.loading-text {
margin-top: 12px;
font-size: 14px;
color: #666;
}
</style>
@@ -1,37 +1,84 @@
<script setup>
//
</script>
<template>
<v-card elevation="0" class="bg-primary overflow-hidden bubble-shape bubble-primary-shape">
<v-card elevation="1" class="stat-card platform-card">
<v-card-text>
<div class="d-flex align-start mb-3">
<v-btn icon rounded="sm" color="darkprimary" variant="flat">
<v-icon icon="mdi-account-multiple-outline"></v-icon>
</v-btn>
<div class="d-flex align-start">
<div class="icon-wrapper">
<v-icon icon="mdi-server-network" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">消息平台</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ stat.platform_count || 0 }}</h2>
</div>
<div class="stat-subtitle">已连接的消息平台数量</div>
</div>
</div>
<v-row>
<v-col cols="6">
<h2 class="text-h1 font-weight-medium">
{{ stat.platform_count }}
</h2>
<span class="text-subtitle-1 text-medium-emphasis text-white">消息平台数</span>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'TotalSession',
props: ['stat'],
data: () => ({
stat: {
platform_count: 0
}
}),
name: 'OnlinePlatform',
props: ['stat']
};
</script>
</script>
<style scoped>
.stat-card {
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.platform-card {
background-color: #2196f3;
color: white;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value-wrapper {
display: flex;
align-items: baseline;
margin-bottom: 4px;
}
.stat-value {
font-size: 32px;
font-weight: 600;
line-height: 1.2;
margin-right: 8px;
}
.stat-subtitle {
font-size: 12px;
opacity: 0.7;
}
</style>
@@ -1,61 +1,190 @@
<script setup lang="ts"></script>
<template>
<v-card elevation="0" class="bg-primary overflow-hidden bubble-shape-sm bubble-primary mb-6">
<v-card-text class="pa-5">
<div class="d-flex align-center gap-3">
<v-btn color="darkprimary" icon rounded="sm" variant="flat">
<v-icon icon="mdi-clock"></v-icon>
</v-btn>
<div>
<h4 class="text-h4 font-weight-medium">{{ stat.running }}</h4>
<span class="text-subtitle-2 text-medium-emphasis text-white">运行时间</span>
<div class="stats-container">
<v-card elevation="1" class="stat-card uptime-card mb-4">
<v-card-text>
<div class="d-flex align-center">
<div class="icon-wrapper">
<v-icon icon="mdi-clock-outline" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">运行时间</div>
<h3 class="uptime-value">{{ stat.running || '加载中...' }}</h3>
</div>
<v-spacer></v-spacer>
<div class="uptime-status">
<v-icon icon="mdi-circle" size="10" color="success" class="blink-animation"></v-icon>
<span class="status-text">在线</span>
</div>
</div>
<v-spacer></v-spacer>
<div>
<v-btn icon rounded="sm" variant="plain">
<v-icon color="black" icon="mdi-stop" size="32"></v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
<v-card elevation="0" class="bubble-shape-sm overflow-hidden bubble-warning">
<v-card-text class="pa-5">
<div class="d-flex align-center gap-3">
<v-btn color="lightwarning" icon rounded="sm" variant="flat">
<v-icon icon="mdi-memory"></v-icon>
</v-btn>
<div>
<h4 class="text-h4 font-weight-medium">{{ stat.memory?.process }} / {{ stat.memory?.system }} MiB</h4>
<span class="text-subtitle-2 text-disabled font-weight-medium">占用内存</span>
<v-card elevation="1" class="stat-card memory-card">
<v-card-text>
<div class="d-flex align-center">
<div class="icon-wrapper">
<v-icon icon="mdi-memory" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">内存占用</div>
<div class="memory-values">
<h3 class="memory-value">{{ stat.memory?.process || 0 }} <span class="memory-unit">MiB</span></h3>
<span class="memory-separator">/</span>
<h4 class="memory-total">{{ stat.memory?.system || 0 }} <span class="memory-unit">MiB</span></h4>
</div>
<v-progress-linear
:model-value="memoryPercentage"
color="warning"
height="4"
class="mt-2"
></v-progress-linear>
<div class="memory-percentage">{{ memoryPercentage }}%</div>
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
</div>
</template>
<script>
export default {
name: 'OnlineTime',
components: {
},
props: ['stat'],
watch: {
},
data: () => ({
stat: {
memory: "Loading",
running: "Loading",
memory: { process: 0, system: 0 },
running: "加载中...",
},
}),
mounted() {
computed: {
memoryPercentage() {
if (!this.stat.memory || !this.stat.memory.process || !this.stat.memory.system) return 0;
return Math.round((this.stat.memory.process / this.stat.memory.system) * 100);
}
}
};
</script>
</script>
<style scoped>
.stats-container {
height: 100%;
display: flex;
flex-direction: column;
}
.stat-card {
border-radius: 8px;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.uptime-card {
background-color: #4caf50;
color: white;
flex: 1;
}
.memory-card {
background-color: #ff9800;
color: white;
flex: 1;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.uptime-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.uptime-status {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.2);
padding: 4px 10px;
border-radius: 20px;
}
.status-text {
margin-left: 6px;
font-size: 12px;
font-weight: 500;
}
.memory-values {
display: flex;
align-items: baseline;
}
.memory-value {
font-size: 22px;
font-weight: 600;
}
.memory-separator {
margin: 0 6px;
font-weight: 300;
opacity: 0.7;
}
.memory-total {
font-size: 18px;
font-weight: 400;
opacity: 0.8;
}
.memory-unit {
font-size: 14px;
font-weight: 400;
opacity: 0.8;
}
.memory-percentage {
font-size: 12px;
margin-top: 4px;
text-align: right;
opacity: 0.9;
}
@keyframes blink {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.blink-animation {
animation: blink 1.5s infinite;
}
</style>
@@ -1,116 +1,253 @@
<script setup>
import { ref, computed } from 'vue';
// chart 1
const chartOptions1 = computed(() => {
return {
chart: {
type: 'area',
height: 95,
fontFamily: `inherit`,
foreColor: '#a1aab2',
sparkline: {
enabled: true
}
},
colors: ['#5e35b1'],
dataLabels: {
enabled: false
},
stroke: {
curve: 'smooth',
width: 1
},
tooltip: {
theme: 'dark',
fixed: {
enabled: false
},
x: {
show: false
},
y: {
title: {
formatter: () => '消息条数 '
}
},
marker: {
show: false
}
}
};
});
// chart 1
const lineChart1 = {
series: [
{
data: [0, 15, 10, 50, 30, 40, 25]
}
]
};
</script>
<template>
<v-card elevation="0">
<v-card variant="outlined">
<v-card-text>
<div class="d-flex align-center">
<h4 class="text-h4 mt-1">平台消息</h4>
<v-card elevation="1" class="platform-stat-card">
<v-card-text>
<div class="platform-header">
<div>
<div class="platform-title">平台消息统计</div>
<div class="platform-subtitle">各平台消息数量分布</div>
</div>
<div class="mt-4">
<v-list lines="two" class="py-0" style="height: 270px;">
</div>
<v-divider class="my-3"></v-divider>
<div v-if="platforms.length > 0" class="platform-list-container">
<v-list class="platform-list" density="compact">
<v-list-item
v-for="(platform, i) in sortedPlatforms"
:key="i"
:value="platform"
class="platform-item"
>
<template v-slot:prepend>
<div class="platform-rank" :class="{'top-rank': i < 3}">{{ i + 1 }}</div>
</template>
<v-list-item v-for="(platform, i) in platforms" :key="i" :value="platform" color="secondary" rounded="sm">
<div class="d-inline-flex align-center justify-space-between w-100">
<div>
<h6 class="text-subtitle-1 text-medium-emphasis font-weight-bold">
{{ platform.name }}
</h6>
</div>
<div class="ml-auto text-subtitle-1 text-medium-emphasis font-weight-bold">{{ platform.count }} </div>
<v-list-item-title class="platform-name">{{ platform.name }}</v-list-item-title>
<template v-slot:append>
<div class="platform-count">
<span class="count-value">{{ platform.count }}</span>
<span class="count-label"></span>
</div>
</v-list-item>
</v-list>
<div class="text-center mt-3">
<v-btn color="primary" variant="text"
>详情
<template v-slot:append>
<ChevronRightIcon stroke-width="1.5" width="20" />
</template>
</v-btn>
</template>
</v-list-item>
</v-list>
<div class="platform-stats-summary">
<div class="platform-stat-item">
<div class="stat-label">平台数</div>
<div class="stat-value">{{ platforms.length }}</div>
</div>
<v-divider vertical></v-divider>
<div class="platform-stat-item">
<div class="stat-label">最活跃</div>
<div class="stat-value">{{ mostActivePlatform }}</div>
</div>
<v-divider vertical></v-divider>
<div class="platform-stat-item">
<div class="stat-label">总消息占比</div>
<div class="stat-value">{{ topPlatformPercentage }}%</div>
</div>
</div>
</v-card-text>
</v-card>
<div class="platform-chart">
<v-progress-linear
v-for="(platform, i) in sortedPlatforms.slice(0, 5)"
:key="i"
:model-value="getPercentage(platform.count)"
height="8"
rounded
class="platform-progress"
:color="i === 0 ? 'primary' : i === 1 ? 'info' : i === 2 ? 'success' : 'grey-lighten-1'"
></v-progress-linear>
</div>
</div>
<div v-else class="no-data">
<v-icon icon="mdi-information-outline" size="40" color="grey-lighten-1"></v-icon>
<div class="no-data-text">暂无平台数据</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'PlatformStat',
components: {
},
props: ['stat'],
data: () => ({
platforms: []
}),
computed: {
sortedPlatforms() {
return [...this.platforms].sort((a, b) => b.count - a.count);
},
totalCount() {
return this.platforms.reduce((sum, platform) => sum + platform.count, 0);
},
mostActivePlatform() {
return this.sortedPlatforms.length > 0 ? this.sortedPlatforms[0].name : '-';
},
topPlatformPercentage() {
if (this.totalCount === 0 || this.sortedPlatforms.length === 0) return 0;
return Math.round((this.sortedPlatforms[0].count / this.totalCount) * 100);
}
},
watch: {
stat: {
handler: function (val, oldVal) {
this.platforms = val.platform
handler: function (val) {
if (val && val.platform) {
this.platforms = val.platform;
}
},
deep: true,
}
},
data: () => ({
platforms: [
]
}),
mounted() {
methods: {
getPercentage(count) {
return this.totalCount ? (count / this.totalCount) * 100 : 0;
}
}
};
</script>
</script>
<style scoped>
.platform-stat-card {
height: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
transition: transform 0.2s;
}
.platform-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.platform-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.platform-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.platform-subtitle {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.platform-list-container {
display: flex;
flex-direction: column;
}
.platform-list {
max-height: 180px;
overflow-y: auto;
padding: 0;
margin-bottom: 16px;
}
.platform-item {
padding: 8px 16px;
margin-bottom: 4px;
border-radius: 8px;
transition: background-color 0.2s;
}
.platform-item:hover {
background-color: #f5f5f5;
}
.platform-rank {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #f0f0f0;
color: #333;
font-weight: 600;
font-size: 14px;
margin-right: 12px;
}
.top-rank {
background-color: #5e35b1;
color: white;
}
.platform-name {
font-weight: 500;
}
.platform-count {
display: flex;
align-items: center;
}
.count-value {
font-weight: 600;
font-size: 14px;
color: #5e35b1;
margin-right: 4px;
}
.count-label {
font-size: 12px;
color: #666;
}
.platform-stats-summary {
display: flex;
justify-content: space-between;
background-color: #f5f5f5;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
}
.platform-stat-item {
flex: 1;
text-align: center;
}
.stat-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.stat-value {
font-weight: 600;
color: #333;
}
.platform-chart {
margin-top: 8px;
}
.platform-progress {
margin-bottom: 12px;
}
.no-data {
height: 250px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.no-data-text {
color: #999;
margin-top: 16px;
font-size: 14px;
}
</style>
@@ -0,0 +1,89 @@
<template>
<v-card elevation="1" class="stat-card uptime-card">
<v-card-text>
<div class="d-flex align-start">
<div class="icon-wrapper">
<v-icon icon="mdi-clock-outline" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">运行时间</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ formattedTime }}</h2>
</div>
<div class="stat-subtitle">AstrBot 运行时间</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'RunningTime',
props: ['stat'],
computed: {
formattedTime() {
return this.stat?.running || '加载中...';
}
}
};
</script>
<style scoped>
.stat-card {
height: 100%;
border-radius: 8px;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.uptime-card {
background-color: #4caf50;
color: white;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value-wrapper {
display: flex;
align-items: baseline;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.stat-subtitle {
font-size: 12px;
opacity: 0.7;
}
</style>
@@ -1,40 +1,97 @@
<script setup>
//
</script>
<template>
<v-card elevation="0" class="bg-secondary overflow-hidden bubble-shape bubble-secondary-shape">
<v-card elevation="1" class="stat-card message-card">
<v-card-text>
<div class="d-flex align-start mb-3">
<v-btn icon rounded="sm" color="darksecondary" variant="flat">
<v-icon icon="mdi-account-multiple-outline"></v-icon>
</v-btn>
<div class="d-flex align-start">
<div class="icon-wrapper">
<v-icon icon="mdi-message-text-outline" size="24"></v-icon>
</div>
<div class="stat-content">
<div class="stat-title">消息总数</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ formattedCount }}</h2>
<v-chip v-if="stat.daily_increase" class="trend-chip" size="x-small" color="success">
+{{ stat.daily_increase }}
</v-chip>
</div>
<div class="stat-subtitle">所有平台发送的消息总计</div>
</div>
</div>
<v-row>
<v-col cols="6">
<h2 class="text-h1 font-weight-medium">
{{ stat.message_count }}
</h2>
<span class="text-subtitle-1 text-medium-emphasis text-white">消息总数</span>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'TotalMessage',
props: ['stat'],
data: () => ({
stat: {
message_count: 0
computed: {
formattedCount() {
const count = this.stat?.message_count;
return count ? count.toLocaleString() : '0';
}
}),
mounted() {
}
};
</script>
</script>
<style scoped>
.stat-card {
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
.message-card {
background-color: #5e35b1;
color: white;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 8px;
margin-right: 16px;
background: rgba(255, 255, 255, 0.2);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value-wrapper {
display: flex;
align-items: baseline;
margin-bottom: 4px;
}
.stat-value {
font-size: 32px;
font-weight: 600;
line-height: 1.2;
margin-right: 8px;
}
.trend-chip {
font-weight: 600;
}
.stat-subtitle {
font-size: 12px;
opacity: 0.7;
}
</style>
+2 -2
View File
@@ -2,7 +2,7 @@ import os
import asyncio
import sys
import mimetypes
from astrbot.dashboard import AstrBotDashBoardLifecycle
from astrbot.core.initial_loader import InitialLoader
from astrbot.core import db_helper
from astrbot.core import logger, LogManager, LogBroker
from astrbot.core.config.default import VERSION
@@ -79,5 +79,5 @@ if __name__ == "__main__":
# print logo
logger.info(logo_tmpl)
dashboard_lifecycle = AstrBotDashBoardLifecycle(db, log_broker)
dashboard_lifecycle = InitialLoader(db, log_broker)
asyncio.run(dashboard_lifecycle.start())
+57
View File
@@ -7,10 +7,13 @@ import astrbot.api.event.filter as filter
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.api import sp
from astrbot.api.provider import ProviderRequest
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.sources.dify_source import ProviderDify
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
from astrbot.core.star.star_handler import star_handlers_registry, StarHandlerMetadata
from astrbot.core.star.star import star_map
from astrbot.core.star.star_manager import PluginManager
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
@@ -88,6 +91,7 @@ class Main(star.Star):
/model: 模型列表
/ls: 对话列表
/new: 创建新对话
/groupnew 群号: 为群聊创建新对话(op)
/switch 序号: 切换对话
/rename 新名字: 重命名当前对话
/del: 删除当前会话对话(op)
@@ -193,7 +197,29 @@ class Main(star.Star):
return
await self.context._star_manager.turn_on_plugin(oper2)
event.set_result(MessageEventResult().message(f"插件 {oper2} 已启用。"))
elif oper1 == "get":
if not oper2:
raise Exception("请输入插件地址。")
if not event.is_admin():
raise Exception(
"改指令限制仅管理员使用,且无法通过 /alter_cmd 更改。"
)
if not oper2.startswith("http"):
oper2 = f"https://github.com/{oper2}"
logger.info(f"准备从 {oper2} 获取插件。")
if self.context._star_manager:
star_mgr: PluginManager = self.context._star_manager
try:
await star_mgr.install_plugin(oper2)
event.set_result(MessageEventResult().message("获取插件成功。"))
except Exception as e:
logger.error(f"获取插件失败: {e}")
event.set_result(
MessageEventResult().message(f"获取插件失败: {e}")
)
return
else:
# 获取插件帮助
plugin = self.context.get_registered_star(oper1)
@@ -700,6 +726,37 @@ UID: {user_id} 此 ID 可用于设置管理员。
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。")
)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("groupnew")
async def groupnew_conv(self, message: AstrMessageEvent, sid: str):
"""创建新群聊对话"""
provider = self.context.get_using_provider()
if provider and provider.meta().type == "dify":
assert isinstance(provider, ProviderDify)
await provider.forget(message.unified_msg_origin)
message.set_result(
MessageEventResult().message("成功,下次聊天将是新对话。")
)
return
if sid:
session = str(
MessageSesion(
platform_name=message.platform_meta.name,
message_type=MessageType("GroupMessage"),
session_id=sid,
)
)
cid = await self.context.conversation_manager.new_conversation(session)
message.set_result(
MessageEventResult().message(
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。"
)
)
else:
message.set_result(
MessageEventResult().message("请输入群聊 ID。/newgroup 群聊ID。")
)
@filter.command("switch")
async def switch_conv(self, message: AstrMessageEvent, index: int = None):
"""通过 /ls 前面的序号切换对话"""
+50 -2
View File
@@ -1,5 +1,6 @@
import astrbot.api.message_components as Comp
import copy
import json
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.star import Context, Star, register
@@ -54,7 +55,45 @@ class Waiter(Star):
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in self.wake_prefix
):
yield event.plain_result("想要问什么呢?😄")
try:
# 尝试使用 LLM 生成更生动的回复
func_tools_mgr = self.context.get_llm_tool_manager()
# 获取用户当前的对话信息
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin
)
conversation = None
context = []
if curr_cid:
conversation = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin, curr_cid
)
context = (
json.loads(conversation.history)
if conversation.history
else []
)
else:
# 创建新对话
curr_cid = await self.context.conversation_manager.new_conversation(
event.unified_msg_origin
)
# 使用 LLM 生成回复
yield event.request_llm(
prompt="用户只是@我或唤醒我,请友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。",
func_tool_manager=func_tools_mgr,
session_id=curr_cid,
contexts=context,
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {str(e)}")
# LLM 回复失败,使用原始预设回复
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
@@ -74,7 +113,16 @@ class Waiter(Star):
try:
await empty_mention_waiter(event)
except TimeoutError as _:
yield event.plain_result("如果需要帮助,请再次 @ 我哦~")
try:
# 超时时也尝试使用 LLM 生成回复
yield event.request_llm(
prompt="用户在提问后超时未回复,请生成一个温馨友好的提醒,告诉用户如果需要帮助可以再次提问,回答要符合人设。",
func_tool_manager=self.context.get_llm_tool_manager(),
system_prompt="",
)
except Exception:
# LLM 回复失败,使用原始预设回复
yield event.plain_result("如果需要帮助,请再次 @ 我哦~")
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
+40
View File
@@ -1,3 +1,43 @@
[project]
name = "AstrBot"
version = "3.4.39"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"aiocqhttp>=1.4.4",
"aiodocker>=0.24.0",
"aiohttp>=3.11.14",
"anthropic>=0.49.0",
"apscheduler>=3.11.0",
"beautifulsoup4>=4.13.3",
"certifi>=2025.1.31",
"chardet~=5.1.0",
"colorlog>=6.9.0",
"cryptography>=44.0.2",
"dashscope>=1.22.2",
"defusedxml>=0.7.1",
"dingtalk-stream>=0.22.1",
"docstring-parser>=0.16",
"googlesearch-python>=1.3.0",
"lark-oapi>=1.4.12",
"lxml-html-clean>=0.4.1",
"mcp>=1.5.0",
"openai>=1.68.2",
"ormsgpack>=1.9.0",
"pillow>=11.1.0",
"pip>=25.0.1",
"psutil>=5.8.0",
"pydantic~=2.10.3",
"pyjwt>=2.10.1",
"python-telegram-bot>=22.0",
"qq-botpy>=1.2.1",
"quart>=0.20.0",
"readability-lxml>=0.8.1",
"silk-python>=0.2.6",
"wechatpy>=1.8.18",
]
[tool.ruff]
exclude = [
"astrbot/core/utils/t2i/local_strategy.py",
+5 -1
View File
@@ -24,4 +24,8 @@ cryptography
dashscope
python-telegram-bot
wechatpy
dingtalk-stream
dingtalk-stream
defusedxml
mcp
certifi
pip
Generated
+2092
View File
File diff suppressed because it is too large Load Diff