Compare commits
528 Commits
refactor/ltm
...
v4.11.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ac37ecd60 | |||
| 2bbe010747 | |||
| 52bba9026a | |||
| 3416e8990c | |||
| eedb62a5a3 | |||
| e8bd821e72 | |||
| 131950b909 | |||
| 2e172804e3 | |||
| 2f3a3f354f | |||
| 86e9b41dde | |||
| 8dfe43f22f | |||
| 6c2f738940 | |||
| c1102f2f5c | |||
| 9a91f2fb11 | |||
| 81309bc908 | |||
| f003b83443 | |||
| 34921e91f0 | |||
| 6c15592cbb | |||
| 8c7a4b87d0 | |||
| 8ff12e3972 | |||
| eefa3f2f00 | |||
| 479284a8dd | |||
| 9322218880 | |||
| 399062f14d | |||
| de82df3c33 | |||
| 9896aebfb5 | |||
| df7653eb99 | |||
| 8e7b44185d | |||
| ef1c66a92e | |||
| 241f1c26d3 | |||
| 3615b7dde2 | |||
| 9bcf9bf2a0 | |||
| 7f5cc7cf1a | |||
| f26867c77d | |||
| a14d588b44 | |||
| e236402d92 | |||
| 454841de10 | |||
| 442b5403df | |||
| 9db7bf59b8 | |||
| 3622504021 | |||
| fc42db40ce | |||
| e413a002c1 | |||
| 6437d759a3 | |||
| c758b2d888 | |||
| 510290fe0e | |||
| c61d62edb6 | |||
| 45bce6fe76 | |||
| f156adddf8 | |||
| b5a4b80c36 | |||
| 792fb69d6d | |||
| 300a73ace0 | |||
| a5b9de3695 | |||
| 90142bcafe | |||
| 79d0487c03 | |||
| 4f15102e79 | |||
| ef1feb639c | |||
| 1039a4f864 | |||
| 66e2f49c11 | |||
| c5773fe63e | |||
| 4e9ef48af2 | |||
| 9eafd7b44a | |||
| fc61f7ad32 | |||
| f51810997a | |||
| fb4baf676f | |||
| 71ad974c3c | |||
| f0fff68947 | |||
| 3e3599835e | |||
| 5255388e2d | |||
| fbdd60b64c | |||
| bd1b0a2836 | |||
| 19541d9d07 | |||
| 2a5d574394 | |||
| f2924fbd1b | |||
| 703e208947 | |||
| 9a5cc977c2 | |||
| aa38fe776a | |||
| 701399c00c | |||
| eaee98d4b8 | |||
| 76c66000a7 | |||
| 4b365143c0 | |||
| 6e4e5011e2 | |||
| d853bfde84 | |||
| a0e856f80f | |||
| 8c94a0010c | |||
| a44fdaaec0 | |||
| 60105c76f5 | |||
| bcf87d3ce4 | |||
| 4d7c8c8453 | |||
| a064a9115f | |||
| 6ef99e1553 | |||
| c0dbe5cf65 | |||
| 3598c51eff | |||
| b5cdb8f650 | |||
| fc5b520f9b | |||
| 904f56b32f | |||
| 2f15fd019c | |||
| 82330b8d10 | |||
| 3ee6af7027 | |||
| 6e20ebe901 | |||
| 4d6150fd6d | |||
| 544e52191b | |||
| f2c2a6da4a | |||
| dd3df425ee | |||
| 40b4a27a3d | |||
| 9d991c7468 | |||
| ad6a8b5c94 | |||
| 1b4bfcbd72 | |||
| 9d3cc593a1 | |||
| f0dee35ba9 | |||
| 4135bd84d5 | |||
| f6da614e5d | |||
| 5f531c9be5 | |||
| 94591d965b | |||
| 8a0f865af1 | |||
| 4aced976a8 | |||
| 0299aa6e4c | |||
| e8b54a019e | |||
| 98ce796275 | |||
| b87dcf2275 | |||
| 591a228431 | |||
| f52f375154 | |||
| 975c685a17 | |||
| 6db80d36a8 | |||
| 4651bd2807 | |||
| 94ada3793e | |||
| fd05b0bf09 | |||
| 4d046f8490 | |||
| 58e32b7b70 | |||
| 903dd0f9f7 | |||
| 1acac0cac2 | |||
| 80b89fd2ea | |||
| 26f863ba81 | |||
| f78a90218e | |||
| a3ecebd2aa | |||
| 67c33b842d | |||
| 5431c9f46e | |||
| 764b91a5f7 | |||
| c20c1b84bf | |||
| fd66a0ac00 | |||
| aaee283367 | |||
| 4a5b7d1976 | |||
| 08244548ab | |||
| b486de6a98 | |||
| e2f928a7e5 | |||
| b8e4068c75 | |||
| 0916177a57 | |||
| 02cd5e396b | |||
| 56673ad78f | |||
| 9a4d05e2b6 | |||
| b2e9dab233 | |||
| 45110200ea | |||
| c3f45449e8 | |||
| 65da469deb | |||
| 16df64c405 | |||
| 6b73b19e54 | |||
| a70088b799 | |||
| e7e97730af | |||
| 467ca1eb5c | |||
| bb45d9cb54 | |||
| 46528391c2 | |||
| 8a0b7717cc | |||
| 3b81fb4985 | |||
| c09d57a820 | |||
| ec408a2aff | |||
| 417179a6b9 | |||
| fcd29445c7 | |||
| 5f535001db | |||
| 750d245b16 | |||
| f624971613 | |||
| aa6d07afcc | |||
| 2c36649874 | |||
| c95735dcc0 | |||
| 03bb278f50 | |||
| a5e0974da3 | |||
| f0fb447fbc | |||
| 37566182b0 | |||
| e460b411da | |||
| e14ed804da | |||
| 8e4e49df20 | |||
| 5d856900ef | |||
| 380a68b96c | |||
| 8879bd7e9d | |||
| 2cce09400f | |||
| 54d26dcd38 | |||
| 205024f27a | |||
| efde994907 | |||
| 8ca4f9cb74 | |||
| 54e49b997b | |||
| 5714944eef | |||
| defc46b6c9 | |||
| 4d819546b0 | |||
| 8006981976 | |||
| f7a716af43 | |||
| a708901e7f | |||
| e9be8cf69f | |||
| 31d53edb9d | |||
| 2ba0460f19 | |||
| 0e034f0fbd | |||
| 2a7d03f9e1 | |||
| 72fac4b9f1 | |||
| 38281ba2cf | |||
| 21aa3174f4 | |||
| dcda871fc0 | |||
| c13c51f499 | |||
| a130db5cf4 | |||
| 7faeb5cea8 | |||
| 8d3ff61e0d | |||
| 4c03e82570 | |||
| e7e8664ab4 | |||
| 1dd1623e7d | |||
| 80d8161d58 | |||
| fc80d7d681 | |||
| c2f036b27c | |||
| 4087bbb512 | |||
| e1c728582d | |||
| 93c69a639a | |||
| a7fdc98b29 | |||
| 85b7f104df | |||
| d76d1bd7fe | |||
| df4412aa80 | |||
| ab2c94e19a | |||
| 37cc4e2121 | |||
| 60dfdd0a66 | |||
| bb8b2cb194 | |||
| 4e29684aa3 | |||
| 0e17e3553d | |||
| 0a55060e89 | |||
| 77859c7daa | |||
| ba39c393a0 | |||
| 6a50d316d9 | |||
| 88c1d77f0b | |||
| 758ce40cc1 | |||
| 3e7bb80492 | |||
| 75e95aa9ca | |||
| a389842e25 | |||
| 0f6a3c3f5a | |||
| 133f27422d | |||
| abc6deb244 | |||
| 06869b4597 | |||
| d32cea9870 | |||
| 4b68100f16 | |||
| 5c5515d462 | |||
| 3932b8f982 | |||
| 82488ca900 | |||
| 29d9b9b2d6 | |||
| 02215e9b7b | |||
| 7160b7a18b | |||
| ea8dac837a | |||
| e2a7a028bd | |||
| 70db8d264b | |||
| 0518e6d487 | |||
| 39eb367866 | |||
| f1d51a22ad | |||
| 77fb554e8f | |||
| 91f8a0ae09 | |||
| 370cda7cf0 | |||
| 66b3eed273 | |||
| 99b061a143 | |||
| 5f3c7ed673 | |||
| a6dc458212 | |||
| 520f521887 | |||
| 01427d9969 | |||
| 34c03ce983 | |||
| 95e9da42d6 | |||
| 1338cab61b | |||
| 7ba98c1e91 | |||
| 9a5f507cbe | |||
| d560671d1f | |||
| 82c9cf4db6 | |||
| 910ec6c695 | |||
| 766d6f2bec | |||
| 9f39140987 | |||
| 89716ef4da | |||
| 3c4ea5a339 | |||
| 601846a8c1 | |||
| 85d66c1056 | |||
| b89d3f663c | |||
| 0260d430d1 | |||
| 2e608cdc09 | |||
| 234ce93dc1 | |||
| 4e2154feb7 | |||
| 604958898c | |||
| a093f5ad0a | |||
| a7e9a7f30c | |||
| 5d1e9de096 | |||
| 89da4eb747 | |||
| 8899a1dee1 | |||
| 384a687ec3 | |||
| 70cfdd2f8b | |||
| bdbd2f009a | |||
| 164e0d26e0 | |||
| cb087b5ff9 | |||
| 1d3928d145 | |||
| 6dc3d161e7 | |||
| e9805ba205 | |||
| d5280dcd88 | |||
| 67a9663eff | |||
| 77dd89b8eb | |||
| 8e511bf14b | |||
| 164a4226ea | |||
| 6d6fefc435 | |||
| aa59532287 | |||
| 2ada1deb9a | |||
| 788ceb9721 | |||
| 8488c9aeab | |||
| 676f9fd4ff | |||
| 1935ce4700 | |||
| e760956353 | |||
| be3e5f3f8b | |||
| cdf617feac | |||
| afb56cf707 | |||
| cd2556ab94 | |||
| cf4a5d9ea4 | |||
| 0747099cac | |||
| 323ec29b02 | |||
| ae81d70685 | |||
| 270c89c12f | |||
| c7a58252fe | |||
| 47ad8c86e5 | |||
| 937e879e5e | |||
| 1ecf26eead | |||
| adbb84530a | |||
| 6cf169f4f2 | |||
| 5ab9ea12c0 | |||
| fd9cb703db | |||
| 388c1ab16d | |||
| f867c2a271 | |||
| 605bb2cb90 | |||
| 5ea15dde5a | |||
| 3ca545c4c7 | |||
| e200835074 | |||
| 3a90348353 | |||
| 5a11d8f0ee | |||
| 824af5eeea | |||
| 08ec787491 | |||
| b062e83d54 | |||
| 17422ba9c3 | |||
| 6849af2bad | |||
| 09c3da64f9 | |||
| 2c8470e8ac | |||
| c4ea3db73d | |||
| 89e79863f6 | |||
| d19945009f | |||
| c77256ee0e | |||
| 7d823af627 | |||
| 3957861878 | |||
| 6ac43c600e | |||
| 27af9ebb6b | |||
| b360c8446e | |||
| 6d00717655 | |||
| bb5f06498e | |||
| aca5743ab6 | |||
| 6903032f7e | |||
| 1ce0ff87bd | |||
| e39d6bae0b | |||
| 8028e9e9a6 | |||
| 817f20ea01 | |||
| ad5579a2f4 | |||
| 81a689a79b | |||
| 1893dd8336 | |||
| 021ca8175b | |||
| 39d6207fe1 | |||
| 23ce687229 | |||
| 3715312fd2 | |||
| 8196922cac | |||
| 8089ad91da | |||
| 2930cc3fd8 | |||
| 0e841a8b25 | |||
| 67fa1611cc | |||
| 91136bb9f7 | |||
| 7c050d1adc | |||
| a0690a6afc | |||
| c51609b261 | |||
| 72148f66eb | |||
| a04993a2bb | |||
| 74f845b06d | |||
| 50144ddcae | |||
| 94bf3b8195 | |||
| e190bbeeed | |||
| 92abc43c9d | |||
| c8e34ff26f | |||
| 630df3e76e | |||
| bdbf382201 | |||
| 00eefc82db | |||
| dc97080837 | |||
| 0b7fc29ac4 | |||
| ff998fdd8d | |||
| d7461ed54c | |||
| 3ce577acf9 | |||
| 50b1dccff3 | |||
| c33e7e30d4 | |||
| bc7f01ba36 | |||
| 2ce653caad | |||
| 0d850d7b22 | |||
| a2be155b8e | |||
| 68aa107689 | |||
| 23096ed3a5 | |||
| 90a65c35c1 | |||
| 3d88827a95 | |||
| 40a0a8df5a | |||
| 20f7129c0b | |||
| 0e962e95dd | |||
| 07ba9c772c | |||
| 0622d88b22 | |||
| 594f0fed55 | |||
| 04b0d9b88d | |||
| 1f2af8ef94 | |||
| 598ea2d857 | |||
| 6dd9bbb516 | |||
| 3cd0b47dc6 | |||
| 65c71b5f20 | |||
| 1152b11202 | |||
| 51246ea31b | |||
| 7e5592dd32 | |||
| c6b28caebf | |||
| ca002f6fff | |||
| 14ec392091 | |||
| 5e2eb91ac0 | |||
| c1626613ce | |||
| 42042d9e73 | |||
| 22c3b53ab8 | |||
| 090c32c90e | |||
| 4f4a9b9e55 | |||
| 6c7d7c9015 | |||
| 562e62a8c0 | |||
| 0823f7aa48 | |||
| eb201c0420 | |||
| 6cfed9a39d | |||
| 33618c4a6b | |||
| ace0a7c219 | |||
| f7d018cf94 | |||
| 8ae2a556e4 | |||
| 4188deb386 | |||
| 82cf4ed909 | |||
| 88fc437abc | |||
| 57f868cab1 | |||
| 6cb5527894 | |||
| 016783a1e5 | |||
| 594ccff9c8 | |||
| 30792f0584 | |||
| 8f021eb35a | |||
| 1969abc340 | |||
| b1b53ab983 | |||
| 9b5af23982 | |||
| 4cedc6d3c8 | |||
| 4e9cce76da | |||
| 9b004f3d2f | |||
| 9430e3090d | |||
| ba44f9117b | |||
| eb56710a72 | |||
| 38e3f27899 | |||
| 3c58d96db5 | |||
| a6be0cc135 | |||
| a53510bc41 | |||
| 1fd482e899 | |||
| 2f130ba009 | |||
| e6d9db9395 | |||
| e0ac743cdb | |||
| b0d3fc11f0 | |||
| 7e0a50fbf2 | |||
| 59df244173 | |||
| deb31a02cf | |||
| e3aa1315ae | |||
| 65bc5efa19 | |||
| abc4bc24b4 | |||
| 5df3f06f83 | |||
| 0e1de82bd7 | |||
| f31e41b3f1 | |||
| 61a68477d0 | |||
| fe8d2718c4 | |||
| 8afefada0a | |||
| 745e1c37c0 | |||
| fdb5988cec | |||
| 36ffcf3cc3 | |||
| e74f626383 | |||
| ef99f64291 | |||
| a0f8f3ae32 | |||
| 130f52f315 | |||
| a05868cc45 | |||
| 2fc77aed15 | |||
| c56edb4da6 | |||
| 6672190760 | |||
| f122b17097 | |||
| 2c5f68e696 | |||
| e1ca645a32 | |||
| 333bf56ddc | |||
| b240594859 | |||
| beccae933f | |||
| e6aa1d2c54 | |||
| 5e808bab65 | |||
| 361d78247b | |||
| 3550103e45 | |||
| 8b0d4d4de4 | |||
| dc71c04b67 | |||
| a0254ed817 | |||
| 2563ecf3c5 | |||
| c04738d9fe | |||
| 1266b4d086 | |||
| 99cf0a1522 | |||
| 98a75e923d | |||
| ad96d676e6 | |||
| 79333bbc35 | |||
| 5c5b0f4fde | |||
| ed6cdfedbb | |||
| 23f13ef05f | |||
| f9c59d9706 | |||
| e1cec42227 | |||
| 8d79c50d53 | |||
| d77830b97f | |||
| 394540f689 | |||
| 7d776e0ce2 | |||
| 17df1692b9 | |||
| 9ab652641d | |||
| 9119f7166f | |||
| da7d9d8eb9 | |||
| 80fccc90b7 | |||
| dcebc70f1a | |||
| 259e7bc322 | |||
| 37bdb6c6f6 | |||
| dc71afdd3f | |||
| 44638108d0 | |||
| 93fcac498c | |||
| 79e2743aac | |||
| 5e9c7cdd91 | |||
| 6f73e5087d | |||
| 8c120b020e | |||
| 12fc6f9d38 | |||
| a6e8483b4c |
+4
-4
@@ -1,9 +1,9 @@
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
# github acions
|
||||
# github actions
|
||||
.git
|
||||
.github/
|
||||
.*ignore
|
||||
.git/
|
||||
# User-specific stuff
|
||||
.idea/
|
||||
# Byte-compiled / optimized / DLL files
|
||||
@@ -15,10 +15,10 @@ env/
|
||||
venv*/
|
||||
ENV/
|
||||
.conda/
|
||||
README*.md
|
||||
dashboard/
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
.astrbot
|
||||
astrbot.lock
|
||||
@@ -16,7 +16,7 @@ body:
|
||||
|
||||
请将插件信息填写到下方的 JSON 代码块中。其中 `tags`(插件标签)和 `social_link`(社交链接)选填。
|
||||
|
||||
不熟悉 JSON ?现在可以从 [这里](https://plugins.astrbot.app/#/submit) 获取你的 JSON 啦!获取到了记得复制粘贴过来哦!
|
||||
不熟悉 JSON ?可以从 [此站](https://plugins.astrbot.app) 右下角提交。
|
||||
|
||||
- type: textarea
|
||||
id: plugin-info
|
||||
@@ -26,12 +26,13 @@ body:
|
||||
value: |
|
||||
```json
|
||||
{
|
||||
"name": "插件名",
|
||||
"desc": "插件介绍",
|
||||
"name": "插件名,请以 astrbot_plugin_ 开头",
|
||||
"display_name": "用于展示的插件名,方便人类阅读",
|
||||
"desc": "插件的简短介绍",
|
||||
"author": "作者名",
|
||||
"repo": "插件仓库链接",
|
||||
"tags": [],
|
||||
"social_link": ""
|
||||
"social_link": "",
|
||||
}
|
||||
```
|
||||
validations:
|
||||
|
||||
@@ -1,46 +1,44 @@
|
||||
name: '🐛 报告 Bug'
|
||||
name: '🐛 Report Bug / 报告 Bug'
|
||||
title: '[Bug]'
|
||||
description: 提交报告帮助我们改进。
|
||||
description: Submit bug report to help us improve. / 提交报告帮助我们改进。
|
||||
labels: [ 'bug' ]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您抽出时间报告问题!请准确解释您的问题。如果可能,请提供一个可复现的片段(这有助于更快地解决问题)。
|
||||
Thank you for taking the time to report this issue! Please describe your problem accurately. If possible, please provide a reproducible snippet (this will help resolve the issue more quickly). Please note that issues that are not detailed or have no logs will be closed immediately. Thank you for your understanding. / 感谢您抽出时间报告问题!请准确解释您的问题。如果可能,请提供一个可复现的片段(这有助于更快地解决问题)。请注意,不详细 / 没有日志的 issue 会被直接关闭,谢谢理解。
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 发生了什么
|
||||
description: 描述你遇到的异常
|
||||
label: What happened / 发生了什么
|
||||
description: Description
|
||||
placeholder: >
|
||||
一个清晰且具体的描述这个异常是什么。
|
||||
Please provide a clear and specific description of what this exception is. Please note that issues that are not detailed or have no logs will be closed immediately. Thank you for your understanding. / 一个清晰且具体的描述这个异常是什么。请注意,不详细 / 没有日志的 issue 会被直接关闭,谢谢理解。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 如何复现?
|
||||
label: Reproduce / 如何复现?
|
||||
description: >
|
||||
复现该问题的步骤
|
||||
The steps to reproduce the issue. / 复现该问题的步骤
|
||||
placeholder: >
|
||||
如: 1. 打开 '...'
|
||||
Example: 1. Open '...'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: AstrBot 版本、部署方式(如 Windows Docker Desktop 部署)、使用的提供商、使用的消息平台适配器
|
||||
description: >
|
||||
请提供您的 AstrBot 版本和部署方式。
|
||||
label: AstrBot version, deployment method (e.g., Windows Docker Desktop deployment), provider used, and messaging platform used. / AstrBot 版本、部署方式(如 Windows Docker Desktop 部署)、使用的提供商、使用的消息平台适配器
|
||||
placeholder: >
|
||||
如: 3.1.8 Docker, 3.1.7 Windows启动器
|
||||
Example: 4.5.7 Docker, 3.1.7 Windows Launcher
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: 操作系统
|
||||
label: OS
|
||||
description: |
|
||||
你在哪个操作系统上遇到了这个问题?
|
||||
On which operating system did you encounter this problem? / 你在哪个操作系统上遇到了这个问题?
|
||||
multiple: false
|
||||
options:
|
||||
- 'Windows'
|
||||
@@ -53,30 +51,30 @@ body:
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 报错日志
|
||||
label: Logs / 报错日志
|
||||
description: >
|
||||
如报错日志、截图等。请提供完整的 Debug 级别的日志,不要介意它很长!
|
||||
Please provide complete Debug-level logs, such as error logs and screenshots. Don't worry if they're long! Please note that issues with insufficient details or no logs will be closed immediately. Thank you for your understanding. / 如报错日志、截图等。请提供完整的 Debug 级别的日志,不要介意它很长!请注意,不详细 / 没有日志的 issue 会被直接关闭,谢谢理解。
|
||||
placeholder: >
|
||||
请提供完整的报错日志或截图。
|
||||
Please provide a complete error log or screenshot. / 请提供完整的报错日志或截图。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 你愿意提交 PR 吗?
|
||||
label: Are you willing to submit a PR? / 你愿意提交 PR 吗?
|
||||
description: >
|
||||
这不是必需的,但我们很乐意在贡献过程中为您提供指导特别是如果你已经很好地理解了如何实现修复。
|
||||
This is not required, but we would be happy to provide guidance during the contribution process, especially if you already have a good understanding of how to implement the fix. / 这不是必需的,但我们很乐意在贡献过程中为您提供指导特别是如果你已经很好地理解了如何实现修复。
|
||||
options:
|
||||
- label: 是的,我愿意提交 PR!
|
||||
- label: Yes!
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
options:
|
||||
- label: >
|
||||
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
|
||||
I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "感谢您填写我们的表单!"
|
||||
value: "Thank you for filling out our form! / 感谢您填写我们的表单!"
|
||||
|
||||
@@ -1,44 +1,25 @@
|
||||
<!-- 如果有的话,请指定此 PR 旨在解决的 ISSUE 编号。 -->
|
||||
<!-- If applicable, please specify the ISSUE number this PR aims to resolve. -->
|
||||
|
||||
fixes #XYZ
|
||||
|
||||
---
|
||||
|
||||
### Motivation / 动机
|
||||
|
||||
<!--请描述此项更改的动机:它解决了什么问题?(例如:修复了 XX 错误,添加了 YY 功能)-->
|
||||
<!--Please describe the motivation for this change: What problem does it solve? (e.g., Fixes XX bug, adds YY feature)-->
|
||||
<!--Please describe the motivation for this change: What problem does it solve? (e.g., Fixes XX issue, adds YY feature)-->
|
||||
<!--请描述此项更改的动机:它解决了什么问题?(例如:修复了 XX issue,添加了 YY 功能)-->
|
||||
|
||||
### Modifications / 改动点
|
||||
|
||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||
|
||||
### Verification Steps / 验证步骤
|
||||
|
||||
<!--请为审查者 (Reviewer) 提供清晰、可复现的验证步骤(例如:1. 导航到... 2. 点击...)。-->
|
||||
<!--Please provide clear and reproducible verification steps for the Reviewer (e.g., 1. Navigate to... 2. Click...).-->
|
||||
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
|
||||
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
|
||||
|
||||
### Screenshots or Test Results / 运行截图或测试结果
|
||||
|
||||
<!--请粘贴截图、GIF 或测试日志,作为执行“验证步骤”的证据,证明此改动有效。-->
|
||||
<!--Please paste screenshots, GIFs, or test logs here as evidence of executing the "Verification Steps" to prove this change is effective.-->
|
||||
|
||||
### Compatibility & Breaking Changes / 兼容性与破坏性变更
|
||||
|
||||
<!--请说明此变更的兼容性:哪些是破坏性变更?哪些地方做了向后兼容处理?是否提供了数据迁移方法?-->
|
||||
<!--Please explain the compatibility of this change: What are the breaking changes? What backward-compatible measures were taken? Are data migration paths provided?-->
|
||||
|
||||
- [ ] 这是一个破坏性变更 (Breaking Change)。/ This is a breaking change.
|
||||
- [ ] 这不是一个破坏性变更。/ This is NOT a breaking change.
|
||||
<!--请粘贴截图、GIF 或测试日志,作为执行“验证步骤”的证据,证明此改动有效。-->
|
||||
|
||||
---
|
||||
|
||||
### Checklist / 检查清单
|
||||
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||
|
||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
|
||||
@@ -11,6 +11,8 @@ reviewers:
|
||||
- Larch-C
|
||||
- anka-afk
|
||||
- advent259141
|
||||
- Fridemn
|
||||
- LIghtJUNction
|
||||
# - zouyonghe
|
||||
|
||||
# A number of reviewers added to the pull request
|
||||
|
||||
@@ -15,7 +15,6 @@ Always reference these instructions first and fallback to search or bash command
|
||||
### Running the Application
|
||||
- Run main application: `uv run main.py` -- starts in ~3 seconds
|
||||
- Application creates WebUI on http://localhost:6185 (default credentials: `astrbot`/`astrbot`)
|
||||
- Application loads plugins automatically from `packages/` and `data/plugins/` directories
|
||||
|
||||
### Dashboard Build (Vue.js/Node.js)
|
||||
- **Prerequisites**: Node.js 20+ and npm 10+ required
|
||||
@@ -35,7 +34,7 @@ Always reference these instructions first and fallback to search or bash command
|
||||
- **ALWAYS** run `uv run ruff check .` and `uv run ruff format .` before committing changes
|
||||
|
||||
### Plugin Development
|
||||
- Plugins load from `packages/` (built-in) and `data/plugins/` (user-installed)
|
||||
- Plugins load from `astrbot/builtin_stars/` (built-in) and `data/plugins/` (user-installed)
|
||||
- Plugin system supports function tools and message handlers
|
||||
- Key plugins: python_interpreter, web_searcher, astrbot, reminder, session_controller
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Dashboard Build
|
||||
run: |
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
needs: build-and-publish-to-github-release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -56,11 +56,11 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -88,6 +88,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -11,13 +11,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'latest'
|
||||
|
||||
- name: npm install, build
|
||||
run: |
|
||||
cd dashboard
|
||||
npm install
|
||||
npm run build
|
||||
npm install pnpm -g
|
||||
pnpm install
|
||||
pnpm i --save-dev @types/markdown-it
|
||||
pnpm run build
|
||||
|
||||
- name: Inject Commit SHA
|
||||
id: get_sha
|
||||
@@ -29,7 +36,7 @@ jobs:
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-without-markdown
|
||||
path: |
|
||||
|
||||
@@ -3,18 +3,125 @@ name: Docker Image CI/CD
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
schedule:
|
||||
# Run at 00:00 UTC every day
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
build-nightly-image:
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
GHCR_OWNER: soulter
|
||||
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
|
||||
|
||||
steps:
|
||||
- name: Pull The Codes
|
||||
uses: actions/checkout@v5
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Must be 0 so we can fetch tags
|
||||
fetch-depth: 1
|
||||
fetch-tag: true
|
||||
|
||||
- name: Check for new commits today
|
||||
if: github.event_name == 'schedule'
|
||||
id: check-commits
|
||||
run: |
|
||||
# Get commits from the last 24 hours
|
||||
commits=$(git log --since="24 hours ago" --oneline)
|
||||
if [ -z "$commits" ]; then
|
||||
echo "No commits in the last 24 hours, skipping build"
|
||||
echo "has_commits=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Found commits in the last 24 hours:"
|
||||
echo "$commits"
|
||||
echo "has_commits=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Exit if no commits
|
||||
if: github.event_name == 'schedule' && steps.check-commits.outputs.has_commits == 'false'
|
||||
run: exit 0
|
||||
|
||||
- name: Build Dashboard
|
||||
run: |
|
||||
cd dashboard
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p dist/assets
|
||||
echo $(git rev-parse HEAD) > dist/assets/version
|
||||
cd ..
|
||||
mkdir -p data
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Determine test image tags
|
||||
id: test-meta
|
||||
run: |
|
||||
short_sha=$(echo "${GITHUB_SHA}" | cut -c1-12)
|
||||
build_date=$(date +%Y%m%d)
|
||||
echo "short_sha=$short_sha" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build nightly image tags list
|
||||
id: test-tags
|
||||
run: |
|
||||
TAGS="${{ env.DOCKER_HUB_USERNAME }}/astrbot:nightly-latest
|
||||
${{ env.DOCKER_HUB_USERNAME }}/astrbot:nightly-${{ steps.test-meta.outputs.build_date }}-${{ steps.test-meta.outputs.short_sha }}"
|
||||
if [ "${{ env.HAS_GHCR_TOKEN }}" = "true" ]; then
|
||||
TAGS="$TAGS
|
||||
ghcr.io/${{ env.GHCR_OWNER }}/astrbot:nightly-latest
|
||||
ghcr.io/${{ env.GHCR_OWNER }}/astrbot:nightly-${{ steps.test-meta.outputs.build_date }}-${{ steps.test-meta.outputs.short_sha }}"
|
||||
fi
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.test-tags.outputs.tags }}
|
||||
|
||||
- name: Post build notifications
|
||||
run: echo "Test Docker image has been built and pushed successfully"
|
||||
|
||||
build-release-image:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
GHCR_OWNER: soulter
|
||||
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tag: true
|
||||
|
||||
- name: Get latest tag (only on manual trigger)
|
||||
id: get-latest-tag
|
||||
@@ -27,21 +134,22 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: git checkout ${{ steps.get-latest-tag.outputs.latest_tag }}
|
||||
|
||||
- name: Check if version is pre-release
|
||||
id: check-prerelease
|
||||
- name: Compute release metadata
|
||||
id: release-meta
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
version="${{ steps.get-latest-tag.outputs.latest_tag }}"
|
||||
else
|
||||
version="${{ github.ref_name }}"
|
||||
version="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
echo "Version $version is a pre-release, will not push latest tag"
|
||||
echo "Version $version marked as pre-release"
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
echo "Version $version is a stable release, will push latest tag"
|
||||
echo "Version $version marked as stable"
|
||||
fi
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Dashboard
|
||||
run: |
|
||||
@@ -67,23 +175,24 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: Soulter
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Docker to DockerHub and Github GHCR
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.check-prerelease.outputs.is_prerelease == 'false' && format('{0}/astrbot:latest', secrets.DOCKER_HUB_USERNAME) || '' }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
|
||||
${{ steps.check-prerelease.outputs.is_prerelease == 'false' && 'ghcr.io/soulter/astrbot:latest' || '' }}
|
||||
ghcr.io/soulter/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
|
||||
${{ steps.release-meta.outputs.is_prerelease == 'false' && format('{0}/astrbot:latest', env.DOCKER_HUB_USERNAME) || '' }}
|
||||
${{ steps.release-meta.outputs.is_prerelease == 'false' && env.HAS_GHCR_TOKEN == 'true' && format('ghcr.io/{0}/astrbot:latest', env.GHCR_OWNER) || '' }}
|
||||
${{ format('{0}/astrbot:{1}', env.DOCKER_HUB_USERNAME, steps.release-meta.outputs.version) }}
|
||||
${{ env.HAS_GHCR_TOKEN == 'true' && format('ghcr.io/{0}/astrbot:{1}', env.GHCR_OWNER, steps.release-meta.outputs.version) || '' }}
|
||||
|
||||
- name: Post build notifications
|
||||
run: echo "Docker image has been built and pushed successfully"
|
||||
run: echo "Release Docker image has been built and pushed successfully"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Smoke Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'README*.md'
|
||||
- 'changelogs/**'
|
||||
- 'dashboard/**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
smoke-test:
|
||||
name: Run smoke tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install UV package manager
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Run smoke tests
|
||||
run: |
|
||||
uv run main.py &
|
||||
APP_PID=$!
|
||||
|
||||
echo "Waiting for application to start..."
|
||||
for i in {1..60}; do
|
||||
if curl -f http://localhost:6185 > /dev/null 2>&1; then
|
||||
echo "Application started successfully!"
|
||||
kill $APP_PID
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Application failed to start within 30 seconds"
|
||||
kill $APP_PID 2>/dev/null || true
|
||||
exit 1
|
||||
timeout-minutes: 2
|
||||
+52
-15
@@ -1,27 +1,64 @@
|
||||
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
|
||||
# 本工作流用于标记并关闭长期不活跃的 Issue。
|
||||
# 目前仅针对带 `bug` 标签的 Issue 生效,不会处理 PR。
|
||||
#
|
||||
# You can adjust the behavior by modifying this file.
|
||||
# For more information, see:
|
||||
# https://github.com/actions/stale
|
||||
name: Mark stale issues and pull requests
|
||||
# 文档: https://github.com/actions/stale
|
||||
name: Mark stale bug issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '21 23 * * *'
|
||||
# 每天 UTC 08:30 执行 (北京时间 16:30)
|
||||
- cron: '30 8 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry-run:
|
||||
description: '仅预览, 不实际执行 (Dry run mode)'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'Stale issue message'
|
||||
stale-pr-message: 'Stale pull request message'
|
||||
stale-issue-label: 'no-issue-activity'
|
||||
stale-pr-label: 'no-pr-activity'
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
operations-per-run: 200
|
||||
|
||||
# 只处理带 bug 标签的 Issue
|
||||
any-of-labels: 'bug'
|
||||
|
||||
# 不处理 PR
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
|
||||
# 不活跃判定与关闭策略: 先标记 stale, 再延迟关闭
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 30
|
||||
|
||||
stale-issue-label: 'stale'
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as **stale** because it has not had any activity.
|
||||
It will be closed in a certain period of time if no further activity occurs.
|
||||
If this issue is still relevant, please leave a comment.
|
||||
|
||||
---
|
||||
|
||||
该 Issue 已较长时间无活动, 已被标记为 `stale`。
|
||||
如无后续活动, 将在一段时间后自动关闭。
|
||||
如仍需跟进, 请回复评论。
|
||||
close-issue-message: |
|
||||
This issue has been automatically closed due to inactivity.
|
||||
If the problem still exists, feel free to reopen or create a new issue with updated information.
|
||||
|
||||
---
|
||||
|
||||
该 Issue 因长期无活动已自动关闭。
|
||||
如问题仍存在, 欢迎补充复现信息并重新打开或新建 Issue。
|
||||
|
||||
remove-stale-when-updated: true
|
||||
|
||||
debug-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run }}
|
||||
|
||||
+41
-24
@@ -1,35 +1,52 @@
|
||||
# Python related
|
||||
__pycache__
|
||||
botpy.log
|
||||
.vscode
|
||||
.mypy_cache
|
||||
.venv*
|
||||
.idea
|
||||
data_v2.db
|
||||
data_v3.db
|
||||
configs/session
|
||||
configs/config.yaml
|
||||
**/.DS_Store
|
||||
temp
|
||||
cmd_config.json
|
||||
data
|
||||
cookies.json
|
||||
logs/
|
||||
addons/plugins
|
||||
.conda/
|
||||
uv.lock
|
||||
.coverage
|
||||
|
||||
# IDE and editors
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Logs and temporary files
|
||||
botpy.log
|
||||
logs/
|
||||
temp
|
||||
cookies.json
|
||||
|
||||
# Data files
|
||||
data_v2.db
|
||||
data_v3.db
|
||||
data
|
||||
configs/session
|
||||
configs/config.yaml
|
||||
cmd_config.json
|
||||
|
||||
# Plugins
|
||||
addons/plugins
|
||||
astrbot/builtin_stars/python_interpreter/workplace
|
||||
tests/astrbot_plugin_openai
|
||||
chroma
|
||||
|
||||
# Dashboard
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
package.json
|
||||
venv/*
|
||||
packages/python_interpreter/workplace
|
||||
.venv/*
|
||||
.conda/
|
||||
.idea
|
||||
pytest.ini
|
||||
.astrbot
|
||||
yarn.lock
|
||||
|
||||
uv.lock
|
||||
# Operating System
|
||||
**/.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# AstrBot specific
|
||||
.astrbot
|
||||
astrbot.lock
|
||||
|
||||
# Other
|
||||
chroma
|
||||
venv/*
|
||||
pytest.ini
|
||||
AGENTS.md
|
||||
IFLOW.md
|
||||
|
||||
+17
-5
@@ -6,8 +6,20 @@ ci:
|
||||
autoupdate_schedule: weekly
|
||||
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.14.1
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff-check
|
||||
types_or: [ python, pyi ]
|
||||
args: [ --fix ]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
types_or: [ python, pyi ]
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py310-plus]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# CONTRIBUTING
|
||||
|
||||
## 贡献指南
|
||||
|
||||
首先,感谢您花时间做出贡献!❤️
|
||||
|
||||
所有类型的贡献都受到鼓励和重视。有关不同的帮助方式和处理方式的详细信息,请参阅[目录](#目录)。在做出贡献之前,请确保阅读相关部分。这将使我们维护人员的工作变得更加容易,并为所有参与者带来顺畅的体验。社区期待您的贡献。🎉
|
||||
|
||||
### 目录
|
||||
|
||||
- [报告问题](#报告问题)
|
||||
- [提交代码更改](#提交代码更改)
|
||||
|
||||
### 报告问题
|
||||
|
||||
如果您在使用 AstrBot 时遇到任何问题,请按照以下步骤报告:
|
||||
|
||||
1. **检查现有问题**:在提交新问题之前,请先检查 [Issues](https://github.com/AstrBotDevs/AstrBot/issues) 中是否已经存在类似的问题。
|
||||
2. **创建新问题**:如果没有类似的问题,请创建一个新问题。请确保提供以下信息:
|
||||
- 问题的简要描述
|
||||
- 重现问题的步骤
|
||||
- 预期结果和实际结果
|
||||
- 相关日志或错误消息
|
||||
|
||||
### 提交代码更改
|
||||
|
||||
#### 分支命名
|
||||
|
||||
我们使用 `fix/` 前缀来修复错误,使用 `feat/` 前缀来添加新功能。对于 `fix/` 分支,请使用简短的描述,或者直接使用 Issue 编号。例如:`fix/1234` 或者 `fix/1234-login-typo`。对于 `feat/` 分支,请使用简短的描述,例如:`feat/add-user-profile`。
|
||||
|
||||
#### PR 描述
|
||||
|
||||
- 请使用英文描述您的 PR。
|
||||
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`。
|
||||
|
||||
#### 代码规范
|
||||
|
||||
##### Core
|
||||
|
||||
我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范:
|
||||
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
如果您使用 VSCode,可以安装 `Ruff` 插件。
|
||||
|
||||
|
||||
## Contributing Guide
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
|
||||
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
|
||||
|
||||
### Table of Contents
|
||||
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Pull Requests](#pull-requests)
|
||||
|
||||
### Reporting Issues
|
||||
|
||||
If you encounter any issues while using AstrBot, please follow these steps to report them:
|
||||
1. **Check Existing Issues**: Before submitting a new issue, please check if a similar issue already exists in the [Issues](https://github.com/AstrBotDevs/AstrBot/issues) section of the repository.
|
||||
2. **Create a New Issue**: If no similar issue exists, please create a new issue. Make sure to provide the following information:
|
||||
- A brief description of the issue
|
||||
- Steps to reproduce the issue
|
||||
- Expected and actual results
|
||||
- Relevant logs or error messages
|
||||
|
||||
### Pull Requests
|
||||
|
||||
#### Branch Naming
|
||||
|
||||
We use the `fix/` prefix for bug fixes and the `feat/` prefix for new features. For `fix/` branches, please use a short description or directly use the Issue number, e.g., `fix/1234` or `fix/1234-login-typo`. For `feat/` branches, please use a short description, e.g., `feat/add-user-profile`.
|
||||
|
||||
#### PR Description
|
||||
- Please use English to describe your PR.
|
||||
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
|
||||
|
||||
#### Code Style
|
||||
|
||||
##### Core
|
||||
|
||||
We use Ruff as our code formatter and static analysis tool. Before submitting your code, please run the following commands to ensure your code adheres to the style guidelines:
|
||||
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
+10
-8
@@ -12,19 +12,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
bash \
|
||||
ffmpeg \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN apt-get update && apt-get install -y curl gnupg && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y curl gnupg \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
RUN python -m pip install uv
|
||||
RUN python -m pip install uv \
|
||||
&& echo "3.11" > .python-version
|
||||
RUN uv pip install -r requirements.txt --no-cache-dir --system
|
||||
RUN uv pip install socksio uv pilk --no-cache-dir --system
|
||||
|
||||
EXPOSE 6185
|
||||
EXPOSE 6186
|
||||
|
||||
CMD [ "python", "main.py" ]
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /AstrBot
|
||||
|
||||
COPY . /AstrBot/
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
curl \
|
||||
unzip \
|
||||
ca-certificates \
|
||||
bash \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Installation of Node.js
|
||||
ENV NVM_DIR="/root/.nvm"
|
||||
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
|
||||
. "$NVM_DIR/nvm.sh" && \
|
||||
nvm install 22 && \
|
||||
nvm use 22
|
||||
RUN /bin/bash -c ". \"$NVM_DIR/nvm.sh\" && node -v && npm -v"
|
||||
|
||||
RUN python -m pip install uv
|
||||
RUN uv pip install -r requirements.txt --no-cache-dir --system
|
||||
RUN uv pip install socksio uv pyffmpeg --no-cache-dir --system
|
||||
|
||||
EXPOSE 6185
|
||||
EXPOSE 6186
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -1,46 +1,67 @@
|
||||
<img width="430" height="31" alt="image" src="https://github.com/user-attachments/assets/474c822c-fab7-41be-8c23-6dae252823ed" /><p align="center">
|
||||
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
[](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="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||

|
||||
|
||||
<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> |
|
||||
<a href="https://astrbot.app/">文档</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://github.com/Soulter/AstrBot/issues">问题提交</a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架。
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://astrbot.app/">文档</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">路线图</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
|
||||
</div>
|
||||
|
||||
AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主流即时通讯软件,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建生产可用的 AI 应用。
|
||||
|
||||

|
||||
|
||||
## 主要功能
|
||||
|
||||
1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能。
|
||||
2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核。
|
||||
3. **Agent**。完善适配的 Agentic 能力。支持多轮工具调用、内置沙盒代码执行器、网页搜索等功能。
|
||||
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。
|
||||
5. **WebUI**。可视化配置和管理机器人,功能齐全。
|
||||
1. 💯 免费 & 开源。
|
||||
1. ✨ AI 大模型对话,多模态,Agent,MCP,知识库,人格设定。
|
||||
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||
3. 📦 插件扩展,已有近 800 个插件可一键安装。
|
||||
5. 💻 WebUI 支持。
|
||||
6. 🌐 国际化(i18n)支持。
|
||||
|
||||
## 部署方式
|
||||
## 快速开始
|
||||
|
||||
#### Docker 部署
|
||||
#### Docker 部署(推荐 🥳)
|
||||
|
||||
推荐使用 Docker / Docker Compose 方式部署 AstrBot。
|
||||
|
||||
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
|
||||
|
||||
#### uv 部署
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
```
|
||||
|
||||
#### 宝塔面板部署
|
||||
|
||||
AstrBot 与宝塔面板合作,已上架至宝塔面板。
|
||||
@@ -63,7 +84,7 @@ AstrBot 已由雨云官方上架至云应用平台,可一键部署。
|
||||
|
||||
社区贡献的部署方式。
|
||||
|
||||
[](https://repl.it/github/Soulter/AstrBot)
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Windows 一键安装器部署
|
||||
|
||||
@@ -92,69 +113,74 @@ uv run main.py
|
||||
|
||||
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
||||
|
||||
## 🌍 社区
|
||||
## 支持的消息平台
|
||||
|
||||
### QQ 群组
|
||||
**官方维护**
|
||||
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 开发者群:975206796
|
||||
- 开发者群(备份):295657329
|
||||
- QQ (官方平台 & OneBot)
|
||||
- Telegram
|
||||
- 企微应用 & 企微智能机器人
|
||||
- 微信客服 & 微信公众号
|
||||
- 飞书
|
||||
- 钉钉
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- Whatsapp (将支持)
|
||||
- LINE (将支持)
|
||||
|
||||
### Telegram 群组
|
||||
**社区维护**
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
||||
- [Bilibili 私信](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
|
||||
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
|
||||
|
||||
### Discord 群组
|
||||
## 支持的模型服务
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
**大模型服务**
|
||||
|
||||
## ⚡ 消息平台支持情况
|
||||
- OpenAI 及兼容服务
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- 智谱 AI
|
||||
- DeepSeek
|
||||
- Ollama (本地部署)
|
||||
- LM Studio (本地部署)
|
||||
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [小马算力](https://www.tokenpony.cn/3YPyf)
|
||||
- [硅基流动](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
| 平台 | 支持性 |
|
||||
| -------- | ------- |
|
||||
| QQ(官方机器人接口) | ✔ |
|
||||
| QQ(OneBot) | ✔ |
|
||||
| Telegram | ✔ |
|
||||
| 企业微信 | ✔ |
|
||||
| 微信客服 | ✔ |
|
||||
| 微信公众号 | ✔ |
|
||||
| 飞书 | ✔ |
|
||||
| 钉钉 | ✔ |
|
||||
| Slack | ✔ |
|
||||
| Discord | ✔ |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
|
||||
| Satori | ✔ |
|
||||
| Misskey | ✔ |
|
||||
**LLMOps 平台**
|
||||
|
||||
## ⚡ 提供商支持情况
|
||||
- Dify
|
||||
- 阿里云百炼应用
|
||||
- Coze
|
||||
|
||||
| 名称 | 支持性 | 类型 | 备注 |
|
||||
| -------- | ------- | ------- | ------- |
|
||||
| OpenAI | ✔ | 文本生成 | 支持任何兼容 OpenAI API 的服务 |
|
||||
| Anthropic | ✔ | 文本生成 | |
|
||||
| Google Gemini | ✔ | 文本生成 | |
|
||||
| Dify | ✔ | LLMOps | |
|
||||
| 阿里云百炼应用 | ✔ | LLMOps | |
|
||||
| Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
|
||||
| LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
|
||||
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | 模型 API 及算力服务平台 | |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | ✔ | 模型 API 服务平台 | |
|
||||
| 硅基流动 | ✔ | 模型 API 服务平台 | |
|
||||
| PPIO 派欧云 | ✔ | 模型 API 服务平台 | |
|
||||
| OneAPI | ✔ | LLM 分发系统 | |
|
||||
| Whisper | ✔ | 语音转文本 | 支持 API、本地部署 |
|
||||
| SenseVoice | ✔ | 语音转文本 | 本地部署 |
|
||||
| OpenAI TTS API | ✔ | 文本转语音 | |
|
||||
| GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference |
|
||||
| GPT-SoVITs | ✔ | 文本转语音 | GPT-Sovits-Inference |
|
||||
| FishAudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
|
||||
| Edge TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
|
||||
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
|
||||
| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS |
|
||||
**语音转文本服务**
|
||||
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
**文本转语音服务**
|
||||
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- 阿里云百炼 TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- 火山引擎 TTS
|
||||
|
||||
## ❤️ 贡献
|
||||
|
||||
@@ -169,11 +195,31 @@ uv run main.py
|
||||
AstrBot 使用 `ruff` 进行代码格式化和检查。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Soulter/AstrBot
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 社区
|
||||
|
||||
### QQ 群组
|
||||
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 开发者群:975206796
|
||||
|
||||
### Telegram 群组
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Discord 群组
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||
@@ -186,26 +232,23 @@ pre-commit install
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
||||
|
||||
另外,一些同类型其他的活跃开源 Bot 项目:
|
||||
|
||||
- [nonebot/nonebot2](https://github.com/nonebot/nonebot2) - 扩展性极强的 Bot 框架
|
||||
- [koishijs/koishi](https://github.com/koishijs/koishi) - 扩展性极强的 Bot 框架
|
||||
- [MaiM-with-u/MaiBot](https://github.com/MaiM-with-u/MaiBot) - 注重拟人功能的 ChatBot
|
||||
- [langbot-app/LangBot](https://github.com/langbot-app/LangBot) - 功能丰富的 Bot 平台
|
||||
- [KroMiose/nekro-agent](https://github.com/KroMiose/nekro-agent) - 注重 Agent 的 ChatBot
|
||||
- [zhenxun-org/zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) - 功能完善的 ChatBot
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我维护这个开源项目的动力 <3
|
||||
> [!TIP]
|
||||
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我们维护这个开源项目的动力 <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#soulter/astrbot&Date)
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
</div
|
||||
|
||||
|
||||
+185
-119
@@ -1,182 +1,248 @@
|
||||
<p align="center">
|
||||
|
||||

|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_✨ Easy-to-use Multi-platform LLM Chatbot & Development Framework ✨_
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
[](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>
|
||||
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||

|
||||
[](https://codecov.io/gh/Soulter/AstrBot)
|
||||
|
||||
<a href="https://astrbot.app/">Documentation</a> |
|
||||
<a href="https://github.com/Soulter/AstrBot/issues">Issue Tracking</a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
AstrBot is a loosely coupled, asynchronous chatbot and development framework that supports multi-platform deployment, featuring an easy-to-use plugin system and comprehensive Large Language Model (LLM) integration capabilities.
|
||||
<br>
|
||||
|
||||
## ✨ Key Features
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/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="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&style=for-the-badge&label=Marketplace&cacheSeconds=3600">
|
||||
</div>
|
||||
|
||||
1. **LLM Conversations** - Supports various LLMs including OpenAI API, Google Gemini, Llama, Deepseek, ChatGLM, etc. Enables local model deployment via Ollama/LLMTuner. Features multi-turn dialogues, personality contexts, multimodal capabilities (image understanding), and speech-to-text (Whisper).
|
||||
2. **Multi-platform Integration** - Supports QQ (OneBot), QQ Channels, WeChat (Gewechat), Feishu, and Telegram. Planned support for DingTalk, Discord, WhatsApp, and Xiaomi Smart Speakers. Includes rate limiting, whitelisting, keyword filtering, and Baidu content moderation.
|
||||
3. **Agent Capabilities** - Native support for code execution, natural language TODO lists, web search. Integrates with [Dify Platform](https://dify.ai/) for easy access to Dify assistants/knowledge bases/workflows.
|
||||
4. **Plugin System** - Optimized plugin mechanism with minimal development effort. Supports multiple installed plugins.
|
||||
5. **Web Dashboard** - Visual configuration management, plugin controls, logging, and WebChat interface for direct LLM interaction.
|
||||
6. **High Stability & Modularity** - Event bus and pipeline architecture ensures high modularization and loose coupling.
|
||||
<br>
|
||||
|
||||
> [!TIP]
|
||||
> Dashboard Demo: [https://demo.astrbot.app/](https://demo.astrbot.app/)
|
||||
> Username: `astrbot`, Password: `astrbot` (LLM not configured for chat page)
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
## ✨ Deployment
|
||||
<a href="https://astrbot.app/">Documentation</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue Tracker</a>
|
||||
</div>
|
||||
|
||||
#### Docker Deployment
|
||||
AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows.
|
||||
|
||||
See docs: [Deploy with Docker](https://astrbot.app/deploy/astrbot/docker.html#docker-deployment)
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
#### Windows Installer
|
||||
## Key Features
|
||||
|
||||
Requires Python (>3.10). See docs: [Windows Installer Guide](https://astrbot.app/deploy/astrbot/windows.html)
|
||||
1. 💯 Free & Open Source.
|
||||
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Knowledge Base, Persona Settings.
|
||||
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze and other agent platforms.
|
||||
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
|
||||
5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation.
|
||||
6. 💻 WebUI Support.
|
||||
7. 🌐 Internationalization (i18n) Support.
|
||||
|
||||
#### Replit Deployment
|
||||
## Quick Start
|
||||
|
||||
[](https://repl.it/github/Soulter/AstrBot)
|
||||
#### Docker Deployment (Recommended 🥳)
|
||||
|
||||
We recommend deploying AstrBot using Docker or Docker Compose.
|
||||
|
||||
Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
#### uv Deployment
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
```
|
||||
|
||||
#### BT-Panel Deployment
|
||||
|
||||
AstrBot has partnered with BT-Panel and is now available in their marketplace.
|
||||
|
||||
Please refer to the official documentation: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||
|
||||
#### 1Panel Deployment
|
||||
|
||||
AstrBot has been officially listed on the 1Panel marketplace.
|
||||
|
||||
Please refer to the official documentation: [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html).
|
||||
|
||||
#### Deploy on RainYun
|
||||
|
||||
AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment.
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Deploy on Replit
|
||||
|
||||
Community-contributed deployment method.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Windows One-Click Installer
|
||||
|
||||
Please refer to the official documentation: [Deploy AstrBot with Windows One-Click Installer](https://astrbot.app/deploy/astrbot/windows.html).
|
||||
|
||||
#### CasaOS Deployment
|
||||
|
||||
Community-contributed method.
|
||||
See docs: [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html)
|
||||
Community-contributed deployment method.
|
||||
|
||||
Please refer to the official documentation: [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html).
|
||||
|
||||
#### Manual Deployment
|
||||
|
||||
See docs: [Source Code Deployment](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
First, install uv:
|
||||
|
||||
## ⚡ Platform Support
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
| Platform | Status | Details | Message Types |
|
||||
| -------------------------------------------------------------- | ------ | ------------------- | ------------------- |
|
||||
| QQ (Official Bot) | ✔ | Private/Group chats | Text, Images |
|
||||
| QQ (OneBot) | ✔ | Private/Group chats | Text, Images, Voice |
|
||||
| WeChat (Personal) | ✔ | Private/Group chats | Text, Images, Voice |
|
||||
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | Private/Group chats | Text, Images |
|
||||
| [WeChat Work](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | Private chats | Text, Images, Voice |
|
||||
| Feishu | ✔ | Group chats | Text, Images |
|
||||
| WeChat Open Platform | 🚧 | Planned | - |
|
||||
| Discord | 🚧 | Planned | - |
|
||||
| WhatsApp | 🚧 | Planned | - |
|
||||
| Xiaomi Speakers | 🚧 | Planned | - |
|
||||
Install AstrBot via Git Clone:
|
||||
|
||||
## Provider Support Status
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
| Name | Support | Type | Notes |
|
||||
|---------------------------|---------|------------------------|-----------------------------------------------------------------------|
|
||||
| OpenAI API | ✔ | Text Generation | Supports all OpenAI API-compatible services including DeepSeek, Google Gemini, GLM, Moonshot, Alibaba Cloud Bailian, Silicon Flow, xAI, etc. |
|
||||
| Claude API | ✔ | Text Generation | |
|
||||
| Google Gemini API | ✔ | Text Generation | |
|
||||
| Dify | ✔ | LLMOps | |
|
||||
| DashScope (Alibaba Cloud) | ✔ | LLMOps | |
|
||||
| Ollama | ✔ | Model Loader | Local deployment for open-source LLMs (DeepSeek, Llama, etc.) |
|
||||
| LM Studio | ✔ | Model Loader | Local deployment for open-source LLMs (DeepSeek, Llama, etc.) |
|
||||
| LLMTuner | ✔ | Model Loader | Local loading of fine-tuned models (e.g. LoRA) |
|
||||
| OneAPI | ✔ | LLM Distribution | |
|
||||
| Whisper | ✔ | Speech-to-Text | Supports API and local deployment |
|
||||
| SenseVoice | ✔ | Speech-to-Text | Local deployment |
|
||||
| OpenAI TTS API | ✔ | Text-to-Speech | |
|
||||
| Fishaudio | ✔ | Text-to-Speech | Project involving GPT-Sovits author |
|
||||
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
# 🦌 Roadmap
|
||||
## Supported Messaging Platforms
|
||||
|
||||
> [!TIP]
|
||||
> Suggestions welcome via Issues <3
|
||||
**Officially Maintained**
|
||||
|
||||
- [ ] Ensure feature parity across all platform adapters
|
||||
- [ ] Optimize plugin APIs
|
||||
- [ ] Add default TTS services (e.g., GPT-Sovits)
|
||||
- [ ] Enhance chat features with persistent memory
|
||||
- [ ] i18n Planning
|
||||
- QQ (Official Platform & OneBot)
|
||||
- Telegram
|
||||
- WeChat Work Application & WeChat Work Intelligent Bot
|
||||
- WeChat Customer Service & WeChat Official Accounts
|
||||
- Feishu (Lark)
|
||||
- DingTalk
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- WhatsApp (Coming Soon)
|
||||
- LINE (Coming Soon)
|
||||
|
||||
## ❤️ Contributions
|
||||
**Community Maintained**
|
||||
|
||||
All Issues/PRs welcome! Simply submit your changes to this project :)
|
||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
||||
- [Bilibili Direct Messages](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
|
||||
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
|
||||
|
||||
For major features, please discuss via Issues first.
|
||||
## Supported Model Services
|
||||
|
||||
## 🌟 Support
|
||||
**LLM Services**
|
||||
|
||||
- Star this project!
|
||||
- Support via [Afdian](https://afdian.com/a/soulter)
|
||||
- WeChat support: [QR Code](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)
|
||||
- OpenAI and Compatible Services
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- Zhipu AI
|
||||
- DeepSeek
|
||||
- Ollama (Self-hosted)
|
||||
- LM Studio (Self-hosted)
|
||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
## ✨ Demos
|
||||
**LLMOps Platforms**
|
||||
|
||||
> [!NOTE]
|
||||
> Code executor file I/O currently tested with Napcat(QQ)/Lagrange(QQ)
|
||||
- Dify
|
||||
- Alibaba Cloud Bailian Applications
|
||||
- Coze
|
||||
|
||||
<div align='center'>
|
||||
**Speech-to-Text Services**
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
_✨ Docker-based Sandboxed Code Executor (Beta) ✨_
|
||||
**Text-to-Speech Services**
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- Alibaba Cloud Bailian TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- Volcano Engine TTS
|
||||
|
||||
_✨ Multimodal Input, Web Search, Text-to-Image ✨_
|
||||
## ❤️ Contributing
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
|
||||
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
||||
|
||||
_✨ Natural Language TODO Lists ✨_
|
||||
### How to Contribute
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
|
||||
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
|
||||
You can contribute by reviewing issues or helping with pull request reviews. Any issues or PRs are welcome to encourage community participation. Of course, these are just suggestions—you can contribute in any way you like. For adding new features, please discuss through an Issue first.
|
||||
|
||||
_✨ Plugin System Showcase ✨_
|
||||
### Development Environment
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/592a8630-14c7-4e06-b496-9c0386e4f36c" width=600>
|
||||
AstrBot uses `ruff` for code formatting and linting.
|
||||
|
||||
_✨ Web Dashboard ✨_
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||

|
||||
## 🌍 Community
|
||||
|
||||
_✨ Built-in Web Chat Interface ✨_
|
||||
### QQ Groups
|
||||
|
||||
</div>
|
||||
- Group 1: 322154837
|
||||
- Group 3: 630166526
|
||||
- Group 5: 822130018
|
||||
- Group 6: 753075035
|
||||
- Developer Group: 975206796
|
||||
|
||||
### Telegram Group
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Discord Server
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - The amazing cat framework
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
> If this project helps you, please give it a star <3
|
||||
> [!TIP]
|
||||
> If this project has helped you in your life or work, or if you're interested in its future development, please give the project a Star. It's the driving force behind maintaining this open-source project <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#soulter/astrbot&Date)
|
||||
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
## Disclaimer
|
||||
|
||||
1. Licensed under `AGPL-v3`.
|
||||
2. WeChat integration uses [Gewechat](https://github.com/Devo919/Gewechat). Use at your own risk with non-critical accounts.
|
||||
3. Users must comply with local laws and regulations.
|
||||
|
||||
<!-- ## ✨ ATRI [Beta]
|
||||
|
||||
Available as plugin: [astrbot_plugin_atri](https://github.com/Soulter/astrbot_plugin_atri)
|
||||
|
||||
1. Qwen1.5-7B-Chat Lora model fine-tuned with ATRI character data
|
||||
2. Long-term memory
|
||||
3. Meme understanding & responses
|
||||
4. TTS integration
|
||||
-->
|
||||
|
||||
</details>
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/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="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&style=for-the-badge&label=Marketplace&cacheSeconds=3600">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<a href="https://astrbot.app/">Documentation</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Feuille de route</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Signaler un problème</a>
|
||||
</div>
|
||||
|
||||
AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègre aux principales applications de messagerie instantanée. Elle fournit une infrastructure d'IA conversationnelle fiable et évolutive pour les particuliers, les développeurs et les équipes. Que vous construisiez un compagnon IA personnel, un service client intelligent, un assistant d'automatisation ou une base de connaissances d'entreprise, AstrBot vous permet de créer rapidement des applications d'IA prêtes pour la production dans les flux de travail de votre plateforme de messagerie.
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
1. 💯 Gratuit & Open Source.
|
||||
2. ✨ Conversations avec LLM IA, Multimodal, Agent, MCP, Base de connaissances, Paramètres de personnalité.
|
||||
3. 🤖 Prise en charge de l'intégration avec Dify, Alibaba Cloud Bailian, Coze et autres plateformes d'agents.
|
||||
4. 🌐 Multi-plateforme : QQ, WeChat Work, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack, et [plus encore](#plateformes-de-messagerie-prises-en-charge).
|
||||
5. 📦 Extensions de plugins avec près de 800 plugins disponibles pour une installation en un clic.
|
||||
6. 💻 Support WebUI.
|
||||
7. 🌐 Support de l'internationalisation (i18n).
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
#### Déploiement Docker (Recommandé 🥳)
|
||||
|
||||
Nous recommandons de déployer AstrBot en utilisant Docker ou Docker Compose.
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
#### Déploiement uv
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
```
|
||||
|
||||
#### Déploiement BT-Panel
|
||||
|
||||
AstrBot s'est associé à BT-Panel et est maintenant disponible sur leur marketplace.
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||
|
||||
#### Déploiement 1Panel
|
||||
|
||||
AstrBot a été officiellement listé sur le marketplace 1Panel.
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html).
|
||||
|
||||
#### Déployer sur RainYun
|
||||
|
||||
AstrBot a été officiellement listé sur la plateforme d'applications cloud de RainYun avec un déploiement en un clic.
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Déployer sur Replit
|
||||
|
||||
Méthode de déploiement contribuée par la communauté.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Installateur Windows en un clic
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déployer AstrBot avec l'installateur Windows en un clic](https://astrbot.app/deploy/astrbot/windows.html).
|
||||
|
||||
#### Déploiement CasaOS
|
||||
|
||||
Méthode de déploiement contribuée par la communauté.
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html).
|
||||
|
||||
#### Déploiement manuel
|
||||
|
||||
Tout d'abord, installez uv :
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
Installez AstrBot via Git Clone :
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
## Plateformes de messagerie prises en charge
|
||||
|
||||
**Maintenues officiellement**
|
||||
|
||||
- QQ (Plateforme officielle & OneBot)
|
||||
- Telegram
|
||||
- Application WeChat Work & Bot intelligent WeChat Work
|
||||
- Service client WeChat & Comptes officiels WeChat
|
||||
- Feishu (Lark)
|
||||
- DingTalk
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- WhatsApp (Bientôt disponible)
|
||||
- LINE (Bientôt disponible)
|
||||
|
||||
**Maintenues par la communauté**
|
||||
|
||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
||||
- [Messages directs Bilibili](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
|
||||
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
|
||||
|
||||
## Services de modèles pris en charge
|
||||
|
||||
**Services LLM**
|
||||
|
||||
- OpenAI et services compatibles
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- Zhipu AI
|
||||
- DeepSeek
|
||||
- Ollama (Auto-hébergé)
|
||||
- LM Studio (Auto-hébergé)
|
||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
**Plateformes LLMOps**
|
||||
|
||||
- Dify
|
||||
- Applications Alibaba Cloud Bailian
|
||||
- Coze
|
||||
|
||||
**Services de reconnaissance vocale**
|
||||
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
**Services de synthèse vocale**
|
||||
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- Alibaba Cloud Bailian TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- Volcano Engine TTS
|
||||
|
||||
## ❤️ Contribuer
|
||||
|
||||
Les Issues et Pull Requests sont toujours les bienvenues ! N'hésitez pas à soumettre vos modifications à ce projet :)
|
||||
|
||||
### Comment contribuer
|
||||
|
||||
Vous pouvez contribuer en examinant les issues ou en aidant à la revue des pull requests. Toutes les issues ou PRs sont les bienvenues pour encourager la participation de la communauté. Bien sûr, ce ne sont que des suggestions - vous pouvez contribuer de la manière que vous souhaitez. Pour l'ajout de nouvelles fonctionnalités, veuillez d'abord en discuter via une Issue.
|
||||
|
||||
### Environnement de développement
|
||||
|
||||
AstrBot utilise `ruff` pour le formatage et le linting du code.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 Communauté
|
||||
|
||||
### Groupes QQ
|
||||
|
||||
- Groupe 1 : 322154837
|
||||
- Groupe 3 : 630166526
|
||||
- Groupe 5 : 822130018
|
||||
- Groupe 6 : 753075035
|
||||
- Groupe développeurs : 975206796
|
||||
|
||||
### Groupe Telegram
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Serveur Discord
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
## ❤️ Remerciements spéciaux
|
||||
|
||||
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - L'incroyable framework chat
|
||||
|
||||
## ⭐ Historique des étoiles
|
||||
|
||||
> [!TIP]
|
||||
> Si ce projet vous a aidé dans votre vie ou votre travail, ou si vous êtes intéressé par son développement futur, veuillez donner une étoile au projet. C'est la force motrice derrière la maintenance de ce projet open source <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
+184
-103
@@ -1,167 +1,248 @@
|
||||
<p align="center">
|
||||
|
||||

|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_✨ 簡単に使えるマルチプラットフォーム LLM チャットボットおよび開発フレームワーク ✨_
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
[](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>
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/QQ群-630166526-purple">
|
||||
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||

|
||||
[](https://codecov.io/gh/Soulter/AstrBot)
|
||||
|
||||
<a href="https://astrbot.app/">ドキュメントを見る</a> |
|
||||
<a href="https://github.com/Soulter/AstrBot/issues">問題を報告する</a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
AstrBot は、疎結合、非同期、複数のメッセージプラットフォームに対応したデプロイ、使いやすいプラグインシステム、および包括的な大規模言語モデル(LLM)接続機能を備えたチャットボットおよび開発フレームワークです。
|
||||
<br>
|
||||
|
||||
## ✨ 主な機能
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/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="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E5%80%8B&style=for-the-badge&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3&cacheSeconds=3600">
|
||||
</div>
|
||||
|
||||
1. **大規模言語モデルの対話**。OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM など、さまざまな大規模言語モデルをサポートし、Ollama、LLMTuner を介してローカルにデプロイされた大規模モデルをサポートします。多輪対話、人格シナリオ、多モーダル機能を備え、画像理解、音声からテキストへの変換(Whisper)をサポートします。
|
||||
2. **複数のメッセージプラットフォームの接続**。QQ(OneBot)、QQ チャンネル、Feishu、Telegram への接続をサポートします。今後、DingTalk、Discord、WhatsApp、Xiaoai 音響をサポートする予定です。レート制限、ホワイトリスト、キーワードフィルタリング、Baidu コンテンツ監査をサポートします。
|
||||
3. **エージェント**。一部のエージェント機能をネイティブにサポートし、コードエグゼキューター、自然言語タスク、ウェブ検索などを提供します。[Dify プラットフォーム](https://dify.ai/)と連携し、Dify スマートアシスタント、ナレッジベース、Dify ワークフローを簡単に接続できます。
|
||||
4. **プラグインの拡張**。深く最適化されたプラグインメカニズムを備え、[プラグインの開発](https://astrbot.app/dev/plugin.html)をサポートし、機能を拡張できます。複数のプラグインのインストールをサポートします。
|
||||
5. **ビジュアル管理パネル**。設定の視覚的な変更、プラグイン管理、ログの表示などをサポートし、設定の難易度を低減します。WebChat を統合し、パネル上で大規模モデルと対話できます。
|
||||
6. **高い安定性と高いモジュール性**。イベントバスとパイプラインに基づくアーキテクチャ設計により、高度にモジュール化され、低結合です。
|
||||
<br>
|
||||
|
||||
> [!TIP]
|
||||
> 管理パネルのオンラインデモを体験する: [https://demo.astrbot.app/](https://demo.astrbot.app/)
|
||||
>
|
||||
> ユーザー名: `astrbot`, パスワード: `astrbot`。LLM が設定されていないため、チャットページで大規模モデルを使用することはできません。(デモのログインパスワードを変更しないでください 😭)
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
## ✨ 使用方法
|
||||
<a href="https://astrbot.app/">ドキュメント</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">ロードマップ</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue</a>
|
||||
</div>
|
||||
|
||||
#### Docker デプロイ
|
||||
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
|
||||
|
||||
公式ドキュメント [Docker を使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) を参照してください。
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
#### Windows ワンクリックインストーラーのデプロイ
|
||||
## 主な機能
|
||||
|
||||
コンピュータに Python(>3.10)がインストールされている必要があります。公式ドキュメント [Windows ワンクリックインストーラーを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/windows.html) を参照してください。
|
||||
1. 💯 無料 & オープンソース。
|
||||
2. ✨ AI 大規模言語モデル対話、マルチモーダル、Agent、MCP、ナレッジベース、ペルソナ設定。
|
||||
3. 🤖 Dify、Alibaba Cloud 百炼、Coze などの Agent プラットフォームとの統合をサポート。
|
||||
4. 🌐 マルチプラットフォーム:QQ、WeChat Work、Feishu、DingTalk、WeChat 公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)。
|
||||
5. 📦 約800個のプラグインをワンクリックでインストール可能なプラグイン拡張機能。
|
||||
6. 💻 WebUI サポート。
|
||||
7. 🌐 国際化(i18n)サポート。
|
||||
|
||||
#### Replit デプロイ
|
||||
## クイックスタート
|
||||
|
||||
[](https://repl.it/github/Soulter/AstrBot)
|
||||
#### Docker デプロイ(推奨 🥳)
|
||||
|
||||
Docker / Docker Compose を使用した AstrBot のデプロイを推奨します。
|
||||
|
||||
公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。
|
||||
|
||||
#### uv デプロイ
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
```
|
||||
|
||||
#### 宝塔パネルデプロイ
|
||||
|
||||
AstrBot は宝塔パネルと提携し、宝塔パネルに公開されています。
|
||||
|
||||
公式ドキュメント [宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html) をご参照ください。
|
||||
|
||||
#### 1Panel デプロイ
|
||||
|
||||
AstrBot は 1Panel 公式により 1Panel パネルに公開されています。
|
||||
|
||||
公式ドキュメント [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) をご参照ください。
|
||||
|
||||
#### 雨云でのデプロイ
|
||||
|
||||
AstrBot は雨云公式によりクラウドアプリケーションプラットフォームに公開され、ワンクリックでデプロイ可能です。
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Replit でのデプロイ
|
||||
|
||||
コミュニティ貢献によるデプロイ方法。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Windows ワンクリックインストーラーデプロイ
|
||||
|
||||
公式ドキュメント [Windows ワンクリックインストーラーを使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/windows.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) を参照してください。
|
||||
まず uv をインストールします:
|
||||
|
||||
## ⚡ メッセージプラットフォームのサポート状況
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
| プラットフォーム | サポート状況 | 詳細 | メッセージタイプ |
|
||||
| -------- | ------- | ------- | ------ |
|
||||
| QQ(公式ロボットインターフェース) | ✔ | プライベートチャット、グループチャット、QQ チャンネルプライベートチャット、グループチャット | テキスト、画像 |
|
||||
| QQ(OneBot) | ✔ | プライベートチャット、グループチャット | テキスト、画像、音声 |
|
||||
| WeChat(個人アカウント) | ✔ | WeChat 個人アカウントのプライベートチャット、グループチャット | テキスト、画像、音声 |
|
||||
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | プライベートチャット、グループチャット | テキスト、画像 |
|
||||
| [WeChat(企業 WeChat)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | プライベートチャット | テキスト、画像、音声 |
|
||||
| Feishu | ✔ | グループチャット | テキスト、画像 |
|
||||
| WeChat 対話オープンプラットフォーム | 🚧 | 計画中 | - |
|
||||
| Discord | 🚧 | 計画中 | - |
|
||||
| WhatsApp | 🚧 | 計画中 | - |
|
||||
| Xiaoai 音響 | 🚧 | 計画中 | - |
|
||||
Git Clone で AstrBot をインストール:
|
||||
|
||||
# 🦌 今後のロードマップ
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Issue でさらに多くの提案を歓迎します <3
|
||||
または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。
|
||||
|
||||
- [ ] 現在のすべてのプラットフォームアダプターの機能の一貫性を確保し、改善する
|
||||
- [ ] プラグインインターフェースの最適化
|
||||
- [ ] GPT-Sovits などの TTS サービスをデフォルトでサポート
|
||||
- [ ] "チャット強化" 部分を完成させ、永続的な記憶をサポート
|
||||
- [ ] i18n の計画
|
||||
## サポートされているメッセージプラットフォーム
|
||||
|
||||
## ❤️ 貢献
|
||||
**公式メンテナンス**
|
||||
|
||||
Issue や Pull Request を歓迎します!このプロジェクトに変更を加えるだけです :)
|
||||
- QQ (公式プラットフォーム & OneBot)
|
||||
- Telegram
|
||||
- WeChat Work アプリケーション & WeChat Work インテリジェントボット
|
||||
- WeChat カスタマーサービス & WeChat 公式アカウント
|
||||
- Feishu (Lark)
|
||||
- DingTalk
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- WhatsApp (近日対応予定)
|
||||
- LINE (近日対応予定)
|
||||
|
||||
新機能の追加については、まず Issue で議論してください。
|
||||
**コミュニティメンテナンス**
|
||||
|
||||
## 🌟 サポート
|
||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
||||
- [Bilibili ダイレクトメッセージ](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
|
||||
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
|
||||
|
||||
- このプロジェクトに Star を付けてください!
|
||||
- [愛発電](https://afdian.com/a/soulter)で私をサポートしてください!
|
||||
- [WeChat](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)で私をサポートしてください~
|
||||
## サポートされているモデルサービス
|
||||
|
||||
## ✨ デモ
|
||||
**大規模言語モデルサービス**
|
||||
|
||||
> [!NOTE]
|
||||
> コードエグゼキューターのファイル入力/出力は現在 Napcat(QQ)、Lagrange(QQ) でのみテストされています
|
||||
- OpenAI および互換サービス
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- 智谱 AI
|
||||
- DeepSeek
|
||||
- Ollama (セルフホスト)
|
||||
- LM Studio (セルフホスト)
|
||||
- [優云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [小馬算力](https://www.tokenpony.cn/3YPyf)
|
||||
- [硅基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
<div align='center'>
|
||||
**LLMOps プラットフォーム**
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
|
||||
- Dify
|
||||
- Alibaba Cloud 百炼アプリケーション
|
||||
- Coze
|
||||
|
||||
_✨ Docker ベースのサンドボックス化されたコードエグゼキューター(ベータテスト中)✨_
|
||||
**音声認識サービス**
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
_✨ 多モーダル、ウェブ検索、長文の画像変換(設定可能)✨_
|
||||
**音声合成サービス**
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- Alibaba Cloud 百炼 TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- Volcano Engine TTS
|
||||
|
||||
_✨ 自然言語タスク ✨_
|
||||
## ❤️ コントリビューション
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
|
||||
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
|
||||
Issue や Pull Request は大歓迎です!このプロジェクトに変更を送信してください :)
|
||||
|
||||
_✨ プラグインシステム - 一部のプラグインの展示 ✨_
|
||||
### コントリビュート方法
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/592a8630-14c7-4e06-b496-9c0386e4f36c" width="600">
|
||||
Issue を確認したり、PR(プルリクエスト)のレビューを手伝うことで貢献できます。どんな Issue や PR への参加も歓迎され、コミュニティ貢献を促進します。もちろん、これらは提案に過ぎず、どんな方法でも貢献できます。新機能の追加については、まず Issue で議論してください。
|
||||
|
||||
_✨ 管理パネル ✨_
|
||||
### 開発環境
|
||||
|
||||

|
||||
AstrBot はコードのフォーマットとチェックに `ruff` を使用しています。
|
||||
|
||||
_✨ 内蔵 Web Chat、オンラインでボットと対話 ✨_
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
</div>
|
||||
## 🌍 コミュニティ
|
||||
|
||||
### QQ グループ
|
||||
|
||||
- 1群: 322154837
|
||||
- 3群: 630166526
|
||||
- 5群: 822130018
|
||||
- 6群: 753075035
|
||||
- 開発者群: 975206796
|
||||
|
||||
### Telegram グループ
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Discord サーバー
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 素晴らしい猫猫フレームワーク
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
> このプロジェクトがあなたの生活や仕事に役立った場合、またはこのプロジェクトの将来の発展に関心がある場合は、プロジェクトに Star を付けてください。これはこのオープンソースプロジェクトを維持するためのモチベーションです <3
|
||||
> このプロジェクトがあなたの生活や仕事に役立ったり、このプロジェクトの今後の発展に関心がある場合は、プロジェクトに Star をください。これがこのオープンソースプロジェクトを維持する原動力です <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#soulter/astrbot&Date)
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
## スポンサー
|
||||
|
||||
[<img src="https://api.gitsponsors.com/api/badge/img?id=575865240" height="20">](https://api.gitsponsors.com/api/badge/link?p=XEpbdGxlitw/RbcwiTX93UMzNK/jgDYC8NiSzamIPMoKvG2lBFmyXhSS/b0hFoWlBBMX2L5X5CxTDsUdyvcIEHTOfnkXz47UNOZvMwyt5CzbYpq0SEzsSV1OJF1cCo90qC/ZyYKYOWedal3MhZ3ikw==)
|
||||
|
||||
## 免責事項
|
||||
|
||||
1. このプロジェクトは `AGPL-v3` オープンソースライセンスの下で保護されています。
|
||||
2. このプロジェクトを使用する際は、現地の法律および規制を遵守してください。
|
||||
|
||||
<!-- ## ✨ ATRI [ベータテスト]
|
||||
|
||||
この機能はプラグインとしてロードされます。プラグインリポジトリのアドレス:[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
|
||||
-->
|
||||
</details>
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/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="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20%D0%BF%D0%BB%D0%B0%D0%B3%D0%B8%D0%BD%D0%BE%D0%B2&style=for-the-badge&label=%D0%9C%D0%B0%D0%B3%D0%B0%D0%B7%D0%B8%D0%BD&cacheSeconds=3600">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
|
||||
|
||||
<a href="https://astrbot.app/">Документация</a> |
|
||||
<a href="https://blog.astrbot.app/">Блог</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Дорожная карта</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Сообщить о проблеме</a>
|
||||
</div>
|
||||
|
||||
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## Основные возможности
|
||||
|
||||
1. 💯 Бесплатно и с открытым исходным кодом.
|
||||
2. ✨ ИИ-диалоги с LLM, мультимодальность, Agent, MCP, база знаний, настройки личности.
|
||||
3. 🤖 Поддержка интеграции с Dify, Alibaba Cloud Bailian, Coze и другими платформами агентов.
|
||||
4. 🌐 Мультиплатформенность: QQ, WeChat Work, Feishu, DingTalk, официальные аккаунты WeChat, Telegram, Slack и [другие](#поддерживаемые-платформы-обмена-сообщениями).
|
||||
5. 📦 Расширения плагинов с почти 800 плагинами, доступными для установки в один клик.
|
||||
6. 💻 Поддержка WebUI.
|
||||
7. 🌐 Поддержка интернационализации (i18n).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
#### Развёртывание Docker (Рекомендуется 🥳)
|
||||
|
||||
Мы рекомендуем развёртывать AstrBot с помощью Docker или Docker Compose.
|
||||
|
||||
См. официальную документацию: [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
#### Развёртывание uv
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
```
|
||||
|
||||
#### Развёртывание BT-Panel
|
||||
|
||||
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
|
||||
|
||||
См. официальную документацию: [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||
|
||||
#### Развёртывание 1Panel
|
||||
|
||||
AstrBot официально размещён на маркетплейсе 1Panel.
|
||||
|
||||
См. официальную документацию: [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html).
|
||||
|
||||
#### Развёртывание на RainYun
|
||||
|
||||
AstrBot официально размещён на облачной платформе приложений RainYun с развёртыванием в один клик.
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### Развёртывание на Replit
|
||||
|
||||
Метод развёртывания от сообщества.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Установщик Windows в один клик
|
||||
|
||||
См. официальную документацию: [Развёртывание AstrBot с установщиком Windows в один клик](https://astrbot.app/deploy/astrbot/windows.html).
|
||||
|
||||
#### Развёртывание CasaOS
|
||||
|
||||
Метод развёртывания от сообщества.
|
||||
|
||||
См. официальную документацию: [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html).
|
||||
|
||||
#### Ручное развёртывание
|
||||
|
||||
Сначала установите uv:
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
Установите AstrBot через Git Clone:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Или см. официальную документацию: [Развёртывание AstrBot из исходного кода](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
## Поддерживаемые платформы обмена сообщениями
|
||||
|
||||
**Официально поддерживаемые**
|
||||
|
||||
- QQ (Официальная платформа и OneBot)
|
||||
- Telegram
|
||||
- Приложение WeChat Work и интеллектуальный бот WeChat Work
|
||||
- Служба поддержки WeChat и официальные аккаунты WeChat
|
||||
- Feishu (Lark)
|
||||
- DingTalk
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- WhatsApp (Скоро)
|
||||
- LINE (Скоро)
|
||||
|
||||
**Поддерживаемые сообществом**
|
||||
|
||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
||||
- [Личные сообщения Bilibili](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
|
||||
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
|
||||
|
||||
## Поддерживаемые сервисы моделей
|
||||
|
||||
**Сервисы LLM**
|
||||
|
||||
- OpenAI и совместимые сервисы
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- Zhipu AI
|
||||
- DeepSeek
|
||||
- Ollama (Самостоятельное размещение)
|
||||
- LM Studio (Самостоятельное размещение)
|
||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
**Платформы LLMOps**
|
||||
|
||||
- Dify
|
||||
- Приложения Alibaba Cloud Bailian
|
||||
- Coze
|
||||
|
||||
**Сервисы распознавания речи**
|
||||
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
**Сервисы синтеза речи**
|
||||
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- Alibaba Cloud Bailian TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- Volcano Engine TTS
|
||||
|
||||
## ❤️ Вклад в проект
|
||||
|
||||
Issues и Pull Request всегда приветствуются! Не стесняйтесь отправлять свои изменения в этот проект :)
|
||||
|
||||
### Как внести вклад
|
||||
|
||||
Вы можете внести вклад, просматривая issues или помогая с ревью pull request. Любые issues или PR приветствуются для поощрения участия сообщества. Конечно, это лишь предложения — вы можете вносить вклад любым удобным для вас способом. Для добавления новых функций сначала обсудите это через Issue.
|
||||
|
||||
### Среда разработки
|
||||
|
||||
AstrBot использует `ruff` для форматирования и линтинга кода.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 Сообщество
|
||||
|
||||
### Группы QQ
|
||||
|
||||
- Группа 1: 322154837
|
||||
- Группа 3: 630166526
|
||||
- Группа 5: 822130018
|
||||
- Группа 6: 753075035
|
||||
- Группа разработчиков: 975206796
|
||||
|
||||
### Группа Telegram
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Сервер Discord
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
## ❤️ Особая благодарность
|
||||
|
||||
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Замечательный кошачий фреймворк
|
||||
|
||||
## ⭐ История звёзд
|
||||
|
||||
> [!TIP]
|
||||
> Если этот проект помог вам в жизни или работе, или если вас интересует его будущее развитие, пожалуйста, поставьте проекту звезду. Это движущая сила поддержки этого проекта с открытым исходным кодом <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/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="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E5%80%8B&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%A0%B4&cacheSeconds=3600">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">简体中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<a href="https://astrbot.app/">文件</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">路線圖</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a>
|
||||
</div>
|
||||
|
||||
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||
|
||||
## 主要功能
|
||||
|
||||
1. 💯 免費 & 開源。
|
||||
2. ✨ AI 大型模型對話,多模態,Agent,MCP,知識庫,人格設定。
|
||||
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體平台。
|
||||
4. 🌐 多平台:QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
|
||||
5. 📦 外掛擴充,已有近 800 個外掛可一鍵安裝。
|
||||
6. 💻 WebUI 支援。
|
||||
7. 🌐 國際化(i18n)支援。
|
||||
|
||||
## 快速開始
|
||||
|
||||
#### Docker 部署(推薦 🥳)
|
||||
|
||||
推薦使用 Docker / Docker Compose 方式部署 AstrBot。
|
||||
|
||||
請參閱官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
#### uv 部署
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
```
|
||||
|
||||
#### 寶塔面板部署
|
||||
|
||||
AstrBot 與寶塔面板合作,已上架至寶塔面板。
|
||||
|
||||
請參閱官方文件 [寶塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html)。
|
||||
|
||||
#### 1Panel 部署
|
||||
|
||||
AstrBot 已由 1Panel 官方上架至 1Panel 面板。
|
||||
|
||||
請參閱官方文件 [1Panel 部署](https://astrbot.app/deploy/astrbot/1panel.html)。
|
||||
|
||||
#### 在雨雲上部署
|
||||
|
||||
AstrBot 已由雨雲官方上架至雲端應用程式平台,可一鍵部署。
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
#### 在 Replit 上部署
|
||||
|
||||
社群貢獻的部署方式。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
#### Windows 一鍵安裝器部署
|
||||
|
||||
請參閱官方文件 [使用 Windows 一鍵安裝器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html)。
|
||||
|
||||
#### CasaOS 部署
|
||||
|
||||
社群貢獻的部署方式。
|
||||
|
||||
請參閱官方文件 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html)。
|
||||
|
||||
#### 手動部署
|
||||
|
||||
首先安裝 uv:
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
透過 Git Clone 安裝 AstrBot:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
或者請參閱官方文件 [透過原始碼部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html)。
|
||||
|
||||
## 支援的訊息平台
|
||||
|
||||
**官方維護**
|
||||
|
||||
- QQ(官方平台 & OneBot)
|
||||
- Telegram
|
||||
- 企微應用 & 企微智慧機器人
|
||||
- 微信客服 & 微信公眾號
|
||||
- 飛書
|
||||
- 釘釘
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- Whatsapp(即將支援)
|
||||
- LINE(即將支援)
|
||||
|
||||
**社群維護**
|
||||
|
||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
||||
- [Bilibili 私訊](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
|
||||
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
|
||||
|
||||
## 支援的模型服務
|
||||
|
||||
**大型模型服務**
|
||||
|
||||
- OpenAI 及相容服務
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- 智譜 AI
|
||||
- DeepSeek
|
||||
- Ollama(本機部署)
|
||||
- LM Studio(本機部署)
|
||||
- [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [小馬算力](https://www.tokenpony.cn/3YPyf)
|
||||
- [矽基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
**LLMOps 平台**
|
||||
|
||||
- Dify
|
||||
- 阿里雲百煉應用
|
||||
- Coze
|
||||
|
||||
**語音轉文字服務**
|
||||
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
|
||||
**文字轉語音服務**
|
||||
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- 阿里雲百煉 TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- 火山引擎 TTS
|
||||
|
||||
## ❤️ 貢獻
|
||||
|
||||
歡迎任何 Issues/Pull Requests!只需要將您的變更提交到此專案 :)
|
||||
|
||||
### 如何貢獻
|
||||
|
||||
您可以透過檢視問題或協助審核 PR(拉取請求)來貢獻。任何問題或 PR 都歡迎參與,以促進社群貢獻。當然,這些只是建議,您可以以任何方式進行貢獻。對於新功能的新增,請先透過 Issue 討論。
|
||||
|
||||
### 開發環境
|
||||
|
||||
AstrBot 使用 `ruff` 進行程式碼格式化和檢查。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🌍 社群
|
||||
|
||||
### QQ 群組
|
||||
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 開發者群:975206796
|
||||
|
||||
### Telegram 群組
|
||||
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
### Discord 群組
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 偉大的貓貓框架
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
> 如果本專案對您的生活 / 工作產生了幫助,或者您關注本專案的未來發展,請給專案 Star,這是我們維護這個開源專案的動力 <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
+10
-11
@@ -1,20 +1,19 @@
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot import logger
|
||||
from astrbot.core import html_renderer
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.star.register import register_llm_tool as llm_tool
|
||||
from astrbot.core.star.register import register_agent as agent
|
||||
from astrbot.core.agent.tool import ToolSet, FunctionTool
|
||||
from astrbot.core import html_renderer, sp
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.star.register import register_agent as agent
|
||||
from astrbot.core.star.register import register_llm_tool as llm_tool
|
||||
|
||||
__all__ = [
|
||||
"AstrBotConfig",
|
||||
"logger",
|
||||
"BaseFunctionToolExecutor",
|
||||
"FunctionTool",
|
||||
"ToolSet",
|
||||
"agent",
|
||||
"html_renderer",
|
||||
"llm_tool",
|
||||
"agent",
|
||||
"logger",
|
||||
"sp",
|
||||
"ToolSet",
|
||||
"FunctionTool",
|
||||
"BaseFunctionToolExecutor",
|
||||
]
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@ from astrbot.core.star.config import *
|
||||
|
||||
|
||||
# provider
|
||||
from astrbot.core.provider import Provider, Personality, ProviderMetaData
|
||||
from astrbot.core.provider import Provider, ProviderMetaData
|
||||
from astrbot.core.db.po import Personality
|
||||
|
||||
# platform
|
||||
from astrbot.core.platform import (
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageEventResult,
|
||||
MessageChain,
|
||||
CommandResult,
|
||||
EventResultType,
|
||||
MessageChain,
|
||||
MessageEventResult,
|
||||
ResultContentType,
|
||||
)
|
||||
|
||||
from astrbot.core.platform import AstrMessageEvent
|
||||
|
||||
__all__ = [
|
||||
"MessageEventResult",
|
||||
"MessageChain",
|
||||
"AstrMessageEvent",
|
||||
"CommandResult",
|
||||
"EventResultType",
|
||||
"AstrMessageEvent",
|
||||
"MessageChain",
|
||||
"MessageEventResult",
|
||||
"ResultContentType",
|
||||
]
|
||||
|
||||
@@ -1,51 +1,56 @@
|
||||
from astrbot.core.star.register import (
|
||||
register_command as command,
|
||||
register_command_group as command_group,
|
||||
register_event_message_type as event_message_type,
|
||||
register_regex as regex,
|
||||
register_platform_adapter_type as platform_adapter_type,
|
||||
register_permission_type as permission_type,
|
||||
register_custom_filter as custom_filter,
|
||||
register_on_astrbot_loaded as on_astrbot_loaded,
|
||||
register_on_platform_loaded as on_platform_loaded,
|
||||
register_on_llm_request as on_llm_request,
|
||||
register_on_llm_response as on_llm_response,
|
||||
register_llm_tool as llm_tool,
|
||||
register_on_decorating_result as on_decorating_result,
|
||||
register_after_message_sent as after_message_sent,
|
||||
)
|
||||
|
||||
from astrbot.core.star.filter.event_message_type import (
|
||||
EventMessageTypeFilter,
|
||||
EventMessageType,
|
||||
)
|
||||
from astrbot.core.star.filter.platform_adapter_type import (
|
||||
PlatformAdapterTypeFilter,
|
||||
PlatformAdapterType,
|
||||
)
|
||||
from astrbot.core.star.filter.permission import PermissionTypeFilter, PermissionType
|
||||
from astrbot.core.star.filter.custom_filter import CustomFilter
|
||||
from astrbot.core.star.filter.event_message_type import (
|
||||
EventMessageType,
|
||||
EventMessageTypeFilter,
|
||||
)
|
||||
from astrbot.core.star.filter.permission import PermissionType, PermissionTypeFilter
|
||||
from astrbot.core.star.filter.platform_adapter_type import (
|
||||
PlatformAdapterType,
|
||||
PlatformAdapterTypeFilter,
|
||||
)
|
||||
from astrbot.core.star.register import register_after_message_sent as after_message_sent
|
||||
from astrbot.core.star.register import register_command as command
|
||||
from astrbot.core.star.register import register_command_group as command_group
|
||||
from astrbot.core.star.register import register_custom_filter as custom_filter
|
||||
from astrbot.core.star.register import register_event_message_type as event_message_type
|
||||
from astrbot.core.star.register import register_llm_tool as llm_tool
|
||||
from astrbot.core.star.register import register_on_astrbot_loaded as on_astrbot_loaded
|
||||
from astrbot.core.star.register import (
|
||||
register_on_decorating_result as on_decorating_result,
|
||||
)
|
||||
from astrbot.core.star.register import register_on_llm_request as on_llm_request
|
||||
from astrbot.core.star.register import register_on_llm_response as on_llm_response
|
||||
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
||||
from astrbot.core.star.register import (
|
||||
register_on_waiting_llm_request as on_waiting_llm_request,
|
||||
)
|
||||
from astrbot.core.star.register import register_permission_type as permission_type
|
||||
from astrbot.core.star.register import (
|
||||
register_platform_adapter_type as platform_adapter_type,
|
||||
)
|
||||
from astrbot.core.star.register import register_regex as regex
|
||||
|
||||
__all__ = [
|
||||
"CustomFilter",
|
||||
"EventMessageType",
|
||||
"EventMessageTypeFilter",
|
||||
"PermissionType",
|
||||
"PermissionTypeFilter",
|
||||
"PlatformAdapterType",
|
||||
"PlatformAdapterTypeFilter",
|
||||
"after_message_sent",
|
||||
"command",
|
||||
"command_group",
|
||||
"event_message_type",
|
||||
"regex",
|
||||
"platform_adapter_type",
|
||||
"permission_type",
|
||||
"EventMessageTypeFilter",
|
||||
"EventMessageType",
|
||||
"PlatformAdapterTypeFilter",
|
||||
"PlatformAdapterType",
|
||||
"PermissionTypeFilter",
|
||||
"CustomFilter",
|
||||
"custom_filter",
|
||||
"PermissionType",
|
||||
"on_astrbot_loaded",
|
||||
"on_platform_loaded",
|
||||
"on_llm_request",
|
||||
"event_message_type",
|
||||
"llm_tool",
|
||||
"on_astrbot_loaded",
|
||||
"on_decorating_result",
|
||||
"after_message_sent",
|
||||
"on_llm_request",
|
||||
"on_llm_response",
|
||||
"on_platform_loaded",
|
||||
"on_waiting_llm_request",
|
||||
"permission_type",
|
||||
"platform_adapter_type",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
from astrbot.core.message.components import *
|
||||
from astrbot.core.platform import (
|
||||
AstrMessageEvent,
|
||||
Platform,
|
||||
AstrBotMessage,
|
||||
AstrMessageEvent,
|
||||
Group,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
Group,
|
||||
)
|
||||
|
||||
from astrbot.core.platform.register import register_platform_adapter
|
||||
from astrbot.core.message.components import *
|
||||
|
||||
__all__ = [
|
||||
"AstrMessageEvent",
|
||||
"Platform",
|
||||
"AstrBotMessage",
|
||||
"AstrMessageEvent",
|
||||
"Group",
|
||||
"MessageMember",
|
||||
"MessageType",
|
||||
"Platform",
|
||||
"PlatformMetadata",
|
||||
"register_platform_adapter",
|
||||
"Group",
|
||||
]
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
from astrbot.core.provider import Provider, STTProvider, Personality
|
||||
from astrbot.core.db.po import Personality
|
||||
from astrbot.core.provider import Provider, STTProvider
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
ProviderMetaData,
|
||||
ProviderRequest,
|
||||
ProviderType,
|
||||
ProviderMetaData,
|
||||
LLMResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Provider",
|
||||
"STTProvider",
|
||||
"LLMResponse",
|
||||
"Personality",
|
||||
"Provider",
|
||||
"ProviderMetaData",
|
||||
"ProviderRequest",
|
||||
"ProviderType",
|
||||
"ProviderMetaData",
|
||||
"LLMResponse",
|
||||
"STTProvider",
|
||||
]
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from astrbot.core.star import Context, Star, StarTools
|
||||
from astrbot.core.star.config import *
|
||||
from astrbot.core.star.register import (
|
||||
register_star as register, # 注册插件(Star)
|
||||
)
|
||||
|
||||
from astrbot.core.star import Context, Star, StarTools
|
||||
from astrbot.core.star.config import *
|
||||
|
||||
__all__ = ["register", "Context", "Star", "StarTools"]
|
||||
__all__ = ["Context", "Star", "StarTools", "register"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
SessionWaiter,
|
||||
SessionController,
|
||||
SessionWaiter,
|
||||
session_waiter,
|
||||
)
|
||||
|
||||
__all__ = ["SessionWaiter", "SessionController", "session_waiter"]
|
||||
__all__ = ["SessionController", "SessionWaiter", "session_waiter"]
|
||||
|
||||
+44
-27
@@ -1,13 +1,14 @@
|
||||
import datetime
|
||||
import uuid
|
||||
import random
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api.event import AstrMessageEvent
|
||||
from astrbot.api.platform import MessageType
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.api.message_components import Plain, Image
|
||||
from astrbot import logger
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
from astrbot.api.platform import MessageType
|
||||
from astrbot.api.provider import LLMResponse, Provider, ProviderRequest
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
|
||||
"""
|
||||
@@ -29,15 +30,13 @@ class LongTermMemory:
|
||||
except BaseException as e:
|
||||
logger.error(e)
|
||||
max_cnt = 300
|
||||
image_caption = (
|
||||
True
|
||||
if cfg["provider_settings"]["default_image_caption_provider_id"]
|
||||
else False
|
||||
)
|
||||
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
|
||||
image_caption_provider_id = cfg["provider_settings"][
|
||||
"default_image_caption_provider_id"
|
||||
]
|
||||
image_caption_provider_id = cfg["provider_ltm_settings"].get(
|
||||
"image_caption_provider_id"
|
||||
)
|
||||
image_caption = cfg["provider_ltm_settings"]["image_caption"] and bool(
|
||||
image_caption_provider_id
|
||||
)
|
||||
active_reply = cfg["provider_ltm_settings"]["active_reply"]
|
||||
enable_active_reply = active_reply.get("enable", False)
|
||||
ar_method = active_reply["method"]
|
||||
@@ -65,7 +64,10 @@ class LongTermMemory:
|
||||
return cnt
|
||||
|
||||
async def get_image_caption(
|
||||
self, image_url: str, image_caption_provider_id: str, image_caption_prompt: str
|
||||
self,
|
||||
image_url: str,
|
||||
image_caption_provider_id: str,
|
||||
image_caption_prompt: str,
|
||||
) -> str:
|
||||
if not image_caption_provider_id:
|
||||
provider = self.context.get_using_provider()
|
||||
@@ -73,6 +75,8 @@ class LongTermMemory:
|
||||
provider = self.context.get_provider_by_id(image_caption_provider_id)
|
||||
if not provider:
|
||||
raise Exception(f"没有找到 ID 为 {image_caption_provider_id} 的提供商")
|
||||
if not isinstance(provider, Provider):
|
||||
raise Exception(f"提供商类型错误({type(provider)}),无法获取图片描述")
|
||||
response = await provider.text_chat(
|
||||
prompt=image_caption_prompt,
|
||||
session_id=uuid.uuid4().hex,
|
||||
@@ -112,26 +116,33 @@ class LongTermMemory:
|
||||
if event.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
final_message = f"[{event.message_obj.sender.nickname}/{datetime_str}]: "
|
||||
parts = [f"[{event.message_obj.sender.nickname}/{datetime_str}]: "]
|
||||
|
||||
cfg = self.cfg(event)
|
||||
|
||||
for comp in event.get_messages():
|
||||
if isinstance(comp, Plain):
|
||||
final_message += f" {comp.text}"
|
||||
parts.append(f" {comp.text}")
|
||||
elif isinstance(comp, Image):
|
||||
if cfg["image_caption"]:
|
||||
try:
|
||||
url = comp.url if comp.url else comp.file
|
||||
if not url:
|
||||
raise Exception("图片 URL 为空")
|
||||
caption = await self.get_image_caption(
|
||||
comp.url if comp.url else comp.file,
|
||||
url,
|
||||
cfg["image_caption_provider_id"],
|
||||
cfg["image_caption_prompt"],
|
||||
)
|
||||
final_message += f" [Image: {caption}]"
|
||||
parts.append(f" [Image: {caption}]")
|
||||
except Exception as e:
|
||||
logger.error(f"获取图片描述失败: {e}")
|
||||
else:
|
||||
final_message += " [Image]"
|
||||
parts.append(" [Image]")
|
||||
elif isinstance(comp, At):
|
||||
parts.append(f" [At: {comp.name}]")
|
||||
|
||||
final_message = "".join(parts)
|
||||
logger.debug(f"ltm | {event.unified_msg_origin} | {final_message}")
|
||||
self.session_chats[event.unified_msg_origin].append(final_message)
|
||||
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
|
||||
@@ -147,8 +158,12 @@ class LongTermMemory:
|
||||
cfg = self.cfg(event)
|
||||
if cfg["enable_active_reply"]:
|
||||
prompt = req.prompt
|
||||
req.prompt = f"You are now in a chatroom. The chat history is as follows:\n{chats_str}"
|
||||
req.prompt += f"\nNow, a new message is coming: `{prompt}`. Please react to it. Only output your response and do not output any other information."
|
||||
req.prompt = (
|
||||
f"You are now in a chatroom. The chat history is as follows:\n{chats_str}"
|
||||
f"\nNow, a new message is coming: `{prompt}`. "
|
||||
"Please react to it. Only output your response and do not output any other information. "
|
||||
"You MUST use the SAME language as the chatroom is using."
|
||||
)
|
||||
req.contexts = [] # 清空上下文,当使用了主动回复,所有聊天记录都在一个prompt中。
|
||||
else:
|
||||
req.system_prompt += (
|
||||
@@ -156,13 +171,15 @@ class LongTermMemory:
|
||||
)
|
||||
req.system_prompt += chats_str
|
||||
|
||||
async def after_req_llm(self, event: AstrMessageEvent):
|
||||
async def after_req_llm(self, event: AstrMessageEvent, llm_resp: LLMResponse):
|
||||
if event.unified_msg_origin not in self.session_chats:
|
||||
return
|
||||
|
||||
if event.get_result() and event.get_result().is_llm_result():
|
||||
final_message = f"[You/{datetime.datetime.now().strftime('%H:%M:%S')}]: {event.get_result().get_plain_text()}"
|
||||
logger.debug(f"ltm | {event.unified_msg_origin} | {final_message}")
|
||||
if llm_resp.completion_text:
|
||||
final_message = f"[You/{datetime.datetime.now().strftime('%H:%M:%S')}]: {llm_resp.completion_text}"
|
||||
logger.debug(
|
||||
f"Recorded AI response: {event.unified_msg_origin} | {final_message}"
|
||||
)
|
||||
self.session_chats[event.unified_msg_origin].append(final_message)
|
||||
cfg = self.cfg(event)
|
||||
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
|
||||
@@ -0,0 +1,120 @@
|
||||
import traceback
|
||||
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.message_components import Image, Plain
|
||||
from astrbot.api.provider import LLMResponse, ProviderRequest
|
||||
from astrbot.core import logger
|
||||
|
||||
from .long_term_memory import LongTermMemory
|
||||
from .process_llm_request import ProcessLLMRequest
|
||||
|
||||
|
||||
class Main(star.Star):
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
self.ltm = None
|
||||
try:
|
||||
self.ltm = LongTermMemory(self.context.astrbot_config_mgr, self.context)
|
||||
except BaseException as e:
|
||||
logger.error(f"聊天增强 err: {e}")
|
||||
|
||||
self.proc_llm_req = ProcessLLMRequest(self.context)
|
||||
|
||||
def ltm_enabled(self, event: AstrMessageEvent):
|
||||
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
|
||||
"provider_ltm_settings"
|
||||
]
|
||||
return ltmse["group_icl_enable"] or ltmse["active_reply"]["enable"]
|
||||
|
||||
@filter.platform_adapter_type(filter.PlatformAdapterType.ALL)
|
||||
async def on_message(self, event: AstrMessageEvent):
|
||||
"""群聊记忆增强"""
|
||||
has_image_or_plain = False
|
||||
for comp in event.message_obj.message:
|
||||
if isinstance(comp, Plain) or isinstance(comp, Image):
|
||||
has_image_or_plain = True
|
||||
break
|
||||
|
||||
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
|
||||
need_active = await self.ltm.need_active_reply(event)
|
||||
|
||||
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
|
||||
"group_icl_enable"
|
||||
]
|
||||
if group_icl_enable:
|
||||
"""记录对话"""
|
||||
try:
|
||||
await self.ltm.handle_message(event)
|
||||
except BaseException as e:
|
||||
logger.error(e)
|
||||
|
||||
if need_active:
|
||||
"""主动回复"""
|
||||
provider = self.context.get_using_provider(event.unified_msg_origin)
|
||||
if not provider:
|
||||
logger.error("未找到任何 LLM 提供商。请先配置。无法主动回复")
|
||||
return
|
||||
try:
|
||||
conv = None
|
||||
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin,
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
logger.error(
|
||||
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。",
|
||||
)
|
||||
return
|
||||
|
||||
conv = await self.context.conversation_manager.get_conversation(
|
||||
event.unified_msg_origin,
|
||||
session_curr_cid,
|
||||
)
|
||||
|
||||
prompt = event.message_str
|
||||
|
||||
if not conv:
|
||||
logger.error("未找到对话,无法主动回复")
|
||||
return
|
||||
|
||||
yield event.request_llm(
|
||||
prompt=prompt,
|
||||
func_tool_manager=self.context.get_llm_tool_manager(),
|
||||
session_id=event.session_id,
|
||||
conversation=conv,
|
||||
)
|
||||
except BaseException as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"主动回复失败: {e}")
|
||||
|
||||
@filter.on_llm_request()
|
||||
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
|
||||
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
|
||||
await self.proc_llm_req.process_llm_request(event, req)
|
||||
|
||||
if self.ltm and self.ltm_enabled(event):
|
||||
try:
|
||||
await self.ltm.on_req_llm(event, req)
|
||||
except BaseException as e:
|
||||
logger.error(f"ltm: {e}")
|
||||
|
||||
@filter.on_llm_response()
|
||||
async def record_llm_resp_to_ltm(self, event: AstrMessageEvent, resp: LLMResponse):
|
||||
"""在 LLM 响应后记录对话"""
|
||||
if self.ltm and self.ltm_enabled(event):
|
||||
try:
|
||||
await self.ltm.after_req_llm(event, resp)
|
||||
except Exception as e:
|
||||
logger.error(f"ltm: {e}")
|
||||
|
||||
@filter.after_message_sent()
|
||||
async def after_message_sent(self, event: AstrMessageEvent):
|
||||
"""消息发送后处理"""
|
||||
if self.ltm and self.ltm_enabled(event):
|
||||
try:
|
||||
clean_session = event.get_extra("_clean_ltm_session", False)
|
||||
if clean_session:
|
||||
await self.ltm.remove_session(event)
|
||||
except Exception as e:
|
||||
logger.error(f"ltm: {e}")
|
||||
@@ -0,0 +1,4 @@
|
||||
name: astrbot
|
||||
desc: AstrBot 自带插件,包含人格注入、思考内容注入、群聊上下文感知等功能的实现,禁用后将无法使用这些功能。
|
||||
author: Soulter
|
||||
version: 4.1.0
|
||||
+89
-39
@@ -1,13 +1,14 @@
|
||||
import astrbot.api.star as star
|
||||
import builtins
|
||||
import copy
|
||||
import datetime
|
||||
import zoneinfo
|
||||
from astrbot.api import logger
|
||||
|
||||
from astrbot.api import logger, sp, star
|
||||
from astrbot.api.event import AstrMessageEvent
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.provider.func_tool_manager import ToolSet
|
||||
from astrbot.api.message_components import Image, Reply
|
||||
from astrbot.api.provider import Provider, ProviderRequest
|
||||
from astrbot.core.agent.message import TextPart
|
||||
from astrbot.core.provider.func_tool_manager import ToolSet
|
||||
|
||||
|
||||
class ProcessLLMRequest:
|
||||
@@ -21,16 +22,26 @@ class ProcessLLMRequest:
|
||||
else:
|
||||
logger.info(f"Timezone set to: {self.timezone}")
|
||||
|
||||
def _ensure_persona(self, req: ProviderRequest, cfg: dict):
|
||||
async def _ensure_persona(self, req: ProviderRequest, cfg: dict, umo: str):
|
||||
"""确保用户人格已加载"""
|
||||
if not req.conversation:
|
||||
return
|
||||
# persona inject
|
||||
persona_id = req.conversation.persona_id or cfg.get("default_personality")
|
||||
if not persona_id and persona_id != "[%None]": # [%None] 为用户取消人格
|
||||
default_persona = self.ctx.persona_manager.selected_default_persona_v3
|
||||
if default_persona:
|
||||
persona_id = default_persona["name"]
|
||||
|
||||
# custom rule is preferred
|
||||
persona_id = (
|
||||
await sp.get_async(
|
||||
scope="umo", scope_id=umo, key="session_service_config", default={}
|
||||
)
|
||||
).get("persona_id")
|
||||
|
||||
if not persona_id:
|
||||
persona_id = req.conversation.persona_id or cfg.get("default_personality")
|
||||
if not persona_id and persona_id != "[%None]": # [%None] 为用户取消人格
|
||||
default_persona = self.ctx.persona_manager.selected_default_persona_v3
|
||||
if default_persona:
|
||||
persona_id = default_persona["name"]
|
||||
|
||||
persona = next(
|
||||
builtins.filter(
|
||||
lambda persona: persona["name"] == persona_id,
|
||||
@@ -41,7 +52,7 @@ class ProcessLLMRequest:
|
||||
if persona:
|
||||
if prompt := persona["prompt"]:
|
||||
req.system_prompt += prompt
|
||||
if begin_dialogs := persona["_begin_dialogs_processed"]:
|
||||
if begin_dialogs := copy.deepcopy(persona["_begin_dialogs_processed"]):
|
||||
req.contexts[:0] = begin_dialogs
|
||||
|
||||
# tools select
|
||||
@@ -63,25 +74,36 @@ class ProcessLLMRequest:
|
||||
logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
|
||||
|
||||
async def _ensure_img_caption(
|
||||
self, req: ProviderRequest, cfg: dict, img_cap_prov_id: str
|
||||
self,
|
||||
req: ProviderRequest,
|
||||
cfg: dict,
|
||||
img_cap_prov_id: str,
|
||||
):
|
||||
try:
|
||||
caption = await self._request_img_caption(
|
||||
img_cap_prov_id, cfg, req.image_urls
|
||||
img_cap_prov_id,
|
||||
cfg,
|
||||
req.image_urls,
|
||||
)
|
||||
if caption:
|
||||
req.prompt = f"(Image Caption: {caption})\n\n{req.prompt}"
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(text=f"<image_caption>{caption}</image_caption>")
|
||||
)
|
||||
req.image_urls = []
|
||||
except Exception as e:
|
||||
logger.error(f"处理图片描述失败: {e}")
|
||||
|
||||
async def _request_img_caption(
|
||||
self, provider_id: str, cfg: dict, image_urls: list[str]
|
||||
self,
|
||||
provider_id: str,
|
||||
cfg: dict,
|
||||
image_urls: list[str],
|
||||
) -> str:
|
||||
if prov := self.ctx.get_provider_by_id(provider_id):
|
||||
if isinstance(prov, Provider):
|
||||
img_cap_prompt = cfg.get(
|
||||
"image_caption_prompt", "Please describe the image."
|
||||
"image_caption_prompt",
|
||||
"Please describe the image.",
|
||||
)
|
||||
logger.debug(f"Processing image caption with provider: {provider_id}")
|
||||
llm_resp = await prov.text_chat(
|
||||
@@ -89,14 +111,12 @@ class ProcessLLMRequest:
|
||||
image_urls=image_urls,
|
||||
)
|
||||
return llm_resp.completion_text
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Cannot get image caption because provider `{provider_id}` is not a valid Provider, it is {type(prov)}."
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Cannot get image caption because provider `{provider_id}` is not exist."
|
||||
f"Cannot get image caption because provider `{provider_id}` is not a valid Provider, it is {type(prov)}.",
|
||||
)
|
||||
raise ValueError(
|
||||
f"Cannot get image caption because provider `{provider_id}` is not exist.",
|
||||
)
|
||||
|
||||
async def process_llm_request(self, event: AstrMessageEvent, req: ProviderRequest):
|
||||
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
|
||||
@@ -112,19 +132,25 @@ class ProcessLLMRequest:
|
||||
else:
|
||||
req.prompt = prefix + req.prompt
|
||||
|
||||
# 收集系统提醒信息
|
||||
system_parts = []
|
||||
|
||||
# user identifier
|
||||
if cfg.get("identifier"):
|
||||
user_id = event.message_obj.sender.user_id
|
||||
user_nickname = event.message_obj.sender.nickname
|
||||
req.prompt = (
|
||||
f"\n[User ID: {user_id}, Nickname: {user_nickname}]\n{req.prompt}"
|
||||
)
|
||||
system_parts.append(f"User ID: {user_id}, Nickname: {user_nickname}")
|
||||
|
||||
# group name identifier
|
||||
if cfg.get("group_name_display") and event.message_obj.group_id:
|
||||
if not event.message_obj.group:
|
||||
logger.error(
|
||||
f"Group name display enabled but group object is None. Group ID: {event.message_obj.group_id}"
|
||||
)
|
||||
return
|
||||
group_name = event.message_obj.group.group_name
|
||||
if group_name:
|
||||
req.system_prompt += f"\nGroup name: {group_name}\n"
|
||||
system_parts.append(f"Group name: {group_name}")
|
||||
|
||||
# time info
|
||||
if cfg.get("datetime_system_prompt"):
|
||||
@@ -140,12 +166,12 @@ class ProcessLLMRequest:
|
||||
current_time = (
|
||||
datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)")
|
||||
)
|
||||
req.system_prompt += f"\nCurrent datetime: {current_time}\n"
|
||||
system_parts.append(f"Current datetime: {current_time}")
|
||||
|
||||
img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
|
||||
if req.conversation:
|
||||
# inject persona for this request
|
||||
self._ensure_persona(req, cfg)
|
||||
await self._ensure_persona(req, cfg, event.unified_msg_origin)
|
||||
|
||||
# image caption
|
||||
if img_cap_prov_id and req.image_urls:
|
||||
@@ -159,37 +185,61 @@ class ProcessLLMRequest:
|
||||
quote = comp
|
||||
break
|
||||
if quote:
|
||||
sender_info = ""
|
||||
if quote.sender_nickname:
|
||||
sender_info = f"(Sent by {quote.sender_nickname})"
|
||||
message_str = quote.message_str or "[Empty Text]"
|
||||
req.system_prompt += (
|
||||
f"\nUser is quoting a message{sender_info}.\n"
|
||||
f"Here are the information of the quoted message: Text Content: {message_str}.\n"
|
||||
content_parts = []
|
||||
|
||||
# 1. 处理引用的文本
|
||||
sender_info = (
|
||||
f"({quote.sender_nickname}): " if quote.sender_nickname else ""
|
||||
)
|
||||
message_str = quote.message_str or "[Empty Text]"
|
||||
content_parts.append(f"{sender_info}{message_str}")
|
||||
|
||||
# 2. 处理引用的图片 (保留原有逻辑,但改变输出目标)
|
||||
image_seg = None
|
||||
if quote.chain:
|
||||
for comp in quote.chain:
|
||||
if isinstance(comp, Image):
|
||||
image_seg = comp
|
||||
break
|
||||
|
||||
if image_seg:
|
||||
try:
|
||||
# 找到可以生成图片描述的 provider
|
||||
prov = None
|
||||
if img_cap_prov_id:
|
||||
prov = self.ctx.get_provider_by_id(img_cap_prov_id)
|
||||
if prov is None:
|
||||
prov = self.ctx.get_using_provider(event.unified_msg_origin)
|
||||
|
||||
# 调用 provider 生成图片描述
|
||||
if prov and isinstance(prov, Provider):
|
||||
llm_resp = await prov.text_chat(
|
||||
prompt="Please describe the image content.",
|
||||
image_urls=[await image_seg.convert_to_file_path()],
|
||||
)
|
||||
if llm_resp.completion_text:
|
||||
req.system_prompt += (
|
||||
f"Image Caption: {llm_resp.completion_text}\n"
|
||||
# 将图片描述作为文本添加到 content_parts
|
||||
content_parts.append(
|
||||
f"[Image Caption in quoted message]: {llm_resp.completion_text}"
|
||||
)
|
||||
else:
|
||||
logger.warning("No provider found for image captioning.")
|
||||
logger.warning(
|
||||
"No provider found for image captioning in quote."
|
||||
)
|
||||
except BaseException as e:
|
||||
logger.error(f"处理引用图片失败: {e}")
|
||||
|
||||
# 3. 将所有部分组合成文本并添加到 extra_user_content_parts 中
|
||||
# 确保引用内容被正确的标签包裹
|
||||
quoted_content = "\n".join(content_parts)
|
||||
# 确保所有内容都在<Quoted Message>标签内
|
||||
quoted_text = f"<Quoted Message>\n{quoted_content}\n</Quoted Message>"
|
||||
|
||||
req.extra_user_content_parts.append(TextPart(text=quoted_text))
|
||||
|
||||
# 统一包裹所有系统提醒
|
||||
if system_parts:
|
||||
system_content = (
|
||||
"<system_reminder>" + "\n".join(system_parts) + "</system_reminder>"
|
||||
)
|
||||
req.extra_user_content_parts.append(TextPart(text=system_content))
|
||||
+15
-15
@@ -1,31 +1,31 @@
|
||||
# Commands module
|
||||
|
||||
from .admin import AdminCommands
|
||||
from .alter_cmd import AlterCmdCommands
|
||||
from .conversation import ConversationCommands
|
||||
from .help import HelpCommand
|
||||
from .llm import LLMCommands
|
||||
from .tool import ToolCommands
|
||||
from .plugin import PluginCommands
|
||||
from .admin import AdminCommands
|
||||
from .conversation import ConversationCommands
|
||||
from .provider import ProviderCommands
|
||||
from .persona import PersonaCommands
|
||||
from .alter_cmd import AlterCmdCommands
|
||||
from .plugin import PluginCommands
|
||||
from .provider import ProviderCommands
|
||||
from .setunset import SetUnsetCommands
|
||||
from .t2i import T2ICommand
|
||||
from .tts import TTSCommand
|
||||
from .sid import SIDCommand
|
||||
from .t2i import T2ICommand
|
||||
from .tool import ToolCommands
|
||||
from .tts import TTSCommand
|
||||
|
||||
__all__ = [
|
||||
"AdminCommands",
|
||||
"AlterCmdCommands",
|
||||
"ConversationCommands",
|
||||
"HelpCommand",
|
||||
"LLMCommands",
|
||||
"ToolCommands",
|
||||
"PluginCommands",
|
||||
"AdminCommands",
|
||||
"ConversationCommands",
|
||||
"ProviderCommands",
|
||||
"PersonaCommands",
|
||||
"AlterCmdCommands",
|
||||
"PluginCommands",
|
||||
"ProviderCommands",
|
||||
"SIDCommand",
|
||||
"SetUnsetCommands",
|
||||
"T2ICommand",
|
||||
"TTSCommand",
|
||||
"SIDCommand",
|
||||
"ToolCommands",
|
||||
]
|
||||
+13
-12
@@ -1,7 +1,7 @@
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, MessageChain
|
||||
from astrbot.core.utils.io import download_dashboard
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain, MessageEventResult
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.io import download_dashboard
|
||||
|
||||
|
||||
class AdminCommands:
|
||||
@@ -13,8 +13,8 @@ class AdminCommands:
|
||||
if not admin_id:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /op <id> 授权管理员;/deop <id> 取消管理员。可通过 /sid 获取 ID。"
|
||||
)
|
||||
"使用方法: /op <id> 授权管理员;/deop <id> 取消管理员。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
self.context.get_config()["admins_id"].append(str(admin_id))
|
||||
@@ -26,8 +26,8 @@ class AdminCommands:
|
||||
if not admin_id:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /deop <id> 取消管理员。可通过 /sid 获取 ID。"
|
||||
)
|
||||
"使用方法: /deop <id> 取消管理员。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
try:
|
||||
@@ -36,7 +36,7 @@ class AdminCommands:
|
||||
event.set_result(MessageEventResult().message("取消授权成功。"))
|
||||
except ValueError:
|
||||
event.set_result(
|
||||
MessageEventResult().message("此用户 ID 不在管理员名单内。")
|
||||
MessageEventResult().message("此用户 ID 不在管理员名单内。"),
|
||||
)
|
||||
|
||||
async def wl(self, event: AstrMessageEvent, sid: str = ""):
|
||||
@@ -44,8 +44,8 @@ class AdminCommands:
|
||||
if not sid:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。"
|
||||
)
|
||||
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
@@ -58,8 +58,8 @@ class AdminCommands:
|
||||
if not sid:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /dwl <id> 删除白名单。可通过 /sid 获取 ID。"
|
||||
)
|
||||
"使用方法: /dwl <id> 删除白名单。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
try:
|
||||
@@ -71,6 +71,7 @@ class AdminCommands:
|
||||
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
|
||||
|
||||
async def update_dashboard(self, event: AstrMessageEvent):
|
||||
"""更新管理面板"""
|
||||
await event.send(MessageChain().message("正在尝试更新管理面板..."))
|
||||
await download_dashboard(version=f"v{VERSION}", latest=False)
|
||||
await event.send(MessageChain().message("管理面板更新完成。"))
|
||||
+23
-38
@@ -1,31 +1,13 @@
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, StarHandlerMetadata
|
||||
from astrbot.core.star.star import star_map
|
||||
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
|
||||
from enum import Enum
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
|
||||
|
||||
class RstScene(Enum):
|
||||
GROUP_UNIQUE_ON = ("group_unique_on", "群聊+会话隔离开启")
|
||||
GROUP_UNIQUE_OFF = ("group_unique_off", "群聊+会话隔离关闭")
|
||||
PRIVATE = ("private", "私聊")
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return self.value[0]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.value[1]
|
||||
|
||||
@classmethod
|
||||
def from_index(cls, index: int) -> "RstScene":
|
||||
mapping = {1: cls.GROUP_UNIQUE_ON, 2: cls.GROUP_UNIQUE_OFF, 3: cls.PRIVATE}
|
||||
return mapping[index]
|
||||
from .utils.rst_scene import RstScene
|
||||
|
||||
|
||||
class AlterCmdCommands(CommandParserMixin):
|
||||
@@ -53,13 +35,14 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
"格式: /alter_cmd <cmd_name> <admin/member>\n"
|
||||
"例1: /alter_cmd c1 admin 将 c1 设为管理员指令\n"
|
||||
"例2: /alter_cmd g1 c1 admin 将 g1 指令组的 c1 子指令设为管理员指令\n"
|
||||
"/alter_cmd reset config 打开 reset 权限配置"
|
||||
)
|
||||
"/alter_cmd reset config 打开 reset 权限配置",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
cmd_name = " ".join(token.tokens[1:-1])
|
||||
cmd_type = token.get(-1)
|
||||
# 兼容 reset scene 的专门配置
|
||||
cmd_name = token.get(1)
|
||||
cmd_type = token.get(2)
|
||||
|
||||
if cmd_name == "reset" and cmd_type == "config":
|
||||
from astrbot.api import sp
|
||||
@@ -93,13 +76,13 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
|
||||
if not scene_num.isdigit() or int(scene_num) < 1 or int(scene_num) > 3:
|
||||
await event.send(
|
||||
MessageChain().message("场景编号必须是 1-3 之间的数字")
|
||||
MessageChain().message("场景编号必须是 1-3 之间的数字"),
|
||||
)
|
||||
return
|
||||
|
||||
if perm_type not in ["admin", "member"]:
|
||||
await event.send(
|
||||
MessageChain().message("权限类型错误,只能是 admin 或 member")
|
||||
MessageChain().message("权限类型错误,只能是 admin 或 member"),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -111,18 +94,20 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
f"已将 reset 命令在{scene.name}场景下的权限设为{perm_type}"
|
||||
)
|
||||
f"已将 reset 命令在{scene.name}场景下的权限设为{perm_type}",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
if cmd_type not in ["admin", "member"]:
|
||||
await event.send(
|
||||
MessageChain().message("指令类型错误,可选类型有 admin, member")
|
||||
MessageChain().message("指令类型错误,可选类型有 admin, member"),
|
||||
)
|
||||
return
|
||||
|
||||
# 查找指令
|
||||
cmd_name = " ".join(token.tokens[1:-1])
|
||||
cmd_type = token.get(-1)
|
||||
found_command = None
|
||||
cmd_group = False
|
||||
for handler in star_handlers_registry:
|
||||
@@ -160,29 +145,29 @@ class AlterCmdCommands(CommandParserMixin):
|
||||
for filter_ in found_command.event_filters:
|
||||
if isinstance(filter_, PermissionTypeFilter):
|
||||
if cmd_type == "admin":
|
||||
import astrbot.api.event.filter as filter
|
||||
from astrbot.api.event import filter
|
||||
|
||||
filter_.permission_type = filter.PermissionType.ADMIN
|
||||
else:
|
||||
import astrbot.api.event.filter as filter
|
||||
from astrbot.api.event import filter
|
||||
|
||||
filter_.permission_type = filter.PermissionType.MEMBER
|
||||
found_permission_filter = True
|
||||
break
|
||||
if not found_permission_filter:
|
||||
import astrbot.api.event.filter as filter
|
||||
from astrbot.api.event import filter
|
||||
|
||||
found_command.event_filters.insert(
|
||||
0,
|
||||
PermissionTypeFilter(
|
||||
filter.PermissionType.ADMIN
|
||||
if cmd_type == "admin"
|
||||
else filter.PermissionType.MEMBER
|
||||
else filter.PermissionType.MEMBER,
|
||||
),
|
||||
)
|
||||
cmd_group_str = "指令组" if cmd_group else "指令"
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
f"已将「{cmd_name}」{cmd_group_str} 的权限级别调整为 {cmd_type}。"
|
||||
)
|
||||
f"已将「{cmd_name}」{cmd_group_str} 的权限级别调整为 {cmd_type}。",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,366 @@
|
||||
import datetime
|
||||
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import MessageSession
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
|
||||
from .utils.rst_scene import RstScene
|
||||
|
||||
THIRD_PARTY_AGENT_RUNNER_KEY = {
|
||||
"dify": "dify_conversation_id",
|
||||
"coze": "coze_conversation_id",
|
||||
"dashscope": "dashscope_conversation_id",
|
||||
}
|
||||
THIRD_PARTY_AGENT_RUNNER_STR = ", ".join(THIRD_PARTY_AGENT_RUNNER_KEY.keys())
|
||||
|
||||
|
||||
class ConversationCommands:
|
||||
def __init__(self, context: star.Context):
|
||||
self.context = context
|
||||
|
||||
async def _get_current_persona_id(self, session_id):
|
||||
curr = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
session_id,
|
||||
)
|
||||
if not curr:
|
||||
return None
|
||||
conv = await self.context.conversation_manager.get_conversation(
|
||||
session_id,
|
||||
curr,
|
||||
)
|
||||
if not conv:
|
||||
return None
|
||||
return conv.persona_id
|
||||
|
||||
async def reset(self, message: AstrMessageEvent):
|
||||
"""重置 LLM 会话"""
|
||||
umo = message.unified_msg_origin
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
is_unique_session = cfg["platform_settings"]["unique_session"]
|
||||
is_group = bool(message.get_group_id())
|
||||
|
||||
scene = RstScene.get_scene(is_group, is_unique_session)
|
||||
|
||||
alter_cmd_cfg = await sp.get_async("global", "global", "alter_cmd", {})
|
||||
plugin_config = alter_cmd_cfg.get("astrbot", {})
|
||||
reset_cfg = plugin_config.get("reset", {})
|
||||
|
||||
required_perm = reset_cfg.get(
|
||||
scene.key,
|
||||
"admin" if is_group and not is_unique_session else "member",
|
||||
)
|
||||
|
||||
if required_perm == "admin" and message.role != "admin":
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"在{scene.name}场景下,reset命令需要管理员权限,"
|
||||
f"您 (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
return
|
||||
|
||||
if not self.context.get_using_provider(umo):
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前未处于对话状态,请 /switch 切换或者 /new 创建。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
await self.context.conversation_manager.update_conversation(
|
||||
umo,
|
||||
cid,
|
||||
[],
|
||||
)
|
||||
|
||||
ret = "清除聊天历史成功!"
|
||||
|
||||
message.set_extra("_clean_ltm_session", True)
|
||||
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
async def his(self, message: AstrMessageEvent, page: int = 1):
|
||||
"""查看对话记录"""
|
||||
if not self.context.get_using_provider(message.unified_msg_origin):
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
size_per_page = 6
|
||||
|
||||
conv_mgr = self.context.conversation_manager
|
||||
umo = message.unified_msg_origin
|
||||
session_curr_cid = await conv_mgr.get_curr_conversation_id(umo)
|
||||
|
||||
if not session_curr_cid:
|
||||
session_curr_cid = await conv_mgr.new_conversation(
|
||||
umo,
|
||||
message.get_platform_id(),
|
||||
)
|
||||
|
||||
contexts, total_pages = await conv_mgr.get_human_readable_context(
|
||||
umo,
|
||||
session_curr_cid,
|
||||
page,
|
||||
size_per_page,
|
||||
)
|
||||
|
||||
parts = []
|
||||
for context in contexts:
|
||||
if len(context) > 150:
|
||||
context = context[:150] + "..."
|
||||
parts.append(f"{context}\n")
|
||||
|
||||
history = "".join(parts)
|
||||
ret = (
|
||||
f"当前对话历史记录:"
|
||||
f"{history or '无历史记录'}\n\n"
|
||||
f"第 {page} 页 | 共 {total_pages} 页\n"
|
||||
f"*输入 /history 2 跳转到第 2 页"
|
||||
)
|
||||
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
|
||||
async def convs(self, message: AstrMessageEvent, page: int = 1):
|
||||
"""查看对话列表"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
size_per_page = 6
|
||||
"""获取所有对话列表"""
|
||||
conversations_all = await self.context.conversation_manager.get_conversations(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
"""计算总页数"""
|
||||
total_pages = (len(conversations_all) + size_per_page - 1) // size_per_page
|
||||
"""确保页码有效"""
|
||||
page = max(1, min(page, total_pages))
|
||||
"""分页处理"""
|
||||
start_idx = (page - 1) * size_per_page
|
||||
end_idx = start_idx + size_per_page
|
||||
conversations_paged = conversations_all[start_idx:end_idx]
|
||||
|
||||
parts = ["对话列表:\n---\n"]
|
||||
"""全局序号从当前页的第一个开始"""
|
||||
global_index = start_idx + 1
|
||||
|
||||
"""生成所有对话的标题字典"""
|
||||
_titles = {}
|
||||
for conv in conversations_all:
|
||||
title = conv.title if conv.title else "新对话"
|
||||
_titles[conv.cid] = title
|
||||
|
||||
"""遍历分页后的对话生成列表显示"""
|
||||
for conv in conversations_paged:
|
||||
persona_id = conv.persona_id
|
||||
if not persona_id or persona_id == "[%None]":
|
||||
persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
persona_id = persona["name"]
|
||||
title = _titles.get(conv.cid, "新对话")
|
||||
parts.append(
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_id}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||
)
|
||||
global_index += 1
|
||||
|
||||
parts.append("---\n")
|
||||
ret = "".join(parts)
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
if curr_cid:
|
||||
"""从所有对话的标题字典中获取标题"""
|
||||
title = _titles.get(curr_cid, "新对话")
|
||||
ret += f"\n当前对话: {title}({curr_cid[:4]})"
|
||||
else:
|
||||
ret += "\n当前对话: 无"
|
||||
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
unique_session = cfg["platform_settings"]["unique_session"]
|
||||
if unique_session:
|
||||
ret += "\n会话隔离粒度: 个人"
|
||||
else:
|
||||
ret += "\n会话隔离粒度: 群聊"
|
||||
|
||||
ret += f"\n第 {page} 页 | 共 {total_pages} 页"
|
||||
ret += "\n*输入 /ls 2 跳转到第 2 页"
|
||||
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
return
|
||||
|
||||
async def new_conv(self, message: AstrMessageEvent):
|
||||
"""创建新对话"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("已创建新对话。"))
|
||||
return
|
||||
|
||||
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
|
||||
cid = await self.context.conversation_manager.new_conversation(
|
||||
message.unified_msg_origin,
|
||||
message.get_platform_id(),
|
||||
persona_id=cpersona,
|
||||
)
|
||||
|
||||
message.set_extra("_clean_ltm_session", True)
|
||||
|
||||
message.set_result(
|
||||
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
|
||||
)
|
||||
|
||||
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = ""):
|
||||
"""创建新群聊对话"""
|
||||
if sid:
|
||||
session = str(
|
||||
MessageSession(
|
||||
platform_name=message.platform_meta.id,
|
||||
message_type=MessageType("GroupMessage"),
|
||||
session_id=sid,
|
||||
),
|
||||
)
|
||||
|
||||
cpersona = await self._get_current_persona_id(session)
|
||||
cid = await self.context.conversation_manager.new_conversation(
|
||||
session,
|
||||
message.get_platform_id(),
|
||||
persona_id=cpersona,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。",
|
||||
),
|
||||
)
|
||||
else:
|
||||
message.set_result(
|
||||
MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。"),
|
||||
)
|
||||
|
||||
async def switch_conv(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
index: int | None = None,
|
||||
):
|
||||
"""通过 /ls 前面的序号切换对话"""
|
||||
if not isinstance(index, int):
|
||||
message.set_result(
|
||||
MessageEventResult().message("类型错误,请输入数字对话序号。"),
|
||||
)
|
||||
return
|
||||
|
||||
if index is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话",
|
||||
),
|
||||
)
|
||||
return
|
||||
conversations = await self.context.conversation_manager.get_conversations(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
if index > len(conversations) or index < 1:
|
||||
message.set_result(
|
||||
MessageEventResult().message("对话序号错误,请使用 /ls 查看"),
|
||||
)
|
||||
else:
|
||||
conversation = conversations[index - 1]
|
||||
title = conversation.title if conversation.title else "新对话"
|
||||
await self.context.conversation_manager.switch_conversation(
|
||||
message.unified_msg_origin,
|
||||
conversation.cid,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"切换到对话: {title}({conversation.cid[:4]})。",
|
||||
),
|
||||
)
|
||||
|
||||
async def rename_conv(self, message: AstrMessageEvent, new_name: str = ""):
|
||||
"""重命名对话"""
|
||||
if not new_name:
|
||||
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
|
||||
return
|
||||
await self.context.conversation_manager.update_conversation_title(
|
||||
message.unified_msg_origin,
|
||||
new_name,
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重命名对话成功。"))
|
||||
|
||||
async def del_conv(self, message: AstrMessageEvent):
|
||||
"""删除当前对话"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
is_unique_session = cfg["platform_settings"]["unique_session"]
|
||||
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
||||
# 群聊,没开独立会话,发送人不是管理员
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
return
|
||||
|
||||
session_curr_cid = (
|
||||
await self.context.conversation_manager.get_curr_conversation_id(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前未处于对话状态,请 /switch 序号 切换或 /new 创建。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
await self.context.conversation_manager.delete_conversation(
|
||||
message.unified_msg_origin,
|
||||
session_curr_cid,
|
||||
)
|
||||
|
||||
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
|
||||
message.set_extra("_clean_ltm_session", True)
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
@@ -0,0 +1,88 @@
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.star import command_management
|
||||
from astrbot.core.utils.io import get_dashboard_version
|
||||
|
||||
|
||||
class HelpCommand:
|
||||
def __init__(self, context: star.Context):
|
||||
self.context = context
|
||||
|
||||
async def _query_astrbot_notice(self):
|
||||
try:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
"https://astrbot.app/notice.json",
|
||||
timeout=2,
|
||||
) as resp:
|
||||
return (await resp.json())["notice"]
|
||||
except BaseException:
|
||||
return ""
|
||||
|
||||
async def _build_reserved_command_lines(self) -> list[str]:
|
||||
"""
|
||||
使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。
|
||||
"""
|
||||
try:
|
||||
commands = await command_management.list_commands()
|
||||
except BaseException:
|
||||
return []
|
||||
|
||||
lines: list[str] = []
|
||||
hidden_commands = {"set", "unset", "websearch"}
|
||||
|
||||
def walk(items: list[dict], indent: int = 0):
|
||||
for item in items:
|
||||
if not item.get("reserved") or not item.get("enabled"):
|
||||
continue
|
||||
# 仅展示顶级指令或指令组
|
||||
if item.get("type") == "sub_command":
|
||||
continue
|
||||
if item.get("parent_signature"):
|
||||
continue
|
||||
|
||||
effective = (
|
||||
item.get("effective_command")
|
||||
or item.get("original_command")
|
||||
or item.get("handler_name")
|
||||
)
|
||||
if not effective:
|
||||
continue
|
||||
if effective in hidden_commands:
|
||||
continue
|
||||
|
||||
description = item.get("description") or ""
|
||||
desc_text = f" - {description}" if description else ""
|
||||
indent_prefix = " " * indent
|
||||
lines.append(f"{indent_prefix}/{effective}{desc_text}")
|
||||
|
||||
walk(commands)
|
||||
return lines
|
||||
|
||||
async def help(self, event: AstrMessageEvent):
|
||||
"""查看帮助"""
|
||||
notice = ""
|
||||
try:
|
||||
notice = await self._query_astrbot_notice()
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
dashboard_version = await get_dashboard_version()
|
||||
command_lines = await self._build_reserved_command_lines()
|
||||
commands_section = (
|
||||
"\n".join(command_lines) if command_lines else "暂无启用的内置指令"
|
||||
)
|
||||
|
||||
msg_parts = [
|
||||
f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
|
||||
"内置指令:",
|
||||
commands_section,
|
||||
]
|
||||
if notice:
|
||||
msg_parts.append(notice)
|
||||
msg = "\n".join(msg_parts)
|
||||
|
||||
event.set_result(MessageEventResult().message(msg).use_t2i(False))
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
|
||||
|
||||
+38
-18
@@ -1,5 +1,6 @@
|
||||
import builtins
|
||||
import astrbot.api.star as star
|
||||
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
|
||||
@@ -14,8 +15,15 @@ class PersonaCommands:
|
||||
curr_persona_name = "无"
|
||||
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
default_persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=umo
|
||||
umo=umo,
|
||||
)
|
||||
|
||||
force_applied_persona_id = (
|
||||
await sp.get_async(
|
||||
scope="umo", scope_id=umo, key="session_service_config", default={}
|
||||
)
|
||||
).get("persona_id")
|
||||
|
||||
curr_cid_title = "无"
|
||||
if cid:
|
||||
conv = await self.context.conversation_manager.get_conversation(
|
||||
@@ -26,8 +34,8 @@ class PersonaCommands:
|
||||
if conv is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前对话不存在,请先使用 /new 新建一个对话。"
|
||||
)
|
||||
"当前对话不存在,请先使用 /new 新建一个对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
if not conv.persona_id and conv.persona_id != "[%None]":
|
||||
@@ -35,6 +43,9 @@ class PersonaCommands:
|
||||
else:
|
||||
curr_persona_name = conv.persona_id
|
||||
|
||||
if force_applied_persona_id:
|
||||
curr_persona_name = f"{curr_persona_name} (自定义规则)"
|
||||
|
||||
curr_cid_title = conv.title if conv.title else "新对话"
|
||||
curr_cid_title += f"({cid[:4]})"
|
||||
|
||||
@@ -53,15 +64,16 @@ class PersonaCommands:
|
||||
当前对话 {curr_cid_title} 的人格情景: {curr_persona_name}
|
||||
|
||||
配置人格情景请前往管理面板-配置页
|
||||
"""
|
||||
""",
|
||||
)
|
||||
.use_t2i(False)
|
||||
.use_t2i(False),
|
||||
)
|
||||
elif l[1] == "list":
|
||||
msg = "人格列表:\n"
|
||||
parts = ["人格列表:\n"]
|
||||
for persona in self.context.provider_manager.personas:
|
||||
msg += f"- {persona['name']}\n"
|
||||
msg += "\n\n*输入 `/persona view 人格名` 查看人格详细信息"
|
||||
parts.append(f"- {persona['name']}\n")
|
||||
parts.append("\n\n*输入 `/persona view 人格名` 查看人格详细信息")
|
||||
msg = "".join(parts)
|
||||
message.set_result(MessageEventResult().message(msg))
|
||||
elif l[1] == "view":
|
||||
if len(l) == 2:
|
||||
@@ -83,11 +95,12 @@ class PersonaCommands:
|
||||
elif l[1] == "unset":
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message("当前没有对话,无法取消人格。")
|
||||
MessageEventResult().message("当前没有对话,无法取消人格。"),
|
||||
)
|
||||
return
|
||||
await self.context.conversation_manager.update_conversation_persona_id(
|
||||
message.unified_msg_origin, "[%None]"
|
||||
message.unified_msg_origin,
|
||||
"[%None]",
|
||||
)
|
||||
message.set_result(MessageEventResult().message("取消人格成功。"))
|
||||
else:
|
||||
@@ -95,8 +108,8 @@ class PersonaCommands:
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前没有对话,请先开始对话或使用 /new 创建一个对话。"
|
||||
)
|
||||
"当前没有对话,请先开始对话或使用 /new 创建一个对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
if persona := next(
|
||||
@@ -107,16 +120,23 @@ class PersonaCommands:
|
||||
None,
|
||||
):
|
||||
await self.context.conversation_manager.update_conversation_persona_id(
|
||||
message.unified_msg_origin, ps
|
||||
message.unified_msg_origin,
|
||||
ps,
|
||||
)
|
||||
force_warn_msg = ""
|
||||
if force_applied_persona_id:
|
||||
force_warn_msg = (
|
||||
"提醒:由于自定义规则,您现在切换的人格将不会生效。"
|
||||
)
|
||||
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"设置成功。如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格。"
|
||||
)
|
||||
f"设置成功。如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格。{force_warn_msg}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"不存在该人格情景。使用 /persona list 查看所有。"
|
||||
)
|
||||
"不存在该人格情景。使用 /persona list 查看所有。",
|
||||
),
|
||||
)
|
||||
+23
-20
@@ -1,10 +1,10 @@
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, StarHandlerMetadata
|
||||
from astrbot.core import DEMO_MODE, logger
|
||||
from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
|
||||
from astrbot.core.star.star_manager import PluginManager
|
||||
from astrbot.core import DEMO_MODE, logger
|
||||
|
||||
|
||||
class PluginCommands:
|
||||
@@ -13,18 +13,21 @@ class PluginCommands:
|
||||
|
||||
async def plugin_ls(self, event: AstrMessageEvent):
|
||||
"""获取已经安装的插件列表。"""
|
||||
plugin_list_info = "已加载的插件:\n"
|
||||
parts = ["已加载的插件:\n"]
|
||||
for plugin in self.context.get_all_stars():
|
||||
plugin_list_info += f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
|
||||
line = f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
|
||||
if not plugin.activated:
|
||||
plugin_list_info += " (未启用)"
|
||||
plugin_list_info += "\n"
|
||||
if plugin_list_info.strip() == "":
|
||||
line += " (未启用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
if len(parts) == 1:
|
||||
plugin_list_info = "没有加载任何插件。"
|
||||
else:
|
||||
plugin_list_info = "".join(parts)
|
||||
|
||||
plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。"
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"{plugin_list_info}").use_t2i(False)
|
||||
MessageEventResult().message(f"{plugin_list_info}").use_t2i(False),
|
||||
)
|
||||
|
||||
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = ""):
|
||||
@@ -34,7 +37,7 @@ class PluginCommands:
|
||||
return
|
||||
if not plugin_name:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin off <插件名> 禁用插件。")
|
||||
MessageEventResult().message("/plugin off <插件名> 禁用插件。"),
|
||||
)
|
||||
return
|
||||
await self.context._star_manager.turn_off_plugin(plugin_name) # type: ignore
|
||||
@@ -47,7 +50,7 @@ class PluginCommands:
|
||||
return
|
||||
if not plugin_name:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin on <插件名> 启用插件。")
|
||||
MessageEventResult().message("/plugin on <插件名> 启用插件。"),
|
||||
)
|
||||
return
|
||||
await self.context._star_manager.turn_on_plugin(plugin_name) # type: ignore
|
||||
@@ -60,7 +63,7 @@ class PluginCommands:
|
||||
return
|
||||
if not plugin_repo:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin get <插件仓库地址> 安装插件")
|
||||
MessageEventResult().message("/plugin get <插件仓库地址> 安装插件"),
|
||||
)
|
||||
return
|
||||
logger.info(f"准备从 {plugin_repo} 安装插件。")
|
||||
@@ -78,7 +81,7 @@ class PluginCommands:
|
||||
"""获取插件帮助"""
|
||||
if not plugin_name:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin help <插件名> 查看插件信息。")
|
||||
MessageEventResult().message("/plugin help <插件名> 查看插件信息。"),
|
||||
)
|
||||
return
|
||||
plugin = self.context.get_registered_star(plugin_name)
|
||||
@@ -98,19 +101,19 @@ class PluginCommands:
|
||||
command_handlers.append(handler)
|
||||
command_names.append(filter_.command_name)
|
||||
break
|
||||
elif isinstance(filter_, CommandGroupFilter):
|
||||
if isinstance(filter_, CommandGroupFilter):
|
||||
command_handlers.append(handler)
|
||||
command_names.append(filter_.group_name)
|
||||
|
||||
if len(command_handlers) > 0:
|
||||
help_msg += "\n\n🔧 指令列表:\n"
|
||||
parts = ["\n\n🔧 指令列表:\n"]
|
||||
for i in range(len(command_handlers)):
|
||||
help_msg += f"- {command_names[i]}"
|
||||
line = f"- {command_names[i]}"
|
||||
if command_handlers[i].desc:
|
||||
help_msg += f": {command_handlers[i].desc}"
|
||||
help_msg += "\n"
|
||||
|
||||
help_msg += "\nTip: 指令的触发需要添加唤醒前缀,默认为 /。"
|
||||
line += f": {command_handlers[i].desc}"
|
||||
parts.append(line + "\n")
|
||||
parts.append("\nTip: 指令的触发需要添加唤醒前缀,默认为 /。")
|
||||
help_msg += "".join(parts)
|
||||
|
||||
ret = f"🧩 插件 {plugin_name} 帮助信息:\n" + help_msg
|
||||
ret += "更多帮助信息请查看插件仓库 README。"
|
||||
@@ -0,0 +1,329 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
|
||||
|
||||
class ProviderCommands:
|
||||
def __init__(self, context: star.Context):
|
||||
self.context = context
|
||||
|
||||
def _log_reachability_failure(
|
||||
self,
|
||||
provider,
|
||||
provider_capability_type: ProviderType | None,
|
||||
err_code: str,
|
||||
err_reason: str,
|
||||
):
|
||||
"""记录不可达原因到日志。"""
|
||||
meta = provider.meta()
|
||||
logger.warning(
|
||||
"Provider reachability check failed: id=%s type=%s code=%s reason=%s",
|
||||
meta.id,
|
||||
provider_capability_type.name if provider_capability_type else "unknown",
|
||||
err_code,
|
||||
err_reason,
|
||||
)
|
||||
|
||||
async def _test_provider_capability(self, provider):
|
||||
"""测试单个 provider 的可用性"""
|
||||
meta = provider.meta()
|
||||
provider_capability_type = meta.provider_type
|
||||
|
||||
try:
|
||||
await provider.test()
|
||||
return True, None, None
|
||||
except Exception as e:
|
||||
err_code = "TEST_FAILED"
|
||||
err_reason = str(e)
|
||||
self._log_reachability_failure(
|
||||
provider, provider_capability_type, err_code, err_reason
|
||||
)
|
||||
return False, err_code, err_reason
|
||||
|
||||
async def provider(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
idx: str | int | None = None,
|
||||
idx2: int | None = None,
|
||||
):
|
||||
"""查看或者切换 LLM Provider"""
|
||||
umo = event.unified_msg_origin
|
||||
cfg = self.context.get_config(umo).get("provider_settings", {})
|
||||
reachability_check_enabled = cfg.get("reachability_check", True)
|
||||
|
||||
if idx is None:
|
||||
parts = ["## 载入的 LLM 提供商\n"]
|
||||
|
||||
# 获取所有类型的提供商
|
||||
llms = list(self.context.get_all_providers())
|
||||
ttss = self.context.get_all_tts_providers()
|
||||
stts = self.context.get_all_stt_providers()
|
||||
|
||||
# 构造待检测列表: [(provider, type_label), ...]
|
||||
all_providers = []
|
||||
all_providers.extend([(p, "llm") for p in llms])
|
||||
all_providers.extend([(p, "tts") for p in ttss])
|
||||
all_providers.extend([(p, "stt") for p in stts])
|
||||
|
||||
# 并发测试连通性
|
||||
if reachability_check_enabled:
|
||||
if all_providers:
|
||||
await event.send(
|
||||
MessageEventResult().message(
|
||||
"正在进行提供商可达性测试,请稍候..."
|
||||
)
|
||||
)
|
||||
check_results = await asyncio.gather(
|
||||
*[self._test_provider_capability(p) for p, _ in all_providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
else:
|
||||
# 用 None 表示未检测
|
||||
check_results = [None for _ in all_providers]
|
||||
|
||||
# 整合结果
|
||||
display_data = []
|
||||
for (p, p_type), reachable in zip(all_providers, check_results):
|
||||
meta = p.meta()
|
||||
id_ = meta.id
|
||||
error_code = None
|
||||
|
||||
if isinstance(reachable, Exception):
|
||||
# 异常情况下兜底处理,避免单个 provider 导致列表失败
|
||||
self._log_reachability_failure(
|
||||
p,
|
||||
None,
|
||||
reachable.__class__.__name__,
|
||||
str(reachable),
|
||||
)
|
||||
reachable_flag = False
|
||||
error_code = reachable.__class__.__name__
|
||||
elif isinstance(reachable, tuple):
|
||||
reachable_flag, error_code, _ = reachable
|
||||
else:
|
||||
reachable_flag = reachable
|
||||
|
||||
# 根据类型构建显示名称
|
||||
if p_type == "llm":
|
||||
info = f"{id_} ({meta.model})"
|
||||
else:
|
||||
info = f"{id_}"
|
||||
|
||||
# 确定状态标记
|
||||
if reachable_flag is True:
|
||||
mark = " ✅"
|
||||
elif reachable_flag is False:
|
||||
if error_code:
|
||||
mark = f" ❌(错误码: {error_code})"
|
||||
else:
|
||||
mark = " ❌"
|
||||
else:
|
||||
mark = "" # 不支持检测时不显示标记
|
||||
|
||||
display_data.append(
|
||||
{
|
||||
"type": p_type,
|
||||
"info": info,
|
||||
"mark": mark,
|
||||
"provider": p,
|
||||
}
|
||||
)
|
||||
|
||||
# 分组输出
|
||||
# 1. LLM
|
||||
llm_data = [d for d in display_data if d["type"] == "llm"]
|
||||
for i, d in enumerate(llm_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
provider_using = self.context.get_using_provider(umo=umo)
|
||||
if (
|
||||
provider_using
|
||||
and provider_using.meta().id == d["provider"].meta().id
|
||||
):
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
# 2. TTS
|
||||
tts_data = [d for d in display_data if d["type"] == "tts"]
|
||||
if tts_data:
|
||||
parts.append("\n## 载入的 TTS 提供商\n")
|
||||
for i, d in enumerate(tts_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
tts_using = self.context.get_using_tts_provider(umo=umo)
|
||||
if tts_using and tts_using.meta().id == d["provider"].meta().id:
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
# 3. STT
|
||||
stt_data = [d for d in display_data if d["type"] == "stt"]
|
||||
if stt_data:
|
||||
parts.append("\n## 载入的 STT 提供商\n")
|
||||
for i, d in enumerate(stt_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
stt_using = self.context.get_using_stt_provider(umo=umo)
|
||||
if stt_using and stt_using.meta().id == d["provider"].meta().id:
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
parts.append("\n使用 /provider <序号> 切换 LLM 提供商。")
|
||||
ret = "".join(parts)
|
||||
|
||||
if ttss:
|
||||
ret += "\n使用 /provider tts <序号> 切换 TTS 提供商。"
|
||||
if stts:
|
||||
ret += "\n使用 /provider stt <序号> 切换 STT 提供商。"
|
||||
if not reachability_check_enabled:
|
||||
ret += "\n已跳过提供商可达性检测,如需检测请在配置文件中开启。"
|
||||
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
elif idx == "tts":
|
||||
if idx2 is None:
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
|
||||
event.set_result(MessageEventResult().message("无效的提供商序号。"))
|
||||
return
|
||||
provider = self.context.get_all_tts_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.TEXT_TO_SPEECH,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
elif idx == "stt":
|
||||
if idx2 is None:
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
|
||||
event.set_result(MessageEventResult().message("无效的提供商序号。"))
|
||||
return
|
||||
provider = self.context.get_all_stt_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.SPEECH_TO_TEXT,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
elif isinstance(idx, int):
|
||||
if idx > len(self.context.get_all_providers()) or idx < 1:
|
||||
event.set_result(MessageEventResult().message("无效的提供商序号。"))
|
||||
return
|
||||
provider = self.context.get_all_providers()[idx - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
else:
|
||||
event.set_result(MessageEventResult().message("无效的参数。"))
|
||||
|
||||
async def model_ls(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
idx_or_name: int | str | None = None,
|
||||
):
|
||||
"""查看或者切换模型"""
|
||||
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||
if not prov:
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
# 定义正则表达式匹配 API 密钥
|
||||
api_key_pattern = re.compile(r"key=[^&'\" ]+")
|
||||
|
||||
if idx_or_name is None:
|
||||
models = []
|
||||
try:
|
||||
models = await prov.get_models()
|
||||
except BaseException as e:
|
||||
err_msg = api_key_pattern.sub("key=***", str(e))
|
||||
message.set_result(
|
||||
MessageEventResult()
|
||||
.message("获取模型列表失败: " + err_msg)
|
||||
.use_t2i(False),
|
||||
)
|
||||
return
|
||||
parts = ["下面列出了此模型提供商可用模型:"]
|
||||
for i, model in enumerate(models, 1):
|
||||
parts.append(f"\n{i}. {model}")
|
||||
|
||||
curr_model = prov.get_model() or "无"
|
||||
parts.append(f"\n当前模型: [{curr_model}]")
|
||||
parts.append(
|
||||
"\nTips: 使用 /model <模型名/编号>,即可实时更换模型。如目标模型不存在于上表,请输入模型名。"
|
||||
)
|
||||
|
||||
ret = "".join(parts)
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
elif isinstance(idx_or_name, int):
|
||||
models = []
|
||||
try:
|
||||
models = await prov.get_models()
|
||||
except BaseException as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message("获取模型列表失败: " + str(e)),
|
||||
)
|
||||
return
|
||||
if idx_or_name > len(models) or idx_or_name < 1:
|
||||
message.set_result(MessageEventResult().message("模型序号错误。"))
|
||||
else:
|
||||
try:
|
||||
new_model = models[idx_or_name - 1]
|
||||
prov.set_model(new_model)
|
||||
except BaseException as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message("切换模型未知错误: " + str(e)),
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]",
|
||||
),
|
||||
)
|
||||
else:
|
||||
prov.set_model(idx_or_name)
|
||||
message.set_result(
|
||||
MessageEventResult().message(f"切换模型到 {prov.get_model()}。"),
|
||||
)
|
||||
|
||||
async def key(self, message: AstrMessageEvent, index: int | None = None):
|
||||
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||
if not prov:
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
if index is None:
|
||||
keys_data = prov.get_keys()
|
||||
curr_key = prov.get_current_key()
|
||||
parts = ["Key:"]
|
||||
for i, k in enumerate(keys_data, 1):
|
||||
parts.append(f"\n{i}. {k[:8]}")
|
||||
|
||||
parts.append(f"\n当前 Key: {curr_key[:8]}")
|
||||
parts.append("\n当前模型: " + prov.get_model())
|
||||
parts.append("\n使用 /key <idx> 切换 Key。")
|
||||
|
||||
ret = "".join(parts)
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
else:
|
||||
keys_data = prov.get_keys()
|
||||
if index > len(keys_data) or index < 1:
|
||||
message.set_result(MessageEventResult().message("Key 序号错误。"))
|
||||
else:
|
||||
try:
|
||||
new_key = keys_data[index - 1]
|
||||
prov.set_key(new_key)
|
||||
except BaseException as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(f"切换 Key 未知错误: {e!s}"),
|
||||
)
|
||||
message.set_result(MessageEventResult().message("切换 Key 成功。"))
|
||||
+5
-6
@@ -1,6 +1,5 @@
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.api import sp
|
||||
|
||||
|
||||
class SetUnsetCommands:
|
||||
@@ -16,8 +15,8 @@ class SetUnsetCommands:
|
||||
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"会话 {uid} 变量 {key} 存储成功。使用 /unset 移除。"
|
||||
)
|
||||
f"会话 {uid} 变量 {key} 存储成功。使用 /unset 移除。",
|
||||
),
|
||||
)
|
||||
|
||||
async def unset_variable(self, event: AstrMessageEvent, key: str):
|
||||
@@ -27,11 +26,11 @@ class SetUnsetCommands:
|
||||
|
||||
if key not in session_var:
|
||||
event.set_result(
|
||||
MessageEventResult().message("没有那个变量名。格式 /unset 变量名。")
|
||||
MessageEventResult().message("没有那个变量名。格式 /unset 变量名。"),
|
||||
)
|
||||
else:
|
||||
del session_var[key]
|
||||
await sp.session_put(uid, "session_variables", session_var)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功。")
|
||||
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功。"),
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""会话ID命令"""
|
||||
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
|
||||
class SIDCommand:
|
||||
"""会话ID命令类"""
|
||||
|
||||
def __init__(self, context: star.Context):
|
||||
self.context = context
|
||||
|
||||
async def sid(self, event: AstrMessageEvent):
|
||||
"""获取消息来源信息"""
|
||||
sid = event.unified_msg_origin
|
||||
user_id = str(event.get_sender_id())
|
||||
umo_platform = event.session.platform_id
|
||||
umo_msg_type = event.session.message_type.value
|
||||
umo_session_id = event.session.session_id
|
||||
ret = (
|
||||
f"UMO: 「{sid}」 此值可用于设置白名单。\n"
|
||||
f"UID: 「{user_id}」 此值可用于设置管理员。\n"
|
||||
f"消息会话来源信息:\n"
|
||||
f" 机器人 ID: 「{umo_platform}」\n"
|
||||
f" 消息类型: 「{umo_msg_type}」\n"
|
||||
f" 会话 ID: 「{umo_session_id}」\n"
|
||||
f"消息来源可用于配置机器人的配置文件路由。"
|
||||
)
|
||||
|
||||
if (
|
||||
self.context.get_config()["platform_settings"]["unique_session"]
|
||||
and event.get_group_id()
|
||||
):
|
||||
ret += f"\n\n当前处于独立会话模式, 此群 ID: 「{event.get_group_id()}」, 也可将此 ID 加入白名单来放行整个群聊。"
|
||||
|
||||
event.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"""文本转图片命令"""
|
||||
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
|
||||
@@ -9,23 +9,23 @@ class ToolCommands:
|
||||
async def tool_ls(self, event: AstrMessageEvent):
|
||||
"""查看函数工具列表"""
|
||||
event.set_result(
|
||||
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。")
|
||||
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
|
||||
)
|
||||
|
||||
async def tool_on(self, event: AstrMessageEvent, tool_name: str = ""):
|
||||
"""启用一个函数工具"""
|
||||
event.set_result(
|
||||
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。")
|
||||
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
|
||||
)
|
||||
|
||||
async def tool_off(self, event: AstrMessageEvent, tool_name: str = ""):
|
||||
"""停用一个函数工具"""
|
||||
event.set_result(
|
||||
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。")
|
||||
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
|
||||
)
|
||||
|
||||
async def tool_all_off(self, event: AstrMessageEvent):
|
||||
"""停用所有函数工具"""
|
||||
event.set_result(
|
||||
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。")
|
||||
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
|
||||
)
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
"""文本转语音命令"""
|
||||
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.star.session_llm_manager import SessionServiceManager
|
||||
|
||||
@@ -14,23 +14,23 @@ class TTSCommand:
|
||||
async def tts(self, event: AstrMessageEvent):
|
||||
"""开关文本转语音(会话级别)"""
|
||||
umo = event.unified_msg_origin
|
||||
ses_tts = SessionServiceManager.is_tts_enabled_for_session(umo)
|
||||
ses_tts = await SessionServiceManager.is_tts_enabled_for_session(umo)
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
tts_enable = cfg["provider_tts_settings"]["enable"]
|
||||
|
||||
# 切换状态
|
||||
new_status = not ses_tts
|
||||
SessionServiceManager.set_tts_status_for_session(umo, new_status)
|
||||
await SessionServiceManager.set_tts_status_for_session(umo, new_status)
|
||||
|
||||
status_text = "已开启" if new_status else "已关闭"
|
||||
|
||||
if new_status and not tts_enable:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"{status_text}当前会话的文本转语音。但 TTS 功能在配置中未启用,请前往 WebUI 开启。"
|
||||
)
|
||||
f"{status_text}当前会话的文本转语音。但 TTS 功能在配置中未启用,请前往 WebUI 开启。",
|
||||
),
|
||||
)
|
||||
else:
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"{status_text}当前会话的文本转语音。")
|
||||
MessageEventResult().message(f"{status_text}当前会话的文本转语音。"),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RstScene(Enum):
|
||||
GROUP_UNIQUE_ON = ("group_unique_on", "群聊+会话隔离开启")
|
||||
GROUP_UNIQUE_OFF = ("group_unique_off", "群聊+会话隔离关闭")
|
||||
PRIVATE = ("private", "私聊")
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return self.value[0]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.value[1]
|
||||
|
||||
@classmethod
|
||||
def from_index(cls, index: int) -> "RstScene":
|
||||
mapping = {1: cls.GROUP_UNIQUE_ON, 2: cls.GROUP_UNIQUE_OFF, 3: cls.PRIVATE}
|
||||
return mapping[index]
|
||||
|
||||
@classmethod
|
||||
def get_scene(cls, is_group: bool, is_unique_session: bool) -> "RstScene":
|
||||
if is_group:
|
||||
return cls.GROUP_UNIQUE_ON if is_unique_session else cls.GROUP_UNIQUE_OFF
|
||||
return cls.PRIVATE
|
||||
@@ -1,47 +1,33 @@
|
||||
import traceback
|
||||
import astrbot.api.star as star
|
||||
import astrbot.api.event.filter as filter
|
||||
from astrbot.api.event import AstrMessageEvent
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.provider.sources.dify_source import ProviderDify
|
||||
from .long_term_memory import LongTermMemory
|
||||
from astrbot.core import logger
|
||||
from astrbot.api.message_components import Plain, Image
|
||||
from typing import Union
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
|
||||
from .commands import (
|
||||
AdminCommands,
|
||||
AlterCmdCommands,
|
||||
ConversationCommands,
|
||||
HelpCommand,
|
||||
LLMCommands,
|
||||
ToolCommands,
|
||||
PluginCommands,
|
||||
AdminCommands,
|
||||
ConversationCommands,
|
||||
ProviderCommands,
|
||||
PersonaCommands,
|
||||
AlterCmdCommands,
|
||||
PluginCommands,
|
||||
ProviderCommands,
|
||||
SetUnsetCommands,
|
||||
T2ICommand,
|
||||
TTSCommand,
|
||||
SIDCommand,
|
||||
T2ICommand,
|
||||
ToolCommands,
|
||||
TTSCommand,
|
||||
)
|
||||
from .process_llm_request import ProcessLLMRequest
|
||||
|
||||
|
||||
class Main(star.Star):
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
self.ltm = None
|
||||
try:
|
||||
self.ltm = LongTermMemory(self.context.astrbot_config_mgr, self.context)
|
||||
except BaseException as e:
|
||||
logger.error(f"聊天增强 err: {e}")
|
||||
|
||||
self.help_c = HelpCommand(self.context)
|
||||
self.llm_c = LLMCommands(self.context)
|
||||
self.tool_c = ToolCommands(self.context)
|
||||
self.plugin_c = PluginCommands(self.context)
|
||||
self.admin_c = AdminCommands(self.context)
|
||||
self.conversation_c = ConversationCommands(self.context, self.ltm)
|
||||
self.conversation_c = ConversationCommands(self.context)
|
||||
self.provider_c = ProviderCommands(self.context)
|
||||
self.persona_c = PersonaCommands(self.context)
|
||||
self.alter_cmd_c = AlterCmdCommands(self.context)
|
||||
@@ -49,13 +35,6 @@ class Main(star.Star):
|
||||
self.t2i_c = T2ICommand(self.context)
|
||||
self.tts_c = TTSCommand(self.context)
|
||||
self.sid_c = SIDCommand(self.context)
|
||||
self.proc_llm_req = ProcessLLMRequest(self.context)
|
||||
|
||||
def ltm_enabled(self, event: AstrMessageEvent):
|
||||
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
|
||||
"provider_ltm_settings"
|
||||
]
|
||||
return ltmse["group_icl_enable"] or ltmse["active_reply"]["enable"]
|
||||
|
||||
@filter.command("help")
|
||||
async def help(self, event: AstrMessageEvent):
|
||||
@@ -70,7 +49,7 @@ class Main(star.Star):
|
||||
|
||||
@filter.command_group("tool")
|
||||
def tool(self):
|
||||
pass
|
||||
"""函数工具管理"""
|
||||
|
||||
@tool.command("ls")
|
||||
async def tool_ls(self, event: AstrMessageEvent):
|
||||
@@ -94,7 +73,7 @@ class Main(star.Star):
|
||||
|
||||
@filter.command_group("plugin")
|
||||
def plugin(self):
|
||||
pass
|
||||
"""插件管理"""
|
||||
|
||||
@plugin.command("ls")
|
||||
async def plugin_ls(self, event: AstrMessageEvent):
|
||||
@@ -182,7 +161,9 @@ class Main(star.Star):
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("model")
|
||||
async def model_ls(
|
||||
self, message: AstrMessageEvent, idx_or_name: Union[int, str, None] = None
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
idx_or_name: int | str | None = None,
|
||||
):
|
||||
"""查看或者切换模型"""
|
||||
await self.provider_c.model_ls(message, idx_or_name)
|
||||
@@ -199,9 +180,7 @@ class Main(star.Star):
|
||||
|
||||
@filter.command("new")
|
||||
async def new_conv(self, message: AstrMessageEvent):
|
||||
"""
|
||||
创建新对话
|
||||
"""
|
||||
"""创建新对话"""
|
||||
await self.conversation_c.new_conv(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@@ -240,6 +219,7 @@ class Main(star.Star):
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("dashboard_update")
|
||||
async def update_dashboard(self, event: AstrMessageEvent):
|
||||
"""更新管理面板"""
|
||||
await self.admin_c.update_dashboard(event)
|
||||
|
||||
@filter.command("set")
|
||||
@@ -250,99 +230,6 @@ class Main(star.Star):
|
||||
async def unset_variable(self, event: AstrMessageEvent, key: str):
|
||||
await self.setunset_c.unset_variable(event, key)
|
||||
|
||||
@filter.platform_adapter_type(filter.PlatformAdapterType.ALL)
|
||||
async def on_message(self, event: AstrMessageEvent):
|
||||
"""群聊记忆增强"""
|
||||
|
||||
has_image_or_plain = False
|
||||
for comp in event.message_obj.message:
|
||||
if isinstance(comp, Plain) or isinstance(comp, Image):
|
||||
has_image_or_plain = True
|
||||
break
|
||||
|
||||
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
|
||||
need_active = await self.ltm.need_active_reply(event)
|
||||
|
||||
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
|
||||
"group_icl_enable"
|
||||
]
|
||||
if group_icl_enable:
|
||||
"""记录对话"""
|
||||
try:
|
||||
await self.ltm.handle_message(event)
|
||||
except BaseException as e:
|
||||
logger.error(e)
|
||||
|
||||
if need_active:
|
||||
"""主动回复"""
|
||||
provider = self.context.get_using_provider(event.unified_msg_origin)
|
||||
if not provider:
|
||||
logger.error("未找到任何 LLM 提供商。请先配置。无法主动回复")
|
||||
return
|
||||
try:
|
||||
conv = None
|
||||
if provider.meta().type != "dify":
|
||||
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
logger.error(
|
||||
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。"
|
||||
)
|
||||
return
|
||||
|
||||
conv = await self.context.conversation_manager.get_conversation(
|
||||
event.unified_msg_origin, session_curr_cid
|
||||
)
|
||||
else:
|
||||
# Dify 自己有维护对话,不需要 bot 端维护。
|
||||
assert isinstance(provider, ProviderDify)
|
||||
cid = provider.conversation_ids.get(
|
||||
event.unified_msg_origin, None
|
||||
)
|
||||
if cid is None:
|
||||
logger.error(
|
||||
"[Dify] 当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。"
|
||||
)
|
||||
return
|
||||
|
||||
prompt = event.message_str
|
||||
|
||||
if not conv:
|
||||
logger.error("未找到对话,无法主动回复")
|
||||
return
|
||||
|
||||
yield event.request_llm(
|
||||
prompt=prompt,
|
||||
func_tool_manager=self.context.get_llm_tool_manager(),
|
||||
session_id=event.session_id,
|
||||
conversation=conv,
|
||||
)
|
||||
except BaseException as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"主动回复失败: {e}")
|
||||
|
||||
@filter.on_llm_request()
|
||||
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
|
||||
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
|
||||
await self.proc_llm_req.process_llm_request(event, req)
|
||||
|
||||
if self.ltm and self.ltm_enabled(event):
|
||||
try:
|
||||
await self.ltm.on_req_llm(event, req)
|
||||
except BaseException as e:
|
||||
logger.error(f"ltm: {e}")
|
||||
|
||||
@filter.after_message_sent()
|
||||
async def after_llm_req(self, event: AstrMessageEvent):
|
||||
"""在 LLM 请求后记录对话"""
|
||||
if self.ltm and self.ltm_enabled(event):
|
||||
try:
|
||||
await self.ltm.after_req_llm(event)
|
||||
except Exception as e:
|
||||
logger.error(f"ltm: {e}")
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("alter_cmd", alias={"alter"})
|
||||
async def alter_cmd(self, event: AstrMessageEvent):
|
||||
@@ -0,0 +1,4 @@
|
||||
name: builtin_commands
|
||||
desc: AstrBot 自带指令,提供常用的对话管理、工具使用、插件管理等功能。
|
||||
author: Soulter
|
||||
version: 0.0.1
|
||||
+177
-160
@@ -1,21 +1,22 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import aiohttp
|
||||
import uuid
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import aiodocker
|
||||
import shutil
|
||||
import time
|
||||
import astrbot.api.star as star
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.api import llm_tool, logger
|
||||
from astrbot.api.event import filter
|
||||
|
||||
import aiodocker
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import llm_tool, logger, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||
from astrbot.api.message_components import File, Image
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.api.message_components import Image, File
|
||||
from astrbot.core.utils.io import download_image_by_url, download_file
|
||||
from astrbot.core.message.components import BaseMessageComponent
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import download_file, download_image_by_url
|
||||
|
||||
PROMPT = """
|
||||
## Task
|
||||
@@ -120,23 +121,21 @@ class Main(star.Star):
|
||||
self.config = DEFAULT_CONFIG
|
||||
self._save_config()
|
||||
else:
|
||||
with open(PATH, "r") as f:
|
||||
with open(PATH) as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
async def initialize(self):
|
||||
ok = await self.is_docker_available()
|
||||
if not ok:
|
||||
logger.info(
|
||||
"Docker 不可用,代码解释器将无法使用,astrbot-python-interpreter 将自动禁用。"
|
||||
"Docker 不可用,代码解释器将无法使用,astrbot-python-interpreter 将自动禁用。",
|
||||
)
|
||||
# await self.context._star_manager.turn_off_plugin(
|
||||
# "astrbot-python-interpreter"
|
||||
# )
|
||||
|
||||
async def file_upload(self, file_path: str):
|
||||
"""
|
||||
上传图像文件到 S3
|
||||
"""
|
||||
"""上传图像文件到 S3"""
|
||||
ext = os.path.splitext(file_path)[1]
|
||||
S3_URL = "https://s3.neko.soulter.top/astrbot-s3"
|
||||
with open(file_path, "rb") as f:
|
||||
@@ -144,20 +143,22 @@ class Main(star.Star):
|
||||
|
||||
s3_file_url = f"{S3_URL}/{uuid.uuid4().hex}{ext}"
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"Accept": "application/json"}, trust_env=True
|
||||
) as session:
|
||||
async with session.put(s3_file_url, data=file) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"Failed to upload image: {resp.status}")
|
||||
return s3_file_url
|
||||
async with (
|
||||
aiohttp.ClientSession(
|
||||
headers={"Accept": "application/json"},
|
||||
trust_env=True,
|
||||
) as session,
|
||||
session.put(s3_file_url, data=file) as resp,
|
||||
):
|
||||
if resp.status != 200:
|
||||
raise Exception(f"Failed to upload image: {resp.status}")
|
||||
return s3_file_url
|
||||
|
||||
async def is_docker_available(self) -> bool:
|
||||
"""Check if docker is available"""
|
||||
try:
|
||||
docker = aiodocker.Docker()
|
||||
await docker.version()
|
||||
await docker.close()
|
||||
async with aiodocker.Docker() as docker:
|
||||
await docker.version()
|
||||
return True
|
||||
except BaseException as e:
|
||||
logger.info(f"检查 Docker 可用性: {e}")
|
||||
@@ -177,7 +178,10 @@ class Main(star.Star):
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
async def download_image(
|
||||
self, image_url: str, workplace_path: str, filename: str
|
||||
self,
|
||||
image_url: str,
|
||||
workplace_path: str,
|
||||
filename: str,
|
||||
) -> str:
|
||||
"""Download image from url to workplace_path"""
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
@@ -220,6 +224,8 @@ class Main(star.Star):
|
||||
del self.user_waiting[uid]
|
||||
elif isinstance(comp, Image):
|
||||
image_url = comp.url if comp.url else comp.file
|
||||
if image_url is None:
|
||||
raise ValueError("Image URL is None")
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
elif image_url.startswith("file:///"):
|
||||
@@ -236,18 +242,20 @@ class Main(star.Star):
|
||||
async def on_llm_req(self, event: AstrMessageEvent, request: ProviderRequest):
|
||||
if event.get_session_id() in self.user_file_msg_buffer:
|
||||
files = self.user_file_msg_buffer[event.get_session_id()]
|
||||
if not request.prompt:
|
||||
request.prompt = ""
|
||||
request.prompt += f"\nUser provided files: {files}"
|
||||
|
||||
@filter.command_group("pi")
|
||||
def pi(self):
|
||||
pass
|
||||
"""代码执行器配置"""
|
||||
|
||||
@pi.command("absdir")
|
||||
async def pi_absdir(self, event: AstrMessageEvent, path: str = ""):
|
||||
"""设置 Docker 宿主机绝对路径"""
|
||||
if not path:
|
||||
yield event.plain_result(
|
||||
f"当前 Docker 宿主机绝对路径: {self.config.get('docker_host_astrbot_abs_path', '')}"
|
||||
f"当前 Docker 宿主机绝对路径: {self.config.get('docker_host_astrbot_abs_path', '')}",
|
||||
)
|
||||
else:
|
||||
self.config["docker_host_astrbot_abs_path"] = path
|
||||
@@ -270,14 +278,14 @@ class Main(star.Star):
|
||||
@pi.command("repull")
|
||||
async def pi_repull(self, event: AstrMessageEvent):
|
||||
"""重新拉取沙箱镜像"""
|
||||
docker = aiodocker.Docker()
|
||||
image_name = await self.get_image_name()
|
||||
try:
|
||||
await docker.images.get(image_name)
|
||||
await docker.images.delete(image_name, force=True)
|
||||
except aiodocker.exceptions.DockerError:
|
||||
pass
|
||||
await docker.images.pull(image_name)
|
||||
async with aiodocker.Docker() as docker:
|
||||
image_name = await self.get_image_name()
|
||||
try:
|
||||
await docker.images.get(image_name)
|
||||
await docker.images.delete(image_name, force=True)
|
||||
except aiodocker.exceptions.DockerError:
|
||||
pass
|
||||
await docker.images.pull(image_name)
|
||||
yield event.plain_result("重新拉取沙箱镜像成功。")
|
||||
|
||||
@pi.command("file")
|
||||
@@ -290,7 +298,7 @@ class Main(star.Star):
|
||||
await asyncio.sleep(60)
|
||||
if uid in self.user_waiting:
|
||||
yield event.plain_result(
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 未在规定时间内上传{tip}。"
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 未在规定时间内上传{tip}。",
|
||||
)
|
||||
self.user_waiting.pop(uid)
|
||||
|
||||
@@ -301,11 +309,11 @@ class Main(star.Star):
|
||||
if uid in self.user_waiting:
|
||||
self.user_waiting.pop(uid)
|
||||
yield event.plain_result(
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 已清理。"
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 已清理。",
|
||||
)
|
||||
else:
|
||||
yield event.plain_result(
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有等待上传文件。"
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有等待上传文件。",
|
||||
)
|
||||
|
||||
@pi.command("list")
|
||||
@@ -315,11 +323,11 @@ class Main(star.Star):
|
||||
if uid in self.user_file_msg_buffer:
|
||||
files = self.user_file_msg_buffer[uid]
|
||||
yield event.plain_result(
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 上传的文件: {files}"
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 上传的文件: {files}",
|
||||
)
|
||||
else:
|
||||
yield event.plain_result(
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有上传文件。"
|
||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有上传文件。",
|
||||
)
|
||||
|
||||
@llm_tool("python_interpreter")
|
||||
@@ -362,133 +370,140 @@ class Main(star.Star):
|
||||
obs = ""
|
||||
n = 5
|
||||
|
||||
for i in range(n):
|
||||
if i > 0:
|
||||
logger.info(f"Try {i + 1}/{n}")
|
||||
async with aiodocker.Docker() as docker:
|
||||
for i in range(n):
|
||||
if i > 0:
|
||||
logger.info(f"Try {i + 1}/{n}")
|
||||
|
||||
PROMPT_ = PROMPT.format(
|
||||
prompt=plain_text,
|
||||
extra_input=extra_inputs,
|
||||
extra_prompt=obs,
|
||||
)
|
||||
provider = self.context.get_using_provider()
|
||||
llm_response = await provider.text_chat(
|
||||
prompt=PROMPT_, session_id=f"{event.session_id}_{magic_code}_{str(i)}"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"code interpreter llm gened code:" + llm_response.completion_text
|
||||
)
|
||||
|
||||
# 整理代码并保存
|
||||
code_clean = await self.tidy_code(llm_response.completion_text)
|
||||
with open(os.path.join(workplace_path, "exec.py"), "w") as f:
|
||||
f.write(code_clean)
|
||||
|
||||
# 启动容器
|
||||
docker = aiodocker.Docker()
|
||||
|
||||
# 检查有没有image
|
||||
image_name = await self.get_image_name()
|
||||
try:
|
||||
await docker.images.get(image_name)
|
||||
except aiodocker.exceptions.DockerError:
|
||||
# 拉取镜像
|
||||
logger.info(f"未找到沙箱镜像,正在尝试拉取 {image_name}...")
|
||||
await docker.images.pull(image_name)
|
||||
|
||||
yield event.plain_result(
|
||||
f"使用沙箱执行代码中,请稍等...(尝试次数: {i + 1}/{n})"
|
||||
)
|
||||
|
||||
self.docker_host_astrbot_abs_path = self.config.get(
|
||||
"docker_host_astrbot_abs_path", ""
|
||||
)
|
||||
if self.docker_host_astrbot_abs_path:
|
||||
host_shared = os.path.join(
|
||||
self.docker_host_astrbot_abs_path, self.shared_path
|
||||
PROMPT_ = PROMPT.format(
|
||||
prompt=plain_text,
|
||||
extra_input=extra_inputs,
|
||||
extra_prompt=obs,
|
||||
)
|
||||
host_output = os.path.join(
|
||||
self.docker_host_astrbot_abs_path, output_path
|
||||
)
|
||||
host_workplace = os.path.join(
|
||||
self.docker_host_astrbot_abs_path, workplace_path
|
||||
provider = self.context.get_using_provider()
|
||||
llm_response = await provider.text_chat(
|
||||
prompt=PROMPT_,
|
||||
session_id=f"{event.session_id}_{magic_code}_{i!s}",
|
||||
)
|
||||
|
||||
else:
|
||||
host_shared = os.path.abspath(self.shared_path)
|
||||
host_output = os.path.abspath(output_path)
|
||||
host_workplace = os.path.abspath(workplace_path)
|
||||
logger.debug(
|
||||
"code interpreter llm gened code:" + llm_response.completion_text,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"host_shared: {host_shared}, host_output: {host_output}, host_workplace: {host_workplace}"
|
||||
)
|
||||
# 整理代码并保存
|
||||
code_clean = await self.tidy_code(llm_response.completion_text)
|
||||
with open(os.path.join(workplace_path, "exec.py"), "w") as f:
|
||||
f.write(code_clean)
|
||||
|
||||
container = await docker.containers.run(
|
||||
{
|
||||
"Image": image_name,
|
||||
"Cmd": ["python", "exec.py"],
|
||||
"Memory": 512 * 1024 * 1024,
|
||||
"NanoCPUs": 1000000000,
|
||||
"HostConfig": {
|
||||
"Binds": [
|
||||
f"{host_shared}:/astrbot_sandbox/shared:ro",
|
||||
f"{host_output}:/astrbot_sandbox/output:rw",
|
||||
f"{host_workplace}:/astrbot_sandbox:rw",
|
||||
]
|
||||
},
|
||||
"Env": [f"MAGIC_CODE={magic_code}"],
|
||||
"AutoRemove": True,
|
||||
}
|
||||
)
|
||||
# 检查有没有image
|
||||
image_name = await self.get_image_name()
|
||||
try:
|
||||
await docker.images.get(image_name)
|
||||
except aiodocker.exceptions.DockerError:
|
||||
# 拉取镜像
|
||||
logger.info(f"未找到沙箱镜像,正在尝试拉取 {image_name}...")
|
||||
await docker.images.pull(image_name)
|
||||
|
||||
logger.debug(f"Container {container.id} created.")
|
||||
logs = await self.run_container(container)
|
||||
yield event.plain_result(
|
||||
f"使用沙箱执行代码中,请稍等...(尝试次数: {i + 1}/{n})",
|
||||
)
|
||||
|
||||
logger.debug(f"Container {container.id} finished.")
|
||||
logger.debug(f"Container {container.id} logs: {logs}")
|
||||
|
||||
# 发送结果
|
||||
pattern = r"\[ASTRBOT_(TEXT|IMAGE|FILE)_OUTPUT#\w+\]: (.*)"
|
||||
ok = False
|
||||
traceback = ""
|
||||
for idx, log in enumerate(logs):
|
||||
match = re.match(pattern, log)
|
||||
if match:
|
||||
ok = True
|
||||
if match.group(1) == "TEXT":
|
||||
yield event.plain_result(match.group(2))
|
||||
elif match.group(1) == "IMAGE":
|
||||
image_path = os.path.join(workplace_path, match.group(2))
|
||||
logger.debug(f"Sending image: {image_path}")
|
||||
yield event.image_result(image_path)
|
||||
elif match.group(1) == "FILE":
|
||||
file_path = os.path.join(workplace_path, match.group(2))
|
||||
# logger.debug(f"Sending file: {file_path}")
|
||||
# file_s3_url = await self.file_upload(file_path)
|
||||
# logger.info(f"文件上传到 AstrBot 云节点: {file_s3_url}")
|
||||
file_name = os.path.basename(file_path)
|
||||
chain = [File(name=file_name, file=file_path)]
|
||||
yield event.set_result(MessageEventResult(chain=chain))
|
||||
|
||||
elif "Traceback (most recent call last)" in log or "[Error]: " in log:
|
||||
traceback = "\n".join(logs[idx:])
|
||||
|
||||
if not ok:
|
||||
if traceback:
|
||||
obs = f"## Observation \n When execute the code: ```python\n{code_clean}\n```\n\n Error occurred:\n\n{traceback}\n Need to improve/fix the code."
|
||||
else:
|
||||
logger.warning(
|
||||
f"未从沙箱输出中捕获到合法的输出。沙箱输出日志: {logs}"
|
||||
self.docker_host_astrbot_abs_path = self.config.get(
|
||||
"docker_host_astrbot_abs_path",
|
||||
"",
|
||||
)
|
||||
if self.docker_host_astrbot_abs_path:
|
||||
host_shared = os.path.join(
|
||||
self.docker_host_astrbot_abs_path,
|
||||
self.shared_path,
|
||||
)
|
||||
break
|
||||
else:
|
||||
# 成功了
|
||||
self.user_file_msg_buffer.pop(event.get_session_id())
|
||||
return
|
||||
host_output = os.path.join(
|
||||
self.docker_host_astrbot_abs_path,
|
||||
output_path,
|
||||
)
|
||||
host_workplace = os.path.join(
|
||||
self.docker_host_astrbot_abs_path,
|
||||
workplace_path,
|
||||
)
|
||||
|
||||
else:
|
||||
host_shared = os.path.abspath(self.shared_path)
|
||||
host_output = os.path.abspath(output_path)
|
||||
host_workplace = os.path.abspath(workplace_path)
|
||||
|
||||
logger.debug(
|
||||
f"host_shared: {host_shared}, host_output: {host_output}, host_workplace: {host_workplace}",
|
||||
)
|
||||
|
||||
container = await docker.containers.run(
|
||||
{
|
||||
"Image": image_name,
|
||||
"Cmd": ["python", "exec.py"],
|
||||
"Memory": 512 * 1024 * 1024,
|
||||
"NanoCPUs": 1000000000,
|
||||
"HostConfig": {
|
||||
"Binds": [
|
||||
f"{host_shared}:/astrbot_sandbox/shared:ro",
|
||||
f"{host_output}:/astrbot_sandbox/output:rw",
|
||||
f"{host_workplace}:/astrbot_sandbox:rw",
|
||||
],
|
||||
},
|
||||
"Env": [f"MAGIC_CODE={magic_code}"],
|
||||
"AutoRemove": True,
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(f"Container {container.id} created.")
|
||||
logs = await self.run_container(container)
|
||||
|
||||
logger.debug(f"Container {container.id} finished.")
|
||||
logger.debug(f"Container {container.id} logs: {logs}")
|
||||
|
||||
# 发送结果
|
||||
pattern = r"\[ASTRBOT_(TEXT|IMAGE|FILE)_OUTPUT#\w+\]: (.*)"
|
||||
ok = False
|
||||
traceback = ""
|
||||
for idx, log in enumerate(logs):
|
||||
match = re.match(pattern, log)
|
||||
if match:
|
||||
ok = True
|
||||
if match.group(1) == "TEXT":
|
||||
yield event.plain_result(match.group(2))
|
||||
elif match.group(1) == "IMAGE":
|
||||
image_path = os.path.join(workplace_path, match.group(2))
|
||||
logger.debug(f"Sending image: {image_path}")
|
||||
yield event.image_result(image_path)
|
||||
elif match.group(1) == "FILE":
|
||||
file_path = os.path.join(workplace_path, match.group(2))
|
||||
# logger.debug(f"Sending file: {file_path}")
|
||||
# file_s3_url = await self.file_upload(file_path)
|
||||
# logger.info(f"文件上传到 AstrBot 云节点: {file_s3_url}")
|
||||
file_name = os.path.basename(file_path)
|
||||
chain: list[BaseMessageComponent] = [
|
||||
File(name=file_name, file=file_path)
|
||||
]
|
||||
yield event.set_result(MessageEventResult(chain=chain))
|
||||
|
||||
elif (
|
||||
"Traceback (most recent call last)" in log or "[Error]: " in log
|
||||
):
|
||||
traceback = "\n".join(logs[idx:])
|
||||
|
||||
if not ok:
|
||||
if traceback:
|
||||
obs = f"## Observation \n When execute the code: ```python\n{code_clean}\n```\n\n Error occurred:\n\n{traceback}\n Need to improve/fix the code."
|
||||
else:
|
||||
logger.warning(
|
||||
f"未从沙箱输出中捕获到合法的输出。沙箱输出日志: {logs}",
|
||||
)
|
||||
break
|
||||
else:
|
||||
# 成功了
|
||||
self.user_file_msg_buffer.pop(event.get_session_id())
|
||||
return
|
||||
|
||||
yield event.plain_result(
|
||||
"经过多次尝试后,未从沙箱输出中捕获到合法的输出,请更换问法或者查看日志。"
|
||||
"经过多次尝试后,未从沙箱输出中捕获到合法的输出,请更换问法或者查看日志。",
|
||||
)
|
||||
|
||||
@pi.command("cleanfile")
|
||||
@@ -504,7 +519,9 @@ class Main(star.Star):
|
||||
yield event.plain_result(f"用户 {event.get_session_id()} 上传的文件已清理。")
|
||||
|
||||
async def run_container(
|
||||
self, container: aiodocker.docker.DockerContainer, timeout: int = 20
|
||||
self,
|
||||
container: aiodocker.docker.DockerContainer,
|
||||
timeout: int = 20,
|
||||
) -> list[str]:
|
||||
"""Run the container and get the output"""
|
||||
try:
|
||||
@@ -1,13 +1,14 @@
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import zoneinfo
|
||||
import astrbot.api.star as star
|
||||
from astrbot.api.event import filter
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.api import llm_tool, logger
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from astrbot.api import llm_tool, logger, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
|
||||
@@ -31,7 +32,7 @@ class Main(star.Star):
|
||||
if not os.path.exists(reminder_file):
|
||||
with open(reminder_file, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
with open(reminder_file, "r", encoding="utf-8") as f:
|
||||
with open(reminder_file, encoding="utf-8") as f:
|
||||
self.reminder_data = json.load(f)
|
||||
|
||||
self._init_scheduler()
|
||||
@@ -56,25 +57,27 @@ class Main(star.Star):
|
||||
trigger="date",
|
||||
args=[group, reminder],
|
||||
run_date=datetime.datetime.strptime(
|
||||
reminder["datetime"], "%Y-%m-%d %H:%M"
|
||||
reminder["datetime"],
|
||||
"%Y-%m-%d %H:%M",
|
||||
),
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
elif "cron" in reminder:
|
||||
trigger = CronTrigger(**self._parse_cron_expr(reminder["cron"]))
|
||||
self.scheduler.add_job(
|
||||
self._reminder_callback,
|
||||
trigger="cron",
|
||||
trigger=trigger,
|
||||
id=id_,
|
||||
args=[group, reminder],
|
||||
misfire_grace_time=60,
|
||||
**self._parse_cron_expr(reminder["cron"]),
|
||||
)
|
||||
|
||||
def check_is_outdated(self, reminder: dict):
|
||||
"""Check if the reminder is outdated."""
|
||||
if "datetime" in reminder:
|
||||
reminder_time = datetime.datetime.strptime(
|
||||
reminder["datetime"], "%Y-%m-%d %H:%M"
|
||||
reminder["datetime"],
|
||||
"%Y-%m-%d %H:%M",
|
||||
).replace(tzinfo=self.timezone)
|
||||
return reminder_time < datetime.datetime.now(self.timezone)
|
||||
return False
|
||||
@@ -99,10 +102,10 @@ class Main(star.Star):
|
||||
async def reminder_tool(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
text: str = None,
|
||||
datetime_str: str = None,
|
||||
cron_expression: str = None,
|
||||
human_readable_cron: str = None,
|
||||
text: str | None = None,
|
||||
datetime_str: str | None = None,
|
||||
cron_expression: str | None = None,
|
||||
human_readable_cron: str | None = None,
|
||||
):
|
||||
"""Call this function when user is asking for setting a reminder.
|
||||
|
||||
@@ -111,6 +114,7 @@ class Main(star.Star):
|
||||
datetime_str(string): Required when user's reminder is a single reminder. The datetime string of the reminder, Must format with %Y-%m-%d %H:%M
|
||||
cron_expression(string): Required when user's reminder is a repeated reminder. The cron expression of the reminder. Monday is 0 and Sunday is 6.
|
||||
human_readable_cron(string): Optional. The human readable cron expression of the reminder.
|
||||
|
||||
"""
|
||||
if event.get_platform_name() == "qq_official":
|
||||
yield event.plain_result("reminder 暂不支持 QQ 官方机器人。")
|
||||
@@ -121,7 +125,7 @@ class Main(star.Star):
|
||||
|
||||
if not cron_expression and not datetime_str:
|
||||
raise ValueError(
|
||||
"The cron_expression and datetime_str cannot be both None."
|
||||
"The cron_expression and datetime_str cannot be both None.",
|
||||
)
|
||||
reminder_time = ""
|
||||
|
||||
@@ -136,21 +140,24 @@ class Main(star.Star):
|
||||
"id": str(uuid.uuid4()),
|
||||
}
|
||||
self.reminder_data[event.unified_msg_origin].append(d)
|
||||
trigger = CronTrigger(**self._parse_cron_expr(cron_expression))
|
||||
self.scheduler.add_job(
|
||||
self._reminder_callback,
|
||||
"cron",
|
||||
trigger,
|
||||
id=d["id"],
|
||||
misfire_grace_time=60,
|
||||
**self._parse_cron_expr(cron_expression),
|
||||
args=[event.unified_msg_origin, d],
|
||||
)
|
||||
if human_readable_cron:
|
||||
reminder_time = f"{human_readable_cron}(Cron: {cron_expression})"
|
||||
else:
|
||||
if datetime_str is None:
|
||||
raise ValueError("datetime_str cannot be None.")
|
||||
d = {"text": text, "datetime": datetime_str, "id": str(uuid.uuid4())}
|
||||
self.reminder_data[event.unified_msg_origin].append(d)
|
||||
datetime_scheduled = datetime.datetime.strptime(
|
||||
datetime_str, "%Y-%m-%d %H:%M"
|
||||
datetime_str,
|
||||
"%Y-%m-%d %H:%M",
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._reminder_callback,
|
||||
@@ -167,13 +174,12 @@ class Main(star.Star):
|
||||
+ text
|
||||
+ "\n时间: "
|
||||
+ reminder_time
|
||||
+ "\n\n使用 /reminder ls 查看所有待办事项。\n使用 /tool off reminder 关闭此功能。"
|
||||
+ "\n\n使用 /reminder ls 查看所有待办事项。\n使用 /tool off reminder 关闭此功能。",
|
||||
)
|
||||
|
||||
@filter.command_group("reminder")
|
||||
def reminder(self):
|
||||
"""The command group of the reminder."""
|
||||
pass
|
||||
"""待办提醒"""
|
||||
|
||||
async def get_upcoming_reminders(self, unified_msg_origin: str):
|
||||
"""Get upcoming reminders."""
|
||||
@@ -186,7 +192,8 @@ class Main(star.Star):
|
||||
for reminder in reminders
|
||||
if "datetime" not in reminder
|
||||
or datetime.datetime.strptime(
|
||||
reminder["datetime"], "%Y-%m-%d %H:%M"
|
||||
reminder["datetime"],
|
||||
"%Y-%m-%d %H:%M",
|
||||
).replace(tzinfo=self.timezone)
|
||||
>= now
|
||||
]
|
||||
@@ -199,14 +206,15 @@ class Main(star.Star):
|
||||
if not reminders:
|
||||
yield event.plain_result("没有正在进行的待办事项。")
|
||||
else:
|
||||
reminder_str = "正在进行的待办事项:\n"
|
||||
parts = ["正在进行的待办事项:\n"]
|
||||
for i, reminder in enumerate(reminders):
|
||||
time_ = reminder.get("datetime", "")
|
||||
if not time_:
|
||||
cron_expr = reminder.get("cron", "")
|
||||
time_ = reminder.get("cron_h", "") + f"(Cron: {cron_expr})"
|
||||
reminder_str += f"{i + 1}. {reminder['text']} - {time_}\n"
|
||||
reminder_str += "\n使用 /reminder rm <id> 删除待办事项。\n"
|
||||
parts.append(f"{i + 1}. {reminder['text']} - {time_}\n")
|
||||
parts.append("\n使用 /reminder rm <id> 删除待办事项。\n")
|
||||
reminder_str = "".join(parts)
|
||||
yield event.plain_result(reminder_str)
|
||||
|
||||
@reminder.command("rm")
|
||||
@@ -233,7 +241,7 @@ class Main(star.Star):
|
||||
except Exception as e:
|
||||
logger.error(f"Remove job error: {e}")
|
||||
yield event.plain_result(
|
||||
f"成功移除对应的待办事项。删除定时任务失败: {str(e)} 可能需要重启 AstrBot 以取消该提醒任务。"
|
||||
f"成功移除对应的待办事项。删除定时任务失败: {e!s} 可能需要重启 AstrBot 以取消该提醒任务。",
|
||||
)
|
||||
await self._save_data()
|
||||
yield event.plain_result("成功删除待办事项:\n" + reminder["text"])
|
||||
@@ -248,7 +256,7 @@ class Main(star.Star):
|
||||
+ d["text"]
|
||||
+ "\n时间: "
|
||||
+ d.get("datetime", "")
|
||||
+ d.get("cron_h", "")
|
||||
+ d.get("cron_h", ""),
|
||||
),
|
||||
)
|
||||
|
||||
+15
-11
@@ -1,19 +1,20 @@
|
||||
import astrbot.api.message_components as Comp
|
||||
import copy
|
||||
from sys import maxsize
|
||||
|
||||
import astrbot.api.message_components as Comp
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.star import Context, Star
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
SessionWaiter,
|
||||
USER_SESSIONS,
|
||||
FILTERS,
|
||||
session_waiter,
|
||||
USER_SESSIONS,
|
||||
SessionController,
|
||||
SessionWaiter,
|
||||
session_waiter,
|
||||
)
|
||||
from sys import maxsize
|
||||
|
||||
|
||||
class Waiter(Star):
|
||||
class Main(Star):
|
||||
"""会话控制"""
|
||||
|
||||
def __init__(self, context: Context):
|
||||
@@ -52,13 +53,14 @@ class Waiter(Star):
|
||||
|
||||
# 获取用户当前的对话信息
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin
|
||||
event.unified_msg_origin,
|
||||
)
|
||||
conversation = None
|
||||
|
||||
if curr_cid:
|
||||
conversation = await self.context.conversation_manager.get_conversation(
|
||||
event.unified_msg_origin, curr_cid
|
||||
event.unified_msg_origin,
|
||||
curr_cid,
|
||||
)
|
||||
else:
|
||||
# 创建新对话
|
||||
@@ -81,16 +83,18 @@ class Waiter(Star):
|
||||
conversation=conversation,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM response failed: {str(e)}")
|
||||
logger.error(f"LLM response failed: {e!s}")
|
||||
# LLM 回复失败,使用原始预设回复
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
|
||||
@session_waiter(60)
|
||||
async def empty_mention_waiter(
|
||||
controller: SessionController, event: AstrMessageEvent
|
||||
controller: SessionController,
|
||||
event: AstrMessageEvent,
|
||||
):
|
||||
event.message_obj.message.insert(
|
||||
0, Comp.At(qq=event.get_self_id(), name=event.get_self_id())
|
||||
0,
|
||||
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
|
||||
)
|
||||
new_event = copy.copy(event)
|
||||
# 重新推入事件队列
|
||||
+45
-33
@@ -1,9 +1,9 @@
|
||||
import random
|
||||
from bs4 import BeautifulSoup
|
||||
from aiohttp import ClientSession
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0",
|
||||
@@ -38,47 +38,55 @@ class SearchResult:
|
||||
|
||||
|
||||
class SearchEngine:
|
||||
"""
|
||||
搜索引擎爬虫基类
|
||||
"""
|
||||
"""搜索引擎爬虫基类"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.TIMEOUT = 10
|
||||
self.page = 1
|
||||
self.headers = HEADERS
|
||||
|
||||
def _set_selector(self, selector: str) -> None:
|
||||
raise NotImplementedError()
|
||||
def _set_selector(self, selector: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def _get_next_page(self):
|
||||
raise NotImplementedError()
|
||||
def _get_next_page(self, query: str):
|
||||
raise NotImplementedError
|
||||
|
||||
async def _get_html(self, url: str, data: dict = None) -> str:
|
||||
async def _get_html(self, url: str, data: dict | None = None) -> str:
|
||||
headers = self.headers
|
||||
headers["Referer"] = url
|
||||
headers["User-Agent"] = random.choice(USER_AGENTS)
|
||||
if data:
|
||||
async with ClientSession() as session:
|
||||
async with session.post(
|
||||
url, headers=headers, data=data, timeout=self.TIMEOUT
|
||||
) as resp:
|
||||
ret = await resp.text(encoding="utf-8")
|
||||
return ret
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.post(
|
||||
url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=self.TIMEOUT,
|
||||
) as resp,
|
||||
):
|
||||
ret = await resp.text(encoding="utf-8")
|
||||
return ret
|
||||
else:
|
||||
async with ClientSession() as session:
|
||||
async with session.get(
|
||||
url, headers=headers, timeout=self.TIMEOUT
|
||||
) as resp:
|
||||
ret = await resp.text(encoding="utf-8")
|
||||
return ret
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=self.TIMEOUT,
|
||||
) as resp,
|
||||
):
|
||||
ret = await resp.text(encoding="utf-8")
|
||||
return ret
|
||||
|
||||
def tidy_text(self, text: str) -> str:
|
||||
"""
|
||||
清理文本,去除空格、换行符等
|
||||
"""
|
||||
"""清理文本,去除空格、换行符等"""
|
||||
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
|
||||
|
||||
async def search(self, query: str, num_results: int) -> List[SearchResult]:
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
return self.tidy_text(tag.get_text())
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
query = urllib.parse.quote(query)
|
||||
|
||||
try:
|
||||
@@ -87,12 +95,16 @@ class SearchEngine:
|
||||
links = soup.select(self._set_selector("links"))
|
||||
results = []
|
||||
for link in links:
|
||||
title = self.tidy_text(
|
||||
link.select_one(self._set_selector("title")).text
|
||||
)
|
||||
url = link.select_one(self._set_selector("url"))
|
||||
# Safely get the title text (select_one may return None)
|
||||
title_elem = link.select_one(self._set_selector("title"))
|
||||
title = ""
|
||||
if title_elem is not None:
|
||||
title = self.tidy_text(title_elem.get_text())
|
||||
|
||||
url_tag = link.select_one(self._set_selector("url"))
|
||||
snippet = ""
|
||||
if title and url:
|
||||
if title and url_tag:
|
||||
url = self._get_url(url_tag)
|
||||
results.append(SearchResult(title=title, url=url, snippet=snippet))
|
||||
return results[:num_results] if len(results) > num_results else results
|
||||
except Exception as e:
|
||||
+1
-11
@@ -1,6 +1,4 @@
|
||||
from typing import List
|
||||
from . import SearchEngine, SearchResult
|
||||
from . import USER_AGENT_BING
|
||||
from . import USER_AGENT_BING, SearchEngine
|
||||
|
||||
|
||||
class Bing(SearchEngine):
|
||||
@@ -30,11 +28,3 @@ class Bing(SearchEngine):
|
||||
self.base_url = base_url
|
||||
continue
|
||||
raise Exception("Bing search failed")
|
||||
|
||||
async def search(self, query: str, num_results: int) -> List[SearchResult]:
|
||||
results = await super().search(query, num_results)
|
||||
for result in results:
|
||||
if not isinstance(result.url, str):
|
||||
result.url = result.url.text
|
||||
|
||||
return results
|
||||
+13
-8
@@ -1,10 +1,10 @@
|
||||
import random
|
||||
import re
|
||||
from bs4 import BeautifulSoup
|
||||
from . import SearchEngine, SearchResult
|
||||
from . import USER_AGENTS
|
||||
from typing import cast
|
||||
|
||||
from typing import List
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from . import USER_AGENTS, SearchEngine, SearchResult
|
||||
|
||||
|
||||
class Sogo(SearchEngine):
|
||||
@@ -27,10 +27,12 @@ class Sogo(SearchEngine):
|
||||
url = f"{self.base_url}/web?query={query}"
|
||||
return await self._get_html(url, None)
|
||||
|
||||
async def search(self, query: str, num_results: int) -> List[SearchResult]:
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
return cast(str, tag.get("href"))
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
results = await super().search(query, num_results)
|
||||
for result in results:
|
||||
result.url = result.url.get("href")
|
||||
if result.url.startswith("/link?"):
|
||||
result.url = self.base_url + result.url
|
||||
result.url = await self._parse_url(result.url)
|
||||
@@ -41,7 +43,10 @@ class Sogo(SearchEngine):
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
script = soup.find("script")
|
||||
if script:
|
||||
url = re.search(r'window.location.replace\("(.+?)"\)', script.string).group(
|
||||
1
|
||||
script_text = (
|
||||
script.string if script.string is not None else script.get_text()
|
||||
)
|
||||
match = re.search(r'window.location.replace\("(.+?)"\)', script_text)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
return url
|
||||
@@ -1,19 +1,18 @@
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import random
|
||||
import astrbot.api.star as star
|
||||
import astrbot.api.event.filter as filter
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
from readability import Document
|
||||
|
||||
from astrbot.api import AstrBotConfig, llm_tool, logger, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.api import llm_tool, logger, AstrBotConfig
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
from .engines import SearchResult
|
||||
|
||||
from .engines import HEADERS, USER_AGENTS, SearchResult
|
||||
from .engines.bing import Bing
|
||||
from .engines.sogo import Sogo
|
||||
from .engines.google import Google
|
||||
from readability import Document
|
||||
from bs4 import BeautifulSoup
|
||||
from .engines import HEADERS, USER_AGENTS
|
||||
|
||||
|
||||
class Main(star.Star):
|
||||
@@ -36,7 +35,7 @@ class Main(star.Star):
|
||||
tavily_key = provider_settings.get("websearch_tavily_key")
|
||||
if isinstance(tavily_key, str):
|
||||
logger.info(
|
||||
"检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。"
|
||||
"检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。",
|
||||
)
|
||||
if tavily_key:
|
||||
provider_settings["websearch_tavily_key"] = [tavily_key]
|
||||
@@ -46,11 +45,7 @@ class Main(star.Star):
|
||||
|
||||
self.bing_search = Bing()
|
||||
self.sogo_search = Sogo()
|
||||
self.google = None
|
||||
try:
|
||||
self.google = Google()
|
||||
except Exception as e:
|
||||
logger.error(f"google search init error: {e}, disable google search")
|
||||
self.baidu_initialized = False
|
||||
|
||||
async def _tidy_text(self, text: str) -> str:
|
||||
"""清理文本,去除空格、换行符等"""
|
||||
@@ -70,7 +65,10 @@ class Main(star.Star):
|
||||
return ret
|
||||
|
||||
async def _process_search_result(
|
||||
self, result: SearchResult, idx: int, websearch_link: bool
|
||||
self,
|
||||
result: SearchResult,
|
||||
idx: int,
|
||||
websearch_link: bool,
|
||||
) -> str:
|
||||
"""处理单个搜索结果"""
|
||||
logger.info(f"web_searcher - scraping web: {result.title} - {result.url}")
|
||||
@@ -90,20 +88,15 @@ class Main(star.Star):
|
||||
return f"{header}\n{result.snippet}\n{site_result}\n\n"
|
||||
|
||||
async def _web_search_default(
|
||||
self, query, num_results: int = 5
|
||||
self,
|
||||
query,
|
||||
num_results: int = 5,
|
||||
) -> list[SearchResult]:
|
||||
results = []
|
||||
if self.google:
|
||||
try:
|
||||
results = await self.google.search(query, num_results)
|
||||
except Exception as e:
|
||||
logger.error(f"google search error: {e}, try the next one...")
|
||||
if len(results) == 0:
|
||||
logger.debug("search google failed")
|
||||
try:
|
||||
results = await self.bing_search.search(query, num_results)
|
||||
except Exception as e:
|
||||
logger.error(f"bing search error: {e}, try the next one...")
|
||||
try:
|
||||
results = await self.bing_search.search(query, num_results)
|
||||
except Exception as e:
|
||||
logger.error(f"bing search error: {e}, try the next one...")
|
||||
if len(results) == 0:
|
||||
logger.debug("search bing failed")
|
||||
try:
|
||||
@@ -128,7 +121,9 @@ class Main(star.Star):
|
||||
return key
|
||||
|
||||
async def _web_search_tavily(
|
||||
self, cfg: AstrBotConfig, payload: dict
|
||||
self,
|
||||
cfg: AstrBotConfig,
|
||||
payload: dict,
|
||||
) -> list[SearchResult]:
|
||||
"""使用 Tavily 搜索引擎进行搜索"""
|
||||
tavily_key = await self._get_tavily_key(cfg)
|
||||
@@ -139,12 +134,15 @@ class Main(star.Star):
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
url, json=payload, headers=header, timeout=6
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Tavily web search failed: {reason}, status: {response.status}"
|
||||
f"Tavily web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
results = []
|
||||
@@ -167,38 +165,46 @@ class Main(star.Star):
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
url, json=payload, headers=header, timeout=6
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Tavily web search failed: {reason}, status: {response.status}"
|
||||
f"Tavily web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
results: list[dict] = data.get("results", [])
|
||||
if not results:
|
||||
raise ValueError(
|
||||
"Error: Tavily web searcher does not return any results."
|
||||
"Error: Tavily web searcher does not return any results.",
|
||||
)
|
||||
return results
|
||||
|
||||
@filter.command("websearch")
|
||||
async def websearch(self, event: AstrMessageEvent, oper: str | None = None):
|
||||
"""网页搜索指令(已废弃)"""
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。"
|
||||
)
|
||||
"此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。",
|
||||
),
|
||||
)
|
||||
|
||||
@llm_tool(name="web_search")
|
||||
async def search_from_search_engine(
|
||||
self, event: AstrMessageEvent, query: str, max_results: int = 5
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
) -> str:
|
||||
"""搜索网络以回答用户的问题。当用户需要搜索网络以获取即时性的信息时调用此工具。
|
||||
|
||||
Args:
|
||||
query(string): 和用户的问题最相关的搜索关键词,用于在 Google 上搜索。
|
||||
max_results(number): 返回的最大搜索结果数量,默认为 5。
|
||||
|
||||
"""
|
||||
logger.info(f"web_searcher - search_from_search_engine: {query}")
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
@@ -225,12 +231,38 @@ class Main(star.Star):
|
||||
|
||||
return ret
|
||||
|
||||
async def ensure_baidu_ai_search_mcp(self, umo: str | None = None):
|
||||
if self.baidu_initialized:
|
||||
return
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
key = cfg.get("provider_settings", {}).get(
|
||||
"websearch_baidu_app_builder_key",
|
||||
"",
|
||||
)
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"Error: Baidu AI Search API key is not configured in AstrBot.",
|
||||
)
|
||||
func_tool_mgr = self.context.get_llm_tool_manager()
|
||||
await func_tool_mgr.enable_mcp_server(
|
||||
"baidu_ai_search",
|
||||
config={
|
||||
"transport": "sse",
|
||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||
"headers": {},
|
||||
"timeout": 30,
|
||||
},
|
||||
)
|
||||
self.baidu_initialized = True
|
||||
logger.info("Successfully initialized Baidu AI Search MCP server.")
|
||||
|
||||
@llm_tool(name="fetch_url")
|
||||
async def fetch_website_content(self, event: AstrMessageEvent, url: str) -> str:
|
||||
"""fetch the content of a website with the given web url
|
||||
"""Fetch the content of a website with the given web url
|
||||
|
||||
Args:
|
||||
url(string): The url of the website to fetch content from
|
||||
|
||||
"""
|
||||
resp = await self._get_from_url(url)
|
||||
return resp
|
||||
@@ -260,6 +292,7 @@ class Main(star.Star):
|
||||
time_range(string): Optional. The time range back from the current date to include in the search results. This feature is available for both 'general' and 'news' search topics. Must be one of 'day', 'week', 'month', 'year'.
|
||||
start_date(string): Optional. The start date for the search results in the format 'YYYY-MM-DD'.
|
||||
end_date(string): Optional. The end date for the search results in the format 'YYYY-MM-DD'.
|
||||
|
||||
"""
|
||||
logger.info(f"web_searcher - search_from_tavily: {query}")
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
@@ -307,13 +340,17 @@ class Main(star.Star):
|
||||
|
||||
@llm_tool("tavily_extract_web_page")
|
||||
async def tavily_extract_web_page(
|
||||
self, event: AstrMessageEvent, url: str = "", extract_depth: str = "basic"
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
url: str = "",
|
||||
extract_depth: str = "basic",
|
||||
) -> str:
|
||||
"""Extract the content of a web page using Tavily.
|
||||
|
||||
Args:
|
||||
url(string): Required. An URl to extract content from.
|
||||
extract_depth(string): Optional. The depth of the extraction, must be one of 'basic', 'advanced'. Default is "basic".
|
||||
|
||||
"""
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
||||
@@ -339,7 +376,9 @@ class Main(star.Star):
|
||||
|
||||
@filter.on_llm_request(priority=-10000)
|
||||
async def edit_web_search_tools(
|
||||
self, event: AstrMessageEvent, req: ProviderRequest
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest,
|
||||
):
|
||||
"""Get the session conversation for the given event."""
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
@@ -371,6 +410,7 @@ class Main(star.Star):
|
||||
tool_set.add_tool(fetch_url_t)
|
||||
tool_set.remove_tool("web_search_tavily")
|
||||
tool_set.remove_tool("tavily_extract_web_page")
|
||||
tool_set.remove_tool("AIsearch")
|
||||
elif provider == "tavily":
|
||||
web_search_tavily = func_tool_mgr.get_func("web_search_tavily")
|
||||
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
|
||||
@@ -380,3 +420,17 @@ class Main(star.Star):
|
||||
tool_set.add_tool(tavily_extract_web_page)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
tool_set.remove_tool("AIsearch")
|
||||
elif provider == "baidu_ai_search":
|
||||
try:
|
||||
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
|
||||
aisearch_tool = func_tool_mgr.get_func("AIsearch")
|
||||
if not aisearch_tool:
|
||||
raise ValueError("Cannot get Baidu AI Search MCP tool.")
|
||||
tool_set.add_tool(aisearch_tool)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
tool_set.remove_tool("web_search_tavily")
|
||||
tool_set.remove_tool("tavily_extract_web_page")
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.5.23"
|
||||
__version__ = "4.11.3"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
AstrBot CLI入口
|
||||
"""
|
||||
"""AstrBot CLI入口"""
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .commands import init, run, plug, conf
|
||||
from .commands import conf, init, plug, run
|
||||
|
||||
logo_tmpl = r"""
|
||||
___ _______.___________..______ .______ ______ .___________.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from .cmd_init import init
|
||||
from .cmd_run import run
|
||||
from .cmd_plug import plug
|
||||
from .cmd_conf import conf
|
||||
from .cmd_init import init
|
||||
from .cmd_plug import plug
|
||||
from .cmd_run import run
|
||||
|
||||
__all__ = ["init", "run", "plug", "conf"]
|
||||
__all__ = ["conf", "init", "plug", "run"]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import json
|
||||
import click
|
||||
import hashlib
|
||||
import json
|
||||
import zoneinfo
|
||||
from typing import Any, Callable
|
||||
from ..utils import get_astrbot_root, check_astrbot_root
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from ..utils import check_astrbot_root, get_astrbot_root
|
||||
|
||||
|
||||
def _validate_log_level(value: str) -> str:
|
||||
@@ -11,7 +14,7 @@ def _validate_log_level(value: str) -> str:
|
||||
value = value.upper()
|
||||
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
raise click.ClickException(
|
||||
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一"
|
||||
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
|
||||
)
|
||||
return value
|
||||
|
||||
@@ -73,7 +76,7 @@ def _load_config() -> dict[str, Any]:
|
||||
root = get_astrbot_root()
|
||||
if not check_astrbot_root(root):
|
||||
raise click.ClickException(
|
||||
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init"
|
||||
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
)
|
||||
|
||||
config_path = root / "data" / "cmd_config.json"
|
||||
@@ -88,7 +91,7 @@ def _load_config() -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(config_path.read_text(encoding="utf-8-sig"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(f"配置文件解析失败: {str(e)}")
|
||||
raise click.ClickException(f"配置文件解析失败: {e!s}")
|
||||
|
||||
|
||||
def _save_config(config: dict[str, Any]) -> None:
|
||||
@@ -96,7 +99,8 @@ def _save_config(config: dict[str, Any]) -> None:
|
||||
config_path = get_astrbot_root() / "data" / "cmd_config.json"
|
||||
|
||||
config_path.write_text(
|
||||
json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig"
|
||||
json.dumps(config, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
|
||||
|
||||
@@ -108,7 +112,7 @@ def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
|
||||
obj[part] = {}
|
||||
elif not isinstance(obj[part], dict):
|
||||
raise click.ClickException(
|
||||
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典"
|
||||
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典",
|
||||
)
|
||||
obj = obj[part]
|
||||
obj[parts[-1]] = value
|
||||
@@ -140,7 +144,6 @@ def conf():
|
||||
|
||||
- callback_api_base: 回调接口基址
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@conf.command(name="set")
|
||||
@@ -148,7 +151,7 @@ def conf():
|
||||
@click.argument("value")
|
||||
def set_config(key: str, value: str):
|
||||
"""设置配置项的值"""
|
||||
if key not in CONFIG_VALIDATORS.keys():
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
|
||||
config = _load_config()
|
||||
@@ -170,17 +173,17 @@ def set_config(key: str, value: str):
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"设置配置失败: {str(e)}")
|
||||
raise click.UsageError(f"设置配置失败: {e!s}")
|
||||
|
||||
|
||||
@conf.command(name="get")
|
||||
@click.argument("key", required=False)
|
||||
def get_config(key: str = None):
|
||||
def get_config(key: str | None = None):
|
||||
"""获取配置项的值,不提供key则显示所有可配置项"""
|
||||
config = _load_config()
|
||||
|
||||
if key:
|
||||
if key not in CONFIG_VALIDATORS.keys():
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
|
||||
try:
|
||||
@@ -191,10 +194,10 @@ def get_config(key: str = None):
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"获取配置失败: {str(e)}")
|
||||
raise click.UsageError(f"获取配置失败: {e!s}")
|
||||
else:
|
||||
click.echo("当前配置:")
|
||||
for key in CONFIG_VALIDATORS.keys():
|
||||
for key in CONFIG_VALIDATORS:
|
||||
try:
|
||||
value = (
|
||||
"********"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from filelock import FileLock, Timeout
|
||||
@@ -6,14 +7,14 @@ from filelock import FileLock, Timeout
|
||||
from ..utils import check_dashboard, get_astrbot_root
|
||||
|
||||
|
||||
async def initialize_astrbot(astrbot_root) -> None:
|
||||
async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||
"""执行 AstrBot 初始化逻辑"""
|
||||
dot_astrbot = astrbot_root / ".astrbot"
|
||||
|
||||
if not dot_astrbot.exists():
|
||||
click.echo(f"Current Directory: {astrbot_root}")
|
||||
click.echo(
|
||||
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。"
|
||||
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。",
|
||||
)
|
||||
if click.confirm(
|
||||
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import shutil
|
||||
|
||||
|
||||
from ..utils import (
|
||||
get_git_repo,
|
||||
build_plug_list,
|
||||
manage_plugin,
|
||||
PluginStatus,
|
||||
build_plug_list,
|
||||
check_astrbot_root,
|
||||
get_astrbot_root,
|
||||
get_git_repo,
|
||||
manage_plugin,
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
def plug():
|
||||
"""插件管理"""
|
||||
pass
|
||||
|
||||
|
||||
def _get_data_path() -> Path:
|
||||
base = get_astrbot_root()
|
||||
if not check_astrbot_root(base):
|
||||
raise click.ClickException(
|
||||
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init"
|
||||
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
)
|
||||
return (base / "data").resolve()
|
||||
|
||||
@@ -41,7 +39,7 @@ def display_plugins(plugins, title=None, color=None):
|
||||
desc = p["desc"][:30] + ("..." if len(p["desc"]) > 30 else "")
|
||||
click.echo(
|
||||
f"{p['name']:<20} {p['version']:<10} {p['status']:<10} "
|
||||
f"{p['author']:<15} {desc:<30}"
|
||||
f"{p['author']:<15} {desc:<30}",
|
||||
)
|
||||
|
||||
|
||||
@@ -78,7 +76,7 @@ def new(name: str):
|
||||
f"desc: {desc}\n"
|
||||
f"version: {version}\n"
|
||||
f"author: {author}\n"
|
||||
f"repo: {repo}\n"
|
||||
f"repo: {repo}\n",
|
||||
)
|
||||
|
||||
# 重写 README.md
|
||||
@@ -86,7 +84,7 @@ def new(name: str):
|
||||
f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n")
|
||||
|
||||
# 重写 main.py
|
||||
with open(plug_path / "main.py", "r", encoding="utf-8") as f:
|
||||
with open(plug_path / "main.py", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
new_content = content.replace(
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
from ..utils import check_dashboard, check_astrbot_root, get_astrbot_root
|
||||
from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
|
||||
|
||||
|
||||
async def run_astrbot(astrbot_root: Path):
|
||||
"""运行 AstrBot"""
|
||||
from astrbot.core import logger, LogManager, LogBroker, db_helper
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
|
||||
await check_dashboard(astrbot_root / "data")
|
||||
@@ -38,7 +37,7 @@ def run(reload: bool, port: str) -> None:
|
||||
|
||||
if not check_astrbot_root(astrbot_root):
|
||||
raise click.ClickException(
|
||||
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init"
|
||||
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
)
|
||||
|
||||
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
from .basic import (
|
||||
get_astrbot_root,
|
||||
check_astrbot_root,
|
||||
check_dashboard,
|
||||
get_astrbot_root,
|
||||
)
|
||||
from .plugin import get_git_repo, manage_plugin, build_plug_list, PluginStatus
|
||||
from .plugin import PluginStatus, build_plug_list, get_git_repo, manage_plugin
|
||||
from .version_comparator import VersionComparator
|
||||
|
||||
__all__ = [
|
||||
"get_astrbot_root",
|
||||
"PluginStatus",
|
||||
"VersionComparator",
|
||||
"build_plug_list",
|
||||
"check_astrbot_root",
|
||||
"check_dashboard",
|
||||
"get_astrbot_root",
|
||||
"get_git_repo",
|
||||
"manage_plugin",
|
||||
"build_plug_list",
|
||||
"VersionComparator",
|
||||
"PluginStatus",
|
||||
]
|
||||
|
||||
+14
-14
@@ -21,8 +21,9 @@ def get_astrbot_root() -> Path:
|
||||
|
||||
async def check_dashboard(astrbot_root: Path) -> None:
|
||||
"""检查是否安装了dashboard"""
|
||||
from astrbot.core.utils.io import get_dashboard_version, download_dashboard
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
|
||||
from .version_comparator import VersionComparator
|
||||
|
||||
try:
|
||||
@@ -48,19 +49,18 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
|
||||
click.echo("管理面板已是最新版本")
|
||||
return
|
||||
else:
|
||||
try:
|
||||
version = dashboard_version.split("v")[1]
|
||||
click.echo(f"管理面板版本: {version}")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
return
|
||||
try:
|
||||
version = dashboard_version.split("v")[1]
|
||||
click.echo(f"管理面板版本: {version}")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
click.echo("初始化管理面板目录...")
|
||||
try:
|
||||
|
||||
+24
-15
@@ -1,14 +1,14 @@
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from .version_comparator import VersionComparator
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None):
|
||||
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
||||
try:
|
||||
with httpx.Client(
|
||||
proxy=proxy if proxy else None, follow_redirects=True
|
||||
proxy=proxy if proxy else None,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
resp = client.get(release_url)
|
||||
resp.raise_for_status()
|
||||
@@ -55,7 +56,8 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None):
|
||||
|
||||
# 下载并解压
|
||||
with httpx.Client(
|
||||
proxy=proxy if proxy else None, follow_redirects=True
|
||||
proxy=proxy if proxy else None,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
resp = client.get(download_url)
|
||||
if (
|
||||
@@ -89,6 +91,7 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
|
||||
Returns:
|
||||
dict: 包含元数据的字典,如果读取失败则返回空字典
|
||||
|
||||
"""
|
||||
yaml_path = plugin_dir / "metadata.yaml"
|
||||
if yaml_path.exists():
|
||||
@@ -107,6 +110,7 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
|
||||
Returns:
|
||||
list: 包含插件信息的字典列表
|
||||
|
||||
"""
|
||||
# 获取本地插件信息
|
||||
result = []
|
||||
@@ -133,7 +137,7 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
"repo": str(metadata.get("repo", "")),
|
||||
"status": PluginStatus.INSTALLED,
|
||||
"local_path": str(plugin_dir),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# 获取在线插件列表
|
||||
@@ -153,7 +157,7 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
"repo": str(plugin_info.get("repo", "")),
|
||||
"status": PluginStatus.NOT_INSTALLED,
|
||||
"local_path": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
||||
@@ -168,7 +172,8 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
)
|
||||
if (
|
||||
VersionComparator.compare_version(
|
||||
local_plugin["version"], online_plugin["version"]
|
||||
local_plugin["version"],
|
||||
online_plugin["version"],
|
||||
)
|
||||
< 0
|
||||
):
|
||||
@@ -186,7 +191,10 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
|
||||
|
||||
def manage_plugin(
|
||||
plugin: dict, plugins_dir: Path, is_update: bool = False, proxy: str | None = None
|
||||
plugin: dict,
|
||||
plugins_dir: Path,
|
||||
is_update: bool = False,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
"""安装或更新插件
|
||||
|
||||
@@ -195,6 +203,7 @@ def manage_plugin(
|
||||
plugins_dir (Path): 插件目录
|
||||
is_update (bool, optional): 是否为更新操作. 默认为 False
|
||||
proxy (str, optional): 代理服务器地址
|
||||
|
||||
"""
|
||||
plugin_name = plugin["name"]
|
||||
repo_url = plugin["repo"]
|
||||
@@ -212,26 +221,26 @@ def manage_plugin(
|
||||
raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新")
|
||||
|
||||
# 备份现有插件
|
||||
if is_update and backup_path.exists():
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
if is_update:
|
||||
if is_update and backup_path is not None:
|
||||
shutil.copytree(target_path, backup_path)
|
||||
|
||||
try:
|
||||
click.echo(
|
||||
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}..."
|
||||
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}...",
|
||||
)
|
||||
get_git_repo(repo_url, target_path, proxy)
|
||||
|
||||
# 更新成功,删除备份
|
||||
if is_update and backup_path.exists():
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功")
|
||||
except Exception as e:
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path, ignore_errors=True)
|
||||
if is_update and backup_path.exists():
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.move(backup_path, target_path)
|
||||
raise click.ClickException(
|
||||
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}"
|
||||
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""
|
||||
拷贝自 astrbot.core.utils.version_comparator
|
||||
"""
|
||||
"""拷贝自 astrbot.core.utils.version_comparator"""
|
||||
|
||||
import re
|
||||
|
||||
@@ -42,15 +40,15 @@ class VersionComparator:
|
||||
for i in range(length):
|
||||
if v1_parts[i] > v2_parts[i]:
|
||||
return 1
|
||||
elif v1_parts[i] < v2_parts[i]:
|
||||
if v1_parts[i] < v2_parts[i]:
|
||||
return -1
|
||||
|
||||
# 比较预发布标签
|
||||
if v1_prerelease is None and v2_prerelease is not None:
|
||||
return 1 # 没有预发布标签的版本高于有预发布标签的版本
|
||||
elif v1_prerelease is not None and v2_prerelease is None:
|
||||
if v1_prerelease is not None and v2_prerelease is None:
|
||||
return -1 # 有预发布标签的版本低于没有预发布标签的版本
|
||||
elif v1_prerelease is not None and v2_prerelease is not None:
|
||||
if v1_prerelease is not None and v2_prerelease is not None:
|
||||
len_pre = max(len(v1_prerelease), len(v2_prerelease))
|
||||
for i in range(len_pre):
|
||||
p1 = v1_prerelease[i] if i < len(v1_prerelease) else None
|
||||
@@ -58,21 +56,21 @@ class VersionComparator:
|
||||
|
||||
if p1 is None and p2 is not None:
|
||||
return -1
|
||||
elif p1 is not None and p2 is None:
|
||||
if p1 is not None and p2 is None:
|
||||
return 1
|
||||
elif isinstance(p1, int) and isinstance(p2, str):
|
||||
if isinstance(p1, int) and isinstance(p2, str):
|
||||
return -1
|
||||
elif isinstance(p1, str) and isinstance(p2, int):
|
||||
if isinstance(p1, str) and isinstance(p2, int):
|
||||
return 1
|
||||
elif isinstance(p1, int) and isinstance(p2, int):
|
||||
if isinstance(p1, int) and isinstance(p2, int):
|
||||
if p1 > p2:
|
||||
return 1
|
||||
elif p1 < p2:
|
||||
if p1 < p2:
|
||||
return -1
|
||||
elif isinstance(p1, str) and isinstance(p2, str):
|
||||
if p1 > p2:
|
||||
return 1
|
||||
elif p1 < p2:
|
||||
if p1 < p2:
|
||||
return -1
|
||||
return 0 # 预发布标签完全相同
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import os
|
||||
from .log import LogManager, LogBroker # noqa
|
||||
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
||||
from astrbot.core.utils.shared_preferences import SharedPreferences
|
||||
from astrbot.core.utils.pip_installer import PipInstaller
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.config.default import DB_PATH
|
||||
|
||||
from astrbot.core.config import AstrBotConfig
|
||||
from astrbot.core.config.default import DB_PATH
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.file_token_service import FileTokenService
|
||||
from astrbot.core.utils.pip_installer import PipInstaller
|
||||
from astrbot.core.utils.shared_preferences import SharedPreferences
|
||||
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
||||
|
||||
from .log import LogBroker, LogManager # noqa
|
||||
from .utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
# 初始化数据存储文件夹
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
from .tool import FunctionTool
|
||||
from typing import Generic
|
||||
from .run_context import TContext
|
||||
|
||||
from .hooks import BaseAgentRunHooks
|
||||
from .run_context import TContext
|
||||
from .tool import FunctionTool
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
from ..message import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot import logger
|
||||
else:
|
||||
try:
|
||||
from astrbot import logger
|
||||
except ImportError:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
from ..context.truncator import ContextTruncator
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ContextCompressor(Protocol):
|
||||
"""
|
||||
Protocol for context compressors.
|
||||
Provides an interface for compressing message lists.
|
||||
"""
|
||||
|
||||
def should_compress(
|
||||
self, messages: list[Message], current_tokens: int, max_tokens: int
|
||||
) -> bool:
|
||||
"""Check if compression is needed.
|
||||
|
||||
Args:
|
||||
messages: The message list to evaluate.
|
||||
current_tokens: The current token count.
|
||||
max_tokens: The maximum allowed tokens for the model.
|
||||
|
||||
Returns:
|
||||
True if compression is needed, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def __call__(self, messages: list[Message]) -> list[Message]:
|
||||
"""Compress the message list.
|
||||
|
||||
Args:
|
||||
messages: The original message list.
|
||||
|
||||
Returns:
|
||||
The compressed message list.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class TruncateByTurnsCompressor:
|
||||
"""Truncate by turns compressor implementation.
|
||||
Truncates the message list by removing older turns.
|
||||
"""
|
||||
|
||||
def __init__(self, truncate_turns: int = 1, compression_threshold: float = 0.82):
|
||||
"""Initialize the truncate by turns compressor.
|
||||
|
||||
Args:
|
||||
truncate_turns: The number of turns to remove when truncating (default: 1).
|
||||
compression_threshold: The compression trigger threshold (default: 0.82).
|
||||
"""
|
||||
self.truncate_turns = truncate_turns
|
||||
self.compression_threshold = compression_threshold
|
||||
|
||||
def should_compress(
|
||||
self, messages: list[Message], current_tokens: int, max_tokens: int
|
||||
) -> bool:
|
||||
"""Check if compression is needed.
|
||||
|
||||
Args:
|
||||
messages: The message list to evaluate.
|
||||
current_tokens: The current token count.
|
||||
max_tokens: The maximum allowed tokens.
|
||||
|
||||
Returns:
|
||||
True if compression is needed, False otherwise.
|
||||
"""
|
||||
if max_tokens <= 0 or current_tokens <= 0:
|
||||
return False
|
||||
usage_rate = current_tokens / max_tokens
|
||||
return usage_rate > self.compression_threshold
|
||||
|
||||
async def __call__(self, messages: list[Message]) -> list[Message]:
|
||||
truncator = ContextTruncator()
|
||||
truncated_messages = truncator.truncate_by_dropping_oldest_turns(
|
||||
messages,
|
||||
drop_turns=self.truncate_turns,
|
||||
)
|
||||
return truncated_messages
|
||||
|
||||
|
||||
def split_history(
|
||||
messages: list[Message], keep_recent: int
|
||||
) -> tuple[list[Message], list[Message], list[Message]]:
|
||||
"""Split the message list into system messages, messages to summarize, and recent messages.
|
||||
|
||||
Ensures that the split point is between complete user-assistant pairs to maintain conversation flow.
|
||||
|
||||
Args:
|
||||
messages: The original message list.
|
||||
keep_recent: The number of latest messages to keep.
|
||||
|
||||
Returns:
|
||||
tuple: (system_messages, messages_to_summarize, recent_messages)
|
||||
"""
|
||||
# keep the system messages
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
|
||||
if len(non_system_messages) <= keep_recent:
|
||||
return system_messages, [], non_system_messages
|
||||
|
||||
# Find the split point, ensuring recent_messages starts with a user message
|
||||
# This maintains complete conversation turns
|
||||
split_index = len(non_system_messages) - keep_recent
|
||||
|
||||
# Search backward from split_index to find the first user message
|
||||
# This ensures recent_messages starts with a user message (complete turn)
|
||||
while split_index > 0 and non_system_messages[split_index].role != "user":
|
||||
# TODO: +=1 or -=1 ? calculate by tokens
|
||||
split_index -= 1
|
||||
|
||||
# If we couldn't find a user message, keep all messages as recent
|
||||
if split_index == 0:
|
||||
return system_messages, [], non_system_messages
|
||||
|
||||
messages_to_summarize = non_system_messages[:split_index]
|
||||
recent_messages = non_system_messages[split_index:]
|
||||
|
||||
return system_messages, messages_to_summarize, recent_messages
|
||||
|
||||
|
||||
class LLMSummaryCompressor:
|
||||
"""LLM-based summary compressor.
|
||||
Uses LLM to summarize the old conversation history, keeping the latest messages.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: "Provider",
|
||||
keep_recent: int = 4,
|
||||
instruction_text: str | None = None,
|
||||
compression_threshold: float = 0.82,
|
||||
):
|
||||
"""Initialize the LLM summary compressor.
|
||||
|
||||
Args:
|
||||
provider: The LLM provider instance.
|
||||
keep_recent: The number of latest messages to keep (default: 4).
|
||||
instruction_text: Custom instruction for summary generation.
|
||||
compression_threshold: The compression trigger threshold (default: 0.82).
|
||||
"""
|
||||
self.provider = provider
|
||||
self.keep_recent = keep_recent
|
||||
self.compression_threshold = compression_threshold
|
||||
|
||||
self.instruction_text = instruction_text or (
|
||||
"Based on our full conversation history, produce a concise summary of key takeaways and/or project progress.\n"
|
||||
"1. Systematically cover all core topics discussed and the final conclusion/outcome for each; clearly highlight the latest primary focus.\n"
|
||||
"2. If any tools were used, summarize tool usage (total call count) and extract the most valuable insights from tool outputs.\n"
|
||||
"3. If there was an initial user goal, state it first and describe the current progress/status.\n"
|
||||
"4. Write the summary in the user's language.\n"
|
||||
)
|
||||
|
||||
def should_compress(
|
||||
self, messages: list[Message], current_tokens: int, max_tokens: int
|
||||
) -> bool:
|
||||
"""Check if compression is needed.
|
||||
|
||||
Args:
|
||||
messages: The message list to evaluate.
|
||||
current_tokens: The current token count.
|
||||
max_tokens: The maximum allowed tokens.
|
||||
|
||||
Returns:
|
||||
True if compression is needed, False otherwise.
|
||||
"""
|
||||
if max_tokens <= 0 or current_tokens <= 0:
|
||||
return False
|
||||
usage_rate = current_tokens / max_tokens
|
||||
return usage_rate > self.compression_threshold
|
||||
|
||||
async def __call__(self, messages: list[Message]) -> list[Message]:
|
||||
"""Use LLM to generate a summary of the conversation history.
|
||||
|
||||
Process:
|
||||
1. Divide messages: keep the system message and the latest N messages.
|
||||
2. Send the old messages + the instruction message to the LLM.
|
||||
3. Reconstruct the message list: [system message, summary message, latest messages].
|
||||
"""
|
||||
if len(messages) <= self.keep_recent + 1:
|
||||
return messages
|
||||
|
||||
system_messages, messages_to_summarize, recent_messages = split_history(
|
||||
messages, self.keep_recent
|
||||
)
|
||||
|
||||
if not messages_to_summarize:
|
||||
return messages
|
||||
|
||||
# build payload
|
||||
instruction_message = Message(role="user", content=self.instruction_text)
|
||||
llm_payload = messages_to_summarize + [instruction_message]
|
||||
|
||||
# generate summary
|
||||
try:
|
||||
response = await self.provider.text_chat(contexts=llm_payload)
|
||||
summary_content = response.completion_text
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate summary: {e}")
|
||||
return messages
|
||||
|
||||
# build result
|
||||
result = []
|
||||
result.extend(system_messages)
|
||||
|
||||
result.append(
|
||||
Message(
|
||||
role="user",
|
||||
content=f"Our previous history conversation summary: {summary_content}",
|
||||
)
|
||||
)
|
||||
result.append(
|
||||
Message(
|
||||
role="assistant",
|
||||
content="Acknowledged the summary of our previous conversation history.",
|
||||
)
|
||||
)
|
||||
|
||||
result.extend(recent_messages)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,35 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .compressor import ContextCompressor
|
||||
from .token_counter import TokenCounter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextConfig:
|
||||
"""Context configuration class."""
|
||||
|
||||
max_context_tokens: int = 0
|
||||
"""Maximum number of context tokens. <= 0 means no limit."""
|
||||
enforce_max_turns: int = -1 # -1 means no limit
|
||||
"""Maximum number of conversation turns to keep. -1 means no limit. Executed before compression."""
|
||||
truncate_turns: int = 1
|
||||
"""Number of conversation turns to discard at once when truncation is triggered.
|
||||
Two processes will use this value:
|
||||
|
||||
1. Enforce max turns truncation.
|
||||
2. Truncation by turns compression strategy.
|
||||
"""
|
||||
llm_compress_instruction: str | None = None
|
||||
"""Instruction prompt for LLM-based compression."""
|
||||
llm_compress_keep_recent: int = 0
|
||||
"""Number of recent messages to keep during LLM-based compression."""
|
||||
llm_compress_provider: "Provider | None" = None
|
||||
"""LLM provider used for compression tasks. If None, truncation strategy is used."""
|
||||
custom_token_counter: TokenCounter | None = None
|
||||
"""Custom token counting method. If None, the default method is used."""
|
||||
custom_compressor: ContextCompressor | None = None
|
||||
"""Custom context compression method. If None, the default method is used."""
|
||||
@@ -0,0 +1,120 @@
|
||||
from astrbot import logger
|
||||
|
||||
from ..message import Message
|
||||
from .compressor import LLMSummaryCompressor, TruncateByTurnsCompressor
|
||||
from .config import ContextConfig
|
||||
from .token_counter import EstimateTokenCounter
|
||||
from .truncator import ContextTruncator
|
||||
|
||||
|
||||
class ContextManager:
|
||||
"""Context compression manager."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ContextConfig,
|
||||
):
|
||||
"""Initialize the context manager.
|
||||
|
||||
There are two strategies to handle context limit reached:
|
||||
1. Truncate by turns: remove older messages by turns.
|
||||
2. LLM-based compression: use LLM to summarize old messages.
|
||||
|
||||
Args:
|
||||
config: The context configuration.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
self.token_counter = config.custom_token_counter or EstimateTokenCounter()
|
||||
self.truncator = ContextTruncator()
|
||||
|
||||
if config.custom_compressor:
|
||||
self.compressor = config.custom_compressor
|
||||
elif config.llm_compress_provider:
|
||||
self.compressor = LLMSummaryCompressor(
|
||||
provider=config.llm_compress_provider,
|
||||
keep_recent=config.llm_compress_keep_recent,
|
||||
instruction_text=config.llm_compress_instruction,
|
||||
)
|
||||
else:
|
||||
self.compressor = TruncateByTurnsCompressor(
|
||||
truncate_turns=config.truncate_turns
|
||||
)
|
||||
|
||||
async def process(
|
||||
self, messages: list[Message], trusted_token_usage: int = 0
|
||||
) -> list[Message]:
|
||||
"""Process the messages.
|
||||
|
||||
Args:
|
||||
messages: The original message list.
|
||||
|
||||
Returns:
|
||||
The processed message list.
|
||||
"""
|
||||
try:
|
||||
result = messages
|
||||
|
||||
# 1. 基于轮次的截断 (Enforce max turns)
|
||||
if self.config.enforce_max_turns != -1:
|
||||
result = self.truncator.truncate_by_turns(
|
||||
result,
|
||||
keep_most_recent_turns=self.config.enforce_max_turns,
|
||||
drop_turns=self.config.truncate_turns,
|
||||
)
|
||||
|
||||
# 2. 基于 token 的压缩
|
||||
if self.config.max_context_tokens > 0:
|
||||
total_tokens = self.token_counter.count_tokens(
|
||||
result, trusted_token_usage
|
||||
)
|
||||
|
||||
if self.compressor.should_compress(
|
||||
result, total_tokens, self.config.max_context_tokens
|
||||
):
|
||||
result = await self._run_compression(result, total_tokens)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error during context processing: {e}", exc_info=True)
|
||||
return messages
|
||||
|
||||
async def _run_compression(
|
||||
self, messages: list[Message], prev_tokens: int
|
||||
) -> list[Message]:
|
||||
"""
|
||||
Compress/truncate the messages.
|
||||
|
||||
Args:
|
||||
messages: The original message list.
|
||||
prev_tokens: The token count before compression.
|
||||
|
||||
Returns:
|
||||
The compressed/truncated message list.
|
||||
"""
|
||||
logger.debug("Compress triggered, starting compression...")
|
||||
|
||||
messages = await self.compressor(messages)
|
||||
|
||||
# double check
|
||||
tokens_after_summary = self.token_counter.count_tokens(messages)
|
||||
|
||||
# calculate compress rate
|
||||
compress_rate = (tokens_after_summary / self.config.max_context_tokens) * 100
|
||||
logger.info(
|
||||
f"Compress completed."
|
||||
f" {prev_tokens} -> {tokens_after_summary} tokens,"
|
||||
f" compression rate: {compress_rate:.2f}%.",
|
||||
)
|
||||
|
||||
# last check
|
||||
if self.compressor.should_compress(
|
||||
messages, tokens_after_summary, self.config.max_context_tokens
|
||||
):
|
||||
logger.info(
|
||||
"Context still exceeds max tokens after compression, applying halving truncation..."
|
||||
)
|
||||
# still need compress, truncate by half
|
||||
messages = self.truncator.truncate_by_halving(messages)
|
||||
|
||||
return messages
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from ..message import Message, TextPart
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TokenCounter(Protocol):
|
||||
"""
|
||||
Protocol for token counters.
|
||||
Provides an interface for counting tokens in message lists.
|
||||
"""
|
||||
|
||||
def count_tokens(
|
||||
self, messages: list[Message], trusted_token_usage: int = 0
|
||||
) -> int:
|
||||
"""Count the total tokens in the message list.
|
||||
|
||||
Args:
|
||||
messages: The message list.
|
||||
trusted_token_usage: The total token usage that LLM API returned.
|
||||
For some cases, this value is more accurate.
|
||||
But some API does not return it, so the value defaults to 0.
|
||||
|
||||
Returns:
|
||||
The total token count.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class EstimateTokenCounter:
|
||||
"""Estimate token counter implementation.
|
||||
Provides a simple estimation of token count based on character types.
|
||||
"""
|
||||
|
||||
def count_tokens(
|
||||
self, messages: list[Message], trusted_token_usage: int = 0
|
||||
) -> int:
|
||||
if trusted_token_usage > 0:
|
||||
return trusted_token_usage
|
||||
|
||||
total = 0
|
||||
for msg in messages:
|
||||
content = msg.content
|
||||
if isinstance(content, str):
|
||||
total += self._estimate_tokens(content)
|
||||
elif isinstance(content, list):
|
||||
# 处理多模态内容
|
||||
for part in content:
|
||||
if isinstance(part, TextPart):
|
||||
total += self._estimate_tokens(part.text)
|
||||
|
||||
# 处理 Tool Calls
|
||||
if msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
tc_str = json.dumps(tc if isinstance(tc, dict) else tc.model_dump())
|
||||
total += self._estimate_tokens(tc_str)
|
||||
|
||||
return total
|
||||
|
||||
def _estimate_tokens(self, text: str) -> int:
|
||||
chinese_count = len([c for c in text if "\u4e00" <= c <= "\u9fff"])
|
||||
other_count = len(text) - chinese_count
|
||||
return int(chinese_count * 0.6 + other_count * 0.3)
|
||||
@@ -0,0 +1,141 @@
|
||||
from ..message import Message
|
||||
|
||||
|
||||
class ContextTruncator:
|
||||
"""Context truncator."""
|
||||
|
||||
def fix_messages(self, messages: list[Message]) -> list[Message]:
|
||||
fixed_messages = []
|
||||
for message in messages:
|
||||
if message.role == "tool":
|
||||
# tool block 前面必须要有 user 和 assistant block
|
||||
if len(fixed_messages) < 2:
|
||||
# 这种情况可能是上下文被截断导致的
|
||||
# 我们直接将之前的上下文都清空
|
||||
fixed_messages = []
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
return fixed_messages
|
||||
|
||||
def truncate_by_turns(
|
||||
self,
|
||||
messages: list[Message],
|
||||
keep_most_recent_turns: int,
|
||||
drop_turns: int = 1,
|
||||
) -> list[Message]:
|
||||
"""截断上下文列表,确保不超过最大长度。
|
||||
一个 turn 包含一个 user 消息和一个 assistant 消息。
|
||||
这个方法会保证截断后的上下文列表符合 OpenAI 的上下文格式。
|
||||
|
||||
Args:
|
||||
messages: 上下文列表
|
||||
keep_most_recent_turns: 保留最近的对话轮数
|
||||
drop_turns: 一次性丢弃的对话轮数
|
||||
|
||||
Returns:
|
||||
截断后的上下文列表
|
||||
"""
|
||||
if keep_most_recent_turns == -1:
|
||||
return messages
|
||||
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
|
||||
if len(non_system_messages) // 2 <= keep_most_recent_turns:
|
||||
return messages
|
||||
|
||||
num_to_keep = keep_most_recent_turns - drop_turns + 1
|
||||
if num_to_keep <= 0:
|
||||
truncated_contexts = []
|
||||
else:
|
||||
truncated_contexts = non_system_messages[-num_to_keep * 2 :]
|
||||
|
||||
# 找到第一个 role 为 user 的索引,确保上下文格式正确
|
||||
index = next(
|
||||
(i for i, item in enumerate(truncated_contexts) if item.role == "user"),
|
||||
None,
|
||||
)
|
||||
if index is not None and index > 0:
|
||||
truncated_contexts = truncated_contexts[index:]
|
||||
|
||||
result = system_messages + truncated_contexts
|
||||
|
||||
return self.fix_messages(result)
|
||||
|
||||
def truncate_by_dropping_oldest_turns(
|
||||
self,
|
||||
messages: list[Message],
|
||||
drop_turns: int = 1,
|
||||
) -> list[Message]:
|
||||
"""丢弃最旧的 N 个对话轮次。"""
|
||||
if drop_turns <= 0:
|
||||
return messages
|
||||
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
|
||||
if len(non_system_messages) // 2 <= drop_turns:
|
||||
truncated_non_system = []
|
||||
else:
|
||||
truncated_non_system = non_system_messages[drop_turns * 2 :]
|
||||
|
||||
index = next(
|
||||
(i for i, item in enumerate(truncated_non_system) if item.role == "user"),
|
||||
None,
|
||||
)
|
||||
if index is not None:
|
||||
truncated_non_system = truncated_non_system[index:]
|
||||
elif truncated_non_system:
|
||||
truncated_non_system = []
|
||||
|
||||
result = system_messages + truncated_non_system
|
||||
|
||||
return self.fix_messages(result)
|
||||
|
||||
def truncate_by_halving(
|
||||
self,
|
||||
messages: list[Message],
|
||||
) -> list[Message]:
|
||||
"""对半砍策略,删除 50% 的消息"""
|
||||
if len(messages) <= 2:
|
||||
return messages
|
||||
|
||||
first_non_system = 0
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role != "system":
|
||||
first_non_system = i
|
||||
break
|
||||
|
||||
system_messages = messages[:first_non_system]
|
||||
non_system_messages = messages[first_non_system:]
|
||||
|
||||
messages_to_delete = len(non_system_messages) // 2
|
||||
if messages_to_delete == 0:
|
||||
return messages
|
||||
|
||||
truncated_non_system = non_system_messages[messages_to_delete:]
|
||||
|
||||
index = next(
|
||||
(i for i, item in enumerate(truncated_non_system) if item.role == "user"),
|
||||
None,
|
||||
)
|
||||
if index is not None:
|
||||
truncated_non_system = truncated_non_system[index:]
|
||||
|
||||
result = system_messages + truncated_non_system
|
||||
|
||||
return self.fix_messages(result)
|
||||
@@ -1,14 +1,18 @@
|
||||
from typing import Generic
|
||||
from .tool import FunctionTool
|
||||
|
||||
from .agent import Agent
|
||||
from .run_context import TContext
|
||||
from .tool import FunctionTool
|
||||
|
||||
|
||||
class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
"""Handoff tool for delegating tasks to another agent."""
|
||||
|
||||
def __init__(
|
||||
self, agent: Agent[TContext], parameters: dict | None = None, **kwargs
|
||||
self,
|
||||
agent: Agent[TContext],
|
||||
parameters: dict | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.agent = agent
|
||||
super().__init__(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import mcp
|
||||
from dataclasses import dataclass
|
||||
from .run_context import ContextWrapper, TContext
|
||||
from typing import Generic
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
|
||||
import mcp
|
||||
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
|
||||
from .run_context import ContextWrapper, TContext
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseAgentRunHooks(Generic[TContext]):
|
||||
async def on_agent_begin(self, run_context: ContextWrapper[TContext]): ...
|
||||
async def on_tool_start(
|
||||
@@ -23,5 +24,7 @@ class BaseAgentRunHooks(Generic[TContext]):
|
||||
tool_result: mcp.types.CallToolResult | None,
|
||||
): ...
|
||||
async def on_agent_done(
|
||||
self, run_context: ContextWrapper[TContext], llm_response: LLMResponse
|
||||
self,
|
||||
run_context: ContextWrapper[TContext],
|
||||
llm_response: LLMResponse,
|
||||
): ...
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
from typing import Generic
|
||||
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.utils.log_pipe import LogPipe
|
||||
|
||||
from .run_context import TContext
|
||||
from .tool import FunctionTool
|
||||
|
||||
try:
|
||||
import anyio
|
||||
import mcp
|
||||
from mcp.client.sse import sse_client
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
|
||||
logger.warning(
|
||||
"Warning: Missing 'mcp' dependency, MCP services will be unavailable."
|
||||
)
|
||||
|
||||
try:
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning(
|
||||
"警告: 缺少依赖库 'mcp' 或者 mcp 库版本过低,无法使用 Streamable HTTP 连接方式。"
|
||||
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
|
||||
)
|
||||
|
||||
|
||||
def _prepare_config(config: dict) -> dict:
|
||||
"""准备配置,处理嵌套格式"""
|
||||
if "mcpServers" in config and config["mcpServers"]:
|
||||
"""Prepare configuration, handle nested format"""
|
||||
if config.get("mcpServers"):
|
||||
first_key = next(iter(config["mcpServers"]))
|
||||
config = config["mcpServers"][first_key]
|
||||
config.pop("active", None)
|
||||
@@ -30,7 +46,7 @@ def _prepare_config(config: dict) -> dict:
|
||||
|
||||
|
||||
async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
"""快速测试 MCP 服务器可达性"""
|
||||
"""Quick test MCP server connectivity"""
|
||||
import aiohttp
|
||||
|
||||
cfg = _prepare_config(config.copy())
|
||||
@@ -40,8 +56,15 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
timeout = cfg.get("timeout", 10)
|
||||
|
||||
try:
|
||||
if "transport" in cfg:
|
||||
transport_type = cfg["transport"]
|
||||
elif "type" in cfg:
|
||||
transport_type = cfg["type"]
|
||||
else:
|
||||
raise Exception("MCP connection config missing transport or type field")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
if cfg.get("transport") == "streamable_http":
|
||||
if transport_type == "streamable_http":
|
||||
test_payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
@@ -64,8 +87,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return True, ""
|
||||
else:
|
||||
return False, f"HTTP {response.status}: {response.reason}"
|
||||
return False, f"HTTP {response.status}: {response.reason}"
|
||||
else:
|
||||
async with session.get(
|
||||
url,
|
||||
@@ -77,11 +99,10 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return True, ""
|
||||
else:
|
||||
return False, f"HTTP {response.status}: {response.reason}"
|
||||
return False, f"HTTP {response.status}: {response.reason}"
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return False, f"连接超时: {timeout}秒"
|
||||
return False, f"Connection timeout: {timeout} seconds"
|
||||
except Exception as e:
|
||||
return False, f"{e!s}"
|
||||
|
||||
@@ -89,8 +110,9 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
class MCPClient:
|
||||
def __init__(self):
|
||||
# Initialize session and client objects
|
||||
self.session: Optional[mcp.ClientSession] = None
|
||||
self.session: mcp.ClientSession | None = None
|
||||
self.exit_stack = AsyncExitStack()
|
||||
self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup
|
||||
|
||||
self.name: str | None = None
|
||||
self.active: bool = True
|
||||
@@ -98,21 +120,32 @@ class MCPClient:
|
||||
self.server_errlogs: list[str] = []
|
||||
self.running_event = asyncio.Event()
|
||||
|
||||
async def connect_to_server(self, mcp_server_config: dict, name: str):
|
||||
"""连接到 MCP 服务器
|
||||
# Store connection config for reconnection
|
||||
self._mcp_server_config: dict | None = None
|
||||
self._server_name: str | None = None
|
||||
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
|
||||
self._reconnecting: bool = False # For logging and debugging
|
||||
|
||||
如果 `url` 参数存在:
|
||||
1. 当 transport 指定为 `streamable_http` 时,使用 Streamable HTTP 连接方式。
|
||||
1. 当 transport 指定为 `sse` 时,使用 SSE 连接方式。
|
||||
2. 如果没有指定,默认使用 SSE 的方式连接到 MCP 服务。
|
||||
async def connect_to_server(self, mcp_server_config: dict, name: str):
|
||||
"""Connect to MCP server
|
||||
|
||||
If `url` parameter exists:
|
||||
1. When transport is specified as `streamable_http`, use Streamable HTTP connection.
|
||||
2. When transport is specified as `sse`, use SSE connection.
|
||||
3. If not specified, default to SSE connection to MCP service.
|
||||
|
||||
Args:
|
||||
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
|
||||
|
||||
"""
|
||||
# Store config for reconnection
|
||||
self._mcp_server_config = mcp_server_config
|
||||
self._server_name = name
|
||||
|
||||
cfg = _prepare_config(mcp_server_config.copy())
|
||||
|
||||
def logging_callback(msg: str):
|
||||
# 处理 MCP 服务的错误日志
|
||||
# Handle MCP service error logs
|
||||
print(f"MCP Server {name} Error: {msg}")
|
||||
self.server_errlogs.append(msg)
|
||||
|
||||
@@ -121,7 +154,14 @@ class MCPClient:
|
||||
if not success:
|
||||
raise Exception(error_msg)
|
||||
|
||||
if cfg.get("transport") != "streamable_http":
|
||||
if "transport" in cfg:
|
||||
transport_type = cfg["transport"]
|
||||
elif "type" in cfg:
|
||||
transport_type = cfg["type"]
|
||||
else:
|
||||
raise Exception("MCP connection config missing transport or type field")
|
||||
|
||||
if transport_type != "streamable_http":
|
||||
# SSE transport method
|
||||
self._streams_context = sse_client(
|
||||
url=cfg["url"],
|
||||
@@ -130,22 +170,22 @@ class MCPClient:
|
||||
sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5),
|
||||
)
|
||||
streams = await self.exit_stack.enter_async_context(
|
||||
self._streams_context
|
||||
self._streams_context,
|
||||
)
|
||||
|
||||
# Create a new client session
|
||||
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20))
|
||||
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(
|
||||
*streams,
|
||||
read_timeout_seconds=read_timeout,
|
||||
logging_callback=logging_callback, # type: ignore
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
timeout = timedelta(seconds=cfg.get("timeout", 30))
|
||||
sse_read_timeout = timedelta(
|
||||
seconds=cfg.get("sse_read_timeout", 60 * 5)
|
||||
seconds=cfg.get("sse_read_timeout", 60 * 5),
|
||||
)
|
||||
self._streams_context = streamablehttp_client(
|
||||
url=cfg["url"],
|
||||
@@ -155,18 +195,18 @@ class MCPClient:
|
||||
terminate_on_close=cfg.get("terminate_on_close", True),
|
||||
)
|
||||
read_s, write_s, _ = await self.exit_stack.enter_async_context(
|
||||
self._streams_context
|
||||
self._streams_context,
|
||||
)
|
||||
|
||||
# Create a new client session
|
||||
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20))
|
||||
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(
|
||||
read_stream=read_s,
|
||||
write_stream=write_s,
|
||||
read_timeout_seconds=read_timeout,
|
||||
logging_callback=logging_callback, # type: ignore
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -175,7 +215,7 @@ class MCPClient:
|
||||
)
|
||||
|
||||
def callback(msg: str):
|
||||
# 处理 MCP 服务的错误日志
|
||||
# Handle MCP service error logs
|
||||
self.server_errlogs.append(msg)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
@@ -192,7 +232,7 @@ class MCPClient:
|
||||
|
||||
# Create a new client session
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(*stdio_transport)
|
||||
mcp.ClientSession(*stdio_transport),
|
||||
)
|
||||
await self.session.initialize()
|
||||
|
||||
@@ -204,7 +244,142 @@ class MCPClient:
|
||||
self.tools = response.tools
|
||||
return response
|
||||
|
||||
async def _reconnect(self) -> None:
|
||||
"""Reconnect to the MCP server using the stored configuration.
|
||||
|
||||
Uses asyncio.Lock to ensure thread-safe reconnection in concurrent environments.
|
||||
|
||||
Raises:
|
||||
Exception: raised when reconnection fails
|
||||
"""
|
||||
async with self._reconnect_lock:
|
||||
# Check if already reconnecting (useful for logging)
|
||||
if self._reconnecting:
|
||||
logger.debug(
|
||||
f"MCP Client {self._server_name} is already reconnecting, skipping"
|
||||
)
|
||||
return
|
||||
|
||||
if not self._mcp_server_config or not self._server_name:
|
||||
raise Exception("Cannot reconnect: missing connection configuration")
|
||||
|
||||
self._reconnecting = True
|
||||
try:
|
||||
logger.info(
|
||||
f"Attempting to reconnect to MCP server {self._server_name}..."
|
||||
)
|
||||
|
||||
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
|
||||
if self.exit_stack:
|
||||
self._old_exit_stacks.append(self.exit_stack)
|
||||
|
||||
# Mark old session as invalid
|
||||
self.session = None
|
||||
|
||||
# Create new exit stack for new connection
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
# Reconnect using stored config
|
||||
await self.connect_to_server(self._mcp_server_config, self._server_name)
|
||||
await self.list_tools_and_save()
|
||||
|
||||
logger.info(
|
||||
f"Successfully reconnected to MCP server {self._server_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to reconnect to MCP server {self._server_name}: {e}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self._reconnecting = False
|
||||
|
||||
async def call_tool_with_reconnect(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict,
|
||||
read_timeout_seconds: timedelta,
|
||||
) -> mcp.types.CallToolResult:
|
||||
"""Call MCP tool with automatic reconnection on failure, max 2 retries.
|
||||
|
||||
Args:
|
||||
tool_name: tool name
|
||||
arguments: tool arguments
|
||||
read_timeout_seconds: read timeout
|
||||
|
||||
Returns:
|
||||
MCP tool call result
|
||||
|
||||
Raises:
|
||||
ValueError: MCP session is not available
|
||||
anyio.ClosedResourceError: raised after reconnection failure
|
||||
"""
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception_type(anyio.ClosedResourceError),
|
||||
stop=stop_after_attempt(2),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=3),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
async def _call_with_retry():
|
||||
if not self.session:
|
||||
raise ValueError("MCP session is not available for MCP function tools.")
|
||||
|
||||
try:
|
||||
return await self.session.call_tool(
|
||||
name=tool_name,
|
||||
arguments=arguments,
|
||||
read_timeout_seconds=read_timeout_seconds,
|
||||
)
|
||||
except anyio.ClosedResourceError:
|
||||
logger.warning(
|
||||
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..."
|
||||
)
|
||||
# Attempt to reconnect
|
||||
await self._reconnect()
|
||||
# Reraise the exception to trigger tenacity retry
|
||||
raise
|
||||
|
||||
return await _call_with_retry()
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up resources"""
|
||||
await self.exit_stack.aclose()
|
||||
self.running_event.set() # Set the running event to indicate cleanup is done
|
||||
"""Clean up resources including old exit stacks from reconnections"""
|
||||
# Close current exit stack
|
||||
try:
|
||||
await self.exit_stack.aclose()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing current exit stack: {e}")
|
||||
|
||||
# Don't close old exit stacks as they may be in different task contexts
|
||||
# They will be garbage collected naturally
|
||||
# Just clear the list to release references
|
||||
self._old_exit_stacks.clear()
|
||||
|
||||
# Set running_event first to unblock any waiting tasks
|
||||
self.running_event.set()
|
||||
|
||||
|
||||
class MCPTool(FunctionTool, Generic[TContext]):
|
||||
"""A function tool that calls an MCP service."""
|
||||
|
||||
def __init__(
|
||||
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs
|
||||
):
|
||||
super().__init__(
|
||||
name=mcp_tool.name,
|
||||
description=mcp_tool.description or "",
|
||||
parameters=mcp_tool.inputSchema,
|
||||
)
|
||||
self.mcp_tool = mcp_tool
|
||||
self.mcp_client = mcp_client
|
||||
self.mcp_server_name = mcp_server_name
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[TContext], **kwargs
|
||||
) -> mcp.types.CallToolResult:
|
||||
return await self.mcp_client.call_tool_with_reconnect(
|
||||
tool_name=self.mcp_tool.name,
|
||||
arguments=kwargs,
|
||||
read_timeout_seconds=timedelta(seconds=context.tool_call_timeout),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# Inspired by MoonshotAI/kosong, credits to MoonshotAI/kosong authors for the original implementation.
|
||||
# License: Apache License 2.0
|
||||
|
||||
from typing import Any, ClassVar, Literal, cast
|
||||
|
||||
from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator
|
||||
from pydantic_core import core_schema
|
||||
|
||||
|
||||
class ContentPart(BaseModel):
|
||||
"""A part of the content in a message."""
|
||||
|
||||
__content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}
|
||||
|
||||
type: Literal["text", "think", "image_url", "audio_url"]
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
invalid_subclass_error_msg = f"ContentPart subclass {cls.__name__} must have a `type` field of type `str`"
|
||||
|
||||
type_value = getattr(cls, "type", None)
|
||||
if type_value is None or not isinstance(type_value, str):
|
||||
raise ValueError(invalid_subclass_error_msg)
|
||||
|
||||
cls.__content_part_registry[type_value] = cls
|
||||
|
||||
@classmethod
|
||||
def __get_pydantic_core_schema__(
|
||||
cls, source_type: Any, handler: GetCoreSchemaHandler
|
||||
) -> core_schema.CoreSchema:
|
||||
# If we're dealing with the base ContentPart class, use custom validation
|
||||
if cls.__name__ == "ContentPart":
|
||||
|
||||
def validate_content_part(value: Any) -> Any:
|
||||
# if it's already an instance of a ContentPart subclass, return it
|
||||
if hasattr(value, "__class__") and issubclass(value.__class__, cls):
|
||||
return value
|
||||
|
||||
# if it's a dict with a type field, dispatch to the appropriate subclass
|
||||
if isinstance(value, dict) and "type" in value:
|
||||
type_value: Any | None = cast(dict[str, Any], value).get("type")
|
||||
if not isinstance(type_value, str):
|
||||
raise ValueError(f"Cannot validate {value} as ContentPart")
|
||||
target_class = cls.__content_part_registry[type_value]
|
||||
return target_class.model_validate(value)
|
||||
|
||||
raise ValueError(f"Cannot validate {value} as ContentPart")
|
||||
|
||||
return core_schema.no_info_plain_validator_function(validate_content_part)
|
||||
|
||||
# for subclasses, use the default schema
|
||||
return handler(source_type)
|
||||
|
||||
|
||||
class TextPart(ContentPart):
|
||||
"""
|
||||
>>> TextPart(text="Hello, world!").model_dump()
|
||||
{'type': 'text', 'text': 'Hello, world!'}
|
||||
"""
|
||||
|
||||
type: str = "text"
|
||||
text: str
|
||||
|
||||
|
||||
class ThinkPart(ContentPart):
|
||||
"""
|
||||
>>> ThinkPart(think="I think I need to think about this.").model_dump()
|
||||
{'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
|
||||
"""
|
||||
|
||||
type: str = "think"
|
||||
think: str
|
||||
encrypted: str | None = None
|
||||
"""Encrypted thinking content, or signature."""
|
||||
|
||||
def merge_in_place(self, other: Any) -> bool:
|
||||
if not isinstance(other, ThinkPart):
|
||||
return False
|
||||
if self.encrypted:
|
||||
return False
|
||||
self.think += other.think
|
||||
if other.encrypted:
|
||||
self.encrypted = other.encrypted
|
||||
return True
|
||||
|
||||
|
||||
class ImageURLPart(ContentPart):
|
||||
"""
|
||||
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
|
||||
{'type': 'image_url', 'image_url': 'http://example.com/image.jpg'}
|
||||
"""
|
||||
|
||||
class ImageURL(BaseModel):
|
||||
url: str
|
||||
"""The URL of the image, can be data URI scheme like `data:image/png;base64,...`."""
|
||||
id: str | None = None
|
||||
"""The ID of the image, to allow LLMs to distinguish different images."""
|
||||
|
||||
type: str = "image_url"
|
||||
image_url: ImageURL
|
||||
|
||||
|
||||
class AudioURLPart(ContentPart):
|
||||
"""
|
||||
>>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
|
||||
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
|
||||
"""
|
||||
|
||||
class AudioURL(BaseModel):
|
||||
url: str
|
||||
"""The URL of the audio, can be data URI scheme like `data:audio/aac;base64,...`."""
|
||||
id: str | None = None
|
||||
"""The ID of the audio, to allow LLMs to distinguish different audios."""
|
||||
|
||||
type: str = "audio_url"
|
||||
audio_url: AudioURL
|
||||
|
||||
|
||||
class ToolCall(BaseModel):
|
||||
"""
|
||||
A tool call requested by the assistant.
|
||||
|
||||
>>> ToolCall(
|
||||
... id="123",
|
||||
... function=ToolCall.FunctionBody(
|
||||
... name="function",
|
||||
... arguments="{}"
|
||||
... ),
|
||||
... ).model_dump()
|
||||
{'type': 'function', 'id': '123', 'function': {'name': 'function', 'arguments': '{}'}}
|
||||
"""
|
||||
|
||||
class FunctionBody(BaseModel):
|
||||
name: str
|
||||
arguments: str | None
|
||||
|
||||
type: Literal["function"] = "function"
|
||||
|
||||
id: str
|
||||
"""The ID of the tool call."""
|
||||
function: FunctionBody
|
||||
"""The function body of the tool call."""
|
||||
extra_content: dict[str, Any] | None = None
|
||||
"""Extra metadata for the tool call."""
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def serialize(self, handler):
|
||||
data = handler(self)
|
||||
if self.extra_content is None:
|
||||
data.pop("extra_content", None)
|
||||
return data
|
||||
|
||||
|
||||
class ToolCallPart(BaseModel):
|
||||
"""A part of the tool call."""
|
||||
|
||||
arguments_part: str | None = None
|
||||
"""A part of the arguments of the tool call."""
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""A message in a conversation."""
|
||||
|
||||
role: Literal[
|
||||
"system",
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
]
|
||||
|
||||
content: str | list[ContentPart] | None = None
|
||||
"""The content of the message."""
|
||||
|
||||
tool_calls: list[ToolCall] | list[dict] | None = None
|
||||
"""The tool calls of the message."""
|
||||
|
||||
tool_call_id: str | None = None
|
||||
"""The ID of the tool call."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_content_required(self):
|
||||
# assistant + tool_calls is not None: allow content to be None
|
||||
if self.role == "assistant" and self.tool_calls is not None:
|
||||
return self
|
||||
|
||||
# other all cases: content is required
|
||||
if self.content is None:
|
||||
raise ValueError(
|
||||
"content is required unless role='assistant' and tool_calls is not None"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def serialize(self, handler):
|
||||
data = handler(self)
|
||||
if self.tool_calls is None:
|
||||
data.pop("tool_calls", None)
|
||||
if self.tool_call_id is None:
|
||||
data.pop("tool_call_id", None)
|
||||
return data
|
||||
|
||||
|
||||
class AssistantMessageSegment(Message):
|
||||
"""A message segment from the assistant."""
|
||||
|
||||
role: Literal["assistant"] = "assistant"
|
||||
|
||||
|
||||
class ToolCallMessageSegment(Message):
|
||||
"""A message segment representing a tool call."""
|
||||
|
||||
role: Literal["tool"] = "tool"
|
||||
|
||||
|
||||
class UserMessageSegment(Message):
|
||||
"""A message segment from the user."""
|
||||
|
||||
role: Literal["user"] = "user"
|
||||
|
||||
|
||||
class SystemMessageSegment(Message):
|
||||
"""A message segment from the system."""
|
||||
|
||||
role: Literal["system"] = "system"
|
||||
@@ -1,6 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
import typing as T
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import TokenUsage
|
||||
|
||||
|
||||
class AgentResponseData(T.TypedDict):
|
||||
@@ -11,3 +13,23 @@ class AgentResponseData(T.TypedDict):
|
||||
class AgentResponse:
|
||||
type: str
|
||||
data: AgentResponseData
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentStats:
|
||||
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
start_time: float = 0.0
|
||||
end_time: float = 0.0
|
||||
time_to_first_token: float = 0.0
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.end_time - self.start_time
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"token_usage": self.token_usage.__dict__,
|
||||
"start_time": self.start_time,
|
||||
"end_time": self.end_time,
|
||||
"time_to_first_token": self.time_to_first_token,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from .message import Message
|
||||
|
||||
TContext = TypeVar("TContext", default=Any)
|
||||
|
||||
@@ -12,7 +14,9 @@ class ContextWrapper(Generic[TContext]):
|
||||
"""A context for running an agent, which can be used to pass additional data or state."""
|
||||
|
||||
context: TContext
|
||||
event: AstrMessageEvent
|
||||
messages: list[Message] = Field(default_factory=list)
|
||||
"""This field stores the llm message context for the agent run, agent runners will maintain this field automatically."""
|
||||
tool_call_timeout: int = 60 # Default tool call timeout in seconds
|
||||
|
||||
|
||||
NoContext = ContextWrapper[None]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import abc
|
||||
import typing as T
|
||||
from enum import Enum, auto
|
||||
from ..run_context import ContextWrapper, TContext
|
||||
from ..response import AgentResponse
|
||||
from ..hooks import BaseAgentRunHooks
|
||||
from ..tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.provider import Provider
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
|
||||
from ..hooks import BaseAgentRunHooks
|
||||
from ..response import AgentResponse
|
||||
from ..run_context import ContextWrapper, TContext
|
||||
|
||||
|
||||
class AgentState(Enum):
|
||||
"""Defines the state of the agent."""
|
||||
@@ -22,37 +23,43 @@ class BaseAgentRunner(T.Generic[TContext]):
|
||||
@abc.abstractmethod
|
||||
async def reset(
|
||||
self,
|
||||
provider: Provider,
|
||||
run_context: ContextWrapper[TContext],
|
||||
tool_executor: BaseFunctionToolExecutor[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
"""
|
||||
Reset the agent to its initial state.
|
||||
"""Reset the agent to its initial state.
|
||||
This method should be called before starting a new run.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def step(self) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
"""
|
||||
Process a single step of the agent.
|
||||
"""
|
||||
"""Process a single step of the agent."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def step_until_done(
|
||||
self, max_step: int
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
"""Process steps until the agent is done."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def done(self) -> bool:
|
||||
"""
|
||||
Check if the agent has completed its task.
|
||||
"""Check if the agent has completed its task.
|
||||
Returns True if the agent is done, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
"""
|
||||
Get the final observation from the agent.
|
||||
"""Get the final observation from the agent.
|
||||
This method should be called after the agent is done.
|
||||
"""
|
||||
...
|
||||
|
||||
def _transition_state(self, new_state: AgentState) -> None:
|
||||
"""Transition the agent state."""
|
||||
if self._state != new_state:
|
||||
logger.debug(f"Agent state transition: {self._state} -> {new_state}")
|
||||
self._state = new_state
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import typing as T
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
ProviderRequest,
|
||||
)
|
||||
|
||||
from ...hooks import BaseAgentRunHooks
|
||||
from ...response import AgentResponseData
|
||||
from ...run_context import ContextWrapper, TContext
|
||||
from ..base import AgentResponse, AgentState, BaseAgentRunner
|
||||
from .coze_api_client import CozeAPIClient
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
class CozeAgentRunner(BaseAgentRunner[TContext]):
|
||||
"""Coze Agent Runner"""
|
||||
|
||||
@override
|
||||
async def reset(
|
||||
self,
|
||||
request: ProviderRequest,
|
||||
run_context: ContextWrapper[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
provider_config: dict,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
self.streaming = kwargs.get("streaming", False)
|
||||
self.final_llm_resp = None
|
||||
self._state = AgentState.IDLE
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
|
||||
self.api_key = provider_config.get("coze_api_key", "")
|
||||
if not self.api_key:
|
||||
raise Exception("Coze API Key 不能为空。")
|
||||
self.bot_id = provider_config.get("bot_id", "")
|
||||
if not self.bot_id:
|
||||
raise Exception("Coze Bot ID 不能为空。")
|
||||
self.api_base: str = provider_config.get("coze_api_base", "https://api.coze.cn")
|
||||
|
||||
if not isinstance(self.api_base, str) or not self.api_base.startswith(
|
||||
("http://", "https://"),
|
||||
):
|
||||
raise Exception(
|
||||
"Coze API Base URL 格式不正确,必须以 http:// 或 https:// 开头。",
|
||||
)
|
||||
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
self.auto_save_history = provider_config.get("auto_save_history", True)
|
||||
|
||||
# 创建 API 客户端
|
||||
self.api_client = CozeAPIClient(api_key=self.api_key, api_base=self.api_base)
|
||||
|
||||
# 会话相关缓存
|
||||
self.file_id_cache: dict[str, dict[str, str]] = {}
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""
|
||||
执行 Coze Agent 的一个步骤
|
||||
"""
|
||||
if not self.req:
|
||||
raise ValueError("Request is not set. Please call reset() first.")
|
||||
|
||||
if self._state == AgentState.IDLE:
|
||||
try:
|
||||
await self.agent_hooks.on_agent_begin(self.run_context)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
|
||||
|
||||
# 开始处理,转换到运行状态
|
||||
self._transition_state(AgentState.RUNNING)
|
||||
|
||||
try:
|
||||
# 执行 Coze 请求并处理结果
|
||||
async for response in self._execute_coze_request():
|
||||
yield response
|
||||
except Exception as e:
|
||||
logger.error(f"Coze 请求失败:{str(e)}")
|
||||
self._transition_state(AgentState.ERROR)
|
||||
self.final_llm_resp = LLMResponse(
|
||||
role="err", completion_text=f"Coze 请求失败:{str(e)}"
|
||||
)
|
||||
yield AgentResponse(
|
||||
type="err",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(f"Coze 请求失败:{str(e)}")
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await self.api_client.close()
|
||||
|
||||
@override
|
||||
async def step_until_done(
|
||||
self, max_step: int = 30
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
while not self.done():
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
|
||||
async def _execute_coze_request(self):
|
||||
"""执行 Coze 请求的核心逻辑"""
|
||||
prompt = self.req.prompt or ""
|
||||
session_id = self.req.session_id or "unknown"
|
||||
image_urls = self.req.image_urls or []
|
||||
contexts = self.req.contexts or []
|
||||
system_prompt = self.req.system_prompt
|
||||
|
||||
# 用户ID参数
|
||||
user_id = session_id
|
||||
|
||||
# 获取或创建会话ID
|
||||
conversation_id = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=user_id,
|
||||
key="coze_conversation_id",
|
||||
default="",
|
||||
)
|
||||
|
||||
# 构建消息
|
||||
additional_messages = []
|
||||
|
||||
if system_prompt:
|
||||
if not self.auto_save_history or not conversation_id:
|
||||
additional_messages.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt,
|
||||
"content_type": "text",
|
||||
},
|
||||
)
|
||||
|
||||
# 处理历史上下文
|
||||
if not self.auto_save_history and contexts:
|
||||
for ctx in contexts:
|
||||
if isinstance(ctx, dict) and "role" in ctx and "content" in ctx:
|
||||
# 处理上下文中的图片
|
||||
content = ctx["content"]
|
||||
if isinstance(content, list):
|
||||
# 多模态内容,需要处理图片
|
||||
processed_content = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get("type") == "text":
|
||||
processed_content.append(item)
|
||||
elif item.get("type") == "image_url":
|
||||
# 处理图片上传
|
||||
try:
|
||||
image_data = item.get("image_url", {})
|
||||
url = image_data.get("url", "")
|
||||
if url:
|
||||
file_id = (
|
||||
await self._download_and_upload_image(
|
||||
url, session_id
|
||||
)
|
||||
)
|
||||
processed_content.append(
|
||||
{
|
||||
"type": "file",
|
||||
"file_id": file_id,
|
||||
"file_url": url,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"处理上下文图片失败: {e}")
|
||||
continue
|
||||
|
||||
if processed_content:
|
||||
additional_messages.append(
|
||||
{
|
||||
"role": ctx["role"],
|
||||
"content": processed_content,
|
||||
"content_type": "object_string",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 纯文本内容
|
||||
additional_messages.append(
|
||||
{
|
||||
"role": ctx["role"],
|
||||
"content": content,
|
||||
"content_type": "text",
|
||||
}
|
||||
)
|
||||
|
||||
# 构建当前消息
|
||||
if prompt or image_urls:
|
||||
if image_urls:
|
||||
# 多模态
|
||||
object_string_content = []
|
||||
if prompt:
|
||||
object_string_content.append({"type": "text", "text": prompt})
|
||||
|
||||
for url in image_urls:
|
||||
# the url is a base64 string
|
||||
try:
|
||||
image_data = base64.b64decode(url)
|
||||
file_id = await self.api_client.upload_file(image_data)
|
||||
object_string_content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"file_id": file_id,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"处理图片失败 {url}: {e}")
|
||||
continue
|
||||
|
||||
if object_string_content:
|
||||
content = json.dumps(object_string_content, ensure_ascii=False)
|
||||
additional_messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": content,
|
||||
"content_type": "object_string",
|
||||
}
|
||||
)
|
||||
elif prompt:
|
||||
# 纯文本
|
||||
additional_messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
"content_type": "text",
|
||||
},
|
||||
)
|
||||
|
||||
# 执行 Coze API 请求
|
||||
accumulated_content = ""
|
||||
message_started = False
|
||||
|
||||
async for chunk in self.api_client.chat_messages(
|
||||
bot_id=self.bot_id,
|
||||
user_id=user_id,
|
||||
additional_messages=additional_messages,
|
||||
conversation_id=conversation_id,
|
||||
auto_save_history=self.auto_save_history,
|
||||
stream=True,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
event_type = chunk.get("event")
|
||||
data = chunk.get("data", {})
|
||||
|
||||
if event_type == "conversation.chat.created":
|
||||
if isinstance(data, dict) and "conversation_id" in data:
|
||||
await sp.put_async(
|
||||
scope="umo",
|
||||
scope_id=user_id,
|
||||
key="coze_conversation_id",
|
||||
value=data["conversation_id"],
|
||||
)
|
||||
|
||||
if event_type == "conversation.message.delta":
|
||||
# 增量消息
|
||||
content = data.get("content", "")
|
||||
if not content and "delta" in data:
|
||||
content = data["delta"].get("content", "")
|
||||
if not content and "text" in data:
|
||||
content = data.get("text", "")
|
||||
|
||||
if content:
|
||||
accumulated_content += content
|
||||
message_started = True
|
||||
|
||||
# 如果是流式响应,发送增量数据
|
||||
if self.streaming:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(content)
|
||||
),
|
||||
)
|
||||
|
||||
elif event_type == "conversation.message.completed":
|
||||
# 消息完成
|
||||
logger.debug("Coze message completed")
|
||||
message_started = True
|
||||
|
||||
elif event_type == "conversation.chat.completed":
|
||||
# 对话完成
|
||||
logger.debug("Coze chat completed")
|
||||
break
|
||||
|
||||
elif event_type == "error":
|
||||
# 错误处理
|
||||
error_msg = data.get("msg", "未知错误")
|
||||
error_code = data.get("code", "UNKNOWN")
|
||||
logger.error(f"Coze 出现错误: {error_code} - {error_msg}")
|
||||
raise Exception(f"Coze 出现错误: {error_code} - {error_msg}")
|
||||
|
||||
if not message_started and not accumulated_content:
|
||||
logger.warning("Coze 未返回任何内容")
|
||||
accumulated_content = ""
|
||||
|
||||
# 创建最终响应
|
||||
chain = MessageChain(chain=[Comp.Plain(accumulated_content)])
|
||||
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
|
||||
self._transition_state(AgentState.DONE)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
|
||||
# 返回最终结果
|
||||
yield AgentResponse(
|
||||
type="llm_result",
|
||||
data=AgentResponseData(chain=chain),
|
||||
)
|
||||
|
||||
async def _download_and_upload_image(
|
||||
self,
|
||||
image_url: str,
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""下载图片并上传到 Coze,返回 file_id"""
|
||||
import hashlib
|
||||
|
||||
# 计算哈希实现缓存
|
||||
cache_key = hashlib.md5(image_url.encode("utf-8")).hexdigest()
|
||||
|
||||
if session_id:
|
||||
if session_id not in self.file_id_cache:
|
||||
self.file_id_cache[session_id] = {}
|
||||
|
||||
if cache_key in self.file_id_cache[session_id]:
|
||||
file_id = self.file_id_cache[session_id][cache_key]
|
||||
logger.debug(f"[Coze] 使用缓存的 file_id: {file_id}")
|
||||
return file_id
|
||||
|
||||
try:
|
||||
image_data = await self.api_client.download_image(image_url)
|
||||
file_id = await self.api_client.upload_file(image_data)
|
||||
|
||||
if session_id:
|
||||
self.file_id_cache[session_id][cache_key] = file_id
|
||||
logger.debug(f"[Coze] 图片上传成功并缓存,file_id: {file_id}")
|
||||
|
||||
return file_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理图片失败 {image_url}: {e!s}")
|
||||
raise Exception(f"处理图片失败: {e!s}")
|
||||
|
||||
@override
|
||||
def done(self) -> bool:
|
||||
"""检查 Agent 是否已完成工作"""
|
||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
||||
|
||||
@override
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
return self.final_llm_resp
|
||||
+27
-17
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import io
|
||||
from typing import Dict, List, Any, AsyncGenerator
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.core import logger
|
||||
|
||||
|
||||
@@ -32,7 +35,9 @@ class CozeAPIClient:
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
self.session = aiohttp.ClientSession(
|
||||
headers=headers, timeout=timeout, connector=connector
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
connector=connector,
|
||||
)
|
||||
return self.session
|
||||
|
||||
@@ -46,6 +51,7 @@ class CozeAPIClient:
|
||||
file_data (bytes): 文件的二进制数据
|
||||
Returns:
|
||||
str: 上传成功后返回的 file_id
|
||||
|
||||
"""
|
||||
session = await self._ensure_session()
|
||||
url = f"{self.api_base}/v1/files/upload"
|
||||
@@ -64,12 +70,12 @@ class CozeAPIClient:
|
||||
|
||||
response_text = await response.text()
|
||||
logger.debug(
|
||||
f"文件上传响应状态: {response.status}, 内容: {response_text}"
|
||||
f"文件上传响应状态: {response.status}, 内容: {response_text}",
|
||||
)
|
||||
|
||||
if response.status != 200:
|
||||
raise Exception(
|
||||
f"文件上传失败,状态码: {response.status}, 响应: {response_text}"
|
||||
f"文件上传失败,状态码: {response.status}, 响应: {response_text}",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -88,8 +94,8 @@ class CozeAPIClient:
|
||||
logger.error("文件上传超时")
|
||||
raise Exception("文件上传超时")
|
||||
except Exception as e:
|
||||
logger.error(f"文件上传失败: {str(e)}")
|
||||
raise Exception(f"文件上传失败: {str(e)}")
|
||||
logger.error(f"文件上传失败: {e!s}")
|
||||
raise Exception(f"文件上传失败: {e!s}")
|
||||
|
||||
async def download_image(self, image_url: str) -> bytes:
|
||||
"""下载图片并返回字节数据
|
||||
@@ -98,6 +104,7 @@ class CozeAPIClient:
|
||||
image_url (str): 图片的URL
|
||||
Returns:
|
||||
bytes: 图片的二进制数据
|
||||
|
||||
"""
|
||||
session = await self._ensure_session()
|
||||
|
||||
@@ -110,19 +117,19 @@ class CozeAPIClient:
|
||||
return image_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"下载图片失败 {image_url}: {str(e)}")
|
||||
raise Exception(f"下载图片失败: {str(e)}")
|
||||
logger.error(f"下载图片失败 {image_url}: {e!s}")
|
||||
raise Exception(f"下载图片失败: {e!s}")
|
||||
|
||||
async def chat_messages(
|
||||
self,
|
||||
bot_id: str,
|
||||
user_id: str,
|
||||
additional_messages: List[Dict] | None = None,
|
||||
additional_messages: list[dict] | None = None,
|
||||
conversation_id: str | None = None,
|
||||
auto_save_history: bool = True,
|
||||
stream: bool = True,
|
||||
timeout: float = 120,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""发送聊天消息并返回流式响应
|
||||
|
||||
Args:
|
||||
@@ -133,6 +140,7 @@ class CozeAPIClient:
|
||||
auto_save_history: 是否自动保存历史
|
||||
stream: 是否流式响应
|
||||
timeout: 超时时间
|
||||
|
||||
"""
|
||||
session = await self._ensure_session()
|
||||
url = f"{self.api_base}/v3/chat"
|
||||
@@ -198,7 +206,7 @@ class CozeAPIClient:
|
||||
except asyncio.TimeoutError:
|
||||
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")
|
||||
except Exception as e:
|
||||
raise Exception(f"Coze API 流式请求失败: {str(e)}")
|
||||
raise Exception(f"Coze API 流式请求失败: {e!s}")
|
||||
|
||||
async def clear_context(self, conversation_id: str):
|
||||
"""清空会话上下文
|
||||
@@ -207,6 +215,7 @@ class CozeAPIClient:
|
||||
conversation_id: 会话ID
|
||||
Returns:
|
||||
dict: API响应结果
|
||||
|
||||
"""
|
||||
session = await self._ensure_session()
|
||||
url = f"{self.api_base}/v3/conversation/message/clear_context"
|
||||
@@ -230,7 +239,7 @@ class CozeAPIClient:
|
||||
except asyncio.TimeoutError:
|
||||
raise Exception("Coze API 请求超时")
|
||||
except aiohttp.ClientError as e:
|
||||
raise Exception(f"Coze API 请求失败: {str(e)}")
|
||||
raise Exception(f"Coze API 请求失败: {e!s}")
|
||||
|
||||
async def get_message_list(
|
||||
self,
|
||||
@@ -248,6 +257,7 @@ class CozeAPIClient:
|
||||
offset: 偏移量
|
||||
Returns:
|
||||
dict: API响应结果
|
||||
|
||||
"""
|
||||
session = await self._ensure_session()
|
||||
url = f"{self.api_base}/v3/conversation/message/list"
|
||||
@@ -264,8 +274,8 @@ class CozeAPIClient:
|
||||
return await response.json()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取Coze消息列表失败: {str(e)}")
|
||||
raise Exception(f"获取Coze消息列表失败: {str(e)}")
|
||||
logger.error(f"获取Coze消息列表失败: {e!s}")
|
||||
raise Exception(f"获取Coze消息列表失败: {e!s}")
|
||||
|
||||
async def close(self):
|
||||
"""关闭会话"""
|
||||
@@ -275,8 +285,8 @@ class CozeAPIClient:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
async def test_coze_api_client():
|
||||
api_key = os.getenv("COZE_API_KEY", "")
|
||||
@@ -0,0 +1,403 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import queue
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import typing as T
|
||||
|
||||
from dashscope import Application
|
||||
from dashscope.app.application_response import ApplicationResponse
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot.core import logger, sp
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
ProviderRequest,
|
||||
)
|
||||
|
||||
from ...hooks import BaseAgentRunHooks
|
||||
from ...response import AgentResponseData
|
||||
from ...run_context import ContextWrapper, TContext
|
||||
from ..base import AgentResponse, AgentState, BaseAgentRunner
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
"""Dashscope Agent Runner"""
|
||||
|
||||
@override
|
||||
async def reset(
|
||||
self,
|
||||
request: ProviderRequest,
|
||||
run_context: ContextWrapper[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
provider_config: dict,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
self.streaming = kwargs.get("streaming", False)
|
||||
self.final_llm_resp = None
|
||||
self._state = AgentState.IDLE
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
|
||||
self.api_key = provider_config.get("dashscope_api_key", "")
|
||||
if not self.api_key:
|
||||
raise Exception("阿里云百炼 API Key 不能为空。")
|
||||
self.app_id = provider_config.get("dashscope_app_id", "")
|
||||
if not self.app_id:
|
||||
raise Exception("阿里云百炼 APP ID 不能为空。")
|
||||
self.dashscope_app_type = provider_config.get("dashscope_app_type", "")
|
||||
if not self.dashscope_app_type:
|
||||
raise Exception("阿里云百炼 APP 类型不能为空。")
|
||||
|
||||
self.variables: dict = provider_config.get("variables", {}) or {}
|
||||
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):
|
||||
"""判断是否有 RAG 选项
|
||||
|
||||
Returns:
|
||||
bool: 是否有 RAG 选项
|
||||
|
||||
"""
|
||||
if self.rag_options and (
|
||||
len(self.rag_options.get("pipeline_ids", [])) > 0
|
||||
or len(self.rag_options.get("file_ids", [])) > 0
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""
|
||||
执行 Dashscope Agent 的一个步骤
|
||||
"""
|
||||
if not self.req:
|
||||
raise ValueError("Request is not set. Please call reset() first.")
|
||||
|
||||
if self._state == AgentState.IDLE:
|
||||
try:
|
||||
await self.agent_hooks.on_agent_begin(self.run_context)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
|
||||
|
||||
# 开始处理,转换到运行状态
|
||||
self._transition_state(AgentState.RUNNING)
|
||||
|
||||
try:
|
||||
# 执行 Dashscope 请求并处理结果
|
||||
async for response in self._execute_dashscope_request():
|
||||
yield response
|
||||
except Exception as e:
|
||||
logger.error(f"阿里云百炼请求失败:{str(e)}")
|
||||
self._transition_state(AgentState.ERROR)
|
||||
self.final_llm_resp = LLMResponse(
|
||||
role="err", completion_text=f"阿里云百炼请求失败:{str(e)}"
|
||||
)
|
||||
yield AgentResponse(
|
||||
type="err",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(f"阿里云百炼请求失败:{str(e)}")
|
||||
),
|
||||
)
|
||||
|
||||
@override
|
||||
async def step_until_done(
|
||||
self, max_step: int = 30
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
while not self.done():
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
|
||||
def _consume_sync_generator(
|
||||
self, response: T.Any, response_queue: queue.Queue
|
||||
) -> None:
|
||||
"""在线程中消费同步generator,将结果放入队列
|
||||
|
||||
Args:
|
||||
response: 同步generator对象
|
||||
response_queue: 用于传递数据的队列
|
||||
|
||||
"""
|
||||
try:
|
||||
if self.streaming:
|
||||
for chunk in response:
|
||||
response_queue.put(("data", chunk))
|
||||
else:
|
||||
response_queue.put(("data", response))
|
||||
except Exception as e:
|
||||
response_queue.put(("error", e))
|
||||
finally:
|
||||
response_queue.put(("done", None))
|
||||
|
||||
async def _process_stream_chunk(
|
||||
self, chunk: ApplicationResponse, output_text: str
|
||||
) -> tuple[str, list | None, AgentResponse | None]:
|
||||
"""处理流式响应的单个chunk
|
||||
|
||||
Args:
|
||||
chunk: Dashscope响应chunk
|
||||
output_text: 当前累积的输出文本
|
||||
|
||||
Returns:
|
||||
(更新后的output_text, doc_references, AgentResponse或None)
|
||||
|
||||
"""
|
||||
logger.debug(f"dashscope stream chunk: {chunk}")
|
||||
|
||||
if chunk.status_code != 200:
|
||||
logger.error(
|
||||
f"阿里云百炼请求失败: request_id={chunk.request_id}, code={chunk.status_code}, message={chunk.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code",
|
||||
)
|
||||
self._transition_state(AgentState.ERROR)
|
||||
error_msg = (
|
||||
f"阿里云百炼请求失败: message={chunk.message} code={chunk.status_code}"
|
||||
)
|
||||
self.final_llm_resp = LLMResponse(
|
||||
role="err",
|
||||
result_chain=MessageChain().message(error_msg),
|
||||
)
|
||||
return (
|
||||
output_text,
|
||||
None,
|
||||
AgentResponse(
|
||||
type="err",
|
||||
data=AgentResponseData(chain=MessageChain().message(error_msg)),
|
||||
),
|
||||
)
|
||||
|
||||
chunk_text = chunk.output.get("text", "") or ""
|
||||
# RAG 引用脚标格式化
|
||||
chunk_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", chunk_text)
|
||||
|
||||
response = None
|
||||
if chunk_text:
|
||||
output_text += chunk_text
|
||||
response = AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(chain=MessageChain().message(chunk_text)),
|
||||
)
|
||||
|
||||
# 获取文档引用
|
||||
doc_references = chunk.output.get("doc_references", None)
|
||||
|
||||
return output_text, doc_references, response
|
||||
|
||||
def _format_doc_references(self, doc_references: list) -> str:
|
||||
"""格式化文档引用为文本
|
||||
|
||||
Args:
|
||||
doc_references: 文档引用列表
|
||||
|
||||
Returns:
|
||||
格式化后的引用文本
|
||||
|
||||
"""
|
||||
ref_parts = []
|
||||
for ref in doc_references:
|
||||
ref_title = (
|
||||
ref.get("title", "") if ref.get("title") else ref.get("doc_name", "")
|
||||
)
|
||||
ref_parts.append(f"{ref['index_id']}. {ref_title}\n")
|
||||
ref_str = "".join(ref_parts)
|
||||
return f"\n\n回答来源:\n{ref_str}"
|
||||
|
||||
async def _build_request_payload(
|
||||
self, prompt: str, session_id: str, contexts: list, system_prompt: str
|
||||
) -> dict:
|
||||
"""构建请求payload
|
||||
|
||||
Args:
|
||||
prompt: 用户输入
|
||||
session_id: 会话ID
|
||||
contexts: 上下文列表
|
||||
system_prompt: 系统提示词
|
||||
|
||||
Returns:
|
||||
请求payload字典
|
||||
|
||||
"""
|
||||
conversation_id = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key="dashscope_conversation_id",
|
||||
default="",
|
||||
)
|
||||
# 获得会话变量
|
||||
payload_vars = self.variables.copy()
|
||||
session_var = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key="session_variables",
|
||||
default={},
|
||||
)
|
||||
payload_vars.update(session_var)
|
||||
|
||||
if (
|
||||
self.dashscope_app_type in ["agent", "dialog-workflow"]
|
||||
and not self.has_rag_options()
|
||||
):
|
||||
# 支持多轮对话的
|
||||
p = {
|
||||
"app_id": self.app_id,
|
||||
"api_key": self.api_key,
|
||||
"prompt": prompt,
|
||||
"biz_params": payload_vars or None,
|
||||
"stream": self.streaming,
|
||||
"incremental_output": True,
|
||||
}
|
||||
if conversation_id:
|
||||
p["session_id"] = conversation_id
|
||||
return p
|
||||
else:
|
||||
# 不支持多轮对话的
|
||||
payload = {
|
||||
"app_id": self.app_id,
|
||||
"prompt": prompt,
|
||||
"api_key": self.api_key,
|
||||
"biz_params": payload_vars or None,
|
||||
"stream": self.streaming,
|
||||
"incremental_output": True,
|
||||
}
|
||||
if self.rag_options:
|
||||
payload["rag_options"] = self.rag_options
|
||||
return payload
|
||||
|
||||
async def _handle_streaming_response(
|
||||
self, response: T.Any, session_id: str
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
"""处理流式响应
|
||||
|
||||
Args:
|
||||
response: Dashscope 流式响应 generator
|
||||
|
||||
Yields:
|
||||
AgentResponse 对象
|
||||
|
||||
"""
|
||||
response_queue = queue.Queue()
|
||||
consumer_thread = threading.Thread(
|
||||
target=self._consume_sync_generator,
|
||||
args=(response, response_queue),
|
||||
daemon=True,
|
||||
)
|
||||
consumer_thread.start()
|
||||
|
||||
output_text = ""
|
||||
doc_references = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
item_type, item_data = await asyncio.get_event_loop().run_in_executor(
|
||||
None, response_queue.get, True, 1
|
||||
)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
if item_type == "done":
|
||||
break
|
||||
elif item_type == "error":
|
||||
raise item_data
|
||||
elif item_type == "data":
|
||||
chunk = item_data
|
||||
assert isinstance(chunk, ApplicationResponse)
|
||||
|
||||
(
|
||||
output_text,
|
||||
chunk_doc_refs,
|
||||
response,
|
||||
) = await self._process_stream_chunk(chunk, output_text)
|
||||
|
||||
if response:
|
||||
if response.type == "err":
|
||||
yield response
|
||||
return
|
||||
yield response
|
||||
|
||||
if chunk_doc_refs:
|
||||
doc_references = chunk_doc_refs
|
||||
|
||||
if chunk.output.session_id:
|
||||
await sp.put_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key="dashscope_conversation_id",
|
||||
value=chunk.output.session_id,
|
||||
)
|
||||
|
||||
# 添加 RAG 引用
|
||||
if self.output_reference and doc_references:
|
||||
ref_text = self._format_doc_references(doc_references)
|
||||
output_text += ref_text
|
||||
|
||||
if self.streaming:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(chain=MessageChain().message(ref_text)),
|
||||
)
|
||||
|
||||
# 创建最终响应
|
||||
chain = MessageChain(chain=[Comp.Plain(output_text)])
|
||||
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
|
||||
self._transition_state(AgentState.DONE)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
|
||||
# 返回最终结果
|
||||
yield AgentResponse(
|
||||
type="llm_result",
|
||||
data=AgentResponseData(chain=chain),
|
||||
)
|
||||
|
||||
async def _execute_dashscope_request(self):
|
||||
"""执行 Dashscope 请求的核心逻辑"""
|
||||
prompt = self.req.prompt or ""
|
||||
session_id = self.req.session_id or "unknown"
|
||||
image_urls = self.req.image_urls or []
|
||||
contexts = self.req.contexts or []
|
||||
system_prompt = self.req.system_prompt
|
||||
|
||||
# 检查图片输入
|
||||
if image_urls:
|
||||
logger.warning("阿里云百炼暂不支持图片输入,将自动忽略图片内容。")
|
||||
|
||||
# 构建请求payload
|
||||
payload = await self._build_request_payload(
|
||||
prompt, session_id, contexts, system_prompt
|
||||
)
|
||||
|
||||
if not self.streaming:
|
||||
payload["incremental_output"] = False
|
||||
|
||||
# 发起请求
|
||||
partial = functools.partial(Application.call, **payload)
|
||||
response = await asyncio.get_event_loop().run_in_executor(None, partial)
|
||||
|
||||
async for resp in self._handle_streaming_response(response, session_id):
|
||||
yield resp
|
||||
|
||||
@override
|
||||
def done(self) -> bool:
|
||||
"""检查 Agent 是否已完成工作"""
|
||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
||||
|
||||
@override
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
return self.final_llm_resp
|
||||
@@ -0,0 +1,336 @@
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import typing as T
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot.core import logger, sp
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
ProviderRequest,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
|
||||
from ...hooks import BaseAgentRunHooks
|
||||
from ...response import AgentResponseData
|
||||
from ...run_context import ContextWrapper, TContext
|
||||
from ..base import AgentResponse, AgentState, BaseAgentRunner
|
||||
from .dify_api_client import DifyAPIClient
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
class DifyAgentRunner(BaseAgentRunner[TContext]):
|
||||
"""Dify Agent Runner"""
|
||||
|
||||
@override
|
||||
async def reset(
|
||||
self,
|
||||
request: ProviderRequest,
|
||||
run_context: ContextWrapper[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
provider_config: dict,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
self.streaming = kwargs.get("streaming", False)
|
||||
self.final_llm_resp = None
|
||||
self._state = AgentState.IDLE
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
|
||||
self.api_key = provider_config.get("dify_api_key", "")
|
||||
self.api_base = provider_config.get("dify_api_base", "https://api.dify.ai/v1")
|
||||
self.api_type = provider_config.get("dify_api_type", "chat")
|
||||
self.workflow_output_key = provider_config.get(
|
||||
"dify_workflow_output_key",
|
||||
"astrbot_wf_output",
|
||||
)
|
||||
self.dify_query_input_key = provider_config.get(
|
||||
"dify_query_input_key",
|
||||
"astrbot_text_query",
|
||||
)
|
||||
self.variables: dict = provider_config.get("variables", {}) or {}
|
||||
self.timeout = provider_config.get("timeout", 60)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
|
||||
self.api_client = DifyAPIClient(self.api_key, self.api_base)
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""
|
||||
执行 Dify Agent 的一个步骤
|
||||
"""
|
||||
if not self.req:
|
||||
raise ValueError("Request is not set. Please call reset() first.")
|
||||
|
||||
if self._state == AgentState.IDLE:
|
||||
try:
|
||||
await self.agent_hooks.on_agent_begin(self.run_context)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
|
||||
|
||||
# 开始处理,转换到运行状态
|
||||
self._transition_state(AgentState.RUNNING)
|
||||
|
||||
try:
|
||||
# 执行 Dify 请求并处理结果
|
||||
async for response in self._execute_dify_request():
|
||||
yield response
|
||||
except Exception as e:
|
||||
logger.error(f"Dify 请求失败:{str(e)}")
|
||||
self._transition_state(AgentState.ERROR)
|
||||
self.final_llm_resp = LLMResponse(
|
||||
role="err", completion_text=f"Dify 请求失败:{str(e)}"
|
||||
)
|
||||
yield AgentResponse(
|
||||
type="err",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(f"Dify 请求失败:{str(e)}")
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await self.api_client.close()
|
||||
|
||||
@override
|
||||
async def step_until_done(
|
||||
self, max_step: int = 30
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
while not self.done():
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
|
||||
async def _execute_dify_request(self):
|
||||
"""执行 Dify 请求的核心逻辑"""
|
||||
prompt = self.req.prompt or ""
|
||||
session_id = self.req.session_id or "unknown"
|
||||
image_urls = self.req.image_urls or []
|
||||
system_prompt = self.req.system_prompt
|
||||
|
||||
conversation_id = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key="dify_conversation_id",
|
||||
default="",
|
||||
)
|
||||
result = ""
|
||||
|
||||
# 处理图片上传
|
||||
files_payload = []
|
||||
for image_url in image_urls:
|
||||
# image_url is a base64 string
|
||||
try:
|
||||
image_data = base64.b64decode(image_url)
|
||||
file_response = await self.api_client.file_upload(
|
||||
file_data=image_data,
|
||||
user=session_id,
|
||||
mime_type="image/png",
|
||||
file_name="image.png",
|
||||
)
|
||||
logger.debug(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"],
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"上传图片失败:{e}")
|
||||
continue
|
||||
|
||||
# 获得会话变量
|
||||
payload_vars = self.variables.copy()
|
||||
# 动态变量
|
||||
session_var = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key="session_variables",
|
||||
default={},
|
||||
)
|
||||
payload_vars.update(session_var)
|
||||
payload_vars["system_prompt"] = system_prompt
|
||||
|
||||
# 处理不同的 API 类型
|
||||
match self.api_type:
|
||||
case "chat" | "agent" | "chatflow":
|
||||
if not prompt:
|
||||
prompt = "请描述这张图片。"
|
||||
|
||||
async for chunk in self.api_client.chat_messages(
|
||||
inputs={
|
||||
**payload_vars,
|
||||
},
|
||||
query=prompt,
|
||||
user=session_id,
|
||||
conversation_id=conversation_id,
|
||||
files=files_payload,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
logger.debug(f"dify resp chunk: {chunk}")
|
||||
if chunk["event"] == "message" or chunk["event"] == "agent_message":
|
||||
result += chunk["answer"]
|
||||
if not conversation_id:
|
||||
await sp.put_async(
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
key="dify_conversation_id",
|
||||
value=chunk["conversation_id"],
|
||||
)
|
||||
conversation_id = chunk["conversation_id"]
|
||||
|
||||
# 如果是流式响应,发送增量数据
|
||||
if self.streaming and chunk["answer"]:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(chunk["answer"])
|
||||
),
|
||||
)
|
||||
elif chunk["event"] == "message_end":
|
||||
logger.debug("Dify message end")
|
||||
break
|
||||
elif chunk["event"] == "error":
|
||||
logger.error(f"Dify 出现错误:{chunk}")
|
||||
raise Exception(
|
||||
f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}"
|
||||
)
|
||||
|
||||
case "workflow":
|
||||
async for chunk in self.api_client.workflow_run(
|
||||
inputs={
|
||||
self.dify_query_input_key: prompt,
|
||||
"astrbot_session_id": session_id,
|
||||
**payload_vars,
|
||||
},
|
||||
user=session_id,
|
||||
files=files_payload,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
logger.debug(f"dify workflow resp chunk: {chunk}")
|
||||
match chunk["event"]:
|
||||
case "workflow_started":
|
||||
logger.info(
|
||||
f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。"
|
||||
)
|
||||
case "node_finished":
|
||||
logger.debug(
|
||||
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。"
|
||||
)
|
||||
case "text_chunk":
|
||||
if self.streaming and chunk["data"]["text"]:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(
|
||||
chunk["data"]["text"]
|
||||
)
|
||||
),
|
||||
)
|
||||
case "workflow_finished":
|
||||
logger.info(
|
||||
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
|
||||
)
|
||||
logger.debug(f"Dify 工作流结果:{chunk}")
|
||||
if chunk["data"]["error"]:
|
||||
logger.error(
|
||||
f"Dify 工作流出现错误:{chunk['data']['error']}"
|
||||
)
|
||||
raise Exception(
|
||||
f"Dify 工作流出现错误:{chunk['data']['error']}"
|
||||
)
|
||||
if self.workflow_output_key not in chunk["data"]["outputs"]:
|
||||
raise Exception(
|
||||
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}"
|
||||
)
|
||||
result = chunk
|
||||
case _:
|
||||
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
|
||||
|
||||
if not result:
|
||||
logger.warning("Dify 请求结果为空,请查看 Debug 日志。")
|
||||
|
||||
# 解析结果
|
||||
chain = await self.parse_dify_result(result)
|
||||
|
||||
# 创建最终响应
|
||||
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
|
||||
self._transition_state(AgentState.DONE)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
|
||||
# 返回最终结果
|
||||
yield AgentResponse(
|
||||
type="llm_result",
|
||||
data=AgentResponseData(chain=chain),
|
||||
)
|
||||
|
||||
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
|
||||
"""解析 Dify 的响应结果"""
|
||||
if isinstance(chunk, str):
|
||||
# Chat
|
||||
return MessageChain(chain=[Comp.Plain(chunk)])
|
||||
|
||||
async def parse_file(item: dict):
|
||||
match item["type"]:
|
||||
case "image":
|
||||
return Comp.Image(file=item["url"], url=item["url"])
|
||||
case "audio":
|
||||
# 仅支持 wav
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
path = os.path.join(temp_dir, f"{item['filename']}.wav")
|
||||
await download_file(item["url"], path)
|
||||
return Comp.Image(file=item["url"], url=item["url"])
|
||||
case "video":
|
||||
return Comp.Video(file=item["url"])
|
||||
case _:
|
||||
return Comp.File(name=item["filename"], file=item["url"])
|
||||
|
||||
output = chunk["data"]["outputs"][self.workflow_output_key]
|
||||
chains = []
|
||||
if isinstance(output, str):
|
||||
# 纯文本输出
|
||||
chains.append(Comp.Plain(output))
|
||||
elif isinstance(output, list):
|
||||
# 主要适配 Dify 的 HTTP 请求结点的多模态输出
|
||||
for item in output:
|
||||
# handle Array[File]
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or item.get("dify_model_identity", "") != "__dify__file__"
|
||||
):
|
||||
chains.append(Comp.Plain(str(output)))
|
||||
break
|
||||
else:
|
||||
chains.append(Comp.Plain(str(output)))
|
||||
|
||||
# scan file
|
||||
files = chunk["data"].get("files", [])
|
||||
for item in files:
|
||||
comp = await parse_file(item)
|
||||
chains.append(comp)
|
||||
|
||||
return MessageChain(chain=chains)
|
||||
|
||||
@override
|
||||
def done(self) -> bool:
|
||||
"""检查 Agent 是否已完成工作"""
|
||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
||||
|
||||
@override
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
return self.final_llm_resp
|
||||
+80
-24
@@ -1,8 +1,11 @@
|
||||
import codecs
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponse, ClientSession, FormData
|
||||
|
||||
from astrbot.core import logger
|
||||
from aiohttp import ClientSession, ClientResponse
|
||||
from typing import Dict, List, Any, AsyncGenerator
|
||||
|
||||
|
||||
async def _stream_sse(resp: ClientResponse) -> AsyncGenerator[dict, None]:
|
||||
@@ -25,7 +28,6 @@ async def _stream_sse(resp: ClientResponse) -> AsyncGenerator[dict, None]:
|
||||
yield json.loads(buffer[5:])
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Drop invalid dify json data: {buffer[5:]}")
|
||||
pass
|
||||
|
||||
|
||||
class DifyAPIClient:
|
||||
@@ -39,69 +41,119 @@ class DifyAPIClient:
|
||||
|
||||
async def chat_messages(
|
||||
self,
|
||||
inputs: Dict,
|
||||
inputs: dict,
|
||||
query: str,
|
||||
user: str,
|
||||
response_mode: str = "streaming",
|
||||
conversation_id: str = "",
|
||||
files: List[Dict[str, Any]] = [],
|
||||
files: list[dict[str, Any]] | None = None,
|
||||
timeout: float = 60,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
if files is None:
|
||||
files = []
|
||||
url = f"{self.api_base}/chat-messages"
|
||||
payload = locals()
|
||||
payload.pop("self")
|
||||
payload.pop("timeout")
|
||||
logger.info(f"chat_messages payload: {payload}")
|
||||
async with self.session.post(
|
||||
url, json=payload, headers=self.headers, timeout=timeout
|
||||
url,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise Exception(
|
||||
f"Dify /chat-messages 接口请求失败:{resp.status}. {text}"
|
||||
f"Dify /chat-messages 接口请求失败:{resp.status}. {text}",
|
||||
)
|
||||
async for event in _stream_sse(resp):
|
||||
yield event
|
||||
|
||||
async def workflow_run(
|
||||
self,
|
||||
inputs: Dict,
|
||||
inputs: dict,
|
||||
user: str,
|
||||
response_mode: str = "streaming",
|
||||
files: List[Dict[str, Any]] = [],
|
||||
files: list[dict[str, Any]] | None = None,
|
||||
timeout: float = 60,
|
||||
):
|
||||
if files is None:
|
||||
files = []
|
||||
url = f"{self.api_base}/workflows/run"
|
||||
payload = locals()
|
||||
payload.pop("self")
|
||||
payload.pop("timeout")
|
||||
logger.info(f"workflow_run payload: {payload}")
|
||||
async with self.session.post(
|
||||
url, json=payload, headers=self.headers, timeout=timeout
|
||||
url,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise Exception(
|
||||
f"Dify /workflows/run 接口请求失败:{resp.status}. {text}"
|
||||
f"Dify /workflows/run 接口请求失败:{resp.status}. {text}",
|
||||
)
|
||||
async for event in _stream_sse(resp):
|
||||
yield event
|
||||
|
||||
async def file_upload(
|
||||
self,
|
||||
file_path: str,
|
||||
user: str,
|
||||
) -> Dict[str, Any]:
|
||||
file_path: str | None = None,
|
||||
file_data: bytes | None = None,
|
||||
file_name: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload a file to Dify. Must provide either file_path or file_data.
|
||||
|
||||
Args:
|
||||
user: The user ID.
|
||||
file_path: The path to the file to upload.
|
||||
file_data: The file data in bytes.
|
||||
file_name: Optional file name when using file_data.
|
||||
Returns:
|
||||
A dictionary containing the uploaded file information.
|
||||
"""
|
||||
url = f"{self.api_base}/files/upload"
|
||||
with open(file_path, "rb") as f:
|
||||
payload = {
|
||||
"user": user,
|
||||
"file": f,
|
||||
}
|
||||
async with self.session.post(
|
||||
url, data=payload, headers=self.headers
|
||||
) as resp:
|
||||
return await resp.json() # {"id": "xxx", ...}
|
||||
|
||||
form = FormData()
|
||||
form.add_field("user", user)
|
||||
|
||||
if file_data is not None:
|
||||
# 使用 bytes 数据
|
||||
form.add_field(
|
||||
"file",
|
||||
file_data,
|
||||
filename=file_name or "uploaded_file",
|
||||
content_type=mime_type or "application/octet-stream",
|
||||
)
|
||||
elif file_path is not None:
|
||||
# 使用文件路径
|
||||
import os
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
file_content = f.read()
|
||||
form.add_field(
|
||||
"file",
|
||||
file_content,
|
||||
filename=os.path.basename(file_path),
|
||||
content_type=mime_type or "application/octet-stream",
|
||||
)
|
||||
else:
|
||||
raise ValueError("file_path 和 file_data 不能同时为 None")
|
||||
|
||||
async with self.session.post(
|
||||
url,
|
||||
data=form,
|
||||
headers=self.headers, # 不包含 Content-Type,让 aiohttp 自动设置
|
||||
) as resp:
|
||||
if resp.status != 200 and resp.status != 201:
|
||||
text = await resp.text()
|
||||
raise Exception(f"Dify 文件上传失败:{resp.status}. {text}")
|
||||
return await resp.json() # {"id": "xxx", ...}
|
||||
|
||||
async def close(self):
|
||||
await self.session.close()
|
||||
@@ -126,7 +178,11 @@ class DifyAPIClient:
|
||||
return await resp.json()
|
||||
|
||||
async def rename(
|
||||
self, conversation_id: str, name: str, user: str, auto_generate: bool = False
|
||||
self,
|
||||
conversation_id: str,
|
||||
name: str,
|
||||
user: str,
|
||||
auto_generate: bool = False,
|
||||
):
|
||||
# /conversations/:conversation_id/name
|
||||
url = f"{self.api_base}/conversations/{conversation_id}/name"
|
||||
@@ -1,31 +1,40 @@
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import typing as T
|
||||
from .base import BaseAgentRunner, AgentResponse, AgentState
|
||||
from ..hooks import BaseAgentRunHooks
|
||||
from ..tool_executor import BaseFunctionToolExecutor
|
||||
from ..run_context import ContextWrapper, TContext
|
||||
from ..response import AgentResponseData
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
from mcp.types import (
|
||||
BlobResourceContents,
|
||||
CallToolResult,
|
||||
EmbeddedResource,
|
||||
ImageContent,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.message import TextPart, ThinkPart
|
||||
from astrbot.core.message.components import Json
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
)
|
||||
from astrbot.core.provider.entities import (
|
||||
ProviderRequest,
|
||||
LLMResponse,
|
||||
ToolCallMessageSegment,
|
||||
AssistantMessageSegment,
|
||||
ProviderRequest,
|
||||
ToolCallsResult,
|
||||
)
|
||||
from mcp.types import (
|
||||
TextContent,
|
||||
ImageContent,
|
||||
EmbeddedResource,
|
||||
TextResourceContents,
|
||||
BlobResourceContents,
|
||||
CallToolResult,
|
||||
)
|
||||
from astrbot import logger
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
from ..context.compressor import ContextCompressor
|
||||
from ..context.config import ContextConfig
|
||||
from ..context.manager import ContextManager
|
||||
from ..context.token_counter import TokenCounter
|
||||
from ..hooks import BaseAgentRunHooks
|
||||
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
|
||||
from ..response import AgentResponseData, AgentStats
|
||||
from ..run_context import ContextWrapper, TContext
|
||||
from ..tool_executor import BaseFunctionToolExecutor
|
||||
from .base import AgentResponse, AgentState, BaseAgentRunner
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
@@ -42,10 +51,47 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
run_context: ContextWrapper[TContext],
|
||||
tool_executor: BaseFunctionToolExecutor[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
streaming: bool = False,
|
||||
# enforce max turns, will discard older turns when exceeded BEFORE compression
|
||||
# -1 means no limit
|
||||
enforce_max_turns: int = -1,
|
||||
# llm compressor
|
||||
llm_compress_instruction: str | None = None,
|
||||
llm_compress_keep_recent: int = 0,
|
||||
llm_compress_provider: Provider | None = None,
|
||||
# truncate by turns compressor
|
||||
truncate_turns: int = 1,
|
||||
# customize
|
||||
custom_token_counter: TokenCounter | None = None,
|
||||
custom_compressor: ContextCompressor | None = None,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
self.streaming = kwargs.get("streaming", False)
|
||||
self.streaming = streaming
|
||||
self.enforce_max_turns = enforce_max_turns
|
||||
self.llm_compress_instruction = llm_compress_instruction
|
||||
self.llm_compress_keep_recent = llm_compress_keep_recent
|
||||
self.llm_compress_provider = llm_compress_provider
|
||||
self.truncate_turns = truncate_turns
|
||||
self.custom_token_counter = custom_token_counter
|
||||
self.custom_compressor = custom_compressor
|
||||
# we will do compress when:
|
||||
# 1. before requesting LLM
|
||||
# TODO: 2. after LLM output a tool call
|
||||
self.context_config = ContextConfig(
|
||||
# <=0 will never do compress
|
||||
max_context_tokens=provider.provider_config.get("max_context_tokens", 0),
|
||||
# enforce max turns before compression
|
||||
enforce_max_turns=self.enforce_max_turns,
|
||||
truncate_turns=self.truncate_turns,
|
||||
llm_compress_instruction=self.llm_compress_instruction,
|
||||
llm_compress_keep_recent=self.llm_compress_keep_recent,
|
||||
llm_compress_provider=self.llm_compress_provider,
|
||||
custom_token_counter=self.custom_token_counter,
|
||||
custom_compressor=self.custom_compressor,
|
||||
)
|
||||
self.context_manager = ContextManager(self.context_config)
|
||||
|
||||
self.provider = provider
|
||||
self.final_llm_resp = None
|
||||
self._state = AgentState.IDLE
|
||||
@@ -53,25 +99,43 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
|
||||
def _transition_state(self, new_state: AgentState) -> None:
|
||||
"""转换 Agent 状态"""
|
||||
if self._state != new_state:
|
||||
logger.debug(f"Agent state transition: {self._state} -> {new_state}")
|
||||
self._state = new_state
|
||||
messages = []
|
||||
# append existing messages in the run context
|
||||
for msg in request.contexts:
|
||||
messages.append(Message.model_validate(msg))
|
||||
if request.prompt is not None:
|
||||
m = await request.assemble_context()
|
||||
messages.append(Message.model_validate(m))
|
||||
if request.system_prompt:
|
||||
messages.insert(
|
||||
0,
|
||||
Message(role="system", content=request.system_prompt),
|
||||
)
|
||||
self.run_context.messages = messages
|
||||
|
||||
self.stats = AgentStats()
|
||||
self.stats.start_time = time.time()
|
||||
|
||||
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
"""Yields chunks *and* a final LLMResponse."""
|
||||
payload = {
|
||||
"contexts": self.run_context.messages, # list[Message]
|
||||
"func_tool": self.req.func_tool,
|
||||
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
|
||||
"session_id": self.req.session_id,
|
||||
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
|
||||
}
|
||||
|
||||
if self.streaming:
|
||||
stream = self.provider.text_chat_stream(**self.req.__dict__)
|
||||
stream = self.provider.text_chat_stream(**payload)
|
||||
async for resp in stream: # type: ignore
|
||||
yield resp
|
||||
else:
|
||||
yield await self.provider.text_chat(**self.req.__dict__)
|
||||
yield await self.provider.text_chat(**payload)
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""
|
||||
Process a single step of the agent.
|
||||
"""Process a single step of the agent.
|
||||
This method should return the result of the step.
|
||||
"""
|
||||
if not self.req:
|
||||
@@ -87,23 +151,45 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self._transition_state(AgentState.RUNNING)
|
||||
llm_resp_result = None
|
||||
|
||||
# do truncate and compress
|
||||
token_usage = self.req.conversation.token_usage if self.req.conversation else 0
|
||||
self.run_context.messages = await self.context_manager.process(
|
||||
self.run_context.messages, trusted_token_usage=token_usage
|
||||
)
|
||||
|
||||
async for llm_response in self._iter_llm_responses():
|
||||
assert isinstance(llm_response, LLMResponse)
|
||||
if llm_response.is_chunk:
|
||||
# update ttft
|
||||
if self.stats.time_to_first_token == 0:
|
||||
self.stats.time_to_first_token = time.time() - self.stats.start_time
|
||||
|
||||
if llm_response.result_chain:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(chain=llm_response.result_chain),
|
||||
)
|
||||
else:
|
||||
elif llm_response.completion_text:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(llm_response.completion_text)
|
||||
chain=MessageChain().message(llm_response.completion_text),
|
||||
),
|
||||
)
|
||||
elif llm_response.reasoning_content:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain(type="reasoning").message(
|
||||
llm_response.reasoning_content,
|
||||
),
|
||||
),
|
||||
)
|
||||
continue
|
||||
llm_resp_result = llm_response
|
||||
|
||||
if not llm_response.is_chunk and llm_response.usage:
|
||||
# only count the token usage of the final response for computation purpose
|
||||
self.stats.token_usage += llm_response.usage
|
||||
break # got final response
|
||||
|
||||
if not llm_resp_result:
|
||||
@@ -115,13 +201,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if llm_resp.role == "err":
|
||||
# 如果 LLM 响应错误,转换到错误状态
|
||||
self.final_llm_resp = llm_resp
|
||||
self.stats.end_time = time.time()
|
||||
self._transition_state(AgentState.ERROR)
|
||||
yield AgentResponse(
|
||||
type="err",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(
|
||||
f"LLM 响应错误: {llm_resp.completion_text or '未知错误'}"
|
||||
)
|
||||
f"LLM 响应错误: {llm_resp.completion_text or '未知错误'}",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -129,6 +216,21 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# 如果没有工具调用,转换到完成状态
|
||||
self.final_llm_resp = llm_resp
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
# record the final assistant message
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
parts.append(TextPart(text=llm_resp.completion_text or "*No response*"))
|
||||
self.run_context.messages.append(Message(role="assistant", content=parts))
|
||||
|
||||
# call the on_agent_done hook
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||
except Exception as e:
|
||||
@@ -144,39 +246,81 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
yield AgentResponse(
|
||||
type="llm_result",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(llm_resp.completion_text)
|
||||
chain=MessageChain().message(llm_resp.completion_text),
|
||||
),
|
||||
)
|
||||
|
||||
# 如果有工具调用,还需处理工具调用
|
||||
if llm_resp.tools_call_name:
|
||||
tool_call_result_blocks = []
|
||||
for tool_call_name in llm_resp.tools_call_name:
|
||||
yield AgentResponse(
|
||||
type="tool_call",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(f"🔨 调用工具: {tool_call_name}")
|
||||
),
|
||||
)
|
||||
async for result in self._handle_function_tools(self.req, llm_resp):
|
||||
if isinstance(result, list):
|
||||
tool_call_result_blocks = result
|
||||
elif isinstance(result, MessageChain):
|
||||
if result.type is None:
|
||||
# should not happen
|
||||
continue
|
||||
if result.type == "tool_direct_result":
|
||||
ar_type = "tool_call_result"
|
||||
else:
|
||||
ar_type = result.type
|
||||
yield AgentResponse(
|
||||
type="tool_call_result",
|
||||
type=ar_type,
|
||||
data=AgentResponseData(chain=result),
|
||||
)
|
||||
# 将结果添加到上下文中
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
parts.append(TextPart(text=llm_resp.completion_text or "*No response*"))
|
||||
tool_calls_result = ToolCallsResult(
|
||||
tool_calls_info=AssistantMessageSegment(
|
||||
role="assistant",
|
||||
tool_calls=llm_resp.to_openai_tool_calls(),
|
||||
content=llm_resp.completion_text,
|
||||
tool_calls=llm_resp.to_openai_to_calls_model(),
|
||||
content=parts,
|
||||
),
|
||||
tool_calls_result=tool_call_result_blocks,
|
||||
)
|
||||
# record the assistant message with tool calls
|
||||
self.run_context.messages.extend(
|
||||
tool_calls_result.to_openai_messages_model()
|
||||
)
|
||||
|
||||
self.req.append_tool_calls_result(tool_calls_result)
|
||||
|
||||
async def step_until_done(
|
||||
self, max_step: int
|
||||
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||
"""Process steps until the agent is done."""
|
||||
step_count = 0
|
||||
while not self.done() and step_count < max_step:
|
||||
step_count += 1
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
|
||||
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
|
||||
if not self.done():
|
||||
logger.warning(
|
||||
f"Agent reached max steps ({max_step}), forcing a final response."
|
||||
)
|
||||
# 拔掉所有工具
|
||||
if self.req:
|
||||
self.req.func_tool = None
|
||||
# 注入提示词
|
||||
self.run_context.messages.append(
|
||||
Message(
|
||||
role="user",
|
||||
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
|
||||
)
|
||||
)
|
||||
# 再执行最后一步
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
|
||||
async def _handle_function_tools(
|
||||
self,
|
||||
req: ProviderRequest,
|
||||
@@ -192,6 +336,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
llm_response.tools_call_args,
|
||||
llm_response.tools_call_ids,
|
||||
):
|
||||
yield MessageChain(
|
||||
type="tool_call",
|
||||
chain=[
|
||||
Json(
|
||||
data={
|
||||
"id": func_tool_id,
|
||||
"name": func_tool_name,
|
||||
"args": func_tool_args,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
try:
|
||||
if not req.func_tool:
|
||||
return
|
||||
@@ -205,13 +362,43 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=f"error: 未找到工具 {func_tool_name}",
|
||||
)
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
valid_params = {} # 参数过滤:只传递函数实际需要的参数
|
||||
|
||||
# 获取实际的 handler 函数
|
||||
if func_tool.handler:
|
||||
logger.debug(
|
||||
f"工具 {func_tool_name} 期望的参数: {func_tool.parameters}",
|
||||
)
|
||||
if func_tool.parameters and func_tool.parameters.get("properties"):
|
||||
expected_params = set(func_tool.parameters["properties"].keys())
|
||||
|
||||
valid_params = {
|
||||
k: v
|
||||
for k, v in func_tool_args.items()
|
||||
if k in expected_params
|
||||
}
|
||||
|
||||
# 记录被忽略的参数
|
||||
ignored_params = set(func_tool_args.keys()) - set(
|
||||
valid_params.keys(),
|
||||
)
|
||||
if ignored_params:
|
||||
logger.warning(
|
||||
f"工具 {func_tool_name} 忽略非期望参数: {ignored_params}",
|
||||
)
|
||||
else:
|
||||
# 如果没有 handler(如 MCP 工具),使用所有参数
|
||||
valid_params = func_tool_args
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_tool_start(
|
||||
self.run_context, func_tool, func_tool_args
|
||||
self.run_context,
|
||||
func_tool,
|
||||
valid_params,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_tool_start hook: {e}", exc_info=True)
|
||||
@@ -219,7 +406,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
executor = self.tool_executor.execute(
|
||||
tool=func_tool,
|
||||
run_context=self.run_context,
|
||||
**func_tool_args,
|
||||
**valid_params, # 只传递有效的参数
|
||||
)
|
||||
|
||||
_final_resp: CallToolResult | None = None
|
||||
@@ -233,19 +420,18 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=res.content[0].text,
|
||||
)
|
||||
),
|
||||
)
|
||||
yield MessageChain().message(res.content[0].text)
|
||||
elif isinstance(res.content[0], ImageContent):
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content="返回了图片(已直接发送给用户)",
|
||||
)
|
||||
),
|
||||
)
|
||||
yield MessageChain(type="tool_direct_result").base64_image(
|
||||
res.content[0].data
|
||||
res.content[0].data,
|
||||
)
|
||||
elif isinstance(res.content[0], EmbeddedResource):
|
||||
resource = res.content[0].resource
|
||||
@@ -255,9 +441,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=resource.text,
|
||||
)
|
||||
),
|
||||
)
|
||||
yield MessageChain().message(resource.text)
|
||||
elif (
|
||||
isinstance(resource, BlobResourceContents)
|
||||
and resource.mimeType
|
||||
@@ -268,10 +453,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content="返回了图片(已直接发送给用户)",
|
||||
)
|
||||
),
|
||||
)
|
||||
yield MessageChain(
|
||||
type="tool_direct_result"
|
||||
type="tool_direct_result",
|
||||
).base64_image(resource.blob)
|
||||
else:
|
||||
tool_call_result_blocks.append(
|
||||
@@ -279,43 +464,73 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content="返回的数据类型不受支持",
|
||||
)
|
||||
),
|
||||
)
|
||||
yield MessageChain().message("返回的数据类型不受支持。")
|
||||
|
||||
elif resp is None:
|
||||
# Tool 直接请求发送消息给用户
|
||||
# 这里我们将直接结束 Agent Loop。
|
||||
# 这里我们将直接结束 Agent Loop
|
||||
# 发送消息逻辑在 ToolExecutor 中处理了
|
||||
logger.warning(
|
||||
f"{func_tool_name} 没有返回值,或者已将结果直接发送给用户。"
|
||||
)
|
||||
self._transition_state(AgentState.DONE)
|
||||
if res := self.run_context.event.get_result():
|
||||
if res.chain:
|
||||
yield MessageChain(
|
||||
chain=res.chain, type="tool_direct_result"
|
||||
)
|
||||
self.stats.end_time = time.time()
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content="*工具没有返回值或者将结果直接发送给了用户*",
|
||||
),
|
||||
)
|
||||
else:
|
||||
# 不应该出现其他类型
|
||||
logger.warning(
|
||||
f"Tool 返回了不支持的类型: {type(resp)},将忽略。"
|
||||
f"Tool 返回了不支持的类型: {type(resp)}。",
|
||||
)
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_tool_end(
|
||||
self.run_context, func_tool, func_tool_args, _final_resp
|
||||
self.run_context,
|
||||
func_tool,
|
||||
func_tool_args,
|
||||
_final_resp,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_tool_end hook: {e}", exc_info=True)
|
||||
|
||||
self.run_context.event.clear_result()
|
||||
except Exception as e:
|
||||
logger.warning(traceback.format_exc())
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=f"error: {str(e)}",
|
||||
)
|
||||
content=f"error: {e!s}",
|
||||
),
|
||||
)
|
||||
|
||||
# yield the last tool call result
|
||||
if tool_call_result_blocks:
|
||||
last_tcr_content = str(tool_call_result_blocks[-1].content)
|
||||
yield MessageChain(
|
||||
type="tool_call_result",
|
||||
chain=[
|
||||
Json(
|
||||
data={
|
||||
"id": func_tool_id,
|
||||
"ts": time.time(),
|
||||
"result": last_tcr_content,
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# 处理函数调用响应
|
||||
if tool_call_result_blocks:
|
||||
yield tool_call_result_blocks
|
||||
|
||||
+66
-43
@@ -1,58 +1,82 @@
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from typing import Any, Generic
|
||||
|
||||
import jsonschema
|
||||
import mcp
|
||||
from deprecated import deprecated
|
||||
from typing import Awaitable, Callable, Literal, Any, Optional
|
||||
from .mcp_client import MCPClient
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
|
||||
from .run_context import ContextWrapper, TContext
|
||||
|
||||
ParametersType = dict[str, Any]
|
||||
ToolExecResult = str | mcp.types.CallToolResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionTool:
|
||||
"""A class representing a function tool that can be used in function calling."""
|
||||
class ToolSchema:
|
||||
"""A class representing the schema of a tool for function calling."""
|
||||
|
||||
name: str
|
||||
parameters: dict | None = None
|
||||
description: str | None = None
|
||||
handler: Callable[..., Awaitable[Any]] | None = None
|
||||
"""处理函数, 当 origin 为 mcp 时,这个为空"""
|
||||
handler_module_path: str | None = None
|
||||
"""处理函数的模块路径,当 origin 为 mcp 时,这个为空
|
||||
"""The name of the tool."""
|
||||
|
||||
必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
|
||||
description: str
|
||||
"""The description of the tool."""
|
||||
|
||||
parameters: ParametersType
|
||||
"""The parameters of the tool, in JSON Schema format."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_parameters(self) -> "ToolSchema":
|
||||
jsonschema.validate(
|
||||
self.parameters, jsonschema.Draft202012Validator.META_SCHEMA
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionTool(ToolSchema, Generic[TContext]):
|
||||
"""A callable tool, for function calling."""
|
||||
|
||||
handler: (
|
||||
Callable[..., Awaitable[str | None] | AsyncGenerator[MessageEventResult, None]]
|
||||
| None
|
||||
) = None
|
||||
"""a callable that implements the tool's functionality. It should be an async function."""
|
||||
|
||||
handler_module_path: str | None = None
|
||||
"""
|
||||
The module path of the handler function. This is empty when the origin is mcp.
|
||||
This field must be retained, as the handler will be wrapped in functools.partial during initialization,
|
||||
causing the handler's __module__ to be functools
|
||||
"""
|
||||
active: bool = True
|
||||
"""是否激活"""
|
||||
|
||||
origin: Literal["local", "mcp"] = "local"
|
||||
"""函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务"""
|
||||
|
||||
# MCP 相关字段
|
||||
mcp_server_name: str | None = None
|
||||
"""MCP 服务名称,当 origin 为 mcp 时有效"""
|
||||
mcp_client: MCPClient | None = None
|
||||
"""MCP 客户端,当 origin 为 mcp 时有效"""
|
||||
"""
|
||||
Whether the tool is active. This field is a special field for AstrBot.
|
||||
You can ignore it when integrating with other frameworks.
|
||||
"""
|
||||
|
||||
def __repr__(self):
|
||||
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})"
|
||||
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
|
||||
|
||||
def __dict__(self) -> dict[str, Any]:
|
||||
"""将 FunctionTool 转换为字典格式"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"parameters": self.parameters,
|
||||
"description": self.description,
|
||||
"active": self.active,
|
||||
"origin": self.origin,
|
||||
"mcp_server_name": self.mcp_server_name,
|
||||
}
|
||||
async def call(self, context: ContextWrapper[TContext], **kwargs) -> ToolExecResult:
|
||||
"""Run the tool with the given arguments. The handler field has priority."""
|
||||
raise NotImplementedError(
|
||||
"FunctionTool.call() must be implemented by subclasses or set a handler."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSet:
|
||||
"""A set of function tools that can be used in function calling.
|
||||
|
||||
This class provides methods to add, remove, and retrieve tools, as well as
|
||||
convert the tools to different API formats (OpenAI, Anthropic, Google GenAI)."""
|
||||
convert the tools to different API formats (OpenAI, Anthropic, Google GenAI).
|
||||
"""
|
||||
|
||||
def __init__(self, tools: list[FunctionTool] | None = None):
|
||||
self.tools: list[FunctionTool] = tools or []
|
||||
tools: list[FunctionTool] = Field(default_factory=list)
|
||||
|
||||
def empty(self) -> bool:
|
||||
"""Check if the tool set is empty."""
|
||||
@@ -71,7 +95,7 @@ class ToolSet:
|
||||
"""Remove a tool by its name."""
|
||||
self.tools = [tool for tool in self.tools if tool.name != name]
|
||||
|
||||
def get_tool(self, name: str) -> Optional[FunctionTool]:
|
||||
def get_tool(self, name: str) -> FunctionTool | None:
|
||||
"""Get a tool by its name."""
|
||||
for tool in self.tools:
|
||||
if tool.name == name:
|
||||
@@ -132,10 +156,8 @@ class ToolSet:
|
||||
}
|
||||
|
||||
if (
|
||||
tool.parameters
|
||||
and tool.parameters.get("properties")
|
||||
or not omit_empty_parameter_field
|
||||
):
|
||||
tool.parameters and tool.parameters.get("properties")
|
||||
) or not omit_empty_parameter_field:
|
||||
func_def["function"]["parameters"] = tool.parameters
|
||||
|
||||
result.append(func_def)
|
||||
@@ -185,7 +207,8 @@ class ToolSet:
|
||||
if "type" in schema and schema["type"] in supported_types:
|
||||
result["type"] = schema["type"]
|
||||
if "format" in schema and schema["format"] in supported_formats.get(
|
||||
result["type"], set()
|
||||
result["type"],
|
||||
set(),
|
||||
):
|
||||
result["format"] = schema["format"]
|
||||
else:
|
||||
@@ -222,7 +245,7 @@ class ToolSet:
|
||||
|
||||
tools = []
|
||||
for tool in self.tools:
|
||||
d = {
|
||||
d: dict[str, Any] = {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, Generic
|
||||
|
||||
import mcp
|
||||
from typing import Any, Generic, AsyncGenerator
|
||||
from .run_context import TContext, ContextWrapper
|
||||
|
||||
from .run_context import ContextWrapper, TContext
|
||||
from .tool import FunctionTool
|
||||
|
||||
|
||||
class BaseFunctionToolExecutor(Generic[TContext]):
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls, tool: FunctionTool, run_context: ContextWrapper[TContext], **tool_args
|
||||
cls,
|
||||
tool: FunctionTool,
|
||||
run_context: ContextWrapper[TContext],
|
||||
**tool_args,
|
||||
) -> AsyncGenerator[Any | mcp.types.CallToolResult, None]: ...
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user