Compare commits
841 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c0fb31e7d | |||
| 7aae048405 | |||
| df1e59e01c | |||
| 25f9effcc9 | |||
| 5caf3a4793 | |||
| 458e8e0db8 | |||
| 976398d1f2 | |||
| f5ba1a026a | |||
| dcffb5269a | |||
| 4b7d42c2a3 | |||
| f6321be8c8 | |||
| ebd232ec8e | |||
| 1fd3d4ce0e | |||
| 26d69c96d1 | |||
| 3dcdb8b29c | |||
| 437adead28 | |||
| d5b98b353c | |||
| acbc5150cf | |||
| 85cfd62014 | |||
| 1c7c2ee0cd | |||
| ed47420678 | |||
| 6d687691a2 | |||
| 6db0959bb1 | |||
| 0c71d351ee | |||
| f00ba5adc6 | |||
| a05bfed15d | |||
| a027fb310c | |||
| d3d4e1db7b | |||
| 78b3e12c66 | |||
| c42ac87ee1 | |||
| 4b0d9ae979 | |||
| a1a3db2218 | |||
| 3e278dbd9e | |||
| 7733ccc54a | |||
| 9c7c0ec95a | |||
| 2685528cbd | |||
| 3f24f82486 | |||
| 38f21675d5 | |||
| 3fbd16b211 | |||
| e77500ff69 | |||
| 2c49ac0dcf | |||
| c0e07971b3 | |||
| 7cce05c459 | |||
| 0a16df2837 | |||
| e2365a53b9 | |||
| 7dc142ddf2 | |||
| 65decfbe87 | |||
| 92c31192de | |||
| 8e6c835b85 | |||
| fb2a2a63f2 | |||
| b795f804a7 | |||
| bc3b5e58a4 | |||
| 7e3c32b828 | |||
| ceb32dce9f | |||
| 84e880af5f | |||
| 9909d774ed | |||
| 6b3868b4be | |||
| 11c840953a | |||
| 2bbca887ce | |||
| dd89a4b334 | |||
| a3fa8a5a7c | |||
| aa60467782 | |||
| d936bb0a10 | |||
| 3f863cce7f | |||
| c42bd3150d | |||
| 4c22abd99c | |||
| f08147dc38 | |||
| 11d40ac0c3 | |||
| 64e0183b55 | |||
| 420d82df11 | |||
| d87cf897da | |||
| 2f51916a73 | |||
| b0e10cf479 | |||
| 20efaa5320 | |||
| 3ccd70cd4e | |||
| da520e573a | |||
| 6d055e81e9 | |||
| d41ccb70c5 | |||
| 18a99a25c2 | |||
| 04aee2890a | |||
| c18165909e | |||
| 0b534f65c2 | |||
| c9910d4a66 | |||
| 342b378de1 | |||
| 7579db11be | |||
| b5a40a66fa | |||
| 282ff8d414 | |||
| f3cdb7c006 | |||
| c3afc3d72b | |||
| 0c74bd1aeb | |||
| 070f281dae | |||
| 28a0f372fc | |||
| d7457f38d4 | |||
| 96cafe001d | |||
| 29d100dd83 | |||
| 14f3701c4a | |||
| 1044fc48ca | |||
| 693c2ca818 | |||
| da1565ee81 | |||
| 7d3401fec0 | |||
| fca691b3ca | |||
| ca8f356812 | |||
| b1c486ba98 | |||
| 9363fb824a | |||
| 044b361ac5 | |||
| 06fd2d2428 | |||
| dd6bc1dcdb | |||
| 52d5258b10 | |||
| 91933bbd19 | |||
| f8d075b5d3 | |||
| a4a0a5bb1a | |||
| 86ef758a9a | |||
| 1a03180643 | |||
| 326183a3fd | |||
| 08fc657755 | |||
| 0ff9539599 | |||
| 38f5e077ee | |||
| 89fbd75e7a | |||
| 493662524a | |||
| 1afbb357db | |||
| 8d2140f607 | |||
| 97732987d9 | |||
| a60a40bca3 | |||
| a8ff2b3d9c | |||
| 3a8bfa0873 | |||
| c07fba7add | |||
| 855483c8c2 | |||
| 048c511b18 | |||
| dfc0c34d95 | |||
| a21bb5b234 | |||
| 994d39241e | |||
| e6c1164755 | |||
| 89cc8a1a65 | |||
| c0e4f1e114 | |||
| 7b43448ce4 | |||
| bdac0b65f4 | |||
| cf9ee6f20c | |||
| 01eae72a64 | |||
| bca1476eab | |||
| fbcbde0a4b | |||
| 3914d766db | |||
| b1a119edb4 | |||
| 3dc4bb8e34 | |||
| f5e7ca12f7 | |||
| 7c3cc7b90c | |||
| a5a1ba72fd | |||
| e1d76117b4 | |||
| ad3911a21f | |||
| 3440dcd14b | |||
| e85eef05b8 | |||
| f16edd4fff | |||
| 3e2cb6a2ab | |||
| 25830524f3 | |||
| 304094630c | |||
| 5c3643c54c | |||
| 589cce18af | |||
| e254caf82d | |||
| 438fc105cd | |||
| 7efcd242d6 | |||
| 5d811d3949 | |||
| 8e6aaee10c | |||
| 6da59cfb07 | |||
| eae87e1ec9 | |||
| 894d72e657 | |||
| 42b8293f99 | |||
| 10ceacfbb1 | |||
| 66f5ccd902 | |||
| 3379587223 | |||
| e25a1a42cf | |||
| 21f1fa82f4 | |||
| 0c771e4a77 | |||
| ff4412a627 | |||
| bf430e659a | |||
| ec21cb13d3 | |||
| 1d26b96d90 | |||
| be017c87f4 | |||
| 23fffa95c8 | |||
| 5b303e2e6d | |||
| fc33b3eb68 | |||
| 795aec9578 | |||
| 7d31140c14 | |||
| 654112ca86 | |||
| 5dd30f9a45 | |||
| a53a1ca49b | |||
| 3fd6c4c8a6 | |||
| 5808784f07 | |||
| 537849c1e7 | |||
| 7f3c0fdeb2 | |||
| 8e431e2076 | |||
| 89c11fd683 | |||
| 7cfe2aca99 | |||
| 3a938d2a13 | |||
| 812834bc9f | |||
| 51ff4f6e46 | |||
| 7ac169c5e8 | |||
| 61648ebe3e | |||
| 0610f0db0a | |||
| 8c935981bb | |||
| 3f3b4e4924 | |||
| af581e7f21 | |||
| 9e371ee10b | |||
| 7cf77adbc8 | |||
| 31673ee521 | |||
| ff22030dde | |||
| 101580fd77 | |||
| 418f05f6e4 | |||
| df421e5554 | |||
| ed84074a60 | |||
| bbf61239ad | |||
| 92ee534a2c | |||
| fa4df0b5f3 | |||
| e5ac31efe7 | |||
| 2a7745c767 | |||
| 82e7502f74 | |||
| 866e546b59 | |||
| 6b642d7674 | |||
| 0711ec346f | |||
| bbafb59cb2 | |||
| eaa1fddfa9 | |||
| 1ffa339a2a | |||
| 0dbe32e2dc | |||
| 4e855a17bc | |||
| f2fc724e0f | |||
| 460acf40c0 | |||
| cf29d9390f | |||
| ac44d1fdef | |||
| 66d0f0afd4 | |||
| 2a7b4f6e64 | |||
| 6e1be64aef | |||
| f818ad0758 | |||
| 4abea2bd30 | |||
| 267abfd552 | |||
| b4450eb617 | |||
| daa2efde14 | |||
| d561046ba3 | |||
| fd223bb259 | |||
| 451ad685ae | |||
| 93decaa997 | |||
| 0d1a3ab18b | |||
| 2a6863cf70 | |||
| 76e0d6d71a | |||
| 974bb6b359 | |||
| 2e410fc728 | |||
| 0e2ca0379f | |||
| 9214d48a2d | |||
| 064495698f | |||
| 7c913093b0 | |||
| edf0982ce4 | |||
| 7bf44bd8d2 | |||
| 881b409ebc | |||
| eacfd14218 | |||
| b8ffecf500 | |||
| 74a46464c8 | |||
| e5d85e402b | |||
| 4aa63dbeaf | |||
| ddc268a732 | |||
| f6ac6b9007 | |||
| ea21d44d60 | |||
| 0f734e19fd | |||
| 6044502968 | |||
| fed11fffa4 | |||
| f79f460b89 | |||
| a6009e2bd8 | |||
| 483048e3dc | |||
| b8c73430fb | |||
| 3141ed52bd | |||
| a219a8b70d | |||
| c1de265baf | |||
| 13c8fa3f92 | |||
| 63ff234f10 | |||
| 5219ba5c4e | |||
| 84994b5d98 | |||
| 1554f71106 | |||
| 4ff4c5f1bf | |||
| 73e665bef7 | |||
| 4b1bda5f2e | |||
| 18114eafda | |||
| 87cbcc9875 | |||
| 1ebc2070c0 | |||
| e95bd8d3a6 | |||
| 476c01469f | |||
| d5a3107f8f | |||
| 8d5841b71f | |||
| 10163ec78a | |||
| 8faed949c2 | |||
| e1719efbc8 | |||
| f01c23ad40 | |||
| 847ef0f3f4 | |||
| 98b89ebcc5 | |||
| 39b9e55434 | |||
| 3eb15089af | |||
| c5b23d12a8 | |||
| 69f2fb291a | |||
| 78660da995 | |||
| c951b14aa2 | |||
| c384439b44 | |||
| 87d2750ff8 | |||
| 6d76d55452 | |||
| d80598b9c3 | |||
| c7d318304b | |||
| bcdbc15635 | |||
| 4749159bb9 | |||
| 5530a2260a | |||
| c24de24ca4 | |||
| b54b4c79ed | |||
| c6cc7aae84 | |||
| 84cd209074 | |||
| afda44fbe3 | |||
| f5d3b93437 | |||
| 069a3628fa | |||
| c81ef2672a | |||
| a5ae27cae0 | |||
| 73faaf6577 | |||
| 29dbd085d4 | |||
| 00b011809a | |||
| 0b46ca7ff3 | |||
| 9294b44831 | |||
| 80fd51119b | |||
| 5af5ad9e36 | |||
| 7b731ebda8 | |||
| 28bfb3b8b2 | |||
| 351895ae66 | |||
| c1009adf52 | |||
| ecaec41208 | |||
| 997b51102b | |||
| c5bd074c28 | |||
| 4c09ed3c09 | |||
| a56e43d17e | |||
| e357d9de74 | |||
| 94736ff199 | |||
| aff92a48bf | |||
| d0998a9dfb | |||
| 3678688433 | |||
| 0c03177840 | |||
| 20ff719c00 | |||
| 8a8ec492d7 | |||
| 02c1443dd1 | |||
| 79301f192c | |||
| 4b2c854c42 | |||
| d02ee7be8b | |||
| dbeadb6833 | |||
| 478cc32de1 | |||
| 7b302445c2 | |||
| ae839ef6d8 | |||
| 144a53f4b3 | |||
| fa1d1e6034 | |||
| a404436f2c | |||
| 48a0b97ac0 | |||
| d21212d0e4 | |||
| c1917ebf4f | |||
| b816045f37 | |||
| 1df1138d04 | |||
| 1962ff2def | |||
| bcb12a0717 | |||
| 5d0fc8ac7a | |||
| 92a8e40cde | |||
| a4d37e2c20 | |||
| c599fb75ed | |||
| e7e0f84edf | |||
| e19a282c59 | |||
| fbc8667968 | |||
| cda49c3a9a | |||
| 4be1027444 | |||
| 46152d3faf | |||
| ed4cacfffb | |||
| 52d1979937 | |||
| b30cb12133 | |||
| 31d4e304fc | |||
| 9a7a594cb5 | |||
| e469178a6b | |||
| 0a517980b7 | |||
| 9c691b2266 | |||
| 3597726aad | |||
| a4a37c268d | |||
| 651a0645c5 | |||
| bf3fa3e918 | |||
| 3b2ce9f500 | |||
| 3769f145ee | |||
| 18ebeae318 | |||
| 7e246477f0 | |||
| 20d6ff4620 | |||
| a2b61e2ab8 | |||
| c6289d8f75 | |||
| 567390e27c | |||
| 0c0f8bf484 | |||
| ae0a9cb591 | |||
| 3f4d7255a0 | |||
| b8d2499475 | |||
| 8cb26d886f | |||
| 3ca8dd204f | |||
| bc3e09f47b | |||
| 3476afce41 | |||
| 9b0e24ec49 | |||
| 92d71fffe9 | |||
| 80c22f4f72 | |||
| 6e22d266dd | |||
| 4c285fb521 | |||
| 51c3521aaa | |||
| 32112a3326 | |||
| f22221f781 | |||
| 707db768ea | |||
| 591803d407 | |||
| b48919246d | |||
| cf9a7235f7 | |||
| d62a6f107b | |||
| 1a539830f8 | |||
| 4250d997b3 | |||
| 153d8cef6b | |||
| c9cdf47603 | |||
| 55ac878648 | |||
| 60abddada3 | |||
| bbc583cc8d | |||
| 7906030037 | |||
| 06b385697d | |||
| 059008a903 | |||
| 418913aa53 | |||
| 4b07aa2bc3 | |||
| 64d8daa67d | |||
| 9d44947500 | |||
| 4043a10531 | |||
| 7c8dac2fd5 | |||
| 97c9e95211 | |||
| a4be369e43 | |||
| bdaca78750 | |||
| 6326d7e4ba | |||
| a809a09e55 | |||
| 963122b916 | |||
| aa3b012d60 | |||
| 401dfb9ee2 | |||
| 1d81c52950 | |||
| 52c4ef2d87 | |||
| 52c31fabe2 | |||
| 79e239ad97 | |||
| 8abaf1015d | |||
| 9a0c814fd4 | |||
| c64e1b42a4 | |||
| 2d23c36067 | |||
| 754144ad99 | |||
| 0faf109c2a | |||
| 7d1eff3ec4 | |||
| e295c470a5 | |||
| 935168c024 | |||
| f44961d065 | |||
| 40c7cf3901 | |||
| 0c7a95ccd8 | |||
| 09215bad57 | |||
| 4ff07e3c74 | |||
| 473e01aadd | |||
| cd5312ba77 | |||
| d87bfb0d5d | |||
| d2de0ea5ad | |||
| 4af064fd17 | |||
| 8ab2b515f6 | |||
| 51a1c0e375 | |||
| 30a0098b2a | |||
| e3cb9eb8af | |||
| b0de33c801 | |||
| bcdd8c463c | |||
| 336e2a2c40 | |||
| 338d8a6610 | |||
| 9d93bda3fe | |||
| a8dda20a30 | |||
| cd7755fe07 | |||
| afe292de35 | |||
| dc995af34b | |||
| d4dcc6430f | |||
| a8cc995633 | |||
| 73251db1da | |||
| d16398a0e8 | |||
| 331ada02fd | |||
| 80e1231e9a | |||
| e61b29ec6a | |||
| 16d49d568b | |||
| 776e17062c | |||
| 8fa8c14b0b | |||
| 64de474139 | |||
| d35771f97d | |||
| 7a4d20d329 | |||
| aab095347f | |||
| 1addd5b2ab | |||
| da4bb6549c | |||
| 7193454d50 | |||
| d204b92877 | |||
| 04faf26140 | |||
| 67b81c279b | |||
| 2afb08d8b2 | |||
| 06b2c7cb16 | |||
| 9c12803ddd | |||
| ce65491d55 | |||
| b67adcf481 | |||
| 1707d55c02 | |||
| 48c2d98dde | |||
| 7dd95d8a59 | |||
| af09b5cb16 | |||
| e1b71540c7 | |||
| 85e1764857 | |||
| 0553f84d6c | |||
| 3fd89808ee | |||
| 96753821b7 | |||
| eca3ede7b0 | |||
| a7e580407c | |||
| 8bd1565696 | |||
| 03e0949067 | |||
| dbe8e33c4b | |||
| 952023db30 | |||
| 4e0b5063c6 | |||
| 30d1d55e3c | |||
| 1e9026d44c | |||
| e48950d260 | |||
| 31f46045d7 | |||
| d6455d774b | |||
| 3e928b9659 | |||
| 5e5207da95 | |||
| def8b730b7 | |||
| 22a109c2ae | |||
| 6416707e35 | |||
| 4658998b85 | |||
| d233fb8b1e | |||
| df1299b192 | |||
| 15ee17724d | |||
| 437c186a66 | |||
| 3610a42ebf | |||
| bf1bde79ec | |||
| f309638192 | |||
| 6439e4e152 | |||
| 4b1395b2c9 | |||
| 1859206007 | |||
| 3b93429353 | |||
| d68ccfcc96 | |||
| 68b8a1a01c | |||
| 75ee46715a | |||
| a8cad50f27 | |||
| fc2a67188f | |||
| d69592aaa8 | |||
| f3397f6f08 | |||
| be92e4f395 | |||
| 912e40e7f0 | |||
| 2876c43387 | |||
| 464882f206 | |||
| 6736fb85c2 | |||
| 1f75255950 | |||
| a954e75547 | |||
| d2b9997620 | |||
| 36432c4361 | |||
| 36f0d1f0f9 | |||
| f65b268bb2 | |||
| fe06dfcca3 | |||
| bc9043bc3f | |||
| 430694aae9 | |||
| c643e3c093 | |||
| ff46eef3b2 | |||
| a0c364aa81 | |||
| 0e0f923a49 | |||
| f2d637b935 | |||
| 96e61a4a92 | |||
| e42c1b6da8 | |||
| 387bba093e | |||
| 123cf9cb11 | |||
| 93277ffac9 | |||
| c091053ea8 | |||
| 8b9f2f1e70 | |||
| 25ca7bd71e | |||
| 093b37e04b | |||
| a12e27f9ab | |||
| ae6e0db053 | |||
| cd6bef4d78 | |||
| de1304dc6a | |||
| f835f63542 | |||
| 5deb045e47 | |||
| 42e84afd89 | |||
| a7ed6b8c76 | |||
| ee43b98ce6 | |||
| 681b4747a6 | |||
| a6da4ebe5e | |||
| e35a604b30 | |||
| 45c9db258d | |||
| 382aaaf053 | |||
| f66edc8d45 | |||
| 3f8d8b5033 | |||
| bf587765de | |||
| 313a6d8a24 | |||
| 2213fb1ebf | |||
| 9bf63354be | |||
| cd6cb1d60c | |||
| 193676012f | |||
| bddf7b8623 | |||
| 4c8c87d3fd | |||
| 83288ca43e | |||
| 7f58a83833 | |||
| 19651d24bb | |||
| dba08edd0d | |||
| dc06bc943a | |||
| b48e6fb1b3 | |||
| 0c5308a132 | |||
| 339d98be35 | |||
| e8be624794 | |||
| b2c6471ab0 | |||
| 4ea865f017 | |||
| 106f352017 | |||
| 5b7805e8d7 | |||
| 831c2150d6 | |||
| a500f2edc8 | |||
| d27099f2da | |||
| 2aa0986295 | |||
| 34c6ceb67c | |||
| 906877cbe6 | |||
| 609180022e | |||
| 49c087a141 | |||
| 70f12cd686 | |||
| 738e69a8af | |||
| 60492d46ee | |||
| ea82e00359 | |||
| 928c557a25 | |||
| 0500ee8e2b | |||
| f92f0a3e5d | |||
| c1b764da04 | |||
| 22bd8d6824 | |||
| a4fc92e803 | |||
| 053c4e989b | |||
| 1bd8eae25a | |||
| a41391f9f2 | |||
| b3a1f4ca7d | |||
| c3e4a52e5f | |||
| 3cf0880f98 | |||
| b04dad1fd2 | |||
| 6d47663842 | |||
| 3765dd46f7 | |||
| 6b39717695 | |||
| 17d642efc9 | |||
| 4839cc6119 | |||
| 127e8c31c2 | |||
| 1cf673154c | |||
| f7c228ede2 | |||
| 78617ec7ce | |||
| e5048bddeb | |||
| eebe31f69d | |||
| 90b57eb5cb | |||
| 2b2edf4852 | |||
| a920e45f96 | |||
| 8910ab3a47 | |||
| c09bbfb8ac | |||
| 02909c62ab | |||
| 978d9cbb6a | |||
| cb3825bb00 | |||
| 5f54becbe2 | |||
| 317b6fa475 | |||
| 8199c83072 | |||
| 776c9ebfdd | |||
| 73fca5d1a2 | |||
| 844773a735 | |||
| 1a7e8456ab | |||
| f6a189f118 | |||
| 82e2e0d02f | |||
| 8771317a1e | |||
| ebae70c514 | |||
| dbdb4f5185 | |||
| af2b3b3bfc | |||
| 6497d9a46f | |||
| 8f4a62a2cb | |||
| acbe83a2e2 | |||
| e0f3fb3c3d | |||
| fef789e4d3 | |||
| 680b900c76 | |||
| f797f132cf | |||
| 941ab6db84 | |||
| 5eea508296 | |||
| 9782d1bff8 | |||
| 0e3d224c12 | |||
| 8aeb2229ce | |||
| 179f3e6426 | |||
| 561741d43d | |||
| 63e8d0634f | |||
| 350667b60f | |||
| 6a86dae76e | |||
| a7eca40fe7 | |||
| ef28dc5001 | |||
| d29ac4023a | |||
| c2af2c6d5e | |||
| d9fb29d314 | |||
| 981421ded6 | |||
| 49ad22ca82 | |||
| 858e245108 | |||
| 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 | |||
| 61dfb0f207 | |||
| 6f9cb770be | |||
| f4e05e1352 | |||
| 8af46ab804 | |||
| 9d32c4e720 | |||
| 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 | |||
| a70088b799 | |||
| bb45d9cb54 |
@@ -17,7 +17,6 @@ ENV/
|
|||||||
.conda/
|
.conda/
|
||||||
dashboard/
|
dashboard/
|
||||||
data/
|
data/
|
||||||
changelogs/
|
|
||||||
tests/
|
tests/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.astrbot
|
.astrbot
|
||||||
|
|||||||
@@ -1,42 +1,40 @@
|
|||||||
|
|
||||||
name: '🎉 功能建议'
|
name: '🎉 Feature Request / 功能建议'
|
||||||
title: "[Feature]"
|
title: "[Feature]"
|
||||||
description: 提交建议帮助我们改进。
|
description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。
|
||||||
labels: [ "enhancement" ]
|
labels: [ "enhancement" ]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 描述
|
label: Description / 描述
|
||||||
description: 简短描述您的功能建议。
|
description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 使用场景
|
label: Use Case / 使用场景
|
||||||
description: 你想要发生什么?
|
description: Please describe the use case for this feature. / 请描述这个功能的使用场景。
|
||||||
placeholder: >
|
|
||||||
一个清晰且具体的描述这个功能的使用场景。
|
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: 你愿意提交PR吗?
|
label: Willing to Submit PR? / 是否愿意提交PR?
|
||||||
description: >
|
description: >
|
||||||
这不是必须的,但我们欢迎您的贡献。
|
This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必需的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激!
|
||||||
options:
|
options:
|
||||||
- label: 是的, 我愿意提交PR!
|
- label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: Code of Conduct
|
label: Code of Conduct
|
||||||
options:
|
options:
|
||||||
- label: >
|
- 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
|
required: true
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "感谢您填写我们的表单!"
|
value: "Thank you for filling out our form!"
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
### Modifications / 改动点
|
### Modifications / 改动点
|
||||||
|
|
||||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
|
||||||
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||||
|
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||||
|
|
||||||
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
|
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
|
||||||
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
|
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
|
||||||
@@ -21,7 +21,14 @@
|
|||||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
<!--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.
|
- [ ] 😊 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**.
|
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
|
||||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
|
||||||
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
|
- [ ] 👀 My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||||
|
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
|
||||||
|
|
||||||
|
- [ ] 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||||
|
/ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
||||||
|
|
||||||
|
- [ ] 😮 My changes do not introduce malicious code.
|
||||||
|
/ 我的更改没有引入恶意代码。
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ Always reference these instructions first and fallback to search or bash command
|
|||||||
### Running the Application
|
### Running the Application
|
||||||
- Run main application: `uv run main.py` -- starts in ~3 seconds
|
- Run main application: `uv run main.py` -- starts in ~3 seconds
|
||||||
- Application creates WebUI on http://localhost:6185 (default credentials: `astrbot`/`astrbot`)
|
- 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)
|
### Dashboard Build (Vue.js/Node.js)
|
||||||
- **Prerequisites**: Node.js 20+ and npm 10+ required
|
- **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
|
- **ALWAYS** run `uv run ruff check .` and `uv run ruff format .` before committing changes
|
||||||
|
|
||||||
### Plugin Development
|
### 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
|
- Plugin system supports function tools and message handlers
|
||||||
- Key plugins: python_interpreter, web_searcher, astrbot, reminder, session_controller
|
- Key plugins: python_interpreter, web_searcher, astrbot, reminder, session_controller
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
name: Auto Release
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-publish-to-github-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Dashboard Build
|
|
||||||
run: |
|
|
||||||
cd dashboard
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
echo "COMMIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
|
||||||
echo ${{ github.ref_name }} > dist/assets/version
|
|
||||||
zip -r dist.zip dist
|
|
||||||
|
|
||||||
- name: Upload to Cloudflare R2
|
|
||||||
env:
|
|
||||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
||||||
R2_BUCKET_NAME: "astrbot"
|
|
||||||
R2_OBJECT_NAME: "astrbot-webui-latest.zip"
|
|
||||||
VERSION_TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
echo "Installing rclone..."
|
|
||||||
curl https://rclone.org/install.sh | sudo bash
|
|
||||||
|
|
||||||
echo "Configuring rclone remote..."
|
|
||||||
mkdir -p ~/.config/rclone
|
|
||||||
cat <<EOF > ~/.config/rclone/rclone.conf
|
|
||||||
[r2]
|
|
||||||
type = s3
|
|
||||||
provider = Cloudflare
|
|
||||||
access_key_id = $R2_ACCESS_KEY_ID
|
|
||||||
secret_access_key = $R2_SECRET_ACCESS_KEY
|
|
||||||
endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Uploading dist.zip to R2 bucket: $R2_BUCKET_NAME/$R2_OBJECT_NAME"
|
|
||||||
mv dashboard/dist.zip dashboard/$R2_OBJECT_NAME
|
|
||||||
rclone copy dashboard/$R2_OBJECT_NAME r2:$R2_BUCKET_NAME --progress
|
|
||||||
mv dashboard/$R2_OBJECT_NAME dashboard/astrbot-webui-${VERSION_TAG}.zip
|
|
||||||
rclone copy dashboard/astrbot-webui-${VERSION_TAG}.zip r2:$R2_BUCKET_NAME --progress
|
|
||||||
mv dashboard/astrbot-webui-${VERSION_TAG}.zip dashboard/dist.zip
|
|
||||||
|
|
||||||
- name: Fetch Changelog
|
|
||||||
run: |
|
|
||||||
echo "changelog=changelogs/${{github.ref_name}}.md" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: ncipollo/release-action@v1
|
|
||||||
with:
|
|
||||||
bodyFile: ${{ env.changelog }}
|
|
||||||
artifacts: "dashboard/dist.zip"
|
|
||||||
|
|
||||||
build-and-publish-to-pypi:
|
|
||||||
# 构建并发布到 PyPI
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build-and-publish-to-github-release
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: '3.10'
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: |
|
|
||||||
python -m pip install uv
|
|
||||||
|
|
||||||
- name: Build package
|
|
||||||
run: |
|
|
||||||
uv build
|
|
||||||
|
|
||||||
- name: Publish to PyPI
|
|
||||||
env:
|
|
||||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
||||||
run: |
|
|
||||||
uv publish
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest # 运行环境
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
- name: nodejs installation
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
- name: npm install
|
||||||
|
run: npm add -D vitepress
|
||||||
|
working-directory: './docs' # working-directory 指定 shell 命令运行目录
|
||||||
|
- name: npm run build
|
||||||
|
run: npm run docs:build
|
||||||
|
working-directory: './docs'
|
||||||
|
- name: scp
|
||||||
|
uses: appleboy/scp-action@v1.0.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST_NEKO }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
password: ${{ secrets.PASSWORDNEKO }}
|
||||||
|
source: 'docs/.vitepress/dist/*'
|
||||||
|
target: '/tmp/'
|
||||||
|
- name: script
|
||||||
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST_NEKO }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
password: ${{ secrets.PASSWORDNEKO }}
|
||||||
|
script: |
|
||||||
|
mkdir -p /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||||
|
rm -rf /root/docker_data/caddy/caddy_data/static_site/abv4/*
|
||||||
|
mv /tmp/docs/.vitepress/dist/* /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||||
|
rm -rf /tmp/docs/
|
||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Install UV
|
- name: Install UV
|
||||||
run: pip install uv
|
run: pip install uv
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
mkdir -p data/temp
|
mkdir -p data/temp
|
||||||
export TESTING=true
|
export TESTING=true
|
||||||
export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
||||||
pytest --cov=. -v -o log_cli=true -o log_level=DEBUG
|
pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG
|
||||||
|
|
||||||
- name: Upload results to Codecov
|
- name: Upload results to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 'latest'
|
node-version: '24.13.0'
|
||||||
|
|
||||||
- name: npm install, build
|
- name: npm install, build
|
||||||
run: |
|
run: |
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
zip -r dist.zip dist
|
zip -r dist.zip dist
|
||||||
|
|
||||||
- name: Archive production artifacts
|
- name: Archive production artifacts
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: dist-without-markdown
|
name: dist-without-markdown
|
||||||
path: |
|
path: |
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1.21.0
|
||||||
with:
|
with:
|
||||||
tag: release-${{ github.sha }}
|
tag: release-${{ github.sha }}
|
||||||
owner: AstrBotDevs
|
owner: AstrBotDevs
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: Deploy Dashboard to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *' # Runs daily at midnight UTC
|
||||||
|
workflow_dispatch: # Allow manual triggering
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# Only allow one concurrent deployment at a time
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: dashboard
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Build dashboard
|
||||||
|
working-directory: dashboard
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: dashboard/dist
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
GHCR_OWNER: soulter
|
GHCR_OWNER: astrbotdevs
|
||||||
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
|
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -64,20 +64,20 @@ jobs:
|
|||||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Set QEMU
|
- name: Set QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4.0.0
|
||||||
|
|
||||||
- name: Set Docker Buildx
|
- name: Set Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4.0.0
|
||||||
|
|
||||||
- name: Log in to DockerHub
|
- name: Log in to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: env.HAS_GHCR_TOKEN == 'true'
|
if: env.HAS_GHCR_TOKEN == 'true'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ env.GHCR_OWNER }}
|
username: ${{ env.GHCR_OWNER }}
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and Push Nightly Image
|
- name: Build and Push Nightly Image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7.0.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
GHCR_OWNER: soulter
|
GHCR_OWNER: astrbotdevs
|
||||||
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
|
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -163,27 +163,27 @@ jobs:
|
|||||||
cp -r dashboard/dist data/
|
cp -r dashboard/dist data/
|
||||||
|
|
||||||
- name: Set QEMU
|
- name: Set QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4.0.0
|
||||||
|
|
||||||
- name: Set Docker Buildx
|
- name: Set Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4.0.0
|
||||||
|
|
||||||
- name: Log in to DockerHub
|
- name: Log in to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: env.HAS_GHCR_TOKEN == 'true'
|
if: env.HAS_GHCR_TOKEN == 'true'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ env.GHCR_OWNER }}
|
username: ${{ env.GHCR_OWNER }}
|
||||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and Push Release Image
|
- name: Build and Push Release Image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7.0.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: PR Title Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, edited, reopened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
title-format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Validate PR title
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const title = (context.payload.pull_request.title || "").trim();
|
||||||
|
// allow only:
|
||||||
|
// feat: xxx
|
||||||
|
// feat(scope): xxx
|
||||||
|
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
|
||||||
|
const isValid = pattern.test(title);
|
||||||
|
const isSameRepo =
|
||||||
|
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
if (isSameRepo) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.pull_request.number,
|
||||||
|
body: [
|
||||||
|
"⚠️ PR title format check failed.",
|
||||||
|
"Required formats:",
|
||||||
|
"- `feat: xxx`",
|
||||||
|
"- `feat(scope): xxx`",
|
||||||
|
"Please update your PR title and push again."
|
||||||
|
].join("\n")
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
core.warning(`Failed to post PR title comment: ${e.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref to build (branch/tag/SHA)"
|
||||||
|
required: false
|
||||||
|
default: "master"
|
||||||
|
tag:
|
||||||
|
description: "Release tag to publish assets to (for example: v4.14.6)"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-dashboard:
|
||||||
|
name: Build Dashboard
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
env:
|
||||||
|
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve tag
|
||||||
|
id: tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
elif [ -n "${{ inputs.tag }}" ]; then
|
||||||
|
tag="${{ inputs.tag }}"
|
||||||
|
else
|
||||||
|
tag="$(git describe --tags --abbrev=0)"
|
||||||
|
fi
|
||||||
|
if [ -z "$tag" ]; then
|
||||||
|
echo "Failed to resolve tag." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4.4.0
|
||||||
|
with:
|
||||||
|
version: 10.28.2
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24.13.0'
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: dashboard/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Build dashboard dist
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
pnpm --dir dashboard install --frozen-lockfile
|
||||||
|
pnpm --dir dashboard run build
|
||||||
|
echo "${{ steps.tag.outputs.tag }}" > dashboard/dist/assets/version
|
||||||
|
cd dashboard
|
||||||
|
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
|
||||||
|
|
||||||
|
- name: Upload dashboard artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||||
|
if-no-files-found: error
|
||||||
|
path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip
|
||||||
|
|
||||||
|
- name: Upload dashboard package to Cloudflare R2
|
||||||
|
if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }}
|
||||||
|
env:
|
||||||
|
R2_BUCKET_NAME: "astrbot"
|
||||||
|
R2_OBJECT_NAME: "astrbot-webui-latest.zip"
|
||||||
|
VERSION_TAG: ${{ steps.tag.outputs.tag }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
curl https://rclone.org/install.sh | sudo bash
|
||||||
|
|
||||||
|
mkdir -p ~/.config/rclone
|
||||||
|
cat <<EOF > ~/.config/rclone/rclone.conf
|
||||||
|
[r2]
|
||||||
|
type = s3
|
||||||
|
provider = Cloudflare
|
||||||
|
access_key_id = $R2_ACCESS_KEY_ID
|
||||||
|
secret_access_key = $R2_SECRET_ACCESS_KEY
|
||||||
|
endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}"
|
||||||
|
rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
|
||||||
|
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
|
||||||
|
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
name: Publish GitHub Release
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs:
|
||||||
|
- build-dashboard
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve tag
|
||||||
|
id: tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
elif [ -n "${{ inputs.tag }}" ]; then
|
||||||
|
tag="${{ inputs.tag }}"
|
||||||
|
else
|
||||||
|
tag="$(git describe --tags --abbrev=0)"
|
||||||
|
fi
|
||||||
|
if [ -z "$tag" ]; then
|
||||||
|
echo "Failed to resolve tag." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Download dashboard artifact
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||||
|
path: release-assets
|
||||||
|
|
||||||
|
|
||||||
|
- name: Resolve release notes
|
||||||
|
id: notes
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
note_file="changelogs/${{ steps.tag.outputs.tag }}.md"
|
||||||
|
if [ ! -f "$note_file" ]; then
|
||||||
|
note_file="$(mktemp)"
|
||||||
|
echo "Release ${{ steps.tag.outputs.tag }}" > "$note_file"
|
||||||
|
fi
|
||||||
|
echo "file=$note_file" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Ensure release exists
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
tag="${{ steps.tag.outputs.tag }}"
|
||||||
|
if ! gh release view "$tag" >/dev/null 2>&1; then
|
||||||
|
gh release create "$tag" --title "$tag" --notes-file "${{ steps.notes.outputs.file }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Remove stale assets from release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
tag="${{ steps.tag.outputs.tag }}"
|
||||||
|
while IFS= read -r asset; do
|
||||||
|
case "$asset" in
|
||||||
|
*.AppImage|*.dmg|*.zip|*.exe|*.blockmap)
|
||||||
|
gh release delete-asset "$tag" "$asset" -y || true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(gh release view "$tag" --json assets --jq '.assets[].name')
|
||||||
|
|
||||||
|
- name: Upload assets to release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
tag="${{ steps.tag.outputs.tag }}"
|
||||||
|
gh release upload "$tag" release-assets/* --clobber
|
||||||
|
|
||||||
|
publish-pypi:
|
||||||
|
name: Publish PyPI
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs:
|
||||||
|
- publish-release
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve tag
|
||||||
|
id: tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
elif [ -n "${{ inputs.tag }}" ]; then
|
||||||
|
tag="${{ inputs.tag }}"
|
||||||
|
else
|
||||||
|
tag="$(git describe --tags --abbrev=0)"
|
||||||
|
fi
|
||||||
|
if [ -z "$tag" ]; then
|
||||||
|
echo "Failed to resolve tag." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Download dashboard artifact
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||||
|
path: dashboard-artifact
|
||||||
|
|
||||||
|
- name: Unpack dashboard dist into package tree
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p astrbot/dashboard/dist
|
||||||
|
unzip -q "dashboard-artifact/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" -d dashboard-artifact/unpacked
|
||||||
|
cp -r dashboard-artifact/unpacked/dist/. astrbot/dashboard/dist/
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
shell: bash
|
||||||
|
run: python -m pip install uv
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
shell: bash
|
||||||
|
# Dashboard assets are already in astrbot/dashboard/dist/;
|
||||||
|
# ASTRBOT_BUILD_DASHBOARD is intentionally unset so the hatch hook skips npm.
|
||||||
|
run: uv build
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
env:
|
||||||
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: uv publish
|
||||||
@@ -5,9 +5,9 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'README*.md'
|
- "README*.md"
|
||||||
- 'changelogs/**'
|
- "changelogs/**"
|
||||||
- 'dashboard/**'
|
- "dashboard/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install UV package manager
|
- name: Install UV package manager
|
||||||
run: |
|
run: |
|
||||||
@@ -40,6 +40,9 @@ jobs:
|
|||||||
- name: Run smoke tests
|
- name: Run smoke tests
|
||||||
run: |
|
run: |
|
||||||
uv run main.py &
|
uv run main.py &
|
||||||
|
# uv tool install -e . --force
|
||||||
|
# astrbot init -y
|
||||||
|
# astrbot run --backend-only &
|
||||||
APP_PID=$!
|
APP_PID=$!
|
||||||
|
|
||||||
echo "Waiting for application to start..."
|
echo "Waiting for application to start..."
|
||||||
|
|||||||
+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.
|
# 文档: https://github.com/actions/stale
|
||||||
# For more information, see:
|
name: Mark stale bug issues
|
||||||
# https://github.com/actions/stale
|
|
||||||
name: Mark stale issues and pull requests
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
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:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v10
|
- uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: 'Stale issue message'
|
operations-per-run: 200
|
||||||
stale-pr-message: 'Stale pull request message'
|
|
||||||
stale-issue-label: 'no-issue-activity'
|
# 只处理带 bug 标签的 Issue
|
||||||
stale-pr-label: 'no-pr-activity'
|
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 }}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: sync wiki
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/sync-wiki.yml'
|
||||||
|
- 'docs/scripts/sync_docs_to_wiki.py'
|
||||||
|
- 'docs/tests/test_sync_docs_to_wiki.py'
|
||||||
|
- 'docs/zh/**'
|
||||||
|
- 'docs/en/**'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sync-wiki-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Validate manual ref
|
||||||
|
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master'
|
||||||
|
run: |
|
||||||
|
echo "This workflow only publishes from refs/heads/master. Re-run it from the master branch."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Check out docs repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Run sync unit tests
|
||||||
|
working-directory: docs
|
||||||
|
run: python -m unittest discover -s tests -p 'test_sync_docs_to_wiki.py' -v
|
||||||
|
|
||||||
|
- name: Validate internal doc links
|
||||||
|
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --check-links-only
|
||||||
|
|
||||||
|
- name: Clone AstrBot wiki
|
||||||
|
env:
|
||||||
|
WIKI_TOKEN: ${{ secrets.ASTRBOT_WIKI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
test -n "$WIKI_TOKEN"
|
||||||
|
git clone "https://x-access-token:${WIKI_TOKEN}@github.com/AstrBotDevs/AstrBot.wiki.git" wiki
|
||||||
|
|
||||||
|
- name: Generate wiki pages
|
||||||
|
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --wiki-root wiki
|
||||||
|
|
||||||
|
- name: Commit and push wiki changes
|
||||||
|
working-directory: wiki
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No wiki changes to push"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "docs: sync wiki from AstrBot-1/docs"
|
||||||
|
git push
|
||||||
+16
-3
@@ -24,18 +24,21 @@ configs/session
|
|||||||
configs/config.yaml
|
configs/config.yaml
|
||||||
cmd_config.json
|
cmd_config.json
|
||||||
|
|
||||||
# Plugins and packages
|
# Plugins
|
||||||
addons/plugins
|
addons/plugins
|
||||||
packages/python_interpreter/workplace
|
astrbot/builtin_stars/python_interpreter/workplace
|
||||||
tests/astrbot_plugin_openai
|
tests/astrbot_plugin_openai
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
dashboard/node_modules/
|
dashboard/node_modules/
|
||||||
dashboard/dist/
|
dashboard/dist/
|
||||||
|
.pnpm-store/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
package.json
|
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# Bundled dashboard dist (generated by hatch_build.py during pip wheel build)
|
||||||
|
astrbot/dashboard/dist/
|
||||||
|
|
||||||
# Operating System
|
# Operating System
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -50,3 +53,13 @@ venv/*
|
|||||||
pytest.ini
|
pytest.ini
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
IFLOW.md
|
IFLOW.md
|
||||||
|
|
||||||
|
# genie_tts data
|
||||||
|
CharacterModels/
|
||||||
|
GenieData/
|
||||||
|
.agent/
|
||||||
|
.codex/
|
||||||
|
.opencode/
|
||||||
|
.kilocode/
|
||||||
|
.serena
|
||||||
|
.worktrees/
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.10
|
3.12
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## Setup commands
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
```
|
||||||
|
uv tool install -e . --force
|
||||||
|
astrbot init
|
||||||
|
astrbot run # start the bot
|
||||||
|
astrbot run --backend-only # start the backend only
|
||||||
|
```
|
||||||
|
|
||||||
|
Exposed an API server on `http://localhost:6185` by default.
|
||||||
|
|
||||||
|
### Dashboard(WebUI)
|
||||||
|
|
||||||
|
```
|
||||||
|
cd dashboard
|
||||||
|
bun install # First time only.
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs on `http://localhost:3000` by default.
|
||||||
|
|
||||||
|
## Dev environment tips
|
||||||
|
|
||||||
|
1. When modifying the WebUI, be sure to maintain componentization and clean code. Avoid duplicate code.
|
||||||
|
2. Do not add any report files such as xxx_SUMMARY.md.
|
||||||
|
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
|
||||||
|
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
|
||||||
|
5. Use English for all new comments.
|
||||||
|
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
|
||||||
|
7. Use Python 3.12+ type hinting syntax (e.g., `list[str]` over `List[str]`, `int | None` over `Optional[int]`). Avoid using `Any` and ensure comprehensive type annotations are provided.
|
||||||
|
|
||||||
|
|
||||||
|
## PR instructions
|
||||||
|
|
||||||
|
1. Title format: use conventional commit messages
|
||||||
|
2. Use English to write PR title and descriptions.
|
||||||
@@ -33,6 +33,46 @@
|
|||||||
- 请使用英文描述您的 PR。
|
- 请使用英文描述您的 PR。
|
||||||
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`。
|
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`。
|
||||||
|
|
||||||
|
#### 代码规范
|
||||||
|
|
||||||
|
##### Core
|
||||||
|
|
||||||
|
我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruff format .
|
||||||
|
ruff check .
|
||||||
|
```
|
||||||
|
|
||||||
|
如果您使用 VSCode,可以安装 `Ruff` 插件。
|
||||||
|
|
||||||
|
##### PR 功能完整性验证(推荐)
|
||||||
|
|
||||||
|
如果您希望在本地做一套接近 CI 的完整验证,可使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pr-test-neo
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令会执行:
|
||||||
|
- `uv sync --group dev`
|
||||||
|
- `ruff format --check .` 与 `ruff check .`
|
||||||
|
- Neo 相关关键测试
|
||||||
|
- `main.py` 启动 smoke test(检测 `http://localhost:6185`)
|
||||||
|
|
||||||
|
需要全量验证时可使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pr-test-full
|
||||||
|
```
|
||||||
|
|
||||||
|
如果只想快速重复执行(跳过依赖同步和 dashboard 构建):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pr-test-full-fast
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Contributing Guide
|
## Contributing Guide
|
||||||
|
|
||||||
First off, thanks for taking the time to contribute! ❤️
|
First off, thanks for taking the time to contribute! ❤️
|
||||||
@@ -63,3 +103,40 @@ We use the `fix/` prefix for bug fixes and the `feat/` prefix for new features.
|
|||||||
#### PR Description
|
#### PR Description
|
||||||
- Please use English to describe your PR.
|
- 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`.
|
- 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 .
|
||||||
|
```
|
||||||
|
|
||||||
|
##### PR completeness checks (recommended)
|
||||||
|
|
||||||
|
To run a local validation flow close to CI, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pr-test-neo
|
||||||
|
```
|
||||||
|
|
||||||
|
This command runs:
|
||||||
|
- `uv sync --group dev`
|
||||||
|
- `ruff format --check .` and `ruff check .`
|
||||||
|
- Neo-related critical tests
|
||||||
|
- a startup smoke test against `http://localhost:6185`
|
||||||
|
|
||||||
|
For full validation, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pr-test-full
|
||||||
|
```
|
||||||
|
|
||||||
|
For faster repeated runs (skip dependency sync and dashboard build), use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pr-test-full-fast
|
||||||
|
```
|
||||||
|
|||||||
+8
-8
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.12-slim
|
||||||
WORKDIR /AstrBot
|
WORKDIR /AstrBot
|
||||||
|
|
||||||
COPY . /AstrBot/
|
COPY . /AstrBot/
|
||||||
@@ -15,17 +15,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
curl \
|
curl \
|
||||||
gnupg \
|
gnupg \
|
||||||
git \
|
git \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
&& 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
|
|
||||||
|
|
||||||
RUN python -m pip install uv \
|
RUN python -m pip install uv \
|
||||||
&& echo "3.11" > .python-version
|
&& echo "3.12" > .python-version \
|
||||||
RUN uv pip install -r requirements.txt --no-cache-dir --system
|
&& uv lock \
|
||||||
RUN uv pip install socksio uv pilk --no-cache-dir --system
|
&& uv export --format requirements.txt --output-file requirements.txt --frozen \
|
||||||
|
&& uv pip install -r requirements.txt --no-cache-dir --system \
|
||||||
|
&& uv pip install socksio uv pilk --no-cache-dir --system
|
||||||
|
|
||||||
EXPOSE 6185
|
EXPOSE 6185
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# 最终用户许可协议(EULA)
|
||||||
|
|
||||||
|
> 我们热爱开源软件,并始终致力于为所有用户提供健康、安全、可靠的使用体验。 ❤️
|
||||||
|
|
||||||
|
For English edition, please refer to the section below the Chinese version.
|
||||||
|
|
||||||
|
**最后更新:** 2026-01-12
|
||||||
|
|
||||||
|
感谢您使用 **AstrBot**。
|
||||||
|
在使用本项目之前,请仔细阅读以下声明内容。
|
||||||
|
|
||||||
|
**您一旦安装、运行或使用本项目,即表示您已阅读、理解并同意本声明中的全部内容。**
|
||||||
|
|
||||||
|
## 1. 项目性质
|
||||||
|
|
||||||
|
AstrBot 是一个遵循 **GNU Affero General Public License v3(AGPLv3)** 协议发布的**免费开源软件项目**。
|
||||||
|
|
||||||
|
* 截至目前,AstrBot 项目未开展任何形式的商业化服务,AstrBot 团队也未通过本项目向用户提供任何收费服务。若您因使用 AstrBot 被要求付费,请务必提高警惕,谨防诈骗行为。
|
||||||
|
* AstrBot 的代码实现未对任何第三方系统进行逆向工程、破解、反编译或绕过安全机制等行为。AstrBot 仅使用并支持各即时通讯(IM)平台官方公开提供的机器人接入接口、开放平台能力或相关通信协议进行集成与通信。
|
||||||
|
|
||||||
|
## 2. 无担保声明
|
||||||
|
|
||||||
|
AstrBot 按“**现状(as is)**”提供,不附带任何形式的明示或暗示担保。
|
||||||
|
|
||||||
|
AstrBot 团队不对以下内容作出任何保证:
|
||||||
|
|
||||||
|
* 系统本身的安全性、可靠性或稳定性;
|
||||||
|
* 任何第三方插件的安全性、正确性或可信度;
|
||||||
|
* 任何第三方 AI 模型或外部服务 API 的可用性、质量、准确性或安全性;
|
||||||
|
* 本软件对任何特定用途的适用性。
|
||||||
|
|
||||||
|
**您使用本软件所产生的一切风险均由您自行承担。**
|
||||||
|
|
||||||
|
## 3. 第三方插件与服务
|
||||||
|
|
||||||
|
* AstrBot 支持第三方插件及外部 AI 服务接入;
|
||||||
|
* AstrBot 团队**不对任何第三方插件、扩展或服务进行审计、控制、背书或担保**;
|
||||||
|
* 因使用第三方插件或服务所产生的任何风险、损失、数据泄露或法律后果,均由用户自行承担。
|
||||||
|
* 第三方插件指代的是非 AstrBot 自带的插件,AstrBot 自带的插件指代的是插件实现代码已经包含在 AstrBotDevs/AstrBot 代码库中的插件。插件市场中的插件都是第三方插件。
|
||||||
|
|
||||||
|
## 4. 使用与内容限制
|
||||||
|
|
||||||
|
您同意不会将 AstrBot 用于以下行为:
|
||||||
|
|
||||||
|
* 输入、生成、传播或处理任何违法、极端、暴力、色情、仇恨、辱骂或其他有害内容;
|
||||||
|
* 从事违反您所在国家或地区法律法规,或任何适用国际法律的行为;
|
||||||
|
* 试图绕过、关闭、削弱或破坏本系统内置的安全机制或内容限制。
|
||||||
|
* 任何侵犯他人合法权益、损害他人和自己身心健康、涉及个人隐私、个人信息等敏感内容的内容。
|
||||||
|
|
||||||
|
## 5. 项目用途说明
|
||||||
|
|
||||||
|
AstrBot 是一个**工具型对话与 Agent 系统**,在**安全、健康、友善**的前提下提供有限的人性化交互能力。
|
||||||
|
|
||||||
|
项目的主要目标是:
|
||||||
|
|
||||||
|
* 提供 Agent 能力与自动化辅助;
|
||||||
|
* 帮助用户提升工作、学习和信息处理效率;
|
||||||
|
* 在合理范围内提供友好的人机交互体验。
|
||||||
|
* 辅助用户成长,提供有益于用户身心健康的内容。
|
||||||
|
|
||||||
|
## 6. 安全措施说明
|
||||||
|
|
||||||
|
AstrBot 团队**已尽合理努力在技术和策略层面设置安全与内容约束机制**,以引导系统输出健康、友善、安全的内容。
|
||||||
|
|
||||||
|
但请理解:
|
||||||
|
|
||||||
|
* 世界上任何的系统均无法保证完全无误、绝对安全或无法被滥用;
|
||||||
|
* 用户仍有责任自行合理配置、监督并正确使用本系统。
|
||||||
|
|
||||||
|
如果您要关闭 AstrBot 默认启用的“健康模式”,请在 cmd_config.json 中将 `provider_settings.llm_safety_mode` 设置为 `False`。但请注意,关闭健康模式不是推荐的使用方式,可能导致系统输出不安全或不适当的内容。关闭该功能所产生的任何风险与后果,均由用户自行承担,AstrBot 团队不对此承担任何责任。
|
||||||
|
|
||||||
|
## 7. 心理健康提示
|
||||||
|
|
||||||
|
如果您在使用本项目过程中因系统输出内容而感到心理不适、情绪困扰,
|
||||||
|
或您本身正处于心理压力较大、情绪不稳定、焦虑、抑郁等状态并因此使用本项目,
|
||||||
|
请优先考虑寻求来自专业人士的帮助,例如心理咨询师、心理医生或当地心理援助机构。
|
||||||
|
|
||||||
|
如遇紧急情况(例如存在自伤或他伤风险),请立即联系当地的紧急救助电话或专业机构。
|
||||||
|
|
||||||
|
## 8. 统计信息与隐私说明
|
||||||
|
|
||||||
|
AstrBot 可能会收集有限的匿名统计信息,用于了解系统使用情况、发现问题以及持续改进项目。
|
||||||
|
|
||||||
|
所收集的统计信息仅包括与系统运行和功能使用相关的基础技术指标,例如功能使用频率、错误信息等。
|
||||||
|
|
||||||
|
AstrBot **不会收集、上传或存储您的对话内容、消息正文、输入文本,或任何能够识别您个人身份的敏感信息**。
|
||||||
|
|
||||||
|
您可以手动关闭此项功能,通过在系统环境变量中设置 `ASTRBOT_DISABLE_METRICS=1` 来禁用匿名统计信息收集。
|
||||||
|
|
||||||
|
## 9. 责任限制
|
||||||
|
|
||||||
|
在法律允许的最大范围内,AstrBot 团队不对因以下原因导致的任何直接或间接损失承担责任,包括但不限于:
|
||||||
|
|
||||||
|
* 使用或无法使用本软件;
|
||||||
|
* 使用第三方插件或服务;
|
||||||
|
* 系统生成的内容或输出;
|
||||||
|
* 数据丢失、服务中断或安全事件。
|
||||||
|
|
||||||
|
## 10. 条款的接受
|
||||||
|
|
||||||
|
您一旦安装、运行、修改或使用 AstrBot,即确认:
|
||||||
|
|
||||||
|
* 您已阅读并理解本声明内容;
|
||||||
|
* 您同意并接受上述所有条款;
|
||||||
|
* 您对自身使用行为承担全部责任。
|
||||||
|
|
||||||
|
如您不同意本声明的任何内容,请勿使用本项目。
|
||||||
|
|
||||||
|
## 11. 许可与版权
|
||||||
|
|
||||||
|
AstrBot 的源代码、文档及相关内容受版权法及相关法律保护。
|
||||||
|
|
||||||
|
在遵守本声明及 AGPLv3 协议的前提下,AstrBot 授予您一项非独占、不可转让、不可再许可的许可,用于下载、安装、运行、修改和分发本软件。
|
||||||
|
|
||||||
|
除非法律另有规定或本声明另有明确说明,AstrBot 团队保留本项目的所有未明确授予的权利。
|
||||||
|
|
||||||
|
## 12. 适用法律
|
||||||
|
|
||||||
|
本声明的解释与适用应遵循您所在地或项目发布地适用的法律法规。
|
||||||
|
|
||||||
|
如本声明的任何条款被认定为无效或不可执行,其余条款仍然有效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# EULA
|
||||||
|
|
||||||
|
> We love open-source software and are always committed to providing all users with a healthy, safe, and reliable experience. ❤️
|
||||||
|
|
||||||
|
**Last updated:** January 12, 2026
|
||||||
|
|
||||||
|
Thank you for using **AstrBot**.
|
||||||
|
Please read the following notice carefully before using this project.
|
||||||
|
|
||||||
|
**By installing, running, or using this project, you acknowledge that you have read, understood, and agreed to all the terms stated below.**
|
||||||
|
|
||||||
|
## 1. Nature of the Project
|
||||||
|
|
||||||
|
AstrBot is a **free and open-source software project** released under the **GNU Affero General Public License v3 (AGPLv3)**.
|
||||||
|
|
||||||
|
* AstrBot does not constitute any form of commercial service;
|
||||||
|
* The AstrBot Team does not provide any paid services through this project;
|
||||||
|
* AstrBot’s implementation does not involve reverse engineering, cracking, decompilation, or circumvention of security mechanisms of any third-party systems. AstrBot only uses and supports officially published bot integration interfaces, open platform capabilities, or related communication protocols provided by instant messaging (IM) platforms for integration and communication.
|
||||||
|
|
||||||
|
## 2. No Warranty
|
||||||
|
|
||||||
|
AstrBot is provided **“as is”**, without any express or implied warranties.
|
||||||
|
|
||||||
|
The AstrBot Team makes no guarantees regarding:
|
||||||
|
|
||||||
|
* The security, reliability, or stability of the system;
|
||||||
|
* The security, correctness, or trustworthiness of any third-party plugins;
|
||||||
|
* The availability, quality, accuracy, or safety of any third-party AI model APIs or external services;
|
||||||
|
* The fitness of the software for any particular purpose.
|
||||||
|
|
||||||
|
**All risks arising from the use of this software are borne solely by the user.**
|
||||||
|
|
||||||
|
## 3. Third-Party Plugins and Services
|
||||||
|
|
||||||
|
* AstrBot supports third-party plugins and external AI services;
|
||||||
|
* The AstrBot Team does **not audit, control, endorse, or guarantee** any third-party plugins, extensions, or services;
|
||||||
|
* Any risks, losses, data leaks, or legal consequences arising from the use of third-party plugins or services are solely the responsibility of the user;
|
||||||
|
* “Third-party plugins” refer to plugins that are not built into AstrBot. Built-in plugins are those whose implementation code is included in the AstrBotDevs/AstrBot repository. All plugins available in the plugin marketplace are third-party plugins.
|
||||||
|
|
||||||
|
## 4. Usage and Content Restrictions
|
||||||
|
|
||||||
|
You agree not to use AstrBot for any of the following activities:
|
||||||
|
|
||||||
|
* Inputting, generating, distributing, or processing any illegal, extremist, violent, pornographic, hateful, abusive, or otherwise harmful content;
|
||||||
|
* Engaging in activities that violate the laws or regulations of your country or region, or any applicable international laws;
|
||||||
|
* Attempting to bypass, disable, weaken, or undermine the built-in safety mechanisms or content restrictions of the system;
|
||||||
|
* Any activities that infringe upon the legitimate rights and interests of others, harm the physical or mental well-being of yourself or others, or involve personal privacy or sensitive personal information.
|
||||||
|
|
||||||
|
## 5. Intended Use
|
||||||
|
|
||||||
|
AstrBot is a **tool-oriented conversational and agent system** that provides limited human-like interaction capabilities under the principles of **safety, health, and friendliness**.
|
||||||
|
|
||||||
|
The primary goals of the project are to:
|
||||||
|
|
||||||
|
* Provide agent capabilities and automation assistance;
|
||||||
|
* Help users improve efficiency in work, study, and information processing;
|
||||||
|
* Offer a friendly human–computer interaction experience within reasonable boundaries;
|
||||||
|
* Support user growth and provide content beneficial to users’ physical and mental well-being.
|
||||||
|
|
||||||
|
## 6. Safety Measures
|
||||||
|
|
||||||
|
The AstrBot Team has made **reasonable efforts** at both technical and policy levels to implement safety and content restriction mechanisms, guiding the system to produce healthy, friendly, and safe outputs.
|
||||||
|
|
||||||
|
However, please understand that:
|
||||||
|
|
||||||
|
* No system in the world can be guaranteed to be completely error-free, absolutely secure, or immune to misuse;
|
||||||
|
* Users remain responsible for properly configuring, supervising, and using the system.
|
||||||
|
|
||||||
|
If you wish to disable AstrBot’s default “Safety Mode,” please set `provider_settings.llm_safety_mode` to `False` in `cmd_config.json`. However, please note that disabling Safety Mode is not recommended and may lead to unsafe or inappropriate outputs. Any risks or consequences arising from disabling this feature are solely borne by the user, and the AstrBot Team assumes no responsibility.
|
||||||
|
|
||||||
|
## 7. Mental Health Notice
|
||||||
|
|
||||||
|
If you experience psychological discomfort or emotional distress due to system outputs during use,
|
||||||
|
or if you are experiencing significant psychological stress, emotional instability, anxiety, or depression and are using this project for such reasons,
|
||||||
|
please prioritize seeking help from qualified professionals, such as psychologists, psychiatrists, or local mental health support services.
|
||||||
|
|
||||||
|
In case of emergency (for example, if there is a risk of self-harm or harm to others), please immediately contact your local emergency number or professional crisis support services.
|
||||||
|
|
||||||
|
## 8. Metrics and Privacy
|
||||||
|
|
||||||
|
AstrBot may collect a limited amount of anonymous usage statistics to understand system usage, identify issues, and continuously improve the project.
|
||||||
|
|
||||||
|
Collected metrics are limited to basic technical indicators related to system operation and feature usage, such as feature usage frequency and error information.
|
||||||
|
|
||||||
|
AstrBot **does not collect, upload, or store your conversation content, message bodies, input text, or any personally identifiable or sensitive information**.
|
||||||
|
|
||||||
|
You may manually disable this feature by setting the environment variable `ASTRBOT_DISABLE_METRICS=1` to turn off anonymous metrics collection.
|
||||||
|
|
||||||
|
## 9. Limitation of Liability
|
||||||
|
|
||||||
|
To the maximum extent permitted by law, the AstrBot Team shall not be liable for any direct or indirect losses arising from, including but not limited to:
|
||||||
|
|
||||||
|
* The use or inability to use this software;
|
||||||
|
* The use of third-party plugins or services;
|
||||||
|
* Generated content or system outputs;
|
||||||
|
* Data loss, service interruptions, or security incidents.
|
||||||
|
|
||||||
|
## 10. Acceptance of Terms
|
||||||
|
|
||||||
|
By installing, running, modifying, or using AstrBot, you confirm that:
|
||||||
|
|
||||||
|
* You have read and understood this Notice;
|
||||||
|
* You agree to and accept all the terms stated above;
|
||||||
|
* You assume full responsibility for your use of the software.
|
||||||
|
|
||||||
|
If you do not agree with any part of this Notice, please do not use this project.
|
||||||
|
|
||||||
|
## 11. License and Copyright
|
||||||
|
|
||||||
|
The source code, documentation, and related materials of AstrBot are protected by copyright laws and applicable regulations.
|
||||||
|
|
||||||
|
Subject to compliance with this Notice and the AGPLv3 license, AstrBot grants you a non-exclusive, non-transferable, non-sublicensable license to download, install, run, modify, and distribute this software.
|
||||||
|
|
||||||
|
Unless otherwise required by law or expressly stated in this Notice, the AstrBot Team reserves all rights not expressly granted.
|
||||||
|
|
||||||
|
## 12. Governing Law
|
||||||
|
|
||||||
|
The interpretation and application of this Notice shall be governed by the laws and regulations applicable in your jurisdiction or the jurisdiction where the project is released.
|
||||||
|
|
||||||
|
If any provision of this Notice is held to be invalid or unenforceable, the remaining provisions shall remain in full force and effect.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
## Welcome to AstrBot
|
||||||
|
|
||||||
|
🌟 Thank you for using AstrBot!
|
||||||
|
|
||||||
|
AstrBot is an Agentic AI assistant for personal and group chats, with support for multiple IM platforms and a wide range of built-in features. We hope it brings you an efficient and enjoyable experience. ❤️
|
||||||
|
|
||||||
|
Important notice:
|
||||||
|
|
||||||
|
AstrBot is a **free and open-source software project** protected by the AGPLv3 license. You can find the full source code and related resources on our [**official website**](https://astrbot.app) and [**GitHub**](https://github.com/astrbotdevs/astrbot).
|
||||||
|
As of now, AstrBot has **no commercial services of any kind**, and the official team **will never charge users any fees** under any name.
|
||||||
|
|
||||||
|
If anyone asks you to pay while using AstrBot, **you are likely being scammed**. Please request a refund immediately and report it to us by email.
|
||||||
|
|
||||||
|
📮 Official email: [community@astrbot.app](mailto:community@astrbot.app)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
## 欢迎使用 AstrBot
|
||||||
|
|
||||||
|
🌟 感谢您使用 AstrBot!
|
||||||
|
|
||||||
|
AstrBot 是一款可接入多种 IM 平台的 Agentic AI 个人 / 群聊助手,内置多项强大功能,希望能为您带来高效、愉快的使用体验。❤️
|
||||||
|
|
||||||
|
我们想特别说明:
|
||||||
|
|
||||||
|
AstrBot 是受 AGPLv3 开源协议保护的**免费开源软件项目**,您可以在[**官方网站**](https://astrbot.app)、[**GitHub**](https://github.com/astrbotdevs/astrbot) 上找到 AstrBot 的全部源代码及相关资源。
|
||||||
|
截至目前,AstrBot 项目**未开展任何形式的商业化服务**,官方**不会以任何名义向用户收取费用**。
|
||||||
|
|
||||||
|
如果您在使用 AstrBot 的过程中被要求付费,**表明您已经遭遇诈骗行为**。请立即向相关方申请退款,并及时通过邮件向我们反馈。
|
||||||
|
|
||||||
|
📮 官方邮箱:[community@astrbot.app](mailto:community@astrbot.app)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.PHONY: worktree worktree-add worktree-rm pr-test-neo pr-test-full pr-test-full-fast
|
||||||
|
|
||||||
|
WORKTREE_DIR ?= ../astrbot_worktree
|
||||||
|
BRANCH ?= $(word 2,$(MAKECMDGOALS))
|
||||||
|
BASE ?= $(word 3,$(MAKECMDGOALS))
|
||||||
|
BASE ?= master
|
||||||
|
|
||||||
|
worktree:
|
||||||
|
@echo "Usage:"
|
||||||
|
@echo " make worktree-add <branch> [base-branch]"
|
||||||
|
@echo " make worktree-rm <branch>"
|
||||||
|
|
||||||
|
worktree-add:
|
||||||
|
ifeq ($(strip $(BRANCH)),)
|
||||||
|
$(error Branch name required. Usage: make worktree-add <branch> [base-branch])
|
||||||
|
endif
|
||||||
|
@mkdir -p $(WORKTREE_DIR)
|
||||||
|
git worktree add $(WORKTREE_DIR)/$(BRANCH) -b $(BRANCH) $(BASE)
|
||||||
|
|
||||||
|
worktree-rm:
|
||||||
|
ifeq ($(strip $(BRANCH)),)
|
||||||
|
$(error Branch name required. Usage: make worktree-rm <branch>)
|
||||||
|
endif
|
||||||
|
@if [ -d "$(WORKTREE_DIR)/$(BRANCH)" ]; then \
|
||||||
|
git worktree remove $(WORKTREE_DIR)/$(BRANCH); \
|
||||||
|
else \
|
||||||
|
echo "Worktree $(WORKTREE_DIR)/$(BRANCH) not found."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
pr-test-neo:
|
||||||
|
./scripts/pr_test_env.sh --profile neo
|
||||||
|
|
||||||
|
pr-test-full:
|
||||||
|
./scripts/pr_test_env.sh --profile full
|
||||||
|
|
||||||
|
pr-test-full-fast:
|
||||||
|
./scripts/pr_test_env.sh --profile full --skip-sync --no-dashboard
|
||||||
|
|
||||||
|
# Swallow extra args (branch/base) so make doesn't treat them as targets
|
||||||
|
%:
|
||||||
|
@true
|
||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.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_zh-TW.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_fr.md">Français</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://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||||
|
|
||||||
@@ -20,220 +19,270 @@
|
|||||||
<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/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://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
<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://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%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTk0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%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>
|
<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://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20Plugins&label=Marketplace&cacheSeconds=3600">
|
||||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<a href="https://astrbot.app/">文档</a> |
|
<a href="https://astrbot.app/">Home</a> |
|
||||||
|
<a href="https://astrbot.app/">Docs</a> |
|
||||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||||
<a href="https://astrbot.featurebase.app/roadmap">路线图</a> |
|
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issues</a>
|
||||||
|
<a href="mailto:community@astrbot.app">Email</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主流即时通讯软件,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建生产可用的 AI 应用。
|
AstrBot is an open-source, all-in-one Agentic personal and group chat assistant that can be deployed on dozens of mainstream instant messaging platforms such as QQ, Telegram, WeCom, Lark, DingTalk, Slack, and more. It also features a built-in lightweight ChatUI similar to OpenWebUI, creating a reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether it's a personal AI companion, smart customer service, automated assistant, or enterprise knowledge base, AstrBot enables you to quickly build AI applications within the workflow of your instant messaging platforms.
|
||||||
|
|
||||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|

|
||||||
|
|
||||||
## 主要功能
|
## Key Features
|
||||||
|
|
||||||
1. 💯 免费 & 开源。
|
1. 💯 Free & Open Source.
|
||||||
1. ✨ AI 大模型对话,多模态,Agent,MCP,知识库,人格设定。
|
2. ✨ Large Language Model (LLM) dialogue, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona settings, automatic dialogue compression.
|
||||||
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
3. 🤖 Supports integration with agent platforms such as Dify, Alibaba Bailian, Coze, etc.
|
||||||
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
4. 🌐 Multi-platform support: QQ, WeCom, Lark, DingTalk, WeChat Official Account, Telegram, Slack, and [more](#supported-message-platforms).
|
||||||
3. 📦 插件扩展,已有近 800 个插件可一键安装。
|
5. 📦 Plugin extension: 1000+ plugins available for one-click installation.
|
||||||
5. 💻 WebUI 支持。
|
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): Isolated environment for safely executing any code, calling Shell commands, and reusing session-level resources.
|
||||||
6. 🌐 国际化(i18n)支持。
|
7. 💻 WebUI support.
|
||||||
|
8. 🌈 Web ChatUI support: Built-in proxy sandbox, web search, etc. within ChatUI.
|
||||||
|
9. 🌐 Internationalization (i18n) support.
|
||||||
|
|
||||||
## 快速开始
|
<br>
|
||||||
|
|
||||||
#### Docker 部署(推荐 🥳)
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th>💙 Roleplay & Companionship</th>
|
||||||
|
<th>✨ Proactive Agent</th>
|
||||||
|
<th>🚀 General Agentic Capabilities</th>
|
||||||
|
<th>🧩 1000+ Community Plugins</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
推荐使用 Docker / Docker Compose 方式部署 AstrBot。
|
## Quick Start
|
||||||
|
|
||||||
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
|
### One-Click Deployment
|
||||||
|
|
||||||
#### uv 部署
|
For users who want to experience AstrBot quickly, are familiar with the command line, and can install the `uv` environment themselves, we recommend using `uv` for one-click deployment ⚡️.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx astrbot
|
uv tool install astrbot
|
||||||
|
astrbot init # Execute this command only for the first time to initialize the environment
|
||||||
|
astrbot run # astrbot run --backend-only starts only the backend service
|
||||||
|
|
||||||
|
# Install development version (more fixes and new features, but less stable; suitable for developers)
|
||||||
|
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 宝塔面板部署
|
> Requires [uv](https://docs.astral.sh/uv/) installed.
|
||||||
|
|
||||||
AstrBot 与宝塔面板合作,已上架至宝塔面板。
|
> [!NOTE]
|
||||||
|
> For macOS users: Due to macOS security checks, the first execution of the `astrbot` command may take a longer time (about 10-20 seconds).
|
||||||
|
|
||||||
请参阅官方文档 [宝塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html) 。
|
Update `astrbot`:
|
||||||
|
|
||||||
#### 1Panel 部署
|
```bash
|
||||||
|
uv tool upgrade astrbot
|
||||||
|
```
|
||||||
|
|
||||||
AstrBot 已由 1Panel 官方上架至 1Panel 面板。
|
### Docker Deployment
|
||||||
|
|
||||||
请参阅官方文档 [1Panel 部署](https://astrbot.app/deploy/astrbot/1panel.html) 。
|
For users familiar with containers who prefer a more stable deployment suitable for production environments, we recommend using Docker / Docker Compose to deploy AstrBot.
|
||||||
|
|
||||||
#### 在 雨云 上部署
|
Please refer to the official documentation [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html).
|
||||||
|
|
||||||
AstrBot 已由雨云官方上架至云应用平台,可一键部署。
|
### Deploy on RainYun
|
||||||
|
|
||||||
|
For users who want to deploy AstrBot with one click and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
#### 在 Replit 上部署
|
### Desktop Client Deployment
|
||||||
|
|
||||||
社区贡献的部署方式。
|
For users who wish to use AstrBot on the desktop with ChatUI as the main interface, we recommend using the AstrBot App.
|
||||||
|
|
||||||
|
Go to [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) to download and install; this method is intended for desktop use and is not recommended for server scenarios.
|
||||||
|
|
||||||
|
### Launcher Deployment
|
||||||
|
|
||||||
|
Also for desktop, users who want quick deployment and isolated environments for multiple instances can use the AstrBot Launcher.
|
||||||
|
|
||||||
|
Go to [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to download and install.
|
||||||
|
|
||||||
|
### Deploy on Replit
|
||||||
|
|
||||||
|
Replit deployment is maintained by the community, suitable for online demos and lightweight trials.
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
#### Windows 一键安装器部署
|
### AUR
|
||||||
|
|
||||||
请参阅官方文档 [使用 Windows 一键安装器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html) 。
|
The AUR method is for Arch Linux users who wish to install AstrBot via the system package manager.
|
||||||
|
|
||||||
#### CasaOS 部署
|
Execute the following command in the terminal to install the `astrbot-git` package. You can start using it after installation completes.
|
||||||
|
|
||||||
社区贡献的部署方式。
|
|
||||||
|
|
||||||
请参阅官方文档 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html) 。
|
|
||||||
|
|
||||||
#### 手动部署
|
|
||||||
|
|
||||||
首先安装 uv:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install uv
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
通过 Git Clone 安装 AstrBot:
|
**More Deployment Methods**
|
||||||
|
|
||||||
```bash
|
If you need panel-based or highly customized deployment, you can refer to [BT Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (BT Panel App Store), [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (1Panel App Store), [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (NAS / Home Server visual deployment), and [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html) (Full custom installation based on source code and `uv`).
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
|
||||||
uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
## Supported Message Platforms
|
||||||
|
|
||||||
## 支持的消息平台
|
Connect AstrBot to your favorite chat platforms.
|
||||||
|
|
||||||
**官方维护**
|
| Platform | Maintainer |
|
||||||
|
|---------|---------------|
|
||||||
|
| **QQ** | Official |
|
||||||
|
| **OneBot v11** | Official |
|
||||||
|
| **Telegram** | Official |
|
||||||
|
| **WeCom App & Bot** | Official |
|
||||||
|
| **WeChat Customer Service & Official Account** | Official |
|
||||||
|
| **Lark (Feishu)** | Official |
|
||||||
|
| **DingTalk** | Official |
|
||||||
|
| **Slack** | Official |
|
||||||
|
| **Discord** | Official |
|
||||||
|
| **LINE** | Official |
|
||||||
|
| **Satori** | Official |
|
||||||
|
| **Misskey** | Official |
|
||||||
|
| **Whatsapp (Coming Soon)** | Official |
|
||||||
|
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
|
||||||
|
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
|
||||||
|
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
|
||||||
|
|
||||||
- QQ (官方平台 & OneBot)
|
## Supported Model Providers
|
||||||
- Telegram
|
|
||||||
- 企微应用 & 企微智能机器人
|
|
||||||
- 微信客服 & 微信公众号
|
|
||||||
- 飞书
|
|
||||||
- 钉钉
|
|
||||||
- Slack
|
|
||||||
- Discord
|
|
||||||
- Satori
|
|
||||||
- Misskey
|
|
||||||
- Whatsapp (将支持)
|
|
||||||
- LINE (将支持)
|
|
||||||
|
|
||||||
**社区维护**
|
| Provider | Type |
|
||||||
|
|---------|---------------|
|
||||||
|
| Custom | Any OpenAI API compatible service |
|
||||||
|
| OpenAI | LLM |
|
||||||
|
| Anthropic | LLM |
|
||||||
|
| Google Gemini | LLM |
|
||||||
|
| Moonshot AI | LLM |
|
||||||
|
| Zhipu AI | LLM |
|
||||||
|
| DeepSeek | LLM |
|
||||||
|
| Ollama (Local) | LLM |
|
||||||
|
| LM Studio (Local) | LLM |
|
||||||
|
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (API Gateway, supports all models) |
|
||||||
|
| [Compshare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (API Gateway, supports all models) |
|
||||||
|
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (API Gateway, supports all models) |
|
||||||
|
| [PPIO](https://ppio.com/user/register?invited_by=AIOONE) | LLM (API Gateway, supports all models) |
|
||||||
|
| [302.AI](https://share.302.ai/rr1M3l) | LLM (API Gateway, supports all models)|
|
||||||
|
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM (API Gateway, supports all models)|
|
||||||
|
| ModelScope | LLM |
|
||||||
|
| OneAPI | LLM |
|
||||||
|
| Dify | LLMOps Platform |
|
||||||
|
| Alibaba Bailian | LLMOps Platform |
|
||||||
|
| Coze | LLMOps Platform |
|
||||||
|
| OpenAI Whisper | Speech-to-Text |
|
||||||
|
| SenseVoice | Speech-to-Text |
|
||||||
|
| OpenAI TTS | Text-to-Speech |
|
||||||
|
| Gemini TTS | Text-to-Speech |
|
||||||
|
| GPT-Sovits-Inference | Text-to-Speech |
|
||||||
|
| GPT-Sovits | Text-to-Speech |
|
||||||
|
| FishAudio | Text-to-Speech |
|
||||||
|
| Edge TTS | Text-to-Speech |
|
||||||
|
| Alibaba Bailian TTS | Text-to-Speech |
|
||||||
|
| Azure TTS | Text-to-Speech |
|
||||||
|
| Minimax TTS | Text-to-Speech |
|
||||||
|
| Volcano Engine TTS | Text-to-Speech |
|
||||||
|
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
## ❤️ Contribution
|
||||||
- [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)
|
|
||||||
|
|
||||||
## 支持的模型服务
|
Welcome any Issues/Pull Requests! Just submit your changes to this project :)
|
||||||
|
|
||||||
**大模型服务**
|
### How to Contribute
|
||||||
|
|
||||||
- OpenAI 及兼容服务
|
You can contribute by viewing issues or helping to review PRs (Pull Requests). Any issues or PRs are welcome to promote community contribution. Of course, these are just suggestions; you can contribute in any way. For new feature additions, please discuss via Issue first.
|
||||||
- Anthropic
|
It is recommended to merge functional PRs into the `dev` branch, which will be merged into the main branch and released as a new version after testing.
|
||||||
- Google Gemini
|
To reduce conflicts, we suggest:
|
||||||
- Moonshot AI
|
1. Create your working branch based on the `dev` branch, avoid working directly on the `main` branch.
|
||||||
- 智谱 AI
|
2. When submitting a PR, select the `dev` branch as the target.
|
||||||
- DeepSeek
|
3. Regularly sync the `dev` branch to your local environment; use `git pull` frequently.
|
||||||
- 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 平台**
|
### Development Environment
|
||||||
|
|
||||||
- Dify
|
AstrBot uses `ruff` for code formatting and checking.
|
||||||
- 阿里云百炼应用
|
|
||||||
- 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
|
```bash
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot
|
git clone https://github.com/AstrBotDevs/AstrBot
|
||||||
pip install pre-commit
|
git switch dev # Switch to dev branch
|
||||||
|
pip install pre-commit # or uv tool install pre-commit
|
||||||
pre-commit install
|
pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌍 社区
|
We recommend using `uv` for local installation and testing:
|
||||||
|
|
||||||
### QQ 群组
|
```bash
|
||||||
|
uv tool install -e . --force
|
||||||
|
astrbot init
|
||||||
|
astrbot run
|
||||||
|
```
|
||||||
|
|
||||||
- 1 群:322154837
|
Frontend Debugging:
|
||||||
- 3 群:630166526
|
|
||||||
- 5 群:822130018
|
|
||||||
- 6 群:753075035
|
|
||||||
- 7 群:743746109
|
|
||||||
- 开发者群:975206796
|
|
||||||
|
|
||||||
### Telegram 群组
|
```bash
|
||||||
|
astrbot run --backend-only
|
||||||
|
cd dashboard
|
||||||
|
bun install # or pnpm, etc.
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
<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>
|
### QQ Groups
|
||||||
|
|
||||||
### Discord 群组
|
- Group 9: 1076659624 (New)
|
||||||
|
- Group 10: 1078079676 (New)
|
||||||
|
- Group 1: 322154837
|
||||||
|
- Group 3: 630166526
|
||||||
|
- Group 5: 822130018
|
||||||
|
- Group 6: 753075035
|
||||||
|
- Group 7: 743746109
|
||||||
|
- Group 8: 1030353265
|
||||||
|
- Developer Group (Casual): 975206796
|
||||||
|
- Developer Group (Official): 1039761811
|
||||||
|
|
||||||
<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>
|
### Discord Channel
|
||||||
|
|
||||||
|
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||||
|
|
||||||
## ❤️ Special Thanks
|
## ❤️ Special Thanks
|
||||||
|
|
||||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
此外,本项目的诞生离不开以下开源项目的帮助:
|
In addition, the birth of this project cannot be separated from the help of the following open-source projects:
|
||||||
|
|
||||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Great Cat Framework
|
||||||
|
|
||||||
|
Open Source Project Friendly Links:
|
||||||
|
|
||||||
|
- [NoneBot2](https://github.com/nonebot/nonebot2) - Excellent Python Asynchronous ChatBot Framework
|
||||||
|
- [Koishi](https://github.com/koishijs/koishi) - Excellent Node.js ChatBot Framework
|
||||||
|
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - Excellent Anthropomorphic AI ChatBot
|
||||||
|
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - Excellent Agent ChatBot
|
||||||
|
- [LangBot](https://github.com/langbot-app/LangBot) - Excellent Multi-platform AI ChatBot
|
||||||
|
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - Excellent Multi-platform AI ChatBot Koishi Plugin
|
||||||
|
- [Operit AI](https://github.com/AAswordman/Operit) - Excellent AI Assistant Android APP
|
||||||
|
|
||||||
## ⭐ Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我们维护这个开源项目的动力 <3
|
> If this project helps your life/work, or you are concerned about the future development of this project, please Star the project. This is our motivation to maintain this open-source project <3
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -241,6 +290,12 @@ pre-commit install
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</details>
|
<div align="center">
|
||||||
|
|
||||||
|
_Companionship and capability should never be opposites. We hope to create a robot that can both understand emotions, provide companionship, and reliably complete tasks._
|
||||||
|
|
||||||
_私は、高性能ですから!_
|
_私は、高性能ですから!_
|
||||||
|
|
||||||
|
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||||
|
|
||||||
|
</div>
|
||||||
-247
@@ -1,247 +0,0 @@
|
|||||||

|
|
||||||
|
|
||||||
</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_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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
#### 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 deployment method.
|
|
||||||
|
|
||||||
Please refer to the official documentation: [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html).
|
|
||||||
|
|
||||||
#### Manual Deployment
|
|
||||||
|
|
||||||
First, install uv:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install uv
|
|
||||||
```
|
|
||||||
|
|
||||||
Install AstrBot via Git Clone:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
|
||||||
uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
|
|
||||||
|
|
||||||
## Supported Messaging Platforms
|
|
||||||
|
|
||||||
**Officially Maintained**
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
**Community Maintained**
|
|
||||||
|
|
||||||
- [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)
|
|
||||||
|
|
||||||
## Supported Model Services
|
|
||||||
|
|
||||||
**LLM Services**
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
**LLMOps Platforms**
|
|
||||||
|
|
||||||
- Dify
|
|
||||||
- Alibaba Cloud Bailian Applications
|
|
||||||
- Coze
|
|
||||||
|
|
||||||
**Speech-to-Text Services**
|
|
||||||
|
|
||||||
- OpenAI Whisper
|
|
||||||
- SenseVoice
|
|
||||||
|
|
||||||
**Text-to-Speech Services**
|
|
||||||
|
|
||||||
- OpenAI TTS
|
|
||||||
- Gemini TTS
|
|
||||||
- GPT-Sovits-Inference
|
|
||||||
- GPT-Sovits
|
|
||||||
- FishAudio
|
|
||||||
- Edge TTS
|
|
||||||
- Alibaba Cloud Bailian TTS
|
|
||||||
- Azure TTS
|
|
||||||
- Minimax TTS
|
|
||||||
- Volcano Engine TTS
|
|
||||||
|
|
||||||
## ❤️ Contributing
|
|
||||||
|
|
||||||
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
|
||||||
|
|
||||||
### How to Contribute
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Development Environment
|
|
||||||
|
|
||||||
AstrBot uses `ruff` for code formatting and linting.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot
|
|
||||||
pip install pre-commit
|
|
||||||
pre-commit install
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🌍 Community
|
|
||||||
|
|
||||||
### QQ Groups
|
|
||||||
|
|
||||||
- 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 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/#astrbotdevs/astrbot&Date)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
_私は、高性能ですから!_
|
|
||||||
+195
-146
@@ -1,10 +1,12 @@
|
|||||||

|

|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<br>
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.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_ja.md">日本語</a> |
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">简体中文</a> |
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||||
|
|
||||||
<div>
|
<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://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>
|
||||||
@@ -14,227 +16,269 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
<div>
|
<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/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?style=for-the-badge&color=76bad9" alt="python">
|
<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?style=for-the-badge&color=76bad9"/></a>
|
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||||
<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://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%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTk4IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></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>
|
<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=%20plugins&style=for-the-badge&label=Marketplace&cacheSeconds=3600">
|
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20Plugins&label=Marketplace&cacheSeconds=3600">
|
||||||
|
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
<a href="https://astrbot.app/">Accueil</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://astrbot.app/">Documentation</a> |
|
||||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||||
<a href="https://astrbot.featurebase.app/roadmap">Feuille de route</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>
|
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Signaler un problème</a>
|
||||||
|
<a href="mailto:community@astrbot.app">Email</a>
|
||||||
|
|
||||||
</div>
|
</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.
|
AstrBot est un assistant de chat personnel et de groupe Agentic tout-en-un et open-source, qui peut être déployé sur des dizaines de logiciels de messagerie instantanée grand public tels que QQ, Telegram, WeCom (WeChat Entreprise), Lark (Feishu), DingTalk, Slack, etc. Il intègre également une interface de chat légère similaire à OpenWebUI, créant ainsi une infrastructure conversationnelle intelligente fiable et extensible pour les particuliers, les développeurs et les équipes. Qu'il s'agisse d'un compagnon IA personnel, d'un service client intelligent, d'un assistant automatisé ou d'une base de connaissances d'entreprise, AstrBot vous permet de construire rapidement des applications IA au sein du flux de travail de vos plateformes de messagerie instantanée.
|
||||||
|
|
||||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|

|
||||||
|
|
||||||
## Fonctionnalités principales
|
## Fonctionnalités Principales
|
||||||
|
|
||||||
1. 💯 Gratuit & Open Source.
|
1. 💯 Gratuit & Open Source.
|
||||||
2. ✨ Conversations avec LLM IA, Multimodal, Agent, MCP, Base de connaissances, Paramètres de personnalité.
|
2. ✨ Dialogue avec de grands modèles d'IA (LLM), multimodal, Agent, MCP, Compétences (Skills), base de connaissances, définition de persona, compression automatique des dialogues.
|
||||||
3. 🤖 Prise en charge de l'intégration avec Dify, Alibaba Cloud Bailian, Coze et autres plateformes d'agents.
|
3. 🤖 Prend en charge l'intégration avec des plateformes d'agents comme Dify, Alibaba Bailian, Coze, etc.
|
||||||
4. 🌐 Multi-plateforme : QQ, WeChat Work, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack, et [plus encore](#plateformes-de-messagerie-prises-en-charge).
|
4. 🌐 Multiplateforme, prend en charge QQ, WeCom, Lark, DingTalk, Compte Officiel 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.
|
5. 📦 Extension par plugins, plus de 1000 plugins disponibles pour une installation en un clic.
|
||||||
6. 💻 Support WebUI.
|
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) : environnement isolé pour exécuter n'importe quel code, appeler le Shell et réutiliser les ressources au niveau de la session en toute sécurité.
|
||||||
7. 🌐 Support de l'internationalisation (i18n).
|
7. 💻 Support WebUI.
|
||||||
|
8. 🌈 Support Web ChatUI, avec sandbox de proxy intégré, recherche web, etc.
|
||||||
|
9. 🌐 Support de l'internationalisation (i18n).
|
||||||
|
|
||||||
## Démarrage rapide
|
<br>
|
||||||
|
|
||||||
#### Déploiement Docker (Recommandé 🥳)
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th>💙 Jeu de rôle & Accompagnement émotionnel</th>
|
||||||
|
<th>✨ Agent Proactif</th>
|
||||||
|
<th>🚀 Capacités Agentic Génériques</th>
|
||||||
|
<th>🧩 1000+ Plugins Communautaires</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
Nous recommandons de déployer AstrBot en utilisant Docker ou Docker Compose.
|
## Démarrage Rapide
|
||||||
|
|
||||||
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 en un clic
|
||||||
|
|
||||||
#### Déploiement uv
|
Pour les utilisateurs qui souhaitent essayer AstrBot rapidement, qui sont familiers avec la ligne de commande et capables d'installer l'environnement `uv` par eux-mêmes, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx astrbot
|
uv tool install astrbot
|
||||||
|
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||||
|
astrbot run # astrbot run --backend-only démarre uniquement le service backend
|
||||||
|
|
||||||
|
# Installer la version de développement (plus de correctifs, nouvelles fonctionnalités, mais moins stable, adapté aux développeurs)
|
||||||
|
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Déploiement BT-Panel
|
> Nécessite l'installation de [uv](https://docs.astral.sh/uv/).
|
||||||
|
|
||||||
AstrBot s'est associé à BT-Panel et est maintenant disponible sur leur marketplace.
|
> [!NOTE]
|
||||||
|
> Pour les utilisateurs de macOS : en raison des contrôles de sécurité de macOS, la première exécution de la commande `astrbot` peut prendre un certain temps (environ 10-20 secondes).
|
||||||
|
|
||||||
Veuillez consulter la documentation officielle : [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
|
Mettre à jour `astrbot` :
|
||||||
|
|
||||||
#### Déploiement 1Panel
|
```bash
|
||||||
|
uv tool upgrade astrbot
|
||||||
|
```
|
||||||
|
|
||||||
AstrBot a été officiellement listé sur le marketplace 1Panel.
|
### Déploiement Docker
|
||||||
|
|
||||||
Veuillez consulter la documentation officielle : [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html).
|
Pour les utilisateurs familiers avec les conteneurs et souhaitant une méthode de déploiement plus stable et adaptée aux environnements de production, nous recommandons d'utiliser Docker / Docker Compose pour déployer AstrBot.
|
||||||
|
|
||||||
#### Déployer sur RainYun
|
Veuillez vous référer à la documentation officielle [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html).
|
||||||
|
|
||||||
AstrBot a été officiellement listé sur la plateforme d'applications cloud de RainYun avec un déploiement en un clic.
|
### Déploiement sur RainYun
|
||||||
|
|
||||||
|
Pour les utilisateurs souhaitant déployer AstrBot en un clic sans gérer de serveur, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
#### Déployer sur Replit
|
### Déploiement Client Bureau
|
||||||
|
|
||||||
Méthode de déploiement contribuée par la communauté.
|
Pour les utilisateurs souhaitant utiliser AstrBot sur ordinateur de bureau et utiliser principalement ChatUI comme point d'entrée, nous recommandons l'application AstrBot App.
|
||||||
|
|
||||||
|
Rendez-vous sur [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) pour télécharger et installer ; cette méthode est destinée à un usage bureautique et n'est pas recommandée pour les scénarios serveur.
|
||||||
|
|
||||||
|
### Déploiement Launcher
|
||||||
|
|
||||||
|
Également pour une utilisation sur bureau, pour les utilisateurs souhaitant un déploiement rapide et une isolation de l'environnement pour plusieurs instances, nous recommandons AstrBot Launcher.
|
||||||
|
|
||||||
|
Rendez-vous sur [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) pour télécharger et installer.
|
||||||
|
|
||||||
|
### Déploiement sur Replit
|
||||||
|
|
||||||
|
Le déploiement sur Replit est maintenu par la communauté et convient aux démonstrations en ligne et aux scénarios d'essai légers.
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
#### Installateur Windows en un clic
|
### AUR
|
||||||
|
|
||||||
Veuillez consulter la documentation officielle : [Déployer AstrBot avec l'installateur Windows en un clic](https://astrbot.app/deploy/astrbot/windows.html).
|
La méthode AUR est destinée aux utilisateurs d'Arch Linux souhaitant installer AstrBot via le gestionnaire de paquets du système.
|
||||||
|
|
||||||
#### Déploiement CasaOS
|
Exécutez la commande ci-dessous dans le terminal pour installer le paquet `astrbot-git`. Une fois l'installation terminée, vous pouvez le lancer.
|
||||||
|
|
||||||
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
|
```bash
|
||||||
pip install uv
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
Installez AstrBot via Git Clone :
|
**Plus de méthodes de déploiement**
|
||||||
|
|
||||||
```bash
|
Si vous avez besoin d'un déploiement via panneau de contrôle ou hautement personnalisé, vous pouvez consulter [BT Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (installation via le magasin d'applications BT Panel), [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (installation via le magasin d'applications 1Panel), [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (déploiement visuel pour NAS / serveur domestique) et [Déploiement Manuel](https://astrbot.app/deploy/astrbot/cli.html) (installation personnalisée complète basée sur le code source et `uv`).
|
||||||
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
|
||||||
|
|
||||||
## Plateformes de messagerie prises en charge
|
Connectez AstrBot à vos plateformes de chat préférées.
|
||||||
|
|
||||||
**Maintenues officiellement**
|
| Plateforme | Mainteneur |
|
||||||
|
|---------|---------------|
|
||||||
|
| **QQ** | Officiel |
|
||||||
|
| **OneBot v11** | Officiel |
|
||||||
|
| **Telegram** | Officiel |
|
||||||
|
| **WeCom (App & Smart Bot)** | Officiel |
|
||||||
|
| **WeChat (Service Client & Compte Officiel)** | Officiel |
|
||||||
|
| **Lark (Feishu)** | Officiel |
|
||||||
|
| **DingTalk** | Officiel |
|
||||||
|
| **Slack** | Officiel |
|
||||||
|
| **Discord** | Officiel |
|
||||||
|
| **LINE** | Officiel |
|
||||||
|
| **Satori** | Officiel |
|
||||||
|
| **Misskey** | Officiel |
|
||||||
|
| **Whatsapp (Bientôt)** | Officiel |
|
||||||
|
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté |
|
||||||
|
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté |
|
||||||
|
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
|
||||||
|
|
||||||
- QQ (Plateforme officielle & OneBot)
|
## Fournisseurs de Modèles Pris en Charge
|
||||||
- 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é**
|
| Fournisseur | Type |
|
||||||
|
|---------|---------------|
|
||||||
|
| Personnalisé | Tout service compatible avec l'API OpenAI |
|
||||||
|
| OpenAI | LLM |
|
||||||
|
| Anthropic | LLM |
|
||||||
|
| Google Gemini | LLM |
|
||||||
|
| Moonshot AI | LLM |
|
||||||
|
| Zhipu AI | LLM |
|
||||||
|
| DeepSeek | LLM |
|
||||||
|
| Ollama (Local) | LLM |
|
||||||
|
| LM Studio (Local) | LLM |
|
||||||
|
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (Passerelle API, supporte tous les modèles) |
|
||||||
|
| [Uyun AI](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (Passerelle API, supporte tous les modèles) |
|
||||||
|
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (Passerelle API, supporte tous les modèles) |
|
||||||
|
| [PPIO](https://ppio.com/user/register?invited_by=AIOONE) | LLM (Passerelle API, supporte tous les modèles) |
|
||||||
|
| [302.AI](https://share.302.ai/rr1M3l) | LLM (Passerelle API, supporte tous les modèles)|
|
||||||
|
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM (Passerelle API, supporte tous les modèles)|
|
||||||
|
| ModelScope | LLM |
|
||||||
|
| OneAPI | LLM |
|
||||||
|
| Dify | Plateforme LLMOps |
|
||||||
|
| Alibaba Bailian | Plateforme LLMOps |
|
||||||
|
| Coze | Plateforme LLMOps |
|
||||||
|
| OpenAI Whisper | Synthèse vocale (Speech-to-Text) |
|
||||||
|
| SenseVoice | Synthèse vocale (Speech-to-Text) |
|
||||||
|
| OpenAI TTS | Synthèse vocale (Text-to-Speech) |
|
||||||
|
| Gemini TTS | Synthèse vocale (Text-to-Speech) |
|
||||||
|
| GPT-Sovits-Inference | Synthèse vocale (Text-to-Speech) |
|
||||||
|
| GPT-Sovits | Synthèse vocale (Text-to-Speech) |
|
||||||
|
| FishAudio | Synthèse vocale (Text-to-Speech) |
|
||||||
|
| Edge TTS | Synthèse vocale (Text-to-Speech) |
|
||||||
|
| Alibaba Bailian TTS | Synthèse vocale (Text-to-Speech) |
|
||||||
|
| Azure TTS | Synthèse vocale (Text-to-Speech) |
|
||||||
|
| Minimax TTS | Synthèse vocale (Text-to-Speech) |
|
||||||
|
| Volcengine TTS | Synthèse vocale (Text-to-Speech) |
|
||||||
|
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
## ❤️ Contribution
|
||||||
- [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
|
Les Issues et Pull Requests sont les bienvenus ! Soumettez simplement vos modifications à ce projet :)
|
||||||
|
|
||||||
**Services LLM**
|
### Comment Contribuer
|
||||||
|
|
||||||
- OpenAI et services compatibles
|
Vous pouvez contribuer en examinant les problèmes ou en aidant à réviser les PR (Pull Requests). Tout problème ou PR est le bienvenu pour promouvoir la contribution communautaire. Bien sûr, ce ne sont que des suggestions, vous pouvez contribuer de n'importe quelle manière. Pour l'ajout de nouvelles fonctionnalités, veuillez d'abord en discuter via une Issue.
|
||||||
- Anthropic
|
Il est recommandé de fusionner les PR fonctionnels dans la branche `dev`, qui sera fusionnée dans la branche principale et publiée en tant que nouvelle version après test des modifications.
|
||||||
- Google Gemini
|
Pour réduire les conflits, nous suggérons :
|
||||||
- Moonshot AI
|
1. Créez votre branche de travail basée sur la branche `dev`, évitez de travailler directement sur la branche `main`.
|
||||||
- Zhipu AI
|
2. Lors de la soumission d'une PR, sélectionnez la branche `dev` comme cible.
|
||||||
- DeepSeek
|
3. Synchronisez régulièrement la branche `dev` en local, utilisez souvent `git pull`.
|
||||||
- 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**
|
### Environnement de Développement
|
||||||
|
|
||||||
- Dify
|
AstrBot utilise `ruff` pour le formatage et la vérification du code.
|
||||||
- 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
|
```bash
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot
|
git clone https://github.com/AstrBotDevs/AstrBot
|
||||||
pip install pre-commit
|
git switch dev # Basculer vers la branche de développement
|
||||||
|
pip install pre-commit # ou uv tool install pre-commit
|
||||||
pre-commit install
|
pre-commit install
|
||||||
```
|
```
|
||||||
|
Il est recommandé d'utiliser `uv` pour l'installation locale et les tests.
|
||||||
## 🌍 Communauté
|
```bash
|
||||||
|
uv tool install -e . --force
|
||||||
|
astrbot init
|
||||||
|
astrbot run
|
||||||
|
```
|
||||||
|
Débogage frontend
|
||||||
|
```bash
|
||||||
|
astrbot run --backend-only
|
||||||
|
cd dashboard
|
||||||
|
bun install # ou pnpm, etc.
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
### Groupes QQ
|
### Groupes QQ
|
||||||
|
|
||||||
|
- Groupe 9 : 1076659624 (Nouveau)
|
||||||
|
- Groupe 10 : 1078079676 (Nouveau)
|
||||||
- Groupe 1 : 322154837
|
- Groupe 1 : 322154837
|
||||||
- Groupe 3 : 630166526
|
- Groupe 3 : 630166526
|
||||||
- Groupe 5 : 822130018
|
- Groupe 5 : 822130018
|
||||||
- Groupe 6 : 753075035
|
- Groupe 6 : 753075035
|
||||||
- Groupe développeurs : 975206796
|
- Groupe 7 : 743746109
|
||||||
|
- Groupe 8 : 1030353265
|
||||||
|
- Groupe Développeurs (Discussion libre) : 975206796
|
||||||
|
- Groupe Développeurs (Officiel) : 1039761811
|
||||||
|
|
||||||
### Groupe Telegram
|
### Canal Discord
|
||||||
|
|
||||||
<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](https://discord.gg/hAVk6tgV36)
|
||||||
|
|
||||||
### Serveur Discord
|
## ❤️ Remerciements Spéciaux
|
||||||
|
|
||||||
<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>
|
Un grand merci à tous les Contributeurs et développeurs de plugins pour leur contribution à AstrBot ❤️
|
||||||
|
|
||||||
## ❤️ 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">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
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
|
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Le grand framework félin
|
||||||
|
|
||||||
## ⭐ Historique des étoiles
|
Liens amicaux vers des projets open source :
|
||||||
|
|
||||||
|
- [NoneBot2](https://github.com/nonebot/nonebot2) - Excellent framework de ChatBot asynchrone en Python
|
||||||
|
- [Koishi](https://github.com/koishijs/koishi) - Excellent framework de ChatBot en Node.js
|
||||||
|
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - Excellent ChatBot IA anthropomorphe
|
||||||
|
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - Excellent ChatBot Agent
|
||||||
|
- [LangBot](https://github.com/langbot-app/LangBot) - Excellent ChatBot IA multiplateforme
|
||||||
|
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - Excellent plugin Koishi de ChatBot IA multiplateforme
|
||||||
|
- [Operit AI](https://github.com/AAswordman/Operit) - Excellente application Android d'assistant intelligent IA
|
||||||
|
|
||||||
|
## ⭐ Historique des Étoiles
|
||||||
|
|
||||||
> [!TIP]
|
> [!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
|
> Si ce projet vous a été utile dans votre vie ou votre travail, ou si vous vous intéressez à son développement futur, merci de lui donner une Étoile. C'est notre motivation pour maintenir ce projet open source <3
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -242,7 +286,12 @@ De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des p
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</details>
|
<div align="center">
|
||||||
|
|
||||||
_私は、高性能ですから!_
|
_La compagnie et la compétence ne devraient jamais être opposées. Nous espérons créer un robot capable à la fois de comprendre les émotions, d'offrir de la compagnie et d'accomplir des tâches de manière fiable._
|
||||||
|
|
||||||
|
_私は、高性能ですから!_ (Je suis performant !)
|
||||||
|
|
||||||
|
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||||
|
|
||||||
|
</div>
|
||||||
+200
-150
@@ -1,10 +1,12 @@
|
|||||||

|

|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<br>
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.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_zh.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>
|
<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://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>
|
||||||
@@ -14,227 +16,269 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
<div>
|
<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/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?style=for-the-badge&color=76bad9" alt="python">
|
<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?style=for-the-badge&color=76bad9"/></a>
|
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||||
<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://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://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></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=%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">
|
<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&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%82%A2&cacheSeconds=3600">
|
||||||
|
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
<a href="https://astrbot.app/">ホーム</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://astrbot.app/">ドキュメント</a> |
|
||||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
<a href="https://blog.astrbot.app/">ブログ</a> |
|
||||||
<a href="https://astrbot.featurebase.app/roadmap">ロードマップ</a> |
|
<a href="https://astrbot.featurebase.app/roadmap">ロードマップ</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/issues">課題の提出</a>
|
||||||
|
<a href="mailto:community@astrbot.app">Email</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
|
AstrBotは、オープンソースのオールインワンAgentic個人およびグループチャットアシスタントです。QQ、Telegram、WeCom(企業微信)、Lark(飛書)、DingTalk(釘釘)、Slackなど、数十種類の主要なインスタントメッセージングソフトウェアに導入できます。さらに、OpenWebUIに似た軽量のChatUIも組み込まれており、個人、開発者、チーム向けに信頼性が高く拡張可能な会話型AIインフラストラクチャを提供します。個人のAIパートナー、インテリジェントなカスタマーサービス、自動化アシスタント、または企業のナレッジベースであっても、AstrBotはインスタントメッセージングプラットフォームのワークフロー内でAIアプリケーションを迅速に構築することを可能にします。
|
||||||
|
|
||||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|

|
||||||
|
|
||||||
## 主な機能
|
## 主な機能
|
||||||
|
|
||||||
1. 💯 無料 & オープンソース。
|
1. 💯 無料 & オープンソース。
|
||||||
2. ✨ AI 大規模言語モデル対話、マルチモーダル、Agent、MCP、ナレッジベース、ペルソナ設定。
|
2. ✨ AI大規模モデル対話、マルチモーダル、エージェント、MCP、スキル、ナレッジベース、人格設定、対話の自動圧縮。
|
||||||
3. 🤖 Dify、Alibaba Cloud 百炼、Coze などの Agent プラットフォームとの統合をサポート。
|
3. 🤖 Dify、Alibaba Bailian(阿里雲百煉)、Cozeなどのエージェントプラットフォームとの連携をサポート。
|
||||||
4. 🌐 マルチプラットフォーム:QQ、WeChat Work、Feishu、DingTalk、WeChat 公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)。
|
4. 🌐 マルチプラットフォーム対応:QQ、WeCom、Lark、DingTalk、WeChat公式アカウント、Telegram、Slack、その他[多数](#対応メッセージングプラットフォーム)。
|
||||||
5. 📦 約800個のプラグインをワンクリックでインストール可能なプラグイン拡張機能。
|
5. 📦 プラグイン拡張:1000以上のプラグインがワンクリックでインストール可能。
|
||||||
6. 💻 WebUI サポート。
|
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):隔離された環境で、あらゆるコードの安全な実行、シェル呼び出し、セッションレベルのリソース再利用が可能。
|
||||||
7. 🌐 国際化(i18n)サポート。
|
7. 💻 WebUIサポート。
|
||||||
|
8. 🌈 Web ChatUIサポート:ChatUIにはプロキシサンドボックス、Web検索などが組み込まれています。
|
||||||
|
9. 🌐 国際化(i18n)サポート。
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th>💙 ロールプレイ & 感情的な付き添い</th>
|
||||||
|
<th>✨ 能動的エージェント</th>
|
||||||
|
<th>🚀 汎用Agentic能力</th>
|
||||||
|
<th>🧩 1000+ コミュニティプラグイン</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## クイックスタート
|
## クイックスタート
|
||||||
|
|
||||||
#### Docker デプロイ(推奨 🥳)
|
### ワンクリックデプロイ
|
||||||
|
|
||||||
Docker / Docker Compose を使用した AstrBot のデプロイを推奨します。
|
AstrBotをすぐに試してみたい方で、コマンドラインに慣れており、`uv`環境を自分でインストールできる方には、`uv`を使用したワンクリックデプロイをお勧めします⚡️。
|
||||||
|
|
||||||
公式ドキュメント [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
|
```bash
|
||||||
uvx astrbot
|
uv tool install astrbot
|
||||||
|
astrbot init # 初回のみ環境初期化のために実行
|
||||||
|
astrbot run # astrbot run --backend-only バックエンドサービスのみ起動
|
||||||
|
|
||||||
|
# 開発版のインストール(修正や新機能が多いですが、不安定な場合があります。開発者向け)
|
||||||
|
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 宝塔パネルデプロイ
|
> [uv](https://docs.astral.sh/uv/)のインストールが必要です。
|
||||||
|
|
||||||
AstrBot は宝塔パネルと提携し、宝塔パネルに公開されています。
|
> [!NOTE]
|
||||||
|
> macOSユーザーの場合:macOSのセキュリティチェックにより、`astrbot`コマンドの初回実行に時間がかかる場合があります(約10〜20秒)。
|
||||||
|
|
||||||
公式ドキュメント [宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html) をご参照ください。
|
`astrbot`の更新:
|
||||||
|
|
||||||
#### 1Panel デプロイ
|
```bash
|
||||||
|
uv tool upgrade astrbot
|
||||||
|
```
|
||||||
|
|
||||||
AstrBot は 1Panel 公式により 1Panel パネルに公開されています。
|
### Dockerデプロイ
|
||||||
|
|
||||||
公式ドキュメント [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) をご参照ください。
|
コンテナに精通しており、より安定的で本番環境に適したデプロイ方法を好むユーザーには、Docker / Docker Composeを使用したAstrBotのデプロイをお勧めします。
|
||||||
|
|
||||||
#### 雨云でのデプロイ
|
公式ドキュメントの[Dockerを使用してAstrBotをデプロイする](https://astrbot.app/deploy/astrbot/docker.html)を参照してください。
|
||||||
|
|
||||||
AstrBot は雨云公式によりクラウドアプリケーションプラットフォームに公開され、ワンクリックでデプロイ可能です。
|
### RainYun(雨云)でのデプロイ
|
||||||
|
|
||||||
|
サーバーを自分で管理せずにAstrBotをワンクリックでデプロイしたいユーザーには、RainYunのワンクリッククラウドデプロイサービスをお勧めします☁️:
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
#### Replit でのデプロイ
|
### デスクトップクライアントデプロイ
|
||||||
|
|
||||||
コミュニティ貢献によるデプロイ方法。
|
デスクトップでAstrBotを使用し、主にChatUIを入り口として使用したいユーザーには、AstrBot Appをお勧めします。
|
||||||
|
|
||||||
|
[AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)にアクセスしてダウンロードおよびインストールしてください。この方法はデスクトップ利用向けであり、サーバーシナリオには推奨されません。
|
||||||
|
|
||||||
|
### ランチャーデプロイ
|
||||||
|
|
||||||
|
同じくデスクトップ向けで、迅速にデプロイし、環境を分離して複数起動したいユーザーには、AstrBot Launcherをお勧めします。
|
||||||
|
|
||||||
|
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher)にアクセスしてダウンロードおよびインストールしてください。
|
||||||
|
|
||||||
|
### Replitでのデプロイ
|
||||||
|
|
||||||
|
Replitデプロイはコミュニティによって維持されており、オンラインデモや軽量な試用シナリオに適しています。
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
#### Windows ワンクリックインストーラーデプロイ
|
### AUR
|
||||||
|
|
||||||
公式ドキュメント [Windows ワンクリックインストーラーを使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/windows.html) をご参照ください。
|
AUR方式はArch Linuxユーザー向けで、システムパッケージマネージャーを通じてAstrBotをインストールしたい場合に適しています。
|
||||||
|
|
||||||
#### CasaOS デプロイ
|
ターミナルで以下のコマンドを実行して`astrbot-git`パッケージをインストールすると、起動して使用できます。
|
||||||
|
|
||||||
コミュニティ貢献によるデプロイ方法。
|
|
||||||
|
|
||||||
公式ドキュメント [CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html) をご参照ください。
|
|
||||||
|
|
||||||
#### 手動デプロイ
|
|
||||||
|
|
||||||
まず uv をインストールします:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install uv
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
Git Clone で AstrBot をインストール:
|
**その他のデプロイ方法**
|
||||||
|
|
||||||
```bash
|
パネル化や高度なカスタマイズデプロイが必要な場合は、[BT Panel(宝塔パネル)](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panelアプリストアインストール)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panelアプリストアインストール)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバーの視覚的デプロイ)、および[手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)(ソースコードと`uv`に基づく完全なカスタムインストール)を参照してください。
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
|
||||||
uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。
|
## 対応メッセージングプラットフォーム
|
||||||
|
|
||||||
## サポートされているメッセージプラットフォーム
|
AstrBotを普段使用しているチャットプラットフォームに接続しましょう。
|
||||||
|
|
||||||
**公式メンテナンス**
|
| プラットフォーム | 管理者 |
|
||||||
|
|---------|---------------|
|
||||||
|
| **QQ** | 公式管理 |
|
||||||
|
| **OneBot v11** | 公式管理 |
|
||||||
|
| **Telegram** | 公式管理 |
|
||||||
|
| **WeComアプリ & WeComボット** | 公式管理 |
|
||||||
|
| **WeChatカスタマーサービス & WeChat公式アカウント** | 公式管理 |
|
||||||
|
| **Lark (飛書)** | 公式管理 |
|
||||||
|
| **DingTalk (釘釘)** | 公式管理 |
|
||||||
|
| **Slack** | 公式管理 |
|
||||||
|
| **Discord** | 公式管理 |
|
||||||
|
| **LINE** | 公式管理 |
|
||||||
|
| **Satori** | 公式管理 |
|
||||||
|
| **Misskey** | 公式管理 |
|
||||||
|
| **Whatsapp (対応予定)** | 公式管理 |
|
||||||
|
| [**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) | コミュニティ管理 |
|
||||||
|
|
||||||
- QQ (公式プラットフォーム & OneBot)
|
## 対応モデルプロバイダー
|
||||||
- Telegram
|
|
||||||
- WeChat Work アプリケーション & WeChat Work インテリジェントボット
|
|
||||||
- WeChat カスタマーサービス & WeChat 公式アカウント
|
|
||||||
- Feishu (Lark)
|
|
||||||
- DingTalk
|
|
||||||
- Slack
|
|
||||||
- Discord
|
|
||||||
- Satori
|
|
||||||
- Misskey
|
|
||||||
- WhatsApp (近日対応予定)
|
|
||||||
- LINE (近日対応予定)
|
|
||||||
|
|
||||||
**コミュニティメンテナンス**
|
| プロバイダー | タイプ |
|
||||||
|
|---------|---------------|
|
||||||
|
| カスタム | OpenAI API互換の任意のサービス |
|
||||||
|
| OpenAI | LLM |
|
||||||
|
| Anthropic | LLM |
|
||||||
|
| Google Gemini | LLM |
|
||||||
|
| Moonshot AI | LLM |
|
||||||
|
| Zhipu AI (智譜AI) | LLM |
|
||||||
|
| DeepSeek | LLM |
|
||||||
|
| Ollama (ローカル) | LLM |
|
||||||
|
| LM Studio (ローカル) | LLM |
|
||||||
|
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (APIゲートウェイ, 全モデル対応) |
|
||||||
|
| [Uyun AI (優雲智算)](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (APIゲートウェイ, 全モデル対応) |
|
||||||
|
| [SiliconFlow (硅基流動)](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (APIゲートウェイ, 全モデル対応) |
|
||||||
|
| [PPIO](https://ppio.com/user/register?invited_by=AIOONE) | LLM (APIゲートウェイ, 全モデル対応) |
|
||||||
|
| [302.AI](https://share.302.ai/rr1M3l) | LLM (APIゲートウェイ, 全モデル対応)|
|
||||||
|
| [TokenPony (小馬算力)](https://www.tokenpony.cn/3YPyf) | LLM (APIゲートウェイ, 全モデル対応)|
|
||||||
|
| ModelScope | LLM |
|
||||||
|
| OneAPI | LLM |
|
||||||
|
| Dify | LLMOpsプラットフォーム |
|
||||||
|
| Alibaba Bailian (阿里雲百煉) | LLMOpsプラットフォーム |
|
||||||
|
| Coze | LLMOpsプラットフォーム |
|
||||||
|
| OpenAI Whisper | 音声認識 (STT) |
|
||||||
|
| SenseVoice | 音声認識 (STT) |
|
||||||
|
| OpenAI TTS | 音声合成 (TTS) |
|
||||||
|
| Gemini TTS | 音声合成 (TTS) |
|
||||||
|
| GPT-Sovits-Inference | 音声合成 (TTS) |
|
||||||
|
| GPT-Sovits | 音声合成 (TTS) |
|
||||||
|
| FishAudio | 音声合成 (TTS) |
|
||||||
|
| Edge TTS | 音声合成 (TTS) |
|
||||||
|
| Alibaba Bailian TTS | 音声合成 (TTS) |
|
||||||
|
| Azure TTS | 音声合成 (TTS) |
|
||||||
|
| Minimax TTS | 音声合成 (TTS) |
|
||||||
|
| Volcengine TTS (火山エンジン) | 音声合成 (TTS) |
|
||||||
|
|
||||||
- [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)
|
|
||||||
|
|
||||||
## サポートされているモデルサービス
|
IssueやPull Requestは大歓迎です!変更をこのプロジェクトに送信してください :)
|
||||||
|
|
||||||
**大規模言語モデルサービス**
|
### 貢献方法
|
||||||
|
|
||||||
- OpenAI および互換サービス
|
問題の確認やPR(プルリクエスト)のレビューを通じて貢献できます。コミュニティの貢献を促進するために、あらゆる問題やPRへの参加を歓迎します。もちろん、これらは提案に過ぎず、どのような方法で貢献しても構いません。新機能の追加については、まずIssueで議論してください。
|
||||||
- Anthropic
|
機能的なPRは`dev`ブランチにマージすることをお勧めします。テスト修正後にメインブランチにマージされ、新しいバージョンとしてリリースされます。
|
||||||
- Google Gemini
|
コンフリクトを減らすために、以下のことを推奨します:
|
||||||
- Moonshot AI
|
1. 作業ブランチは`dev`ブランチに基づいて作成し、`main`ブランチで直接作業することは避けてください。
|
||||||
- 智谱 AI
|
2. PRを送信する際は、ターゲットブランチとして`dev`ブランチを選択してください。
|
||||||
- DeepSeek
|
3. 定期的に`dev`ブランチをローカルに同期し、`git pull`を頻繁に使用してください。
|
||||||
- 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
|
|
||||||
- Alibaba Cloud 百炼アプリケーション
|
|
||||||
- Coze
|
|
||||||
|
|
||||||
**音声認識サービス**
|
|
||||||
|
|
||||||
- OpenAI Whisper
|
|
||||||
- SenseVoice
|
|
||||||
|
|
||||||
**音声合成サービス**
|
|
||||||
|
|
||||||
- OpenAI TTS
|
|
||||||
- Gemini TTS
|
|
||||||
- GPT-Sovits-Inference
|
|
||||||
- GPT-Sovits
|
|
||||||
- FishAudio
|
|
||||||
- Edge TTS
|
|
||||||
- Alibaba Cloud 百炼 TTS
|
|
||||||
- Azure TTS
|
|
||||||
- Minimax TTS
|
|
||||||
- Volcano Engine TTS
|
|
||||||
|
|
||||||
## ❤️ コントリビューション
|
|
||||||
|
|
||||||
Issue や Pull Request は大歓迎です!このプロジェクトに変更を送信してください :)
|
|
||||||
|
|
||||||
### コントリビュート方法
|
|
||||||
|
|
||||||
Issue を確認したり、PR(プルリクエスト)のレビューを手伝うことで貢献できます。どんな Issue や PR への参加も歓迎され、コミュニティ貢献を促進します。もちろん、これらは提案に過ぎず、どんな方法でも貢献できます。新機能の追加については、まず Issue で議論してください。
|
|
||||||
|
|
||||||
### 開発環境
|
### 開発環境
|
||||||
|
|
||||||
AstrBot はコードのフォーマットとチェックに `ruff` を使用しています。
|
AstrBotはコードのフォーマットとチェックに`ruff`を使用しています。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot
|
git clone https://github.com/AstrBotDevs/AstrBot
|
||||||
pip install pre-commit
|
git switch dev # 開発ブランチに切り替え
|
||||||
|
pip install pre-commit # または uv tool install pre-commit
|
||||||
pre-commit install
|
pre-commit install
|
||||||
```
|
```
|
||||||
|
ローカルでのインストールとテストには`uv`の使用をお勧めします。
|
||||||
|
```bash
|
||||||
|
uv tool install -e . --force
|
||||||
|
astrbot init
|
||||||
|
astrbot run
|
||||||
|
```
|
||||||
|
フロントエンドのデバッグ
|
||||||
|
```bash
|
||||||
|
astrbot run --backend-only
|
||||||
|
cd dashboard
|
||||||
|
bun install # または pnpm など
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
## 🌍 コミュニティ
|
### QQグループ
|
||||||
|
|
||||||
### QQ グループ
|
- 9群: 1076659624 (新)
|
||||||
|
- 10群: 1078079676 (新)
|
||||||
|
- 1群:322154837
|
||||||
|
- 3群:630166526
|
||||||
|
- 5群:822130018
|
||||||
|
- 6群:753075035
|
||||||
|
- 7群:743746109
|
||||||
|
- 8群:1030353265
|
||||||
|
- 開発者群(雑談):975206796
|
||||||
|
- 開発者群(公式):1039761811
|
||||||
|
|
||||||
- 1群: 322154837
|
### Discordチャンネル
|
||||||
- 3群: 630166526
|
|
||||||
- 5群: 822130018
|
|
||||||
- 6群: 753075035
|
|
||||||
- 開発者群: 975206796
|
|
||||||
|
|
||||||
### Telegram グループ
|
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||||
|
|
||||||
<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
|
## ❤️ Special Thanks
|
||||||
|
|
||||||
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
AstrBotに貢献してくださったすべてのコントリビューターとプラグイン開発者に感謝します ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
さらに、このプロジェクトの誕生は、以下のオープンソースプロジェクトの助けなしにはあり得ませんでした:
|
||||||
|
|
||||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 素晴らしい猫猫フレームワーク
|
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 偉大な猫フレームワーク
|
||||||
|
|
||||||
|
オープンソースプロジェクトのフレンドリーリンク:
|
||||||
|
|
||||||
|
- [NoneBot2](https://github.com/nonebot/nonebot2) - 優れたPython非同期チャットボットフレームワーク
|
||||||
|
- [Koishi](https://github.com/koishijs/koishi) - 優れたNode.jsチャットボットフレームワーク
|
||||||
|
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 優れた擬人化AIチャットボット
|
||||||
|
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 優れたエージェントチャットボット
|
||||||
|
- [LangBot](https://github.com/langbot-app/LangBot) - 優れたマルチプラットフォームAIチャットボット
|
||||||
|
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 優れたマルチプラットフォームAIチャットボットKoishiプラグイン
|
||||||
|
- [Operit AI](https://github.com/AAswordman/Operit) - 優れたAIインテリジェントアシスタントAndroidアプリ
|
||||||
|
|
||||||
## ⭐ Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> このプロジェクトがあなたの生活や仕事に役立ったり、このプロジェクトの今後の発展に関心がある場合は、プロジェクトに Star をください。これがこのオープンソースプロジェクトを維持する原動力です <3
|
> もしこのプロジェクトがあなたの生活や仕事の助けになったなら、あるいはこのプロジェクトの将来の発展に関心があるなら、プロジェクトにStarを付けてください。これは私たちがこのオープンソースプロジェクトを維持するための原動力となります <3
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -242,6 +286,12 @@ AstrBot への貢献をしていただいたすべてのコントリビュータ
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</details>
|
<div align="center">
|
||||||
|
|
||||||
|
_付き添いと能力は決して対立するものであってはなりません。私たちが創造したいのは、感情を理解し、寄り添いながらも、確実に仕事を遂行できるロボットです。_
|
||||||
|
|
||||||
_私は、高性能ですから!_
|
_私は、高性能ですから!_
|
||||||
|
|
||||||
|
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||||
|
|
||||||
|
</div>
|
||||||
+192
-143
@@ -1,10 +1,12 @@
|
|||||||

|

|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<br>
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.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_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_zh.md">简体中文</a>
|
||||||
|
|
||||||
<div>
|
<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://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>
|
||||||
@@ -14,227 +16,269 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
<div>
|
<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/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?style=for-the-badge&color=76bad9" alt="python">
|
<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?style=for-the-badge&color=76bad9"/></a>
|
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||||
<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://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://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></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=%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">
|
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20Plugins&label=%D0%9C%D0%B0%D1%80%D0%BA%D0%B5%D1%82%D0%BF%D0%BB%D0%B5%D0%B9%D1%81&cacheSeconds=3600">
|
||||||
|
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
<a href="https://astrbot.app/">Главная</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://astrbot.app/">Документация</a> |
|
||||||
<a href="https://blog.astrbot.app/">Блог</a> |
|
<a href="https://blog.astrbot.app/">Блог</a> |
|
||||||
<a href="https://astrbot.featurebase.app/roadmap">Дорожная карта</a> |
|
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Сообщить о проблеме</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Сообщить о проблеме</a>
|
||||||
|
<a href="mailto:community@astrbot.app">Email</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
|
AstrBot — это универсальный агентский помощник для личных и групповых чатов с открытым исходным кодом. Он может быть развернут в десятках популярных мессенджеров, таких как QQ, Telegram, WeCom (Enterprise WeChat), Lark (Feishu), DingTalk, Slack и других. Кроме того, он имеет встроенный легковесный веб-интерфейс чата (ChatUI), похожий на OpenWebUI, создавая надежную и масштабируемую диалоговую интеллектуальную инфраструктуру для частных лиц, разработчиков и команд. Будь то личный AI-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний, AstrBot позволяет быстро создавать AI-приложения в рабочем процессе ваших платформ обмена мгновенными сообщениями.
|
||||||
|
|
||||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|

|
||||||
|
|
||||||
## Основные возможности
|
## Основные возможности
|
||||||
|
|
||||||
1. 💯 Бесплатно и с открытым исходным кодом.
|
1. 💯 Бесплатно и с открытым исходным кодом.
|
||||||
2. ✨ ИИ-диалоги с LLM, мультимодальность, Agent, MCP, база знаний, настройки личности.
|
2. ✨ Поддержка диалога с большими языковыми моделями (LLM), мультимодальность, Агенты, MCP, Навыки (Skills), База знаний, Персонализация, автоматическое сжатие диалога.
|
||||||
3. 🤖 Поддержка интеграции с Dify, Alibaba Cloud Bailian, Coze и другими платформами агентов.
|
3. 🤖 Поддержка интеграции с платформами агентов, такими как Dify, Alibaba Bailian, Coze и др.
|
||||||
4. 🌐 Мультиплатформенность: QQ, WeChat Work, Feishu, DingTalk, официальные аккаунты WeChat, Telegram, Slack и [другие](#поддерживаемые-платформы-обмена-сообщениями).
|
4. 🌐 Мультиплатформенность: поддержка QQ, WeCom, Lark, DingTalk, WeChat Official Account, Telegram, Slack и [других](#поддерживаемые-платформы-сообщений).
|
||||||
5. 📦 Расширения плагинов с почти 800 плагинами, доступными для установки в один клик.
|
5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик.
|
||||||
6. 💻 Поддержка WebUI.
|
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): Изолированная среда для безопасного выполнения любого кода, вызова Shell и повторного использования ресурсов на уровне сессии.
|
||||||
7. 🌐 Поддержка интернационализации (i18n).
|
7. 💻 Поддержка WebUI.
|
||||||
|
8. 🌈 Поддержка Web ChatUI: встроенная прокси-песочница, веб-поиск и многое другое внутри ChatUI.
|
||||||
|
9. 🌐 Поддержка интернационализации (i18n).
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th>💙 Ролевые игры и Эмоциональное общение</th>
|
||||||
|
<th>✨ Проактивный Агент</th>
|
||||||
|
<th>🚀 Общие агентские возможности</th>
|
||||||
|
<th>🧩 1000+ Плагинов сообщества</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
#### Развёртывание Docker (Рекомендуется 🥳)
|
### Развертывание в один клик
|
||||||
|
|
||||||
Мы рекомендуем развёртывать AstrBot с помощью Docker или Docker Compose.
|
Для пользователей, которые хотят быстро протестировать AstrBot, знакомы с командной строкой и могут самостоятельно установить среду `uv`, мы рекомендуем метод развертывания в один клик с помощью `uv` ⚡️.
|
||||||
|
|
||||||
См. официальную документацию: [Развёртывание 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
|
```bash
|
||||||
uvx astrbot
|
uv tool install astrbot
|
||||||
|
astrbot init # Выполните эту команду только в первый раз для инициализации среды
|
||||||
|
astrbot run # astrbot run --backend-only запускает только бэкенд сервис
|
||||||
|
|
||||||
|
# Установка версии для разработчиков (больше исправлений и новых функций, но менее стабильна; подходит для разработчиков)
|
||||||
|
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Развёртывание BT-Panel
|
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||||
|
|
||||||
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
|
> [!NOTE]
|
||||||
|
> Для пользователей macOS: Из-за проверок безопасности macOS первый запуск команды `astrbot` может занять длительное время (около 10-20 секунд).
|
||||||
|
|
||||||
См. официальную документацию: [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
|
Обновление `astrbot`:
|
||||||
|
|
||||||
#### Развёртывание 1Panel
|
```bash
|
||||||
|
uv tool upgrade astrbot
|
||||||
|
```
|
||||||
|
|
||||||
AstrBot официально размещён на маркетплейсе 1Panel.
|
### Развертывание через Docker
|
||||||
|
|
||||||
См. официальную документацию: [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html).
|
Для пользователей, знакомых с контейнерами и предпочитающих более стабильный метод развертывания, подходящий для производственных сред, мы рекомендуем использовать Docker / Docker Compose для развертывания AstrBot.
|
||||||
|
|
||||||
#### Развёртывание на RainYun
|
Пожалуйста, обратитесь к официальной документации [Развертывание AstrBot с помощью Docker](https://astrbot.app/deploy/astrbot/docker.html).
|
||||||
|
|
||||||
AstrBot официально размещён на облачной платформе приложений RainYun с развёртыванием в один клик.
|
### Развертывание на RainYun
|
||||||
|
|
||||||
|
Для пользователей, которые хотят развернуть AstrBot в один клик и не хотят самостоятельно управлять серверами, мы рекомендуем облачный сервис развертывания в один клик от RainYun ☁️:
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
#### Развёртывание на Replit
|
### Развертывание настольного клиента
|
||||||
|
|
||||||
Метод развёртывания от сообщества.
|
Для пользователей, желающих использовать AstrBot на рабочем столе и использовать ChatUI в качестве основного интерфейса, мы рекомендуем приложение AstrBot App.
|
||||||
|
|
||||||
|
Перейдите на [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) для загрузки и установки; этот метод предназначен для использования на рабочем столе и не рекомендуется для серверных сценариев.
|
||||||
|
|
||||||
|
### Развертывание через лаунчер
|
||||||
|
|
||||||
|
Также для настольных компьютеров, для пользователей, которым требуется быстрое развертывание и изоляция среды для нескольких экземпляров, мы рекомендуем AstrBot Launcher.
|
||||||
|
|
||||||
|
Перейдите на [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) для загрузки и установки.
|
||||||
|
|
||||||
|
### Развертывание на Replit
|
||||||
|
|
||||||
|
Развертывание на Replit поддерживается сообществом и подходит для онлайн-демонстраций и легких тестовых сценариев.
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
#### Установщик Windows в один клик
|
### AUR
|
||||||
|
|
||||||
См. официальную документацию: [Развёртывание AstrBot с установщиком Windows в один клик](https://astrbot.app/deploy/astrbot/windows.html).
|
Метод AUR предназначен для пользователей Arch Linux, желающих установить AstrBot через системный менеджер пакетов.
|
||||||
|
|
||||||
#### Развёртывание CasaOS
|
Выполните приведенную ниже команду в терминале, чтобы установить пакет `astrbot-git`. После завершения установки вы сможете запустить его.
|
||||||
|
|
||||||
Метод развёртывания от сообщества.
|
|
||||||
|
|
||||||
См. официальную документацию: [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html).
|
|
||||||
|
|
||||||
#### Ручное развёртывание
|
|
||||||
|
|
||||||
Сначала установите uv:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install uv
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
Установите AstrBot через Git Clone:
|
**Другие методы развертывания**
|
||||||
|
|
||||||
```bash
|
Если вам требуется панельное управление или более кастомизированное развертывание, вы можете обратиться к [BT Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (установка через магазин приложений BT Panel), [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (установка через магазин приложений 1Panel), [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (визуальное развертывание для NAS / домашнего сервера) и [Ручное развертывание](https://astrbot.app/deploy/astrbot/cli.html) (полная пользовательская установка на основе исходного кода и `uv`).
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
|
||||||
uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Или см. официальную документацию: [Развёртывание AstrBot из исходного кода](https://astrbot.app/deploy/astrbot/cli.html).
|
## Поддерживаемые платформы сообщений
|
||||||
|
|
||||||
## Поддерживаемые платформы обмена сообщениями
|
Подключите AstrBot к вашим любимым платформам чата.
|
||||||
|
|
||||||
**Официально поддерживаемые**
|
| Платформа | Поддержка |
|
||||||
|
|---------|---------------|
|
||||||
|
| **QQ** | Официальная |
|
||||||
|
| **OneBot v11** | Официальная |
|
||||||
|
| **Telegram** | Официальная |
|
||||||
|
| **WeCom (Приложение & Смарт-бот)** | Официальная |
|
||||||
|
| **WeChat (Служба поддержки & Официальный аккаунт)** | Официальная |
|
||||||
|
| **Lark (Feishu)** | Официальная |
|
||||||
|
| **DingTalk** | Официальная |
|
||||||
|
| **Slack** | Официальная |
|
||||||
|
| **Discord** | Официальная |
|
||||||
|
| **LINE** | Официальная |
|
||||||
|
| **Satori** | Официальная |
|
||||||
|
| **Misskey** | Официальная |
|
||||||
|
| **Whatsapp (Скоро)** | Официальная |
|
||||||
|
| [**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) | Сообщество |
|
||||||
|
|
||||||
- QQ (Официальная платформа и OneBot)
|
## Поддерживаемые провайдеры моделей
|
||||||
- Telegram
|
|
||||||
- Приложение WeChat Work и интеллектуальный бот WeChat Work
|
|
||||||
- Служба поддержки WeChat и официальные аккаунты WeChat
|
|
||||||
- Feishu (Lark)
|
|
||||||
- DingTalk
|
|
||||||
- Slack
|
|
||||||
- Discord
|
|
||||||
- Satori
|
|
||||||
- Misskey
|
|
||||||
- WhatsApp (Скоро)
|
|
||||||
- LINE (Скоро)
|
|
||||||
|
|
||||||
**Поддерживаемые сообществом**
|
| Провайдер | Тип |
|
||||||
|
|---------|---------------|
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
| Пользовательский | Любой сервис, совместимый с OpenAI API |
|
||||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
| OpenAI | LLM |
|
||||||
- [Личные сообщения Bilibili](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
|
| Anthropic | LLM |
|
||||||
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
|
| Google Gemini | LLM |
|
||||||
|
| Moonshot AI | LLM |
|
||||||
## Поддерживаемые сервисы моделей
|
| Zhipu AI | LLM |
|
||||||
|
| DeepSeek | LLM |
|
||||||
**Сервисы LLM**
|
| Ollama (Локально) | LLM |
|
||||||
|
| LM Studio (Локально) | LLM |
|
||||||
- OpenAI и совместимые сервисы
|
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (API шлюз, поддерживает все модели) |
|
||||||
- Anthropic
|
| [Uyun AI](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (API шлюз, поддерживает все модели) |
|
||||||
- Google Gemini
|
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (API шлюз, поддерживает все модели) |
|
||||||
- Moonshot AI
|
| [PPIO](https://ppio.com/user/register?invited_by=AIOONE) | LLM (API шлюз, поддерживает все модели) |
|
||||||
- Zhipu AI
|
| [302.AI](https://share.302.ai/rr1M3l) | LLM (API шлюз, поддерживает все модели)|
|
||||||
- DeepSeek
|
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM (API шлюз, поддерживает все модели)|
|
||||||
- Ollama (Самостоятельное размещение)
|
| ModelScope | LLM |
|
||||||
- LM Studio (Самостоятельное размещение)
|
| OneAPI | LLM |
|
||||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
| Dify | Платформа LLMOps |
|
||||||
- [302.AI](https://share.302.ai/rr1M3l)
|
| Alibaba Bailian | Платформа LLMOps |
|
||||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
| Coze | Платформа LLMOps |
|
||||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
|
| OpenAI Whisper | Распознавание речи (STT) |
|
||||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
| SenseVoice | Распознавание речи (STT) |
|
||||||
- ModelScope
|
| OpenAI TTS | Синтез речи (TTS) |
|
||||||
- OneAPI
|
| Gemini TTS | Синтез речи (TTS) |
|
||||||
|
| GPT-Sovits-Inference | Синтез речи (TTS) |
|
||||||
**Платформы LLMOps**
|
| GPT-Sovits | Синтез речи (TTS) |
|
||||||
|
| FishAudio | Синтез речи (TTS) |
|
||||||
- Dify
|
| Edge TTS | Синтез речи (TTS) |
|
||||||
- Приложения Alibaba Cloud Bailian
|
| Alibaba Bailian TTS | Синтез речи (TTS) |
|
||||||
- Coze
|
| Azure TTS | Синтез речи (TTS) |
|
||||||
|
| Minimax TTS | Синтез речи (TTS) |
|
||||||
**Сервисы распознавания речи**
|
| Volcengine TTS | Синтез речи (TTS) |
|
||||||
|
|
||||||
- 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 Requests! Просто отправьте свои изменения в этот проект :)
|
||||||
|
|
||||||
### Как внести вклад
|
### Как внести вклад
|
||||||
|
|
||||||
Вы можете внести вклад, просматривая issues или помогая с ревью pull request. Любые issues или PR приветствуются для поощрения участия сообщества. Конечно, это лишь предложения — вы можете вносить вклад любым удобным для вас способом. Для добавления новых функций сначала обсудите это через Issue.
|
Вы можете внести свой вклад, просматривая проблемы (Issues) или помогая проверять PR (Pull Requests). Любая проблема или PR приветствуются для поощрения участия сообщества. Конечно, это всего лишь предложения, вы можете внести свой вклад любым способом. Для добавления новых функций, пожалуйста, сначала обсудите это через Issue.
|
||||||
|
Рекомендуется объединять функциональные PR в ветку `dev`, которая будет объединена с основной веткой (`main`) и выпущена как новая версия после тестирования изменений.
|
||||||
|
Для уменьшения конфликтов мы рекомендуем:
|
||||||
|
1. Создавайте рабочую ветку на основе ветки `dev`, избегайте работы напрямую в ветке `main`.
|
||||||
|
2. При отправке PR выбирайте ветку `dev` в качестве целевой.
|
||||||
|
3. Регулярно синхронизируйте ветку `dev` с локальной средой, чаще используйте `git pull`.
|
||||||
|
|
||||||
### Среда разработки
|
### Среда разработки
|
||||||
|
|
||||||
AstrBot использует `ruff` для форматирования и линтинга кода.
|
AstrBot использует `ruff` для форматирования и проверки кода.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot
|
git clone https://github.com/AstrBotDevs/AstrBot
|
||||||
pip install pre-commit
|
git switch dev # Переключиться на ветку разработки
|
||||||
|
pip install pre-commit # или uv tool install pre-commit
|
||||||
pre-commit install
|
pre-commit install
|
||||||
```
|
```
|
||||||
|
Рекомендуется использовать `uv` для локальной установки и тестирования:
|
||||||
## 🌍 Сообщество
|
```bash
|
||||||
|
uv tool install -e . --force
|
||||||
|
astrbot init
|
||||||
|
astrbot run
|
||||||
|
```
|
||||||
|
Отладка фронтенда:
|
||||||
|
```bash
|
||||||
|
astrbot run --backend-only
|
||||||
|
cd dashboard
|
||||||
|
bun install # или pnpm и т.д.
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
### Группы QQ
|
### Группы QQ
|
||||||
|
|
||||||
|
- Группа 9: 1076659624 (Новая)
|
||||||
|
- Группа 10: 1078079676 (Новая)
|
||||||
- Группа 1: 322154837
|
- Группа 1: 322154837
|
||||||
- Группа 3: 630166526
|
- Группа 3: 630166526
|
||||||
- Группа 5: 822130018
|
- Группа 5: 822130018
|
||||||
- Группа 6: 753075035
|
- Группа 6: 753075035
|
||||||
- Группа разработчиков: 975206796
|
- Группа 7: 743746109
|
||||||
|
- Группа 8: 1030353265
|
||||||
|
- Группа разработчиков (Неформальное общение): 975206796
|
||||||
|
- Группа разработчиков (Официальная): 1039761811
|
||||||
|
|
||||||
### Группа Telegram
|
### Канал Discord
|
||||||
|
|
||||||
<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](https://discord.gg/hAVk6tgV36)
|
||||||
|
|
||||||
### Сервер 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 ❤️
|
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
Кроме того, рождение этого проекта было бы невозможным без помощи следующих проектов с открытым исходным кодом:
|
||||||
|
|
||||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Замечательный кошачий фреймворк
|
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Великий кошачий фреймворк
|
||||||
|
|
||||||
## ⭐ История звёзд
|
Дружественные ссылки на проекты с открытым исходным кодом:
|
||||||
|
|
||||||
|
- [NoneBot2](https://github.com/nonebot/nonebot2) - Отличный асинхронный фреймворк ChatBot на Python
|
||||||
|
- [Koishi](https://github.com/koishijs/koishi) - Отличный фреймворк ChatBot на Node.js
|
||||||
|
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - Отличный антропоморфный AI ChatBot
|
||||||
|
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - Отличный агентский ChatBot
|
||||||
|
- [LangBot](https://github.com/langbot-app/LangBot) - Отличный мультиплатформенный AI ChatBot
|
||||||
|
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - Отличный плагин мультиплатформенного AI ChatBot для Koishi
|
||||||
|
- [Operit AI](https://github.com/AAswordman/Operit) - Отличное Android-приложение интеллектуального AI-помощника
|
||||||
|
|
||||||
|
## ⭐ История звезд
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Если этот проект помог вам в жизни или работе, или если вас интересует его будущее развитие, пожалуйста, поставьте проекту звезду. Это движущая сила поддержки этого проекта с открытым исходным кодом <3
|
> Если этот проект помог вам в жизни или работе, или если вы заинтересованы в будущем развитии этого проекта, пожалуйста, поставьте проекту звезду (Star). Это наша мотивация поддерживать этот проект с открытым исходным кодом <3
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -242,7 +286,12 @@ pre-commit install
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</details>
|
<div align="center">
|
||||||
|
|
||||||
_私は、高性能ですから!_
|
_Компаньонство и способности никогда не должны быть противоположностями. Мы надеемся создать робота, который сможет одновременно понимать эмоции, быть компаньоном и надежно выполнять работу._
|
||||||
|
|
||||||
|
_私は、高性能ですから!_ (Я высокопроизводительный!)
|
||||||
|
|
||||||
|
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||||
|
|
||||||
|
</div>
|
||||||
+192
-143
@@ -1,10 +1,12 @@
|
|||||||

|

|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<br>
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.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_fr.md">Français</a> |
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||||
|
|
||||||
<div>
|
<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://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>
|
||||||
@@ -14,227 +16,269 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
<div>
|
<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/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?style=for-the-badge&color=76bad9" alt="python">
|
<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?style=for-the-badge&color=76bad9"/></a>
|
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||||
<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://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://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></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=%E5%80%8B&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%A0%B4&cacheSeconds=3600">
|
<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&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%A0%B4&cacheSeconds=3600">
|
||||||
|
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">简体中文</a> |
|
<a href="https://astrbot.app/">首頁</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
<a href="https://astrbot.app/">文檔</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
<a href="https://blog.astrbot.app/">博客</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://astrbot.featurebase.app/roadmap">路線圖</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題提交</a>
|
||||||
|
<a href="mailto:community@astrbot.app">Email</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
|
AstrBot 是一個開源的一站式 Agentic 個人和群聊助手,可在 QQ、Telegram、企業微信、飛書、釘钉、Slack 等數十款主流即時通訊軟件上部署,此外還內置類似 OpenWebUI 的輕量化 ChatUI,為個人、開發者和團隊打造可靠、可擴展的對話式智能基礎設施。無論是個人 AI 夥伴、智能客服、自動化助手,還是企業知識庫,AstrBot 都能在你的即時通訊軟件平台的工作流中快速構建 AI 應用。
|
||||||
|
|
||||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|

|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
1. 💯 免費 & 開源。
|
1. 💯 免費 & 開源。
|
||||||
2. ✨ AI 大型模型對話,多模態,Agent,MCP,知識庫,人格設定。
|
2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。
|
||||||
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體平台。
|
3. 🤖 支持接入 Dify、阿里雲百煉、Coze 等智能體平台。
|
||||||
4. 🌐 多平台:QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
|
4. 🌐 多平台,支持 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||||
5. 📦 外掛擴充,已有近 800 個外掛可一鍵安裝。
|
5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。
|
||||||
6. 💻 WebUI 支援。
|
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。
|
||||||
7. 🌐 國際化(i18n)支援。
|
7. 💻 WebUI 支持。
|
||||||
|
8. 🌈 Web ChatUI 支持,ChatUI 內置代理沙盒、網頁搜索等。
|
||||||
|
9. 🌐 國際化(i18n)支持。
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th>💙 角色扮演 & 情感陪伴</th>
|
||||||
|
<th>✨ 主動式 Agent</th>
|
||||||
|
<th>🚀 通用 Agentic 能力</th>
|
||||||
|
<th>🧩 1000+ 社區插件</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## 快速開始
|
## 快速開始
|
||||||
|
|
||||||
#### Docker 部署(推薦 🥳)
|
### 一鍵部署
|
||||||
|
|
||||||
推薦使用 Docker / Docker Compose 方式部署 AstrBot。
|
對於想快速體驗 AstrBot、且熟悉命令行並能夠自行安裝 `uv` 環境的用戶,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
|
||||||
|
|
||||||
請參閱官方文件 [使用 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
|
```bash
|
||||||
uvx astrbot
|
uv tool install astrbot
|
||||||
|
astrbot init # 僅首次執行此命令以初始化環境
|
||||||
|
astrbot run # astrbot run --backend-only 僅啟動後端服務
|
||||||
|
|
||||||
|
# 安裝開發版本(更多修復,新功能,但不夠穩定,適合開發者)
|
||||||
|
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 寶塔面板部署
|
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||||
|
|
||||||
AstrBot 與寶塔面板合作,已上架至寶塔面板。
|
> [!NOTE]
|
||||||
|
> 對於 macOS 用戶:由於 macOS 安全檢查,首次運行 `astrbot` 命令可能需要較長時間(約 10-20 秒)。
|
||||||
|
|
||||||
請參閱官方文件 [寶塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html)。
|
更新 `astrbot`:
|
||||||
|
|
||||||
#### 1Panel 部署
|
```bash
|
||||||
|
uv tool upgrade astrbot
|
||||||
|
```
|
||||||
|
|
||||||
AstrBot 已由 1Panel 官方上架至 1Panel 面板。
|
### Docker 部署
|
||||||
|
|
||||||
請參閱官方文件 [1Panel 部署](https://astrbot.app/deploy/astrbot/1panel.html)。
|
對於熟悉容器、希望獲得更穩定且更適合生產環境部署方式的用戶,我們推薦使用 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)。
|
||||||
|
|
||||||
AstrBot 已由雨雲官方上架至雲端應用程式平台,可一鍵部署。
|
### 在 雨雲 上部署
|
||||||
|
|
||||||
|
對於希望一鍵部署 AstrBot 且不想自行管理服務器的用戶,我們推薦使用雨雲的一鍵雲部署服務 ☁️:
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
#### 在 Replit 上部署
|
### 桌面客戶端部署
|
||||||
|
|
||||||
社群貢獻的部署方式。
|
對於希望在桌面端使用 AstrBot、並以 ChatUI 為主要入口的用戶,我們推薦使用 AstrBot App。
|
||||||
|
|
||||||
|
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下載並安裝;該方式面向桌面使用,不推薦服務器場景。
|
||||||
|
|
||||||
|
### 啟動器部署
|
||||||
|
|
||||||
|
同樣在桌面端,希望快速部署並實現環境隔離多開的用戶,我們推薦使用 AstrBot Launcher。
|
||||||
|
|
||||||
|
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下載並安裝。
|
||||||
|
|
||||||
|
### 在 Replit 上部署
|
||||||
|
|
||||||
|
Replit 部署由社區維護,適合在線演示和輕量試用場景。
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
#### Windows 一鍵安裝器部署
|
### AUR
|
||||||
|
|
||||||
請參閱官方文件 [使用 Windows 一鍵安裝器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html)。
|
AUR 方式面向 Arch Linux 用戶,適合希望通過系統包管理器安裝 AstrBot 的場景。
|
||||||
|
|
||||||
#### CasaOS 部署
|
在終端執行下方命令安裝 `astrbot-git` 包,安裝完成後即可啟動使用。
|
||||||
|
|
||||||
社群貢獻的部署方式。
|
|
||||||
|
|
||||||
請參閱官方文件 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html)。
|
|
||||||
|
|
||||||
#### 手動部署
|
|
||||||
|
|
||||||
首先安裝 uv:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install uv
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
透過 Git Clone 安裝 AstrBot:
|
**更多部署方式**
|
||||||
|
|
||||||
```bash
|
若你需要面板化或更高自定義部署,可參考 [寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服務器可視化部署)和 [手動部署](https://astrbot.app/deploy/astrbot/cli.html)(基於源碼與 `uv` 的完整自定義安裝)。
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
|
||||||
uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
或者請參閱官方文件 [透過原始碼部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html)。
|
## 支持的消息平台
|
||||||
|
|
||||||
## 支援的訊息平台
|
將 AstrBot 連接到你常用的聊天平台。
|
||||||
|
|
||||||
**官方維護**
|
| 平台 | 維護方 |
|
||||||
|
|---------|---------------|
|
||||||
|
| **QQ** | 官方維護 |
|
||||||
|
| **OneBot v11** | 官方維護 |
|
||||||
|
| **Telegram** | 官方維護 |
|
||||||
|
| **企微應用 & 企微智能機器人** | 官方維護 |
|
||||||
|
| **微信客服 & 微信公眾號** | 官方維護 |
|
||||||
|
| **飛書** | 官方維護 |
|
||||||
|
| **釘釘** | 官方維護 |
|
||||||
|
| **Slack** | 官方維護 |
|
||||||
|
| **Discord** | 官方維護 |
|
||||||
|
| **LINE** | 官方維護 |
|
||||||
|
| **Satori** | 官方維護 |
|
||||||
|
| **Misskey** | 官方維護 |
|
||||||
|
| **Whatsapp (將支持)** | 官方維護 |
|
||||||
|
| [**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) | 社區維護 |
|
||||||
|
|
||||||
- QQ(官方平台 & OneBot)
|
## 支持的模型提供商
|
||||||
- Telegram
|
|
||||||
- 企微應用 & 企微智慧機器人
|
|
||||||
- 微信客服 & 微信公眾號
|
|
||||||
- 飛書
|
|
||||||
- 釘釘
|
|
||||||
- Slack
|
|
||||||
- Discord
|
|
||||||
- Satori
|
|
||||||
- Misskey
|
|
||||||
- Whatsapp(即將支援)
|
|
||||||
- LINE(即將支援)
|
|
||||||
|
|
||||||
**社群維護**
|
| 提供商 | 類型 |
|
||||||
|
|---------|---------------|
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
| 自定義 | 任何 OpenAI API 兼容的服務 |
|
||||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
| OpenAI | LLM |
|
||||||
- [Bilibili 私訊](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
|
| Anthropic | LLM |
|
||||||
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
|
| Google Gemini | LLM |
|
||||||
|
| Moonshot AI | LLM |
|
||||||
## 支援的模型服務
|
| 智譜 AI | LLM |
|
||||||
|
| DeepSeek | LLM |
|
||||||
**大型模型服務**
|
| Ollama (本地部署) | LLM |
|
||||||
|
| LM Studio (本地部署) | LLM |
|
||||||
- OpenAI 及相容服務
|
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (API 網關, 支持所有模型) |
|
||||||
- Anthropic
|
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (API 網關, 支持所有模型) |
|
||||||
- Google Gemini
|
| [硅基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (API 網關, 支持所有模型) |
|
||||||
- Moonshot AI
|
| [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE) | LLM (API 網關, 支持所有模型) |
|
||||||
- 智譜 AI
|
| [302.AI](https://share.302.ai/rr1M3l) | LLM (API 網關, 支持所有模型)|
|
||||||
- DeepSeek
|
| [小馬算力](https://www.tokenpony.cn/3YPyf) | LLM (API 網關, 支持所有模型)|
|
||||||
- Ollama(本機部署)
|
| ModelScope | LLM |
|
||||||
- LM Studio(本機部署)
|
| OneAPI | LLM |
|
||||||
- [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
| Dify | LLMOps 平台 |
|
||||||
- [302.AI](https://share.302.ai/rr1M3l)
|
| 阿里雲百煉應用 | LLMOps 平台 |
|
||||||
- [小馬算力](https://www.tokenpony.cn/3YPyf)
|
| Coze | LLMOps 平台 |
|
||||||
- [矽基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
| OpenAI Whisper | 語音轉文本 |
|
||||||
- [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE)
|
| SenseVoice | 語音轉文本 |
|
||||||
- ModelScope
|
| OpenAI TTS | 文本轉語音 |
|
||||||
- OneAPI
|
| Gemini TTS | 文本轉語音 |
|
||||||
|
| GPT-Sovits-Inference | 文本轉語音 |
|
||||||
**LLMOps 平台**
|
| GPT-Sovits | 文本轉語音 |
|
||||||
|
| FishAudio | 文本轉語音 |
|
||||||
- Dify
|
| Edge TTS | 文本轉語音 |
|
||||||
- 阿里雲百煉應用
|
| 阿里雲百煉 TTS | 文本轉語音 |
|
||||||
- Coze
|
| Azure TTS | 文本轉語音 |
|
||||||
|
| Minimax TTS | 文本轉語音 |
|
||||||
**語音轉文字服務**
|
| 火山引擎 TTS | 文本轉語音 |
|
||||||
|
|
||||||
- OpenAI Whisper
|
|
||||||
- SenseVoice
|
|
||||||
|
|
||||||
**文字轉語音服務**
|
|
||||||
|
|
||||||
- OpenAI TTS
|
|
||||||
- Gemini TTS
|
|
||||||
- GPT-Sovits-Inference
|
|
||||||
- GPT-Sovits
|
|
||||||
- FishAudio
|
|
||||||
- Edge TTS
|
|
||||||
- 阿里雲百煉 TTS
|
|
||||||
- Azure TTS
|
|
||||||
- Minimax TTS
|
|
||||||
- 火山引擎 TTS
|
|
||||||
|
|
||||||
## ❤️ 貢獻
|
## ❤️ 貢獻
|
||||||
|
|
||||||
歡迎任何 Issues/Pull Requests!只需要將您的變更提交到此專案 :)
|
歡迎任何 Issues/Pull Requests!只需要將你的更改提交到此項目 :)
|
||||||
|
|
||||||
### 如何貢獻
|
### 如何貢獻
|
||||||
|
|
||||||
您可以透過檢視問題或協助審核 PR(拉取請求)來貢獻。任何問題或 PR 都歡迎參與,以促進社群貢獻。當然,這些只是建議,您可以以任何方式進行貢獻。對於新功能的新增,請先透過 Issue 討論。
|
你可以通過查看問題或幫助審核 PR(拉取請求)來貢獻。任何問題或 PR 都歡迎參與,以促進社區貢獻。當然,這些只是建議,你可以以任何方式進行貢獻。對於新功能的添加,請先通過 Issue 討論。
|
||||||
|
建議將功能性PR合併至dev分支,將在測試修改後合併到主分支並發布新版本。
|
||||||
|
為了減少衝突,建議如下:
|
||||||
|
1. 工作分支最好基於 `dev` 分支創建,避免直接在 `main` 分支上工作。
|
||||||
|
2. 提交 PR 時,選擇 `dev` 分支作為目標分支。
|
||||||
|
3. 定期同步 `dev` 分支到本地,多使用git pull。
|
||||||
|
|
||||||
### 開發環境
|
### 開發環境
|
||||||
|
|
||||||
AstrBot 使用 `ruff` 進行程式碼格式化和檢查。
|
AstrBot 使用 `ruff` 進行代碼格式化和檢查。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot
|
git clone https://github.com/AstrBotDevs/AstrBot
|
||||||
pip install pre-commit
|
git switch dev # 切換到開發分支
|
||||||
|
pip install pre-commit # 或者uv tool install pre-commit
|
||||||
pre-commit install
|
pre-commit install
|
||||||
```
|
```
|
||||||
|
推薦使用uv本地安裝,進行測試
|
||||||
## 🌍 社群
|
```bash
|
||||||
|
uv tool install -e . --force
|
||||||
|
astrbot init
|
||||||
|
astrbot run
|
||||||
|
```
|
||||||
|
調試前端
|
||||||
|
```bash
|
||||||
|
astrbot run --backend-only
|
||||||
|
cd dashboard
|
||||||
|
bun install # 或者pnpm 等
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
### QQ 群組
|
### QQ 群組
|
||||||
|
|
||||||
|
- 9 群: 1076659624 (新)
|
||||||
|
- 10 群: 1078079676 (新)
|
||||||
- 1 群:322154837
|
- 1 群:322154837
|
||||||
- 3 群:630166526
|
- 3 群:630166526
|
||||||
- 5 群:822130018
|
- 5 群:822130018
|
||||||
- 6 群:753075035
|
- 6 群:753075035
|
||||||
- 開發者群:975206796
|
- 7 群:743746109
|
||||||
|
- 8 群:1030353265
|
||||||
|
- 開發者群(偏閒聊吹水):975206796
|
||||||
|
- 開發者群(正式):1039761811
|
||||||
|
|
||||||
### Telegram 群組
|
### Discord 頻道
|
||||||
|
|
||||||
<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](https://discord.gg/hAVk6tgV36)
|
||||||
|
|
||||||
### 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
|
## ❤️ Special Thanks
|
||||||
|
|
||||||
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
特別感謝所有 Contributors 和插件開發者對 AstrBot 的貢獻 ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
此外,本項目的誕生離不開以下開源項目的幫助:
|
||||||
|
|
||||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 偉大的貓貓框架
|
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 偉大的貓貓框架
|
||||||
|
|
||||||
|
開源項目友情鏈接:
|
||||||
|
|
||||||
|
- [NoneBot2](https://github.com/nonebot/nonebot2) - 優秀的 Python 異步 ChatBot 框架
|
||||||
|
- [Koishi](https://github.com/koishijs/koishi) - 優秀的 Node.js ChatBot 框架
|
||||||
|
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 優秀的擬人化 AI ChatBot
|
||||||
|
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 優秀的 Agent ChatBot
|
||||||
|
- [LangBot](https://github.com/langbot-app/LangBot) - 優秀的多平台 AI ChatBot
|
||||||
|
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 優秀的多平台 AI ChatBot Koishi 插件
|
||||||
|
- [Operit AI](https://github.com/AAswordman/Operit) - 優秀的 AI 智能助手 Android APP
|
||||||
|
|
||||||
## ⭐ Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果本專案對您的生活 / 工作產生了幫助,或者您關注本專案的未來發展,請給專案 Star,這是我們維護這個開源專案的動力 <3
|
> 如果本項目對您的生活 / 工作產生了幫助,或者您關注本項目的未來發展,請給項目 Star,這是我們維護這個開源項目的動力 <3
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -242,7 +286,12 @@ pre-commit install
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</details>
|
<div align="center">
|
||||||
|
|
||||||
|
_陪伴與能力從來不應該是對立面。我們希望創造的是一個既能理解情緒、給予陪伴,也能可靠完成工作的機器人。_
|
||||||
|
|
||||||
_私は、高性能ですから!_
|
_私は、高性能ですから!_
|
||||||
|
|
||||||
|
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||||
|
|
||||||
|
</div>
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|

|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.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_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>
|
||||||
|
|
||||||
|
<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?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://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>
|
||||||
|
<a href="mailto:community@astrbot.app">Email</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
1. 💯 免费 & 开源。
|
||||||
|
2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
||||||
|
3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||||
|
4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||||
|
5. 📦 插件扩展,已有 1000+ 个插件可一键安装。
|
||||||
|
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
|
||||||
|
7. 💻 WebUI 支持。
|
||||||
|
8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
|
||||||
|
9. 🌐 国际化(i18n)支持。
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th>💙 角色扮演 & 情感陪伴</th>
|
||||||
|
<th>✨ 主动式 Agent</th>
|
||||||
|
<th>🚀 通用 Agentic 能力</th>
|
||||||
|
<th>🧩 1000+ 社区插件</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 一键部署
|
||||||
|
|
||||||
|
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install astrbot
|
||||||
|
astrbot init # 仅首次执行此命令以初始化环境
|
||||||
|
astrbot run # astrbot run --backend-only 仅启动后端服务
|
||||||
|
|
||||||
|
# 安装开发版本(更多修复,新功能,但不够稳定,适合开发者)
|
||||||
|
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
|
||||||
|
```
|
||||||
|
|
||||||
|
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
|
||||||
|
|
||||||
|
更新 `astrbot`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool upgrade 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)。
|
||||||
|
|
||||||
|
### 在 雨云 上部署
|
||||||
|
|
||||||
|
对于希望一键部署 AstrBot 且不想自行管理服务器的用户,我们推荐使用雨云的一键云部署服务 ☁️:
|
||||||
|
|
||||||
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
|
### 桌面客户端部署
|
||||||
|
|
||||||
|
对于希望在桌面端使用 AstrBot、并以 ChatUI 为主要入口的用户,我们推荐使用 AstrBot App。
|
||||||
|
|
||||||
|
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下载并安装;该方式面向桌面使用,不推荐服务器场景。
|
||||||
|
|
||||||
|
### 启动器部署
|
||||||
|
|
||||||
|
同样在桌面端,希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher。
|
||||||
|
|
||||||
|
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下载并安装。
|
||||||
|
|
||||||
|
### 在 Replit 上部署
|
||||||
|
|
||||||
|
Replit 部署由社区维护,适合在线演示和轻量试用场景。
|
||||||
|
|
||||||
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
|
### AUR
|
||||||
|
|
||||||
|
AUR 方式面向 Arch Linux 用户,适合希望通过系统包管理器安装 AstrBot 的场景。
|
||||||
|
|
||||||
|
在终端执行下方命令安装 `astrbot-git` 包,安装完成后即可启动使用。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S astrbot-git
|
||||||
|
```
|
||||||
|
|
||||||
|
**更多部署方式**
|
||||||
|
|
||||||
|
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
|
||||||
|
|
||||||
|
## 支持的消息平台
|
||||||
|
|
||||||
|
将 AstrBot 连接到你常用的聊天平台。
|
||||||
|
|
||||||
|
| 平台 | 维护方 |
|
||||||
|
|---------|---------------|
|
||||||
|
| **QQ** | 官方维护 |
|
||||||
|
| **OneBot v11** | 官方维护 |
|
||||||
|
| **Telegram** | 官方维护 |
|
||||||
|
| **企微应用 & 企微智能机器人** | 官方维护 |
|
||||||
|
| **微信客服 & 微信公众号** | 官方维护 |
|
||||||
|
| **飞书** | 官方维护 |
|
||||||
|
| **钉钉** | 官方维护 |
|
||||||
|
| **Slack** | 官方维护 |
|
||||||
|
| **Discord** | 官方维护 |
|
||||||
|
| **LINE** | 官方维护 |
|
||||||
|
| **Satori** | 官方维护 |
|
||||||
|
| **Misskey** | 官方维护 |
|
||||||
|
| **Whatsapp (将支持)** | 官方维护 |
|
||||||
|
| [**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) | 社区维护 |
|
||||||
|
|
||||||
|
## 支持的模型提供商
|
||||||
|
|
||||||
|
| 提供商 | 类型 |
|
||||||
|
|---------|---------------|
|
||||||
|
| 自定义 | 任何 OpenAI API 兼容的服务 |
|
||||||
|
| OpenAI | LLM |
|
||||||
|
| Anthropic | LLM |
|
||||||
|
| Google Gemini | LLM |
|
||||||
|
| Moonshot AI | LLM |
|
||||||
|
| 智谱 AI | LLM |
|
||||||
|
| DeepSeek | LLM |
|
||||||
|
| Ollama (本地部署) | LLM |
|
||||||
|
| LM Studio (本地部署) | LLM |
|
||||||
|
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (API 网关, 支持所有模型) |
|
||||||
|
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (API 网关, 支持所有模型) |
|
||||||
|
| [硅基流动](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (API 网关, 支持所有模型) |
|
||||||
|
| [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE) | LLM (API 网关, 支持所有模型) |
|
||||||
|
| [302.AI](https://share.302.ai/rr1M3l) | LLM (API 网关, 支持所有模型)|
|
||||||
|
| [小马算力](https://www.tokenpony.cn/3YPyf) | LLM (API 网关, 支持所有模型)|
|
||||||
|
| ModelScope | LLM |
|
||||||
|
| OneAPI | LLM |
|
||||||
|
| Dify | LLMOps 平台 |
|
||||||
|
| 阿里云百炼应用 | LLMOps 平台 |
|
||||||
|
| Coze | LLMOps 平台 |
|
||||||
|
| 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 讨论。
|
||||||
|
建议将功能性PR合并至dev分支,将在测试修改后合并到主分支并发布新版本。
|
||||||
|
为了减少冲突,建议如下:
|
||||||
|
1. 工作分支最好基于 `dev` 分支创建,避免直接在 `main` 分支上工作。
|
||||||
|
2. 提交 PR 时,选择 `dev` 分支作为目标分支。
|
||||||
|
3. 定期同步 `dev` 分支到本地,多使用git pull。
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
|
||||||
|
AstrBot 使用 `ruff` 进行代码格式化和检查。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/AstrBotDevs/AstrBot
|
||||||
|
git switch dev # 切换到开发分支
|
||||||
|
pip install pre-commit # 或者uv tool install pre-commit
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
推荐使用uv本地安装,进行测试
|
||||||
|
```bash
|
||||||
|
uv tool install -e . --force
|
||||||
|
astrbot init
|
||||||
|
astrbot run
|
||||||
|
```
|
||||||
|
调试前端
|
||||||
|
```bash
|
||||||
|
astrbot run --backend-only
|
||||||
|
cd dashboard
|
||||||
|
bun install # 或者pnpm 等
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### QQ 群组
|
||||||
|
|
||||||
|
- 9 群: 1076659624 (新)
|
||||||
|
- 10 群: 1078079676 (新)
|
||||||
|
- 1 群:322154837
|
||||||
|
- 3 群:630166526
|
||||||
|
- 5 群:822130018
|
||||||
|
- 6 群:753075035
|
||||||
|
- 7 群:743746109
|
||||||
|
- 8 群:1030353265
|
||||||
|
- 开发者群(偏闲聊吹水):975206796
|
||||||
|
- 开发者群(正式):1039761811
|
||||||
|
|
||||||
|
### Discord 频道
|
||||||
|
|
||||||
|
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||||
|
|
||||||
|
## ❤️ Special Thanks
|
||||||
|
|
||||||
|
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||||
|
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
此外,本项目的诞生离不开以下开源项目的帮助:
|
||||||
|
|
||||||
|
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
||||||
|
|
||||||
|
开源项目友情链接:
|
||||||
|
|
||||||
|
- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架
|
||||||
|
- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架
|
||||||
|
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot
|
||||||
|
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot
|
||||||
|
- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot
|
||||||
|
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件
|
||||||
|
- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP
|
||||||
|
|
||||||
|
## ⭐ Star History
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我们维护这个开源项目的动力 <3
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
|
||||||
|
|
||||||
|
_私は、高性能ですから!_
|
||||||
|
|
||||||
|
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -20,7 +20,17 @@ from astrbot.core.star.register import (
|
|||||||
)
|
)
|
||||||
from astrbot.core.star.register import register_on_llm_request as on_llm_request
|
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_llm_response as on_llm_response
|
||||||
|
from astrbot.core.star.register import (
|
||||||
|
register_on_llm_tool_respond as on_llm_tool_respond,
|
||||||
|
)
|
||||||
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
||||||
|
from astrbot.core.star.register import register_on_plugin_error as on_plugin_error
|
||||||
|
from astrbot.core.star.register import register_on_plugin_loaded as on_plugin_loaded
|
||||||
|
from astrbot.core.star.register import register_on_plugin_unloaded as on_plugin_unloaded
|
||||||
|
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
|
||||||
|
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_permission_type as permission_type
|
||||||
from astrbot.core.star.register import (
|
from astrbot.core.star.register import (
|
||||||
register_platform_adapter_type as platform_adapter_type,
|
register_platform_adapter_type as platform_adapter_type,
|
||||||
@@ -45,8 +55,14 @@ __all__ = [
|
|||||||
"on_decorating_result",
|
"on_decorating_result",
|
||||||
"on_llm_request",
|
"on_llm_request",
|
||||||
"on_llm_response",
|
"on_llm_response",
|
||||||
|
"on_plugin_error",
|
||||||
|
"on_plugin_loaded",
|
||||||
|
"on_plugin_unloaded",
|
||||||
"on_platform_loaded",
|
"on_platform_loaded",
|
||||||
|
"on_waiting_llm_request",
|
||||||
"permission_type",
|
"permission_type",
|
||||||
"platform_adapter_type",
|
"platform_adapter_type",
|
||||||
"regex",
|
"regex",
|
||||||
|
"on_using_llm_tool",
|
||||||
|
"on_llm_tool_respond",
|
||||||
]
|
]
|
||||||
|
|||||||
+6
-4
@@ -17,7 +17,7 @@ from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
|||||||
|
|
||||||
|
|
||||||
class LongTermMemory:
|
class LongTermMemory:
|
||||||
def __init__(self, acm: AstrBotConfigManager, context: star.Context):
|
def __init__(self, acm: AstrBotConfigManager, context: star.Context) -> None:
|
||||||
self.acm = acm
|
self.acm = acm
|
||||||
self.context = context
|
self.context = context
|
||||||
self.session_chats = defaultdict(list)
|
self.session_chats = defaultdict(list)
|
||||||
@@ -111,7 +111,7 @@ class LongTermMemory:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def handle_message(self, event: AstrMessageEvent):
|
async def handle_message(self, event: AstrMessageEvent) -> None:
|
||||||
"""仅支持群聊"""
|
"""仅支持群聊"""
|
||||||
if event.get_message_type() == MessageType.GROUP_MESSAGE:
|
if event.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||||
datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
|
datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
|
||||||
@@ -148,7 +148,7 @@ class LongTermMemory:
|
|||||||
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
|
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
|
||||||
self.session_chats[event.unified_msg_origin].pop(0)
|
self.session_chats[event.unified_msg_origin].pop(0)
|
||||||
|
|
||||||
async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest):
|
async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||||
"""当触发 LLM 请求前,调用此方法修改 req"""
|
"""当触发 LLM 请求前,调用此方法修改 req"""
|
||||||
if event.unified_msg_origin not in self.session_chats:
|
if event.unified_msg_origin not in self.session_chats:
|
||||||
return
|
return
|
||||||
@@ -171,7 +171,9 @@ class LongTermMemory:
|
|||||||
)
|
)
|
||||||
req.system_prompt += chats_str
|
req.system_prompt += chats_str
|
||||||
|
|
||||||
async def after_req_llm(self, event: AstrMessageEvent, llm_resp: LLMResponse):
|
async def after_req_llm(
|
||||||
|
self, event: AstrMessageEvent, llm_resp: LLMResponse
|
||||||
|
) -> None:
|
||||||
if event.unified_msg_origin not in self.session_chats:
|
if event.unified_msg_origin not in self.session_chats:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ from astrbot.api.provider import LLMResponse, ProviderRequest
|
|||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
|
|
||||||
from .long_term_memory import LongTermMemory
|
from .long_term_memory import LongTermMemory
|
||||||
from .process_llm_request import ProcessLLMRequest
|
|
||||||
|
|
||||||
|
|
||||||
class Main(star.Star):
|
class Main(star.Star):
|
||||||
@@ -19,8 +18,6 @@ class Main(star.Star):
|
|||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.error(f"聊天增强 err: {e}")
|
logger.error(f"聊天增强 err: {e}")
|
||||||
|
|
||||||
self.proc_llm_req = ProcessLLMRequest(self.context)
|
|
||||||
|
|
||||||
def ltm_enabled(self, event: AstrMessageEvent):
|
def ltm_enabled(self, event: AstrMessageEvent):
|
||||||
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
|
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
|
||||||
"provider_ltm_settings"
|
"provider_ltm_settings"
|
||||||
@@ -80,7 +77,6 @@ class Main(star.Star):
|
|||||||
|
|
||||||
yield event.request_llm(
|
yield event.request_llm(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
func_tool_manager=self.context.get_llm_tool_manager(),
|
|
||||||
session_id=event.session_id,
|
session_id=event.session_id,
|
||||||
conversation=conv,
|
conversation=conv,
|
||||||
)
|
)
|
||||||
@@ -89,10 +85,10 @@ class Main(star.Star):
|
|||||||
logger.error(f"主动回复失败: {e}")
|
logger.error(f"主动回复失败: {e}")
|
||||||
|
|
||||||
@filter.on_llm_request()
|
@filter.on_llm_request()
|
||||||
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
|
async def decorate_llm_req(
|
||||||
|
self, event: AstrMessageEvent, req: ProviderRequest
|
||||||
|
) -> None:
|
||||||
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
|
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
|
||||||
await self.proc_llm_req.process_llm_request(event, req)
|
|
||||||
|
|
||||||
if self.ltm and self.ltm_enabled(event):
|
if self.ltm and self.ltm_enabled(event):
|
||||||
try:
|
try:
|
||||||
await self.ltm.on_req_llm(event, req)
|
await self.ltm.on_req_llm(event, req)
|
||||||
@@ -100,16 +96,10 @@ class Main(star.Star):
|
|||||||
logger.error(f"ltm: {e}")
|
logger.error(f"ltm: {e}")
|
||||||
|
|
||||||
@filter.on_llm_response()
|
@filter.on_llm_response()
|
||||||
async def inject_reasoning(self, event: AstrMessageEvent, resp: LLMResponse):
|
async def record_llm_resp_to_ltm(
|
||||||
"""在 LLM 响应后基于配置注入思考过程文本 / 在 LLM 响应后记录对话"""
|
self, event: AstrMessageEvent, resp: LLMResponse
|
||||||
umo = event.unified_msg_origin
|
) -> None:
|
||||||
cfg = self.context.get_config(umo).get("provider_settings", {})
|
"""在 LLM 响应后记录对话"""
|
||||||
show_reasoning = cfg.get("display_reasoning_text", False)
|
|
||||||
if show_reasoning and resp.reasoning_content:
|
|
||||||
resp.completion_text = (
|
|
||||||
f"🤔 思考: {resp.reasoning_content}\n\n{resp.completion_text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.ltm and self.ltm_enabled(event):
|
if self.ltm and self.ltm_enabled(event):
|
||||||
try:
|
try:
|
||||||
await self.ltm.after_req_llm(event, resp)
|
await self.ltm.after_req_llm(event, resp)
|
||||||
@@ -117,7 +107,7 @@ class Main(star.Star):
|
|||||||
logger.error(f"ltm: {e}")
|
logger.error(f"ltm: {e}")
|
||||||
|
|
||||||
@filter.after_message_sent()
|
@filter.after_message_sent()
|
||||||
async def after_message_sent(self, event: AstrMessageEvent):
|
async def after_message_sent(self, event: AstrMessageEvent) -> None:
|
||||||
"""消息发送后处理"""
|
"""消息发送后处理"""
|
||||||
if self.ltm and self.ltm_enabled(event):
|
if self.ltm and self.ltm_enabled(event):
|
||||||
try:
|
try:
|
||||||
-2
@@ -11,7 +11,6 @@ from .provider import ProviderCommands
|
|||||||
from .setunset import SetUnsetCommands
|
from .setunset import SetUnsetCommands
|
||||||
from .sid import SIDCommand
|
from .sid import SIDCommand
|
||||||
from .t2i import T2ICommand
|
from .t2i import T2ICommand
|
||||||
from .tool import ToolCommands
|
|
||||||
from .tts import TTSCommand
|
from .tts import TTSCommand
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -27,5 +26,4 @@ __all__ = [
|
|||||||
"SetUnsetCommands",
|
"SetUnsetCommands",
|
||||||
"T2ICommand",
|
"T2ICommand",
|
||||||
"TTSCommand",
|
"TTSCommand",
|
||||||
"ToolCommands",
|
|
||||||
]
|
]
|
||||||
+7
-6
@@ -5,10 +5,10 @@ from astrbot.core.utils.io import download_dashboard
|
|||||||
|
|
||||||
|
|
||||||
class AdminCommands:
|
class AdminCommands:
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def op(self, event: AstrMessageEvent, admin_id: str = ""):
|
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
|
||||||
"""授权管理员。op <admin_id>"""
|
"""授权管理员。op <admin_id>"""
|
||||||
if not admin_id:
|
if not admin_id:
|
||||||
event.set_result(
|
event.set_result(
|
||||||
@@ -21,7 +21,7 @@ class AdminCommands:
|
|||||||
self.context.get_config().save_config()
|
self.context.get_config().save_config()
|
||||||
event.set_result(MessageEventResult().message("授权成功。"))
|
event.set_result(MessageEventResult().message("授权成功。"))
|
||||||
|
|
||||||
async def deop(self, event: AstrMessageEvent, admin_id: str = ""):
|
async def deop(self, event: AstrMessageEvent, admin_id: str = "") -> None:
|
||||||
"""取消授权管理员。deop <admin_id>"""
|
"""取消授权管理员。deop <admin_id>"""
|
||||||
if not admin_id:
|
if not admin_id:
|
||||||
event.set_result(
|
event.set_result(
|
||||||
@@ -39,7 +39,7 @@ class AdminCommands:
|
|||||||
MessageEventResult().message("此用户 ID 不在管理员名单内。"),
|
MessageEventResult().message("此用户 ID 不在管理员名单内。"),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def wl(self, event: AstrMessageEvent, sid: str = ""):
|
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
|
||||||
"""添加白名单。wl <sid>"""
|
"""添加白名单。wl <sid>"""
|
||||||
if not sid:
|
if not sid:
|
||||||
event.set_result(
|
event.set_result(
|
||||||
@@ -53,7 +53,7 @@ class AdminCommands:
|
|||||||
cfg.save_config()
|
cfg.save_config()
|
||||||
event.set_result(MessageEventResult().message("添加白名单成功。"))
|
event.set_result(MessageEventResult().message("添加白名单成功。"))
|
||||||
|
|
||||||
async def dwl(self, event: AstrMessageEvent, sid: str = ""):
|
async def dwl(self, event: AstrMessageEvent, sid: str = "") -> None:
|
||||||
"""删除白名单。dwl <sid>"""
|
"""删除白名单。dwl <sid>"""
|
||||||
if not sid:
|
if not sid:
|
||||||
event.set_result(
|
event.set_result(
|
||||||
@@ -70,7 +70,8 @@ class AdminCommands:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
|
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
|
||||||
|
|
||||||
async def update_dashboard(self, event: AstrMessageEvent):
|
async def update_dashboard(self, event: AstrMessageEvent) -> None:
|
||||||
|
"""更新管理面板"""
|
||||||
await event.send(MessageChain().message("正在尝试更新管理面板..."))
|
await event.send(MessageChain().message("正在尝试更新管理面板..."))
|
||||||
await download_dashboard(version=f"v{VERSION}", latest=False)
|
await download_dashboard(version=f"v{VERSION}", latest=False)
|
||||||
await event.send(MessageChain().message("管理面板更新完成。"))
|
await event.send(MessageChain().message("管理面板更新完成。"))
|
||||||
+3
-3
@@ -11,10 +11,10 @@ from .utils.rst_scene import RstScene
|
|||||||
|
|
||||||
|
|
||||||
class AlterCmdCommands(CommandParserMixin):
|
class AlterCmdCommands(CommandParserMixin):
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def update_reset_permission(self, scene_key: str, perm_type: str):
|
async def update_reset_permission(self, scene_key: str, perm_type: str) -> None:
|
||||||
"""更新reset命令在特定场景下的权限设置"""
|
"""更新reset命令在特定场景下的权限设置"""
|
||||||
from astrbot.api import sp
|
from astrbot.api import sp
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class AlterCmdCommands(CommandParserMixin):
|
|||||||
alter_cmd_cfg["astrbot"] = plugin_cfg
|
alter_cmd_cfg["astrbot"] = plugin_cfg
|
||||||
await sp.global_put("alter_cmd", alter_cmd_cfg)
|
await sp.global_put("alter_cmd", alter_cmd_cfg)
|
||||||
|
|
||||||
async def alter_cmd(self, event: AstrMessageEvent):
|
async def alter_cmd(self, event: AstrMessageEvent) -> None:
|
||||||
token = self.parse_commands(event.message_str)
|
token = self.parse_commands(event.message_str)
|
||||||
if token.len < 3:
|
if token.len < 3:
|
||||||
await event.send(
|
await event.send(
|
||||||
+76
-22
@@ -2,8 +2,13 @@ import datetime
|
|||||||
|
|
||||||
from astrbot.api import sp, star
|
from astrbot.api import sp, star
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||||
|
from astrbot.core.agent.runners.deerflow.constants import (
|
||||||
|
DEERFLOW_PROVIDER_TYPE,
|
||||||
|
DEERFLOW_THREAD_ID_KEY,
|
||||||
|
)
|
||||||
from astrbot.core.platform.astr_message_event import MessageSession
|
from astrbot.core.platform.astr_message_event import MessageSession
|
||||||
from astrbot.core.platform.message_type import MessageType
|
from astrbot.core.platform.message_type import MessageType
|
||||||
|
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||||
|
|
||||||
from .utils.rst_scene import RstScene
|
from .utils.rst_scene import RstScene
|
||||||
|
|
||||||
@@ -11,12 +16,13 @@ THIRD_PARTY_AGENT_RUNNER_KEY = {
|
|||||||
"dify": "dify_conversation_id",
|
"dify": "dify_conversation_id",
|
||||||
"coze": "coze_conversation_id",
|
"coze": "coze_conversation_id",
|
||||||
"dashscope": "dashscope_conversation_id",
|
"dashscope": "dashscope_conversation_id",
|
||||||
|
DEERFLOW_PROVIDER_TYPE: DEERFLOW_THREAD_ID_KEY,
|
||||||
}
|
}
|
||||||
THIRD_PARTY_AGENT_RUNNER_STR = ", ".join(THIRD_PARTY_AGENT_RUNNER_KEY.keys())
|
THIRD_PARTY_AGENT_RUNNER_STR = ", ".join(THIRD_PARTY_AGENT_RUNNER_KEY.keys())
|
||||||
|
|
||||||
|
|
||||||
class ConversationCommands:
|
class ConversationCommands:
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def _get_current_persona_id(self, session_id):
|
async def _get_current_persona_id(self, session_id):
|
||||||
@@ -33,7 +39,7 @@ class ConversationCommands:
|
|||||||
return None
|
return None
|
||||||
return conv.persona_id
|
return conv.persona_id
|
||||||
|
|
||||||
async def reset(self, message: AstrMessageEvent):
|
async def reset(self, message: AstrMessageEvent) -> None:
|
||||||
"""重置 LLM 会话"""
|
"""重置 LLM 会话"""
|
||||||
umo = message.unified_msg_origin
|
umo = message.unified_msg_origin
|
||||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||||
@@ -62,6 +68,7 @@ class ConversationCommands:
|
|||||||
|
|
||||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||||
|
active_event_registry.stop_all(umo, exclude=message)
|
||||||
await sp.remove_async(
|
await sp.remove_async(
|
||||||
scope="umo",
|
scope="umo",
|
||||||
scope_id=umo,
|
scope_id=umo,
|
||||||
@@ -86,6 +93,8 @@ class ConversationCommands:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
active_event_registry.stop_all(umo, exclude=message)
|
||||||
|
|
||||||
await self.context.conversation_manager.update_conversation(
|
await self.context.conversation_manager.update_conversation(
|
||||||
umo,
|
umo,
|
||||||
cid,
|
cid,
|
||||||
@@ -98,7 +107,31 @@ class ConversationCommands:
|
|||||||
|
|
||||||
message.set_result(MessageEventResult().message(ret))
|
message.set_result(MessageEventResult().message(ret))
|
||||||
|
|
||||||
async def his(self, message: AstrMessageEvent, page: int = 1):
|
async def stop(self, message: AstrMessageEvent) -> None:
|
||||||
|
"""停止当前会话正在运行的 Agent"""
|
||||||
|
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||||
|
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||||
|
umo = message.unified_msg_origin
|
||||||
|
|
||||||
|
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||||
|
stopped_count = active_event_registry.stop_all(umo, exclude=message)
|
||||||
|
else:
|
||||||
|
stopped_count = active_event_registry.request_agent_stop_all(
|
||||||
|
umo,
|
||||||
|
exclude=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stopped_count > 0:
|
||||||
|
message.set_result(
|
||||||
|
MessageEventResult().message(
|
||||||
|
f"已请求停止 {stopped_count} 个运行中的任务。"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
|
||||||
|
|
||||||
|
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||||
"""查看对话记录"""
|
"""查看对话记录"""
|
||||||
if not self.context.get_using_provider(message.unified_msg_origin):
|
if not self.context.get_using_provider(message.unified_msg_origin):
|
||||||
message.set_result(
|
message.set_result(
|
||||||
@@ -141,7 +174,7 @@ class ConversationCommands:
|
|||||||
|
|
||||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||||
|
|
||||||
async def convs(self, message: AstrMessageEvent, page: int = 1):
|
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||||
"""查看对话列表"""
|
"""查看对话列表"""
|
||||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||||
@@ -178,16 +211,33 @@ class ConversationCommands:
|
|||||||
_titles[conv.cid] = title
|
_titles[conv.cid] = title
|
||||||
|
|
||||||
"""遍历分页后的对话生成列表显示"""
|
"""遍历分页后的对话生成列表显示"""
|
||||||
|
provider_settings = cfg.get("provider_settings", {})
|
||||||
|
platform_name = message.get_platform_name()
|
||||||
for conv in conversations_paged:
|
for conv in conversations_paged:
|
||||||
persona_id = conv.persona_id
|
(
|
||||||
if not persona_id or persona_id == "[%None]":
|
persona_id,
|
||||||
persona = await self.context.persona_manager.get_default_persona_v3(
|
_,
|
||||||
umo=message.unified_msg_origin,
|
force_applied_persona_id,
|
||||||
)
|
_,
|
||||||
persona_id = persona["name"]
|
) = await self.context.persona_manager.resolve_selected_persona(
|
||||||
|
umo=message.unified_msg_origin,
|
||||||
|
conversation_persona_id=conv.persona_id,
|
||||||
|
platform_name=platform_name,
|
||||||
|
provider_settings=provider_settings,
|
||||||
|
)
|
||||||
|
if persona_id == "[%None]":
|
||||||
|
persona_name = "无"
|
||||||
|
elif persona_id:
|
||||||
|
persona_name = persona_id
|
||||||
|
else:
|
||||||
|
persona_name = "无"
|
||||||
|
|
||||||
|
if force_applied_persona_id:
|
||||||
|
persona_name = f"{persona_name} (自定义规则)"
|
||||||
|
|
||||||
title = _titles.get(conv.cid, "新对话")
|
title = _titles.get(conv.cid, "新对话")
|
||||||
parts.append(
|
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"
|
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||||
)
|
)
|
||||||
global_index += 1
|
global_index += 1
|
||||||
|
|
||||||
@@ -216,11 +266,12 @@ class ConversationCommands:
|
|||||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||||
return
|
return
|
||||||
|
|
||||||
async def new_conv(self, message: AstrMessageEvent):
|
async def new_conv(self, message: AstrMessageEvent) -> None:
|
||||||
"""创建新对话"""
|
"""创建新对话"""
|
||||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||||
|
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||||
await sp.remove_async(
|
await sp.remove_async(
|
||||||
scope="umo",
|
scope="umo",
|
||||||
scope_id=message.unified_msg_origin,
|
scope_id=message.unified_msg_origin,
|
||||||
@@ -229,6 +280,7 @@ class ConversationCommands:
|
|||||||
message.set_result(MessageEventResult().message("已创建新对话。"))
|
message.set_result(MessageEventResult().message("已创建新对话。"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||||
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
|
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
|
||||||
cid = await self.context.conversation_manager.new_conversation(
|
cid = await self.context.conversation_manager.new_conversation(
|
||||||
message.unified_msg_origin,
|
message.unified_msg_origin,
|
||||||
@@ -242,7 +294,7 @@ class ConversationCommands:
|
|||||||
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
|
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = ""):
|
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = "") -> None:
|
||||||
"""创建新群聊对话"""
|
"""创建新群聊对话"""
|
||||||
if sid:
|
if sid:
|
||||||
session = str(
|
session = str(
|
||||||
@@ -273,7 +325,7 @@ class ConversationCommands:
|
|||||||
self,
|
self,
|
||||||
message: AstrMessageEvent,
|
message: AstrMessageEvent,
|
||||||
index: int | None = None,
|
index: int | None = None,
|
||||||
):
|
) -> None:
|
||||||
"""通过 /ls 前面的序号切换对话"""
|
"""通过 /ls 前面的序号切换对话"""
|
||||||
if not isinstance(index, int):
|
if not isinstance(index, int):
|
||||||
message.set_result(
|
message.set_result(
|
||||||
@@ -308,7 +360,7 @@ class ConversationCommands:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def rename_conv(self, message: AstrMessageEvent, new_name: str = ""):
|
async def rename_conv(self, message: AstrMessageEvent, new_name: str = "") -> None:
|
||||||
"""重命名对话"""
|
"""重命名对话"""
|
||||||
if not new_name:
|
if not new_name:
|
||||||
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
|
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
|
||||||
@@ -319,9 +371,10 @@ class ConversationCommands:
|
|||||||
)
|
)
|
||||||
message.set_result(MessageEventResult().message("重命名对话成功。"))
|
message.set_result(MessageEventResult().message("重命名对话成功。"))
|
||||||
|
|
||||||
async def del_conv(self, message: AstrMessageEvent):
|
async def del_conv(self, message: AstrMessageEvent) -> None:
|
||||||
"""删除当前对话"""
|
"""删除当前对话"""
|
||||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
umo = message.unified_msg_origin
|
||||||
|
cfg = self.context.get_config(umo=umo)
|
||||||
is_unique_session = cfg["platform_settings"]["unique_session"]
|
is_unique_session = cfg["platform_settings"]["unique_session"]
|
||||||
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
||||||
# 群聊,没开独立会话,发送人不是管理员
|
# 群聊,没开独立会话,发送人不是管理员
|
||||||
@@ -334,18 +387,17 @@ class ConversationCommands:
|
|||||||
|
|
||||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||||
|
active_event_registry.stop_all(umo, exclude=message)
|
||||||
await sp.remove_async(
|
await sp.remove_async(
|
||||||
scope="umo",
|
scope="umo",
|
||||||
scope_id=message.unified_msg_origin,
|
scope_id=umo,
|
||||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||||
)
|
)
|
||||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||||
return
|
return
|
||||||
|
|
||||||
session_curr_cid = (
|
session_curr_cid = (
|
||||||
await self.context.conversation_manager.get_curr_conversation_id(
|
await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||||
message.unified_msg_origin,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not session_curr_cid:
|
if not session_curr_cid:
|
||||||
@@ -356,8 +408,10 @@ class ConversationCommands:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
active_event_registry.stop_all(umo, exclude=message)
|
||||||
|
|
||||||
await self.context.conversation_manager.delete_conversation(
|
await self.context.conversation_manager.delete_conversation(
|
||||||
message.unified_msg_origin,
|
umo,
|
||||||
session_curr_cid,
|
session_curr_cid,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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) -> None:
|
||||||
|
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) -> None:
|
||||||
|
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) -> None:
|
||||||
|
"""查看帮助"""
|
||||||
|
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))
|
||||||
+2
-2
@@ -3,10 +3,10 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
|
|||||||
|
|
||||||
|
|
||||||
class LLMCommands:
|
class LLMCommands:
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def llm(self, event: AstrMessageEvent):
|
async def llm(self, event: AstrMessageEvent) -> None:
|
||||||
"""开启/关闭 LLM"""
|
"""开启/关闭 LLM"""
|
||||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||||
enable = cfg["provider_settings"].get("enable", True)
|
enable = cfg["provider_settings"].get("enable", True)
|
||||||
+93
-19
@@ -1,14 +1,56 @@
|
|||||||
import builtins
|
import builtins
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from astrbot.api import sp, star
|
from astrbot.api import star
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from astrbot.core.db.po import Persona
|
||||||
|
|
||||||
|
|
||||||
class PersonaCommands:
|
class PersonaCommands:
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def persona(self, message: AstrMessageEvent):
|
def _build_tree_output(
|
||||||
|
self,
|
||||||
|
folder_tree: list[dict],
|
||||||
|
all_personas: list["Persona"],
|
||||||
|
depth: int = 0,
|
||||||
|
) -> list[str]:
|
||||||
|
"""递归构建树状输出,使用短线条表示层级"""
|
||||||
|
lines: list[str] = []
|
||||||
|
# 使用短线条作为缩进前缀,每层只用 "│" 加一个空格
|
||||||
|
prefix = "│ " * depth
|
||||||
|
|
||||||
|
for folder in folder_tree:
|
||||||
|
# 输出文件夹
|
||||||
|
lines.append(f"{prefix}├ 📁 {folder['name']}/")
|
||||||
|
|
||||||
|
# 获取该文件夹下的人格
|
||||||
|
folder_personas = [
|
||||||
|
p for p in all_personas if p.folder_id == folder["folder_id"]
|
||||||
|
]
|
||||||
|
child_prefix = "│ " * (depth + 1)
|
||||||
|
|
||||||
|
# 输出该文件夹下的人格
|
||||||
|
for persona in folder_personas:
|
||||||
|
lines.append(f"{child_prefix}├ 👤 {persona.persona_id}")
|
||||||
|
|
||||||
|
# 递归处理子文件夹
|
||||||
|
children = folder.get("children", [])
|
||||||
|
if children:
|
||||||
|
lines.extend(
|
||||||
|
self._build_tree_output(
|
||||||
|
children,
|
||||||
|
all_personas,
|
||||||
|
depth + 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
async def persona(self, message: AstrMessageEvent) -> None:
|
||||||
l = message.message_str.split(" ") # noqa: E741
|
l = message.message_str.split(" ") # noqa: E741
|
||||||
umo = message.unified_msg_origin
|
umo = message.unified_msg_origin
|
||||||
|
|
||||||
@@ -17,12 +59,7 @@ class PersonaCommands:
|
|||||||
default_persona = await self.context.persona_manager.get_default_persona_v3(
|
default_persona = await self.context.persona_manager.get_default_persona_v3(
|
||||||
umo=umo,
|
umo=umo,
|
||||||
)
|
)
|
||||||
|
force_applied_persona_id = None
|
||||||
force_applied_persona_id = (
|
|
||||||
await sp.get_async(
|
|
||||||
scope="umo", scope_id=umo, key="session_service_config", default={}
|
|
||||||
)
|
|
||||||
).get("persona_id")
|
|
||||||
|
|
||||||
curr_cid_title = "无"
|
curr_cid_title = "无"
|
||||||
if cid:
|
if cid:
|
||||||
@@ -38,10 +75,27 @@ class PersonaCommands:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not conv.persona_id and conv.persona_id != "[%None]":
|
|
||||||
curr_persona_name = default_persona["name"]
|
provider_settings = self.context.get_config(umo=umo).get(
|
||||||
else:
|
"provider_settings",
|
||||||
curr_persona_name = conv.persona_id
|
{},
|
||||||
|
)
|
||||||
|
(
|
||||||
|
persona_id,
|
||||||
|
_,
|
||||||
|
force_applied_persona_id,
|
||||||
|
_,
|
||||||
|
) = await self.context.persona_manager.resolve_selected_persona(
|
||||||
|
umo=umo,
|
||||||
|
conversation_persona_id=conv.persona_id,
|
||||||
|
platform_name=message.get_platform_name(),
|
||||||
|
provider_settings=provider_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
if persona_id == "[%None]":
|
||||||
|
curr_persona_name = "无"
|
||||||
|
elif persona_id:
|
||||||
|
curr_persona_name = persona_id
|
||||||
|
|
||||||
if force_applied_persona_id:
|
if force_applied_persona_id:
|
||||||
curr_persona_name = f"{curr_persona_name} (自定义规则)"
|
curr_persona_name = f"{curr_persona_name} (自定义规则)"
|
||||||
@@ -69,12 +123,32 @@ class PersonaCommands:
|
|||||||
.use_t2i(False),
|
.use_t2i(False),
|
||||||
)
|
)
|
||||||
elif l[1] == "list":
|
elif l[1] == "list":
|
||||||
parts = ["人格列表:\n"]
|
# 获取文件夹树和所有人格
|
||||||
for persona in self.context.provider_manager.personas:
|
folder_tree = await self.context.persona_manager.get_folder_tree()
|
||||||
parts.append(f"- {persona['name']}\n")
|
all_personas = self.context.persona_manager.personas
|
||||||
parts.append("\n\n*输入 `/persona view 人格名` 查看人格详细信息")
|
|
||||||
msg = "".join(parts)
|
lines = ["📂 人格列表:\n"]
|
||||||
message.set_result(MessageEventResult().message(msg))
|
|
||||||
|
# 构建树状输出
|
||||||
|
tree_lines = self._build_tree_output(folder_tree, all_personas)
|
||||||
|
lines.extend(tree_lines)
|
||||||
|
|
||||||
|
# 输出根目录下的人格(没有文件夹的)
|
||||||
|
root_personas = [p for p in all_personas if p.folder_id is None]
|
||||||
|
if root_personas:
|
||||||
|
if tree_lines: # 如果有文件夹内容,加个空行
|
||||||
|
lines.append("")
|
||||||
|
for persona in root_personas:
|
||||||
|
lines.append(f"👤 {persona.persona_id}")
|
||||||
|
|
||||||
|
# 统计信息
|
||||||
|
total_count = len(all_personas)
|
||||||
|
lines.append(f"\n共 {total_count} 个人格")
|
||||||
|
lines.append("\n*使用 `/persona <人格名>` 设置人格")
|
||||||
|
lines.append("*使用 `/persona view <人格名>` 查看详细信息")
|
||||||
|
|
||||||
|
msg = "\n".join(lines)
|
||||||
|
message.set_result(MessageEventResult().message(msg).use_t2i(False))
|
||||||
elif l[1] == "view":
|
elif l[1] == "view":
|
||||||
if len(l) == 2:
|
if len(l) == 2:
|
||||||
message.set_result(MessageEventResult().message("请输入人格情景名"))
|
message.set_result(MessageEventResult().message("请输入人格情景名"))
|
||||||
+6
-6
@@ -8,10 +8,10 @@ from astrbot.core.star.star_manager import PluginManager
|
|||||||
|
|
||||||
|
|
||||||
class PluginCommands:
|
class PluginCommands:
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def plugin_ls(self, event: AstrMessageEvent):
|
async def plugin_ls(self, event: AstrMessageEvent) -> None:
|
||||||
"""获取已经安装的插件列表。"""
|
"""获取已经安装的插件列表。"""
|
||||||
parts = ["已加载的插件:\n"]
|
parts = ["已加载的插件:\n"]
|
||||||
for plugin in self.context.get_all_stars():
|
for plugin in self.context.get_all_stars():
|
||||||
@@ -30,7 +30,7 @@ class PluginCommands:
|
|||||||
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 = ""):
|
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||||
"""禁用插件"""
|
"""禁用插件"""
|
||||||
if DEMO_MODE:
|
if DEMO_MODE:
|
||||||
event.set_result(MessageEventResult().message("演示模式下无法禁用插件。"))
|
event.set_result(MessageEventResult().message("演示模式下无法禁用插件。"))
|
||||||
@@ -43,7 +43,7 @@ class PluginCommands:
|
|||||||
await self.context._star_manager.turn_off_plugin(plugin_name) # type: ignore
|
await self.context._star_manager.turn_off_plugin(plugin_name) # type: ignore
|
||||||
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
|
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
|
||||||
|
|
||||||
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = ""):
|
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||||
"""启用插件"""
|
"""启用插件"""
|
||||||
if DEMO_MODE:
|
if DEMO_MODE:
|
||||||
event.set_result(MessageEventResult().message("演示模式下无法启用插件。"))
|
event.set_result(MessageEventResult().message("演示模式下无法启用插件。"))
|
||||||
@@ -56,7 +56,7 @@ class PluginCommands:
|
|||||||
await self.context._star_manager.turn_on_plugin(plugin_name) # type: ignore
|
await self.context._star_manager.turn_on_plugin(plugin_name) # type: ignore
|
||||||
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
|
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
|
||||||
|
|
||||||
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = ""):
|
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
|
||||||
"""安装插件"""
|
"""安装插件"""
|
||||||
if DEMO_MODE:
|
if DEMO_MODE:
|
||||||
event.set_result(MessageEventResult().message("演示模式下无法安装插件。"))
|
event.set_result(MessageEventResult().message("演示模式下无法安装插件。"))
|
||||||
@@ -77,7 +77,7 @@ class PluginCommands:
|
|||||||
event.set_result(MessageEventResult().message(f"安装插件失败: {e}"))
|
event.set_result(MessageEventResult().message(f"安装插件失败: {e}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = ""):
|
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||||
"""获取插件帮助"""
|
"""获取插件帮助"""
|
||||||
if not plugin_name:
|
if not plugin_name:
|
||||||
event.set_result(
|
event.set_result(
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from astrbot import logger
|
||||||
|
from astrbot.api import star
|
||||||
|
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||||
|
from astrbot.core.provider.entities import ProviderType
|
||||||
|
from astrbot.core.utils.error_redaction import safe_error
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from astrbot.core.provider.provider import Provider
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT = 30.0
|
||||||
|
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT = 4
|
||||||
|
MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND = 16
|
||||||
|
MODEL_LIST_CACHE_TTL_KEY = "model_list_cache_ttl_seconds"
|
||||||
|
MODEL_LOOKUP_MAX_CONCURRENCY_KEY = "model_lookup_max_concurrency"
|
||||||
|
MODEL_CACHE_MAX_ENTRIES = 512
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ModelLookupConfig:
|
||||||
|
umo: str | None
|
||||||
|
cache_ttl_seconds: float
|
||||||
|
max_concurrency: int
|
||||||
|
|
||||||
|
|
||||||
|
class _ModelCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._store: dict[tuple[str, str | None], tuple[float, list[str]]] = {}
|
||||||
|
|
||||||
|
def get(self, provider_id: str, umo: str | None, ttl: float) -> list[str] | None:
|
||||||
|
if ttl <= 0:
|
||||||
|
return None
|
||||||
|
entry = self._store.get((provider_id, umo))
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
timestamp, models = entry
|
||||||
|
if time.monotonic() - timestamp > ttl:
|
||||||
|
self._store.pop((provider_id, umo), None)
|
||||||
|
return None
|
||||||
|
return models
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self, provider_id: str, umo: str | None, models: list[str], ttl: float
|
||||||
|
) -> None:
|
||||||
|
if ttl <= 0:
|
||||||
|
return
|
||||||
|
self._store[(provider_id, umo)] = (time.monotonic(), list(models))
|
||||||
|
self._evict_if_needed()
|
||||||
|
|
||||||
|
def _evict_if_needed(self) -> None:
|
||||||
|
if len(self._store) <= MODEL_CACHE_MAX_ENTRIES:
|
||||||
|
return
|
||||||
|
# Drop oldest entries first when cache grows too large.
|
||||||
|
overflow = len(self._store) - MODEL_CACHE_MAX_ENTRIES
|
||||||
|
for key, _ in sorted(
|
||||||
|
self._store.items(),
|
||||||
|
key=lambda item: item[1][0],
|
||||||
|
)[:overflow]:
|
||||||
|
self._store.pop(key, None)
|
||||||
|
|
||||||
|
def invalidate(
|
||||||
|
self, provider_id: str | None = None, *, umo: str | None = None
|
||||||
|
) -> None:
|
||||||
|
if provider_id is None:
|
||||||
|
self._store.clear()
|
||||||
|
return
|
||||||
|
if umo is not None:
|
||||||
|
self._store.pop((provider_id, umo), None)
|
||||||
|
return
|
||||||
|
stale_keys = [
|
||||||
|
cache_key for cache_key in self._store if cache_key[0] == provider_id
|
||||||
|
]
|
||||||
|
for cache_key in stale_keys:
|
||||||
|
self._store.pop(cache_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderCommands:
|
||||||
|
def __init__(self, context: star.Context) -> None:
|
||||||
|
self.context = context
|
||||||
|
self._model_cache = _ModelCache()
|
||||||
|
self._register_provider_change_hook()
|
||||||
|
|
||||||
|
def _register_provider_change_hook(self) -> None:
|
||||||
|
set_change_callback = getattr(
|
||||||
|
self.context.provider_manager,
|
||||||
|
"set_provider_change_callback",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if callable(set_change_callback):
|
||||||
|
set_change_callback(self._on_provider_manager_changed)
|
||||||
|
return
|
||||||
|
register_change_hook = getattr(
|
||||||
|
self.context.provider_manager,
|
||||||
|
"register_provider_change_hook",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if callable(register_change_hook):
|
||||||
|
register_change_hook(self._on_provider_manager_changed)
|
||||||
|
|
||||||
|
def invalidate_provider_models_cache(
|
||||||
|
self, provider_id: str | None = None, *, umo: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Public hook for cache invalidation on external provider config changes."""
|
||||||
|
self._model_cache.invalidate(provider_id, umo=umo)
|
||||||
|
|
||||||
|
def _on_provider_manager_changed(
|
||||||
|
self,
|
||||||
|
provider_id: str,
|
||||||
|
provider_type: ProviderType,
|
||||||
|
umo: str | None,
|
||||||
|
) -> None:
|
||||||
|
if provider_type == ProviderType.CHAT_COMPLETION:
|
||||||
|
self.invalidate_provider_models_cache(provider_id, umo=umo)
|
||||||
|
|
||||||
|
def _get_provider_settings(self, umo: str | None) -> dict:
|
||||||
|
if not umo:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return self.context.get_config(umo).get("provider_settings", {}) or {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"读取 provider_settings 失败,使用默认值: %s",
|
||||||
|
safe_error("", e),
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _get_model_cache_ttl(self, umo: str | None) -> float:
|
||||||
|
settings = self._get_provider_settings(umo)
|
||||||
|
raw = settings.get(
|
||||||
|
MODEL_LIST_CACHE_TTL_KEY,
|
||||||
|
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return max(float(raw), 0.0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"读取 %s 失败,回退默认值 %r: %s",
|
||||||
|
MODEL_LIST_CACHE_TTL_KEY,
|
||||||
|
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
|
||||||
|
safe_error("", e),
|
||||||
|
)
|
||||||
|
return MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT
|
||||||
|
|
||||||
|
def _get_model_lookup_concurrency(self, umo: str | None) -> int:
|
||||||
|
settings = self._get_provider_settings(umo)
|
||||||
|
raw = settings.get(
|
||||||
|
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
|
||||||
|
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"读取 %s 失败,回退默认值 %r: %s",
|
||||||
|
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
|
||||||
|
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
|
||||||
|
safe_error("", e),
|
||||||
|
)
|
||||||
|
value = MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT
|
||||||
|
return min(max(value, 1), MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND)
|
||||||
|
|
||||||
|
def _get_model_lookup_config(self, umo: str | None) -> _ModelLookupConfig:
|
||||||
|
return _ModelLookupConfig(
|
||||||
|
umo=umo,
|
||||||
|
cache_ttl_seconds=self._get_model_cache_ttl(umo),
|
||||||
|
max_concurrency=self._get_model_lookup_concurrency(umo),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_model_name(
|
||||||
|
self,
|
||||||
|
model_name: str,
|
||||||
|
models: Sequence[str],
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve model name with precedence:
|
||||||
|
exact > case-insensitive > provider-qualified suffix.
|
||||||
|
"""
|
||||||
|
requested = model_name.strip()
|
||||||
|
if not requested:
|
||||||
|
return None
|
||||||
|
|
||||||
|
requested_norm = requested.casefold()
|
||||||
|
|
||||||
|
# exact / case-insensitive match
|
||||||
|
for candidate in models:
|
||||||
|
if candidate == requested or candidate.casefold() == requested_norm:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# provider-qualified suffix match:
|
||||||
|
# e.g. candidate `openai/gpt-4o` should match requested `gpt-4o`.
|
||||||
|
for candidate in models:
|
||||||
|
cand_norm = candidate.casefold()
|
||||||
|
if cand_norm.endswith(f"/{requested_norm}") or cand_norm.endswith(
|
||||||
|
f":{requested_norm}"
|
||||||
|
):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _apply_model(
|
||||||
|
self, prov: Provider, model_name: str, *, umo: str | None = None
|
||||||
|
) -> str:
|
||||||
|
prov.set_model(model_name)
|
||||||
|
self.invalidate_provider_models_cache(prov.meta().id, umo=umo)
|
||||||
|
return f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]"
|
||||||
|
|
||||||
|
async def _get_provider_models(
|
||||||
|
self,
|
||||||
|
provider: Provider,
|
||||||
|
*,
|
||||||
|
config: _ModelLookupConfig,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
provider_id = provider.meta().id
|
||||||
|
ttl_seconds = config.cache_ttl_seconds
|
||||||
|
umo = config.umo
|
||||||
|
if use_cache:
|
||||||
|
cached = self._model_cache.get(provider_id, umo, ttl_seconds)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
models = list(await provider.get_models())
|
||||||
|
if use_cache:
|
||||||
|
self._model_cache.set(provider_id, umo, models, ttl_seconds)
|
||||||
|
return models
|
||||||
|
|
||||||
|
async def _get_models_or_reply_error(
|
||||||
|
self,
|
||||||
|
message: AstrMessageEvent,
|
||||||
|
prov: Provider,
|
||||||
|
config: _ModelLookupConfig,
|
||||||
|
*,
|
||||||
|
error_prefix: str,
|
||||||
|
disable_t2i: bool = False,
|
||||||
|
warning_log: str | None = None,
|
||||||
|
) -> list[str] | None:
|
||||||
|
try:
|
||||||
|
return await self._get_provider_models(prov, config=config)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if warning_log is not None:
|
||||||
|
logger.warning(
|
||||||
|
warning_log,
|
||||||
|
prov.meta().id,
|
||||||
|
safe_error("", e),
|
||||||
|
)
|
||||||
|
result = MessageEventResult().message(safe_error(error_prefix, e))
|
||||||
|
if disable_t2i:
|
||||||
|
result = result.use_t2i(False)
|
||||||
|
message.set_result(result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _log_reachability_failure(
|
||||||
|
self,
|
||||||
|
provider,
|
||||||
|
provider_capability_type: ProviderType | None,
|
||||||
|
err_code: str,
|
||||||
|
err_reason: str,
|
||||||
|
) -> None:
|
||||||
|
"""记录不可达原因到日志。"""
|
||||||
|
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 = safe_error("", e)
|
||||||
|
self._log_reachability_failure(
|
||||||
|
provider, provider_capability_type, err_code, err_reason
|
||||||
|
)
|
||||||
|
return False, err_code, err_reason
|
||||||
|
|
||||||
|
async def _find_provider_for_model(
|
||||||
|
self,
|
||||||
|
model_name: str,
|
||||||
|
*,
|
||||||
|
exclude_provider_id: str | None = None,
|
||||||
|
config: _ModelLookupConfig,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> tuple[Provider | None, str | None]:
|
||||||
|
all_providers = []
|
||||||
|
for provider in self.context.get_all_providers():
|
||||||
|
provider_meta = provider.meta()
|
||||||
|
if provider_meta.provider_type != ProviderType.CHAT_COMPLETION:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
exclude_provider_id is not None
|
||||||
|
and provider_meta.id == exclude_provider_id
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
all_providers.append(provider)
|
||||||
|
if not all_providers:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
semaphore = asyncio.Semaphore(config.max_concurrency)
|
||||||
|
|
||||||
|
async def fetch_models(
|
||||||
|
provider: Provider,
|
||||||
|
) -> tuple[Provider, list[str] | None, str | None]:
|
||||||
|
async with semaphore:
|
||||||
|
try:
|
||||||
|
models = await self._get_provider_models(
|
||||||
|
provider,
|
||||||
|
config=config,
|
||||||
|
use_cache=use_cache,
|
||||||
|
)
|
||||||
|
return provider, models, None
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
err = safe_error("", e)
|
||||||
|
logger.debug(
|
||||||
|
"跨提供商查找模型 %s 获取 %s 模型列表失败: %s",
|
||||||
|
model_name,
|
||||||
|
provider.meta().id,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return provider, None, err
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(fetch_models(provider) for provider in all_providers)
|
||||||
|
)
|
||||||
|
failed_provider_errors: list[tuple[str, str]] = []
|
||||||
|
for provider, models, err in results:
|
||||||
|
if err is not None:
|
||||||
|
failed_provider_errors.append((provider.meta().id, err))
|
||||||
|
continue
|
||||||
|
if models is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched_model_name = self._resolve_model_name(model_name, models)
|
||||||
|
if matched_model_name is not None:
|
||||||
|
return provider, matched_model_name
|
||||||
|
|
||||||
|
if failed_provider_errors and len(failed_provider_errors) == len(all_providers):
|
||||||
|
failed_ids = ",".join(
|
||||||
|
provider_id for provider_id, _ in failed_provider_errors
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
"跨提供商查找模型 %s 时,所有 %d 个提供商的 get_models() 均失败: %s。请检查配置或网络",
|
||||||
|
model_name,
|
||||||
|
len(all_providers),
|
||||||
|
failed_ids,
|
||||||
|
)
|
||||||
|
elif failed_provider_errors:
|
||||||
|
logger.debug(
|
||||||
|
"跨提供商查找模型 %s 时有 %d 个提供商获取模型失败: %s",
|
||||||
|
model_name,
|
||||||
|
len(failed_provider_errors),
|
||||||
|
",".join(
|
||||||
|
f"{provider_id}({error})"
|
||||||
|
for provider_id, error in failed_provider_errors
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def provider(
|
||||||
|
self,
|
||||||
|
event: AstrMessageEvent,
|
||||||
|
idx: str | int | None = None,
|
||||||
|
idx2: int | None = 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, asyncio.CancelledError):
|
||||||
|
raise reachable
|
||||||
|
if isinstance(reachable, Exception):
|
||||||
|
# 异常情况下兜底处理,避免单个 provider 导致列表失败
|
||||||
|
self._log_reachability_failure(
|
||||||
|
p,
|
||||||
|
None,
|
||||||
|
reachable.__class__.__name__,
|
||||||
|
safe_error("", 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 _switch_model_by_name(
|
||||||
|
self, message: AstrMessageEvent, model_name: str, prov: Provider
|
||||||
|
) -> None:
|
||||||
|
model_name = model_name.strip()
|
||||||
|
if not model_name:
|
||||||
|
message.set_result(MessageEventResult().message("模型名不能为空。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
umo = message.unified_msg_origin
|
||||||
|
config = self._get_model_lookup_config(umo)
|
||||||
|
curr_provider_id = prov.meta().id
|
||||||
|
|
||||||
|
models = await self._get_models_or_reply_error(
|
||||||
|
message,
|
||||||
|
prov,
|
||||||
|
config,
|
||||||
|
error_prefix="获取当前提供商模型列表失败: ",
|
||||||
|
warning_log="获取当前提供商 %s 模型列表失败,停止跨提供商查找: %s",
|
||||||
|
)
|
||||||
|
if models is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
matched_model_name = self._resolve_model_name(model_name, models)
|
||||||
|
if matched_model_name is not None:
|
||||||
|
message.set_result(
|
||||||
|
MessageEventResult().message(
|
||||||
|
self._apply_model(prov, matched_model_name, umo=umo)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
target_prov, matched_target_model_name = await self._find_provider_for_model(
|
||||||
|
model_name,
|
||||||
|
exclude_provider_id=curr_provider_id,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
if target_prov is None or matched_target_model_name is None:
|
||||||
|
message.set_result(
|
||||||
|
MessageEventResult().message(
|
||||||
|
f"模型 [{model_name}] 未在任何已配置的提供商中找到,或所有提供商模型列表获取失败,请检查配置或网络后重试。",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
target_id = target_prov.meta().id
|
||||||
|
try:
|
||||||
|
await self.context.provider_manager.set_provider(
|
||||||
|
provider_id=target_id,
|
||||||
|
provider_type=ProviderType.CHAT_COMPLETION,
|
||||||
|
umo=umo,
|
||||||
|
)
|
||||||
|
self._apply_model(target_prov, matched_target_model_name, umo=umo)
|
||||||
|
message.set_result(
|
||||||
|
MessageEventResult().message(
|
||||||
|
f"检测到模型 [{matched_target_model_name}] 属于提供商 [{target_id}],已自动切换提供商并设置模型。",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
message.set_result(
|
||||||
|
MessageEventResult().message(
|
||||||
|
safe_error("跨提供商切换并设置模型失败: ", e)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def model_ls(
|
||||||
|
self,
|
||||||
|
message: AstrMessageEvent,
|
||||||
|
idx_or_name: int | str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""查看或者切换模型"""
|
||||||
|
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||||
|
if not prov:
|
||||||
|
message.set_result(
|
||||||
|
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
config = self._get_model_lookup_config(message.unified_msg_origin)
|
||||||
|
|
||||||
|
if idx_or_name is None:
|
||||||
|
models = await self._get_models_or_reply_error(
|
||||||
|
message,
|
||||||
|
prov,
|
||||||
|
config,
|
||||||
|
error_prefix="获取模型列表失败: ",
|
||||||
|
disable_t2i=True,
|
||||||
|
)
|
||||||
|
if models is None:
|
||||||
|
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 <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换。"
|
||||||
|
)
|
||||||
|
|
||||||
|
ret = "".join(parts)
|
||||||
|
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||||
|
elif isinstance(idx_or_name, int):
|
||||||
|
models = await self._get_models_or_reply_error(
|
||||||
|
message,
|
||||||
|
prov,
|
||||||
|
config,
|
||||||
|
error_prefix="获取模型列表失败: ",
|
||||||
|
)
|
||||||
|
if models is None:
|
||||||
|
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]
|
||||||
|
message.set_result(
|
||||||
|
MessageEventResult().message(
|
||||||
|
self._apply_model(
|
||||||
|
prov,
|
||||||
|
new_model,
|
||||||
|
umo=message.unified_msg_origin,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
message.set_result(
|
||||||
|
MessageEventResult().message(
|
||||||
|
safe_error("切换模型未知错误: ", e)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
await self._switch_model_by_name(message, idx_or_name, prov)
|
||||||
|
|
||||||
|
async def key(self, message: AstrMessageEvent, index: int | None = 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)
|
||||||
|
self.invalidate_provider_models_cache(
|
||||||
|
prov.meta().id,
|
||||||
|
umo=message.unified_msg_origin,
|
||||||
|
)
|
||||||
|
message.set_result(MessageEventResult().message("切换 Key 成功。"))
|
||||||
|
except Exception as e:
|
||||||
|
message.set_result(
|
||||||
|
MessageEventResult().message(
|
||||||
|
safe_error("切换 Key 未知错误: ", e)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
+3
-3
@@ -3,10 +3,10 @@ from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
|||||||
|
|
||||||
|
|
||||||
class SetUnsetCommands:
|
class SetUnsetCommands:
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def set_variable(self, event: AstrMessageEvent, key: str, value: str):
|
async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None:
|
||||||
"""设置会话变量"""
|
"""设置会话变量"""
|
||||||
uid = event.unified_msg_origin
|
uid = event.unified_msg_origin
|
||||||
session_var = await sp.session_get(uid, "session_variables", {})
|
session_var = await sp.session_get(uid, "session_variables", {})
|
||||||
@@ -19,7 +19,7 @@ class SetUnsetCommands:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def unset_variable(self, event: AstrMessageEvent, key: str):
|
async def unset_variable(self, event: AstrMessageEvent, key: str) -> None:
|
||||||
"""移除会话变量"""
|
"""移除会话变量"""
|
||||||
uid = event.unified_msg_origin
|
uid = event.unified_msg_origin
|
||||||
session_var = await sp.session_get(uid, "session_variables", {})
|
session_var = await sp.session_get(uid, "session_variables", {})
|
||||||
+2
-2
@@ -7,10 +7,10 @@ from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
|||||||
class SIDCommand:
|
class SIDCommand:
|
||||||
"""会话ID命令类"""
|
"""会话ID命令类"""
|
||||||
|
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def sid(self, event: AstrMessageEvent):
|
async def sid(self, event: AstrMessageEvent) -> None:
|
||||||
"""获取消息来源信息"""
|
"""获取消息来源信息"""
|
||||||
sid = event.unified_msg_origin
|
sid = event.unified_msg_origin
|
||||||
user_id = str(event.get_sender_id())
|
user_id = str(event.get_sender_id())
|
||||||
+2
-2
@@ -7,10 +7,10 @@ from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
|||||||
class T2ICommand:
|
class T2ICommand:
|
||||||
"""文本转图片命令类"""
|
"""文本转图片命令类"""
|
||||||
|
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def t2i(self, event: AstrMessageEvent):
|
async def t2i(self, event: AstrMessageEvent) -> None:
|
||||||
"""开关文本转图片"""
|
"""开关文本转图片"""
|
||||||
config = self.context.get_config(umo=event.unified_msg_origin)
|
config = self.context.get_config(umo=event.unified_msg_origin)
|
||||||
if config["t2i"]:
|
if config["t2i"]:
|
||||||
+4
-4
@@ -8,19 +8,19 @@ from astrbot.core.star.session_llm_manager import SessionServiceManager
|
|||||||
class TTSCommand:
|
class TTSCommand:
|
||||||
"""文本转语音命令类"""
|
"""文本转语音命令类"""
|
||||||
|
|
||||||
def __init__(self, context: star.Context):
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
async def tts(self, event: AstrMessageEvent):
|
async def tts(self, event: AstrMessageEvent) -> None:
|
||||||
"""开关文本转语音(会话级别)"""
|
"""开关文本转语音(会话级别)"""
|
||||||
umo = event.unified_msg_origin
|
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)
|
cfg = self.context.get_config(umo=umo)
|
||||||
tts_enable = cfg["provider_tts_settings"]["enable"]
|
tts_enable = cfg["provider_tts_settings"]["enable"]
|
||||||
|
|
||||||
# 切换状态
|
# 切换状态
|
||||||
new_status = not ses_tts
|
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 "已关闭"
|
status_text = "已开启" if new_status else "已关闭"
|
||||||
|
|
||||||
+40
-58
@@ -13,7 +13,6 @@ from .commands import (
|
|||||||
SetUnsetCommands,
|
SetUnsetCommands,
|
||||||
SIDCommand,
|
SIDCommand,
|
||||||
T2ICommand,
|
T2ICommand,
|
||||||
ToolCommands,
|
|
||||||
TTSCommand,
|
TTSCommand,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ class Main(star.Star):
|
|||||||
|
|
||||||
self.help_c = HelpCommand(self.context)
|
self.help_c = HelpCommand(self.context)
|
||||||
self.llm_c = LLMCommands(self.context)
|
self.llm_c = LLMCommands(self.context)
|
||||||
self.tool_c = ToolCommands(self.context)
|
|
||||||
self.plugin_c = PluginCommands(self.context)
|
self.plugin_c = PluginCommands(self.context)
|
||||||
self.admin_c = AdminCommands(self.context)
|
self.admin_c = AdminCommands(self.context)
|
||||||
self.conversation_c = ConversationCommands(self.context)
|
self.conversation_c = ConversationCommands(self.context)
|
||||||
@@ -37,108 +35,84 @@ class Main(star.Star):
|
|||||||
self.sid_c = SIDCommand(self.context)
|
self.sid_c = SIDCommand(self.context)
|
||||||
|
|
||||||
@filter.command("help")
|
@filter.command("help")
|
||||||
async def help(self, event: AstrMessageEvent):
|
async def help(self, event: AstrMessageEvent) -> None:
|
||||||
"""查看帮助"""
|
"""查看帮助"""
|
||||||
await self.help_c.help(event)
|
await self.help_c.help(event)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("llm")
|
@filter.command("llm")
|
||||||
async def llm(self, event: AstrMessageEvent):
|
async def llm(self, event: AstrMessageEvent) -> None:
|
||||||
"""开启/关闭 LLM"""
|
"""开启/关闭 LLM"""
|
||||||
await self.llm_c.llm(event)
|
await self.llm_c.llm(event)
|
||||||
|
|
||||||
@filter.command_group("tool")
|
|
||||||
def tool(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@tool.command("ls")
|
|
||||||
async def tool_ls(self, event: AstrMessageEvent):
|
|
||||||
"""查看函数工具列表"""
|
|
||||||
await self.tool_c.tool_ls(event)
|
|
||||||
|
|
||||||
@tool.command("on")
|
|
||||||
async def tool_on(self, event: AstrMessageEvent, tool_name: str):
|
|
||||||
"""启用一个函数工具"""
|
|
||||||
await self.tool_c.tool_on(event, tool_name)
|
|
||||||
|
|
||||||
@tool.command("off")
|
|
||||||
async def tool_off(self, event: AstrMessageEvent, tool_name: str):
|
|
||||||
"""停用一个函数工具"""
|
|
||||||
await self.tool_c.tool_off(event, tool_name)
|
|
||||||
|
|
||||||
@tool.command("off_all")
|
|
||||||
async def tool_all_off(self, event: AstrMessageEvent):
|
|
||||||
"""停用所有函数工具"""
|
|
||||||
await self.tool_c.tool_all_off(event)
|
|
||||||
|
|
||||||
@filter.command_group("plugin")
|
@filter.command_group("plugin")
|
||||||
def plugin(self):
|
def plugin(self) -> None:
|
||||||
pass
|
"""插件管理"""
|
||||||
|
|
||||||
@plugin.command("ls")
|
@plugin.command("ls")
|
||||||
async def plugin_ls(self, event: AstrMessageEvent):
|
async def plugin_ls(self, event: AstrMessageEvent) -> None:
|
||||||
"""获取已经安装的插件列表。"""
|
"""获取已经安装的插件列表。"""
|
||||||
await self.plugin_c.plugin_ls(event)
|
await self.plugin_c.plugin_ls(event)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@plugin.command("off")
|
@plugin.command("off")
|
||||||
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = ""):
|
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||||
"""禁用插件"""
|
"""禁用插件"""
|
||||||
await self.plugin_c.plugin_off(event, plugin_name)
|
await self.plugin_c.plugin_off(event, plugin_name)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@plugin.command("on")
|
@plugin.command("on")
|
||||||
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = ""):
|
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||||
"""启用插件"""
|
"""启用插件"""
|
||||||
await self.plugin_c.plugin_on(event, plugin_name)
|
await self.plugin_c.plugin_on(event, plugin_name)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@plugin.command("get")
|
@plugin.command("get")
|
||||||
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = ""):
|
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
|
||||||
"""安装插件"""
|
"""安装插件"""
|
||||||
await self.plugin_c.plugin_get(event, plugin_repo)
|
await self.plugin_c.plugin_get(event, plugin_repo)
|
||||||
|
|
||||||
@plugin.command("help")
|
@plugin.command("help")
|
||||||
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = ""):
|
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||||
"""获取插件帮助"""
|
"""获取插件帮助"""
|
||||||
await self.plugin_c.plugin_help(event, plugin_name)
|
await self.plugin_c.plugin_help(event, plugin_name)
|
||||||
|
|
||||||
@filter.command("t2i")
|
@filter.command("t2i")
|
||||||
async def t2i(self, event: AstrMessageEvent):
|
async def t2i(self, event: AstrMessageEvent) -> None:
|
||||||
"""开关文本转图片"""
|
"""开关文本转图片"""
|
||||||
await self.t2i_c.t2i(event)
|
await self.t2i_c.t2i(event)
|
||||||
|
|
||||||
@filter.command("tts")
|
@filter.command("tts")
|
||||||
async def tts(self, event: AstrMessageEvent):
|
async def tts(self, event: AstrMessageEvent) -> None:
|
||||||
"""开关文本转语音(会话级别)"""
|
"""开关文本转语音(会话级别)"""
|
||||||
await self.tts_c.tts(event)
|
await self.tts_c.tts(event)
|
||||||
|
|
||||||
@filter.command("sid")
|
@filter.command("sid")
|
||||||
async def sid(self, event: AstrMessageEvent):
|
async def sid(self, event: AstrMessageEvent) -> None:
|
||||||
"""获取会话 ID 和 管理员 ID"""
|
"""获取会话 ID 和 管理员 ID"""
|
||||||
await self.sid_c.sid(event)
|
await self.sid_c.sid(event)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("op")
|
@filter.command("op")
|
||||||
async def op(self, event: AstrMessageEvent, admin_id: str = ""):
|
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
|
||||||
"""授权管理员。op <admin_id>"""
|
"""授权管理员。op <admin_id>"""
|
||||||
await self.admin_c.op(event, admin_id)
|
await self.admin_c.op(event, admin_id)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("deop")
|
@filter.command("deop")
|
||||||
async def deop(self, event: AstrMessageEvent, admin_id: str):
|
async def deop(self, event: AstrMessageEvent, admin_id: str) -> None:
|
||||||
"""取消授权管理员。deop <admin_id>"""
|
"""取消授权管理员。deop <admin_id>"""
|
||||||
await self.admin_c.deop(event, admin_id)
|
await self.admin_c.deop(event, admin_id)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("wl")
|
@filter.command("wl")
|
||||||
async def wl(self, event: AstrMessageEvent, sid: str = ""):
|
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
|
||||||
"""添加白名单。wl <sid>"""
|
"""添加白名单。wl <sid>"""
|
||||||
await self.admin_c.wl(event, sid)
|
await self.admin_c.wl(event, sid)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("dwl")
|
@filter.command("dwl")
|
||||||
async def dwl(self, event: AstrMessageEvent, sid: str):
|
async def dwl(self, event: AstrMessageEvent, sid: str) -> None:
|
||||||
"""删除白名单。dwl <sid>"""
|
"""删除白名单。dwl <sid>"""
|
||||||
await self.admin_c.dwl(event, sid)
|
await self.admin_c.dwl(event, sid)
|
||||||
|
|
||||||
@@ -149,88 +123,96 @@ class Main(star.Star):
|
|||||||
event: AstrMessageEvent,
|
event: AstrMessageEvent,
|
||||||
idx: str | int | None = None,
|
idx: str | int | None = None,
|
||||||
idx2: int | None = None,
|
idx2: int | None = None,
|
||||||
):
|
) -> None:
|
||||||
"""查看或者切换 LLM Provider"""
|
"""查看或者切换 LLM Provider"""
|
||||||
await self.provider_c.provider(event, idx, idx2)
|
await self.provider_c.provider(event, idx, idx2)
|
||||||
|
|
||||||
@filter.command("reset")
|
@filter.command("reset")
|
||||||
async def reset(self, message: AstrMessageEvent):
|
async def reset(self, message: AstrMessageEvent) -> None:
|
||||||
"""重置 LLM 会话"""
|
"""重置 LLM 会话"""
|
||||||
await self.conversation_c.reset(message)
|
await self.conversation_c.reset(message)
|
||||||
|
|
||||||
|
@filter.command("stop")
|
||||||
|
async def stop(self, message: AstrMessageEvent) -> None:
|
||||||
|
"""停止当前会话中正在运行的 Agent"""
|
||||||
|
await self.conversation_c.stop(message)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("model")
|
@filter.command("model")
|
||||||
async def model_ls(
|
async def model_ls(
|
||||||
self,
|
self,
|
||||||
message: AstrMessageEvent,
|
message: AstrMessageEvent,
|
||||||
idx_or_name: int | str | None = None,
|
idx_or_name: int | str | None = None,
|
||||||
):
|
) -> None:
|
||||||
"""查看或者切换模型"""
|
"""查看或者切换模型"""
|
||||||
await self.provider_c.model_ls(message, idx_or_name)
|
await self.provider_c.model_ls(message, idx_or_name)
|
||||||
|
|
||||||
@filter.command("history")
|
@filter.command("history")
|
||||||
async def his(self, message: AstrMessageEvent, page: int = 1):
|
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||||
"""查看对话记录"""
|
"""查看对话记录"""
|
||||||
await self.conversation_c.his(message, page)
|
await self.conversation_c.his(message, page)
|
||||||
|
|
||||||
@filter.command("ls")
|
@filter.command("ls")
|
||||||
async def convs(self, message: AstrMessageEvent, page: int = 1):
|
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||||
"""查看对话列表"""
|
"""查看对话列表"""
|
||||||
await self.conversation_c.convs(message, page)
|
await self.conversation_c.convs(message, page)
|
||||||
|
|
||||||
@filter.command("new")
|
@filter.command("new")
|
||||||
async def new_conv(self, message: AstrMessageEvent):
|
async def new_conv(self, message: AstrMessageEvent) -> None:
|
||||||
"""创建新对话"""
|
"""创建新对话"""
|
||||||
await self.conversation_c.new_conv(message)
|
await self.conversation_c.new_conv(message)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("groupnew")
|
@filter.command("groupnew")
|
||||||
async def groupnew_conv(self, message: AstrMessageEvent, sid: str):
|
async def groupnew_conv(self, message: AstrMessageEvent, sid: str) -> None:
|
||||||
"""创建新群聊对话"""
|
"""创建新群聊对话"""
|
||||||
await self.conversation_c.groupnew_conv(message, sid)
|
await self.conversation_c.groupnew_conv(message, sid)
|
||||||
|
|
||||||
@filter.command("switch")
|
@filter.command("switch")
|
||||||
async def switch_conv(self, message: AstrMessageEvent, index: int | None = None):
|
async def switch_conv(
|
||||||
|
self, message: AstrMessageEvent, index: int | None = None
|
||||||
|
) -> None:
|
||||||
"""通过 /ls 前面的序号切换对话"""
|
"""通过 /ls 前面的序号切换对话"""
|
||||||
await self.conversation_c.switch_conv(message, index)
|
await self.conversation_c.switch_conv(message, index)
|
||||||
|
|
||||||
@filter.command("rename")
|
@filter.command("rename")
|
||||||
async def rename_conv(self, message: AstrMessageEvent, new_name: str):
|
async def rename_conv(self, message: AstrMessageEvent, new_name: str) -> None:
|
||||||
"""重命名对话"""
|
"""重命名对话"""
|
||||||
await self.conversation_c.rename_conv(message, new_name)
|
await self.conversation_c.rename_conv(message, new_name)
|
||||||
|
|
||||||
@filter.command("del")
|
@filter.command("del")
|
||||||
async def del_conv(self, message: AstrMessageEvent):
|
async def del_conv(self, message: AstrMessageEvent) -> None:
|
||||||
"""删除当前对话"""
|
"""删除当前对话"""
|
||||||
await self.conversation_c.del_conv(message)
|
await self.conversation_c.del_conv(message)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("key")
|
@filter.command("key")
|
||||||
async def key(self, message: AstrMessageEvent, index: int | None = None):
|
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
|
||||||
"""查看或者切换 Key"""
|
"""查看或者切换 Key"""
|
||||||
await self.provider_c.key(message, index)
|
await self.provider_c.key(message, index)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("persona")
|
@filter.command("persona")
|
||||||
async def persona(self, message: AstrMessageEvent):
|
async def persona(self, message: AstrMessageEvent) -> None:
|
||||||
"""查看或者切换 Persona"""
|
"""查看或者切换 Persona"""
|
||||||
await self.persona_c.persona(message)
|
await self.persona_c.persona(message)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("dashboard_update")
|
@filter.command("dashboard_update")
|
||||||
async def update_dashboard(self, event: AstrMessageEvent):
|
async def update_dashboard(self, event: AstrMessageEvent) -> None:
|
||||||
|
"""更新管理面板"""
|
||||||
await self.admin_c.update_dashboard(event)
|
await self.admin_c.update_dashboard(event)
|
||||||
|
|
||||||
@filter.command("set")
|
@filter.command("set")
|
||||||
async def set_variable(self, event: AstrMessageEvent, key: str, value: str):
|
async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None:
|
||||||
await self.setunset_c.set_variable(event, key, value)
|
await self.setunset_c.set_variable(event, key, value)
|
||||||
|
|
||||||
@filter.command("unset")
|
@filter.command("unset")
|
||||||
async def unset_variable(self, event: AstrMessageEvent, key: str):
|
async def unset_variable(self, event: AstrMessageEvent, key: str) -> None:
|
||||||
await self.setunset_c.unset_variable(event, key)
|
await self.setunset_c.unset_variable(event, key)
|
||||||
|
|
||||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||||
@filter.command("alter_cmd", alias={"alter"})
|
@filter.command("alter_cmd", alias={"alter"})
|
||||||
async def alter_cmd(self, event: AstrMessageEvent):
|
async def alter_cmd(self, event: AstrMessageEvent) -> None:
|
||||||
"""修改命令权限"""
|
"""修改命令权限"""
|
||||||
await self.alter_cmd_c.alter_cmd(event)
|
await self.alter_cmd_c.alter_cmd(event)
|
||||||
+4
-5
@@ -17,11 +17,11 @@ from astrbot.core.utils.session_waiter import (
|
|||||||
class Main(Star):
|
class Main(Star):
|
||||||
"""会话控制"""
|
"""会话控制"""
|
||||||
|
|
||||||
def __init__(self, context: Context):
|
def __init__(self, context: Context) -> None:
|
||||||
super().__init__(context)
|
super().__init__(context)
|
||||||
|
|
||||||
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
|
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
|
||||||
async def handle_session_control_agent(self, event: AstrMessageEvent):
|
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
|
||||||
"""会话控制代理"""
|
"""会话控制代理"""
|
||||||
for session_filter in FILTERS:
|
for session_filter in FILTERS:
|
||||||
session_id = session_filter.filter(event)
|
session_id = session_filter.filter(event)
|
||||||
@@ -49,7 +49,7 @@ class Main(Star):
|
|||||||
if p_settings.get("empty_mention_waiting_need_reply", True):
|
if p_settings.get("empty_mention_waiting_need_reply", True):
|
||||||
try:
|
try:
|
||||||
# 尝试使用 LLM 生成更生动的回复
|
# 尝试使用 LLM 生成更生动的回复
|
||||||
func_tools_mgr = self.context.get_llm_tool_manager()
|
# func_tools_mgr = self.context.get_llm_tool_manager()
|
||||||
|
|
||||||
# 获取用户当前的对话信息
|
# 获取用户当前的对话信息
|
||||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||||
@@ -76,7 +76,6 @@ class Main(Star):
|
|||||||
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
|
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
|
||||||
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
|
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
|
||||||
),
|
),
|
||||||
func_tool_manager=func_tools_mgr,
|
|
||||||
session_id=curr_cid,
|
session_id=curr_cid,
|
||||||
contexts=[],
|
contexts=[],
|
||||||
system_prompt="",
|
system_prompt="",
|
||||||
@@ -91,7 +90,7 @@ class Main(Star):
|
|||||||
async def empty_mention_waiter(
|
async def empty_mention_waiter(
|
||||||
controller: SessionController,
|
controller: SessionController,
|
||||||
event: AstrMessageEvent,
|
event: AstrMessageEvent,
|
||||||
):
|
) -> None:
|
||||||
event.message_obj.message.insert(
|
event.message_obj.message.insert(
|
||||||
0,
|
0,
|
||||||
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
|
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
|
||||||
+2
-1
@@ -32,6 +32,7 @@ class SearchResult:
|
|||||||
title: str
|
title: str
|
||||||
url: str
|
url: str
|
||||||
snippet: str
|
snippet: str
|
||||||
|
favicon: str | None = None
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.title} - {self.url}\n{self.snippet}"
|
return f"{self.title} - {self.url}\n{self.snippet}"
|
||||||
@@ -48,7 +49,7 @@ class SearchEngine:
|
|||||||
def _set_selector(self, selector: str) -> str:
|
def _set_selector(self, selector: str) -> str:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def _get_next_page(self, query: str):
|
async def _get_next_page(self, query: str) -> str:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def _get_html(self, url: str, data: dict | None = None) -> str:
|
async def _get_html(self, url: str, data: dict | None = None) -> str:
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import random
|
import random
|
||||||
|
import uuid
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from readability import Document
|
from readability import Document
|
||||||
|
|
||||||
from astrbot.api import AstrBotConfig, llm_tool, logger, star
|
from astrbot.api import AstrBotConfig, llm_tool, logger, sp, star
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
from astrbot.api.event import AstrMessageEvent, filter
|
||||||
from astrbot.api.provider import ProviderRequest
|
from astrbot.api.provider import ProviderRequest
|
||||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ class Main(star.Star):
|
|||||||
"fetch_url",
|
"fetch_url",
|
||||||
"web_search_tavily",
|
"web_search_tavily",
|
||||||
"tavily_extract_web_page",
|
"tavily_extract_web_page",
|
||||||
|
"web_search_bocha",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, context: star.Context) -> None:
|
def __init__(self, context: star.Context) -> None:
|
||||||
@@ -28,6 +31,9 @@ class Main(star.Star):
|
|||||||
self.tavily_key_index = 0
|
self.tavily_key_index = 0
|
||||||
self.tavily_key_lock = asyncio.Lock()
|
self.tavily_key_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
self.bocha_key_index = 0
|
||||||
|
self.bocha_key_lock = asyncio.Lock()
|
||||||
|
|
||||||
# 将 str 类型的 key 迁移至 list[str],并保存
|
# 将 str 类型的 key 迁移至 list[str],并保存
|
||||||
cfg = self.context.get_config()
|
cfg = self.context.get_config()
|
||||||
provider_settings = cfg.get("provider_settings")
|
provider_settings = cfg.get("provider_settings")
|
||||||
@@ -43,6 +49,14 @@ class Main(star.Star):
|
|||||||
provider_settings["websearch_tavily_key"] = []
|
provider_settings["websearch_tavily_key"] = []
|
||||||
cfg.save_config()
|
cfg.save_config()
|
||||||
|
|
||||||
|
bocha_key = provider_settings.get("websearch_bocha_key")
|
||||||
|
if isinstance(bocha_key, str):
|
||||||
|
if bocha_key:
|
||||||
|
provider_settings["websearch_bocha_key"] = [bocha_key]
|
||||||
|
else:
|
||||||
|
provider_settings["websearch_bocha_key"] = []
|
||||||
|
cfg.save_config()
|
||||||
|
|
||||||
self.bing_search = Bing()
|
self.bing_search = Bing()
|
||||||
self.sogo_search = Sogo()
|
self.sogo_search = Sogo()
|
||||||
self.baidu_initialized = False
|
self.baidu_initialized = False
|
||||||
@@ -56,7 +70,7 @@ class Main(star.Star):
|
|||||||
header = HEADERS
|
header = HEADERS
|
||||||
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
||||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||||
async with session.get(url, headers=header, timeout=6) as response:
|
async with session.get(url, headers=header) as response:
|
||||||
html = await response.text(encoding="utf-8")
|
html = await response.text(encoding="utf-8")
|
||||||
doc = Document(html)
|
doc = Document(html)
|
||||||
ret = doc.summary(html_partial=True)
|
ret = doc.summary(html_partial=True)
|
||||||
@@ -137,7 +151,6 @@ class Main(star.Star):
|
|||||||
url,
|
url,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers=header,
|
headers=header,
|
||||||
timeout=6,
|
|
||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
reason = await response.text()
|
reason = await response.text()
|
||||||
@@ -151,6 +164,7 @@ class Main(star.Star):
|
|||||||
title=item.get("title"),
|
title=item.get("title"),
|
||||||
url=item.get("url"),
|
url=item.get("url"),
|
||||||
snippet=item.get("content"),
|
snippet=item.get("content"),
|
||||||
|
favicon=item.get("favicon"),
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
return results
|
return results
|
||||||
@@ -168,7 +182,6 @@ class Main(star.Star):
|
|||||||
url,
|
url,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers=header,
|
headers=header,
|
||||||
timeout=6,
|
|
||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
reason = await response.text()
|
reason = await response.text()
|
||||||
@@ -183,14 +196,6 @@ class Main(star.Star):
|
|||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@filter.command("websearch")
|
|
||||||
async def websearch(self, event: AstrMessageEvent, oper: str | None = None):
|
|
||||||
event.set_result(
|
|
||||||
MessageEventResult().message(
|
|
||||||
"此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@llm_tool(name="web_search")
|
@llm_tool(name="web_search")
|
||||||
async def search_from_search_engine(
|
async def search_from_search_engine(
|
||||||
self,
|
self,
|
||||||
@@ -230,7 +235,7 @@ class Main(star.Star):
|
|||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
async def ensure_baidu_ai_search_mcp(self, umo: str | None = None):
|
async def ensure_baidu_ai_search_mcp(self, umo: str | None = None) -> None:
|
||||||
if self.baidu_initialized:
|
if self.baidu_initialized:
|
||||||
return
|
return
|
||||||
cfg = self.context.get_config(umo=umo)
|
cfg = self.context.get_config(umo=umo)
|
||||||
@@ -249,7 +254,7 @@ class Main(star.Star):
|
|||||||
"transport": "sse",
|
"transport": "sse",
|
||||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||||
"headers": {},
|
"headers": {},
|
||||||
"timeout": 30,
|
"timeout": 600,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.baidu_initialized = True
|
self.baidu_initialized = True
|
||||||
@@ -271,7 +276,7 @@ class Main(star.Star):
|
|||||||
self,
|
self,
|
||||||
event: AstrMessageEvent,
|
event: AstrMessageEvent,
|
||||||
query: str,
|
query: str,
|
||||||
max_results: int = 5,
|
max_results: int = 7,
|
||||||
search_depth: str = "basic",
|
search_depth: str = "basic",
|
||||||
topic: str = "general",
|
topic: str = "general",
|
||||||
days: int = 3,
|
days: int = 3,
|
||||||
@@ -284,7 +289,7 @@ class Main(star.Star):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
query(string): Required. Search query.
|
query(string): Required. Search query.
|
||||||
max_results(number): Optional. The maximum number of results to return. Default is 5. Range is 5-20.
|
max_results(number): Optional. The maximum number of results to return. Default is 7. Range is 5-20.
|
||||||
search_depth(string): Optional. The depth of the search, must be one of 'basic', 'advanced'. Default is "basic".
|
search_depth(string): Optional. The depth of the search, must be one of 'basic', 'advanced'. Default is "basic".
|
||||||
topic(string): Optional. The topic of the search, must be one of 'general', 'news'. Default is "general".
|
topic(string): Optional. The topic of the search, must be one of 'general', 'news'. Default is "general".
|
||||||
days(number): Optional. The number of days back from the current date to include in the search results. Please note that this feature is only available when using the 'news' search topic.
|
days(number): Optional. The number of days back from the current date to include in the search results. Please note that this feature is only available when using the 'news' search topic.
|
||||||
@@ -295,15 +300,12 @@ class Main(star.Star):
|
|||||||
"""
|
"""
|
||||||
logger.info(f"web_searcher - search_from_tavily: {query}")
|
logger.info(f"web_searcher - search_from_tavily: {query}")
|
||||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||||
websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||||
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
||||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
||||||
|
|
||||||
# build payload
|
# build payload
|
||||||
payload = {
|
payload = {"query": query, "max_results": max_results, "include_favicon": True}
|
||||||
"query": query,
|
|
||||||
"max_results": max_results,
|
|
||||||
}
|
|
||||||
if search_depth not in ["basic", "advanced"]:
|
if search_depth not in ["basic", "advanced"]:
|
||||||
search_depth = "basic"
|
search_depth = "basic"
|
||||||
payload["search_depth"] = search_depth
|
payload["search_depth"] = search_depth
|
||||||
@@ -327,14 +329,22 @@ class Main(star.Star):
|
|||||||
return "Error: Tavily web searcher does not return any results."
|
return "Error: Tavily web searcher does not return any results."
|
||||||
|
|
||||||
ret_ls = []
|
ret_ls = []
|
||||||
for result in results:
|
ref_uuid = str(uuid.uuid4())[:4]
|
||||||
ret_ls.append(f"\nTitle: {result.title}")
|
for idx, result in enumerate(results, 1):
|
||||||
ret_ls.append(f"URL: {result.url}")
|
index = f"{ref_uuid}.{idx}"
|
||||||
ret_ls.append(f"Content: {result.snippet}")
|
ret_ls.append(
|
||||||
ret = "\n".join(ret_ls)
|
{
|
||||||
|
"title": f"{result.title}",
|
||||||
if websearch_link:
|
"url": f"{result.url}",
|
||||||
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
|
"snippet": f"{result.snippet}",
|
||||||
|
# TODO: do not need ref for non-webchat platform adapter
|
||||||
|
"index": index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if result.favicon:
|
||||||
|
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
|
||||||
|
# ret = "\n".join(ret_ls)
|
||||||
|
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@llm_tool("tavily_extract_web_page")
|
@llm_tool("tavily_extract_web_page")
|
||||||
@@ -373,12 +383,166 @@ class Main(star.Star):
|
|||||||
return "Error: Tavily web searcher does not return any results."
|
return "Error: Tavily web searcher does not return any results."
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
async def _get_bocha_key(self, cfg: AstrBotConfig) -> str:
|
||||||
|
"""并发安全的从列表中获取并轮换BoCha API密钥。"""
|
||||||
|
bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", [])
|
||||||
|
if not bocha_keys:
|
||||||
|
raise ValueError("错误:BoCha API密钥未在AstrBot中配置。")
|
||||||
|
|
||||||
|
async with self.bocha_key_lock:
|
||||||
|
key = bocha_keys[self.bocha_key_index]
|
||||||
|
self.bocha_key_index = (self.bocha_key_index + 1) % len(bocha_keys)
|
||||||
|
return key
|
||||||
|
|
||||||
|
async def _web_search_bocha(
|
||||||
|
self,
|
||||||
|
cfg: AstrBotConfig,
|
||||||
|
payload: dict,
|
||||||
|
) -> list[SearchResult]:
|
||||||
|
"""使用 BoCha 搜索引擎进行搜索"""
|
||||||
|
bocha_key = await self._get_bocha_key(cfg)
|
||||||
|
url = "https://api.bochaai.com/v1/web-search"
|
||||||
|
header = {
|
||||||
|
"Authorization": f"Bearer {bocha_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||||
|
async with session.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers=header,
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
reason = await response.text()
|
||||||
|
raise Exception(
|
||||||
|
f"BoCha web search failed: {reason}, status: {response.status}",
|
||||||
|
)
|
||||||
|
data = await response.json()
|
||||||
|
data = data["data"]["webPages"]["value"]
|
||||||
|
results = []
|
||||||
|
for item in data:
|
||||||
|
result = SearchResult(
|
||||||
|
title=item.get("name"),
|
||||||
|
url=item.get("url"),
|
||||||
|
snippet=item.get("snippet"),
|
||||||
|
favicon=item.get("siteIcon"),
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@llm_tool("web_search_bocha")
|
||||||
|
async def search_from_bocha(
|
||||||
|
self,
|
||||||
|
event: AstrMessageEvent,
|
||||||
|
query: str,
|
||||||
|
freshness: str = "noLimit",
|
||||||
|
summary: bool = False,
|
||||||
|
include: str = "",
|
||||||
|
exclude: str = "",
|
||||||
|
count: int = 10,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
A web search tool based on Bocha Search API, used to retrieve web pages
|
||||||
|
related to the user's query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query (string): Required. User's search query.
|
||||||
|
|
||||||
|
freshness (string): Optional. Specifies the time range of the search.
|
||||||
|
Supported values:
|
||||||
|
- "noLimit": No time limit (default, recommended).
|
||||||
|
- "oneDay": Within one day.
|
||||||
|
- "oneWeek": Within one week.
|
||||||
|
- "oneMonth": Within one month.
|
||||||
|
- "oneYear": Within one year.
|
||||||
|
- "YYYY-MM-DD..YYYY-MM-DD": Search within a specific date range.
|
||||||
|
Example: "2025-01-01..2025-04-06".
|
||||||
|
- "YYYY-MM-DD": Search on a specific date.
|
||||||
|
Example: "2025-04-06".
|
||||||
|
It is recommended to use "noLimit", as the search algorithm will
|
||||||
|
automatically optimize time relevance. Manually restricting the
|
||||||
|
time range may result in no search results.
|
||||||
|
|
||||||
|
summary (boolean): Optional. Whether to include a text summary
|
||||||
|
for each search result.
|
||||||
|
- True: Include summary.
|
||||||
|
- False: Do not include summary (default).
|
||||||
|
|
||||||
|
include (string): Optional. Specifies the domains to include in
|
||||||
|
the search. Multiple domains can be separated by "|" or ",".
|
||||||
|
A maximum of 100 domains is allowed.
|
||||||
|
Examples:
|
||||||
|
- "qq.com"
|
||||||
|
- "qq.com|m.163.com"
|
||||||
|
|
||||||
|
exclude (string): Optional. Specifies the domains to exclude from
|
||||||
|
the search. Multiple domains can be separated by "|" or ",".
|
||||||
|
A maximum of 100 domains is allowed.
|
||||||
|
Examples:
|
||||||
|
- "qq.com"
|
||||||
|
- "qq.com|m.163.com"
|
||||||
|
|
||||||
|
count (number): Optional. Number of search results to return.
|
||||||
|
- Range: 1–50
|
||||||
|
- Default: 10
|
||||||
|
The actual number of returned results may be less than the
|
||||||
|
specified count.
|
||||||
|
"""
|
||||||
|
logger.info(f"web_searcher - search_from_bocha: {query}")
|
||||||
|
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||||
|
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||||
|
if not cfg.get("provider_settings", {}).get("websearch_bocha_key", []):
|
||||||
|
raise ValueError("Error: BoCha API key is not configured in AstrBot.")
|
||||||
|
|
||||||
|
# build payload
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"count": count,
|
||||||
|
}
|
||||||
|
|
||||||
|
# freshness:时间范围
|
||||||
|
if freshness:
|
||||||
|
payload["freshness"] = freshness
|
||||||
|
|
||||||
|
# 是否返回摘要
|
||||||
|
payload["summary"] = summary
|
||||||
|
|
||||||
|
# include:限制搜索域
|
||||||
|
if include:
|
||||||
|
payload["include"] = include
|
||||||
|
|
||||||
|
# exclude:排除搜索域
|
||||||
|
if exclude:
|
||||||
|
payload["exclude"] = exclude
|
||||||
|
|
||||||
|
results = await self._web_search_bocha(cfg, payload)
|
||||||
|
if not results:
|
||||||
|
return "Error: BoCha web searcher does not return any results."
|
||||||
|
|
||||||
|
ret_ls = []
|
||||||
|
ref_uuid = str(uuid.uuid4())[:4]
|
||||||
|
for idx, result in enumerate(results, 1):
|
||||||
|
index = f"{ref_uuid}.{idx}"
|
||||||
|
ret_ls.append(
|
||||||
|
{
|
||||||
|
"title": f"{result.title}",
|
||||||
|
"url": f"{result.url}",
|
||||||
|
"snippet": f"{result.snippet}",
|
||||||
|
"index": index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if result.favicon:
|
||||||
|
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
|
||||||
|
# ret = "\n".join(ret_ls)
|
||||||
|
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
|
||||||
|
return ret
|
||||||
|
|
||||||
@filter.on_llm_request(priority=-10000)
|
@filter.on_llm_request(priority=-10000)
|
||||||
async def edit_web_search_tools(
|
async def edit_web_search_tools(
|
||||||
self,
|
self,
|
||||||
event: AstrMessageEvent,
|
event: AstrMessageEvent,
|
||||||
req: ProviderRequest,
|
req: ProviderRequest,
|
||||||
):
|
) -> None:
|
||||||
"""Get the session conversation for the given event."""
|
"""Get the session conversation for the given event."""
|
||||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||||
prov_settings = cfg.get("provider_settings", {})
|
prov_settings = cfg.get("provider_settings", {})
|
||||||
@@ -410,6 +574,7 @@ class Main(star.Star):
|
|||||||
tool_set.remove_tool("web_search_tavily")
|
tool_set.remove_tool("web_search_tavily")
|
||||||
tool_set.remove_tool("tavily_extract_web_page")
|
tool_set.remove_tool("tavily_extract_web_page")
|
||||||
tool_set.remove_tool("AIsearch")
|
tool_set.remove_tool("AIsearch")
|
||||||
|
tool_set.remove_tool("web_search_bocha")
|
||||||
elif provider == "tavily":
|
elif provider == "tavily":
|
||||||
web_search_tavily = func_tool_mgr.get_func("web_search_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")
|
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
|
||||||
@@ -420,6 +585,7 @@ class Main(star.Star):
|
|||||||
tool_set.remove_tool("web_search")
|
tool_set.remove_tool("web_search")
|
||||||
tool_set.remove_tool("fetch_url")
|
tool_set.remove_tool("fetch_url")
|
||||||
tool_set.remove_tool("AIsearch")
|
tool_set.remove_tool("AIsearch")
|
||||||
|
tool_set.remove_tool("web_search_bocha")
|
||||||
elif provider == "baidu_ai_search":
|
elif provider == "baidu_ai_search":
|
||||||
try:
|
try:
|
||||||
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
|
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
|
||||||
@@ -431,5 +597,15 @@ class Main(star.Star):
|
|||||||
tool_set.remove_tool("fetch_url")
|
tool_set.remove_tool("fetch_url")
|
||||||
tool_set.remove_tool("web_search_tavily")
|
tool_set.remove_tool("web_search_tavily")
|
||||||
tool_set.remove_tool("tavily_extract_web_page")
|
tool_set.remove_tool("tavily_extract_web_page")
|
||||||
|
tool_set.remove_tool("web_search_bocha")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
|
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
|
||||||
|
elif provider == "bocha":
|
||||||
|
web_search_bocha = func_tool_mgr.get_func("web_search_bocha")
|
||||||
|
if web_search_bocha:
|
||||||
|
tool_set.add_tool(web_search_bocha)
|
||||||
|
tool_set.remove_tool("web_search")
|
||||||
|
tool_set.remove_tool("fetch_url")
|
||||||
|
tool_set.remove_tool("AIsearch")
|
||||||
|
tool_set.remove_tool("web_search_tavily")
|
||||||
|
tool_set.remove_tool("tavily_extract_web_page")
|
||||||
@@ -1 +1,6 @@
|
|||||||
__version__ = "4.9.0"
|
from importlib import metadata
|
||||||
|
|
||||||
|
try:
|
||||||
|
__version__ = metadata.version("AstrBot")
|
||||||
|
except metadata.PackageNotFoundError:
|
||||||
|
__version__ = "unknown"
|
||||||
|
|||||||
+10
-8
@@ -1,11 +1,11 @@
|
|||||||
"""AstrBot CLI入口"""
|
"""AstrBot CLI entry point"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .commands import conf, init, plug, run
|
from .commands import bk, conf, init, plug, run, uninstall
|
||||||
|
|
||||||
logo_tmpl = r"""
|
logo_tmpl = r"""
|
||||||
___ _______.___________..______ .______ ______ .___________.
|
___ _______.___________..______ .______ ______ .___________.
|
||||||
@@ -29,23 +29,23 @@ def cli() -> None:
|
|||||||
@click.command()
|
@click.command()
|
||||||
@click.argument("command_name", required=False, type=str)
|
@click.argument("command_name", required=False, type=str)
|
||||||
def help(command_name: str | None) -> None:
|
def help(command_name: str | None) -> None:
|
||||||
"""显示命令的帮助信息
|
"""Display help information for commands
|
||||||
|
|
||||||
如果提供了 COMMAND_NAME,则显示该命令的详细帮助信息。
|
If COMMAND_NAME is provided, display detailed help for that command.
|
||||||
否则,显示通用帮助信息。
|
Otherwise, display general help information.
|
||||||
"""
|
"""
|
||||||
ctx = click.get_current_context()
|
ctx = click.get_current_context()
|
||||||
if command_name:
|
if command_name:
|
||||||
# 查找指定命令
|
# Find the specified command
|
||||||
command = cli.get_command(ctx, command_name)
|
command = cli.get_command(ctx, command_name)
|
||||||
if command:
|
if command:
|
||||||
# 显示特定命令的帮助信息
|
# Display help for the specific command
|
||||||
click.echo(command.get_help(ctx))
|
click.echo(command.get_help(ctx))
|
||||||
else:
|
else:
|
||||||
click.echo(f"Unknown command: {command_name}")
|
click.echo(f"Unknown command: {command_name}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
# 显示通用帮助信息
|
# Display general help information
|
||||||
click.echo(cli.get_help(ctx))
|
click.echo(cli.get_help(ctx))
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +54,8 @@ cli.add_command(run)
|
|||||||
cli.add_command(help)
|
cli.add_command(help)
|
||||||
cli.add_command(plug)
|
cli.add_command(plug)
|
||||||
cli.add_command(conf)
|
cli.add_command(conf)
|
||||||
|
cli.add_command(uninstall)
|
||||||
|
cli.add_command(bk)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
from .cmd_bk import bk
|
||||||
from .cmd_conf import conf
|
from .cmd_conf import conf
|
||||||
from .cmd_init import init
|
from .cmd_init import init
|
||||||
from .cmd_plug import plug
|
from .cmd_plug import plug
|
||||||
from .cmd_run import run
|
from .cmd_run import run
|
||||||
|
from .cmd_uninstall import uninstall
|
||||||
|
|
||||||
__all__ = ["conf", "init", "plug", "run"]
|
__all__ = ["conf", "init", "plug", "run", "uninstall", "bk"]
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from astrbot.core import astrbot_config, db_helper
|
||||||
|
from astrbot.core.backup import AstrBotExporter, AstrBotImporter
|
||||||
|
|
||||||
|
# Try importing KnowledgeBaseManager to support KB backup
|
||||||
|
try:
|
||||||
|
from astrbot.core.knowledge.kb_manager import KnowledgeBaseManager
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from astrbot.core.knowledge_base.kb_manager import KnowledgeBaseManager
|
||||||
|
except ImportError:
|
||||||
|
KnowledgeBaseManager = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_kb_manager():
|
||||||
|
if KnowledgeBaseManager is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Best effort initialization
|
||||||
|
kb_mgr = KnowledgeBaseManager(astrbot_config, db_helper)
|
||||||
|
# If there are async load methods, we might need to call them
|
||||||
|
if hasattr(kb_mgr, "load_kbs_from_db"):
|
||||||
|
await kb_mgr.load_kbs_from_db()
|
||||||
|
elif hasattr(kb_mgr, "load_all"):
|
||||||
|
await kb_mgr.load_all()
|
||||||
|
return kb_mgr
|
||||||
|
except Exception:
|
||||||
|
# If KB manager fails to load (e.g. missing dependencies), return None
|
||||||
|
# so we can still backup other data
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(name="bk")
|
||||||
|
def bk():
|
||||||
|
"""Backup management (Export/Import)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@bk.command(name="export")
|
||||||
|
@click.option("--output", "-o", help="Output directory", default=None)
|
||||||
|
@click.option(
|
||||||
|
"--gpg-sign", "-S", is_flag=True, help="Sign backup with GPG default private key"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--gpg-encrypt",
|
||||||
|
"-E",
|
||||||
|
help="Encrypt for GPG recipient (Asymmetric)",
|
||||||
|
metavar="RECIPIENT",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--gpg-symmetric", "-C", is_flag=True, help="Encrypt with symmetric cipher (GPG)"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--digest",
|
||||||
|
"-d",
|
||||||
|
type=click.Choice(["md5", "sha1", "sha256", "sha512"]),
|
||||||
|
help="Generate digital digest",
|
||||||
|
)
|
||||||
|
def export_data(
|
||||||
|
output: str | None,
|
||||||
|
gpg_sign: bool,
|
||||||
|
gpg_encrypt: str | None,
|
||||||
|
gpg_symmetric: bool,
|
||||||
|
digest: str | None,
|
||||||
|
):
|
||||||
|
"""Export all AstrBot data to a backup archive.
|
||||||
|
|
||||||
|
If any GPG option (-S, -E, -C) is used, the output file will be processed by GPG
|
||||||
|
and saved with a .gpg extension.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
\b
|
||||||
|
1. Standard Export:
|
||||||
|
astrbot bk export
|
||||||
|
-> Generates a plain .zip file.
|
||||||
|
|
||||||
|
\b
|
||||||
|
2. Signed Backup (Integrity Check):
|
||||||
|
astrbot bk export -S
|
||||||
|
-> Generates a .zip.gpg file containing the backup and your signature.
|
||||||
|
-> NOT ENCRYPTED, but packaged in OpenPGP format.
|
||||||
|
-> Use 'astrbot bk import' or 'gpg --verify' to check integrity.
|
||||||
|
|
||||||
|
\b
|
||||||
|
3. Password Protected (Symmetric Encryption):
|
||||||
|
astrbot bk export -C
|
||||||
|
-> Generates an encrypted .zip.gpg file.
|
||||||
|
-> Prompts for a passphrase.
|
||||||
|
-> Only accessible with the passphrase.
|
||||||
|
|
||||||
|
\b
|
||||||
|
4. Encrypted for Recipient (Asymmetric Encryption):
|
||||||
|
astrbot bk export -E "alice@example.com"
|
||||||
|
-> Generates an encrypted .zip.gpg file for Alice.
|
||||||
|
-> Only Alice's private key can decrypt it.
|
||||||
|
|
||||||
|
\b
|
||||||
|
5. Signed and Encrypted with Digest:
|
||||||
|
astrbot bk export -S -E "bob@example.com" -d sha256
|
||||||
|
-> Signs, encrypts for Bob, and generates a SHA256 checksum file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Handle case where -E consumes the next flag (e.g. -E -S)
|
||||||
|
if gpg_encrypt and gpg_encrypt.startswith("-"):
|
||||||
|
consumed_flag = gpg_encrypt
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
f"Warning: Flag '{consumed_flag}' was interpreted as the recipient for -E.",
|
||||||
|
fg="yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recover flags
|
||||||
|
if consumed_flag == "-S":
|
||||||
|
gpg_sign = True
|
||||||
|
click.echo("Recovered flag -S (Sign).")
|
||||||
|
elif consumed_flag == "-C":
|
||||||
|
gpg_symmetric = True
|
||||||
|
click.echo("Recovered flag -C (Symmetric).")
|
||||||
|
|
||||||
|
# Prompt for the actual recipient
|
||||||
|
gpg_encrypt = click.prompt("Please enter the GPG recipient (email or key ID)")
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
if gpg_sign or gpg_encrypt or gpg_symmetric:
|
||||||
|
if not shutil.which("gpg"):
|
||||||
|
raise click.ClickException(
|
||||||
|
"GPG tool not found. Please install GnuPG to use encryption/signing features."
|
||||||
|
)
|
||||||
|
|
||||||
|
kb_mgr = await _get_kb_manager()
|
||||||
|
exporter = AstrBotExporter(db_helper, kb_mgr)
|
||||||
|
|
||||||
|
async def on_progress(stage, current, total, message):
|
||||||
|
click.echo(f"[{stage}] {message}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
path_str = await exporter.export_all(output, progress_callback=on_progress)
|
||||||
|
final_path = Path(path_str)
|
||||||
|
click.echo(
|
||||||
|
click.style(f"\nRaw backup exported to: {final_path}", fg="green")
|
||||||
|
)
|
||||||
|
|
||||||
|
# GPG Operations
|
||||||
|
if gpg_sign or gpg_encrypt or gpg_symmetric:
|
||||||
|
# Construct GPG command
|
||||||
|
# output file usually ends with .gpg
|
||||||
|
gpg_output = final_path.with_name(final_path.name + ".gpg")
|
||||||
|
cmd = ["gpg", "--output", str(gpg_output), "--yes"]
|
||||||
|
|
||||||
|
if gpg_symmetric:
|
||||||
|
if gpg_encrypt:
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
"Warning: Symmetric encryption selected, ignoring asymmetric recipient.",
|
||||||
|
fg="yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cmd.append("--symmetric")
|
||||||
|
# No --batch to allow interactive passphrase entry on TTY
|
||||||
|
else:
|
||||||
|
# Asymmetric or just Sign
|
||||||
|
# Note: If encrypting, -s adds signature to the encrypted packet.
|
||||||
|
if gpg_encrypt:
|
||||||
|
cmd.extend(["--encrypt", "--recipient", gpg_encrypt])
|
||||||
|
|
||||||
|
if gpg_sign:
|
||||||
|
cmd.append("--sign")
|
||||||
|
|
||||||
|
cmd.append(str(final_path))
|
||||||
|
|
||||||
|
click.echo(f"Running GPG: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
# Replace subprocess.run with asyncio.create_subprocess_exec to avoid blocking the event loop
|
||||||
|
process = await asyncio.create_subprocess_exec(*cmd)
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise subprocess.CalledProcessError(process.returncode or 1, cmd)
|
||||||
|
|
||||||
|
# Clean up original file
|
||||||
|
final_path.unlink()
|
||||||
|
final_path = gpg_output
|
||||||
|
click.echo(
|
||||||
|
click.style(f"Processed backup created: {final_path}", fg="green")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Digest Generation
|
||||||
|
if digest:
|
||||||
|
click.echo(f"Calculating {digest} digest...")
|
||||||
|
hash_func = getattr(hashlib, digest)()
|
||||||
|
# Read file in chunks
|
||||||
|
with open(final_path, "rb") as f:
|
||||||
|
while chunk := f.read(8192):
|
||||||
|
hash_func.update(chunk)
|
||||||
|
|
||||||
|
digest_val = hash_func.hexdigest()
|
||||||
|
digest_file = final_path.with_name(final_path.name + f".{digest}")
|
||||||
|
digest_file.write_text(
|
||||||
|
f"{digest_val} *{final_path.name}\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
click.echo(click.style(f"Digest generated: {digest_file}", fg="green"))
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
click.echo(click.style(f"\nGPG process failed: {e}", fg="red"), err=True)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style(f"\nExport failed: {e}", fg="red"), err=True)
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@bk.command(name="import")
|
||||||
|
@click.argument("backup_file")
|
||||||
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
|
||||||
|
def import_data_command(backup_file: str, yes: bool):
|
||||||
|
"""Import AstrBot data from a backup archive.
|
||||||
|
|
||||||
|
Automatically handles .zip files and .gpg files (signed or encrypted).
|
||||||
|
If the file is encrypted, you will be prompted for the passphrase.
|
||||||
|
If a digest file (.sha256, .md5, etc.) exists, it will be verified automatically.
|
||||||
|
"""
|
||||||
|
backup_path = Path(backup_file)
|
||||||
|
if not backup_path.exists():
|
||||||
|
raise click.ClickException(f"Backup file not found: {backup_file}")
|
||||||
|
|
||||||
|
# 1. Verify Digest if exists
|
||||||
|
def _verify_digest(file_path: Path) -> bool:
|
||||||
|
supported_digests = ["sha256", "sha512", "md5", "sha1"]
|
||||||
|
digest_verified = True # Default true if no digest file found
|
||||||
|
|
||||||
|
for algo in supported_digests:
|
||||||
|
digest_file = file_path.with_name(f"{file_path.name}.{algo}")
|
||||||
|
if digest_file.exists():
|
||||||
|
click.echo(f"Found digest file: {digest_file.name}")
|
||||||
|
try:
|
||||||
|
# Parse digest file
|
||||||
|
content = digest_file.read_text(encoding="utf-8").strip()
|
||||||
|
# Format: "digest *filename" or "digest filename"
|
||||||
|
# We expect the hash to be the first part
|
||||||
|
if " " in content:
|
||||||
|
expected_digest = content.split()[0].lower()
|
||||||
|
else:
|
||||||
|
expected_digest = content.lower()
|
||||||
|
|
||||||
|
click.echo(f"Verifying {algo} digest...")
|
||||||
|
hash_func = getattr(hashlib, algo)()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
while chunk := f.read(8192):
|
||||||
|
hash_func.update(chunk)
|
||||||
|
|
||||||
|
calculated_digest = hash_func.hexdigest().lower()
|
||||||
|
|
||||||
|
if calculated_digest == expected_digest:
|
||||||
|
click.echo(
|
||||||
|
click.style("Digest verification PASSED.", fg="green")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
"Digest verification FAILED!", fg="red", bold=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
click.echo(f" Expected: {expected_digest}")
|
||||||
|
click.echo(f" Actual: {calculated_digest}")
|
||||||
|
digest_verified = False
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style(f"Error checking digest: {e}", fg="red"))
|
||||||
|
digest_verified = False
|
||||||
|
|
||||||
|
return digest_verified
|
||||||
|
|
||||||
|
if not _verify_digest(backup_path):
|
||||||
|
if not yes:
|
||||||
|
if not click.confirm(
|
||||||
|
"Digest verification failed. Abort import?", default=True, abort=True
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
"Warning: Digest verification failed. Continuing due to --yes.",
|
||||||
|
fg="yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not yes:
|
||||||
|
click.confirm(
|
||||||
|
"This will OVERWRITE all current data (DB, Config, Plugins). Continue?",
|
||||||
|
abort=True,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
zip_path = backup_path
|
||||||
|
is_temp_file = False
|
||||||
|
|
||||||
|
# Handle GPG encrypted files
|
||||||
|
if backup_path.suffix == ".gpg":
|
||||||
|
if not shutil.which("gpg"):
|
||||||
|
raise click.ClickException(
|
||||||
|
"GPG tool not found. Cannot decrypt .gpg file."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove .gpg extension for output
|
||||||
|
decrypted_path = backup_path.with_suffix("")
|
||||||
|
# If it doesn't look like a zip after stripping .gpg, maybe append .zip?
|
||||||
|
# But the exporter creates .zip.gpg, so stripping .gpg gives .zip.
|
||||||
|
|
||||||
|
click.echo(f"Processing GPG file {backup_path}...")
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
"gpg",
|
||||||
|
"--output",
|
||||||
|
str(decrypted_path),
|
||||||
|
"--decrypt", # This handles both decryption and signature verification/extraction
|
||||||
|
str(backup_path),
|
||||||
|
]
|
||||||
|
# Allow interactive passphrase
|
||||||
|
process = await asyncio.create_subprocess_exec(*cmd)
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise subprocess.CalledProcessError(process.returncode or 1, cmd)
|
||||||
|
|
||||||
|
zip_path = decrypted_path
|
||||||
|
is_temp_file = True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
"GPG processing failed. Verify signature or decryption key.",
|
||||||
|
fg="red",
|
||||||
|
),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
kb_mgr = await _get_kb_manager()
|
||||||
|
importer = AstrBotImporter(db_helper, kb_mgr)
|
||||||
|
|
||||||
|
async def on_progress(stage, current, total, message):
|
||||||
|
click.echo(f"[{stage}] {message}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await importer.import_all(
|
||||||
|
str(zip_path), progress_callback=on_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.errors:
|
||||||
|
click.echo(
|
||||||
|
click.style("\nImport failed with errors:", fg="red"), err=True
|
||||||
|
)
|
||||||
|
for err in result.errors:
|
||||||
|
click.echo(f" - {err}", err=True)
|
||||||
|
else:
|
||||||
|
click.echo(click.style("\nImport completed successfully!", fg="green"))
|
||||||
|
|
||||||
|
if result.warnings:
|
||||||
|
click.echo(click.style("\nWarnings:", fg="yellow"))
|
||||||
|
for warn in result.warnings:
|
||||||
|
click.echo(f" - {warn}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if is_temp_file and zip_path.exists():
|
||||||
|
zip_path.unlink()
|
||||||
|
click.echo(f"Cleaned up temporary file: {zip_path}")
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
@@ -6,61 +6,67 @@ from typing import Any
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from ..utils import check_astrbot_root, get_astrbot_root
|
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||||
|
|
||||||
|
from ..utils import check_astrbot_root
|
||||||
|
|
||||||
|
|
||||||
def _validate_log_level(value: str) -> str:
|
def _validate_log_level(value: str) -> str:
|
||||||
"""验证日志级别"""
|
"""Validate log level"""
|
||||||
value = value.upper()
|
value = value.upper()
|
||||||
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
|
"Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
|
||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _validate_dashboard_port(value: str) -> int:
|
def _validate_dashboard_port(value: str) -> int:
|
||||||
"""验证 Dashboard 端口"""
|
"""Validate Dashboard port"""
|
||||||
try:
|
try:
|
||||||
port = int(value)
|
port = int(value)
|
||||||
if port < 1 or port > 65535:
|
if port < 1 or port > 65535:
|
||||||
raise click.ClickException("端口必须在 1-65535 范围内")
|
raise click.ClickException("Port must be in range 1-65535")
|
||||||
return port
|
return port
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise click.ClickException("端口必须是数字")
|
raise click.ClickException("Port must be a number")
|
||||||
|
|
||||||
|
|
||||||
def _validate_dashboard_username(value: str) -> str:
|
def _validate_dashboard_username(value: str) -> str:
|
||||||
"""验证 Dashboard 用户名"""
|
"""Validate Dashboard username"""
|
||||||
if not value:
|
if not value:
|
||||||
raise click.ClickException("用户名不能为空")
|
raise click.ClickException("Username cannot be empty")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _validate_dashboard_password(value: str) -> str:
|
def _validate_dashboard_password(value: str) -> str:
|
||||||
"""验证 Dashboard 密码"""
|
"""Validate Dashboard password"""
|
||||||
if not value:
|
if not value:
|
||||||
raise click.ClickException("密码不能为空")
|
raise click.ClickException("Password cannot be empty")
|
||||||
return hashlib.md5(value.encode()).hexdigest()
|
return hashlib.md5(value.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _validate_timezone(value: str) -> str:
|
def _validate_timezone(value: str) -> str:
|
||||||
"""验证时区"""
|
"""Validate timezone"""
|
||||||
try:
|
try:
|
||||||
zoneinfo.ZoneInfo(value)
|
zoneinfo.ZoneInfo(value)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise click.ClickException(f"无效的时区: {value},请使用有效的IANA时区名称")
|
raise click.ClickException(
|
||||||
|
f"Invalid timezone: {value}. Please use a valid IANA timezone name"
|
||||||
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _validate_callback_api_base(value: str) -> str:
|
def _validate_callback_api_base(value: str) -> str:
|
||||||
"""验证回调接口基址"""
|
"""Validate callback API base URL"""
|
||||||
if not value.startswith("http://") and not value.startswith("https://"):
|
if not value.startswith("http://") and not value.startswith("https://"):
|
||||||
raise click.ClickException("回调接口基址必须以 http:// 或 https:// 开头")
|
raise click.ClickException(
|
||||||
|
"Callback API base must start with http:// or https://"
|
||||||
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
# 可通过CLI设置的配置项,配置键到验证器函数的映射
|
# Configuration items settable via CLI, mapping config keys to validator functions
|
||||||
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||||
"timezone": _validate_timezone,
|
"timezone": _validate_timezone,
|
||||||
"log_level": _validate_log_level,
|
"log_level": _validate_log_level,
|
||||||
@@ -72,14 +78,14 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
|||||||
|
|
||||||
|
|
||||||
def _load_config() -> dict[str, Any]:
|
def _load_config() -> dict[str, Any]:
|
||||||
"""加载或初始化配置文件"""
|
"""Load or initialize config file"""
|
||||||
root = get_astrbot_root()
|
root = astrbot_paths.root
|
||||||
if not check_astrbot_root(root):
|
if not check_astrbot_root(root):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||||
)
|
)
|
||||||
|
|
||||||
config_path = root / "data" / "cmd_config.json"
|
config_path = astrbot_paths.data / "cmd_config.json"
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
from astrbot.core.config.default import DEFAULT_CONFIG
|
from astrbot.core.config.default import DEFAULT_CONFIG
|
||||||
|
|
||||||
@@ -91,12 +97,12 @@ def _load_config() -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
return json.loads(config_path.read_text(encoding="utf-8-sig"))
|
return json.loads(config_path.read_text(encoding="utf-8-sig"))
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise click.ClickException(f"配置文件解析失败: {e!s}")
|
raise click.ClickException(f"Failed to parse config file: {e!s}")
|
||||||
|
|
||||||
|
|
||||||
def _save_config(config: dict[str, Any]) -> None:
|
def _save_config(config: dict[str, Any]) -> None:
|
||||||
"""保存配置文件"""
|
"""Save config file"""
|
||||||
config_path = get_astrbot_root() / "data" / "cmd_config.json"
|
config_path = astrbot_paths.data / "cmd_config.json"
|
||||||
|
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
json.dumps(config, ensure_ascii=False, indent=2),
|
json.dumps(config, ensure_ascii=False, indent=2),
|
||||||
@@ -105,21 +111,21 @@ def _save_config(config: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
|
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
|
||||||
"""设置嵌套字典中的值"""
|
"""Set a value in a nested dictionary"""
|
||||||
parts = path.split(".")
|
parts = path.split(".")
|
||||||
for part in parts[:-1]:
|
for part in parts[:-1]:
|
||||||
if part not in obj:
|
if part not in obj:
|
||||||
obj[part] = {}
|
obj[part] = {}
|
||||||
elif not isinstance(obj[part], dict):
|
elif not isinstance(obj[part], dict):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典",
|
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
|
||||||
)
|
)
|
||||||
obj = obj[part]
|
obj = obj[part]
|
||||||
obj[parts[-1]] = value
|
obj[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
||||||
"""获取嵌套字典中的值"""
|
"""Get a value from a nested dictionary"""
|
||||||
parts = path.split(".")
|
parts = path.split(".")
|
||||||
for part in parts:
|
for part in parts:
|
||||||
obj = obj[part]
|
obj = obj[part]
|
||||||
@@ -127,32 +133,32 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@click.group(name="conf")
|
@click.group(name="conf")
|
||||||
def conf():
|
def conf() -> None:
|
||||||
"""配置管理命令
|
"""Configuration management commands
|
||||||
|
|
||||||
支持的配置项:
|
Supported config keys:
|
||||||
|
|
||||||
- timezone: 时区设置 (例如: Asia/Shanghai)
|
- timezone: Timezone setting (e.g. Asia/Shanghai)
|
||||||
|
|
||||||
- log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
- log_level: Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||||
|
|
||||||
- dashboard.port: Dashboard 端口
|
- dashboard.port: Dashboard port
|
||||||
|
|
||||||
- dashboard.username: Dashboard 用户名
|
- dashboard.username: Dashboard username
|
||||||
|
|
||||||
- dashboard.password: Dashboard 密码
|
- dashboard.password: Dashboard password
|
||||||
|
|
||||||
- callback_api_base: 回调接口基址
|
- callback_api_base: Callback API base URL
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@conf.command(name="set")
|
@conf.command(name="set")
|
||||||
@click.argument("key")
|
@click.argument("key")
|
||||||
@click.argument("value")
|
@click.argument("value")
|
||||||
def set_config(key: str, value: str):
|
def set_config(key: str, value: str) -> None:
|
||||||
"""设置配置项的值"""
|
"""Set the value of a config item"""
|
||||||
if key not in CONFIG_VALIDATORS:
|
if key not in CONFIG_VALIDATORS:
|
||||||
raise click.ClickException(f"不支持的配置项: {key}")
|
raise click.ClickException(f"Unsupported config key: {key}")
|
||||||
|
|
||||||
config = _load_config()
|
config = _load_config()
|
||||||
|
|
||||||
@@ -162,29 +168,29 @@ def set_config(key: str, value: str):
|
|||||||
_set_nested_item(config, key, validated_value)
|
_set_nested_item(config, key, validated_value)
|
||||||
_save_config(config)
|
_save_config(config)
|
||||||
|
|
||||||
click.echo(f"配置已更新: {key}")
|
click.echo(f"Config updated: {key}")
|
||||||
if key == "dashboard.password":
|
if key == "dashboard.password":
|
||||||
click.echo(" 原值: ********")
|
click.echo(" Old value: ********")
|
||||||
click.echo(" 新值: ********")
|
click.echo(" New value: ********")
|
||||||
else:
|
else:
|
||||||
click.echo(f" 原值: {old_value}")
|
click.echo(f" Old value: {old_value}")
|
||||||
click.echo(f" 新值: {validated_value}")
|
click.echo(f" New value: {validated_value}")
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise click.ClickException(f"未知的配置项: {key}")
|
raise click.ClickException(f"Unknown config key: {key}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.UsageError(f"设置配置失败: {e!s}")
|
raise click.UsageError(f"Failed to set config: {e!s}")
|
||||||
|
|
||||||
|
|
||||||
@conf.command(name="get")
|
@conf.command(name="get")
|
||||||
@click.argument("key", required=False)
|
@click.argument("key", required=False)
|
||||||
def get_config(key: str | None = None):
|
def get_config(key: str | None = None) -> None:
|
||||||
"""获取配置项的值,不提供key则显示所有可配置项"""
|
"""Get the value of a config item. If no key is provided, show all configurable items"""
|
||||||
config = _load_config()
|
config = _load_config()
|
||||||
|
|
||||||
if key:
|
if key:
|
||||||
if key not in CONFIG_VALIDATORS:
|
if key not in CONFIG_VALIDATORS:
|
||||||
raise click.ClickException(f"不支持的配置项: {key}")
|
raise click.ClickException(f"Unsupported config key: {key}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = _get_nested_item(config, key)
|
value = _get_nested_item(config, key)
|
||||||
@@ -192,11 +198,11 @@ def get_config(key: str | None = None):
|
|||||||
value = "********"
|
value = "********"
|
||||||
click.echo(f"{key}: {value}")
|
click.echo(f"{key}: {value}")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise click.ClickException(f"未知的配置项: {key}")
|
raise click.ClickException(f"Unknown config key: {key}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.UsageError(f"获取配置失败: {e!s}")
|
raise click.UsageError(f"Failed to get config: {e!s}")
|
||||||
else:
|
else:
|
||||||
click.echo("当前配置:")
|
click.echo("Current config:")
|
||||||
for key in CONFIG_VALIDATORS:
|
for key in CONFIG_VALIDATORS:
|
||||||
try:
|
try:
|
||||||
value = (
|
value = (
|
||||||
|
|||||||
@@ -1,23 +1,47 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from filelock import FileLock, Timeout
|
from filelock import FileLock, Timeout
|
||||||
|
|
||||||
from ..utils import check_dashboard, get_astrbot_root
|
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||||
|
|
||||||
|
from ..utils import check_dashboard
|
||||||
|
|
||||||
|
SYSTEMD_SERVICE = r"""
|
||||||
|
# user service
|
||||||
|
[Unit]
|
||||||
|
Description=AstrBot Service
|
||||||
|
Documentation=https://github.com/AstrBotDevs/AstrBot
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/.local/share/astrbot
|
||||||
|
ExecStart=/usr/bin/sh -c '/usr/bin/astrbot run || { /usr/bin/astrbot init && /usr/bin/astrbot run; }'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=astrbot-%u
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def initialize_astrbot(astrbot_root: Path) -> None:
|
async def initialize_astrbot(astrbot_root: Path, *, yes: bool) -> None:
|
||||||
"""执行 AstrBot 初始化逻辑"""
|
"""Execute AstrBot initialization logic"""
|
||||||
dot_astrbot = astrbot_root / ".astrbot"
|
dot_astrbot = astrbot_root / ".astrbot"
|
||||||
|
|
||||||
if not dot_astrbot.exists():
|
if not dot_astrbot.exists():
|
||||||
click.echo(f"Current Directory: {astrbot_root}")
|
if yes or click.confirm(
|
||||||
click.echo(
|
f"Install AstrBot to this directory? {astrbot_root}",
|
||||||
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。",
|
|
||||||
)
|
|
||||||
if click.confirm(
|
|
||||||
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
|
|
||||||
default=True,
|
default=True,
|
||||||
abort=True,
|
abort=True,
|
||||||
):
|
):
|
||||||
@@ -33,24 +57,60 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
|
|||||||
|
|
||||||
for name, path in paths.items():
|
for name, path in paths.items():
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
|
click.echo(
|
||||||
|
f"{'Created' if not path.exists() else f'{name} Directory exists'}: {path}"
|
||||||
await check_dashboard(astrbot_root / "data")
|
)
|
||||||
|
if yes or click.confirm(
|
||||||
|
"是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)",
|
||||||
|
default=True,
|
||||||
|
):
|
||||||
|
await check_dashboard(astrbot_root)
|
||||||
|
else:
|
||||||
|
click.echo("你可以使用在线面版(v4.14.4+),填写后端地址的方式来控制。")
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
def init() -> None:
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
|
||||||
"""初始化 AstrBot"""
|
def init(yes: bool) -> None:
|
||||||
|
"""Initialize AstrBot"""
|
||||||
click.echo("Initializing AstrBot...")
|
click.echo("Initializing AstrBot...")
|
||||||
astrbot_root = get_astrbot_root()
|
|
||||||
|
# 检查当前系统是否为 Linux 且存在 systemd
|
||||||
|
if platform.system() == "Linux" and shutil.which("systemctl"):
|
||||||
|
if yes or click.confirm(
|
||||||
|
"Detected Linux with systemd. Install AstrBot user service?", default=True
|
||||||
|
):
|
||||||
|
user_config_dir = Path.home() / ".config" / "systemd" / "user"
|
||||||
|
user_config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
service_path = user_config_dir / "astrbot.service"
|
||||||
|
|
||||||
|
service_path.write_text(SYSTEMD_SERVICE)
|
||||||
|
click.echo(f"Created service file at {service_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
||||||
|
click.echo("Systemd daemon reloaded.")
|
||||||
|
click.echo("Management commands:")
|
||||||
|
click.echo(" Start: systemctl --user start astrbot")
|
||||||
|
click.echo(" Stop: systemctl --user stop astrbot")
|
||||||
|
click.echo(" Enable: systemctl --user enable astrbot")
|
||||||
|
click.echo(" Log: journalctl --user -u astrbot -f")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
click.echo(f"Failed to reload systemd daemon: {e}", err=True)
|
||||||
|
|
||||||
|
astrbot_root = astrbot_paths.root
|
||||||
lock_file = astrbot_root / "astrbot.lock"
|
lock_file = astrbot_root / "astrbot.lock"
|
||||||
lock = FileLock(lock_file, timeout=5)
|
lock = FileLock(lock_file, timeout=5)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with lock.acquire():
|
with lock.acquire():
|
||||||
asyncio.run(initialize_astrbot(astrbot_root))
|
asyncio.run(initialize_astrbot(astrbot_root, yes=yes))
|
||||||
|
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
|
||||||
except Timeout:
|
except Timeout:
|
||||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
raise click.ClickException(
|
||||||
|
"Cannot acquire lock file. Please check if another instance is running"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.ClickException(f"初始化失败: {e!s}")
|
raise click.ClickException(f"Initialization failed: {e!s}")
|
||||||
|
|||||||
@@ -4,35 +4,38 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||||
|
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
PluginStatus,
|
PluginStatus,
|
||||||
build_plug_list,
|
build_plug_list,
|
||||||
check_astrbot_root,
|
check_astrbot_root,
|
||||||
get_astrbot_root,
|
|
||||||
get_git_repo,
|
get_git_repo,
|
||||||
manage_plugin,
|
manage_plugin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
def plug():
|
def plug() -> None:
|
||||||
"""插件管理"""
|
"""Plugin management"""
|
||||||
|
|
||||||
|
|
||||||
def _get_data_path() -> Path:
|
def _get_data_path() -> Path:
|
||||||
base = get_astrbot_root()
|
base = astrbot_paths.root
|
||||||
if not check_astrbot_root(base):
|
if not check_astrbot_root(base):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
f"{base} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||||
)
|
)
|
||||||
return (base / "data").resolve()
|
return astrbot_paths.data.resolve()
|
||||||
|
|
||||||
|
|
||||||
def display_plugins(plugins, title=None, color=None):
|
def display_plugins(plugins, title=None, color=None) -> None:
|
||||||
if title:
|
if title:
|
||||||
click.echo(click.style(title, fg=color, bold=True))
|
click.echo(click.style(title, fg=color, bold=True))
|
||||||
|
|
||||||
click.echo(f"{'名称':<20} {'版本':<10} {'状态':<10} {'作者':<15} {'描述':<30}")
|
click.echo(
|
||||||
|
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
|
||||||
|
)
|
||||||
click.echo("-" * 85)
|
click.echo("-" * 85)
|
||||||
|
|
||||||
for p in plugins:
|
for p in plugins:
|
||||||
@@ -45,31 +48,31 @@ def display_plugins(plugins, title=None, color=None):
|
|||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
def new(name: str):
|
def new(name: str) -> None:
|
||||||
"""创建新插件"""
|
"""Create a new plugin"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plug_path = base_path / "plugins" / name
|
plug_path = base_path / "plugins" / name
|
||||||
|
|
||||||
if plug_path.exists():
|
if plug_path.exists():
|
||||||
raise click.ClickException(f"插件 {name} 已存在")
|
raise click.ClickException(f"Plugin {name} already exists")
|
||||||
|
|
||||||
author = click.prompt("请输入插件作者", type=str)
|
author = click.prompt("Enter plugin author", type=str)
|
||||||
desc = click.prompt("请输入插件描述", type=str)
|
desc = click.prompt("Enter plugin description", type=str)
|
||||||
version = click.prompt("请输入插件版本", type=str)
|
version = click.prompt("Enter plugin version", type=str)
|
||||||
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
|
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
|
||||||
raise click.ClickException("版本号必须为 x.y 或 x.y.z 格式")
|
raise click.ClickException("Version must be in x.y or x.y.z format")
|
||||||
repo = click.prompt("请输入插件仓库:", type=str)
|
repo = click.prompt("Enter plugin repository URL:", type=str)
|
||||||
if not repo.startswith("http"):
|
if not repo.startswith("http"):
|
||||||
raise click.ClickException("仓库地址必须以 http 开头")
|
raise click.ClickException("Repository URL must start with http")
|
||||||
|
|
||||||
click.echo("下载插件模板...")
|
click.echo("Downloading plugin template...")
|
||||||
get_git_repo(
|
get_git_repo(
|
||||||
"https://github.com/Soulter/helloworld",
|
"https://github.com/Soulter/helloworld",
|
||||||
plug_path,
|
plug_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
click.echo("重写插件信息...")
|
click.echo("Rewriting plugin metadata...")
|
||||||
# 重写 metadata.yaml
|
# Rewrite metadata.yaml
|
||||||
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
|
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
|
||||||
f.write(
|
f.write(
|
||||||
f"name: {name}\n"
|
f"name: {name}\n"
|
||||||
@@ -79,11 +82,13 @@ def new(name: str):
|
|||||||
f"repo: {repo}\n",
|
f"repo: {repo}\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 重写 README.md
|
# Rewrite README.md
|
||||||
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
|
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
|
||||||
f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n")
|
f.write(
|
||||||
|
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
|
||||||
|
)
|
||||||
|
|
||||||
# 重写 main.py
|
# Rewrite main.py
|
||||||
with open(plug_path / "main.py", encoding="utf-8") as f:
|
with open(plug_path / "main.py", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
@@ -95,54 +100,54 @@ def new(name: str):
|
|||||||
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
|
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
|
||||||
f.write(new_content)
|
f.write(new_content)
|
||||||
|
|
||||||
click.echo(f"插件 {name} 创建成功")
|
click.echo(f"Plugin {name} created successfully")
|
||||||
|
|
||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.option("--all", "-a", is_flag=True, help="列出未安装的插件")
|
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
|
||||||
def list(all: bool):
|
def list(all: bool) -> None:
|
||||||
"""列出插件"""
|
"""List plugins"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
|
|
||||||
# 未发布的插件
|
# Unpublished plugins
|
||||||
not_published_plugins = [
|
not_published_plugins = [
|
||||||
p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED
|
p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED
|
||||||
]
|
]
|
||||||
if not_published_plugins:
|
if not_published_plugins:
|
||||||
display_plugins(not_published_plugins, "未发布的插件", "red")
|
display_plugins(not_published_plugins, "Unpublished Plugins", "red")
|
||||||
|
|
||||||
# 需要更新的插件
|
# Plugins needing update
|
||||||
need_update_plugins = [
|
need_update_plugins = [
|
||||||
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
|
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
|
||||||
]
|
]
|
||||||
if need_update_plugins:
|
if need_update_plugins:
|
||||||
display_plugins(need_update_plugins, "需要更新的插件", "yellow")
|
display_plugins(need_update_plugins, "Plugins Needing Update", "yellow")
|
||||||
|
|
||||||
# 已安装的插件
|
# Installed plugins
|
||||||
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
|
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
|
||||||
if installed_plugins:
|
if installed_plugins:
|
||||||
display_plugins(installed_plugins, "已安装的插件", "green")
|
display_plugins(installed_plugins, "Installed Plugins", "green")
|
||||||
|
|
||||||
# 未安装的插件
|
# Uninstalled plugins
|
||||||
not_installed_plugins = [
|
not_installed_plugins = [
|
||||||
p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED
|
p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED
|
||||||
]
|
]
|
||||||
if not_installed_plugins and all:
|
if not_installed_plugins and all:
|
||||||
display_plugins(not_installed_plugins, "未安装的插件", "blue")
|
display_plugins(not_installed_plugins, "Uninstalled Plugins", "blue")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not any([not_published_plugins, need_update_plugins, installed_plugins])
|
not any([not_published_plugins, need_update_plugins, installed_plugins])
|
||||||
and not all
|
and not all
|
||||||
):
|
):
|
||||||
click.echo("未安装任何插件")
|
click.echo("No plugins installed")
|
||||||
|
|
||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
@click.option("--proxy", help="代理服务器地址")
|
@click.option("--proxy", help="Proxy server address")
|
||||||
def install(name: str, proxy: str | None):
|
def install(name: str, proxy: str | None) -> None:
|
||||||
"""安装插件"""
|
"""Install a plugin"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plug_path = base_path / "plugins"
|
plug_path = base_path / "plugins"
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
@@ -157,38 +162,40 @@ def install(name: str, proxy: str | None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not plugin:
|
if not plugin:
|
||||||
raise click.ClickException(f"未找到可安装的插件 {name},可能是不存在或已安装")
|
raise click.ClickException(f"Plugin {name} not found or already installed")
|
||||||
|
|
||||||
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
|
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
|
||||||
|
|
||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
def remove(name: str):
|
def remove(name: str) -> None:
|
||||||
"""卸载插件"""
|
"""Uninstall a plugin"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
plugin = next((p for p in plugins if p["name"] == name), None)
|
plugin = next((p for p in plugins if p["name"] == name), None)
|
||||||
|
|
||||||
if not plugin or not plugin.get("local_path"):
|
if not plugin or not plugin.get("local_path"):
|
||||||
raise click.ClickException(f"插件 {name} 不存在或未安装")
|
raise click.ClickException(f"Plugin {name} does not exist or is not installed")
|
||||||
|
|
||||||
plugin_path = plugin["local_path"]
|
plugin_path = plugin["local_path"]
|
||||||
|
|
||||||
click.confirm(f"确定要卸载插件 {name} 吗?", default=False, abort=True)
|
click.confirm(
|
||||||
|
f"Are you sure you want to uninstall plugin {name}?", default=False, abort=True
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(plugin_path)
|
shutil.rmtree(plugin_path)
|
||||||
click.echo(f"插件 {name} 已卸载")
|
click.echo(f"Plugin {name} has been uninstalled")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.ClickException(f"卸载插件 {name} 失败: {e}")
|
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("name", required=False)
|
@click.argument("name", required=False)
|
||||||
@click.option("--proxy", help="Github代理地址")
|
@click.option("--proxy", help="GitHub proxy address")
|
||||||
def update(name: str, proxy: str | None):
|
def update(name: str, proxy: str | None) -> None:
|
||||||
"""更新插件"""
|
"""Update plugins"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plug_path = base_path / "plugins"
|
plug_path = base_path / "plugins"
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
@@ -204,7 +211,9 @@ def update(name: str, proxy: str | None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not plugin:
|
if not plugin:
|
||||||
raise click.ClickException(f"插件 {name} 不需要更新或无法更新")
|
raise click.ClickException(
|
||||||
|
f"Plugin {name} does not need updating or cannot be updated"
|
||||||
|
)
|
||||||
|
|
||||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||||
else:
|
else:
|
||||||
@@ -213,20 +222,20 @@ def update(name: str, proxy: str | None):
|
|||||||
]
|
]
|
||||||
|
|
||||||
if not need_update_plugins:
|
if not need_update_plugins:
|
||||||
click.echo("没有需要更新的插件")
|
click.echo("No plugins need updating")
|
||||||
return
|
return
|
||||||
|
|
||||||
click.echo(f"发现 {len(need_update_plugins)} 个插件需要更新")
|
click.echo(f"Found {len(need_update_plugins)} plugin(s) needing update")
|
||||||
for plugin in need_update_plugins:
|
for plugin in need_update_plugins:
|
||||||
plugin_name = plugin["name"]
|
plugin_name = plugin["name"]
|
||||||
click.echo(f"正在更新插件 {plugin_name}...")
|
click.echo(f"Updating plugin {plugin_name}...")
|
||||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||||
|
|
||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("query")
|
@click.argument("query")
|
||||||
def search(query: str):
|
def search(query: str) -> None:
|
||||||
"""搜索插件"""
|
"""Search for plugins"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
|
|
||||||
@@ -239,7 +248,7 @@ def search(query: str):
|
|||||||
]
|
]
|
||||||
|
|
||||||
if not matched_plugins:
|
if not matched_plugins:
|
||||||
click.echo(f"未找到匹配 '{query}' 的插件")
|
click.echo(f"No plugins matching '{query}' found")
|
||||||
return
|
return
|
||||||
|
|
||||||
display_plugins(matched_plugins, f"搜索结果: '{query}'", "cyan")
|
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")
|
||||||
|
|||||||
@@ -7,15 +7,21 @@ from pathlib import Path
|
|||||||
import click
|
import click
|
||||||
from filelock import FileLock, Timeout
|
from filelock import FileLock, Timeout
|
||||||
|
|
||||||
from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
|
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||||
|
|
||||||
|
from ..utils import check_astrbot_root, check_dashboard
|
||||||
|
|
||||||
|
|
||||||
async def run_astrbot(astrbot_root: Path):
|
async def run_astrbot(astrbot_root: Path) -> None:
|
||||||
"""运行 AstrBot"""
|
"""Run AstrBot"""
|
||||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||||
from astrbot.core.initial_loader import InitialLoader
|
from astrbot.core.initial_loader import InitialLoader
|
||||||
|
|
||||||
await check_dashboard(astrbot_root / "data")
|
if (
|
||||||
|
os.environ.get("ASTRBOT_DASHBOARD_ENABLE", os.environ.get("DASHBOARD_ENABLE"))
|
||||||
|
== "True"
|
||||||
|
):
|
||||||
|
await check_dashboard(astrbot_root)
|
||||||
|
|
||||||
log_broker = LogBroker()
|
log_broker = LogBroker()
|
||||||
LogManager.set_queue_handler(logger, log_broker)
|
LogManager.set_queue_handler(logger, log_broker)
|
||||||
@@ -26,28 +32,49 @@ async def run_astrbot(astrbot_root: Path):
|
|||||||
await core_lifecycle.start()
|
await core_lifecycle.start()
|
||||||
|
|
||||||
|
|
||||||
@click.option("--reload", "-r", is_flag=True, help="插件自动重载")
|
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
|
||||||
@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str)
|
@click.option("--host", "-H", help="AstrBot Dashboard Host", required=False, type=str)
|
||||||
|
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
|
||||||
|
@click.option(
|
||||||
|
"--backend-only",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Disable WebUI, run backend only",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--log-level",
|
||||||
|
help="Log level",
|
||||||
|
required=False,
|
||||||
|
type=str,
|
||||||
|
default="INFO",
|
||||||
|
)
|
||||||
@click.command()
|
@click.command()
|
||||||
def run(reload: bool, port: str) -> None:
|
def run(reload: bool, host: str, port: str, backend_only: bool, log_level: str) -> None:
|
||||||
"""运行 AstrBot"""
|
"""Run AstrBot"""
|
||||||
try:
|
try:
|
||||||
os.environ["ASTRBOT_CLI"] = "1"
|
os.environ["ASTRBOT_CLI"] = "1"
|
||||||
astrbot_root = get_astrbot_root()
|
astrbot_root = astrbot_paths.root
|
||||||
|
|
||||||
if not check_astrbot_root(astrbot_root):
|
if not check_astrbot_root(astrbot_root):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||||
)
|
)
|
||||||
|
|
||||||
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
||||||
sys.path.insert(0, str(astrbot_root))
|
sys.path.insert(0, str(astrbot_root))
|
||||||
|
|
||||||
if port:
|
if port is not None:
|
||||||
os.environ["DASHBOARD_PORT"] = port
|
os.environ["ASTRBOT_DASHBOARD_PORT"] = port
|
||||||
|
os.environ["DASHBOARD_PORT"] = port # 今后应该移除
|
||||||
|
if host is not None:
|
||||||
|
os.environ["ASTRBOT_DASHBOARD_HOST"] = host
|
||||||
|
os.environ["DASHBOARD_HOST"] = host # 今后应该移除
|
||||||
|
os.environ["ASTRBOT_DASHBOARD_ENABLE"] = str(not backend_only)
|
||||||
|
os.environ["DASHBOARD_ENABLE"] = str(not backend_only) # 今后应该移除
|
||||||
|
os.environ["ASTRBOT_LOG_LEVEL"] = log_level
|
||||||
|
|
||||||
if reload:
|
if reload:
|
||||||
click.echo("启用插件自动重载")
|
click.echo("Plugin auto-reload enabled")
|
||||||
os.environ["ASTRBOT_RELOAD"] = "1"
|
os.environ["ASTRBOT_RELOAD"] = "1"
|
||||||
|
|
||||||
lock_file = astrbot_root / "astrbot.lock"
|
lock_file = astrbot_root / "astrbot.lock"
|
||||||
@@ -55,8 +82,10 @@ def run(reload: bool, port: str) -> None:
|
|||||||
with lock.acquire():
|
with lock.acquire():
|
||||||
asyncio.run(run_astrbot(astrbot_root))
|
asyncio.run(run_astrbot(astrbot_root))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
click.echo("AstrBot 已关闭...")
|
click.echo("AstrBot has been shut down.")
|
||||||
except Timeout:
|
except Timeout:
|
||||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
raise click.ClickException(
|
||||||
|
"Cannot acquire lock file. Please check if another instance is running"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.ClickException(f"运行时出现错误: {e}\n{traceback.format_exc()}")
|
raise click.ClickException(f"Runtime error: {e}\n{traceback.format_exc()}")
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
|
||||||
|
@click.option(
|
||||||
|
"--keep-data", is_flag=True, help="Keep data directory (config, plugins, etc.)"
|
||||||
|
)
|
||||||
|
def uninstall(yes: bool, keep_data: bool) -> None:
|
||||||
|
"""Uninstall AstrBot systemd service and cleanup data"""
|
||||||
|
|
||||||
|
# 1. Remove Systemd Service
|
||||||
|
if platform.system() == "Linux" and shutil.which("systemctl"):
|
||||||
|
service_path = Path.home() / ".config" / "systemd" / "user" / "astrbot.service"
|
||||||
|
|
||||||
|
if service_path.exists():
|
||||||
|
if yes or click.confirm(
|
||||||
|
"Detected AstrBot systemd service. Stop and remove it?",
|
||||||
|
default=True,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
click.echo("Stopping AstrBot service...")
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "--user", "stop", "astrbot"], check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo("Disabling AstrBot service...")
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "--user", "disable", "astrbot"], check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(f"Removing service file: {service_path}")
|
||||||
|
service_path.unlink()
|
||||||
|
|
||||||
|
click.echo("Reloading systemd daemon...")
|
||||||
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
||||||
|
click.echo("Systemd service uninstalled.")
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
click.echo(f"Failed to remove systemd service: {e}", err=True)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(
|
||||||
|
f"An error occurred during service removal: {e}", err=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Remove Data
|
||||||
|
if keep_data:
|
||||||
|
click.echo("Skipping data removal as requested.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Helper paths
|
||||||
|
dot_astrbot = astrbot_paths.root / ".astrbot"
|
||||||
|
lock_file = astrbot_paths.root / "astrbot.lock"
|
||||||
|
data_dir = astrbot_paths.data
|
||||||
|
|
||||||
|
# Check if this looks like an AstrBot root before blowing things up
|
||||||
|
if not dot_astrbot.exists() and not data_dir.exists():
|
||||||
|
click.echo("No AstrBot initialization found in current directory.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if yes or click.confirm(
|
||||||
|
f"Are you sure you want to remove AstrBot data at {astrbot_paths.root}? \n"
|
||||||
|
f"This will delete:\n"
|
||||||
|
f" - {data_dir} (Config, Plugins, Database)\n"
|
||||||
|
f" - {dot_astrbot}\n"
|
||||||
|
f" - {lock_file}",
|
||||||
|
default=False,
|
||||||
|
abort=True,
|
||||||
|
):
|
||||||
|
if data_dir.exists():
|
||||||
|
click.echo(f"Removing directory: {data_dir}")
|
||||||
|
shutil.rmtree(data_dir)
|
||||||
|
|
||||||
|
if dot_astrbot.exists():
|
||||||
|
click.echo(f"Removing file: {dot_astrbot}")
|
||||||
|
dot_astrbot.unlink()
|
||||||
|
|
||||||
|
if lock_file.exists():
|
||||||
|
click.echo(f"Removing file: {lock_file}")
|
||||||
|
lock_file.unlink()
|
||||||
|
|
||||||
|
click.echo("AstrBot data removed successfully.")
|
||||||
|
click.echo("uv: uv tool uninstall astrbot")
|
||||||
|
click.echo("paru/yay: paru -R astrbot")
|
||||||
+38
-23
@@ -1,10 +1,17 @@
|
|||||||
|
from importlib import resources
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from astrbot.core.utils.astrbot_path import astrbot_paths
|
||||||
|
|
||||||
|
# Static assets bundled inside the installed wheel (built by hatch_build.py).
|
||||||
|
# _BUNDLED_DIST = Path(__file__).parent.parent.parent / "dashboard" / "dist"
|
||||||
|
_BUNDLED_DIST = resources.files("astrbot") / "dashboard" / "dist"
|
||||||
|
|
||||||
|
|
||||||
def check_astrbot_root(path: str | Path) -> bool:
|
def check_astrbot_root(path: str | Path) -> bool:
|
||||||
"""检查路径是否为 AstrBot 根目录"""
|
"""Check if the path is an AstrBot root directory"""
|
||||||
if not isinstance(path, Path):
|
if not isinstance(path, Path):
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
if not path.exists() or not path.is_dir():
|
if not path.exists() or not path.is_dir():
|
||||||
@@ -15,62 +22,70 @@ def check_astrbot_root(path: str | Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_astrbot_root() -> Path:
|
def get_astrbot_root() -> Path:
|
||||||
"""获取Astrbot根目录路径"""
|
"""Get the AstrBot root directory path"""
|
||||||
return Path.cwd()
|
return astrbot_paths.root
|
||||||
|
|
||||||
|
|
||||||
async def check_dashboard(astrbot_root: Path) -> None:
|
async def check_dashboard(astrbot_root: Path) -> None:
|
||||||
"""检查是否安装了dashboard"""
|
"""Check if the dashboard is installed"""
|
||||||
from astrbot.core.config.default import VERSION
|
from astrbot.core.config.default import VERSION
|
||||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||||
|
|
||||||
from .version_comparator import VersionComparator
|
from .version_comparator import VersionComparator
|
||||||
|
|
||||||
|
# If the wheel ships bundled dashboard assets, no network download is needed.
|
||||||
|
if _BUNDLED_DIST.is_dir():
|
||||||
|
click.echo("Dashboard is bundled with the package – skipping download.")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dashboard_version = await get_dashboard_version()
|
dashboard_version = await get_dashboard_version()
|
||||||
match dashboard_version:
|
match dashboard_version:
|
||||||
case None:
|
case None:
|
||||||
click.echo("未安装管理面板")
|
click.echo("Dashboard is not installed")
|
||||||
if click.confirm(
|
if click.confirm(
|
||||||
"是否安装管理面板?",
|
"Install dashboard?",
|
||||||
default=True,
|
default=True,
|
||||||
abort=True,
|
abort=True,
|
||||||
):
|
):
|
||||||
click.echo("正在安装管理面板...")
|
click.echo("Installing dashboard...")
|
||||||
await download_dashboard(
|
try:
|
||||||
path="data/dashboard.zip",
|
await download_dashboard(
|
||||||
extract_path=str(astrbot_root),
|
path="data/dashboard.zip",
|
||||||
version=f"v{VERSION}",
|
extract_path=str(astrbot_root / "data"),
|
||||||
latest=False,
|
version=f"v{VERSION}",
|
||||||
)
|
latest=False,
|
||||||
click.echo("管理面板安装完成")
|
)
|
||||||
|
click.echo("Dashboard installed successfully")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Failed to install dashboard: {e}")
|
||||||
|
|
||||||
case str():
|
case str():
|
||||||
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
|
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
|
||||||
click.echo("管理面板已是最新版本")
|
click.echo("Dashboard is already up to date")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
version = dashboard_version.split("v")[1]
|
version = dashboard_version.split("v")[1]
|
||||||
click.echo(f"管理面板版本: {version}")
|
click.echo(f"Dashboard version: {version}")
|
||||||
await download_dashboard(
|
await download_dashboard(
|
||||||
path="data/dashboard.zip",
|
path="data/dashboard.zip",
|
||||||
extract_path=str(astrbot_root),
|
extract_path=str(astrbot_root / "data"),
|
||||||
version=f"v{VERSION}",
|
version=f"v{VERSION}",
|
||||||
latest=False,
|
latest=False,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"下载管理面板失败: {e}")
|
click.echo(f"Failed to download dashboard: {e}")
|
||||||
return
|
return
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
click.echo("初始化管理面板目录...")
|
click.echo("Initializing dashboard directory...")
|
||||||
try:
|
try:
|
||||||
await download_dashboard(
|
await download_dashboard(
|
||||||
path=str(astrbot_root / "dashboard.zip"),
|
path=str(astrbot_root / "data" / "dashboard.zip"),
|
||||||
extract_path=str(astrbot_root),
|
extract_path=str(astrbot_root / "data"),
|
||||||
version=f"v{VERSION}",
|
version=f"v{VERSION}",
|
||||||
latest=False,
|
latest=False,
|
||||||
)
|
)
|
||||||
click.echo("管理面板初始化完成")
|
click.echo("Dashboard initialized successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"下载管理面板失败: {e}")
|
click.echo(f"Failed to download dashboard: {e}")
|
||||||
return
|
return
|
||||||
|
|||||||
+48
-44
@@ -13,22 +13,22 @@ from .version_comparator import VersionComparator
|
|||||||
|
|
||||||
|
|
||||||
class PluginStatus(str, Enum):
|
class PluginStatus(str, Enum):
|
||||||
INSTALLED = "已安装"
|
INSTALLED = "installed"
|
||||||
NEED_UPDATE = "需更新"
|
NEED_UPDATE = "needs-update"
|
||||||
NOT_INSTALLED = "未安装"
|
NOT_INSTALLED = "not-installed"
|
||||||
NOT_PUBLISHED = "未发布"
|
NOT_PUBLISHED = "unpublished"
|
||||||
|
|
||||||
|
|
||||||
def get_git_repo(url: str, target_path: Path, proxy: str | None = None):
|
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||||
"""从 Git 仓库下载代码并解压到指定路径"""
|
"""Download code from a Git repository and extract to the specified path"""
|
||||||
temp_dir = Path(tempfile.mkdtemp())
|
temp_dir = Path(tempfile.mkdtemp())
|
||||||
try:
|
try:
|
||||||
# 解析仓库信息
|
# Parse repository info
|
||||||
repo_namespace = url.split("/")[-2:]
|
repo_namespace = url.split("/")[-2:]
|
||||||
author = repo_namespace[0]
|
author = repo_namespace[0]
|
||||||
repo = repo_namespace[1]
|
repo = repo_namespace[1]
|
||||||
|
|
||||||
# 尝试获取最新的 release
|
# Try to get the latest release
|
||||||
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
||||||
try:
|
try:
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
@@ -40,21 +40,21 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None):
|
|||||||
releases = resp.json()
|
releases = resp.json()
|
||||||
|
|
||||||
if releases:
|
if releases:
|
||||||
# 使用最新的 release
|
# Use the latest release
|
||||||
download_url = releases[0]["zipball_url"]
|
download_url = releases[0]["zipball_url"]
|
||||||
else:
|
else:
|
||||||
# 没有 release,使用默认分支
|
# No release found, use default branch
|
||||||
click.echo(f"正在从默认分支下载 {author}/{repo}")
|
click.echo(f"Downloading {author}/{repo} from default branch")
|
||||||
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"获取 release 信息失败: {e},将直接使用提供的 URL")
|
click.echo(f"Failed to get release info: {e}. Using provided URL directly")
|
||||||
download_url = url
|
download_url = url
|
||||||
|
|
||||||
# 应用代理
|
# Apply proxy
|
||||||
if proxy:
|
if proxy:
|
||||||
download_url = f"{proxy}/{download_url}"
|
download_url = f"{proxy}/{download_url}"
|
||||||
|
|
||||||
# 下载并解压
|
# Download and extract
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
proxy=proxy if proxy else None,
|
proxy=proxy if proxy else None,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
@@ -65,7 +65,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None):
|
|||||||
and "archive/refs/heads/master.zip" in download_url
|
and "archive/refs/heads/master.zip" in download_url
|
||||||
):
|
):
|
||||||
alt_url = download_url.replace("master.zip", "main.zip")
|
alt_url = download_url.replace("master.zip", "main.zip")
|
||||||
click.echo("master 分支不存在,尝试下载 main 分支")
|
click.echo("Branch 'master' not found, trying 'main' branch")
|
||||||
resp = client.get(alt_url)
|
resp = client.get(alt_url)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
else:
|
else:
|
||||||
@@ -84,13 +84,13 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None):
|
|||||||
|
|
||||||
|
|
||||||
def load_yaml_metadata(plugin_dir: Path) -> dict:
|
def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||||
"""从 metadata.yaml 文件加载插件元数据
|
"""Load plugin metadata from metadata.yaml file
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_dir: 插件目录路径
|
plugin_dir: Plugin directory path
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: 包含元数据的字典,如果读取失败则返回空字典
|
dict: Dictionary containing metadata, or empty dict if loading fails
|
||||||
|
|
||||||
"""
|
"""
|
||||||
yaml_path = plugin_dir / "metadata.yaml"
|
yaml_path = plugin_dir / "metadata.yaml"
|
||||||
@@ -98,33 +98,33 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
|
|||||||
try:
|
try:
|
||||||
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"读取 {yaml_path} 失败: {e}", err=True)
|
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def build_plug_list(plugins_dir: Path) -> list:
|
def build_plug_list(plugins_dir: Path) -> list:
|
||||||
"""构建插件列表,包含本地和在线插件信息
|
"""Build plugin list containing local and online plugin information
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugins_dir (Path): 插件目录路径
|
plugins_dir (Path): Plugin directory path
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: 包含插件信息的字典列表
|
list: List of dicts containing plugin information
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# 获取本地插件信息
|
# Get local plugin info
|
||||||
result = []
|
result = []
|
||||||
if plugins_dir.exists():
|
if plugins_dir.exists():
|
||||||
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
|
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
|
||||||
plugin_dir = plugins_dir / plugin_name
|
plugin_dir = plugins_dir / plugin_name
|
||||||
|
|
||||||
# 从 metadata.yaml 加载元数据
|
# Load metadata from metadata.yaml
|
||||||
metadata = load_yaml_metadata(plugin_dir)
|
metadata = load_yaml_metadata(plugin_dir)
|
||||||
|
|
||||||
if "desc" not in metadata and "description" in metadata:
|
if "desc" not in metadata and "description" in metadata:
|
||||||
metadata["desc"] = metadata["description"]
|
metadata["desc"] = metadata["description"]
|
||||||
|
|
||||||
# 如果成功加载元数据,添加到结果列表
|
# If metadata loaded successfully, add to result list
|
||||||
if metadata and all(
|
if metadata and all(
|
||||||
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
||||||
):
|
):
|
||||||
@@ -140,7 +140,7 @@ def build_plug_list(plugins_dir: Path) -> list:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取在线插件列表
|
# Get online plugin list
|
||||||
online_plugins = []
|
online_plugins = []
|
||||||
try:
|
try:
|
||||||
with httpx.Client() as client:
|
with httpx.Client() as client:
|
||||||
@@ -160,13 +160,13 @@ def build_plug_list(plugins_dir: Path) -> list:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
click.echo(f"Failed to get online plugin list: {e}", err=True)
|
||||||
|
|
||||||
# 与在线插件比对,更新状态
|
# Compare with online plugins and update status
|
||||||
online_plugin_names = {plugin["name"] for plugin in online_plugins}
|
online_plugin_names = {plugin["name"] for plugin in online_plugins}
|
||||||
for local_plugin in result:
|
for local_plugin in result:
|
||||||
if local_plugin["name"] in online_plugin_names:
|
if local_plugin["name"] in online_plugin_names:
|
||||||
# 查找对应的在线插件
|
# Find the corresponding online plugin
|
||||||
online_plugin = next(
|
online_plugin = next(
|
||||||
p for p in online_plugins if p["name"] == local_plugin["name"]
|
p for p in online_plugins if p["name"] == local_plugin["name"]
|
||||||
)
|
)
|
||||||
@@ -179,10 +179,10 @@ def build_plug_list(plugins_dir: Path) -> list:
|
|||||||
):
|
):
|
||||||
local_plugin["status"] = PluginStatus.NEED_UPDATE
|
local_plugin["status"] = PluginStatus.NEED_UPDATE
|
||||||
else:
|
else:
|
||||||
# 本地插件未在线上发布
|
# Local plugin is not published online
|
||||||
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
|
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
|
||||||
|
|
||||||
# 添加未安装的在线插件
|
# Add uninstalled online plugins
|
||||||
for online_plugin in online_plugins:
|
for online_plugin in online_plugins:
|
||||||
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
|
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
|
||||||
result.append(online_plugin)
|
result.append(online_plugin)
|
||||||
@@ -196,19 +196,19 @@ def manage_plugin(
|
|||||||
is_update: bool = False,
|
is_update: bool = False,
|
||||||
proxy: str | None = None,
|
proxy: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""安装或更新插件
|
"""Install or update a plugin
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin (dict): 插件信息字典
|
plugin (dict): Plugin info dict
|
||||||
plugins_dir (Path): 插件目录
|
plugins_dir (Path): Plugins directory
|
||||||
is_update (bool, optional): 是否为更新操作. 默认为 False
|
is_update (bool, optional): Whether this is an update operation. Defaults to False
|
||||||
proxy (str, optional): 代理服务器地址
|
proxy (str, optional): Proxy server address
|
||||||
|
|
||||||
"""
|
"""
|
||||||
plugin_name = plugin["name"]
|
plugin_name = plugin["name"]
|
||||||
repo_url = plugin["repo"]
|
repo_url = plugin["repo"]
|
||||||
|
|
||||||
# 如果是更新且有本地路径,直接使用本地路径
|
# If updating and local path exists, use it directly
|
||||||
if is_update and plugin.get("local_path"):
|
if is_update and plugin.get("local_path"):
|
||||||
target_path = Path(plugin["local_path"])
|
target_path = Path(plugin["local_path"])
|
||||||
else:
|
else:
|
||||||
@@ -216,11 +216,13 @@ def manage_plugin(
|
|||||||
|
|
||||||
backup_path = Path(f"{target_path}_backup") if is_update else None
|
backup_path = Path(f"{target_path}_backup") if is_update else None
|
||||||
|
|
||||||
# 检查插件是否存在
|
# Check if plugin exists
|
||||||
if is_update and not target_path.exists():
|
if is_update and not target_path.exists():
|
||||||
raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新")
|
raise click.ClickException(
|
||||||
|
f"Plugin {plugin_name} is not installed and cannot be updated"
|
||||||
|
)
|
||||||
|
|
||||||
# 备份现有插件
|
# Backup existing plugin
|
||||||
if is_update and backup_path is not None and backup_path.exists():
|
if is_update and backup_path is not None and backup_path.exists():
|
||||||
shutil.rmtree(backup_path)
|
shutil.rmtree(backup_path)
|
||||||
if is_update and backup_path is not None:
|
if is_update and backup_path is not None:
|
||||||
@@ -228,19 +230,21 @@ def manage_plugin(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(
|
click.echo(
|
||||||
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}...",
|
f"{'Updating' if is_update else 'Downloading'} plugin {plugin_name} from {repo_url}...",
|
||||||
)
|
)
|
||||||
get_git_repo(repo_url, target_path, proxy)
|
get_git_repo(repo_url, target_path, proxy)
|
||||||
|
|
||||||
# 更新成功,删除备份
|
# Update succeeded, delete backup
|
||||||
if is_update and backup_path is not None and backup_path.exists():
|
if is_update and backup_path is not None and backup_path.exists():
|
||||||
shutil.rmtree(backup_path)
|
shutil.rmtree(backup_path)
|
||||||
click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功")
|
click.echo(
|
||||||
|
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if target_path.exists():
|
if target_path.exists():
|
||||||
shutil.rmtree(target_path, ignore_errors=True)
|
shutil.rmtree(target_path, ignore_errors=True)
|
||||||
if is_update and backup_path is not None and backup_path.exists():
|
if is_update and backup_path is not None and backup_path.exists():
|
||||||
shutil.move(backup_path, target_path)
|
shutil.move(backup_path, target_path)
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}",
|
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""拷贝自 astrbot.core.utils.version_comparator"""
|
"""Copied from astrbot.core.utils.version_comparator"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -6,11 +6,11 @@ import re
|
|||||||
class VersionComparator:
|
class VersionComparator:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compare_version(v1: str, v2: str) -> int:
|
def compare_version(v1: str, v2: str) -> int:
|
||||||
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
|
"""Compare version numbers according to Semver semantics. Supports version numbers with more than 3 digits and handles pre-release tags.
|
||||||
|
|
||||||
参考: https://semver.org/lang/zh-CN/
|
Reference: https://semver.org/
|
||||||
|
|
||||||
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2。
|
Returns 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2.
|
||||||
"""
|
"""
|
||||||
v1 = v1.lower().replace("v", "")
|
v1 = v1.lower().replace("v", "")
|
||||||
v2 = v2.lower().replace("v", "")
|
v2 = v2.lower().replace("v", "")
|
||||||
@@ -24,7 +24,7 @@ class VersionComparator:
|
|||||||
return [], None
|
return [], None
|
||||||
major_minor_patch = match.group(1).split(".")
|
major_minor_patch = match.group(1).split(".")
|
||||||
prerelease = match.group(2)
|
prerelease = match.group(2)
|
||||||
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
|
# buildmetadata = match.group(3) # Build metadata is ignored in comparison
|
||||||
parts = [int(x) for x in major_minor_patch]
|
parts = [int(x) for x in major_minor_patch]
|
||||||
prerelease = VersionComparator._split_prerelease(prerelease)
|
prerelease = VersionComparator._split_prerelease(prerelease)
|
||||||
return parts, prerelease
|
return parts, prerelease
|
||||||
@@ -32,7 +32,7 @@ class VersionComparator:
|
|||||||
v1_parts, v1_prerelease = split_version(v1)
|
v1_parts, v1_prerelease = split_version(v1)
|
||||||
v2_parts, v2_prerelease = split_version(v2)
|
v2_parts, v2_prerelease = split_version(v2)
|
||||||
|
|
||||||
# 比较数字部分
|
# Compare numeric parts
|
||||||
length = max(len(v1_parts), len(v2_parts))
|
length = max(len(v1_parts), len(v2_parts))
|
||||||
v1_parts.extend([0] * (length - len(v1_parts)))
|
v1_parts.extend([0] * (length - len(v1_parts)))
|
||||||
v2_parts.extend([0] * (length - len(v2_parts)))
|
v2_parts.extend([0] * (length - len(v2_parts)))
|
||||||
@@ -43,11 +43,11 @@ class VersionComparator:
|
|||||||
if v1_parts[i] < v2_parts[i]:
|
if v1_parts[i] < v2_parts[i]:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
# 比较预发布标签
|
# Compare pre-release tags
|
||||||
if v1_prerelease is None and v2_prerelease is not None:
|
if v1_prerelease is None and v2_prerelease is not None:
|
||||||
return 1 # 没有预发布标签的版本高于有预发布标签的版本
|
return 1 # Version without pre-release tag is higher than one with it
|
||||||
if v1_prerelease is not None and v2_prerelease is None:
|
if v1_prerelease is not None and v2_prerelease is None:
|
||||||
return -1 # 有预发布标签的版本低于没有预发布标签的版本
|
return -1 # Version with pre-release tag is lower than one without it
|
||||||
if 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))
|
len_pre = max(len(v1_prerelease), len(v2_prerelease))
|
||||||
for i in range(len_pre):
|
for i in range(len_pre):
|
||||||
@@ -72,9 +72,9 @@ class VersionComparator:
|
|||||||
return 1
|
return 1
|
||||||
if p1 < p2:
|
if p1 < p2:
|
||||||
return -1
|
return -1
|
||||||
return 0 # 预发布标签完全相同
|
return 0 # Pre-release tags are identical
|
||||||
|
|
||||||
return 0 # 数字部分和预发布标签都相同
|
return 0 # Both numeric parts and pre-release tags are equal
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _split_prerelease(prerelease):
|
def _split_prerelease(prerelease):
|
||||||
|
|||||||
@@ -4,7 +4,21 @@ from astrbot.core.config import AstrBotConfig
|
|||||||
from astrbot.core.config.default import DB_PATH
|
from astrbot.core.config.default import DB_PATH
|
||||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||||
from astrbot.core.file_token_service import FileTokenService
|
from astrbot.core.file_token_service import FileTokenService
|
||||||
from astrbot.core.utils.pip_installer import PipInstaller
|
from astrbot.core.utils.pip_installer import (
|
||||||
|
DependencyConflictError as DependencyConflictError,
|
||||||
|
)
|
||||||
|
from astrbot.core.utils.pip_installer import (
|
||||||
|
PipInstaller,
|
||||||
|
)
|
||||||
|
from astrbot.core.utils.requirements_utils import (
|
||||||
|
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
|
||||||
|
)
|
||||||
|
from astrbot.core.utils.requirements_utils import (
|
||||||
|
find_missing_requirements as find_missing_requirements,
|
||||||
|
)
|
||||||
|
from astrbot.core.utils.requirements_utils import (
|
||||||
|
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
|
||||||
|
)
|
||||||
from astrbot.core.utils.shared_preferences import SharedPreferences
|
from astrbot.core.utils.shared_preferences import SharedPreferences
|
||||||
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
||||||
|
|
||||||
@@ -14,12 +28,16 @@ from .utils.astrbot_path import get_astrbot_data_path
|
|||||||
# 初始化数据存储文件夹
|
# 初始化数据存储文件夹
|
||||||
os.makedirs(get_astrbot_data_path(), exist_ok=True)
|
os.makedirs(get_astrbot_data_path(), exist_ok=True)
|
||||||
|
|
||||||
DEMO_MODE = os.getenv("DEMO_MODE", False)
|
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
|
||||||
|
|
||||||
astrbot_config = AstrBotConfig()
|
astrbot_config = AstrBotConfig()
|
||||||
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
|
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
|
||||||
html_renderer = HtmlRenderer(t2i_base_url)
|
html_renderer = HtmlRenderer(t2i_base_url)
|
||||||
logger = LogManager.GetLogger(log_name="astrbot")
|
logger = LogManager.GetLogger(log_name="astrbot")
|
||||||
|
LogManager.configure_logger(
|
||||||
|
logger, astrbot_config, override_level=os.getenv("ASTRBOT_LOG_LEVEL")
|
||||||
|
)
|
||||||
|
LogManager.configure_trace_logger(astrbot_config)
|
||||||
db_helper = SQLiteDatabase(DB_PATH)
|
db_helper = SQLiteDatabase(DB_PATH)
|
||||||
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
|
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
|
||||||
sp = SharedPreferences(db_helper=db_helper)
|
sp = SharedPreferences(db_helper=db_helper)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Generic
|
from typing import Any, Generic
|
||||||
|
|
||||||
from .hooks import BaseAgentRunHooks
|
from .hooks import BaseAgentRunHooks
|
||||||
from .run_context import TContext
|
from .run_context import TContext
|
||||||
@@ -12,3 +12,4 @@ class Agent(Generic[TContext]):
|
|||||||
instructions: str | None = None
|
instructions: str | None = None
|
||||||
tools: list[str | FunctionTool] | None = None
|
tools: list[str | FunctionTool] | None = None
|
||||||
run_hooks: BaseAgentRunHooks[TContext] | None = None
|
run_hooks: BaseAgentRunHooks[TContext] | None = None
|
||||||
|
begin_dialogs: list[Any] | None = None
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
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
|
||||||
|
) -> None:
|
||||||
|
"""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,
|
||||||
|
) -> None:
|
||||||
|
"""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,
|
||||||
|
) -> None:
|
||||||
|
"""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,182 @@
|
|||||||
|
from ..message import Message
|
||||||
|
|
||||||
|
|
||||||
|
class ContextTruncator:
|
||||||
|
"""Context truncator."""
|
||||||
|
|
||||||
|
def _has_tool_calls(self, message: Message) -> bool:
|
||||||
|
"""Check if a message contains tool calls."""
|
||||||
|
return (
|
||||||
|
message.role == "assistant"
|
||||||
|
and message.tool_calls is not None
|
||||||
|
and len(message.tool_calls) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def fix_messages(self, messages: list[Message]) -> list[Message]:
|
||||||
|
"""修复消息列表,确保 tool call 和 tool response 的配对关系有效。
|
||||||
|
|
||||||
|
此方法确保:
|
||||||
|
1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息
|
||||||
|
2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应
|
||||||
|
|
||||||
|
这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
fixed_messages: list[Message] = []
|
||||||
|
pending_assistant: Message | None = None
|
||||||
|
pending_tools: list[Message] = []
|
||||||
|
|
||||||
|
def flush_pending_if_valid() -> None:
|
||||||
|
nonlocal pending_assistant, pending_tools
|
||||||
|
if pending_assistant is not None and pending_tools:
|
||||||
|
fixed_messages.append(pending_assistant)
|
||||||
|
fixed_messages.extend(pending_tools)
|
||||||
|
pending_assistant = None
|
||||||
|
pending_tools = []
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
if msg.role == "tool":
|
||||||
|
# 只有在有挂起的 assistant(tool_calls) 时才记录 tool 响应
|
||||||
|
if pending_assistant is not None:
|
||||||
|
pending_tools.append(msg)
|
||||||
|
# else: 孤立的 tool 消息,直接忽略
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self._has_tool_calls(msg):
|
||||||
|
# 遇到新的 assistant(tool_calls) 前,先处理旧的 pending 链
|
||||||
|
flush_pending_if_valid()
|
||||||
|
pending_assistant = msg
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 非 tool,且不含 tool_calls 的消息
|
||||||
|
# 先结束任何 pending 链,再正常追加
|
||||||
|
flush_pending_if_valid()
|
||||||
|
fixed_messages.append(msg)
|
||||||
|
|
||||||
|
# 结束时处理最后一个 pending 链
|
||||||
|
flush_pending_if_valid()
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -12,16 +12,29 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
|||||||
self,
|
self,
|
||||||
agent: Agent[TContext],
|
agent: Agent[TContext],
|
||||||
parameters: dict | None = None,
|
parameters: dict | None = None,
|
||||||
|
tool_description: str | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
) -> None:
|
||||||
self.agent = agent
|
# Avoid passing duplicate `description` to the FunctionTool dataclass.
|
||||||
|
# Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs
|
||||||
|
# to override what the main agent sees, while we also compute a default
|
||||||
|
# description here.
|
||||||
|
# `tool_description` is the public description shown to the main LLM.
|
||||||
|
# Keep a separate kwarg to avoid conflicting with FunctionTool's `description`.
|
||||||
|
description = tool_description or self.default_description(agent.name)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name=f"transfer_to_{agent.name}",
|
name=f"transfer_to_{agent.name}",
|
||||||
parameters=parameters or self.default_parameters(),
|
parameters=parameters or self.default_parameters(),
|
||||||
description=agent.instructions or self.default_description(agent.name),
|
description=description,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Optional provider override for this subagent. When set, the handoff
|
||||||
|
# execution will use this chat provider id instead of the global/default.
|
||||||
|
self.provider_id: str | None = None
|
||||||
|
# Note: Must assign after super().__init__() to prevent parent class from overriding this attribute
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
def default_parameters(self) -> dict:
|
def default_parameters(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -30,9 +43,22 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
|
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
|
||||||
},
|
},
|
||||||
|
"image_urls": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional: An array of image sources (public HTTP URLs or local file paths) used as references in multimodal tasks such as video generation.",
|
||||||
|
},
|
||||||
|
"background_task": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": (
|
||||||
|
"Defaults to false. "
|
||||||
|
"Set to true if the task may take noticeable time, involves external tools, or the user does not need to wait. "
|
||||||
|
"Use false only for quick, immediate tasks."
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def default_description(self, agent_name: str | None) -> str:
|
def default_description(self, agent_name: str | None) -> str:
|
||||||
agent_name = agent_name or "another"
|
agent_name = agent_name or "another"
|
||||||
return f"Delegate tasks to {self.name} agent to handle the request."
|
return f"Delegate tasks to {agent_name} agent to handle the request."
|
||||||
|
|||||||
@@ -9,22 +9,22 @@ from .run_context import ContextWrapper, TContext
|
|||||||
|
|
||||||
|
|
||||||
class BaseAgentRunHooks(Generic[TContext]):
|
class BaseAgentRunHooks(Generic[TContext]):
|
||||||
async def on_agent_begin(self, run_context: ContextWrapper[TContext]): ...
|
async def on_agent_begin(self, run_context: ContextWrapper[TContext]) -> None: ...
|
||||||
async def on_tool_start(
|
async def on_tool_start(
|
||||||
self,
|
self,
|
||||||
run_context: ContextWrapper[TContext],
|
run_context: ContextWrapper[TContext],
|
||||||
tool: FunctionTool,
|
tool: FunctionTool,
|
||||||
tool_args: dict | None,
|
tool_args: dict | None,
|
||||||
): ...
|
) -> None: ...
|
||||||
async def on_tool_end(
|
async def on_tool_end(
|
||||||
self,
|
self,
|
||||||
run_context: ContextWrapper[TContext],
|
run_context: ContextWrapper[TContext],
|
||||||
tool: FunctionTool,
|
tool: FunctionTool,
|
||||||
tool_args: dict | None,
|
tool_args: dict | None,
|
||||||
tool_result: mcp.types.CallToolResult | None,
|
tool_result: mcp.types.CallToolResult | None,
|
||||||
): ...
|
) -> None: ...
|
||||||
async def on_agent_done(
|
async def on_agent_done(
|
||||||
self,
|
self,
|
||||||
run_context: ContextWrapper[TContext],
|
run_context: ContextWrapper[TContext],
|
||||||
llm_response: LLMResponse,
|
llm_response: LLMResponse,
|
||||||
): ...
|
) -> None: ...
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
|||||||
|
|
||||||
|
|
||||||
class MCPClient:
|
class MCPClient:
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
# Initialize session and client objects
|
# Initialize session and client objects
|
||||||
self.session: mcp.ClientSession | None = None
|
self.session: mcp.ClientSession | None = None
|
||||||
self.exit_stack = AsyncExitStack()
|
self.exit_stack = AsyncExitStack()
|
||||||
@@ -126,7 +126,7 @@ class MCPClient:
|
|||||||
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
|
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
|
||||||
self._reconnecting: bool = False # For logging and debugging
|
self._reconnecting: bool = False # For logging and debugging
|
||||||
|
|
||||||
async def connect_to_server(self, mcp_server_config: dict, name: str):
|
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
|
||||||
"""Connect to MCP server
|
"""Connect to MCP server
|
||||||
|
|
||||||
If `url` parameter exists:
|
If `url` parameter exists:
|
||||||
@@ -144,10 +144,14 @@ class MCPClient:
|
|||||||
|
|
||||||
cfg = _prepare_config(mcp_server_config.copy())
|
cfg = _prepare_config(mcp_server_config.copy())
|
||||||
|
|
||||||
def logging_callback(msg: str):
|
def logging_callback(
|
||||||
|
msg: str | mcp.types.LoggingMessageNotificationParams,
|
||||||
|
) -> None:
|
||||||
# Handle MCP service error logs
|
# Handle MCP service error logs
|
||||||
print(f"MCP Server {name} Error: {msg}")
|
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||||
self.server_errlogs.append(msg)
|
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
|
||||||
|
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||||
|
self.server_errlogs.append(log_msg)
|
||||||
|
|
||||||
if "url" in cfg:
|
if "url" in cfg:
|
||||||
success, error_msg = await _quick_test_mcp_connection(cfg)
|
success, error_msg = await _quick_test_mcp_connection(cfg)
|
||||||
@@ -214,15 +218,24 @@ class MCPClient:
|
|||||||
**cfg,
|
**cfg,
|
||||||
)
|
)
|
||||||
|
|
||||||
def callback(msg: str):
|
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
|
||||||
# Handle MCP service error logs
|
# Handle MCP service error logs
|
||||||
self.server_errlogs.append(msg)
|
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||||
|
if msg.level in (
|
||||||
|
"warning",
|
||||||
|
"error",
|
||||||
|
"critical",
|
||||||
|
"alert",
|
||||||
|
"emergency",
|
||||||
|
):
|
||||||
|
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||||
|
self.server_errlogs.append(log_msg)
|
||||||
|
|
||||||
stdio_transport = await self.exit_stack.enter_async_context(
|
stdio_transport = await self.exit_stack.enter_async_context(
|
||||||
mcp.stdio_client(
|
mcp.stdio_client(
|
||||||
server_params,
|
server_params,
|
||||||
errlog=LogPipe(
|
errlog=LogPipe(
|
||||||
level=logging.ERROR,
|
level=logging.INFO,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
identifier=f"MCPServer-{name}",
|
identifier=f"MCPServer-{name}",
|
||||||
callback=callback,
|
callback=callback,
|
||||||
@@ -343,7 +356,7 @@ class MCPClient:
|
|||||||
|
|
||||||
return await _call_with_retry()
|
return await _call_with_retry()
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self) -> None:
|
||||||
"""Clean up resources including old exit stacks from reconnections"""
|
"""Clean up resources including old exit stacks from reconnections"""
|
||||||
# Close current exit stack
|
# Close current exit stack
|
||||||
try:
|
try:
|
||||||
@@ -365,7 +378,7 @@ class MCPTool(FunctionTool, Generic[TContext]):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs
|
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs
|
||||||
):
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name=mcp_tool.name,
|
name=mcp_tool.name,
|
||||||
description=mcp_tool.description or "",
|
description=mcp_tool.description or "",
|
||||||
@@ -374,6 +387,7 @@ class MCPTool(FunctionTool, Generic[TContext]):
|
|||||||
self.mcp_tool = mcp_tool
|
self.mcp_tool = mcp_tool
|
||||||
self.mcp_client = mcp_client
|
self.mcp_client = mcp_client
|
||||||
self.mcp_server_name = mcp_server_name
|
self.mcp_server_name = mcp_server_name
|
||||||
|
self.source = "mcp"
|
||||||
|
|
||||||
async def call(
|
async def call(
|
||||||
self, context: ContextWrapper[TContext], **kwargs
|
self, context: ContextWrapper[TContext], **kwargs
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
|
|
||||||
from typing import Any, ClassVar, Literal, cast
|
from typing import Any, ClassVar, Literal, cast
|
||||||
|
|
||||||
from pydantic import BaseModel, GetCoreSchemaHandler, model_validator
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
GetCoreSchemaHandler,
|
||||||
|
PrivateAttr,
|
||||||
|
model_serializer,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
from pydantic_core import core_schema
|
from pydantic_core import core_schema
|
||||||
|
|
||||||
|
|
||||||
@@ -12,7 +18,7 @@ class ContentPart(BaseModel):
|
|||||||
|
|
||||||
__content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}
|
__content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}
|
||||||
|
|
||||||
type: str
|
type: Literal["text", "think", "image_url", "audio_url"]
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||||
super().__init_subclass__(**kwargs)
|
super().__init_subclass__(**kwargs)
|
||||||
@@ -63,6 +69,28 @@ class TextPart(ContentPart):
|
|||||||
text: str
|
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):
|
class ImageURLPart(ContentPart):
|
||||||
"""
|
"""
|
||||||
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
|
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
|
||||||
@@ -122,10 +150,12 @@ class ToolCall(BaseModel):
|
|||||||
extra_content: dict[str, Any] | None = None
|
extra_content: dict[str, Any] | None = None
|
||||||
"""Extra metadata for the tool call."""
|
"""Extra metadata for the tool call."""
|
||||||
|
|
||||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
@model_serializer(mode="wrap")
|
||||||
|
def serialize(self, handler):
|
||||||
|
data = handler(self)
|
||||||
if self.extra_content is None:
|
if self.extra_content is None:
|
||||||
kwargs.setdefault("exclude", set()).add("extra_content")
|
data.pop("extra_content", None)
|
||||||
return super().model_dump(**kwargs)
|
return data
|
||||||
|
|
||||||
|
|
||||||
class ToolCallPart(BaseModel):
|
class ToolCallPart(BaseModel):
|
||||||
@@ -154,6 +184,8 @@ class Message(BaseModel):
|
|||||||
tool_call_id: str | None = None
|
tool_call_id: str | None = None
|
||||||
"""The ID of the tool call."""
|
"""The ID of the tool call."""
|
||||||
|
|
||||||
|
_no_save: bool = PrivateAttr(default=False)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def check_content_required(self):
|
def check_content_required(self):
|
||||||
# assistant + tool_calls is not None: allow content to be None
|
# assistant + tool_calls is not None: allow content to be None
|
||||||
@@ -167,6 +199,15 @@ class Message(BaseModel):
|
|||||||
)
|
)
|
||||||
return self
|
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):
|
class AssistantMessageSegment(Message):
|
||||||
"""A message segment from the assistant."""
|
"""A message segment from the assistant."""
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import typing as T
|
import typing as T
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from astrbot.core.message.message_event_result import MessageChain
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
|
from astrbot.core.provider.entities import TokenUsage
|
||||||
|
|
||||||
|
|
||||||
class AgentResponseData(T.TypedDict):
|
class AgentResponseData(T.TypedDict):
|
||||||
@@ -12,3 +13,23 @@ class AgentResponseData(T.TypedDict):
|
|||||||
class AgentResponse:
|
class AgentResponse:
|
||||||
type: str
|
type: str
|
||||||
data: AgentResponseData
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from .message import Message
|
|||||||
TContext = TypeVar("TContext", default=Any)
|
TContext = TypeVar("TContext", default=Any)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(config={"arbitrary_types_allowed": True})
|
@dataclass
|
||||||
class ContextWrapper(Generic[TContext]):
|
class ContextWrapper(Generic[TContext]):
|
||||||
"""A context for running an agent, which can be used to pass additional data or state."""
|
"""A context for running an agent, which can be used to pass additional data or state."""
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from astrbot.core import logger
|
|||||||
|
|
||||||
|
|
||||||
class CozeAPIClient:
|
class CozeAPIClient:
|
||||||
def __init__(self, api_key: str, api_base: str = "https://api.coze.cn"):
|
def __init__(self, api_key: str, api_base: str = "https://api.coze.cn") -> None:
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.api_base = api_base
|
self.api_base = api_base
|
||||||
self.session = None
|
self.session = None
|
||||||
@@ -277,7 +277,7 @@ class CozeAPIClient:
|
|||||||
logger.error(f"获取Coze消息列表失败: {e!s}")
|
logger.error(f"获取Coze消息列表失败: {e!s}")
|
||||||
raise Exception(f"获取Coze消息列表失败: {e!s}")
|
raise Exception(f"获取Coze消息列表失败: {e!s}")
|
||||||
|
|
||||||
async def close(self):
|
async def close(self) -> None:
|
||||||
"""关闭会话"""
|
"""关闭会话"""
|
||||||
if self.session:
|
if self.session:
|
||||||
await self.session.close()
|
await self.session.close()
|
||||||
@@ -288,7 +288,7 @@ if __name__ == "__main__":
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
|
||||||
async def test_coze_api_client():
|
async def test_coze_api_client() -> None:
|
||||||
api_key = os.getenv("COZE_API_KEY", "")
|
api_key = os.getenv("COZE_API_KEY", "")
|
||||||
bot_id = os.getenv("COZE_BOT_ID", "")
|
bot_id = os.getenv("COZE_BOT_ID", "")
|
||||||
client = CozeAPIClient(api_key=api_key)
|
client = CozeAPIClient(api_key=api_key)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
if isinstance(self.timeout, str):
|
if isinstance(self.timeout, str):
|
||||||
self.timeout = int(self.timeout)
|
self.timeout = int(self.timeout)
|
||||||
|
|
||||||
def has_rag_options(self):
|
def has_rag_options(self) -> bool:
|
||||||
"""判断是否有 RAG 选项
|
"""判断是否有 RAG 选项
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -302,7 +302,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
item_type, item_data = await asyncio.get_event_loop().run_in_executor(
|
item_type, item_data = await asyncio.get_running_loop().run_in_executor(
|
||||||
None, response_queue.get, True, 1
|
None, response_queue.get, True, 1
|
||||||
)
|
)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
@@ -388,7 +388,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
|
|
||||||
# 发起请求
|
# 发起请求
|
||||||
partial = functools.partial(Application.call, **payload)
|
partial = functools.partial(Application.call, **payload)
|
||||||
response = await asyncio.get_event_loop().run_in_executor(None, partial)
|
response = await asyncio.get_running_loop().run_in_executor(None, partial)
|
||||||
|
|
||||||
async for resp in self._handle_streaming_response(response, session_id):
|
async for resp in self._handle_streaming_response(response, session_id):
|
||||||
yield resp
|
yield resp
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DEERFLOW_PROVIDER_TYPE = "deerflow"
|
||||||
|
DEERFLOW_THREAD_ID_KEY = "deerflow_thread_id"
|
||||||
|
DEERFLOW_SESSION_PREFIX = "deerflow-ephemeral"
|
||||||
|
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY = "deerflow_agent_runner_provider_id"
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import typing as T
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
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 astrbot.core.utils.config_number import coerce_int_config
|
||||||
|
|
||||||
|
from ...hooks import BaseAgentRunHooks
|
||||||
|
from ...response import AgentResponseData
|
||||||
|
from ...run_context import ContextWrapper, TContext
|
||||||
|
from ..base import AgentResponse, AgentState, BaseAgentRunner
|
||||||
|
from .constants import DEERFLOW_SESSION_PREFIX, DEERFLOW_THREAD_ID_KEY
|
||||||
|
from .deerflow_api_client import DeerFlowAPIClient
|
||||||
|
from .deerflow_content_mapper import (
|
||||||
|
build_chain_from_ai_content,
|
||||||
|
build_user_content,
|
||||||
|
image_component_from_url,
|
||||||
|
)
|
||||||
|
from .deerflow_stream_utils import (
|
||||||
|
build_task_failure_summary,
|
||||||
|
extract_ai_delta_from_event_data,
|
||||||
|
extract_clarification_from_event_data,
|
||||||
|
extract_latest_ai_message,
|
||||||
|
extract_latest_ai_text,
|
||||||
|
extract_latest_clarification_text,
|
||||||
|
extract_messages_from_values_data,
|
||||||
|
extract_task_failures_from_custom_event,
|
||||||
|
get_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 12):
|
||||||
|
from typing import override
|
||||||
|
else:
|
||||||
|
from typing_extensions import override
|
||||||
|
|
||||||
|
|
||||||
|
class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
|
||||||
|
"""DeerFlow Agent Runner via LangGraph HTTP API."""
|
||||||
|
|
||||||
|
_MAX_VALUES_HISTORY = 200
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _RunnerConfig:
|
||||||
|
api_base: str
|
||||||
|
api_key: str
|
||||||
|
auth_header: str
|
||||||
|
proxy: str
|
||||||
|
assistant_id: str
|
||||||
|
model_name: str
|
||||||
|
thinking_enabled: bool
|
||||||
|
plan_mode: bool
|
||||||
|
subagent_enabled: bool
|
||||||
|
max_concurrent_subagents: int
|
||||||
|
timeout: int
|
||||||
|
recursion_limit: int
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _StreamState:
|
||||||
|
latest_text: str = ""
|
||||||
|
prev_text_for_streaming: str = ""
|
||||||
|
clarification_text: str = ""
|
||||||
|
task_failures: list[str] = field(default_factory=list)
|
||||||
|
seen_message_ids: set[str] = field(default_factory=set)
|
||||||
|
seen_message_order: deque[str] = field(default_factory=deque)
|
||||||
|
# Fallback tracking for backends that omit message ids in values events.
|
||||||
|
no_id_message_fingerprints: dict[int, str] = field(default_factory=dict)
|
||||||
|
baseline_initialized: bool = False
|
||||||
|
has_values_text: bool = False
|
||||||
|
run_values_messages: list[dict[str, T.Any]] = field(default_factory=list)
|
||||||
|
timed_out: bool = False
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _FinalResult:
|
||||||
|
chain: MessageChain
|
||||||
|
role: str
|
||||||
|
|
||||||
|
def _format_exception(self, err: Exception) -> str:
|
||||||
|
err_type = type(err).__name__
|
||||||
|
detail = str(err).strip()
|
||||||
|
|
||||||
|
if isinstance(err, (asyncio.TimeoutError, TimeoutError)):
|
||||||
|
timeout_text = (
|
||||||
|
f"{self.timeout}s"
|
||||||
|
if isinstance(getattr(self, "timeout", None), (int, float))
|
||||||
|
else "configured timeout"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{err_type}: request timed out after {timeout_text}. "
|
||||||
|
"Please check DeerFlow service health and backend logs."
|
||||||
|
)
|
||||||
|
|
||||||
|
if detail:
|
||||||
|
if detail.startswith(f"{err_type}:"):
|
||||||
|
return detail
|
||||||
|
return f"{err_type}: {detail}"
|
||||||
|
|
||||||
|
return f"{err_type}: no detailed error message provided."
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Explicit cleanup hook for long-lived workers."""
|
||||||
|
api_client = getattr(self, "api_client", None)
|
||||||
|
if isinstance(api_client, DeerFlowAPIClient) and not api_client.is_closed:
|
||||||
|
try:
|
||||||
|
await api_client.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to close DeerFlowAPIClient during runner shutdown: %s",
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _notify_agent_done_hook(self) -> None:
|
||||||
|
if not self.final_llm_resp:
|
||||||
|
return
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def _finish_with_result(
|
||||||
|
self, chain: MessageChain, role: str
|
||||||
|
) -> AgentResponse:
|
||||||
|
self.final_llm_resp = LLMResponse(
|
||||||
|
role=role,
|
||||||
|
result_chain=chain,
|
||||||
|
)
|
||||||
|
self._transition_state(AgentState.DONE)
|
||||||
|
await self._notify_agent_done_hook()
|
||||||
|
return AgentResponse(
|
||||||
|
type="llm_result",
|
||||||
|
data=AgentResponseData(chain=chain),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _finish_with_error(self, err_msg: str) -> AgentResponse:
|
||||||
|
err_text = f"DeerFlow request failed: {err_msg}"
|
||||||
|
err_chain = MessageChain().message(err_text)
|
||||||
|
self.final_llm_resp = LLMResponse(
|
||||||
|
role="err",
|
||||||
|
completion_text=err_text,
|
||||||
|
result_chain=err_chain,
|
||||||
|
)
|
||||||
|
self._transition_state(AgentState.ERROR)
|
||||||
|
await self._notify_agent_done_hook()
|
||||||
|
return AgentResponse(
|
||||||
|
type="err",
|
||||||
|
data=AgentResponseData(
|
||||||
|
chain=err_chain,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_runner_config(self, provider_config: dict) -> _RunnerConfig:
|
||||||
|
api_base = provider_config.get("deerflow_api_base", "http://127.0.0.1:2026")
|
||||||
|
if not isinstance(api_base, str) or not api_base.startswith(
|
||||||
|
("http://", "https://"),
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"DeerFlow API Base URL format is invalid. It must start with http:// or https://.",
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy = provider_config.get("proxy", "")
|
||||||
|
normalized_proxy = proxy.strip() if isinstance(proxy, str) else ""
|
||||||
|
|
||||||
|
return self._RunnerConfig(
|
||||||
|
api_base=api_base,
|
||||||
|
api_key=provider_config.get("deerflow_api_key", ""),
|
||||||
|
auth_header=provider_config.get("deerflow_auth_header", ""),
|
||||||
|
proxy=normalized_proxy,
|
||||||
|
assistant_id=provider_config.get("deerflow_assistant_id", "lead_agent"),
|
||||||
|
model_name=provider_config.get("deerflow_model_name", ""),
|
||||||
|
thinking_enabled=bool(
|
||||||
|
provider_config.get("deerflow_thinking_enabled", False),
|
||||||
|
),
|
||||||
|
plan_mode=bool(provider_config.get("deerflow_plan_mode", False)),
|
||||||
|
subagent_enabled=bool(
|
||||||
|
provider_config.get("deerflow_subagent_enabled", False),
|
||||||
|
),
|
||||||
|
max_concurrent_subagents=coerce_int_config(
|
||||||
|
provider_config.get("deerflow_max_concurrent_subagents", 3),
|
||||||
|
default=3,
|
||||||
|
min_value=1,
|
||||||
|
field_name="deerflow_max_concurrent_subagents",
|
||||||
|
source="DeerFlow config",
|
||||||
|
),
|
||||||
|
timeout=coerce_int_config(
|
||||||
|
provider_config.get("timeout", 300),
|
||||||
|
default=300,
|
||||||
|
min_value=1,
|
||||||
|
field_name="timeout",
|
||||||
|
source="DeerFlow config",
|
||||||
|
),
|
||||||
|
recursion_limit=coerce_int_config(
|
||||||
|
provider_config.get("deerflow_recursion_limit", 1000),
|
||||||
|
default=1000,
|
||||||
|
min_value=1,
|
||||||
|
field_name="deerflow_recursion_limit",
|
||||||
|
source="DeerFlow config",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _load_config_and_client(self, provider_config: dict) -> None:
|
||||||
|
config = self._parse_runner_config(provider_config)
|
||||||
|
|
||||||
|
self.api_base = config.api_base
|
||||||
|
self.api_key = config.api_key
|
||||||
|
self.auth_header = config.auth_header
|
||||||
|
self.proxy = config.proxy
|
||||||
|
self.assistant_id = config.assistant_id
|
||||||
|
self.model_name = config.model_name
|
||||||
|
self.thinking_enabled = config.thinking_enabled
|
||||||
|
self.plan_mode = config.plan_mode
|
||||||
|
self.subagent_enabled = config.subagent_enabled
|
||||||
|
self.max_concurrent_subagents = config.max_concurrent_subagents
|
||||||
|
self.timeout = config.timeout
|
||||||
|
self.recursion_limit = config.recursion_limit
|
||||||
|
|
||||||
|
new_client_signature = (
|
||||||
|
config.api_base,
|
||||||
|
config.api_key,
|
||||||
|
config.auth_header,
|
||||||
|
config.proxy,
|
||||||
|
)
|
||||||
|
old_client = getattr(self, "api_client", None)
|
||||||
|
old_signature = getattr(self, "_api_client_signature", None)
|
||||||
|
|
||||||
|
if (
|
||||||
|
isinstance(old_client, DeerFlowAPIClient)
|
||||||
|
and old_signature == new_client_signature
|
||||||
|
and not old_client.is_closed
|
||||||
|
):
|
||||||
|
self.api_client = old_client
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(old_client, DeerFlowAPIClient):
|
||||||
|
try:
|
||||||
|
await old_client.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to close previous DeerFlow API client cleanly: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.api_client = DeerFlowAPIClient(
|
||||||
|
api_base=config.api_base,
|
||||||
|
api_key=config.api_key,
|
||||||
|
auth_header=config.auth_header,
|
||||||
|
proxy=config.proxy,
|
||||||
|
)
|
||||||
|
self._api_client_signature = new_client_signature
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
await self._load_config_and_client(provider_config)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def step(self):
|
||||||
|
if not self.req:
|
||||||
|
raise ValueError("Request is not set. Please call reset() first.")
|
||||||
|
if self.done():
|
||||||
|
return
|
||||||
|
|
||||||
|
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:
|
||||||
|
async for response in self._execute_deerflow_request():
|
||||||
|
yield response
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Let caller manage cancellation semantics.
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
err_msg = self._format_exception(e)
|
||||||
|
logger.error(f"DeerFlow request failed: {err_msg}", exc_info=True)
|
||||||
|
yield await self._finish_with_error(err_msg)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def step_until_done(
|
||||||
|
self, max_step: int = 30
|
||||||
|
) -> T.AsyncGenerator[AgentResponse, None]:
|
||||||
|
if max_step <= 0:
|
||||||
|
raise ValueError("max_step must be greater than 0")
|
||||||
|
|
||||||
|
step_count = 0
|
||||||
|
while not self.done() and step_count < max_step:
|
||||||
|
step_count += 1
|
||||||
|
async for resp in self.step():
|
||||||
|
yield resp
|
||||||
|
|
||||||
|
if not self.done():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"DeerFlow agent reached max_step ({max_step}) without completion."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_new_messages_from_values(
|
||||||
|
self,
|
||||||
|
values_messages: list[T.Any],
|
||||||
|
state: _StreamState,
|
||||||
|
) -> list[dict[str, T.Any]]:
|
||||||
|
new_messages: list[dict[str, T.Any]] = []
|
||||||
|
no_id_indexes_seen: set[int] = set()
|
||||||
|
for idx, msg in enumerate(values_messages):
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
msg_id = get_message_id(msg)
|
||||||
|
if msg_id:
|
||||||
|
if msg_id in state.seen_message_ids:
|
||||||
|
continue
|
||||||
|
self._remember_seen_message_id(state, msg_id)
|
||||||
|
new_messages.append(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
no_id_indexes_seen.add(idx)
|
||||||
|
msg_fingerprint = self._fingerprint_message(msg)
|
||||||
|
if state.no_id_message_fingerprints.get(idx) == msg_fingerprint:
|
||||||
|
continue
|
||||||
|
state.no_id_message_fingerprints[idx] = msg_fingerprint
|
||||||
|
new_messages.append(msg)
|
||||||
|
|
||||||
|
# Keep no-id index state aligned with latest values payload shape.
|
||||||
|
for idx in list(state.no_id_message_fingerprints.keys()):
|
||||||
|
if idx not in no_id_indexes_seen:
|
||||||
|
state.no_id_message_fingerprints.pop(idx, None)
|
||||||
|
return new_messages
|
||||||
|
|
||||||
|
def _fingerprint_message(self, message: dict[str, T.Any]) -> str:
|
||||||
|
try:
|
||||||
|
raw = json.dumps(message, sort_keys=True, ensure_ascii=False, default=str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raw = repr(message)
|
||||||
|
return hashlib.sha1(raw.encode("utf-8", errors="ignore")).hexdigest()
|
||||||
|
|
||||||
|
def _remember_seen_message_id(self, state: _StreamState, msg_id: str) -> None:
|
||||||
|
if not msg_id or msg_id in state.seen_message_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
state.seen_message_ids.add(msg_id)
|
||||||
|
state.seen_message_order.append(msg_id)
|
||||||
|
while len(state.seen_message_order) > self._MAX_VALUES_HISTORY:
|
||||||
|
dropped = state.seen_message_order.popleft()
|
||||||
|
state.seen_message_ids.discard(dropped)
|
||||||
|
|
||||||
|
async def _ensure_thread_id(self, session_id: str) -> str:
|
||||||
|
thread_id = await sp.get_async(
|
||||||
|
scope="umo",
|
||||||
|
scope_id=session_id,
|
||||||
|
key=DEERFLOW_THREAD_ID_KEY,
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
if thread_id:
|
||||||
|
return thread_id
|
||||||
|
|
||||||
|
thread = await self.api_client.create_thread(timeout=min(30, self.timeout))
|
||||||
|
thread_id = thread.get("thread_id", "")
|
||||||
|
if not thread_id:
|
||||||
|
raise Exception(
|
||||||
|
f"DeerFlow create thread returned invalid payload: {thread}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await sp.put_async(
|
||||||
|
scope="umo",
|
||||||
|
scope_id=session_id,
|
||||||
|
key=DEERFLOW_THREAD_ID_KEY,
|
||||||
|
value=thread_id,
|
||||||
|
)
|
||||||
|
return thread_id
|
||||||
|
|
||||||
|
def _build_messages(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
image_urls: list[str],
|
||||||
|
system_prompt: str | None,
|
||||||
|
) -> list[dict[str, T.Any]]:
|
||||||
|
messages: list[dict[str, T.Any]] = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": build_user_content(prompt, image_urls),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _build_runtime_context(self, thread_id: str) -> dict[str, T.Any]:
|
||||||
|
runtime_context: dict[str, T.Any] = {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"thinking_enabled": self.thinking_enabled,
|
||||||
|
"is_plan_mode": self.plan_mode,
|
||||||
|
"subagent_enabled": self.subagent_enabled,
|
||||||
|
}
|
||||||
|
if self.subagent_enabled:
|
||||||
|
runtime_context["max_concurrent_subagents"] = self.max_concurrent_subagents
|
||||||
|
if self.model_name:
|
||||||
|
runtime_context["model_name"] = self.model_name
|
||||||
|
return runtime_context
|
||||||
|
|
||||||
|
def _build_payload(
|
||||||
|
self,
|
||||||
|
thread_id: str,
|
||||||
|
prompt: str,
|
||||||
|
image_urls: list[str],
|
||||||
|
system_prompt: str | None,
|
||||||
|
) -> dict[str, T.Any]:
|
||||||
|
return {
|
||||||
|
"assistant_id": self.assistant_id,
|
||||||
|
"input": {
|
||||||
|
"messages": self._build_messages(prompt, image_urls, system_prompt),
|
||||||
|
},
|
||||||
|
"stream_mode": ["values", "messages-tuple", "custom"],
|
||||||
|
# LangGraph 0.6+ prefers context instead of configurable.
|
||||||
|
"context": self._build_runtime_context(thread_id),
|
||||||
|
"config": {
|
||||||
|
"recursion_limit": self.recursion_limit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _update_text_and_maybe_stream(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: _StreamState,
|
||||||
|
new_full_text: str | None = None,
|
||||||
|
delta_text: str | None = None,
|
||||||
|
) -> list[AgentResponse]:
|
||||||
|
if new_full_text:
|
||||||
|
state.latest_text = new_full_text
|
||||||
|
if not self.streaming:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if new_full_text.startswith(state.prev_text_for_streaming):
|
||||||
|
delta = new_full_text[len(state.prev_text_for_streaming) :]
|
||||||
|
else:
|
||||||
|
delta = new_full_text
|
||||||
|
|
||||||
|
if not delta:
|
||||||
|
return []
|
||||||
|
|
||||||
|
state.prev_text_for_streaming = new_full_text
|
||||||
|
return [
|
||||||
|
AgentResponse(
|
||||||
|
type="streaming_delta",
|
||||||
|
data=AgentResponseData(chain=MessageChain().message(delta)),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if delta_text:
|
||||||
|
state.latest_text += delta_text
|
||||||
|
if self.streaming:
|
||||||
|
return [
|
||||||
|
AgentResponse(
|
||||||
|
type="streaming_delta",
|
||||||
|
data=AgentResponseData(
|
||||||
|
chain=MessageChain().message(delta_text)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _handle_values_event(
|
||||||
|
self,
|
||||||
|
data: T.Any,
|
||||||
|
state: _StreamState,
|
||||||
|
) -> list[AgentResponse]:
|
||||||
|
responses: list[AgentResponse] = []
|
||||||
|
values_messages = extract_messages_from_values_data(data)
|
||||||
|
if not values_messages:
|
||||||
|
return responses
|
||||||
|
|
||||||
|
new_messages: list[dict[str, T.Any]] = []
|
||||||
|
if not state.baseline_initialized:
|
||||||
|
state.baseline_initialized = True
|
||||||
|
for idx, msg in enumerate(values_messages):
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
new_messages.append(msg)
|
||||||
|
msg_id = get_message_id(msg)
|
||||||
|
if msg_id:
|
||||||
|
self._remember_seen_message_id(state, msg_id)
|
||||||
|
continue
|
||||||
|
state.no_id_message_fingerprints[idx] = self._fingerprint_message(msg)
|
||||||
|
else:
|
||||||
|
new_messages = self._extract_new_messages_from_values(
|
||||||
|
values_messages,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
latest_text = ""
|
||||||
|
if new_messages:
|
||||||
|
state.run_values_messages.extend(new_messages)
|
||||||
|
if len(state.run_values_messages) > self._MAX_VALUES_HISTORY:
|
||||||
|
state.run_values_messages = state.run_values_messages[
|
||||||
|
-self._MAX_VALUES_HISTORY :
|
||||||
|
]
|
||||||
|
latest_text = extract_latest_ai_text(state.run_values_messages)
|
||||||
|
if latest_text:
|
||||||
|
state.has_values_text = True
|
||||||
|
latest_clarification = extract_latest_clarification_text(
|
||||||
|
state.run_values_messages,
|
||||||
|
)
|
||||||
|
if latest_clarification:
|
||||||
|
state.clarification_text = latest_clarification
|
||||||
|
|
||||||
|
responses.extend(
|
||||||
|
self._update_text_and_maybe_stream(
|
||||||
|
state=state,
|
||||||
|
new_full_text=latest_text or None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return responses
|
||||||
|
|
||||||
|
def _handle_message_event(
|
||||||
|
self,
|
||||||
|
data: T.Any,
|
||||||
|
state: _StreamState,
|
||||||
|
) -> AgentResponse | None:
|
||||||
|
delta = extract_ai_delta_from_event_data(data)
|
||||||
|
|
||||||
|
responses: list[AgentResponse] = []
|
||||||
|
if delta and not state.has_values_text:
|
||||||
|
responses.extend(
|
||||||
|
self._update_text_and_maybe_stream(
|
||||||
|
state=state,
|
||||||
|
delta_text=delta,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
maybe_clarification = extract_clarification_from_event_data(data)
|
||||||
|
if maybe_clarification:
|
||||||
|
state.clarification_text = maybe_clarification
|
||||||
|
return responses[0] if responses else None
|
||||||
|
|
||||||
|
def _build_final_result(self, state: _StreamState) -> _FinalResult:
|
||||||
|
failures_only = False
|
||||||
|
|
||||||
|
if state.clarification_text:
|
||||||
|
final_chain = MessageChain(chain=[Comp.Plain(state.clarification_text)])
|
||||||
|
else:
|
||||||
|
final_chain = MessageChain()
|
||||||
|
latest_ai_message = extract_latest_ai_message(state.run_values_messages)
|
||||||
|
if latest_ai_message:
|
||||||
|
final_chain = build_chain_from_ai_content(
|
||||||
|
latest_ai_message.get("content"),
|
||||||
|
image_component_from_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not final_chain.chain and state.latest_text:
|
||||||
|
final_chain = MessageChain(chain=[Comp.Plain(state.latest_text)])
|
||||||
|
|
||||||
|
if not final_chain.chain:
|
||||||
|
failure_text = build_task_failure_summary(state.task_failures)
|
||||||
|
if failure_text:
|
||||||
|
final_chain = MessageChain(chain=[Comp.Plain(failure_text)])
|
||||||
|
failures_only = True
|
||||||
|
|
||||||
|
if not final_chain.chain:
|
||||||
|
logger.warning("DeerFlow returned no text content in stream events.")
|
||||||
|
final_chain = MessageChain(
|
||||||
|
chain=[Comp.Plain("DeerFlow returned an empty response.")],
|
||||||
|
)
|
||||||
|
|
||||||
|
if state.timed_out:
|
||||||
|
timeout_note = (
|
||||||
|
f"DeerFlow stream timed out after {self.timeout}s. "
|
||||||
|
"Returning partial result."
|
||||||
|
)
|
||||||
|
if final_chain.chain and isinstance(final_chain.chain[-1], Comp.Plain):
|
||||||
|
last_text = final_chain.chain[-1].text
|
||||||
|
final_chain.chain[-1].text = (
|
||||||
|
f"{last_text}\n\n{timeout_note}" if last_text else timeout_note
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
final_chain.chain.append(Comp.Plain(timeout_note))
|
||||||
|
|
||||||
|
role = "err" if (state.timed_out or failures_only) else "assistant"
|
||||||
|
return self._FinalResult(chain=final_chain, role=role)
|
||||||
|
|
||||||
|
def _emit_non_plain_components_at_end(
|
||||||
|
self,
|
||||||
|
final_chain: MessageChain,
|
||||||
|
) -> AgentResponse | None:
|
||||||
|
non_plain_components = [
|
||||||
|
component
|
||||||
|
for component in final_chain.chain
|
||||||
|
if not isinstance(component, Comp.Plain)
|
||||||
|
]
|
||||||
|
if not non_plain_components:
|
||||||
|
return None
|
||||||
|
return AgentResponse(
|
||||||
|
type="streaming_delta",
|
||||||
|
data=AgentResponseData(
|
||||||
|
chain=MessageChain(chain=non_plain_components),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute_deerflow_request(self):
|
||||||
|
prompt = self.req.prompt or ""
|
||||||
|
session_id = self.req.session_id or f"{DEERFLOW_SESSION_PREFIX}-{uuid4()}"
|
||||||
|
image_urls = self.req.image_urls or []
|
||||||
|
system_prompt = self.req.system_prompt
|
||||||
|
|
||||||
|
thread_id = await self._ensure_thread_id(session_id)
|
||||||
|
payload = self._build_payload(
|
||||||
|
thread_id=thread_id,
|
||||||
|
prompt=prompt,
|
||||||
|
image_urls=image_urls,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
)
|
||||||
|
state = self._StreamState()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for event in self.api_client.stream_run(
|
||||||
|
thread_id=thread_id,
|
||||||
|
payload=payload,
|
||||||
|
timeout=self.timeout,
|
||||||
|
):
|
||||||
|
event_type = event.get("event")
|
||||||
|
data = event.get("data")
|
||||||
|
|
||||||
|
if event_type == "values":
|
||||||
|
for response in self._handle_values_event(data, state):
|
||||||
|
yield response
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type in {"messages-tuple", "messages", "message"}:
|
||||||
|
response = self._handle_message_event(data, state)
|
||||||
|
if response:
|
||||||
|
yield response
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type == "custom":
|
||||||
|
state.task_failures.extend(
|
||||||
|
extract_task_failures_from_custom_event(data),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type == "error":
|
||||||
|
raise Exception(f"DeerFlow stream returned error event: {data}")
|
||||||
|
|
||||||
|
if event_type == "end":
|
||||||
|
break
|
||||||
|
except (asyncio.TimeoutError, TimeoutError):
|
||||||
|
logger.warning(
|
||||||
|
"DeerFlow stream timed out after %ss for thread_id=%s; returning partial result.",
|
||||||
|
self.timeout,
|
||||||
|
thread_id,
|
||||||
|
)
|
||||||
|
state.timed_out = True
|
||||||
|
|
||||||
|
final_result = self._build_final_result(state)
|
||||||
|
|
||||||
|
if self.streaming:
|
||||||
|
extra_response = self._emit_non_plain_components_at_end(final_result.chain)
|
||||||
|
if extra_response:
|
||||||
|
yield extra_response
|
||||||
|
|
||||||
|
yield await self._finish_with_result(final_result.chain, final_result.role)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def done(self) -> bool:
|
||||||
|
"""Check whether the agent has finished or failed."""
|
||||||
|
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,245 @@
|
|||||||
|
import codecs
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import ClientResponse, ClientSession, ClientTimeout
|
||||||
|
|
||||||
|
from astrbot.core import logger
|
||||||
|
|
||||||
|
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sse_newlines(text: str) -> str:
|
||||||
|
"""Normalize CRLF/CR to LF so SSE block splitting works reliably."""
|
||||||
|
return text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_data_lines(data_lines: list[str]) -> Any:
|
||||||
|
raw_data = "\n".join(data_lines)
|
||||||
|
try:
|
||||||
|
return json.loads(raw_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Some LangGraph-compatible servers emit multiple JSON fragments
|
||||||
|
# in one SSE event using repeated data lines (e.g. tuple payloads).
|
||||||
|
parsed_lines: list[Any] = []
|
||||||
|
can_parse_all = True
|
||||||
|
for line in data_lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed_lines.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
can_parse_all = False
|
||||||
|
break
|
||||||
|
if can_parse_all and parsed_lines:
|
||||||
|
return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines
|
||||||
|
return raw_data
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_block(block: str) -> dict[str, Any] | None:
|
||||||
|
if not block.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_name = "message"
|
||||||
|
data_lines: list[str] = []
|
||||||
|
for line in block.splitlines():
|
||||||
|
if line.startswith("event:"):
|
||||||
|
event_name = line[6:].strip()
|
||||||
|
elif line.startswith("data:"):
|
||||||
|
data_lines.append(line[5:].lstrip())
|
||||||
|
|
||||||
|
if not data_lines:
|
||||||
|
return None
|
||||||
|
return {"event": event_name, "data": _parse_sse_data_lines(data_lines)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_sse(resp: ClientResponse) -> AsyncGenerator[dict[str, Any], None]:
|
||||||
|
"""Parse SSE response blocks into event/data dictionaries."""
|
||||||
|
# Use a forgiving decoder at network boundaries so malformed bytes do not abort stream parsing.
|
||||||
|
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||||
|
buffer = ""
|
||||||
|
|
||||||
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
buffer += _normalize_sse_newlines(decoder.decode(chunk))
|
||||||
|
|
||||||
|
while "\n\n" in buffer:
|
||||||
|
block, buffer = buffer.split("\n\n", 1)
|
||||||
|
parsed = _parse_sse_block(block)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
|
||||||
|
if len(buffer) > SSE_MAX_BUFFER_CHARS:
|
||||||
|
logger.warning(
|
||||||
|
"DeerFlow SSE parser buffer exceeded %d chars without delimiter; "
|
||||||
|
"flushing oversized block to prevent unbounded memory growth.",
|
||||||
|
SSE_MAX_BUFFER_CHARS,
|
||||||
|
)
|
||||||
|
parsed = _parse_sse_block(buffer)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
buffer = ""
|
||||||
|
|
||||||
|
# flush any remaining buffered text
|
||||||
|
buffer += _normalize_sse_newlines(decoder.decode(b"", final=True))
|
||||||
|
while "\n\n" in buffer:
|
||||||
|
block, buffer = buffer.split("\n\n", 1)
|
||||||
|
parsed = _parse_sse_block(block)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
|
||||||
|
if buffer.strip():
|
||||||
|
parsed = _parse_sse_block(buffer)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
|
||||||
|
|
||||||
|
class DeerFlowAPIClient:
|
||||||
|
"""HTTP client for DeerFlow LangGraph API.
|
||||||
|
|
||||||
|
Lifecycle is explicitly managed by callers (runner/stage). `__del__` is only a
|
||||||
|
fallback diagnostic and must not be relied on for cleanup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_base: str = "http://127.0.0.1:2026",
|
||||||
|
api_key: str = "",
|
||||||
|
auth_header: str = "",
|
||||||
|
proxy: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.api_base = api_base.rstrip("/")
|
||||||
|
self._session: ClientSession | None = None
|
||||||
|
self._closed = False
|
||||||
|
self.proxy = proxy.strip() if isinstance(proxy, str) else None
|
||||||
|
if self.proxy == "":
|
||||||
|
self.proxy = None
|
||||||
|
self.headers: dict[str, str] = {}
|
||||||
|
if auth_header:
|
||||||
|
self.headers["Authorization"] = auth_header
|
||||||
|
elif api_key:
|
||||||
|
self.headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
def _get_session(self) -> ClientSession:
|
||||||
|
if self._closed:
|
||||||
|
raise RuntimeError("DeerFlowAPIClient is already closed.")
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = ClientSession(trust_env=True)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "DeerFlowAPIClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc: BaseException | None,
|
||||||
|
tb: object | None,
|
||||||
|
) -> None:
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def create_thread(self, timeout: float = 20) -> dict[str, Any]:
|
||||||
|
session = self._get_session()
|
||||||
|
url = f"{self.api_base}/api/langgraph/threads"
|
||||||
|
payload = {"metadata": {}}
|
||||||
|
async with session.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=timeout,
|
||||||
|
proxy=self.proxy,
|
||||||
|
) as resp:
|
||||||
|
if resp.status not in (200, 201):
|
||||||
|
text = await resp.text()
|
||||||
|
raise Exception(
|
||||||
|
f"DeerFlow create thread failed: {resp.status}. {text}",
|
||||||
|
)
|
||||||
|
return await resp.json()
|
||||||
|
|
||||||
|
async def stream_run(
|
||||||
|
self,
|
||||||
|
thread_id: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
timeout: float = 120,
|
||||||
|
) -> AsyncGenerator[dict[str, Any], None]:
|
||||||
|
session = self._get_session()
|
||||||
|
url = f"{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream"
|
||||||
|
input_payload = payload.get("input")
|
||||||
|
message_count = 0
|
||||||
|
if isinstance(input_payload, dict) and isinstance(
|
||||||
|
input_payload.get("messages"), list
|
||||||
|
):
|
||||||
|
message_count = len(input_payload["messages"])
|
||||||
|
# Log only a minimal summary to avoid exposing sensitive user content.
|
||||||
|
logger.debug(
|
||||||
|
"deerflow stream_run payload summary: thread_id=%s, keys=%s, message_count=%d, stream_mode=%s",
|
||||||
|
thread_id,
|
||||||
|
list(payload.keys()),
|
||||||
|
message_count,
|
||||||
|
payload.get("stream_mode"),
|
||||||
|
)
|
||||||
|
# For long-running SSE streams, avoid aiohttp total timeout.
|
||||||
|
# Use socket read timeout so active heartbeats/chunks can keep the stream alive.
|
||||||
|
stream_timeout = ClientTimeout(
|
||||||
|
total=None,
|
||||||
|
connect=min(timeout, 30),
|
||||||
|
sock_connect=min(timeout, 30),
|
||||||
|
sock_read=timeout,
|
||||||
|
)
|
||||||
|
async with session.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={
|
||||||
|
**self.headers,
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout=stream_timeout,
|
||||||
|
proxy=self.proxy,
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise Exception(
|
||||||
|
f"DeerFlow runs/stream request failed: {resp.status}. {text}",
|
||||||
|
)
|
||||||
|
async for event in _stream_sse(resp):
|
||||||
|
yield event
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
session = self._session
|
||||||
|
if session is None:
|
||||||
|
self._closed = True
|
||||||
|
return
|
||||||
|
|
||||||
|
if session.closed:
|
||||||
|
self._session = None
|
||||||
|
self._closed = True
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to close DeerFlowAPIClient session cleanly: %s",
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Cleanup is best-effort and should not make teardown paths fail loudly.
|
||||||
|
self._session = None
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
session = getattr(self, "_session", None)
|
||||||
|
closed = bool(getattr(self, "_closed", False))
|
||||||
|
if closed or session is None or session.closed:
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"DeerFlowAPIClient garbage collected with unclosed session; "
|
||||||
|
"explicit close() should be called by runner lifecycle (or `async with`)."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closed(self) -> bool:
|
||||||
|
return self._closed
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import base64
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import astrbot.core.message.components as Comp
|
||||||
|
from astrbot import logger
|
||||||
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
|
|
||||||
|
from .deerflow_stream_utils import extract_text
|
||||||
|
|
||||||
|
|
||||||
|
def is_likely_base64_image(value: str) -> bool:
|
||||||
|
if " " in value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
compact = value.replace("\n", "").replace("\r", "")
|
||||||
|
if not compact or len(compact) < 32 or len(compact) % 4 != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||||
|
if any(ch not in base64_chars for ch in compact):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
base64.b64decode(compact, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_content(prompt: str, image_urls: list[str]) -> Any:
|
||||||
|
if not image_urls:
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
content: list[dict[str, Any]] = []
|
||||||
|
skipped_invalid_images = 0
|
||||||
|
any_valid_image = False
|
||||||
|
if prompt:
|
||||||
|
content.append({"type": "text", "text": prompt})
|
||||||
|
|
||||||
|
for image_url in image_urls:
|
||||||
|
url = image_url
|
||||||
|
if not isinstance(url, str):
|
||||||
|
skipped_invalid_images += 1
|
||||||
|
logger.debug(
|
||||||
|
"Skipped DeerFlow image input because value is not a string: %r",
|
||||||
|
type(image_url).__name__,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
url = url.strip()
|
||||||
|
if not url:
|
||||||
|
skipped_invalid_images += 1
|
||||||
|
logger.debug("Skipped DeerFlow image input because value is empty.")
|
||||||
|
continue
|
||||||
|
if url.startswith(("http://", "https://", "data:")):
|
||||||
|
content.append({"type": "image_url", "image_url": {"url": url}})
|
||||||
|
any_valid_image = True
|
||||||
|
continue
|
||||||
|
if not is_likely_base64_image(url):
|
||||||
|
skipped_invalid_images += 1
|
||||||
|
logger.debug(
|
||||||
|
"Skipped DeerFlow image input because it is neither URL/data URI nor valid base64."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
compact_base64 = url.replace("\n", "").replace("\r", "")
|
||||||
|
content.append(
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/png;base64,{compact_base64}"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
any_valid_image = True
|
||||||
|
|
||||||
|
if skipped_invalid_images:
|
||||||
|
note_text = (
|
||||||
|
"Note: some images could not be processed and were ignored."
|
||||||
|
if any_valid_image
|
||||||
|
else "Note: none of the provided images could be processed."
|
||||||
|
)
|
||||||
|
content.insert(0, {"type": "text", "text": note_text})
|
||||||
|
if not any_valid_image:
|
||||||
|
logger.warning(
|
||||||
|
"All %d provided DeerFlow image inputs were rejected as invalid or unsupported.",
|
||||||
|
skipped_invalid_images,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"%d DeerFlow image input(s) were rejected as invalid or unsupported.",
|
||||||
|
skipped_invalid_images,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Skipped %d DeerFlow image inputs that were neither URL/data URI nor valid base64.",
|
||||||
|
skipped_invalid_images,
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def image_component_from_url(url: Any) -> Comp.Image | None:
|
||||||
|
if not isinstance(url, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = url.strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if normalized.startswith(("http://", "https://")):
|
||||||
|
try:
|
||||||
|
return Comp.Image.fromURL(normalized)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not normalized.startswith("data:"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
header, sep, payload = normalized.partition(",")
|
||||||
|
if not sep:
|
||||||
|
return None
|
||||||
|
if ";base64" not in header.lower():
|
||||||
|
return None
|
||||||
|
|
||||||
|
compact_payload = payload.replace("\n", "").replace("\r", "").strip()
|
||||||
|
if not compact_payload:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
base64.b64decode(compact_payload, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return Comp.Image.fromBase64(compact_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def append_components_from_content(
|
||||||
|
content: Any,
|
||||||
|
components: list[Comp.BaseMessageComponent],
|
||||||
|
image_resolver: Callable[[Any], Comp.Image | None],
|
||||||
|
) -> None:
|
||||||
|
if isinstance(content, str):
|
||||||
|
if content:
|
||||||
|
components.append(Comp.Plain(content))
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(content, list):
|
||||||
|
for item in content:
|
||||||
|
append_components_from_content(item, components, image_resolver)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(content, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
item_type = str(content.get("type", "")).lower()
|
||||||
|
if item_type == "text" and isinstance(content.get("text"), str):
|
||||||
|
text = content["text"]
|
||||||
|
if text:
|
||||||
|
components.append(Comp.Plain(text))
|
||||||
|
return
|
||||||
|
|
||||||
|
if item_type == "image_url":
|
||||||
|
image_payload = content.get("image_url")
|
||||||
|
image_url: Any = image_payload
|
||||||
|
if isinstance(image_payload, dict):
|
||||||
|
image_url = image_payload.get("url")
|
||||||
|
image_comp = image_resolver(image_url)
|
||||||
|
if image_comp is not None:
|
||||||
|
components.append(image_comp)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "content" in content:
|
||||||
|
append_components_from_content(
|
||||||
|
content.get("content"), components, image_resolver
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
kwargs = content.get("kwargs")
|
||||||
|
if isinstance(kwargs, dict) and "content" in kwargs:
|
||||||
|
append_components_from_content(
|
||||||
|
kwargs.get("content"), components, image_resolver
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chain_from_ai_content(
|
||||||
|
content: Any,
|
||||||
|
image_resolver: Callable[[Any], Comp.Image | None],
|
||||||
|
) -> MessageChain:
|
||||||
|
components: list[Comp.BaseMessageComponent] = []
|
||||||
|
append_components_from_content(content, components, image_resolver)
|
||||||
|
if components:
|
||||||
|
return MessageChain(chain=components)
|
||||||
|
|
||||||
|
fallback_text = extract_text(content)
|
||||||
|
if fallback_text:
|
||||||
|
return MessageChain(chain=[Comp.Plain(fallback_text)])
|
||||||
|
return MessageChain()
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import typing as T
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text(content: T.Any) -> str:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, dict):
|
||||||
|
if isinstance(content.get("text"), str):
|
||||||
|
return content["text"]
|
||||||
|
if "content" in content:
|
||||||
|
return extract_text(content.get("content"))
|
||||||
|
if "kwargs" in content and isinstance(content["kwargs"], dict):
|
||||||
|
return extract_text(content["kwargs"].get("content"))
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, str):
|
||||||
|
parts.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
item_type = item.get("type")
|
||||||
|
if item_type == "text" and isinstance(item.get("text"), str):
|
||||||
|
parts.append(item["text"])
|
||||||
|
elif "content" in item:
|
||||||
|
parts.append(extract_text(item["content"]))
|
||||||
|
return "\n".join([p for p in parts if p]).strip()
|
||||||
|
return str(content) if content is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_messages_from_values_data(data: T.Any) -> list[T.Any]:
|
||||||
|
"""Extract messages list from possible values event payload shapes."""
|
||||||
|
candidates: list[T.Any] = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
candidates.append(data)
|
||||||
|
if isinstance(data.get("values"), dict):
|
||||||
|
candidates.append(data["values"])
|
||||||
|
elif isinstance(data, list):
|
||||||
|
candidates.extend([x for x in data if isinstance(x, dict)])
|
||||||
|
|
||||||
|
for item in candidates:
|
||||||
|
messages = item.get("messages")
|
||||||
|
if isinstance(messages, list):
|
||||||
|
return messages
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def is_ai_message(message: dict[str, T.Any]) -> bool:
|
||||||
|
role = str(message.get("role", "")).lower()
|
||||||
|
if role in {"assistant", "ai"}:
|
||||||
|
return True
|
||||||
|
|
||||||
|
msg_type = str(message.get("type", "")).lower()
|
||||||
|
if msg_type in {"ai", "assistant", "aimessage", "aimessagechunk"}:
|
||||||
|
return True
|
||||||
|
if "ai" in msg_type and all(
|
||||||
|
token not in msg_type for token in ("human", "tool", "system")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_latest_ai_text(messages: Iterable[T.Any]) -> str:
|
||||||
|
# Scan backwards to get the latest assistant/ai message text.
|
||||||
|
if isinstance(messages, (list, tuple)):
|
||||||
|
iterable = reversed(messages)
|
||||||
|
else:
|
||||||
|
# Fallback for generic iterables (e.g. generators).
|
||||||
|
iterable = reversed(list(messages))
|
||||||
|
|
||||||
|
for msg in iterable:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if is_ai_message(msg):
|
||||||
|
text = extract_text(msg.get("content"))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_latest_ai_message(messages: Iterable[T.Any]) -> dict[str, T.Any] | None:
|
||||||
|
if isinstance(messages, (list, tuple)):
|
||||||
|
iterable = reversed(messages)
|
||||||
|
else:
|
||||||
|
iterable = reversed(list(messages))
|
||||||
|
|
||||||
|
for msg in iterable:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if is_ai_message(msg):
|
||||||
|
return msg
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_clarification_tool_message(message: dict[str, T.Any]) -> bool:
|
||||||
|
msg_type = str(message.get("type", "")).lower()
|
||||||
|
tool_name = str(message.get("name", "")).lower()
|
||||||
|
return msg_type == "tool" and tool_name == "ask_clarification"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_latest_clarification_text(messages: Iterable[T.Any]) -> str:
|
||||||
|
if isinstance(messages, (list, tuple)):
|
||||||
|
iterable = reversed(messages)
|
||||||
|
else:
|
||||||
|
iterable = reversed(list(messages))
|
||||||
|
|
||||||
|
for msg in iterable:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if is_clarification_tool_message(msg):
|
||||||
|
text = extract_text(msg.get("content"))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_message_id(message: T.Any) -> str:
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
return ""
|
||||||
|
msg_id = message.get("id")
|
||||||
|
return msg_id if isinstance(msg_id, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_event_message_obj(data: T.Any) -> dict[str, T.Any] | None:
|
||||||
|
msg_obj = data
|
||||||
|
if isinstance(data, (list, tuple)) and data:
|
||||||
|
msg_obj = data[0]
|
||||||
|
if isinstance(msg_obj, dict) and isinstance(msg_obj.get("data"), dict):
|
||||||
|
# Some servers wrap message body in {"data": {...}}
|
||||||
|
msg_obj = msg_obj["data"]
|
||||||
|
return msg_obj if isinstance(msg_obj, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ai_delta_from_event_data(data: T.Any) -> str:
|
||||||
|
# LangGraph messages-tuple events usually carry either:
|
||||||
|
# - {"type": "ai", "content": "..."}
|
||||||
|
# - [message_obj, metadata]
|
||||||
|
msg_obj = extract_event_message_obj(data)
|
||||||
|
if not msg_obj:
|
||||||
|
return ""
|
||||||
|
if is_ai_message(msg_obj):
|
||||||
|
return extract_text(msg_obj.get("content"))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_clarification_from_event_data(data: T.Any) -> str:
|
||||||
|
msg_obj = extract_event_message_obj(data)
|
||||||
|
if not msg_obj:
|
||||||
|
return ""
|
||||||
|
if is_clarification_tool_message(msg_obj):
|
||||||
|
return extract_text(msg_obj.get("content"))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_custom_event_items(data: T.Any) -> list[dict[str, T.Any]]:
|
||||||
|
items: list[dict[str, T.Any]] = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return [data]
|
||||||
|
if isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
items.append(item)
|
||||||
|
elif isinstance(item, (list, tuple)):
|
||||||
|
for nested in item:
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
items.append(nested)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def extract_task_failures_from_custom_event(data: T.Any) -> list[str]:
|
||||||
|
failures: list[str] = []
|
||||||
|
for item in _iter_custom_event_items(data):
|
||||||
|
event_type = str(item.get("type", "")).lower()
|
||||||
|
if event_type not in {"task_failed", "task_timed_out"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_id = str(item.get("task_id", "")).strip()
|
||||||
|
error_text = extract_text(item.get("error")).strip()
|
||||||
|
if task_id and error_text:
|
||||||
|
failures.append(f"{task_id}: {error_text}")
|
||||||
|
elif error_text:
|
||||||
|
failures.append(error_text)
|
||||||
|
elif task_id:
|
||||||
|
failures.append(f"{task_id}: unknown error")
|
||||||
|
else:
|
||||||
|
failures.append("unknown task failure")
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_failure_summary(failures: list[str]) -> str:
|
||||||
|
if not failures:
|
||||||
|
return ""
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for failure in failures:
|
||||||
|
if failure not in seen:
|
||||||
|
seen.add(failure)
|
||||||
|
deduped.append(failure)
|
||||||
|
if len(deduped) == 1:
|
||||||
|
return f"DeerFlow subtask failed: {deduped[0]}"
|
||||||
|
joined = "\n".join([f"- {item}" for item in deduped[:5]])
|
||||||
|
return f"DeerFlow subtasks failed:\n{joined}"
|
||||||
@@ -10,7 +10,7 @@ from astrbot.core.provider.entities import (
|
|||||||
LLMResponse,
|
LLMResponse,
|
||||||
ProviderRequest,
|
ProviderRequest,
|
||||||
)
|
)
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||||
from astrbot.core.utils.io import download_file
|
from astrbot.core.utils.io import download_file
|
||||||
|
|
||||||
from ...hooks import BaseAgentRunHooks
|
from ...hooks import BaseAgentRunHooks
|
||||||
@@ -291,8 +291,8 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
return Comp.Image(file=item["url"], url=item["url"])
|
return Comp.Image(file=item["url"], url=item["url"])
|
||||||
case "audio":
|
case "audio":
|
||||||
# 仅支持 wav
|
# 仅支持 wav
|
||||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
temp_dir = get_astrbot_temp_path()
|
||||||
path = os.path.join(temp_dir, f"{item['filename']}.wav")
|
path = os.path.join(temp_dir, f"dify_{item['filename']}.wav")
|
||||||
await download_file(item["url"], path)
|
await download_file(item["url"], path)
|
||||||
return Comp.Image(file=item["url"], url=item["url"])
|
return Comp.Image(file=item["url"], url=item["url"])
|
||||||
case "video":
|
case "video":
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async def _stream_sse(resp: ClientResponse) -> AsyncGenerator[dict, None]:
|
|||||||
|
|
||||||
|
|
||||||
class DifyAPIClient:
|
class DifyAPIClient:
|
||||||
def __init__(self, api_key: str, api_base: str = "https://api.dify.ai/v1"):
|
def __init__(self, api_key: str, api_base: str = "https://api.dify.ai/v1") -> None:
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.api_base = api_base
|
self.api_base = api_base
|
||||||
self.session = ClientSession(trust_env=True)
|
self.session = ClientSession(trust_env=True)
|
||||||
@@ -155,7 +155,7 @@ class DifyAPIClient:
|
|||||||
raise Exception(f"Dify 文件上传失败:{resp.status}. {text}")
|
raise Exception(f"Dify 文件上传失败:{resp.status}. {text}")
|
||||||
return await resp.json() # {"id": "xxx", ...}
|
return await resp.json() # {"id": "xxx", ...}
|
||||||
|
|
||||||
async def close(self):
|
async def close(self) -> None:
|
||||||
await self.session.close()
|
await self.session.close()
|
||||||
|
|
||||||
async def get_chat_convs(self, user: str, limit: int = 20):
|
async def get_chat_convs(self, user: str, limit: int = 20):
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import typing as T
|
import typing as T
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
BlobResourceContents,
|
BlobResourceContents,
|
||||||
@@ -12,9 +16,16 @@ from mcp.types import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from astrbot import logger
|
from astrbot import logger
|
||||||
|
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
|
||||||
|
from astrbot.core.agent.tool import ToolSet
|
||||||
|
from astrbot.core.agent.tool_image_cache import tool_image_cache
|
||||||
|
from astrbot.core.message.components import Json
|
||||||
from astrbot.core.message.message_event_result import (
|
from astrbot.core.message.message_event_result import (
|
||||||
MessageChain,
|
MessageChain,
|
||||||
)
|
)
|
||||||
|
from astrbot.core.persona_error_reply import (
|
||||||
|
extract_persona_custom_error_message_from_event,
|
||||||
|
)
|
||||||
from astrbot.core.provider.entities import (
|
from astrbot.core.provider.entities import (
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
ProviderRequest,
|
ProviderRequest,
|
||||||
@@ -22,9 +33,13 @@ from astrbot.core.provider.entities import (
|
|||||||
)
|
)
|
||||||
from astrbot.core.provider.provider import Provider
|
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 ..hooks import BaseAgentRunHooks
|
||||||
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
|
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
|
||||||
from ..response import AgentResponseData
|
from ..response import AgentResponseData, AgentStats
|
||||||
from ..run_context import ContextWrapper, TContext
|
from ..run_context import ContextWrapper, TContext
|
||||||
from ..tool_executor import BaseFunctionToolExecutor
|
from ..tool_executor import BaseFunctionToolExecutor
|
||||||
from .base import AgentResponse, AgentState, BaseAgentRunner
|
from .base import AgentResponse, AgentState, BaseAgentRunner
|
||||||
@@ -35,7 +50,42 @@ else:
|
|||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _HandleFunctionToolsResult:
|
||||||
|
kind: T.Literal["message_chain", "tool_call_result_blocks", "cached_image"]
|
||||||
|
message_chain: MessageChain | None = None
|
||||||
|
tool_call_result_blocks: list[ToolCallMessageSegment] | None = None
|
||||||
|
cached_image: T.Any = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_message_chain(cls, chain: MessageChain) -> "_HandleFunctionToolsResult":
|
||||||
|
return cls(kind="message_chain", message_chain=chain)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_tool_call_result_blocks(
|
||||||
|
cls, blocks: list[ToolCallMessageSegment]
|
||||||
|
) -> "_HandleFunctionToolsResult":
|
||||||
|
return cls(kind="tool_call_result_blocks", tool_call_result_blocks=blocks)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_cached_image(cls, image: T.Any) -> "_HandleFunctionToolsResult":
|
||||||
|
return cls(kind="cached_image", cached_image=image)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FollowUpTicket:
|
||||||
|
seq: int
|
||||||
|
text: str
|
||||||
|
consumed: bool = False
|
||||||
|
resolved: asyncio.Event = field(default_factory=asyncio.Event)
|
||||||
|
|
||||||
|
|
||||||
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||||
|
def _get_persona_custom_error_message(self) -> str | None:
|
||||||
|
"""Read persona-level custom error message from event extras when available."""
|
||||||
|
event = getattr(self.run_context.context, "event", None)
|
||||||
|
return extract_persona_custom_error_message_from_event(event)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def reset(
|
async def reset(
|
||||||
self,
|
self,
|
||||||
@@ -44,21 +94,98 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
run_context: ContextWrapper[TContext],
|
run_context: ContextWrapper[TContext],
|
||||||
tool_executor: BaseFunctionToolExecutor[TContext],
|
tool_executor: BaseFunctionToolExecutor[TContext],
|
||||||
agent_hooks: BaseAgentRunHooks[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,
|
||||||
|
tool_schema_mode: str | None = "full",
|
||||||
|
fallback_providers: list[Provider] | None = None,
|
||||||
**kwargs: T.Any,
|
**kwargs: T.Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.req = request
|
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.provider = provider
|
||||||
|
self.fallback_providers: list[Provider] = []
|
||||||
|
seen_provider_ids: set[str] = {str(provider.provider_config.get("id", ""))}
|
||||||
|
for fallback_provider in fallback_providers or []:
|
||||||
|
fallback_id = str(fallback_provider.provider_config.get("id", ""))
|
||||||
|
if fallback_provider is provider:
|
||||||
|
continue
|
||||||
|
if fallback_id and fallback_id in seen_provider_ids:
|
||||||
|
continue
|
||||||
|
self.fallback_providers.append(fallback_provider)
|
||||||
|
if fallback_id:
|
||||||
|
seen_provider_ids.add(fallback_id)
|
||||||
self.final_llm_resp = None
|
self.final_llm_resp = None
|
||||||
self._state = AgentState.IDLE
|
self._state = AgentState.IDLE
|
||||||
self.tool_executor = tool_executor
|
self.tool_executor = tool_executor
|
||||||
self.agent_hooks = agent_hooks
|
self.agent_hooks = agent_hooks
|
||||||
self.run_context = run_context
|
self.run_context = run_context
|
||||||
|
self._stop_requested = False
|
||||||
|
self._aborted = False
|
||||||
|
self._pending_follow_ups: list[FollowUpTicket] = []
|
||||||
|
self._follow_up_seq = 0
|
||||||
|
|
||||||
|
# These two are used for tool schema mode handling
|
||||||
|
# We now have two modes:
|
||||||
|
# - "full": use full tool schema for LLM calls, default.
|
||||||
|
# - "skills_like": use light tool schema for LLM calls, and re-query with param-only schema when needed.
|
||||||
|
# Light tool schema does not include tool parameters.
|
||||||
|
# This can reduce token usage when tools have large descriptions.
|
||||||
|
# See #4681
|
||||||
|
self.tool_schema_mode = tool_schema_mode
|
||||||
|
self._tool_schema_param_set = None
|
||||||
|
self._skill_like_raw_tool_set = None
|
||||||
|
if tool_schema_mode == "skills_like":
|
||||||
|
tool_set = self.req.func_tool
|
||||||
|
if not tool_set:
|
||||||
|
return
|
||||||
|
self._skill_like_raw_tool_set = tool_set
|
||||||
|
light_set = tool_set.get_light_tool_set()
|
||||||
|
self._tool_schema_param_set = tool_set.get_param_only_tool_set()
|
||||||
|
# MODIFIE the req.func_tool to use light tool schemas
|
||||||
|
self.req.func_tool = light_set
|
||||||
|
|
||||||
messages = []
|
messages = []
|
||||||
# append existing messages in the run context
|
# append existing messages in the run context
|
||||||
for msg in request.contexts:
|
for msg in request.contexts:
|
||||||
messages.append(Message.model_validate(msg))
|
m = Message.model_validate(msg)
|
||||||
|
if isinstance(msg, dict) and msg.get("_no_save"):
|
||||||
|
m._no_save = True
|
||||||
|
messages.append(m)
|
||||||
if request.prompt is not None:
|
if request.prompt is not None:
|
||||||
m = await request.assemble_context()
|
m = await request.assemble_context()
|
||||||
messages.append(Message.model_validate(m))
|
messages.append(Message.model_validate(m))
|
||||||
@@ -69,14 +196,154 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
)
|
)
|
||||||
self.run_context.messages = messages
|
self.run_context.messages = messages
|
||||||
|
|
||||||
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
|
self.stats = AgentStats()
|
||||||
|
self.stats.start_time = time.time()
|
||||||
|
|
||||||
|
async def _iter_llm_responses(
|
||||||
|
self, *, include_model: bool = True
|
||||||
|
) -> T.AsyncGenerator[LLMResponse, None]:
|
||||||
"""Yields chunks *and* a final LLMResponse."""
|
"""Yields chunks *and* a final LLMResponse."""
|
||||||
|
payload = {
|
||||||
|
"contexts": self.run_context.messages, # list[Message]
|
||||||
|
"func_tool": self.req.func_tool,
|
||||||
|
"session_id": self.req.session_id,
|
||||||
|
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
|
||||||
|
}
|
||||||
|
if include_model:
|
||||||
|
# For primary provider we keep explicit model selection if provided.
|
||||||
|
payload["model"] = self.req.model
|
||||||
if self.streaming:
|
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
|
async for resp in stream: # type: ignore
|
||||||
yield resp
|
yield resp
|
||||||
else:
|
else:
|
||||||
yield await self.provider.text_chat(**self.req.__dict__)
|
yield await self.provider.text_chat(**payload)
|
||||||
|
|
||||||
|
async def _iter_llm_responses_with_fallback(
|
||||||
|
self,
|
||||||
|
) -> T.AsyncGenerator[LLMResponse, None]:
|
||||||
|
"""Wrap _iter_llm_responses with provider fallback handling."""
|
||||||
|
candidates = [self.provider, *self.fallback_providers]
|
||||||
|
total_candidates = len(candidates)
|
||||||
|
last_exception: Exception | None = None
|
||||||
|
last_err_response: LLMResponse | None = None
|
||||||
|
|
||||||
|
for idx, candidate in enumerate(candidates):
|
||||||
|
candidate_id = candidate.provider_config.get("id", "<unknown>")
|
||||||
|
is_last_candidate = idx == total_candidates - 1
|
||||||
|
if idx > 0:
|
||||||
|
logger.warning(
|
||||||
|
"Switched from %s to fallback chat provider: %s",
|
||||||
|
self.provider.provider_config.get("id", "<unknown>"),
|
||||||
|
candidate_id,
|
||||||
|
)
|
||||||
|
self.provider = candidate
|
||||||
|
has_stream_output = False
|
||||||
|
try:
|
||||||
|
async for resp in self._iter_llm_responses(include_model=idx == 0):
|
||||||
|
if resp.is_chunk:
|
||||||
|
has_stream_output = True
|
||||||
|
yield resp
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
resp.role == "err"
|
||||||
|
and not has_stream_output
|
||||||
|
and (not is_last_candidate)
|
||||||
|
):
|
||||||
|
last_err_response = resp
|
||||||
|
logger.warning(
|
||||||
|
"Chat Model %s returns error response, trying fallback to next provider.",
|
||||||
|
candidate_id,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
yield resp
|
||||||
|
return
|
||||||
|
|
||||||
|
if has_stream_output:
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last_exception = exc
|
||||||
|
logger.warning(
|
||||||
|
"Chat Model %s request error: %s",
|
||||||
|
candidate_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if last_err_response:
|
||||||
|
yield last_err_response
|
||||||
|
return
|
||||||
|
if last_exception:
|
||||||
|
yield LLMResponse(
|
||||||
|
role="err",
|
||||||
|
completion_text=(
|
||||||
|
"All chat models failed: "
|
||||||
|
f"{type(last_exception).__name__}: {last_exception}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
yield LLMResponse(
|
||||||
|
role="err",
|
||||||
|
completion_text="All available chat models are unavailable.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _simple_print_message_role(self, tag: str = ""):
|
||||||
|
roles = []
|
||||||
|
for message in self.run_context.messages:
|
||||||
|
roles.append(message.role)
|
||||||
|
logger.debug(f"{tag} RunCtx.messages -> [{len(roles)}] {','.join(roles)}")
|
||||||
|
|
||||||
|
def follow_up(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message_text: str,
|
||||||
|
) -> FollowUpTicket | None:
|
||||||
|
"""Queue a follow-up message for the next tool result."""
|
||||||
|
if self.done():
|
||||||
|
return None
|
||||||
|
text = (message_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
ticket = FollowUpTicket(seq=self._follow_up_seq, text=text)
|
||||||
|
self._follow_up_seq += 1
|
||||||
|
self._pending_follow_ups.append(ticket)
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
def _resolve_unconsumed_follow_ups(self) -> None:
|
||||||
|
if not self._pending_follow_ups:
|
||||||
|
return
|
||||||
|
follow_ups = self._pending_follow_ups
|
||||||
|
self._pending_follow_ups = []
|
||||||
|
for ticket in follow_ups:
|
||||||
|
ticket.resolved.set()
|
||||||
|
|
||||||
|
def _consume_follow_up_notice(self) -> str:
|
||||||
|
if not self._pending_follow_ups:
|
||||||
|
return ""
|
||||||
|
follow_ups = self._pending_follow_ups
|
||||||
|
self._pending_follow_ups = []
|
||||||
|
for ticket in follow_ups:
|
||||||
|
ticket.consumed = True
|
||||||
|
ticket.resolved.set()
|
||||||
|
follow_up_lines = "\n".join(
|
||||||
|
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
|
||||||
|
"was in progress. Prioritize these follow-up instructions in your next "
|
||||||
|
"actions. In your very next action, briefly acknowledge to the user "
|
||||||
|
"that their follow-up message(s) were received before continuing.\n"
|
||||||
|
f"{follow_up_lines}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _merge_follow_up_notice(self, content: str) -> str:
|
||||||
|
notice = self._consume_follow_up_notice()
|
||||||
|
if not notice:
|
||||||
|
return content
|
||||||
|
return f"{content}{notice}"
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def step(self):
|
async def step(self):
|
||||||
@@ -96,8 +363,20 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
self._transition_state(AgentState.RUNNING)
|
self._transition_state(AgentState.RUNNING)
|
||||||
llm_resp_result = None
|
llm_resp_result = None
|
||||||
|
|
||||||
async for llm_response in self._iter_llm_responses():
|
# do truncate and compress
|
||||||
|
token_usage = self.req.conversation.token_usage if self.req.conversation else 0
|
||||||
|
self._simple_print_message_role("[BefCompact]")
|
||||||
|
self.run_context.messages = await self.context_manager.process(
|
||||||
|
self.run_context.messages, trusted_token_usage=token_usage
|
||||||
|
)
|
||||||
|
self._simple_print_message_role("[AftCompact]")
|
||||||
|
|
||||||
|
async for llm_response in self._iter_llm_responses_with_fallback():
|
||||||
if llm_response.is_chunk:
|
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:
|
if llm_response.result_chain:
|
||||||
yield AgentResponse(
|
yield AgentResponse(
|
||||||
type="streaming_delta",
|
type="streaming_delta",
|
||||||
@@ -119,11 +398,68 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if self._stop_requested:
|
||||||
|
llm_resp_result = LLMResponse(
|
||||||
|
role="assistant",
|
||||||
|
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||||
|
reasoning_content=llm_response.reasoning_content,
|
||||||
|
reasoning_signature=llm_response.reasoning_signature,
|
||||||
|
)
|
||||||
|
break
|
||||||
continue
|
continue
|
||||||
llm_resp_result = llm_response
|
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
|
||||||
|
if self.req.conversation:
|
||||||
|
self.req.conversation.token_usage = llm_response.usage.total
|
||||||
break # got final response
|
break # got final response
|
||||||
|
|
||||||
if not llm_resp_result:
|
if not llm_resp_result:
|
||||||
|
if self._stop_requested:
|
||||||
|
llm_resp_result = LLMResponse(role="assistant", completion_text="")
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._stop_requested:
|
||||||
|
logger.info("Agent execution was requested to stop by user.")
|
||||||
|
llm_resp = llm_resp_result
|
||||||
|
if llm_resp.role != "assistant":
|
||||||
|
llm_resp = LLMResponse(
|
||||||
|
role="assistant",
|
||||||
|
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||||
|
)
|
||||||
|
self.final_llm_resp = llm_resp
|
||||||
|
self._aborted = True
|
||||||
|
self._transition_state(AgentState.DONE)
|
||||||
|
self.stats.end_time = time.time()
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||||
|
parts.append(
|
||||||
|
ThinkPart(
|
||||||
|
think=llm_resp.reasoning_content,
|
||||||
|
encrypted=llm_resp.reasoning_signature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if llm_resp.completion_text:
|
||||||
|
parts.append(TextPart(text=llm_resp.completion_text))
|
||||||
|
if parts:
|
||||||
|
self.run_context.messages.append(
|
||||||
|
Message(role="assistant", content=parts)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||||
|
|
||||||
|
yield AgentResponse(
|
||||||
|
type="aborted",
|
||||||
|
data=AgentResponseData(chain=MessageChain(type="aborted")),
|
||||||
|
)
|
||||||
|
self._resolve_unconsumed_follow_ups()
|
||||||
return
|
return
|
||||||
|
|
||||||
# 处理 LLM 响应
|
# 处理 LLM 响应
|
||||||
@@ -132,31 +468,50 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
if llm_resp.role == "err":
|
if llm_resp.role == "err":
|
||||||
# 如果 LLM 响应错误,转换到错误状态
|
# 如果 LLM 响应错误,转换到错误状态
|
||||||
self.final_llm_resp = llm_resp
|
self.final_llm_resp = llm_resp
|
||||||
|
self.stats.end_time = time.time()
|
||||||
self._transition_state(AgentState.ERROR)
|
self._transition_state(AgentState.ERROR)
|
||||||
|
self._resolve_unconsumed_follow_ups()
|
||||||
|
custom_error_message = self._get_persona_custom_error_message()
|
||||||
|
error_text = custom_error_message or (
|
||||||
|
f"LLM 响应错误: {llm_resp.completion_text or '未知错误'}"
|
||||||
|
)
|
||||||
yield AgentResponse(
|
yield AgentResponse(
|
||||||
type="err",
|
type="err",
|
||||||
data=AgentResponseData(
|
data=AgentResponseData(
|
||||||
chain=MessageChain().message(
|
chain=MessageChain().message(error_text),
|
||||||
f"LLM 响应错误: {llm_resp.completion_text or '未知错误'}",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if not llm_resp.tools_call_name:
|
if not llm_resp.tools_call_name:
|
||||||
# 如果没有工具调用,转换到完成状态
|
# 如果没有工具调用,转换到完成状态
|
||||||
self.final_llm_resp = llm_resp
|
self.final_llm_resp = llm_resp
|
||||||
self._transition_state(AgentState.DONE)
|
self._transition_state(AgentState.DONE)
|
||||||
|
self.stats.end_time = time.time()
|
||||||
|
|
||||||
# record the final assistant message
|
# record the final assistant message
|
||||||
self.run_context.messages.append(
|
parts = []
|
||||||
Message(
|
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||||
role="assistant",
|
parts.append(
|
||||||
content=llm_resp.completion_text or "",
|
ThinkPart(
|
||||||
),
|
think=llm_resp.reasoning_content,
|
||||||
)
|
encrypted=llm_resp.reasoning_signature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if llm_resp.completion_text:
|
||||||
|
parts.append(TextPart(text=llm_resp.completion_text))
|
||||||
|
if len(parts) == 0:
|
||||||
|
logger.warning(
|
||||||
|
"LLM returned empty assistant message with no tool calls."
|
||||||
|
)
|
||||||
|
self.run_context.messages.append(Message(role="assistant", content=parts))
|
||||||
|
|
||||||
|
# call the on_agent_done hook
|
||||||
try:
|
try:
|
||||||
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||||
|
self._resolve_unconsumed_follow_ups()
|
||||||
|
|
||||||
# 返回 LLM 结果
|
# 返回 LLM 结果
|
||||||
if llm_resp.result_chain:
|
if llm_resp.result_chain:
|
||||||
@@ -174,30 +529,50 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
|
|
||||||
# 如果有工具调用,还需处理工具调用
|
# 如果有工具调用,还需处理工具调用
|
||||||
if llm_resp.tools_call_name:
|
if llm_resp.tools_call_name:
|
||||||
|
if self.tool_schema_mode == "skills_like":
|
||||||
|
llm_resp, _ = await self._resolve_tool_exec(llm_resp)
|
||||||
|
|
||||||
tool_call_result_blocks = []
|
tool_call_result_blocks = []
|
||||||
for tool_call_name in llm_resp.tools_call_name:
|
cached_images = [] # Collect cached images for LLM visibility
|
||||||
yield AgentResponse(
|
|
||||||
type="tool_call",
|
|
||||||
data=AgentResponseData(
|
|
||||||
chain=MessageChain(type="tool_call").message(
|
|
||||||
f"🔨 调用工具: {tool_call_name}"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async for result in self._handle_function_tools(self.req, llm_resp):
|
async for result in self._handle_function_tools(self.req, llm_resp):
|
||||||
if isinstance(result, list):
|
if result.kind == "tool_call_result_blocks":
|
||||||
tool_call_result_blocks = result
|
if result.tool_call_result_blocks is not None:
|
||||||
elif isinstance(result, MessageChain):
|
tool_call_result_blocks = result.tool_call_result_blocks
|
||||||
result.type = "tool_call_result"
|
elif result.kind == "cached_image":
|
||||||
|
if result.cached_image is not None:
|
||||||
|
# Collect cached image info
|
||||||
|
cached_images.append(result.cached_image)
|
||||||
|
elif result.kind == "message_chain":
|
||||||
|
chain = result.message_chain
|
||||||
|
if chain is None or chain.type is None:
|
||||||
|
# should not happen
|
||||||
|
continue
|
||||||
|
if chain.type == "tool_direct_result":
|
||||||
|
ar_type = "tool_call_result"
|
||||||
|
else:
|
||||||
|
ar_type = chain.type
|
||||||
yield AgentResponse(
|
yield AgentResponse(
|
||||||
type="tool_call_result",
|
type=ar_type,
|
||||||
data=AgentResponseData(chain=result),
|
data=AgentResponseData(chain=chain),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 将结果添加到上下文中
|
# 将结果添加到上下文中
|
||||||
|
parts = []
|
||||||
|
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||||
|
parts.append(
|
||||||
|
ThinkPart(
|
||||||
|
think=llm_resp.reasoning_content,
|
||||||
|
encrypted=llm_resp.reasoning_signature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if llm_resp.completion_text:
|
||||||
|
parts.append(TextPart(text=llm_resp.completion_text))
|
||||||
|
if len(parts) == 0:
|
||||||
|
parts = None
|
||||||
tool_calls_result = ToolCallsResult(
|
tool_calls_result = ToolCallsResult(
|
||||||
tool_calls_info=AssistantMessageSegment(
|
tool_calls_info=AssistantMessageSegment(
|
||||||
tool_calls=llm_resp.to_openai_to_calls_model(),
|
tool_calls=llm_resp.to_openai_to_calls_model(),
|
||||||
content=llm_resp.completion_text,
|
content=parts,
|
||||||
),
|
),
|
||||||
tool_calls_result=tool_call_result_blocks,
|
tool_calls_result=tool_call_result_blocks,
|
||||||
)
|
)
|
||||||
@@ -206,6 +581,41 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
tool_calls_result.to_openai_messages_model()
|
tool_calls_result.to_openai_messages_model()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If there are cached images and the model supports image input,
|
||||||
|
# append a user message with images so LLM can see them
|
||||||
|
if cached_images:
|
||||||
|
modalities = self.provider.provider_config.get("modalities", [])
|
||||||
|
supports_image = "image" in modalities
|
||||||
|
if supports_image:
|
||||||
|
# Build user message with images for LLM to review
|
||||||
|
image_parts = []
|
||||||
|
for cached_img in cached_images:
|
||||||
|
img_data = tool_image_cache.get_image_base64_by_path(
|
||||||
|
cached_img.file_path, cached_img.mime_type
|
||||||
|
)
|
||||||
|
if img_data:
|
||||||
|
base64_data, mime_type = img_data
|
||||||
|
image_parts.append(
|
||||||
|
TextPart(
|
||||||
|
text=f"[Image from tool '{cached_img.tool_name}', path='{cached_img.file_path}']"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
image_parts.append(
|
||||||
|
ImageURLPart(
|
||||||
|
image_url=ImageURLPart.ImageURL(
|
||||||
|
url=f"data:{mime_type};base64,{base64_data}",
|
||||||
|
id=cached_img.file_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if image_parts:
|
||||||
|
self.run_context.messages.append(
|
||||||
|
Message(role="user", content=image_parts)
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Appended {len(cached_images)} cached image(s) to context for LLM review"
|
||||||
|
)
|
||||||
|
|
||||||
self.req.append_tool_calls_result(tool_calls_result)
|
self.req.append_tool_calls_result(tool_calls_result)
|
||||||
|
|
||||||
async def step_until_done(
|
async def step_until_done(
|
||||||
@@ -218,35 +628,110 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
async for resp in self.step():
|
async for resp in self.step():
|
||||||
yield resp
|
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(
|
async def _handle_function_tools(
|
||||||
self,
|
self,
|
||||||
req: ProviderRequest,
|
req: ProviderRequest,
|
||||||
llm_response: LLMResponse,
|
llm_response: LLMResponse,
|
||||||
) -> T.AsyncGenerator[MessageChain | list[ToolCallMessageSegment], None]:
|
) -> T.AsyncGenerator[_HandleFunctionToolsResult, None]:
|
||||||
"""处理函数工具调用。"""
|
"""处理函数工具调用。"""
|
||||||
tool_call_result_blocks: list[ToolCallMessageSegment] = []
|
tool_call_result_blocks: list[ToolCallMessageSegment] = []
|
||||||
logger.info(f"Agent 使用工具: {llm_response.tools_call_name}")
|
logger.info(f"Agent 使用工具: {llm_response.tools_call_name}")
|
||||||
|
|
||||||
|
def _append_tool_call_result(tool_call_id: str, content: str) -> None:
|
||||||
|
tool_call_result_blocks.append(
|
||||||
|
ToolCallMessageSegment(
|
||||||
|
role="tool",
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
content=self._merge_follow_up_notice(content),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_image_content(
|
||||||
|
base64_data: str,
|
||||||
|
mime_type: str,
|
||||||
|
tool_call_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
content_index: int,
|
||||||
|
) -> _HandleFunctionToolsResult:
|
||||||
|
"""Helper to cache image and return result for LLM visibility."""
|
||||||
|
cached_img = tool_image_cache.save_image(
|
||||||
|
base64_data=base64_data,
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
index=content_index,
|
||||||
|
mime_type=mime_type,
|
||||||
|
)
|
||||||
|
_append_tool_call_result(
|
||||||
|
tool_call_id,
|
||||||
|
(
|
||||||
|
f"Image returned and cached at path='{cached_img.file_path}'. "
|
||||||
|
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
|
||||||
|
f"with type='image' and path='{cached_img.file_path}'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _HandleFunctionToolsResult.from_cached_image(cached_img)
|
||||||
|
|
||||||
# 执行函数调用
|
# 执行函数调用
|
||||||
for func_tool_name, func_tool_args, func_tool_id in zip(
|
for func_tool_name, func_tool_args, func_tool_id in zip(
|
||||||
llm_response.tools_call_name,
|
llm_response.tools_call_name,
|
||||||
llm_response.tools_call_args,
|
llm_response.tools_call_args,
|
||||||
llm_response.tools_call_ids,
|
llm_response.tools_call_ids,
|
||||||
):
|
):
|
||||||
|
yield _HandleFunctionToolsResult.from_message_chain(
|
||||||
|
MessageChain(
|
||||||
|
type="tool_call",
|
||||||
|
chain=[
|
||||||
|
Json(
|
||||||
|
data={
|
||||||
|
"id": func_tool_id,
|
||||||
|
"name": func_tool_name,
|
||||||
|
"args": func_tool_args,
|
||||||
|
"ts": time.time(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if not req.func_tool:
|
if not req.func_tool:
|
||||||
return
|
return
|
||||||
func_tool = req.func_tool.get_func(func_tool_name)
|
|
||||||
|
if (
|
||||||
|
self.tool_schema_mode == "skills_like"
|
||||||
|
and self._skill_like_raw_tool_set
|
||||||
|
):
|
||||||
|
# in 'skills_like' mode, raw.func_tool is light schema, does not have handler
|
||||||
|
# so we need to get the tool from the raw tool set
|
||||||
|
func_tool = self._skill_like_raw_tool_set.get_tool(func_tool_name)
|
||||||
|
else:
|
||||||
|
func_tool = req.func_tool.get_tool(func_tool_name)
|
||||||
|
|
||||||
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
|
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
|
||||||
|
|
||||||
if not func_tool:
|
if not func_tool:
|
||||||
logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。")
|
logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。")
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
f"error: Tool {func_tool_name} not found.",
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content=f"error: 未找到工具 {func_tool_name}",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -298,74 +783,69 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
if isinstance(resp, CallToolResult):
|
if isinstance(resp, CallToolResult):
|
||||||
res = resp
|
res = resp
|
||||||
_final_resp = resp
|
_final_resp = resp
|
||||||
if isinstance(res.content[0], TextContent):
|
# Process all content items in the result
|
||||||
tool_call_result_blocks.append(
|
for content_index, content in enumerate(res.content):
|
||||||
ToolCallMessageSegment(
|
if isinstance(content, TextContent):
|
||||||
role="tool",
|
_append_tool_call_result(
|
||||||
|
func_tool_id,
|
||||||
|
content.text,
|
||||||
|
)
|
||||||
|
elif isinstance(content, ImageContent):
|
||||||
|
# Cache the image instead of sending directly
|
||||||
|
yield _handle_image_content(
|
||||||
|
base64_data=content.data,
|
||||||
|
mime_type=content.mimeType or "image/png",
|
||||||
tool_call_id=func_tool_id,
|
tool_call_id=func_tool_id,
|
||||||
content=res.content[0].text,
|
tool_name=func_tool_name,
|
||||||
),
|
content_index=content_index,
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
elif isinstance(res.content[0], EmbeddedResource):
|
|
||||||
resource = res.content[0].resource
|
|
||||||
if isinstance(resource, TextResourceContents):
|
|
||||||
tool_call_result_blocks.append(
|
|
||||||
ToolCallMessageSegment(
|
|
||||||
role="tool",
|
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content=resource.text,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
yield MessageChain().message(resource.text)
|
elif isinstance(content, EmbeddedResource):
|
||||||
elif (
|
resource = content.resource
|
||||||
isinstance(resource, BlobResourceContents)
|
if isinstance(resource, TextResourceContents):
|
||||||
and resource.mimeType
|
_append_tool_call_result(
|
||||||
and resource.mimeType.startswith("image/")
|
func_tool_id,
|
||||||
):
|
resource.text,
|
||||||
tool_call_result_blocks.append(
|
)
|
||||||
ToolCallMessageSegment(
|
elif (
|
||||||
role="tool",
|
isinstance(resource, BlobResourceContents)
|
||||||
|
and resource.mimeType
|
||||||
|
and resource.mimeType.startswith("image/")
|
||||||
|
):
|
||||||
|
# Cache the image instead of sending directly
|
||||||
|
yield _handle_image_content(
|
||||||
|
base64_data=resource.blob,
|
||||||
|
mime_type=resource.mimeType,
|
||||||
tool_call_id=func_tool_id,
|
tool_call_id=func_tool_id,
|
||||||
content="返回了图片(已直接发送给用户)",
|
tool_name=func_tool_name,
|
||||||
),
|
content_index=content_index,
|
||||||
)
|
)
|
||||||
yield MessageChain(
|
else:
|
||||||
type="tool_direct_result",
|
_append_tool_call_result(
|
||||||
).base64_image(resource.blob)
|
func_tool_id,
|
||||||
else:
|
"The tool has returned a data type that is not supported.",
|
||||||
tool_call_result_blocks.append(
|
)
|
||||||
ToolCallMessageSegment(
|
|
||||||
role="tool",
|
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content="返回的数据类型不受支持",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
yield MessageChain().message("返回的数据类型不受支持。")
|
|
||||||
|
|
||||||
elif resp is None:
|
elif resp is None:
|
||||||
# Tool 直接请求发送消息给用户
|
# Tool 直接请求发送消息给用户
|
||||||
# 这里我们将直接结束 Agent Loop。
|
# 这里我们将直接结束 Agent Loop
|
||||||
# 发送消息逻辑在 ToolExecutor 中处理了。
|
# 发送消息逻辑在 ToolExecutor 中处理了
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中。"
|
f"{func_tool_name} 没有返回值,或者已将结果直接发送给用户。"
|
||||||
)
|
)
|
||||||
self._transition_state(AgentState.DONE)
|
self._transition_state(AgentState.DONE)
|
||||||
|
self.stats.end_time = time.time()
|
||||||
|
_append_tool_call_result(
|
||||||
|
func_tool_id,
|
||||||
|
"The tool has no return value, or has sent the result directly to the user.",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# 不应该出现其他类型
|
# 不应该出现其他类型
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Tool 返回了不支持的类型: {type(resp)},将忽略。",
|
f"Tool 返回了不支持的类型: {type(resp)}。",
|
||||||
|
)
|
||||||
|
_append_tool_call_result(
|
||||||
|
func_tool_id,
|
||||||
|
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -379,21 +859,110 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
logger.error(f"Error in on_tool_end hook: {e}", exc_info=True)
|
logger.error(f"Error in on_tool_end hook: {e}", exc_info=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(traceback.format_exc())
|
logger.warning(traceback.format_exc())
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
f"error: {e!s}",
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
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 _HandleFunctionToolsResult.from_message_chain(
|
||||||
|
MessageChain(
|
||||||
|
type="tool_call_result",
|
||||||
|
chain=[
|
||||||
|
Json(
|
||||||
|
data={
|
||||||
|
"id": func_tool_id,
|
||||||
|
"ts": time.time(),
|
||||||
|
"result": last_tcr_content,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"Tool `{func_tool_name}` Result: {last_tcr_content}")
|
||||||
|
|
||||||
# 处理函数调用响应
|
# 处理函数调用响应
|
||||||
if tool_call_result_blocks:
|
if tool_call_result_blocks:
|
||||||
yield tool_call_result_blocks
|
yield _HandleFunctionToolsResult.from_tool_call_result_blocks(
|
||||||
|
tool_call_result_blocks
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_tool_requery_context(
|
||||||
|
self, tool_names: list[str]
|
||||||
|
) -> list[dict[str, T.Any]]:
|
||||||
|
"""Build contexts for re-querying LLM with param-only tool schemas."""
|
||||||
|
contexts: list[dict[str, T.Any]] = []
|
||||||
|
for msg in self.run_context.messages:
|
||||||
|
if hasattr(msg, "model_dump"):
|
||||||
|
contexts.append(msg.model_dump()) # type: ignore[call-arg]
|
||||||
|
elif isinstance(msg, dict):
|
||||||
|
contexts.append(copy.deepcopy(msg))
|
||||||
|
instruction = (
|
||||||
|
"You have decided to call tool(s): "
|
||||||
|
+ ", ".join(tool_names)
|
||||||
|
+ ". Now call the tool(s) with required arguments using the tool schema, "
|
||||||
|
"and follow the existing tool-use rules."
|
||||||
|
)
|
||||||
|
if contexts and contexts[0].get("role") == "system":
|
||||||
|
content = contexts[0].get("content") or ""
|
||||||
|
contexts[0]["content"] = f"{content}\n{instruction}"
|
||||||
|
else:
|
||||||
|
contexts.insert(0, {"role": "system", "content": instruction})
|
||||||
|
return contexts
|
||||||
|
|
||||||
|
def _build_tool_subset(self, tool_set: ToolSet, tool_names: list[str]) -> ToolSet:
|
||||||
|
"""Build a subset of tools from the given tool set based on tool names."""
|
||||||
|
subset = ToolSet()
|
||||||
|
for name in tool_names:
|
||||||
|
tool = tool_set.get_tool(name)
|
||||||
|
if tool:
|
||||||
|
subset.add_tool(tool)
|
||||||
|
return subset
|
||||||
|
|
||||||
|
async def _resolve_tool_exec(
|
||||||
|
self,
|
||||||
|
llm_resp: LLMResponse,
|
||||||
|
) -> tuple[LLMResponse, ToolSet | None]:
|
||||||
|
"""Used in 'skills_like' tool schema mode to re-query LLM with param-only tool schemas."""
|
||||||
|
tool_names = llm_resp.tools_call_name
|
||||||
|
if not tool_names:
|
||||||
|
return llm_resp, self.req.func_tool
|
||||||
|
full_tool_set = self.req.func_tool
|
||||||
|
if not isinstance(full_tool_set, ToolSet):
|
||||||
|
return llm_resp, self.req.func_tool
|
||||||
|
|
||||||
|
subset = self._build_tool_subset(full_tool_set, tool_names)
|
||||||
|
if not subset.tools:
|
||||||
|
return llm_resp, full_tool_set
|
||||||
|
|
||||||
|
if isinstance(self._tool_schema_param_set, ToolSet):
|
||||||
|
param_subset = self._build_tool_subset(
|
||||||
|
self._tool_schema_param_set, tool_names
|
||||||
|
)
|
||||||
|
if param_subset.tools and tool_names:
|
||||||
|
contexts = self._build_tool_requery_context(tool_names)
|
||||||
|
requery_resp = await self.provider.text_chat(
|
||||||
|
contexts=contexts,
|
||||||
|
func_tool=param_subset,
|
||||||
|
model=self.req.model,
|
||||||
|
session_id=self.req.session_id,
|
||||||
|
)
|
||||||
|
if requery_resp:
|
||||||
|
llm_resp = requery_resp
|
||||||
|
|
||||||
|
return llm_resp, subset
|
||||||
|
|
||||||
def done(self) -> bool:
|
def done(self) -> bool:
|
||||||
"""检查 Agent 是否已完成工作"""
|
"""检查 Agent 是否已完成工作"""
|
||||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
return self._state in (AgentState.DONE, AgentState.ERROR)
|
||||||
|
|
||||||
|
def request_stop(self) -> None:
|
||||||
|
self._stop_requested = True
|
||||||
|
|
||||||
|
def was_aborted(self) -> bool:
|
||||||
|
return self._aborted
|
||||||
|
|
||||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||||
return self.final_llm_resp
|
return self.final_llm_resp
|
||||||
|
|||||||
+104
-31
@@ -1,3 +1,4 @@
|
|||||||
|
import copy
|
||||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
from typing import Any, Generic
|
from typing import Any, Generic
|
||||||
|
|
||||||
@@ -57,8 +58,18 @@ class FunctionTool(ToolSchema, Generic[TContext]):
|
|||||||
Whether the tool is active. This field is a special field for AstrBot.
|
Whether the tool is active. This field is a special field for AstrBot.
|
||||||
You can ignore it when integrating with other frameworks.
|
You can ignore it when integrating with other frameworks.
|
||||||
"""
|
"""
|
||||||
|
is_background_task: bool = False
|
||||||
|
"""
|
||||||
|
Declare this tool as a background task. Background tasks return immediately
|
||||||
|
with a task identifier while the real work continues asynchronously.
|
||||||
|
"""
|
||||||
|
source: str = "plugin"
|
||||||
|
"""
|
||||||
|
Origin of this tool: 'plugin' (from star plugins), 'internal' (AstrBot built-in),
|
||||||
|
or 'mcp' (from MCP servers). Used by WebUI for display grouping.
|
||||||
|
"""
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self) -> str:
|
||||||
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
|
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
|
||||||
|
|
||||||
async def call(self, context: ContextWrapper[TContext], **kwargs) -> ToolExecResult:
|
async def call(self, context: ContextWrapper[TContext], **kwargs) -> ToolExecResult:
|
||||||
@@ -82,7 +93,7 @@ class ToolSet:
|
|||||||
"""Check if the tool set is empty."""
|
"""Check if the tool set is empty."""
|
||||||
return len(self.tools) == 0
|
return len(self.tools) == 0
|
||||||
|
|
||||||
def add_tool(self, tool: FunctionTool):
|
def add_tool(self, tool: FunctionTool) -> None:
|
||||||
"""Add a tool to the set."""
|
"""Add a tool to the set."""
|
||||||
# 检查是否已存在同名工具
|
# 检查是否已存在同名工具
|
||||||
for i, existing_tool in enumerate(self.tools):
|
for i, existing_tool in enumerate(self.tools):
|
||||||
@@ -91,10 +102,19 @@ class ToolSet:
|
|||||||
return
|
return
|
||||||
self.tools.append(tool)
|
self.tools.append(tool)
|
||||||
|
|
||||||
def remove_tool(self, name: str):
|
def remove_tool(self, name: str) -> None:
|
||||||
"""Remove a tool by its name."""
|
"""Remove a tool by its name."""
|
||||||
self.tools = [tool for tool in self.tools if tool.name != name]
|
self.tools = [tool for tool in self.tools if tool.name != name]
|
||||||
|
|
||||||
|
def normalize(self) -> None:
|
||||||
|
"""Sort tools by name for deterministic serialization.
|
||||||
|
|
||||||
|
This ensures the serialized tool schema sent to the LLM is
|
||||||
|
identical across requests regardless of registration/injection
|
||||||
|
order, enabling LLM provider prefix cache hits.
|
||||||
|
"""
|
||||||
|
self.tools.sort(key=lambda t: t.name)
|
||||||
|
|
||||||
def get_tool(self, name: str) -> FunctionTool | None:
|
def get_tool(self, name: str) -> FunctionTool | None:
|
||||||
"""Get a tool by its name."""
|
"""Get a tool by its name."""
|
||||||
for tool in self.tools:
|
for tool in self.tools:
|
||||||
@@ -102,6 +122,47 @@ class ToolSet:
|
|||||||
return tool
|
return tool
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_light_tool_set(self) -> "ToolSet":
|
||||||
|
"""Return a light tool set with only name/description."""
|
||||||
|
light_tools = []
|
||||||
|
for tool in self.tools:
|
||||||
|
if hasattr(tool, "active") and not tool.active:
|
||||||
|
continue
|
||||||
|
light_params = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}
|
||||||
|
light_tools.append(
|
||||||
|
FunctionTool(
|
||||||
|
name=tool.name,
|
||||||
|
parameters=light_params,
|
||||||
|
description=tool.description,
|
||||||
|
handler=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ToolSet(light_tools)
|
||||||
|
|
||||||
|
def get_param_only_tool_set(self) -> "ToolSet":
|
||||||
|
"""Return a tool set with name/parameters only (no description)."""
|
||||||
|
param_tools = []
|
||||||
|
for tool in self.tools:
|
||||||
|
if hasattr(tool, "active") and not tool.active:
|
||||||
|
continue
|
||||||
|
params = (
|
||||||
|
copy.deepcopy(tool.parameters)
|
||||||
|
if tool.parameters
|
||||||
|
else {"type": "object", "properties": {}}
|
||||||
|
)
|
||||||
|
param_tools.append(
|
||||||
|
FunctionTool(
|
||||||
|
name=tool.name,
|
||||||
|
parameters=params,
|
||||||
|
description="",
|
||||||
|
handler=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ToolSet(param_tools)
|
||||||
|
|
||||||
@deprecated(reason="Use add_tool() instead", version="4.0.0")
|
@deprecated(reason="Use add_tool() instead", version="4.0.0")
|
||||||
def add_func(
|
def add_func(
|
||||||
self,
|
self,
|
||||||
@@ -109,7 +170,7 @@ class ToolSet:
|
|||||||
func_args: list,
|
func_args: list,
|
||||||
desc: str,
|
desc: str,
|
||||||
handler: Callable[..., Awaitable[Any]],
|
handler: Callable[..., Awaitable[Any]],
|
||||||
):
|
) -> None:
|
||||||
"""Add a function tool to the set."""
|
"""Add a function tool to the set."""
|
||||||
params = {
|
params = {
|
||||||
"type": "object", # hard-coded here
|
"type": "object", # hard-coded here
|
||||||
@@ -129,7 +190,7 @@ class ToolSet:
|
|||||||
self.add_tool(_func)
|
self.add_tool(_func)
|
||||||
|
|
||||||
@deprecated(reason="Use remove_tool() instead", version="4.0.0")
|
@deprecated(reason="Use remove_tool() instead", version="4.0.0")
|
||||||
def remove_func(self, name: str):
|
def remove_func(self, name: str) -> None:
|
||||||
"""Remove a function tool by its name."""
|
"""Remove a function tool by its name."""
|
||||||
self.remove_tool(name)
|
self.remove_tool(name)
|
||||||
|
|
||||||
@@ -147,18 +208,15 @@ class ToolSet:
|
|||||||
"""Convert tools to OpenAI API function calling schema format."""
|
"""Convert tools to OpenAI API function calling schema format."""
|
||||||
result = []
|
result = []
|
||||||
for tool in self.tools:
|
for tool in self.tools:
|
||||||
func_def = {
|
func_def = {"type": "function", "function": {"name": tool.name}}
|
||||||
"type": "function",
|
if tool.description:
|
||||||
"function": {
|
func_def["function"]["description"] = tool.description
|
||||||
"name": tool.name,
|
|
||||||
"description": tool.description,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if tool.parameters is not None:
|
||||||
tool.parameters and tool.parameters.get("properties")
|
if (
|
||||||
) or not omit_empty_parameter_field:
|
tool.parameters and tool.parameters.get("properties")
|
||||||
func_def["function"]["parameters"] = tool.parameters
|
) or not omit_empty_parameter_field:
|
||||||
|
func_def["function"]["parameters"] = tool.parameters
|
||||||
|
|
||||||
result.append(func_def)
|
result.append(func_def)
|
||||||
return result
|
return result
|
||||||
@@ -171,11 +229,9 @@ class ToolSet:
|
|||||||
if tool.parameters:
|
if tool.parameters:
|
||||||
input_schema["properties"] = tool.parameters.get("properties", {})
|
input_schema["properties"] = tool.parameters.get("properties", {})
|
||||||
input_schema["required"] = tool.parameters.get("required", [])
|
input_schema["required"] = tool.parameters.get("required", [])
|
||||||
tool_def = {
|
tool_def = {"name": tool.name, "input_schema": input_schema}
|
||||||
"name": tool.name,
|
if tool.description:
|
||||||
"description": tool.description,
|
tool_def["description"] = tool.description
|
||||||
"input_schema": input_schema,
|
|
||||||
}
|
|
||||||
result.append(tool_def)
|
result.append(tool_def)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -204,8 +260,18 @@ class ToolSet:
|
|||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
if "type" in schema and schema["type"] in supported_types:
|
# Avoid side effects by not modifying the original schema
|
||||||
result["type"] = schema["type"]
|
origin_type = schema.get("type")
|
||||||
|
target_type = origin_type
|
||||||
|
|
||||||
|
# Compatibility fix: Gemini API expects 'type' to be a string (enum),
|
||||||
|
# but standard JSON Schema (MCP) allows lists (e.g. ["string", "null"]).
|
||||||
|
# We fallback to the first non-null type.
|
||||||
|
if isinstance(origin_type, list):
|
||||||
|
target_type = next((t for t in origin_type if t != "null"), "string")
|
||||||
|
|
||||||
|
if target_type in supported_types:
|
||||||
|
result["type"] = target_type
|
||||||
if "format" in schema and schema["format"] in supported_formats.get(
|
if "format" in schema and schema["format"] in supported_formats.get(
|
||||||
result["type"],
|
result["type"],
|
||||||
set(),
|
set(),
|
||||||
@@ -233,6 +299,9 @@ class ToolSet:
|
|||||||
prop_value = convert_schema(value)
|
prop_value = convert_schema(value)
|
||||||
if "default" in prop_value:
|
if "default" in prop_value:
|
||||||
del prop_value["default"]
|
del prop_value["default"]
|
||||||
|
# see #5217
|
||||||
|
if "additionalProperties" in prop_value:
|
||||||
|
del prop_value["additionalProperties"]
|
||||||
properties[key] = prop_value
|
properties[key] = prop_value
|
||||||
|
|
||||||
if properties:
|
if properties:
|
||||||
@@ -245,10 +314,9 @@ class ToolSet:
|
|||||||
|
|
||||||
tools = []
|
tools = []
|
||||||
for tool in self.tools:
|
for tool in self.tools:
|
||||||
d: dict[str, Any] = {
|
d: dict[str, Any] = {"name": tool.name}
|
||||||
"name": tool.name,
|
if tool.description:
|
||||||
"description": tool.description,
|
d["description"] = tool.description
|
||||||
}
|
|
||||||
if tool.parameters:
|
if tool.parameters:
|
||||||
d["parameters"] = convert_schema(tool.parameters)
|
d["parameters"] = convert_schema(tool.parameters)
|
||||||
tools.append(d)
|
tools.append(d)
|
||||||
@@ -274,17 +342,22 @@ class ToolSet:
|
|||||||
"""获取所有工具的名称列表"""
|
"""获取所有工具的名称列表"""
|
||||||
return [tool.name for tool in self.tools]
|
return [tool.name for tool in self.tools]
|
||||||
|
|
||||||
def __len__(self):
|
def merge(self, other: "ToolSet") -> None:
|
||||||
|
"""Merge another ToolSet into this one."""
|
||||||
|
for tool in other.tools:
|
||||||
|
self.add_tool(tool)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
return len(self.tools)
|
return len(self.tools)
|
||||||
|
|
||||||
def __bool__(self):
|
def __bool__(self) -> bool:
|
||||||
return len(self.tools) > 0
|
return len(self.tools) > 0
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
return iter(self.tools)
|
return iter(self.tools)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self) -> str:
|
||||||
return f"ToolSet(tools={self.tools})"
|
return f"ToolSet(tools={self.tools})"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"ToolSet(tools={self.tools})"
|
return f"ToolSet(tools={self.tools})"
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Tool image cache module for storing and retrieving images returned by tools.
|
||||||
|
|
||||||
|
This module allows LLM to review images before deciding whether to send them to users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from astrbot import logger
|
||||||
|
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CachedImage:
|
||||||
|
"""Represents a cached image from a tool call."""
|
||||||
|
|
||||||
|
tool_call_id: str
|
||||||
|
"""The tool call ID that produced this image."""
|
||||||
|
tool_name: str
|
||||||
|
"""The name of the tool that produced this image."""
|
||||||
|
file_path: str
|
||||||
|
"""The file path where the image is stored."""
|
||||||
|
mime_type: str
|
||||||
|
"""The MIME type of the image."""
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
"""Timestamp when the image was cached."""
|
||||||
|
|
||||||
|
|
||||||
|
class ToolImageCache:
|
||||||
|
"""Manages cached images from tool calls.
|
||||||
|
|
||||||
|
Images are stored in data/temp/tool_images/ and can be retrieved by file path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instance: ClassVar["ToolImageCache | None"] = None
|
||||||
|
CACHE_DIR_NAME: ClassVar[str] = "tool_images"
|
||||||
|
# Cache expiry time in seconds (1 hour)
|
||||||
|
CACHE_EXPIRY: ClassVar[int] = 3600
|
||||||
|
|
||||||
|
def __new__(cls) -> "ToolImageCache":
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
self._cache_dir = os.path.join(get_astrbot_temp_path(), self.CACHE_DIR_NAME)
|
||||||
|
os.makedirs(self._cache_dir, exist_ok=True)
|
||||||
|
logger.debug(f"ToolImageCache initialized, cache dir: {self._cache_dir}")
|
||||||
|
|
||||||
|
def _get_file_extension(self, mime_type: str) -> str:
|
||||||
|
"""Get file extension from MIME type."""
|
||||||
|
mime_to_ext = {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/jpg": ".jpg",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/bmp": ".bmp",
|
||||||
|
"image/svg+xml": ".svg",
|
||||||
|
}
|
||||||
|
return mime_to_ext.get(mime_type.lower(), ".png")
|
||||||
|
|
||||||
|
def save_image(
|
||||||
|
self,
|
||||||
|
base64_data: str,
|
||||||
|
tool_call_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
index: int = 0,
|
||||||
|
mime_type: str = "image/png",
|
||||||
|
) -> CachedImage:
|
||||||
|
"""Save an image to cache and return the cached image info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base64_data: Base64 encoded image data.
|
||||||
|
tool_call_id: The tool call ID that produced this image.
|
||||||
|
tool_name: The name of the tool that produced this image.
|
||||||
|
index: The index of the image (for multiple images from same tool call).
|
||||||
|
mime_type: The MIME type of the image.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CachedImage object with file path.
|
||||||
|
"""
|
||||||
|
ext = self._get_file_extension(mime_type)
|
||||||
|
file_name = f"{tool_call_id}_{index}{ext}"
|
||||||
|
file_path = os.path.join(self._cache_dir, file_name)
|
||||||
|
|
||||||
|
# Decode and save the image
|
||||||
|
try:
|
||||||
|
image_bytes = base64.b64decode(base64_data)
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
logger.debug(f"Saved tool image to: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save tool image: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return CachedImage(
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
file_path=file_path,
|
||||||
|
mime_type=mime_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_image_base64_by_path(
|
||||||
|
self, file_path: str, mime_type: str = "image/png"
|
||||||
|
) -> tuple[str, str] | None:
|
||||||
|
"""Read an image file and return its base64 encoded data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: The file path of the cached image.
|
||||||
|
mime_type: The MIME type of the image.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (base64_data, mime_type) if found, None otherwise.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
base64_data = base64.b64encode(image_bytes).decode("utf-8")
|
||||||
|
return base64_data, mime_type
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read cached image {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cleanup_expired(self) -> int:
|
||||||
|
"""Clean up expired cached images.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of images cleaned up.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
cleaned = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for file_name in os.listdir(self._cache_dir):
|
||||||
|
file_path = os.path.join(self._cache_dir, file_name)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
file_age = now - os.path.getmtime(file_path)
|
||||||
|
if file_age > self.CACHE_EXPIRY:
|
||||||
|
os.remove(file_path)
|
||||||
|
cleaned += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error during cache cleanup: {e}")
|
||||||
|
|
||||||
|
if cleaned:
|
||||||
|
logger.info(f"Cleaned up {cleaned} expired cached images")
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton instance
|
||||||
|
tool_image_cache = ToolImageCache()
|
||||||
@@ -6,8 +6,10 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
|||||||
from astrbot.core.star.context import Context
|
from astrbot.core.star.context import Context
|
||||||
|
|
||||||
|
|
||||||
@dataclass(config={"arbitrary_types_allowed": True})
|
@dataclass
|
||||||
class AstrAgentContext:
|
class AstrAgentContext:
|
||||||
|
__pydantic_config__ = {"arbitrary_types_allowed": True}
|
||||||
|
|
||||||
context: Context
|
context: Context
|
||||||
"""The star context instance"""
|
"""The star context instance"""
|
||||||
event: AstrMessageEvent
|
event: AstrMessageEvent
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Any
|
|||||||
from mcp.types import CallToolResult
|
from mcp.types import CallToolResult
|
||||||
|
|
||||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||||
|
from astrbot.core.agent.message import Message
|
||||||
from astrbot.core.agent.run_context import ContextWrapper
|
from astrbot.core.agent.run_context import ContextWrapper
|
||||||
from astrbot.core.agent.tool import FunctionTool
|
from astrbot.core.agent.tool import FunctionTool
|
||||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
@@ -11,22 +12,73 @@ from astrbot.core.star.star_handler import EventType
|
|||||||
|
|
||||||
|
|
||||||
class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
||||||
async def on_agent_done(self, run_context, llm_response):
|
async def on_agent_done(self, run_context, llm_response) -> None:
|
||||||
# 执行事件钩子
|
# 执行事件钩子
|
||||||
|
if llm_response and llm_response.reasoning_content:
|
||||||
|
# we will use this in result_decorate stage to inject reasoning content to chain
|
||||||
|
run_context.context.event.set_extra(
|
||||||
|
"_llm_reasoning_content", llm_response.reasoning_content
|
||||||
|
)
|
||||||
|
|
||||||
await call_event_hook(
|
await call_event_hook(
|
||||||
run_context.context.event,
|
run_context.context.event,
|
||||||
EventType.OnLLMResponseEvent,
|
EventType.OnLLMResponseEvent,
|
||||||
llm_response,
|
llm_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def on_tool_start(
|
||||||
|
self,
|
||||||
|
run_context: ContextWrapper[AstrAgentContext],
|
||||||
|
tool: FunctionTool[Any],
|
||||||
|
tool_args: dict | None,
|
||||||
|
) -> None:
|
||||||
|
await call_event_hook(
|
||||||
|
run_context.context.event,
|
||||||
|
EventType.OnUsingLLMToolEvent,
|
||||||
|
tool,
|
||||||
|
tool_args,
|
||||||
|
)
|
||||||
|
|
||||||
async def on_tool_end(
|
async def on_tool_end(
|
||||||
self,
|
self,
|
||||||
run_context: ContextWrapper[AstrAgentContext],
|
run_context: ContextWrapper[AstrAgentContext],
|
||||||
tool: FunctionTool[Any],
|
tool: FunctionTool[Any],
|
||||||
tool_args: dict | None,
|
tool_args: dict | None,
|
||||||
tool_result: CallToolResult | None,
|
tool_result: CallToolResult | None,
|
||||||
):
|
) -> None:
|
||||||
run_context.context.event.clear_result()
|
run_context.context.event.clear_result()
|
||||||
|
await call_event_hook(
|
||||||
|
run_context.context.event,
|
||||||
|
EventType.OnLLMToolRespondEvent,
|
||||||
|
tool,
|
||||||
|
tool_args,
|
||||||
|
tool_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
# special handle web_search_tavily
|
||||||
|
platform_name = run_context.context.event.get_platform_name()
|
||||||
|
if (
|
||||||
|
platform_name == "webchat"
|
||||||
|
and tool.name in ["web_search_tavily", "web_search_bocha"]
|
||||||
|
and len(run_context.messages) > 0
|
||||||
|
and tool_result
|
||||||
|
and len(tool_result.content)
|
||||||
|
):
|
||||||
|
# inject system prompt
|
||||||
|
first_part = run_context.messages[0]
|
||||||
|
if (
|
||||||
|
isinstance(first_part, Message)
|
||||||
|
and first_part.role == "system"
|
||||||
|
and first_part.content
|
||||||
|
and isinstance(first_part.content, str)
|
||||||
|
):
|
||||||
|
# we assume system part is str
|
||||||
|
first_part.content += (
|
||||||
|
"Always cite web search results you rely on. "
|
||||||
|
"Index is a unique identifier for each search result. "
|
||||||
|
"Use the exact citation format <ref>index</ref> (e.g. <ref>abcd.3</ref>) "
|
||||||
|
"after the sentence that uses the information. Do not invent citations."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EmptyAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
class EmptyAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
||||||
|
|||||||
@@ -1,48 +1,206 @@
|
|||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
|
from astrbot.core.agent.message import Message
|
||||||
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
|
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
|
||||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
|
from astrbot.core.message.components import BaseMessageComponent, Json, Plain
|
||||||
from astrbot.core.message.message_event_result import (
|
from astrbot.core.message.message_event_result import (
|
||||||
MessageChain,
|
MessageChain,
|
||||||
MessageEventResult,
|
MessageEventResult,
|
||||||
ResultContentType,
|
ResultContentType,
|
||||||
)
|
)
|
||||||
|
from astrbot.core.persona_error_reply import (
|
||||||
|
extract_persona_custom_error_message_from_event,
|
||||||
|
)
|
||||||
from astrbot.core.provider.entities import LLMResponse
|
from astrbot.core.provider.entities import LLMResponse
|
||||||
|
from astrbot.core.provider.provider import TTSProvider
|
||||||
|
|
||||||
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
|
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
|
||||||
|
|
||||||
|
|
||||||
|
def _should_stop_agent(astr_event) -> bool:
|
||||||
|
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_tool_result(text: str, limit: int = 70) -> str:
|
||||||
|
if limit <= 0:
|
||||||
|
return ""
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
if limit <= 3:
|
||||||
|
return text[:limit]
|
||||||
|
return f"{text[: limit - 3]}..."
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_chain_json_data(msg_chain: MessageChain) -> dict | None:
|
||||||
|
if not msg_chain.chain:
|
||||||
|
return None
|
||||||
|
first_comp = msg_chain.chain[0]
|
||||||
|
if isinstance(first_comp, Json) and isinstance(first_comp.data, dict):
|
||||||
|
return first_comp.data
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_tool_call_name(
|
||||||
|
tool_info: dict | None, tool_name_by_call_id: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(tool_info, dict):
|
||||||
|
return
|
||||||
|
tool_call_id = tool_info.get("id")
|
||||||
|
tool_name = tool_info.get("name")
|
||||||
|
if tool_call_id is None or tool_name is None:
|
||||||
|
return
|
||||||
|
tool_name_by_call_id[str(tool_call_id)] = str(tool_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tool_call_status_message(tool_info: dict | None) -> str:
|
||||||
|
if tool_info:
|
||||||
|
return f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
|
||||||
|
return "🔨 调用工具..."
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tool_result_status_message(
|
||||||
|
msg_chain: MessageChain, tool_name_by_call_id: dict[str, str]
|
||||||
|
) -> str:
|
||||||
|
tool_name = "unknown"
|
||||||
|
tool_result = ""
|
||||||
|
|
||||||
|
result_data = _extract_chain_json_data(msg_chain)
|
||||||
|
if result_data:
|
||||||
|
tool_call_id = result_data.get("id")
|
||||||
|
if tool_call_id is not None:
|
||||||
|
tool_name = tool_name_by_call_id.pop(str(tool_call_id), "unknown")
|
||||||
|
tool_result = str(result_data.get("result", ""))
|
||||||
|
|
||||||
|
if not tool_result:
|
||||||
|
tool_result = msg_chain.get_plain_text(with_other_comps_mark=True)
|
||||||
|
tool_result = _truncate_tool_result(tool_result, 70)
|
||||||
|
|
||||||
|
status_msg = f"🔨 调用工具: {tool_name}"
|
||||||
|
if tool_result:
|
||||||
|
status_msg = f"{status_msg}\n📎 返回结果: {tool_result}"
|
||||||
|
return status_msg
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_final_streaming_chain(msg_chain: MessageChain) -> MessageChain | None:
|
||||||
|
if not msg_chain.chain:
|
||||||
|
return None
|
||||||
|
|
||||||
|
final_chain: list[BaseMessageComponent] = []
|
||||||
|
for comp in msg_chain.chain:
|
||||||
|
if isinstance(comp, Plain):
|
||||||
|
continue
|
||||||
|
final_chain.append(comp)
|
||||||
|
|
||||||
|
if not final_chain:
|
||||||
|
return None
|
||||||
|
return MessageChain(chain=final_chain, type=msg_chain.type)
|
||||||
|
|
||||||
|
|
||||||
async def run_agent(
|
async def run_agent(
|
||||||
agent_runner: AgentRunner,
|
agent_runner: AgentRunner,
|
||||||
max_step: int = 30,
|
max_step: int = 30,
|
||||||
show_tool_use: bool = True,
|
show_tool_use: bool = True,
|
||||||
|
show_tool_call_result: bool = False,
|
||||||
stream_to_general: bool = False,
|
stream_to_general: bool = False,
|
||||||
show_reasoning: bool = False,
|
show_reasoning: bool = False,
|
||||||
) -> AsyncGenerator[MessageChain | None, None]:
|
) -> AsyncGenerator[MessageChain | None, None]:
|
||||||
step_idx = 0
|
step_idx = 0
|
||||||
astr_event = agent_runner.run_context.context.event
|
astr_event = agent_runner.run_context.context.event
|
||||||
while step_idx < max_step:
|
tool_name_by_call_id: dict[str, str] = {}
|
||||||
|
while step_idx < max_step + 1:
|
||||||
step_idx += 1
|
step_idx += 1
|
||||||
|
|
||||||
|
if step_idx == max_step + 1:
|
||||||
|
logger.warning(
|
||||||
|
f"Agent reached max steps ({max_step}), forcing a final response."
|
||||||
|
)
|
||||||
|
if not agent_runner.done():
|
||||||
|
# 拔掉所有工具
|
||||||
|
if agent_runner.req:
|
||||||
|
agent_runner.req.func_tool = None
|
||||||
|
# 注入提示词
|
||||||
|
agent_runner.run_context.messages.append(
|
||||||
|
Message(
|
||||||
|
role="user",
|
||||||
|
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stop_watcher = asyncio.create_task(
|
||||||
|
_watch_agent_stop_signal(agent_runner, astr_event),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
async for resp in agent_runner.step():
|
async for resp in agent_runner.step():
|
||||||
if astr_event.is_stopped():
|
if _should_stop_agent(astr_event):
|
||||||
|
agent_runner.request_stop()
|
||||||
|
|
||||||
|
if resp.type == "aborted":
|
||||||
|
if not stop_watcher.done():
|
||||||
|
stop_watcher.cancel()
|
||||||
|
try:
|
||||||
|
await stop_watcher
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
astr_event.set_extra("agent_user_aborted", True)
|
||||||
|
astr_event.set_extra("agent_stop_requested", False)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if _should_stop_agent(astr_event):
|
||||||
|
continue
|
||||||
|
|
||||||
if resp.type == "tool_call_result":
|
if resp.type == "tool_call_result":
|
||||||
msg_chain = resp.data["chain"]
|
msg_chain = resp.data["chain"]
|
||||||
|
|
||||||
|
astr_event.trace.record(
|
||||||
|
"agent_tool_result",
|
||||||
|
tool_result=msg_chain.get_plain_text(
|
||||||
|
with_other_comps_mark=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if msg_chain.type == "tool_direct_result":
|
if msg_chain.type == "tool_direct_result":
|
||||||
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
|
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
|
||||||
await astr_event.send(resp.data["chain"])
|
await astr_event.send(msg_chain)
|
||||||
continue
|
continue
|
||||||
|
if astr_event.get_platform_id() == "webchat":
|
||||||
|
await astr_event.send(msg_chain)
|
||||||
|
elif show_tool_use and show_tool_call_result:
|
||||||
|
status_msg = _build_tool_result_status_message(
|
||||||
|
msg_chain, tool_name_by_call_id
|
||||||
|
)
|
||||||
|
await astr_event.send(
|
||||||
|
MessageChain(type="tool_call").message(status_msg)
|
||||||
|
)
|
||||||
# 对于其他情况,暂时先不处理
|
# 对于其他情况,暂时先不处理
|
||||||
continue
|
continue
|
||||||
elif resp.type == "tool_call":
|
elif resp.type == "tool_call":
|
||||||
if agent_runner.streaming:
|
if agent_runner.streaming:
|
||||||
# 用来标记流式响应需要分节
|
# 用来标记流式响应需要分节
|
||||||
yield MessageChain(chain=[], type="break")
|
yield MessageChain(chain=[], type="break")
|
||||||
if show_tool_use:
|
|
||||||
|
tool_info = _extract_chain_json_data(resp.data["chain"])
|
||||||
|
astr_event.trace.record(
|
||||||
|
"agent_tool_call",
|
||||||
|
tool_name=tool_info if tool_info else "unknown",
|
||||||
|
)
|
||||||
|
_record_tool_call_name(tool_info, tool_name_by_call_id)
|
||||||
|
|
||||||
|
if astr_event.get_platform_name() == "webchat":
|
||||||
await astr_event.send(resp.data["chain"])
|
await astr_event.send(resp.data["chain"])
|
||||||
|
elif show_tool_use:
|
||||||
|
if show_tool_call_result and isinstance(tool_info, dict):
|
||||||
|
# Delay tool status notification until tool_call_result.
|
||||||
|
continue
|
||||||
|
chain = MessageChain(type="tool_call").message(
|
||||||
|
_build_tool_call_status_message(tool_info)
|
||||||
|
)
|
||||||
|
await astr_event.send(chain)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if stream_to_general and resp.type == "streaming_delta":
|
if stream_to_general and resp.type == "streaming_delta":
|
||||||
@@ -68,13 +226,49 @@ async def run_agent(
|
|||||||
# display the reasoning content only when configured
|
# display the reasoning content only when configured
|
||||||
continue
|
continue
|
||||||
yield resp.data["chain"] # MessageChain
|
yield resp.data["chain"] # MessageChain
|
||||||
|
elif resp.type == "llm_result":
|
||||||
|
if final_chain := _extract_final_streaming_chain(
|
||||||
|
resp.data["chain"]
|
||||||
|
):
|
||||||
|
yield final_chain
|
||||||
|
if not stop_watcher.done():
|
||||||
|
stop_watcher.cancel()
|
||||||
|
try:
|
||||||
|
await stop_watcher
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
if agent_runner.done():
|
if agent_runner.done():
|
||||||
|
# send agent stats to webchat
|
||||||
|
if astr_event.get_platform_name() == "webchat":
|
||||||
|
await astr_event.send(
|
||||||
|
MessageChain(
|
||||||
|
type="agent_stats",
|
||||||
|
chain=[Json(data=agent_runner.stats.to_dict())],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if "stop_watcher" in locals() and not stop_watcher.done():
|
||||||
|
stop_watcher.cancel()
|
||||||
|
try:
|
||||||
|
await stop_watcher
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
|
custom_error_message = extract_persona_custom_error_message_from_event(
|
||||||
|
astr_event
|
||||||
|
)
|
||||||
|
if custom_error_message:
|
||||||
|
err_msg = custom_error_message
|
||||||
|
else:
|
||||||
|
err_msg = (
|
||||||
|
f"Error occurred during AI execution.\n"
|
||||||
|
f"Error Type: {type(e).__name__}\n"
|
||||||
|
f"Error Message: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
error_llm_response = LLMResponse(
|
error_llm_response = LLMResponse(
|
||||||
role="err",
|
role="err",
|
||||||
@@ -92,3 +286,259 @@ async def run_agent(
|
|||||||
else:
|
else:
|
||||||
astr_event.set_result(MessageEventResult().message(err_msg))
|
astr_event.set_result(MessageEventResult().message(err_msg))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> None:
|
||||||
|
while not agent_runner.done():
|
||||||
|
if _should_stop_agent(astr_event):
|
||||||
|
agent_runner.request_stop()
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_live_agent(
|
||||||
|
agent_runner: AgentRunner,
|
||||||
|
tts_provider: TTSProvider | None = None,
|
||||||
|
max_step: int = 30,
|
||||||
|
show_tool_use: bool = True,
|
||||||
|
show_tool_call_result: bool = False,
|
||||||
|
show_reasoning: bool = False,
|
||||||
|
) -> AsyncGenerator[MessageChain | None, None]:
|
||||||
|
"""Live Mode 的 Agent 运行器,支持流式 TTS
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_runner: Agent 运行器
|
||||||
|
tts_provider: TTS Provider 实例
|
||||||
|
max_step: 最大步数
|
||||||
|
show_tool_use: 是否显示工具使用
|
||||||
|
show_tool_call_result: 是否显示工具返回结果
|
||||||
|
show_reasoning: 是否显示推理过程
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
MessageChain: 包含文本或音频数据的消息链
|
||||||
|
"""
|
||||||
|
# 如果没有 TTS Provider,直接发送文本
|
||||||
|
if not tts_provider:
|
||||||
|
async for chain in run_agent(
|
||||||
|
agent_runner,
|
||||||
|
max_step=max_step,
|
||||||
|
show_tool_use=show_tool_use,
|
||||||
|
show_tool_call_result=show_tool_call_result,
|
||||||
|
stream_to_general=False,
|
||||||
|
show_reasoning=show_reasoning,
|
||||||
|
):
|
||||||
|
yield chain
|
||||||
|
return
|
||||||
|
|
||||||
|
support_stream = tts_provider.support_stream()
|
||||||
|
if support_stream:
|
||||||
|
logger.info("[Live Agent] 使用流式 TTS(原生支持 get_audio_stream)")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"[Live Agent] 使用 TTS({tts_provider.meta().type} "
|
||||||
|
"使用 get_audio,将按句子分块生成音频)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 统计数据初始化
|
||||||
|
tts_start_time = time.time()
|
||||||
|
tts_first_frame_time = 0.0
|
||||||
|
first_chunk_received = False
|
||||||
|
|
||||||
|
# 创建队列
|
||||||
|
text_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
|
# audio_queue stored bytes or (text, bytes)
|
||||||
|
audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
|
||||||
|
|
||||||
|
# 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
|
||||||
|
feeder_task = asyncio.create_task(
|
||||||
|
_run_agent_feeder(
|
||||||
|
agent_runner,
|
||||||
|
text_queue,
|
||||||
|
max_step,
|
||||||
|
show_tool_use,
|
||||||
|
show_tool_call_result,
|
||||||
|
show_reasoning,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 启动 TTS 任务:负责从 text_queue 读取文本并生成音频到 audio_queue
|
||||||
|
if support_stream:
|
||||||
|
tts_task = asyncio.create_task(
|
||||||
|
_safe_tts_stream_wrapper(tts_provider, text_queue, audio_queue)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tts_task = asyncio.create_task(
|
||||||
|
_simulated_stream_tts(tts_provider, text_queue, audio_queue)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 主循环:从 audio_queue 读取音频并 yield
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
queue_item = await audio_queue.get()
|
||||||
|
|
||||||
|
if queue_item is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
text = None
|
||||||
|
if isinstance(queue_item, tuple):
|
||||||
|
text, audio_data = queue_item
|
||||||
|
else:
|
||||||
|
audio_data = queue_item
|
||||||
|
|
||||||
|
if not first_chunk_received:
|
||||||
|
# 记录首帧延迟(从开始处理到收到第一个音频块)
|
||||||
|
tts_first_frame_time = time.time() - tts_start_time
|
||||||
|
first_chunk_received = True
|
||||||
|
|
||||||
|
# 将音频数据封装为 MessageChain
|
||||||
|
import base64
|
||||||
|
|
||||||
|
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
|
||||||
|
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
|
||||||
|
if text:
|
||||||
|
comps.append(Json(data={"text": text}))
|
||||||
|
chain = MessageChain(chain=comps, type="audio_chunk")
|
||||||
|
yield chain
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
# 清理任务
|
||||||
|
if not feeder_task.done():
|
||||||
|
feeder_task.cancel()
|
||||||
|
if not tts_task.done():
|
||||||
|
tts_task.cancel()
|
||||||
|
|
||||||
|
# 确保队列被消费
|
||||||
|
pass
|
||||||
|
|
||||||
|
tts_end_time = time.time()
|
||||||
|
|
||||||
|
# 发送 TTS 统计信息
|
||||||
|
try:
|
||||||
|
astr_event = agent_runner.run_context.context.event
|
||||||
|
if astr_event.get_platform_name() == "webchat":
|
||||||
|
tts_duration = tts_end_time - tts_start_time
|
||||||
|
await astr_event.send(
|
||||||
|
MessageChain(
|
||||||
|
type="tts_stats",
|
||||||
|
chain=[
|
||||||
|
Json(
|
||||||
|
data={
|
||||||
|
"tts_total_time": tts_duration,
|
||||||
|
"tts_first_frame_time": tts_first_frame_time,
|
||||||
|
"tts": tts_provider.meta().type,
|
||||||
|
"chat_model": agent_runner.provider.get_model(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发送 TTS 统计信息失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_agent_feeder(
|
||||||
|
agent_runner: AgentRunner,
|
||||||
|
text_queue: asyncio.Queue,
|
||||||
|
max_step: int,
|
||||||
|
show_tool_use: bool,
|
||||||
|
show_tool_call_result: bool,
|
||||||
|
show_reasoning: bool,
|
||||||
|
) -> None:
|
||||||
|
"""运行 Agent 并将文本输出分句放入队列"""
|
||||||
|
buffer = ""
|
||||||
|
try:
|
||||||
|
async for chain in run_agent(
|
||||||
|
agent_runner,
|
||||||
|
max_step=max_step,
|
||||||
|
show_tool_use=show_tool_use,
|
||||||
|
show_tool_call_result=show_tool_call_result,
|
||||||
|
stream_to_general=False,
|
||||||
|
show_reasoning=show_reasoning,
|
||||||
|
):
|
||||||
|
if chain is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 提取文本
|
||||||
|
text = chain.get_plain_text()
|
||||||
|
if text:
|
||||||
|
buffer += text
|
||||||
|
|
||||||
|
# 分句逻辑:匹配标点符号
|
||||||
|
# r"([.。!!??\n]+)" 会保留分隔符
|
||||||
|
parts = re.split(r"([.。!!??\n]+)", buffer)
|
||||||
|
|
||||||
|
if len(parts) > 1:
|
||||||
|
# 处理完整的句子
|
||||||
|
# range step 2 因为 split 后是 [text, delim, text, delim, ...]
|
||||||
|
temp_buffer = ""
|
||||||
|
for i in range(0, len(parts) - 1, 2):
|
||||||
|
sentence = parts[i]
|
||||||
|
delim = parts[i + 1]
|
||||||
|
full_sentence = sentence + delim
|
||||||
|
temp_buffer += full_sentence
|
||||||
|
|
||||||
|
if len(temp_buffer) >= 10:
|
||||||
|
if temp_buffer.strip():
|
||||||
|
logger.info(f"[Live Agent Feeder] 分句: {temp_buffer}")
|
||||||
|
await text_queue.put(temp_buffer)
|
||||||
|
temp_buffer = ""
|
||||||
|
|
||||||
|
# 更新 buffer 为剩余部分
|
||||||
|
buffer = temp_buffer + parts[-1]
|
||||||
|
|
||||||
|
# 处理剩余 buffer
|
||||||
|
if buffer.strip():
|
||||||
|
await text_queue.put(buffer)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Live Agent Feeder] Error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
# 发送结束信号
|
||||||
|
await text_queue.put(None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _safe_tts_stream_wrapper(
|
||||||
|
tts_provider: TTSProvider,
|
||||||
|
text_queue: asyncio.Queue[str | None],
|
||||||
|
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
|
||||||
|
) -> None:
|
||||||
|
"""包装原生流式 TTS 确保异常处理和队列关闭"""
|
||||||
|
try:
|
||||||
|
await tts_provider.get_audio_stream(text_queue, audio_queue)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Live TTS Stream] Error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
await audio_queue.put(None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _simulated_stream_tts(
|
||||||
|
tts_provider: TTSProvider,
|
||||||
|
text_queue: asyncio.Queue[str | None],
|
||||||
|
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
|
||||||
|
) -> None:
|
||||||
|
"""模拟流式 TTS 分句生成音频"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
text = await text_queue.get()
|
||||||
|
if text is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_path = await tts_provider.get_audio(text)
|
||||||
|
|
||||||
|
if audio_path:
|
||||||
|
with open(audio_path, "rb") as f:
|
||||||
|
audio_data = f.read()
|
||||||
|
await audio_queue.put((text, audio_data))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[Live TTS Simulated] Error processing text '{text[:20]}...': {e}"
|
||||||
|
)
|
||||||
|
# 继续处理下一句
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Live TTS Simulated] Critical Error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
await audio_queue.put(None)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user